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
110 lines
4.8 KiB
Python
110 lines
4.8 KiB
Python
import re, struct
|
|
|
|
exe = open("HAVOC_NOCD.EXE", "rb").read()
|
|
|
|
# ── Step 1: Find GetPrivateProfileStringA in the import name table ──
|
|
# Import names are stored as: hint(2 bytes) + NUL-terminated name
|
|
# Scan ALL occurrences of this name in the file
|
|
gpps_positions = []
|
|
for m in re.finditer(b'GetPrivateProfileStringA\x00', exe):
|
|
gpps_positions.append(m.start())
|
|
print(f"GetPrivateProfileStringA name at file offset 0x{m.start():06x}")
|
|
|
|
if not gpps_positions:
|
|
print("NOT FOUND in EXE")
|
|
import sys; sys.exit(1)
|
|
|
|
# The IAT entry for this function is at a dword that originally holds the INT pointer.
|
|
# Find that dword by scanning for the VA of each occurrence as an INT entry.
|
|
# Then find CALL [mem] (FF 15 xx xx xx xx) instructions referencing the IAT slot.
|
|
|
|
print("\n=== Scanning for CALL GetPrivateProfileStringA ===")
|
|
call_sites = []
|
|
for name_fo in gpps_positions:
|
|
# The INT entry value = VA of hint+name = image_base + RVA of (name_fo - 2)
|
|
hint_fo = name_fo - 2
|
|
# This is in a different section (idata), compute its VA
|
|
# idata is usually mapped near rdata; let's scan for its VA as a dword anywhere
|
|
# Actually: try both rdata and idata VA mappings
|
|
for section_offset, section_rva in [(0x000400, 0x001000), (0x06ac00, 0x07bc00)]:
|
|
rva_of_hint = (hint_fo - section_offset + section_rva)
|
|
va_of_hint = 0x400000 + rva_of_hint
|
|
needle = struct.pack('<I', va_of_hint)
|
|
# Find this VA in the IAT (it's in an IAT array somewhere before loading)
|
|
for m in re.finditer(re.escape(needle), exe[0x06ac00:0x06fc00]):
|
|
iat_fo = 0x06ac00 + m.start()
|
|
iat_rva = iat_fo - 0x000400 + 0x001000
|
|
iat_va = 0x400000 + iat_rva
|
|
# Find CALL [iat_va] in .text
|
|
call_needle = b'\xff\x15' + struct.pack('<I', iat_va)
|
|
for cm in re.finditer(re.escape(call_needle), exe[0x400:0x067000]):
|
|
cfo = 0x400 + cm.start()
|
|
if cfo not in call_sites:
|
|
call_sites.append(cfo)
|
|
print(f" CALL @ 0x{cfo:06x} (via IAT @ 0x{iat_fo:06x})")
|
|
|
|
if not call_sites:
|
|
print(" None found via IAT scan -- trying direct search")
|
|
# Fallback: search for CALL DWORD PTR (FF 15) followed by any dword in idata range
|
|
# and check if it resolves to GetPrivateProfileStringA via hint table
|
|
for m in re.finditer(b'\xff\x15', exe[0x400:0x067000]):
|
|
cfo = 0x400 + m.start()
|
|
iat_va = struct.unpack_from('<I', exe, cfo+2)[0]
|
|
iat_fo = iat_va - 0x400000 - 0x001000 + 0x000400
|
|
if 0x06ac00 <= iat_fo < 0x06fc00:
|
|
# read the dword at iat_fo -- pre-load it's the INT pointer (hint+name VA)
|
|
int_ptr = struct.unpack_from('<I', exe, iat_fo)[0]
|
|
hint_name_fo = int_ptr - 0x400000 - 0x001000 + 0x000400
|
|
if 0 < hint_name_fo < len(exe) - 30:
|
|
try:
|
|
name = exe[hint_name_fo+2 : exe.index(b'\x00', hint_name_fo+2)].decode('latin1')
|
|
if name == 'GetPrivateProfileStringA':
|
|
call_sites.append(cfo)
|
|
print(f" CALL @ 0x{cfo:06x}")
|
|
except:
|
|
pass
|
|
|
|
# ── Step 2: For each call site, walk backward to find pushed args ──
|
|
# GetPrivateProfileStringA(lpAppName, lpKeyName, lpDefault, lpReturnedString, nSize, lpFileName)
|
|
# That's 6 args. In stdcall, they're pushed right-to-left.
|
|
# Push order: lpFileName, nSize, lpReturnedString, lpDefault, lpKeyName, lpAppName
|
|
|
|
def read_string(exe, va):
|
|
fo = va - 0x400000 - 0x001000 + 0x000400
|
|
if fo < 0 or fo >= len(exe):
|
|
return f"<VA 0x{va:08x}>"
|
|
try:
|
|
end = exe.index(b'\x00', fo)
|
|
return exe[fo:end].decode('latin1', errors='replace')
|
|
except:
|
|
return f"<err@0x{fo:06x}>"
|
|
|
|
print("\n=== GetPrivateProfileStringA parameters at each call site ===")
|
|
for cfo in sorted(call_sites):
|
|
# Scan backward up to 128 bytes for PUSH imm32 (0x68) instructions
|
|
ctx_start = max(0x400, cfo - 128)
|
|
ctx = exe[ctx_start:cfo]
|
|
pushes = [] # list of (offset_in_ctx, value)
|
|
pos = 0
|
|
while pos < len(ctx):
|
|
b = ctx[pos]
|
|
if b == 0x68: # PUSH imm32
|
|
if pos + 5 <= len(ctx):
|
|
val = struct.unpack_from('<I', ctx, pos+1)[0]
|
|
pushes.append((ctx_start + pos, val))
|
|
pos += 5
|
|
elif b == 0x6a: # PUSH imm8
|
|
pushes += [] # ignore small int pushes for now
|
|
pos += 2
|
|
else:
|
|
pos += 1
|
|
# Last 3-4 imm32 pushes before the call are likely lpDefault, lpKeyName, lpAppName
|
|
# (lpFileName is typically pushed early, nSize is a small constant)
|
|
imm_pushes = [v for _, v in pushes[-6:]]
|
|
args = []
|
|
for v in imm_pushes:
|
|
if 0x400000 <= v <= 0x500000:
|
|
args.append(repr(read_string(exe, v)))
|
|
else:
|
|
args.append(hex(v))
|
|
print(f" 0x{cfo:06x}: {' | '.join(args)}")
|