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
599 lines
30 KiB
Python
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())
|