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
112 lines
4.5 KiB
Python
112 lines
4.5 KiB
Python
import struct, re
|
|
|
|
exe = open("HAVOC_NOCD.EXE", "rb").read()
|
|
base = 0x00400000
|
|
|
|
def rva2fo(rva):
|
|
# Sections from pe_sections.py output
|
|
sections = [
|
|
(".text", 0x00001000, 0x00066bbd, 0x00000400, 0x00066c00),
|
|
(".rdata", 0x00079000, 0x000023c0, 0x00067000, 0x00002400),
|
|
(".data", 0x0007c000, 0x00005650, 0x00069400, 0x00005800),
|
|
(".idata", 0x00082000, 0x00000d5a, 0x0006ec00, 0x00000e00),
|
|
(".rsrc", 0x00083000, 0x0000080c, 0x0006fa00, 0x00000a00),
|
|
(".reloc", 0x00084000, 0x000063ba, 0x00070400, 0x00006400),
|
|
]
|
|
for (name, vrva, vsz, raw, rsz) in sections:
|
|
if vrva <= rva < vrva + vsz:
|
|
return raw + (rva - vrva)
|
|
return None
|
|
|
|
# Data directory entry 1 = import table
|
|
pe_off = 0x80
|
|
opt_off = pe_off + 24
|
|
import_rva = struct.unpack_from('<I', exe, opt_off + 104)[0]
|
|
import_size = struct.unpack_from('<I', exe, opt_off + 108)[0]
|
|
print(f"Import directory RVA: 0x{import_rva:08x} size: 0x{import_size:08x}")
|
|
import_fo = rva2fo(import_rva)
|
|
print(f"Import directory file offset: 0x{import_fo:06x}")
|
|
|
|
# Parse IMAGE_IMPORT_DESCRIPTOR array (each 20 bytes, terminated by all-zeros)
|
|
pos = import_fo
|
|
gpps_iat_va = None
|
|
all_funcs = {} # func_name -> iat_va
|
|
|
|
while True:
|
|
orig_thunk_rva = struct.unpack_from('<I', exe, pos)[0]
|
|
name_rva = struct.unpack_from('<I', exe, pos + 12)[0]
|
|
first_thunk_rva= struct.unpack_from('<I', exe, pos + 16)[0]
|
|
if orig_thunk_rva == 0 and name_rva == 0:
|
|
break
|
|
|
|
dll_name_fo = rva2fo(name_rva)
|
|
dll_name = exe[dll_name_fo : exe.index(b'\x00', dll_name_fo)].decode('latin1') if dll_name_fo else "?"
|
|
print(f"\nDLL: {dll_name} INT_RVA=0x{orig_thunk_rva:08x} IAT_RVA=0x{first_thunk_rva:08x}")
|
|
|
|
# Walk INT (OriginalFirstThunk array)
|
|
int_fo = rva2fo(orig_thunk_rva)
|
|
iat_fo = rva2fo(first_thunk_rva)
|
|
if not int_fo or not iat_fo:
|
|
pos += 20
|
|
continue
|
|
|
|
i = 0
|
|
while True:
|
|
entry_rva = struct.unpack_from('<I', exe, int_fo + i*4)[0]
|
|
if entry_rva == 0:
|
|
break
|
|
# High bit = import by ordinal
|
|
if entry_rva & 0x80000000:
|
|
ordinal = entry_rva & 0x7fff
|
|
func_name = f"ord_{ordinal}"
|
|
else:
|
|
name_entry_fo = rva2fo(entry_rva)
|
|
if name_entry_fo:
|
|
func_name = exe[name_entry_fo+2 : exe.index(b'\x00', name_entry_fo+2)].decode('latin1', errors='replace')
|
|
else:
|
|
func_name = f"rva_{entry_rva:08x}"
|
|
|
|
iat_slot_fo = iat_fo + i*4
|
|
iat_slot_va = base + first_thunk_rva + i*4
|
|
all_funcs[func_name] = iat_slot_va
|
|
if 'PrivateProfile' in func_name or 'CreateFile' in func_name or 'OpenFile' in func_name or 'GetModule' in func_name or 'lopen' in func_name.lower() or 'SetCurrentDir' in func_name:
|
|
print(f" [{i:3d}] {func_name:<40} IAT slot VA: 0x{iat_slot_va:08x}")
|
|
i += 1
|
|
|
|
pos += 20
|
|
|
|
# Now find CALL [GetPrivateProfileStringA_IAT_VA] in .text
|
|
print("\n=== GetPrivateProfileStringA call sites ===")
|
|
if 'GetPrivateProfileStringA' in all_funcs:
|
|
iat_va = all_funcs['GetPrivateProfileStringA']
|
|
print(f" IAT slot VA: 0x{iat_va:08x}")
|
|
call_needle = b'\xff\x15' + struct.pack('<I', iat_va)
|
|
for m in re.finditer(re.escape(call_needle), exe[0x400:0x067000]):
|
|
cfo = 0x400 + m.start()
|
|
print(f" CALL @ 0x{cfo:06x}")
|
|
# extract pushed args (last 3 PUSH imm32 before the call)
|
|
ctx = exe[max(0x400, cfo-128):cfo]
|
|
pushes = []
|
|
for pm in re.finditer(b'\x68....', ctx, re.DOTALL):
|
|
val = struct.unpack_from('<I', ctx, pm.start()+1)[0]
|
|
if 0x400000 <= val <= 0x500000:
|
|
fo = rva2fo(val - 0x400000)
|
|
if fo and fo < len(exe):
|
|
try:
|
|
s = exe[fo : exe.index(b'\x00', fo)].decode('latin1', errors='replace')[:30]
|
|
pushes.append(repr(s))
|
|
except:
|
|
pushes.append(hex(val))
|
|
else:
|
|
pushes.append(hex(val))
|
|
print(f" string args: {pushes}")
|
|
else:
|
|
print(" GetPrivateProfileStringA not found in resolved imports!")
|
|
print(" Available funcs with 'Profile':", [k for k in all_funcs if 'Profile' in k])
|
|
|
|
# Also show all resolved file I/O functions
|
|
print("\n=== File I/O related imports ===")
|
|
io_keywords = ['File', 'Open', 'Read', 'Write', 'Create', 'Handle', 'lopen', 'Profile', 'Module']
|
|
for name, va in sorted(all_funcs.items()):
|
|
if any(kw.lower() in name.lower() for kw in io_keywords):
|
|
print(f" {name:<45} IAT VA: 0x{va:08x}")
|