Files
psxrecomp/tools/devtrace_diff.py
T
Matthew Stanley 18c94a1394 device_trace: general two-process device-event (IRQ) cycle ring
New always-on ring (device_trace.{c,h}, compiles into both binaries like
parity_trace) recording every hardware IRQ-raise edge with its guest cycle,
source (I_STAT bit) and detail. All native device sources funnel their raise
through one choke point psx_irq_raise(bit,detail) in interrupts.c (VBLANK/GPU/
CDROM/DMA/timers/SIO routed; DMA passes DICR channel flags, timers pass index),
so the device-timing audit sees every event from one place. devtrace_dump/ctl
debug commands (cycle-window + source filters) + tools/devtrace_diff.py align
the two device-event timelines by source ordinal and flag where cycles drift.
The Beetle oracle side (mednafen IRQ_Assert rising-edge hook) lives in the main
checkout.

This is the level below parity_trace: parity names the divergent control-flow /
gate input; device_trace names the divergent HARDWARE EVENT behind it.

First result (MMX6 cutscene asset-load over-fast wedge): the load's CD+DMA
activity is compressed into ~232M cycles on native vs ~384M on Beetle (~1.65x),
completing the func_8001CB3C scene setup ~100M cycles / ~176 frames early. The
producer is CD-read / DMA-completion timing (DMA channels 2/3/4 = GPU/CD/SPU
asset streaming) -- confirming the device-completion-latency hypothesis and
ruling in the CDROM/DMA cycle model as the over-fast subsystem. (VBlank verified
correct: native VBlank edges are exactly 564480 cyc apart.)

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

105 lines
4.4 KiB
Python

#!/usr/bin/env python3
"""devtrace_diff.py — two-process DEVICE-EVENT cycle-timeline diff.
Pulls the always-on device-event ring (devtrace_dump) from psx-runtime (code
under test) and psx-beetle (independent oracle) and compares, per IRQ source
(VBLANK / GPU / CDROM / DMA / TIMER0-2 / SIO / SPU), the GUEST CYCLE at which the
Nth event of that source fired. VBlank is cycle-paced so it should track between
the two; a source that drifts (its Nth event at a very different cycle) is the
device whose completion timing diverges — the producer of an over-fast/over-slow
wait. This is the level below parity_diff (control flow): it names the hardware
event, not the branch.
Both backends emit the IDENTICAL row schema (device_trace.h), so one tool reads
both ports by switching the port number.
Usage:
python tools/devtrace_diff.py [--native PORT] [--beetle PORT]
[--cyc-lo C] [--cyc-hi C] # restrict to a guest-cycle window
[--drift N] # flag first ordinal whose |Δcycle| > N (default 2_000_000)
[--show K] # also print first K events per source per side
"""
import argparse, json, socket, sys
SRC = {0:"vblank",1:"gpu",2:"cdrom",3:"dma",4:"timer0",5:"timer1",6:"timer2",
7:"sio0",8:"sio1",9:"spu",10:"pio"}
def query(port, cmd, **kw):
obj = {"cmd": cmd}; obj.update(kw)
s = socket.create_connection(("127.0.0.1", port), timeout=15)
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 pull(port, cyc_lo, cyc_hi):
kw = {"count": 1 << 20}
if cyc_lo is not None: kw["cyc_lo"] = str(cyc_lo)
if cyc_hi is not None: kw["cyc_hi"] = str(cyc_hi)
r = query(port, "devtrace_dump", **kw)
if not r.get("ok"):
print(f"[port {port}] devtrace_dump failed: {r}", file=sys.stderr); sys.exit(2)
return r
def by_source(events):
d = {}
for e in events:
d.setdefault(e["srcn"], []).append(e)
return d
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--native", type=int, default=4490)
ap.add_argument("--beetle", type=int, default=4382)
ap.add_argument("--cyc-lo", type=int, default=None)
ap.add_argument("--cyc-hi", type=int, default=None)
ap.add_argument("--drift", type=int, default=2_000_000)
ap.add_argument("--show", type=int, default=0)
args = ap.parse_args()
nat = pull(args.native, args.cyc_lo, args.cyc_hi)
bet = pull(args.beetle, args.cyc_lo, args.cyc_hi)
ne, be = nat["events"], bet["events"]
print(f"native(:{args.native}): {len(ne)} events (ring total={nat['total']}, armed={nat['armed']})")
print(f"beetle(:{args.beetle}): {len(be)} events (ring total={bet['total']}, armed={bet['armed']})")
ns, bs = by_source(ne), by_source(be)
sources = sorted(set(ns) | set(bs))
print(f"\n{'source':<8} {'native#':>8} {'beetle#':>8} {'first divergent ordinal (cycle delta)':<40}")
print("-" * 78)
for s in sources:
n, b = ns.get(s, []), bs.get(s, [])
name = SRC.get(s, f"src{s}")
# align by ordinal, find first where the cycle gap exceeds --drift
flag = ""
m = min(len(n), len(b))
for i in range(m):
dc = n[i]["cycle"] - b[i]["cycle"]
if abs(dc) > args.drift:
flag = f"#{i}: native cyc={n[i]['cycle']} beetle cyc={b[i]['cycle']} (delta={dc:+d})"
break
if not flag and len(n) != len(b):
flag = f"count differs ({len(n)} vs {len(b)}) — no big per-ordinal drift in first {m}"
if not flag:
flag = "tracks (no drift > threshold)"
print(f"{name:<8} {len(n):>8} {len(b):>8} {flag}")
if args.show:
for s in sources:
name = SRC.get(s, f"src{s}")
print(f"\n--- {name} first {args.show} (native | beetle) ---")
n, b = ns.get(s, []), bs.get(s, [])
for i in range(min(args.show, max(len(n), len(b)))):
nc = f"cyc={n[i]['cycle']} f={n[i]['frame']} d={n[i]['detail']}" if i < len(n) else "-"
bc = f"cyc={b[i]['cycle']} f={b[i]['frame']} d={b[i]['detail']}" if i < len(b) else "-"
print(f" #{i:<4} {nc:<34} | {bc}")
if __name__ == "__main__":
main()