havoc-remaster/tools/ini_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

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