mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-28 10:59:16 -04:00
106845956f
Run with --reg v0 it reported 'varies-within-frame' for a colour computation that is provably correct. The probe arms a WINDOW of block leaders rather than one instruction, so the six values it counted came from six different PCs -- and they decode exactly right for source (248,136,8) at $s6=80: 248*80>>7 = 155, 136*80>>7 = 85, 8*80>>7 = 5, and the assembled word 939873691 = 0x3805559B carries precisely those bytes under command 0x38. 136 and 8 are the source bytes mid-load. The test is only meaningful for a value that should be fixed for the whole frame -- the callee-saved fade scale it was written for. For a scratch register, counting across PCs measures the program running. Scratch registers now return not-applicable, and --per-pc breaks values down by instruction so each can be read on its own. The oracle's source table also came back byte-identical to psx-runtime's: same 3 words, same counts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
198 lines
7.8 KiB
Python
198 lines
7.8 KiB
Python
#!/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 by_pc(samples, reg):
|
|
"""Values of `reg` grouped by the PC that was sampled.
|
|
|
|
The probe arms a WINDOW of candidate block leaders, not one instruction,
|
|
so samples come from several PCs. For a callee-saved register like $s6
|
|
that does not matter -- it holds one value throughout. For a scratch
|
|
register like $v0 it matters completely: $v0 legitimately holds a
|
|
different intermediate at every instruction, and counting across PCs
|
|
reports variation that is just the program running.
|
|
"""
|
|
out = collections.defaultdict(collections.Counter)
|
|
for s in samples:
|
|
v = (s.get("regs") or {}).get(reg)
|
|
if v:
|
|
out[s.get("pc", "?")][int(v, 16)] += 1
|
|
return out
|
|
|
|
|
|
SCRATCH = {"v0", "v1", "a0", "a1", "a2", "a3",
|
|
"t0", "t1", "t2", "t3", "t4", "t5", "t6", "t7", "t8", "t9"}
|
|
|
|
|
|
def verdict_of(per_frame, reg="s6"):
|
|
"""per_frame: list of Counters, one per frame sampled."""
|
|
if reg in SCRATCH:
|
|
return ("not-applicable",
|
|
f"${reg} is a scratch register: it holds a different "
|
|
f"intermediate at each instruction, and the probe samples a "
|
|
f"WINDOW of block leaders rather than one PC, so counting "
|
|
f"distinct values across them measures the program running, "
|
|
f"not a fault. Use --per-pc to see each instruction "
|
|
f"separately; this constant-within-frame test is only "
|
|
f"meaningful for a value that should be fixed for the whole "
|
|
f"frame, such as the callee-saved fade scale $s6.")
|
|
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("--per-pc", action="store_true",
|
|
help="break the values down by the PC sampled -- required "
|
|
"to read anything into a scratch register")
|
|
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)
|
|
grouped = by_pc(hits, args.reg)
|
|
raw.append({"distinct": len(vals),
|
|
"values": {str(k): v for k, v in vals.items()},
|
|
"by_pc": {p: sorted(c) for p, c in grouped.items()}})
|
|
print(f" frame {len(per_frame)}: {len(hits)} samples, "
|
|
f"{len(vals)} distinct ${args.reg} "
|
|
f"{sorted(vals)[:8]}", flush=True)
|
|
if args.per_pc:
|
|
for p, c in sorted(grouped.items()):
|
|
print(f" {p}: {sorted(c)}", 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())
|