Commit Graph

231 Commits

Author SHA1 Message Date
Matthew Stanley bee93160c4 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-15 12:52:16 -07:00
Matthew Stanley bd6ddc32f1 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-15 12:52:16 -07:00
Matthew Stanley 7fcc76f1d8 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-15 12:52:16 -07:00
Matthew Stanley fddd15f44f 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-15 12:52:16 -07:00
Matthew Stanley 265183872d 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-15 12:52:15 -07:00
Matthew Stanley ac0814c913 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-15 12:52:15 -07:00
Matthew Stanley 5e397620f8 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-15 12:52:15 -07:00
Matthew Stanley be76e4d4b5 widescreen: [video] aspect_ratio — GTE X-squash + stretched present
Adds the DuckStation-style widescreen hack as a config option,
implemented in the runtime (no regen needed):

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 12:52:14 -07:00
Matthew Stanley 8e2ad53424 tools: coverage_vault.py — clean-proof additive coverage backup
Overlay coverage (overlay_captures.json + the gcc-compiled cache DLLs) normally
lives only in gitignored build dirs, so a `cmake` clean or fresh worktree wipes
it (re-derivable only by replaying those areas). coverage_vault.py maintains one
additive vault OUTSIDE any build dir: `merge` unions captures by variant
(load_addr + bytes hash; same variant unions executed/entry PCs, distinct
variants all kept) and copies content-keyed cache DLLs (copy-if-absent/newer).
Wired into Tomba's overlay_autocompile_cmd so every compile backs up additively;
also runnable by hand to consolidate scattered build dirs. The vault holds
game-derived bytes, so it stays private/gitignored (this script does not).

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:22:05 -07:00
Matthew Stanley a017edbb7e crash_trace: stamp build identity (git rev + build time) into reports
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>
2026-06-15 12:08:50 -07:00
Matthew Stanley f0701c0e01 dispatch: always-on runaway-recursion guard (catchable bail, not a hard crash)
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>
2026-06-15 12:08:50 -07:00
Matthew Stanley b3cc33a678 crash_trace: record dispatch_depth (runaway-recursion fingerprint)
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>
2026-06-15 11:30:31 -07:00
Matthew Stanley 6d8ae54a1a runtime: skip-launcher toggle (mirrors snesrecomp)
Add a persistent skip-launcher mechanism so the runtime can boot straight
into the game without the GUI launcher window (removes the dismiss round-
trip for scripted/debug runs; the debug server then comes up immediately).

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:21:46 -07:00
Matthew Stanley ce94e91ba2 crash_trace: JSON-escape module paths so reports are valid JSON
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>
2026-06-15 11:21:37 -07:00
Matthew Stanley 1010c58c41 overlay: flavor-tag the cache so variant builds can't cross-contaminate
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>
2026-06-14 16:04:21 -07:00
Matthew Stanley 46a653aa63 feat(fmv): auto-skip FMVs via the game's own skip + launcher toggle
Adds an opt-in "skip full-motion videos" feature ([video] auto_skip_fmv,
default off). A streaming FMV is detected as active MDEC decode
(mdec_get_decode_count climbing per vblank) + cdrom_xa_stream_active();
while detected, the frontend injects START into pad slot 0 so the game's
OWN FMV player aborts the movie and jumps cleanly to the next screen —
instant, invisible, and desync-free, with all guest state correct.

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:55:58 -07:00
Matthew Stanley 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