Files
Dragon-s-Lair-X68k/tools/bench/verify_packed_audio.py
T
prosolis ba966efe7e 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
2026-08-25 15:10:20 -07:00

599 lines
30 KiB
Python

#!/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] [--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,
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.
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.
"""
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 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():
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:
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.
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"
+ ("" 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 _, c in chunks:
off += len(c)
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")
# ---- 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 "
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 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 "
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())