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

114 lines
4.5 KiB
Python

import struct, sys
# Set TEST_MODE=True to use dwExStyle=0 (original), False for WS_EX_APPWINDOW
TEST_MODE = True
exe = bytearray(open("HAVOC_NOCD.EXE", "rb").read())
BASE = 0x400000
# ---- Locate .text section header -------------------------------------------
e_lfanew = struct.unpack_from('<I', exe, 0x3c)[0]
coff_off = e_lfanew + 4
num_sects = struct.unpack_from('<H', exe, coff_off + 2)[0]
opt_size = struct.unpack_from('<H', exe, coff_off + 16)[0]
sects_off = coff_off + 20 + opt_size
TEXT_SECHDOFF = None
for i in range(num_sects):
sh = sects_off + i * 40
name = exe[sh:sh+8].rstrip(b'\x00')
if name == b'.text':
TEXT_SECHDOFF = sh
break
if TEXT_SECHDOFF is None:
print("ERROR: .text section not found"); sys.exit(1)
sh = TEXT_SECHDOFF
TEXT_VRVA = struct.unpack_from('<I', exe, sh+12)[0]
TEXT_VSIZ = struct.unpack_from('<I', exe, sh+8)[0]
TEXT_RAWSIZ = struct.unpack_from('<I', exe, sh+16)[0]
TEXT_RAWOFF = struct.unpack_from('<I', exe, sh+20)[0]
print(f".text: VirtRVA=0x{TEXT_VRVA:x} VirtSize=0x{TEXT_VSIZ:x} "
f"RawOff=0x{TEXT_RAWOFF:x} RawSize=0x{TEXT_RAWSIZ:x}")
def va2fo(va):
rva = va - BASE
return TEXT_RAWOFF + (rva - TEXT_VRVA)
# ---- Cave: placed in raw padding beyond ORIGINAL VirtualSize ----------------
# Hardcode original VirtualSize so this script is safe to re-run even after
# VirtualSize has already been extended to RawSize.
ORIG_TEXT_VSIZ = 0x66bbd
cave_fo = TEXT_RAWOFF + ORIG_TEXT_VSIZ # FO 0x66fbd (raw padding start)
cave_va = BASE + TEXT_VRVA + ORIG_TEXT_VSIZ # VA 0x467bbd
PATCH_VA = 0x4260c7
patch_fo = va2fo(PATCH_VA)
BACK_VA = 0x4260d3
print(f"Cave FO=0x{cave_fo:05x} VA=0x{cave_va:07x}")
# Verify cave area is zero/CC/or previously-patched bytes (safe to overwrite)
cave_bytes = exe[cave_fo:cave_fo+20]
all_safe = all(b in (0x00, 0xCC) for b in cave_bytes)
print(f"Cave current bytes: {cave_bytes.hex(' ')}")
if not all_safe:
print("(overwriting previous cave code)")
# ---- Verify patch site (may already be patched; accept either) -------------
original_12 = bytes.fromhex("740051689cda47006a00ffd0")
actual_12 = bytes(exe[patch_fo:patch_fo+12])
if actual_12 == original_12:
print("Patch site: original (unpatched)")
elif actual_12[0] == 0x51 and actual_12[1] == 0xe9:
print("Patch site: already patched -- overwriting")
else:
print(f"ERROR: unexpected bytes at patch site: {actual_12.hex(' ')}")
sys.exit(1)
# ---- Cave code: explicit pushes (no LEA trick) to minimise risk -----------
# Push className as immediate, not computed.
# arg3 (title/lpWindowName) is pushed at patch site via 'push ecx'.
# Cave pushes arg2 (className) and arg1 (dwExStyle), then calls CreateWindowExA.
CLASSNAME_VA = 0x0047da9c
EX_STYLE = 0 if TEST_MODE else 0x00040000
print(f"dwExStyle = 0x{EX_STYLE:08x} ({'TEST: original style' if TEST_MODE else 'WS_EX_APPWINDOW'})")
cave_code = bytearray()
cave_code += b'\x68' + struct.pack('<I', CLASSNAME_VA) # push className (5b)
cave_code += b'\x68' + struct.pack('<I', EX_STYLE) # push dwExStyle (5b)
cave_code += b'\xFF\xD0' # call eax (2b)
jmp_back = BACK_VA - (cave_va + len(cave_code) + 5)
cave_code += b'\xE9' + struct.pack('<i', jmp_back) # jmp BACK_VA (5b)
CAVE_SIZE = len(cave_code) # 17
print(f"Cave ({CAVE_SIZE}b): {cave_code.hex(' ')}")
print(f" jmp_back = {jmp_back:+d} -> IP after jmp = 0x{cave_va+CAVE_SIZE:07x}, "
f"target = 0x{cave_va+CAVE_SIZE+jmp_back:07x}")
# ---- Patch (12 bytes at 0x4260c7) -----------------------------------------
jmp_cave = cave_va - (PATCH_VA + 1 + 5)
patch = bytearray()
patch += b'\x51' # push ecx (title) (1b)
patch += b'\xE9' + struct.pack('<i', jmp_cave) # jmp cave (5b)
patch += b'\x90' * 6 # 6x NOP (6b)
print(f"Patch (12b): {patch.hex(' ')}")
print(f" jmp_cave = {jmp_cave:+d} -> IP after jmp = 0x{PATCH_VA+6:07x}, "
f"target = 0x{PATCH_VA+6+jmp_cave:07x}")
# Confirm jump targets
assert PATCH_VA + 6 + jmp_cave == cave_va, "jmp_cave target mismatch"
assert cave_va + CAVE_SIZE + jmp_back == BACK_VA, "jmp_back target mismatch"
print("Jump target assertions: OK")
# ---- Apply -----------------------------------------------------------------
new_vsiz = TEXT_RAWSIZ # expose all raw data as executable
struct.pack_into('<I', exe, sh + 8, new_vsiz)
print(f".text VirtualSize: 0x{TEXT_VSIZ:x} -> 0x{new_vsiz:x}")
exe[cave_fo:cave_fo+CAVE_SIZE] = cave_code
exe[patch_fo:patch_fo+12] = patch
open("HAVOC_NOCD.EXE", "wb").write(exe)
print("Patch R applied.")