mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-28 10:59:16 -04:00
scale_within_frame: check the fade scale is constant across one frame
packet_writers named the guilty code. Six PCs write the additive quads'
colour words, all running the same idiom:
lbu ,64() ; mult , ; mflo ; sra ,,7 ; sb ...
$s6 is the fade level, one value per frame, so every vertex in a frame
must see the same $s6 and the 64 quads should carry a handful of
colours. psx-runtime carries ~153 where DuckStation carries 3. A $s6
that changes WITHIN a frame explains that exactly: same source colours,
same arithmetic, a different scale per vertex.
Unlike every other comparison here this needs no oracle and no phase
lock -- 'constant within a frame' is checkable against one emulator on
its own, which is why it is worth a tool of its own after so many runs
lost to synchronising two.
$s6 is callee-saved and stays live across the swc2/NCLIP at
0x800684C4..0x800684D8, which is where a recompiled routine could lose it.
Ruled out first, by reading the implementations: mult computes eagerly
into cpu->lo and mflo reads it (no deferred-evaluation hazard), sb and
lw both handle scratchpad byte-exactly, and the lwl/lwr/swl/swr tables
are correct for little-endian R3000A.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
"""scale_within_frame.py -- is the fade scale constant across ONE frame?
|
||||
|
||||
python3 tools/scale_within_frame.py
|
||||
|
||||
The effect's colours are built by, per vertex:
|
||||
|
||||
lbu $v0,64($sp) ; source byte
|
||||
mult $v0,$s6 ; * fade scale
|
||||
mflo $t6
|
||||
sra $v0,$t6,7 ; >> 7
|
||||
|
||||
$s6 is the fade level. It is one value for the whole frame -- the effect
|
||||
fades as a unit -- so every vertex in a frame must see the SAME $s6, and the
|
||||
64 additive quads should carry only a handful of distinct colours. psx-runtime
|
||||
carries about 153 where DuckStation carries 3.
|
||||
|
||||
If $s6 changes WITHIN a frame on psx-runtime, that alone explains it: same
|
||||
source colours, same arithmetic, a different scale per vertex. And unlike
|
||||
every other comparison in this investigation, it needs no oracle and no phase
|
||||
matching -- "constant within a frame" is a property of one emulator, checkable
|
||||
against itself.
|
||||
|
||||
$s6 is callee-saved and stays live across the GTE ops in this routine
|
||||
(swc2 / NCLIP at 0x800684C4..0x800684D8), which is exactly where a
|
||||
recompiled routine could lose it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from probe_regs import search_windows # noqa: E402
|
||||
from psx_gpu_frame import DEFAULT_NATIVE_PORT, DebugConn, DebugError # noqa: E402
|
||||
|
||||
KIND = "psx-scale-within-frame"
|
||||
|
||||
|
||||
def distinct_scales(samples, reg="s6"):
|
||||
"""Distinct values of `reg` across samples taken inside one frame."""
|
||||
vals = collections.Counter()
|
||||
for s in samples:
|
||||
v = (s.get("regs") or {}).get(reg)
|
||||
if v:
|
||||
vals[int(v, 16)] += 1
|
||||
return vals
|
||||
|
||||
|
||||
def verdict_of(per_frame, reg="s6"):
|
||||
"""per_frame: list of Counters, one per frame sampled."""
|
||||
live = [c for c in per_frame if c]
|
||||
if not live:
|
||||
return ("no-samples",
|
||||
f"${reg} was never captured -- the probe did not fire inside "
|
||||
f"the effect. Replay it while this runs.")
|
||||
worst = max(live, key=len)
|
||||
if len(worst) == 1:
|
||||
return ("constant-within-frame",
|
||||
f"${reg} held a single value in every frame sampled "
|
||||
f"({len(live)} frame(s)). The scale is not the source of the "
|
||||
f"extra colour variety; look at the source bytes instead.")
|
||||
return ("varies-within-frame",
|
||||
f"${reg} took {len(worst)} DIFFERENT values inside one frame "
|
||||
f"({sorted(worst)[:8]}{' …' if len(worst) > 8 else ''}). The fade "
|
||||
f"is one level per frame, so every vertex should see the same "
|
||||
f"value. A per-vertex scale multiplies the same source colours by "
|
||||
f"different amounts -- which is exactly the extra colour variety "
|
||||
f"psx-runtime shows and DuckStation does not.")
|
||||
|
||||
|
||||
def sample_one_frame(conn, pc, n, leader=None):
|
||||
"""Arm at `pc`, advance exactly one frame, return the probe samples."""
|
||||
windows = search_windows(pc, 0x400)
|
||||
cands = [leader] if leader else windows[0]
|
||||
conn.cmd("pc_probe_clear")
|
||||
conn.cmd("pc_probe_arm", n=n,
|
||||
pcs=",".join(f"0x{c:08X}" for c in cands))
|
||||
f0 = conn.frame()
|
||||
conn.cmd("step", n=1)
|
||||
for _ in range(200):
|
||||
st = conn.raw("pause_state")
|
||||
if st.get("paused") and conn.frame() > f0:
|
||||
break
|
||||
time.sleep(0.02)
|
||||
rep = conn.cmd("pc_probe_dump")
|
||||
hits = [s for s in rep.get("samples", []) if s.get("regs")]
|
||||
return hits, rep
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--host", default="127.0.0.1")
|
||||
ap.add_argument("--port", type=int, default=DEFAULT_NATIVE_PORT)
|
||||
ap.add_argument("--timeout", type=float, default=30.0)
|
||||
ap.add_argument("--pc", default="0x80068474",
|
||||
help="the multiply that scales a source byte by $s6")
|
||||
ap.add_argument("--reg", default="s6")
|
||||
ap.add_argument("--n", type=int, default=64,
|
||||
help="probe slots per frame (64 quads x 4 verts = 256)")
|
||||
ap.add_argument("--frames", type=int, default=6,
|
||||
help="frames to sample; each is checked on its own")
|
||||
ap.add_argument("--wait-secs", type=float, default=120.0)
|
||||
ap.add_argument("--out", default="analysis/frames/scale_within_frame.json")
|
||||
args = ap.parse_args()
|
||||
|
||||
pc = int(args.pc, 0)
|
||||
conn = DebugConn(args.host, args.port, args.timeout)
|
||||
print(f"sampling ${args.reg} at {args.pc} — trigger the effect now",
|
||||
flush=True)
|
||||
|
||||
per_frame = []
|
||||
raw = []
|
||||
deadline = time.monotonic() + args.wait_secs
|
||||
try:
|
||||
conn.cmd("pause")
|
||||
while len(per_frame) < args.frames and time.monotonic() < deadline:
|
||||
try:
|
||||
hits, rep = sample_one_frame(conn, pc, args.n)
|
||||
except DebugError as e:
|
||||
print(f" probe failed: {e}", file=sys.stderr)
|
||||
break
|
||||
vals = distinct_scales(hits, args.reg)
|
||||
if not vals:
|
||||
continue
|
||||
per_frame.append(vals)
|
||||
raw.append({"distinct": len(vals),
|
||||
"values": {str(k): v for k, v in vals.items()}})
|
||||
print(f" frame {len(per_frame)}: {len(hits)} samples, "
|
||||
f"{len(vals)} distinct ${args.reg} "
|
||||
f"{sorted(vals)[:8]}", flush=True)
|
||||
finally:
|
||||
try:
|
||||
conn.cmd("continue")
|
||||
except DebugError:
|
||||
pass
|
||||
|
||||
v, why = verdict_of(per_frame, args.reg)
|
||||
doc = {"kind": KIND, "version": 1, "pc": args.pc, "reg": args.reg,
|
||||
"frames": raw, "verdict": v, "explanation": why}
|
||||
print(f"\nVERDICT: {v}\n{why}")
|
||||
os.makedirs(os.path.dirname(args.out) or ".", exist_ok=True)
|
||||
with open(args.out, "w") as f:
|
||||
json.dump(doc, f, indent=1)
|
||||
print(f"\nreport: {args.out}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Tests for the within-frame fade-scale check.
|
||||
|
||||
This is the one comparison in this investigation that needs no oracle and no
|
||||
phase matching: the fade is one level per frame, so $s6 constant within a
|
||||
frame is a property psx-runtime can be checked against on its own.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import scale_within_frame as swf # noqa: E402
|
||||
|
||||
|
||||
def sample(v):
|
||||
return {"regs": {"s6": f"0x{v:X}"}}
|
||||
|
||||
|
||||
class DistinctTest(unittest.TestCase):
|
||||
def test_counts_distinct_values(self):
|
||||
c = swf.distinct_scales([sample(128), sample(128), sample(54)])
|
||||
self.assertEqual(len(c), 2)
|
||||
self.assertEqual(c[128], 2)
|
||||
|
||||
def test_ignores_samples_without_regs(self):
|
||||
c = swf.distinct_scales([{"regs": {}}, {}, sample(80)])
|
||||
self.assertEqual(len(c), 1)
|
||||
|
||||
def test_other_register_selectable(self):
|
||||
s = [{"regs": {"s4": "0x1F800234"}}]
|
||||
self.assertEqual(len(swf.distinct_scales(s, "s4")), 1)
|
||||
|
||||
|
||||
class VerdictTest(unittest.TestCase):
|
||||
def test_constant_is_reported_and_redirects(self):
|
||||
v, why = swf.verdict_of([swf.distinct_scales([sample(128)] * 20)])
|
||||
self.assertEqual(v, "constant-within-frame")
|
||||
self.assertIn("source bytes", why)
|
||||
|
||||
def test_varying_within_one_frame_is_the_finding(self):
|
||||
frame = swf.distinct_scales([sample(x) for x in (128, 96, 54, 12)])
|
||||
v, why = swf.verdict_of([frame])
|
||||
self.assertEqual(v, "varies-within-frame")
|
||||
self.assertIn("4 DIFFERENT", why)
|
||||
|
||||
def test_worst_frame_drives_the_verdict(self):
|
||||
good = swf.distinct_scales([sample(128)] * 10)
|
||||
bad = swf.distinct_scales([sample(x) for x in (128, 96, 54)])
|
||||
v, _ = swf.verdict_of([good, bad])
|
||||
self.assertEqual(v, "varies-within-frame")
|
||||
|
||||
def test_no_samples_refuses(self):
|
||||
v, why = swf.verdict_of([])
|
||||
self.assertEqual(v, "no-samples")
|
||||
self.assertIn("did not fire", why)
|
||||
|
||||
def test_empty_frames_ignored_not_counted_as_constant(self):
|
||||
v, _ = swf.verdict_of([swf.distinct_scales([])])
|
||||
self.assertEqual(v, "no-samples")
|
||||
Reference in New Issue
Block a user