Commit Graph

61 Commits

Author SHA1 Message Date
Matthew Stanley be76e4d4b5 widescreen: [video] aspect_ratio — GTE X-squash + stretched present
Adds the DuckStation-style widescreen hack as a config option,
implemented in the runtime (no regen needed):

- gte.cpp: gte_set_display_aspect(num, den). RTPS/RTPT scale the
  IR1*h/sz screen-X term by (4*den)/(3*num) around the game's own
  OFX projection centre (identity at 4:3). Games cull against the
  SXY values they read back from us, so post-projection screen-
  bounds culls stay aligned with the visible frame.
- present: GL letterbox_rect generalised to the configured aspect
  (gl_renderer_set_display_aspect); SDL_Renderer path gets an
  aspect-derived logical size + dst rect. Window opens at the
  configured aspect (clamp_window_4x3 -> clamp_window_aspect).
- config: [video] aspect_ratio = W:H (4:3 default, 16:9, up
  to 32:9) in game.toml, settings.toml (load+save), launcher seed.

World geometry keeps correct proportions on the stretched present;
screen-space 2D (HUD, FMV, sprite quad widths) stretches — the
known limitation of the squash tier. EXPERIMENTAL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 12:52:14 -07:00
Matthew Stanley b632171c77 title: drop the "[OpenGL]"/"[Software]" renderer suffix from the window title
The window title showed "<game>  [OpenGL]". For a shipped game that's noise;
leave the clean game title (set at window creation). The active renderer is
still reported via the debug server / config.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:22:05 -07:00
Matthew Stanley 6d8ae54a1a runtime: skip-launcher toggle (mirrors snesrecomp)
Add a persistent skip-launcher mechanism so the runtime can boot straight
into the game without the GUI launcher window (removes the dismiss round-
trip for scripted/debug runs; the debug server then comes up immediately).

Mirrors snesrecomp's SkipLauncher:
- UserSettings.skip_launcher (config_loader.{h,cpp}): persisted under
  [launcher] skip_launcher in settings.toml; round-trips through a Launch
  (seeded before the window runs) so it is never silently dropped.
- main.cpp: --launcher forces the GUI back on even when skip_launcher is
  set; --no-launcher and the existing PSX_NO_LAUNCHER=1 env force it off.
  Precedence: --launcher wins; else skip if env / --no-launcher / setting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:21:46 -07:00
Matthew Stanley 46a653aa63 feat(fmv): auto-skip FMVs via the game's own skip + launcher toggle
Adds an opt-in "skip full-motion videos" feature ([video] auto_skip_fmv,
default off). A streaming FMV is detected as active MDEC decode
(mdec_get_decode_count climbing per vblank) + cdrom_xa_stream_active();
while detected, the frontend injects START into pad slot 0 so the game's
OWN FMV player aborts the movie and jumps cleanly to the next screen —
instant, invisible, and desync-free, with all guest state correct.

Mechanism notes (why this and not the alternatives):
- Flooding XA sectors ('instant' CD during streaming) desyncs the player
  and hangs the movie on a white screen — rejected.
- Uncapped-pacing fast-forward is safe but slow and choppy (plays the
  whole stream) — rejected.
- Triggering the game's native skip is instant and correct. Injection is
  gated on raw xa_stream_active so START stops the moment the FMV ends and
  never bleeds into the next screen (verified: title stays interactive).
Present/pacing are suppressed + audio muted as a safety net for the 1-2
frames before the game's abort takes effect.

Plumbed end-to-end: config_loader [video] auto_skip_fmv + UserSettings
(settings.toml load/save), main.cpp detector/injection + settings layering
+ launcher seed/apply, and a "Skip FMVs" toggle in the launcher Settings
(launcher.cpp + launcher.rml), mirroring the existing SPU/AA toggles.
Runtime-only; no emitter/regen impact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:29:01 -07:00
Matthew Stanley 4ea9ecd2a2 launcher: 4:3 window-size picker for launcher + game
The game window was hardcoded to 640x480, so it booted into a tiny window
while the launcher was much larger — a jarring shrink on LAUNCH. Add a
window-size setting that both windows honor, with a default that matches.

- config_loader: settings.toml [video] window_width (height always w*3/4 to
  keep the PSX 4:3 aspect); load/save, validated 640..3840.
- main.cpp: the game window now opens at the chosen size (default 1280x960)
  instead of 640x480; the launcher opens at the same size (floored at 1280
  wide so the dense dashboard stays usable). clamp_window_4x3() fits an
  oversized choice (e.g. 1920 on a 1080p panel) to the display's usable area.
  Both present paths already scale a 640x480 logical space to the window, so
  the larger window just fills with no aspect distortion.
- launcher: "Window size (4:3)" toggle in the VIDEO settings cycling
  960x720 / 1280x960 / 1600x1200 / 1920x1440; seeded from settings, persisted
  on launch.

Verified: default launcher and game windows both open at 1280x960 (matched,
no resize jump); settings.toml window_width=960 boots the game at 960x720 and
the PS logo renders correct 4:3 with no distortion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 15:29:02 -07:00
Matthew Stanley 5f0654f188 launcher: Phase 5 — controller device picker + DualShock toggle
Per-player input routing with a real device dropdown (None / Keyboard /
each plugged-in SDL controller) and a digital-vs-DualShock pad-type toggle,
wired end-to-end through the runtime.

- sio.c/.h: per-slot pad state (pad_buttons[2]) + per-slot pad type. Analog
  poll emulation (id 0x73 + 4 stick axes) when a slot is in DualShock mode;
  digital (0x41) stays the default so existing games are unaffected. New
  setters: sio_set_pad_state_slot / sio_set_pad_analog / sio_set_pad_connected,
  getters sio_get_pad_{buttons,connected,analog}_slot.
- config_loader: settings.toml [controller] p1_device/p2_device (none/keyboard/
  <SDL GUID>) + p1_analog/p2_analog; load + save round-trip.
- main.cpp: replaced the single auto-opened controller with a two-slot
  PlayerInput model. Devices opened by GUID (with a first-free fallback so a
  re-paired pad still works), keyboard or controller feeds each PSX port, and
  analog stick axes drive the DualShock poll. Hot-plug re-resolves both slots.
  Defaults: P1 keyboard/digital, P2 none.
