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
This commit is contained in:
prosolis
2026-08-25 09:50:03 -07:00
parent f925a1dd9a
commit 6dd3fb3597
15 changed files with 1240 additions and 22 deletions
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Build the nibble stream that asks the MSM6258 which decoder it is.
ROADMAP P6a.
WHAT HAS TO BE DISCRIMINATED, and it is four things rather than the one
FINDINGS 65 named:
1. DELTA FORMULA -- 'shift' (ffmpeg's adpcm_ima_oki) against 'terms' (the
datasheet's per-term truncation). Worth 25 dB (65.2).
2. NIBBLE ORDER -- which half of a byte handed to the data register is played
FIRST. 65.1 measured 'high' AGAINST FFMPEG, which is a fact about the VOX
file convention and not about a chip's data register.
3. THE CLAMP -- the accumulator saturates somewhere, and where is inside
the recursion, so it is not an output scaling that can be undone.
4. THE INITIAL ACCUMULATOR at the instant of PLAY.
The stream is in three parts and each part exists for a reason:
PROLOGUE, 16 zero nibbles. Nibble 0 moves the step index DOWN, so it stays
pinned at 0 and the delta is a constant +2 under every candidate. That makes
the prologue a RAMP that both formulas agree on, which is what absorbs the one
thing this rig cannot control: how many times the chip consumes byte 0 before
the channel delivers byte 1. The verifier reads that count off the capture
instead of assuming it.
SEGMENT A, a quiet sine, encoded by tools/encoder/adpcm.py itself. Amplitude
300 keeps it clear of even the 10-bit clamp, so A discriminates the FORMULA
and the ORDER without the clamp confounding either. Using the shipping
encoder rather than a hand-written pattern is deliberate: the nibbles the chip
is asked about are the kind of nibbles it will be sent.
SEGMENT B, loud bursts. It exists ONLY to cross the 10-bit clamp, which
segment A is built never to reach, and it is last because a clamp is
irreversible state and everything after it would be measuring segment B.
"""
import json, math, os, sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "encoder"))
import adpcm
PRO_NIB = 16 # prologue nibbles (byte 0 = $00, so a repeat costs nothing)
A_SAMPLES = 1500 # segment A, one nibble each
A_AMP = 300 # clear of the 10-bit clamp at 511 with room for the ramp
A_HZ = 61.0 # ~256 samples a cycle at 15,625 Hz: many step indices
RATE = 15625.0
BUF = 0x30000 # where the harness pushes the bytes
OUT_BIN = "tmp/adpcm_data.bin"
OUT_META = "tmp/adpcm_meta.lua"
OUT_SEQ = "tmp/adpcm_seq.json"
# THE TRIGGER, and it is here because the first cut of this file did not have
# one and measured ONE differing sample in 1,676. The two formulas are
# IDENTICAL whenever the step value is a multiple of 8:
#
# terms - shift = b2*floor(r/2) + b3*floor(r/4) - floor((4*b2+2*b3+1)*r/8)
#
# with r = step mod 8 and (b1,b2,b3) the nibble's low three bits. It is zero
# for r = 0, and the step table STARTS at 16. A quiet signal never moves the
# step index off its floor, so a probe made of quiet nibbles asks the chip a
# question that has the same answer either way.
#
# nibble 4 at step 16: delta 18 under both, and it moves the index to 2
# nibble 3 at step 19: shift 16, terms 15 <- the two states part company
#
# After that they never rejoin, because the delta is added to a running
# predictor -- so ONE two-nibble trigger converts the rest of the stream into
# discriminating evidence. That is the same recursion 65.2 priced at 25 dB,
# used deliberately instead of suffered.
TRIGGER = [4, 3]
def segment_a():
"""The trigger, then a sine encoded by the shipping encoder. The model the
sine is encoded under does not matter for discrimination -- once the trigger
has parted the two states, any nibble stream keeps them apart -- so the
defaults are used and the choice is recorded rather than tuned. Using the
shipping encoder rather than a hand-written pattern is the point: the
nibbles the chip is asked about are the kind of nibbles it will be sent."""
sig = [int(round(A_AMP * math.sin(2 * math.pi * A_HZ * i / RATE)))
for i in range(A_SAMPLES)]
return TRIGGER + list(adpcm.encode(sig, "shift"))
def segment_b():
"""Loud, and alternating in sign so the step index does not simply pin: 40
up, 40 down, twice. Under a 10-bit accumulator this saturates; under a
12-bit one it does not, and that difference is the whole point of it."""
return ([7] * 40 + [15] * 40) * 2
def main():
core = segment_a() + segment_b()
nibs = [0] * PRO_NIB + core
data = adpcm.pack(nibs, "high") # HIGH first: the encoder's convention,
# which is one of the things on trial
os.makedirs("tmp", exist_ok=True)
open(OUT_BIN, "wb").write(data)
# HOW MUCH DISCRIMINATING POWER IS IN IT, counted rather than asserted. A
# probe that cannot separate two candidates reports a match against both and
# a gate that did not count this would call that a result.
ref = adpcm.decode(nibs, "shift", init=-2, bits=10)
axes = {}
for name, kw in (("formula", dict(variant="terms")),
("order", dict(order="low")),
("clamp", dict(bits=12)),
("init", dict(init=0))):
order = kw.pop("order", "high")
n2 = ([0] * PRO_NIB
+ list(adpcm.unpack(data, len(nibs), order))[PRO_NIB:]) \
if order != "high" else nibs
n2 = list(adpcm.unpack(data, len(nibs), order))
alt = adpcm.decode(n2, kw.get("variant", "shift"),
init=kw.get("init", -2), bits=kw.get("bits", 10))
d = sum(1 for a, b in zip(ref, alt) if a != b)
axes[name] = d
seq = {"nibbles": nibs, "core": core, "pro": PRO_NIB,
"bytes": len(data), "buf": BUF, "axes": axes,
"a_samples": A_SAMPLES, "a_amp": A_AMP, "a_hz": A_HZ}
json.dump(seq, open(OUT_SEQ, "w"))
with open(OUT_META, "w") as f:
f.write("return {\n")
f.write(f" buf = 0x{BUF:X},\n")
f.write(f" nbytes = {len(data)},\n")
f.write(f" nnibs = {len(nibs)},\n")
f.write("}\n")
print(f" probe stream: {len(nibs)} nibbles = {len(data)} B "
f"= {len(nibs)/RATE*1000:.1f} ms at 15,625 Hz")
print(f" prologue {PRO_NIB} zero nibbles, segment A {len(segment_a())} "
f"(sine {A_AMP} @ {A_HZ} Hz), segment B {len(segment_b())} (loud)")
print(" DISCRIMINATING POWER -- samples that change when ONE axis is "
"flipped away from MAME's own model:")
for k, v in axes.items():
print(f" {k:8s} {v:5d} of {len(ref)}")
if __name__ == "__main__":
main()