mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-28 19:07:47 -04:00
ce0f2b677e
The first live run produced the decisive timeline -- the 216-colour content arrives from CD-ROM DMA (0x8001FE80, ch3) at the scene load, so the savestate theory is dead and the table holds raw disc data -- but a Ctrl-C at the end lost the report, because it only saved on clean exit. Every transition now flushes immediately. Also runs against DuckStation: it has no wtrace, so transitions report unattributed there -- which is all the oracle side needs, since the question is WHERE ITS TIMELINE DIVERGES from native's: does DS's copy of the region also become 216 raw colours after the CD load and then get overwritten to ~5 by a later step that native never runs, or does DS's CD load deliver different data in the first place? Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
185 lines
7.6 KiB
Python
185 lines
7.6 KiB
Python
#!/usr/bin/env python3
|
|
"""table_watch.py -- catch the moment the effect's colour table changes.
|
|
|
|
python3 tools/table_watch.py # then play: title -> scene -> effect
|
|
|
|
Where this fits
|
|
---------------
|
|
The effect's 64 additive quads read per-vertex source colours from a table
|
|
around 0x800E25F0..0x800E2C60. DuckStation's copy holds ~5 distinct colour
|
|
words and renders a clean bloom; psx-runtime's holds ~216 and renders the
|
|
wedges. Write-tracing that range never fires: CPU stores and every DMA
|
|
channel go through psx_write_word and would be visible, so whatever fills it
|
|
is one of the paths that bypass the traced write layer entirely -- above all
|
|
SAVESTATE and REWIND restores, which memcpy whole RAM images in. If the
|
|
corrupt table rides in via a state file, no writer exists to find: the
|
|
corruption happened in whatever session CREATED the state.
|
|
|
|
So do not hunt the writer; watch the DATA. This polls the table region a few
|
|
times a second while you play from as far back as you can start, and reports
|
|
every transition of its content fingerprint -- along with the distinct
|
|
colour-word count before and after, the frame number, and whether the write
|
|
trace (armed the whole time) saw CPU/DMA stores in the window. A transition
|
|
with trace entries names the writer; a transition with NONE is a wholesale
|
|
restore, and the question becomes what loaded the state, not what wrote the
|
|
bytes.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import collections
|
|
import hashlib
|
|
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, DebugConn, DebugError, read_ram_range,
|
|
)
|
|
|
|
KIND = "psx-table-watch"
|
|
|
|
|
|
def distinct_colours(blob):
|
|
out = set()
|
|
for i in range(0, len(blob) - 3, 4):
|
|
w = int.from_bytes(blob[i:i + 4], "little")
|
|
out.add(w & 0xFFFFFF)
|
|
return len(out)
|
|
|
|
|
|
def fingerprint(blob):
|
|
return hashlib.sha1(blob).hexdigest()[:16]
|
|
|
|
|
|
def classify(n_writes):
|
|
"""What a transition with/without traced writes means."""
|
|
if n_writes > 0:
|
|
return ("written", "CPU or DMA stores were traced in the window -- "
|
|
"the writer is in the dump.")
|
|
return ("restored", "NO stores were traced, yet the bytes changed: a "
|
|
"wholesale restore (savestate / rewind) replaced the "
|
|
"region. The corruption rides in the state file; the "
|
|
"writer to find is in the session that CREATED it.")
|
|
|
|
|
|
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("--lo", default="0x800E25F0")
|
|
ap.add_argument("--hi", default="0x800E2C60")
|
|
ap.add_argument("--poll", type=float, default=0.4)
|
|
ap.add_argument("--watch-secs", type=float, default=300.0)
|
|
ap.add_argument("--out", default="analysis/frames/table_watch.json")
|
|
args = ap.parse_args()
|
|
|
|
lo, hi = int(args.lo, 0), int(args.hi, 0)
|
|
length = ((hi - lo) & ~3) + 4
|
|
conn = DebugConn(args.host, args.port, args.timeout)
|
|
|
|
# Arm the trace for the whole watch so any traceable writer is captured
|
|
# retroactively. The oracle has no wtrace; transitions still report, just
|
|
# unattributed -- which is fine, since the oracle side only needs the
|
|
# TIMELINE to compare against native's.
|
|
have_trace = True
|
|
try:
|
|
conn.cmd("wtrace_reset")
|
|
conn.cmd("wtrace_add", lo=f"0x{lo & 0x1FFFFFFF:08X}",
|
|
hi=f"0x{(lo & 0x1FFFFFFF) + length:08X}")
|
|
except DebugError as e:
|
|
have_trace = False
|
|
print(f"note: write trace unavailable here ({e}); transitions will "
|
|
f"be reported without attribution", file=sys.stderr)
|
|
|
|
doc = {"kind": KIND, "version": 1, "lo": args.lo, "hi": args.hi,
|
|
"port": args.port, "attributed": have_trace, "transitions": []}
|
|
|
|
def flush():
|
|
os.makedirs(os.path.dirname(args.out) or ".", exist_ok=True)
|
|
with open(args.out, "w") as f:
|
|
json.dump(doc, f, indent=1)
|
|
|
|
last_fp = None
|
|
last_n = None
|
|
last_seq = 0
|
|
print(f"watching 0x{lo:08X}..0x{hi:08X} — play from as far back as you "
|
|
f"can (title screen onward); every content change is reported",
|
|
flush=True)
|
|
deadline = time.monotonic() + args.watch_secs
|
|
try:
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
blob = read_ram_range(conn, lo, length)
|
|
except DebugError as e:
|
|
print(f" read failed ({e}); retrying", flush=True)
|
|
time.sleep(args.poll)
|
|
continue
|
|
try:
|
|
frame = conn.frame()
|
|
except DebugError:
|
|
frame = -1
|
|
fp = fingerprint(blob)
|
|
n = distinct_colours(blob)
|
|
if fp != last_fp:
|
|
writes = []
|
|
seq_hi = last_seq
|
|
if have_trace:
|
|
try:
|
|
rep = conn.cmd(
|
|
"wtrace_dump",
|
|
addr_lo=f"0x{lo & 0x1FFFFFFF:08X}",
|
|
addr_hi=f"0x{(lo & 0x1FFFFFFF) + length:08X}",
|
|
count=2048, newest=1)
|
|
for e in rep.get("entries", []):
|
|
seq_hi = max(seq_hi, int(e.get("seq", 0)))
|
|
if int(e.get("seq", 0)) > last_seq:
|
|
writes.append({"pc": e.get("pc"),
|
|
"dma_ch": e.get("dma_ch"),
|
|
"frame": e.get("frame")})
|
|
except DebugError:
|
|
pass
|
|
if last_fp is not None:
|
|
kind, meaning = classify(len(writes)) if have_trace \
|
|
else ("unattributed", "no write trace on this side")
|
|
by_pc = collections.Counter(
|
|
(w["pc"], w["dma_ch"]) for w in writes)
|
|
t = {"frame": frame, "from_colours": last_n,
|
|
"to_colours": n, "kind": kind,
|
|
"traced_writes": len(writes),
|
|
"writers": [{"pc": p, "dma_ch": d, "count": c}
|
|
for (p, d), c in by_pc.most_common(8)]}
|
|
doc["transitions"].append(t)
|
|
flush() # survive a Ctrl-C at any moment
|
|
print(f" CHANGE at frame {frame}: {last_n} -> {n} "
|
|
f"distinct colours [{kind}: {len(writes)} traced "
|
|
f"write(s)]", flush=True)
|
|
for (p, d), c in by_pc.most_common(4):
|
|
tag = f" dma_ch={d}" if d is not None and d >= 0 else ""
|
|
print(f" writer {p}{tag} x{c}", flush=True)
|
|
else:
|
|
doc["baseline"] = {"frame": frame, "colours": n,
|
|
"fingerprint": fp}
|
|
flush()
|
|
print(f" baseline at frame {frame}: {n} distinct "
|
|
f"colours, fingerprint {fp}", flush=True)
|
|
last_fp, last_n, last_seq = fp, n, seq_hi
|
|
time.sleep(args.poll)
|
|
except KeyboardInterrupt:
|
|
print("\nstopped", flush=True)
|
|
|
|
doc["final_colours"] = last_n
|
|
flush()
|
|
print(f"report: {args.out}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|