- launcher: enumerates SDL controllers (name+GUID); each player card has a
  custom click-to-open dropdown (option list injected via data-rml, the
  data-for path being unreliable in this RmlUi build) and a DualShock switch
  that drives the pad art (digital/analog) + status line. Zero-config default
  selects the first plugged-in pad. A saved-but-unplugged controller is kept
  as an "(offline)" option so the selection survives. Choices persist on launch.
- debug_server: pad_status now reports both slots' buttons + connected + analog.

UI polish from review:
- Removed the redundant disc-verdict widget (the green checks already convey it);
  Change ISO now anchors to the disc panel instead of floating at the page top.
- Added a prominent "Settings" button in the top bar (renderer/SS/AA/etc. were
  only reachable via the easily-missed footer link).
- Fixed the player-card asymmetry on toggle: status text no longer wraps and the
  player card body is a fixed height, so the pad art renders at a consistent size
  (measured 216x110 analog vs 219x110 digital) regardless of pad type.

Verified: device dropdown enumerates a real DualSense; selection + DualShock
toggle update the card; pad_status confirms per-slot connect + analog routing
(slot0 analog / slot1 digital and the reverse); default keyboard P1 unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 15:15:08 -07:00
Matthew Stanley c86482cc65 launcher: Phase 4 — memory-card introspection
Wire the launcher's Memory Card 1/2 panels to real on-disk data and make
the per-card enable/path choices flow through to the runtime.

- memcard.c/.h: add stateless introspection (memcard_summary_path parses the
  directory frames for used-block count + mtime + validity, no live state),
  memcard_format_file for "New", and memcard_init_slots for per-slot paths +
  enable flags. memcard_init now delegates to it.
- config_loader: settings.toml [memcard] gains card1/card2 (explicit paths)
  and enable1/enable2; load + save round-trip them (UserSettings extended).
- main.cpp: resolve the effective memcard dir before the launcher so it can
  introspect the real files; seed/apply per-card paths+enable; boot via
  memcard_init_slots (a disabled slot is left absent — SIO reports no device).
- launcher: MC cards now show real filename, size, used-block count, last-
  modified date, and a live 15-cell block grid; Browse picks an existing
  .mcd, New formats a fresh one (overwrite-prompt save dialog). Per-card
  enable + paths are committed to settings on launch.
- The block grid is injected via data-rml (the structural data-for view does
  not capture inner-xml in this RmlUi build; data-rml is the robust path and
  the markup is fully launcher-controlled).
- Add LauncherSystemInterface so RmlUi warnings/errors reach stdout (they
  otherwise go to OutputDebugString on Windows); drop an unsupported
  text-overflow RCSS declaration it surfaced.

Verified on Tomba: card1 3/15 + card2 1/15 match the on-disk directory
frames; disabling slot 2 in settings.toml leaves it not-present via mc_status.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:55:58 -07:00
Matthew Stanley b2461b9049 launcher: dashboard redesign matching the UI mockup
Rebuild the launcher as a card-based dashboard (n64recomp-style) per the
reference design, replacing the flat settings list.

Image pipeline: RenderInterface_GL3 only decodes TGA, so subclass it
(LauncherRenderInterface) and override LoadTexture to decode PNG via a
vendored stb_image (third_party/stb_image.h, STBI_ONLY_PNG, separate
impl TU), falling back to the base TGA path. This is done without
touching the pinned RmlUi submodule. RCSS now uses decorator:image(...)
for all art.

Layout (launcher.rml):
  - Top bar: PS-glyph logo + "<game> Recomp".
  - Disc Verification panel (Phase 3 — fully wired): disc art, FILE /
    REGION / SERIAL, a three-check VERIFICATION RESULTS row
    (Header Match / CRC-Hash Match / Disc Verified, driven by the
    DiscIdentity fields) and a colour-coded verdict (icon + title +
    detail). "Change ISO" re-runs verification.
  - Player 1 / 2 and Memory Card 1 / 2 cards: full visual shell with
    live enable toggles; controller enumeration (Phase 5) and memcard
    introspection (Phase 4) wire in later — the rest is placeholder.
  - Settings view (footer "Settings" <-> "Back"): the video / audio /
    system controls moved off the home screen.
  - Footer: Settings, recompiler-ready status, LAUNCH GAME.

launcher.cpp: model gains disc_file/region/serial + v_header/v_crc/
v_verified + verdict_{title,detail,state}, a view toggle, and card
enable bools; refresh_disc_status maps DiscIdentity onto the three
checks and the verdict; new events change_iso / show_settings /
show_dashboard / toggle_p1.p2.mc1.mc2.

Window bumped to 1280x924 for the dashboard. tools/gen_launcher_assets.ps1
generates the placeholder art (disc, controllers, memory card, PS-glyph
logo, check/verdict icons) into assets/img — swap for real renders later;
the PNG pipeline is identical.

Verified end-to-end on Tomba: dashboard renders with all three checks
green + "Tomba! disc verified"; Settings view and Back work; player /
memcard cards and toggles render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:09:03 -07:00
Matthew Stanley 6f4ad8968f launcher: Phase 3 — disc verification
Add a shared disc-identity module (runtime/{include,src}/disc_identity)
that opens a .cue/.bin/.iso, checks for the ISO9660 PVD (raw 2352 and
cooked 2048 layouts), reads the PVD volume id, parses the boot serial
from SYSTEM.CNF, derives the region from the serial prefix, and
optionally streams a full-file CRC32 and compares against expected
serial/CRC. This is now the single source of truth for "is this the
right disc?" — main.cpp's launch-time validate_disc_image is rewritten
to delegate to it (dropping its duplicated raw-read logic and the now
dead cue_data_path/is_iso_pvd/psx_exe_id helpers) so the launch check
and the launcher badge can never drift.

game.toml [game] gains optional disc_crc / disc_sha1 (GameConfig +
config_loader). Left unset by default: the serial check is dump-
agnostic, whereas a baked CRC would flag every legitimate dump of a
different revision/region.

Launcher: GameInfo carries the expected serial + CRC; the launcher
recomputes verification on entry and on every Browse, showing a
colour-coded badge (Serial verified / Disc verified / CRC mismatch /
Wrong game? / Not a PlayStation disc) plus a detail line with the
detected serial · region · CRC. CRC is only hashed when an expected
CRC is configured.

Verified all three tiers on Tomba: Serial verified (no CRC),
CRC mismatch (wrong CRC), Disc verified (CRC B00ECB0D match).

