mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-28 10:59:16 -04:00
d40eebd3e5
The tool ran clean and reported "source-differs". The report was wrong, and the
data it printed is what shows why.
The oracle's half is solid and self-confirming: its table at 0x000E4BF8 holds
(248,80,80) and (8,8,8), and scaled by the recorded factor of 126 those give
(244,78,78) and (7,7,7) -- the colours in its own display list. That is the
colour table, verified by reproducing its own output rather than by assumption.
psx-runtime's bytes at that same address are not a colour table at all: 24 of
24 odd bytes are zero, so it is an array of 16-bit values. Two different
structures, not one structure with different contents.
The assumption underneath was that the table sits at the same address on both,
and it does not. Measured in the same report: the oracle's packet pointer was
0x8010D9A0 while psx-runtime's packets were at 0x00115078..0x00115974. The two
allocators place the same structures at different addresses, so reading the
oracle's pointer on psx-runtime reads unrelated memory -- and the difference
reported was entirely the tool's own doing.
The table is now LOCATED rather than assumed: search psx-runtime's RAM for the
oracle's actual bytes. Three outcomes instead of two, and the new one is the
most informative:
* found, identical -> input fine; scale or arithmetic is at fault
* found, differing -> the table itself diverged
* not found anywhere -> psx-runtime never built this table at all, so the
divergence is upstream of the colour routine
A structural check refuses to read a 16-bit array as a colour table, and a
needle shorter than 12 bytes is refused because a short match in 2 MB is
meaningless and would return an arbitrary address.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
272 lines
11 KiB
Python
272 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""colour_inputs.py -- compare the INPUTS to a packet's colour computation.
|
|
|
|
python3 colour_inputs.py --pc 0x8006844C
|
|
|
|
What this settles
|
|
-----------------
|
|
The display-list comparison showed the two emulators build identical geometry
|
|
and different vertex colours. packet_writers then found the code, and it
|
|
computes every colour the same way:
|
|
|
|
lwl/lwr $t6, ($s4-12) load the source RGB
|
|
mult $v0, $s6 scale it
|
|
sra $v0, 7 >> 7
|
|
sw $v0, n($a3) store into the packet
|
|
|
|
So a wrong colour has exactly two possible causes: the SOURCE RGB differs, or
|
|
the SCALE differs. This reads both and says which.
|
|
|
|
How, given only one side can break on a PC
|
|
------------------------------------------
|
|
The DuckStation oracle supports pc_break and captures all 32 GPRs on hit;
|
|
psx-runtime has no equivalent (its pc_probe fires at basic-block leaders with
|
|
a fixed set of registers, and adding a real one means emitting per-instruction
|
|
hooks from the recompiler).
|
|
|
|
That asymmetry does not matter here, because the register we need from the
|
|
oracle is a POINTER. Once it reports $s4, the source table's address is known
|
|
-- and the same address can be read on BOTH emulators with read_ram, which
|
|
both support. The comparison that matters is of memory, not registers.
|
|
|
|
What the answer means:
|
|
* source bytes differ -> the divergence is upstream, in whatever fills that
|
|
table; the colour code is faithfully scaling bad
|
|
input.
|
|
* source bytes match -> the input is fine, so the scale $s6 or the
|
|
arithmetic differs, and the fault is in this code.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
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_DUCKSTATION_PORT, DEFAULT_NATIVE_PORT, ORACLE_PAUSED_POLL_S,
|
|
DebugConn, DebugError, oracle_resume, read_ram_range, snapshot_ram,
|
|
)
|
|
|
|
KIND = "psx-colour-inputs"
|
|
SRC_OFFSET = -12 # lwr $t6,-12($s4): the source word's base
|
|
DEFAULT_SPAN = 48 # enough to see the table around it
|
|
|
|
|
|
def looks_like_triplets(b, stride=4):
|
|
"""Does this block look like packed RGB entries rather than 16-bit values?
|
|
|
|
A colour table here is RGB triplets on a 4-byte stride. An array of 16-bit
|
|
values has a zero in every odd byte, which is how the first version of this
|
|
tool was caught reading something that was not a colour table at all --
|
|
while confidently reporting the two emulators disagreed about one.
|
|
"""
|
|
if len(b) < 8:
|
|
return False
|
|
odd_zero = sum(1 for i, x in enumerate(b) if i % 2 and x == 0)
|
|
if odd_zero >= (len(b) // 2) * 0.9:
|
|
return False # 16-bit array, not triplets
|
|
return True
|
|
|
|
|
|
def find_table(ram, needle, min_len=12):
|
|
"""Where does this exact byte run appear in a RAM image?
|
|
|
|
Address correspondence between the two emulators cannot be assumed: their
|
|
allocators land the same structures at different addresses (measured -- the
|
|
oracle's packet buffer at 0x10D9A0 against psx-runtime's 0x115078). Reading
|
|
the oracle's pointer on psx-runtime therefore reads unrelated memory, and
|
|
comparing it produces a difference that is entirely the tool's own doing.
|
|
|
|
So the table is FOUND rather than assumed: search for the bytes themselves.
|
|
"""
|
|
if len(needle) < min_len:
|
|
return []
|
|
hits, at = [], 0
|
|
while True:
|
|
i = ram.find(needle, at)
|
|
if i < 0:
|
|
return hits
|
|
hits.append(i)
|
|
at = i + 4
|
|
if len(hits) >= 8:
|
|
return hits
|
|
|
|
|
|
def wait_for_hit(conn, pc, timeout, poll=ORACLE_PAUSED_POLL_S):
|
|
"""Arm a PC breakpoint on the oracle and wait for it to fire.
|
|
|
|
Paced for a PAUSED oracle, not a running one. The breakpoint pauses
|
|
DuckStation when it fires, which drops its debug socket to the Qt idle
|
|
timer -- about 1 Hz with no gamepad attached. Polling every 100 ms then
|
|
stacks up connections it cannot accept, and the failure surfaces as
|
|
"server closed without replying", which does not read like a pacing
|
|
problem at all.
|
|
|
|
Resumes first, because a previous run that parked the oracle and exited
|
|
without resuming leaves it crippled for every attempt after it.
|
|
"""
|
|
oracle_resume(conn)
|
|
for cmd in ("pc_hit_clear",):
|
|
try:
|
|
conn.cmd(cmd)
|
|
except DebugError:
|
|
pass # not fatal; the arm below is what matters
|
|
conn.cmd("pc_break", addr=pc)
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
try:
|
|
rep = conn.cmd("pc_hit_last")
|
|
except DebugError:
|
|
# A hit parks the oracle mid-exchange, so one dropped reply here is
|
|
# expected rather than fatal.
|
|
time.sleep(poll)
|
|
continue
|
|
if rep.get("valid"):
|
|
return rep
|
|
time.sleep(poll)
|
|
return None
|
|
|
|
|
|
def main(argv=None):
|
|
ap = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("--host", default="127.0.0.1")
|
|
ap.add_argument("--native-port", type=int, default=DEFAULT_NATIVE_PORT)
|
|
ap.add_argument("--ds-port", type=int, default=DEFAULT_DUCKSTATION_PORT)
|
|
ap.add_argument("--pc", required=True,
|
|
help="a colour-writing PC, from the Shading pane")
|
|
ap.add_argument("--ptr-reg", default="s4",
|
|
help="register holding the source pointer")
|
|
ap.add_argument("--scale-reg", default="s6")
|
|
ap.add_argument("--span", type=int, default=DEFAULT_SPAN)
|
|
ap.add_argument("--wait", type=float, default=45.0,
|
|
help="seconds to wait for the PC to be reached. "
|
|
"A paused oracle answers about once a second, so this needs headroom.")
|
|
ap.add_argument("--timeout", type=float, default=60.0)
|
|
ap.add_argument("--json", default=None)
|
|
args = ap.parse_args(argv)
|
|
|
|
pc = int(args.pc, 16)
|
|
doc = {"kind": KIND, "version": 1, "pc": f"0x{pc:08X}",
|
|
"ptr_reg": args.ptr_reg, "scale_reg": args.scale_reg}
|
|
|
|
ds = DebugConn(args.host, args.ds_port, args.timeout)
|
|
native = DebugConn(args.host, args.native_port, args.timeout)
|
|
try:
|
|
print(f"arming the oracle at 0x{pc:08X} …")
|
|
hit = wait_for_hit(ds, pc, args.wait)
|
|
if not hit:
|
|
msg = (f"the oracle never reached 0x{pc:08X} within {args.wait:.0f}s. "
|
|
f"That PC is OVERLAY code — it only exists while the right "
|
|
f"overlay is resident, so the oracle has to be at the same "
|
|
f"point in the game.")
|
|
print(f"error: {msg}", file=sys.stderr)
|
|
doc["error"] = msg
|
|
raise SystemExit(_finish(doc, args, 1))
|
|
regs = hit.get("regs", {})
|
|
ptr = int(regs.get(args.ptr_reg, "0x0"), 16)
|
|
scale = int(regs.get(args.scale_reg, "0x0"), 16)
|
|
doc["oracle_regs"] = {k: regs.get(k) for k in
|
|
(args.ptr_reg, args.scale_reg, "sp", "a3")}
|
|
doc["oracle_scale"] = scale
|
|
print(f" hit at frame-time: ${args.ptr_reg}=0x{ptr:08X} "
|
|
f"${args.scale_reg}={scale} (scale/128 = {scale / 128:.3f})")
|
|
|
|
src = (ptr + SRC_OFFSET) & 0x1FFFFFFF
|
|
doc["source_addr"] = f"0x{src:08X}"
|
|
if src == 0 or src >= 0x200000:
|
|
msg = (f"${args.ptr_reg} does not point into RAM "
|
|
f"(0x{ptr:08X}); nothing to compare.")
|
|
print(f"error: {msg}", file=sys.stderr)
|
|
doc["error"] = msg
|
|
raise SystemExit(_finish(doc, args, 1))
|
|
|
|
print(f"reading {args.span} bytes at 0x{src:08X} from the oracle …")
|
|
b = read_ram_range(ds, 0x80000000 | src, args.span)
|
|
doc["oracle_bytes"] = b.hex()
|
|
doc["oracle_looks_like_table"] = looks_like_triplets(b)
|
|
|
|
# Locate the SAME table on psx-runtime rather than assuming the address
|
|
# carries over. It does not: the two allocators place the same
|
|
# structures at different addresses, so the oracle's pointer read on
|
|
# psx-runtime lands on unrelated memory.
|
|
print("searching psx-runtime's RAM for the same table …")
|
|
ram = snapshot_ram(native)
|
|
needle = b[:16]
|
|
hits = find_table(ram, needle)
|
|
doc["native_hits"] = [f"0x{h:08X}" for h in hits]
|
|
if not hits:
|
|
doc["verdict"] = "table-absent-on-native"
|
|
msg = ("psx-runtime's RAM does not contain this colour table "
|
|
"ANYWHERE. The oracle's table is confirmed correct (its "
|
|
"entries scaled by the recorded factor reproduce the colours "
|
|
"in its own display list), so the data psx-runtime is "
|
|
"scaling was never built the same way. The divergence is "
|
|
"upstream of this routine, in whatever produces the table.")
|
|
print(f"\nVERDICT: {msg}")
|
|
doc["note"] = msg
|
|
return _finish(doc, args, 0)
|
|
|
|
native_addr = hits[0]
|
|
doc["native_source_addr"] = f"0x{native_addr:08X}"
|
|
doc["address_delta"] = native_addr - src
|
|
print(f" found at 0x{native_addr:08X} "
|
|
f"(delta {native_addr - src:+d} from the oracle's)")
|
|
a = ram[native_addr:native_addr + args.span]
|
|
except DebugError as e:
|
|
print(f"error: {e}", file=sys.stderr)
|
|
doc["error"] = str(e)
|
|
return _finish(doc, args, 2)
|
|
finally:
|
|
# Clear the breakpoint AND resume. Leaving the oracle parked was the
|
|
# actual defect here: it exits at 1 Hz and every later run then fails
|
|
# in a way that points at the network rather than at this tool.
|
|
try:
|
|
ds.cmd("pc_unbreak", addr=pc)
|
|
except DebugError:
|
|
pass
|
|
if not oracle_resume(ds):
|
|
print("warning: could not resume the oracle — it may still be "
|
|
"parked, which will make later runs time out. Stop and "
|
|
"restart it from the Oracle tab.", file=sys.stderr)
|
|
|
|
doc["native_bytes"] = a.hex()
|
|
same = a == b
|
|
doc["source_identical"] = same
|
|
|
|
print(f"\n psx-runtime: {a[:16].hex(' ')}")
|
|
print(f" oracle : {b[:16].hex(' ')}")
|
|
if same:
|
|
doc["verdict"] = "source-matches"
|
|
print("\nVERDICT: the source RGB table is IDENTICAL on both (found on "
|
|
f"psx-runtime at {doc['native_source_addr']}). The input to the "
|
|
"colour computation is fine, so the divergence is the scale "
|
|
f"(${args.scale_reg}) or the arithmetic in this routine.")
|
|
else:
|
|
n = sum(1 for x, y in zip(a, b) if x != y)
|
|
doc["verdict"] = "source-differs"
|
|
doc["differing_bytes"] = n
|
|
first = next(i for i, (x, y) in enumerate(zip(a, b)) if x != y)
|
|
doc["first_difference"] = f"0x{src + first:08X}"
|
|
print(f"\nVERDICT: the source RGB DIFFERS ({n}/{len(a)} bytes, first at "
|
|
f"0x{src + first:08X}). The colour code is faithfully scaling bad "
|
|
f"input — the fault is upstream, in whatever fills this table.")
|
|
return _finish(doc, args, 0)
|
|
|
|
|
|
def _finish(doc, args, rc):
|
|
if args.json:
|
|
with open(args.json, "w", encoding="utf-8") as f:
|
|
json.dump(doc, f, indent=1)
|
|
print(f"wrote {args.json}")
|
|
return rc
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|