Download CLAUDE.md from nmarshall/wormz-a: direct link, hf CLI and curl.
- Browser
- Download file 12.8 kB
-
https://huggingface.co/nmarshall/wormz-a/resolve/main/CLAUDE.md
- Command line
-
hf download hf://nmarshall/wormz-a/CLAUDE.md
-
curl -L -o CLAUDE.md https://huggingface.co/nmarshall/wormz-a/resolve/main/CLAUDE.md
OpenWA
Project
OpenWA (short for "Open Worms Armageddon") is an incremental Rust reimplementation of Worms Armageddon 3.8.1 (Steam). The replacement strategy is the same as was used in OpenRCT2: a custom launcher (openwa-launcher) injects a DLL (openwa-dll) into a suspended game process, which replaces game functions with Rust implementations (from openwa-core).
The original WA.exe is a 32-bit x86 Windows PE binary built with MSVC 2005 + MFC. All Rust code targets i686-pc-windows-msvc.
Crate Architecture
openwa-coreβ Types, addresses, parsers, ASLR rebasing, typed WA function wrappers, and game logic. The source of truth for all reverse-engineered type layouts, known addresses, and Rust reimplementations of WA functions. Containsregistry(structured address database + field registries),rebase(ASLR delta),wa_call(calling convention helpers),wa/(typed handle wrappers), and game logic modules (game/weapon_fire.rs,game/weapon_release.rs,audio/sound_ops.rs,engine/team_ops.rs).openwa-deriveβ Proc macro crate. Provides#[derive(FieldRegistry)]for struct field maps and#[vtable(...)]for typed vtable definitions with introspection, calling wrappers, and replacement support.openwa-dllβ Injected DLL (openwa.dll): thin hook installation shims (trampolines,usercall_trampoline!,install()) that wire core's game logic into WA.exe via MinHook. Logs toOpenWA.log. Runs registry-driven startup checks automatically at load.openwa-test-runnerβ Headless replay test runner (openwa-testbinary). Discovers replay tests, runs them concurrently via WA.exe's/getlogmode, compares output logs. See "Replay Testing" section.openwa-launcherβ Launches WA.exe with the DLL injected via CREATE_SUSPENDED + remote thread.openwa-debuguiβ In-process egui debug window (entity census, struct inspector, cheats). Enabled viaOPENWA_DEBUG_UI=1+debug-uicargo feature.openwa-debug-cliβ CLI tool for live memory inspection (openwa-debugbinary). Connects to the debug server in the DLL.openwa-debug-protoβ Shared protocol types (Request/Response enums, MessagePack framing) between CLI and server.
Build & Test
Build: cargo build --release (default target is i686-pc-windows-msvc via .cargo/config.toml).
Unit tests: cargo test. These are standard Rust tests covering parsers and type logic.
Replay tests are the primary verification method β see below.
Replay Testing
WA.exe can deterministically replay recorded games (.WAgame files). Each replay test runs the game with the injected DLL and checks that the output matches a baseline log (*_expected.log) captured from unmodified WA.exe.
Two ways to run replay tests:
Headless (.\run-tests.ps1)
Pure CPU simulation, no rendering. Fast, runs in parallel (default 4 concurrent). Validates game logic β a log mismatch means the Rust code caused a desync. Spurious flakes from race conditions can occur; retry with -j 1 to confirm.
Headful (.\replay-test.ps1 or openwa-test headful)
Runs with graphics and sound. The game window must be focused once to start. Needed to validate visual/audio hooks β code can pass headless but crash headful (or vice versa) since rendering and sound paths are only exercised headfully. Checks for crashes, panics, and [GAMEPLAY PASS/FAIL] markers. Timeout configurable with --timeout SECS (default 150s).
See crates/openwa-test-runner/CLAUDE.md for test isolation, crash detection, adding new tests, and env vars. Use /desync-debug skill after a test failure to diagnose.
Use replay testing to validate assumptions and test theories
Implement a hypothesis, run tests, iterate. You can add temporary log statements and see their results by running replay tests.
IMPORTANT: Replay tests can only test two things:
- The game simulation matches the original WA.exe, to the extent covered by the replay log and the replay's built-in checksums
- The game doesn't crash
You, as an agent, CANNOT see or hear the game, and therefore you can't verify that anything related to graphics or audio is correct. A passing headful replay test is NOT a guarantee that the rendering is correct. You need to involve the user to verify all graphics and audio related changes before committing or merging.
IMPORTANT: The *_expected.log baselines are ground truth from unmodified WA.exe. They must NEVER be deleted or regenerated, unless explicitly requested by the user. If a test fails, the Rust code is wrong.
Debug CLI
Use the /debug-cli skill for live memory inspection, struct inspection, frame-level control, and pointer chain traversal.
Hardware Watchpoints
crates/openwa-dll/src/debug_watchpoint.rs β x86 debug register instrumentation (DR0-DR3) for answering "who writes this memory?" without an external debugger. Uses INT3βVEH trick, logs symbolicated stack traces via registry::format_va(). Max 4 watchpoints per run (hardware limit).
API: arm_watchpoint(base_ptr, offset, size) sets a write watchpoint. prepare() initializes the VEH handler, teardown() removes it. Can be armed at any point during execution β on object allocation, at a specific frame via env var, or manually from debug server commands.
Env vars: OPENWA_WATCH_FRAME=N (arm at frame N), OPENWA_WATCH_WRAPPER=1 (base=DDGameWrapper), OPENWA_WATCH_DISPLAY=1 (base=display object).
ASLR Rebasing
WA.exe has ASLR enabled. Ghidra shows addresses at image base 0x400000, but runtime base varies. Both DLLs compute a delta at startup:
let base = GetModuleHandleA(NULL) as u32;
let delta = base.wrapping_sub(0x400000);
// rb(ghidra_addr) = ghidra_addr + delta
All addresses in address.rs are Ghidra VAs. Always use rb() to convert to runtime addresses.
Calling Convention Rules
These are critical β wrong conventions cause stack corruption and crashes.
The Ghidra decompiler is UNTRUSTWORTHY for calling conventions. It frequently misidentifies stdcall/thiscall/usercall. Always verify via disassembly: check the RET imm16 instruction AND the caller's register setup at the call site.
- Constructors are usually
__stdcallβthispassed on stack, not ECX. Exception: CTaskMissile constructor (0x507D10) is__thiscall(ECX=this, 3 stack params, RET 0xC). Always verify by checking the call site's register setup. - VTable methods are
__thiscall: ECX = this, remaining params on stack. - Always check
RET imm16in disassembly to verify stack parameter count. The immediate value = bytes of params cleaned by callee (stdcall/thiscall).RET 0x10= 16 bytes = 4 params. - MSVC
__usercall: Some functions pass implicit params in registers (e.g., FrontendChangeScreen uses ESI for dialog pointer). These need#[unsafe(naked)]trampolines.
Hooking & Desync Debugging
See crates/openwa-dll/CLAUDE.md for hooking patterns (passthrough, full replacement, vtable, trap), bridge function patterns, usercall_trampoline! macro, and hook installation details.
Use the /desync-debug skill for desync diagnosis (trace-desync, per-frame analysis). Only after a headless test has already detected a failure.
Ghidra MCP
A Ghidra MCP bridge is configured in .mcp.json. When using Ghidra tools:
- Prefer batch tools (
batch_create_labels,batch_rename_function_components) β single-item tools have address parsing bugs. - WA.exe is loaded at image base 0x400000 in Ghidra.
- When you encounter unnamed functions, globals or structs, name them in Ghidra if you know their purpose. Even a guess is helpful for future reference, but add
_Maybesuffix if uncertain. - Remove
_Maybesuffix when you confirm the purpose. - When you learn more about a function or address, update both the Ghidra database (rename function / label and update signature) and the corresponding Rust code.
Address Registry, FieldRegistry & Vtable Macros
See crates/openwa-core/CLAUDE.md for the full reference on define_addresses!, #[derive(FieldRegistry)], #[vtable(...)], vtable_replace!, and the registry query API.
Design Conventions
- Unknown struct fields as
_unknown_XXpadding arrays - Fixed-point:
Fixed(i32)newtype, 16.16 format (0x10000 = 1.0) - Naked asm uses
naked_asm!(Rust 1.79+ syntax), notasm! - Generic CTask for typed vtables:
CTask<V: Vtable = *const c_void>andCGameTask<V: Vtable = *const c_void>take a vtable pointer type parameter. Subclasses specify their typed vtable:CTaskTeam { base: CTask<*const CTaskTeamVTable> }. TheVtablemarker trait is auto-implemented by the#[vtable]macro.FieldRegistryderive supports generics by substituting type params with their defaults foroffset_of/size_of. Tasktrait: Providestask(),ddgame(),as_task_ptr(),as_task_ptr_mut(), andbroadcast_message()on all CTask subclasses. Eliminates.base.basechains. Implemented for all task types intask/mod.rs.broadcast_message()/broadcast_message_raw(): Pure Rust port of CTask::HandleMessage (0x562F30). Iterates the sparse children array and calls each child's vtable[2]. Usesread_volatileforchildren_watermarkandchildren_dataβ required because LLVM caches these reads across virtual dispatch calls even through*mut. PreferCTask::broadcast_message_raw(ptr, ...)over(*ptr).broadcast_message(...)β see noalias rule above.- CTask base vtable: 7 slots (not 8). ProcessFrame is slot 6. Slots 7+ are class-specific extensions. CGameTask adds no virtuals.
- Noalias rule β ALWAYS use
_rawmethods on WA objects:bind_!methods with&mut selfgive LLVM noalias guarantees that WA bridge calls violate, causing silent miscompilation. Always useType::method_raw(ptr, args)instead of(*ptr).method(args). Similarly useCTask::ddgame_raw(ptr)instead of(*ptr).ddgame(), andCTask::broadcast_message_raw(ptr, ...)instead of(*ptr).broadcast_message(...). Foras_task_ptr, just cast:worm as *mut CTask. vcall!macro: Still available for raw-pointer vtable dispatch without bind wrappers. Expands to((*(*obj).vtable).method)(obj, args...).
FFI Style
Add type safety incrementally where it's beneficial β this is a reverse engineering project, not a greenfield codebase. Perfect types aren't always possible, but small improvements compound.
- Structs over raw memory: Create
#[repr(C)]structs for known memory layouts. Access fields by name, not pointer arithmetic. Even partially-known structs (with_unknown_XXpadding) are better than raw offsets. - Constant improvements: Adding a new struct or a missing field (by splitting an existing
_unknownfield), renaming a field from_unknownto a descriptive name, replacing an integer or void pointer with a better type, or labeling a function in Ghidra are ALWAYS welcome improvements. Small improvements to documentation and type safety should NEVER be considered out of scope. Improving the codebase is more important than keeping change sets small. - Typed pointers over integers: Function signatures (ported functions, FFI wrappers, hook impls) MUST use pointer types for pointer arguments, never
u32. If the pointee struct exists in Rust, use it (*mut DDGame,*mut PaletteContext). If it doesn't exist yet, strongly consider adding an incomplete#[repr(C)]struct with_unknown_XXpadding β even a stub enables typed field access later. Use*mut u8/*const u8ONLY when the pointee is truly untyped (generic memory likememcpy, raw byte buffers). Use*const c_charfor C string pointers. Note: on our target (i686-pc-windows-msvc),u32 == usize == *const T == *mut Tare all 4 bytes, so casts between them are always safe and zero-cost. - Constants over magic numbers: Name addresses (
va::FESFX_WAV_PLAYER), sizes (MAX_PATH), and offsets. Magic numbers in code should be rare and commented. Use typed enums (Weapon,KnownSoundId,FireType,SpecialFireSubtype) and newtypes (Fixed,SoundId) instead of rawu32/i32. - Wrap inline asm in safe-to-call functions: Isolate
asm!/naked_asm!blocks in small dedicated functions (e.g.,get_team_config_name(),wav_player_stop_raw()). Hook functions should read like normal Rust, calling into asm wrappers only when needed. - ESI/EDI are LLVM-reserved on x86: Cannot use
in("esi")orin("edi")incore::arch::asm!. Use#[unsafe(naked)]functions withnaked_asm!when these registers are needed. heapless::CString<N>for stack-allocated null-terminated path buffers (auto nul terminator,as_ptr()returns*const c_char).