havoc-remaster/tools/full_audit.py
pyr0ball 27174a1c79 feat: NOCD patch boots Havoc on modern Windows (defeats CD copy protection)
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
2026-07-03 09:29:59 -07:00

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.")