Diagnosed and fixed the crash that killed HAVOC_NOCD.EXE ~1s after launch
(dgVoodoo window appears black, then the process exits). It was not a clean
exit: an ACCESS_VIOLATION (null-pointer read at VA 0x444E6A) during init.
Root cause is a copy-protection check in the WORLDS\ data-file loader
(0x42a480). It opens each file (GRAFIX/LAND/MAP/STUF) from both a local
"WORLDS\<name>" path and a CD-drive path (HAVOC.INI [SETUP] DRIVE=D:\), and
returns a valid stream only when the local open fails and the CD-drive open
succeeds. With files present locally and no D: drive it returned NULL, and the
GRAFIX loader (0x444db0) dereferenced that NULL without checking.
Fix: 14-byte patch at FO 0x29a2b rewrites the return decision to hand back the
successfully-opened local object. The game now boots to the title screen
("HAVOC(tm) by Reality Bytes"), responsive, main loop running.
- docs/PATCHES.md: full patch table (28 patches) + crash write-up
- tools/: RE + patching scripts (r2pipe disasm, minidump parser, ctypes
debugger, PE/IAT/import analysis)
- run_probe.bat / run_and_log.bat: reliable native launch for repro
- .gitignore: exclude CD images, Ghidra install/project, dgVoodoo, *.ini
Diagnosed via WER minidumps (%LOCALAPPDATA%\CrashDumps) parsed in pure Python
(no cdb/windbg available).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TN4Ytn3gdWRonNmHpisWQv
109 lines
4.1 KiB
Python
109 lines
4.1 KiB
Python
import re, struct
|
|
|
|
exe = open("HAVOC_NOCD.EXE", "rb").read()
|
|
pe_off = struct.unpack_from('<I', exe, 0x3c)[0]
|
|
img_base = struct.unpack_from('<I', exe, pe_off + 52)[0]
|
|
num_secs = struct.unpack_from('<H', exe, pe_off+6)[0]
|
|
opt_sz = struct.unpack_from('<H', exe, pe_off+20)[0]
|
|
secs = []
|
|
for i in range(num_secs):
|
|
s = pe_off + 24 + opt_sz + i*40
|
|
name = exe[s:s+8].rstrip(b'\x00').decode()
|
|
vsz = struct.unpack_from('<I', exe, s+8)[0]
|
|
vrva = struct.unpack_from('<I', exe, s+12)[0]
|
|
rsz = struct.unpack_from('<I', exe, s+16)[0]
|
|
roff = struct.unpack_from('<I', exe, s+20)[0]
|
|
secs.append((name, roff, rsz, vrva, vsz))
|
|
|
|
def fo_to_va(fo):
|
|
for n, ro, rs, vr, vs in secs:
|
|
if ro and ro <= fo < ro+rs:
|
|
return img_base + vr + (fo - ro)
|
|
return None
|
|
|
|
LABELS = {
|
|
fo_to_va(0x06b27c): 'MUSIC.FF',
|
|
fo_to_va(0x06b288): 'INTRFACE.FF',
|
|
fo_to_va(0x06b2b8): 'WORLDS',
|
|
fo_to_va(0x06b3b8): 'BIGFILE.DAT',
|
|
fo_to_va(0x06bee8): 'STRDATA.DAT',
|
|
}
|
|
|
|
def dump(start_fo, end_fo, title):
|
|
print(f"\n=== {title} ===")
|
|
row = start_fo & ~0xF
|
|
while row < end_fo:
|
|
h = ' '.join(f'{b:02x}' for b in exe[row:row+16])
|
|
a = ''.join(chr(b) if 0x20<=b<0x7f else '.' for b in exe[row:row+16])
|
|
ann = ''
|
|
for off in range(13):
|
|
if row+off+4 <= row+16:
|
|
dw = struct.unpack_from('<I', exe, row+off)[0]
|
|
if dw in LABELS:
|
|
ann = f' <-{LABELS[dw]}'
|
|
break
|
|
print(f" {row:06x}: {h:<48} {a}{ann}")
|
|
row += 16
|
|
|
|
# Show the file-loading init region
|
|
dump(0x029550, 0x029600, "0x029550-0x029600 (INTRFACE.FF area)")
|
|
dump(0x0295c0, 0x029620, "0x0295c0-0x029620 (MUSIC.FF area)")
|
|
dump(0x029870, 0x0298c0, "0x029870-0x0298c0 (WORLDS area)")
|
|
dump(0x029900, 0x029960, "0x029900-0x029960 (after WORLDS)")
|
|
dump(0x033de0, 0x033e60, "0x033de0-0x033e60 (flag-setter context)")
|
|
|
|
# Find the CALL target at 0x033df7
|
|
call_site = 0x033df7
|
|
rel32 = struct.unpack_from('<i', exe, call_site+1)[0]
|
|
target = call_site + 5 + rel32
|
|
print(f"\nCALL at 0x{call_site:06x}: target = 0x{target:06x}")
|
|
# section boundary check
|
|
for n, ro, rs, vr, vs in secs:
|
|
if ro and ro <= target < ro+rs:
|
|
print(f" In section {n} (raw 0x{ro:06x})")
|
|
break
|
|
|
|
# Dump start of that target function
|
|
dump(target, target+0x80, f"Target fn at 0x{target:06x}")
|
|
|
|
# Now find MessageBoxA IAT using correct section math
|
|
print("\n=== Finding MessageBoxA IAT ===")
|
|
mbox_name_off = exe.find(b'MessageBoxA\x00')
|
|
print(f" 'MessageBoxA' string at 0x{mbox_name_off:06x}")
|
|
# Search .idata for a thunk pointing near that name
|
|
idata_start = 0x06ec00
|
|
idata_end = 0x06fa00
|
|
for fo in range(idata_start, idata_end, 4):
|
|
val = struct.unpack_from('<I', exe, fo)[0]
|
|
if val == 0:
|
|
continue
|
|
# Try to dereference as a VA pointing to a hint+name
|
|
rva = val - img_base
|
|
candidate_fo = None
|
|
for n, ro, rs, vr, vs in secs:
|
|
if ro and vr <= rva < vr+rs:
|
|
candidate_fo = ro + (rva - vr)
|
|
break
|
|
if candidate_fo and 0 < candidate_fo < len(exe) - 20:
|
|
try:
|
|
name_start = candidate_fo + 2
|
|
end = exe.index(b'\x00', name_start)
|
|
nm = exe[name_start:end].decode('latin1', errors='replace')
|
|
if 'MessageBox' in nm:
|
|
iat_va = fo_to_va(fo)
|
|
print(f" IAT entry at fo=0x{fo:06x} VA=0x{iat_va:08x} fn='{nm}'")
|
|
# Find all call sites: ff 15 <le32 iat_va>
|
|
needle = b'\xff\x15' + struct.pack('<I', iat_va)
|
|
for m in re.finditer(re.escape(needle), exe[0x400:0x067000]):
|
|
cfo = 0x400 + m.start()
|
|
ctx = exe[max(0x400,cfo-20):cfo+12]
|
|
print(f" call site 0x{cfo:06x}: {ctx.hex(' ')}")
|
|
except Exception:
|
|
pass
|
|
|
|
# Also search for error-string "corrupt" via STRDATA.DAT open in code
|
|
print("\n=== STRDATA.DAT open call at 0x038c8e ===")
|
|
dump(0x038c6e, 0x038cd0, "STRDATA.DAT open region")
|
|
|
|
# Look at what function contains 0x020270 (flag check before error)
|
|
dump(0x020200, 0x020300, "0x020200-0x020300 (error gate function)")
|