From e36ff2d2c203aa4fc9f994b4457ea42e3a9d335e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sun, 8 Nov 2026 17:41:19 +0100 Subject: [PATCH] mountain 3 crash: rollingbouldermountain spawners end while their boulders still write parent.v13 (use after free), seen in game, one-word level fix tested; diag tooling in notes/dev --- notes/dev/mountain3-diag/Mtn3DiagMod.java | 44 ++++ notes/dev/mountain3-diag/cave.asm | 285 ++++++++++++++++++++++ notes/dev/mountain3-diag/rd.py | 54 ++++ notes/dev/mountain3-diag/wadfix.py | 10 + notes/mountain3-crash.md | 144 +++++++++++ 5 files changed, 537 insertions(+) create mode 100644 notes/dev/mountain3-diag/Mtn3DiagMod.java create mode 100644 notes/dev/mountain3-diag/cave.asm create mode 100755 notes/dev/mountain3-diag/rd.py create mode 100644 notes/dev/mountain3-diag/wadfix.py create mode 100644 notes/mountain3-crash.md diff --git a/notes/dev/mountain3-diag/Mtn3DiagMod.java b/notes/dev/mountain3-diag/Mtn3DiagMod.java new file mode 100644 index 0000000..0a3611a --- /dev/null +++ b/notes/dev/mountain3-diag/Mtn3DiagMod.java @@ -0,0 +1,44 @@ +package mtn3; + +import com.eliasmueller.llamamod.exe.ExeImage; +import com.eliasmueller.llamamod.mods.Mod; +import com.eliasmueller.llamamod.mods.ModContext; +import com.eliasmueller.llamamod.mods.ModOption; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +/** Throwaway diagnostics: stale parent-variable access log + local heap consistency check. */ +public final class Mtn3DiagMod implements Mod { + static final Path DIR = Path.of("/home/elias/.claude/jobs/90c6eed7/tmp/m3/diag"); + + public String id() { return "mtn3-diag"; } + public String name() { return "Mountain 3 diagnostics"; } + public String description() { return "Logs stale parent writes and checks the local heap (test only)."; } + public boolean modifiesExecutable() { return true; } + public List options() { + return List.of(ModOption.bool("fix", "Fix", "Redirect stale parent writes to a dummy", false), + ModOption.string("rev", "Rev", "cache buster", "1")); + } + + public void apply(ModContext ctx) throws IOException { + ExeImage exe = ctx.exe(); + long data = exe.allocateData(8192, "mtn3 diag data"); + long cave = exe.nextAllocation(); + String fix = ctx.optionBool("fix") ? "1" : "0"; + Path bin = DIR.resolve(String.format("cave_%s_%x_%x.bin", fix, cave, data)); + Files.writeString(DIR.resolve("addresses.txt"), String.format("CAVE=0x%x DATA=0x%x FIX=%s%n", cave, data, fix)); + if (!Files.exists(bin)) { + throw new IOException("assemble " + bin); + } + long at = exe.place(Files.readAllBytes(bin), "mtn3 diag code"); + if (at != cave) { + throw new IOException("cave moved " + Long.toHexString(at)); + } + exe.hookJmp(0x553770, "8B 44 24 04 8B", cave, "pushParentVarAddr"); + exe.hookJmp(0x553a40, "56 8B 74 24 08", cave + 5, "popParentVar"); + exe.hookJmp(0x41ada0, "8B 44 24 04 56", cave + 10, "FreeLocalMem"); + exe.hookJmp(0x41ae20, "8B 0D 9C 49 58 00", cave + 15, "AllocLocalMem"); + } +} diff --git a/notes/dev/mountain3-diag/cave.asm b/notes/dev/mountain3-diag/cave.asm new file mode 100644 index 0000000..7f6eb37 --- /dev/null +++ b/notes/dev/mountain3-diag/cave.asm @@ -0,0 +1,285 @@ +; Mountain 3 crash diagnostics (German groove.exe). nasm -f bin -DCAVE=.. -DDATA=.. -DFIX=0/1 +bits 32 +org CAVE +%define D(o) dword [DATA+o] +; DATA layout: 0 magic, 16 cnt_parent_ok, 20 cnt_stale_addr, 24 cnt_stale_pop, 28 cnt_free, 32 cnt_anom, +; 36 cnt_alloc, 40 stale ring idx, 44 all ring idx, 48 dummy, 52 last free ptr, 56 last alloc size, 60 tmp idx, +; 64 stale ring 32x32, 1088 anomaly record 64 bytes, 1152 dump 256 bytes (hdr-128..+128), 1408 all-ring 64x32 (2048) +; 3456 free history 64 x 16 +entry_addr: jmp near push_addr +entry_pop: jmp near pop_var +entry_free: jmp near free_hook +entry_alloc: jmp near alloc_hook + +push_addr: + mov eax,[esp+4] + mov ecx,[eax+0xc] + mov edx,[esp+8] + mov [DATA+60],edx + mov edx,0x11 + call record_all + test ecx,ecx + jz .stale + cmp dword [ecx+0xec],0 + jz .stale + inc D(16) +.ok: + mov edx,[ecx+0xf4] + mov ecx,[esp+8] + lea edx,[edx+ecx*4] +.push: + push edx + push eax + call 0x54bbd0 + add esp,8 + ret +.stale: + inc D(20) + mov edx,1 + call record + call stale_info + call check_heap +%if FIX + mov edx,DATA+48 + jmp .push +%else + test ecx,ecx + jnz .ok + mov edx,DATA+48 + jmp .push +%endif + +pop_var: + push esi + mov esi,[esp+8] + push esi + call 0x54bc00 + add esp,4 + mov ecx,[esi+0xc] + mov edx,[esp+0xc] + mov [DATA+60],edx + push eax + mov eax,esi + mov edx,0x12 + call record_all + pop eax + test ecx,ecx + jz .stale + cmp dword [ecx+0xec],0 + jz .stale + inc D(16) +.ok: + mov edx,[ecx+0xf4] + mov ecx,[esp+0xc] + lea edx,[edx+ecx*4] +.store: + pop esi + mov [edx],eax + ret +.stale: + inc D(24) + push eax + mov eax,esi + mov edx,2 + call record + call stale_info + call check_heap + pop eax +%if FIX + mov edx,DATA+48 + jmp .store +%else + test ecx,ecx + jnz .ok + mov edx,DATA+48 + jmp .store +%endif + +; record: eax strat, ecx parent, edx kind, [DATA+60] idx. Preserves eax, ecx. +record: + push esi + push edi + push ebx + mov esi,[DATA+40] + and esi,31 + shl esi,5 + lea edi,[DATA+64+esi] + jmp rec_common +record_all: + push esi + push edi + push ebx + mov esi,[DATA+44] + and esi,63 + shl esi,5 + lea edi,[DATA+1408+esi] + inc D(44) + dec D(40) ; compensated below +rec_common: + inc D(40) + mov [edi],eax + mov [edi+4],ecx + mov [edi+8],edx + mov ebx,[DATA+60] + mov [edi+12],ebx + mov ebx,[eax+0x18] + mov [edi+16],ebx + mov dword [edi+20],0 + mov dword [edi+24],0 + mov dword [edi+28],0 + test ecx,ecx + jz .np + mov ebx,[ecx+0x18] + mov [edi+20],ebx + mov ebx,[ecx+0xf4] + mov [edi+24],ebx + mov ebx,[ecx+0xec] + mov [edi+28],ebx +.np: + pop ebx + pop edi + pop esi + ret + +free_hook: + pushad + inc D(28) + mov eax,[esp+36] + mov [DATA+52],eax + mov esi,[DATA+28] + and esi,63 + shl esi,4 + mov [DATA+3456+esi],eax + mov ebx,[esp+32] ; caller + mov [DATA+3456+esi+4],ebx + mov ebx,[eax-4] ; size|used + mov [DATA+3456+esi+8],ebx + mov ebx,[eax-12] ; next + mov [DATA+3456+esi+12],ebx + call check_heap + popad + mov eax,[esp+4] + push esi + jmp 0x41ada5 + +alloc_hook: + pushad + inc D(36) + mov eax,[esp+36] + mov [DATA+56],eax + call check_heap + popad + mov ecx,[0x58499c] + jmp 0x41ae26 + +; walks the local heap; on the first inconsistency records it (code in anomaly+0) +check_heap: + pushad + mov esi,[0x58499c] + test esi,esi + jz .done + xor edi,edi + mov ecx,70000 +.loop: + mov edx,1 + cmp [esi+4],edi + jne .bad + mov edx,2 + movzx eax,word [esi+10] + cmp eax,1 + ja .bad + mov ebx,[esi] + test ebx,ebx + jz .done + mov edx,3 + movzx eax,word [esi+8] + lea eax,[esi+eax*4+12] + cmp ebx,eax + jne .bad + mov edi,esi + mov esi,ebx + dec ecx + jnz .loop +.done: + popad + ret +.bad: + inc D(32) + cmp dword [DATA+32],1 + jne .done + mov [DATA+1088],edx + mov [DATA+1092],esi + mov [DATA+1096],edi + mov eax,[esi] + mov [DATA+1100],eax + mov eax,[esi+4] + mov [DATA+1104],eax + mov eax,[esi+8] + mov [DATA+1108],eax + mov eax,[DATA+28] + mov [DATA+1112],eax + mov eax,[DATA+36] + mov [DATA+1116],eax + mov eax,[DATA+40] + mov [DATA+1120],eax + mov eax,[esp+32] ; return address of check_heap caller + mov [DATA+1124],eax + mov eax,[DATA+52] + mov [DATA+1128],eax + mov eax,[DATA+56] + mov [DATA+1132],eax + ; dump 256 bytes from edi (previous header) if set, else esi-128 + test edi,edi + jnz .src + lea edi,[esi-128] +.src: + mov esi,edi + lea edi,[DATA+1152] + mov ecx,64 + rep movsd + jmp .done + +; stale_info: ecx parent, [DATA+60] idx -> DATA+4480 + (n&31)*16: target, block hdr, size|used, value before +stale_info: + pushad + test ecx,ecx + jz .out + mov edi,[ecx+0xf4] + mov eax,[DATA+60] + lea edi,[edi+eax*4] + mov ebx,[DATA+40] + dec ebx + and ebx,31 + shl ebx,4 + lea ebx,[DATA+4480+ebx] + mov [ebx],edi + mov eax,[edi] + mov [ebx+12],eax + mov dword [ebx+4],0 + mov dword [ebx+8],0 + mov esi,[0x58499c] + mov ecx,70000 +.l: + test esi,esi + jz .out + mov edx,[esi] + test edx,edx + jnz .h + movzx eax,word [esi+8] + lea edx,[esi+eax*4+12] +.h: + cmp edi,esi + jb .next + cmp edi,edx + jae .next + mov [ebx+4],esi + mov eax,[esi+8] + mov [ebx+8],eax + jmp .out +.next: + mov esi,[esi] + dec ecx + jnz .l +.out: + popad + ret diff --git a/notes/dev/mountain3-diag/rd.py b/notes/dev/mountain3-diag/rd.py new file mode 100755 index 0000000..09b003d --- /dev/null +++ b/notes/dev/mountain3-diag/rd.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""rd.py SESSION : attach briefly with winedbg, dump diag data, print summary""" +import os, re, subprocess, sys, struct +S = sys.argv[1]; DATA = 0x70f010; N = 4992 +env = dict(os.environ, WINEPREFIX='/home/elias/.claude/jobs/90c6eed7/tmp/%s/data/wine' % S, WINEDEBUG='-all') +def dbg(cmds): + out = subprocess.run(['winedbg'], input='\n'.join(cmds) + '\ndetach\nquit\n', env=env, capture_output=True, text=True, timeout=60).stdout + return out +pl = dbg(['info proc']) +m = re.search(r'([0-9a-f]{8})\s+\d+\s+\\_ \'groove.exe\'', pl) +if not m: print('no groove.exe'); sys.exit(1) +pid = m.group(1) +out = dbg(['attach 0x' + pid, 'x/%dx 0x%x' % (N // 4, DATA), 'x/16x 0x58499c']) +words = {} +for l in out.splitlines(): + mm = re.match(r'.*?0x([0-9a-f]{8}):\s+((?:[0-9a-f]{8}\s*)+)$', l) + if mm: + a = int(mm.group(1), 16) + for i, w in enumerate(mm.group(2).split()): words[a + 4 * i] = int(w, 16) +def W(o): return words.get(DATA + o, 0) +names = set() +for base, cnt in ((64, 32), (1408, 64)): + for i in range(cnt): + e = DATA + base + 32 * i + for k in (16, 20): + if words.get(e + k): names.add(words[e + k]) +nm = {} +if names: + o2 = dbg(['attach 0x' + pid] + ['x/s 0x%x' % a for a in sorted(names)]) + ss = [l for l in o2.splitlines() if l.startswith('Wine-dbg>') and '"' not in l or True] + segs = o2.split('Wine-dbg>') + for i, a in enumerate(sorted(names)): + nm[a] = segs[2 + i].strip() if 2 + i < len(segs) else hex(a) +print('parent_ok=%d stale_addr=%d stale_pop=%d free=%d alloc=%d anomalies=%d staleidx=%d allidx=%d' % (W(16), W(20), W(24), W(28), W(36), W(32), W(40), W(44))) +def ring(base, cnt, idx, label, last=12): + print('--', label) + n = min(idx, cnt) + for j in range(max(0, idx - last), idx): + e = base + 32 * (j % cnt) + s, p, k, vi, sn, pn, pv, pec = (W(e + 4 * q) for q in range(8)) + print(' #%d kind=%x strat=%x(%s) parent=%x(%s) v%d parentvars=%x parent_ec=%x target=%x' % (j, k, s, nm.get(sn, '-'), p, nm.get(pn, '-'), vi, pv, pec, pv + 4 * vi)) +ring(64, 32, W(40), 'stale', 32) +for j in range(max(0, W(40) - 32), W(40)): + e = 4480 + 16 * (j % 32); t, h, su, v = (W(e + 4 * q) for q in range(4)) + print(' stale#%d target=%x block=%x off=+0x%x %s size=%d value_before=%x%s' % (j, t, h, t - h if h else 0, 'USED' if su >> 16 else 'FREE', su & 0xffff, v, ' <<< HEADER HIT' if h and t - h < 12 else '')) +ring(1408, 64, W(44), 'all parent accesses (last 16)', 16) +if W(32): + print('ANOMALY code=%d hdr=%x prevhdr=%x hdr.next=%x hdr.prev=%x sizeused=%x frees=%d allocs=%d staleidx=%d caller=%x lastfree=%x lastalloc=%d' % tuple(W(1088 + 4 * q) for q in range(12))) + d = [W(1152 + 4 * q) for q in range(64)] + for q in range(0, 64, 8): print(' ' + ' '.join('%08x' % x for x in d[q:q + 8])) +fh = [(W(3456 + 16 * q), W(3456 + 16 * q + 4), W(3456 + 16 * q + 8), W(3456 + 16 * q + 12)) for q in range(64)] +print('heapbase', hex(words.get(0x58499c, 0))) +if '-f' in sys.argv: + for q in range(64): print(' free ptr=%x caller=%x sizeused=%x next=%x' % fh[q]) diff --git a/notes/dev/mountain3-diag/wadfix.py b/notes/dev/mountain3-diag/wadfix.py new file mode 100644 index 0000000..1903d73 --- /dev/null +++ b/notes/dev/mountain3-diag/wadfix.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +"""wadfix.py IN OUT: RollingBoulderMountain never ends (its final 'wait until v13 == 0' compares with 0.5).""" +import struct, sys +d = bytearray(open(sys.argv[1], 'rb').read()) +pat = struct.pack('<6I', 0x1002f, 0xd0004, 0x44, 0x4e, 0xfffc0012, 0xb4) # freezeOffCamera 1; v13; pushShort 0; eq; jzYield -4; end +i = d.find(pat) +assert i >= 0 and d.find(pat, i + 1) < 0, 'pattern not unique' +struct.pack_into('next->prev = hdr`. The crash means the header after the + freed block had `used == 0` and `next == 0x3409`. 0x3409 is not 4-aligned, so it can't be a real + block pointer. **The heap metadata was corrupted earlier, and the free only shows it.** +* Clips: index.mp4 is in a snowy canyon with a pushable boulder (red X), wall shards + (`SmashWallShrapnel`), a ruby-idol pickup (HUD), and a crash at the cave about 6 s later. + index2.mp4 is the Kronk ice race (`KronkOnIceDoor`/`KronkOnIce`, same WAD): black screen about + 2 s after the race starts. Both are in `t1l5m003`. Our boot to (74.6, 14.8, 6.3) shows the same canyon + and boulder as clip 1, so clip 1 is most likely the `#47` push-boulder section (hypothesis, + based on what the scenes look like). +* Side note: the heap is `operator_new(0x200000)` but is initialised with size `0x800000` into a u16 + field, so it is really 0xFFFD dwords (≈256 KB). Mountain 3 uses about 88 KB of it, so the heap + is not exhausted. No leak was seen: block count stayed stable over minutes. + +## The bug (strat code of unit 1 of t1l5m003, verified by disassembly) + +`RollingBoulderMountain` (6 MAP instances, params v0..v9). `v13` is set to 0 and never +incremented anywhere. The only other access is the boulder's `parent.v13 -= 1.0` +(`pushParentVarAddr v13; dec`) in `L_0574`, which runs when the boulder ends its path or falls: + +``` +spawner: ... spawn loop (L_0c24: spawn<7,31,4,0x14c000,0>(…RollingBoulderForMountain)) ... + freezeOffCamera 1 +L_0d90: pushVar v13 / pushShort 0 / eq / jzYield L_0d90 / end ; ends at once when v13 == 0 +boulder: L_0574: parent.v13 -= 1.0 ; ... ; remove +``` + +* With `v1 == 0` (instances #17 and #35) or `v4 != 0` (#22 and #47, the pushable "red X" boulders, + which wait to be charged), the spawner spawns **one** boulder and **ends in the same frame** + (v13 is still 0). +* Instances #1 and #27 (`v1` = 8.3 / 8.75) loop: a boulder about every 8–10 s, until the player + is near the path start. They end the same way if no boulder has died yet when the loop exits. +* Strat removal (strat update loop `FUN_0054c4a0`) frees the var block + (`+0xF4`) and the collision block (`+0xC0`) and puts the struct on the free list (LIFO). It does + **not** clear the children's `+0xC` parent pointers. The parent opcodes (`0x553750`/`0x553770`/ + `0x553a40`) have no check. +* So the boulder's write goes to `deadSpawner.vars + 0x34`: either memory of the freed var block + (now part of a free block or of another allocation, possibly a **block header**), or, if the + struct was already reused, `v13` of another strat. For a boulder, `v13` is its sound address + (`v13 = sound:4`, used by `playSoundChannel`). +* "Predictable intervals" (runner's guess) fits: the stale writes happen a fixed time after you + pass a spawner, or after you push a boulder, when that boulder reaches the end of its path. + Whether a write hurts depends on the heap layout at that moment, so it depends on timing. + It is not deterministic across runs. + +## In-game verification (apptest, Xvfb :26, sessions `mtn3-*`) + +Tooling (throwaway, see `notes/dev/mountain3-diag/`): a test-only mod `Mtn3DiagMod` (loaded through +`APPTEST_CP` + `-Dllamamod.extraMods`). It installs a code cave (`cave.asm`, nasm) that replaces the +`pushParentVarAddr` and `popParentVar` handlers. They log every access, flag **stale** ones +(parent `+0xEC == 0`, i.e. removed) together with the block that the target address falls in, and +walk the whole heap on every alloc and free (`next/prev/used` consistency). +`rd.py` reads the log by attaching `winedbg` for a moment (stdin script `attach`/`x`/`detach`; +works under ptrace_scope 1). `apptest.py` in the agent copy got an env switch +`APPTEST_IGNORE_WINEDBG` so the short attach is not taken for a crash (not copied back). +A gdb-over-winedbg monitor with breakpoints was too slow: the game froze. + +Boot-to-level `start=29.5, 14, 23.2, 180 @ 5/3` (next to spawners #1 and #17), no input, 70–85 s: + +| | runs | runs with a stale write | heap anomalies | crash | +|---|---|---|---|---| +| original WAD | 6 (1 manual + 5) | 5 | 0 | 0 | +| level fix (below) | 3 | 0 | 0 | 0 | + +Every stale record was the same and deterministic for this start (logged by the cave): +`RollingBoulderForMountain` → parent `RollingBoulderMountain` (struct `+0xEC == 0`, removed), `v13`, +target = `freed var block + 0x34`. At the time of the write this address was inside a **used +16-dword block** (a `0x40000` collision block, most likely the next boulder's), at offset +0x40 from its +header, which is 12 bytes before the next block header. In this layout it corrupts collision data +(u16 fields `+0x34/+0x36` become 0xF000/0xFFFF), not the heap. Any other layout (other timing, +other route, other spawns in between, e.g. an 11-dword allocation such as a `0x28000` pickup's +collision block puts a remainder header's size/used word exactly there) turns the same write into +header corruption. **Not verified:** a crash caused by this write. No natural run crashed, and the +real runner route (from the chapter start at full speed, pushing #22/#47, then the race) could not +be driven reliably by key scripts (the push needs speed and alignment; several attempts missed). +Also seen in game: strat structs are recycled LIFO (the dead spawner's struct was later reused by a +boulder). + +Other checks in this WAD: every `collisionOn` user is spawned or placed with collision type flags +(no `typeFlags 0 + collisionOn` case like in the arena), and all `extra` operands are 0. Other +parent-variable writers (`KronkOnIcePickup` → `parent.v7`, `CheckPointHead`, `RubyIdolStatue`, +`AxeGuardAxe`) have parents that do not end while they live, as far as the code shows. That was +checked by reading the code only; the cave saw no stale access from them. + +Side finding (not part of the crash): boot-to-level `start=28.5, 10.5, 22.5, 0 @ 5/3` passes the +floor check, but the game crashes while loading (`page fault read 0x00000002 at 0x405527`, floor/ +tile lookup in `FUN_004054c0`). Out of scope here. + +## Fix / avoidance + +* **Level data (tested):** in `RollingBoulderMountain`, change `pushShort 0` before `eq` to + `pushShort 0.5` (word `0x00000044` → `0x08000044`, the only match of the 6-word pattern + `0x1002f 0xd0004 0x44 0x4e 0xfffc0012 0xb4`, file offset 0x4a0a27 in the German + `t1l5m003.wad`). `v13` only takes whole numbers (0, -1, -2 …), so the spawner never ends. It + stays frozen off camera and costs nothing, and every boulder writes into a live spawner. The + boulders still roll (in-game: 6–7 normal parent writes per run, 0 stale). `notes/dev/mountain3-diag/wadfix.py IN OUT`. + The English CD's WAD is not checked; the patch looks for the pattern and refuses if it isn't unique. +* **Exe (general, untested):** let removal (`+0xE8 & 4` path) clear `+0xC` of all strats whose + parent is the removed one (pool `DAT_006d9dc0`, stride 0x154), and let the parent opcodes + redirect a NULL or removed parent to a dummy dword. This also covers similar bugs in other levels. +* **Runners (hypothesis):** there's no reliable avoidance. The write happens when the boulders of + #17 and #35 (spawned when you pass their trigger spots) and the pushed boulders #22 and #47 reach + the end of their path. Not pushing #22/#47 removes two of the four guaranteed stale writes, if + the route allows that. + +Diag tooling notes: the files in `notes/dev/mountain3-diag/` are as used, with absolute paths +of the agent job dir. The mod writes `addresses.txt` and expects `cave___.bin` +(`nasm -f bin -DCAVE=0x6ef0e0 -DDATA=0x70f010 -DFIX=0 cave.asm`; these addresses are valid with +boot-to-level + mtn3-diag on the German exe). This is only for the German exe; nothing is registered in the app. + +## Discord answer (draft) + +> We looked into the Mountain 3 crash. Your dump crashes in the game's own memory manager +> (`FreeLocalMem`, writing to 0x340d). That is the symptom: its bookkeeping was overwritten +> earlier. **Verified:** in Mountain 3 the rolling-boulder spawner can delete itself while +> its boulder is still rolling. When that boulder reaches the end of its path (or falls off), it +> writes into the spawner's memory, which has already been freed and reused. We saw this +> write in the game with a debug build, and a one-value level patch makes it go away. +> **Not verified yet:** that this exact write causes your crash. We never got the game to +> crash. Where the write lands depends on what else is in memory at that moment, so it +> breaks things only sometimes, and the crash can come seconds later somewhere else +> (e.g. in the Kronk race). That would explain why it's Mountain 3 only, at different spots, +> without an error message, and "every so often" around boulders. The likely triggers are the +> one-shot boulders near the start/top of the slope and the two red-X push boulders. We'll +> probably ship the fix in LlamaMod.