Files
psxrecomp/tools/class_census.py
T
Alex Vanderveen 14b1163b32 census: report WHERE a missing class is built, not just how many
Asked whether the bug can be fixed now. Not yet — there is a symptom and no
cause, and guessing at this point would join the six hypotheses already dead.

But the captured lists narrow it further without touching an emulator. For
PolyG3+semi|B+F:

  strides   psx-runtime [28, 2332]      oracle [28, 1052, 2332]
  range     0x10D05C..0x10DCC0          0x10D0AC..0x10FD48
  colours   (30,0,0) (24,0,30) dark     (47,23,0) (127,55,0) bright orange

28 is one packet, so those are contiguous runs. The oracle has a THIRD group
the runtime does not — the 1052 stride — and builds it in a byte range the
runtime never touches. The colours differ in character too: dark red/blue
against bright red/green, which is what the screenshots show as missing orbs.

So the census now records the address span each class is built in, and when a
class is systematically absent it names the region the oracle uses and the
runtime does not. That converts "short by 258 primitives" into an address range
to point range_writers at: if nothing on psx-runtime writes there, that is
where they are lost, and the code that should have written it is the fix site.

Still a single capture, so still to be confirmed by the census itself — phase
can hide a group in one frame, not in forty.

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

236 lines
9.6 KiB
Python

