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:
@@ -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 "
|
||||
|
||||
Reference in New Issue
Block a user