docs: Tomba 2 Whoopee-Camp freeze root cause (wild-call-contract bail storm)

Clear writeup of the splash freeze: what's ruled out (all hardware-sim healthy
- interrupts/timer/CD/events), the root cause (wild-call/return-contract
corruption at the exception<->B0 boundary -  clobbered to 1 on RFE resume of
an interrupted wild/dirty call -> infinite bail loop = the 68k/frame spin), the
captured evidence (trigger 0xB0->0x8008AE50 @frame 1823, register state), open
questions, and fix direction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Matthew Stanley
2026-06-22 14:22:36 -07:00
parent b2cfdac236
commit 90585eafab
+104
View File
@@ -0,0 +1,104 @@
# Tomba 2 — Whoopee-Camp splash freeze (wild-call/return-contract bail storm)
Branch: `wt/tomba2`. Single source of truth for the splash freeze. Update as we learn.
## Symptom
Tomba 2 (SCUS-94454) boots to the Whoopee-Camp publisher splash and **freezes there**
— it never rolls into the intro FMV (the oracle, Beetle PSX on the same disc + BIOS,
sails past it to ~frame 270k). With the interrupt-pump fix (`0a6920a`) the process no
longer hard-deadlocks, but the splash never advances and the process degrades to a
Windows "Not Responding" state within ~2 minutes (the main thread is saturated).
## What it is NOT (ruled out with always-on heartbeat probes)
All of the hardware-sim is **healthy** at the spin — verified, not assumed
(probes survive the degradation because they are read from the heartbeat file):
- **Interrupts** (`75f1468`/`e1dce63`): VBLANK is **raised + delivered + ACKed exactly
1/frame**; `irq_deliver` = 1/frame. Not an ack failure, not a delivery storm, not a
re-entry storm (`exc_reentry` growth is just the one slow interpreted handler being
polled, not actual nested entries).
- **Timer 1 / RootCounter 1**: free-run HBlank counter, `mode 0x1D00` (no IRQ
configured = correct), `t1_count` advances, `t1_irq_fired = 0` is correct.
- **CD-ROM**: idle / Pause (correct — the splash hasn't issued the FMV load yet).
- **Kernel events (EvCB)**: snapshot at base `0xA000E028`, 16 entries, **all status =
FREE** — no active event, so it is **not** a `WaitEvent`/`TestEvent` wait either.
## Root cause: wild-call/return-contract corruption at the exception ⟷ B0 boundary
The freeze is **not** an emulation bug — it is a **recompiler control-flow correctness
bug** in the wild-call/return-contract machinery (`psx_call_contract` in
`cpu_state.h`, and the generated `psx_dispatch_impl` trampoline). Same family as the
Bug A/C/D "wild call contract" freezes.
The contract bails when a callee C-returns but the guest state doesn't match the call
site: `cpu->gpr[31] != stop_addr` **or** `cpu->gpr[29] != sp_at_call`. On a bail it
sets `cpu->pc = $ra` and "flattens" at the outermost dispatch (re-dispatches the wild
target on a clean host stack).
Tomba 2's splash drives this **68,000 times per frame** (Tomba 1 at gameplay does
**zero** — so it is Tomba-2-specific, not a generic cost). Captured evidence:
- **Bail source** (runtime-only capture via `fntrace.c`, `f1aaedd`): dominant bailing
function = **`0x000000B0`** (the **B0 kernel-syscall vector** / last compiled frame
on the boundary).
- **Trigger** (first bail, frame **1823** = spin onset): `0xB0` → wild target
**`0x8008AE50`**. Disassembly of the caller:
```
0x8008AE48: jal 0x80085900 ; call
0x8008AE4C: li $a0, -1 ; delay slot
0x8008AE50: lui $v1, 0x8010 ; <- jal+8 return address == the "wild target"
```
So `0x8008AE50` is the **correct** return address of `jal 0x80085900` — the bail
target is a *legitimate outer-frame return*, not a corrupt pointer. Both
`0x80085900` and its caller are **high RAM (~0x80085000), OUTSIDE the recompiled
game text** (`0x80010000`–`0x80038800`) → runtime-loaded / install-at-runtime code.
- **Register state at the spin** (clean snapshot):
- `$ra = 0x00000001` — **corrupted** (degenerate dominant key has `$ra=$sp=1`).
- `$sp = 0x801FFF48`, `s2 = 0x8008AE50` (the outer return, saved callee-side),
`s3 = 0x8008ACFC`, `t1 = 0x35`.
- `k0 = 0xBFC0193C` (**BIOS exception handler**), `epc = 0x80000048` (our EPC
sentinel) → the spin runs in **exception-handler context**.
### Mechanism (current best model)
A VBLANK exception (delivered cleanly, 1/frame) fires while the high-RAM code is
**mid-call across the B0 / dirty-RAM boundary**. The exception delivery + resume/RFE
path does **not** correctly restore the interrupted frame's `$ra` (and, in the
degenerate steady state, `$sp`): the resumed code's `$ra` is clobbered to `1`. The
next return is therefore "wild" (`$ra != stop_addr`), the contract bails and flattens,
the flatten re-dispatches a wrong target, that corrupts state further, and it
**degenerates into an infinite bail loop** — the 68k-bails/frame splash spin.
This is consistent with the interrupt being *healthy at the controller level* (raised/
delivered/acked 1/frame) yet the *guest continuation* after RFE being corrupted — the
boundary between a delivered exception and an interrupted wild/dirty call.
## Open questions (for the fix)
1. Exact B0 function index at the trigger (`$t1` captured = `0x35` in the degenerate
state; candidate `B0:0x17 ReturnFromException` fits the non-local-return shape —
confirm by capturing `$t1` at the *first* bail, e.g. latch it in the heartbeat).
2. Precisely where `$ra` is clobbered to `1` — the exception push/RFE path
(`interrupts.c` / `traps.c psx_syscall` ReturnFromException / the EPC-sentinel
`0x80000048` handling) vs the bail-flatten re-dispatch.
3. Whether the first bail is itself a **false positive** (the contract wrongly flags a
legitimate non-local return) or a **genuine** wild return whose *resume* is what
corrupts state. The trigger target being a valid `jal+8` suggests the corruption is
in the resume, not the detection.
## Fix direction
The exception/RFE resume across a wild/dirty call boundary must restore the
interrupted frame's `$ra`/`$sp` so the continuation returns to its real call site
instead of being flagged wild. Likely in the recompiler call-contract + the
exception-delivery/RFE path (never in generated C). Design before implementing — the
wild-call-contract family is subtle and has regressed under pressure before.
## Committed so far (`wt/tomba2`)
- `0a6920a` interrupt-pump fix (low-RAM kernel loops) — the original hard deadlock.
- `75f1468` Timer1/RCnt1 heartbeat telemetry.
- `e1dce63` IRQ raise/deliver/ack + bail-source ledger telemetry.
- `f1aaedd` `fntrace.c` dispatch-level bail-source capture (runtime-only).