The controller map had no l3/r3 rows at all, so pad bits 1/2 could never
be produced by a gamepad — DualShock-only games (Ape Escape) were
unplayable. Keyboard slots existed but shipped unbound.
- main.cpp: PAD_L3/PAD_R3 bits; controller_map 14->16 entries; defaults
l3=leftstick, r3=rightstick; input.ini template + doc comment updated.
- psx_keybinds.c: keyboard defaults L3=T, R3=Y (continue the Q/W/E/R
shoulder row). Launcher Controls page picks them up automatically.
- beetle_main.cpp: same T/Y keys on the oracle so psx-beetle stays
drivable for L3/R3 games.
- tools/package_release.ps1: shipped input.ini template gains l3/r3.
- README: keyboard + controller map tables.
Wire is unchanged: sio.c transmits the button word verbatim, matching
Beetle's dualshock.cpp. Validated in-game on Ape Escape (user-confirmed
R3/L3 inputs on a real pad).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Beetle-side fntrace ring recorded only a0/a1; four-argument kernel and
game calls could not be attributed from the oracle side. Capture a2/a3 via
PS_CPU::GetRegister at the trace hook and emit them in fntrace_dump, matching
the native runtime's fntrace field set.
Co-Authored-By: Claude Fable 5 <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>
Root cause (freeze dump 1781046069, addr2line-verified): the wall-clock
pacing loop in sdl_vblank_present read the performance counter twice per
iteration — once in the while-condition, once to compute remaining time.
If the frame deadline was crossed between the two reads (a preemption
window), the unsigned subtraction underflowed, *1000 overflowed, and
SDL_Delay received ~2^64/10^4 ms mod 2^32 ≈ 24.7 days at the standard
10 MHz QPC. The app parks in one SDL_Delay with every counter frozen,
wedged between 'i_stat |= VBLANK' (interrupts.c:286) and exception
delivery — which is why dumps showed i_stat=1 pinned. VBLANK was being
ACK'd every frame right up to the wedge in every dump examined; the
'VBLANK-ACK reentry storm' framing was a symptom misread.
- frame_pacing.c/h: shared race-free pacer (single counter read per
decision, compare-before-subtract, sleep clamped to one period).
Both call sites switched: main.cpp sdl_vblank_present and
beetle_main.cpp. Unit test pins the underflow case.
- Vsync self-heal: the 'recovered storm' dump (1781045865) was 8/8
main-thread samples inside wglSwapBuffers — NV driver blocking ~1.5s
per present. After 3 presents >250ms, drop driver vsync (we pace to
59.94 Hz ourselves). Counters surfaced in the freeze dump.
- Watchdog classifier: 'reentry_storm' threshold (5K/sec) was below the
healthy baseline (~2K reentry blocks/frame × 30-60 fps), mislabeling
normal play and host stalls — that label misdirected two prior
investigations. Now normalized per frame (>20K/frame), plus a new
slow_frames kind (<5 fps) with multi-sample stack capture.
Verified: test_frame_pacing ALL PASS; live Tomba paced at 59.962 fps
over 20s (single-process measurement).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The actual fix for TombaRecomp/ISSUES.md #6 ("Not Responding"
freezes). Earlier claim that SDL_RENDERER_SOFTWARE fixed it
(commit 9eb4ab3) was wrong — based on a too-short stress test
that didn't reach the freeze threshold. The corrected story:
1. SDL_RENDERER_ACCELERATED → froze.
2. SDL_RENDERER_SOFTWARE (Windows GDI backend) → still froze.
3. SDL_HINT_RENDER_DRIVER=opengl + ACCELERATED → no freeze
after 11+ min / 40k+ frames, well past every prior failure.
Why it intermittently triggered: the FMV-speed fix (b486c13)
raised guest cycle throughput, which raised the GDI present
rate enough to trip the underlying Windows GDI hang. Before
that fix the workload stayed below threshold.
Three changes in this commit:
* runtime/src/main.cpp + runtime/src/beetle_main.cpp:
switch from SDL_RENDERER_SOFTWARE to OpenGL via
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "opengl").
* runtime/src/main.cpp: PSX_SDL_NO_AUDIO and PSX_SDL_NO_RENDER
compile-flag guards added for bisection. Pass via -DCMAKE_C_FLAGS
when needed; default behavior is unchanged.
* runtime/src/freeze_heartbeat.c:
- Always-on regardless of PSX_NO_DEBUG_TOOLS. This thread
was the key diagnostic — it survives main-thread stalls
because it runs in its own thread, so its ring captures
pre-freeze state when the TCP server can't respond.
- Added a 64-entry pre-freeze history ring (~6 s window)
to the heartbeat JSON. Each entry snapshots frame_count,
psx_cycle_count, exc_reentry_blocks, dirty_ram_insns,
current_func, last_store_pc, i_stat, sio_stat/ctrl,
in_exception, mc_max_state. When all those columns stop
advancing while wall_clock keeps ticking, that's the
stall point. This signal proved the freeze is in HOST
code (not guest), which redirected the investigation
from "fix the recomp" to "fix the SDL/Windows path."
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two observability additions, both gated by the new
-DPSX_DEBUG_TOOLS=OFF flag for production builds.
1. Freeze heartbeat thread (`runtime/src/freeze_heartbeat.c`):
Background thread spawned at startup that snapshots runtime
state (frame_count, psx_cycle_count, in_exception, exception
counters, sio/mc state, i_stat/i_mask, dirty_ram counters) to
`psx_freeze_heartbeat.json` every 100 ms. Survives main-thread
freezes — writer is independent of the SDL loop and the TCP
debug server. Atomic file replace via .tmp + MoveFileEx so a
reader never catches a half-written JSON.
Already paid off: captured a freeze pattern (frame_count stuck,
psx_cycle_count stuck, in_exception=0, current_func in
install-stub kernel code) that the TCP server couldn't show
because its accept queue was wedged by the same stalled main
thread.
2. PSX_DEBUG_TOOLS CMake flag (`runtime/runtime.cmake`):
- Default ON: builds with the TCP debug server, heartbeat
thread, and per-block recording (debug_server_log_call_entry
fires for every recompiled function entry).
- OFF (`cmake -DPSX_DEBUG_TOOLS=OFF`): production build. The
hot-path log functions early-return; TCP server doesn't
start; heartbeat doesn't spawn. The ring buffer arrays still
exist in the binary (their definitions live in
debug_server.c) but no writes happen so the demand-paged
memory stays committed at a much lower level (~77 MB at
idle vs ~245 MB in debug mode).
The flag propagates to psx-beetle via the option declared at
the top of runtime.cmake.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The SDL accelerated renderer (`SDL_RENDERER_ACCELERATED`) lets
`SDL_RenderPresent` block inside the GPU driver, which has been
reproducibly hanging the SDL main thread mid-frame after extended
uptime. Both psx-runtime and psx-beetle showed the same signature:
process alive, debug socket dead, partial framebuffer (top half
rendered, bottom half stale) — a classic GPU-driver-side hang.
Switching both binaries to `SDL_RENDERER_SOFTWARE` removes the GPU
driver from the path. Verified under stress: five consecutive
`wtrace_dump count=200000` (~6.2 s each, 31 s cumulative
main-thread block) without a single freeze. Beetle, which had been
freezing at ~15 min of plain title-screen idle with constant 90 MB
RSS (so memory pressure was definitively not the cause), now stays
responsive indefinitely.
Cost: a few percent more CPU. Worth it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
psx-beetle previously hard-coded "dummy.cue" in the BIOS system dir
and ejected the disc on init so the BIOS booted to its shell. That
covered BIOS-only testing but not "what should this game look like at
this frame" comparisons against psx-runtime.
Add `beetle_init_with_disc(bios_path, disc_path)`: if disc_path is
non-NULL, retro_load_game() loads that real disc and the disc stays
inserted (BIOS proceeds straight into game boot). NULL keeps the
existing dummy-cue + eject behavior. beetle_init() is now a wrapper
preserving the old signature.
beetle_main.cpp learns `--disc <path>` CLI flag.
Verified: `psx-beetle.exe bios/SCPH1001.BIN --disc tomba.cue` boots
Tomba through the SCEA splash on the SDL window. Debug server on port
4380 responds (ping returns frame counter), so the same wire protocol
that psx-runtime exposes on 4470 is now available for side-by-side
state comparison while both binaries run the same game.
Use case driving this: Issue #5 OPTIONS-black SIO protocol divergence
diagnosis — need beetle as authoritative reference for what bytes the
card should send Tomba.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Boot was running at ~17 simulated FPS (12x slower than realtime) and
audio dropouts followed because the SPU pump generated 735 samples
per simulated vblank. Two changes:
* fntrace_record now no-ops by default (record only when armed). The
legacy "record-all-when-unarmed" path made the runtime burn ~10%
CPU writing every dispatch into a 4M-entry ring. Opt-in to
record-all via fntrace_arm(0xFFFFFFFF).
* function_trace_record gated behind s_fn_trace_active flag (default
off). Previously did a stack walk + tail-call detection + 9-field
ring write on every dispatch, even when no fn-trace filter was set.
Flipped on by handle_fn_filter; new fn_disable command turns it
off again.
Combined: ~12x speedup. Recomp now hits 200+ simulated FPS uncapped,
matching beetle's ~230 FPS.
* Wall-clock pacer in sdl_vblank_present and beetle_main.cpp locks
both backends to PSX-native 59.94 Hz. Audio queue (SDL device
drains at 44100 Hz) now matches generation rate (735 samples * 60
≈ 44100/sec). TAB still works as turbo override on both windows.
Switches diagnostic methodology from a single binary running both backends
in lockstep (psx-beetleoracle.exe) to two independent processes:
psx-runtime.exe (recomp only, port 4370)
psx-beetle.exe (Beetle libretro only, port 4380)
Both expose the same JSON wire protocol for the common debug commands
(read_ram, press, set_input, clear_input, pad_status, screenshot_file,
wtrace_*, fntrace_* on Beetle). Cross-process comparison is done by
querying both ports from a tool, NOT by sharing input or memory.
Why: the embedded-oracle setup required keyboard input to drive both
backends in lockstep. Once their state diverged, the same keypress
drove them to different screens and "compare on the same press" became
unreliable. Two independent processes lets each backend be navigated
to its own state and queried on its own timeline.
Removed:
- runtime/src/beetle_psx_bridge.cpp (cannibalised into beetle_libretro.cpp)
- runtime/src/duckstation_bridge.cpp
- runtime/src/psx_oracle_cmds.c
- runtime/src/psx_oracle_stub.c
- runtime/include/psx_oracle_backend.h
- psx-beetleoracle and psx-dsoracle cmake targets
- 22 dual-backend tools (emu_read_ram / find_first_divergence / etc)
Added:
- runtime/src/beetle_main.cpp (SDL window + libretro driver + keyboard)
- runtime/src/beetle_libretro.cpp (libretro callbacks + wtrace/fntrace/sio rings)
- runtime/src/beetle_debug_server.c (TCP server, port 4380, same wire protocol)
- psx-beetle cmake target
Also bumps tooling caps surfaced during modal investigation:
- DIRTY_RAM_BLOCK_LOG_CAP: 16K -> 4M (28ms eviction -> ~7s retention)
- WTRACE_MAX_RANGES: 16 -> 64 (boot defaults filled the prior cap)
- DirtyRamBlockLogEntry: added a0/a1 fields for richer dispatch context
CLAUDE.md section 16 rewritten for the new methodology. New memory note
locks the direction so future sessions don't reach for the deleted
embedded-oracle commands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>