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
120 lines
5.4 KiB
Python
120 lines
5.4 KiB
Python
import re, struct, sys
|
|
|
|
exe = open("HAVOC_NOCD.EXE", "rb").read()
|
|
|
|
# ── 1. All NUL-terminated strings in rdata that look like paths/filenames ──
|
|
print("=== Path/filename strings in rdata (0x067000+) ===")
|
|
rdata = exe[0x067000:]
|
|
base = 0x067000
|
|
seen = set()
|
|
for m in re.finditer(rb'(?:[\x21-\x7e]{3,60})(?=\x00)', rdata):
|
|
s = m.group().decode('latin1')
|
|
fo = base + m.start()
|
|
if s in seen:
|
|
continue
|
|
# keep only things that look like filenames or paths
|
|
has_dot = '.' in s
|
|
has_bs = chr(0x5c) in s # backslash
|
|
has_colon = ':' in s
|
|
if not (has_dot or has_bs or has_colon):
|
|
continue
|
|
# skip long prose strings
|
|
if len(s) > 50:
|
|
continue
|
|
seen.add(s)
|
|
print(f" 0x{fo:06x}: {s!r}")
|
|
|
|
# ── 2. Find the IAT VA for every imported function ──
|
|
# Scan the import descriptor table to map function name -> IAT file offset
|
|
print("\n=== Imports (name -> IAT file offset -> VA) ===")
|
|
iat_map = {} # name -> iat_va
|
|
|
|
# The IAT in HAVOC.EXE is around 0x06ec00 based on prior analysis.
|
|
# Scan 0x06ec00..0x06fd00 for hint+name entries and build a name->IAT map.
|
|
# Actually easier: find all FF 15 (CALL [mem]) targets in .text, then resolve names.
|
|
call_indirect = {} # iat_va -> [call_fo, ...]
|
|
for m in re.finditer(b'\xff\x15', exe[0x400:0x067000]):
|
|
call_fo = 0x400 + m.start()
|
|
iat_va = struct.unpack_from('<I', exe, call_fo + 2)[0]
|
|
call_indirect.setdefault(iat_va, []).append(call_fo)
|
|
|
|
# For each IAT VA, try to resolve the function name via the image import dir.
|
|
# image_base=0x400000. IAT file offset = iat_va - 0x400000 - 0x07b800 + 0x06ac00 (approx)
|
|
# Easier: just scan the file around the known IAT area for function names.
|
|
# Known: function names are NUL-terminated, preceded by a 2-byte hint in the INT.
|
|
# We'll just scan the rdata for known API names and match to nearby IAT entries.
|
|
|
|
apis_of_interest = [
|
|
b'MessageBoxA', b'CreateFileA', b'OpenFile', b'FindFirstFileA',
|
|
b'GetVolumeInformationA', b'GetPrivateProfileStringA',
|
|
b'WritePrivateProfileStringA', b'RegOpenKeyExA', b'RegQueryValueExA',
|
|
b'SetCurrentDirectoryA', b'GetCurrentDirectoryA', b'GetModuleFileNameA',
|
|
b'DirectDrawCreate', b'GetFileAttributesA',
|
|
]
|
|
resolved = {} # name -> iat_va
|
|
for api in apis_of_interest:
|
|
idx = exe.find(api)
|
|
while idx != -1:
|
|
# the hint is 2 bytes before the name; the IAT entry points here via the INT
|
|
# Find which IAT entry references this (INT entry = &hint+name)
|
|
hint_fo = idx - 2
|
|
int_rva = 0x400000 + hint_fo - 0x000400 + 0x001000 # VA of hint record
|
|
needle = struct.pack('<I', int_rva)
|
|
# INT entries are in the IDT (idata section); find the IAT address
|
|
for m in re.finditer(re.escape(needle), exe[0x06ac00:0x06fc00]):
|
|
int_fo_in_idata = 0x06ac00 + m.start()
|
|
# The IAT entry is at the same index in the IAT array.
|
|
# Distance from IDT import entry start tells us which DLL/ordinal.
|
|
# Shortcut: find CALL [mem] in .text where the target dword equals
|
|
# the runtime-filled IAT value. We can't do that statically, but
|
|
# we know the IAT RVA = INT RVA (before loading). So IAT entry at
|
|
# int_fo_in_idata contains the same pointer before load.
|
|
iat_va = 0x400000 + int_fo_in_idata - 0x000400 + 0x001000
|
|
name = api.rstrip(b'\x00').decode()
|
|
resolved[name] = (int_fo_in_idata, iat_va)
|
|
if iat_va in call_indirect:
|
|
sites = call_indirect[iat_va]
|
|
print(f" {name}: IAT 0x{iat_va:08x}, called from: {[hex(f) for f in sites[:6]]}")
|
|
idx = exe.find(api, idx + 1)
|
|
|
|
# ── 3. Trace MessageBoxA call sites ──
|
|
print("\n=== MessageBoxA call sites + pushed string VAs ===")
|
|
if 'MessageBoxA' in resolved:
|
|
_, mbox_iat_va = resolved['MessageBoxA']
|
|
needle = b'\xff\x15' + struct.pack('<I', mbox_iat_va)
|
|
for m in re.finditer(re.escape(needle), exe[0x400:0x067000]):
|
|
call_fo = 0x400 + m.start()
|
|
# look backward up to 64 bytes for PUSH imm32 (68 xx xx xx xx)
|
|
ctx = exe[max(0x400, call_fo-64):call_fo+8]
|
|
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')
|
|
pushes.append(repr(s[:40]))
|
|
except:
|
|
pass
|
|
print(f" CALL @ 0x{call_fo:06x} pushed strings: {pushes}")
|
|
|
|
# ── 4. Find STRDATA error display: look for references to string-index 6 ──
|
|
# The game likely calls a helper like ShowError(int index) or
|
|
# uses STRDATA string pointers. Find code that pushes small integers (0-10)
|
|
# before calling a common sub_ function.
|
|
print("\n=== Functions called with small int arg (error index?) ===")
|
|
# Find all PUSH 0x06 (error string 6) = 6a 06, followed within 20 bytes by a CALL
|
|
for m in re.finditer(b'\x6a\x06', exe[0x400:0x067000]):
|
|
fo = 0x400 + m.start()
|
|
ctx = exe[fo:fo+30]
|
|
if b'\xe8' in ctx[2:22]: # there's a CALL nearby
|
|
print(f" PUSH 6; near 0x{fo:06x}: {ctx.hex(' ')}")
|
|
|
|
print("\n=== All remaining C:\\ byte sequences ===")
|
|
c_colon_bs = bytes([0x43, 0x3a, 0x5c])
|
|
for m in re.finditer(re.escape(c_colon_bs), exe):
|
|
fo = m.start()
|
|
ctx = exe[fo:fo+30]
|
|
print(f" 0x{fo:06x}: {ctx!r}")
|
|
|
|
print("\nDone.")
|