Commit Graph

491 Commits

Author SHA1 Message Date
Matthew Stanley 76ddcc008a test(spu): pin END-only voice mute semantics
Document the overlap with kem0x PR #14 and explicitly record the game-specific and unrelated changes excluded from this branch.

Co-authored-by: Kareem Olim <kareemolim@gmail.com>
2026-07-13 21:40:11 -07:00
Matthew Stanley dde268dc0f input: decouple analog stick from D-pad in analog pad mode
The default controller map folds the left stick onto the D-pad bits
(up=dpup,lefty- … right=dpright,leftx+) so the stick acts as a D-pad
for digital-only games. In ANALOG pad mode that fold is wrong: the
stick already drives the analog axes, and on a real DualShock the
stick and D-pad are independent. A dual-analog title that uses the
D-pad as its own control therefore saw phantom D-pad presses from
every stick movement — and constant presses from stick centre drift.

Ape Escape is the concrete victim: it is locked to analog, its D-pad
left/right rotates the camera, so walking with the left stick (or a
drifting Series-X stick at rest, past the 12%/4000 deadzone) spun the
camera with no D-pad input. Reported by VGEsoterica.

Fix in the framework (class fix, all analog titles inherit): resolve
the effective pad type before building the button word, and when the
pad presents as analog this frame, suppress the LEFTX/LEFTY/RIGHTX/
RIGHTY axis sources as button contributors. Trigger axes (L2/R2) and
button sources are unaffected; digital mode keeps the stick->D-pad
fold (its only outlet there). The physical D-pad still drives the
D-pad bits, so Ape's camera rotate works from the D-pad as intended.
Stick/axis output is byte-for-byte unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 12:54:28 -07:00
Matthew Stanley 2bebd29629 Harden audio reserve and transient frame catch-up 2026-07-13 05:23:33 -07:00
Matthew Stanley b39387eb00 Fix exact native and static overlay dispatch 2026-07-13 05:23:16 -07:00
Matthew Stanley d53742310d release hygiene: no baked build-machine paths, loud malformed-settings warning
Triage of Tomba2Recomp GH issue #1 exposed three ways a release exe
behaves differently on the build machine than on every user machine:

- DEFAULT_BIOS_PATH baked an ABSOLUTE build-tree path into the binary,
  so promoted/release exes silently load the builder's BIOS wherever
  that path exists and the clean-install picker flow is never
  exercised where releases are validated. runtime.cmake now refuses
  absolute values (loud configure warning, converts to the relative
  default); dev checkouts keep prompt-free boots via a new
  resolve_bios_path rung that walks exe-dir ancestors for
  psxrecomp-v4/<relative>, a layout only dev trees have.

