Commit Graph

225 Commits

Author SHA1 Message Date
Matthew Stanley 023300bc95 docs: record backdrop squash + depth-gated un-squash + native-wide direction
Extend the widescreen enhancement record with the 2026-06-13 work: the 2D
parallax-backdrop screenX squash (8ae73c6) and the depth-gated far-backdrop
GTE-3D un-squash (1e28a94), the live diagnostics (ws_aspect / ws_far_threshold /
ws_census), and a 'native-wide rendering' strategic-direction section (the
proper fix that retires the squash and its artifact class; PoC proven + shelved).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:58:45 -07:00
Matthew Stanley 1e28a9455a widescreen(8C): far-backdrop GTE un-squash, depth-gated
The far backdrop (ocean/cloud/distant mountain) is GTE-projected 3D drawn
by the main-EXE driver FUN_8004db3c (sole caller of the GTE emitter
FUN_80027600). The global widescreen GTE X-squash compresses it toward
centre, so its geometry falls short of the revealed 16:9 edges -> blue void
that pops as the camera moves.

Fix: bracket the backdrop driver with gte_ws_set_suppress(1)/(0) so its
draws skip the X-squash and fill the stretched frame (skybox treatment),
while the near 3D world keeps the wider FOV.

FUN_8004db3c draws a MIX (far backdrop + nearer props/scenery), so the
suppress is DEPTH-GATED in gte_rtps_internal: only vertices with projected
SZ >= s_ws_far_threshold (default 900, tunable live via the ws_far_threshold
TCP cmd) un-squash; nearer props stay squashed and aligned. Validated:
threshold 900 fills the void, keeps the foreground door + background
scenery (path/tree) aligned. Residual: the mountain grass-top straddles the
depth band (per-vertex limitation) -> flickers when the camera dips; this
is the checkpoint before exploring native-wide rendering to remove the
squash (and this whole artifact class) entirely.

Mechanism:
- recompiler: [widescreen.backdrop] unsquash_funcs emits
  gte_ws_set_suppress(1) at entry + (0) before each jr-$ra return
  (code_generator + config_loader + main_psx). Main-EXE -> regen-class.
- runtime: gte.cpp depth-gated squash + s_ws_far_threshold; cpu_state.h
  decl; debug_server.c ws_aspect (live squash on/off) + ws_far_threshold
  (set split + read SZ stats) diagnostics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:31:27 -07:00
Matthew Stanley 8ae73c610a widescreen(8C): backdrop screenX squash on native + interp paths
The 16:9 edge pop-in is the parallax 2D BACKDROP layer (ocean/cloud/mountain/
grass), drawn by overlay actor handlers (FUN_801216bc family) that compute
screenX = (worldX - camX) >> parallax in pure integer math and store it to the
object's +0x12 field WITHOUT the GTE — so the GTE X-squash that gives 3D the
wider 16:9 FOV never reaches them and far pieces sit past the 320px edge,
clipped (the blue void / half-rectangles at the edges). See TombaRecomp
ISSUES.md #8C for the full root-cause trail.