tools/shot_launcher.ps1: bring the launcher GL window to front and
screenshot its client area (GL windows screenshot black via PrintWindow).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:57:23 -07:00
Matthew Stanley 5b00d497e4 launcher: Phase 1 — config override layer + CRT/SPU keys
Add a settings.toml override layer read at boot (next to the exe),
plus two config-driven runtime knobs feeding the GL/shadow merges.

config_loader: parse [video] crt_filter (raw/crt/composite/trinitron
-> 0..3) into video_screen_kind and [audio] spu_hq into audio_spu_hq;
add UserSettings + load_user_settings()/save_user_settings()
(best-effort, never throws; hand-written TOML).

gpu: gpu_set_screen_kind(int); screen_lut_ensure() precedence is
PSX_SCREEN env > config, default raw stays byte-identical.

spu_shadow: spu_shadow_set_enabled(int); env PSX_AUDIO_SHADOW > config.

main.cpp: g_video_screen + g_audio_spu_hq globals from game.toml; load
<exe_dir>/settings.toml overriding renderer/SS/AA/texfilter/screen +
spu_hq + bios/disc (CLI --bios/--disc still win); call setters at init.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:42:42 -07:00
Matthew Stanley b228c6270e runtime: GL present wiring + turbo-loads audio fade
Present path: 15-bit frames always blit the display rect straight from
the GL VRAM FBO (4:3 letterboxed); 24-bit FMV frames sync the FBO down
and present the CPU conversion. One deterministic path per depth — the
old per-frame FBO-vs-CPU alternation (menu jitter) is structurally gone.

Audio: sdl_audio_update() ramps gain over 40 ms at turbo-loads
mute/unmute edges with an 8-frame unmute hangover to debounce burst
gaps. The fade tail is sized to the ramp (1764 frames) and clamped to
the buffer — the first version rendered 2205 frames into the 2048-frame
sdl_audio_buf and corrupted adjacent statics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 05:19:17 -07:00
Matthew Stanley b592447136 gl-renderer: present via glBlitFramebuffer; screenshot syncs FBO
The FBO-present crop shader sampled a sub-region of the 1024x512 VRAM
texture with vertex-interpolated UVs; sub-texel precision over the
fullscreen triangle produced a vertical seam and text smearing on screen
(the GPU render itself was correct — forcing the software present path
showed a clean image, which localized the bug to the present sampling).

Replace the crop shader with glBlitFramebuffer: an exact integer-rect
copy of the display region from the FBO to the window, with the vertical
flip folded into the dst rect. No per-fragment UV math, no sub-texel
error. linear=GL_LINEAR (AA) else GL_NEAREST.

Also:
- screenshot now calls gl_renderer_sync_cpu() first, so captures reflect
  the on-screen frame under the FBO-present path (was reading stale CPU
  VRAM). Safe: the debug server is pumped on the main GL-context thread.
- PSX_GL_FORCE_CPU_PRESENT=1 forces the software readout present path —
  a fallback/diagnostic that also keeps CPU VRAM current every frame.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:49:31 -07:00
Matthew Stanley af49fb02d6 gl-renderer: GPU rects/fills/lines + present straight from the FBO
Move the remaining 2D primitives (flat/textured rects, fill, lines) onto
the GPU, and present directly from the FBO color texture cropped to the
display region instead of reading the whole 1 MB FBO back to CPU VRAM
every frame.

Why both at once: the native present read the `vram` array directly and
relied on an incidental software op each frame (the HUD/sprites, drawn
last) to trigger the FBO->CPU sync. Moving every primitive to the GPU
removed that trigger, so a fully-GPU frame left `vram` stale -> black
present (the earlier FMV->black regression). The fix is to present from
the FBO when the GPU holds the freshest frame (gl_renderer_have_gpu_frame)
and to sync explicitly (gl_renderer_sync_cpu) on the software/24-bit path.

Result: the per-frame readback is gone for GPU scenes (the forest-lag
source), software/FMV frames keep the CPU present path (verified: the
MDEC intro renders correctly), and loads are faster.

KNOWN-FOLLOWUP cosmetic artifacts to clean up: the GPU textured shader
doesn't yet implement the texture window (wrap/mask), so tiled/wrapped
sprites show seams; bilinear texture_filtering isn't honored on the GPU
path; and the debug screenshot still reads CPU VRAM (stale under the
FBO-present path) so captures can lag the on-screen image.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:39:51 -07:00
Matthew Stanley 25fc85b2e5 gl-renderer: show active renderer ([OpenGL]/[Software]) in the window title
So it's unambiguous at a glance which backend is live during play/testing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:39:18 -07:00
Matthew Stanley 1eaee11299 gl-renderer phase 2a: real GL context + VRAM-texture present
The opengl backend now creates a genuine OpenGL context and presents through
it. Verified: Tomba boots and renders gameplay correctly via GL (context
"4.6.0 NVIDIA"), pixel-identical to software.

- gpu_gl_renderer.c: gl_renderer_init_context (SDL_GL context on a
  SDL_WINDOW_OPENGL window), gl_renderer_present (upload ARGB image -> GL
  texture -> full-screen quad -> SDL_GL_SwapWindow), present_blank, shutdown.
  Legacy GL 1.x (immediate mode) so no GL loader needed yet (-lopengl32).
  Backend vtable still delegates rasterization to sw_* this phase, so the GL
  path is pixel-identical — this isolates/validates the context+present.
- main.cpp: window gains SDL_WINDOW_OPENGL when renderer=opengl; GL context
  created with graceful fallback to the SDL_Renderer path on failure; present
  + disabled-frame + cleanup branch on g_gl_active. SW path untouched.
- runtime.cmake: link opengl32 (Win) / OpenGL::GL (other).

Phase 2b (next): move rasterization onto the GPU — VRAM as a GL texture/FBO,
modern-GL loader + shaders, primitives at internal resolution (the actual
scaling-without-perf-loss win).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:34:46 -07:00
Matthew Stanley 72799bda93 gl-renderer phase 1: renderer facade + [video] renderer config option
Foundation for a selectable hardware (OpenGL) renderer alongside the software
rasterizer. No behavior change: software remains the default and routes through
the facade to the exact same sw_* functions.

- config: [video] renderer = "software" (default) | "opengl" (config_loader).
- gpu_render.h/.c: gr_* facade + GpuRenderBackend vtable. gpu.c (50 call sites)
  and main.cpp (present/scale/filter) now call gr_* instead of sw_* directly.
  The software backend table points straight at sw_*, so the SW path is
  byte-identical.
- gpu_gl_renderer.c: Phase-1 stub. gl_backend_get() returns NULL, so
  requesting renderer="opengl" logs a notice and falls back to software.
