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