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

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

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

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

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

ALL GREEN, two new stages included.

Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
This commit is contained in:
prosolis
2026-08-25 15:10:20 -07:00
parent 8b5f51704c
commit ba966efe7e
11 changed files with 1400 additions and 35 deletions
+238
View File
@@ -0,0 +1,238 @@
#!/usr/bin/env python3
"""What a BRANCH costs the chip, and what an encoder could do about it.
python3 tools/analysis/37_audio_seek.py [container.dlxp] [--raw au.raw]
python3 tools/analysis/37_audio_seek.py --gate # the check.sh stage
FINDINGS 71. Session 39 put an audio seek path in src/player/packed.s and ran
it: 132,162 B across a real branch, every byte accounted for in MAME's own
capture. That settled the BYTES. This settles what is left, which is a
property of the codec rather than of the player and which no counter in the
player can reach.
THE MSM6258'S ACCUMULATOR HAS NO LEAKAGE TERM. It is a pure integrator of
deltas, clamped, and nothing pulls it back toward zero. So a branch that hands
the chip bytes chosen for a state it is not in does not produce a transient with
a time constant -- it produces a DC OFFSET THAT NEVER DECAYS. The machine run
measures both designs at one branch point; this measures the CENSUS, over every
frame boundary of the container, and prices the only fix that is worth anything,
which is in the encoder and not in the player.
* PLAY THROUGH the branch: the chip keeps whatever accumulator and step index
the previous scene's audio left it in. Unbounded, and its decay is the
signal's own clamping rather than the recursion forgetting.
* STOP and re-PLAY: the accumulator goes to the container's `init` and the
step index to 0 -- a state this script knows exactly, so the error is
EXACTLY `init - acc(target)`, constant, forever.
* ...and the third option is the ENCODER'S: encode the stream with the
predictor RESET at every point a branch can land on. Then a re-PLAYing
player is not close, it is exact. What that costs is a codec question and
is measured below.
NAME THE LAYER. Everything here is host arithmetic over one container and its
source PCM. The chip's four axes are the ones FINDINGS 66 measured on the
machine and 67.3 put in the header; the branch behaviour is the one session 39
ran. No emulator is involved and no rate is claimed.
"""
import argparse, math, os, statistics, sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "encoder"))
import adpcm
from dlxp import DLXP
def acc_trajectory(nibbles, dec):
"""The decoder's accumulator after every sample. This IS the encoder's
assumed state, because adpcm.encode runs its decoder inside its own search
loop -- the encoder cannot hold a state the decoder will not reach."""
lo, hi = adpcm.clamp_bounds(dec["bits"])
sig, idx = dec["init"], 0
out = []
for n in nibbles:
sig += adpcm.delta(n, adpcm.STEP[idx], dec["variant"])
sig = lo if sig < lo else (hi if sig > hi else sig)
idx += adpcm.INDEX_ADJUST[n & 7]
idx = 0 if idx < 0 else (48 if idx > 48 else idx)
out.append((sig, idx))
return out
def encode_reset(src12, dec, period):
"""adpcm.encode with the predictor forced back to (init, 0) every `period`
samples. period=None is the ordinary encode.
THIS CHANGES THE BYTES, so it is a container property and not a flag a
player can set. It is written here rather than in tools/encoder/adpcm.py
because nothing has decided to ship it: 71.5 is the trade and the deciding
number is a hardware one."""
if period is None:
return adpcm.encode(src12, variant=dec["variant"], init=dec["init"],
bits=dec["bits"])
out = bytearray()
for i in range(0, len(src12), period):
out += adpcm.encode(src12[i:i + period], variant=dec["variant"],
init=dec["init"], bits=dec["bits"])
return bytes(out)
def snr(ref, got):
n = min(len(ref), len(got))
sig = sum(x * x for x in ref[:n])
err = sum((ref[i] - got[i]) ** 2 for i in range(n))
if err == 0:
return float("inf")
return 10 * math.log10(sig / err) if sig else float("-inf")
def decode_reset(nib, dec, period):
if period is None:
return list(adpcm.decode_state(nib, variant=dec["variant"],
init=dec["init"], bits=dec["bits"])[0])
out = []
for i in range(0, len(nib), period):
out += list(adpcm.decode_state(nib[i:i + period], variant=dec["variant"],
init=dec["init"], bits=dec["bits"])[0])
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument("container", nargs="?", default="tmp/packed_singe.dlxp")
ap.add_argument("--raw", default="tmp/au_singe.raw")
ap.add_argument("--gate", action="store_true")
a = ap.parse_args()
d = DLXP(a.container)
if not d.has_audio:
sys.exit("this container is silent -- there is no branch to price")
dec = d.decoder()
lo, hi = adpcm.clamp_bounds(dec["bits"])
data = d.audio()
nib = adpcm.unpack(data, len(data) * 2, order=dec["order"])
traj = acc_trajectory(nib, dec)
fails = []
def ck(ok, msg):
print(("OK " if ok else "FAIL ") + msg)
if not ok:
fails.append(msg)
# ---- 1. THE CENSUS. What a re-PLAY costs at every frame boundary the
# container has, which is every point a branch in this design can land on:
# 56.3's targets are frame indices and this player seeks to a frame.
den = 2 * d.fps
# THE INDEX IS A NIBBLE INDEX AND THE POSITION IS A BYTE ONE, which is the
# one conversion in this file and it is worth the line: getting it wrong
# reads the trajectory at HALF the target and produces a census that is
# entirely plausible -- a distribution of the right shape over the wrong
# instants. The cross-check against the machine's own branch point below is
# what caught it.
pos = lambda f: 2 * (f * d.aud_hz // den) - 1
frames = [f for f in range(1, d.nframes) if pos(f) < len(traj)]
dcs = [abs(dec["init"] - traj[pos(f)][0]) for f in frames]
idxs = [traj[pos(f)][1] for f in frames]
dcs_s = sorted(dcs)
p = lambda q: dcs_s[min(len(dcs_s) - 1, int(q * len(dcs_s)))]
print(f"--- 1. A RE-PLAYED BRANCH COSTS `init - acc(target)`, EXACTLY AND "
f"FOREVER. {len(frames)} frame boundaries of {a.container}:")
print(f" |DC| against the {hi}-unit clamp: mean {statistics.mean(dcs):.1f} "
f"({statistics.mean(dcs)*100/hi:.1f}%), median {statistics.median(dcs):.0f}, "
f"p90 {p(0.90)}, worst {max(dcs)} ({max(dcs)*100/hi:.1f}%)")
print(f" ...and the step index the encoder assumed at those points runs "
f"{min(idxs)}..{max(idxs)} of 48, median {statistics.median(idxs):.0f} "
f"-- a re-PLAY sets it to 0, so a branch into a LOUD passage gets the "
f"offset AND a step index that has to climb back")
# The machine run's own branch, so the two layers are checked against each
# other rather than merely agreeing in prose.
F37 = 37
dc37 = dec["init"] - traj[pos(F37)][0]
print(f" frame {F37}, the branch tools/bench/packed_run.sh runs on the "
f"machine: DC {dc37} -- and MAME's capture measured the chip at "
f"exactly that, constant over 62,500 samples (FINDINGS 71.3)")
ck(abs(dc37) == 65,
f"the host's arithmetic for the machine's own branch point is {abs(dc37)} "
f"and the capture said 65 -- one number, two layers")
# ---- 2. THE DECAY THAT ISN'T. A re-PLAY's error is constant BY
# CONSTRUCTION -- same step index, same nibbles, one offset -- and playing
# through is not, because the step indices differ too. The point of
# measuring it here is that the constancy is a PROPERTY OF THE PREDICTOR
# and not of the ten seconds this container happens to hold.
print(f"--- 2. AND IT DOES NOT DECAY. The accumulator is an integrator with "
f"no leak: a re-PLAY changes the STARTING value and nothing else, so "
f"the same nibbles produce the same deltas and the offset is carried "
f"to the end of the stream. The machine agrees -- AC 0.00 over four "
f"seconds (FINDINGS 71.3). Playing THROUGH the branch is worse and is "
f"not constant, because the step index differs as well: -355 falling "
f"to -108 over four seconds, which is clamping and not forgetting.")
# ---- 3. THE ENCODER'S FIX, PRICED. Reset the predictor where a branch can
# land and a re-PLAYing player is EXACT rather than close.
if not os.path.exists(a.raw):
print(f" (no {a.raw}: the encoder trade below needs the source PCM)")
return 1 if fails else 0
import struct
pcm = struct.unpack("<%dh" % (os.path.getsize(a.raw) // 2),
open(a.raw, "rb").read())
src12 = [max(-2048, min(2047, x >> 4)) for x in pcm][:len(nib)]
per_frame = d.aud_hz // den * 2 # samples in one frame slot
print(f"--- 3. THE ONLY FIX THAT MAKES A BRANCH FREE IS THE ENCODER'S, and "
f"here is its bill. Reset the predictor every N frames when encoding; "
f"a player that re-PLAYs at a branch landing on one of those points is "
f"then EXACT, not close:")
print(f" {"reset every":>24} {'SNR dB':>8} {'vs shipped':>10} "
f"{'branch points made free':>24}")
base = None
rows = []
for label, period in [("never (shipped)", None),
(f"{d.cad_f} frames (the cadence)", d.cad_f * per_frame),
("1 frame", per_frame)]:
nb = encode_reset(src12, dec, period)
got = decode_reset(nb, dec, period)
v = snr(src12, got)
if base is None:
base = v
free = (0 if period is None
else (len(frames) // d.cad_f if period != per_frame
else len(frames)))
rows.append((label, v, v - base, free))
print(f" {label:>24} {v:8.2f} {v-base:+10.2f} "
f"{free:>15} of {len(frames)}")
# THE ASSERTION IS THE ORDER AND THE SIGN, not the decibel: the source PCM
# is a property of the disc and the encoder is greedy, so the exact figures
# move with the window. What must not move is that resetting COSTS SNR and
# that resetting more often costs more -- if it ever came out free, the
# predictor would not be doing anything and the codec would be pointless.
ck(rows[1][1] <= rows[0][1] + 1e-9 and rows[2][1] <= rows[1][1] + 1e-9,
f"resetting the predictor costs SNR, and resetting it more often costs "
f"more: {rows[0][1]:.2f} -> {rows[1][1]:.2f} -> {rows[2][1]:.2f} dB")
ck(rows[2][1] > rows[0][1] - 3.0,
f"...and a reset EVERY FRAME is {rows[0][1]-rows[2][1]:.2f} dB, which is "
f"the price of making all {len(frames)} of this container's branch points "
f"exact. The step table's floor is a constant 16 and the recursion "
f"re-converges in a few samples, which is why twelve resets a second is "
f"not twelve times anything")
print(f"--- 4. WHAT THIS DOES NOT SETTLE.")
print(f" * Nothing here is a rate and nothing here ran on silicon. The "
f"branch behaviour is MAME's okim6258 -- PLAY sets the accumulator to "
f"-2, the step index to 0 and the nibble select to 0 -- which is the "
f"model FINDINGS 66 fitted to the machine and NOT a measurement of an "
f"MSM6258V. It joins session 34's fifth hardware item.")
print(f" * The census is ONE container, ten seconds, one passage at "
f"-13.4 dBFS (FINDINGS 69). The offset a re-PLAY costs is the signal's "
f"own value at the cut, so a louder passage costs more, up to the "
f"clamp -- and the disc peaks at 946 of 2048 (69.2).")
print(f" * The reset-every-frame encode is NOT in tools/encoder. It is "
f"a container change (a DLXP3), it costs bytes nothing and SNR "
f"something, and what decides it is whether a branch is allowed to "
f"land anywhere or only on frames the encoder was told about.")
print("AUDIO SEEK GATE " + ("GREEN" if not fails else f"RED: {len(fails)}"))
return 1 if fails else 0
if __name__ == "__main__":
sys.exit(main())