Skip to content

fix(gpu): sync the GPU to the 68000 on writes into GPU local RAM (Pitfall crash, #138) - #222

Merged
JoeMatt merged 2 commits into
developfrom
fix/gpu-sync-m68k
Jul 31, 2026
Merged

fix(gpu): sync the GPU to the 68000 on writes into GPU local RAM (Pitfall crash, #138)#222
JoeMatt merged 2 commits into
developfrom
fix/gpu-sync-m68k

Conversation

@JoeMatt

@JoeMatt JoeMatt commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

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:

  • Dispatch rate: disp == irq0 at every sample, so nothing re-dispatches — the latch is being cleared correctly.
  • Nesting: the ISR clears IMASK at $F03022 (unmask + clear the int0 latch), which per docs/jtrm-gpu-dsp.md is exactly what silicon permits — so the nesting we perform is legitimate.
  • Push/pop stayed balanced for 5407 frames (320 executions each of the ISR entry and its epilogue). The 1-per-frame dispatch rate after that is the consequence, not the cause.

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, and r31 walks 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:

68kpc $031FF4  p5 $F03E14 = $00043C68
68kpc $031FFE  mailbox    = $00F03080      (kick)
68kpc $004A02  p3 $F03E0C = $0000031F      (interrupt clobbers the count)
GPU            enters $F03080 with p3 = $0000031F

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() ran m68k_execute(whole slice) and only then GPUExec(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 hooking GPUWriteWord fired between the two word writes a MOVE.L decomposes 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

  1. Pitfall: 10,800 frames (3x the old crash window), both blitter modes — clean. Alive at the end, not merely un-crashed: 89.5% non-black, 25.97% frame-to-frame motion, RMS 808, and the frame-10800 screenshot shows real gameplay (score 000580, 27 items, 3 lives, Harry on a jungle ledge). Control: stock gives gpu_pc_escape frame=5615 on the same command.
  2. 40-title A/B: 33/40 bit-identical, and the trigger table explains the rest — 19 titles run the sync yet only 7 diverge (the title with the most sync runs, 10,332, is bit-identical). Four of the seven are video bit-identical with audio counts moving ≤0.0075%. The three with real video changes were behaviour-validated instead: Power Drive Rally and jagniccc are pixel-identical at frame 2400; Doom differs only in attract-demo phase (same scene, same HUD — AMMO 28 / HEALTH 100% / AREA 6 — a few frames further along). Many titles have thousands of calls and zero runs because their GPU is halted while the 68000 writes (Theme Park 202831/0, Tempest 2000 66511/0, Super Burnout 1183/0).
  3. CD titles — gap closed since the original run. cd_wedge_probe at 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.
  4. make TEST_EXPORTS=1 test exit 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

…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>
Copilot AI review requested due to automatic review settings July 30, 2026 05:57
@github-actions github-actions Bot added gpu TOM GPU (graphics RISC) m68k Motorola 68000 (UAE-derived main CPU) core Core orchestration (jaguar.c, event, memory, settings) labels Jul 30, 2026
@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown

Regression: linux-x64

Regression Test Results

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

@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown

Regression: macos-arm64

Regression Test Results

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

@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown

Regression: linux-arm64

Regression Test Results

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/m68000/m68kinterface.c Outdated
Comment thread src/core/jaguar.c Outdated
…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>
@JoeMatt
JoeMatt merged commit 13de90e into develop Jul 31, 2026
36 checks passed
@JoeMatt
JoeMatt deleted the fix/gpu-sync-m68k branch July 31, 2026 21:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core Core orchestration (jaguar.c, event, memory, settings) gpu TOM GPU (graphics RISC) m68k Motorola 68000 (UAE-derived main CPU)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants