psx_mod_render_pass_plan() / psx_mod_render_pass() let a trusted plugin
redraw its game between two logic ticks with the game's own draw code. A
pass is a sandboxed nested guest call:
- time is frozen: cycles are counted (GTE and mult/div deadlines keep
working) but no device advances, no VBlank or device event fires and no
interrupt is delivered; GPU linked-list DMA and delayed completions finish
synchronously (psx_cycles freeze, interrupts, dma);
- stores go straight to RAM/scratchpad, bypassing code-page tracking,
overlay watch and write traces; MMIO is an allow-list (GP0, GP1 DMA mode /
info, GPU and OTC DMA, I_STAT/I_MASK) and SPU, CD, timer and other device
stores are dropped and counted (memory.c);
- afterwards CPU (with the GTE), RAM, scratchpad, I-cache tags, interrupt,
timer, DMA and GPU registers are restored exactly (render_pass.c, a GPU
register checkpoint without savestate side effects), with a guest-cycle
watchdog that rolls a runaway pass back;
- on OpenGL the declared display rect (hr colour + mask stencil, raw mirror,
native-wide band, CPU VRAM rows) is backed up, drawn, captured and rolled
back; writes outside it are journaled and restored, or refused.
The presenter keeps two generations of images (the game's own frame plus
one image per pass phase), promotes one when the flip-aware source sees the
display flip to its rect, and shows the newest image at each output
deadline's phase, crossfading across gaps when passes were shed. Presents
that fall due while passes run are made between them. The plan predicts the
output deadlines of the coming frame and sheds passes to fit a host-time
budget learnt from the presenter's idle time (PSX_RENDER_PASS_BUDGET).
PSX_MOD_FRAME_INTERPOLATION_HOLD repeats the newest frame where no pass
image applies, so a pass-driven title never shows anything later than stock.
Default off: nothing runs unless a plugin calls the API, and the plan
refuses in netplay, rollback, rewind, turbo, self-check resimulation, while
suspended (FMV) or without GL flip-aware interpolation. Pass writes are kept
out of frame_fingerprint. Debug: render_pass_stats, render_pass_dump,
PSX_RENDER_PASS_VERIFY=1 (state + VRAM hashes around every pass).
The freeze API lives in the runtime-only psx_cycle_freeze.h. psx_cycles.h is
part of the overlay codegen hash and stays byte-identical, so titles that
never use passes keep their overlay caches and savestates.
Tests: render_pass_plan_test (deadline-exact phases per rate, shedding,
selection), render_pass_freeze_test (10^6 frozen cycles, exact restore,
watchdog), render_pass_guards (sandbox choke points; the codegen-hashed headers stay
free of the pass API).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add passive DMA2 ordering-table counters to dma_state so long or aborted linked-list walks are visible without relying on screenshots. The counters track starts, duplicate starts dropped while a walk is active, completions, cancels, last/max walk size and cycle cost, CHCR poll counts, the initiator PC, and the last eight cancel records.
This intentionally does not port PR #302's superseded walker changes or the PSX_GPU_LL_SYNC diagnostic lever; PR #304 remains the timing implementation.
Co-authored-by: Kevin Kline <9866117+kerokline@users.noreply.github.com>
Slicing the GPU linked-list DMA across guest cycles (8af48ae9, PR #277) also
changed what a walk costs: 8 clocks for every node header plus 5 setup clocks
per non-empty node, where the pre-slicing path charged one clock per word via
schedule_delayed_complete(2, actual_words, DMA_GPU_CYCLES_PER_WORD).
An ordering table is mostly EMPTY nodes -- Final Fantasy VII's runs to
ot_max 4094 -- and each empty slot still paid the full 8-cycle header for a
packet that does not exist. Measured on FF7 (SCUS-94163):
4,807 nodes / 12,757 words 52,384 cycles vs 17,564 (3.0x)
44,010 nodes / 117,507 words 499,187 cycles vs 161,517 (3.1x)
An NTSC frame is 564,480 cycles, so a single walk reached 88% of a frame.
FF7's libgpu polls CHCR bit 24, finds the channel still busy after 790-890
reads, and force-clears the start bit at 0x800484F0 -- the documented
stuck-channel path. cancel_async_transfer() then abandons the walk partway
through, and the rest of the frame's geometry is never sent to GP0. In a
battle 12.6% of ordering tables were aborted this way; models rendered with
missing or shattered limbs.
The guest is behaving correctly: it inspected the busy bit before clearing it.
The slicing is also correct and worth keeping -- a guest really can change a
packet after starting DMA and before DMA reaches it, and only a sliced walker
models that. Only the cost was wrong.
Header is one clock, setup is free, so a walk costs exactly (nodes + words).
When setup is free the payload boundary is the same service as the header, so
the header phase falls through instead of returning; cycles_to_event() never
reports zero, and returning would spend an unaccounted scheduler cycle per
non-empty node. The packet is still read at its own boundary, so #277's
mid-walk mutation behaviour is unchanged and its test still passes.
(nodes + words) is not a new assertion -- it is the cost this runtime shipped
and was validated against. A non-zero per-node overhead may well be real on
hardware, but it needs a measurement behind it, not a substituted constant.
Observability, because none of the above was visible:
dma_state gains a gpu_ot block -- starts, completes, cancels, starts_dropped,
nodes/words/cycles of the last walk and the max, plus guest reads of CHCR(2)
and a ring of the last 8 aborts carrying the guest PC, CHCR value, how far
the walk got, and how many times the guest polled before giving up. The
silent early return in start_async_gpu_linked_list() is now counted as
starts_dropped rather than losing an ordering table with nothing said.
PSX_GPU_LL_SYNC=1 drains a walk at start. Diagnostic A/B lever only: it
completes immediately as well, so it is not a faithful restoration of the
pre-slicing timing and must not be used as a fix.
The timing test now asserts the cost model rather than describing it: a table
of empty nodes must cost exactly one cycle per node. That is the check that
would have caught this.
Verified on FF7 SCUS-94163 from a save state: cycles_max 58,933 -> 16,972,
a 144-node/490-word walk costs exactly 490 cycles, and battle geometry renders
correctly with the walk still sliced.
Not fixed here: the same commit also broke gpu_frame_dump's func attribution.
GP0 writes now issue from dma_advance() on the cycle scheduler, so every
primitive is stamped with whatever the scheduler interrupted -- all 1,803
primitives in a captured battle frame attributed to BIOS 0x1FC02B50 instead of
the issuing game function. That disables gpu_frame_diff's "a function stopped
drawing" verdict and gpu_frame_layers entirely. Stamping the DMA initiator was
tried and does not work: g_debug_current_func_addr reads 0 at kick time. The
ra column still carries real game addresses as a fallback. Left as a separate
issue rather than shipped as a fix that does not fix it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9v7e6AVZe3eRP8VXey9Pz
Phase 1 of the cycle-budgeted precise interrupt-take-point fix (root cause:
compiled blocks take IRQs only at block edges; HW + the dirty-RAM interpreter
take them at the exact instruction — oracle-confirmed at the Tomba 2 logo
Timer1 debounce). Design validated with ChatGPT; full spec in PRECISE_IRQ_SLICE.md.
This commit lands the additive, no-behavior-change foundation (Task #2) plus the
prior session's diagnostic take-PC capture, and a build-hygiene dedup:
- cycles_to_next_event() (interrupts.c): single source of truth — min guest-cycle
distance to the next DELIVERABLE interrupt (source raises its I_STAT bit AND
that bit is unmasked in i_mask). Aggregates VBLANK + per-peripheral queries.
- timers/cdrom/dma/sio _cycles_to_irq(i_mask): each peripheral owns its own
next-IRQ-distance query; all conservative under-estimates (smaller => slice
more => never run past an event).
- debug_server: cycles_to_next_event command for live validation.
- interrupts.c: take-PC capture into the IRQ_DELIVER event-ring aux (prior
session's diagnostic; records the architectural take-PC for cross-backend diff).
- overlay_loader.c: remove duplicate psx_native_bad_entry definition (HEAD had it
twice; would not link cleanly).
Live-validated at the logo: returns the VBLANK distance, correctly excludes the
three free-running (unarmed/masked) timers and idle CD/DMA/SIO. Runtime-only,
no regen. The two-tier executor (Task #3) that consumes this is next.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Framework changes that get Ape Escape (USA) booting through to its
attract/demo sequence. Captures the working state at this milestone.
Runtime:
- cdrom: read/seek + data-path fixes for disc streaming
- dma / interrupts: channel + IRQ handling needed by the boot chain
- dirty_ram_interp: expanded coverage for self-modifying / RAM handlers
- sio, traps, crash_trace: supporting fixes
- debug_server: probe/inspection commands used during bring-up
Recompiler:
- code_generator / full_function_emitter / function_analysis / main_psx:
codegen fixes surfaced by the Ape Escape boot path
Committed as-is (no further edits) since the build is in a known-good,
demo-loading state. Branch: codex/ape-escape-support.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Uncommitted framework work on codex/ape-escape-support reaching the
post-boot DualShock-required screen. Recompiler codegen (code_generator,
full_function_emitter, function_analysis, config_loader) + runtime
(cdrom, sio, dirty_ram_interp, dma, iso_reader, mdec, gpu, memory,
traps, debug_server, fntrace, freeze_heartbeat, psx_cycles) and headers.
Committed as a restore point before rebuilding Tomba against this
framework. Build dirs (recompiler/build-codex*) intentionally excluded.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Framework work on codex/mmx6-support that makes Tomba build and play
(title screen, 0 dispatch misses) and MMX6 boot through BIOS to its
intro. Touches recompiler codegen (code_generator, function_analysis)
and runtime CD-ROM/DMA/MDEC/GPU/SIO timing (cdrom, dma, mdec, gpu,
psx_cycles, traps, fntrace, debug_server) + headers + runtime.cmake.
Milestone snapshot for collapsing onto master. Build dir excluded.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "v4" suffix was a holdover from iterative project restarts.
There is now one psxrecomp; all identifiers, include guards, cmake
variables, function names, log strings, schema tags, and tool paths
drop the v4 suffix.
Changes:
- PSXRECOMP_V4_ROOT → PSXRECOMP_ROOT (cmake)
- PSXRECOMP_V4_RUNTIME_SOURCES/_INCLUDE_DIRS/_BIOS_GENERATED → no V4
- psxrecomp_v4_add_runtime_target → psxrecomp_add_runtime_target
- PSXRECOMP_V4_*_H include guards → PSXRECOMP_*_H
- "psxrecomp-v4" log/window strings → "psxrecomp"
- Schema version strings in recompiler JSON output
- Tool script hardcoded absolute paths updated
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Recompiler — `recompiler/src/function_analysis.cpp` now stops a
function's end_addr at its mapped `jr $ra` + delay slot instead of
stretching through trailing data. Fixes Tomba's 0x80076DB0, a real
136-byte function that was being emitted as a psx_unknown_dispatch
stub because data after it poisoned the boundary heuristic.
`code_generator.cpp` emits `func_<next>(cpu); return;` as a tail
call when a mid-function seed split leaves the physical fall-through
in a different C function, plus a `debug_server_log_call_entry` at
every function entry. `full_function_emitter.cpp` splits dispatch
into `psx_dispatch_impl(cpu, addr, stop_addr)` with public
`psx_dispatch` and new `psx_dispatch_call(cpu, addr, return_addr)`
so JAL/JALR into RAM stubs can return cleanly without growing the
native stack.
Runtime — `runtime/src/dma.c`/`include/dma.h`/`src/memory.c` now
expose `dma_write_masked()`; the 16-bit and 8-bit mmio paths route
DICR/DPCR/CHCR partial writes through it so a partial DICR write no
longer accidentally clears unrelated channels' pending IRQ flags,
and CHCR only triggers DMA when the written mask actually includes
the start bit. Unified `complete_transfer(ch)` across all channels,
removed `prev_master_flag` edge detection.
MDEC (`runtime/src/mdec.c`, `include/mdec.h`) gains a full debug
surface: `MDECDebugState` snapshot, an always-on 4096-entry
`MDECDebugEvent` ring with 11 event kinds (reset, ctrl_write,
cmd_begin/done, decode_done, dma_in/out start/end, output_drained,
read_underflow), and `mdec_state` / `mdec_trace` / `mdec_trace_clear`
debug-server commands. DMA ch0/1 emit start/end events around MDEC
transfers.
CDROM (`runtime/src/cdrom.c`, `include/cdrom.h`) — read stream
separated from one-shot pending state (`read_cmd`, `read_delay`,
`start_read_stream`, `stop_read_stream`, `deliver_read_sector`);
`cdrom_state`/`cdrom_trace` report the new fields.
Thread/fiber tracing — `traps.c`'s Win32-fiber TCB save/restore now
emits 12 thread-event kinds; new `ThreadTraceEntry` ring (64 K) plus
`thread_trace` / `thread_trace_clear` debug-server commands report
both live CPU regs and the target TCB's full saved register block.
wtrace / mmio rings — `WriteTraceEntry` grew (cpu_pc, sp, v0/v1/a0-a3/
t0/t1); capacity 1 M -> 4 M. `MmioTraceEntry` grew (pc, cpu_pc, sp,
a0-a3, sr, epc, i_stat, i_mask). Both dump commands accept
`count`/`newest=1` for reverse iteration. `restore_trace` and
`dirty_block_log` emit extended register fields (a2/a3/sp).
Misc — `interrupts.c` polls debug-server every 16K checks so it
stays responsive without a separate thread. `psx_runtime.h`'s
`call_by_address` routes through `psx_dispatch_call`. Dirty-RAM
interp JAL/JALR consult `psx_dispatch_game_compiled` when the game
is wired in. iso_reader gains the entry points needed by the game
disc path. New `tools/_tomba_probe.py` for port-4470 health checks.
Generated — regenerated `SCPH1001_dispatch.c` against the new
`psx_dispatch_impl` shape.
ISSUES.md — opened Issue #3 (BIOS PS-logo glyph missing on the
disc-detected screen, cosmetic).
.gitignore — added `recompiler/build-asan*/`, `build-checked/`,
`__pycache__/`.
Validated end-to-end: BIOS boots, disc detected, Whoopee Camp FMV
plays with audio. Frame count advances through ~40K frames without
freezes. `unknown_dispatch_log.total = 0`. Remaining FMV cluster
issues (1/4-size render, FMV-2 audio missing, no skip) tracked
under TombaRecomp/ISSUES.md.
Co-Authored-By: Codex (OpenAI) <noreply@openai.com>
Root cause of VSync counter stuck at 0: DMA completion IRQs were
deliberately suppressed in dma.c, preventing the shell's chain-E004
handler (func_1FC40628) from ever seeing I_STAT bit 3. The handler
gates all VBlank callback processing on DMA interrupt presence.
Changes:
- dma.c: directly assert i_stat bit 3 on DMA ch2/ch6 completion
(bypass DICR edge detection which gets stuck because the shell
handler only acknowledges ch3 in DICR, not ch2/ch6)
- dma.c: expose dma_get_dicr()/dma_get_dpcr() for debug server
- memory.c: add DMA register range (0x1F801080-0x1F8010FF) to
mmio_read8/write8/read16/write16 — fixes crash on DICR byte read
- debug_server.c: add DPCR/DICR to irq_state command
- seeds: add 0xBFC42BA0 (shell_vblank_dispatch), 0xBFC38AF0 (shell
hot function, 178 misses), 0xBFC137D0 (kernel RAM), 0xBFC42BE0
(shell_vblank_process), 0xBFC4226C (mid-function callback loop)
- regen: 1051 dispatch entries (was 1046)
Verified: chain-E004 handler + verify now dispatch. VSync counter
still 0 because the VBlank callback loop inside func_1FC42160 is
gated by an init-done flag and the mid-function entry mechanism is
not yet resolved. New EvCB crash at BFC12A84 exposed by DMA IRQ —
next session.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three interrupt delivery bugs fixed that were blocking shell progression:
1. RFE after handler exit: The exception handler's normal exit path
(jmpbuf longjmp at 0xBFC10944) does not execute RFE. In our model
the handler returns to psx_check_interrupts, not to the WaitEvent
caller. Without RFE, IEc stays 0 forever after the first interrupt.
Fix: apply RFE in psx_check_interrupts if IEc is still 0 after the
handler returns.
2. GPR save/restore around handler dispatch: The handler's jmpbuf exit
restores callee-saved registers from the kernel jmpbuf (intended for
a non-local jump to WaitEvent's caller). In our model this corrupts
the interrupted code's registers. Fix: save all GPRs before handler
dispatch and restore them after, matching real hardware where the
interrupted code's registers are preserved via TCB save/restore.
3. VBlank tick gated on !in_exception: The handler runs thousands of
block leaders, each calling psx_check_interrupts. Without gating,
dispatch_count advances inside the handler, firing new VBlanks that
stack up and starve the main code. Fix: only increment dispatch_count
and fire VBlank/timer ticks when not in exception handling.
4. DMA IRQ edge-triggered: update_master_irq() was level-triggered,
re-asserting i_stat bit 3 on every call while master_flag=1. Combined
with no BIOS callback to acknowledge DMA IRQs, this caused an
interrupt storm. Fix: edge-detect master_flag 0->1 transition, and
suppress DMA completion IRQs entirely since transfers are synchronous.
Also adds GPU debug diagnostics: GP0 command counters (nop/fill/draw/
env/copy), draw area/offset in gpu_state, vram_peek debug command.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>