Fix = psx_ws_backdrop_x() (gpu.c): squash screenX around screen-centre by the
same ws factor as the GTE, identity at 4:3. Applied on BOTH execution paths
because overlay code runs interpreted when no cache DLL is loaded (the common
case in dev — see #8G):
- Native: recompiler emits write_half(.., psx_ws_backdrop_x(rt)) at the
  [widescreen.backdrop] x_sites store (config_loader + code_generator +
  main_psx --ws-config so the overlay compile gets the sites). Forwarded into
  overlay DLLs via OverlayCallbacks {ws_backdrop_x, ws_x_margin, ws_sprite_tag}
  (overlay ABI v2->v3 as the cache discriminator); compile_overlays.py glue +
  --ws-config passthrough.
- Interp: dirty_ram_interp SH hook keyed by psx_ws_is_backdrop_site(pc); the
  runtime registers the x_sites from game.toml (main.cpp).

Verified in data: backdrop slot screenX squashes 371 -> 318 (pulled on-screen).
PARTIAL: visible culling persists -> remaining cause is the backdrop spawn/
despawn window (separate from screenX clip); see #8C "REMAINING CAUSE".

Also added (earlier this session): ws_census draw ring, ws_margin override,
gpu_state ws fields.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 00:22:44 -07:00
Matthew Stanley c51b3b9b0e widescreen(8C): always-on draw-census ring + live cull-margin override
Observability for the 16:9 backdrop-cull investigation (runtime-only,
4:3-identity-safe, no regen):

- gpu.c: always-on per-prim draw-census ring (ws_census). Every drawn GP0
  primitive (0x20-0x7F) records {frame, src_addr, camX/camY, first-vertex
  screen x/y, opcode} so object spawn/despawn and edge culls are observable
  in data instead of screenshots. gpu_ws_census_dump() writes a frame range
  to CSV (large dumps must not ride TCP).
- gpu.c: gpu_ws_set_margin_override() forces psx_ws_x_margin() to any value
  for a live cull sweep at a fixed camera; gpu_ws_get_debug() snapshots live
  widescreen state.
- debug_server.c: gpu_state now reports ws.{configured,active,game_mode,
  present_native_43,x_margin,squash,cur_frame,last_tag_frame}; new ws_margin
  and ws_census TCP commands.
- gpu.h: GpuWsDebug + census/override API.
- debug_client.py: ws_margin and ws_census subcommands.

Used to prove the shipped [widescreen.cull] sites are a no-op for 8C (their
4:3 margins already cover the 16:9 view) and that the real pop is an
overlay-resident backdrop layer. See TombaRecomp ISSUES.md #8C.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:00:40 -07:00
Matthew Stanley 3d04f19d3d widescreen: couple squash to present (fix menu garble) + cull-margin widening
Menu fix (user-validated): per-prim center-squash mangled composite 2D
UI (dialog boxes built from tiled cap/middle/cap SPRTs no longer
aligned). Root cause was decoupling — content was squashed while the
frame presented 4:3. Now squash applies IFF the frame is stretched:
gpu_ws_present_native_43() is the single predicate (FMV video OR a
full-2D screen = no character billboards this frame), shared by the
GTE squash, the GPU per-prim squash, and the present 4:3 pin. Full-2D
menus get zero squash + 4:3 pillarbox = pixel-native. Removed the old
menu-2D squash paths (lines/flat-rects/mono-sprites/polylines).

Cull-margin widening (recompiler feature): the game's per-object draw
classifier tests (objX-camX+BIAS) <u RANGE sized for the 4:3 screen;
the GTE squash shows ~33% more world so the margin collapses and
objects pop near the wide edges. New [widescreen.cull] config emits
those immediates with a runtime term psx_ws_x_margin() (bias +margin,
range +2*margin, plus a1+=margin for param-margin variants) — 0 at
4:3/boot/menu/FMV so the original cull is byte-identical, ~53px when
stretching 16:9. Site instruction types are verified at emit (fatal on
mismatch). Requires regen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 19:27:31 -07:00
Matthew Stanley e1ef284eb0 docs: WIDESCREEN.md — feature status + known issues + culling patch set
Documents the feat/widescreen branch: what works (config, launcher
toggle, GTE squash, per-prim correction, FMV/boot 4:3), the config
reference, and outstanding issues. Critically records the full
Ghidra-evidenced patch set for the world-space culling pop-in (root-
caused, classifier family at 0x80022E44, 11 immediate sites) with the
recommended recompiler-emit implementation, so it can be picked up
without re-investigation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 19:04:16 -07:00
Matthew Stanley 5151e5ba68 widescreen: authentic 4:3 BIOS boot (Sony/PS logos, shell)
The GTE squash + stretched present are held off until the game's
entry PC fires (fntrace_is_game_started): the Sony logo, PlayStation
diamond and BIOS shell render exactly as on original 4:3 hardware,
then widescreen engages at game entry. While disengaged the present
path pins to native 4:3 (same path FMV frames use), and the GTE/GPU
squash configure calls are deferred to the engage point so no boot
geometry is squashed.

Starts already-engaged when the configured aspect is 4:3 (nothing to
defer). User-requested.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 19:03:02 -07:00
Matthew Stanley 0792086f6c widescreen: FMV 4:3 pillarbox + menu-2D coherence + HUD edge anchoring
- FMV: 24-bit frames AND streamed 15-bit MDEC video (Tomba plays its
  movies 15-bit; depth24 alone missed them — user-reported) present
  pinned to native 4:3. Detector = colour MDEC decode within the last
  30 vblanks (mdec_recently_active); texture-decompression luma
  decodes do not stamp. Both GL present paths take force_4_3; SW path
  centers a 4:3 dst rect. User-verified on the intro movies.
- Menu garble: full-2D screens (no tagged prims in flight for 2
  frames) now squash lines, polylines, flat rects and mono sprites
  too — dialog borders are drawn with these and stuck out past the
  squashed SPRT boxes. Full-width TILEs (fades) stay exempt; in-game
  non-textured prims stay untouched (may be world-anchored).
- HUD positioning: in-game untagged SPRTs pivot by thirds — outer-
  third elements anchor to their screen edge (16:9 corner position,
  native proportions), middle third to centre. Full-2D screens keep
  the single centre pivot so composites stay coherent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:55:44 -07:00
Matthew Stanley cad8c96ae1 widescreen: per-prim proportion correction (sprite tags + HUD squash)
User-validated on Tomba: world, characters and HUD all present at
native proportions on the 16:9 stretch.

Mechanism (all parameterized via game.toml [widescreen], inert by
default and at 4:3):

- recompiler: [widescreen] sprite_tag_funcs emits a
  psx_ws_sprite_tag(cpu) callback at the entry of each listed
  function (the game engine's shared per-prim helper). The runtime
  records prim pointer ($a0) -> GTE-projected anchor (read from
  sprite_anchor_addr scratchpad) in a frame-stamped hash.
- gpu.c: at GP0 execution, prims whose guest address (from the DMA
  linked-list walk's source tracking) is tagged get every X
  re-squashed around their own anchor — undoing the present stretch
  for CPU-computed pixel offsets the GTE squash never saw. Covers
  POLY_FT4 billboards and SPRT parts; SPRT texels keep full coverage
  via gr_draw_textured_rect_scaled.
- hud_sprt_squash: untagged textured rects (SPRT family — pure
  screen-space 2D: HUD, menus) center-squash around the display
  centre. Untextured TILEs (fades) untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:43:15 -07:00
Matthew Stanley 970670bac4 launcher: Aspect ratio setting (4:3 Native / 16:9 Widescreen / 21:9)
One build serves both presentations: the launcher Settings panel gets
an Aspect ratio toggle wired to the existing UserSettings aspect
plumbing (persists to settings.toml, overrides game.toml). Window-size
label height now follows the chosen aspect. Default 4:3 (Native).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:19:23 -07:00
Matthew Stanley f8a29e8a97 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-12 17:54:49 -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 de2cf5fb3d docs: ISSUES.md #6 — launcher art has rough cutout edges (deferred) 2026-06-12 13:24:21 -07:00
Matthew Stanley 64888b3966 launcher: authentic art cropped from the design mockup
Replace the procedural placeholder art (disc/controllers/memory card/
logo) with the real PlayStation hardware cropped straight out of the
reference mockup, with the dark background knocked out to transparent so
each asset blends seamlessly on the panels — no seams, no border-glow
artifacts.

tools/crop_launcher_assets.ps1: crops the five assets from the mockup
and removes the background. Photo assets use an edge flood-fill (flood
the dark background inward from the borders; bright object pixels are
walls, so interior dark detail — the disc's PlayStation logo and centre
hole, the controller buttons — is preserved). The logo uses a global
luminance key (thin bright glyphs on near-black). PowerShell [int] of a
division rounds, not floors — the flood uses [Math]::Floor for the row
index.

launcher.rml: add `contain` fit to the photo decorators so the crops
keep their aspect ratio, and nudge the art sizes closer to the mockup's
proportions (disc 216, pads 206x128, memcard 118).

The check/verdict icons remain the generated PNGs (already clean +
transparent). Pixel-verified: dashboard renders with authentic disc,
PS1 digital pad, DualShock, and memory cards, all seamless on the panels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:22:44 -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 f2ad1ea2d1 launcher: flexbox layout polish
Rework launcher.rml/RCSS so the UI is presentable instead of the
default inline-flow overlap. RmlUi does not default custom elements to
block, so h1/.subtitle/.panel/.section-title now set display:block, and
each setting .row is a flex row: fixed-width label + flexing .control.

Toggle/cycle buttons share a fixed .toggle width so the value column
aligns; BIOS/Disc paths flex-grow with ellipsis overflow and show a red
"(not set)" placeholder when empty. Actions row (flex) makes LAUNCH GAME
the primary full-width button with Quit alongside. Content is centered
in a fixed-width column.

Pixel-verified: three card panels render cleanly with aligned controls,
seeded from the game's effective config.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:46:29 -07:00
Matthew Stanley ecc9f76358 launcher: Phase 2 — integrated RmlUi launcher window
Add an integrated configuration launcher (n64recomp-style) that runs
before the emulator boots, so users pick renderer/SS/AA/colour-model/
SPU/BIOS/disc from a GUI instead of editing TOML by hand.

runtime/launcher/launcher.{h,cpp}: psx_launcher::run(window, glctx,
UserSettings&, game_name, assets_dir) -> {Launch, Quit, Unavailable}.
Base RenderInterface_GL3 (no SDL_image dep) + SystemInterface_SDL +
RmlUi data-model binding; Win32 comdlg32 file picker for BIOS/disc.
Caller owns the window/context (clean seam for a future runtime
"re-open settings" overlay).

assets/launcher.rml (embedded <style>) + bundled LatoLatin fonts.
NB: the TTF family name is "LatoLatin", not "Lato" — RCSS must match it.

runtime.cmake: PSX_LAUNCHER option (ON; never for ORACLE builds);
ensure-launcher-libs adds freetype + RmlUi statically; per-target wiring
links RmlUi::Core + GL3/SDL backends, POST_BUILD copies assets next to
the exe. GL3 backend force-includes <algorithm> (GCC/Clang).

main.cpp: before BIOS resolution, if PSX_LAUNCHER && !PSX_NO_LAUNCHER,
spin up an isolated GL 3.3-core window, run the launcher, apply choices
+ save_user_settings on Launch (return 0 on Quit), then tear the window
down. Isolated window avoids the software-renderer-vs-GL window-flag
conflict; the emulator's own boot path below is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:43:06 -07:00
Matthew Stanley c5860d785c launcher: add RmlUi 6.0 + FreeType 2.13.3 as pinned submodules
lib/RmlUi @ tag 6.0 (mikke89/RmlUi, stock — verified not a fork).
lib/freetype @ tag VER-2-13-3. Both built statically into the runtime;
pinned to the exact tags that were build- and pixel-verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:42:47 -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 8a4ac4e362 merge: feat/shadow-enhancements — SPU float-shadow + 15-bit CRT/Trinitron color LUT (env-gated, default OFF) 2026-06-12 11:15:45 -07:00
Matthew Stanley 7ef6415558 merge: feat/gl-renderer — GPU-authoritative OpenGL renderer (v2) into master 2026-06-12 11:15:39 -07:00
Matthew Stanley bba973da12 docs: GL renderer handoff updated to v2 architecture + seam root cause
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 05:20:34 -07:00
Matthew Stanley 45cc5c3887 psx_fiber: SIGSTKSZ compile-time fallback for glibc >= 2.34
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 05:19:17 -07:00
Matthew Stanley ca6f423edb debug: gl_coh_ring/gl_fbo_peek/gl_vram_diff commands; richer SEH dumps
New debug server commands for the GL backend: gl_fbo_peek reads a rect
of GPU-side VRAM (via the pack pass) without touching the CPU array,
gl_vram_diff reports full-VRAM FBO-vs-CPU mismatch count/bbox/samples,
and gl_coh_ring dumps the always-on coherency event ring (last n events,
optional frame_min filter).

crash_trace SEH dumps now report the faulting module + base + offset and
scan the stack for executable return addresses, so dumps stay
addr2line-able despite ASLR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 05:19:17 -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 0631538407 gl-renderer v2: GPU-authoritative VRAM FBO + Beetle-style filtered sampling
Full rewrite of the GL backend around a single authoritative hr VRAM FBO
(RGBA8 + stencil mask-bit mirror, internal scale from [video]
supersampling): GPU fills/copies/draws rasterize into it, CPU uploads
batch through a pending-rect quad blit, a PACK pass re-encodes dirty
regions into the native R16UI raw mirror for texture sampling and
readback, and gpu.c routes GPUREAD/mask-check/peek reads through the
facade so the CPU array syncs before any CPU-side read.

Texture sampling adopts the Beetle-PSX model (structure only, studied
from beetle-psx-libretro):
- Per-primitive uv sampling bounds, clamped in the fragment shader when
  no texture window is active. Filtered neighbours and S>1 interpolation
  overshoot can never read outside the prim's own texture rect. Bounds
  back off the exclusive max edge for axis-aligned (2D) uv mappings and
  widen to the full page when the uv range crosses a 256 wrap boundary.
- Bilinear takes the NEAREST texel as base (cutout + STP authority),
  neighbours toward the sub-texel offset, with per-texel opacity weights
  and renormalised colour. The old "-0.5 then floor" base sampled one
  texel OUTSIDE the prim on top/left edges; where that neighbour was
  empty VRAM (e.g. tiled menu backgrounds with u0=0) the whole edge
  column/row discarded — the vertical line on Tomba's title and the
  cross seams on the memory-card screens.

The software renderer's bilinear gets the same reformulation so GL and
SW render filtered output identically; the SW nearest path (shipping
default) is untouched.

Also: always-on coherency event ring (flushes, fills, copies, draw
bboxes, packs, readbacks, presents, probe perturbations, with rects +
frame numbers) queryable via the debug server, and gl_vram_diff now
forces a full pack so it reads FBO truth even where the raw-mirror
invariant is broken.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 05:19:01 -07:00
Matthew Stanley cb00b9c0af feat(psx): verified-enhancement shadow — SPU audio + 15-bit CRT LUT
SPU Catmull-Rom float shadow (spu_shadow), 15-bit CRT color LUT (raw bit-exact), verifier. Opt-in (env-gated), default OFF: raw output byte-identical and remains the verify oracle. Reverts loud (DEGRADED) on divergence. ShadowVerifier ported from JRickey/gba-recomp (MIT OR Apache-2.0). Compiles clean; full game-build A/B is the remaining integration gate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 04:25:56 -07:00
Matthew Stanley 88b20c5666 docs: OpenGL renderer handoff — architecture, open issues, next tasks
Hand-off for the next session (Fable): current GL renderer architecture
and verified state, the open visual issues with the diagnostic evidence
gathered (menu jitter/seam = present-path alternation, not the GPU
render; garbled text = missing texture window; fullscreen stretch =
no aspect/letterbox; copy_rect still software), and the user-approved
audio fade-in/out task for turbo transition windows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:55:36 -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 2140c13945 gitignore: ignore _wip/ scratch dir (local WIP patches)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:28:10 -07:00
Matthew Stanley 846e402b98 gl-renderer: fix double-applied draw offset dropping terrain layers
gpu.c applies draw_offset_x/y to every primitive's coordinates before
calling the gr_* facade (see gp0_exec_* — triangles, rects, sprites,
lines all pre-offset), and the software raster consumes them directly
(g_offset_x is never read during rasterization). The GPU triangle paths
were adding s_off again, double-applying the offset.

At the logos the offset is 0 so it was invisible, but in the scrolling
overworld it displaced every GPU-rendered triangle by 2x the scroll,
pushing the background/foreground terrain quads off-screen while
sprites (drawn as rects -> software, single offset) stayed put. Net
effect: "the entire overworld is gone" — only sprites survived.

Drop the s_off addition in gpu_triangle/gpu_textured_triangle. Verified:
the attract-demo overworld now renders full terrain (grass, mushrooms,
cliffs, signpost, coins) instead of sprites on an empty gradient.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:17:22 -07:00
Matthew Stanley 6c30b65b3d gl-renderer phase 2b step 4: GPU semi-transparency (4 PS1 blend modes)
Route semi-transparent flat/gouraud/textured polys to the GPU instead of
falling back to software (which forced a full FBO readback per prim).
The four PS1 modes (B/2+F/2, B+F, B-F, B+F/4) map to fixed-function
blending: FUNC_ADD/REVERSE_SUBTRACT with ONE/CONSTANT_ALPHA factors, the
fragment as source and the FBO as dest; framebuffer clamp matches the PS1
saturate. glBlendEquation/glBlendColor loaded via the modern-GL loader.

Textured semi-transparency honors the per-texel STP bit via a two-pass
split (new u_semipass uniform): pass 1 lays opaque texels with blend off,
pass 2 blends only STP texels. Untextured semi prims blend every pixel in
one pass. Opaque prims are unchanged (semi = -1, blend disabled).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:06:39 -07:00
Matthew Stanley ee0a454a56 debug server: screenshot writes a real PNG instead of a .png-named BMP
handle_screenshot_file emitted a 24-bit BMP regardless of extension, so
"screenshot_file path=foo.png" produced a file that PNG-only consumers
(and the agent Read tool) rejected. Replace with a self-contained PNG
encoder: 8-bit truecolor, IDAT carried as a zlib stream of stored
(uncompressed) DEFLATE blocks with correct CRC32 + Adler32. No new link
dependency, so the static self-contained runtime stays self-contained.
Default name is now psx_screenshot.png.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:06:39 -07:00
Matthew Stanley c2b2959af4 gl-renderer phase 2b step 3: GPU rasterization of textured polys
Route opaque textured/shaded-textured triangles to the GPU via a raw
16-bit VRAM integer sampler (GL_R16UI) + a CLUT-decoding fragment shader
(4/8/15-bit depths, texel-0 discard, PS1 *2-around-0x80 modulation). The
raw VRAM texture mirrors CPU VRAM in ensure_gpu; render-to-texture within
a single GPU batch is a known later-step limitation. Semi-transparent
textured polys still fall back to software.

Two play-test fixes folded in:
- Present at native res under the GL backend (glb_set_scale/glb_scale
  return 1). GPU draws write the FBO only, not the software SSAA hi-res
  mirror g_hr, so presenting via g_hr dropped every GPU layer ("entire
  layers missing"). GPU-side hi-res scaling is a later step.
- noperspective on GEO/TEX varyings for PS1 affine (non-perspective)
  parity ("logos look horrible" -> clean).

Verified: PlayStation BIOS logo + Tomba face cutscene render correctly
via the GPU path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:58:05 -07:00
Matthew Stanley d7b430cf4f gl-renderer phase 2b step 2: GPU rasterization of opaque flat/gouraud polys
First real GPU rasterization. VRAM is mirrored as an RGBA8 GL texture bound to
an FBO (the render target); opaque flat and gouraud triangles are rasterized on
the GPU into that FBO via a geometry shader. Everything else (textured polys,
rects/sprites, lines, fills, semi-transparent polys, VRAM transfers/copies)
stays on the software rasterizer over CPU VRAM.

Coherency: two dirty flags keep CPU VRAM and the FBO in sync — a GPU draw
uploads CPU VRAM if the CPU side is ahead; a software op or the display readout
reads the FBO back (glReadPixels) if the GPU side is ahead. So both paths
interleave correctly in PS1 draw order. The sync is entirely inside the backend
vtable, so main.cpp/present are unchanged.

Verified on Tomba: GPU raster path enabled (FBO complete), renders correctly
and matches software A/B (Whoopee Camp logo intro), no corruption/crash.

Known/expected: the per-op full-VRAM sync is simple, not fast — with most
drawing still software it thrashes and runs slow. That disappears as textured
polys move to the GPU (next steps): textured-poly CLUT sampling shader, then
semi-transparency/dither/mask, then internal-res scaling, then drop the
readback and keep VRAM GPU-side.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:08:16 -07:00
Matthew Stanley b509e8ffa7 gl-renderer phase 2b step 1: modern-GL loader + shader present pipeline
Replaces the legacy immediate-mode present quad with a modern-GL pipeline:
SDL_GL_GetProcAddress loader for the GL 2.0+/3.0 entry points, a compiled
vertex+fragment present program, and a fullscreen-triangle draw via an empty
VAO. Verified: Tomba renders correctly via the shader path (log "GL present
path = shader", GL 4.6 NVIDIA). On any loader/compile/link failure it degrades
to the immediate-mode quad, so present never black-screens.

Rasterization still delegates to software (vtable unchanged) — this isolates
and validates the shader/VAO plumbing that the GPU rasterizer builds on next:
VRAM as a GL texture + an FBO at internal resolution, primitive shaders.

Avoids GLchar/GLsizeiptr (absent in MinGW GL 1.1 headers) by spelling loaded
prototypes with char/ptrdiff_t (ABI-identical).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:50:22 -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 9474765acd video: fix red/blue swap in hi-res display readout
rgb555_to_argb packed the ARGB bytes as A|B|G|R instead of A|R|G|B, so the
supersampling display path (scale>1) showed red and blue swapped. The
native scale==1 path uses gpu_display_pixel_argb and was unaffected. Match
the authoritative packing exactly: A<<24 | R<<16 | G<<8 | B.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 23:28:15 -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 9bf42a4ffd docs: overlay cache is private; keep overlay_captures.json off public issues
The cache file contains verbatim copyrighted game code read from the user's
disc, so the README no longer asks players to attach it to GitHub issues.
Discoveries persist locally (cache rebuilt from the file automatically); a
metadata-only contribution format is planned. Also clarifies the
distribution model (statically recompiled builds, no BIOS/disc/assets).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 22:52:42 -07:00
Matthew Stanley 2439d4dd44 dispatch call contract: continuations only run when the guest really returns
Bug D root cause (Tomba chest freeze, also QA issue #1): the game's own
dispatcher func_80056E08 has a dead jumptable case (type 0x21 ->
jal 0x80120B3C) whose target is mid-function in EVERY overlay variant on
the disc (only the boot EXE references it; the real handler entry is
0x80120AC8). Entering mid-function skips the prologue, runs an epilogue
for a frame that was never pushed, and returns through garbage. Our C
model then resumed the suspended generated continuations on the moved
guest stack -> double-executed tails -> infinite zombie loop (~3K wild
calls/frame) -> MMIO fatal. Family root of Bugs A/C/D.

The contract: a generated C continuation may only run if the guest
actually arrived at it ($ra == call site return address, $sp == caller
sp at the call). Violations begin a "bail" unwind: cpu->pc := the wild
jr's true target, every generated frame returns immediately, and the
unwind resolves at the first enclosing site whose contract matches (or
flattens at the outermost dispatch loop, re-dispatching the wild target
on a clean host stack -- matching real-HW one-stack semantics).

- cpu_state.h: bail state + psx_call_contract() helper (+ overlay-DLL
  pointer indirection under PSX_OVERLAY_DLL_BUILD)
- emitters: (ra, sp) checks at every BIOS and game jal/jalr site;
  psx_dispatch_impl validates pc==0 and stop-hits against (stop, sp),
  handles bail resolve/propagate/flatten
- dirty_ram_interp: surfaced calls validate the contract on return;
  bail aborts the interp run; tail transfers into compiled code now run
  with pc cleared (closes a latent double-run on plain C returns)
- overlay cache ABI v2: DLLs export overlay_abi(); loader rejects and
  deletes stale DLLs (autocompile regenerates with contract checks)
- telemetry: bail_first/resolved/flattened/anomaly in heartbeat + dump
  headers; anomaly guard at exception entry; TCP_COMMANDS.md docs

Verified: BIOS+Tomba regen, boot to intro at locked 60.0 fps, all bail
counters 0 through boot/title/attract (no false positives). Chest-area
gameplay verification pending (requires user play).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:30:41 -07:00
Matthew Stanley 4bdb7e23e9 freeze dumps: serialize concurrent dumps, snapshot wedge_kind, never self-suspend
The chest-freeze postmortem (2026-06-10) exposed a three-way race when
psx_fatal_halt (main thread) and the watchdog (heartbeat thread) dump
concurrently: shared static io_buf corrupted both 159MB JSONs, and the
watchdog flipping s_last_wedge_kind mid-write sent the fatal dump (which
runs ON the main thread) into the SuspendThread stack walkers -> main
thread suspended itself two instructions before its own ResumeThread,
killing TCP on the held state. Fixes: CAS gate (first dump wins),
wedge_kind snapshotted at entry, walkers refuse to suspend self.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:44:35 -07:00
Matthew Stanley aa536b899c debug server: total send budget + serve-stall telemetry (root cause of 'attract degradation')
The two pre-fix attract-idle 'degradations' (2026-06-10 dumps ...393 and
...707) were observer interference, not guest bugs:
- both wedge dumps show 8/8 main-thread stack samples inside WS2_32!send
  (same call site) — the main-thread-pumped server blocked sending to a
  trickle-draining client, throttling the loop to ~6 fps;
- the 'frame counter reset to 0' was a second process instance (no reset
  path exists for s_frame_count or psx_cycle_count; dump ...707 has lower
  cycles than ...393);
- the 'kernel-only dispatch loop' signature (0xB0/0x650, restore target
  0x80068024, EPC 0x80000048, dispatch_count 0) is the normal Tomba
  title-attract idle pattern — identical in a healthy 60-min post-fix soak.

Fix the loophole and make recurrence self-evident:
- send_all_blocking gains a 15 s TOTAL budget per response (the 2 s
  SO_SNDTIMEO only bounded zero-progress chunks; a slow-but-alive client
  could throttle the emulator indefinitely while heartbeating the
  starvation watchdog). Over budget -> client dropped, runtime unharmed.
- cumulative tcp_send_stall_ms + tcp_clients_dropped exported in the
  freeze heartbeat (top level + per-tick ring 'tcp_ms') and in every
  wedge/fatal dump header, so a TCP-throttled window is attributable at
  a glance. Documented in TCP_COMMANDS.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:12:11 -07:00