Files
psxrecomp/tools/range_writers.py
T
Alex Vanderveen 0f70aacccc tools: wait for the effect instead of racing it
"the effect is not on screen on psx-runtime, so this code never ran. Currently
drawing: PolyFT4 x584, PolyF4+semi x184, PolyG3+semi x176, PolyFT3 x151" — the
diagnostic added last commit, doing its job on the first run. The effect simply
was not up when the tool looked.

That is the real problem, and it is not a diagnostic problem. Every tool here
needs the effect RUNNING, and requiring it to be on screen at the instant a
button is clicked means racing a transient animation. Losing that race produces
a different-looking failure in each tool — no writes recorded, no candidate
fired, no table found, the block was not reached — four symptoms, one cause,
none of them naming it. Running lockstep beforehand appeared to help only
because it takes long enough for the effect to come round again.

So the tools wait. Press the button first, then trigger the effect; each polls
the display list until its class appears, up to two minutes, and each side is
waited for INDEPENDENTLY in its own thread. That last part matters: it removes
the need to hold two emulators inside the same effect simultaneously, which was
never realistic by hand.

Wired into scale_trace, packet_writers, range_writers and colour_inputs — every
tool that needs the effect. Timing out reports what WAS drawing, because "wrong
scene" and "tool is broken" look identical otherwise. A transient DebugError
mid-poll does not abort the wait: the emulator can be mid-pause, and one failed
poll is not a reason to stop waiting for something that has not happened yet.

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

202 lines
8.1 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("--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}")
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:
msg = (f"nothing wrote to this range across {advanced} frame(s). Either "
f"it is written less often than that, or the code that fills it "
f"is not running.")
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())