The native-wide mirror target clipped Y to the full surface (0..511), but a
vertically double-buffered game (MMX6: front y0..239 / back y240..479, both
sharing base_x=0) keeps both buffers in one shared wide surface. The background
tile loop starts a partial top row at y = buffer_top - (scrollY & 0xf), so
back-buffer draws bled across the band boundary into the front buffer's region
of the wide surface -> top/bottom edge flicker as the buffers flipped. Canonical
VRAM never showed it because its draw-area clip already confines each buffer.
Clip the wide target's Y to the canonical draw area (g_clip_y1..g_clip_y2),
exactly like rt_native, so each buffer's draws stay in its own band. X still
spans the full wide width (that is where the reveal margins live). User-confirmed
the top/bottom flicker is fixed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pure-2D sprite games (Mega Man X6) render a true 4:3 field of view with no
overscan to reveal, so 16:9 requires making the engine draw more. Two parts:
- [widescreen] full_2d opts a pure-2D game into the native-wide present path
(it never emits the 3D sprite-tag hook the gameplay detector keys on, so
every in-game frame was misclassified as a 4:3 menu and pillarboxed).
- [widescreen.bg2d] is a gen-time hook on the per-layer background tile
renderer (MMX6 FUN_800270d0) at its column-count / start-tile-column /
start-screen-x instructions, routed through gpu.c psx_ws_mmx6_bg_{cols,
startcol,startx}. It draws LEFT=ceil(nw_offset/16) extra 16px columns on
BOTH sides so the loop fills the 16:9 reveal margins with real adjacent
stage, existing columns staying pixel-aligned with objects. Identity at
4:3 and in the engine's own 512 hi-res mode (so the title is untouched).
Tooling: wide_full debug command + sw_wide_dump_full dump the entire
native-wide compositor surface (both vertical-double-buffer y-bands and both
reveal margins) for diagnosis.
Known follow-ups: object screen-X cull widen (actors in the margins);
top/bottom flicker + stale-margin refresh on the vertical double-buffer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Proof-of-concept for 2D "overscan reveal" widescreen. The native-wide
compositor is gated off for full-2D screens (ws_game_mode() is false unless the
recompiler sprite-tag hook fires, which only Tomba's 3D actors do). MMX6 is an
all-2D sprite engine that over-draws ~192px of world past the 320 viewport
(clipped by the draw area). Env PSX_WS_FORCE_2D=1 forces game_mode so
native-wide engages: draw_offset shift + draw-area widen + wide mirror +
widened present reveal the over-drawn columns.
VALIDATED: at 16:9 the present grows to 512px and shows real world content on
both sides (enemies/terrain previously clipped) with no texture corruption —
MMX6 widescreen via overscan-reveal is viable. KNOWN ISSUE: flicker/tearing
because the wide compositor was built for Tomba's HORIZONTAL double-buffering
(surfaces keyed by VRAM X-origin); MMX6 double-buffers VERTICALLY (front Y0..239
/ back Y240..479, same X) — needs the buffer-swap/present band handling adapted.
Experiment-only (env-gated, regen-free); not wired to a game flag yet.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Turbo-through-loads no longer mutes audio (the mute/fade was removed), so it is
now the right default: fast loads with audio playing through and all guest
timing preserved. Flip the launcher/settings fallback default to ON
(LauncherModel + UserSettings) and refresh the now-stale "briefly mutes audio"
launcher hint to "much faster loading, audio plays through". The per-game
game.toml value still seeds the launcher toggle and a user choice in
settings.toml still overrides it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
GL-only painter's-order bug: the player drew IN FRONT of sprites he should be
behind (an in-world AP item-block's letters, a save post) — correct on the
software renderer. GP0 capture + USER-confirmed isolation pinned it to the
textured-prim batch.
flush_tex_batch drew every batch in two passes split by the per-texel STP bit:
pass 1 = all prims' STP=0 texels, pass 2 = all prims' STP=1 texels. The split
ran for OPAQUE batches too (it also drives the PSX mask bit, which STP=1 texels
must set), so a BEHIND opaque prim's STP=1 texels (pass 2) overwrote a FRONT
opaque prim's STP=0 colour (pass 1) — the in-world objects are opaque prims with
STP=1 cutout texels, so they reordered against the character.
For an OPAQUE primitive the STP bit must not gate COLOUR (every texel is opaque).
So opaque batches now draw colour in ONE ordered pass over all texels (frag.a
still carries the per-texel mask bit → colour-attachment mask correct), followed
by a COLOUR-MASKED pass that only fixes the STENCIL for STP=1 texels. Same draw
count, submission order preserved, mask preserved. Semi-transparent batches keep
the genuine blended two-pass and are isolated to one prim per batch so their
cross-prim order is also correct. Opaque foliage/terrain batching (the perf win)
is untouched. USER-VALIDATED fixed on Tomba GL.
A future single-pass for semi modes 0/1/3 (GL_ONE,GL_SRC_ALPHA with a per-
fragment destination factor) could re-batch semi prims; mode 2 needs isolation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three refinements that make turbo-during-loads the production fast-load path
at authentic CD timing, validated live on MMX6 and Tomba.
1. Arbiter bypass at authentic 1x (cdrom.c). The response arbiter (queue +
inter-IRQ gap + command latch) only matters when responses are delivered
FASTER than hardware (2x/4x/instant), where a new response can land before
the guest acks the prior one. At 1x that race cannot occur, and the queue/
gap only perturbs timing the game was validated against — which desynced
CD-streamed audio (boss/stage-select music looped/restarted early on MMX6).
set_irq/command-write/ack now take the exact pre-arbiter immediate path when
g_disc_speed_divisor == 1; the arbiter stays active for the accelerated
modes that need it.
2. Turbo engage-hysteresis (main.cpp). Only enter turbo after a load has been
continuously in progress for TURBO_LOADS_ENGAGE_FRAMES (20). Brief incidental
reads on music screens (a boss/stage-select streaming a few preview sectors
over its XA music) would otherwise flicker turbo on for a frame or two,
chopping audio and frame rate. Real loads hold for hundreds of frames, so
they lose only this short authentic-paced prefix.
3. Turbo no longer mutes audio (main.cpp). At 1x+turbo loads are sub-second,
and the mute/fade/hangover model produced more audible disruption (cuts and
"restarts") than just letting the stream play through; the audio queue is
capped, so a brief turbo burst can't build unbounded latency. FMV-skip still
mutes (fast-forwarding a whole movie's audio would be noise).
The shipped per-game config is disc_speed="1x" + turbo_loads=true: authentic
guest timing (nothing breaks, audio intact) with host-speed fast-forward during
loads (blazing real load times). Runtime-only, no regen.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two CD-controller correctness fixes toward universal fast disc loads
(disc_speed=4x/instant) without per-game tuning.
1. Response arbiter (the core fix). The PS1 CD controller serializes
responses: only one occupies the IRQ slot (irq_flag + response FIFO)
at a time, and a later response is not exposed until the guest ACKs
the current one. set_irq() previously OVERWROTE irq_flag directly,
so when responses arrived faster than the guest acked (fast disc
timing) it silently DROPPED them. Proven: 24 dropped responses at
MMX6 boot under disc_speed=instant (INT2 completion clobbering an
unacked INT3 ACK), which wedged the loader.
set_irq() now snapshots the response bytes and either exposes (slot
free, gap elapsed) or QUEUES behind the visible response. The guest
ACK edge actively pumps the arbiter (cd_arbiter_pump): drain queued
responses in order, then start a command latched while the controller
was busy. cdrom_advance also pumps once the inter-IRQ gap elapses, so
a queued response is delivered even when no further ACK edge arrives.
Commands written while busy are latched and executed on ACK.
Result: 0 dropped responses at 1x/4x/instant. 1x boots to title
identically (no regression). The overwrite counter is retained as a
regression detector (now only counts true queue overflow).
2. Command-completion latency is now fixed, not speed-scaled. Pause/
Init/SeekL/GetID/ReadTOC second responses are controller/firmware
operations whose timing does NOT scale with read speed on real HW.
They used apply_speed() (divides by the disc-speed divisor), so at
4x the Pause completed 4x too fast and desynced the BIOS CD driver.
New cmd_latency() keeps them at authentic timing; only sector DATA
delivery (sector_delay_cycles) scales with disc_speed — which is
where essentially all load time lives, so loads still get fast.
Runtime-only (no regen). 1x verified clean on MMX6. A downstream
load-timing race still wedges 4x/instant at boot (guest spins with
interrupts disabled after a clean Pause) — tracked separately.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Model the R3000A having no usable D-cache (its data cache is repurposed as
the scratchpad): every CPU data read from main DRAM costs +6 wait-state
cycles on top of the 1-cycle opcode (DuckStation RAM_READ_TICKS = 6). The
recompiler previously charged 1 cycle/instruction flat, so BIOS/game busy-
wait poll loops ran ~6x too fast in guest time.
Concretely this fixes Mega Man X6's "This data is invalid" memory-card
error: the game's fixed-iteration TestEvent poll (A0(0xAC) directory load)
exhausted at ~43 frames while the VBlank-paced card directory op needs ~72
frames, so the poll timed out mid-operation; the retry re-called A0(0xAC),
A0(0x4F) returned card-busy, cleared A[port]=0, and orphaned the in-flight
sector completion -> permanent stall. With realistic per-read latency the
poll now outlasts the op (op runs once to completion, broken-sector index
reaches 20, terminal event fires), and the card screen shows the correct
"no save data found" message. Verified: empty card + a populated card both
load correctly; gameplay stable.
- memory.c: psx_guest_read_word/half/byte wrappers add +6 cyc for reads
whose effective physical address is main RAM (keyed on runtime address,
so relocated kernel code is timed correctly; scratchpad/MMIO/ROM excluded).
- main.cpp: wire cpu.read_word/half/byte to the guest wrappers (covers
static-recompiled and dirty-RAM-interpreted loads, incl. LWC2).
- debug_server.c: trace_read_word/half use psx_read_* directly so debug
reads (IO thread) never charge guest cycles or race the emu thread.
Also includes prior uncommitted DualShock SIO config state machine
(sio.c) + dual keyboard/pad P1 input (main.cpp) from the MMX6 bring-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Exposes turbo-through-loads as a launcher Settings option instead of a
game.toml-only knob. Turbo runs the guest unpaced + mutes audio while the CD
data stream is active (faster loads, but a brief audio cut/fade). Now defaults
OFF so loads play at natural 60fps with continuous audio (disc_speed=instant
already keeps data delivery fast); users opt into turbo via the launcher.
Wired through the full settings chain, mirroring auto_skip_fmv: UserSettings
turbo_loads/has_turbo_loads, settings.toml read+write, runtime override/seed/
apply over game.toml [runtime] turbo_loads, launcher data-model + toggle event,
and the RML row. Runtime/launcher only — no regen, codegen unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New universal FMV auto-skip: when auto_skip_fmv is on and a movie is detected
(MDEC+XA), write the CURRENT movie's per-movie frame-total down to fmv_skip_end_total
so the player's MDEC loop (Tomba FUN_8001efe8: teardown when streamed frame# >=
total-3) ends it on the next frame — a NATURAL end that reaches EVERY movie,
including ones whose caller never polls the skip button (Whoopee Camp logo, opening,
cutscene movies). Only the active movie's table entry is touched (via the movie-id
byte). Config: [video] fmv_skip_total_table / fmv_skip_movie_id / fmv_skip_end_total
(0 table => START-injection fallback). Present-suppress + audio mute (existing) hide
the 1-2 transition frames. Runtime-only, no regen. USER-VALIDATED on Tomba.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The manual PSX_OVERLAY_CODEGEN_VER must be hand-bumped on every emitter change;
when it isn't (e.g. between CPS emitter iterations), a stale-but-cgN DLL is reused
-> the v0.3.0 black screen. Now hash_codegen.cmake hashes the recompiler codegen
sources into a (gitignored) overlay_codegen_hash.h at build time; overlay_api.h
exposes PSX_OVERLAY_CODEGEN_HASH and the cache path becomes gcc/<arch-abi>/cg<N>_<hash>/.
The loader and compile_overlays.py read the same value, so any emitter change
auto-invalidates the cache (old dirs coexist, no stale load). Falls back to hash 0
when the header is absent (fresh checkout) — identical to today's single-cgN path.
Verified: build emits hash 0x347751c1; autocompile + loader both use cg4_347751c1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A persisted option value outside its declared [min,max] is now REJECTED at load
(the game's own default is used instead), so a corrupt/stale options file can no
longer inject an out-of-range value into a config global the game may use as a
jump-table index (wild jump = softlock). Ranges declared per option in
game_options.toml (min/max; absent => no validation, e.g. signed screen offsets).
Adds a format_version header (newer-than-known files are rejected wholesale).
Restore stays name-keyed (offset-drift-safe). Runtime-only, no regen.
Validated: corrupt values rejected (defaults applied); valid file restores intact.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Makes overlay_sljit_available() honor PSX_NO_SLJIT=1 (report unavailable), so
combined with [runtime] overlay_cache=false the overlay path falls entirely to
the dirty-RAM interpreter. An A/B isolation aid for bisecting the static<->interp
boundary vs JIT codegen. Off by default; benign in release.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Keeps the merged Vulkan renderer as a silent, build-excluded WIP: gpu_vk_renderer.c
compiles to its inert stub unless -DPSX_ENABLE_VULKAN=ON, so master builds and
behaves identically to the GL/software path with no SDK/glslc dependency even on
machines where $VULKAN_SDK is set. The OpenGL low_latency_input + vsync/swap-interval
optimization stays active. Re-enable the WIP with -DPSX_ENABLE_VULKAN=ON.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per-op counters (allocs/alloc_kb, oneshots, submits, syncs, pack_flushes,
blits, upload_blocks, copy_rects, geo/tex flushes) accumulate per frame and
snapshot into a 256-entry ring at each present(); queryable via the debug
server "vk_perf" command (count=N, default 60). Always on (Release too),
bounded memory. Purpose: pinpoint which op explodes during the VK transition
stall (e.g. make_staging vkAllocateMemory churn) instead of guessing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Vulkan backend alongside software + OpenGL: geometry/textured-prim
pipelines, GPU-authoritative VRAM image, deferred-submit + drain-at-present
perf model, GPU-direct present (vk_renderer_present_vram), and a
full-VRAM-pack readback path (ensure_cpu rect_adds the whole VRAM rect
before pack_flush so screenshots/guest VRAM reads reflect live content,
not just the incremental pack-dirty rect — readback-only, no per-frame cost).
Runtime plumbing: --debug-port / --renderer / --window-title CLI overrides
(multi-instance fleet), latency_ring, SPIR-V shaders + embed_spirv.py,
config_loader renderer selection, debug_server screenshot wiring.
All on feat/vulkan-renderer; snapshot in backup/vulkan-wip-20260617.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pre-existing uncommitted launcher.cpp work (~50 lines, inert) carried on bug/recursion;
committed for a clean tree as the branch lands. No CPS dependency.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The user-requested async overlay tiering (interp-now / compile-async / native-next, gcc-if-present
else sljit, always async, file-based handoff) is LARGELY ALREADY IMPLEMENTED — overlay_capture.c
(autocapture on interp pressure) + autocompile.c (background compile via a configured command,
toolchain-probed, watcher thread, file-based) + the code_provider abstraction. It just needed
CPS wiring.
This commit:
- overlay_loader_apply_live_policy: when the active backend is gcc, force sljit-live OFF so a gcc
box is gcc>interp (NOT the previous complementary gcc+sljit). Toolchain-less boxes stay
sljit>interp. PSX_OVERLAY_SLJIT_LIVE still overrides. ("mutually exclusive" per user.)
- ASYNC_OVERLAY_COMPILE.md: re-headed to reflect the feature is built+validated under CPS, with
the deltas this session added (compile_overlays --cps from §25.5, the gcc-only policy, the
dev-config overlay_autocompile_cmd --cps).
VALIDATED under CPS: autocapture fires -> background compile_overlays --cps produces CPS overlay
DLLs -> loader loads them -> dispatch_native rises; gcc box shows 0 sljit shards; ce_profile flat.
The native overlay cache auto-builds as you play (each scene compiles on its first sustained
visit). PRODUCTION game.toml overlay_autocompile_cmd still needs --cps appended when CPS ships.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RECURSION_BUG.md §25.5. Brings the overlay native path under continuation-passing so the
idle-freeze leak fix holds with overlay_cache=true (the production gcc>sljit>interp path).
Runtime CPS-mode flag g_psx_cps_mode (overlay_loader.c) set by a __attribute__((constructor))
emitted in the BIOS + game CPS dispatch (full_function_emitter.cpp / main_psx.cpp). Gates all
overlay/sljit CPS behavior; legacy unaffected.
sljit JIT (overlay_sljit.c), scoped:
- jr $ra publishes cpu->pc (was a bare return). jr rX already tail-transferred.
- call-containing fragments DECLINE to the interpreter (CPS-safe, depth-1) — leaf shards still
JIT. (Full sljit call coverage / per-fragment entry-switch is future work.)
gcc-DLL path — FULL native overlay coverage (validated):
- compile_overlays.py --cps sets PSX_CPS for the recompiler -> CPS overlay C (tail-transfer +
per-function entry-switch; --overlay mode only affects widescreen, not CPS). DISPATCH_PREAMBLE
psx_syscall decl fixed void->int.
- PSX_OVERLAY_CODEGEN_VER 3->4: fresh cg4/ cache namespace; legacy unit-model DLLs (cg3) ignored.
- overlay_loader_dispatch: under CPS, when a dispatched addr is not an overlay ENTRY but lies
inside a registered candidate's code range (a tail-transferred call's continuation),
overlay_find_by_range() re-enters that function with cpu->pc=addr so its entry-switch routes
to the block. gcc DLLs are the trusted tier (diff-validated at entry) -> run native directly.
This is the CPS equivalent of the old unit-model that fixed the dwarf->overworld blue screen.
Validated: CPS overlay DLLs compile + load + dispatch_native>0; with continuation routing
dispatch_interp_fallback -> 0 (full native), ce_profile FLAT (6 KB), boots clean. Addresses are
KSEG0-consistent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The complete §25 fix: make BOTH recompiler emitters emit guest calls as
TAIL-TRANSFERS driven by the unified flat trampoline + the guest's own $ra/$sp,
instead of nesting one host C frame per guest call. The host stack can no longer
mirror unbounded guest re-entrancy — the architectural root of the ~14-min idle
freeze. Gated gen-time by PSX_CPS; legacy codegen byte-identical when unset.
VALIDATED (user): full-CPS Tomba boots BIOS -> FMV -> New Game -> village idle and
plays; ce_profile cur_max_kb FLAT at 5 KB from frame ~9.9k through 56,890 (turbo
soak) — blew past the ~50k-frame freeze threshold with the host stack bounded
(legacy climbed +1.17 KB/frame -> 48 MB -> freeze).
KEY CORRECTION to the prior session's §25.1: there are TWO emitters. The BIOS
(full_function_emitter.cpp) was done last session but the §8 leaking chain
(func_8001A954 ...) is GAME code, emitted by code_generator.cpp (via main_psx.cpp)
— never converted. Also, §25.1's "continuations not in the dispatch table"
hypothesis was wrong (they were always added); the real boot regressions were
found by dumping the always-on fntrace ring on the abnormal psx_dispatch return.
Emitter changes (gated PSX_CPS):
- full_function_emitter.cpp (BIOS): BIOS A0/B0/C0 vector runtime-fallback
tail-transfers instead of nested psx_dispatch_call (was the frame-0 exit).
- code_generator.cpp/.h (game): jal/jalr/jr-$ra/jr-table/branch-split/j-split ->
tail-transfer (jalr captures rs before the link, alias-safe); each call's
return point registered as a dispatchable continuation; entry-switch (keyed on
cpu->pc) at the top of every function AND every overlapping-alias body. 0
psx_call_contract / call_by_address emitted under CPS.
- main_psx.cpp: psx_dispatch_game_compiled gains continuation cases
(case cont: cpu->pc=cont; func_owner(cpu)); entries clear cpu->pc.
- strict_translator.cpp: syscall emits `if (psx_syscall(...)) return;` so a void
syscall falls through to the inline post-syscall jr $ra (real-HW EPC+4).
Runtime changes (the legacy "return via nested dispatch / cpu->pc=0" idioms that
exit the flat CPS trampoline):
- traps.c: psx_syscall returns int (0=void/fall-through, 1=transfer);
Enter/ExitCriticalSection return 0; case-3 ChangeThread-success returns 0 — the
fiber scheduler leaves cpu->pc=0 on resume, which was the frame-712 exit.
- cpu_state.h / overlay_api.h: psx_syscall signature void->int; overlay callback
ABI bumped 4->5 (stale overlay caches rejected).
- dirty_ram_interp.c: gate interp_enter_compiled behind PSX_HAS_GAME_DISPATCH
(un-breaks the BIOS-only build).
- main.cpp: dump the always-on fntrace ring tail + stack to psx_cps_exit_trace.json
on an abnormal psx_dispatch return (diagnostic; CLAUDE.md ring-buffer model).
Docs: RECURSION_BUG.md §25.2-§25.4. Remaining: dwarf->overworld playtest;
Stage 3 (sljit/overlay CPS parity); then consider making CPS default.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Oracle disambiguation (RECURSION_BUG.md §23–§24). Traced one func_8001A954
invocation on real HW (psx-beetle) vs the recomp in the IDENTICAL Tomba idle
state (New Game village dialogue; object 0x801FD950, state/scene/jumptable=0).
VERDICT: real HW's per-frame chain is BALANCED — func_8001A954 is entered once
per frame from main_loop (JAL 0x8001A630, sp=0x801FE3E0) and RETURNS once per
frame (jr ra @0x8001A9E8 -> 0x8001A638, SP fully restored); no re-entry, the
overlay 0x80063864 never calls back (refutes §21/§22's "call-cycle"). The recomp
runs the same guest flow and the guest SP unwinds identically, but the HOST stack
leaks +1.17 KB/frame (ce_profile, live-reconfirmed: 20099->22215 KB / 1807 frames)
because the per-frame compiled chain (func_8001A954->AC00->B2B4->B5A8->func_80046264,
emitted as DIRECT func_X(cpu) host calls) re-enters itself via func_80046264's
CD-DMA-dirty hop and never unwinds.
native_stack early-trip (PSX_STACK_GUARD_KB=6144, frame 7592, 6 MB) pins the
leaking host cycle. Cross-checked: xprobe mixed_depth bounded at 6, dirty-insn/
dirty-block show NO literal func_8001A954 transfer, and a954=1/frame is a SURFACED
guest tail-transfer (j/jr) to func_8001A954 that the oracle proves real HW never
makes — i.e. there is both (A) a correctness divergence (spurious per-frame
re-entry) and (B) the architectural host-mirroring limitation that amplifies it.
Fix scope CORRECTED from §23's "quick contained runtime patch": the UNIVERSAL fix
is §11 — route in-range guest calls through the depth-managed psx_dispatch_impl
trampoline instead of mirroring them as direct host calls, so the host stack stays
bounded regardless of guest re-entrancy (real or fabricated). §12 (CD-DMA dirty
precision) and fixing the specific divergence are surgical mitigations, not
universal. Performance loss accepted for now (static recomp: right before fast).
Instruments added (runtime-only, no regen):
- SP capture in the fntrace control-flow ring on BOTH backends (GPR[29] read live):
psx-beetle (beetle_libretro.cpp + beetle_debug_server.c) and the recomp
(fntrace.h/.c + debug_server.c) — enables the func_8001A954 SP/RA-lifecycle oracle.
- psx-beetle default debug port 4380 -> 4382 (+ --port N override): 4380 collided
with psxref and cdirecomp/CdiRuntime.exe; two LISTENers on one port silently
misroute connections (the phantom "oracle keeps dying" early in the session).
Oracle tooling: the control-flow oracle is psx-beetle (Beetle interprets -> records
every J/JAL/JR/JALR), NOT psxref (no PC trace; the assumed emu_step/emu_trace_addr
never existed). Loads Tomba via: psx-beetle.exe --disc tomba.cue <bios> --port 4382.
Next: implement the §11 universal fix (codegen + full regen).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The ~14-min Tomba idle freeze is a gradual per-frame host-stack LEAK (not a
within-one-frame spiral, not the gradual-marking theory). Confirmed via a new
compiled-entry depth profile (§19): per-frame MAX guest-stack-used climbs dead-linear
+1.17 KB/frame from gameplay start to 48 MB on the 64 MB main-thread stack -> native
stack guard -> freeze. Once per frame an interpreted overlay (reached via
func_80046264's dirty hop jal 0x80063864) does jalr func_8001A954, nesting a host call
that never unwinds (the per-frame loop never returns). Full writeup RECURSION_BUG.md
§16-§21.
Instruments (always-on rings, runtime-only, no regen):
- §17 stack_profile: per-vblank host-stack sample (main.cpp; stack_profile TCP cmd).
- §18 xprobe / xprobe_arm: interp<->compiled boundary crossing flight recorder
(dirty_ram_interp.c; per-frame summary + per-crossing detail).
- §19 ce_profile: per-compiled-entry depth profile + raw TEB at the trip
(debug_server.c; ce_profile TCP cmd) — the trustworthy instrument; the regression
check for the fix (max_kb must stay FLAT).
- PSX_STACK_GUARD_KB early-trip + PSX_FIBER_STACK_KB (debug_server.c / traps.c).
Fix progress:
- Block-loop tail-transfer surface (dirty_ram_interp.c ~1514): surface compiled
tail-transfers to the trampoline instead of nesting. Correct for tail-transfers but
RULED OUT as the leak (§19 climb unchanged) — the leak is the CALL path (jalr/jal).
- Real fix (next): surface compiled-game CALLS to the owner trampoline, gated to
compiled-game targets only (a naive all-targets surface reintroduces the
dwarf->overworld lost-continuation bug). Needs a non-nesting membership test
(generated psx_dispatch_game_has) or §12 CD-DMA dirty-marking precision.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Run #1: interp->0x8001A954 edge is ORDINARY (1/frame). Run #2: dispatch_8001A954=0
(dirty_ram_dispatch never called with 0x8001A954) AND recursion_func VARIES
(0x8004DEE0/0x8004DFA0/0x8005FF60) -> address-specific counters are the wrong
instrument. dirty_block_tail shows the real cycle: dirty_ram_dispatch(overlay
0x8011-0x8013) called from main-EXE 0x8002-0x8005 = the per-frame MESSAGE/RENDER
overlay system recursing. SITE_CAP 256->32 fixes the report-JSON overflow. Pivot:
use data-driven rings (native_stack/dirty_block_tail/dispatch_tail) from a clean
report, not guessed addresses; then last-writer slice on the loop termination var.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per-frame count of the re-entry edge interp->func_8001A954 (the per-frame loop
head) + cycle counter, in a 256-frame ring the crash report dumps
(dirty_ram_re954_json). Answers ChatGPT's step-1 question: is the re-entry
ORDINARY bounded per-frame behavior that stops terminating at ~50k, or a new edge
— and whether cycles advance (it's within ONE frame; 23k laps ~= 23k cycles <<
565k/frame, so no VBlank before overflow => control-flow cycle, not starvation).
docs/RECURSION_BUG.md §15 captures the full strategy reset (stop RAM-diffing;
triggered control-flow recorder + backward last-writer slice; narrow oracle use
only on the resulting small address set). The separately-navigated oracle RAM-diff
(§14) was a dead end (alignment noise).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Trigger the interp->compiled bail off ACTUAL host-stack usage (TEB StackBase -
rsp) instead of a nesting counter. The counter leaked across the fiber exception
longjmp (psx_exception_longjmp never returns -> the decrement is skipped) and
crept up until it falsely tripped on a benign call (observed: the first NPC
dialogue froze). Stack usage can't leak; legitimate code stays far below the
watermark, only the runaway approaches it. PSX_MIXED_STACK_KB (default 700).
NOTE (validated live): this bounds the host stack (no overflow crash) but is a
SYMPTOM fix. With overlay_cache OFF, every dialogue is interpreted and the SAME
boundary recursion fires at the FIRST dialogue; surfacing into the wedged guest
state spins the main thread to "(Not Responding)". Cache ON renders early
dialogues natively (correct) -> freeze only ~14 min later. => the INTERPRETER's
execution of the message overlay diverges from the native path (Hypothesis C).
Root cause is upstream of this fix; see docs/RECURSION_BUG.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The dirty interpreter nested psx_dispatch_game_compiled on every interp->compiled
control transfer. A guest tail-dispatch loop crossing the boundary (interpreted
overlay <-> the main-EXE per-frame loop func_8001A954) grew the HOST stack
unboundedly while the GUEST stack stayed flat -> native-stack guard -> freeze
(docs/RECURSION_BUG.md §7).
Fix (runtime-only, no regen): interp_enter_compiled() lets only the FIRST
interp->compiled crossing on a native chain nest; a NESTED crossing publishes the
target as a wild-flow g_psx_call_bail. The existing contract unwinds it to the
outermost psx_dispatch_impl, whose flatten path re-dispatches cpu->pc in its loop
-> the mutual tail-recursion becomes bounded iteration. Routed exec_one JALR/JAL +
dirty_ram_dispatch_inner's surfaced-target path through the helper. Gated by
PSX_MIXED_OWNER (default on; =0 restores the old nesting for A/B).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
append_native_stack walks raw host stack memory inside the crash dump;
on a hostile crash state (e.g. the recursion's exhausted fiber stack) it
can re-fault and take the whole report with it (silent SIGSEGV, no JSON).
Write a complete-but-native_stack-less report to disk FIRST so the trigger
context (reason, cpu, recent_fn) always survives; the final fwrite still
overwrites it with the full report if the walk completes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Snapshot of the uncommitted native_stack capture tool (crash_trace.c,
append_native_stack) that recovers the true compiled<->dirty-interp
recursion cycle the recent_fn ring can't show, plus docs/RECURSION_BUG.md
documenting the freeze root cause, the 4-instance soak reproduction
harness, and the overworld-load regression now blocking repro.
This is the build that currently insta-crashes soaks on overworld load;
preserved as-is before hardening so the regression stays reproducible.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Removes the widescreen-debugging convenience that forced slot 0 connected and
OR'd the keyboard into Player 1 every frame regardless of the assigned device.
P1 now follows its configured device like P2 (keyboard still drives P1 when the
device is "keyboard", which is the default). The hybrid pad-mode logic is
unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SW renderer: add the same gated centre-stretch the GL renderer does. wide_bd_get()
resolves a per-prim scale (psx_ws_prim_in_backdrop gate + g_wide_w/native_w about
g_wide_cur_base+native_w/2, matching wide_set_bd_scale) and wide_bd_x() maps each
wide-mirror vertex x. Applied to every wide-mirror prim: flat/gouraud/textured/
shaded-textured triangles (per-vertex), and flat/textured/scaled rects (scale
x+width). Textured rects route through the SCALED rasterizer when stretching so a
widened sprite stretches its texels instead of tiling. Identity for non-backdrop
prims and at 4:3, so software output is unchanged outside native-wide backdrops.
launcher: drop the "Tomba Special Edition" reference from the Hybrid pad tooltip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Folds feat/controller-input-modes (hybrid/analog/digital pad mode + 3-way
launcher selector, e0410e9) on top of the native-wide 2D-backdrop stretch fix so
both can be tested in one build. No file overlap between the two.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts:
# runtime/src/main.cpp
Replaces the analog on/off boolean with a per-player PadMode {hybrid, analog,
digital}. Hybrid (default) auto-switches the reported pad from the most-recent
input: nudge the stick -> DualShock (0x73, variable sticks); press the D-pad ->
digital pad (0x41) so the game runs its own digital path at true d-pad
sensitivity -- mirroring a DualShock's analog LED and Tomba Special Edition's
auto-detect. analog pins 0x73; digital pins 0x41.
config_loader parses [controller] default_mode / p1_mode / p2_mode (legacy
default_analog / p1_analog / p2_analog booleans still accepted); the launcher
exposes a Hybrid|Analog|D-Pad segmented selector per player with a tooltip;
settings persist as p1_mode / p2_mode.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The far 2D backdrop (a sprite-tagged tile grid: sky gradient band + flower-ball
field) draws first each frame, before the GTE 3D world, and bypasses the GTE -- so
in native-wide it stays 4:3-width and leaves a void on the revealed margin (the
flower seam and, behind it, the gradient-blue sky seam at the same width).
Fix: gate the existing GL centre-stretch on native-wide + sprite-tagged +
BACKGROUND PHASE. A per-frame latch (ws_bg_phase_note at GP0 decode) marks the
phase over once the first shaded/gouraud prim (the 3D world's signature) is drawn,
so the early tagged backdrop tiles are stretched (filling both the flower and sky
seams, width scaled too -> no inter-tile gaps) while HUD/characters (tagged but
post-3D) and the GTE world (untagged, already widened) are excluded.
Replaces the dead flower-struct address-range gate (drawn prims are rebuilt into
the OT, disjoint from the struct -> never matched). USER-VERIFIED live: both seams
gone, HUD/Tomba/3D undistorted. ws_dbg_stretch debug modes retained for analysis.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Live-tunable selectable stretch gate to identify which drawn prims form the
native-wide 2D-backdrop void: ws_dbg_stretch modes = OT-range / clut / tpage /
tagged / untagged-tex / all-tex, with per-frame matched + matched-tagged counts
(psx_ws_dbg_gate_frame_snapshot). Routes through the existing GL centre-stretch.
Confirmed live: the void layer is the early-drawn, SPRITE-TAGGED 0x65 background
grid (6 cols x 7, step-64, cluts unique to the backdrop); stretching it about
screen-centre fills the void with no inter-tile gaps (GL scales width too).
Tagged fills it; untagged (3D) does not. The prior content-match plan is
DISPROVEN (template transformed beyond recognition before draw) -- corrected
model recorded in BACKDROP_PRELOAD.md. Fix gate chosen: native-wide + tagged +
background-phase (draw-time), since backdrop X is written by a shared sprite
helper with no clean per-emit source signal.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 16:9 dwarf/flower-field void is native-wide 2D-backdrop POSITIONING, not a
column-loading problem. The earlier auto_backdrop whole-row preload is proven
IRRELEVANT (ws_backdrop_ring: whole 41-col row loads, void persists; margin off
vs whole-row pixel-identical). The flower-field is a fixed-screen 2D tile field
(static 11-bit vertices, ~4:3 width) that bypasses the GTE, so native-wide (which
widens 3D via the GTE) never widens it. Fix = geometric X-stretch about
screen-centre of the 2D backdrop in the GL wide-mirror — USER-CONFIRMED it fills
the void (with the gate disabled). Full record: BACKDROP_PRELOAD.md (2026-06-16).
Remaining: gate the stretch to ONLY the backdrop (currently also stretches Tomba/
HUD/3D). Path forward = draw-time gp0_cmd_source_addr / sprite-tag classification.
Runtime WIP checkpoint (all behind native-wide + runtime toggles, 4:3 untouched):
- gpu_gl_renderer.c: u_xscale/u_xcenter geometric stretch in the geo+tex
wide-mirror passes; per-frame backdrop-phase reset moved to gl_perf_present_enter
(both present paths); textured-only phase mode; per-prim trace + dbg counters.
- gpu.c: ws_backdrop_ring (always-on rewrite ring); ws_backdrop_margin live
column-widen (proven irrelevant); psx_ws_backdrop_value whole-row path.
- dirty_ram_interp.c: ring capture (extent/camx/count/a1/dl) at the interp hook.
- debug_server.c: ws_backdrop_ring / ws_backdrop_margin / ws_backdrop_stretch.
- main.cpp: keyboard ALWAYS drives player 1 (OR'd with any controller).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
[widescreen.cull] auto_backdrop — generic far-backdrop column preload for
16:9. A shared detector (runtime/include/ws_backdrop_detect.h) finds each
scrolling-backdrop column-window generator by its /96-magic + 0x176-camX +
sra,5 divide-tail invariant and records the window's START/END bound
instructions + width (window_cols). All four execution paths route those
bounds through psx_ws_backdrop_value(orig, is_end, window_cols), which widens
the camera-tracked window by exactly the 16:9 reveal (margin scales with
window_cols; identity at 4:3 so the build stays byte-identical with widescreen
off). gcc emit + sljit + dirty-RAM interp + overlay ABI v4 / codegen ver 3.
Also: CodeGenConfig::overlay_mode — explicit [widescreen.*] per-address site
shape checks now SKIP (apply only where bytes match) instead of hard-exit in
overlay mode, because overlay regions reuse the same address for different
code across scene variants. Main-EXE mode keeps the hard error.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On macOS, defining _POSIX_C_SOURCE restricts the visible namespace and HIDES
BSD symbols the TCP debug server needs — notably INADDR_LOOPBACK (and
struct timeval). clang then fails with 'use of undeclared identifier
INADDR_LOOPBACK'. Use _DARWIN_C_SOURCE on Apple (which exposes the full
BSD+POSIX namespace incl. clock_gettime), keeping _POSIX_C_SOURCE for glibc.
Mirrors the same __APPLE__/_DARWIN_C_SOURCE guard already used in psx_fiber.c.
handle_synth_recurse (a command handler, compiled even in Release) called
debug_server_synth_recurse_arm, which is defined only under #ifndef
PSX_NO_DEBUG_TOOLS — so a Release build (PSX_DEBUG_TOOLS=OFF) failed to link with
an undefined reference. The self-test is debug-only and the TCP server never
starts in Release, so guard the call; the handler is dead code there.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
.card used flex-basis auto (content-sized), so selecting a controller — which
lengthens that card's device/status text — widened Player 1's box relative to
Player 2. Use flex-basis 0 + min-width 0 so the row always splits evenly
regardless of content.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Persist validated sljit shards across launches (SLJIT_PERSIST_CACHE.md). On JIT,
serialize the (position-independent, Stage 1) LIR and write it to
<cache>/<game_id>/sljit/<arch-abi>/cg<N>/<entry8>_<crc8>.sljit with a small header
(magic, format/helper-order versions, entry/lo/len/crc/size). At init, scan that
dir, deserialize + sljit_generate_code for THIS process, and register each as a
dll<0 candidate carrying the blob's crc; dispatch re-hashes live RAM against it so
a reloaded shard re-earns trust exactly like a fresh JIT. Priority gcc > sljit >
interp is unchanged (a gcc DLL still supersedes a reloaded shard via the obsolete
path). Persist is write-on-generate (measured negligible; crash-safe).
Threads the serialized blob through OverlaySljitResult -> CompiledFragment;
register_sljit_candidate gains a crc_override (reloaded shards use the header crc
since the overlay RAM may not be loaded at init). sljit_status exposes
reloaded / persist / reload-diagnostic counters.
Two gotchas found + fixed in testing (vs the original design notes):
- serialize MUST precede generate_code (generate sets compiler->error =
SLJIT_ERR_COMPILED, after which serialize returns NULL);
- serialize with options=0, not SLJIT_SERIALIZE_IGNORE_DEBUG, so an
argument-checks build can deserialize its own blobs.
Validated: two-launch test reloaded 12 shards (deserfail=0), ran them at a fresh
process base with no crash, gameplay renders correctly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Prerequisite for persisting sljit shards to disk (SLJIT_PERSIST_CACHE.md). The
JIT baked absolute host-helper pointers as SLJIT_IMM operands at three sites
(emit_helper2: memx/cop2; emit_ws_cull; PEND_CALL: call), so a shard serialized
in one process would call stale addresses when reloaded at a different ASLR base.
Route all four helpers (SLJIT_HLP_MEMX/COP2/WS_CULL/CALL) through a cpu-relative
table CPUState.sljit_helpers[], loaded via MEM1(R_CPU)[offset] instead of a baked
immediate, so serialized LIR holds zero absolute host addresses. The table is
appended at the END of CPUState (existing gcc overlay-DLL field offsets stay
put) and populated once at startup (overlay_sljit_init_helpers). In-session JIT
is unchanged behaviourally (one extra indirection per helper call).
Smoke-tested: forced sljit with the gcc cache moved aside, 12 shards JIT'd and
ran, title renders, no crash. Broader playtest folds into the Stage 2 reload test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Controller (item 3): present an analog (DualShock) pad by default for
analog-capable games so the left stick gives variable run speed; fold the
physical D-pad / keyboard arrows onto the left analog axes at full deflection so
they still move the character (avoids the Special Edition full-throttle-D-pad
bug). Add a launcher-editable analog-stick deadzone option (settings.toml +
input.ini + game.toml [controller] default), modeled on snesrecomp.
Settings persistence (item 6): deterministic restore-at-init + save-on-exit for a
game's native OPTION-screen settings, declared per game in a dedicated
game_options.toml. The recompiler rewrites each setting's boot-init store (sb/sh)
to route its value through psx_game_option_store(addr,val), applying a value
persisted from a prior session over the game default (identity when none, so a
fresh install is byte-identical). The runtime saves the live config globals to
<memcard_dir>/<game_id>.options on exit (atexit). Adds a game_options debug
command. Requires a regen for the hook to emit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The native stack guard captures a report when a runaway guest recursion is
about to overflow, but the report couldn't reliably NAME the recursing function:
the SEH stack_scan (host offsets, build-specific) is absent on the graceful-halt
path, last_func_addr comes from the dispatcher (blind to direct func_X
recursion), and the existing fn-entry ring is debug-only + fntrace-gated (empty
in release).
Add an always-on (debug AND release) ring of the last 64 recompiled function
entries in GUEST addresses, plus g_psx_recursion_func = the function being
entered when the guard trips. crash_trace dumps recursion_func + recent_fn. For
a runaway the recent_fn tail repeats the recursing func, so the next ORGANIC
crash is root-causable in Ghidra with no symbol map — pair it with cpu.gpr (the
args/corrupt value at the trip).
Validated via the new `synth_recurse` debug self-test (forces a runaway): guard
halts cleanly, report shows recursion_func=0x8000DEAD + recent_fn all 0x8000DEAD.
No measurable overhead (16:9 stays 60fps); no false-trips across village play.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The watchtower force-close (and the matching GitHub issue) is a host C-stack
overflow (SEH 0xC00000FD) from runaway guest recursion — the seesaw/Bug-D
wild-call family, where a corrupt jump-table loops a handler back on itself.
An in-range guest `jal` emits a DIRECT func_X(cpu) call, so this recursion grows
the native stack WITHOUT going through psx_dispatch_impl: g_psx_dispatch_depth
(and the existing depth-counter guard in dirty_ram_dispatch) is blind to it —
the captured report shows STACK_OVERFLOW with dispatch_depth=0.
Add a path-independent guard at debug_server_log_call_entry (emitted at the top
of every recompiled function, so it covers direct-call recursion too, and runs
in release since it precedes the PSX_NO_DEBUG_TOOLS early-return). It reads the
running fiber's real stack bounds from the x64 TEB (StackBase GS:[0x08],
DeallocationStack GS:[0x1478]) and, when headroom drops below 1/4 of the stack,
halts via psx_fatal_halt — captured crash report + halt-and-serve in debug,
clean exit + report in release — BEFORE the stack overflows. Guest fibers are
1 MB (traps.c); the 1/4 headroom only trips on runaway recursion (thousands of
frames), never on legitimate gameplay depth. Verified: no false-trigger across
extended village play (frame 6117); compiles + present in both debug and release.
TENTATIVE containment + diagnostics, NOT a root fix — converts a silent
force-close into a captured, parseable report (dirty_block cycle names the
recursing PCs) so the intermittent crash can finally be root-caused from a
repro. The upstream data corruption is unaddressed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Queue textured prims sharing blend/mask/texwindow/filter into a single
glDrawArrays instead of one draw (x2 STP passes, x2 wide) per primitive. With
per-prim texture state already in vertex attributes (prior commit), only those
keys force a flush. flush_tex_batch() drains the queue before any op that reads
VRAM, writes it outside the batch, or changes batch state (flush_cpu_upload on
upload, flush_pack_if_sampling on pack, ensure_cpu readback, flat/fill/copy,
draw-area/offset + wide-target setters, every present path); the batch carries
its own captured blend/mask/twin state so those changes split it cleanly.
Measured (Tomba village, frame_perf, debug build): 16:9 scene_gpu 28.3->16.0ms
=> ~34fps to ~60fps; 4:3 16.5->5.5ms (3x headroom). Visually identical across
the opening scene. The cost was ~14us per glDrawArrays x ~1000-2000 draws/frame;
batching collapses the draw count. Remaining 16:9 cost is the native-wide
double-draw (single-pass wide render is the next lever if more headroom needed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move texpage/clut/depth/raw/uv-limits from per-prim uniforms into flat vertex
attributes (TEXV=18-float vertex). Keeps per-prim draws for now — verified
pixel-identical in the Tomba village and perf-neutral (scene_gpu 28.3 vs 28.4ms;
the cost is draw-call bound, not vertex-data bound). This is the foundation for
coalescing consecutive same-state textured prims into one draw: with texture
state per-vertex, only blend/mask/texwindow remain as batch keys.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Measured Tomba village = 93% textured prims, so draw batching must target the
textured path (per-prim uv-limits/texpage/clut/depth move to vertex attributes
so consecutive textured prims coalesce into one draw). Flat batching alone
would touch only 7% of prims.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds prims/frame and per_prim_us to the frame_perf aggregate. Proved Tomba's
village is draw-call bound: ~1000 prims/frame at ~14us of GPU time PER
glDrawArrays (a 1-triangle draw should be <1us) — pipeline-flush overhead from
re-binding state between every primitive. 16:9 is 2x slower purely because the
native-wide double-draw issues 2x the draw calls. Points squarely at draw
batching (group consecutive same-state prims) as the fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Always-on per-frame ring in the GL renderer: two GL_TIME_ELAPSED queries
bracket each frame's scene draws vs the present clear+blit (true GPU time,
independent of CPU/GPU overlap), plus SDL perf-counter CPU wall time
(present-to-present total, present call). Exposed via the debug server
"frame_perf" command, aggregated over the recent ring and split by 4:3 vs
native-wide so a 16:9-vs-4:3 A/B reads off one query.
Built to answer where a frame goes without guessing. First use proved
Tomba's 16:9 village lag is the native-wide DOUBLE-DRAW (every primitive
re-issued into the wide FBO), NOT supersampling (SS2==SS1) and NOT the
compositor (present_gpu ~1ms): scene_gpu 16:9=28ms vs 4:3=17ms.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>