#!/usr/bin/env python3
"""class_census.py -- what each emulator EVER draws, over many frames.
python3 class_census.py --samples 40
Why maxima rather than a snapshot
---------------------------------
A single display-list capture from each side is confounded by animation phase:
the two are never at the same moment, so any class-count difference could be
"this emulator does not draw that" or "it was not drawing it just then". That
ambiguity has cost this investigation several wrong turns.
Maxima are not symmetric in that way. If psx-runtime never once reaches the
count the oracle routinely shows, across many samples spanning the whole
effect, "it was not drawing it just then" stops being available as an
explanation. Phase can hide a primitive in one frame; it cannot hide it in
forty.
This is what the images pointed at: the oracle renders two glowing orbs and a
green artifact that psx-runtime does not, and a single-capture comparison put
psx-runtime at 32 additive gouraud triangles against the oracle's 290.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import threading
import time
from collections import defaultdict
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from psx_gpu_frame import ( # noqa: E402
DEFAULT_DUCKSTATION_PORT, DEFAULT_NATIVE_PORT, RAM_SIZE, STP_MODES,
DebugConn, DebugError, decode_entries, find_display_lists, oracle_resume,
read_ram_range, snapshot_ram, walk_ordering_table,
)
KIND = "psx-class-census"
def prim_class(p):
mode = STP_MODES.get(p.get("stp", 0), "?") if p.get("semi") else "opaque"
return f"{p['op_name']}|{mode}"
def census(conn, samples, gap, out=sys.stderr):
"""Per-class counts over `samples` captures. Returns {class: [counts]}."""
oracle_resume(conn)
seen = defaultdict(list)
spans = {}
root = span = None
misses = 0
for _ in range(samples):
try:
if root is None:
ram = snapshot_ram(conn)
cands = find_display_lists(ram)
if not cands:
misses += 1
time.sleep(gap)
continue
root = cands[0]["root"]
span = (cands[0]["lo"], cands[0]["hi"])
else:
# Re-read only the list's span: a full 2 MB snapshot per sample
# would make forty samples take minutes, and against a parked
# oracle it would not finish at all.
lo = max(0, (span[0] - 0x800) & ~3)
hi = min(RAM_SIZE, span[1] + 0x800)
blob = read_ram_range(conn, 0x80000000 + lo, hi - lo)
buf = bytearray(RAM_SIZE)
buf[lo:lo + len(blob)] = blob
ram = bytes(buf)
prims = decode_entries(walk_ordering_table(ram, root))
if not prims:
root = span = None
misses += 1
time.sleep(gap)
continue
counts = defaultdict(int)
lo_hi = {}
for p in prims:
if p["kind"] in ("poly", "rect", "line", "fill"):
k = prim_class(p)
counts[k] += 1
if p.get("src"):
a = int(p["src"], 16) & 0x1FFFFFFF
cur = lo_hi.get(k)
lo_hi[k] = (min(cur[0], a), max(cur[1], a)) if cur else (a, a)
for k in set(seen) | set(counts):
seen[k].append(counts.get(k, 0))
# Track WHERE each class is built, not just how many.
#
# A class that is short by 258 primitives is one thing; knowing the
# oracle builds them in a byte range psx-runtime never touches is
# what turns that into somewhere to look. Measured on a single
# capture: the oracle used 0x10D0AC..0x10FD48 for this class and
# psx-runtime only 0x10D05C..0x10DCC0.
for k, (a, b) in lo_hi.items():
cur = spans.get(k)
spans[k] = (min(cur[0], a), max(cur[1], b)) if cur else (a, b)
except DebugError:
root = span = None
misses += 1
time.sleep(gap)
if misses:
print(f" ({misses} of {samples} captures could not be walked)", file=out)
return dict(seen), spans
def summarise(nat, orc, out=sys.stdout, ratio=3.0, floor=20,
nat_spans=None, orc_spans=None):
keys = sorted(set(nat) | set(orc))
rows = []
for k in keys:
a, b = nat.get(k, []), orc.get(k, [])
rows.append({"key": k,
"native_max": max(a) if a else 0,
"oracle_max": max(b) if b else 0,
"native_med": sorted(a)[len(a) // 2] if a else 0,
"oracle_med": sorted(b)[len(b) // 2] if b else 0,
"native_span": (nat_spans or {}).get(k),
"oracle_span": (orc_spans or {}).get(k)})
rows.sort(key=lambda r: -(r["oracle_max"] - r["native_max"]))
print(f"\n {'class':<26}{'runtime max':>12}{'oracle max':>12}{'gap':>8}",
file=out)
for r in rows:
gap = r["oracle_max"] - r["native_max"]
mark = ""
if r["oracle_max"] >= floor and r["native_max"] * ratio < r["oracle_max"]:
mark = " <- psx-runtime never draws these"
elif r["native_max"] >= floor and r["oracle_max"] * ratio < r["native_max"]:
mark = " <- the ORACLE never draws these"
print(f" {r['key']:<24}{r['native_max']:>12}{r['oracle_max']:>12}"
f"{gap:>+8}{mark}", file=out)
return rows
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("--samples", type=int, default=40)
ap.add_argument("--gap", type=float, default=0.15)
ap.add_argument("--timeout", type=float, default=60.0)
ap.add_argument("--json", default=None)
args = ap.parse_args(argv)
n = DebugConn(args.host, args.native_port, args.timeout)
o = DebugConn(args.host, args.ds_port, args.timeout)
res = {}
def go(key, conn):
try:
res[key], res[key + "_spans"] = census(conn, args.samples, args.gap)
except DebugError as e:
res[key] = {}
res[key + "_err"] = str(e)
print(f"censusing {args.samples} captures on each side — "
f"run the effect through on BOTH")
ts = [threading.Thread(target=go, args=("nat", n)),
threading.Thread(target=go, args=("orc", o))]
for t in ts:
t.start()
for t in ts:
t.join()
nat, orc = res.get("nat", {}), res.get("orc", {})
doc = {"kind": KIND, "version": 1, "samples": args.samples}
if not nat or not orc:
missing = "psx-runtime" if not nat else "the oracle"
doc["error"] = f"{missing} produced no captures"
print(f"error: {doc['error']}", file=sys.stderr)
return _finish(doc, args, 1)
rows = summarise(nat, orc,
nat_spans=res.get("nat_spans"),
orc_spans=res.get("orc_spans"))
doc["classes"] = rows
absent = [r for r in rows
if r["oracle_max"] >= 20 and r["native_max"] * 3 < r["oracle_max"]]
doc["absent_on_native"] = [r["key"] for r in absent]
if absent:
total = sum(r["oracle_max"] - r["native_max"] for r in absent)
for r in absent:
ns, os_ = r.get("native_span"), r.get("oracle_span")
if ns and os_:
r["unwritten_region"] = [f"0x{max(ns[1], os_[0]):08X}",
f"0x{os_[1]:08X}"]
print(f"\n {r['key']}: the oracle builds these across "
f"0x{os_[0]:08X}..0x{os_[1]:08X}; psx-runtime only uses "
f"0x{ns[0]:08X}..0x{ns[1]:08X}. Trace the writers of "
f"{r['unwritten_region'][0]}..{r['unwritten_region'][1]} "
f"on psx-runtime — if nothing writes there, that is where "
f"the primitives are lost.", file=sys.stdout)
elif os_:
r["unwritten_region"] = [f"0x{os_[0]:08X}", f"0x{os_[1]:08X}"]
print(f"\n {r['key']}: the oracle builds these across "
f"0x{os_[0]:08X}..0x{os_[1]:08X}; psx-runtime builds none "
f"at all.", file=sys.stdout)
print(f"\nFINDING: psx-runtime never draws {total} primitive(s) the "
f"oracle does, across {args.samples} captures spanning the "
f"effect: {', '.join(r['key'] for r in absent)}.\n"
f"Phase can hide a primitive in one frame; it cannot hide it in "
f"{args.samples}. These are not being SUBMITTED, which puts the "
f"fault in the guest code that builds them — not in the "
f"renderer.")
doc["verdict"] = "classes-absent-on-native"
else:
print("\nNo class is systematically absent: every one psx-runtime draws "
"fewer of at some moment, it also reaches comparable counts at "
"another. The single-capture differences were phase.")
doc["verdict"] = "no-systematic-absence"
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())