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

This commit is contained in:
MineTec committed 2026-11-08 17:41:19 +01:00
1 parent 8b3c38fe56
commit e36ff2d2c2
5 files changed
+537

No files matched your search

+44
View File
@@ -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<ModOption> 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");
}
}
+285
View File
@@ -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
+54
View File
@@ -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])
+10
View File
@@ -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('<I', d, i + 8, 0x08000044) # pushShort 0.5
open(sys.argv[2], 'wb').write(d)
print('patched at 0x%x' % (i + 8))
+144
View File
@@ -0,0 +1,144 @@
# "Mountain 3 crash" (speedrun community) – investigation 2026-11-08
Short version: **Mountain 3 = `t1l5m003`** (world 5, chapter 3). Its boulder spawner
(`RollingBoulderMountain`) can end while a boulder it spawned is still alive. When that boulder
finishes its path, it decrements `parent.v13` of the dead spawner. That write
(`-= 1.0`, i.e. `-0x1000`) goes into freed or reused memory (use after free). If it hits a header of
the game's local heap, a later `FreeLocalMem` crashes. That matches the community crash dump.
The stale write is **verified in game**. A full crash was **not** reproduced. A one-word level-data
patch removes the stale write (verified in game).
## Evidence from the community
* DebugDiag dump (English exe, 2000-10-17): access violation **writing** `0x0000340d` at
`groove+0x1ad52` = VA `0x41AD52`, thread 0, Argonaut name `FreeLocalMem`.
* English `0x41AD10..0x41AD89` is byte-identical to German **`FUN_0041ada0`** (only the rel32 of
the "ITS NOT USED! %08x" debug printf differs). Crash instruction = German `0x41ADE2`
`mov [eax+4], esi`.
* The local heap (`FUN_0041af00` init, `FUN_0041ae20` alloc, `FUN_0041ada0` free) holds blocks with a
12-byte header `{next, prev, u16 size (dwords), u16 used}`. The blocks are contiguous
(`next == hdr + (size+3)*4`). On free, the routine merges with the **next** block if
`next.used == 0`, then does `next->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_<fix>_<CAVE>_<DATA>.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.