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

109 lines
4.1 KiB
Python

import re, struct
exe = open("HAVOC_NOCD.EXE", "rb").read()
pe_off = struct.unpack_from('<I', exe, 0x3c)[0]
img_base = struct.unpack_from('<I', exe, pe_off + 52)[0]
num_secs = struct.unpack_from('<H', exe, pe_off+6)[0]
opt_sz = struct.unpack_from('<H', exe, pe_off+20)[0]
secs = []
for i in range(num_secs):
s = pe_off + 24 + opt_sz + i*40
name = exe[s:s+8].rstrip(b'\x00').decode()
vsz = struct.unpack_from('<I', exe, s+8)[0]
vrva = struct.unpack_from('<I', exe, s+12)[0]
rsz = struct.unpack_from('<I', exe, s+16)[0]
roff = struct.unpack_from('<I', exe, s+20)[0]
secs.append((name, roff, rsz, vrva, vsz))
def fo_to_va(fo):
for n, ro, rs, vr, vs in secs:
if ro and ro <= fo < ro+rs:
return img_base + vr + (fo - ro)
return None
LABELS = {
fo_to_va(0x06b27c): 'MUSIC.FF',
fo_to_va(0x06b288): 'INTRFACE.FF',
fo_to_va(0x06b2b8): 'WORLDS',
fo_to_va(0x06b3b8): 'BIGFILE.DAT',
fo_to_va(0x06bee8): 'STRDATA.DAT',
}
def dump(start_fo, end_fo, title):
print(f"\n=== {title} ===")
row = start_fo & ~0xF
while row < end_fo:
h = ' '.join(f'{b:02x}' for b in exe[row:row+16])
a = ''.join(chr(b) if 0x20<=b<0x7f else '.' for b in exe[row:row+16])
ann = ''
for off in range(13):
if row+off+4 <= row+16:
dw = struct.unpack_from('<I', exe, row+off)[0]
if dw in LABELS:
ann = f' <-{LABELS[dw]}'
break
print(f" {row:06x}: {h:<48} {a}{ann}")
row += 16
# Show the file-loading init region
dump(0x029550, 0x029600, "0x029550-0x029600 (INTRFACE.FF area)")
dump(0x0295c0, 0x029620, "0x0295c0-0x029620 (MUSIC.FF area)")
dump(0x029870, 0x0298c0, "0x029870-0x0298c0 (WORLDS area)")
dump(0x029900, 0x029960, "0x029900-0x029960 (after WORLDS)")
dump(0x033de0, 0x033e60, "0x033de0-0x033e60 (flag-setter context)")
# Find the CALL target at 0x033df7
call_site = 0x033df7
rel32 = struct.unpack_from('<i', exe, call_site+1)[0]
target = call_site + 5 + rel32
print(f"\nCALL at 0x{call_site:06x}: target = 0x{target:06x}")
# section boundary check
for n, ro, rs, vr, vs in secs:
if ro and ro <= target < ro+rs:
print(f" In section {n} (raw 0x{ro:06x})")
break
# Dump start of that target function
dump(target, target+0x80, f"Target fn at 0x{target:06x}")
# Now find MessageBoxA IAT using correct section math
print("\n=== Finding MessageBoxA IAT ===")
mbox_name_off = exe.find(b'MessageBoxA\x00')
print(f" 'MessageBoxA' string at 0x{mbox_name_off:06x}")
# Search .idata for a thunk pointing near that name
idata_start = 0x06ec00
idata_end = 0x06fa00
for fo in range(idata_start, idata_end, 4):
val = struct.unpack_from('<I', exe, fo)[0]
if val == 0:
continue
# Try to dereference as a VA pointing to a hint+name
rva = val - img_base
candidate_fo = None
for n, ro, rs, vr, vs in secs:
if ro and vr <= rva < vr+rs:
candidate_fo = ro + (rva - vr)
break
if candidate_fo and 0 < candidate_fo < len(exe) - 20:
try:
name_start = candidate_fo + 2
end = exe.index(b'\x00', name_start)
nm = exe[name_start:end].decode('latin1', errors='replace')
if 'MessageBox' in nm:
iat_va = fo_to_va(fo)
print(f" IAT entry at fo=0x{fo:06x} VA=0x{iat_va:08x} fn='{nm}'")
# Find all call sites: ff 15 <le32 iat_va>
needle = b'\xff\x15' + struct.pack('<I', iat_va)
for m in re.finditer(re.escape(needle), exe[0x400:0x067000]):
cfo = 0x400 + m.start()
ctx = exe[max(0x400,cfo-20):cfo+12]
print(f" call site 0x{cfo:06x}: {ctx.hex(' ')}")
except Exception:
pass
# Also search for error-string "corrupt" via STRDATA.DAT open in code
print("\n=== STRDATA.DAT open call at 0x038c8e ===")
dump(0x038c6e, 0x038cd0, "STRDATA.DAT open region")
# Look at what function contains 0x020270 (flag check before error)
dump(0x020200, 0x020300, "0x020200-0x020300 (error gate function)")