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

116 lines
4.6 KiB
Python

"""Final analysis: trace paths around error gate, find D:\ usage, plan bypass patches."""
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]
def fo_to_va(fo):
num_secs = struct.unpack_from('<H', exe, pe_off+6)[0]
opt_sz = struct.unpack_from('<H', exe, pe_off+20)[0]
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]
if roff and roff <= fo < roff+rsz:
return img_base + vrva + (fo - roff)
return None
def call_target(call_fo):
rel = struct.unpack_from('<i', exe, call_fo+1)[0]
next_va = fo_to_va(call_fo + 5)
if next_va is None:
return None, None
target_va = next_va + rel
# Find file offset from VA
num_secs = struct.unpack_from('<H', exe, pe_off+6)[0]
opt_sz = struct.unpack_from('<H', exe, pe_off+20)[0]
rva = target_va - img_base
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]
if roff and vrva <= rva < vrva+rsz:
return roff + (rva - vrva), target_va
return None, target_va
# Dump exact bytes from file at key offsets
def hexdump(fo, n, label=""):
print(f"\n{label} [0x{fo:06x}..0x{fo+n-1:06x}]:")
for row in range(0, n, 16):
fo2 = fo + row
h = ' '.join(f'{b:02x}' for b in exe[fo2:fo2+16])
a = ''.join(chr(b) if 0x20<=b<0x7f else '.' for b in exe[fo2:fo2+16])
print(f" {fo2:06x}: {h:<48} {a}")
# Dump raw bytes around 0x020270
hexdump(0x020270, 80, "Error gate region")
# Decode JNZ manually
jnz_fo = 0x020277
jnz_imm = exe[jnz_fo + 1]
next_instr_fo = jnz_fo + 2
# JNZ target = next_instr_va + signed(imm)
next_va = fo_to_va(next_instr_fo)
target_va = next_va + (jnz_imm if jnz_imm < 128 else jnz_imm - 256)
print(f"\nJNZ at 0x{jnz_fo:06x}: imm={jnz_imm} (0x{jnz_imm:02x})")
print(f" Next instr fo=0x{next_instr_fo:06x} VA=0x{next_va:08x}")
print(f" Target VA=0x{target_va:08x} (+/- {jnz_imm if jnz_imm < 128 else jnz_imm-256})")
# Convert target VA to file offset
tfo, _ = call_target(jnz_fo - 4) # hacky: find target fo from VA
# Manual: target_fo = text_raw + (target_va - img_base - text_vRVA)
text_roff = 0x000400
text_vrva = 0x001000
tfo = text_roff + (target_va - img_base - text_vrva)
print(f" Target fo=0x{tfo:06x}")
hexdump(tfo, 24, "JNZ target")
# Also decode JZ at 0x02026A
jz_fo = 0x02026A
jz_imm = exe[jz_fo + 1]
jz_next_va = fo_to_va(jz_fo + 2)
jz_target_va = jz_next_va + (jz_imm if jz_imm < 128 else jz_imm - 256)
jz_tfo = text_roff + (jz_target_va - img_base - text_vrva)
print(f"\nJZ at 0x{jz_fo:06x}: imm={jz_imm} (0x{jz_imm:02x})")
print(f" Target VA=0x{jz_target_va:08x} fo=0x{jz_tfo:06x}")
hexdump(jz_tfo, 24, "JZ target")
# D:\ usage context
hexdump(0x029a90, 0x80, "D:\\ usage at 0x029ab0")
# Now let's check what HAVOC.INI read code does with drive letter
# Search for code that reads from HAVOC.INI section "SETUP" key "DRIVE"
setup_va = fo_to_va(0x06b2c4) # "SETUP"
drive_va = fo_to_va(0x06b2cc) # "DRIVE" (check actual offsets)
print(f"\n=== Looking for SETUP/DRIVE string VAs ===")
# Find "SETUP" and "DRIVE" strings
setup_off = exe.find(b'SETUP\x00', 0x069400)
drive_off = exe.find(b'DRIVE\x00', 0x069400)
havocini_off = exe.find(b'HAVOC.INI\x00', 0x069400)
print(f" SETUP at 0x{setup_off:06x}, VA=0x{fo_to_va(setup_off) or 0:08x}")
print(f" DRIVE at 0x{drive_off:06x}, VA=0x{fo_to_va(drive_off) or 0:08x}")
print(f" HAVOC.INI at 0x{havocini_off:06x}, VA=0x{fo_to_va(havocini_off) or 0:08x}")
# Find code refs to DRIVE key VA
if drive_off:
dva = fo_to_va(drive_off)
needle = struct.pack('<I', dva)
refs = [0x400 + m.start() for m in re.finditer(re.escape(needle), exe[0x400:0x067000])]
print(f" DRIVE refs={len(refs)}")
for r in refs:
ctx = exe[max(0,r-8):r+8]
print(f" 0x{r:06x}: {ctx.hex(' ')}")
# Find code refs to HAVOC.INI VA
if havocini_off:
hva = fo_to_va(havocini_off)
needle = struct.pack('<I', hva)
refs = [0x400 + m.start() for m in re.finditer(re.escape(needle), exe[0x400:0x067000])]
print(f" HAVOC.INI refs={len(refs)}")
for r in refs:
ctx = exe[max(0,r-8):r+12]
print(f" 0x{r:06x}: {ctx.hex(' ')}")