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>
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>
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>
- 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>
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>
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>
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>
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>
A user-submitted crash report (issue #1) had no way to identify which build
produced it. Embed PSX_BUILD_REV (git describe, computed in runtime.cmake at
configure time; "unknown" on failure) plus __DATE__/__TIME__ as a "build" field
in every report, so any user crash can be correlated to an exact build.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The host C stack mirrors the guest call graph, so an unbounded self-recursion —
e.g. a bogus jump-table handler that re-enters its own entity loop (issue #1
seesaw crash: v0=0x8001DFF8, confirmed a runtime out-of-bounds table read, NOT a
legit handler and NOT a mis-compile of func_8001DFD4) — silently overflows the
host stack with a bare STATUS_STACK_OVERFLOW and no diagnostic.
Guard g_psx_dispatch_depth in dirty_ram_dispatch (the re-entry point on every
recursion level): trip well below overflow (default 256, PSX_RECURSION_LIMIT
override; normal guest nesting is <~64) and psx_fatal_halt -> dump the report
(dispatch_depth + dirty_block cycle name the recursing PC) and keep the TCP
server live for inspection. Converts a hard crash into a captured, diagnosable,
live-inspectable bail.
NOT a fix for the bug — the upstream bogus-pointer data divergence still needs an
oracle repro. This makes the next occurrence observable (Rule 15) for whoever
reproduces it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The crash report named the recursing PCs (dirty_block tail) but not the depth,
so "this is a stack overflow from unbounded native recursion" had to be inferred
by hand. Serialize g_psx_dispatch_depth (generated/SCPH1001_dispatch.c, a global,
read-only here) into the report header: a huge value at crash time is the direct
fingerprint of runaway recursion overflowing the host C stack (0xC00000FD), since
the host stack mirrors the guest call graph. Pairs with the dirty_block tail
(which names the recursing function) for a self-evident report.
No generated code modified; this is pure serialization of an existing global.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Windows module paths (GetModuleFileNameA -> C:\Games\...\TombaRecomp.exe) were
written into the report with raw backslashes, producing invalid JSON that
parsers reject — a user-submitted crash report (issue #1) could not be parsed
without manual sanitizing. Add a json_escape() helper and route both the
faulting-module path and the stack-scan module names through it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The overlay cache keys DLLs by guest-bytes CRC, which is flavor-blind — the
same guest overlay yields the same filename whether compiled by the base
recompiler or a variant (e.g. widescreen, which emits psx_ws_*/GTE-adjust
symbols). Two flavors sharing a cache dir would collide: a base runtime could
pick up a widescreen DLL (or vice versa) with no guard, since both report ABI
v2.
Fold a codegen-flavor id into overlay_abi() (high 16 bits) and check the
combined tag in the loader's existing ABI gate. Base/master flavor is 0, so the
tag stays == PSX_OVERLAY_ABI_VERSION — fully backward compatible with every
existing base DLL (verified: an 8-DLL base cache loads with zero rejections).
A variant build sets PSX_OVERLAY_FLAVOR (e.g. widescreen: -DPSX_OVERLAY_FLAVOR=1
for the runtime, --flavor 1 for compile_overlays.py); its overlays then report a
distinct tag, so the base runtime rejects+regenerates them and vice versa. The
caches stay separate automatically, even in a shared directory — no new paths.
overlay_api.h: PSX_OVERLAY_FLAVOR (default 0) + PSX_OVERLAY_ABI_TAG.
overlay_loader.c: both ABI gates check the tag.
compile_overlays.py: --flavor arg -> -DPSX_OVERLAY_FLAVOR, baked into overlay_abi().
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- crash_trace.c: SEH and signal handlers now also write the freeze-style
full ring dump (wedge kind 4), so a host fault no longer takes the
rings with it. freeze_heartbeat_fatal_dump gains a first-dump-wins
guard so a post-mortem fault during halt-and-serve can't overwrite the
original fatal dump.
- freeze_heartbeat.c: raise per-ring auto-dump caps (wtrace_all/dirty
16K->256K, others 4-8K->64K, frame history 600->3600). The old caps
spanned under a second of activity — too short to cross a degradation
transition; the underlying rings hold 4M entries. Dumps grow to
~50-80 MB, which prior investigations handled fine.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A pre-fix degraded session died via host SEH (wild write, frame 282184)
with garbage DMA ch2 regs in flight, losing every ring. Convert the
corruption escape paths into held, fully-queryable states:
- dma.c: validate BCR-derived transfer length on every kick (cap 0x80000
words = 2 MB; nothing legitimate exceeds RAM/VRAM size). Violation ->
psx_fatal_halt with MADR/BCR/CHCR in the reason. Linked-list mode is
exempt (BCR unused; node walker already has MAX_NODES).
- dma.c: clamp the CD overlay-capture span to host RAM end — the DMA
word loop wraps inside the 2 MB mask but the capture memcpy/CRC took a
flat ram+offset span and could read past the RAM array.
- dma.c, gpu.c: all six fprintf+exit(1) fatal sites (unimplemented DMA
channel, unknown DMA reg, unimplemented/unknown GP0, unknown GP1) now
route through psx_fatal_halt — rings stay live for post-mortem TCP.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>