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

60 lines
2.3 KiB
Python

import struct, sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
data = open('HAVOC_NOCD.EXE','rb').read()
FO2VA = lambda fo: fo + 0x400C00
VA2FO_data = lambda va: va - 0x47C000 + 0x69400 # .data section
# Check the value at [0x480964]
fo = VA2FO_data(0x480964)
val = struct.unpack_from('<I', data, fo)[0]
print("[0x480964] at FO 0x%05x = 0x%08x" % (fo, val))
# Check surrounding .data at 0x480960
print("Data at 0x480960 area (FO 0x%05x):" % VA2FO_data(0x480960))
for i in range(0, 32, 4):
fo2 = VA2FO_data(0x480960 + i)
v = struct.unpack_from('<I', data, fo2)[0]
print(" [0x%x] at FO 0x%05x = 0x%08x" % (0x480960 + i, fo2, v))
# Also check what callers of 0x4633BE pass as the heap handle
print()
print("=== 0x4633BE wrapper (FO 0x627be): reads [0x480964] ===")
fo = 0x627be
chunk = data[fo:fo+16]
print("FO 0x%05x: %s" % (fo, ' '.join('%02x'%b for b in chunk)))
# Find callers of 0x4633BE
print()
print("=== All callers of 0x4633BE ===")
tgt_va = 0x4633BE
for fo in range(0, 0x67000):
if data[fo] == 0xe8 and fo+5 <= len(data):
rel = struct.unpack_from('<i', data, fo+1)[0]
call_tgt = (FO2VA(fo+5) + rel) & 0xFFFFFFFF
if call_tgt == tgt_va:
ctx = data[fo-8:fo+12]
print(" FO 0x%05x VA 0x%07x: ...%s..." % (fo, FO2VA(fo), ' '.join('%02x'%b for b in ctx)))
# Also search for what calls 0x4633D1 (the actual impl)
print()
print("=== All callers of 0x4633D1 (real alloc impl) ===")
tgt_va2 = 0x4633D1
for fo in range(0, 0x67000):
if data[fo] == 0xe8 and fo+5 <= len(data):
rel = struct.unpack_from('<i', data, fo+1)[0]
call_tgt = (FO2VA(fo+5) + rel) & 0xFFFFFFFF
if call_tgt == tgt_va2:
ctx = data[fo-8:fo+12]
print(" FO 0x%05x VA 0x%07x: ...%s..." % (fo, FO2VA(fo), ' '.join('%02x'%b for b in ctx)))
# Check what functions initialize the allocator - search for CALL 0x464E77 (used inside 0x4633C7)
print()
print("=== Callers of 0x464E77 (alloc helper) ===")
tgt_va3 = 0x464E77
for fo in range(0, 0x67000):
if data[fo] == 0xe8 and fo+5 <= len(data):
rel = struct.unpack_from('<i', data, fo+1)[0]
call_tgt = (FO2VA(fo+5) + rel) & 0xFFFFFFFF
if call_tgt == tgt_va3:
ctx = data[fo-4:fo+8]
print(" FO 0x%05x VA 0x%07x: %s" % (fo, FO2VA(fo), ' '.join('%02x'%b for b in ctx)))