Commit Graph

327 Commits

Author SHA1 Message Date
Matthew Stanley 2e7b751bfc widescreen: fix vertical-double-buffer top/bottom flicker (rt_wide Y-clip)
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>
2026-06-20 12:27:42 -07:00
Matthew Stanley a8f37c1f76 widescreen: MMX6 2D true-FOV — full_2d opt-in + [widescreen.bg2d] BG widen
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>
2026-06-20 11:57:32 -07:00
Matthew Stanley 226298ae14 ws: PSX_WS_FORCE_2D experiment — engage native-wide on all-2D games (MMX6)
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>
2026-06-20 00:11:44 -07:00
Matthew Stanley 1951dc2a49 launcher: turbo-loads default ON; UI text matches new behaviour
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>
2026-06-19 23:05:49 -07:00
Matthew Stanley 15f5d60f0c gl: fix sprite Z-order — opaque batches draw colour in one ordered pass
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>
2026-06-19 22:48:34 -07:00
Matthew Stanley dbfd92f01e Merge feat/cd-adaptive-instant: turbo-loads fast-load model + CD response arbiter
Fast loads at authentic CD timing via turbo-during-loads (host fast-forward,
all guest timing preserved), plus the CD response arbiter + authentic command
latencies (dormant at the shipped 1x config, available for accelerated modes).
Validated live on MMX6 and Tomba: blazing loads, audio intact.
2026-06-19 21:41:28 -07:00
Matthew Stanley 646c83a5ef cdrom/turbo: bypass arbiter at 1x; turbo engage-hysteresis; no turbo audio mute
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>
2026-06-19 21:41:17 -07:00
Matthew Stanley c5993a9dee cdrom: response arbiter + fixed command-completion latency
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>
2026-06-19 20:37:45 -07:00
Matthew Stanley f4bbcaf4ca overlay compile: auto-detect gcc toolchain dir onto PATH
mingw gcc invoked by absolute path (the runtime's cmd.exe autocompile spawn,
or any launch without mingw64/bin on PATH) fails SILENTLY — exit 1, empty
stderr — because cc1/as/ld and the runtime DLLs resolve via PATH. _toolchain_env
auto-detects the bin dir ($PSX_MINGW_BIN, the --gcc path's dir, `which gcc`, then
common locations) and prepends it for the gcc subprocess. Fixes overlay-DLL
autocompile so it works regardless of launch environment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 19:41:19 -07:00
Matthew Stanley 98aa688928 runtime: PS1 main-RAM read wait states — fix MMX6 memcard "invalid"
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>
2026-06-19 17:50:37 -07:00
Matthew Stanley 9932dabc4c launcher: add Turbo loads toggle (Settings, default off)
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>
2026-06-19 10:34:01 -07:00
Matthew Stanley dc7f5c52f9 fmv-skip: end movies via the game's own frame-count teardown ([video] fmv_skip_*)
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>
2026-06-18 19:21:31 -07:00
Matthew Stanley 1f6713bfc0 overlay cache: auto codegen-hash in the cache path (cg<N>_<hash>)
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>
2026-06-18 18:57:52 -07:00
Matthew Stanley 905b52da57 game_options: restore-time value-range validation + format version
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>
2026-06-18 18:47:27 -07:00
Matthew Stanley 2a9d1e51ba overlay(sljit): PSX_NO_SLJIT debug knob to force interp-only overlay execution
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>
2026-06-18 18:25:14 -07:00
Matthew Stanley badfddc394 build: gate Vulkan backend behind PSX_ENABLE_VULKAN (default OFF)
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>
2026-06-18 16:03:46 -07:00
Matthew Stanley 64acc70027 Merge feat/vulkan-renderer: Vulkan backend (WIP, build-excluded) + GL low-latency/vsync optimization
Brings the 3rd renderer backend (Vulkan) in as dormant WIP and the OpenGL
low_latency_input + configurable vsync/swap-interval optimization. A follow-up
commit gates the Vulkan BUILD off by default so it stays a silent excluded WIP
(gpu_vk_renderer.c compiles to its inert stub); the GL improvements are active.
2026-06-18 16:02:39 -07:00
Matthew Stanley b5f7593728 vulkan: always-on per-frame perf ring + "vk_perf" debug command
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>
2026-06-18 15:33:05 -07:00
Matthew Stanley 0ac63b655c vulkan: 3rd renderer backend (WIP) — emitter, GPU-direct present, full-frame readback
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>
2026-06-18 14:56:12 -07:00
Matthew Stanley 16291fd1d3 Merge bug/recursion: universal continuation-passing recompiler — FIXES the idle-freeze leak
The ~14-min Tomba idle freeze was an unbounded host-stack leak: the recompiler emitted each
guest call as a nested host C frame, so unbounded guest re-entrancy (the per-frame func_8001A954
chain) overflowed the fiber. Fix = continuation-passing call/return: every call is a TAIL-TRANSFER
driven by the unified flat trampoline + the guest's own $ra/$sp, so the host stack can never
mirror guest re-entrancy — for any game.

