mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-28 19:07:47 -04:00
0b2040a387
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>
336 lines
14 KiB
Python
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())
|