From 85bb9304db976ccff587eeb6891b6be97f098f09 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Sun, 23 Aug 2026 03:12:54 -0400 Subject: [PATCH] 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) --- tools/scale_within_frame.py | 157 +++++++++++++++++++++++++ tools/tests/test_scale_within_frame.py | 60 ++++++++++ 2 files changed, 217 insertions(+) create mode 100644 tools/scale_within_frame.py create mode 100644 tools/tests/test_scale_within_frame.py diff --git a/tools/scale_within_frame.py b/tools/scale_within_frame.py new file mode 100644 index 00000000..5629d18d --- /dev/null +++ b/tools/scale_within_frame.py @@ -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()) diff --git a/tools/tests/test_scale_within_frame.py b/tools/tests/test_scale_within_frame.py new file mode 100644 index 00000000..9d6ee113 --- /dev/null +++ b/tools/tests/test_scale_within_frame.py @@ -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")