Files
psxrecomp/tools/parity_diff.py
T
Matthew Stanley f8aefefec1 tooling(parity): general two-process control-flow parity trace (native producer)
End the "somewhere upstream" blindness with a reusable first-divergence
instrument (PRINCIPLES.md "find the first divergence; is the value WRONG or the
behavior MISSING"). A TCB-scoped, freeze-on-trigger control-flow ring with an
identical struct + JSON wire format on both processes, so a generic sequence-diff
(tools/parity_diff.py) locates the first logical divergence between recomp and
oracle — not a frame diff.

- parity_trace.{h,c}: armed-from-boot ring, gated on current_tcb==watched_tcb,
  latches on a configured trigger target so the pre-divergence window survives
  the wedge loop. Up to 6 watch-word snapshots/row. Self-contained TU shared by
  both binaries; frame source decoupled via parity_host_frame().
- fntrace.c: per-dispatch producer hook (cheap armed gate on the hot path).
- traps.c: YIELD control-event hook in the scheduler switch.
- main.cpp: env-armed from boot (PSX_PARITY_TRACE=1; TCB/trigger/watch tunable),
  defaults target the MMX6 cutscene wedge.
- debug_server.c: parity_dump + parity_ctl commands.
- tools/parity_diff.py: pulls both rings, sequence-diffs by (kind,pc,target).

First use (MMX6 freeze) confirmed MISSING behavior, not a wrong value: native
thread1 is permanently parked at func_8002000C resume (TCB EPC 0x800200F4),
handshake flag 0x8006D9AC never set (0 across 16384 rows), setter func_8001344C
never runs; thread1 re-resumes a dead func_8002000C frame whose saved-ra slot was
reused -> jr 0x800CD3F8 wedge. Upstream context-staleness/scheduling root.
Beetle mirror producer + the cross-process diff are the next step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 16:38:38 -07:00

105 lines
4.1 KiB
Python

#!/usr/bin/env python3
"""parity_diff.py — generic two-process control-flow first-divergence finder.
Pulls the `parity_dump` ring from psx-runtime (code under test) and psx-beetle
(independent oracle), aligns the two thread-scoped control-flow streams by
LOGICAL SEQUENCE (not frame), and reports the FIRST point where they diverge —
plus the watched state-word deltas at that point. This is the instrument that
replaces "somewhere upstream" guessing (recomp-template PRINCIPLES.md: "find the
first divergence, not the final visible bug").
Both sides emit the IDENTICAL row schema (parity_trace.h), so the diff is a plain
sequence match on the (kind, pc, target) tuple. Each side is typically armed from
boot and frozen on its own trigger (native: the wedge target; beetle: the
expected-but-missing producer), so the common prefix aligns and the tail reveals
the missing/extra/reordered behavior.
Usage:
python tools/parity_diff.py [--native PORT] [--beetle PORT] [--key kind,pc,target]
[--context N] [--count N]
"""
import argparse, json, socket, sys, difflib
def query(port, cmd, **kw):
obj = {"cmd": cmd}; obj.update(kw)
s = socket.create_connection(("127.0.0.1", port), timeout=10)
try:
s.sendall((json.dumps(obj) + "\n").encode())
buf = b""
while b"\n" not in buf:
chunk = s.recv(1 << 20)
if not chunk: break
buf += chunk
return json.loads(buf.decode(errors="replace").split("\n", 1)[0])
finally:
s.close()
def rows(port, count):
r = query(port, "parity_dump", count=count)
if not r.get("ok"):
print(f"[port {port}] parity_dump failed: {r}", file=sys.stderr); sys.exit(2)
return r
def keytuple(e, fields):
return tuple(e.get(f, "") for f in fields)
def fmt(e):
w = e.get("w", [])
return (f"seq={e['seq']:>5} f={e['frame']:>5} {e['kind']:<12} "
f"cur={e['cur_tcb']} pc={e['pc']} tgt={e['target']} "
f"epc={e['epc']} st={e['state']} ra={e['ra']} sp={e['sp']} "
f"flag={w[1] if len(w)>1 else '?'} struct={w[2] if len(w)>2 else '?'}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--native", type=int, default=4490)
ap.add_argument("--beetle", type=int, default=4382)
ap.add_argument("--count", type=int, default=16384)
ap.add_argument("--key", default="kind,pc,target",
help="comma fields forming the per-row alignment key")
ap.add_argument("--context", type=int, default=12)
args = ap.parse_args()
fields = args.key.split(",")
nat = rows(args.native, args.count)
bet = rows(args.beetle, args.count)
ne, be = nat["entries"], bet["entries"]
print(f"native(:{args.native}): {len(ne)} rows total={nat['total']} "
f"armed={nat['armed']} frozen={nat['frozen']}")
print(f"beetle(:{args.beetle}): {len(be)} rows total={bet['total']} "
f"armed={bet['armed']} frozen={bet['frozen']}")
if not ne or not be:
print("\nOne side has no rows — arm both from boot and reach the window.")
return
nk = [keytuple(e, fields) for e in ne]
bk = [keytuple(e, fields) for e in be]
sm = difflib.SequenceMatcher(a=nk, b=bk, autojunk=False)
blocks = sm.get_matching_blocks()
# First divergence = end of the first matching block that does not extend
# to the end of both streams.
first = None
for blk in blocks:
end_a, end_b = blk.a + blk.size, blk.b + blk.size
if blk.size and (end_a < len(nk) or end_b < len(bk)):
first = (end_a, end_b); break
if first is None:
print("\nNo divergence in the compared window (streams match to the end).")
return
ia, ib = first
C = args.context
print(f"\n===== FIRST DIVERGENCE (native idx {ia}, beetle idx {ib}) =====")
print("--- last common rows (native) ---")
for e in ne[max(0, ia - C):ia]:
print(" ", fmt(e))
print(f"--- NATIVE diverges here (idx {ia}+) ---")
for e in ne[ia:ia + C]:
print(" N", fmt(e))
print(f"--- ORACLE/BEETLE path here (idx {ib}+) ---")
for e in be[ib:ib + C]:
print(" B", fmt(e))
if __name__ == "__main__":
main()