mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-25 18:05:12 -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>
774 lines
33 KiB
Python
774 lines
33 KiB
Python
#!/usr/bin/env python3
|
|
"""Self-test for tools/fp_identity.py (no runtime, disc or build needed).
|
|
|
|
`compare` runs on synthetic fingerprint dumps: column judging, the VBlank
|
|
straddle and FMV-quiet tolerances, truncated and mismatched frame sets, dumps
|
|
from a runtime that predates the ws/qc columns, and the exit codes. `run`
|
|
drives a fake runtime (this file, started with --fake-runtime) through the
|
|
real launch path: command templates, --runtime arguments, seeding, the
|
|
environment defaults and a taken port.
|
|
|
|
python3 tools/tests/test_fp_identity.py (or ctest -R fp_identity)
|
|
"""
|
|
import argparse
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import shlex
|
|
import signal
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
TOOL = os.path.join(os.path.dirname(HERE), "fp_identity.py")
|
|
|
|
|
|
def load_tool():
|
|
spec = importlib.util.spec_from_file_location("fp_identity", TOOL)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
# Sparse like a real run: the ring holds one entry per guest frame that
|
|
# snapshotted, not one per vblank.
|
|
FRAMES = [f for f in range(1, 2001) if f % 4 != 3]
|
|
LAST = 2000
|
|
MASK64 = (1 << 64) - 1
|
|
|
|
|
|
def term(frame):
|
|
return (frame * 0x9E3779B97F4A7C15) & MASK64
|
|
|
|
|
|
def entry(frame, quiet=False, **override):
|
|
"""One ring entry. Cumulative columns grow with the frame number."""
|
|
e = {"frame": frame, "wr": f"0x{frame * 7919:016x}",
|
|
"pc": f"0x{frame * 104729:016x}", "wc": frame * 11,
|
|
"ws": f"0x{(frame * 0x2545F4914F6CDD1D) & MASK64:016x}",
|
|
"mmio": f"0x{frame * 31:016x}", "mc": frame * 3,
|
|
"sp": f"0x{frame * 17:016x}", "sc": frame * 5,
|
|
"qc": frame * 2 if quiet else 0, "cyc": frame * 564480}
|
|
e.update(override)
|
|
return e
|
|
|
|
|
|
def run_dump(frames=None, last=LAST, misses=0, quiet=False, env=True,
|
|
**extra):
|
|
dump = {"schema": 2, "build": "build", "frames": last,
|
|
"miss_total": misses, "miss_unique": 1 if misses else 0,
|
|
"ring_total": len(FRAMES) + 40, "ring_available": len(FRAMES) + 40,
|
|
"entries": [entry(f, quiet) for f in
|
|
(FRAMES if frames is None else frames)]}
|
|
if env:
|
|
dump["env"] = {"PSX_DEBUG_FMV_QUIET": "1" if quiet else "0",
|
|
"PSX_OVERLAY_AUTOCOMPILE_OFF": "1"}
|
|
dump.update(extra)
|
|
return dump
|
|
|
|
|
|
def bump(dump, index, stop=None, **delta):
|
|
"""Add delta to wc/qc/ws of entries[index:stop] (ws modulo 2^64)."""
|
|
for e in dump["entries"][index:stop]:
|
|
for k, v in delta.items():
|
|
if k == "ws":
|
|
e[k] = f"0x{(int(e[k], 16) + v) & MASK64:016x}"
|
|
else:
|
|
e[k] += v
|
|
return dump
|
|
|
|
|
|
def at(i):
|
|
return FRAMES[i]
|
|
|
|
|
|
class Base(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.dir = self.tmp.name
|
|
|
|
def tearDown(self):
|
|
self.tmp.cleanup()
|
|
|
|
def tool(self, *args, timeout=120):
|
|
proc = subprocess.run([sys.executable, TOOL, *args],
|
|
capture_output=True, text=True, timeout=timeout)
|
|
return proc.returncode, proc.stdout + proc.stderr
|
|
|
|
def compare(self, a, b, *flags):
|
|
paths = []
|
|
for name, dump in (("a.json", a), ("b.json", b)):
|
|
path = os.path.join(self.dir, name)
|
|
with open(path, "w") as f:
|
|
json.dump(dump, f)
|
|
paths.append(path)
|
|
return self.tool("compare", *paths, *flags)
|
|
|
|
def assertVerdict(self, result, code, verdict, *details):
|
|
status, out = result
|
|
self.assertEqual(status, code, out)
|
|
verdicts = [line.split(":")[0] for line in out.splitlines()
|
|
if line.split(":")[0] in
|
|
("IDENTICAL", "MISMATCH", "INCOMPLETE", "MISSES")]
|
|
self.assertEqual(verdicts, [verdict], out)
|
|
for detail in details:
|
|
self.assertIn(detail, out)
|
|
return out
|
|
|
|
|
|
class FrameSetTest(Base):
|
|
"""The frame-set and miss checks carried over from the R4 tool."""
|
|
|
|
def test_identical(self):
|
|
out = self.assertVerdict(
|
|
self.compare(run_dump(), run_dump()), 0, "IDENTICAL",
|
|
f"IDENTICAL: {len(FRAMES)} frames (1..2000) agree on "
|
|
f"cyc,mmio,mc,sp,sc,wc,ws,qc; no tolerance applied",
|
|
"locator wr: equal at every shared frame",
|
|
"FMV-quiet off in both runs")
|
|
self.assertNotIn("tolerated:", out)
|
|
|
|
def test_truncated_candidate_with_misses_is_not_identical(self):
|
|
# A candidate cut to its first 100 entries, with miss_total=5, once
|
|
# reported IDENTICAL over the shared frames.
|
|
cut = run_dump(misses=5)
|
|
cut["entries"] = cut["entries"][:100]
|
|
self.assertVerdict(self.compare(run_dump(), cut), 3, "INCOMPLETE",
|
|
"B lacks", "outside its range 1..133",
|
|
"B had 5 dispatch misses")
|
|
|
|
def test_truncated_either_side(self):
|
|
short = FRAMES[:500]
|
|
self.assertVerdict(self.compare(run_dump(), run_dump(short)), 3,
|
|
"INCOMPLETE", "B lacks 1000 of A's frames")
|
|
self.assertVerdict(self.compare(run_dump(short), run_dump()), 3,
|
|
"INCOMPLETE", "A lacks 1000 of B's frames")
|
|
|
|
def test_missing_head(self):
|
|
self.assertVerdict(self.compare(run_dump(), run_dump(FRAMES[10:])), 3,
|
|
"INCOMPLETE", "B lacks 10 of A's frames",
|
|
"1..2, 4..6")
|
|
|
|
def test_different_window(self):
|
|
self.assertVerdict(self.compare(run_dump(), run_dump(last=LAST + 1)), 3,
|
|
"INCOMPLETE", "A at 2000, B at 2001")
|
|
|
|
def test_interior_frame_set_difference_is_a_mismatch(self):
|
|
holed = [f for f in FRAMES if f != 1000]
|
|
self.assertVerdict(self.compare(run_dump(), run_dump(holed)), 1,
|
|
"MISMATCH", "fingerprinted by A are missing inside "
|
|
"B's range: 1000")
|
|
|
|
def test_divergence_and_truncation_report_both(self):
|
|
b = run_dump(FRAMES[:500])
|
|
b["entries"][10]["mmio"] = "0x0"
|
|
self.assertVerdict(self.compare(run_dump(), b), 1, "MISMATCH",
|
|
f"DIVERGE at frame {at(10)}",
|
|
"B lacks 1000 of A's frames")
|
|
|
|
def test_dispatch_misses(self):
|
|
self.assertVerdict(self.compare(run_dump(misses=2), run_dump()), 4,
|
|
"MISSES", "A had 2 dispatch misses")
|
|
|
|
def test_unknown_miss_count(self):
|
|
self.assertVerdict(self.compare(run_dump(), run_dump(miss_total=None)),
|
|
3, "INCOMPLETE", "B has no dispatch-miss count")
|
|
|
|
def test_wrapped_ring(self):
|
|
wrapped = run_dump(ring_total=40000, ring_available=32768)
|
|
self.assertVerdict(self.compare(run_dump(), wrapped), 3, "INCOMPLETE",
|
|
"B's fingerprint ring wrapped")
|
|
|
|
def test_dump_without_ring_counts_still_compares(self):
|
|
old = run_dump()
|
|
del old["ring_total"], old["ring_available"]
|
|
self.assertVerdict(self.compare(old, run_dump()), 0, "IDENTICAL")
|
|
|
|
def test_empty_run(self):
|
|
self.assertVerdict(self.compare(run_dump(), run_dump([])), 3,
|
|
"INCOMPLETE", "B has no fingerprint entries",
|
|
"no shared frames")
|
|
|
|
|
|
class ColumnTest(Base):
|
|
"""Judge columns fail a run; locator columns only say where runs part."""
|
|
|
|
def test_every_judge_column_is_judged(self):
|
|
for col, value in (("cyc", 1), ("mmio", "0x1"), ("mc", 1),
|
|
("sp", "0x1"), ("sc", 1), ("qc", 99),
|
|
("ws", "0x1")):
|
|
with self.subTest(col=col):
|
|
b = run_dump()
|
|
b["entries"][300][col] = value
|
|
self.assertVerdict(self.compare(run_dump(), b), 1, "MISMATCH",
|
|
f"DIVERGE at frame {at(300)}: judge "
|
|
f"columns ['{col}']",
|
|
f"record_frame {at(300)}")
|
|
|
|
def test_ws_value_fork_with_equal_count_is_not_a_straddle(self):
|
|
# Same number of writes, different values: a real fork.
|
|
b = bump(run_dump(), 300, 301, ws=12345)
|
|
self.assertVerdict(self.compare(run_dump(), b), 1, "MISMATCH",
|
|
f"DIVERGE at frame {at(300)}: judge columns ['ws']")
|
|
|
|
def test_locators_are_reported_not_judged(self):
|
|
b = run_dump()
|
|
for e in b["entries"][700:]:
|
|
e["wr"], e["pc"] = "0x1", "0x2"
|
|
self.assertVerdict(self.compare(run_dump(), b), 0, "IDENTICAL",
|
|
f"locator wr: first differs at frame {at(700)} "
|
|
f"({len(FRAMES) - 700} of {len(FRAMES)} frames); "
|
|
f"not a failure",
|
|
f"locator pc: first differs at frame {at(700)}")
|
|
|
|
def test_mismatch_reports_locators_too(self):
|
|
b = run_dump()
|
|
b["entries"][500]["wr"] = "0x1"
|
|
b["entries"][600]["sc"] = 0
|
|
self.assertVerdict(self.compare(run_dump(), b), 1, "MISMATCH",
|
|
f"DIVERGE at frame {at(600)}: judge columns ['sc']",
|
|
f"locator wr: first differs at frame {at(500)}")
|
|
|
|
def test_hex_and_integer_spellings_compare_equal(self):
|
|
b = run_dump()
|
|
for e in b["entries"]:
|
|
e["mmio"] = int(e["mmio"], 16)
|
|
e["wc"] = str(e["wc"])
|
|
self.assertVerdict(self.compare(run_dump(), b), 0, "IDENTICAL")
|
|
|
|
def test_older_runtime_without_ws_qc(self):
|
|
# Dumps from a runtime before psxrecomp #420: no ws/qc, no env.
|
|
old = run_dump(env=False)
|
|
for e in old["entries"]:
|
|
del e["ws"], e["qc"]
|
|
self.assertVerdict(self.compare(old, json.loads(json.dumps(old))), 3,
|
|
"INCOMPLETE", "A lacks judge column(s) ws,qc",
|
|
"B lacks judge column(s) ws,qc",
|
|
"judged on cyc,mmio,mc,sp,sc,wc")
|
|
|
|
def test_older_runtime_on_one_side_only(self):
|
|
old = run_dump()
|
|
for e in old["entries"]:
|
|
del e["ws"]
|
|
self.assertVerdict(self.compare(run_dump(), old), 3, "INCOMPLETE",
|
|
"B lacks judge column(s) ws")
|
|
|
|
def test_older_runtime_still_reports_divergence(self):
|
|
old = run_dump(env=False)
|
|
for e in old["entries"]:
|
|
del e["ws"], e["qc"]
|
|
b = json.loads(json.dumps(old))
|
|
b["entries"][40]["mc"] = 0
|
|
self.assertVerdict(self.compare(old, b), 1, "MISMATCH",
|
|
f"DIVERGE at frame {at(40)}: judge columns ['mc']",
|
|
"lacks judge column(s) ws,qc")
|
|
|
|
|
|
class StraddleTest(Base):
|
|
"""The one-write VBlank straddle of docs/TCP_COMMANDS.md."""
|
|
|
|
def straddled(self, index, writes=1, sign=1):
|
|
return bump(run_dump(), index, index + 1, wc=sign * writes,
|
|
ws=sign * term(index))
|
|
|
|
def test_one_write_straddle_is_tolerated_and_reported(self):
|
|
b = self.straddled(300)
|
|
b = bump(b, 900, 901, wc=-1, ws=-term(900))
|
|
self.assertVerdict(self.compare(run_dump(), b), 0, "IDENTICAL",
|
|
"tolerated 2 VBlank straddle(s)",
|
|
"tolerated: 2 VBlank straddle frame(s) (ws/wc off "
|
|
f"by <= 1 write(s), re-converged at the next "
|
|
f"frame): {at(300)}, {at(900)}")
|
|
|
|
def test_strict_rejects_a_straddle(self):
|
|
self.assertVerdict(self.compare(run_dump(), self.straddled(300),
|
|
"--strict"), 1, "MISMATCH",
|
|
f"DIVERGE at frame {at(300)}: judge columns "
|
|
f"['wc', 'ws'] (the one-write VBlank straddle "
|
|
f"pattern; --strict rejects it)")
|
|
|
|
def test_straddle_that_does_not_reconverge(self):
|
|
b = bump(run_dump(), 300, None, wc=1, ws=term(300))
|
|
self.assertVerdict(self.compare(run_dump(), b), 1, "MISMATCH",
|
|
f"DIVERGE at frame {at(300)}: judge columns "
|
|
f"['wc', 'ws'] (a 1-write straddle pattern, but "
|
|
f"frame {at(301)} did not re-converge)")
|
|
|
|
def test_two_write_straddle_needs_a_wider_limit(self):
|
|
b = self.straddled(300, writes=2)
|
|
self.assertVerdict(self.compare(run_dump(), b), 1, "MISMATCH",
|
|
"2 writes moved, more than --straddle-writes 1")
|
|
self.assertVerdict(self.compare(run_dump(), b, "--straddle-writes",
|
|
"2"), 0, "IDENTICAL",
|
|
"ws/wc off by <= 2 write(s)")
|
|
|
|
def test_straddle_with_another_column_is_a_divergence(self):
|
|
b = self.straddled(300)
|
|
b["entries"][300]["mmio"] = "0x5"
|
|
self.assertVerdict(self.compare(run_dump(), b), 1, "MISMATCH",
|
|
f"DIVERGE at frame {at(300)}: judge columns "
|
|
f"['mmio', 'wc', 'ws']")
|
|
|
|
def test_next_frame_diverging_is_not_a_straddle(self):
|
|
b = self.straddled(300)
|
|
b["entries"][301]["cyc"] = 1
|
|
self.assertVerdict(self.compare(run_dump(), b), 1, "MISMATCH",
|
|
f"DIVERGE at frame {at(300)}",
|
|
f"frame {at(301)} did not re-converge")
|
|
|
|
def test_straddle_on_the_last_frame_is_unconfirmed(self):
|
|
b = self.straddled(len(FRAMES) - 1)
|
|
self.assertVerdict(self.compare(run_dump(), b), 3, "INCOMPLETE",
|
|
f"frame {FRAMES[-1]}, the last shared frame",
|
|
"a 1-write VBlank straddle",
|
|
"rerun with a larger --frames")
|
|
|
|
def test_bad_straddle_limit_is_a_usage_error(self):
|
|
status, out = self.compare(run_dump(), run_dump(),
|
|
"--straddle-writes", "-1")
|
|
self.assertEqual(status, 2, out)
|
|
|
|
|
|
class FmvQuietTest(Base):
|
|
"""With FMV-quiet on, a straddle can move a write between wc and qc."""
|
|
|
|
def shifted(self, index, **extra):
|
|
# From `index` on, B counted one write in wc that A counted in qc.
|
|
return bump(run_dump(quiet=True, **extra), index, None, wc=1, qc=-1,
|
|
ws=term(index))
|
|
|
|
def test_quiet_shift_is_tolerated_with_carried_offsets(self):
|
|
self.assertVerdict(self.compare(run_dump(quiet=True),
|
|
self.shifted(300)), 0, "IDENTICAL",
|
|
"tolerated 1 FMV-quiet shift(s)",
|
|
f"FMV-quiet shift(s) (wc+qc agreed, offsets "
|
|
f"carried forward): {at(300)}",
|
|
"wc+qc must agree")
|
|
|
|
def test_quiet_shift_after_a_quiet_straddle(self):
|
|
# A's write lands in a quiet frame (qc) one frame before B's, which
|
|
# lands in the next, non-quiet frame (wc).
|
|
b = run_dump(quiet=True)
|
|
bump(b, 300, 301, qc=-1)
|
|
bump(b, 301, None, wc=1, qc=-1, ws=term(301))
|
|
self.assertVerdict(self.compare(run_dump(quiet=True), b), 0,
|
|
"IDENTICAL", "1 VBlank straddle(s) and 1 FMV-quiet "
|
|
"shift(s)")
|
|
|
|
def test_ws_drift_after_a_shift_is_a_divergence(self):
|
|
b = bump(self.shifted(300), 800, None, ws=77)
|
|
self.assertVerdict(self.compare(run_dump(quiet=True), b), 1,
|
|
"MISMATCH", f"DIVERGE at frame {at(800)}: judge "
|
|
f"columns ['ws']", "after 1 FMV-quiet shift(s)")
|
|
|
|
def test_wc_plus_qc_must_agree(self):
|
|
b = bump(run_dump(quiet=True), 300, None, qc=1)
|
|
self.assertVerdict(self.compare(run_dump(quiet=True), b), 1,
|
|
"MISMATCH", f"DIVERGE at frame {at(300)}: judge "
|
|
f"columns ['qc']")
|
|
|
|
def test_shift_is_not_tolerated_with_quiet_off(self):
|
|
# The same pattern with FMV-quiet off in both runs is a fork.
|
|
a = run_dump()
|
|
b = bump(run_dump(), 300, None, wc=1, qc=-1, ws=term(300))
|
|
self.assertVerdict(self.compare(a, b), 1, "MISMATCH",
|
|
f"DIVERGE at frame {at(300)}: judge columns "
|
|
f"['wc', 'qc', 'ws']")
|
|
|
|
def test_different_quiet_settings_are_incomplete(self):
|
|
a = run_dump(quiet=True)
|
|
b = run_dump(quiet=True)
|
|
b["env"]["PSX_DEBUG_FMV_QUIET"] = "0"
|
|
self.assertVerdict(self.compare(a, b), 3, "INCOMPLETE",
|
|
"different FMV-quiet settings (A on, B off)")
|
|
|
|
def test_mixed_settings_with_quiet_frames_are_not_judged(self):
|
|
# As on R4: the quiet run skips the fingerprint on quiet frames and
|
|
# counts their writes (MMIO too) in qc, so frames and columns part
|
|
# from the first quiet frame. That is the setting, not a fork.
|
|
a = run_dump()
|
|
quiet_frames = set(FRAMES[300:400])
|
|
b = run_dump([f for f in FRAMES if f not in quiet_frames])
|
|
b["env"]["PSX_DEBUG_FMV_QUIET"] = "1"
|
|
for e in b["entries"]:
|
|
if e["frame"] > FRAMES[300]:
|
|
e["qc"], e["mmio"], e["mc"] = 50, "0x1", 0
|
|
out = self.assertVerdict(
|
|
self.compare(a, b), 3, "INCOMPLETE",
|
|
"not judged (FMV-quiet settings differ)",
|
|
"different FMV-quiet settings (A off, B on)",
|
|
"B counted quiet writes (qc > 0)",
|
|
f"the frame sets differ ({len(FRAMES)} and {len(FRAMES) - 100} "
|
|
f"frames fingerprinted)",
|
|
"Rerun both with the same setting",
|
|
"locator wr: not compared")
|
|
for artefact in ("DIVERGE", "record_frame", "missing inside",
|
|
"tolerated:"):
|
|
self.assertNotIn(artefact, out)
|
|
|
|
def test_mixed_settings_without_qc_column_are_not_judged(self):
|
|
a, b = run_dump(), run_dump(env=False)
|
|
for dump in (a, b):
|
|
for e in dump["entries"]:
|
|
del e["qc"]
|
|
self.assertVerdict(self.compare(a, b), 3, "INCOMPLETE",
|
|
"qc is not reported, so quiet frames cannot be "
|
|
"ruled out")
|
|
|
|
def test_mixed_settings_without_a_quiet_frame_are_judged(self):
|
|
# FMV-quiet on in B, but no MDEC decode: nothing was kept quiet.
|
|
b = run_dump()
|
|
b["env"]["PSX_DEBUG_FMV_QUIET"] = "1"
|
|
self.assertVerdict(self.compare(run_dump(), b), 0, "IDENTICAL",
|
|
"FMV-quiet settings differ (A off, B on), but no "
|
|
"quiet frame occurred")
|
|
b["entries"][300]["cyc"] = 1
|
|
self.assertVerdict(self.compare(run_dump(), b), 1, "MISMATCH",
|
|
f"DIVERGE at frame {at(300)}: judge columns "
|
|
f"['cyc']")
|
|
|
|
def test_unrecorded_quiet_setting_uses_the_quiet_rule(self):
|
|
a, b = run_dump(quiet=True, env=False), self.shifted(300, env=False)
|
|
self.assertVerdict(self.compare(a, b), 0, "IDENTICAL",
|
|
"not recorded (runtime default: on)",
|
|
"tolerated 1 FMV-quiet shift(s)")
|
|
|
|
|
|
class UsageTest(Base):
|
|
def test_compare_needs_two_dumps(self):
|
|
status, out = self.tool("compare", "only-one")
|
|
self.assertEqual(status, 2, out)
|
|
|
|
def test_unreadable_dump(self):
|
|
bad = os.path.join(self.dir, "bad.json")
|
|
with open(bad, "w") as f:
|
|
f.write("{not json")
|
|
status, out = self.tool("compare", bad, bad)
|
|
self.assertEqual(status, 2, out)
|
|
self.assertIn("cannot read", out)
|
|
|
|
def test_no_command(self):
|
|
status, _ = self.tool()
|
|
self.assertEqual(status, 2)
|
|
|
|
def test_run_needs_a_launcher(self):
|
|
status, out = self.tool("run", os.path.join(self.dir, "x.json"))
|
|
self.assertEqual(status, 2, out)
|
|
self.assertIn("--launch", out)
|
|
|
|
def test_windowed_with_a_literal_headless(self):
|
|
status, out = self.tool("run", "x.json", "--launch",
|
|
"game --headless", "--windowed")
|
|
self.assertEqual(status, 2, out)
|
|
self.assertIn("{headless}", out)
|
|
|
|
def test_unknown_placeholder(self):
|
|
status, out = self.tool("run", "x.json", "--launch", "game {bogus}")
|
|
self.assertEqual(status, 2, out)
|
|
self.assertIn("bad placeholder", out)
|
|
|
|
def test_seed_without_state_dir(self):
|
|
status, out = self.tool("run", "x.json", "--launch", "game {port}",
|
|
"--seed", self.dir)
|
|
self.assertEqual(status, 2, out)
|
|
self.assertIn("--seed needs --state-dir", out)
|
|
|
|
|
|
class LaunchTemplateTest(unittest.TestCase):
|
|
"""How --launch is split into arguments (no runtime needed)."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.fp = load_tool()
|
|
|
|
def argv(self, template, windows, **extra):
|
|
args = argparse.Namespace(launch=template, build="build", port=4781,
|
|
windowed=False, extra=[], **extra)
|
|
return self.fp.launch_argv(args, None, windows=windows)
|
|
|
|
def test_windows_paths_keep_their_backslashes(self):
|
|
self.assertEqual(
|
|
self.argv(r"C:\psx\build\game-runtime.exe --game "
|
|
r"build\game.toml --debug-port {port} {headless}", True),
|
|
[r"C:\psx\build\game-runtime.exe", "--game", r"build\game.toml",
|
|
"--debug-port", "4781", "--headless"])
|
|
|
|
def test_windows_quoted_path_with_spaces(self):
|
|
self.assertEqual(
|
|
self.argv(r'"C:\Program Files\psx\game.exe" --debug-port {port}',
|
|
True),
|
|
[r"C:\Program Files\psx\game.exe", "--debug-port", "4781"])
|
|
|
|
def test_posix_keeps_shell_escapes(self):
|
|
self.assertEqual(
|
|
self.argv(r"tools/run\ game.sh '{build}' --debug-port {port}",
|
|
False),
|
|
["tools/run game.sh", "build", "--debug-port", "4781"])
|
|
|
|
def test_unbalanced_quote_is_a_usage_error(self):
|
|
for windows in (False, True):
|
|
with self.subTest(windows=windows):
|
|
with self.assertRaises(self.fp.UsageError):
|
|
self.argv('"C:\\psx\\game.exe --debug-port {port}',
|
|
windows)
|
|
|
|
|
|
# ---- fake runtime ------------------------------------------------------------
|
|
|
|
def fake_runtime(argv):
|
|
"""A debug server that answers the four commands `run` sends."""
|
|
port = int(argv[argv.index("--debug-port") + 1])
|
|
report = argv[argv.index("--report") + 1]
|
|
with open(report, "w") as f:
|
|
json.dump({"argv": argv, "env": {
|
|
k: os.environ.get(k) for k in
|
|
("PSX_DEBUG_FMV_QUIET", "PSX_OVERLAY_AUTOCOMPILE_OFF", "FAKE_X")},
|
|
"cwd": os.getcwd()}, f)
|
|
if "--die" in argv:
|
|
sys.exit(7)
|
|
srv = socket.socket()
|
|
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
srv.bind(("127.0.0.1", port))
|
|
srv.listen(8)
|
|
start = time.time()
|
|
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
|
while True:
|
|
conn, _ = srv.accept()
|
|
with conn:
|
|
line = b""
|
|
while not line.endswith(b"\n"):
|
|
chunk = conn.recv(65536)
|
|
if not chunk:
|
|
break
|
|
line += chunk
|
|
req = json.loads(line or b"{}")
|
|
frame = int((time.time() - start) * 40000)
|
|
cmd = req.get("cmd")
|
|
if "--crash-on" in argv and cmd == argv[argv.index("--crash-on")
|
|
+ 1]:
|
|
os._exit(9) # dies mid-request, no reply
|
|
if "--hang-up-on" in argv and cmd == argv[
|
|
argv.index("--hang-up-on") + 1]:
|
|
continue # closes the connection, no reply
|
|
if cmd == "frame_fingerprint":
|
|
lo, hi = req.get("frame_lo", 0), req.get("frame_hi", frame)
|
|
entries = [entry(f) for f in FRAMES if lo <= f <= hi]
|
|
reply = {"ok": True, "total": len(FRAMES),
|
|
"available": len(FRAMES), "entries": entries}
|
|
elif cmd == "dispatch_stats":
|
|
reply = {"ok": True, "miss_total": 0, "miss_unique": 0}
|
|
else:
|
|
reply = {"ok": True, "frame": frame}
|
|
conn.sendall(json.dumps(reply).encode() + b"\n")
|
|
|
|
|
|
def free_port():
|
|
with socket.socket() as s:
|
|
s.bind(("127.0.0.1", 0))
|
|
return s.getsockname()[1]
|
|
|
|
|
|
@unittest.skipIf(os.name == "nt", "the fake runtime is a POSIX launch")
|
|
class RunTest(Base):
|
|
def launch(self, out, *args):
|
|
return self.tool("run", os.path.join(self.dir, out), "--frames",
|
|
str(LAST), "--boot-timeout", "30", "--stall", "30",
|
|
"--timeout", "60", *args)
|
|
|
|
def template(self, report, extra=""):
|
|
return (f"{shlex.quote(sys.executable)} {shlex.quote(__file__)} "
|
|
f"--fake-runtime {{headless}} --debug-port {{port}} "
|
|
f"--report {shlex.quote(report)} {extra}")
|
|
|
|
def report(self, name):
|
|
with open(os.path.join(self.dir, name)) as f:
|
|
return json.load(f)
|
|
|
|
def test_template_run_then_compare(self):
|
|
rep = os.path.join(self.dir, "rep.json")
|
|
port = str(free_port())
|
|
for out in ("a.json", "b.json"):
|
|
status, text = self.launch(out, "--launch", self.template(rep),
|
|
"--port", port, "--env", "FAKE_X=7",
|
|
"--", "--extra-arg")
|
|
self.assertEqual(status, 0, text)
|
|
seen = self.report("rep.json")
|
|
self.assertEqual(seen["env"], {"PSX_DEBUG_FMV_QUIET": "0",
|
|
"PSX_OVERLAY_AUTOCOMPILE_OFF": "1",
|
|
"FAKE_X": "7"})
|
|
self.assertIn("--headless", seen["argv"])
|
|
self.assertEqual(seen["argv"][-1], "--extra-arg")
|
|
with open(os.path.join(self.dir, "a.json")) as f:
|
|
dump = json.load(f)
|
|
self.assertEqual(dump["frames"], LAST)
|
|
self.assertEqual(dump["miss_total"], 0)
|
|
self.assertEqual(dump["env"]["PSX_DEBUG_FMV_QUIET"], "0")
|
|
self.assertEqual(len(dump["entries"]), len(FRAMES))
|
|
status, text = self.tool("compare", os.path.join(self.dir, "a.json"),
|
|
os.path.join(self.dir, "b.json"))
|
|
self.assertEqual(status, 0, text)
|
|
self.assertIn("IDENTICAL", text)
|
|
|
|
def test_windowed_and_opt_ins(self):
|
|
rep = os.path.join(self.dir, "rep.json")
|
|
status, text = self.launch("a.json", "--launch", self.template(rep),
|
|
"--port", str(free_port()), "--windowed",
|
|
"--fmv-quiet", "--autocompile")
|
|
self.assertEqual(status, 0, text)
|
|
seen = self.report("rep.json")
|
|
self.assertNotIn("--headless", seen["argv"])
|
|
self.assertEqual(seen["env"]["PSX_DEBUG_FMV_QUIET"], "1")
|
|
self.assertEqual(seen["env"]["PSX_OVERLAY_AUTOCOMPILE_OFF"], "0")
|
|
|
|
def test_runtime_arguments_and_seed(self):
|
|
# --runtime mode: the state dir defaults to the runtime's directory.
|
|
bindir = os.path.join(self.dir, "build")
|
|
os.makedirs(os.path.join(bindir, "cache", "stale"))
|
|
with open(os.path.join(bindir, "overlay_captures.json"), "w") as f:
|
|
f.write("old")
|
|
runtime = os.path.join(bindir, "fake-runtime")
|
|
rep = os.path.join(self.dir, "rep.json")
|
|
with open(runtime, "w") as f:
|
|
f.write(f"#!/bin/sh\nexec {shlex.quote(sys.executable)} "
|
|
f"{shlex.quote(__file__)} --fake-runtime --report "
|
|
f"{shlex.quote(rep)} \"$@\"\n")
|
|
os.chmod(runtime, 0o755)
|
|
seed = os.path.join(self.dir, "seed")
|
|
os.makedirs(os.path.join(seed, "cache", "shard"))
|
|
status, text = self.launch("a.json", "--runtime", "build/fake-runtime",
|
|
"--game", "g.toml", "--disc", "d.cue",
|
|
"--cwd", self.dir, "--seed", seed,
|
|
"--port", str(free_port()))
|
|
self.assertEqual(status, 0, text)
|
|
seen = self.report("rep.json")
|
|
self.assertEqual(seen["argv"][seen["argv"].index("--game") + 1],
|
|
"g.toml")
|
|
self.assertIn("--no-launcher", seen["argv"])
|
|
self.assertEqual(os.path.realpath(seen["cwd"]),
|
|
os.path.realpath(self.dir))
|
|
self.assertTrue(os.path.isdir(os.path.join(bindir, "cache", "shard")))
|
|
self.assertFalse(os.path.exists(os.path.join(bindir, "cache",
|
|
"stale")))
|
|
self.assertFalse(os.path.exists(os.path.join(
|
|
bindir, "overlay_captures.json")))
|
|
|
|
def test_snapshot_copies_overlay_state(self):
|
|
state = os.path.join(self.dir, "state")
|
|
os.makedirs(os.path.join(state, "cache", "shard"))
|
|
with open(os.path.join(state, "overlay_captures.json"), "w") as f:
|
|
f.write("{}")
|
|
seed = os.path.join(self.dir, "seed")
|
|
status, text = self.tool("snapshot", state, seed)
|
|
self.assertEqual(status, 0, text)
|
|
self.assertTrue(os.path.isdir(os.path.join(seed, "cache", "shard")))
|
|
self.assertTrue(os.path.isfile(os.path.join(
|
|
seed, "overlay_captures.json")))
|
|
|
|
def test_taken_port_is_refused(self):
|
|
with socket.socket() as s:
|
|
s.bind(("127.0.0.1", 0))
|
|
s.listen(1)
|
|
port = str(s.getsockname()[1])
|
|
status, text = self.launch(
|
|
"a.json", "--launch",
|
|
self.template(os.path.join(self.dir, "rep.json")),
|
|
"--port", port)
|
|
self.assertEqual(status, 3, text)
|
|
self.assertIn("already has a listener", text)
|
|
self.assertFalse(os.path.exists(os.path.join(self.dir, "rep.json")))
|
|
|
|
def test_runtime_that_fails_while_dumping(self):
|
|
# Reaches frame N, then crashes or drops the connection on one of
|
|
# the final queries: exit 3 with a reason, never 1 (MISMATCH) with a
|
|
# traceback, and no dump.
|
|
for how, cmd, reason in (
|
|
("--crash-on", "frame_fingerprint",
|
|
"runtime exited (status 9) on frame_fingerprint after "
|
|
"reaching frame"),
|
|
("--crash-on", "dispatch_stats",
|
|
"runtime exited (status 9) on dispatch_stats"),
|
|
("--hang-up-on", "frame_fingerprint",
|
|
"runtime did not answer on frame_fingerprint"),
|
|
("--hang-up-on", "dispatch_stats",
|
|
"runtime did not answer on dispatch_stats")):
|
|
with self.subTest(how=how, cmd=cmd):
|
|
rep = os.path.join(self.dir, "rep.json")
|
|
status, text = self.launch(
|
|
"a.json", "--launch", self.template(rep, f"{how} {cmd}"),
|
|
"--port", str(free_port()))
|
|
self.assertEqual(status, 3, text)
|
|
self.assertIn(reason, text)
|
|
self.assertNotIn("Traceback", text)
|
|
for name in ("a.json", "a.json.tmp"):
|
|
self.assertFalse(os.path.exists(os.path.join(self.dir,
|
|
name)))
|
|
|
|
@unittest.skipIf(hasattr(os, "geteuid") and os.geteuid() == 0,
|
|
"root reads a mode-000 file")
|
|
def test_io_errors_exit_incomplete(self):
|
|
seed = os.path.join(self.dir, "seed")
|
|
os.makedirs(os.path.join(seed, "cache"))
|
|
locked = os.path.join(seed, "cache", "shard.bin")
|
|
with open(locked, "w") as f:
|
|
f.write("x")
|
|
os.chmod(locked, 0)
|
|
state = os.path.join(self.dir, "state")
|
|
os.makedirs(state)
|
|
rep = os.path.join(self.dir, "rep.json")
|
|
try:
|
|
status, text = self.launch("a.json", "--launch",
|
|
self.template(rep), "--port",
|
|
str(free_port()), "--state-dir", state,
|
|
"--seed", seed)
|
|
self.assertEqual(status, 3, text)
|
|
self.assertIn("run failed", text)
|
|
self.assertNotIn("Traceback", text)
|
|
self.assertFalse(os.path.exists(rep)) # never launched
|
|
status, text = self.tool("snapshot", seed,
|
|
os.path.join(self.dir, "copy"))
|
|
self.assertEqual(status, 3, text)
|
|
self.assertIn("snapshot failed", text)
|
|
finally:
|
|
os.chmod(locked, 0o644)
|
|
|
|
def test_missing_output_directory_is_a_usage_error(self):
|
|
status, text = self.launch(os.path.join("no", "such", "a.json"),
|
|
"--launch", self.template("rep.json"),
|
|
"--port", str(free_port()))
|
|
self.assertEqual(status, 2, text)
|
|
self.assertIn("output directory", text)
|
|
|
|
def test_stop_reports_a_process_that_will_not_exit(self):
|
|
fp = load_tool()
|
|
|
|
class Stuck:
|
|
pid = 999999999
|
|
|
|
def wait(self, timeout):
|
|
raise subprocess.TimeoutExpired("runtime", timeout)
|
|
|
|
with mock.patch.object(fp.os, "killpg") as killpg:
|
|
self.assertIn("did not exit after SIGKILL", fp.stop(Stuck()))
|
|
self.assertEqual([c.args[1] for c in killpg.call_args_list],
|
|
[signal.SIGTERM, signal.SIGKILL])
|
|
|
|
def test_runtime_that_exits_early(self):
|
|
rep = os.path.join(self.dir, "rep.json")
|
|
status, text = self.launch("a.json", "--launch",
|
|
self.template(rep, "--die"),
|
|
"--port", str(free_port()))
|
|
self.assertEqual(status, 3, text)
|
|
self.assertIn("runtime exited (status 7)", text)
|
|
self.assertFalse(os.path.exists(os.path.join(self.dir, "a.json")))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if "--fake-runtime" in sys.argv:
|
|
fake_runtime(sys.argv)
|
|
else:
|
|
unittest.main()
|