- main.cpp: selects the backend (gr_set_backend) before gpu_init().

Phase 2 (next) builds the real GL backend: SDL GL context, VRAM-as-texture/FBO,
shader rasterization at internal resolution, present. Phase 3: coherency,
transparency/dither/mask, then default-with-fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:14:06 -07:00
Matthew Stanley 34561b5418 video: optional bilinear texture filtering
New [video] texture_filtering = "nearest" | "bilinear" (default nearest).
Bilinear smooths textures and 2D backgrounds by sampling the 4 neighbouring
texels and blending them. Done correctly for PSX:
- blends in RGB after the CLUT lookup (never interpolates palette indices)
- transparency-aware: keeps cutouts where the nearest texel is transparent,
  and replaces transparent neighbours with the nearest colour so edges don't
  bleed to black
- neighbours wrap within the 256-texel page, so the blend stays inside one
  texture instead of bleeding across packed VRAM
Applied to all textured primitives (triangles, shaded triangles, sprites,
scaled sprites); independent of supersampling and resolution, so cost is in
texel fetches only, not pixel count. Nearest path is byte-identical when off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 23:43:08 -07:00
Matthew Stanley 2a0536963c video: configurable internal-resolution supersampling (SSAA) + AA
Adds a software-renderer supersampling path so PSX games can render at a
higher internal resolution with anti-aliased edges, configured per game via
a new [video] block:

  [video]
  supersampling = 1..4   # internal-res SSAA factor (1 = native, default)
  antialiasing  = true   # linear present filtering

How it works: when scale > 1, gpu_sw_renderer maintains g_hr, an S*-scaled
mirror of VRAM. Every primitive is rasterized into native VRAM (byte-
identical to the scale==1 path — VRAM stays authoritative for framebuffer
readback, render-to-texture, transfers) AND into g_hr at S* resolution;
textures are always sampled from native VRAM so texels keep native res while
geometry/shading gain resolution. Block ops (fill/copy/CPU->VRAM/pixel write)
replicate into the mirror. Display reads the mirror and the present path
downsamples to the window (true ordered-grid supersampling). 24-bit (FMV)
frames fall back to the native path. scale==1 allocates nothing and behaves
exactly as before.

To keep the mirror coherent, the two GP0 handlers that wrote vram[] directly
(fill-rect 0x02 and the A0 CPU->VRAM image transfer) now funnel through
sw_fill_rect / sw_vram_write — no VRAM write site bypasses the renderer.

main.cpp sizes the SDL texture/logical-size/staging buffer to the internal
resolution and selects linear vs nearest present filtering from the config.

Verified on Tomba at supersampling=2: boots, renders a correct frame, holds
59.96 fps (pacing-limited, render headroom), dispatch_miss_total 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 23:21:27 -07:00
Matthew Stanley 724912338c turbo_loads: mute audio while turbo is engaged
During turbo the guest runs at host speed, so SPU output rendered in
that window is time-compressed garble — queueing it is the audible
stutter players hear at every load burst. Hoist the turbo-active test
above the audio pump and skip queueing while it holds: the device
drains the last real-time samples, underruns to silence for the load,
and the SPU (no longer sampled) resumes from the same musical position
when pacing returns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:27:18 -07:00
Matthew Stanley cd32149a08 tooling: unify dump/screenshot surface, bound TCP sends, fix beetle input + big-dump truncation
- debug_server.c: dump_ram = alias of read_ram (one request, one response,
  up to full 2 MB); screenshot = screenshot_file (24-bit BMP, optional
  "path", default psx_screenshot.bmp); vram_peek cap 64->128; read_ram hex
  encode via nibble table (was 2M snprintf calls stalling the main thread);
  send_all_blocking bounded (1 MB chunks, SO_SNDTIMEO 2s, heartbeats the
  starvation watchdog per chunk) and drops the client on send failure
  instead of letting the watchdog kill the runtime; handle_quit tags exit
  origin.
- beetle_debug_server.c: read_ram cap 64 KB->2 MB via heap+send_raw;
  dump_ram/screenshot aliases; screenshot w/h->width/height parity;
  set_input now holds until clear_input (frames param optional) matching
  psx-runtime semantics — the old default expired after 1 frame and the
  off-by-one in the getter cleared the override before it was ever read,
  so scripted beetle navigation silently did nothing; wtrace_dump response
  heap-built + send_raw (per-entry send_fmt dropped chunks on multi-MB
  responses, producing broken JSON).
- crash_trace: new psx_crash_trace_set_exit_origin(); report gains
  exit_origin (tcp_quit / sdl_window_close / unknown).
- debug_client.py: O(n) string-aware brace parser (old per-byte loop was
  O(n^2) — minutes on a 2 MB response, which starved the server's send and
  got the runtime killed); generic key=value passthrough for any server
  command without a bespoke CLI mapping.
- TCP_COMMANDS.md updated for all of the above.

Verified: 2 MB read_ram round-trip 0.75 s; scripted beetle navigation
reaches the memcard file-select; wtrace_dump returns valid JSON at 21 MB.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 07:49:35 -07:00
Matthew Stanley 035a9fa968 release hygiene: explicit two-step file pickers, prod event-ring gate, decouple capture from compile
- First-run pickers now state exactly which user-supplied file is being
  requested: an info box + step-numbered dialog title for the BIOS
  (Step 1 of 2, SCPH1001.BIN) and the game disc (Step 2 of 2, named
  game + accepted formats), each noting what it is NOT. Game name comes
  from the loaded game config.
- event_ring recording is now compiled out under PSX_NO_DEBUG_TOOLS:
  it is only readable via the TCP debug server (stripped in production)
  and is not part of the freeze dump, so recording was pure hot-path
  cost. All other rings were already no-op gated.
- Overlay autocapture (which writes the player-shareable
  overlay_captures.json) is now enabled whenever overlay_cache is on,
  no longer coupled to the dev-only autocompile command — player builds
  produce the contribution file the README promises; the background
  compile still requires a configured command and no-ops without one.