- A settings.toml with a TOML syntax error was silently discarded
  wholesale (user's renderer choice, BIOS/disc paths - everything),
  and the next launcher save then overwrote the file with defaults.
  load_user_settings now reports parse_error; the runtime preserves
  the broken file as settings.toml.bad and warns via message box +
  stderr.

- package_release.ps1 gains guards: fail packaging if the staged exe
  contains a drive-letter absolute SCPH1001.BIN path, if the stage
  carries BIOS/disc/save/sidecar files (SCPH*.BIN, *.cue, *.iso,
  *.mcd, bios.cfg, disc.cfg, settings.toml, keybinds.ini,
  overlay_captures.json), or if saves/ is non-empty.

Verified: rebuilt Tomba2 against this tree - configure warning fires
on the absolute arg, strings scan of the exe finds no drive-letter
BIOS path, and a boot with no [bios] setting and no bios.cfg resolves
the BIOS through the psxrecomp-v4 dev rung.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 18:52:37 -07:00
Matthew Stanley 5a2c6fd3f0 iso_reader: support multi-FILE cue sheets (redump track-split dumps)
The cue parser kept a single bin_filename that every FILE line
overwrote, so a redump-style dump (one .bin per track) mounted only
the LAST track file - the CD-DA audio bin - as the entire disc: no
ISO9660 filesystem, no boot EXE, black screen at startup (reported
against Tomba! 2, whose USA disc is data + one CD-DA track).

Replace the single ifstream with a BinSegment table: one entry per
FILE line, opened for the reader's lifetime and laid out at cumulative
disc-relative sector offsets in cue order. ReadSector/ReadRawSector
resolve a disc LBA to its owning segment; INDEX times, which a cue
states relative to the owning file, are converted to disc-relative
track starts once the segment layout is known. Single-file images are
a one-segment table with unchanged addressing.

Also fixed in passing: Open() never cleared tracks_ from a previous
open (reopening a reader accumulated stale TOC entries), per-read
sector-geometry probing is now computed once per segment, a cue whose
FILE is missing or non-BINARY (WAVE/MP3) fails Open() instead of
mis-mapping the TOC, and GetSectorCount() reports the whole-disc
total.

Verified: Tomba! 2 single-bin dump split at the track-2 boundary
(concatenation SHA1-identical to the original); a host-side test
opens both cues and confirms all 186430 sectors byte-identical and
the TOC identical (track 2 at LBA 174674, audio); the game boots to
intro FMV, title, and attract gameplay from the multi-file dump with
CD audio flowing, and the single-file dump boots identically on the
same binary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 18:29:32 -07:00
Matthew Stanley 34ca37cc65 Merge feat/opengl-performance: full-rate OpenGL presentation 2026-07-11 14:08:08 -07:00
Matthew Stanley fb585541ec opengl: sustain full-rate gameplay and FMV presentation 2026-07-11 14:07:17 -07:00
Matthew Stanley 8f2389211c Merge feat/kernel-stub-bless: byte-verified static dispatch for the relocated kernel window
Closes the latent Rule-18 soundness hole: kernel-window static dispatch
hits (RAM [0x500,0x8500), the BIOS's boot-time ROM copy) now require the
live RAM bytes of the function's full reachable extent to byte-match the
ROM source (lazy verify, cached, precisely invalidated on writes); patched
bodies divert to the faithful interpreter. Emitter ships
psx_bios_kernel_bodies[] (499 entries); runtime guard in memory.c; TCP
kernel_bless observability; PSX_KERNEL_BLESS=0 A/B switch.

USER-VALIDATED on Tomba (SCUS-94236): multiple play runs, LLE + HLE boots,
pads/menus/events, title 1.00x / village 0.92x parity; 208-214/219 kernel
functions verified clean, the 5 mismatches (runtime-patched pad-driver
family) faithfully interpreted as before.

NOTE: rolls the codegen hash — regen BIOS+game generated C and recompile
overlay shards with a same-tree recompiler on each title's next fw bump
(the overlay loader fail-closes loudly on tag mismatch).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 19:59:20 -07:00
Matthew Stanley 71c2df0f54 kernel-image bless: byte-verified static dispatch for the relocated kernel window
The generated dispatcher ran kernel-window table hits (RAM [0x500,0x8500),
the BIOS's boot-time copy of ROM 0x1FC10000+) natively with NO check that
the live RAM bytes still match the ROM image the functions were compiled
from — unsound for runtime-patched kernel code (the BIOS installs pad/SIO
handlers INSIDE compiled bodies; live diff: 15 clusters, 348 bytes). It
never misfired only because entry points happened to avoid patched keys.

A static kernel hit now requires psx_kernel_bless_dispatchable(): the
emitter emits psx_bios_kernel_bodies[] (key -> full RAM extent of the code
reachable from it; continuations carry their parent's extent, 499 entries),
and the runtime lazily byte-compares each entry's body against the ROM
source, caches the verdict, and invalidates precisely on writes into the
body (store chokepoint, DMA/EXE range marks, savestate restore). Clean ->
guarded native; patched/unverifiable -> the faithful dirty-RAM interpreter,
exactly as Rule 18 prescribes. This implements the runtime half of the
relocation-manifest contract (docs/RELOCATION_MANIFEST_FORMAT.md).

Measured on Tomba (SCUS-94236): 208-214 of 219 verified functions clean
(the 5 mismatches are the genuinely patched pad-driver family, faithfully
interpreted); millions of guarded native hits per boot; title 1.00x,
village 0.92x (parity — kernel keys were already native, now they are
PROVEN native); LLE and HLE boots + gameplay input verified. Kernel-window
interp measures 1.3% of interp burden (dirty_ram_stats), so this is a
soundness fix, not a speedup; the dominant interp cost is overlay-region
coverage (cg-tag hygiene), tracked separately.

Observability: TCP kernel_bless (entries/clean/mismatch/native_hits/
verifies/invalidations), always-on. PSX_KERNEL_BLESS=0 reverts to
unguarded-table behavior for A/B.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 19:12:32 -07:00
Matthew Stanley eefea1295f Merge codex/ape-widescreen-framework: scope curved backdrop projection sites (Ape widescreen) 2026-07-10 16:33:00 -07:00
Matthew Stanley 244474a147 widescreen: scope curved backdrop projection sites 2026-07-10 15:51:07 -07:00
Matthew Stanley a0818de0b4 Merge feat/ws-2d-scene-pillarbox: 2D-only scenes pillarbox in native-wide; present ring; full-pad input injection
Tomba 16:9: rooms, sky falls, and task-card sequences presented stretched
because native-wide's 2D-backdrop stretch fires wholesale in scenes whose
background phase never ends. Such scenes now present native 4:3 from the
faithful canonical buffer, keyed on DEEP UNTAGGED POLYGON OVERHANG (>=24px
past the canonical window, >=4 polys, sustained 2 frames) — the only world
signal that survived adversarial scenes (shaded prims, GTE volume, any-poly
and shallow overhang all falsified by census evidence; lineage in gpu.c).
Scoped to sprite-tag titles; full_2d (MMX6) and gte_game_mode (Ape) paths
unchanged. Plus: native-wide never stretches a canonical-width game frame
(fallback pillarboxes); always-on present-classification ring (TCP
present_ring) across all backends; debug input injection drives the full
pad model (hybrid d-pad drop + stick axes); savestate TCP honesty; census
tagged column + ws_hud_mode A/B (HUD-corners deferred: ENHANCEMENTS.md W2).
USER-VALIDATED on Tomba (SCUS-94236).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:41:52 -07:00
Matthew Stanley d57ce7a121 widescreen: census tagged column + ws_hud_mode A/B; defer HUD-corners for tag titles (ENHANCEMENTS W2)
The Tomba nw_hud_corners experiment surfaced two blockers, both documented
in ENHANCEMENTS.md W2 with census evidence: composite dialog boxes spanning
zone boundaries tear (end caps pulled to opposite edges), and the AP counter
renders through the TAGGED sprite funnel so the untagged-only gate skips it.
Groundwork kept for the next attempt:
- WsCensusEntry gains a `tagged` column (ws_census CSV) so funnel membership
  is measurable per prim.
- TCP ws_hud_mode {"tag_rects":0|1} live-A/Bs the tagged-rect gate.
Tomba's game.toml reverts nw_hud_corners to false (its own repo).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:37:59 -07:00
Matthew Stanley b85f10959b widescreen: pillarbox 2D-only scenes in native-wide; HUD corner re-anchor for tag titles; present ring
2D-only-scene classifier (fixes room/fall/title-card stretch, Tomba):
- Native-wide's 2D-backdrop stretch fires wholesale in scenes whose
  background phase never ends (room interiors, sky-only falls), stretching
  the whole frame. Such scenes now present native 4:3 from the faithful
  canonical buffer (gpu_ws_present_native_43).
- The world signal is DEEP UNTAGGED POLYGON OVERHANG: polys whose raw SX
  extent crosses >=24px past the canonical window, >=4 per frame, sustained
  2+ consecutive frames. Lineage of rejected signals (each defeated by a
  real scene, census-evidenced): shaded-prim presence (title-card letter
  tiles are gouraud), GTE projection volume (task-jingle projects
  world-scale counts; title screen idles at 1680 verts), any-poly overhang
  (card letters slide in from off-screen), shallow overhang (jingle
  sparkles poke <=20px). Outdoor terrain measures 55 deep-overhang
  polys/frame vs 0-1 for every observed 2D scene/effect.
- Scoped to sprite-tag titles: full_2d (MMX6) and gte_game_mode (Ape)
  classification is unchanged.

Native-wide present invariant:
- A game frame that cannot present wide (compositor fallback) pillarboxes
  4:3 instead of stretching the canonical read across the wide window
  (all backends; squash mode still stretches by design).

HUD corner re-anchoring for sprite-tag titles ([widescreen] nw_hud_corners):
- Poly/line prims never shift (they are the world / tagged billboards);
  rect-family prims shift only when UNTAGGED — the proven hud_sprt_squash
  discriminator — so enabling nw_hud_corners is safe without a packet
  arena range.

Observability (always-on, institutional):
- present_ring: per-present classification ring (path, present width,
  game_mode, native_43, wide fallback, tag age, gte_verts, ovh_prims)
  for every backend; TCP "present_ring". Answers "why did this frame
  stretch" after the fact.
- gpu_state ws block gains last_3d_frame, gte_verts, last_world3d_frame,
  ovh_prims, last_ovh_frame; GTE vert counting is now always-on.

Input injection drives the full pad model:
- set_input/press now resolve pad type like real sampling: injected d-pad
  drops a hybrid pad to digital (movement works), optional lx/ly/rx/ry
  stick override (0..255), type via the coherent request channel.
  Previously buttons-only: menus reacted, walking never did.

Savestate honesty:
- savestate_request_save/load return staged/refused; the TCP handler
  reports ok:false instead of silently acking loads it cannot perform
  (LLE host-fiber runs). Torn-restore reliability audit remains open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:23:44 -07:00
Matthew Stanley 2ba81d5d7b Merge feat/audio-mmx4-attract: MMX4 attract-demo audio fixed (two defects)
1) cdrom: continuous disc clock — the read stream froze disc time while
   the guest had an unacked INT, starving the XA->SPU ring ~5-6x/s
   (6 Hz music dropout flutter). Replaced with the Beetle-mirrored
   SetAIP/CheckAIP model: disc never pauses, data-ready INT1 pends one
   deep, presented on ack. read_hold_cycles/int1_* tripwires in
   cdrom_state.

2) spu: END-without-REPEAT now jumps to the repeat address AND forces
   the envelope to zero (Beetle spu.cpp:333,341-352). Previously parked
   one-shot SFX voices decoded past the terminator at full envelope —
   X4's SFX bank parks them on +28672-DC filler with ~infinite release,
   railing the mix at +32767 (38-41% samples clipped = the 'static').

User ear-validated on the MMX4 attract ice-stage demo. Gate: MMX4 0.00%
clip post-fix; MMX5 boots healthy (pend path exercised 12-13x, 0 lost);
Tomba behavior equivalent to master. Runtime-only — no regen needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:19:14 -07:00
Matthew Stanley 462e2bf5fd spu: END-without-REPEAT jumps to repeat and forces the envelope to zero
An ADPCM block whose flags carry END (bit 0) but not REPEAT (bit 1)
must (a) jump the decode pointer to the latched repeat address and
(b) force the ADSR envelope to ZERO on entering Release — Beetle
spu.cpp:333 (CurAddr = LoopAddr, unconditional) and :341-352 ("Force
enveloping to 0 if not looping"). Our voice_next_sample did neither:
it entered Release with the envelope intact and kept decoding FORWARD
past the terminator, assuming the release ramp would mask whatever
followed in SPU RAM.

X4's SFX bank breaks that assumption three ways: its one-shot samples
terminate on an END-only block; the next block in RAM is non-silent
filler (0x77 nibbles at shift 0 = constant +28672) carrying a
self-referential LOOPSTART flag, so the walker parks there forever;
and the bank's ADSR release shift is ~infinite, so the envelope never
decays. Every finished SFX voice became a full-scale DC source. In the
X4 attract ice-stage demo (user report), a handful of ice-block-break
SFX stacked ~15 such voices and pinned the whole mix at +32767 for
~35 s — heard as static / the music "cutting out". Measured: 38-41%
of samples hard-clipped in the spu_out AND host taps during the demo.

With the fix (jump + env=0; the env-0 Release path then deactivates
the voice, matching Beetle's silent-voice behavior): 0.00% clipped
samples over the same demo window, peak 32291, zero railed polls in
150 across the full attract cycle; the ice-crack SFX bodies still
play normally.

Found via the new rail-triggered spu_voices capture + the spu_ram
peek: 15 voices sharing start=0x16AD0 all parked at cur_addr=0x17930
looping 0x17920 (flags 0x07) at env 0x7FBF in phase RELEASE, and the
block bytes 00 07 77 77 .. decoding to +28672 DC.

Debug surface added (always-on doctrine):
- spu_voices now reports live env level + ADSR phase per voice.
- new spu_ram command: {"addr","len"} -> hex peek of SPU sample RAM.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:02:31 -07:00
Matthew Stanley 9306db59b3 cdrom: continuous disc clock — never freeze the read stream on INT ack
The read stream froze read_delay whenever the guest had an unacked
controller INT (and while the sector buffer was undrained). Real drives
never pause: measured on MMX4's attract/title XA music, the freeze
accumulated ~1M guest cycles/second (~3% of disc time), every XA sector
arrived 32-92 samples late, and the XA->SPU cd_ring starved ~147 samples
5-6x per second — audible as a 6 Hz crackle/dropout flutter in all
XA-streamed music (user report: MMX4 Zero ice-stage attract demo).

Faithful model, mirrored from the in-tree Beetle oracle (cdc.cpp
HandleSectorRead / SetAIP / CheckAIP / ClearAIP):

- Disc time NEVER pauses while reading; sectors are read on exact
  cycle schedule. XA-ADPCM realtime audio flows to the SPU decoder
  unconditionally (this alone removes the crackle).
- A data sector that comes due while the guest still has an unacked
  INT overwrites the sector buffer on schedule (hardware clobbers its
  FIFO the same way) and its data-ready INT1 pends ONE deep, presented
  the moment the guest fully acks (Beetle CheckAIP). A newer sector
  replacing an unpresented notification is counted + traced 'P'
  (Beetle "Previous notification skipped").
- Drive-state changes (Read/Play/Pause/Stop/Seek paths through
  start/stop_read_stream) cancel the pended notification (ClearAIP).
- The active-DMA refill path (deliver without new INT while a
  multi-sector CD DMA drains) is unchanged.

Verification on MMX4 (SLUS-00561) title/attract XA:
- CD_PUSH guest-cycle spacing: exactly 1,806,336 cycles (8 sectors at
  2x), min == max over 148 pushes (was 1.83-1.88M, systematically late).
- read_hold_cycles == 0 (was ~30.8M per 30 s) — kept as an always-on
  regression tripwire in cdrom_state.
- int1_pended == 0 during music: X4 acks fast; the pending path only
  arms for genuinely slow acks.
- spu_out/host tap rings: 6 Hz zero-gap storm (86-144 gaps/95 s of
  147-265 samples) eliminated; only real content silences remain.
- cd_underflow_frames stays 0 during playback.

Always-on observability added (ring-buffer doctrine):
- XA zero-run scanner in the decode path -> AUDIO_EV_XA_ZERO events
  (stage 0 = post-ADPCM, stage 1 = post-resample+volume, with LBA).
- CD_PUSH audio events now stamp the global guest-cycle clock (the
  spu_out sample stamp is pump-quantized and cannot measure cadence).
- cdrom_state: read_hold_cycles/read_hold_events (must stay 0),
  int1_pended / int1_lost / int1_pending_now.
- Savestate: pending_dataready fields appended to CDROM_SNAP_FIELDS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 13:55:52 -07:00
Matthew Stanley e8943945e1 Merge codex/tomba2-whoopee-beach-fix: native-wide flat/phase backdrop gates + FMV silent-hold
Tomba 2 widescreen enablers: [widescreen] nw_flat_backdrop /
nw_phase_backdrop native-wide mirror gates (canonical 4:3 untouched),
[video] fmv_skip_no_xa_hold for silent preloaded-logo auto-skip,
ws_dbg_stretch modes 7/8, fmv_state JSON escaping.

Conflicts (config_loader.h/.cpp, main.cpp) were both-sides-adds vs the
MMX4/5 clear_reveal work - resolved keeping both feature sets.
Compile + boot sanity: Ape build-release rebuilt and boots.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 13:48:56 -07:00
Matthew Stanley 264f00cee5 widescreen: native-wide flat/phase backdrop gates + FMV silent-hold (Tomba 2 Beach Town)
- [widescreen] nw_flat_backdrop: in the native-wide mirror only, stretch
  untextured flat primitives across the wide output (flat-colour sky/water
  backdrops). Canonical 4:3 framebuffer stays byte-for-byte untouched.
- [widescreen] nw_phase_backdrop: stretch textured prims emitted before the
  frame first shaded 3D primitive (authored 2D backdrop phase), isolating
  the sky from the textured 3D foreground. Both runtime-only, off by default,
  title-opted (draw ordering is title-specific).
- [video] fmv_skip_no_xa_hold (4..3600 vblanks): presentation-side
  fast-forward latch after the last silent MDEC decode, so auto-skip covers a
  preloaded logo authored post-decode wait (Whoopee Camp logo). Guest
  timeline unchanged; default stays the generic 4-vblank hysteresis.
- ws_dbg_stretch correlation modes 7 (all prims) / 8 (all flat prims).
- fmv_state: JSON-escape config_path + report fmv_skip_no_xa_hold.

Validated on Tomba 2: Release build, unattended first-attract captures at
4:3 / 16:9 / 21:9, zero unknown dispatches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 13:45:23 -07:00
Matthew Stanley e5a958f86a Arm the text-image guard from the disc image; require it for the alternate game-start latch
The dirty-RAM text-image guard (685f38d) only armed when the loose local
boot-EXE file next to game.toml existed - found via find_project_root's
repo-marker walk. End-user release installs have neither a repo checkout
nor the EXE file (it is copyrighted game data), so the guard silently
never armed. Without it, fntrace's alternate game-start latch
(psx_game_address_in_text && dirty_ram_text_native_ok) cannot fire for
titles whose BIOS handoff enters text inside the shell-window shadow
(RAM 0x30000-0x5AFFF -> shell ROM until game-start): the shadow never
lifts, execution runs dead shell ROM bytes over real game text, and the
boot dies in a jump to PC=0 (Ape Escape v0.0.2/v0.0.3 release zips,
frame 14, both HLE and LLE boots; in-repo runs armed invisibly and
masked the bug during validation).

Fix, class-level for every title:
- main.cpp: defer guard arming until the disc path is resolved and add a
  second source - extract the boot EXE from the mounted disc image via
  ISOReader (basename of game.toml's exe field, SYSTEM.CNF BOOT token
  fallback). The disc is the same bytes the BIOS loads, i.e. the true
  reference; every install has one. Local EXE file still wins for dev
  checkouts. A loud WARNING prints when neither source arms (previously
  silent, which is how the broken releases shipped green).
- fntrace.c: the alternate game-start latch now requires a registered
  image (dirty_ram_text_image_registered). Its safety argument - "RAM
  still holds shell bytes that differ from the game image, so it cannot
  fire prematurely" - only holds when an image exists; with no image,
  native_ok degrades to !dirty and untouched text pages pass it,
  latching game-start before the EXE loads and clearing the dirty
  baseline mid-load. Without an image, only the exact entry_pc dispatch
  may latch (the pre-685f38d conservative behavior).

Verified: v0.0.3 zip contents + rebuilt exe in an isolated directory
(no repo above it) boots to the Ape Escape intro on both HLE and LLE
paths, guard armed from disc SCUS_944.23; in-repo runs still arm from
the local EXE.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 12:54:00 -07:00
Matthew Stanley f1afc7fa44 Merge feat/l3-r3-stick-binds: first-class L3/R3 stick-click binds
Controller: l3=leftstick / r3=rightstick defaults (map rows were missing
entirely). Keyboard: T/Y defaults. Beetle oracle keyboard: T/Y. Shipped
input.ini templates + README updated. Validated on Ape Escape (user-
confirmed L3/R3 on real pad).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 11:09:50 -07:00
Matthew Stanley 29b1fd6246 input: first-class L3/R3 (stick click) binds across the keybind ecosystem
The controller map had no l3/r3 rows at all, so pad bits 1/2 could never
be produced by a gamepad — DualShock-only games (Ape Escape) were
unplayable. Keyboard slots existed but shipped unbound.

- main.cpp: PAD_L3/PAD_R3 bits; controller_map 14->16 entries; defaults
  l3=leftstick, r3=rightstick; input.ini template + doc comment updated.
- psx_keybinds.c: keyboard defaults L3=T, R3=Y (continue the Q/W/E/R
  shoulder row). Launcher Controls page picks them up automatically.
- beetle_main.cpp: same T/Y keys on the oracle so psx-beetle stays
  drivable for L3/R3 games.
- tools/package_release.ps1: shipped input.ini template gains l3/r3.
- README: keyboard + controller map tables.

Wire is unchanged: sio.c transmits the button word verbatim, matching
Beetle's dualshock.cpp. Validated in-game on Ape Escape (user-confirmed
R3/L3 inputs on a real pad).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 11:09:33 -07:00
Matthew Stanley dbfc124856 Merge master into feat/mmx4-widescreen-fw
Master brought the MMX5 true-wide generalization, which evolved this
branch's earlier X4 work (unified psx_ws_bg2d_* helpers, copied-a1 cull,
sltiu count sites, nw_left_hud_packet arena filter). Resolution keeps
master's evolved implementations and grafts the X4-only pieces on top:

- bg2d count_site accepts signed slti alongside sltiu (master style
  ?1u:0u emission); startx_site accepts subu rd,zero,rt (X4's
  -(scrollX & 15)).
- cull a1 move sites accept both move encodings (addu rD,a1,zero and
  addu rD,zero,a1) with a distinct addu-but-not-move error.
- HUD arena filter: master's nw_left_hud_packet_lo/hi is the single
  mechanism (X4's parallel nw_hud_source_lo/hi dropped); X4's broader
  prim coverage kept — ws_nw_hud_shift_vertices re-anchors polygons,
  lines and dots as rigid composites alongside the rect/sprite sites.
- mono_rect gets both ws_expand_fullscreen_rect and the HUD re-anchor.

Also carried from this branch: binary-search dispatch table and
Release-gated GL frame_perf instrumentation (auto-merged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:52:04 -07:00
Matthew Stanley c6df17102d gl: compile frame_perf instrumentation out of Release builds
Release compiles out the debug server, so leave the GL timer-query
plumbing disabled there too: a native-wide frame can otherwise issue
hundreds of unused mirror timestamp queries per frame. Developer
builds keep the always-on ring unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:32:52 -07:00
Matthew Stanley 97ea975580 widescreen: X4-generation 2D hooks — inline-count bg2d variants, copied-a1 cull, HUD source-range filter
Generalizes the Capcom 2D widescreen machinery for the MMX4 engine revision:

- [widescreen.bg2d] count_site accepts the inline slti/sltiu loop compare
  (X4/X5 style) alongside MMX6's li; startx_site accepts subu rd,zero,rt
  (X4's -(scrollX & 15)); inline-count sites route through the generic
  psx_ws_bg2d_* helpers that keep the emitted ring mask and skip MMX6's
  title-specific host-side refill state.
- [widescreen.cull] a1_sites now also accept a move rd,a1 (addu encoding):
  classifiers that copy the caller margin with no delay-slot nop widen the
  copied value instead.
- [widescreen] nw_hud_source_lo/hi: optional GP0 command-source range filter
  for nw_hud_corners, so a pure-2D game re-anchors only its dedicated HUD
  packet arena and never world sprites. 0/0 preserves legacy behavior.
  ws_nw_hud_shift_vertices re-anchors polygon/line prims as one rigid
  composite; mono rect/dot/8x8 sites shift too.

All helpers identity at 4:3; unset config keys are byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:32:40 -07:00
Matthew Stanley 40b476c4e4 widescreen: finish MMX5 edge handling 2026-07-10 10:07:40 -07:00
Matthew Stanley 57ce271990 Merge branch 'master' into feat/mmx5-widescreen-fw
# Conflicts:
#	recompiler/src/config_loader.cpp
#	recompiler/src/config_loader.h
#	recompiler/src/main_psx.cpp
#	runtime/src/gpu.c
#	runtime/src/main.cpp
2026-07-10 08:52:29 -07:00
Matthew Stanley e4145d7008 Merge feat/crashbash-fw-fixes: GTE lm/sf depth-cue/lighting fix + dispatch shell-window + VBlank pacing
GTE honors per-instruction lm (bit 10) and sf (bit 19) in the depth-cue and
lighting family (INTPL/DPCS/DPCT/DPCL/NCDS/NCDT/CDP/...) with the faithful
hardware formula and always-on GTE observability rings (gte_ring_dump,
gte_intpl_dump, gte_frame_stats, gte_latch_dump). Fixes Crash Bash
menu-character strobe/flicker and in-game flattening (signed INTPL vertex
tweens were clamped to zero by the hardcoded lm=1).

Also: dispatch no longer shadows runtime-relocated game code in the BIOS
shell window (native-ok byte compare routes matching text to the game path);
stale GPUSTAT-poll VBlank over-delivery fix + present pacing tooling.

Cross-title validation (GTE_LM_FIX.md): Crash Bash (fix target), Ape Escape,
Tomba 2, Mega Man X6 - all rebuilt branch-flavor (regen included) and verified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 08:43:38 -07:00
Matthew Stanley c3f4aab028 Merge feat/mmx6-widescreen: MMX6 true-wide support
# Conflicts:
#	runtime/launcher/launcher.cpp
#	runtime/launcher/launcher.h
2026-07-10 08:41:36 -07:00
Matthew Stanley e9ca97ad11 feat: finish MMX6 widescreen edge handling 2026-07-10 08:28:43 -07:00
Matthew Stanley 54df1ecf7b Merge feat/audio-quality-pin: measured audio-quality campaign (MMX5 complaint)
Always-on audio tap rings + SPU event ring (runtime + psx-beetle oracle),
audio_stats/audio_wav/audio_events debug commands, offline A/B toolchain.

Fixes, all measured against the Beetle oracle:
- clock-domain DRC bridge for SPU output (pull-mode, polyphase resampler)
- guest-cycle sample budget (768 cyc/sample exact; was 60.00Hz assumption)
- frame pacer bounded catch-up (was forgiving all >1-period debt)
- PI fill servo with anti-windup (was P-only with standing deficit)
- voice loop-address register is LIVE state (X5 stuck-voice +11dB fix)
- CDC decode-volume matrix (X5 music fades)

MMX5: underruns ~360 -> 1 per 150s; level -3.8 dBFS clipping -> -15.5
vs oracle -14.1. Cross-title gate: Tomba built+booted, bridge fill
stable at target, user ear-validated both titles.
2026-07-10 08:13:17 -07:00
Matthew Stanley 8dbe04e6c5 spu/cdrom: live voice loop-address + CD-audio volume matrix (X5 +11dB fix)
Two hardware-faithfulness fixes found by the always-on tap rings + Beetle
oracle A/B on fingerprint-matched attract music (tools/audio_pair_segments):
the recomp played the identical track at -3.8 dBFS vs the oracle's
-14.1 dBFS — +10.3 dB hot, clipping continuously.

1. spu.c: the voice repeat/loop address register (voice reg 7) is LIVE
   state on real hardware — writing it after KEYON retargets where the
   next END+REPEAT block jumps (Beetle spu.cpp:1150/333), and the block
   auto-latch (flag bit 2) updates the guest-visible register (386/1302).
   The recomp cached repeat_addr at KEYON only, so X5's driver idiom —
   KEYON, then point the loop register at a silent tail block; no KEYOFF
   ever — left voices looping the LOUD sample body forever. Measured: 3
   stuck voices re-looping ~1400x/s, 160k SPU events/min vs the oracle's
   633, zero game KEYOFF writes (the game never needs one), +11 dB.

2. cdrom.c: implement the CD controller's decode volume matrix
   ([src][port], 0x80 unity, pending banks + apply latch at 1F801803.3
   bit 5 — mapping copied from Beetle cdc.cpp:1483-1501, applied after
   the XA resample per PS1 hardware tests). X5 drives it constantly:
   0x73-0x7E steady, ramped to 0x00 at scene changes (measured via the
   new beetle `cdc_volume` command + PS_CDC::PSXRecomp_GetDecodeVolume
   peek). Without it music fades never happened and steady level sat
   ~0.6 dB hot.

Also: AUDIO_TAP_VOICES (T0 voice-sum tap) for source-by-source mix
decomposition, per-tap WAV rates, bridge stats in audio_stats.

Measured after (MMX5 attract, 20-25 s windows):
  spu_out RMS: -3.8 dBFS (clipping)  ->  -15.5 dBFS   [oracle -14.1]
  music-only chain already matched the oracle at -14.8 dBFS, confirming
  decoder + mixer gain equivalence; the entire excess was stuck voices.
  underruns: 2 events / 98 s (delivery fix holding).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 01:54:46 -07:00
Matthew Stanley 79cdb12eed feat: support MMX6 finite-map widescreen 2026-07-10 01:47:29 -07:00
Matthew Stanley 49e758e4c9 audio: guest-cycle sample budget + pacer catch-up + PI bridge servo
Three producer/delivery fixes, each measured on MMX5 attract (port 4520
audio_stats, always-on rings):

1. Guest-cycle sample budget (main.cpp): the SPU pump produced 44100/60
   samples per host present — assuming a 60.00 Hz vblank against the real
   NTSC 59.94 Hz and quantizing to presents. Production measured 43950/s
   vs the device's 44100/s. Now the budget derives from psx_cycle_count:
   exactly 768 guest cycles per output frame (33.8688 MHz / 44100), so
   audio tracks guest time like every other device.

2. Bounded pacer catch-up (frame_pacing.c): frame_pacer_wait re-anchored
   (forgave all debt) whenever a frame ran >1 period late, permanently
   slipping the timeline on every heavy frame — measured 59.80 Hz average
   against the 59.94 target. Debt up to 8 periods is now repaid by running
   unpaced, so transient stalls no longer starve the audio ring; only
   sustained sub-realtime forgives.

3. PI bridge servo (recomp_audio_drc.h): the P-only fill servo needed a
   standing fill deficit of corr/kp to hold any steady correction (0.3%
   skew = 7.5 ms of permanently eaten margin -> jitter spikes hit the dry
   floor). Added an anti-windup integral term that holds steady clock skew
   at zero fill error, and raised target/ring 50/150 -> 80/250 ms for
   emulated-machine jitter (tens of ms, far beyond host-driver jitter).
   Also: bridge dry-pulls during an intentional turbo mute are resynced
   past, not reported as gaps (the mute IS the silence).

Measured, MMX5 attract, 150 s steady windows:
  legacy push baseline:   ~360 underrun events / 150 s (queue to 0 = gap)
  bridge as merged:       ~90 underrun events-equivalent / 150 s
  this commit:            1 underrun event / 150 s, fill ~55-73 ms,
                          production 43981/s, corr -0.5% (absorbs the
                          guest's residual ~0.27% sub-realtime pace).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 01:14:36 -07:00
Matthew Stanley d9dae48c8e widescreen: generalize Capcom 2D background hooks for MMX5 2026-07-10 00:57:03 -07:00
Matthew Stanley 9cf627113a audio: clock-domain DRC bridge for SPU output (fixes sustained crackle)
Replace the push-model SDL_QueueAudio output with the shared band-limited
polyphase resampler + P-only fill servo (recomp_audio_drc.h), fed by an SPSC
ring under SDL_LockAudioDevice and drained by a pull callback. The SPU renders
44100/60 per wall-clock frame while the host consumes on its own crystal; the
unsynchronized clocks drifted the queue to underrun (silence gaps = sustained
crackle). The bridge nudges the resample ratio <=+/-0.5% to hold the ring near
target so the queue never runs dry.

Measured on MMX6: legacy push ~1.5-2.5 underruns/s (queue draining to 0ms) ->
bridge 0 underruns, fill held ~50ms. PSXRECOMP_AUDIO_LEGACY=1 keeps the old
push path for A/B; PSXRECOMP_AUDIO_PROBE=1 prints the fill/underrun trend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 00:50:55 -07:00
Matthew Stanley f36b458a78 launcher: revert stb launcher to RmlUi on master (user decision)
The stb launcher revamp (8b098bd, ae89a8f, 89e0e0f) uses modern GL 3.0+
without the SDL_GL_GetProcAddress loader ('Phase 2b'), so it fails to
link into game builds, and its rendered UI is not acceptable (user
review 2026-07-10). Revert master to the working RmlUi launcher — the
same revert every recent release (Ape v0.0.2, Tomba2 v0.0.2) already
shipped with. The stb work remains reachable via the original commits
if a Phase-2b GL-loader follow-up ever revives it.

(cherry picked from release/ape-v0.0.2-rmlui 929b78b)
2026-07-10 00:12:26 -07:00
Matthew Stanley 5a5922d98f audio: always-on PCM tap rings + oracle WAV capture (SNES-campaign port)
Measurement infrastructure for the audio-quality campaign (MMX5 complaint),
ported from snesrecomp's audio_trace/oracle methodology and the
_audio_round2 tap model. No behavior change to the audio path itself.

- audio_trace.{c,h}: three always-on 95 s PCM rings — T1 spu_out (canonical
  spu_render output), T2 cd_in (XA/CD-DA PCM entering the SPU CD bus),
  T3 host (exact post-fade bytes handed to SDL_QueueAudio) — plus a 512k
  event ring (every SPU reg write stamped with the SPU sample clock;
  RENDER/PUMP_SKIP/UNDERRUN/MUTE/UNMUTE/CD_PUSH/SPU-DMA), audible-vs-
  silent counters (-42 dBFS gate), queue watermarks, and a server-side
  44100 Hz WAV dumper (rate exactness matters: snesrecomp da33c06).
- psx-beetle: the libretro audio callbacks stop discarding Beetle's fully-
  mixed reference SPU PCM and record it into the same tap ring. Port 4380
  gains audio_stats/audio_wav/audio_events with the identical wire protocol
  as the runtime (rule 16 symmetric harness) — the oracle WAV capture.
- debug_server: audio_stats / audio_wav / audio_events on the runtime port.
- tools: audio_ab_diff.py + audio_spectral_ab.py (drift-tolerant offline
  A/B: drift ppm, onsets, cents, timbre, click rate) and audio_analyze.py
  (first-dirty-tap cross-tap decision tree) ported from snesrecomp/
  _audio_round2; audio_capture_ab.py dumps both processes symmetrically.

Probes query history; nothing is armed at probe time.
2026-07-10 00:00:09 -07:00
Matthew Stanley fee5535870 sio+memory: fix memory-card saves for Tomba 2 (write handshake + RAM-0 store visibility)
- sio: echo sector MSB/LSB during the memcard write address phase
  (new MC_WRITE_LSB_ECHO state), matching hardware/Beetle; the BIOS
  card-write path validates this echo before scheduling the follow-up
  directory reads.
- memory: treat BIOS trampoline-mirror and delay-loop scratch stores to
  RAM 0x0..0x10 as non-visible after game start (hardware/Beetle do not
  expose them), preserving Tomba 2 sector-63 card-management payload
  passed as buffer=0 to _card_write.
- memory: stop the BIOS card-manager cleanup helper (kernel 0x1C5C)
  from re-arming/consuming the public MARK card event between
  DeliverEvent and the game TestEvent poll (narrowly keyed to the EvCB
  layout), which left Tomba 2 stuck at "Checking MEMORY CARD...".
- fntrace: clear the low boot scratch at game-start handoff.

Fix authored by Codex; validated by user: Tomba 2 save/load PASS, plus
cross-title regression gate (Ape Escape, Tomba, Mega Man X6) all PASS,
all built against this runtime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 23:25:42 -07:00
Matthew Stanley c4474b9340 fix(gte): honor lm/sf from the instruction in the depth-cue/lighting family; add always-on GTE observability rings
The depth-cue tail (INTPL/DPCS/DPCT/DPCL/NCDS/NCDT/CDP) and the lighting
helpers (light_transform / light_color / color_output) hardcoded lm=1 on
every IR write-back, clamping all negative components to zero. Crash Bash
tweens its vertex animation through INTPL (cmd 0x0980011, lm=0): pose A in
IR1-3, pose B in the far-color registers, blend in IR0. With lm forced on,
every negative vertex component collapsed to 0 on tween frames, squashing
characters onto their anchor point. At 10fps keyframes / 30fps renders that
made the menu character strobe (2 of 3 renders flattened, so exactly one
display buffer ever held a healthy pose) and in-game characters flatten.
Beetle/hardware render the same frames correctly.

depth_cue_from_ir is now the faithful hardware formula: base = IR<<12,
step = lim(((FC<<12) - base) >> sf*12) with the forced-unsigned-off
intermediate clamp, MAC = (base + IR0*step) >> sf*12, final IR clamp with
lm from the instruction word. sf (bit 19) is honored instead of assuming 12.
The lighting helpers take lm from the instruction too (games light with
lm=1, so their behavior is unchanged).

Observability (always-on, build-dbg commands):
- GTE projection ring (gte_ring_dump): every RTPS/RTPT's V/RT/TR/H/OFX/OFY
  inputs and SXY/SZ/FLAG outputs, frame-stamped, with caller ra.
- GTE INTPL ring (gte_intpl_dump): every INTPL's IR0/IR1-3/FC inputs
  (snapshotted pre-op) and MAC/IR/FLAG outputs, with frame filter AND
  offset paging so a whole frame is reachable.
- gte_frame_stats: per-frame nproj/nsat/nflat/nintpl/nintpl_tiny —
  nintpl_tiny (all-tiny output from non-tiny input) is the collapse
  signature and pinned this bug to the INTPL math in one query.
- gte_latch_dump: retained ring of saturated projections.

Verified on Crash Bash SELECT GAME TYPE: nintpl_tiny 335-352/473 -> 0-3,
vertex buffer at 0x80056AC8 no longer alternates healthy/collapsed, and a
20-shot jittered screenshot burst shows the character in every frame
(was bimodal present/absent).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 23:19:21 -07:00
Matthew Stanley 184a18a2a8 Merge fix/mmx6-sprites: mirrored-2D uv sampling fix + shared uv model + display ring
- gpu_uv.h: the ONE shared PS1 uv-sampling model for GL/VK/SW (limits +
  Beetle Calc_UVOffsets mirrored-2D compensation)
- fixes the MMX6 sprite-sliver class (detached 1px sprite-colored lines at
  quad seams of X/Y-flipped sprites; pink line under wheel mechaniloids):
  poly path AND the gpu.c axis-aligned-mirrored-quad -> rect_scaled path
- always-on display ring (display_ring_get/aux/stats): frame-exact display
  + full-VRAM capture for cross-renderer forensics
- user-validated clean on MMX6 (GL); cross-title gate: Tomba GL boot +
  attract verified clean against this branch

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 19:27:52 -07:00
Matthew Stanley 03c3f79ad9 gpu: extract the ONE shared uv-sampling model into gpu_uv.h (DRY)
The uv limits + mirrored-2D compensation math lived in five hand-copied
sites across three backends (GL/VK: tri_uv_mirror_offset + tri_uv_limits +
the rect-path block; SW: sw_tri_uv_limits + sw_rect_uv_limits). That
duplication is exactly how the sliver bug family happened — the copies
drifted, and the fix then had to be mirrored by hand into every site
(e86099f missed the rect path; 148f171 patched it separately).

gpu_uv.h now carries the model once:
  psx_uv_tri_limits / psx_uv_rect_limits    exact inclusive sampled range,
    direction-aware on the ORIGINAL uvs (forward [lo,hi-1], mirrored
    [lo+1,hi]), page-wrap widen
  psx_uv_tri_mirror_offset / psx_uv_rect_mirror_offset   the Beetle
    center-sampling +1 bump — GL/VK only; the SW DDA is PS1-exact and
    takes none

GL/VK compute limits from the original uvs then bump; SW delegates its two
limit functions. Verified byte-identical: deterministic attract frame 2850
display-ring capture hashes equal pre/post refactor. Semantics note: for
rare half-diagonal prims the limits are now per-axis (the old blunt rule
also backed off the diagonal axis); SW mirrored-rect bilinear clamp
tightens to the actually-sampled range — both only observable with
texture_filter on (non-default).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 19:04:39 -07:00
Matthew Stanley 3d05c9e98b fix(dispatch): don't shadow runtime-relocated game code in the BIOS shell window
Boots Crash Bash (SCUS-94570) past its frame-~144 hang into the attract loop.
Root cause: the Class-A shell-window fix (normalize() maps RAM 0x30000-0x5AFFF ->
shell ROM) collides with games whose code lives in that window, and Crash Bash
RELOCATES a code page onto 0x30000 at runtime. Three interacting gaps sent that
real game code to dead shell ROM -> unknown-dispatch abort:

1. fntrace.c — game_started never latched: it only tripped on a dispatch to
   entry_pc (0x2E7B0), but Crash Bash reaches entry via compiled internal flow,
   so the latch never armed and the shell-window shadow stayed active. Now also
   latches on the first dispatch to native-ok game text (safe pre-load: that RAM
   still holds shell bytes that differ from the game image).

2. dirty_ram_interp.c — THE decisive fix. When a game-text address diverged from
   the static EXE image (native_ok=0, i.e. runtime-relocated/overlaid code) but
   was not "dirty", dirty_ram_dispatch_inner bailed (return 0) and the caller
   shadowed it to shell ROM. Now a diverged game-text address is marked a clean
   game-text miss so the dirty interpreter executes the live RAM bytes.

3. full_function_emitter.cpp (BIOS dispatch driver) — also route native-ok
   game-text shell-window addresses to the game path regardless of the latch.
   Likely redundant now that (1) latches correctly; flagged to slim during the
   final cross-game validation.

NOT merged/validated against other titles yet (MMX6/Tomba2) — per plan that
happens after Crash is fully running. Verified: Crash Bash runs headless to
6000+ frames and windowed with no dispatch abort; the vblank fix delivers 60/s.
Known remaining issue: character flicker at the game-type menu (Bart's original
report) — under investigation, unrelated to this dispatch fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 18:47:38 -07:00
Matthew Stanley 148f171daf gpu gl/vk: mirrored-2D uv compensation for the RECT path too
The e86099f fix put the Beetle Calc_UVOffsets mirrored-uv bump in the
textured-POLY path only — but gpu.c's axis-aligned-quad fast path converts
mirrored quads (X/Y-flipped 2D sprites, e.g. every right-facing MMX entity
cel) into gr_draw_textured_rect_scaled calls with u0>u1 / v0>v1, so they
enter gpu_textured_rect and never see the poly compensation. That is why
the sprite slivers persisted on X (worst facing right / crouching) after
e86099f: the visible producers were all rect-path prims.

Fix: same +1 bump along each decreasing uv axis in gpu_textured_rect (GL
and VK), with the sampling bounds tightened to [min, max-1] post-bump
(max is the exclusive edge in both directions). Forward rects are
unaffected (bump is a no-op; bounds identical to before).

Frame-exact verification (deterministic attract, frame 2850, X idle
facing right): pre-fix GL vs software full-frame diff = 3707 px spread
across the sprite area; post-fix = 0 px in the entire gameplay area (the
only remaining diff is the attract logo overlay blink-phase offset caused
by the known GL blend-rounding timeline fork, tracked separately).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 18:46:38 -07:00
Matthew Stanley e8ad8ade3d debug: always-on display ring + full-VRAM aux capture
New always-on ring in the debug server: every vblank captures the display
area's raw 15-bit pixels AND the full 1024x512 VRAM as the ACTIVE RENDERER
sees it (GL: FBO truth via fbo_peek; SW: CPU truth), 64 frames of lookback.
Commands: display_ring_get (frame -> PNG), display_ring_aux (frame -> raw
u16 VRAM blob), display_ring_stats.

Purpose: FRAME-EXACT visual forensics. screenshot_file returns whatever is
on screen when the command lands (1-3 frames late), which cannot support
cross-renderer same-frame comparison; the ring can. Proven use (MMX6 sprite
triage): pulled the SAME deterministic frame from a GL run and a software
run, micro-cosimed the suspect sprite prim from the GP0 ring against the
same-frame captured texels/CLUT, and established GL and SW render the scene
pixel-identically with byte-identical texture content - reclassifying the
reported artifact from renderer bug to (pending Beetle oracle) game content.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 18:26:31 -07:00
Matthew Stanley 7b746a0a4b fix(gpu): stop stale GPUSTAT-poll VBlank over-delivery; add present pacing + strobe tooling
Framework-level improvements distilled from an external Crash Bash fork of
psxrecomp, rebased onto current master. Two of the fork's five touched files
(code_provider.c stddef include, overlay_loader.c non-Windows atomic shim) are
already fixed upstream and were dropped; the CrashBash runtime target is
game-side and lives in the game repo, not here.

gpu.c — the core bug. gpu_read_gpustat()'s poll fallback raised IRQ_VBLANK+LCF
every 1000 GPUSTAT reads with no cycle gate. A title that busy-polls GPUSTAT for
GPU/DMA readiness (not just vsync) got ~38 extra "fake" vblanks/s — ~96/s
delivered vs the correct 60 — over-advancing the guest VSync frame counter and
jittering animation (measured on Crash Bash as character strobe). The recompiled
BIOS+game now carry per-block interrupt checks + cycle charging, so the tight
poll loops that once justified this hack already advance guest cycles and let the
one cycle-paced VBlank authority (interrupts.c) fire on schedule. The hack is now
OFF by default; PSX_POLLHACK_VBLANK=1 restores legacy for A/B. Also adds per-frame
GP0(E5) draw-offset-Y range instrumentation for double-buffer analysis.

debug_server.c — two debug commands: `vblank_rate` (cycle-paced raise/deliver
counts, poll-fallback raises, per-frame draw-offset range) and `dump_buffer`
(dumps a raw 512x240 VRAM band at a given display-Y so both double-buffer halves
can be inspected in one call).

main.cpp — two presentation improvements, both generic:
 * sync-to-host-refresh: nudge the wall-clock pacer to the exact panel period
   when the display is within ~2% of 60 Hz, so the pacer and SDL PRESENTVSYNC
   stop fighting (a 59.94 pacer vs a 60.00 panel beats 2/3/1 = judder). ON by
   default but only engages on ~60 Hz panels; other refresh rates keep 59.94.
 * frame-blend masker: optional two-frame average for 30fps double-buffered
   content, guarded to the plain software path. OFF by default (PSX_FRAME_BLEND=1).

Not yet compile-verified in this environment (no toolchain/ISO here); anchors,
symbol linkage (g_vblank_*/g_doff_*/g_pollhack_*, gpu_display_pixel_rgb,
png_write_rgb) and brace balance verified against master by inspection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 17:05:37 -07:00
Matthew Stanley e86099f46e gpu gl/vk: fix mirrored-2D uv sampling (MMX6 sprite slivers)
Modern-GPU center-sampled interpolation vs the PS1 DDA's top-left-corner
latch: the sample-grid shift (u_shift = 0.5/S - 1/64) makes floor(uv) land
on the exact PS1 texel for FORWARD axis-aligned mappings, but MIRRORED ones
(u or v decreasing along their screen axis - X/Y-flipped 2D sprites, e.g.
right-facing MMX entity quads whose cels are stored facing left) interpolate
1/64 short of each integer and floor one texel LOW. That painted the cel's
never-sampled edge column/row (opaque body texels) as detached 1px slivers
at quad seams: stray sprite-colored lines right of X when facing right, a
pink line under the rolling wheel mechaniloids. The old tri_uv_limits
comment explicitly 'accepted' this.

Fix = the Beetle-PSX / parallel-psx Calc_UVOffsets_Adjust_Verts model:
bump the prim's uv by +1 along the decreasing axis (tri_uv_mirror_offset,
applied in the GL and VK poly paths before limits are derived). This also
puts the max-uv vertex back on the exclusive raster edge, so the existing
unconditional max back-off in tri_uv_limits is correct for both directions.
Forward mappings are untouched (no regression surface for the original
Tomba seam fix). The software renderer's native DDA truncates exactly like
the PS1 and needs no compensation; its limits function only feeds the
bilinear neighbour clamp (comment clarified).

Verified on MMX6 attract demo (deterministic boot): pre-fix GL showed
detached X-palette 1px columns at quad seam x=173/175/179/181 (frames
~2088/2250) while software was clean; post-fix GL matches software and
user-confirmed clean live. Residual detector hits classified via GP0 ring
dumps as legit charge-spark effect tiles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 15:40:32 -07:00
Matthew Stanley 0cb2a77bff fix(irq): gate deferred thread-switch off dirty-pump and low-kernel sites
Ape Escape memcard save/load. The deferred in-exception cooperative
thread-switch could be honored at a boundary where a candidate resume PC
was committed but the live CPUState (stack/GPRs) still described the
previous local frame, corrupting the resumed thread.

- Keep the deferral pending when g_cosim_dirty_pump_site != 0: the
  dirty-RAM interpreter pump sites expose committed PCs for IRQ timing
  but the matching CPUState may not be materialized yet. Honor the
  switch only once execution reaches a normal interrupt poll (site 0).
- Do not defer when the interrupted EPC is in low BIOS/kernel space
  (< 0x10000): that code is already scheduler code, and deferring it
  re-enters the same VBlank EPC forever, starving the target thread.
- debug_server: name thread-event kinds 31/32/33 (defer / escape /
  stale) for observability.

Validated: Ape Escape save+load confirmed working by user; MMX6 and
Tomba 1 regression-checked with no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 13:50:09 -07:00
NyperYuhgard fe95491ab1 PSXRecomp-Toml Tool 2026-07-09 14:43:05 -05:00