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
103 lines
4.4 KiB
Python
103 lines
4.4 KiB
Python
import re, struct, os
|
|
|
|
exe = open("HAVOC_NOCD.EXE", "rb").read()
|
|
|
|
# ── 1. Verify raw bytes at known path locations ──
|
|
print("=== Raw bytes at path string locations ===")
|
|
spots = [
|
|
(0x06b27c, 16, "MUSIC.FF area"),
|
|
(0x06b288, 16, "INTRFACE.FF area"),
|
|
(0x06b2b0, 24, "WORLDS\\ area"),
|
|
(0x06b390, 30, "C:\\WORLDS\\LAND0101 area"),
|
|
(0x06b3a8, 20, "HAVOCDAT area"),
|
|
(0x06b3b8, 16, "BIGFILE area"),
|
|
(0x06b978, 24, "GAMIMAGE area"),
|
|
]
|
|
for fo, length, label in spots:
|
|
raw = exe[fo:fo+length]
|
|
asc = ''.join(chr(b) if 0x20 <= b < 0x7f else '.' for b in raw)
|
|
print(f" 0x{fo:06x} {label}: {raw.hex(' ')}")
|
|
print(f" ascii: {asc!r}")
|
|
|
|
# ── 2. Check which expected files exist ──
|
|
print("\n=== File existence check in game dir ===")
|
|
game_files = [
|
|
"BIGFILE.DAT", "MUSIC.FF", "INTRFACE.FF", "OBJECTS.FF", "SOUND.FF",
|
|
"STRDATA.DAT", "HAVOC.INI", "GAMIMAGE.DAT", "KEYSET.DAT",
|
|
"WORLDS/LAND0101.DAT", "WORLDS/GRAFIX00.DAT",
|
|
]
|
|
for f in game_files:
|
|
exists = os.path.exists(f)
|
|
print(f" {'OK' if exists else 'MISSING':8s} {f}")
|
|
|
|
# ── 3. Find SetCurrentDirectory / GetModuleFileName in imports ──
|
|
print("\n=== SetCurrentDirectory / GetModuleFileName call sites ===")
|
|
for api_bytes in [b'SetCurrentDirectoryA', b'GetModuleFileNameA', b'_chdir']:
|
|
idx = exe.find(api_bytes)
|
|
if idx == -1:
|
|
print(f" NOT IN EXE: {api_bytes.decode()}")
|
|
continue
|
|
print(f" {api_bytes.decode()} name at 0x{idx:06x}")
|
|
# find IAT VA for this
|
|
hint_fo = idx - 2
|
|
int_rva = hint_fo - 0x000400 + 0x001000
|
|
iat_needle = struct.pack('<I', 0x400000 + int_rva)
|
|
# search for CALL [mem] referencing this
|
|
for m in re.finditer(b'\xff\x15' + re.escape(iat_needle), exe[0x400:0x067000]):
|
|
call_fo = 0x400 + m.start()
|
|
print(f" CALL @ 0x{call_fo:06x}: {exe[max(0,call_fo-8):call_fo+12].hex(' ')}")
|
|
|
|
# ── 4. Find GAMIMAGE.DAT context and its callers ──
|
|
print("\n=== GAMIMAGE.DAT context ===")
|
|
idx = exe.find(b'GAMIMAGE.DAT')
|
|
if idx != -1:
|
|
region = exe[max(0, idx-64):idx+80]
|
|
for row in range(0, len(region), 16):
|
|
hex_part = ' '.join(f'{b:02x}' for b in region[row:row+16])
|
|
asc_part = ''.join(chr(b) if 0x20 <= b < 0x7f else '.' for b in region[row:row+16])
|
|
print(f" {max(0,idx-64)+row:06x}: {hex_part:<48} {asc_part}")
|
|
# find its VA and look for PUSH imm32 references in .text
|
|
img_va = 0x400000 + idx - 0x000400 + 0x001000
|
|
needle = struct.pack('<I', img_va)
|
|
for m in re.finditer(re.escape(needle), exe[0x400:0x067000]):
|
|
ref_fo = 0x400 + m.start()
|
|
print(f" ref in .text @ 0x{ref_fo:06x}: {exe[ref_fo-4:ref_fo+8].hex(' ')}")
|
|
|
|
# ── 5. Find the error-display function: search for code that opens STRDATA.DAT
|
|
# then calls MessageBox with a string index ──
|
|
print("\n=== STRDATA.DAT open call site ===")
|
|
strdata_va = 0x400000 + exe.find(b'STRDATA.DAT\x00') - 0x000400 + 0x001000
|
|
needle = struct.pack('<I', strdata_va)
|
|
print(f" STRDATA.DAT string VA: 0x{strdata_va:08x}")
|
|
for m in re.finditer(re.escape(needle), exe[0x400:0x067000]):
|
|
ref_fo = 0x400 + m.start()
|
|
ctx = exe[max(0x400, ref_fo-32):ref_fo+32]
|
|
print(f" ref @ 0x{ref_fo:06x}: {ctx.hex(' ')}")
|
|
|
|
# ── 6. Find all CreateFileA call sites and what string they push ──
|
|
print("\n=== CreateFileA / _lopen call sites (first 20) ──")
|
|
for api_bytes in [b'CreateFileA\x00', b'_lopen\x00', b'OpenFile\x00']:
|
|
idx = exe.find(api_bytes)
|
|
if idx == -1:
|
|
continue
|
|
hint_fo = idx - 2
|
|
int_rva = hint_fo - 0x000400 + 0x001000
|
|
iat_needle = struct.pack('<I', 0x400000 + int_rva)
|
|
sites = []
|
|
for m in re.finditer(b'\xff\x15' + re.escape(iat_needle), exe[0x400:0x067000]):
|
|
sites.append(0x400 + m.start())
|
|
print(f" {api_bytes.rstrip(b'\\x00').decode()}: {len(sites)} call sites")
|
|
for call_fo in sites[:6]:
|
|
# look backward for PUSH imm32 (string arg)
|
|
ctx = exe[max(0x400, call_fo-64):call_fo]
|
|
pushes = []
|
|
for pm in re.finditer(b'\x68', ctx):
|
|
va = struct.unpack_from('<I', ctx, pm.start()+1)[0]
|
|
fo = va - 0x400000 - 0x001000 + 0x000400
|
|
if 0x067000 <= fo < len(exe):
|
|
try:
|
|
s = exe[fo:exe.index(b'\x00', fo)].decode('latin1', errors='replace')[:30]
|
|
pushes.append(repr(s))
|
|
except:
|
|
pass
|
|
print(f" call @ 0x{call_fo:06x}: pushed={pushes}")
|