Landed (RECURSION_BUG.md §23-§25.6):
- BIOS (full_function_emitter.cpp) + GAME (code_generator.cpp) emitters both emit CPS.
- Runtime contracts fixed (vector fallback, psx_syscall, ChangeThread fiber resume).
- Full native overlay coverage under CPS (gcc-DLL + sljit), loader continuation routing.
- Async background overlay compile is CPS-wired (compile_overlays --cps) + gcc-only on gcc boxes.
- CPS is now the DEFAULT (PSX_CPS=0 = legacy opt-out).

VALIDATED: Tomba boots->FMV->New Game->village and plays; ce_profile FLAT at 5-6 KB through a
56.9k-frame soak (legacy climbed +1.17 KB/frame -> 48 MB -> freeze); overlays go native.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 14:11:36 -07:00
Matthew Stanley 39fd406682 launcher: WIP (pre-existing, inert) — landed with the CPS branch
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>
2026-06-18 14:10:44 -07:00
Matthew Stanley 7281319005 recursion: make CPS the DEFAULT (opt-out via PSX_CPS=0)
The continuation-passing leak fix (RECURSION_BUG.md §25) is validated (Tomba boots+plays,
ce_profile flat through a 56.9k-frame soak, full overlay CPS). Flip the gate in all three
emitter sites so a normal regen emits CPS; PSX_CPS=0 is the legacy escape hatch. Verified:
default regen -> CPS markers present; PSX_CPS=0 -> none; default-CPS Tomba boots clean, leak flat.
Doc §25.6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 14:10:44 -07:00
Matthew Stanley 5c893d472a overlay(async): gcc-only on gcc boxes (mutually exclusive with sljit) + async-compile is built
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>
2026-06-18 13:54:57 -07:00
Matthew Stanley d99323c72b docs: async background overlay compilation — implementation plan
Build plan for the next session: interp-now / compile-async / native-next overlay tiering
(gcc-if-toolchain-present else sljit, mutually exclusive, always async). FILE-BASED handoff —
the worker writes .dll/.sljit artifacts; the dispatch fiber loads them via the existing
try_load_region / deserialize paths, so the candidate table stays single-threaded (no race).
Motivated by the CPS title screen being interp-bound (12.2M interp vs 14.8K native) because its
overlays aren't gcc-compiled + the sync sljit JIT-on-miss hitches the dispatch thread.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:40:45 -07:00
Matthew Stanley 2d468ee5b8 recursion(stage3): overlay (sljit + gcc-DLL) CPS parity — full native overlay coverage under CPS
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>
2026-06-18 13:35:31 -07:00
Matthew Stanley 94a1966444 recursion(fix): universal continuation-passing — FIXES the idle-freeze host-stack leak (BIOS+game)
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>
2026-06-18 12:45:02 -07:00
Matthew Stanley e28c2a0ff9 recursion(fix): WIP universal continuation-passing call/return (gated PSX_CPS) — Stage 1 emitter
The universal fix for the idle-freeze host-stack leak (RECURSION_BUG.md §25): make
the recompiler emit guest calls as TAIL-TRANSFERS driven by the trampoline + the
guest's own $ra/$sp, instead of nesting one psx_dispatch_impl per guest call frame.
This makes the host stack incapable of mirroring unbounded guest re-entrancy — the
architectural root of the leak — for ANY game. Perf cost accepted ("right before
fast").

Stage 1 (full_function_emitter.cpp), GATED behind gen-time env PSX_CPS:
- jal/jalr (orphaned-delay + pending-delay paths): emit `cpu->pc=target; return;`
  (tail-transfer) + register the return address as a dispatchable continuation,
  instead of nested psx_dispatch + psx_call_contract. JALR captures the target reg
  BEFORE the link write (rd==rs alias-safe).
- jr $ra (all 3 sites): publish `cpu->pc = cpu->gpr[31]; return;` under CPS so the
  trampoline dispatches the caller's continuation (legacy `return;` relied on the
  nested-dispatch C-return). Required — without it the BIOS exits at frame 0.

