Files
psxrecomp/tools/_options_session/_analyze_baseline.py
T
Matthew Stanley fdbd2b515f runtime: cross-fiber longjmp fix + decouple vblank-present from MMIO read
Two distinct bugs were causing OPTIONS-black hard freezes after 12K-65K
frames:

1. interrupts.c + traps.c: exception_jmpbuf is a single global. setjmp
   captures the running fiber, but longjmp may be requested from a
   different fiber via psx_exception_longjmp / psx_restore_state_escape
   during chain-handler dispatch. Cross-fiber longjmp restores RSP to
   the wrong stack while Win32 still tracks the calling fiber, corrupting
   fiber state until SwitchToFiber eventually hangs.

   Fix: track g_exception_owner_fiber at setjmp time. New
   deferred_exception_longjmp checks the current fiber; if it's not the
   owner, sets a pending flag and SwitchToFiber's back to the owner.
   psx_change_thread_fiber's post-SwitchToFiber path observes the flag
   and executes the longjmp on the correct stack.

2. gpu.c: gpu_read_gpustat fired gpu_vblank_tick() after 1000 polls,
   which invoked sdl_vblank_present (SDL_Delay -> Sleep) from inside
   an MMIO read code path. That meant recompiled MIPS spent wall time
   in Sleep deep inside the fiber call tree -- confusing to reason
   about and a contributor to fiber/longjmp interactions.

   Fix: synthetic VBLANK from a poll-storm only updates emulation state
   (LCF + I_STAT). The host-side render+sleep happens only at the proper
   cycle-paced VBLANK trigger in psx_check_interrupts.

Also adds: dirty_block ring slice to the freeze auto-dump, Win32 main-
thread stack capture for wedge_kind=1 hard freezes (SuspendThread +
StackWalk64 + SymFromAddr), and dbghelp link.

Validated: TombaRecomp psx-runtime ran 30+ minutes / 105K+ frames at
OPTIONS with no hard freeze. Previous freezes consistently hit at
11900, 20493, and 64596 frames.

Diagnostic tooling under tools/_options_session/ that produced this
diagnosis is included.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 12:49:13 -07:00

110 lines
4.3 KiB
Python

"""Analyze the captured options_baseline_*.json snapshot.
Goal: characterize the divergence between runtime (stuck in OPTIONS-black,
constantly rebuilding display list) and beetle (stable OPTIONS, only
touching state cells).
For each side, group the wtrace_all entries by (addr, pc, ra) and
report counts. Tells us:
- which PCs are writing the OPTIONS-area cells on each backend
- whether the same PCs exist on both (= same code, wrong data) or
different PCs (= different code paths entirely)
"""
from __future__ import annotations
import sys, os, json, glob
from collections import defaultdict
if len(sys.argv) > 1:
path = sys.argv[1]
else:
out_dir = os.path.join(os.environ.get("TEMP", "/tmp"), "optsess")
matches = sorted(glob.glob(os.path.join(out_dir, "options_baseline_*.json")))
if not matches:
print("no snapshot found", file=sys.stderr); sys.exit(1)
path = matches[-1]
print(f"using {path}")
with open(path) as f:
snap = json.load(f)
def hx(v):
if isinstance(v, str): return int(v, 16) if v.lower().startswith("0x") else int(v)
return v
def analyze_side(side_name, side):
print(f"\n{'='*70}\n {side_name.upper()}\n{'='*70}")
print(f" frame={side['ping']['frame']}")
if "freeze_check" in side:
fc = side["freeze_check"]
if isinstance(fc, dict) and fc.get("ok"):
print(f" freeze: current_func={fc.get('current_func')} "
f"last_store_pc={fc.get('last_store_pc')} "
f"in_exc={fc.get('in_exception')} "
f"exc_reentry={fc.get('exception_reentry_blocks')}")
# OPTIONS-area cluster (filtered)
dmp = side["wtrace_all_dump_optstate"]
entries = dmp.get("entries", [])
print(f"\n optstate writes in ring: {len(entries)} (filter 0x80090C00..0x80091000)")
if not entries: return
# Group by addr (which cells are written most)
by_addr = defaultdict(int)
for e in entries:
by_addr[e["addr"]] += 1
top_addr = sorted(by_addr.items(), key=lambda x: -x[1])[:12]
print(f"\n top 12 addrs (count of writes):")
for a, c in top_addr:
print(f" {a} {c}")
# Group by (pc, ra) — who is writing
by_pcra = defaultdict(int)
for e in entries:
by_pcra[(e["pc"], e["ra"])] += 1
top_pcra = sorted(by_pcra.items(), key=lambda x: -x[1])[:10]
print(f"\n top 10 (pc, ra) (count):")
for (pc, ra), c in top_pcra:
print(f" pc={pc} ra={ra} {c}")
# Distinct PCs (writers) and distinct RAs (callers of writers)
distinct_pcs = sorted({e["pc"] for e in entries})
distinct_ras = sorted({e["ra"] for e in entries})
print(f"\n distinct write PCs: {len(distinct_pcs)}")
print(f" distinct write RAs (callers): {len(distinct_ras)}")
print(f" first 6 distinct PCs: {distinct_pcs[:6]}")
print(f" first 6 distinct RAs: {distinct_ras[:6]}")
# Frame range covered in this window
frames = sorted({e["frame"] for e in entries})
print(f"\n frame range in ring: [{min(frames)}, {max(frames)}] ({len(frames)} distinct frames)")
# Unfiltered tail — where IS the rest of the activity?
tail = side["wtrace_all_dump_tail"]
tail_entries = tail.get("entries", [])
if tail_entries:
# Top 8 addr bins (granularity: 256-byte buckets)
bucket = defaultdict(int)
for e in tail_entries:
a = hx(e["addr"])
bucket[a & ~0xFF] += 1
top_bucket = sorted(bucket.items(), key=lambda x: -x[1])[:8]
print(f"\n top 8 256-byte buckets in unfiltered tail of {len(tail_entries)} entries:")
for a, c in top_bucket:
print(f" 0x{a:08X}-0x{a+0xFF:08X} {c}")
analyze_side("runtime", snap["runtime"])
analyze_side("beetle", snap["beetle"])
# Cross-side: are the same PCs writing the same cells on both?
r_pc_set = {e["pc"] for e in snap["runtime"]["wtrace_all_dump_optstate"]["entries"]}
b_pc_set = {e["pc"] for e in snap["beetle"]["wtrace_all_dump_optstate"]["entries"]}
print(f"\n{'='*70}\n CROSS-SIDE\n{'='*70}")
print(f" runtime distinct PCs writing optstate: {len(r_pc_set)}")
print(f" beetle distinct PCs writing optstate: {len(b_pc_set)}")
print(f" intersection: {len(r_pc_set & b_pc_set)}")
print(f" runtime-only PCs (first 10): {sorted(r_pc_set - b_pc_set)[:10]}")
print(f" beetle-only PCs (first 10): {sorted(b_pc_set - r_pc_set)[:10]}")