ROADMAP P6b, FINDINGS 67. DLXP2: a 64-byte header and then groups -- one audio lump of A sectors, then F records -- so record i is at off_frm + i*rec + (i//F)*A*512 and lump k at off_aud + k*(F*rec + A*512). Still no index and still none needed, which is the packed branch's whole claim surviving the one change that could have ended it. The player carries the third term in six instructions once a frame and zero parsing, and 120 of 120 records are still pixel-exact off a real MB89352 volume with the interleave in, against a silent control that says no picture byte moved. The finding is what 65.3 called padding. A lump is 7,168 B of SPACE; eleven frames of audio is 7,161.4583... B, so the payload alternates 7,161 and 7,162 and the rest is zero. A player that fed the chip the whole lump -- which is what "14 sectors every 11 frames" invites -- runs 0.09% fast, and that is not waste, it is drift: 0.84 ms a group, 1.25 s of lip-sync over the game's 22.8 minutes. What a player carries is one accumulator, acc += 11*15625; n = acc//24; acc %= 24, which is clock.i's shape for clock.i's reason and the third time this tree has met the pattern. The four ADPCM axes ride in the header as fields rather than a version number, and the gate flips each one to prove they earn it: nibble order -31.99 dB, delta formula -24.86, clamp 0.00, accumulator -0.49. Nothing parses a packed container, so the gate partitions the whole file -- 131 spans, no overlap, no gap -- and asserts what a cadence-blind player would read: exactly records 11..119 wrong, and frames 0..10 identical either way, which is how an off-by-one like that survives a rig that checks frame 0. Wire 582.0 + 7.64 = 589.6 KB/s, 65.3's prediction to the tenth. Green light ALL GREEN before (tmp/check_s35_start.log) and after (tmp/check_s35_end.log), with the new stage in it. Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
464 lines
23 KiB
Python
464 lines
23 KiB
Python
#!/usr/bin/env python3
|
|
"""DLXP1 -- the PACKED container, and the one place its layout rule is written.
|
|
|
|
from dlxp import DLXP, pack_picture, unpack_picture, write
|
|
|
|
THIS CONTAINER HAS NO DECODER. That is the point of it (FINDINGS 61): a record
|
|
is the bytes a DMA channel puts straight into the palette registers and GVRAM,
|
|
so the "reference decoder" here is not a decoder at all -- it is an assertion
|
|
about where each byte lands. `dlx.py` exists because a 68000 has to PARSE the
|
|
codec's container and can get it wrong; this file exists because a DMA channel
|
|
must NOT have to parse anything, and the format is what makes that true.
|
|
|
|
The layout, all of it fixed, all of it verified as a picture in FINDINGS 47.2:
|
|
|
|
* 256-colour GVRAM normally throws away the high byte of every word a CPU
|
|
writes, so a picture byte costs two disc bytes. CRTC R20 bit 11 turns the
|
|
masking off (46.5/47.1), and with the two 256-colour pages scrolled apart by
|
|
384 one word carries TWO pixels: word `i` of a row is
|
|
(pix[y][i+128] << 8) | pix[y][i]
|
|
-- page 1 (X-scrolled 384, transparent top) shows columns 128..255, page 0
|
|
(unscrolled, opaque bottom) shows columns 0..127.
|
|
* so a row is 128 words = 256 BYTES for 256 pixels, and big-endian storage
|
|
makes the byte order `pix[128], pix[0], pix[129], pix[1], ...`. That byte
|
|
order is not a serialisation choice: it is what the 68000's bus puts on the
|
|
high half of the word, and the channel copies bytes.
|
|
* 192 rows -> 49,152 B of picture, 1.0 B/pixel against the unpacked 2.0.
|
|
* index 0 is the TRANSPARENCY KEY and never appears; black is 255, which is
|
|
what the letterbox rows display (`vq.frame_palette`).
|
|
* words 128..511 of each row, and the letterbox rows themselves, are STATIC
|
|
SETUP -- written once at scene setup, never per frame -- so they are not in
|
|
the container. FINDINGS 47.2 lists them; keeping them out is what makes the
|
|
per-frame payload exactly the picture.
|
|
* the GVRAM line stride is 1,024 B and a row is 256 B, so the container's rows
|
|
are CONTIGUOUS and the 1,024 B step is the channel's, walked by array
|
|
chaining from one start (FINDINGS 62). A container that carried the stride
|
|
would be 4x the size and would say nothing extra.
|
|
|
|
header, 64 bytes, big-endian, then zero pad to the first sector:
|
|
0 'DLXP'
|
|
4 u16 version (2)
|
|
6 u16 flags bit 0: a per-frame palette is present
|
|
bit 1: the palette is at the END of the record
|
|
bit 2: an audio stream is interleaved (DLXP2)
|
|
8 u16 width, u16 height
|
|
12 u16 fps, u16 nframes
|
|
16 u32 record bytes fixed, and a whole number of 512 B sectors
|
|
20 u32 palette bytes 512 (256 GRB555+I words), or 0
|
|
24 u32 picture bytes 49,152
|
|
28 u32 frames offset offset of RECORD 0, lump 0 already skipped
|
|
-- DLXP2 adds, and every field is zero in a silent container:
|
|
32 u32 audio offset offset of LUMP 0, 512 B, i.e. sector 1
|
|
36 u32 audio sample Hz 15,625 -- the chip's, not the disc's
|
|
40 u32 audio bytes the ADPCM payload, padding NOT counted
|
|
44 u16 cadence F frames between one lump and the next
|
|
46 u16 cadence A whole sectors in a lump
|
|
48 u16 audio format bit 0: LOW nibble first. bit 1: the OKI
|
|
datasheet's per-term delta ('terms')
|
|
50 u16 audio clamp bits where the accumulator saturates, 10 or 12
|
|
52 i16 audio init the accumulator at PLAY, -2 on this chip
|
|
54 u16 reserved (0)
|
|
56 u32 reserved (0)
|
|
60 u32 reserved (0)
|
|
|
|
then, from sector 1, GROUPS: one audio lump of A sectors, then F records.
|
|
A record is 49,664 B = 97 sectors EXACTLY and a lump is A*512 B EXACTLY, so
|
|
|
|
record i is at off_frm + i*rec_bytes + (i//F)*A*512
|
|
lump k is at off_aud + k*(F*rec_bytes + A*512)
|
|
|
|
-- still arithmetic, still no index, still nothing walked.
|
|
|
|
WHY AUDIO IS A CADENCE AND NOT A FIELD IN THE RECORD (FINDINGS 65.3). 15,625
|
|
samples a second, two to a byte, is 651.0416... B per 12 fps slot, and the dots
|
|
are the whole problem: put slot i's audio in record i and records become
|
|
VARIABLE LENGTH, which needs an index, which ends the format. A fixed cadence
|
|
of F frames per A sectors keeps `LBA(i)` arithmetic and pays instead in padding,
|
|
and the padding is a rational-approximation problem whose answer is not the
|
|
obvious cadence: F=1 wastes 57.3% of every audio sector and F=11, A=14 wastes
|
|
0.09%.
|
|
|
|
AND THE PADDING IS NOT WHERE THE BYTES ARE (FINDINGS 67). A lump is A*512 B of
|
|
SPACE and it does NOT carry A*512 B of audio: F frames need F*15625/24 B, which
|
|
is 7,161.4583... at F=11, so a lump's PAYLOAD alternates 7,161 and 7,162 by the
|
|
same remainder arithmetic FINDINGS 54's frame clock carries, and the rest of the
|
|
sector is zero. A player that fed the chip the whole lump would be handing it
|
|
6.54 B a group it should not have -- 0.09% too much audio, which is not waste,
|
|
it is DRIFT: 0.83 ms a group, 1.2 s of lip-sync over the game's 22 minutes. So
|
|
`lump_bytes(k)` below is the format, not a convenience, and a player computes it
|
|
with one accumulator: `acc += F*aud_hz; n = acc // (2*fps); acc %= 2*fps`.
|
|
|
|
THE FOUR ADPCM AXES ARE IN THE HEADER BECAUSE GETTING ONE WRONG COSTS 25 dB
|
|
(FINDINGS 66). Nibble order, delta formula, clamp width and the accumulator's
|
|
value at PLAY are properties of the DECODER, and a container encoded for one
|
|
decoder and played on another comes out with the noise louder than the signal.
|
|
They are four fields rather than a version number so that a mismatch is legible
|
|
in a hexdump rather than inferred from a container's age.
|
|
|
|
THERE IS NO RECORD INDEX AND NO LENGTH WORD, and that is the difference DLX4's
|
|
index was invented for (49.3): a codec record's length is content-dependent, so
|
|
a producer cannot know where record i+1 starts without being told. A packed
|
|
record's length is GEOMETRY -- 192 rows of 256 B plus a palette -- so record `i`
|
|
is at `off_frm + i * rec_bytes` and a seek is arithmetic. Nothing in this
|
|
format has to be walked, which is also why the packed player has no ring
|
|
(ROADMAP K3): there is no variable-length thing to keep contiguous.
|
|
|
|
THE PALETTE ORDER IS A DECISION, NOT AN ACCIDENT (FINDINGS 62.5). Palette first
|
|
or 193rd is visible on screen for one paint -- old rows under the new palette, or
|
|
new rows under the old one -- and it is moot if buffer mode blanks the layer
|
|
(47.4/B2). It is a CONTAINER property here, chosen at encode time by
|
|
`--palette-last` and recorded in flags bit 1, so K3 can measure both without a
|
|
re-encode being an argument about which one the format assumed.
|
|
"""
|
|
import struct
|
|
import numpy as np
|
|
|
|
MAGIC = b"DLXP"
|
|
VERSION = 2
|
|
SECTOR = 512 # same rule and the same reason as dlx.SECTOR
|
|
PAL_BYTES = 512 # 256 entries, one GRB555+I word each
|
|
HDR_BYTES = 64
|
|
FLAG_PALETTE = 1 << 0
|
|
FLAG_PALETTE_LAST = 1 << 1
|
|
FLAG_AUDIO = 1 << 2
|
|
|
|
# The two ADPCM axes that are booleans. The other two -- the clamp width and
|
|
# the accumulator at PLAY -- are numbers and get their own fields, because
|
|
# encoding them as flags would mean this file deciding which values are legal.
|
|
AFMT_ORDER_LOW = 1 << 0
|
|
AFMT_VARIANT_TERMS = 1 << 1
|
|
|
|
# The default cadence, and it is a MEASUREMENT rather than a taste (FINDINGS
|
|
# 65.3): the sweep's floor is F=81 and costs 91,136 B more of player RAM for the
|
|
# last 0.09 of a point of padding, on a machine where two record buffers already
|
|
# want 99,328 B.
|
|
CADENCE_F = 11
|
|
|
|
|
|
def audio_format(variant, order, bits, init):
|
|
"""adpcm.py's four axes -> the three header fields that carry them.
|
|
|
|
This file does not import adpcm.py and must not: a container format that
|
|
depended on an encoder would be a format that could not be read without one.
|
|
What it carries is the DESCRIPTION, and `tools/analysis/34_packed_audio.py`
|
|
is what checks the description against the encoder that wrote the bytes.
|
|
"""
|
|
if variant not in ("shift", "terms") or order not in ("high", "low"):
|
|
raise ValueError(f"unknown ADPCM decoder ({variant!r}, {order!r})")
|
|
fmt = ((AFMT_ORDER_LOW if order == "low" else 0)
|
|
| (AFMT_VARIANT_TERMS if variant == "terms" else 0))
|
|
return fmt, int(bits), int(init)
|
|
|
|
|
|
def audio_decoder(fmt, bits, init):
|
|
"""The inverse: what a player, or a gate, has to run to hear the bytes."""
|
|
return dict(variant="terms" if fmt & AFMT_VARIANT_TERMS else "shift",
|
|
order="low" if fmt & AFMT_ORDER_LOW else "high",
|
|
bits=int(bits), init=int(init))
|
|
|
|
|
|
def cadence(fps, hz, F=CADENCE_F):
|
|
"""(F, A): F frames of audio rounded UP to whole sectors.
|
|
|
|
A*512 must cover F frames or the chip runs dry, so A is a ceiling and the
|
|
excess is padding the wire pays for and nothing plays. Integer arithmetic
|
|
throughout: the whole point of 65.3 is that this ratio has a remainder, and
|
|
a float here would hide the case where it does not.
|
|
"""
|
|
num, den = F * hz, 2 * fps # bytes per group = num/den
|
|
return F, -(-num // (den * SECTOR))
|
|
|
|
|
|
def lump_bytes(k, F, fps, hz, total=None):
|
|
"""The PAYLOAD of lump k -- what is handed to the chip, padding excluded.
|
|
|
|
Exact, and exactness is the finding (FINDINGS 67): floor((k+1)*F*hz/(2*fps))
|
|
- floor(k*F*hz/(2*fps)) alternates 7,161 and 7,162 at F=11, and a player
|
|
that fed the chip the whole A*512 B lump instead would run 0.09% fast --
|
|
1.2 s of lip-sync over 22 minutes.
|
|
"""
|
|
den = 2 * fps
|
|
n = ((k + 1) * F * hz) // den - (k * F * hz) // den
|
|
if total is not None: # the last lump is short, not padded
|
|
n = max(0, min(n, total - (k * F * hz) // den))
|
|
return n
|
|
|
|
|
|
def n_lumps(nframes, F):
|
|
return -(-nframes // F)
|
|
|
|
|
|
def pack_picture(idx):
|
|
"""(H,W) palette indices -> the bytes GVRAM wants, in GVRAM order.
|
|
|
|
The ONE place the interleave rule is applied, for the same reason
|
|
`dlx.record_lengths` is the one place the alignment rule is: every caller
|
|
that carries its own copy of a layout rule is a place the layout can drift.
|
|
"""
|
|
H, W = idx.shape
|
|
if W % 2:
|
|
raise ValueError(f"packed layout needs an even width, got {W}")
|
|
half = W // 2
|
|
left, right = idx[:, :half], idx[:, half:]
|
|
out = np.empty((H, half, 2), np.uint8)
|
|
out[:, :, 0] = right # high byte of the word -> page 1 -> col i+128
|
|
out[:, :, 1] = left # low byte -> page 0 -> col i
|
|
return out.reshape(H, half * 2)
|
|
|
|
|
|
def unpack_picture(buf, W, H):
|
|
"""The inverse, and the assertion that `pack_picture` is reversible."""
|
|
b = np.frombuffer(buf, np.uint8, W * H).reshape(H, W // 2, 2)
|
|
idx = np.empty((H, W), np.uint8)
|
|
idx[:, W // 2:] = b[:, :, 0]
|
|
idx[:, :W // 2] = b[:, :, 1]
|
|
return idx
|
|
|
|
|
|
def record_bytes(W, H, palette=True):
|
|
n = W * H + (PAL_BYTES if palette else 0)
|
|
if n % SECTOR:
|
|
raise ValueError(f"a {W}x{H} packed record is {n} B, which is not a "
|
|
f"whole number of {SECTOR} B sectors")
|
|
return n
|
|
|
|
|
|
def write(path, W, H, fps, frames, palette_last=False, audio=None):
|
|
"""`frames` is a sequence of (palette_words_bytes | None, picture_bytes).
|
|
|
|
`audio`, when given, is a dict: `data` the packed ADPCM bytes, `hz` the
|
|
chip's sample rate, `variant`/`order`/`bits`/`init` the four axes FINDINGS
|
|
66 measured, and optionally `F`. The stream is CUT UP HERE and nowhere
|
|
else, by `lump_bytes`, for the same reason `pack_picture` is the one place
|
|
the interleave lives: a second copy of a layout rule is a place the layout
|
|
can drift.
|
|
"""
|
|
frames = list(frames)
|
|
pal_b = PAL_BYTES if frames and frames[0][0] is not None else 0
|
|
pic_b = W * H
|
|
rec_b = record_bytes(W, H, palette=bool(pal_b))
|
|
flags = ((FLAG_PALETTE if pal_b else 0)
|
|
| (FLAG_PALETTE_LAST if palette_last and pal_b else 0)
|
|
| (FLAG_AUDIO if audio else 0))
|
|
|
|
if audio:
|
|
hz = int(audio["hz"])
|
|
F, A = cadence(fps, hz, audio.get("F", CADENCE_F))
|
|
au = audio["data"]
|
|
fmt, bits, init = audio_format(audio["variant"], audio["order"],
|
|
audio["bits"], audio["init"])
|
|
lumps = []
|
|
got = 0
|
|
for k in range(n_lumps(len(frames), F)):
|
|
n = lump_bytes(k, F, fps, hz, total=len(au))
|
|
if n > A * SECTOR:
|
|
raise ValueError(f"lump {k} needs {n} B and the cadence gives "
|
|
f"{A*SECTOR}")
|
|
lumps.append(au[got:got + n].ljust(A * SECTOR, b"\0"))
|
|
got += n
|
|
# A container whose audio runs out before its pictures do is a container
|
|
# that goes silent part way through, which is exactly the failure a
|
|
# writer should refuse rather than a player discover.
|
|
if got < len(au):
|
|
raise ValueError(f"{len(au)-got} B of audio have no lump to ride in "
|
|
f"-- {len(frames)} frames hold {got} B")
|
|
if got < min(len(au), (len(frames) * hz) // (2 * fps)):
|
|
raise ValueError(f"the audio stream is short: {len(au)} B for "
|
|
f"{len(frames)} frames at {fps} fps")
|
|
off_aud, off_frm = SECTOR, SECTOR + A * SECTOR
|
|
atail = struct.pack(">IIIHHHHhHII", off_aud, hz, len(au), F, A,
|
|
fmt, bits, init, 0, 0, 0)
|
|
else:
|
|
F = A = 0
|
|
lumps = []
|
|
off_aud, off_frm = 0, SECTOR
|
|
atail = struct.pack(">IIIHHHHhHII", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
|
|
|
|
hdr = (MAGIC + struct.pack(">HHHHHH", VERSION, flags, W, H, fps, len(frames))
|
|
+ struct.pack(">IIII", rec_b, pal_b, pic_b, off_frm) + atail)
|
|
assert len(hdr) == HDR_BYTES, len(hdr)
|
|
with open(path, "wb") as fh:
|
|
fh.write(hdr + b"\0" * (SECTOR - HDR_BYTES))
|
|
for i, (pw, pic) in enumerate(frames):
|
|
if len(pic) != pic_b or (pal_b and len(pw) != pal_b):
|
|
raise ValueError(f"frame {i}: record parts are the wrong size")
|
|
# The lump goes BEFORE the group it feeds, which is the one place
|
|
# this format makes a choice audio forced on it. 65.3's formula put
|
|
# it after; a stream is read forwards, so bytes that arrive after
|
|
# the slot they belong to are bytes a player has to have fetched
|
|
# early anyway. Placing it first makes the fetch order the play
|
|
# order and costs one lump of offset in the arithmetic.
|
|
if audio and i % F == 0:
|
|
fh.write(lumps[i // F])
|
|
rec = pic if not pal_b else (pic + pw if palette_last else pw + pic)
|
|
fh.write(rec)
|
|
return rec_b
|
|
|
|
|
|
class DLXP:
|
|
"""Reader, and every invariant the format claims, CHECKED rather than read.
|
|
|
|
The checks are not defensive coding. A packed record is written into GVRAM
|
|
and the palette registers with no bounds test anywhere -- the channel has no
|
|
opinion about what it is copying -- so a container whose geometry is a byte
|
|
wrong does not fail, it paints.
|
|
"""
|
|
|
|
def __init__(self, path):
|
|
b = self.raw = open(path, "rb").read()
|
|
if b[:4] != MAGIC:
|
|
raise ValueError(f"{path}: not a DLXP container")
|
|
(self.version, self.flags, self.W, self.H, self.fps,
|
|
self.nframes) = struct.unpack(">HHHHHH", b[4:16])
|
|
(self.rec_bytes, self.pal_bytes,
|
|
self.pic_bytes, self.off_frm) = struct.unpack(">IIII", b[16:32])
|
|
if self.version != VERSION:
|
|
raise ValueError(f"{path}: DLXP version {self.version}")
|
|
self.has_palette = bool(self.flags & FLAG_PALETTE)
|
|
self.palette_last = bool(self.flags & FLAG_PALETTE_LAST)
|
|
self.has_audio = bool(self.flags & FLAG_AUDIO)
|
|
(self.off_aud, self.aud_hz, self.aud_bytes, self.cad_f, self.cad_a,
|
|
self.aud_fmt, self.aud_bits, self.aud_init,
|
|
_r0, _r1, _r2) = struct.unpack(">IIIHHHHhHII", b[32:64])
|
|
if not self.has_audio:
|
|
if any((self.off_aud, self.aud_hz, self.aud_bytes, self.cad_f,
|
|
self.cad_a, self.aud_fmt, self.aud_bits, self.aud_init)):
|
|
raise ValueError(f"{path}: silent container with audio fields set")
|
|
else:
|
|
if (self.cad_f, self.cad_a) != cadence(self.fps, self.aud_hz,
|
|
self.cad_f):
|
|
raise ValueError(f"{path}: cadence F={self.cad_f} A={self.cad_a} "
|
|
f"does not cover {self.cad_f} frames of "
|
|
f"{self.aud_hz} Hz audio at {self.fps} fps")
|
|
if self.off_aud != SECTOR or self.off_aud % SECTOR:
|
|
raise ValueError(f"{path}: audio starts at {self.off_aud}")
|
|
if self.aud_bits not in (10, 12):
|
|
raise ValueError(f"{path}: ADPCM clamp is {self.aud_bits} bits")
|
|
# The stream has to fill the groups it is cut into, or the last
|
|
# frames of the scene play in silence and nothing says so.
|
|
want = sum(lump_bytes(k, self.cad_f, self.fps, self.aud_hz)
|
|
for k in range(n_lumps(self.nframes, self.cad_f)))
|
|
if not (want - self.cad_f * self.aud_hz // (2 * self.fps)
|
|
<= self.aud_bytes <= want):
|
|
raise ValueError(f"{path}: {self.aud_bytes} B of audio for "
|
|
f"{self.nframes} frames, geometry wants {want}")
|
|
if self.pic_bytes != self.W * self.H:
|
|
raise ValueError(f"{path}: picture is {self.pic_bytes} B for "
|
|
f"{self.W}x{self.H} -- the packed layout is 1.0 B/px")
|
|
if self.pal_bytes != (PAL_BYTES if self.has_palette else 0):
|
|
raise ValueError(f"{path}: palette section is {self.pal_bytes} B")
|
|
if self.rec_bytes != self.pal_bytes + self.pic_bytes:
|
|
raise ValueError(f"{path}: record is {self.rec_bytes} B, parts are "
|
|
f"{self.pal_bytes} + {self.pic_bytes}")
|
|
# The whole reason for DLX5 (58.3/59.4), and here it is free rather than
|
|
# a re-encode: the record is a fixed multiple of a sector by geometry.
|
|
if self.off_frm % SECTOR or self.rec_bytes % SECTOR:
|
|
raise ValueError(f"{path}: not sector-aligned -- stream at "
|
|
f"{self.off_frm}, record {self.rec_bytes}")
|
|
if self.off_frm != (SECTOR + (self.cad_a * SECTOR if self.has_audio
|
|
else 0)):
|
|
raise ValueError(f"{path}: record 0 is at {self.off_frm}, and a "
|
|
f"{self.cad_a}-sector lump comes before it")
|
|
want = (self.off_frm + self.nframes * self.rec_bytes
|
|
+ (n_lumps(self.nframes, self.cad_f) - 1) * self.cad_a * SECTOR
|
|
if self.has_audio else
|
|
self.off_frm + self.nframes * self.rec_bytes)
|
|
if len(b) != want:
|
|
raise ValueError(f"{path}: {len(b)} bytes, geometry says {want}")
|
|
|
|
def frame_off(self, i):
|
|
"""The arithmetic, and the ONE place it is written on the host side --
|
|
src/player/packed.s is the other, in six instructions, and
|
|
tools/analysis/34_packed_audio.py is what makes the two agree."""
|
|
if not (0 <= i < self.nframes):
|
|
raise IndexError(i)
|
|
o = self.off_frm + i * self.rec_bytes
|
|
return o + (i // self.cad_f) * self.cad_a * SECTOR if self.has_audio else o
|
|
|
|
def lump_off(self, k):
|
|
if not self.has_audio or not (0 <= k < self.n_lumps):
|
|
raise IndexError(k)
|
|
return self.off_aud + k * (self.cad_f * self.rec_bytes
|
|
+ self.cad_a * SECTOR)
|
|
|
|
@property
|
|
def n_lumps(self):
|
|
return n_lumps(self.nframes, self.cad_f) if self.has_audio else 0
|
|
|
|
def lump(self, k, padding=False):
|
|
"""Lump k's PAYLOAD -- what the chip is fed. `padding=True` returns the
|
|
whole A*512 B sector run instead, which is what the disc moves and what
|
|
a player must NOT hand to the chip (FINDINGS 67)."""
|
|
o = self.lump_off(k)
|
|
if padding:
|
|
return self.raw[o:o + self.cad_a * SECTOR]
|
|
n = lump_bytes(k, self.cad_f, self.fps, self.aud_hz, total=self.aud_bytes)
|
|
return self.raw[o:o + n]
|
|
|
|
def audio(self):
|
|
"""The whole ADPCM stream, reassembled from its lumps."""
|
|
return b"".join(self.lump(k) for k in range(self.n_lumps))
|
|
|
|
def decoder(self):
|
|
"""The four axes the bytes were encoded for, as adpcm.decode's kwargs."""
|
|
return audio_decoder(self.aud_fmt, self.aud_bits, self.aud_init)
|
|
|
|
def record(self, i):
|
|
o = self.frame_off(i)
|
|
return self.raw[o:o + self.rec_bytes]
|
|
|
|
def _split(self, i):
|
|
r = self.record(i)
|
|
if not self.has_palette:
|
|
return None, r
|
|
if self.palette_last:
|
|
return r[self.pic_bytes:], r[:self.pic_bytes]
|
|
return r[:self.pal_bytes], r[self.pal_bytes:]
|
|
|
|
def palette_words(self, i):
|
|
pw, _ = self._split(i)
|
|
if pw is None:
|
|
raise ValueError("this container carries no palette -- it was made "
|
|
"with --no-palette, and the palette a player would "
|
|
"display is not in the file to be read back")
|
|
return np.frombuffer(pw, ">u2").astype(np.uint16)
|
|
|
|
def indices(self, i):
|
|
_, pic = self._split(i)
|
|
return unpack_picture(pic, self.W, self.H)
|
|
|
|
def palette_rgb(self, i):
|
|
"""(256,3) uint8 -- what the DISPLAY produces, not what the encoder meant.
|
|
|
|
The palette in a record is already a GRB555+I word, so this is where the
|
|
5-bit hardware quantisation gets charged. Everything upstream of the
|
|
container is in RGB888 and 61.9's +4.89 dB was quoted there; a player's
|
|
number has to come from here. Same maths as `dlxload.pack_palette` and
|
|
`tools/bench/verify_frame256.py` -- the shared LSB `I` is a bit in the
|
|
word, so unpacking it needs no choice made.
|
|
"""
|
|
w = self.palette_words(i).astype(int)
|
|
f = np.stack([(w >> 6) & 31, (w >> 11) & 31, (w >> 1) & 31], 1) # R,G,B
|
|
p6 = lambda v: ((v << 2) | (v >> 4)) & 0xFF
|
|
return p6((f << 1) | (w & 1)[:, None]).astype(np.uint8)
|
|
|
|
def render(self, i):
|
|
"""(H,W,3) uint8 -- the frame as the display produces it."""
|
|
return self.palette_rgb(i)[self.indices(i)]
|
|
|
|
def video_kbps(self):
|
|
"""The picture on the wire. FIXED by geometry -- there is no lever."""
|
|
return self.rec_bytes * self.fps / 1024
|
|
|
|
def audio_kbps(self):
|
|
"""What the CADENCE costs, padding included, which is the honest figure:
|
|
the disc moves whole sectors and the wire pays for the ones that are
|
|
zero as well as the ones that are audio (FINDINGS 65.3)."""
|
|
if not self.has_audio:
|
|
return 0.0
|
|
return self.cad_a * SECTOR / self.cad_f * self.fps / 1024
|
|
|
|
def kbps(self):
|
|
"""Both, over the scene's own duration."""
|
|
return self.video_kbps() + self.audio_kbps()
|