Admit post-EXE RAM to the dirty interpreter only after a real control transfer and an existing MIPS decodability check. Keep the already-merged overlay initialization guard out of this focused change.
Co-authored-by: Martin Penkava <mpenkava1337@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Port Douglas Vanderveer's manifest-driven macOS/Linux overlay discovery onto the current cache index and exact-dispatch loader. Validate both filename key fields, preserve ABI cleanup and rescan idempotence, and cover the POSIX primitives with a real shared-library fixture.
Co-authored-by: douglasjv <vanderveerdouglas@gmail.com>
Adapt only the fail-closed overlay lifecycle checks from mstan/psxrecomp#16 commit e6809ccf7d. Add a focused structural regression test and record the excluded dirty-interpreter and game/runtime work.
Co-authored-by: Martin Penkava <mpenkava1337@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
- psx_cyc.h / psx_cycles.c: _BitScanForward/_BitScanReverse shims for
__builtin_ctz/__builtin_clz under _MSC_VER
- full_function_emitter.cpp / main_psx.cpp: emit a portable .CRT$XCU
init-pointer instead of GCC-only __attribute__((constructor)) in
generated dispatch/game code
- main.cpp: declare cross-language globals extern "C" so MSVC links
them against their C definitions (no-op for GCC/Clang)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>