Files
psxrecomp/tools/lockstep_check.py
T
Alex Vanderveen 651acb42e5 lockstep: say what a skip reason MEANS, not just how many
The segment-level run came back with 10,619 checked and 74,111 skipped, and the
weak-coverage guard correctly refused to call it a result. But "74,111" on its
own does not distinguish the two situations that produce it, and they call for
opposite responses: something is wrong, or this mode structurally cannot cover
this game.

Here it is the second. 99% of the skips are segments containing an interrupt,
which cannot be replayed — so in an interrupt-heavy game segment granularity
skips almost everything by construction. The tool now names the dominant reason
when one accounts for 60% or more, explains it, and in this case says outright
to use block granularity instead.

That is not hypothetical: the block-level run over the same game covered
5,679,898 blocks with ZERO skips. Same instrument, same question, and the two
modes differ by four orders of magnitude in what they actually compared.

No single reason above 60% names no cause at all, since inventing one would be
worse than staying quiet.

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

218 lines
10 KiB
Python

#!/usr/bin/env python3
"""lockstep_check.py -- compare our COMPILED code against our own interpreter.
python3 lockstep_check.py --frames 120
python3 lockstep_check.py --func --frames 120 # dispatch-segment level
Why this is the right instrument for a recompilation bug
--------------------------------------------------------
Every cross-emulator comparison in this investigation has been fought on three
fronts before it could say anything: aligning frames, matching addresses across
two allocators, and getting both emulators to the same point in the game. Each
of those has produced a confident wrong answer at least once.
Lockstep needs none of them. It runs the same guest code twice inside ONE
process -- once compiled, once interpreted -- and reports the first place they
disagree, with the register or memory value each produced. There is no oracle,
no frame number to align, no address to correspond.
What "found: false" does and does not mean
------------------------------------------
It means no divergence was seen in the blocks that were actually CHECKED, which
is not the same as none existing. The segment-level comparator skips segments it
cannot replay -- interrupts, overflow, unhandled ops, conflicts -- and a run
that skipped everything reports exactly the same "found: false" as a clean one.
So the counters are reported alongside the verdict, and a check with nothing
checked is called out rather than read as a pass.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from psx_gpu_frame import DEFAULT_NATIVE_PORT, DebugConn, DebugError # noqa: E402
KIND = "psx-lockstep"
# What each divergence kind tells you about where to look.
MEANING = {
"reg": "a general register held a different value",
"gpr": "a general register held a different value",
"hi": "the HI multiply/divide result differed",
"lo": "the LO multiply/divide result differed",
"pc": "control flow went somewhere else",
"write-val": "the same address was written with a different VALUE",
"read-addr": "a load came from a different ADDRESS",
"write-addr": "a store went to a different ADDRESS",
"trace-exhausted": "the compiled path ran out of recorded operations",
"trace-leftover": "the interpreter had operations the compiled path did not",
"compiled-extra-ops": "the compiled path performed operations the interpreter did not",
"path-cap": "the comparison hit its own length limit, not a divergence",
"unsupported": "this segment shape is not replayable, not a divergence",
}
SKIPS = ("skipped_irq", "skipped_overflow", "skipped_unhandled",
"skipped_conflict", "skipped_disabled")
# What a dominant skip reason means for the operator. The distinction that
# matters is between "this mode cannot cover this game" and "something is
# wrong": a bare count of 74,111 does not say which, and the two call for
# opposite responses.
SKIP_MEANING = {
"skipped_irq":
"segments containing an interrupt cannot be replayed. In an "
"interrupt-heavy game that is most of them, and it is a property of "
"SEGMENT granularity rather than a fault. Use block granularity, which "
"does not have this limitation.",
"skipped_overflow":
"the recorded operation trace ran out of room. Shorten the window.",
"skipped_unhandled":
"these segments contain operations the replayer does not implement, so "
"they are outside what this comparison can check at all.",
"skipped_conflict":
"overlapping recordings; usually harmless in small numbers.",
"skipped_disabled":
"the comparator was switched off for these segments.",
}
def summarise(d, func_mode, out=sys.stdout):
checked = d.get("segments_checked" if func_mode else "blocks_checked", 0)
lo, hi = (d.get("window") or [0, 0])[:2]
unit = "segment" if func_mode else "block"
print(f"window frames {lo}..{hi}, {checked} {unit}(s) checked", file=out)
skipped = {k: d.get(k, 0) for k in SKIPS if d.get(k)}
total_skipped = sum(skipped.values())
if skipped:
print(" skipped: " + ", ".join(f"{k[8:]}={v}" for k, v in skipped.items()),
file=out)
top = max(skipped, key=skipped.get)
if total_skipped and skipped[top] >= total_skipped * 0.6:
print(f" mostly {top[8:]} ({100 * skipped[top] // total_skipped}%): "
f"{SKIP_MEANING.get(top, '')}", file=out)
d["dominant_skip"] = top
if not d.get("found"):
if not checked:
print("\nINCONCLUSIVE: nothing was checked. This is NOT a clean "
"result — it is the same output a clean run would give. Widen "
"the frame window, or confirm the comparator is enabled in "
"this build.", file=out)
return "inconclusive"
if skipped and total_skipped >= checked:
print(f"\nWEAK: {total_skipped} {unit}(s) were skipped against "
f"{checked} checked — {total_skipped / max(1, checked):.1f}x "
f"more skipped than compared. A divergence could easily sit "
f"in what was skipped, so this is not evidence of anything.",
file=out)
if d.get("dominant_skip") == "skipped_irq" and func_mode:
print("Run it again at BLOCK granularity: blocks do not skip "
"interrupts, and cover far more of the code.", file=out)
return "weak"
print(f"\nNo divergence across {checked} {unit}(s). The compiled code "
f"matched the interpreter everywhere it was compared.\n"
f"\nThat exonerates the TRANSLATION — compiled and interpreted "
f"agree. It does not exonerate anything the two share: the memory "
f"subsystem, HLE, DMA and device emulation are common to both "
f"paths, so a fault there is invisible to this comparison.",
file=out)
return "clean"
kind = d.get("div_kind", d.get("kind", "?"))
print(f"\nFIRST DIVERGENCE ({kind}): {MEANING.get(kind, 'see the fields below')}",
file=out)
print(f" frame {d.get('frame')}", file=out)
print(f" {unit:<9} {d.get('entry') or d.get('block')}", file=out)
print(f" pc {d.get('pc')}", file=out)
if d.get("addr", "0x00000000") != "0x00000000":
print(f" address {d.get('addr')}", file=out)
if d.get("reg", -1) >= 0:
print(f" register ${d.get('reg')}", file=out)
print(f" interpreter expected {d.get('interp_expected')}", file=out)
print(f" compiled produced {d.get('compiled_actual')}", file=out)
tr = d.get("trace") or []
if tr:
print(f" last {len(tr)} memory op(s) before it:", file=out)
for t in tr[-12:]:
print(f" {t}", file=out)
if kind in ("path-cap", "unsupported"):
print("\nNote: this kind is a limit of the comparator, not a bug in the "
"compiled code.", file=out)
return "diverged"
def main(argv=None):
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("--frames", type=int, default=120,
help="how many frames ahead to arm the comparison over")
ap.add_argument("--lo", type=int, default=None,
help="explicit window start (default: the current frame)")
ap.add_argument("--func", action="store_true",
help="compare whole dispatch segments instead of blocks")
ap.add_argument("--wait", type=float, default=0.0,
help="seconds to wait after arming before reading back")
ap.add_argument("--timeout", type=float, default=30.0)
ap.add_argument("--json", default=None)
args = ap.parse_args(argv)
cmd = "lockstep_func" if args.func else "lockstep"
conn = DebugConn(args.host, args.port, args.timeout)
doc = {"kind": KIND, "version": 1, "mode": cmd}
try:
lo = args.lo if args.lo is not None else conn.frame()
hi = lo + max(1, args.frames)
doc["window"] = [lo, hi]
print(f"arming {cmd} over frames {lo}..{hi}")
conn.cmd(cmd, lo=lo, hi=hi)
if args.wait > 0:
time.sleep(args.wait)
else:
# Let the window actually elapse; reading back immediately would
# report on frames that have not run yet.
deadline = time.time() + max(2.0, (hi - lo) / 50.0 + 2.0)
while time.time() < deadline and conn.frame() < hi:
time.sleep(0.25)
rep = conn.cmd(cmd)
except DebugError as e:
print(f"error: {e}", file=sys.stderr)
doc["error"] = str(e)
if args.json:
with open(args.json, "w", encoding="utf-8") as f:
json.dump(doc, f, indent=1)
return 2
body = rep.get("lockstep") or rep.get("lockstep_func") or {}
if isinstance(body, dict):
body = dict(body)
# The engine calls the DIVERGENCE type "kind", and so does every report
# document this toolchain writes. Merging the reply as-is overwrote the
# document type with "none" on a clean run, and the Studio then rejected
# its own output as a foreign file. Keep the two names apart.
doc["div_kind"] = body.pop("kind", "none")
doc.update(body)
doc["kind"] = KIND
doc["verdict"] = summarise(doc, args.func)
# Carry the plain-English reading of the divergence kind, so the GUI does
# not have to keep its own copy of this table and drift from it.
doc["meaning"] = MEANING.get(doc.get("div_kind", ""), "")
doc["skipped_total"] = sum(doc.get(k, 0) for k in SKIPS)
if args.json:
with open(args.json, "w", encoding="utf-8") as f:
json.dump(doc, f, indent=1)
print(f"\nwrote {args.json}")
return 0 if doc["verdict"] in ("clean", "diverged") else 1
if __name__ == "__main__":
raise SystemExit(main())