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
+88
View File
@@ -0,0 +1,88 @@
-- Play one buffer of ADPCM nibbles on the emulated MSM6258V, from 68000 code
-- (ROADMAP P6a). The Lua here does what Lua is allowed to do in this tree:
-- push bytes in, start the CPU, read the mailbox out. It is NOT in the feed
-- path -- session 33's probe was, and a host that writes the data register at
-- host-frame rate is not feeding a chip that consumes at 15,625 Hz (65.5).
--
-- WHAT THE MEASUREMENT IS. MAME's -wavwrite capture, at a sample rate chosen
-- to EQUAL the chip's stream rate so nothing resamples it, is the chip's own
-- output. tools/bench/verify_adpcm_chip.py reads the four model axes out of it.
M = manager.machine
SP = M.devices[":maincpu"].spaces["program"]
local META = loadfile("adpcm_meta.lua")()
local AD_FLAG, AD_BUF, AD_LEN = 0x18600, 0x18604, 0x18608
local AD_MTC0, AD_CSRF, AD_CERF = 0x1860C, 0x18610, 0x18614
local AD_MTCF, AD_MARF, AD_SPIN, AD_STAT = 0x18618, 0x1861C, 0x18620, 0x18624
local code do local f=io.open("adpcmgate.bin","rb"); code=f:read("a"); f:close() end
local data do local f=io.open("adpcm_data.bin","rb"); data=f:read("a"); f:close() end
local function P(s) print("[ADP] "..s) end
local function T() local t=M.time; return t.seconds + t.attoseconds/1e18 end
local st, t0, tplay = "boot", nil, nil
SUB = emu.add_machine_frame_notifier(function()
local ok, err = pcall(function()
local t = T()
if st == "boot" then
if t < 3.0 then return end
for i = 1, #data do SP:write_u8(META.buf + i - 1, string.byte(data, i)) end
for i = 1, #code do SP:write_u8(0x10000 + i - 1, string.byte(code, i)) end
SP:write_u32(AD_FLAG, 0)
SP:write_u32(AD_BUF, META.buf)
SP:write_u32(AD_LEN, META.nbytes)
local cpu = M.devices[":maincpu"]
cpu.state["SR"].value = 0x2700 -- supervisor, ALL interrupts masked
cpu.state["SP"].value = 0x8000
cpu.state["PC"].value = 0x10000
P(string.format("pushed %d B of code and %d B of nibbles at 0x%X",
#code, META.nbytes, META.buf))
-- THE CAPTURE'S OWN CLOCK. The wav starts at t=0 of the run, so the host
-- has to know when PLAY happened to find the stream in it -- but it is
-- NOT used as the alignment: the verifier searches a small window around
-- it, because a host frame is 17.6 ms and a sample is 64 us.
st, t0 = "running", t
return
end
if st == "running" then
local fl = SP:read_u32(AD_FLAG)
if fl == 2 and not tplay then
tplay = t
P(string.format("PLAY at t=%.4f s, chip status $%02X (bit7 clear = playing), "
.."MTC then = %d of %d", t, SP:read_u32(AD_STAT),
SP:read_u32(AD_MTC0), META.nbytes))
end
if fl == 0xFF or fl == 0xEE then
P(string.format("channel finished: CSR=$%02X CER=$%02X MTC=%d MAR=$%06X "
.."spin=%d", SP:read_u32(AD_CSRF), SP:read_u32(AD_CERF),
SP:read_u32(AD_MTCF), SP:read_u32(AD_MARF),
SP:read_u32(AD_SPIN)))
local dt = t - (tplay or t)
P(string.format("%d bytes took %.4f s = %.1f B/s "
.."(15,625 nibbles/s wants 7,812.5)",
META.nbytes, dt, META.nbytes/dt))
if fl == 0xEE then P("ERROR: the gate flagged a channel error or a timeout") end
local f = io.open("adpcm_run.lua", "w")
f:write(string.format("return { tplay = %.9f, ok = %s, nbytes = %d,\n"
.." csr = %d, cer = %d, mtc = %d, spin = %d }\n",
tplay or -1, tostring(fl == 0xFF), META.nbytes,
SP:read_u32(AD_CSRF), SP:read_u32(AD_CERF),
SP:read_u32(AD_MTCF), SP:read_u32(AD_SPIN)))
f:close()
st = "drain"; t0 = t
return
end
if t - t0 > 60 then P("TIMEOUT flag="..string.format("%08X", fl)); M:exit() end
return
end
if st == "drain" then
-- let the capture run past the end of the stream, so a truncated wav is
-- never mistaken for a short stream
if t - t0 < 0.3 then return end
P("done")
M:exit()
end
end)
if not ok then print("[ADP] LUA ERROR: "..tostring(err)); M:exit() end
end)
+56
View File
@@ -0,0 +1,56 @@
#!/bin/bash
# ROADMAP P6a: ask the machine's own MSM6258V which decoder it is.
#
# tools/bench/adpcm_run.sh
#
# WHAT A GREEN RUN MEANS: 68000 code programmed HD63450 channel 3 exactly as the
# IPL ROM programs it -- dual address, 8-bit port, cycle steal, EXTERNAL request
# -- fed the chip a designed nibble stream at the chip's own pace, and exactly
# ONE of sixteen candidate decoder models reproduces MAME's capture of the
# result SAMPLE-EXACT, with every one of the four axes shown to matter.
#
# WHAT IT DOES NOT MEAN: anything about an MSM6258. This is MAME's device model
# measured end to end through the machine's real transport. It settles the RIG.
# The silicon stays on the hardware list.
set -e
cd "$(dirname "$0")/../.."
tools/vasm/vasmm68k_mot -Fbin -o tmp/adpcmgate.bin src/player/adpcmgate.s > /dev/null
python3 tools/bench/prep_adpcm.py
# -samplerate 15625 is not a preference: it is the chip's own stream rate
# (8 MHz / 512), and equal rates are what keep MAME's resampler from filtering
# the thing being measured. The first cut of this ran at the default 48000 and
# every reconstructed sample arrived as an interpolated pair.
( cd tmp && SDL_VIDEODRIVER=dummy stdbuf -oL timeout -k 5 300 \
mame x68000 -bios ipl10 -ramsize 2M -video soft -window \
-samplerate 15625 -wavwrite adpcm.wav -nothrottle -plugins \
-autoboot_script ../tools/bench/adpcm.lua \
-seconds_to_run 12 > adpcm_run.log 2>&1 )
grep -aq "^\[ADP\] done" tmp/adpcm_run.log || {
echo "FAIL: the ADPCM gate did not finish -- no completion marker."
tail -8 tmp/adpcm_run.log; exit 1; }
grep -a "^\[ADP\]" tmp/adpcm_run.log | sed 's/^\[ADP\] / /'
fail() { echo "FAIL: $1"; exit 1; }
if grep -aq "^\[ADP\] ERROR" tmp/adpcm_run.log; then
fail "the channel reported an error or the gate timed out -- see CSR/CER above."
fi
grep -aq "bit7 clear = playing" tmp/adpcm_run.log || \
fail "the chip never reported itself playing."
# THE FEED RATE IS A GATE, not a note. The chip is the pacemaker: one byte per
# #DRQ3 and #DRQ3 at half the sample rate. If the bytes went out at some other
# rate then the channel was NOT being paced by the device, and every sample
# below is of a stream that arrived faster or slower than it was consumed --
# which is precisely the failure session 33 hit from Lua.
RATE=$(sed -n 's/.*= \([0-9.]*\) B\/s .*/\1/p' tmp/adpcm_run.log | head -1)
python3 - "$RATE" <<'PY' || fail "the feed was not paced by the chip (see above)."
import sys
r = float(sys.argv[1])
want = 7812.5
print(f" feed rate {r:,.1f} B/s against the chip's own {want:,.1f} B/s "
f"({100*(r-want)/want:+.2f}%)")
sys.exit(0 if abs(r - want) / want < 0.02 else 1)
PY
python3 tools/bench/verify_adpcm_chip.py tmp/adpcm.wav tmp/adpcm_seq.json
+17
View File
@@ -753,4 +753,21 @@ python3 tools/analysis/32_audio_wire.py tmp/packed_singe.dlxp \
> tmp/audio_wire.log 2>&1 || { cat tmp/audio_wire.log; exit 1; }
grep -aE "^ ( 1| 11| 81) |THE FLOOR|F=1 |F=11|is ZERO|SOUND IS WHAT" tmp/audio_wire.log
echo "--- session 34: THE CHIP'S OWN DECODER, off the machine (FINDINGS 66) ---"
# ROADMAP P6a. 68000 code programs HD63450 channel 3 exactly as the IPL ROM
# programs it and feeds the MSM6258 a designed nibble stream at the chip's own
# pace; ONE of sixteen candidate decoder models reproduces MAME's capture
# sample-exact, and every axis has a negative control. The encoder disagreed
# with the chip on ALL FOUR axes, and the largest of them is not the delta
# formula 65 named -- it is the NIBBLE ORDER, at -25.7 dB.
bash tools/bench/adpcm_run.sh > tmp/adpcm_gate.log 2>&1 || {
cat tmp/adpcm_gate.log; exit 1; }
grep -aE "^(OK|FAIL) |^ (feed|nibbles|delta|clamp|accumulator)" tmp/adpcm_gate.log \
| sed 's/^/ /'
# And the bill, on the same ten seconds every other audio figure is quoted on.
python3 tools/analysis/33_adpcm_model.py tmp/au_singe.raw > tmp/adpcm_model.log 2>&1 \
|| { cat tmp/adpcm_model.log; exit 1; }
grep -aE "wrong only here|played on the chip|decoded on the encoder|headroom left" \
tmp/adpcm_model.log
echo "ALL GREEN"
+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()
+33
View File
@@ -0,0 +1,33 @@
-- SPIKE (session 34). Not a gate: it exists to find out whether the chip can be
-- driven at all, what scale its samples arrive at in a -wavwrite capture, and
-- where it clamps. It sets EVERY thing session 33's probes left to the IPL:
-- * YM2151 reg $1B bit1 = 0 -> CT1 = 0 -> ADPCM master clock 8 MHz
-- * PPI control $92 -> port C is an OUTPUT (without this the i8255's
-- out_pc_callback never fires and the pan and
-- divider writes go nowhere)
-- * PPI port C $08 -> pan 00 = BOTH, rate 10 = /512 -> 15,625 Hz
-- * ctrl $02 = COMMAND_PLAY -- session 33's probes wrote $01, COMMAND_STOP.
M = manager.machine
local sp = M.devices[":maincpu"].spaces["program"]
local YMA, YMD = 0xE90001, 0xE90003
local PPIC, PPICTL = 0xE9A005, 0xE9A007
local CTRL, DATA = 0xE92001, 0xE92003
local BYTE = tonumber(os.getenv("AD_BYTE") or "0x77")
local n = 0
SUB = emu.add_machine_frame_notifier(function()
n = n + 1
if n == 40 then
sp:write_u8(YMA, 0x1B); sp:write_u8(YMD, 0x00)
elseif n == 60 then
sp:write_u8(YMA, 0x1B); sp:write_u8(YMD, 0x00)
sp:write_u8(PPICTL, 0x92)
sp:write_u8(PPIC, 0x08)
print(string.format("[AD4] portC readback = $%02X", sp:read_u8(PPIC)))
sp:write_u8(CTRL, 0x02)
sp:write_u8(DATA, BYTE)
print(string.format("[AD4] PLAY, data $%02X, status = $%02X",
BYTE, sp:read_u8(CTRL)))
elseif n == 90 then
print("[AD4] done"); M:exit()
end
end)
+18 -2
View File
@@ -2,6 +2,14 @@
"""Gate tools/encoder/adpcm.py against the only independent decoder on this
machine: ffmpeg's `adpcm_ima_oki`.
THIS FILE IS NOT ABOUT THE X68000's CHIP and after session 34 that distinction
is load-bearing. It checks this implementation against an independent one, so
its parameters stay ffmpeg's -- variant 'shift', high nibble first, a 12-bit
clamp, accumulator from 0. What the MACHINE's MSM6258 does is measured by
tools/bench/adpcm_run.sh and it is a different set of four values on all four
axes (adpcm.CHIP, FINDINGS 66). Do not "fix" the defaults here to match it: a
reference check whose reference has been adjusted to agree is not a check.
There is no ffmpeg ENCODER for this format -- `adpcm_ima_oki` is decode-only --
so the encoder here cannot be checked against a reference implementation. What
CAN be checked, and is, is that the decoder our encoder runs in its own loop is
@@ -70,14 +78,22 @@ ck(ff == ours, f"variant 'shift' is SAMPLE-EXACT vs ffmpeg over {len(nibs)} nibb
lowfirst = [adpcm.unpack(data, len(nibs))[i ^ 1] for i in range(len(nibs))]
bad = [v * 16 for v in adpcm.decode(lowfirst, "shift")]
ndiff = sum(1 for a, b in zip(ff, bad) if a != b)
ck(ndiff > 0, f"low-nibble-first DISAGREES on {ndiff}/{len(ff)} -- so the order is measured, not assumed")
ck(ndiff > 0, f"low-nibble-first DISAGREES on {ndiff}/{len(ff)} -- so what ffmpeg reads is measured, not assumed")
# AND IT IS A FACT ABOUT A FILE FORMAT, NOT ABOUT A CHIP. Session 33 recorded
# this line as "nibble order: HIGH FIRST, measured", which it is -- of the VOX
# convention ffmpeg implements. Session 34 asked the machine's own MSM6258 the
# same question through HD63450 channel 3 and got the OTHER answer: the chip
# takes the LOW nibble of a delivered byte first (FINDINGS 66), and encoding
# for the wrong one of the two costs -25.7 dB on the Singe window. The two
# claims do not conflict; they are about different things, and only one of them
# is about the machine this is being ported to.
print("--- and the second variant is not the same decoder ---")
terms = [v * 16 for v in adpcm.decode(adpcm.unpack(data, len(nibs)), "terms")]
d = [abs(a - b) // 16 for a, b in zip(ff, terms)]
nd = sum(1 for x in d if x)
ck(nd > 0, f"variant 'terms' differs on {nd}/{len(d)} samples, max {max(d)} in 12-bit units"
" -- OPEN: which one the MSM6258 runs is unmeasured")
" -- and 'terms' is the one the machine runs (adpcm_run.sh, FINDINGS 66)")
print("--- and getting the variant wrong is NOT a rounding error ---")
# THE MEASUREMENT THAT CHANGED THIS FROM A FOOTNOTE INTO AN OPEN ITEM. The two
+176
View File
@@ -0,0 +1,176 @@
#!/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())