Files
psxrecomp/tools/cd_diff.py
T
Alex Vanderveen 03e4d6be56 cd_diff: anchor on the palette file's Setlocs, not the Getstat heartbeat
The first live run aligned on the game's periodic Getstat poll -- every
30 frames, identical everywhere -- and reported a fork between native
mid-scene and the oracle still at boot (its tail showed lbas 304, 2864,
12422: boot loads; the scene had not been played there yet).

Anchor on Setlocs into the palette file's LBA range instead, compare the
two LBA request sequences positionally, and print each side's full
command window around the file with the first diverged request marked.
A side with no palette-file Setlocs in its history is told to play the
scene there rather than diffed against unrelated traffic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 12:55:58 -04:00

207 lines
8.2 KiB
Python

#!/usr/bin/env python3
"""cd_diff.py -- diff the game's CD request streams between the two emulators.
python3 tools/cd_diff.py # both emulators at/after the scene
Every layer below the game has measured clean on psx-runtime: requests are
executed exactly (delivered == requested, data heads match the ISO), INT1s are
presented (lost=0), the arithmetic and tables downstream are correct. What
diverges is the game's OWN request stream -- native asks Setloc 125111 then
125113 where the oracle's table proves it asked for 125112. The game computes
those requests from what the emulator tells it, so the first differing command
between the two streams, and the traffic just before it, IS the runtime bug.
Both emulators now keep a flood-immune per-command history (psx-runtime:
cdrom_command_history; oracle: cd_history, added for this). Play the scene on
BOTH, then run this: it aligns the streams on the last common Setloc and
prints the fork.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from psx_gpu_frame import ( # noqa: E402
DEFAULT_DUCKSTATION_PORT, DEFAULT_NATIVE_PORT, DebugConn, DebugError,
)
from cd_verify import CMD_NAMES, bcd # noqa: E402
KIND = "psx-cd-diff"
def normalise(entries):
"""Common shape: [{cmd, params, lba?, frame}] oldest first."""
out = []
for e in entries:
cmd = e.get("cmd")
cmd = int(cmd, 16) if isinstance(cmd, str) else int(cmd or 0)
ps = [int(x, 16) if isinstance(x, str) else int(x)
for x in (e.get("params") or [])]
row = {"cmd": CMD_NAMES.get(cmd, f"0x{cmd:02X}"), "params": ps,
"frame": int(e.get("frame", 0))}
if cmd == 0x02 and len(ps) >= 3:
m, s, f = (bcd(x) for x in ps[:3])
row["lba"] = (m * 60 + s) * 75 + f - 150
out.append(row)
return out
def setloc_runs(rows, lo, hi):
"""Indexes of Setloc commands whose LBA falls inside the target file."""
return [i for i, r in enumerate(rows)
if r["cmd"] == "Setloc" and lo <= r.get("lba", -1) <= hi]
def lba_sequence(rows, lo, hi):
return [rows[i]["lba"] for i in setloc_runs(rows, lo, hi)]
def command_key(row):
return (row["cmd"], row.get("lba", tuple(row["params"])))
def find_fork(a, b, window):
"""Align the two streams on their last long common run inside `window`
and return the index pair where they first differ afterwards."""
ka = [command_key(r) for r in a]
kb = [command_key(r) for r in b]
best = None
for i in range(len(ka)):
for j in range(len(kb)):
if ka[i] != kb[j]:
continue
n = 0
while (i + n < len(ka) and j + n < len(kb)
and ka[i + n] == kb[j + n]):
n += 1
if n >= window and (best is None or n > best[2]):
best = (i, j, n)
if best is None:
return None
i, j, n = best
return {"a_start": i, "b_start": j, "common": n,
"a_fork": i + n, "b_fork": j + n}
def in_lba_range(rows, lo, hi):
return [r for r in rows if lo <= r.get("lba", -1) <= hi
or True] # keep all; range used only for reporting focus
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=DEFAULT_NATIVE_PORT)
ap.add_argument("--ds-port", type=int, default=DEFAULT_DUCKSTATION_PORT)
ap.add_argument("--timeout", type=float, default=30.0)
ap.add_argument("--count", type=int, default=2048)
ap.add_argument("--lba-lo", type=int, default=125000)
ap.add_argument("--lba-hi", type=int, default=125200,
help="the palette file's LBA neighbourhood; alignment "
"anchors on Setlocs into this range, because generic "
"alignment latches onto the periodic Getstat "
"heartbeat and forks on unrelated journey points")
ap.add_argument("--window", type=int, default=6,
help="minimum matching run to anchor the alignment")
ap.add_argument("--out", default="analysis/frames/cd_diff.json")
args = ap.parse_args()
doc = {"kind": KIND, "version": 1}
try:
nat = normalise(DebugConn(args.host, args.port, args.timeout)
.cmd("cdrom_command_history",
count=args.count).get("entries", []))
except DebugError as e:
print(f"psx-runtime: {e}", file=sys.stderr)
return 2
try:
orc = normalise(DebugConn(args.host, args.ds_port, args.timeout)
.cmd("cd_history", count=args.count)
.get("entries", []))
except DebugError as e:
print(f"oracle: {e} -- rebuild it (duckstation_oracle.py build + "
f"install) and replay the scene there", file=sys.stderr)
return 2
# psx-runtime's history is newest-first; oracle's is oldest-first.
if nat and len(nat) > 1 and nat[0]["frame"] > nat[-1]["frame"]:
nat = nat[::-1]
if orc and len(orc) > 1 and orc[0]["frame"] > orc[-1]["frame"]:
orc = orc[::-1]
doc["native_count"], doc["oracle_count"] = len(nat), len(orc)
print(f"native: {len(nat)} commands; oracle: {len(orc)} commands")
# Anchor on the palette file itself. Generic longest-common-run
# alignment latched onto the periodic Getstat heartbeat (every 30
# frames, identical everywhere) and reported a fork between two
# unrelated journey points.
na = setloc_runs(nat, args.lba_lo, args.lba_hi)
ob = setloc_runs(orc, args.lba_lo, args.lba_hi)
seq_n = lba_sequence(nat, args.lba_lo, args.lba_hi)
seq_o = lba_sequence(orc, args.lba_lo, args.lba_hi)
doc["native_lbas"], doc["oracle_lbas"] = seq_n, seq_o
for label, seq in (("psx-runtime", seq_n), ("oracle", seq_o)):
if not seq:
print(f"\n{label}: NO Setloc into LBA {args.lba_lo}.."
f"{args.lba_hi} in its history -- the palette load is not "
f"captured there. Play the scene on {label} (through the "
f"land-creation moment) and rerun.")
if not seq_n or not seq_o:
_save(doc, args)
return 1
print(f"\nSetloc sequences into the palette file:")
print(f" psx-runtime: {seq_n}")
print(f" oracle: {seq_o}")
fork_at = next((k for k in range(min(len(seq_n), len(seq_o)))
if seq_n[k] != seq_o[k]), None)
if fork_at is None and len(seq_n) != len(seq_o):
fork_at = min(len(seq_n), len(seq_o))
doc["lba_fork_index"] = fork_at
def show(rows, idxs, label, upto):
print(f"\n {label} (full command window):")
lo_i = max(0, idxs[0] - 4)
hi_i = min(len(rows), idxs[-1] + 10)
fork_row = idxs[upto] if upto is not None and upto < len(idxs) else None
for k in range(lo_i, hi_i):
r = rows[k]
lba = f" -> lba {r['lba']}" if "lba" in r else ""
mark = " <<< FIRST DIVERGED LBA" if k == fork_row else ""
print(f" f{r['frame']}: {r['cmd']}"
f"({' '.join(f'{x:02X}' for x in r['params'])}){lba}{mark}")
show(nat, na, "psx-runtime", fork_at)
show(orc, ob, "oracle", fork_at)
if fork_at is None:
print("\nThe two request sequences into the palette file are "
"IDENTICAL. The divergence is not in which sectors were "
"requested -- re-examine what was DELIVERED for them.")
else:
a = seq_n[fork_at] if fork_at < len(seq_n) else None
b = seq_o[fork_at] if fork_at < len(seq_o) else None
print(f"\nFORK at request #{fork_at} into the file: native asked "
f"lba {a}, oracle asked lba {b}. The game computes these from "
f"what the emulator told it during the requests BEFORE this "
f"one -- that is where the runtime diverges.")
_save(doc, args)
return 0
def _save(doc, args):
os.makedirs(os.path.dirname(args.out) or ".", exist_ok=True)
with open(args.out, "w") as f:
json.dump(doc, f, indent=1)
print(f"\nreport: {args.out}")
if __name__ == "__main__":
sys.exit(main())