Files
Dragon-s-Lair-X68k/tools/bench/verify_adpcm_chip.py
T
prosolis 6dd3fb3597 Ask the chip which decoder it is, and find four wrong axes where one was expected
ROADMAP P6a, on the machine. 68000 code programs HD63450 channel 3 with the
IPL ROM's own ADPCM bytes -- dual address, 8-bit port, cycle steal, external
request -- and feeds the MSM6258 a designed 1,678-nibble stream at the chip's
own pace: 839 B in 0.1074 s = 7,811.4 B/s against the format's 7,812.5, CER=$00.
That transport is P6b's, not scaffolding.

Sixteen candidate decoder models, three capture decimations and a searched
prologue are fitted to MAME's capture. Exactly one reproduces it sample-exact
over all 1,678 samples, and every axis carries a negative control: flip it
alone and the closest survivor disagrees on 826, 1,504, 156 and 1,522 samples.

The chip runs 'terms', takes the LOW nibble of a byte first, clamps the
accumulator at 10 bits and starts it at -2. tools/encoder/adpcm.py defaulted to
the opposite of all four, and 65.2 named the wrong axis as the risk: the delta
formula is worth -2.88 dB and the NIBBLE ORDER is worth -25.74 dB. 65.1's "high
first, measured" was a measurement of ffmpeg, i.e. of the VOX file convention,
which is a different question from what a chip does with a byte in its data
register.

The 10-bit clamp is free on the Singe window and only because that window peaks
at 435 of 511 -- 1.4 dB of headroom on a -13.4 dBFS passage, 12.1 dB below where
the encoder was clamping, and inside the recursion. So the audio level is an
open choice again, downward, and the loudest passage on the disc is unmeasured.

Session 33's silence had two ordinary causes: the PPI's port C is an input until
control word $92 says otherwise, and $01 is COMMAND_STOP. And a rig fact worth
the space: the 8 MHz ADPCM clock is CT1 in the YM2151's $1B, delivered on the
sound system's schedule rather than at the store, so a transfer started in the
same breath as the setup plays its first ~17 ms at the old clock and no model
fits a stream that changed rate part way through.

Name the layer: this is MAME 0.277's okim6258 device model measured end to end
through the machine's real transport. It settles the rig and not the silicon.
Also struck: 64.4's "no MAME source tree is on this machine" -- there is none on
disk, but the machine has network and the upstream tag fetches.

check.sh ALL GREEN before (tmp/check_s34_start.log) and after
(tmp/check_s34_end.log), with one new stage.

Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
2026-08-25 09:50:03 -07:00

177 lines
8.2 KiB
Python

