Take the player through a branch with sound, and find the predictor does not seek

FINDINGS 71, ROADMAP P6d.  70.3 named exactly what was missing -- packed.s starts
PG_AK/PG_AKF at lump 0 and has no audio seek path -- and priced its absence at a
mean 416.5 ms of silence over the arcade's 409 within-container seek targets.
pg_aseek is that path: the lump index, the stream position, the remainder
accumulator and the byte offset into the group, then the second READ(10) at the
lump's own LBA and a re-arm part way into the buffer.  Measured off a real volume:
132,162 B of spliced stream accounted for byte by byte in MAME's own capture,
across a branch at frame 37 -- four frames into lump 3, deliberately NOT on a
group boundary -- in both chip configurations.  Skip computed 2,604 B, cadence
says 2,604.

THE PREDICTOR DOES NOT SEEK, AND THE ERROR IS DC.  The MSM6258's accumulator is a
pure integrator with no leakage term, so a branch that hands the chip bytes chosen
for a state it is not in produces an offset that does not decay.  Playing through:
DC -355 of 511 with AC 0.00 -- the right shape from the wrong ground -- still -108
four seconds later.  STOP and re-PLAY: all 62,500 post-seek samples are EXACTLY a
decode from the container's own init, and the whole error is the single constant
-65.  A re-PLAY is 5.5x better and neither is zero, so PG_ARST is a mailbox with a
number under it.  The host computes -65 out of the container's bytes and the gate
asserts the equality rather than printing both.

AND THE ONLY FIX THAT REACHES ZERO IS THE ENCODER'S.  A player cannot set the
chip's accumulator, only reset it.  Resetting the encoder's predictor every frame
makes all 119 of the container's branch points exact for 0.33 dB (21.99 -> 21.66),
because the step table's floor is a constant 16.  That is a DLXP3 and it is
deliberately not in tools/encoder.

TWO SILENT BUGS, BOTH CAUGHT BY THE CAPTURE.  pg_udiv32 trashes d4 and pg_aseek
held hz there, so the offset came out 1 byte instead of 2,604 -- 166 ms of the
wrong part of the scene at exactly the right rate, every counter agreeing.  And
one already in the tree that had passed this gate three times: pg_ainit waited on
a READ-BACK MTC before PLAY, which is the same test as "a byte has left RAM" only
if no byte leaves in between.  One does, and the chip then plays the scene one
byte in, forever.  Found by locating the capture's opening samples in the
container image: sector 1 + 1.  The witness is now the count that was written.

