mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-28 02:56:36 -04:00
1c1281e455
The fallback worked — psx-runtime produced 12 samples free-running. The oracle still reported "PolyG4+semi never appeared within 120s. Drawing instead: nothing", and the cost of the check is why. class_on_screen() snapshots all 2 MB of RAM: 128 read_ram calls. Against a PAUSED oracle, whose socket falls back to a ~1 Hz idle timer, that is 128 seconds for ONE poll. A 120-second wait therefore completes a single partial read and reports "nothing" — not a statement about what is drawing, a statement about a read that never finished. The oracle was very likely parked from an earlier breakpoint run, which nothing in the wait undid. So the wait now resumes first, pays for the full scan once, and then re-reads only the span the display list occupies — a couple of KB, one or two reads instead of 128. A cached root that stops walking means the list moved, and the next poll pays for a rescan rather than sticking to a dead pointer. A `check` seam lets a caller supply its own probe, which is also how this is tested without standing up a synthetic display list in fake RAM. Recorded for the next run: psx-runtime's series came from the free-running fallback, so its steps (median 12, max 64) are ALIASED — samples ~90 frames apart — and are not an animation increment. native_sampling says so in the report, and the per-frame verdict is suppressed when the fallback ran. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
149 lines
5.6 KiB
Python
149 lines
5.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Waiting for the effect instead of racing it.
|
|
|
|
Every tool here needs the effect RUNNING — a write trace over its packets, a
|
|
block probe at one of its instructions, a walk of the list it builds. Requiring
|
|
it to be on screen at the instant a button is clicked means racing a transient
|
|
animation, and losing that race produces a different-looking failure in each
|
|
tool: "no writes recorded", "no candidate fired", "no table found", "the block
|
|
was not reached". Four symptoms, one cause, none of them naming it.
|
|
"""
|
|
|
|
import importlib.util
|
|
import io
|
|
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
|
|
|
|
|
|
GF = _load("psx_gpu_frame")
|
|
|
|
|
|
class Appears:
|
|
"""class_on_screen stand-in: absent for `after` calls, then present."""
|
|
|
|
def __init__(self, after=2, drawing=None):
|
|
self.calls = 0
|
|
self.after = after
|
|
self.drawing = drawing or {"PolyFT4": 584}
|
|
|
|
def __call__(self, conn, op):
|
|
self.calls += 1
|
|
if self.calls > self.after:
|
|
return True, dict(self.drawing, **{op: 64})
|
|
return False, self.drawing
|
|
|
|
|
|
class TestWaitForClass(unittest.TestCase):
|
|
def test_it_returns_as_soon_as_the_class_appears(self):
|
|
on, drawing = GF.wait_for_class(None, "PolyG4+semi", timeout=5, poll=0,
|
|
check=Appears(after=2))
|
|
self.assertTrue(on)
|
|
self.assertEqual(drawing["PolyG4+semi"], 64)
|
|
|
|
def test_it_gives_up_and_reports_what_WAS_drawing(self):
|
|
# The failure has to name what was on screen instead, or the reader
|
|
# cannot tell "wrong scene" from "tool is broken".
|
|
on, drawing = GF.wait_for_class(None, "PolyG4+semi", timeout=0.05,
|
|
poll=0.01, check=Appears(after=10**6))
|
|
self.assertFalse(on)
|
|
self.assertIn("PolyFT4", drawing)
|
|
|
|
def test_an_already_present_class_does_not_wait(self):
|
|
on, _ = GF.wait_for_class(None, "PolyG4+semi", timeout=5, poll=99,
|
|
check=Appears(after=0))
|
|
self.assertTrue(on, "should not have slept at all")
|
|
|
|
def test_a_transient_debug_error_does_not_abort_the_wait(self):
|
|
# The emulator can be mid-pause while polling; one failed poll is not a
|
|
# reason to stop waiting for an effect that has not happened yet.
|
|
calls = {"n": 0}
|
|
|
|
def flaky(conn, op):
|
|
calls["n"] += 1
|
|
if calls["n"] == 1:
|
|
raise GF.DebugError("read_ram: timed out")
|
|
return True, {op: 64}
|
|
|
|
on, _ = GF.wait_for_class(None, "PolyG4+semi", timeout=5, poll=0,
|
|
check=flaky)
|
|
self.assertTrue(on)
|
|
|
|
def test_it_tells_the_operator_to_trigger_the_effect(self):
|
|
buf = io.StringIO()
|
|
GF.wait_for_class(None, "PolyG4+semi", timeout=5, poll=0, out=buf,
|
|
check=Appears(after=3))
|
|
self.assertIn("trigger the effect", buf.getvalue())
|
|
|
|
def test_the_prompt_is_printed_once_not_per_poll(self):
|
|
buf = io.StringIO()
|
|
GF.wait_for_class(None, "PolyG4+semi", timeout=5, poll=0, out=buf,
|
|
check=Appears(after=5))
|
|
self.assertEqual(buf.getvalue().count("trigger the effect"), 1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|
|
|
|
|
|
class TestWaitIsAffordable(unittest.TestCase):
|
|
"""The cost of the check is the whole design problem.
|
|
|
|
class_on_screen() snapshots all 2 MB of RAM — 128 read_ram calls. Against a
|
|
PAUSED oracle, whose socket falls back to a ~1 Hz idle timer, that is 128
|
|
seconds for ONE poll. A 120-second wait then completes a single partial read
|
|
and reports "Drawing instead: nothing", which is exactly what it did.
|
|
"""
|
|
|
|
class CountingConn:
|
|
def __init__(self, present_after=1):
|
|
self.reads = 0
|
|
self.calls = []
|
|
self.present_after = present_after
|
|
|
|
def cmd(self, name, **kw):
|
|
self.calls.append(name)
|
|
if name == "read_ram":
|
|
self.reads += 1
|
|
n = int(kw["len"])
|
|
return {"ok": True, "hex": ("00" * n)}
|
|
return {"ok": True}
|
|
|
|
def raw(self, name, **kw):
|
|
return self.cmd(name, **kw)
|
|
|
|
def test_it_resumes_before_polling(self):
|
|
# A tool that parked the emulator earlier is the usual reason it is
|
|
# answering at 1 Hz, and nothing else works at that rate.
|
|
c = self.CountingConn()
|
|
GF.wait_for_class(c, "PolyG4+semi", timeout=0.05, poll=0.01)
|
|
self.assertEqual(c.calls[0], "continue")
|
|
|
|
def test_a_full_scan_is_not_repeated_when_a_root_is_known(self):
|
|
# The first poll pays for 2 MB; later polls must not. This asserts the
|
|
# cheap path exists in the source, since exercising it needs a real
|
|
# display list in the fake RAM.
|
|
import inspect
|
|
src = inspect.getsource(GF.wait_for_class)
|
|
self.assertIn("read_ram_range", src)
|
|
self.assertIn("root = span = None", src,
|
|
"a stale root must trigger a rescan, not stick forever")
|
|
|
|
def test_the_seam_bypasses_the_scan_entirely(self):
|
|
c = self.CountingConn()
|
|
on, _ = GF.wait_for_class(c, "X", timeout=1, poll=0,
|
|
check=lambda conn, op: (True, {op: 1}))
|
|
self.assertTrue(on)
|
|
self.assertEqual(c.reads, 0, "the seam must not fall through to a scan")
|