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

67 lines
2.9 KiB
Python

import struct
exe = open("HAVOC_NOCD.EXE", "rb").read()
# Read PE header
pe_off = struct.unpack_from('<I', exe, 0x3c)[0]
print(f"PE offset: 0x{pe_off:04x}")
print(f"Machine: 0x{struct.unpack_from('<H', exe, pe_off+4)[0]:04x}")
# Optional header
opt_off = pe_off + 24
image_base = struct.unpack_from('<I', exe, opt_off + 28)[0]
print(f"ImageBase: 0x{image_base:08x}")
# Number of sections
num_sections = struct.unpack_from('<H', exe, pe_off + 6)[0]
size_of_opt = struct.unpack_from('<H', exe, pe_off + 20)[0]
sections_off = pe_off + 24 + size_of_opt
print(f"\n{'Section':<12} {'VirtAddr':>10} {'VirtSize':>10} {'RawOff':>10} {'RawSize':>10}")
sections = []
for i in range(num_sections):
s = sections_off + i * 40
name = exe[s:s+8].rstrip(b'\x00').decode('latin1', errors='replace')
virt_rva = struct.unpack_from('<I', exe, s+12)[0]
virt_size = struct.unpack_from('<I', exe, s+8)[0]
raw_off = struct.unpack_from('<I', exe, s+20)[0]
raw_size = struct.unpack_from('<I', exe, s+16)[0]
sections.append((name, virt_rva, virt_size, raw_off, raw_size))
print(f" {name:<10} 0x{virt_rva:08x} 0x{virt_size:08x} 0x{raw_off:08x} 0x{raw_size:08x}")
# Find which section GetPrivateProfileStringA name (at 0x06f1c8) falls in
name_fo = 0x06f1c8
print(f"\nGetPrivateProfileStringA name at file offset 0x{name_fo:06x}")
for (sname, vrva, vsz, raw, rsz) in sections:
if raw <= name_fo < raw + rsz:
rva = vrva + (name_fo - raw)
va = image_base + rva
print(f" In section [{sname}]: RVA=0x{rva:08x} VA=0x{va:08x}")
hint_fo = name_fo - 2
hint_rva = vrva + (hint_fo - raw)
hint_va = image_base + hint_rva
print(f" Hint+name VA: 0x{hint_va:08x}")
# Search for this VA in the IAT/INT (which lives in idata too)
import re
needle = struct.pack('<I', hint_va)
print(f"\n Searching for INT entry 0x{hint_va:08x}...")
for m in re.finditer(re.escape(needle), exe):
ref_fo = m.start()
ref_in_sec = [(sn, vr, vs, ro, rs) for (sn, vr, vs, ro, rs) in sections if ro <= ref_fo < ro + rs]
sec_label = ref_in_sec[0][0] if ref_in_sec else "?"
print(f" Found at file offset 0x{ref_fo:06x} in [{sec_label}]")
# This is the INT entry. The IAT entry is at the same index in the IAT array.
iat_fo = ref_fo
iat_rva = None
for (sn, vr, vs, ro, rs) in sections:
if ro <= iat_fo < ro + rs:
iat_rva = vr + (iat_fo - ro)
if iat_rva:
iat_va = image_base + iat_rva
print(f" IAT entry VA (pre-load): 0x{iat_va:08x}")
# 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()
print(f" CALL @ 0x{cfo:06x}")