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:
1 parent
8b3c38fe56
commit
e36ff2d2c2
5 files changed
+537
No files matched your search
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Executable
+54
@@ -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])
|
||||
@@ -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))
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user