Production surface after this: no console (-mwindows), no TCP server,
no ring recording; keeps overlay cache + captures, freeze watchdog JSON,
and crash report.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 20:28:10 -07:00
Matthew Stanley ffc15219fd fix: Bug B hard freeze — TOCTOU underflow in frame pacing slept ~24.7 days
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>
2026-06-09 20:09:25 -07:00
Matthew Stanley 739994cfcd Step 2.8: variant-capture automation (detect -> capture -> compile -> rescan)
Runtime detects an uncovered overlay/kernel variant by sustained dirty-RAM
interp pressure inside the capture windows (g_dirty_window_dispatches,
sampled every 600 vblanks; fires only at a coherent non-loading moment via
cdrom_load_in_progress, 60s cooldown, 16/session cap), writes the capture
JSON, and spawns the game.toml-configured compile command
([runtime] overlay_autocompile_cmd, cmd.exe /C, cwd=project root, child
output kept in an in-memory ring — no log files). On child exit 0 the emu
thread rescans the cache (overlay_loader_rescan: idempotent dir re-scan +
checked-regions reset) so the new DLL loads on the next dispatch and the
variant goes native in-session. Dedup is tool-side (existing-CRC skip);
pressure drops to zero once code is native, so the loop self-limits.

New TCP: autocompile_status / autocompile_run / overlay_rescan.
compile_dll now uses absolute native paths and reports exit codes.

VERIFIED live (user-played route): 2 auto-triggers fired, captures written,
child ran recompiler+audit (new variants 1E7DDE2C, A62D0A7C discovered and
audit-clean), rescan picked the compiled DLL up in-session. OPEN ISSUE: gcc
exits 1 with no output inside the spawned cmd.exe child (same absolute-path
command succeeds from an interactive shell) — compile step currently
completed manually; suspect child-env difference, to be isolated next
session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 16:16:36 -07:00
Matthew Stanley 0466f867f9 Step 4: turbo-through-loads (opt-in per game)
While the game is loading, skip wall-clock pacing in sdl_vblank_present
so the guest runs at host speed (presents 1-in-30 for visual progress).
Gated three ways: game.toml [runtime] turbo_loads (OPT-IN — streaming
titles must stay off), post-BIOS-handoff only (fntrace_is_game_started),
and cdrom_load_in_progress() — a data-sector read stream is active or a
data sector landed within the burst-gap window (bridges per-file
Setloc/seek gaps); XA streaming (FMV/CD audio) is never a load. New
turbo_loads TCP command gets/sets the enable and reports load_active +
unpaced-vblank count.

Measured (Tomba boot, vs step-3 baseline; user-confirmed in play):
intro load 1066 sectors 7.59s -> 6.20s; later loads 3.3-5.1 ms/frame
vs 16.7 paced (3-5x); 704 vblanks ran unpaced; no watchdog events.
Compression ceiling is guest speed during loads, which is currently
interpreter-bound (the 0x11xxxx loader-region family, ~12M insns) —
step 2.8 variant-capture automation multiplies turbo directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 15:46:48 -07:00
Matthew Stanley 3e326acef3 Step 3: runtime-tunable instant-CD budget + always-on load-burst ring
CDROM_INSTANT_MAX_PER_FRAME becomes a runtime variable (default 32,
clamp [1,4096], period still floored at CDROM_MIN_DELAY). Three writers
share the knob: game.toml [runtime] instant_max_per_frame, the new
cdrom_instant_rate TCP command (in-session A/B without rebuilds), and
the upcoming turbo-through-loads predicate (step 4).

New always-on CD load-burst ring (ring-buffer rule): one record per
gap-separated run of delivered data sectors — start/end frame, host
wall ms, sector count, rate+divisor in effect. cdrom_bursts TCP command
dumps newest-first, making "load duration" a measured quantity.

