Put the container on the chip, and find the held bus costs 463 times the seam

ROADMAP P6c, FINDINGS 68. 78,125 B of a DLXP2's audio out of channel 3,
sample-exact, while the video channel fetches records off the same disc.

The two pieces 67.6 said were missing: the lump buffer (pg_afill/pg_afetch,
three slots and the minimum is unmeasured) and 67.2's remainder accumulator
(pg_apay). The capture prices what the accumulator avoided at 1.26 s of
lip-sync over the game, against 67.2's predicted 1.25.

The finding is the third piece, which nothing had named: the MSM6258 has no
starvation state, so the gap between a channel counting out and the next arm
is a held nibble pair driving the predictor. Stealing, the seam is 0.51 ms
over ten seconds because dma.i's new DM_HOOK services the chip from inside
the transfer wait -- 250,000 of 250,240 looks. Held, the 68000 is halted and
gets 369: every one of the ten lump boundaries has a seam, worst 72.8 ms,
2.31% of the audio. Identical bytes, different sound. 64.3 reaching the audio.

Two bugs, and no counter in the player could see either. Clearing DM_BARV does
not unchain a channel -- OCR bits 3-2 are what it obeys -- and the symptom is
POLL TIMEOUT on the lump and every record after it. And the refill ran one lump
ahead of its ring and overwrote the buffer the channel was reading: 11 of 11
armed, 11 fetched, no starve, and the sound wrong from 0.2 s in. Which is why
the gate is a WAV: verify_packed_audio.py walks the stream one delivered byte
at a time, because MAME's okim6258 resets the nibble select on every write and
a byte is two nibbles only 99.994% of the time.

