mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 02:11:36 -04:00
c4e50d92e0
- run: a crash, hang-up or socket timeout on the final frame_fingerprint / dispatch_stats queries is a RunError (exit 3, INCOMPLETE), not a JSONDecodeError traceback with exit 1 (MISMATCH). Seeding, log and dump I/O errors and a runtime that survives SIGKILL also exit 3. A missing output directory is a usage error. The dump is written to OUT.tmp and renamed. - compare: with different FMV-quiet settings, once a quiet frame occurred (qc > 0, a different frame set, or no qc column) the runs are not comparable. Report INCOMPLETE and skip the DIVERGE, missing-frame and locator lines, which were artefacts of the setting. Mixed settings with no quiet frame are judged as usual, with a note. - --launch on Windows keeps backslashes (shlex with no escape character), so C:\psx\build\game-runtime.exe is not mangled into C:psxbuild... Self-test: 59 cases (11 new), ctest fp_identity passes. R4 dumps re-judged and fresh warm/cold runs repeat the IDENTICAL results with 497 tolerated straddles. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
821 lines
35 KiB
Python
821 lines
35 KiB
Python
#!/usr/bin/env python3
|
|
"""fp_identity.py - A/B check that two runs executed the guest identically.
|
|
|
|
`run` starts a runtime with no pad input (turbo, headless by default), lets it
|
|
reach guest frame N, and saves the debug server's per-frame guest-write
|
|
fingerprint ring (`frame_fingerprint`, docs/TCP_COMMANDS.md) with the
|
|
dispatch-miss count. `compare` judges two such dumps on the guest-fact
|
|
columns. Any title works: give the launch as a command template or as
|
|
runtime + game.toml + disc. The runtime needs PSX_DEBUG_TOOLS=ON.
|
|
|
|
Run a build against itself first (A/A) to show the run is deterministic, then
|
|
the candidate against the base (A/B). Warm vs cold overlay cache on R4, from
|
|
the game repo's root:
|
|
|
|
T=psxrecomp/tools/fp_identity.py
|
|
L='tools/run_r4.sh {build} --debug-port {port} {headless}'
|
|
python3 $T snapshot build /tmp/seed-warm # after a warm-up run
|
|
mkdir -p /tmp/seed-cold # empty seed = cold cache
|
|
python3 $T run warm.json --launch "$L" --port 4781 --frames 12000 --seed /tmp/seed-warm
|
|
python3 $T run cold.json --launch "$L" --port 4781 --frames 12000 --seed /tmp/seed-cold
|
|
python3 $T compare warm.json cold.json
|
|
|
|
python3 $T run out.json --runtime build/game-runtime --game build/game.toml \\
|
|
--disc disc/game.cue --port 4781 --frames 6000
|
|
|
|
--launch placeholders: {build} (--build, default "build"), {port}, {headless}
|
|
("--headless", or nothing with --windowed) and {state_dir}. The template is
|
|
split like a shell command line but not run through a shell; on Windows a
|
|
backslash is a path separator, not an escape, so C:\\psx\\game.exe works
|
|
(quote paths with spaces). Arguments after `--` are appended unchanged.
|
|
--runtime launches
|
|
`RUNTIME --game TOML --disc DISC --no-launcher --debug-port PORT [--headless]`.
|
|
|
|
Environment: runs set PSX_DEBUG_FMV_QUIET=0 (FMV-quiet would keep MDEC-era
|
|
writes out of the judge columns) and PSX_OVERLAY_AUTOCOMPILE_OFF=1 (the
|
|
overlay state stays what --seed made it). --fmv-quiet and --autocompile opt
|
|
back in; --env K=V adds more. The dump records what the run used.
|
|
|
|
Overlay state is part of the input: a cold overlay cache runs code in the
|
|
interpreter that a warm one runs natively. --seed DIR replaces the state
|
|
directory's cache/ and overlay_captures.json(.d) with DIR's before launching
|
|
(an empty DIR gives a cold cache). Use the same DIR for both runs of an A/A
|
|
pair. The state directory is --state-dir, else the --runtime executable's
|
|
directory, else --build under --cwd when the template uses {build}.
|
|
|
|
Judging (docs/TCP_COMMANDS.md, `frame_fingerprint`):
|
|
judge cyc mmio mc sp sc wc ws qc must agree at every shared frame
|
|
locator wr pc reported as where the runs part, never
|
|
a failure (host device-service order)
|
|
Tolerances, each counted and listed in the output, never silent:
|
|
VBlank straddle a device write lands on the other side of a snapshot in one
|
|
run: at one frame ws/wc differ by at most --straddle-writes
|
|
(default 1) writes and every other judge column agrees; the
|
|
next shared frame must agree again. A straddle on the last
|
|
shared frame cannot be confirmed and makes the result
|
|
INCOMPLETE.
|
|
FMV-quiet shift only when FMV-quiet was on (or not recorded) in a run: a
|
|
straddled write that one run counted in qc and the other
|
|
in wc. wc+qc must still agree; wc, qc and
|
|
ws are judged against the offsets carried from the shift.
|
|
--strict (= --straddle-writes 0) turns both off.
|
|
|
|
`compare` prints one verdict and exits with its code:
|
|
0 IDENTICAL same frame set, every judge column equal (tolerances listed),
|
|
0 dispatch misses in both
|
|
1 MISMATCH a shared frame differs on a judge column, or one run
|
|
fingerprinted frames inside the other's range that the other
|
|
did not
|
|
3 INCOMPLETE the shared frames agree but identity is not proven: different
|
|
--frames, frames missing at either end of one run, a wrapped
|
|
fingerprint ring, an empty run, no dispatch-miss count, a
|
|
judge column the runtime did not report (runtimes before
|
|
psxrecomp #420 lack ws and qc), or a straddle on the last
|
|
shared frame. Also different FMV-quiet settings once a quiet
|
|
frame occurred (qc > 0, or the frame sets differ): quiet
|
|
frames are not fingerprinted and their writes count only in
|
|
qc, so the frames and columns are not judged at all
|
|
4 MISSES everything else holds but a run had dispatch misses (resolve
|
|
them before trusting an identity result)
|
|
2 usage error
|
|
Every problem found is listed, not only the first. `run` exits 0 once the
|
|
dump is written, 3 when it did not write one (the runtime did not start, the
|
|
port was taken, the runtime exited, stalled, timed out or dropped the
|
|
connection, or seeding or writing failed), and 2 on usage errors. `snapshot`
|
|
exits 3 when the copy fails.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
import shlex
|
|
import shutil
|
|
import signal
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from debug_client import query # noqa: E402
|
|
|
|
EXIT_IDENTICAL, EXIT_MISMATCH, EXIT_USAGE, EXIT_INCOMPLETE, EXIT_MISSES = 0, 1, 2, 3, 4
|
|
HOST = "127.0.0.1"
|
|
EXACT = ("cyc", "mmio", "mc", "sp", "sc") # judged with no tolerance
|
|
WRITES = ("wc", "ws", "qc") # judged, straddle-aware
|
|
JUDGE = EXACT + WRITES
|
|
LOCATORS = ("wr", "pc")
|
|
HASHES = ("mmio", "sp", "ws", "wr", "pc")
|
|
MASK64 = (1 << 64) - 1
|
|
RING_CAP = 32768
|
|
OVERLAY_STATE = ("cache", "overlay_captures.json", "overlay_captures.json.d")
|
|
DEFAULT_ENV = {"PSX_DEBUG_FMV_QUIET": "0", "PSX_OVERLAY_AUTOCOMPILE_OFF": "1"}
|
|
SCHEMA = 2
|
|
|
|
|
|
class UsageError(Exception):
|
|
pass
|
|
|
|
|
|
class RunError(Exception):
|
|
pass
|
|
|
|
|
|
# ---- run -------------------------------------------------------------------
|
|
|
|
def copy_overlay_state(src, dst):
|
|
for name in OVERLAY_STATE:
|
|
target = os.path.join(dst, name)
|
|
if os.path.isdir(target) and not os.path.islink(target):
|
|
shutil.rmtree(target)
|
|
elif os.path.lexists(target):
|
|
os.remove(target)
|
|
source = os.path.join(src, name)
|
|
if os.path.isdir(source):
|
|
shutil.copytree(source, target, symlinks=True)
|
|
elif os.path.exists(source):
|
|
shutil.copy2(source, target)
|
|
|
|
|
|
def resolve_state_dir(args, cwd):
|
|
if args.state_dir:
|
|
return os.path.abspath(os.path.join(cwd, args.state_dir))
|
|
if args.runtime:
|
|
return os.path.dirname(os.path.abspath(os.path.join(cwd, args.runtime)))
|
|
if args.launch and "{build}" in args.launch:
|
|
return os.path.abspath(os.path.join(cwd, args.build))
|
|
return None
|
|
|
|
|
|
def split_template(text, windows=None):
|
|
"""Split a --launch template into arguments, without a shell.
|
|
|
|
POSIX: shell quoting and backslash escapes (shlex.split). Windows:
|
|
backslashes are path separators, not escapes, so C:\\psx\\game.exe stays
|
|
as written; quote a path with spaces as "C:\\Program Files\\...".
|
|
"""
|
|
if windows is None:
|
|
windows = os.name == "nt"
|
|
if not windows:
|
|
return shlex.split(text)
|
|
lex = shlex.shlex(text, posix=True)
|
|
lex.whitespace_split = True
|
|
lex.commenters = ""
|
|
lex.escape = ""
|
|
return list(lex)
|
|
|
|
|
|
def launch_argv(args, state_dir, windows=None):
|
|
headless = [] if args.windowed else ["--headless"]
|
|
if not args.launch:
|
|
return [args.runtime, "--game", args.game, "--disc", args.disc,
|
|
"--no-launcher", "--debug-port", str(args.port),
|
|
*headless, *args.extra]
|
|
try:
|
|
tokens = split_template(args.launch, windows)
|
|
except ValueError as e:
|
|
raise UsageError(f"--launch: {e}")
|
|
if args.windowed and "--headless" in tokens:
|
|
raise UsageError("--windowed, but the --launch template passes "
|
|
"--headless itself; write {headless} instead")
|
|
values = {"build": args.build, "port": str(args.port),
|
|
"headless": " ".join(headless), "state_dir": state_dir or ""}
|
|
argv = []
|
|
for token in tokens:
|
|
try:
|
|
value = token.format(**values)
|
|
except (KeyError, IndexError, ValueError) as e:
|
|
raise UsageError(f"--launch: bad placeholder in {token!r} ({e!r}); "
|
|
f"known: {{build}} {{port}} {{headless}} "
|
|
f"{{state_dir}}")
|
|
if value:
|
|
argv.append(value)
|
|
if not argv:
|
|
raise UsageError("--launch is empty")
|
|
return argv + args.extra
|
|
|
|
|
|
def port_in_use(port):
|
|
try:
|
|
with socket.create_connection((HOST, port), timeout=0.5):
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def stop(proc):
|
|
"""Stop the launched process group: a launcher script may not exec.
|
|
|
|
Returns None, or why the runtime could not be stopped."""
|
|
try:
|
|
if os.name == "nt":
|
|
subprocess.run(["taskkill", "/T", "/F", "/PID", str(proc.pid)],
|
|
capture_output=True)
|
|
else:
|
|
os.killpg(proc.pid, signal.SIGTERM)
|
|
except OSError:
|
|
pass
|
|
try:
|
|
proc.wait(timeout=15)
|
|
return None
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
try:
|
|
if os.name == "nt":
|
|
proc.kill()
|
|
else:
|
|
os.killpg(proc.pid, signal.SIGKILL)
|
|
except OSError:
|
|
pass
|
|
try:
|
|
proc.wait(timeout=10)
|
|
return None
|
|
except subprocess.TimeoutExpired:
|
|
return (f"runtime pid {proc.pid} did not exit after SIGKILL; it may "
|
|
f"still hold its port")
|
|
|
|
|
|
def ask(port, obj, timeout):
|
|
return query(HOST, port, obj, timeout=timeout)
|
|
|
|
|
|
def drive(proc, args, log_path):
|
|
"""Turbo to frame N; return (fingerprint reply, dispatch_stats reply)."""
|
|
port = args.port
|
|
deadline = time.time() + args.boot_timeout
|
|
while True:
|
|
try:
|
|
ask(port, {"cmd": "turbo", "enabled": 1}, 2)
|
|
break
|
|
except (OSError, ValueError):
|
|
if proc.poll() is not None:
|
|
raise RunError(f"runtime exited (status {proc.returncode}) "
|
|
f"before its debug server answered on port "
|
|
f"{port} (see {log_path})")
|
|
if time.time() > deadline:
|
|
raise RunError(f"no debug server on port {port} after "
|
|
f"{args.boot_timeout}s (built with "
|
|
f"PSX_DEBUG_TOOLS=ON? does the launch pass the "
|
|
f"port? see {log_path})")
|
|
time.sleep(0.3)
|
|
start = last_move = time.time()
|
|
last = -1
|
|
while True:
|
|
if proc.poll() is not None:
|
|
raise RunError(f"runtime exited (status {proc.returncode}) at "
|
|
f"frame {last} of {args.frames} (see {log_path})")
|
|
try:
|
|
frame = int(ask(port, {"cmd": "frame"}, 120).get("frame", 0))
|
|
except (OSError, ValueError):
|
|
frame = last
|
|
if frame >= args.frames:
|
|
break
|
|
now = time.time()
|
|
if frame != last:
|
|
last, last_move = frame, now
|
|
elif now - last_move > args.stall:
|
|
raise RunError(f"stalled: frame {frame} did not advance for "
|
|
f"{args.stall}s (see {log_path})")
|
|
if now - start > args.timeout:
|
|
raise RunError(f"reached only frame {frame} of {args.frames} in "
|
|
f"{args.timeout}s (see {log_path})")
|
|
time.sleep(0.2)
|
|
replies = []
|
|
for req, timeout in (({"cmd": "frame_fingerprint",
|
|
"count": min(args.frames + 1, RING_CAP),
|
|
"frame_lo": 0, "frame_hi": args.frames}, 300),
|
|
({"cmd": "dispatch_stats"}, 120)):
|
|
try:
|
|
reply = ask(port, req, timeout)
|
|
except (OSError, ValueError) as e:
|
|
# A crash, a dropped connection or a socket timeout after frame
|
|
# N: no dump, so INCOMPLETE rather than a traceback.
|
|
try:
|
|
proc.wait(timeout=2) # a crash drops the socket first
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
state = ("exited (status %s)" % proc.returncode
|
|
if proc.returncode is not None else "did not answer")
|
|
raise RunError(f"runtime {state} on {req['cmd']} after reaching "
|
|
f"frame {frame} ({e!r}; see {log_path})")
|
|
if not isinstance(reply, dict):
|
|
raise RunError(f"{req['cmd']} replied {reply!r}, not an object")
|
|
if reply.get("ok") is False:
|
|
raise RunError(f"{req['cmd']} failed: {reply.get('error', reply)}")
|
|
replies.append(reply)
|
|
fp, stats = replies
|
|
if "entries" not in fp:
|
|
raise RunError(f"frame_fingerprint returned no entries: {fp}")
|
|
return fp, stats
|
|
|
|
|
|
def cmd_run(args):
|
|
cwd = os.path.abspath(args.cwd)
|
|
if not args.launch and not (args.runtime and args.game and args.disc):
|
|
raise UsageError("run needs --launch TEMPLATE, or --runtime, --game "
|
|
"and --disc")
|
|
if args.launch and (args.runtime or args.game or args.disc):
|
|
raise UsageError("give --launch or --runtime/--game/--disc, not both")
|
|
state_dir = resolve_state_dir(args, cwd)
|
|
if args.seed:
|
|
if not state_dir:
|
|
raise UsageError("--seed needs --state-dir (or --runtime, or "
|
|
"{build} in --launch)")
|
|
if not os.path.isdir(args.seed):
|
|
raise UsageError(f"--seed {args.seed} is not a directory")
|
|
if not os.path.isdir(state_dir):
|
|
raise UsageError(f"state directory {state_dir} does not exist")
|
|
out = os.path.abspath(args.out)
|
|
if not os.path.isdir(os.path.dirname(out)):
|
|
raise UsageError(f"output directory {os.path.dirname(out)} does not "
|
|
f"exist")
|
|
argv = launch_argv(args, state_dir)
|
|
env = dict(os.environ)
|
|
record = dict(DEFAULT_ENV)
|
|
if args.fmv_quiet:
|
|
record["PSX_DEBUG_FMV_QUIET"] = "1"
|
|
if args.autocompile:
|
|
record["PSX_OVERLAY_AUTOCOMPILE_OFF"] = "0"
|
|
for kv in args.env:
|
|
if "=" not in kv:
|
|
raise UsageError(f"--env {kv!r}: expected KEY=VALUE")
|
|
key, value = kv.split("=", 1)
|
|
record[key] = value
|
|
env.update(record)
|
|
if port_in_use(args.port):
|
|
raise RunError(f"port {args.port} already has a listener; pick a free "
|
|
f"--port so the run cannot query another runtime")
|
|
if args.seed:
|
|
copy_overlay_state(args.seed, state_dir)
|
|
log_path = out + ".log"
|
|
label = args.label or (args.build if args.launch else args.runtime)
|
|
with open(log_path, "w") as log:
|
|
try:
|
|
proc = subprocess.Popen(argv, cwd=cwd, env=env, stdout=log,
|
|
stderr=subprocess.STDOUT,
|
|
start_new_session=(os.name != "nt"))
|
|
except OSError as e:
|
|
raise RunError(f"cannot launch {argv[0]}: {e}")
|
|
failure = None
|
|
try:
|
|
fp, stats = drive(proc, args, log_path)
|
|
except RunError as e:
|
|
failure = str(e)
|
|
except BaseException:
|
|
stop(proc)
|
|
raise
|
|
stuck = stop(proc)
|
|
if stuck:
|
|
failure = f"{failure}; {stuck}" if failure else stuck
|
|
if failure:
|
|
raise RunError(failure)
|
|
entries = fp.get("entries", [])
|
|
# Write then rename: an interrupted write never leaves a partial dump.
|
|
tmp = out + ".tmp"
|
|
try:
|
|
with open(tmp, "w") as f:
|
|
json.dump({"schema": SCHEMA, "build": label,
|
|
"frames": args.frames, "launch": argv, "cwd": cwd,
|
|
"state_dir": state_dir,
|
|
"seed": os.path.abspath(args.seed) if args.seed
|
|
else None,
|
|
"env": record, "port": args.port,
|
|
"miss_total": stats.get("miss_total"),
|
|
"miss_unique": stats.get("miss_unique"),
|
|
"ring_total": fp.get("total"),
|
|
"ring_available": fp.get("available"),
|
|
"entries": entries}, f)
|
|
os.replace(tmp, out)
|
|
except BaseException:
|
|
try:
|
|
os.remove(tmp)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
print(f"{label}: {len(entries)} frames fingerprinted to frame "
|
|
f"{args.frames}, dispatch misses total={stats.get('miss_total')} "
|
|
f"unique={stats.get('miss_unique')} -> {out}")
|
|
return EXIT_IDENTICAL
|
|
|
|
|
|
def cmd_snapshot(args):
|
|
if not os.path.isdir(args.state_dir):
|
|
raise UsageError(f"{args.state_dir} is not a directory")
|
|
os.makedirs(args.seed_dir, exist_ok=True)
|
|
copy_overlay_state(args.state_dir, args.seed_dir)
|
|
kept = [n for n in OVERLAY_STATE
|
|
if os.path.lexists(os.path.join(args.seed_dir, n))]
|
|
print(f"overlay state of {args.state_dir} -> {args.seed_dir} "
|
|
f"({', '.join(kept) if kept else 'empty: a cold seed'})")
|
|
return EXIT_IDENTICAL
|
|
|
|
|
|
# ---- compare ---------------------------------------------------------------
|
|
|
|
def num(value):
|
|
return int(value, 0) if isinstance(value, str) else int(value)
|
|
|
|
|
|
def load_run(path):
|
|
try:
|
|
with open(path) as f:
|
|
run = json.load(f)
|
|
except (OSError, ValueError) as e:
|
|
raise UsageError(f"cannot read {path}: {e}")
|
|
if not isinstance(run, dict) or not isinstance(run.get("entries", []), list):
|
|
raise UsageError(f"{path} is not an fp_identity dump")
|
|
frames, present = {}, None
|
|
try:
|
|
for e in run.get("entries", []):
|
|
cols = {k: num(e[k]) for k in JUDGE + LOCATORS if k in e}
|
|
frames[num(e["frame"])] = cols
|
|
present = set(cols) if present is None else present & set(cols)
|
|
except (KeyError, TypeError, ValueError) as e:
|
|
raise UsageError(f"{path}: malformed fingerprint entry ({e!r})")
|
|
return run, frames, present or set()
|
|
|
|
|
|
def show(cols):
|
|
"""One entry's columns, hashes in hex as the server prints them."""
|
|
return " ".join(f"{k}={v:#018x}" if k in HASHES else f"{k}={v}"
|
|
for k, v in cols.items())
|
|
|
|
|
|
def fmv_quiet(run):
|
|
"""True/False as the run set it; None when the dump did not record it."""
|
|
value = (run.get("env") or {}).get("PSX_DEBUG_FMV_QUIET")
|
|
return None if value is None else value[:1] != "0"
|
|
|
|
|
|
def quiet_text(setting):
|
|
return {True: "on", False: "off", None: "not recorded (runtime default: on)"}[setting]
|
|
|
|
|
|
def frame_ranges(frames, limit=8):
|
|
"""Sorted frame numbers as compact runs: '1..100, 205, 300..310'."""
|
|
runs = []
|
|
for frame in frames:
|
|
if runs and frame == runs[-1][1] + 1:
|
|
runs[-1][1] = frame
|
|
else:
|
|
runs.append([frame, frame])
|
|
text = [f"{lo}" if lo == hi else f"{lo}..{hi}" for lo, hi in runs[:limit]]
|
|
if len(runs) > limit:
|
|
text.append(f"... (+{len(runs) - limit} more runs)")
|
|
return ", ".join(text)
|
|
|
|
|
|
def scan(common, fa, fb, judged, quiet, limit):
|
|
"""Walk the shared frames in order and judge them; see the module doc."""
|
|
exact = [c for c in EXACT if c in judged]
|
|
|
|
def offset(f):
|
|
a, b = fa[f], fb[f]
|
|
return (a["wc"] - b["wc"] if "wc" in judged else 0,
|
|
a["qc"] - b["qc"] if "qc" in judged else 0,
|
|
(a["ws"] - b["ws"]) & MASK64 if "ws" in judged else 0)
|
|
|
|
def exact_diff(f):
|
|
return [c for c in exact if fa[f][c] != fb[f][c]]
|
|
|
|
def moved(d, ref):
|
|
"""Writes a straddle moved across the snapshot (0 = none)."""
|
|
if quiet:
|
|
return abs((d[0] + d[1]) - (ref[0] + ref[1]))
|
|
return abs(d[0] - ref[0]) if d[1] == ref[1] else 0
|
|
|
|
def shift(d, ref, cap):
|
|
return (quiet and d != ref and d[0] + d[1] == ref[0] + ref[1]
|
|
and 1 <= abs(d[0] - ref[0]) <= cap)
|
|
|
|
base = (0, 0, 0)
|
|
out = {"straddles": [], "shifts": [], "diverge": None, "unconfirmed": None}
|
|
for i, f in enumerate(common):
|
|
xd, d = exact_diff(f), offset(f)
|
|
if not xd and d == base:
|
|
continue
|
|
note = None
|
|
last = i + 1 == len(common)
|
|
if not xd and limit and shift(d, base, limit):
|
|
if last:
|
|
out["unconfirmed"] = (f, "an FMV-quiet shift")
|
|
break
|
|
out["shifts"].append((f, abs(d[0] - base[0])))
|
|
base = d
|
|
continue
|
|
m = 0 if xd else moved(d, base)
|
|
if m and limit and m <= limit:
|
|
if last:
|
|
out["unconfirmed"] = (f, f"a {m}-write VBlank straddle")
|
|
break
|
|
g = common[i + 1]
|
|
dn = offset(g)
|
|
if not exact_diff(g) and (dn == base or shift(dn, base, limit)):
|
|
out["straddles"].append((f, m))
|
|
continue
|
|
note = (f"a {m}-write straddle pattern, but frame {g} did not "
|
|
f"re-converge")
|
|
elif m and limit:
|
|
note = f"{m} writes moved, more than --straddle-writes {limit}"
|
|
elif not xd and not limit and (m == 1 or shift(d, base, 1)):
|
|
note = ("the one-write VBlank straddle pattern; --strict rejects "
|
|
"it")
|
|
cols = xd + [c for c, x, y in zip(("wc", "qc", "ws"), d, base)
|
|
if x != y and c in judged]
|
|
out["diverge"] = (f, cols, note)
|
|
break
|
|
return out
|
|
|
|
|
|
def compare(a_path, b_path, limit):
|
|
loaded = {"A": load_run(a_path), "B": load_run(b_path)}
|
|
runs = {tag: v[0] for tag, v in loaded.items()}
|
|
fps = {tag: v[1] for tag, v in loaded.items()}
|
|
present = {tag: v[2] for tag, v in loaded.items()}
|
|
fa, fb = fps["A"], fps["B"]
|
|
mismatch, incomplete, misses, notes = [], [], [], []
|
|
quiet_set = {tag: fmv_quiet(runs[tag]) for tag in runs}
|
|
|
|
for tag, path in (("A", a_path), ("B", b_path)):
|
|
run, frames = runs[tag], fps[tag]
|
|
span = f"{min(frames)}..{max(frames)}" if frames else "none"
|
|
env = run.get("env") or {}
|
|
print(f"{tag}: {path} build={run.get('build')} "
|
|
f"frames={run.get('frames')} fingerprinted={len(frames)} "
|
|
f"({span}) misses={run.get('miss_total')} "
|
|
f"fmv_quiet={quiet_text(quiet_set[tag])} "
|
|
f"autocompile_off={env.get('PSX_OVERLAY_AUTOCOMPILE_OFF', '?')} "
|
|
f"seed={run.get('seed', '?')}")
|
|
if not frames:
|
|
incomplete.append(f"{tag} has no fingerprint entries")
|
|
total, kept = run.get("ring_total"), run.get("ring_available")
|
|
if total is not None and kept is not None and total > kept:
|
|
incomplete.append(f"{tag}'s fingerprint ring wrapped ({total} "
|
|
f"snapshots, {kept} kept): its earliest frames "
|
|
f"were overwritten")
|
|
if run.get("miss_total") is None:
|
|
incomplete.append(f"{tag} has no dispatch-miss count")
|
|
elif run["miss_total"]:
|
|
misses.append(f"{tag} had {run['miss_total']} dispatch misses "
|
|
f"({run.get('miss_unique')} unique)")
|
|
lacking = [c for c in JUDGE if c not in present[tag]]
|
|
if frames and lacking:
|
|
incomplete.append(f"{tag} lacks judge column(s) "
|
|
f"{','.join(lacking)} (runtimes before psxrecomp "
|
|
f"#420 report no ws/qc): not judged")
|
|
|
|
if runs["A"].get("frames") != runs["B"].get("frames"):
|
|
incomplete.append(f"the runs stopped at different frames: A at "
|
|
f"{runs['A'].get('frames')}, B at "
|
|
f"{runs['B'].get('frames')}")
|
|
|
|
judged = [c for c in JUDGE if c in present["A"] and c in present["B"]]
|
|
common = sorted(set(fa) & set(fb))
|
|
quiet = any(q is not False for q in quiet_set.values())
|
|
effective = {t: quiet_set[t] is not False for t in quiet_set}
|
|
# FMV-quiet frames are not fingerprinted and their writes count only in
|
|
# qc. With the setting different in the two runs, once a quiet frame has
|
|
# happened the frame sets and every write column part for that reason
|
|
# alone: a DIVERGE or a missing frame would be an artefact, not a fork.
|
|
comparable = True
|
|
if effective["A"] != effective["B"]:
|
|
settings = (f"A {quiet_text(quiet_set['A'])}, "
|
|
f"B {quiet_text(quiet_set['B'])}")
|
|
signs = [f"{t} counted quiet writes (qc > 0)" for t in fps
|
|
if "qc" in judged and any(fps[t][f]["qc"] for f in fps[t])]
|
|
if set(fa) != set(fb):
|
|
signs.append(f"the frame sets differ ({len(fa)} and {len(fb)} "
|
|
f"frames fingerprinted)")
|
|
if "qc" not in judged:
|
|
signs.append("qc is not reported, so quiet frames cannot be "
|
|
"ruled out")
|
|
if signs:
|
|
comparable = False
|
|
incomplete.append(
|
|
f"the runs used different FMV-quiet settings ({settings}) "
|
|
f"and {'; '.join(signs)}: quiet frames are not fingerprinted "
|
|
f"and their writes count only in qc, so frames and columns "
|
|
f"were not judged. Rerun both with the same setting (the "
|
|
f"default is off)")
|
|
else:
|
|
notes.append(f"FMV-quiet settings differ ({settings}), but no "
|
|
f"quiet frame occurred (qc = 0, same frame set): "
|
|
f"judged as usual")
|
|
|
|
if comparable:
|
|
for tag, other_tag in (("A", "B"), ("B", "A")):
|
|
only = sorted(set(fps[tag]) - set(fps[other_tag]))
|
|
other = fps[other_tag]
|
|
if not only:
|
|
continue
|
|
if other:
|
|
lo, hi = min(other), max(other)
|
|
inside = [f for f in only if lo <= f <= hi]
|
|
outside = [f for f in only if not lo <= f <= hi]
|
|
where = f"outside its range {lo}..{hi}"
|
|
else:
|
|
inside, outside, where = [], only, "(it has none)"
|
|
if inside:
|
|
mismatch.append(f"{len(inside)} frames fingerprinted by {tag} "
|
|
f"are missing inside {other_tag}'s range: "
|
|
f"{frame_ranges(inside)}")
|
|
if outside:
|
|
incomplete.append(f"{other_tag} lacks {len(outside)} of "
|
|
f"{tag}'s frames {where}: "
|
|
f"{frame_ranges(outside)}")
|
|
if quiet:
|
|
why = ", ".join(f"{t} {quiet_text(q)}"
|
|
for t, q in quiet_set.items())
|
|
notes.append(f"FMV-quiet ({why}): "
|
|
f"wc+qc must agree; wc, qc and ws are judged against "
|
|
f"the offsets carried from any FMV-quiet shift. Rerun "
|
|
f"without --fmv-quiet for the strict rule")
|
|
else:
|
|
notes.append("FMV-quiet off in both runs: wc, qc and ws judged "
|
|
"directly")
|
|
result = scan(common, fa, fb, judged, quiet, limit)
|
|
else:
|
|
result = {"straddles": [], "shifts": [], "diverge": None,
|
|
"unconfirmed": None}
|
|
|
|
if result["diverge"]:
|
|
f, cols, note = result["diverge"]
|
|
text = f"DIVERGE at frame {f}: judge columns {cols}"
|
|
if note:
|
|
text += f" ({note})"
|
|
if result["shifts"]:
|
|
text += (f" [after {len(result['shifts'])} FMV-quiet shift(s); "
|
|
f"wc/qc/ws compared against the carried offsets]")
|
|
text += (f"\n A {show(fa[f])}\n B {show(fb[f])}"
|
|
f"\n next: arm record_frame {f} in both runs and diff the "
|
|
f"ordered logs")
|
|
mismatch.insert(0, text)
|
|
if result["unconfirmed"]:
|
|
f, what = result["unconfirmed"]
|
|
incomplete.append(f"frame {f}, the last shared frame, differs on "
|
|
f"ws/wc/qc in the pattern of {what}, but no later "
|
|
f"frame shows the runs re-converge; rerun with a "
|
|
f"larger --frames")
|
|
|
|
tolerated = []
|
|
if result["straddles"]:
|
|
n = len(result["straddles"])
|
|
most = max(m for _, m in result["straddles"])
|
|
tolerated.append(f"{n} VBlank straddle frame(s) (ws/wc off by <= "
|
|
f"{most} write(s), re-converged at the next frame): "
|
|
f"{frame_ranges([f for f, _ in result['straddles']])}")
|
|
if result["shifts"]:
|
|
tolerated.append(f"{len(result['shifts'])} FMV-quiet shift(s) (wc+qc "
|
|
f"agreed, offsets carried forward): "
|
|
f"{frame_ranges([f for f, _ in result['shifts']])}")
|
|
notes += [f"tolerated: {t}" for t in tolerated]
|
|
|
|
for loc in LOCATORS:
|
|
if not comparable:
|
|
notes.append(f"locator {loc}: not compared (FMV-quiet settings "
|
|
f"differ)")
|
|
continue
|
|
if loc not in present["A"] or loc not in present["B"]:
|
|
notes.append(f"locator {loc}: not reported by both runs")
|
|
continue
|
|
parted = [f for f in common if fa[f][loc] != fb[f][loc]]
|
|
notes.append(f"locator {loc}: " + (
|
|
f"first differs at frame {parted[0]} ({len(parted)} of "
|
|
f"{len(common)} frames); not a failure: device writes are "
|
|
f"recorded in host service order" if parted
|
|
else "equal at every shared frame"))
|
|
|
|
shared = (f"{len(common)} shared frames ({common[0]}..{common[-1]})"
|
|
if common else "no shared frames")
|
|
if mismatch:
|
|
verdict, code = "MISMATCH", EXIT_MISMATCH
|
|
elif incomplete:
|
|
verdict, code = "INCOMPLETE", EXIT_INCOMPLETE
|
|
elif misses:
|
|
verdict, code = "MISSES", EXIT_MISSES
|
|
else:
|
|
verdict, code = "IDENTICAL", EXIT_IDENTICAL
|
|
if code == EXIT_IDENTICAL:
|
|
tol = ("; tolerated " + " and ".join(
|
|
f"{len(result[k])} {what}" for k, what in
|
|
(("straddles", "VBlank straddle(s)"),
|
|
("shifts", "FMV-quiet shift(s)")) if result[k])
|
|
if tolerated else "; no tolerance applied")
|
|
print(f"IDENTICAL: {len(common)} frames ({common[0]}..{common[-1]}) "
|
|
f"agree on {','.join(judged)}{tol}")
|
|
elif comparable:
|
|
print(f"{verdict}: {shared}, judged on {','.join(judged) or 'nothing'}")
|
|
else:
|
|
print(f"{verdict}: {shared}, not judged (FMV-quiet settings differ)")
|
|
for problem in mismatch + incomplete + misses:
|
|
print(f" {problem}")
|
|
for note in notes:
|
|
print(f" {note}")
|
|
return code
|
|
|
|
|
|
def cmd_compare(args):
|
|
limit = 0 if args.strict else args.straddle_writes
|
|
if limit < 0:
|
|
raise UsageError("--straddle-writes must be >= 0")
|
|
return compare(args.a, args.b, limit)
|
|
|
|
|
|
# ---- command line ------------------------------------------------------------
|
|
|
|
class Parser(argparse.ArgumentParser):
|
|
def error(self, message):
|
|
self.print_usage(sys.stderr)
|
|
raise UsageError(message)
|
|
|
|
|
|
def parser():
|
|
top = Parser(prog="fp_identity.py", description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
sub = top.add_subparsers(dest="command", parser_class=Parser)
|
|
|
|
snap = sub.add_parser("snapshot", help="copy a state dir's overlay state "
|
|
"into a seed dir")
|
|
snap.add_argument("state_dir")
|
|
snap.add_argument("seed_dir")
|
|
snap.set_defaults(func=cmd_snapshot)
|
|
|
|
run = sub.add_parser("run", help="launch, turbo to frame N, dump the "
|
|
"fingerprint ring")
|
|
run.add_argument("out", help="dump to write (OUT.log gets the runtime log)")
|
|
run.add_argument("--launch", help="command template, e.g. "
|
|
"'tools/run_game.sh {build} --debug-port {port} {headless}'")
|
|
run.add_argument("--build", default="build", help="value of {build}")
|
|
run.add_argument("--runtime", help="runtime executable (instead of "
|
|
"--launch)")
|
|
run.add_argument("--game", help="game.toml for --runtime")
|
|
run.add_argument("--disc", help="disc image for --runtime")
|
|
run.add_argument("--cwd", default=".", help="working directory of the "
|
|
"launch (default: current)")
|
|
run.add_argument("--state-dir", help="directory holding cache/ and "
|
|
"overlay_captures.json")
|
|
run.add_argument("--seed", help="overlay state to install before launching")
|
|
run.add_argument("--port", type=int, default=4370)
|
|
run.add_argument("--frames", type=int, default=6000)
|
|
run.add_argument("--label", help="name recorded in the dump")
|
|
run.add_argument("--windowed", action="store_true",
|
|
help="no --headless (renderer-dependent checks)")
|
|
run.add_argument("--fmv-quiet", action="store_true",
|
|
help="leave FMV-quiet on (PSX_DEBUG_FMV_QUIET=1)")
|
|
run.add_argument("--autocompile", action="store_true",
|
|
help="allow overlay autocompile "
|
|
"(PSX_OVERLAY_AUTOCOMPILE_OFF=0)")
|
|
run.add_argument("--env", action="append", default=[],
|
|
metavar="KEY=VALUE", help="extra environment (repeatable)")
|
|
run.add_argument("--boot-timeout", type=float, default=180,
|
|
help="seconds for the debug server to answer")
|
|
run.add_argument("--stall", type=float, default=120,
|
|
help="seconds the frame counter may stand still")
|
|
run.add_argument("--timeout", type=float, default=3600,
|
|
help="seconds to reach frame N")
|
|
run.set_defaults(func=cmd_run)
|
|
|
|
cmp_ = sub.add_parser("compare", help="judge two dumps")
|
|
cmp_.add_argument("a")
|
|
cmp_.add_argument("b")
|
|
cmp_.add_argument("--straddle-writes", type=int, default=1,
|
|
help="most writes one VBlank straddle may move "
|
|
"(default 1; 0 = strict)")
|
|
cmp_.add_argument("--strict", action="store_true",
|
|
help="no tolerance: same as --straddle-writes 0")
|
|
cmp_.set_defaults(func=cmd_compare)
|
|
return top
|
|
|
|
|
|
def main(argv):
|
|
extra = []
|
|
if "--" in argv:
|
|
i = argv.index("--")
|
|
argv, extra = argv[:i], argv[i + 1:]
|
|
top = parser()
|
|
args = None
|
|
try:
|
|
args = top.parse_args(argv)
|
|
if not getattr(args, "func", None):
|
|
top.print_help()
|
|
return EXIT_USAGE
|
|
args.extra = extra
|
|
if extra and args.command != "run":
|
|
raise UsageError("arguments after -- only apply to run")
|
|
return args.func(args)
|
|
except UsageError as e:
|
|
print(f"fp_identity.py: error: {e}", file=sys.stderr)
|
|
return EXIT_USAGE
|
|
except RunError as e:
|
|
print(f"fp_identity.py: run failed: {e}", file=sys.stderr)
|
|
return EXIT_INCOMPLETE
|
|
except (OSError, subprocess.SubprocessError) as e:
|
|
# Seeding, the log or the dump could not be written: no result, and
|
|
# never exit 1, which means MISMATCH.
|
|
what = getattr(args, "command", None) or "command"
|
|
print(f"fp_identity.py: {what} failed: {e}", file=sys.stderr)
|
|
return EXIT_INCOMPLETE
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|