mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-28 10:59:16 -04:00
0371952721
Tracing 0x800E2718..0x800E2C60 across 2 frames of the effect recorded nothing, consistently. That is a real result -- the table is STATIC while the effect plays, which removes the phase caveat from the finding that psx-runtime holds 216 distinct colour words there where DuckStation holds 5, at the same addresses, 1067/1712 bytes differing. But the old message said 'nothing wrote to this range... the code that fills it is not running', which reads as 'nothing ever writes this' and sends you looking for a missing writer. A table filled once at scene load is simply not written during the effect that reads it. --watch N traces free-running so the fill can be caught by walking into the scene with the trace already armed. The empty-result messages now distinguish the two cases, and the watched one names the remaining possibility: a DMA fill writing RAM directly is not visible to a trace built on CPU store hooks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
234 lines
9.9 KiB
Python
234 lines
9.9 KiB
Python
#!/usr/bin/env python3
|
|
"""range_writers.py -- which code writes an arbitrary range of guest RAM.
|
|
|
|
python3 range_writers.py --lo 0x000E0BF8 --hi 0x000E6628 \
|
|
--expect-class PolyG4+semi
|
|
|
|
Why an arbitrary range
|
|
----------------------
|
|
packet_writers answers this for a primitive's packets, where the layout is
|
|
known and every write can be classified as colour or vertex. This is the same
|
|
instrument without that assumption: point it at any span and it reports which
|
|
instructions store there, how much, and over what addresses.
|
|
|
|
It exists because the colour investigation reached a region rather than a
|
|
structure. With the effect off, 0x000E0BF8..0x000E6628 is byte-identical
|
|
between the two emulators; with it running they differ. So the divergence is in
|
|
what the effect WRITES there, and the question became "what writes this?".
|
|
|
|
Tracing is done over a bounded number of STEPPED frames, for the same reason
|
|
packet_writers does it: the game rebuilds its buffers constantly, and a
|
|
free-running trace attributes writes to whatever the addresses meant at some
|
|
other moment. Registers and code are captured while parked, because these are
|
|
overlay addresses -- the same PC decodes differently once another overlay
|
|
loads.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
from collections import Counter, defaultdict
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
from packet_writers import disasm_around # noqa: E402
|
|
from psx_gpu_frame import ( # noqa: E402
|
|
DEFAULT_NATIVE_PORT, DebugConn, DebugError, class_on_screen,
|
|
wait_for_class,
|
|
)
|
|
|
|
KIND = "psx-range-writers"
|
|
|
|
|
|
def summarise(rows, lo, hi, out=sys.stdout, limit=16):
|
|
by_pc = defaultdict(lambda: {"n": 0, "lo": None, "hi": None,
|
|
"vals": Counter(), "widths": Counter()})
|
|
for e in rows:
|
|
a = int(e["addr"], 16) & 0x1FFFFFFF
|
|
d = by_pc[e["pc"]]
|
|
d["n"] += 1
|
|
d["lo"] = a if d["lo"] is None else min(d["lo"], a)
|
|
d["hi"] = a if d["hi"] is None else max(d["hi"], a)
|
|
v = e.get("new") or e.get("val")
|
|
if v:
|
|
d["vals"][v] += 1
|
|
if e.get("w"):
|
|
d["widths"][e["w"]] += 1
|
|
|
|
ranked = sorted(by_pc.items(), key=lambda kv: -kv[1]["n"])
|
|
print(f"\n{len(rows)} write(s) into 0x{lo:08X}..0x{hi:08X} "
|
|
f"from {len(ranked)} instruction(s)\n", file=out)
|
|
print(f" {'pc':<12}{'writes':>8} {'address span':<26}common values", file=out)
|
|
for pc, d in ranked[:limit]:
|
|
span = f"0x{d['lo']:06X}..0x{d['hi']:06X}"
|
|
common = " ".join(f"{v}x{n}" for v, n in d["vals"].most_common(2))
|
|
print(f" {pc:<12}{d['n']:>8} {span:<26}{common}", file=out)
|
|
return ranked
|
|
|
|
|
|
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("--port", type=int, default=DEFAULT_NATIVE_PORT)
|
|
ap.add_argument("--lo", required=True, help="start of the range (hex)")
|
|
ap.add_argument("--hi", required=True, help="end of the range (hex)")
|
|
ap.add_argument("--frames", type=int, default=1,
|
|
help="frames to step while tracing")
|
|
ap.add_argument("--expect-class", default=None,
|
|
help="primitive class the code of interest draws; if it is "
|
|
"not on screen that is reported instead of an empty "
|
|
"trace")
|
|
ap.add_argument("--disasm", type=int, default=3,
|
|
help="how many top writers to disassemble")
|
|
ap.add_argument("--watch", type=float, default=0.0,
|
|
help="seconds to trace FREE-RUNNING instead of stepping "
|
|
"frames. Use this for data written at scene load "
|
|
"rather than per frame -- stepping two frames during "
|
|
"an effect cannot see a table that was filled before "
|
|
"it started")
|
|
ap.add_argument("--wait-secs", type=float, default=120.0,
|
|
help="wait this long for --expect-class to "
|
|
"appear; 0 disables the wait")
|
|
ap.add_argument("--timeout", type=float, default=60.0)
|
|
ap.add_argument("--json", default=None)
|
|
args = ap.parse_args(argv)
|
|
|
|
lo = int(args.lo, 16) & 0x1FFFFFFF
|
|
hi = int(args.hi, 16) & 0x1FFFFFFF
|
|
if hi <= lo:
|
|
print("error: --hi must be above --lo", file=sys.stderr)
|
|
return 2
|
|
doc = {"kind": KIND, "version": 1,
|
|
"lo": f"0x{lo:08X}", "hi": f"0x{hi:08X}", "frames": args.frames}
|
|
|
|
conn = DebugConn(args.host, args.port, args.timeout)
|
|
if args.expect_class:
|
|
# class_on_screen walks the display list, which means talking to the
|
|
# emulator. An unreachable one has to read as a message, not as a
|
|
# traceback from somewhere three calls down.
|
|
try:
|
|
# Wait rather than sample once: the effect is transient, and an
|
|
# empty trace looks like a statement about the code.
|
|
if args.wait_secs > 0:
|
|
on, drawing = wait_for_class(conn, args.expect_class,
|
|
args.wait_secs, out=sys.stderr)
|
|
else:
|
|
on, drawing = class_on_screen(conn, args.expect_class)
|
|
except DebugError as e:
|
|
print(f"error: {e}", file=sys.stderr)
|
|
doc["error"] = str(e)
|
|
return _finish(doc, args, 2)
|
|
doc["expect_class"] = args.expect_class
|
|
doc["on_screen"] = on
|
|
if not on:
|
|
top = ", ".join(f"{k} x{v}" for k, v in
|
|
sorted(drawing.items(), key=lambda kv: -kv[1])[:5])
|
|
msg = (f"{args.expect_class} is not being drawn, so the code that "
|
|
f"writes this range is not running. Currently drawing: "
|
|
f"{top or 'nothing'}.")
|
|
print(f"error: {msg}", file=sys.stderr)
|
|
doc["error"] = msg
|
|
return _finish(doc, args, 1)
|
|
|
|
listings = {}
|
|
try:
|
|
conn.cmd("pause")
|
|
f0 = conn.frame()
|
|
conn.cmd("wtrace_reset")
|
|
conn.cmd("wtrace_add", lo=f"0x{lo:08X}", hi=f"0x{hi:08X}")
|
|
if args.watch > 0:
|
|
# Free-running trace. A table that is STATIC while an effect plays
|
|
# is not written during it -- stepping a couple of frames there
|
|
# proves only that, and reads as "nothing writes this". The fill
|
|
# happens at scene load, so run and let the user walk into the
|
|
# scene with the trace already armed.
|
|
print(f"tracing free-running for {args.watch:.0f}s — enter the "
|
|
f"scene NOW so the load is inside the window",
|
|
file=sys.stderr, flush=True)
|
|
conn.cmd("continue")
|
|
time.sleep(args.watch)
|
|
conn.cmd("pause")
|
|
for _ in range(400):
|
|
if conn.raw("pause_state").get("paused"):
|
|
break
|
|
time.sleep(0.02)
|
|
else:
|
|
conn.cmd("step", n=max(1, args.frames))
|
|
for _ in range(400):
|
|
st = conn.raw("pause_state")
|
|
if st.get("paused") and conn.frame() > f0:
|
|
break
|
|
time.sleep(0.02)
|
|
advanced = conn.frame() - f0
|
|
doc["frames_advanced"] = advanced
|
|
if advanced <= 0:
|
|
msg = "the emulator did not advance a frame, so nothing was traced"
|
|
print(f"error: {msg}", file=sys.stderr)
|
|
doc["error"] = msg
|
|
return _finish(doc, args, 1)
|
|
rep = conn.cmd("wtrace_dump", addr_lo=f"0x{lo:08X}",
|
|
addr_hi=f"0x{hi:08X}", count=16000)
|
|
rows = rep.get("entries", [])
|
|
if rows and args.disasm:
|
|
counts = Counter(e["pc"] for e in rows)
|
|
for pc, _ in counts.most_common(args.disasm):
|
|
listings[pc] = disasm_around(conn, int(pc, 16))
|
|
except DebugError as e:
|
|
print(f"error: {e}", file=sys.stderr)
|
|
doc["error"] = str(e)
|
|
return _finish(doc, args, 2)
|
|
finally:
|
|
try:
|
|
conn.cmd("continue")
|
|
except DebugError:
|
|
pass
|
|
|
|
doc["writes"] = len(rows)
|
|
if not rows:
|
|
if args.watch > 0:
|
|
msg = (f"nothing wrote to this range in {args.watch:.0f}s of "
|
|
f"free running ({advanced} frame(s)). If the scene was "
|
|
f"loaded inside that window, the data did not arrive by a "
|
|
f"CPU store at all -- a DMA fill that writes RAM directly "
|
|
f"is not visible to this trace.")
|
|
else:
|
|
msg = (f"nothing wrote to this range across {advanced} frame(s). "
|
|
f"That means it is STATIC over those frames -- useful in "
|
|
f"itself -- not that nothing ever fills it. Data written "
|
|
f"once at scene load needs --watch N with the trace armed "
|
|
f"BEFORE entering the scene.")
|
|
print(f"error: {msg}", file=sys.stderr)
|
|
doc["error"] = msg
|
|
return _finish(doc, args, 1)
|
|
|
|
ranked = summarise(rows, lo, hi)
|
|
doc["writers"] = [{"pc": pc, "writes": d["n"],
|
|
"lo": f"0x{d['lo']:08X}", "hi": f"0x{d['hi']:08X}",
|
|
"common": [{"value": v, "count": n}
|
|
for v, n in d["vals"].most_common(4)]}
|
|
for pc, d in ranked]
|
|
doc["listings"] = listings
|
|
for pc in list(listings)[:2]:
|
|
print(f"\n --- {pc} ---")
|
|
for r in listings[pc]:
|
|
print(f" {'>>' if r['is_target'] else ' '} {r['pc']}: "
|
|
f"{r['word']} {r['text']}")
|
|
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"\nwrote {args.json}")
|
|
return rc
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|