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>
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>
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.
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>
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>
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>
TCP surfaces for the new instrumentation:
- phase_profile / phase_hot: a ~1 kHz background sampler over g_exec_phase
reporting wall-time share per backend, with a since-boot histogram and
windowed diffing;
- idle_skip: idle-loop cycle-skip counters and a runtime enable/disable
toggle;
- overlay_shadow_dump gains interior_gated / in_shadow / native_exec /
escapes / escapes_native for the shadow-diff hardening.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add compiled-in always-on wtrace ranges (slots 16-21) for the Ape memcard
higher-layer investigation: libcard op struct count/state (0x800b4e30),
save-name ptr+buffer (0x800b4e50), op-handler ptr (0x800b4ed0), top-scene
SCENE/TRIG (0x800e3880), game card-menu substate (0x8013af50), and kernel
card-driver per-state byte (0x7264). Per the ring-buffer rule these extend
the always-on capture path (query via wtrace_dump addr_lo/addr_hi) rather
than probe-time arming.
Checkpoint of the memcard save-stall investigation (not yet fixed): both
failure modes (hard freeze at 0x801382CC register smear; soft stall = async
read 0x25AC aborts at MC_ID2) trace to one root: the interrupt-driven async
card read per-byte SIO ACK-IRQ clocking is unreliable under the HLE-scheduler
+ dirty-interp + IRQ-delivery interaction (Stage-5 SIO axis). Low-level
protocol + completion bridge confirmed working; masking, low-level
event-delivery, compiled-resume-latch, and deferred-switch all ruled out.
Full detail in .claude/GOAL.md. Fixes #2/#3 kept.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract the runtime's dependency-free RGB->PNG writer into a shared header
(png_write.h, static linkage so each self-contained binary keeps its own copy,
no new link deps). The Beetle debug server's screenshot_file now writes a PNG
(top-down 24bpp RGB) instead of a BMP and defaults its path to .png, matching
the runtime backend — so screenshots are viewer/Read-friendly with no manual
BMP->PNG conversion. Runtime already emitted PNG; this unifies the encoder.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extend the always-on irqctx_ring with the live sp and dirty pump-site captured
at exception entry — the interrupted-thread TCB save records exactly this
(resume_pc=take_pc, sp=entry_sp). A pair where take_pc is a dirty return point
(0x801384AC) but entry_sp belongs to a native callee's frame is the corrupt
snapshot behind the Ape memcard native<->interp resume desync. Reader typedef +
JSON emit updated to match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ape Escape runs its own cooperative scheduler inside the kernel exception
handler (VBlank callback moves PCB[0]); handle the in-exception thread switch,
bump the IRQ-context ring to 4096 with a matching debug_server dump cap, and
carry the scheduler-escape trace state. WIP snapshot on the Ape branch (the
thread-smear investigation), preserved so the shared checkout can return to
master cleanly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tally which GUEST function (guest ra at gte_execute, not the libgte leaf
0x80000F40 that g_debug_current_func_addr reports) projects far/sky vertices,
to identify the sky-draw fn for the per-function dome-expand bracket. ws_dome_probe
on=1 thr=<SZ> arms; dump lists ra + count + max_sz. FINDING (Ape): the title sky
is a curved dome with a RANGE of depths, so the depth-gated dome-expand only
scales its farthest verts (warps it, can't cleanly fill) — confirms the per-
function bracket is required. Lead: far-sky verts came from game code in the
~0x80138xxx overlay region. Probe stays as a tool; Ape dome wiring is a follow-up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Depth-gated dome-expand: in native-wide (no squash), scale far-depth
(SZ >= ws_far_threshold) GTE-projected X outward from the projection centre by
(3*num)/(4*den) so a FINITE sky-dome mesh grows to fill the wider FOV instead
of leaving black corners. Live-tunable via the ws_dome + ws_far_threshold TCP
commands; off by default (no config wires it on), so inert unless toggled.
NOTE: depth alone does not cleanly isolate Ape's title dome (its sky is not the
uniquely-farthest layer — distant world geometry shares the depth range), so the
Ape title needs the per-function bracket route (find the dome draw fn, bracket
with gte_ws_set_dome_expand like the far-backdrop unsquash_funcs). The mechanism
lands here; the Ape wiring is a follow-up. Correct as-is for titles whose sky IS
the farthest layer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- wide_blit_center now copies the canonical column into the wide-surface CENTRE
over the FULL VRAM height, not just [disp_y, disp_h]. Ape vertical-double-
buffers (display_y 0<->256, both bands in one wide surface); a scrolling
cutscene (the cityscape intro) exposed centre rows outside the display band,
which the disp_h-only blit left black -> horizontal black-band tearing. Full-
height blit makes the wide centre bit-identical to the full-mirror output for
every band. Fixes the regression; keeps the perf win.
- sticky gte_game_mode threshold/hysteresis retained (cutscene flicker fix).
- New GL wide-surface inspection over TCP: wide_dump_full backend method
(glb_wide_dump_full) so the `wide_full` command dumps the ENTIRE GL compositor
surface (both double-buffer bands + margins), matching the SW path. wide_shot
(displayed band) already worked on GL. Lets native-wide be debugged entirely
over the TCP server, no window focus.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds full 16:9/21:9 native-wide support for fully-3D titles (Ape Escape,
SCUS-94423) as a generic, opt-in, 4:3-byte-identical framework capability, plus
a native-wide GL compositor perf fix and a cutscene present-mode flicker fix.
Widescreen (generic, per-game opt-in via [widescreen*]):
- Shared screen-extent cull detector (runtime/include/ws_cull_detect.h) used by
all three backends (recompiler, dirty-RAM interp, sljit). Per-game signature
immediates ([widescreen.cull] screen_w_imms/screen_h_imms) — Ape's funnel
tests 0x181/0xF1 on its 368px display, not Tomba's 0x140/0xE0.
- Three funnel idioms widened: unsigned per-vertex sltiu (psx_ws_cull_sltiu),
signed min/max slti right edge + bare-bltz left edge (psx_ws_cull_slti/bltz,
new helpers + SLJIT_HLP appends), and billboard center+/-halfwidth. Explicit
[widescreen.cull] slti_sites for X-only funnels the auto-detector can't
qualify.
- gte_game_mode: GTE-activity gameplay detector for 3D titles with no sprite-tag
helper (gte.cpp notes every RTPS/RTPT). STICKY (>=3 verts, 45-frame hysteresis)
so sparse cutscene frames never flip to a 4:3 pillarbox = fixes intro flicker.
- nw_hud_corners: push outer-third screen-space HUD sprites to the true wide
corners in native-wide (else inset by the reveal).
- nw_backdrop: stretch a full-frame axis-aligned 2D backdrop quad to fill the
wide frame (for titles with a flat sky plane).
- Pattern-scanned interp/sljit widen hooks now gated per-game (auto_screen_x /
auto_backdrop): fixes an ungated auto_backdrop false-positive that rewrote a
live GPR = the ~24s native-wide crash on Ape.
Native-wide GL perf (fixes 16:9 frame drops):
- The wide surface centre equals the canonical 4:3 framebuffer, so wide_blit_center
copies canonical->wide-centre at present (both present paths) and the per-prim
mirror SKIPS prims fully inside the 4:3 frame (mirror_x/geo/batch_center_only).
Only reveal-margin prims re-rasterize. Peak frame time 38.8ms -> 17.5ms, no
vsync misses. Toggle gl_wide_fast; 4:3 untouched (all gated on g_wide_cur).
Runtime-only except the recompiler cull emit (regen needed for cull widening).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two interlocking pieces from the Tsumu HUD/menu-label investigation:
- GP0 ring builder attribution (gpu.h/gpu.c/debug_server.c): op-0x80
entries now carry a bounded guest-stack unwind (bld[6]) + copy-time
$sp (csp); gpu_frame_dump emits func/ra per entry and csp+bld[] for
copies. This cracked the HUD compose: whole label strips are
MoveImaged from the glyph sheet (src 834/852,352 -> HUD cache y=32).
- [[vram_patch]] (text_xlate.cpp/.h + gpu.c hook): third translation
layer for text that exists only as pre-rendered pixels in an
uploaded texture asset (Tsumu HUD labels, menus, prompts live in
the glyph-sheet image; the 144-glyph font has no katakana at all).
On every GP0 0xA0 upload completion the configured rects are
verified halfword-for-halfword against expected JP pixels and only
then rewritten with the target-language pixels via the renderer
facade (software/GL/supersample coherent). Re-applies on every
re-upload; verify-fail leaves foreign assets untouched. Debug:
xlate stats gains vram_patches/vram_applied; new "vpatch"
force-apply subcommand for config iteration without rebuild.
Verified on-screen: HUD "STAGE: 1-1 / BATTERY: 100", pause menu
(CONTINUE/RESTART/GIVE UP/CHANGE CONTROLS, TARGET BATT), title-menu
strips. docs/STRING_TRANSLATION.md Appendix Z documents the model,
mechanism, authoring recipe, and remaining strip backlog.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implements the framework from docs/STRING_TRANSLATION.md: an always-on
capture inventory + a draw-time apply hook, both hung off the psx_dispatch
chokepoint via fntrace_record (no generated-code edits, no BIOS regen,
works in Release).
- text_xlate.{h,cpp}: EncodingProfile abstraction (shift_jis profile:
SJIS record reader with NUL/0xFFFF terminators + halfwidth-kana + control
framing; UTF-8 -> fullwidth-SJIS transcoder so English renders with the
font's existing fullwidth Latin, zero glyph injection). Capture inventory
(FNV-1a64 keyed) queryable over TCP; apply repoints the string-arg reg at
guest-stack scratch (KV-gated: only known records are swapped, stray
pointers untouched; own scratch is skipped to avoid recapture).
- fntrace.c: call text_xlate_on_dispatch() at the dispatch chokepoint.
- config_loader: [localization].language (+ [runtime].language alias);
env PSX_LANG overrides. Loads translations/*.toml per project.
- main.cpp: text_xlate_init() at game-config load.
- debug_server: "xlate" command (stats|dump|todo|reload) — observability
over TCP, no log files.
Verified: builds (Release), Tsumu boots headless, capture live
(distinct records accumulating), apply disabled until a table is present.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Give ReadN/ReadS an initial read delay before the steady-state sector
cadence, classify SetLoc as near vs far and drive SeekL/SeekP completion
timing off it (the far-seek case PCSX calls out for Rockman X5), and fix
GetlocP to report the drive sub-Q position during an active read rather
than the last data-ready sector.
Previously the CD data-ready cadence was too eager, so the MMX5 intro
FMV / CD script never accumulated a valid decode window and the movie
player bailed with zero decodes. With faithful read/seek latency the
intro cutscene now decodes and plays.
debug_server: add a cdrom_command_history TCP command exposing the CD
command/seq ring (kind, cmd, params, read MSF, pending/queued state, pc)
to back this investigation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add two always-on counters (per the no-printf / ring-buffer rule) to diagnose the
MMX5 intro-FMV stall:
- cdrom.c: s_dataready_fires — CD INT1 (data-ready) raised per streamed sector,
exposed via cdrom_get_dataready_fires().
- interrupts.c: g_cdrom_deliver_count — CD IRQ (IRQ_CDROM) exceptions actually
taken by the guest.
Both surfaced in the fmv_state debug command (cd_dataready_fires / cd_irq_delivered).
These proved CD INT1 + CD-IRQ delivery are NOT the MMX5 FMV blocker (fires=627,
delivered=746 during the movie while the STR ring still never commits a frame) —
narrowing the bug to the CD-DMA-ch3-completion -> frame-commit path. Harmless
observability; no behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The post-exception IRQ cooldown counted psx_check_interrupts CALLS. A call is a
backend-dependent unit: compiled checks at block edges (plus extra sites like a
`jr ra` return transition), the dirty-RAM interp checks per-instruction. So the
per-call countdown reached zero at a DIFFERENT guest cycle in each backend, the
next IRQ was delivered at a different cycle/EPC, and the two backends forked
(cosim first-divergence cp 3259 @ cyc ~213.5M). Convert the cooldown to a
guest-cycle DEADLINE (post_exception_cooldown_until): guest cycles advance
identically in both backends (shared, ruler-validated cost model), so the
delivery decision is now backend-independent. Magnitudes preserve prior
breathing room (claimed ~= old 8 blocks, unclaimed ~= old 500 blocks).
Also:
- gpu: GP1(00h) reset no longer clears VRAM. VRAM survives a GPU reset on real
hardware; only power-on gpu_init() clears our backing store (gpu_reset_state).
- codegen hash: include the emitter/translator/discovery .cpp TUs, not just
their .h. A change confined to full_function_emitter.cpp altered generated
dispatch+overlay code while leaving the hash unchanged -> stale overlay-cache
hazard (same class as the ws-emission-not-in-hash gap).
- debug: ring-buffer WINDOW-QUERY primitives -- frame_lo/frame_hi filters on
wtrace/mmio dumps + a capture-freeze frame so a causal window's tail is not
evicted by a post-window event storm before a probe reads it (query the
always-on ring for the window of interest, not arm-and-hope).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
a0_history had no timestamp, so an upload's timing couldn't be compared against
the CD-load timeline. Add frame_stamp (s_frame_count at the upload) to the entry,
return it from gpu_get_a0_src, and emit "frame" in the a0_history JSON.
This pinned the MMX5 font bug: the (896/832/960,256) font uploads run at frames
1374/1381/1388 reading zero/0x77 garbage, while the CD delivers that buffer run
(LBA 10473 -> 0x800D5208..0x800DD208, real data) at frame ~1508 — the upload
races ~130 frames ahead of the font's CD load.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DMA-to-RAM writes were captured (all dma.c writes route through psx_write_word
-> the wtrace/parity hooks), but with misleading provenance: `pc`/`ra` came from
g_debug_last_store_pc, i.e. whatever CPU store ran last — meaningless mid-DMA,
and worse for async channels whose RAM writes run frames after the kick. There
was no way to tell a DMA write from a CPU store, or which channel produced it.
Fix (framework-level, all channels):
- dma.c: capture the DMA kick PC (the CHCR store) into g_dma_initiator_pc at
trigger, stash per-channel, and re-publish it for the async channels whose RAM
writes run in a deferred advance step (CDROM ch3; MDEC-out ch1, which also
lacked the g_dma_exec_depth marking entirely and is now wrapped like CDROM).
- debug_server.c: WriteTraceEntry gains dma_ch (0-6, or -1 = CPU store); when a
DMA is in flight, the entry's pc is the kick PC, not the stale store PC.
Emitted as "dma_ch" in wtrace_dump.
- memory.c: parity note_write uses effective_store_pc() (kick PC during DMA).
Verified on MMX5: CD-streamed font buffer fills (0x800D5208) now tag dma_ch=3
with pc=0x80067264 (the CD loader kick); BIOS scratch CPU stores tag dma_ch=-1.
This is what let write-provenance see the font buffer's clear->CD-load->clear
cycle and localize the upload-races-the-load bug.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
a0_history recorded $s1 and labeled it "source data pointer", but for the MMX
LoadImage (FUN_80069dfc) $s1 is the scratch RECT descriptor (x/y/w/h, reused
every call) — the true pixel source is $a1, preserved in $s2 (`move s2,a1` at
entry). The mislabel sent font-load investigation to the wrong buffer.
Capture s2 (gpr[18]) = true source, plus a0/a1, expose via gpu_get_a0_src, and
emit s2/a0/a1 in the a0_history JSON. This is what let us find the real MMX5
font source buffers (0x800D5208 / 0x800DD208) and prove the upload reads them
during a post-clear/pre-CD-load zero window.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The #2 lockstep comparator's record trace was polluted by debug-server
observer reads: handle_read_ram's psx_read_byte loop (and any other
handler touching guest RAM) executes at debug_server_poll safe points,
which can land inside an armed leader-to-leader record window. The
observer ops entered the trace as phantom guest ops and produced false
read-addr divergences whose 'compiled_actual' address was exactly the
TCP client's polled address (seen live on MMX5: probe reads of
0x8006FC24 and 0x800D1C00 flagged block 0x62FE8).
Fix at the process_command chokepoint: wrap every handler in
ls_suppress_begin/end so no individual handler can forget. Verified on
MMX5: with probes polling every 15s, 189M+ blocks check clean past the
point where the leak previously false-flagged at ~58M.
NOTE (negative result, do not re-attempt): also making the memory.c
g_ls_mode==2 replay fast-paths honor g_ls_suppress_record ("observer
reads must not consume trace ops") corrupts guest RAM — replayed guest
ops can run under a suppress held by in-guest instrumentation, and the
fall-through turns their side-effect-free replay writes into real
writes (deterministic FATAL ADEL at the MMX5 title press, IntRP walk
from addr 7). Replay integrity needs no suppress check: handlers only
run at safe points, never inside ls_at_leader's replay.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
debug_server_record_frame (every vblank) and the sio_state handler read
SIO_STAT/CTRL/RX via the GUEST-BUS sio_read(), which has hardware
side-effects: sio_tick(0) advances the delayed-IRQ state machine, reading
SIO_STAT decrements sio_ack_visible_reads and clears the ACK bit, reading
SIO_RX_DATA pops the RX FIFO. So the observer silently mutated the SIO/pad
handshake every frame in dev builds (the cardinal observability-alters-state
violation the event_ring header itself warns against).
Add side-effect-free sio_peek_stat/ctrl/rx_data() and use them on the debug
path; the guest bus keeps sio_read() with its real side effects. Also add an
fmv_state TCP command reporting the resolved config path + auto_skip_fmv +
frame-count-table params + live MDEC/XA/pad state, so the FMV-skip + config
state can be observed rather than inferred.
Note: this removes a real per-vblank SIO perturbation but is NOT the full fix
for the dev-build FMV-skip divergence (residual is the cycle-timing-core
class; see precise_irq_slice_state). It is correct observability hygiene.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tomba2 16:9 GL reproduced the reported collapse (12-14fps) and the GPU-side
split EXONERATED the GPU: scene_gpu ~10ms, mirror ~0.2ms, while emu_cpu is
~82ms (vs ~15ms in squash). gl_ws_ablate=1 (mirror passes fully off) leaves
it at ~75ms, so the wedge is CPU work outside the mirror draws. The GL
submission paths run INSIDE guest emulation (GP0/DMA processing), so driver
CPU cost lands in emu_cpu where no GPU timer sees it.
frame_perf now reports, per wide/4:3 frame class: CPU wall inside
flush_tex_batch (cpu_flush_ms_avg), CPU wall inside the glb_wide_* entry
points (cpu_wide_ms_avg), batches/frame, wide target sets/frame, and wide
FBO creations/frame (a nonzero steady value = surface recreation churn).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Native-wide perf attribution instrumentation (permanent):
- Each wide-mirror pass (tex-batch mirror, flat-geometry mirror, overlay
full-width rect, wide_clear) is bracketed with a GL_TIMESTAMP pair
(glQueryCounter; TIME_ELAPSED cannot nest inside the frame scene query).
Pairs pool per buffered frame (1024/frame), summed at readback into
mirror_gpu_ms + mirror_passes per sample; frame_perf now reports
mirror_gpu_ms_avg/max, canon_gpu_ms_avg (scene minus mirror),
mirror_passes_avg and mirror_pass_us for wide frames.
- New debug cmd gl_ws_ablate mode=<0..3>: 0 normal, 1 skip mirror passes
entirely, 2 mirror state churn without draws, 3 mirror draws stay on the
hr FBO (no per-pass FBO rebind; content-corrupting perf probe).
First MMX6 16:9 measurements with the split: mirror passes cost 20-125us
EACH (FBO ping-pong penalty per pass) but MMX6 2D batches to ~3-15
passes/frame so 16:9 attract holds ~60fps on this branch; the per-pass
cost x ~900 immediate flat prims is exactly the Tomba2 3D 60ms scene.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause (proven via the new present ring + gl_coh_ring correlation):
flush_cpu_upload() merged all pending CPU->VRAM writes into ONE union
bounding box. A frame with two disjoint uploads (sprite column + tile row —
routine MMX6 texture streaming) produced a union spanning the display
framebuffers, which the flush painted from the STALE CPU VRAM mirror (the
FBO is authoritative under GL) — stomping live frames with black for
exactly two presents (one per double-buffer parity). Scene-dependence of
the uploads made it look config/timing dependent; the software renderer is
immune because its CPU array is authoritative.
Fix: exact pending-rect list (16 rects; merges only when zero uncovered
pixels are added — containment or same-band extension; wrap-aware GP0(A0)
transfers split into up to 4 exact rects instead of the old "wrapped: take
all" full-VRAM union; overflow -> order-preserving flush-all; pre-context
union retained where the CPU array is authoritative). Merge rule verified
by a 20k-randomized-rect host test (0 stale / 0 missing painted pixels).
Observability (always-on): gl_present_ring records every SwapWindow (path,
src/letterbox rects, drained glGetError, wall-ms, backbuffer + blit-source
pixel samples) — the instrument that gave the 1:1 black-capture
correlation. debug_server_send_fmt now heap-formats responses >64KB (they
previously truncated into unparseable JSON, breaking large ring dumps).
(debug_server.c also carries the Vulkan screenshot_file sync hunk from the
concurrent VK bring-up; see the next commit.)
Validated: ~18-min MMX6 GL attract soak, ~1600 window captures across 3+
attract cycles — zero isolated black frames, zero GL errors (pre-fix:
19/40 captures black at the intro). Tomba1 build-gl boots clean; its full
attract soak follows. ENHANCEMENTS.md added (framework enhancement ledger,
R1 OpenGL / R2 Vulkan state).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause of the fresh-launch logo freeze (present at every commit in the
stack; window-capture-verified 13/13 repros, never a regression): the BIOS
dispatch normalize() maps RAM phys 0x30000-0x5AFFF to the shell ROM alias.
The Class-A fix routed post-game-start shell-window targets to the game/dirty
path first, but its gate also required psx_game_address_in_text() — which
exempts runtime-loaded OVERLAY code above the boot-EXE text end. Tomba2's
text ends at 0x38800; its post-logo main-thread resume PC 0x50B80 fell
through to normalize() and executed func_1FC38B80, a stale shell-image
data-as-code self-loop, starving the main thread forever (one VBlank
delivery per frame at the same EPC, zero main-thread instructions retired;
the game's ISR ran fine, so TCP frame counters kept ticking — the freeze
was invisible to every telemetry-only validation). Even PSX_FORCE_INTERP
missed it: normalize() shadowed the address before the interp path saw it.
Fix: post-game-start, the WHOLE shell-overlap window routes to
dirty_ram_dispatch before normalize() — real hardware executes the RAM
bytes regardless of who loaded them (boot EXE, overlay loader, DMA stream).
Pre-start shell addresses still normalize to the shell as before.
Also:
- interrupts/debug_server: nestgate_* per-guard veto-attribution counters on
the nested-delivery gate (freeze_check), used to exonerate the IEc/SR
guards and redirect the hunt from interrupts.c to dispatch.
- tools/window_capture.py: PrintWindow-based SDL-window capture with
LIVE/FROZEN series verdict — window-liveness ground truth; TCP/VRAM
screenshots provably lie about presentation (this bug's entire history).
Validated: fresh launch now runs logo -> intro FMV -> title -> attract with
sustained full-frame motion (window-level capture series).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
json_get_str fed every consumer the raw value text, and address fields
went through hex_to_u32: a client passing a bare int addr (python
json.dumps of 0x8013DDC4 emits '2148784580') got it parsed AS HEX;
strtoul saturates 10-digit decimals to 0xFFFFFFFF, so read_ram/write_ram
silently operated on junk. This fabricated a false 'stream overwrites
overlay code' lead during the Tomba2 attract hunt (2026-07-02).
json_get_str now normalizes bare pure-decimal values to 0x-hex text
(JSON bare numbers are decimal by definition; quoted values keep the
hex wire convention), and json_get_int scans the raw json itself so
integer fields keep decimal semantics.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause of MMX6 "A game data could not be found" (weeks-old, cross-backend):
the game's save-scan calls memset through the kernel A0 jump table; entry
A0:2B points into BIOS ROM at 0xBFC02B8C (one of the entries the kernel never
patches into RAM). Transitive discovery can never find it (reachable only via
the data table, no jal), so the dispatch missed and psx_unknown_dispatch
silently no-op'd — killing the scan before firstfile ever ran. Every card
layer (SIO, IRQs, SwCARD events, sector data, kernel directory parse) was
verified correct along the way; the sole defect was the undiscovered ROM
function plus the silent miss.
- recompiler/seeds: enumerate the post-init A0/B0/C0 tables (kernel RAM
0x200/0x874/0x674) and seed every ROM-pointing entry with no dispatch
coverage — 30 functions (memset, memcmp, memmove, strncat, rand-family,
card/dev services...). memset was merely the one MMX6 called; the rest were
identical latent bugs. BIOS regen: 4406 -> 4439 dispatch entries.
MMX6 CONTINUE now lists and loads saves (user-validated + scripted run:
full FILE SELECT, 1093 card txns, unknown_dispatch_log total=0).
- traps.c: an unresolvable dispatch target now dumps a crash report and halts
BY DEFAULT (PSX_FAIL_FAST_UNKNOWN_DISPATCH=0 opts out for diagnostic
sessions; the miss is ring-recorded either way). The old silent-no-op
default left stale registers and a severed call chain, and masked this bug
for weeks. Deliberately NO interp fallback: the dirty-RAM interpreter is
for runtime-written code (Rule 18); interpreting around a static-discovery
hole would hide it exactly like the no-op did.
- runtime.cmake: EXE_NAME option — per-game process names (mmx6-runtime.exe)
so one game's `taskkill /IM psx-runtime.exe` dev sweep no longer kills
every other game's instance.
- debug rings (the tooling that found this): HLE call ring gains
consecutive-duplicate collapse w/ repeat counts (a 4-way TestEvent poll at
175K calls/s no longer flushes the ring in 0.1s), per-entry TCB + in_exc
(thread attribution), a "since" paging param and 8MB dump buffer;
card-trace EvCB watch widened to all 7 card event slots (slot 0 =
SwCARD-IOE, the success event, was previously invisible).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause of the Tomba pause-menu wedge (loaded saves only): autocompiled
overlay shards were emitted by a recompiler binary OLDER than the last emitter
change, yet stamped with the CURRENT cg tag (compile_overlays derives the tag
from --runtime-include; nothing tied it to the emitting binary). The stale
shards corrupted the display-list task queue during load-game. NOT the
IRQ-resume class the prior handoff claimed - disproven with the new ring.
- recompiler: bake the emitter-source hash (shared canonical list
runtime/codegen_hash_sources.cmake + hash_codegen.cmake) into psxrecomp-game;
new --codegen-hash flag prints it (machine-parseable, pre-banner).
- compile_overlays.py: HARD-FAIL when the recompiler binary's baked hash
differs from the cg tag hash it stamps (or the binary lacks the flag).
Applies to both cache and --static modes. Closes the read==write-tag /
stale-content drift class structurally.
- runtime.cmake: codegen-hash source list moved to the shared canonical file.
- interrupts.c: always-on exception-EXIT half added to irqctx_ring (take_pc,
real_epc, exit_pc, escape reason, same-thread/restore flags, v1/ra
saved-vs-exit, RestoreState redirect count) - proves per delivery which
escape path fired and whether the interrupted GPRs were restored. Kept the
same_thread_resume GPR-restore refinement (ring-verified equal-value in
practice; faithful: hardware restores all GPRs on every exception return).
- debug_server.c: irqctx_ring dump extended with the exit-half fields.
Validated live: Tomba menu opens on loaded save (Stormy Mountain) with the
cache fully enabled after regenerating shards with a rebuilt recompiler
(build-cosim + build-prod caches). Guard verified positive and negative.
MMX6 gate passed: boot -> title -> opening cutscene -> gameplay handoff OK.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New always-on ring (device_trace.{c,h}, compiles into both binaries like
parity_trace) recording every hardware IRQ-raise edge with its guest cycle,
source (I_STAT bit) and detail. All native device sources funnel their raise
through one choke point psx_irq_raise(bit,detail) in interrupts.c (VBLANK/GPU/
CDROM/DMA/timers/SIO routed; DMA passes DICR channel flags, timers pass index),
so the device-timing audit sees every event from one place. devtrace_dump/ctl
debug commands (cycle-window + source filters) + tools/devtrace_diff.py align
the two device-event timelines by source ordinal and flag where cycles drift.
The Beetle oracle side (mednafen IRQ_Assert rising-edge hook) lives in the main
checkout.
This is the level below parity_trace: parity names the divergent control-flow /
gate input; device_trace names the divergent HARDWARE EVENT behind it.
First result (MMX6 cutscene asset-load over-fast wedge): the load's CD+DMA
activity is compressed into ~232M cycles on native vs ~384M on Beetle (~1.65x),
completing the func_8001CB3C scene setup ~100M cycles / ~176 frames early. The
producer is CD-read / DMA-completion timing (DMA channels 2/3/4 = GPU/CD/SPU
asset streaming) -- confirming the device-completion-latency hypothesis and
ruling in the CDROM/DMA cycle model as the over-fast subsystem. (VBlank verified
correct: native VBlank edges are exactly 564480 cyc apart.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>