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
80 lines
3 KiB
Python
80 lines
3 KiB
Python
import struct, sys, io
|
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
|
|
|
data = open('HAVOC_NOCD.EXE','rb').read()
|
|
FO2VA = lambda fo: fo + 0x400C00
|
|
VA2FO = lambda va: va - 0x400C00
|
|
|
|
# Parse PE import table to find GetPrivateProfileStringA IAT entry
|
|
# PE header
|
|
e_lfanew = struct.unpack_from('<I', data, 0x3c)[0]
|
|
pe_off = e_lfanew
|
|
sig = data[pe_off:pe_off+4]
|
|
print("PE sig: %s" % sig)
|
|
|
|
# COFF header
|
|
machine = struct.unpack_from('<H', data, pe_off+4)[0]
|
|
num_sections = struct.unpack_from('<H', data, pe_off+6)[0]
|
|
opt_hdr_size = struct.unpack_from('<H', data, pe_off+20)[0]
|
|
|
|
# Optional header
|
|
opt_off = pe_off + 24
|
|
imagebase = struct.unpack_from('<I', data, opt_off+28)[0]
|
|
print("ImageBase: 0x%08x" % imagebase)
|
|
|
|
# Data directories
|
|
dd_off = opt_off + 96 # offset to data directories in optional header
|
|
import_rva = struct.unpack_from('<I', data, dd_off + 8)[0]
|
|
import_size = struct.unpack_from('<I', data, dd_off + 12)[0]
|
|
print("Import table RVA: 0x%08x, size: 0x%x" % (import_rva, import_size))
|
|
|
|
# Convert import RVA to file offset
|
|
import_fo = import_rva # RVA = FO + 0x400 - 0x1000? Let's try VA2FO
|
|
import_fo_calc = VA2FO(imagebase + import_rva)
|
|
print("Import table FO (calc): 0x%05x" % import_fo_calc)
|
|
|
|
# Parse import descriptors
|
|
fo = import_fo_calc
|
|
while fo < len(data) - 20:
|
|
orig_first_thunk = struct.unpack_from('<I', data, fo)[0]
|
|
timedatestamp = struct.unpack_from('<I', data, fo+4)[0]
|
|
forwarder_chain = struct.unpack_from('<I', data, fo+8)[0]
|
|
name_rva = struct.unpack_from('<I', data, fo+12)[0]
|
|
first_thunk = struct.unpack_from('<I', data, fo+16)[0]
|
|
if orig_first_thunk == 0 and name_rva == 0:
|
|
break
|
|
name_fo = VA2FO(imagebase + name_rva)
|
|
dll_name = b''
|
|
nf = name_fo
|
|
while nf < len(data) and data[nf] != 0:
|
|
dll_name += bytes([data[nf]])
|
|
nf += 1
|
|
print("\nDLL: %s (FirstThunk RVA=0x%08x, FO=0x%05x)" %
|
|
(dll_name.decode('ascii','replace'), first_thunk, VA2FO(imagebase + first_thunk)))
|
|
|
|
# Parse thunks to find GetPrivateProfile*
|
|
thunk_fo = VA2FO(imagebase + first_thunk)
|
|
thunk_idx = 0
|
|
while thunk_fo < len(data) - 4:
|
|
thunk_val = struct.unpack_from('<I', data, thunk_fo)[0]
|
|
if thunk_val == 0:
|
|
break
|
|
# Check if it's an ordinal import
|
|
if thunk_val & 0x80000000:
|
|
pass
|
|
else:
|
|
hint_fo = VA2FO(imagebase + thunk_val)
|
|
if 0 <= hint_fo < len(data) - 2:
|
|
fn_name_fo = hint_fo + 2 # skip hint word
|
|
fn_name = b''
|
|
fn = fn_name_fo
|
|
while fn < len(data) and data[fn] != 0:
|
|
fn_name += bytes([data[fn]])
|
|
fn += 1
|
|
fn_str = fn_name.decode('ascii','replace')
|
|
iat_va = imagebase + first_thunk + thunk_idx * 4
|
|
if 'Private' in fn_str or 'File' in fn_str or 'Find' in fn_str:
|
|
print(" [0x%08x] -> %s" % (iat_va, fn_str))
|
|
thunk_fo += 4
|
|
thunk_idx += 1
|
|
fo += 20
|