ALL GREEN, two new stages included.

Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
This commit is contained in:
prosolis
2026-08-25 15:10:20 -07:00
parent 8b5f51704c
commit ba966efe7e
11 changed files with 1400 additions and 35 deletions
+272 -8
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env python3
"""The container's own audio, READ BACK OFF THE SPEAKER. ROADMAP P6c.
python3 tools/bench/verify_packed_audio.py <in.dlxp> <capture.wav> [counters.json]
python3 tools/bench/verify_packed_audio.py <in.dlxp> <capture.wav> \
[counters.json] [--seek FRAME --iters N]
WHY THIS READS THE CAPTURE AND NOT THE PLAYER'S COUNTERS. src/player/packed.s
reports how many lumps it armed and how many payload bytes it handed the chip,
@@ -36,6 +37,25 @@ WHAT IS CHECKED, and it is the whole scene rather than a sample of it:
search below carries the state across the seam and reports its LENGTH,
which is the audible cost of every design decision on the video path.
4. THE SEEK, when the run made one (--seek). A branch is a frame index and
a DLXP2 group puts lump k in FRONT of its records, so the player has to
issue a second read and enter the lump `f mod F` frames in (FINDINGS 70.3).
The stream the chip should then have been fed is the container's own bytes
SPLICED -- everything, then everything from byte floor(f*hz/(2*fps)) on --
and the walk above accounts for it as one continuous run, which is the
check: a player that dropped the byte offset feeds a stream that starts up
to F frames early and NOTHING ABOUT IT IS AN ERROR. It is a rate.
AND THE THING THE PLAYER CANNOT SEE. An MSM6258 has no seek: its
accumulator and step index are the product of every nibble since PLAY, so a
seek hands it bytes the encoder chose for a state it is not in. The
samples are then wrong while the recursion re-converges, and they are wrong
WITHOUT ANY BYTE BEING WRONG -- every counter in the player stays right and
the walk above still passes, because the walk tracks the chip rather than
the intent. This measures it: the chip's own samples across the splice
against the samples the ENCODER meant, which are the same bytes decoded
from the state a continuous play would have been in.
THE NEGATIVE CONTROL IS BUILT IN. A seam is found by searching for the repeat
count that makes the next lump match; if the player had fed the wrong bytes, no
repeat count would make it match and the run fails rather than sliding.
@@ -190,9 +210,88 @@ def _look(rec, pos, st, data, i, one, look=LOOK):
return True
def stream_pos(d, frame):
"""The byte of the ADPCM stream frame `frame` starts at. Exact, and the
exactness is the same one FINDINGS 67.2 is about: 15,625 samples a second
over 24 half-frames does not divide, and a seek that rounded it would be a
rate error rather than a byte error."""
return frame * d.aud_hz // (2 * d.fps)
def spliced(d, seek, iters):
"""The stream the player should have fed, as (label, bytes) chunks.
One pass is the container's lumps in order. A pass after a SEEK starts at
stream byte stream_pos(seek): the tail of lump k = seek//F, then every lump
after it. The chunks are kept separate because their BOUNDARIES are what
the stretched-byte assertion is measured against -- the chip replays its
last byte at a lump end and at a seek, and nowhere else.
"""
out = [(f"lump {k}", d.lump(k)) for k in range(d.n_lumps)]
if iters <= 1 or seek is None:
return out
b0 = stream_pos(d, seek)
for it in range(1, iters):
off = 0
for k in range(d.n_lumps):
lb = d.lump(k)
if off + len(lb) <= b0:
off += len(lb)
continue
cut = max(0, b0 - off)
out.append((f"pass {it+1} lump {k}" + (f" +{cut} B" if cut else ""),
lb[cut:]))
off += len(lb)
return out
def walk_reset(rec, start, data, dec, sp):
"""The walk, SPLIT AT A CHIP RESET.
A STOP/PLAY at the branch is a discontinuity the continuous model cannot
cross: the accumulator, the step index and the nibble select all go back to
the container's own start state, so no repeat count of the last byte of
pass 1 can be followed by pass 2's first sample. The walk would report
"byte 78,124 does not account for capture sample N", which is true and is
the wrong complaint.
So pass 1 is walked without its final byte, the RESTART is searched for --
the first capture position at which pass 2 walks cleanly from `init` -- and
pass 2 is walked from there. The final byte's run length is then the gap,
which is exactly what it is: the chip replaying it while the 68000 fetched
a lump and stopped the chip.
The search is cheap because the first post-reset sample is DETERMINED: one
nibble of a known byte from a known state. Only positions carrying that
value are tried at all.
"""
one = stepper(dec)
bad, pos, runs = walk(rec, start, (dec["init"], 0), data[:sp - 1], dec)
if bad is not None:
return bad, pos, runs, None
first, _ = one((dec["init"], 0), data[sp] & 15)
K = 64 # bytes of pass 2 a candidate must
for p in range(pos, min(pos + MAXRUN, len(rec))): # survive before the
if rec[p] != first: # full walk is run
continue
b2, _, _ = walk(rec, p, (dec["init"], 0), data[sp:sp + K], dec)
if b2 is None:
b3, pos3, runs3 = walk(rec, p, (dec["init"], 0), data[sp:], dec)
if b3 is None:
return None, pos3, runs + [p - pos] + runs3, p
return sp - 1, pos, runs, None
def main():
if len(sys.argv) < 3:
argv = [a for a in sys.argv[1:]]
seek, iters = None, 1
while "--seek" in argv:
i = argv.index("--seek"); seek = int(argv[i+1]); del argv[i:i+2]
while "--iters" in argv:
i = argv.index("--iters"); iters = int(argv[i+1]); del argv[i:i+2]
if len(argv) < 2:
sys.exit(__doc__)
sys.argv = [sys.argv[0]] + argv
d = DLXP(sys.argv[1])
counters = json.load(open(sys.argv[3])) if len(sys.argv) > 3 else None
if not d.has_audio:
@@ -229,8 +328,26 @@ def main():
# sector runs -- so a player that fed the chip whole lumps does not merely
# score worse here, it fails to walk: the 6.54 B of zero at the end of a
# lump are nibbles that are not in this stream.
data = d.audio()
bad, pos, runs = walk(rec, start, (dec["init"], 0), data, dec)
chunks = spliced(d, seek, iters)
data = b"".join(c for _, c in chunks)
if seek is not None and iters > 1:
print(f"--- THE SEEK: {iters} passes, passes 2..{iters} start at frame "
f"{seek} = stream byte {stream_pos(d, seek):,}, which is "
f"{seek % d.cad_f} frame(s) into lump {seek // d.cad_f} "
f"(FINDINGS 70.3). The stream below is SPLICED and is "
f"{len(data):,} B against the container's {d.aud_bytes:,}.")
replay = bool(counters and counters.get("arst"))
if replay and seek is not None and iters > 1:
sp0 = sum(len(c) for _, c in chunks[:d.n_lumps])
bad, pos, runs, rp = walk_reset(rec, start, data, dec, sp0)
if bad is None:
print(f" the chip was STOPPED and re-PLAYED at the branch: the "
f"walk is SPLIT there, and pass 2 restarts at capture sample "
f"{rp:,}, {runs[sp0-1]:,} samples after the last byte of pass "
f"1 was handed over ({runs[sp0-1]/rate*1000:.0f} ms of "
f"replayed byte and stopped chip)")
else:
bad, pos, runs = walk(rec, start, (dec["init"], 0), data, dec)
ck(bad is None,
f"all {len(data):,} bytes of the container's audio reached the chip, in "
f"order, and every sample the chip produced from them is exact"
@@ -278,8 +395,8 @@ def main():
# else is the CPU losing the chip in the middle of a buffer.
ends = set()
off = 0
for k in range(d.n_lumps):
off += len(d.lump(k))
for _, c in chunks:
off += len(c)
ends.add(off - 1)
stray = [i for i, _ in seams if i not in ends]
ck(not stray,
@@ -310,6 +427,129 @@ def main():
f"game's 22.8 min, and the accumulator in pg_apay is the three "
f"lines that do not spend it")
# ---- 4. WHAT A SEEK COSTS THE CHIP, and it is the thing no counter in the
# player can reach. The walk above proves every BYTE arrived; this asks
# whether the SAMPLES the chip made out of them are the ones the encoder
# meant. They are not, and they cannot be: the MSM6258's accumulator is a
# pure integrator with NO LEAKAGE TERM, so a state mismatch at a branch is a
# DC offset that does not decay -- it is not a transient with a time
# constant, and calling it one would be the flattering reading.
#
# THE REFERENCE IS THE ENCODER'S OWN STATE, not a fresh one. adpcm.py
# encoded the stream in one pass, so the state it chose byte B(f)'s nibbles
# for is the state a CONTINUOUS play reaches at B(f). The FRESH control is
# the other design -- STOP the chip and PLAY it again at the branch, which
# is what DLX_PK_ARST does -- and the two numbers are what choose between
# them. Under --replay the fresh series is not a control at all: it is the
# prediction, and it has to be sample-exact.
if seek is not None and iters > 1 and bad is None:
one = stepper(dec)
sp = sum(len(c) for _, c in chunks[:d.n_lumps])
b0 = stream_pos(d, seek)
whole = d.audio()
def run_from(st, i0, nmax):
"""The samples the chip WOULD have made from byte i0 on, had it been
in state `st` -- with the run lengths it actually used, so the two
series are sample-aligned across a seam as well as a byte."""
out, i = [], i0
while i < len(data) and len(out) < nmax:
b, c = data[i], runs[i]
for j in range(c):
smp, st = one(st, (b & 15) if j % 2 == 0 else (b >> 4))
out.append(smp)
i += 1
return out
if replay:
st = (dec["init"], 0) # by construction: PLAY sets
else: # both, and the run above
st = (dec["init"], 0) # proved it sample-exact
for i in range(sp): # ...otherwise the chip is
b, c = data[i], runs[i] # wherever the previous
for j in range(c): # scene's audio left it,
_, st = one(st, (b & 15) if j % 2 == 0 else (b >> 4))
pos0 = start + sum(runs[:sp])
N = min(4 * rate, len(rec) - pos0)
ref = (dec["init"], 0) # ...and the ENCODER's state
for by in whole[:b0]: # at the SAME stream byte,
for nib in (by & 15, by >> 4): # reached continuously
_, ref = one(ref, nib)
got = rec[pos0:pos0 + N]
want = run_from(ref, sp, N)
fresh = run_from((dec["init"], 0), sp, N)
n = min(len(got), len(want), len(fresh))
e = [got[i] - want[i] for i in range(n)]
def band(lo, hi):
seg = e[lo:min(hi, n)]
if not seg:
return None
m = sum(seg) / len(seg)
ac = (sum((x - m) ** 2 for x in seg) / len(seg)) ** 0.5
return m, ac, max(abs(x) for x in seg)
lo_c, hi_c = adpcm.clamp_bounds(dec["bits"])
print(f"--- THE PREDICTOR DOES NOT SEEK (FINDINGS 71). The chip's state "
f"at the branch is accumulator {st[0]}, step index {st[1]}; the "
f"encoder chose byte {b0:,}'s nibbles for accumulator {ref[0]}, "
f"step index {ref[1]}.")
print(f" error against the encoder's intent, DC and AC separately "
f"(full scale is {hi_c}):")
for lo, hi, lab in [(0, 100, "0-6 ms"), (100, 1000, "6-64 ms"),
(1000, 5000, "64-320 ms"), (5000, rate, "0.3-1.0 s"),
(rate, 2*rate, "1-2 s"), (2*rate, 4*rate, "2-4 s")]:
r = band(lo, hi)
if r:
print(f" {lab:>10} DC {r[0]:8.1f} AC {r[1]:7.2f} "
f"|max| {r[2]}")
if replay:
# THE STRONGEST FORM THIS CAN TAKE. A STOP/PLAY puts the chip in a
# state this script knows exactly, so the prediction is not "close",
# it is every sample. A run that claimed to reset and did not fails
# here and passes everything else on the page.
diff = [i for i in range(n) if got[i] != fresh[i]]
ck(not diff,
f"the chip was STOPPED and re-PLAYED at the branch, so all "
f"{n:,} post-seek samples are EXACTLY a decode from the "
f"container's own accumulator ({dec['init']}) and step index 0"
+ ("" if not diff else f" -- {len(diff):,} differ, first at "
f"sample {diff[0]}"))
dc = [x for x in e]
const = len(set(dc)) == 1
ck(const,
f"...and the whole error against the encoder's intent is the "
f"SINGLE CONSTANT {dc[0]}"
+ ("" if const else f" -- it takes {len(set(dc))} values, so the "
f"step indices differ too and this is distortion, not offset")
)
if const:
print(f" -> a re-PLAYED branch costs a PERMANENT DC offset "
f"of {dc[0]} = {abs(dc[0])*100/hi_c:.1f}% of full scale "
f"and {abs(dc[0])*100/511:.1f}% of the 10-bit clamp's "
f"headroom. It is inaudible as a tone and it is not free: "
f"it is headroom, and it clicks once at the branch.")
else:
r0, r4 = band(0, 100), band(2*rate, 4*rate)
ck(band(1000, 5000)[1] < abs(band(1000, 5000)[0]),
f"the error at the branch is an OFFSET and not distortion: over "
f"64-320 ms its DC is {band(1000,5000)[0]:.1f} and its AC is "
f"{band(1000,5000)[1]:.2f}, so the chip is decoding the right "
f"shape from the wrong ground")
if r4:
print(f" -> playing THROUGH the branch, the offset is still "
f"{r4[0]:.0f} four seconds later ({abs(r4[0])*100/hi_c:.0f}%"
f" of full scale). There is no leakage term in this "
f"predictor; what decay there is comes from the signal's "
f"own clamping, not from the recursion forgetting.")
# AND THE CONTROL THAT MAKES EITHER READING MEAN ANYTHING.
ck(any(want[i] != fresh[i] for i in range(n)),
f"the two references are distinguishable over these {n:,} samples, "
f"so 'carry the predictor' and 'reset it' are different runs and this "
f"measurement has a subject")
if counters:
ck(counters["late"] == 0,
f"the player never re-armed a channel that still had bytes to send "
@@ -318,8 +558,32 @@ def main():
f"the player never found the channel counted out with no lump ready "
f"({counters['starve']} times it did)")
ck(counters["bytes"] == len(data),
f"the player's own byte count ({counters['bytes']:,}) is the "
f"container's whole payload ({len(data):,})")
f"the player's own byte count ({counters['bytes']:,}) is the whole "
f"stream it should have fed ({len(data):,})")
if seek is not None and iters > 1:
# THE TWO CELLS, ASSERTED APART. A player that kept one counter for
# "where the stream is" and "what the chip got" reports a number
# that is right for neither, and the symptom is a LAST LUMP that is
# long by the skip -- which is not an error, it is a rate.
want_skip = (iters - 1) * (stream_pos(d, seek)
- stream_pos(d, seek - seek % d.cad_f))
ck(counters.get("seekn") == iters - 1,
f"the player made {counters.get('seekn')} audio seek(s) for "
f"{iters-1} branch point(s) -- the second read FINDINGS 70.3 "
f"asks for, at a separate LBA")
ck(counters.get("seekb") == want_skip,
f"it skipped {counters.get('seekb')} B into the head of a lump "
f"and the cadence says {want_skip} -- the byte offset is what "
f"makes a branch land on its own frame instead of up to "
f"{d.cad_f-1} frames early")
ck(counters["pos"] == d.aud_bytes,
f"the stream POSITION ended at {counters['pos']:,} B, the "
f"container's whole stream ({d.aud_bytes:,}) -- while the chip "
f"was handed {counters['bytes']:,}. TWO CELLS FOR TWO FACTS: the "
f"position is where the container has got to and is what the "
f"last lump's length is measured against; the byte count is what "
f"the capture has to account for, and a seek moves one and not "
f"the other")
print(f" the player: {counters['armed']} lumps armed, "
f"{counters['fetched']} fetched, {counters['serv']:,} service "
f"calls over {counters['shown']} frames "