Files
psxrecomp/tools/colour_source.py
T
Alex Vanderveen 0b2040a387 colour_source: --diff-span, compare the same address on both emulators
The oracle's $s4 reads 0x800E2C04; psx-runtime's covers
0x800E260C..0x800E2664, about 1.4 KB away. That is suggestive but not
conclusive on its own: the samples came from different moments, and if
$s4 walks a larger table across the effect then a phase difference alone
produces it.

What decides it is whether the CONTENTS differ. --diff-span reads one
range from both emulators and compares the bytes, which is
phase-independent while the table is static: identical data means a
differing $s4 is just a different offset into the same table, while
differing data is a fault upstream of the colour routine entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 12:55:58 -04:00

336 lines
14 KiB
Python

#!/usr/bin/env python3
"""colour_source.py -- find and inspect the table the effect's colours come from.
python3 tools/colour_source.py
Where this sits
---------------
The effect's colours are built at 0x8006844C and friends by:
lwl $t6,-9($s4) / lwr $t6,-12($s4) ; source colour word
swl/swr -> 64($sp) ; staged in scratchpad
lbu 64/65/66($sp) ; mult by $s6 ; sra >>7 ; sb ; scaled
beq $s6,$t3 -> skip the multiply ; at neutral scale, copied verbatim
$s6 has been measured constant within each frame and ramping smoothly
(128,124,120,...), so the scale is not the source of psx-runtime's ~153
distinct vertex colours where DuckStation has 3. At neutral scale the branch
copies the source word straight through, so those 153 values ARE the source
table.
This locates that table by probing $s4, reads it, counts what is actually in
it, and traces which code wrote it. Comparing the same table on the oracle
(--port 4371, read-only) answers whether the data or the code filling it is
at fault.
"""
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 psx_gpu_frame import ( # noqa: E402
DEFAULT_NATIVE_PORT, ORACLE_PAUSED_POLL_S, DebugConn, DebugError,
OracleBreak, oracle_resume, read_ram_range,
)
from scale_within_frame import sample_one_frame # noqa: E402
KIND = "psx-colour-source"
# The source pointer is read as $s4-12 .. $s4-1 (lwr -12, lwl -9 for the first
# of three words), so the table starts before the lowest $s4 seen.
SRC_BACK = 16
def pointer_span(samples, reg="s4", back=SRC_BACK):
"""Address range the source pointer covered, widened to what it reads."""
vals = []
for s in samples:
v = (s.get("regs") or {}).get(reg)
if v:
vals.append(int(v, 16))
if not vals:
return None
return (min(vals) - back, max(vals)), sorted(set(vals))
def colours_in(blob, base, lo, hi):
"""Decode a span of RAM as 24-bit colour words."""
out = collections.Counter()
for a in range(lo & ~3, (hi & ~3) + 4, 4):
off = a - base
if off < 0 or off + 4 > len(blob):
continue
w = int.from_bytes(blob[off:off + 4], "little")
out[(w & 0xFF, (w >> 8) & 0xFF, (w >> 16) & 0xFF)] += 1
return out
def oracle_pointer(conn, pc, reg="s4", tries=12, out=sys.stderr):
"""Read `reg` on the oracle by breaking on `pc`.
DuckStation has pc_break (which captures every GPR) but no pc_probe, so
it cannot be sampled the way psx-runtime is. One hit is enough here: the
question is only WHICH table the pointer names, not how it varies.
The breakpoint is removed and the emulator resumed on every exit path --
a leaked one keeps re-pausing it and outlives the tool that set it.
"""
seen = []
try:
with OracleBreak(conn, pc):
for _ in range(tries):
try:
conn.raw("pc_hit_clear")
except DebugError:
pass
oracle_resume(conn)
time.sleep(ORACLE_PAUSED_POLL_S)
try:
hit = conn.cmd("pc_hit_last")
except DebugError:
continue
if not hit.get("valid"):
continue
v = (hit.get("regs") or {}).get(reg)
if v:
seen.append(int(v, 16))
except DebugError as e:
print(f" oracle probe: {e}", file=out)
return sorted(set(seen))
def verdict_of(n_colours, expect_max=8):
if n_colours == 0:
return ("no-source",
"the source span could not be read, so nothing is decided.")
if n_colours <= expect_max:
return ("source-is-uniform",
f"the source table holds {n_colours} distinct colour word(s) "
f"-- consistent with DuckStation's 3. If the packets still "
f"carry ~153, the fault is between this table and the packet, "
f"not in the table.")
return ("source-is-varied",
f"the source table holds {n_colours} distinct colour words. That "
f"matches what psx-runtime writes into its packets, so the wrong "
f"data is already in the table before the colour routine reads "
f"it -- the fault is in whatever FILLS this table.")
def _save(doc, args):
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}")
def _report(conn, args, doc, span, ptrs):
"""Read the located table, decode it, and report."""
lo, hi = span
doc["span"] = [f"0x{lo:08X}", f"0x{hi:08X}"]
doc["pointer_values"] = [f"0x{v:08X}" for v in ptrs[:32]]
blob = read_ram_range(conn, lo & ~3, ((hi - lo) & ~3) + 64)
cols = colours_in(blob, lo & ~3, lo, hi)
doc["distinct_colours"] = len(cols)
doc["top_colours"] = [[list(c), n] for c, n in cols.most_common(12)]
print(f"\nsource span 0x{lo:08X}..0x{hi:08X}: {len(cols)} distinct "
f"colour word(s)")
for c, n in cols.most_common(10):
print(f" {c} x{n}")
v, why = verdict_of(len(cols))
doc["verdict"], doc["explanation"] = v, why
print(f"\nVERDICT: {v}\n{why}")
if args.trace and v == "source-is-varied":
print(f"\ntracing writes to the table …", flush=True)
try:
conn.cmd("wtrace_reset")
conn.cmd("wtrace_add", lo=f"0x{lo:08X}", hi=f"0x{hi:08X}")
f0 = conn.frame()
conn.cmd("step", n=2)
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("wtrace_dump", addr_lo=f"0x{lo:08X}",
addr_hi=f"0x{hi:08X}", count=8000)
by_pc = collections.Counter(e["pc"] for e in
rep.get("entries", []))
doc["writers"] = [[p, n] for p, n in by_pc.most_common(12)]
if by_pc:
print(" writers:")
for p, n in by_pc.most_common(12):
print(f" {p} x{n}")
else:
print(" no writes in the traced window -- the table may "
"be filled less often than every frame, or copied "
"in by DMA rather than by CPU stores.")
except DebugError as e:
print(f" trace failed: {e}", file=sys.stderr)
_save(doc, args)
return 0
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="0x80068450",
help="the lwl that loads a source colour word")
ap.add_argument("--reg", default="s4")
ap.add_argument("--n", type=int, default=64)
ap.add_argument("--frames", type=int, default=3)
ap.add_argument("--span", default=None, metavar="LO:HI",
help="skip probing and read this range instead, e.g. "
"0x800E260C:0x800E2664. Required for the oracle, "
"which has pc_break but no pc_probe -- and "
"unnecessary to probe there anyway, since the table "
"address is the same in both emulators")
ap.add_argument("--diff-span", default=None, metavar="LO:HI",
help="read this range from BOTH emulators and compare the "
"bytes. Phase-independent when the table is static, "
"which is what decides whether a differing $s4 means "
"different DATA or just a different offset into the "
"same repeating table")
ap.add_argument("--ds-port", type=int, default=4371,
help="oracle port for --diff-span")
ap.add_argument("--oracle-pointer", action="store_true",
help="read $s4 on the oracle via pc_break and report "
"which table it names, to compare with psx-runtime's")
ap.add_argument("--trace", action="store_true",
help="also trace which code writes the table (native only)")
ap.add_argument("--wait-secs", type=float, default=120.0)
ap.add_argument("--out", default="analysis/frames/colour_source.json")
args = ap.parse_args()
pc = int(args.pc, 0)
conn = DebugConn(args.host, args.port, args.timeout)
doc = {"kind": KIND, "version": 1, "pc": args.pc, "port": args.port}
print(f"probing ${args.reg} at {args.pc} — trigger the effect now",
flush=True)
span = None
ptrs = []
deadline = time.monotonic() + args.wait_secs
if args.diff_span:
lo_s, _, hi_s = args.diff_span.partition(":")
lo, hi = int(lo_s, 0), int(hi_s, 0)
length = ((hi - lo) & ~3) + 64
sides = {}
for label, port in (("psx-runtime", DEFAULT_NATIVE_PORT),
("oracle", args.ds_port)):
try:
c = DebugConn(args.host, port, args.timeout)
sides[label] = read_ram_range(c, lo & ~3, length)
except DebugError as e:
print(f" {label}: {e}", file=sys.stderr)
if len(sides) < 2:
print("\nneed both emulators running to compare")
return 1
a, b = sides["psx-runtime"], sides["oracle"]
n = min(len(a), len(b))
diffs = [i for i in range(n) if a[i] != b[i]]
doc["diff_span"] = [f"0x{lo:08X}", f"0x{hi:08X}"]
doc["bytes_differing"] = len(diffs)
print(f"\n0x{lo:08X}..0x{hi:08X}: {len(diffs)}/{n} bytes differ")
for label, blob in (("psx-runtime", a), ("oracle", b)):
cols = colours_in(blob, lo & ~3, lo, hi)
doc[f"colours_{label}"] = [[list(c), k]
for c, k in cols.most_common(8)]
print(f" {label:12s} {len(cols)} distinct colour word(s): "
f"{[list(c) for c, _ in cols.most_common(4)]}")
if diffs:
print(f"\nfirst differing offsets: "
f"{[hex((lo & ~3) + d) for d in diffs[:8]]}")
print("\nThe two emulators hold DIFFERENT data at the same "
"address. That is upstream of the colour routine entirely.")
else:
print("\nThe data is byte-identical, so a differing $s4 is a "
"different OFFSET into the same table -- i.e. the two were "
"sampled at different points of the effect, not a fault.")
_save(doc, args)
return 0
if args.oracle_pointer:
vals = oracle_pointer(conn, args.pc, args.reg)
doc["oracle_pointer_values"] = [f"0x{v:08X}" for v in vals]
if vals:
print(f"\noracle ${args.reg}: {len(vals)} distinct value(s) "
f"0x{min(vals):08X}..0x{max(vals):08X}")
for v in vals[:12]:
print(f" 0x{v:08X}")
print(f"\npsx-runtime reported 0x800E260C..0x800E2664. Same range "
f"means both read the same table, so the colours in the "
f"packets cannot differ because of the source; a different "
f"range means psx-runtime is reading the wrong table, and "
f"THAT is the bug.")
else:
print(f"\nno ${args.reg} captured on the oracle -- the break "
f"never fired inside the effect. Replay it while this runs.")
_save(doc, args)
return 0 if vals else 1
if args.span:
lo_s, _, hi_s = args.span.partition(":")
span = (int(lo_s, 0), int(hi_s, 0))
print(f" reading 0x{span[0]:08X}..0x{span[1]:08X} (given, not probed)",
flush=True)
return _report(conn, args, doc, span, ptrs)
try:
conn.cmd("pause")
for _ in range(args.frames):
if time.monotonic() > deadline:
break
try:
hits, _ = sample_one_frame(conn, pc, args.n)
except DebugError as e:
print(f" probe failed: {e}", file=sys.stderr)
break
got = pointer_span(hits, args.reg)
if not got:
continue
(lo, hi), vals = got
ptrs = vals
span = (lo, hi) if span is None else (min(span[0], lo),
max(span[1], hi))
print(f" ${args.reg} covered 0x{lo:08X}..0x{hi:08X} "
f"({len(vals)} distinct pointer values)", flush=True)
if span is None:
print(f"\n${args.reg} was never captured -- the probe did not "
f"fire inside the effect. Replay it while this runs.")
if args.port != DEFAULT_NATIVE_PORT:
print(f"\nNOTE: the DuckStation oracle has pc_break but no "
f"pc_probe, so it cannot be probed this way at all. "
f"Pass --span LO:HI to read the table directly -- the "
f"address is the same in both emulators, so use the "
f"span psx-runtime reported.")
return 1
return _report(conn, args, doc, span, ptrs)
finally:
try:
conn.cmd("continue")
except DebugError:
pass
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())