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

66 lines
2.9 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 = lambda va: va - 0x400C00
def show(start, length):
for row in range(start, start + length, 16):
chunk = data[row:row+16]
ann = []
for j in range(len(chunk)):
f = row + j
try:
if data[f] == 0xe8 and f+5 <= len(data):
rel = struct.unpack_from('<i', data, f+1)[0]
tgt = (FO2VA(f+5) + rel) & 0xFFFFFFFF
ann.append('+%d:CALL_0x%x' % (j, tgt))
if data[f] == 0xff and data[f+1] == 0x15 and f+6 <= len(data):
iat = struct.unpack_from('<I', data, f+2)[0]
d = {
0x4822f4:'DirectDrawCreate', 0x4822fc:'DirectSoundCreate',
0x48240c:'ExitProcess', 0x48248c:'MessageBoxA',
0x4823cc:'LocalAlloc', 0x482370:'VirtualAlloc',
0x482390:'CreateFileA', 0x4823B0:'HeapAlloc',
0x482438:'HeapCreate',
}
ann.append('+%d:[%s]' % (j, d.get(iat, 'iat_0x%x' % iat)))
if data[f] == 0xa3 and f+5 <= len(data):
va = struct.unpack_from('<I', data, f+1)[0]
if va == 0x480964:
ann.append('+%d:MOV[0x480964],EAX' % j)
if data[f] == 0xc3: ann.append('+%d:RET' % j)
if data[f] == 0xc2 and f+3 <= len(data):
n = struct.unpack_from('<H', data, f+1)[0]
ann.append('+%d:RET%d' % (j, n))
except Exception:
pass
line = 'FO 0x%05x VA 0x%07x: %s' % (row, FO2VA(row), ' '.join('%02x' % b for b in chunk))
if ann:
line += ' [%s]' % ','.join(ann)
print(line)
# Search for all writes to [0x480964]
print("=== All writes to [0x480964] (heap handle) ===")
for fo in range(0, 0x70000):
# MOV [0x480964], EAX
if data[fo] == 0xa3 and fo+5 <= len(data):
va = struct.unpack_from('<I', data, fo+1)[0]
if va == 0x480964:
ctx = data[fo-16:fo+16]
ctx_hex = ' '.join('%02x'%b for b in ctx)
print(' FO 0x%05x VA 0x%07x: %s' % (fo, FO2VA(fo), ctx_hex))
# MOV [0x480964], r32 (89 05 ...)
if data[fo] == 0x89 and data[fo+1] == 0x05 and fo+6 <= len(data):
va = struct.unpack_from('<I', data, fo+2)[0]
if va == 0x480964:
ctx = data[fo-8:fo+16]
print(' FO 0x%05x (89 05): %s' % (fo, ' '.join('%02x'%b for b in ctx)))
print()
print("=== function 0x4633C7 (underlying alloc, FO 0x%05x) ===" % VA2FO(0x4633C7))
show(VA2FO(0x4633C7), 80)
print()
print("=== function 0x43A960: EDX vs ECX setup (what initializes them) ===")
show(VA2FO(0x43A960), 96)