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

88 lines
2.9 KiB
Python

import struct, re
exe = open("HAVOC_NOCD.EXE", "rb").read()
base = 0x00400000
sections = [
(".text", 0x00001000, 0x00066bbd, 0x00000400, 0x00066c00),
(".rdata", 0x00079000, 0x000023c0, 0x00067000, 0x00002400),
(".data", 0x0007c000, 0x00005650, 0x00069400, 0x00005800),
(".idata", 0x00082000, 0x00000d5a, 0x0006ec00, 0x00000e00),
]
def rva2fo(rva):
for (n, vrva, vsz, raw, rsz) in sections:
if vrva <= rva < vrva + vsz:
return raw + (rva - vrva)
return None
def va2str(va):
fo = rva2fo(va - base)
if fo is None or fo >= len(exe):
return None
try:
end = exe.index(b'\x00', fo)
return exe[fo:end].decode('latin1', errors='replace')
except:
return None
def find_call_sites(iat_va):
needle = b'\xff\x15' + struct.pack('<I', iat_va)
sites = []
for m in re.finditer(re.escape(needle), exe[0x400:0x067000]):
sites.append(0x400 + m.start())
return sites
# IAT slot VAs from parse_iat.py output
iat = {
'CreateFileA': 0x00482390,
'GetModuleFileNameA': 0x00482364,
'GetPrivateProfileIntA':0x004823b4,
'ReadFile': 0x00482398,
'CreateFileMappingA': 0x004823bc,
'MapViewOfFile': 0x004823b8,
'DeleteFileA': 0x00482388,
'WriteFile': 0x004823a0,
'WritePrivateProfileStringA': 0x0048238c,
}
def extract_string_pushes(call_fo, lookback=160):
ctx_start = max(0x400, call_fo - lookback)
ctx = exe[ctx_start:call_fo]
pushes = []
pos = 0
while pos < len(ctx):
b = ctx[pos]
if b == 0x68 and pos + 5 <= len(ctx):
val = struct.unpack_from('<I', ctx, pos+1)[0]
s = va2str(val)
if s is not None and len(s) >= 2:
pushes.append((ctx_start + pos, s))
pos += 5
else:
pos += 1
return pushes
print("=== CreateFileA call sites ===")
for cfo in find_call_sites(iat['CreateFileA']):
pushes = extract_string_pushes(cfo)
print(f" CALL @ 0x{cfo:06x} strings: {[s for _, s in pushes]}")
print("\n=== DeleteFileA call sites ===")
for cfo in find_call_sites(iat['DeleteFileA']):
pushes = extract_string_pushes(cfo)
print(f" CALL @ 0x{cfo:06x} strings: {[s for _, s in pushes]}")
print("\n=== GetModuleFileNameA call sites ===")
for cfo in find_call_sites(iat['GetModuleFileNameA']):
ctx = exe[cfo:cfo+80]
print(f" CALL @ 0x{cfo:06x}: {ctx.hex(' ')}")
print("\n=== GetPrivateProfileIntA call sites ===")
for cfo in find_call_sites(iat['GetPrivateProfileIntA']):
pushes = extract_string_pushes(cfo)
print(f" CALL @ 0x{cfo:06x} strings: {[s for _, s in pushes]}")
print("\n=== WritePrivateProfileStringA call sites ===")
for cfo in find_call_sites(iat['WritePrivateProfileStringA']):
pushes = extract_string_pushes(cfo)
print(f" CALL @ 0x{cfo:06x} strings: {[s for _, s in pushes]}")