GATING PROVEN SAFE: regen+build WITHOUT PSX_CPS boots Tomba normally — default
codegen byte-unaffected; CPS fully opt-in.

OPEN (CPS path only): with PSX_CPS=1 the BIOS boots main() then exits at frame 0
(execution completed, PC=0). Root cause (high confidence): the trampoline must
dispatch mid-function continuation addresses, but the dispatch table holds only
function entries → a return to a continuation misses → psx_unknown_dispatch →
pc=0 → exit. NEXT: add registered continuations to the dispatch table (map
continuation_addr → containing func), then re-regen PSX_CPS=1 and run the §25 gate
(ce_profile flat + oracle-identical + 15-min soak + dwarf->overworld). Stages 2
(interp interp_enter_compiled surface) + 3 (sljit/overlay parity) follow.

Safe revert point for the whole feature: commit ae47e69.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 08:25:39 -07:00
Matthew Stanley ae47e69902 recursion(oracle): oracle disambiguation DONE + SP-fntrace instruments; verdict = §11 universal fix (docs §23–§24)
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>
2026-06-18 08:03:04 -07:00
Matthew Stanley fd2570ff13 recursion(doc): §22 — fix (A) interp-call surface validated INSUFFICIENT, reverted
Pinned the exact leaking crossing (xprobe detail): entry to func_8001A954 is a single
JAL, src 0x8001A630 (the main loop's only call site), once/frame. Implemented (A):
surface interp JAL/JALR to game-text targets to the owner trampoline (gated by
psx_game_address_in_text so overlay-native keeps the nesting contract). §19 result: leak
STILL CLIMBS (~0.875 KB/frame, down from 1.17) -> reduced, not eliminated -> REVERTED.

It is a guest CALL-CYCLE (main_loop 0x8001A51C <-> func_8001A954): surfacing the interp
side just relocates the nest to the compiled-side jal back to the main loop. Can't fix a
call-cycle by surfacing one crossing. Leak is ~1 host frame PER FRAME (not per cycle
iteration) -> the loop mostly returns; exactly one call/frame fails to unwind. Real HW
idles forever (balanced), so either we mishandle one specific return/frame (Hyp C,
contained) or it needs the §11 continuation-passing redesign. DECISIVE NEXT = oracle
(psxref): does real HW return from func_8001A954 to 0x8001A634 each frame, or never?
Stop surfacing single crossings (block-loop §20 + JAL/JALR §22 both dead ends).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 00:57:46 -07:00
Matthew Stanley add57f5560 recursion: SOLVE root (gradual per-frame host-stack leak) + instruments + pin exact crossing
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>
2026-06-18 00:15:15 -07:00
Matthew Stanley 08484afc68 recursion: flight-recorder runs #1/#2 findings + fix buffer overflow (doc §16)
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>
2026-06-17 21:12:34 -07:00
Matthew Stanley 8cbea2a887 recursion: re-target flight recorder to 3 loop sites — finding from run #1 (RECURSION_BUG.md §15): the interp->0x8001A954 edge is ORDINARY (1/frame, never spikes even at the freeze); the recursion re-dispatches the compiled loop via dirty_ram_dispatch's entry (CD-DMA-dirty pages route compiled funcs through it), which also explains the symptom-only fix (wrong site). Now record dirty_ram_dispatch(0x8001A954)=head + (0x80046264)=tail per frame to confirm the spike + pin 'loop stopped terminating'. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 2026-06-17 21:01:40 -07:00
Matthew Stanley 2475ee0550 recursion: re-entry flight recorder + strategy reset (doc §15)
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>
2026-06-17 20:42:06 -07:00
Matthew Stanley afd843cdc9 docs(recursion): CORRECT the dialogue-trigger error — it is the ~14-min IDLE freeze (frame ~50k, location-independent); the boundary fix is symptom-only; NEXT = oracle idle-diff (turbo both, find first divergence vs real HW). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 2026-06-17 20:01:22 -07:00
Matthew Stanley e59043be97 recursion fix: host-stack watermark trigger (supersedes nesting counter)
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>
2026-06-17 19:52:14 -07:00
Matthew Stanley f488dc57d8 recursion fix: single mixed-dispatch owner at the interp<->compiled boundary
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>
2026-06-17 18:34:23 -07:00
Matthew Stanley 0d3a1623e6 docs(recursion): full root-cause + fix design + oracle plan
Capture everything for recall in a future session:
- Resolved the overworld-load regression (stale build artifact, not tooling/master).
- 3 organic captures (deterministic: v0=0x8004DEE0, ra=v0+8, pc=0; both New-Game
  and Dwarf-Forest reproduce).
- The contract mechanism (S7): psx_dispatch_impl tail-call trampoline + psx_call_contract
  bail keep COMPILED tail-loops flat; the dirty interpreter bypasses them
  (exec_one JAL/JALR + dirty_ram_dispatch_inner:~1179 nest psx_dispatch_game_compiled).
  Proof it's host-only: guest sp flat (~7KB) across ~23k laps.
- Ghidra map of the guest state machine (S8): func_8001A954 state[0x4a]==1 ->
  func_8001AC00 chain -> func_80046264 jumptable (selector @0x8009BCDD, table
  @0x80013C60) -> func_8004DFA0 (a render handler that advances no selector).
- External-review verdict (S9): Hypothesis A overwhelmingly likely; B has no
  positive evidence; C (interp control-transfer bug fabricating the re-entry) must
  be excluded by an oracle PC-trace.
- Oracle disambiguation plan (S10), the single mixed-dispatch-owner fix design
  (S11, runtime-only / no regen), CD-DMA dirty-marking critique (S12), and the next
  disassembly target around 0x80063864 (S13).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 18:12:17 -07:00
Matthew Stanley e63fdee4d2 crash_trace: flush report before the native_stack walk
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>
2026-06-17 16:05:15 -07:00
Matthew Stanley d9b6ae5e16 recursion: capture native_stack tooling + working doc
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>
2026-06-17 16:02:14 -07:00
Matthew Stanley 3ba40b02ae controller: drop debug keyboard-always-P1 tweak (restore device-driven input)
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>
2026-06-17 09:34:20 -07:00
Matthew Stanley ad9fdb9558 ws backdrop: mirror the native-wide 2D-backdrop stretch in the SW renderer; drop SE tooltip
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>
2026-06-17 09:21:22 -07:00
Matthew Stanley 83bccf01ec Merge hybrid controller pad mode into ws-backdrop work for combined testing
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
2026-06-17 09:09:40 -07:00
Matthew Stanley e0410e95fe controller: hybrid pad mode (auto analog/digital) + 3-way launcher selector
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>
2026-06-17 09:03:31 -07:00
Matthew Stanley 4177029320 ws backdrop: native-wide 2D-backdrop stretch via tagged + background-phase gate
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>
2026-06-17 08:57:59 -07:00
Matthew Stanley e13a1f6660 ws backdrop: prim<->pixel correlation gate (ws_dbg_stretch) + void-layer ID
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>
2026-06-17 08:35:22 -07:00
Matthew Stanley 5ef2ecaccb ws backdrop: native-wide 2D stretch (mechanism proven) + debug tooling; root cause corrected
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>
2026-06-16 22:15:08 -07:00
Matthew Stanley c983ef521f ws backdrop preload: camera-tracked window widening (auto_backdrop)
[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>
2026-06-16 14:48:15 -07:00
Matthew Stanley 5b9c0bc58e docs: generic backdrop-preload (widescreen) design + plan
Full diagnosis + cross-verified design for eliminating the 16:9 far-background
camera-window cull generically. Root cause: the backdrop tile-grid generator is
overlay-resident AND scene-specific (each area's overlay has its own copy at its
own addresses) — the main EXE has no camX cull. Captures the invariant signature
(magic 0x66666667 /96 divide of camX@0x176 -> move start + addiu end, finite
tile-table extent), verified on BOTH the village (FUN_80116a28) and flower-field
(~0x80116800) overlays, plus the exact 2-rewrite patch (force start=0, widen end
to full extent), the recompiler-pass + multi-backend + overlay-cache plan, and
the verify path. Implementation not yet started; analysis complete.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:47:14 -07:00
Matthew Stanley eec3d7893d debug_server: use _DARWIN_C_SOURCE on macOS for full BSD+POSIX namespace
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.
2026-06-16 14:38:53 -07:00
Matthew Stanley 2cd5d22773 debug_server: fix Release link (guard the synth_recurse self-test call)
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>
2026-06-16 14:29:46 -07:00