fix(gpu): sync the GPU to the 68000 on writes into GPU local RAM (Pitfall crash, #138) - #222
Merged
Conversation
…fall crash, #138) JaguarExecuteNew ran the 68000's whole slice, then the GPU's, so the 68000 could clobber a GPU parameter block while the GPU was actively using it. In Pitfall: The Mayan Adventure the 68000 overwrote the palette-fade loop's iteration count at $44036 with $FE7C (65148), so a single ISR invocation could no longer finish inside a frame. Every following int0 nested, pushed 4 more bytes, and never returned — r31 walked 4 KB down GPU local RAM until it overwrote the GPU's own code at $F03CBC (gpu_pc_escape frame=5615, then a permanent black screen). The JTRM rules out the ordering we had: the GPU runs at the full system clock and the 68000 at half (exactly 2:1), and the CPU is last of 11 in the bus-priority table, so a GPU spinning on its own local RAM cannot lag the CPU by ~200 cycles. GPUSyncToM68K() advances the GPU to the 68000's position within the current slice (m68k_cycles_run() * 2, clamped to the slice budget) when the 68000 writes into GPU local RAM; GPUBeginSlice()/GPUSliceRemaining() make the end-of-slice call run only the remainder, so total RISC cycles per slice are unchanged — only when they are spent moves. The clamp is load-bearing, not an optimisation: without it the GPU ran ~30x its budget and boot broke at frame 75. The hook sits at the 68000 access boundary with in-long-write suppression, because hooking GPUWriteWord fired between the two halves of a MOVE.L and the guest read a partial pointer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Regression:
|
| ROM | Status | Details | Diff |
|---|---|---|---|
| jagniccc | 🆕 NEW | no baseline | - |
| yarc | 🆕 NEW | no baseline | - |
| jagniccc (determinism) | ✅ PASS | identical across runs | - |
| yarc (determinism) | ✅ PASS | identical across runs | - |
| jagniccc (frameskip) | ✅ PASS | skip=0 matches skip=3 | - |
| yarc (frameskip) | ✅ PASS | skip=0 matches skip=3 | - |
| jagniccc (save state) | ✅ PASS | round-trip matches | - |
| yarc (save state) | ✅ PASS | round-trip matches | - |
| jagniccc (rewind) | ✅ PASS | rewind matches | - |
| yarc (rewind) | ✅ PASS | rewind matches | - |
Platform: Linux x86_64
Updated by CI at 2026-07-31T20:25:55.784Z
Regression:
|
| ROM | Status | Details | Diff |
|---|---|---|---|
| jagniccc | 🆕 NEW | no baseline | - |
| yarc | 🆕 NEW | no baseline | - |
| jagniccc (determinism) | ✅ PASS | identical across runs | - |
| yarc (determinism) | ✅ PASS | identical across runs | - |
| jagniccc (frameskip) | ✅ PASS | skip=0 matches skip=3 | - |
| yarc (frameskip) | ✅ PASS | skip=0 matches skip=3 | - |
| jagniccc (save state) | ✅ PASS | round-trip matches | - |
| yarc (save state) | ✅ PASS | round-trip matches | - |
| jagniccc (rewind) | ✅ PASS | rewind matches | - |
| yarc (rewind) | ✅ PASS | rewind matches | - |
Platform: Darwin arm64
Updated by CI at 2026-07-31T20:25:41.246Z
Regression:
|
| ROM | Status | Details | Diff |
|---|---|---|---|
| jagniccc | 🆕 NEW | no baseline | - |
| yarc | 🆕 NEW | no baseline | - |
| jagniccc (determinism) | ✅ PASS | identical across runs | - |
| yarc (determinism) | ✅ PASS | identical across runs | - |
| jagniccc (frameskip) | ✅ PASS | skip=0 matches skip=3 | - |
| yarc (frameskip) | ✅ PASS | skip=0 matches skip=3 | - |
| jagniccc (save state) | ✅ PASS | round-trip matches | - |
| yarc (save state) | ✅ PASS | round-trip matches | - |
| jagniccc (rewind) | ✅ PASS | rewind matches | - |
| yarc (rewind) | ✅ PASS | rewind matches | - |
Platform: Linux aarch64
Updated by CI at 2026-07-31T20:25:55.848Z
Contributor
There was a problem hiding this comment.
Pull request overview
This PR addresses Pitfall: The Mayan Adventure’s GPU PC-escape/black-screen crash (#138) by improving CPU↔GPU concurrency modeling: the GPU is advanced mid-slice when the 68000 writes into GPU local RAM, closing an ordering gap created by coarse slice scheduling in JaguarExecuteNew().
Changes:
- Added GPU slice bookkeeping (
GPUBeginSlice,GPUSliceRemaining) and a mid-slice catch-up hook (GPUSyncToM68K) driven by 68K cycle progress. - Hooked the 68000 memory-write boundary to sync the GPU on writes into GPU local RAM, with suppression across the two halves of a MOVE.L longword write.
- Implemented
m68k_cycles_run()and adjusted the main scheduler to only run the GPU for the remaining slice budget after any mid-slice sync.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| src/tom/gpu.h | Exposes new GPU slice/sync APIs used by the scheduler and 68K write boundary. |
| src/tom/gpu.c | Implements slice budget tracking and GPUSyncToM68K() mid-slice GPU advancement. |
| src/m68000/m68kinterface.c | Implements m68k_cycles_run() to report cycle progress for GPU catch-up. |
| src/core/jaguar.c | Hooks 68K writes into GPU local RAM and adjusts JaguarExecuteNew() to run only remaining GPU slice cycles. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…ress Copilot review on #222, both points. M68KGPURAMSync() tested only the base address of the access. A 68000 long write is issued as two word writes, so a long starting at GPU_WORK_RAM_BASE - 2 ($00F02FFE) lands its second word inside GPU local RAM while the base sits outside the region -- the inner word writes are suppressed by m68kInLongWrite, and the outer call then declined to sync. The one write that crosses into the GPU's memory at the boundary was exactly the one that skipped the sync. Take an access width and test span overlap instead. Edges verified by hand: a word at $F02FFE still does not sync (it ends at $F02FFF), a long at $F02FFE now does, a long at $F03FFE still syncs on its first word, and a long at $F04000 still does not. For every access that was already covered the result is unchanged. Also document what m68k_cycles_run() actually returns. m68k_end_timeslice() moves regs.remainingCycles into initialCycles and zeroes the former, so calls after an early timeslice end report the unspent count, not cycles run -- it is neither monotonic nor spent-cycles. GPUSyncToM68K() is unharmed because it clamps upward to the slice budget and returns early on a non-positive delta, but the old comment invited a caller to trust it without a bound. Build clean, c89-lint clean on both files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the third and final defect behind #138. Pitfall: The Mayan Adventure went permanently black ~16 s into level 1 and stayed frozen; with the two earlier fixes (#212) in place, the remaining failure was
gpu_pc_escape frame=5615.Not a GPU interrupt bug — a scheduling-order bug
The task premise (a leaking GPU ISR stack pointer) turned out to be three layers downstream. Measurements that killed the obvious hypotheses:
disp == irq0at every sample, so nothing re-dispatches — the latch is being cleared correctly.$F03022(unmask + clear the int0 latch), which perdocs/jtrm-gpu-dsp.mdis exactly what silicon permits — so the nesting we perform is legitimate.What actually happens: from frame 5408 the epilogue count drops to zero while the ISR body's palette-fade loop runs 73,422 times in five frames. Its iteration count comes from
LOADW ($00044036), and that word had been overwritten with$FE7C(65,148) — so one invocation can no longer finish inside a frame. Every subsequent int0 nests, pushes 4 more bytes, never returns, andr31walks 4 KB down GPU local RAM until it overwrites the GPU's own code at$F03CBC.The corruption is the GPU's own object-list builder, driven through a mailbox the 68000 writes. Traced ordering on frame 5407:
Store-pass table: pass 0 writes 213 stores over
$43C68-$43FB8(correct); pass 1 writes 800, flooding$043FBC-$0448E8— which is where the fade parameter block lives, and where the odd pointer behind the address error fixed in #212 also lived.Why the old ordering is wrong per hardware
docs/jtrm-clocks-timing.md: the GPU runs at the full system clock (26.590906 MHz) and the 68000 at half — exactly 2:1 — and the bus-priority table puts the CPU last of 11, below GPU normal. A GPU spinning on its own local RAM cannot lag the CPU by ~200 cycles.JaguarExecuteNew()ranm68k_execute(whole slice)and only thenGPUExec(whole slice), so it did.The fix
GPUSyncToM68K()advances the GPU to the 68000's position inside the current slice (m68k_cycles_run() * 2, clamped to the slice budget) on a 68000 write into GPU local RAM.GPUBeginSlice()/GPUSliceRemaining()make the end-of-slice call run only the remainder, so total RISC cycles per slice are unchanged — only when they are spent moves.m68k_cycles_run()was a stub returning 0 with no callers; it is now real.Two failed attempts worth recording: an unclamped
GPUExec(64)per write let the GPU run ~30x its budget and broke boot at frame 75 (the clamp is the fix, not an optimisation); and hookingGPUWriteWordfired between the two word writes aMOVE.Ldecomposes into, so the guest's poll loop read a partial pointer and jumped into the TOM register file. The hook therefore sits at the 68000 access boundary with in-long-write suppression.Gates
gpu_pc_escape frame=5615on the same command.cd_wedge_probeat 3000 frames: BrainDead 13 clean in both boot modes — this was the specific open question, since BD13 is the title whose lost-wakeup bug motivated the event-scheduled CPUINT delivery this touches. Battle Morph, Iron Soldier 2, Myst and Primal Rage signature sets byte-identical to stock. Hover Strike HLE wedges at frame 2025 — identically on stock develop, byte-for-byte the same signature, so pre-existing and untouched.make TEST_EXPORTS=1 testexit 0; audio triple at documented baselines (Skyhammer 3079.8, IS2 2599.3, IS1 presence 1175.7); c89-lint clean on all three touched files; benchmark within noise.Reviewer note: after
m68k_end_timeslice(),m68k_cycles_run()returns the unspent count, so the clamp hands the GPU its whole remaining slice — harmless, since the 68000 has already stopped for that slice. The DSP still runs end-of-slice unchanged (deliberately out of scope).🤖 Generated with Claude Code