MEASURED CONCLUSION (Tomba boot, identical deterministic workload):
the 32/frame cap is NOT the load-time bottleneck. Intro load at rate 32
= 1066 sectors / 456 frames / 7.59s; at rate 256 = 1064 / 449 / 7.48s
(identical within noise; actual delivery 2.2-4.8 sectors/frame, never
near either cap). Tomba's loads are paced by the game's own per-sector
processing at real-time speed — wall-clock load time is a job for
step 4 turbo (run the guest above real-time while loading), not for a
bigger IRQ budget. Default stays 32. No watchdog transient at either
rate (the old frame ~184-213 reentry storm did not reproduce — likely
calmed by step 2.7's native exception tail).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 15:38:47 -07:00
Matthew Stanley b2929aa504 overlay: restore overlay DLL cache + capture (faithful port of codex/cache-discovery)
Reinstates the runtime-loaded overlay DLL cache (Layer A) and the offline
overlay compilation pipeline that were built on the pruned codex/cache-discovery
branch (tip 2bbafc2) and never merged. Ported onto current master; rebuilt
rather than history-restored.

Runtime (off by default, opt in via [runtime] overlay_cache = true):
- overlay_loader.c/.h, overlay_api.h: CRC32-keyed DLL cache at
  cache/<game_id>/<region>_<crc>.dll. On a dirty-RAM dispatch miss the loader
  LoadLibrary's the matching DLL, wires runtime callbacks via overlay_init(),
  enumerates func_XXXXXXXX exports, and dispatches them BEFORE the interpreter.
- overlay_capture.c/.h: write-once capture set; records overlay DMA bytes and
  writes overlay_captures.json (execution-verified seeds) at clean exit.
- dma.c: capture + cd_dma_log hook at CD DMA completion (async model).
- cdrom.c: cdrom_get_setloc_lba() for disc-position correlation.
- dirty_ram_interp.c: g_dirty_ram_exec_pc_table (exec-verified PCs), a
  decodability guard on JAL-into-dirty targets, and the overlay dispatch hooks.
- debug_server.c: cd_read_log + overlay_loader_status TCP commands.
- config_loader: [runtime] overlay_cache flag (default false).

Recompiler (compile_overlays.py / extract_overlays.py drive these):
- main_psx.cpp --overlay-exact: analyze only explicit entries + direct JAL
  targets and set split_mid_function_targets=false, so overlay basic-block
  targets are NOT split into bogus standalone functions (the mid-function-seed
  failure mode that produced black-on-load / blue-on-new-game).
- function_analysis.cpp: analyze_exact_entries(); code_generator.cpp: gate the
  mid-function split pre-pass behind split_mid_function_targets (default true,
  so normal main-EXE codegen is unchanged - Tomba still regens to 2854 funcs).

Deltas from the original: save states dropped (never worked); overlay enable is
runtime-config-gated (off by default) instead of a build-time define; loader
stderr logging converted to the overlay_loader_status TCP path (CLAUDE.md Rule 3).

Recovered from _backups/2026-06-06-consolidation/psxrecomp-all-PRE.bundle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 12:32:19 -07:00
Matthew Stanley ec4306fbb3 mmx6 framework: CD/DMA/MDEC + codegen fixes (Tomba builds+plays, MMX6 boots to intro)
Framework work on codex/mmx6-support that makes Tomba build and play
(title screen, 0 dispatch misses) and MMX6 boot through BIOS to its
intro. Touches recompiler codegen (code_generator, function_analysis)
and runtime CD-ROM/DMA/MDEC/GPU/SIO timing (cdrom, dma, mdec, gpu,
psx_cycles, traps, fntrace, debug_server) + headers + runtime.cmake.

Milestone snapshot for collapsing onto master. Build dir excluded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:06:02 -07:00
Matthew Stanley 963a87a327 Replace DMA overlay hook with overlay_dump TCP command
The CD DMA hook (overlay_log.c) could not distinguish code overlays from
FMV streaming data — Tomba buffers FMV through the same physical address
range and DMA channel. Ripped it out entirely.

Replace with overlay_dump TCP command in debug_server.c: scans the
dirty_ram bitmap for contiguous dirty pages above a configurable physical
threshold (default 0x98000), writes <crc32>.bin files to a caller-supplied
directory, and returns a JSON manifest. dirty_ram IS the oracle for
"executed as code" — no false positives from data transfers.

tools/dump_overlays.py queries overlay_dump post-session and appends new
entries to logs/<game_id>/overlay_map.jsonl with (crc32, load_addr, size,
bytes_file). First run on Tomba captured 4 regions (8KB, 264KB, 356KB,
748KB) matching the known dirty_ram miss clusters.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-30 12:33:45 -07:00
Matthew Stanley 38ec2e7426 Add overlay logger (B+A Layer B-1)
Hooks CD DMA channel 3 completion to log code overlay loads to
logs/<game_id>/overlay_map.jsonl for build-time pre-compilation.

Hot path cost: two integer comparisons (address + size filters) and
return. CRC32 is NOT computed inline — too expensive for FMV-heavy
scenes that stream many small DMA transfers through channel 3.

Filters:
- phys_addr < 0x80000: skip (main EXE, kernel, data)
- size_bytes < 4096:   skip (streaming data, not code)

Deduplicates on (addr, size) in a 128-entry table; appends new entries
to an open file handle (no fopen/fclose per transfer). overlay_log_init
is called from main.cpp with the game_id and game.toml directory.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-30 11:28:00 -07:00
Matthew Stanley b29065aa56 Add dispatch hit counter and fast boot with BIOS snapshot
dispatch_stats TCP command: g_dispatch_static_hits incremented on every
binary-search hit in generated dispatch; exposed alongside miss_total and
miss_unique so static coverage ratio is measurable numerically.

fast_boot = true in [runtime]: first launch runs BIOS normally (logos
visible) and captures full system state at game handoff to
fast_boot_<bios_cksum>_<entry_pc>.bin adjacent to the BIOS. Every
subsequent launch restores the snapshot and dispatches directly to the
game entry PC — no BIOS execution, instant start.

Snapshot captures: CPU (gpr/hi/lo/cop0/gte), RAM (2 MB), scratchpad,
dirty-RAM bitmap, i_stat/i_mask, timer state. GPU/SPU/VRAM not included
(game reinitialises). Display-disabled vblanks now clear SDL to black so
the window never shows OS-background white during game crt0 setup.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-30 10:40:54 -07:00
Matthew Stanley 8b59da8202 Disc speed: post-BIOS switch via entry_pc, XA guard for FMVs
Boot stays at authentic 1x. Speed switch fires once when the game's
entry_pc is first dispatched — safely after the BIOS disc init and
Sony/PlayStation logo sequences complete. Uses entry_pc exact-match
rather than text range to avoid false-triggering on the BIOS shell
(RAM 0x30000-0x5B000 overlaps the game text range).

XA guard in apply_speed(): reverts to 1x whenever xa_stream_active,
protecting FMV playback from speed-up regardless of disc_speed setting.

CDROM_MIN_DELAY floor of 500 cycles prevents instant mode from firing
IRQs faster than the interrupt handler can process them.

Tomba game.toml: disc_speed = "4x" (instant proved too aggressive
for early game disc reads; 4x safe with XA guard protecting FMVs).

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-29 23:59:35 -07:00
Matthew Stanley aef852e11b SIO alias dispatch + instant disc speed + game miss collector
SIO handler alias (0x0CF0 → func_0000641C):
  Runtime capture showed BIOS installs lui+addiu+jalr to RAM 0x641C at 0x0CF0.
  New [[recompiler.bios_aliases]] TOML mechanism emits a one-liner wrapper and
  registers it in the dispatch table. Eliminates 30K dirty_ram blocks/session.

Instant disc speed (disc_speed = "instant" in game.toml [runtime]):
  cdrom_set_speed(0) collapses all seek/read delays to 1 cycle. Correct INT
  sequence still fires; no artificial wait. "2x" and "4x" divisors available.
  Default remains authentic 1x. Tomba set to instant.

Persistent game miss collector (tools/collect_game_misses.py):
  Appends newly-seen dirty_ram dispatch misses in the game text range to a
  file (TombaRecomp/seeds/dirty_ram_misses.txt) for later seeding.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-29 23:42:16 -07:00
Matthew Stanley bd60a359fc Controller: fall back to first available, add Steam Remote Play hints
- open_configured_controller: two-pass scan — first tries the configured
  device index, then falls back to the first available game controller.
  Fixes Steam Remote Play where the virtual Xbox controller appears at an
  unpredictable SDL joystick index, causing silent failure when device=0
  in input.ini doesn't match.
- SDL_HINT_JOYSTICK_HIDAPI=1 / SDL_HINT_JOYSTICK_RAWINPUT=0 set before
  SDL_Init so Steam Input's virtual controller is enumerated via HIDAPI
  rather than the raw Windows HID path, making it visible as a proper
  SDL_GameController.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-29 21:40:36 -07:00
Matthew Stanley af90bd7123 Release/debug split: auto-disable debug tools, no console, resizable window
- PSX_DEBUG_TOOLS now defaults OFF for Release/MinSizeRel, ON for
  Debug/RelWithDebInfo. A plain -DCMAKE_BUILD_TYPE=Release produces a
  production binary with no TCP server and no per-block recording.
  Explicit -DPSX_DEBUG_TOOLS=ON/OFF overrides the default.
- Release MSVC: /SUBSYSTEM:WINDOWS + /ENTRY:mainCRTStartup — no console
  window appears when launching the exe from Explorer/desktop.
- Release MinGW: -mwindows for the same effect.
- SDL_WINDOW_RESIZABLE added so the window can be dragged to any size;
  SDL_RenderSetLogicalSize(640,480) already letterboxes correctly.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-29 21:16:49 -07:00
Matthew Stanley 6f7fcdc89b Remove all psxrecomp-v4 naming — rename to psxrecomp throughout
The "v4" suffix was a holdover from iterative project restarts.
There is now one psxrecomp; all identifiers, include guards, cmake
variables, function names, log strings, schema tags, and tool paths
drop the v4 suffix.

Changes:
- PSXRECOMP_V4_ROOT → PSXRECOMP_ROOT (cmake)
- PSXRECOMP_V4_RUNTIME_SOURCES/_INCLUDE_DIRS/_BIOS_GENERATED → no V4
- psxrecomp_v4_add_runtime_target → psxrecomp_add_runtime_target
- PSXRECOMP_V4_*_H include guards → PSXRECOMP_*_H
- "psxrecomp-v4" log/window strings → "psxrecomp"
- Schema version strings in recompiler JSON output
- Tool script hardcoded absolute paths updated

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-29 21:14:30 -07:00
Matthew Stanley b9716154f0 Add native macOS/Linux support (ucontext thread scheduler, fullscreen, build fixes)
The recompiler and runtime now build and run natively on macOS (Apple
Silicon & Intel) and Linux, alongside Windows. The recompiled BIOS boots to
the PlayStation logo, and Tomba boots into gameplay with working memory-card
save/load — the previously Windows-only path.

Core change — the BIOS thread scheduler:
- runtime/src/psx_fiber.{h,c} (new): one cooperative-fiber API, backed by
  Win32 Fibers on Windows and ucontext (makecontext/swapcontext) on POSIX,
  with a GetCurrentFiber analog (psx_fiber_current).
- traps.c: the BIOS thread switcher now uses psx_fiber_* on every platform.
  The old non-Windows setjmp/longjmp fallback is removed — it ran target
  threads nested on the caller's C stack and so couldn't resume an arbitrary
  suspended thread, which hung the BIOS CD-boot WaitEvent handoff. Real
  per-thread stacks (fibers/ucontext) fix it.
- interrupts.c: the exception-deferral path (g_exception_owner_fiber,
  deferred_exception_longjmp) is now fiber-based on all platforms via the
  same abstraction, instead of #ifdef _WIN32.

Cross-platform build fixes:
- recompiler/CMakeLists.txt: vendored fmt 9.1.0 force-enables consteval on
  Apple clang (the __cpp_consteval clause overrides its own Apple exclusion),
  which clang 21 then rejects; force FMT_CONSTEVAL empty on non-MSVC.
- runtime.cmake: add psx_fiber.c; add pkg-config's SDL2_LIBRARY_DIRS so
  -lSDL2 resolves outside default paths (Homebrew /opt/homebrew/lib).
- crash_trace.c: include <stdarg.h> (not pulled in transitively off Windows).
- main.cpp: the forced "opengl" render-driver hint (a Windows-GDI-freeze
  workaround) is now Windows-only so macOS uses Metal; create the renderer
  with PRESENTVSYNC; SDL_RenderSetLogicalSize(640,480) so fullscreen
  letterboxes; add an F11 / Alt+Enter / Cmd+F fullscreen toggle.

Verified: SCPH1001 BIOS boots to the Sony logo on Apple Silicon, and Tomba!
(SCUS-94236) boots into gameplay with memory-card saves persisting across
relaunches. Gamepad (SDL_GameController) was already cross-platform.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 18:53:22 -07:00
Matthew Stanley 092c9fc0f6 release: add controller config and BIOS package 2026-05-17 22:25:50 -07:00
Matthew Stanley 4068518b04 runtime: add release asset picker 2026-05-17 17:16:14 -07:00
Matthew Stanley 747410abe9 runtime: SDL OpenGL renderer (fix freeze) + heartbeat-always-on + bisect hooks
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>
2026-05-13 22:16:20 -07:00
Matthew Stanley 3dce5e43ce runtime: PSX_DEBUG_TOOLS CMake flag + freeze heartbeat thread
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>
2026-05-13 21:36:37 -07:00
Matthew Stanley 9eb4ab3f21 runtime: SDL software renderer only
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>
2026-05-13 19:25:42 -07:00
Matthew Stanley 2f709ee542 Phase B: config-driven unified runtime + BIOS shell boot restored
- Config loader (recompiler/src/config_loader.{h,cpp}): single source of
  truth for both BIOS and game runtime settings (debug_port, window_title,
  memcard_dir, disc path). psxrecomp-bios and psxrecomp-game both accept
  --config <toml>; runtime takes --game <toml> (no more CLI overrides).
- runtime/src/main.cpp: replaced --disc/--memcard-dir/--game-root CLI
  overrides with --game <toml>; game.toml is the single source of truth.
  Discless launch (no args) goes to BIOS shell.
- runtime/src/dma.c: fixed update_master_irq() re-asserting I_STAT bit 3
  on every DICR read. BIOS shell DMA handler at 0x80058628 only clears
  DICR bit 27 (ch3 flag), so level-sensitive re-assert pinned the kernel
  in the exception loop. Bit 3 is now edge-triggered (one assert per DMA
  completion). Discless boot now reaches MAIN MENU / MEMORY CARD / CD
  PLAYER. With-disc Tomba boots through to Whoopee Camp splash.
- Audit tooling: codegen_audit.py (BIOS), codegen_audit_game.py (game),
  audit_config.py (config loader for tools), verify_audit_schema.py.
  Replaces older tools/_codegen_audit.py probe.
- Docs: CURRENT_STATE.md (project state), UNIFICATION_PLAN.md (Phase C
  translator merge plan), docs/config_schema.md (TOML schema reference),
  docs/audit_inventory.md (audit tier inventory).
- .gitignore: tools/_*.py convention for ad-hoc scratch probes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 01:31:27 -07:00
Matthew Stanley 185162428e runtime + recompiler: boot through main menu, Load Game enter/exit
Recompiler: register jump-table targets with the cross-function
continuation pass before filtering local goto labels — BIOS jump
tables can legally branch into another discovered function's body,
so the prior local-only check dropped valid emit-side edges.

Dirty-RAM interpreter:
  - Dispatch non-local JAL/JALR targets back through psx_dispatch_call
    instead of forcing them into the interpreter when the target sits
    in static-recompiled code (only targets in dirty pages stay local).
  - New always-on flow + per-instruction ring buffers (insn_log,
    flow_log) capturing reg state, current_tcb, and task fields for
    post-hoc analysis. Block log gains t0/t1/t2.
  - guard_yields counter; richer last_unsupported state (entry PC,
    entry RA/SP, insn count) for dead-end diagnosis.

SIO: ACK is now visible across a small read window (2 STAT reads)
matching BIOS poll patterns that re-read STAT after CTRL_ACK clears
IRQ but expect ACK to still latch. Per-channel ack_irq_en captured
at shift start so a CTRL write that disables ACK_IRQ_EN mid-shift
doesn't suppress the firing. sio_init now zeros all paced-model
state. Read/write paths poll the debug server every ~1k accesses
so long pad/card spins don't starve diagnostics.

Memcard: auto-format slot files on first init when missing, so
the BIOS shell sees a present-but-empty card instead of "no card".

Runtime main: SDL nearest-neighbor texture scaling (no bilinear
blur on the integer-scaled framebuffer).

Debug server: large expansion of ring-buffer queries and dump
commands feeding the new insn/flow logs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:34:22 -07:00
Matthew Stanley 88a7ecdcaa runtime: fix Tomba FMV presentation audio and skip 2026-05-11 14:34:17 -07:00
Matthew Stanley b96f2a7098 runtime: MDEC + DMA channels 0/1 + full-RAM dirty bitmap
New runtime/src/mdec.c + runtime/include/mdec.h: MDEC register port
and DMA word ports. Reads command stream from DMA channel 0 (in);
emits decoded macroblocks via DMA channel 1 (out). mdec_init wired
into main.cpp init sequence.

dma.c — execute_ch0_mdec_in and execute_ch1_mdec_out implementations
for MDEC pipes. transfer_word_count handles sync mode 1 block-count
form (BCR upper half * BCR lower half) plus default block size.
complete_transfer(ch) clears active/start bits and raises DICR + i_stat
bit 3. Channels 2/3/4 still default.

memory.c — dirty-RAM bitmap promoted from u32 (16-page kernel-only) to
DIRTY_RAM_BITMAP_WORDS covering all 2 MB RAM. CD-ROM DMA flags overlay
load ranges via new dirty_ram_mark_executable_range(phys, len), without
treating arbitrary data writes as executable. CPU writes still only
mark the kernel-RAM code region (0..0xFFFF) as before.

dirty_ram_interp.c — opcode coverage extended for game-side install
stubs: MFHI/MTHI/MFLO/MTLO/COP0 MFC0/MTC0/RFE. Add
psx_dispatch_game_compiled hook gated on PSX_HAS_GAME_DISPATCH so
recompiled game EXE entries can take precedence over interpretation
on RAM PCs that are also covered by static codegen.

dirty_ram_interp.h — expose dirty_ram_get_bitmap_word(i) +
_word_count, dirty_ram_mark_executable_range, and the
last_unsupported_pc/insn/reason for the debug_server query landed
in a later commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:50:51 -07:00
Matthew Stanley 1c189530ba Add reusable runtime target plumbing 2026-05-10 15:54:15 -07:00
Matthew Stanley 2191f929b4 Smooth boot: gate per-dispatch diagnostics + 59.94 Hz wall-clock pacer
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.
2026-05-09 15:43:28 -07:00
Matthew Stanley 696ffbd1f6 Persist BIOS memory-card copies + dev/prod memcard layout
The COPY/DELETE flows were silently no-oping the SIO write protocol:
60 dispatches per copy were missing the 3-instruction predicate fn at
0xBFC0989C (the WRITE-command sibling of 0xBFC09890), so the BIOS's
write-handler fork test returned junk via uninitialized v0 and the
BIOS skipped sending 0x57 over SIO entirely. The in-memory card cache
appeared updated (visual UI worked, leave/re-enter showed copies) but
nothing reached memcard_write_sector or the .mcd files.

Also reorganize memcard directory resolution: production builds put
cards next to the exe; dev builds (CMake build tree detected) keep
them in the project root next to bios/ so a runtime rebuild doesn't
delete them. --memcard-dir still overrides.
2026-05-09 13:15:33 -07:00
Matthew Stanley 9b332ba5be Persist memcards and add SPU audio output 2026-05-09 06:32:22 -07:00
Matthew Stanley cf87fd8fc6 crash_trace: structured post-mortem dump (mirrors SMW recomp pattern)
New runtime/src/crash_trace.c writes psx_last_run_report.json with:
  reason / timestamp / frame / last_func_addr / last_store_pc
  full CPU register state (all 32 GPRs + hi/lo/sr/cause/epc)
  dispatch_ring tail (last 256 dispatched func entries)
  unknown_dispatch tail (last 50 entries with addr/phys/ra/a0/a1)
  dirty_block_log tail (last 100 entries)
  Windows SEH info (when triggered from SEH filter)

Triggered from:
  - SIGSEGV / SIGABRT signal handlers
  - Windows SetUnhandledExceptionFilter (catches access violations)
  - atexit
  - psx_unknown_dispatch fail-fast path (set PSX_FAIL_FAST_UNKNOWN_DISPATCH=1)
  - trap_crash (existing fatal trap path)

File overwrites per dump (last-write-wins). Per CLAUDE.md §3 this is not
a log — it's a one-shot final state snapshot for crashes the running
TCP server cannot intercept.

Pattern mirrors F:/Projects/SuperMarioWorldRecomp/src/post_mortem.c.

Used the new infrastructure to capture a controlled crash with
0xBFC0989C re-seeded. Crash dump revealed the chain:
  - JAL at 0xBFC09788 is jalr $s3, NOT jalr $a0
  - $s3 was set in func_1FC09598 prologue: $s3 = $a2 (callee saves arg)
  - Caller at 0xBFC093C4 in func_1FC092EC: $a2 = $s3 - 128 (delay slot)
  - func_1FC092EC at 0xBFC09314: $s3 = $a3 (callee saves arg)
  So real bug is upstream-of-upstream caller passing $a3 = 131 (0x83)
  to func_1FC092EC, propagating down to a misaligned function pointer.
  The 0xA000E1E8 we suspected as a function-pointer table was a
  red herring — $a0 in the loop body is loaded from $fp+s1*4 (the
  ARG to pass to the called fn), not the call target.

0xBFC0989C reverted out of seed list (was diagnostic-only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:45:33 -07:00