#!/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())