mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-28 19:07:47 -04:00
64141b77d9
147 discarded, 0 accepted, while the walked lists plainly contained PolyG4+semi|B+F x144 -- the very quads being looked for. The 90% node count re-check rejects every frame in which the list changes size, and during an animation that is every frame. This is the THIRD second-read test to fail the same way: byte identity, then list shape, now node count. Each was narrower than the last and each discarded exactly the frames it existed to capture. The pattern is the problem, not the threshold -- any comparison between two reads taken a moment apart rejects a list that is being rebuilt, which is what a list being drawn looks like. A 256 KB scoped read is ~16 chunks, comparable to a single frame, so the snapshot IS a coherent picture and needs no second opinion. Unscoped reads keep the existing check. Also survives an emulator that is not running instead of raising ConnectionRefusedError out of main. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
170 lines
6.3 KiB
Python
170 lines
6.3 KiB
Python
"""Tests for walk_side's coherence check.
|
|
|
|
Reading a display list out of a RUNNING emulator can catch the game
|
|
mid-rebuild. A torn read does not look like an error -- it looks like a frame
|
|
of primitives carrying a handful of near-identical colours, which is
|
|
indistinguishable from a real finding. It must be detected, and detecting it
|
|
must not require pausing the emulator: parking DuckStation for every sample
|
|
made it stutter through the very animation it was watching.
|
|
"""
|
|
import io
|
|
import os
|
|
import sys
|
|
import unittest
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
import gpu_display_list as gdl # noqa: E402
|
|
|
|
|
|
class RecordingConn:
|
|
"""Serves RAM reads from a script of successive memory states."""
|
|
|
|
def __init__(self, states):
|
|
self.states = list(states)
|
|
self.reads = 0
|
|
self.commands = []
|
|
|
|
def cmd(self, name, **kw):
|
|
self.commands.append(name)
|
|
if name == "frame":
|
|
return {"frame": 1}
|
|
return {}
|
|
|
|
def raw(self, name, **kw):
|
|
self.commands.append(name)
|
|
return {}
|
|
|
|
def frame(self):
|
|
return 1
|
|
|
|
def read(self):
|
|
"""Each read consumes the next state; the last one repeats."""
|
|
self.reads += 1
|
|
return self.states[min(self.reads - 1, len(self.states) - 1)]
|
|
|
|
|
|
class ListShapeTest(unittest.TestCase):
|
|
"""Structure, not bytes.
|
|
|
|
Byte-identity was the obvious coherence check and it is exactly wrong:
|
|
while the effect plays the game rewrites the list every frame, so two
|
|
consecutive reads never match and every ANIMATED frame is discarded as
|
|
torn -- keeping only the frames without the animation in them. That is
|
|
what made a real run report '172 walks read, none held additive quads'
|
|
after the animation had been played several times.
|
|
"""
|
|
|
|
def entries(self, ops):
|
|
return [{"op": op, "n_words": n} for op, n in ops]
|
|
|
|
def test_same_shape_when_only_data_changed(self):
|
|
a = self.entries([(0x38, 8), (0x30, 6)])
|
|
b = self.entries([(0x38, 8), (0x30, 6)])
|
|
self.assertEqual(gdl._list_shape(a), gdl._list_shape(b))
|
|
|
|
def test_differs_when_an_entry_is_lost(self):
|
|
a = self.entries([(0x38, 8), (0x30, 6)])
|
|
b = self.entries([(0x38, 8)])
|
|
self.assertNotEqual(gdl._list_shape(a), gdl._list_shape(b))
|
|
|
|
def test_differs_when_a_length_is_malformed(self):
|
|
"""A torn read shows up as a word count that no longer fits its op."""
|
|
a = self.entries([(0x38, 8)])
|
|
b = self.entries([(0x38, 3)])
|
|
self.assertNotEqual(gdl._list_shape(a), gdl._list_shape(b))
|
|
|
|
def test_differs_when_an_opcode_changes(self):
|
|
a = self.entries([(0x38, 8)])
|
|
b = self.entries([(0x30, 8)])
|
|
self.assertNotEqual(gdl._list_shape(a), gdl._list_shape(b))
|
|
|
|
def test_empty_lists_compare_equal(self):
|
|
self.assertEqual(gdl._list_shape([]), gdl._list_shape([]))
|
|
|
|
def test_walk_side_accepts_park_for_reread_flag(self):
|
|
"""The flag exists and defaults to parking for CLI callers."""
|
|
import inspect
|
|
sig = inspect.signature(gdl.walk_side)
|
|
self.assertIn("park_for_reread", sig.parameters)
|
|
self.assertTrue(sig.parameters["park_for_reread"].default)
|
|
|
|
def test_verify_branch_does_not_compare_two_reads(self):
|
|
"""Guard against reintroducing either rejected-everything filter.
|
|
|
|
Byte-identity rejected every frame in which a colour changed; whole
|
|
list-shape equality rejected every frame in which the list changed
|
|
size. During an animation that is all of them, so both kept only the
|
|
frames without the animation and called the rest corruption.
|
|
"""
|
|
src = inspect_source(gdl.walk_side)
|
|
verify = src.split("else:\n # Read once", 1)[1]
|
|
verify = verify.split('meta["coherent"] = coherent', 1)[0]
|
|
self.assertNotIn("if first == second:", verify)
|
|
self.assertNotIn("_list_shape(", verify)
|
|
self.assertIn("_accept(first)", verify)
|
|
|
|
def test_single_read_in_the_verify_branch(self):
|
|
"""One read, not two: the walk's own validation is the check."""
|
|
src = inspect_source(gdl.walk_side)
|
|
verify = src.split("else:\n # Read once", 1)[1]
|
|
verify = verify.split('meta["coherent"] = coherent', 1)[0]
|
|
self.assertEqual(verify.count("read_ram_range("), 1)
|
|
|
|
def test_non_parking_path_never_pauses(self):
|
|
"""With park_for_reread=False no pause/continue may be issued.
|
|
|
|
Asserted against the source of the branch rather than a live socket:
|
|
the whole point is that this path issues no pause at all.
|
|
"""
|
|
src = inspect_source(gdl.walk_side)
|
|
marker = "if park_for_reread:"
|
|
self.assertIn(marker, src)
|
|
after = src.split("else:\n # Read once", 1)[1]
|
|
# The verify branch runs up to the end of the coherence block.
|
|
verify_branch = after.split('meta["coherent"] = coherent', 1)[0]
|
|
self.assertNotIn('conn.cmd("pause")', verify_branch)
|
|
self.assertNotIn('conn.cmd("continue")', verify_branch)
|
|
|
|
def test_torn_is_flagged_in_meta(self):
|
|
src = inspect_source(gdl.walk_side)
|
|
self.assertIn('meta["torn"] = True', src)
|
|
|
|
|
|
def inspect_source(fn):
|
|
import inspect
|
|
return inspect.getsource(fn)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|
|
|
|
|
|
class ScopedReadTest(unittest.TestCase):
|
|
"""A scoped read must not be re-read to 'verify' it.
|
|
|
|
Three second-read tests were tried and each rejected every frame in which
|
|
the list changed -- byte identity, list shape, then node count. During an
|
|
animation that is every frame; the last discarded 147 of 147 reads while
|
|
the walked lists plainly contained the quads being looked for.
|
|
"""
|
|
|
|
SRC = None
|
|
|
|
def setUp(self):
|
|
import inspect
|
|
self.SRC = inspect.getsource(gdl.walk_side)
|
|
|
|
def test_scoped_span_short_circuits_the_recheck(self):
|
|
self.assertIn("if ram_span and entries:", self.SRC)
|
|
branch = self.SRC.split("if ram_span and entries:", 1)[1]
|
|
branch = branch.split("elif", 1)[0]
|
|
self.assertIn('meta["coherent"] = True', branch)
|
|
self.assertNotIn("read_ram_range(", branch)
|
|
|
|
def test_unscoped_path_still_checks(self):
|
|
self.assertIn("elif not meta.get(\"paused\") and entries:", self.SRC)
|
|
|
|
def test_coherence_reason_is_recorded(self):
|
|
self.assertIn("scoped-single-read", self.SRC)
|