mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 02:11:36 -04:00
f8aefefec1
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>
105 lines
4.1 KiB
Python
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()
|