Put sound in the packed container, and find the padding is a rate error

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
This commit is contained in:
prosolis
2026-08-25 11:22:38 -07:00
parent 6dd3fb3597
commit e3778f62b0
13 changed files with 1001 additions and 51 deletions
+67 -4
View File
@@ -3,7 +3,7 @@
python3 tools/encoder/pack.py <frames_dir> <out.dlxp> [--fps 12]
[--nframes N] [--palette-last] [--no-palette]
[--scene-palette]
[--scene-palette] [--audio tmp/au_singe.raw]
WHAT IS NOT HERE IS THE POINT. No VQ, no codebooks, no mode map, no rate
control, no `lam`, no leaky bucket, no span geometry -- `encode.py` is 452 lines
@@ -25,6 +25,15 @@ as "a direction, not the player's number" (risk 2 in the session 30 handoff).
`vq.frame_palette` is what actually ships it: 254 colours, index 0 held free for
the transparency key, black at 255. `tools/analysis/30_packed_container.py`
re-derives the figure against this and charges the GRB555 word on top.
AND `--audio` MAKES IT A DLXP2, WHICH IS THE ONE PLACE THE FOUR ADPCM AXES ARE
CHOSEN. It encodes for `adpcm.CHIP` -- the datasheet's per-term delta, the LOW
nibble of a byte first, a 10-bit clamp, the accumulator at -2 -- because that is
the set FINDINGS 66 measured out of the machine's own chip through the machine's
own DMA channel, and encoding for any other set costs up to 25.7 dB. It does
NOT use adpcm.py's module defaults, which are ffmpeg's on purpose so that
tools/bench/verify_adpcm.py stays a check against an independent implementation.
The axes go in the header, so a player never has to be told.
"""
import argparse, glob, os, sys
import numpy as np
@@ -35,6 +44,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "bench"))
import vq as VQ
import dlxp as P
import adpcm
from dlxload import pack_palette
ap = argparse.ArgumentParser()
@@ -52,6 +62,14 @@ ap.add_argument("--no-palette", action="store_true",
ap.add_argument("--scene-palette", action="store_true",
help="one palette for the whole scene, repeated in every record "
"-- the CONTROL for 61.9's per-frame claim")
ap.add_argument("--audio", default=None,
help="raw s16le mono at --audio-hz, from extract_audio.py. "
"Makes the output a DLXP2: the stream is encoded for "
"adpcm.CHIP and interleaved on the 65.3 cadence")
ap.add_argument("--audio-hz", type=int, default=15625,
help="the CHIP's rate, and the rate the .raw was resampled to")
ap.add_argument("--cadence", type=int, default=P.CADENCE_F,
help="frames between audio lumps (65.3's sweep picks 11)")
a = ap.parse_args()
files = sorted(glob.glob(f"{a.frames_dir}/f*.png"))
@@ -95,7 +113,35 @@ for n, src in enumerate(rgb):
psnr_pal.append(VQ.psnr(src, pal[idx]))
recs.append((palw, P.pack_picture(idx).tobytes()))
rec_b = P.write(a.out, W, H, a.fps, recs, palette_last=a.palette_last)
# THE AUDIO, AND IT IS ENCODED FOR THE CHIP AND NOT FOR ffmpeg. The source is
# s16le because that is what ffmpeg resamples to; the chip's word is 12 bits
# signed, so the shift is a requantisation and not a format conversion, and it
# is the same one tools/bench/verify_adpcm.py makes.
audio = None
if a.audio:
import struct as _struct
raw = open(a.audio, "rb").read()
pcm = _struct.unpack("<%dh" % (len(raw) // 2), raw)
src12 = [max(-2048, min(2047, x >> 4)) for x in pcm]
need = len(files) * a.audio_hz // (2 * a.fps)
nib = adpcm.encode(src12, variant=adpcm.CHIP["variant"],
init=adpcm.CHIP["init"], bits=adpcm.CHIP["bits"])
data = adpcm.pack(nib, order=adpcm.CHIP["order"])[:need]
# What the encoder thinks it wrote, checked by DECODING it with the same
# four axes -- the encoder runs its decoder inside its own loop, so this is
# not circular in the way it looks: it is the PACKED bytes going back
# through unpack(), which is where a nibble-order slip would land.
back = adpcm.decode(adpcm.unpack(data, len(src12), order=adpcm.CHIP["order"]),
variant=adpcm.CHIP["variant"], init=adpcm.CHIP["init"],
bits=adpcm.CHIP["bits"])
ref = src12[:len(back)]
e = [(x - y) ** 2 for x, y in zip(ref, back)]
sig = sum(x * x for x in ref)
snr = 10 * np.log10(sig / sum(e)) if sum(e) else float("inf")
audio = dict(data=data, hz=a.audio_hz, F=a.cadence, **adpcm.CHIP)
rec_b = P.write(a.out, W, H, a.fps, recs, palette_last=a.palette_last,
audio=audio)
d = P.DLXP(a.out) # re-read: every invariant is checked
if d.nframes != len(recs):
sys.exit("writer and reader disagree about the frame count")
@@ -103,10 +149,27 @@ if d.nframes != len(recs):
kind = ("scene palette" if a.scene_palette else "per-frame palette")
if a.no_palette:
kind += ", NONE in the record"
print(f"{a.out}: DLXP1 {W}x{H} {a.fps}fps {d.nframes} frames, {kind}"
print(f"{a.out}: DLXP{P.VERSION} {W}x{H} {a.fps}fps {d.nframes} frames, {kind}"
f"{', palette LAST' if a.palette_last else ''}")
print(f" record {rec_b:,} B = {rec_b // P.SECTOR} sectors exactly, "
f"file {os.path.getsize(a.out):,} B")
print(f" wire {d.kbps():.1f} KB/s -- FIXED by geometry, there is no lever")
if d.has_audio:
print(f" DLXP2: audio {d.aud_bytes:,} B at {d.aud_hz:,} Hz, SNR {snr:.2f} dB, "
f"cadence F={d.cad_f} A={d.cad_a} ({d.n_lumps} lumps)")
print(f" the four axes, in the header: "
+ ", ".join(f"{k}={v}" for k, v in d.decoder().items()))
# STEADY STATE, not the file: the last lump of a 120-frame window feeds 4
# frames out of 11 and occupies 14 sectors either way, so a whole-file
# padding figure is a boundary effect of the WINDOW and would change with
# its length. 65.3's 0.09% is the cadence's, and the cadence is the thing.
grp = d.cad_f * d.aud_hz / (2 * d.fps)
print(f" lump payload {P.lump_bytes(0, d.cad_f, d.fps, d.aud_hz):,}.."
f"{P.lump_bytes(2, d.cad_f, d.fps, d.aud_hz):,} B of {d.cad_a*P.SECTOR:,} "
f"-- {100*(d.cad_a*P.SECTOR-grp)/grp:.3f}% padding steady state, and the "
f"payload is NOT the lump (FINDINGS 67)")
print(f" wire {d.video_kbps():.1f} + {d.audio_kbps():.2f} = {d.kbps():.1f} KB/s "
f"-- FIXED by geometry, there is no lever")
else:
print(f" wire {d.kbps():.1f} KB/s -- FIXED by geometry, there is no lever")
print(f" palette-domain PSNR vs the 24-bit source: "
f"{np.mean(psnr_pal):.2f} dB (min {np.min(psnr_pal):.2f})")