#!/usr/bin/env python3
"""Read the MSM6258's own decoder out of a MAME capture. ROADMAP P6a.
FIVE THINGS ARE UNKNOWN, not the one FINDINGS 65 named:
feed which nibble(s) of a delivered BYTE the chip actually plays
variant 'shift' | 'terms' the delta formula (65.2, worth 25 dB)
bits 12 | 10 where the accumulator clamps
init 0 | -2 the accumulator at PLAY
(and the capture's own decimation, below)
`feed` is on the list because the run MEASURED it and it is not what anybody
assumed. Fed by HD63450 channel 3 in the IPL ROM's own configuration, this
machine plays ONE nibble per delivered byte -- the prologue is 16 zero nibbles
in 8 bytes and the accumulator climbs by 8 steps, not 16. A probe that had
assumed two would have found no model that fit and reported a broken rig. So
the hypothesis is enumerated with the others and the capture picks.
THE CAPTURE'S DECIMATION is enumerated for the same reason. MAME resamples the
chip's stream to the wav's rate, and a filtered 2x upsample is not recognisable
from a single sample -- on a slow ramp it looks like an exact repeat and on a
step it does not. So (factor, phase) is searched over {1x, 2x phase 0, 2x phase
1}, and the SCALE RESIDUAL is then checked on the decimated stream: MAME's
okim6258 puts `signal << 4` into a stream scaled to 32768 and the machine routes
it to the speaker at gain 0.50, so a chip sample is `signal * 8`. If the
winning decimation does not land within a couple of counts of a multiple of 8 on
every sample, it is not the chip's own stream and the run says so instead of
rounding to the nearest story.
WHAT THIS DOES NOT SETTLE. It measures MAME's device model driven through the
machine's real transport. It settles the RIG -- an emulated audio test encoded
against the wrong model is 25 dB of nothing -- and it leaves the silicon where
it was: needing a board or a datasheet.
"""
import json, os, struct, sys, wave
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "encoder"))
import adpcm
WAV = sys.argv[1] if len(sys.argv) > 1 else "tmp/adpcm.wav"
SEQ = sys.argv[2] if len(sys.argv) > 2 else "tmp/adpcm_seq.json"
SCALE = 8 # okim6258's <<4, times the machine's 0.50 speaker route
PRO_MAX = 40 # zero nibbles the prologue is allowed to have grown by
SKEW = 8 # samples of slack on where PLAY lands in the capture
DECIM = ((1, 0), (2, 0), (2, 1))
FEEDS = ("both-high-first", "both-low-first", "low-only", "high-only")
fails = []
def ck(ok, msg):
print(("OK " if ok else "FAIL ") + msg)
if not ok: fails.append(msg)
def nibbles_for(data, feed):
"""The nibble sequence the chip is hypothesised to have PLAYED, given the
bytes the channel delivered."""
if feed == "both-high-first":
return list(adpcm.unpack(data, None, "high"))
if feed == "both-low-first":
return list(adpcm.unpack(data, None, "low"))
if feed == "low-only":
return [b & 15 for b in data]
return [b >> 4 for b in data]
def main():
seq = json.load(open(SEQ))
pro, core = seq["pro"], seq["core"]
data = adpcm.pack([0] * pro + core, "high")
pro_bytes = pro // 2 # bytes of prologue, all $00
w = wave.open(WAV)
rate = w.getframerate()
n, ch = w.getnframes(), w.getnchannels()
s = struct.unpack("<%dh" % (n * ch), w.readframes(n))
left, right = list(s[0::ch]), list(s[1::ch])
ck(left == right, "both speakers carry the same samples (pan 00 = BOTH)")
ck(any(left), "the capture contains a signal at all")
if not any(left):
return 1
# ---- search: (decimation) x (feed) x (variant, bits, init) x (prologue)
results = {}
for fac, ph in DECIM:
rec = [round(v / SCALE) for v in left[ph::fac]]
nz = next((i for i, v in enumerate(rec) if v), None)
if nz is None:
continue
lo, hi = max(0, nz - SKEW), nz + 1
for feed in FEEDS:
base = nibbles_for(data, feed)
# a repeat of byte 0 costs whole nibbles under 'both' and one nibble
# under 'low-only'/'high-only'; either way it is zeros
for extra in range(PRO_MAX):
for variant in ("shift", "terms"):
for bits in (12, 10):
for init in (0, -2):
want = adpcm.decode([0] * extra + base, variant,
init=init, bits=bits)
for off in range(lo, hi):
if rec[off:off + len(want)] == want:
results.setdefault(
(feed, variant, bits, init),
(fac, ph, extra, off, len(want)))
print("--- candidates that reproduce the capture SAMPLE-EXACT ---")
for k, v in results.items():
print(f" feed={k[0]:<15s} variant={k[1]:<5s} bits={k[2]} init={k[3]:<2d}"
f" decimation {v[0]}x phase {v[1]}, prologue +{v[2]}, "
f"{v[4]:,} samples")
ck(len(results) == 1,
f"exactly one model reproduces the capture ({len(results)} did)")
if len(results) != 1:
return 1
model, (fac, ph, extra, off, ln) = next(iter(results.items()))
feed, variant, bits, init = model
# ---- the scale residual, on the stream the winner actually matched
seg = left[ph::fac][off:off + ln]
worst = max(abs(v - SCALE * round(v / SCALE)) for v in seg)
ck(worst <= 2,
f"every matched sample is within {worst} of a multiple of {SCALE} -- so "
f"`signal = round(sample/{SCALE})` is a recovery and not a rounding")
print("--- THE CHIP, AS THIS MACHINE MODELS IT ---")
print(f" nibbles played {feed}")
print(f" delta formula {variant}")
print(f" clamp {bits}-bit accumulator "
f"{adpcm.clamp_bounds(bits)}")
print(f" accumulator at PLAY {init}")
print(f" matched {ln:,} consecutive samples, "
f"capture decimated {fac}x at phase {ph}")
print(f" chip stream rate = {rate}/{fac} = {rate/fac:,.1f} Hz, and the "
f"channel delivered {len(data):,} B")
# ---- THE NEGATIVE CONTROLS. Flip one axis alone; the match must die.
# Without these an axis the probe is BLIND to reads exactly like an axis it
# has settled, which is 58.3's vacuous-counter trap in a new place.
print("--- and every axis was actually asked (flip one, the match dies) ---")
rec = [round(v / SCALE) for v in left[ph::fac]]
flips = {"feed": [f for f in FEEDS if f != feed],
"formula": ["terms" if variant == "shift" else "shift"],
"clamp": [12 if bits == 10 else 10],
"init": [0 if init == -2 else -2]}
for name, alts in flips.items():
worst_axis = None
for a in alts:
m = dict(feed=feed, variant=variant, bits=bits, init=init)
m[{"feed": "feed", "formula": "variant",
"clamp": "bits", "init": "init"}[name]] = a
want = adpcm.decode([0] * extra + nibbles_for(data, m["feed"]),
m["variant"], init=m["init"], bits=m["bits"])
got = rec[off:off + len(want)]
d = sum(1 for x, y in zip(got, want) if x != y)
worst_axis = d if worst_axis is None else min(worst_axis, d)
ck(worst_axis > 0,
f"{name:8s} flipped: the closest alternative still disagrees on "
f"{worst_axis:,} of {ln:,} samples")
print("--- against what tools/encoder/adpcm.py DEFAULTS to ---")
cur = {"feed": "both-high-first", "formula": "shift", "clamp": 12, "init": 0}
got = {"feed": feed, "formula": variant, "clamp": bits, "init": init}
for k in cur:
print(f" {k:8s} encoder {str(cur[k]):<15s} chip {str(got[k]):<15s}"
f" {'agree' if cur[k] == got[k] else 'DISAGREE'}")
json.dump({"feed": feed, "variant": variant, "bits": bits, "init": init,
"decimation": fac, "phase": ph, "extra": extra,
"matched": ln, "chip_rate": rate / fac},
open("tmp/adpcm_model.json", "w"))
print("ADPCM CHIP GATE " + ("GREEN" if not fails else f"RED: {len(fails)} failed"))
return 1 if fails else 0
if __name__ == "__main__":
sys.exit(main())