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

106 lines
4.1 KiB
Python

"""Find all code references to D:\ buffer and the file-open call chain."""
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
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((roff, rsz, vrva, vsz))
def fo_to_va(fo):
for ro, rs, vr, vs in secs:
if ro and ro <= fo < ro+rs:
return img_base + vr + (fo - ro)
return None
def va_to_fo(va):
rva = va - img_base
for ro, rs, vr, vs in secs:
if ro and vr <= rva < vr+rs:
return ro + (rva - vr)
return None
def search_refs(fo, label=""):
va = fo_to_va(fo)
if not va:
print(f" {label}: fo 0x{fo:06x} -> no VA")
return
needle = struct.pack('<I', va)
refs = [0x400 + m.start() for m in re.finditer(re.escape(needle), exe[0x400:0x067000])]
print(f"\n {label} fo=0x{fo:06x} VA=0x{va:08x} refs={len(refs)}")
for r in refs[:8]:
ctx = exe[max(0x400,r-8):r+8]
print(f" 0x{r:06x}: {ctx.hex(' ')}")
# D:\ buffer and neighboring strings
print("=== D:\\ and neighboring strings ===")
for fo in [0x06b2b0, 0x06b2b8, 0x06b2d4, 0x06b390, 0x06b3a8, 0x06b3b8, 0x06b2d0]:
s_bytes = exe[fo:fo+24]
s = s_bytes.rstrip(b'\x00').decode('latin1', errors='replace')
search_refs(fo, repr(s))
# What's around D:\ in .data?
print("\n=== Hex context around D:\\ (0x06b2c0-0x06b420) ===")
for row in range(0, 0x160, 16):
fo = 0x06b2c0 + row
h = ' '.join(f'{b:02x}' for b in exe[fo:fo+16])
a = ''.join(chr(b) if 0x20<=b<0x7f else '.' for b in exe[fo:fo+16])
print(f" {fo:06x}: {h:<48} {a}")
# Dump the init function at 0x009C60
print("\n=== Init function at 0x009C60 ===")
for row in range(0, 0xc0, 16):
fo = 0x009c60 + row
h = ' '.join(f'{b:02x}' for b in exe[fo:fo+16])
a = ''.join(chr(b) if 0x20<=b<0x7f else '.' for b in exe[fo:fo+16])
ann = ''
for off in range(13):
if fo+off+4 <= fo+16:
dw = struct.unpack_from('<I', exe, fo+off)[0]
if dw == fo_to_va(0x06b2d4): ann = ' <-D:\\'
elif dw == fo_to_va(0x06b27c): ann = ' <-MUSIC.FF'
elif dw == fo_to_va(0x06b2b8): ann = ' <-WORLDS\\'
elif dw == fo_to_va(0x06b3b8): ann = ' <-BIGFILE.DAT'
elif dw == fo_to_va(0x06bee8): ann = ' <-STRDATA.DAT'
# Also annotate CALL targets
if exe[fo] == 0xe8:
rel = struct.unpack_from('<i', exe, fo+1)[0]
tva = img_base + 0x001000 + (fo+5 - 0x000400) + rel
tfo = 0x000400 + (tva - img_base - 0x001000)
ann += f' CALL->0x{tfo:06x}'
print(f" {fo:06x}: {h:<48} {a}{ann}")
# Context around CALL at 0x020253 -> 0x009C60
print("\n=== Context before error check (0x020230-0x020285) ===")
for row in range(0, 0x60, 16):
fo = 0x020230 + row
h = ' '.join(f'{b:02x}' for b in exe[fo:fo+16])
a = ''.join(chr(b) if 0x20<=b<0x7f else '.' for b in exe[fo:fo+16])
ann = ''
if exe[fo] == 0xe8:
rel = struct.unpack_from('<i', exe, fo+1)[0]
tva = img_base + 0x001000 + (fo+5 - 0x000400) + rel
tfo = 0x000400 + (tva - img_base - 0x001000)
ann += f' CALL->0x{tfo:06x}'
print(f" {fo:06x}: {h:<48} {a}{ann}")
# Check the flag check, and suggest bypass bytes
print("\n=== Error gate at 0x020270 (current bytes) ===")
ctx = exe[0x020270:0x020282]
print(f" {ctx.hex(' ')}")
print(f" Byte at 0x020277: 0x{ctx[7]:02x} (should be 75=JNZ for unfixed, or eb=JMP for bypass)")
# Check if there's a HAVOC.INI read code that stores the drive to 0x06b2d4
drive_va = fo_to_va(0x06b2d4)
print(f"\n=== All writes to D:\\ buffer area VA=0x{drive_va:08x} ===")
# Look for MOV BYTE/DWORD PTR [0x47DED4] patterns
# Could be: a2, c6 05, a3, c7 05 ...
# Also look for LEA/MOV reg, addr then strcpy/memcpy