mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-28 10:59:16 -04:00
76a43de2ee
Gives the DuckStation oracle the same per-frame write fingerprint psxrecomp
already had, so the two can be compared by "which frame did execution fork"
rather than by pictures. Comparing pictures needs the two aligned to the same
instant, which nothing short of deterministic replay achieves; the fingerprint
is cumulative, so frame N's hash answers whether the whole write history up to
frame N matched, and phase does not enter into it.
Ported byte for byte from debug_server.c -- same FNV-1a constants, same JSON
field names, same decision to keep main RAM, store-PC, device registers, and
scratchpad in SEPARATE columns. Folding device churn into the RAM hash is what
hid a real first divergence before: the RAM hash sees only the aftermath of a
device-interaction fork, so it points frames too late.
Two traps are closed rather than left for the reader:
* The store hook lives in the interpreter's write path (hooked in
WriteMemoryByte/HalfWord/Word so swl/swr/sc are covered, not at the
sb/sh/sw cases). A recompiler build inlines stores, never reaches it, and
would record almost nothing -- so the reply carries "interpreter" and
fingerprint_diff.py refuses to report on a build that is not in that mode.
* Values are truncated to the width actually written before hashing, matching
what memory.c hands fp_record_write. Hashing the untruncated register would
make every byte store disagree and every frame "diverge" for no guest
reason.
fingerprint_diff.py compares write COUNTS before it compares hashes and refuses
to name a divergence frame when they do not track. The oracle's hook does not
see DMA-written RAM; a mismatch from that is indistinguishable from a real fork,
and a confident wrong answer would send you hunting one that is not there.
Unverified: hash equality between the two has not been demonstrated on a live
pair yet -- the oracle needs a rebuild first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
90 lines
3.1 KiB
Python
90 lines
3.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Tests for the coverage gate in fingerprint_diff.
|
|
|
|
The gate is the whole point of the tool being trustworthy. Two emulators can
|
|
only be compared by write-hash if they observe the same set of writes, and they
|
|
do not by default: psxrecomp hooks its memory.c write paths, the oracle hooks
|
|
the interpreter's store path, and DMA-written RAM passes through one and not
|
|
the other. A hash mismatch caused by that is indistinguishable from a real
|
|
guest divergence unless something checks first -- so these pin the refusal.
|
|
"""
|
|
|
|
import importlib.util
|
|
import sys
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
|
|
def _load(name):
|
|
spec = importlib.util.spec_from_file_location(name, ROOT / f"{name}.py")
|
|
mod = importlib.util.module_from_spec(spec)
|
|
assert spec.loader is not None
|
|
spec.loader.exec_module(mod)
|
|
return mod
|
|
|
|
|
|
FD = _load("fingerprint_diff")
|
|
|
|
|
|
def rows(counts, h="0xabc"):
|
|
return {f: {"frame": f, "wc": c, "wr": h} for f, c in counts.items()}
|
|
|
|
|
|
class TestCoverageGate(unittest.TestCase):
|
|
def test_matching_counts_pass(self):
|
|
a = rows({1: 1000, 2: 2100, 3: 3050})
|
|
b = rows({1: 1010, 2: 2090, 3: 3070})
|
|
ok, why = FD.coverage_verdict(a, b, [1, 2, 3])
|
|
self.assertTrue(ok, why)
|
|
|
|
def test_a_side_seeing_far_fewer_writes_is_refused(self):
|
|
# The recompiler-mode trap: the oracle's hook is never reached, so it
|
|
# records a trickle. Hashes would "differ" for a reason that has
|
|
# nothing to do with the guest.
|
|
a = rows({1: 100000, 2: 200000})
|
|
b = rows({1: 300, 2: 610})
|
|
ok, why = FD.coverage_verdict(a, b, [1, 2])
|
|
self.assertFalse(ok)
|
|
self.assertIn("not observing the same", why)
|
|
|
|
def test_a_side_recording_nothing_is_refused(self):
|
|
a = rows({1: 5000})
|
|
b = rows({1: 0})
|
|
ok, why = FD.coverage_verdict(a, b, [1])
|
|
self.assertFalse(ok)
|
|
self.assertIn("no writes", why)
|
|
|
|
def test_drifting_ratio_is_refused_even_when_the_median_looks_fine(self):
|
|
# Median ~1.0 but the ratio swings wildly: the two are tracking
|
|
# different things and happen to cross over. A median-only check would
|
|
# wave this through.
|
|
a = rows({1: 1000, 2: 1000, 3: 1000})
|
|
b = rows({1: 100, 2: 1000, 3: 9000})
|
|
ok, why = FD.coverage_verdict(a, b, [1, 2, 3])
|
|
self.assertFalse(ok)
|
|
self.assertIn("drifts", why)
|
|
|
|
def test_no_overlap_is_not_a_pass(self):
|
|
ok, why = FD.coverage_verdict({}, {}, [])
|
|
self.assertIsNone(ok)
|
|
|
|
|
|
class TestColumns(unittest.TestCase):
|
|
def test_ram_scratchpad_mmio_and_pc_are_kept_separate(self):
|
|
keys = [c[0] for c in FD.COLUMNS]
|
|
self.assertEqual(keys, ["wr", "pc", "mmio", "sp"])
|
|
|
|
def test_every_counted_column_names_its_count_field(self):
|
|
# pc has no count of its own; the rest must carry one or the report
|
|
# cannot show how many writes backed a hash.
|
|
for key, cnt, _ in FD.COLUMNS:
|
|
if key != "pc":
|
|
self.assertIsNotNone(cnt, f"{key} has no count field")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|