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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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)
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.
- 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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>