check.sh ALL GREEN before (tmp/check_s36_start.log) and after
(tmp/check_s36_end.log), with the new stage.

Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
This commit is contained in:
prosolis
2026-08-25 13:11:37 -07:00
parent e3778f62b0
commit 191f2b47bb
13 changed files with 1413 additions and 20 deletions
+334
View File
@@ -0,0 +1,334 @@
#!/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]
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,
and every one of those numbers can be right while the sound is wrong. Nothing
in this format parses anything (FINDINGS 67.4): a lump fetched one sector out is
not an error, it is 7,168 B of noise played at the right length; a payload one
byte long is not an error either, it is a rate. The only instrument that can
tell those apart from a correct run is the stream the chip actually produced.
WHAT IS CHECKED, and it is the whole scene rather than a sample of it:
1. every lump's payload, decoded with the FOUR AXES OUT OF THE CONTAINER'S OWN
HEADER (FINDINGS 66/67.3), appears in the capture SAMPLE-EXACT and in
order. Not "close": the recursion is exact arithmetic and MAME's okim6258
puts `signal << 4` into a stream the machine routes to the speaker at gain
0.50, so a chip sample is `signal * 8` and recovering it is a division and
not a rounding. The residual is asserted.
2. the PAYLOAD lengths are the accumulator's and not the lump's. A player
that fed the chip the whole A*512 B lump runs 0.09% fast -- 1.25 s of
lip-sync over the game (67.2) -- and the difference between the two is 6.54
B a group, which is 13 samples. So this is checked by LENGTH: lump k's run
of matched samples must be exactly 2*lump_bytes(k), and that alternates
14,322 / 14,324 rather than being 14,336 every time.
3. THE SEAMS, measured rather than assumed. Channel 3 counts out at the end
of a lump and the chip has no FIFO and no starvation state -- it goes on
decoding whatever byte its data register still holds, alternating that
byte's low and high nibbles, until the CPU arms the next lump. Those
samples are NOT silence, they are the recursion running on a repeated byte,
and the state they leave behind is what lump k+1 decodes from. So the
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.
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.
"""
import json, os, struct, sys, wave
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "encoder"))
import adpcm
from dlxp import DLXP, lump_bytes as _lump_bytes
def dlxp_lump_bytes(d, k):
"""What the CADENCE gives lump k, before the stream's end
truncates it -- so a lump that is short because the scene ran
out can be told from one that is short because the remainder
arithmetic said so."""
return _lump_bytes(k, d.cad_f, d.fps, d.aud_hz)
SCALE = 8 # okim6258's `signal << 4` into a 32768 stream, times the
# machine's 0.50 speaker route. verify_adpcm_chip.py's.
RESID = 2 # counts of slack on that recovery, as 66 measured it
LOOK = 32 # samples of continuation a candidate run has to survive
MAXRUN = 60000 # nibbles one delivered byte may be stretched over: 3.8 s
# at 15,625 Hz, far past any seam a working player makes.
# A bound is what makes a failure say "not found" rather
# than run until the host is bored.
LEAD = 400000 # samples of silence before PLAY
def stepper(dec):
"""One sample of the recursion, as a closure over the container's own four
axes. adpcm.decode_state is the same arithmetic and is what the whole-lump
paths use; this exists because the walk below needs it ONE NIBBLE AT A TIME
and a function call per sample over 78,125 bytes is the difference between
a gate that runs in seconds and one that does not."""
lo, hi = adpcm.clamp_bounds(dec["bits"])
variant, step, adj = dec["variant"], adpcm.STEP, adpcm.INDEX_ADJUST
def one(st, n):
sig, idx = st
sig += adpcm.delta(n, step[idx], variant)
sig = lo if sig < lo else (hi if sig > hi else sig)
idx += adj[n & 7]
idx = 0 if idx < 0 else (48 if idx > 48 else idx)
return sig, (sig, idx)
return one
def walk(rec, pos, st, data, dec):
"""Account for EVERY byte the player handed the chip, in order.
WHY A WALK AND NOT A COMPARISON. A whole-stream `decode(data) == capture`
is the check this wanted to be and it does not survive contact with the
machine. The MSM6258 has no FIFO and no handshake at all: the DMA channel
writes a byte into the data register whenever #DRQ3 asks, and the chip
decodes a nibble out of whatever is in there on every sample tick. Those
are two clocks -- 7,812.5 B/s and 15,625 Hz -- and MAME's okim6258 data_w
RESETS the nibble select on every write. So a byte is normally played as
two nibbles, and near a boundary it can be played as one (the high nibble
dropped) or as three or more (the low/high pair repeated) purely from where
the write lands inside a sound-stream slice.
THE MODEL IS THEREFORE ONE LINE: byte b was played as `c` nibbles taken from
the cycle (b&15, b>>4), c >= 1. This walk finds `c` for every byte, and the
HISTOGRAM of c is the result -- c=2 everywhere is a chip being fed exactly
at its own rate, and every c>2 is the chip replaying a byte while the 68000
was somewhere else, which is what a SEAM is.
It is not a loose check. Every one of the `c` samples has to be exactly
right, the run has to be followed by LOOK samples that are exactly right,
and a byte the player never sent leaves no c at all. Returns
(fail_index, pos, runs); fail_index is None on success.
"""
one = stepper(dec)
runs = []
n = len(data)
pos_ = pos
for i in range(n):
b = data[i]
pair = (b & 15, b >> 4)
nxt = data[i + 1] & 15 if i + 1 < n else None
# DEEP FIRST, THEN SHALLOW. LOOK samples of continuation is what tells
# a real seam from a coincidence -- a repeated pair can agree with the
# next lump's first nibble for one sample and does not for thirty-two.
# But the window is 16 bytes wide and a SECOND stretched byte inside it
# rejects the true answer as well as the false ones, so a byte that no
# candidate survives is retried with a shallow window rather than
# failing -- and the retry window is TWO samples, the next byte's own
# pair, because a four-sample one reaches into the stretched byte
# itself. The cost of resolving it wrong is a failed walk, not a pass.
got = _pick(rec, pos_, st, pair, nxt, data, i, one, LOOK)
if got is None:
got = _pick(rec, pos_, st, pair, nxt, data, i, one, 2)
if got is None:
return i, pos_, runs
c, st = got
pos_ += c
runs.append(c)
return None, pos_, runs
def _pick(rec, pos, st, pair, nxt, data, i, one, look):
"""The run length for one byte: every candidate `c` whose samples are exact
and whose continuation survives `look`, resolved to c=2 where c=2 is one of
them. Two nibbles a byte is what the two clocks agree on; anything else is
an event and an event needs the evidence, which is what `look` is."""
cands = []
s2, c = st, 0
while c < MAXRUN and pos + c < len(rec):
smp, s3 = one(s2, pair[c & 1])
if rec[pos + c] != smp:
break
c += 1
s2 = s3
if nxt is None:
cands.append((c, s2))
break
smp2, _ = one(s2, nxt)
if pos + c < len(rec) and rec[pos + c] == smp2 \
and _look(rec, pos + c, s2, data, i + 1, one, look):
cands.append((c, s2))
if c > 2 and len(cands) >= 2:
break
if not cands:
return None
for cc in cands:
if cc[0] == 2:
return cc
return cands[0]
def _look(rec, pos, st, data, i, one, look=LOOK):
"""`look` samples of continuation, assuming two nibbles a byte from here.
This is what tells a real seam from a coincidence. At a lump boundary the
repeated pair can happen to agree with the next lump's first nibble for one
sample; it does not go on agreeing for thirty-two.
"""
n, k = len(data), 0
while k < look and i < n:
b = data[i]
for nib in (b & 15, b >> 4):
if pos >= len(rec):
return True
smp, st = one(st, nib)
if rec[pos] != smp:
return False
pos += 1
k += 1
i += 1
return True
def main():
if len(sys.argv) < 3:
sys.exit(__doc__)
d = DLXP(sys.argv[1])
counters = json.load(open(sys.argv[3])) if len(sys.argv) > 3 else None
if not d.has_audio:
sys.exit("this container is silent -- there is nothing to have heard")
dec = d.decoder()
fails = []
def ck(ok, msg):
print(("OK " if ok else "FAIL ") + msg)
if not ok:
fails.append(msg)
w = wave.open(sys.argv[2])
n, ch, rate = w.getnframes(), w.getnchannels(), w.getframerate()
s = struct.unpack("<%dh" % (n * ch), w.readframes(n))
left, right = s[0::ch], s[1::ch]
ck(rate == d.aud_hz,
f"the capture is at {rate:,} Hz and the chip's stream is {d.aud_hz:,} -- "
f"equal rates are what keep MAME's resampler out of the measurement")
ck(list(left) == list(right), "both speakers carry the same samples (pan 00)")
ck(any(left), "the capture contains a signal at all")
if not any(left) or rate != d.aud_hz:
return 1
rec = [round(v / SCALE) for v in left]
start = next(i for i, v in enumerate(rec) if v)
ck(start < LEAD, f"the chip starts playing {start/rate:.2f} s in")
print(f"--- {sys.argv[1]}: {d.n_lumps} lumps, decoder {dec['variant']}/"
f"{dec['order']}, {dec['bits']}-bit clamp, accumulator {dec['init']} "
f"at PLAY -- ALL FOUR out of the header (67.3)")
# ---- THE STREAM, ACCOUNTED FOR BYTE BY BYTE. d.audio() is the container's
# own lumps reassembled BY PAYLOAD -- 67.2's accumulator, not the padded
# 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)
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"
+ ("" if bad is None else f" -- byte {bad:,} of {len(data):,} does not "
f"account for capture sample {pos:,}"))
if bad is not None:
k = bad * 2 * d.fps // (d.cad_f * d.aud_hz)
print(f" that is inside lump {k}, {bad - sum(len(d.lump(j)) for j in range(k)):,} B in")
return 1
matched = pos - start
worst = max(abs(v - SCALE * round(v / SCALE))
for v in left[start:start + matched])
ck(worst <= RESID,
f"every one of {matched:,} matched samples is within {worst} of a "
f"multiple of {SCALE} -- so `signal = sample/{SCALE}` recovers the chip's "
f"own stream rather than rounding to the nearest story")
# ---- 1. THE FEED. c=2 is a chip being fed at exactly its own rate.
hist = {}
for c in runs:
hist[c] = hist.get(c, 0) + 1
two = hist.get(2, 0)
ck(two * 1000 >= len(runs) * 999,
f"{two:,} of {len(runs):,} bytes ({two*100/len(runs):.3f}%) were played "
f"as exactly two nibbles -- the chip was paced by its own #DRQ3 and not "
f"by the CPU")
print(f" nibbles per delivered byte: "
+ ", ".join(f"{c}x{v:,}" for c, v in sorted(hist.items())))
# ---- 2. THE SEAMS, which is what every c > 2 is. A lump's channel counts
# out and the chip goes on replaying the last byte until the CPU arms the
# next one; the excess nibbles ARE that interval, measured in the only place
# it exists, which is the sound.
seams = [(i, c - 2) for i, c in enumerate(runs) if c > 2]
print(f"--- THE SEAMS: {len(seams)} byte(s) were stretched, out of "
f"{d.n_lumps - 1} lump boundaries")
if seams:
ex = sum(c for _, c in seams)
print(f" worst {max(c for _, c in seams)} samples = "
f"{max(c for _, c in seams)/rate*1000:.2f} ms; total {ex} samples "
f"= {ex/rate*1000:.2f} ms of replayed byte over "
f"{matched/rate:.2f} s of audio ({ex*100/matched:.4f}%)")
# and every stretched byte must BE a lump boundary -- a stretch anywhere
# 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))
ends.add(off - 1)
stray = [i for i, _ in seams if i not in ends]
ck(not stray,
f"every stretched byte is the LAST byte of a lump ({len(stray)} were not)"
+ ("" if not stray else f" -- first at byte {stray[0]:,}, which is the "
f"chip running dry in the middle of a buffer"))
# ---- 3. THE PAYLOAD IS THE ACCUMULATOR'S (FINDINGS 67.2). The walk
# already proves it -- a whole-lump player's stream contains the padding and
# would not walk -- so what is left is to price what was avoided. The LAST
# lump is left out: 120 frames is not a multiple of F=11, so it carries ten
# frames of audio and is short for an arithmetic reason and not a rate one.
full = [k for k in range(d.n_lumps)
if len(d.lump(k)) == dlxp_lump_bytes(d, k)]
ck(len(full) >= d.n_lumps - 1,
f"{len(full)} of {d.n_lumps} lumps carry a whole group of audio")
if len(full) > 1:
pad = d.cad_a * 512
got = sum(len(d.lump(k)) for k in full)
over = pad * len(full) - got
secs = got * 2 / rate
print(f"--- THE PADDING IS DRIFT (FINDINGS 67.2), over the {len(full)} "
f"lumps that carry a whole group")
print(f" payload {got:,} B against {pad*len(full):,} B of lump "
f"space: {over:,} B more, {over*100/got:.3f}%, "
f"{over*2/rate*1000:.2f} ms over {secs:.2f} s of audio")
print(f" -> {over*2/rate/secs*22.8*60:.2f} s of lip-sync over the "
f"game's 22.8 min, and the accumulator in pg_apay is the three "
f"lines that do not spend it")
if counters:
ck(counters["late"] == 0,
f"the player never re-armed a channel that still had bytes to send "
f"({counters['late']} did)")
ck(counters["starve"] == 0,
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):,})")
print(f" the player: {counters['armed']} lumps armed, "
f"{counters['fetched']} fetched, {counters['serv']:,} service "
f"calls over {counters['shown']} frames "
f"({'BUS HELD' if counters['held'] else 'CYCLE STEALING'})")
print("PACKED AUDIO GATE " + ("GREEN" if not fails
else f"RED: {len(fails)} failed"))
return 1 if fails else 0
if __name__ == "__main__":
sys.exit(main())