paavohtl commited on
Commit
5ecd5ea
·
1 Parent(s): e1816b6

docs: remove outdated plans, add re-notes disclaimer

Browse files
docs/plans/2026-03-05-offline-harness-design.md DELETED
@@ -1,101 +0,0 @@
1
- # Offline Test Harness Design
2
-
3
- **Goal:** Load WA.exe into a 32-bit test process via `cargo test`, enabling offline validation of addresses, vtable contents, and eventually calling WA.exe functions — without running the game.
4
-
5
- **Architecture:** A new `openwa-harness` crate that uses `LoadLibraryExA` with `DONT_RESOLVE_DLL_REFERENCES` to map WA.exe into process memory. This gives us a relocated PE image with readable `.text`, `.rdata`, and `.data` sections. Tests run via `cargo test --target i686-pc-windows-msvc -p openwa-harness`.
6
-
7
- ## How PE Loading Works
8
-
9
- ```
10
- LoadLibraryExA("WA.exe", NULL, DONT_RESOLVE_DLL_REFERENCES)
11
- → Windows maps PE sections into memory
12
- → Applies base relocations (WA.exe has .reloc section)
13
- → Does NOT resolve imports (IAT entries are unpatched)
14
- → Does NOT call entry point
15
- → Returns HMODULE = base address of mapped image
16
- ```
17
-
18
- Delta computation: `loaded_base - IMAGE_BASE (0x400000)`.
19
- Any Ghidra address `addr` maps to `loaded_base + (addr - 0x400000)`.
20
-
21
- If `LoadLibraryEx` refuses to load an EXE, fallback: manual PE loader (read file, parse headers, VirtualAlloc, copy sections, apply relocations).
22
-
23
- ## Test Layers
24
-
25
- ### Layer 1: Read-only validation (initial scope)
26
-
27
- Load WA.exe, read memory at known offsets:
28
- - Vtable entries in `.rdata` match our address constants
29
- - Function prologues at known `.text` offsets match expected bytes
30
- - String data at known locations matches expected values
31
- - Cross-reference consistency (vtable method pointers land in `.text`)
32
-
33
- This replaces/supplements the runtime validator's static checks but runs via `cargo test` with no game needed.
34
-
35
- ### Layer 2: Function calls (stretch goal)
36
-
37
- Call simple WA.exe functions from the test process:
38
- - Requires resolving at minimum CRT imports (malloc, free, memset, memcpy)
39
- - IAT patching: find import entries, overwrite with pointers to our own implementations
40
- - Start with `WA_MallocMemset` (0x53E910) — uses EDI register for size, calls malloc+memset
41
- - Validate return values and memory contents
42
-
43
- ### Layer 3: Complex function execution (future)
44
-
45
- - Broader IAT patching for Windows API stubs
46
- - Mock subsystems (DirectDraw, DirectSound) to prevent crashes
47
- - Test constructors, initialization sequences
48
-
49
- ## Crate Structure
50
-
51
- ```
52
- crates/openwa-harness/
53
- Cargo.toml # 32-bit test crate, depends on openwa-types, windows-sys
54
- src/
55
- lib.rs # WaImage loader struct, rebase helpers
56
- tests/ # (or use #[cfg(test)] modules)
57
- pe_loading.rs # Layer 1: read-only checks
58
- ```
59
-
60
- ## Key Type: WaImage
61
-
62
- ```rust
63
- pub struct WaImage {
64
- base: *const u8, // HMODULE from LoadLibraryEx
65
- delta: i32, // base - 0x400000
66
- }
67
-
68
- impl WaImage {
69
- /// Load WA.exe from given path
70
- pub fn load(exe_path: &str) -> Result<Self, ...>;
71
-
72
- /// Convert Ghidra address to pointer in loaded image
73
- pub fn ptr(&self, ghidra_addr: u32) -> *const u8;
74
-
75
- /// Read u32 at a Ghidra address
76
- pub fn read_u32(&self, ghidra_addr: u32) -> u32;
77
- }
78
-
79
- impl Drop for WaImage {
80
- fn drop(&mut self) { FreeLibrary(self.base); }
81
- }
82
- ```
83
-
84
- ## WA.exe Path
85
-
86
- The harness needs to find WA.exe. Options in priority order:
87
- 1. `WAEXE_PATH` environment variable
88
- 2. Known Steam path: `I:\games\SteamLibrary\steamapps\common\Worms Armageddon\WA.exe`
89
- 3. Skip tests with clear message if not found
90
-
91
- ## Dependencies
92
-
93
- - `windows-sys` crate for `LoadLibraryExA`, `FreeLibrary` (links to kernel32)
94
- - `openwa-types` for address constants and struct definitions
95
- - No other external dependencies needed for Layer 1
96
-
97
- ## Risks
98
-
99
- - `LoadLibraryEx` might refuse to load an EXE (unlikely but possible) → fallback to manual PE mapping
100
- - WA.exe might not have a `.reloc` section → would need to load at exact base address 0x400000 (but we confirmed it has one: 188KB .reloc section)
101
- - Function calls (Layer 2+) may crash if they touch unresolved imports → start read-only, add IAT patching incrementally
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/plans/2026-03-05-openwa-foundation-design.md DELETED
@@ -1,37 +0,0 @@
1
- # OpenWA Foundation Design
2
-
3
- ## Goal
4
-
5
- Build the type foundation for an incremental Rust reimplementation of Worms Armageddon 3.8.1 (Steam). Start with Ghidra-assisted reverse engineering and Rust type definitions, later expand to a WormKit module that replaces subsystems.
6
-
7
- ## Strategy: Hybrid Bootstrap
8
-
9
- - **Constants/enums**: Translate immediately from wkJellyWorm's well-documented Constants.h (weapons, messages, sprites, sounds, class types)
10
- - **Struct layouts**: Explore in Ghidra first, write Rust types only for verified fields, pad unknowns
11
-
12
- ## Architecture
13
-
14
- ```
15
- OpenWA/ (Cargo workspace)
16
- crates/
17
- openwa-types/ (no_std-compatible type definitions)
18
- - Enums: ClassType, TaskMessage, Weapon, SoundId, SpriteId
19
- - Structs: CTask, CGameTask (partial, Ghidra-verified)
20
- - Primitives: FixedPoint (16.16 fixed-point math)
21
- - Addresses: Known function/global addresses
22
- openwa-dll/ (future: WormKit DLL module)
23
- openwa-render/ (future: modern rendering backend)
24
- ```
25
-
26
- ## Key Decisions
27
-
28
- - `#[repr(C)]` and `#[repr(u32)]` for FFI compatibility from day one
29
- - Unknown struct fields as `_unknown_XX: [u8; N]` padding — honest about what we don't know
30
- - Fixed-point newtype rather than raw i32 — prevents unit confusion
31
- - No external dependencies in openwa-types (pure Rust, no_std compatible)
32
-
33
- ## Sources
34
-
35
- - wkJellyWorm: Task hierarchy, enums, vtable layouts, offset documentation
36
- - WormKit: Game state pointers, network protocol, fixed-point conventions
37
- - Ghidra: WA.exe analysis (6,859 functions, image base 0x400000)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/plans/2026-03-05-runtime-validator-design.md DELETED
@@ -1,98 +0,0 @@
1
- # Runtime Validator DLL Design
2
-
3
- ## Goal
4
-
5
- Validate that our reverse-engineered struct layouts, field offsets, and address
6
- constants in `openwa-types` are correct by checking them against the live
7
- Worms Armageddon 3.8.1 process at runtime.
8
-
9
- ## Architecture
10
-
11
- A 32-bit Rust cdylib (`wkOpenWAValidator.dll`) loaded by WormKit's HookLib into
12
- the WA.exe process. On load it:
13
-
14
- 1. Validates address constants (vtables contain plausible function pointers,
15
- function addresses start with valid x86 prologues)
16
- 2. Hooks key constructors to capture object pointers
17
- 3. When constructors fire, validates struct field offsets against live memory
18
- 4. Writes results to `OpenWA_validation.log`
19
-
20
- ## Crate structure
21
-
22
- ```
23
- crates/openwa-validator/
24
- ├── Cargo.toml # cdylib, i686-pc-windows-msvc
25
- └── src/
26
- └── lib.rs # DLL entry, hooks, validation
27
- ```
28
-
29
- ## Dependencies
30
-
31
- - `retour` (with `static-detour` feature) — inline function hooking
32
- - `openwa-types` (path dependency) — the types being validated
33
- - `windows` or raw FFI — for DllMain, file I/O
34
-
35
- ## Build
36
-
37
- ```sh
38
- cargo build --target i686-pc-windows-msvc -p openwa-validator --release
39
- ```
40
-
41
- Output: `target/i686-pc-windows-msvc/release/openwa_validator.dll`
42
- Copy to game dir as `wkOpenWAValidator.dll`.
43
-
44
- ## Validation checks
45
-
46
- ### Address validation (immediate, on DLL load)
47
-
48
- For each vtable address in `va::*_VTABLE`:
49
- - Read the first entry (should be a function pointer in .text range)
50
- - PASS if value is in 0x401000..0x619FFF
51
-
52
- For each function address in `va::*`:
53
- - Read first 1-3 bytes at that address
54
- - PASS if starts with common x86 prologue (55 8B EC = push ebp; mov ebp, esp)
55
- or other valid patterns (83 EC, 56, 53, etc.)
56
-
57
- ### Struct validation (on constructor hook)
58
-
59
- **CTask constructor (0x5625A0):**
60
- - After constructor returns, read object
61
- - Check vtable == 0x669F8C
62
- - Check _unknown_08 == 0x10 (set by constructor)
63
-
64
- **CGameTask constructor (0x4FED50):**
65
- - Check vtable2 at offset 0xE8 == 0x669CF8
66
- - Check total struct offsets are consistent
67
-
68
- **DDGameWrapper constructor (0x56DEF0):**
69
- - Check vtable at +0x00 == 0x66A30C
70
- - Check ddgame pointer at +0x488 is non-null (after init)
71
-
72
- **DDGame constructor (0x56E220):**
73
- - Check landscape at +0x20 matches wrapper's landscape at +0x4CC
74
- - Check known init values (field_7EFC == 1, etc.)
75
-
76
- ## Output format
77
-
78
- ```
79
- === OpenWA Validator ===
80
- [PASS] va::CTASK_VTABLE (0x669F8C) -> first entry 0x562710 (in .text)
81
- [PASS] va::CONSTRUCT_DD_GAME (0x56E220) -> prologue 55 8B EC
82
- [FAIL] DDGame+0x020 (landscape): expected non-null, got 0x00000000
83
- [INFO] 45/47 checks passed, 2 failed
84
- ```
85
-
86
- ## Deployment
87
-
88
- 1. Build the DLL
89
- 2. Copy to WA game directory as `wkOpenWAValidator.dll`
90
- 3. Launch WA via WormKit (or directly — HookLib loads wk*.dll)
91
- 4. Play through to trigger constructors (start a game)
92
- 5. Check `OpenWA_validation.log` in game directory
93
-
94
- ## Non-goals
95
-
96
- - No signature scanning (hardcoded addresses for 3.8.1 only)
97
- - No game modification (read-only checks)
98
- - No complex hook chains
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/plans/2026-03-05-runtime-validator-plan.md DELETED
@@ -1,675 +0,0 @@
1
- # Runtime Validator DLL Implementation Plan
2
-
3
- > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
-
5
- **Goal:** Build a 32-bit Rust WormKit DLL that validates our `openwa-types` struct layouts and address constants against live WA.exe 3.8.1 memory.
6
-
7
- **Architecture:** A `cdylib` crate (`openwa-validator`) compiled for `i686-pc-windows-msvc`. Loaded by WormKit's HookLib as `wkOpenWAValidator.dll`. On attach: validates addresses immediately, hooks constructors with `retour`, logs PASS/FAIL to a file.
8
-
9
- **Tech Stack:** Rust (stable), `retour` (inline hooking), `openwa-types` (path dep), raw Win32 FFI for DllMain.
10
-
11
- ---
12
-
13
- ### Task 1: Install the i686 target
14
-
15
- **Step 1: Add the Rust target**
16
-
17
- Run: `rustup target add i686-pc-windows-msvc`
18
- Expected: "installed" or "already installed"
19
-
20
- ---
21
-
22
- ### Task 2: Create the openwa-validator crate
23
-
24
- **Files:**
25
- - Create: `crates/openwa-validator/Cargo.toml`
26
- - Create: `crates/openwa-validator/src/lib.rs`
27
-
28
- **Step 1: Create Cargo.toml**
29
-
30
- ```toml
31
- [package]
32
- name = "openwa-validator"
33
- version = "0.1.0"
34
- edition = "2021"
35
- description = "WormKit DLL that validates openwa-types against live WA.exe memory"
36
-
37
- [lib]
38
- crate-type = ["cdylib"]
39
-
40
- [dependencies]
41
- openwa-types = { path = "../openwa-types" }
42
- retour = { version = "0.4", features = ["static-detour", "thiscall-abi"] }
43
- ```
44
-
45
- **Step 2: Create minimal lib.rs with DllMain**
46
-
47
- This is the DLL entry point. On `DLL_PROCESS_ATTACH`, it spawns a thread
48
- (to avoid loader lock) that runs all validation.
49
-
50
- ```rust
51
- #![allow(non_snake_case)]
52
-
53
- use std::ffi::c_void;
54
-
55
- const DLL_PROCESS_ATTACH: u32 = 1;
56
-
57
- #[no_mangle]
58
- unsafe extern "system" fn DllMain(
59
- _module: *mut c_void,
60
- reason: u32,
61
- _reserved: *mut c_void,
62
- ) -> i32 {
63
- if reason == DLL_PROCESS_ATTACH {
64
- std::thread::spawn(|| {
65
- if let Err(e) = run_validation() {
66
- let _ = log_line(&format!("[FATAL] Validation failed to run: {}", e));
67
- }
68
- });
69
- }
70
- 1 // TRUE
71
- }
72
-
73
- fn run_validation() -> Result<(), Box<dyn std::error::Error>> {
74
- let _ = log_line("=== OpenWA Validator ===");
75
- let _ = log_line("DLL loaded successfully. Validation will follow.");
76
- Ok(())
77
- }
78
-
79
- fn log_line(msg: &str) -> std::io::Result<()> {
80
- use std::io::Write;
81
- let mut f = std::fs::OpenOptions::new()
82
- .create(true)
83
- .append(true)
84
- .open("OpenWA_validation.log")?;
85
- writeln!(f, "{}", msg)?;
86
- Ok(())
87
- }
88
- ```
89
-
90
- **Step 3: Verify it compiles**
91
-
92
- Run: `cargo build --target i686-pc-windows-msvc -p openwa-validator`
93
- Expected: Compiles successfully, produces `target/i686-pc-windows-msvc/debug/openwa_validator.dll`
94
-
95
- **Step 4: Commit**
96
-
97
- ```
98
- feat: scaffold openwa-validator WormKit DLL crate
99
- ```
100
-
101
- ---
102
-
103
- ### Task 3: Add address validation
104
-
105
- **Files:**
106
- - Modify: `crates/openwa-validator/src/lib.rs`
107
-
108
- This runs immediately on DLL load — no hooks needed. It reads memory at our
109
- known addresses and checks plausibility.
110
-
111
- **Step 1: Add address validation module**
112
-
113
- Add a `validate_addresses` function that checks:
114
- - Vtable addresses: read first DWORD, verify it's in .text range (0x401000..0x61A000)
115
- - Function addresses: read first bytes, verify common x86 prologue patterns
116
- - Global variable addresses: verify they're in .data range (0x694000..0x8C4158)
117
-
118
- ```rust
119
- use openwa_types::address::va;
120
-
121
- struct ValidationResult {
122
- pass: u32,
123
- fail: u32,
124
- }
125
-
126
- impl ValidationResult {
127
- fn new() -> Self {
128
- Self { pass: 0, fail: 0 }
129
- }
130
-
131
- fn check(&mut self, name: &str, passed: bool, detail: &str) {
132
- if passed {
133
- self.pass += 1;
134
- let _ = log_line(&format!("[PASS] {}: {}", name, detail));
135
- } else {
136
- self.fail += 1;
137
- let _ = log_line(&format!("[FAIL] {}: {}", name, detail));
138
- }
139
- }
140
-
141
- fn summary(&self) {
142
- let total = self.pass + self.fail;
143
- let _ = log_line(&format!(
144
- "[INFO] {}/{} checks passed, {} failed",
145
- self.pass, total, self.fail
146
- ));
147
- }
148
- }
149
-
150
- unsafe fn read_u32(addr: u32) -> u32 {
151
- *(addr as *const u32)
152
- }
153
-
154
- unsafe fn read_u8(addr: u32) -> u8 {
155
- *(addr as *const u8)
156
- }
157
-
158
- fn is_in_text(addr: u32) -> bool {
159
- addr >= va::TEXT_START && addr <= va::TEXT_END
160
- }
161
-
162
- fn is_in_rdata(addr: u32) -> bool {
163
- addr >= va::RDATA_START && addr < va::DATA_START
164
- }
165
-
166
- fn validate_addresses(result: &mut ValidationResult) {
167
- unsafe {
168
- // --- Vtable checks: first entry should be a function pointer in .text ---
169
- let vtables: &[(&str, u32)] = &[
170
- ("CTASK_VTABLE", va::CTASK_VTABLE),
171
- ("CGAMETASK_VTABLE", va::CGAMETASK_VTABLE),
172
- ("CGAMETASK_VTABLE2", va::CGAMETASK_VTABLE2),
173
- ("DDGAME_WRAPPER_VTABLE", va::DDGAME_WRAPPER_VTABLE),
174
- ("GFX_HANDLER_VTABLE", va::GFX_HANDLER_VTABLE),
175
- ("DISPLAY_GFX_VTABLE", va::DISPLAY_GFX_VTABLE),
176
- ("PC_LANDSCAPE_VTABLE", va::PC_LANDSCAPE_VTABLE),
177
- ("LANDSCAPE_SHADER_VTABLE", va::LANDSCAPE_SHADER_VTABLE),
178
- ("DS_SOUND_VTABLE", va::DS_SOUND_VTABLE),
179
- ("TASK_STATE_MACHINE_VTABLE", va::TASK_STATE_MACHINE_VTABLE),
180
- ("OPENGL_CPU_VTABLE", va::OPENGL_CPU_VTABLE),
181
- ("WATER_EFFECT_VTABLE", va::WATER_EFFECT_VTABLE),
182
- ];
183
-
184
- for (name, addr) in vtables {
185
- // Vtable itself should be in .rdata
186
- let in_rdata = is_in_rdata(*addr);
187
- result.check(
188
- &format!("{} location", name),
189
- in_rdata,
190
- &format!("0x{:08X} in .rdata: {}", addr, in_rdata),
191
- );
192
-
193
- // First entry should point to .text
194
- let first_entry = read_u32(*addr);
195
- let valid = is_in_text(first_entry);
196
- result.check(
197
- &format!("{} first entry", name),
198
- valid,
199
- &format!("0x{:08X} -> 0x{:08X} (in .text: {})", addr, first_entry, valid),
200
- );
201
- }
202
-
203
- // --- CTask vtable content: verify known method pointers ---
204
- let ctask_vt_checks: &[(&str, u32, u32)] = &[
205
- ("CTask vt[0] init", va::CTASK_VTABLE, va::CTASK_VT0_INIT),
206
- ("CTask vt[1] free", va::CTASK_VTABLE + 4, va::CTASK_VT1_FREE),
207
- ("CTask vt[2] handleMsg", va::CTASK_VTABLE + 8, va::CTASK_VT2_HANDLE_MESSAGE),
208
- ("CTask vt[7] processFrame", va::CTASK_VTABLE + 0x1C, va::CTASK_VT7_PROCESS_FRAME),
209
- ];
210
-
211
- for (name, vt_addr, expected_fn) in ctask_vt_checks {
212
- let actual = read_u32(*vt_addr);
213
- result.check(
214
- name,
215
- actual == *expected_fn,
216
- &format!("at 0x{:08X}: expected 0x{:08X}, got 0x{:08X}", vt_addr, expected_fn, actual),
217
- );
218
- }
219
-
220
- // --- Function prologue checks ---
221
- let functions: &[(&str, u32)] = &[
222
- ("CTASK_CONSTRUCTOR", va::CTASK_CONSTRUCTOR),
223
- ("CGAMETASK_CONSTRUCTOR", va::CGAMETASK_CONSTRUCTOR),
224
- ("CONSTRUCT_DD_GAME", va::CONSTRUCT_DD_GAME),
225
- ("CONSTRUCT_DD_GAME_WRAPPER", va::CONSTRUCT_DD_GAME_WRAPPER),
226
- ("CONSTRUCT_PC_LANDSCAPE", va::CONSTRUCT_PC_LANDSCAPE),
227
- ("CONSTRUCT_DS_SOUND", va::CONSTRUCT_DS_SOUND),
228
- ("CREATE_EXPLOSION", va::CREATE_EXPLOSION),
229
- ("FRONTEND_CHANGE_SCREEN", va::FRONTEND_CHANGE_SCREEN),
230
- ];
231
-
232
- // Common x86 prologues: 55 (push ebp), 53 (push ebx), 56 (push esi),
233
- // 83 EC (sub esp), 8B (mov), 6A (push imm8), 81 (sub esp imm32)
234
- let valid_first_bytes: &[u8] = &[0x55, 0x53, 0x56, 0x57, 0x83, 0x8B, 0x6A, 0x81, 0xB8, 0x51, 0x52];
235
-
236
- for (name, addr) in functions {
237
- let first_byte = read_u8(*addr);
238
- let valid = valid_first_bytes.contains(&first_byte);
239
- result.check(
240
- name,
241
- valid,
242
- &format!("0x{:08X} starts with 0x{:02X} (valid prologue: {})", addr, first_byte, valid),
243
- );
244
- }
245
- }
246
- }
247
- ```
248
-
249
- **Step 2: Wire into run_validation**
250
-
251
- ```rust
252
- fn run_validation() -> Result<(), Box<dyn std::error::Error>> {
253
- // Clear log file
254
- let _ = std::fs::write("OpenWA_validation.log", "");
255
-
256
- let _ = log_line("=== OpenWA Validator ===");
257
- let mut result = ValidationResult::new();
258
-
259
- validate_addresses(&mut result);
260
-
261
- result.summary();
262
- Ok(())
263
- }
264
- ```
265
-
266
- **Step 3: Verify it compiles**
267
-
268
- Run: `cargo build --target i686-pc-windows-msvc -p openwa-validator`
269
- Expected: Compiles
270
-
271
- **Step 4: Commit**
272
-
273
- ```
274
- feat(validator): add address and vtable content validation
275
- ```
276
-
277
- ---
278
-
279
- ### Task 4: Add constructor hooks for struct validation
280
-
281
- **Files:**
282
- - Modify: `crates/openwa-validator/src/lib.rs`
283
-
284
- Hook CTask and DDGameWrapper constructors to validate struct field offsets
285
- when objects are actually constructed in the live game.
286
-
287
- **Step 1: Define static detours and hook functions**
288
-
289
- WA.exe constructors use `__thiscall` (ECX = this, args on stack).
290
- `retour`'s `extern "thiscall"` maps the first parameter to ECX.
291
-
292
- Note: DDGame constructor has many params. For validation we only need `this`
293
- (ECX) and the DDGameWrapper pointer. We capture what we can.
294
-
295
- ```rust
296
- use retour::static_detour;
297
- use std::sync::atomic::{AtomicBool, Ordering};
298
-
299
- // Track whether we've validated each struct (only do it once)
300
- static CTASK_VALIDATED: AtomicBool = AtomicBool::new(false);
301
- static DDGAME_WRAPPER_VALIDATED: AtomicBool = AtomicBool::new(false);
302
-
303
- // CTask constructor: __thiscall with (this, parent_ptr, class_type)
304
- // At 0x5625A0
305
- type CTaskCtorFn = unsafe extern "thiscall" fn(this: u32, parent: u32, class_type: u32) -> u32;
306
-
307
- static_detour! {
308
- static CTaskCtorHook: unsafe extern "thiscall" fn(u32, u32, u32) -> u32;
309
- }
310
-
311
- fn on_ctask_constructed(this: u32, parent: u32, class_type: u32) -> u32 {
312
- let ret = unsafe { CTaskCtorHook.call(this, parent, class_type) };
313
-
314
- if !CTASK_VALIDATED.swap(true, Ordering::Relaxed) {
315
- validate_ctask(this);
316
- }
317
-
318
- ret
319
- }
320
-
321
- fn validate_ctask(this: u32) {
322
- let mut result = ValidationResult::new();
323
- unsafe {
324
- // CTask.vtable at offset 0x00 should be CTASK_VTABLE
325
- // Note: subclasses override this, so we check it's a valid .text-pointing vtable
326
- let vtable = read_u32(this);
327
- let first_method = read_u32(vtable);
328
- result.check(
329
- "CTask+0x00 (vtable)",
330
- is_in_rdata(vtable),
331
- &format!("vtable ptr 0x{:08X} (in .rdata: {})", vtable, is_in_rdata(vtable)),
332
- );
333
- result.check(
334
- "CTask vtable[0] -> .text",
335
- is_in_text(first_method),
336
- &format!("first method 0x{:08X} (in .text: {})", first_method, is_in_text(first_method)),
337
- );
338
-
339
- // CTask._unknown_08 at offset 0x08 should be 0x10
340
- let field_08 = read_u32(this + 0x08);
341
- result.check(
342
- "CTask+0x08 (init value)",
343
- field_08 == 0x10,
344
- &format!("expected 0x10, got 0x{:X}", field_08),
345
- );
346
-
347
- // CTask.class_type at offset 0x20 should be a valid ClassType
348
- let class_type_val = read_u32(this + 0x20);
349
- result.check(
350
- "CTask+0x20 (class_type)",
351
- class_type_val < 200, // reasonable range for enum
352
- &format!("value {} (reasonable range: {})", class_type_val, class_type_val < 200),
353
- );
354
- }
355
-
356
- let _ = log_line("--- CTask struct validation ---");
357
- result.summary();
358
- }
359
-
360
- // DDGameWrapper constructor: __thiscall with (this) — simple
361
- // At 0x56DEF0
362
- type DDGameWrapperCtorFn = unsafe extern "thiscall" fn(this: u32) -> u32;
363
-
364
- static_detour! {
365
- static DDGameWrapperCtorHook: unsafe extern "thiscall" fn(u32) -> u32;
366
- }
367
-
368
- fn on_ddgame_wrapper_constructed(this: u32) -> u32 {
369
- let ret = unsafe { DDGameWrapperCtorHook.call(this) };
370
-
371
- if !DDGAME_WRAPPER_VALIDATED.swap(true, Ordering::Relaxed) {
372
- // Delay validation slightly — the constructor sets fields but
373
- // DDGame is allocated later. We validate what we can now.
374
- validate_ddgame_wrapper_immediate(this);
375
-
376
- // Spawn a thread that waits briefly then checks DDGame pointer
377
- let wrapper_addr = this;
378
- std::thread::spawn(move || {
379
- std::thread::sleep(std::time::Duration::from_secs(5));
380
- validate_ddgame_wrapper_deferred(wrapper_addr);
381
- });
382
- }
383
-
384
- ret
385
- }
386
-
387
- fn validate_ddgame_wrapper_immediate(this: u32) {
388
- let mut result = ValidationResult::new();
389
- unsafe {
390
- // DDGameWrapper.vtable at +0x00
391
- let vtable = read_u32(this);
392
- result.check(
393
- "DDGameWrapper+0x00 (vtable)",
394
- vtable == va::DDGAME_WRAPPER_VTABLE,
395
- &format!("expected 0x{:08X}, got 0x{:08X}", va::DDGAME_WRAPPER_VTABLE, vtable),
396
- );
397
-
398
- // DDGameWrapper._field_4e0 at +0x4E0 should be -100 (0xFFFFFF9C)
399
- let field_4e0 = read_u32(this + 0x4E0);
400
- result.check(
401
- "DDGameWrapper+0x4E0 (init -100)",
402
- field_4e0 == 0xFFFFFF9C,
403
- &format!("expected 0xFFFFFF9C, got 0x{:08X}", field_4e0),
404
- );
405
- }
406
-
407
- let _ = log_line("--- DDGameWrapper struct validation (immediate) ---");
408
- result.summary();
409
- }
410
-
411
- fn validate_ddgame_wrapper_deferred(this: u32) {
412
- let mut result = ValidationResult::new();
413
- unsafe {
414
- // DDGameWrapper.ddgame at +0x488
415
- let ddgame_ptr = read_u32(this + 0x488);
416
- result.check(
417
- "DDGameWrapper+0x488 (ddgame ptr)",
418
- ddgame_ptr != 0,
419
- &format!("0x{:08X} (non-null: {})", ddgame_ptr, ddgame_ptr != 0),
420
- );
421
-
422
- if ddgame_ptr != 0 {
423
- // DDGame.landscape at +0x20 should match wrapper.landscape at +0x4CC
424
- let ddgame_landscape = read_u32(ddgame_ptr + 0x20);
425
- let wrapper_landscape = read_u32(this + 0x4CC);
426
- result.check(
427
- "DDGame+0x20 == DDGameWrapper+0x4CC (landscape)",
428
- ddgame_landscape == wrapper_landscape,
429
- &format!(
430
- "DDGame: 0x{:08X}, Wrapper: 0x{:08X}",
431
- ddgame_landscape, wrapper_landscape
432
- ),
433
- );
434
-
435
- // DDGame+0x7EFC should be 1 (init value)
436
- let field_7efc = read_u32(ddgame_ptr + 0x7EFC);
437
- result.check(
438
- "DDGame+0x7EFC (init 1)",
439
- field_7efc == 1,
440
- &format!("expected 1, got {}", field_7efc),
441
- );
442
-
443
- // DDGame.display_gfx at +0x138 — if set, vtable should be DISPLAY_GFX_VTABLE
444
- let display_gfx = read_u32(ddgame_ptr + 0x138);
445
- if display_gfx != 0 {
446
- let dgfx_vt = read_u32(display_gfx);
447
- result.check(
448
- "DDGame+0x138 display_gfx vtable",
449
- dgfx_vt == va::DISPLAY_GFX_VTABLE,
450
- &format!("expected 0x{:08X}, got 0x{:08X}", va::DISPLAY_GFX_VTABLE, dgfx_vt),
451
- );
452
- }
453
-
454
- // DDGame.task_state_machine at +0x380 — if set, vtable should be TASK_STATE_MACHINE_VTABLE
455
- let tsm = read_u32(ddgame_ptr + 0x380);
456
- if tsm != 0 {
457
- let tsm_vt = read_u32(tsm);
458
- result.check(
459
- "DDGame+0x380 task_state_machine vtable",
460
- tsm_vt == va::TASK_STATE_MACHINE_VTABLE,
461
- &format!("expected 0x{:08X}, got 0x{:08X}", va::TASK_STATE_MACHINE_VTABLE, tsm_vt),
462
- );
463
- }
464
- }
465
-
466
- // DDGameWrapper.gfx_handler_0 at +0x4C0 — vtable should be GFX_HANDLER_VTABLE
467
- let gfx0 = read_u32(this + 0x4C0);
468
- if gfx0 != 0 {
469
- let gfx0_vt = read_u32(gfx0);
470
- result.check(
471
- "DDGameWrapper+0x4C0 gfx_handler_0 vtable",
472
- gfx0_vt == va::GFX_HANDLER_VTABLE,
473
- &format!("expected 0x{:08X}, got 0x{:08X}", va::GFX_HANDLER_VTABLE, gfx0_vt),
474
- );
475
- }
476
-
477
- // DDGameWrapper.landscape at +0x4CC — vtable should be PC_LANDSCAPE_VTABLE
478
- let landscape = read_u32(this + 0x4CC);
479
- if landscape != 0 {
480
- let land_vt = read_u32(landscape);
481
- result.check(
482
- "DDGameWrapper+0x4CC landscape vtable",
483
- land_vt == va::PC_LANDSCAPE_VTABLE,
484
- &format!("expected 0x{:08X}, got 0x{:08X}", va::PC_LANDSCAPE_VTABLE, land_vt),
485
- );
486
- }
487
- }
488
-
489
- let _ = log_line("--- DDGameWrapper/DDGame struct validation (deferred) ---");
490
- result.summary();
491
- }
492
- ```
493
-
494
- **Step 2: Install hooks in run_validation**
495
-
496
- ```rust
497
- fn run_validation() -> Result<(), Box<dyn std::error::Error>> {
498
- let _ = std::fs::write("OpenWA_validation.log", "");
499
- let _ = log_line("=== OpenWA Validator ===");
500
-
501
- let mut result = ValidationResult::new();
502
- validate_addresses(&mut result);
503
- result.summary();
504
-
505
- // Install constructor hooks
506
- let _ = log_line("\nInstalling constructor hooks...");
507
-
508
- unsafe {
509
- let ctask_ctor: CTaskCtorFn = std::mem::transmute(va::CTASK_CONSTRUCTOR as usize);
510
- CTaskCtorHook
511
- .initialize(ctask_ctor, |this, parent, class_type| {
512
- on_ctask_constructed(this, parent, class_type)
513
- })?
514
- .enable()?;
515
- let _ = log_line("[HOOK] CTask constructor hooked at 0x5625A0");
516
-
517
- let wrapper_ctor: DDGameWrapperCtorFn =
518
- std::mem::transmute(va::CONSTRUCT_DD_GAME_WRAPPER as usize);
519
- DDGameWrapperCtorHook
520
- .initialize(wrapper_ctor, |this| {
521
- on_ddgame_wrapper_constructed(this)
522
- })?
523
- .enable()?;
524
- let _ = log_line("[HOOK] DDGameWrapper constructor hooked at 0x56DEF0");
525
- }
526
-
527
- let _ = log_line("Hooks installed. Start a game to trigger struct validation.\n");
528
- Ok(())
529
- }
530
- ```
531
-
532
- **Step 3: Verify it compiles**
533
-
534
- Run: `cargo build --target i686-pc-windows-msvc -p openwa-validator`
535
- Expected: Compiles
536
-
537
- **Step 4: Commit**
538
-
539
- ```
540
- feat(validator): add constructor hooks for CTask and DDGameWrapper validation
541
- ```
542
-
543
- ---
544
-
545
- ### Task 5: Manual testing with WA.exe
546
-
547
- **Step 1: Build release**
548
-
549
- Run: `cargo build --target i686-pc-windows-msvc -p openwa-validator --release`
550
-
551
- **Step 2: Deploy to game directory**
552
-
553
- Copy `target/i686-pc-windows-msvc/release/openwa_validator.dll` to
554
- `I:\games\SteamLibrary\steamapps\common\Worms Armageddon\wkOpenWAValidator.dll`
555
-
556
- Note: WormKit's HookLib auto-loads any `wk*.dll` in the game directory.
557
- If HookLib isn't present, the DLL won't load. In that case, we can use
558
- a simple injector or rename to test with manual LoadLibrary.
559
-
560
- **Step 3: Launch the game and start a match**
561
-
562
- 1. Launch WA.exe (via Steam or directly)
563
- 2. Start a single-player deathmatch (to trigger DDGame/CTask constructors)
564
- 3. Exit the game
565
-
566
- **Step 4: Check the log**
567
-
568
- Read `I:\games\SteamLibrary\steamapps\common\Worms Armageddon\OpenWA_validation.log`
569
-
570
- Expected: A mix of PASS/FAIL lines showing which addresses and struct offsets
571
- are correct. Any FAILs indicate bugs in our `openwa-types` definitions.
572
-
573
- **Step 5: Fix any failures**
574
-
575
- If struct offsets are wrong, update `openwa-types` accordingly and re-run.
576
- This is the core value of the validator — it tells us exactly which
577
- fields are at wrong offsets.
578
-
579
- **Step 6: Commit any fixes**
580
-
581
- ```
582
- fix(types): correct struct offsets based on runtime validation
583
- ```
584
-
585
- ---
586
-
587
- ### Task 6: Add offset_of validation for Rust struct layouts
588
-
589
- **Files:**
590
- - Modify: `crates/openwa-validator/src/lib.rs`
591
-
592
- Use `core::mem::offset_of!` (stable since Rust 1.77) to verify our `#[repr(C)]`
593
- struct field offsets match the documented byte offsets. This catches mistakes
594
- where padding or field ordering is wrong, without needing live game memory.
595
-
596
- **Step 1: Add compile-time / load-time offset checks**
597
-
598
- ```rust
599
- use openwa_types::task::{CTask, CGameTask};
600
- use openwa_types::ddgame::DDGame;
601
- use openwa_types::ddgame_wrapper::DDGameWrapper;
602
-
603
- fn validate_struct_offsets(result: &mut ValidationResult) {
604
- // CTask offsets
605
- let checks: &[(&str, usize, usize)] = &[
606
- ("CTask::vtable", core::mem::offset_of!(CTask, vtable), 0x00),
607
- ("CTask::parent", core::mem::offset_of!(CTask, parent), 0x04),
608
- ("CTask::_unknown_08", core::mem::offset_of!(CTask, _unknown_08), 0x08),
609
- ("CTask::class_type", core::mem::offset_of!(CTask, class_type), 0x20),
610
-
611
- // CGameTask offsets
612
- ("CGameTask::base", core::mem::offset_of!(CGameTask, base), 0x00),
613
- ("CGameTask::pos_x", core::mem::offset_of!(CGameTask, pos_x), 0x84),
614
- ("CGameTask::pos_y", core::mem::offset_of!(CGameTask, pos_y), 0x88),
615
- ("CGameTask::speed_x", core::mem::offset_of!(CGameTask, speed_x), 0x90),
616
- ("CGameTask::speed_y", core::mem::offset_of!(CGameTask, speed_y), 0x94),
617
- ("CGameTask::vtable2", core::mem::offset_of!(CGameTask, vtable2), 0xE8),
618
-
619
- // DDGame offsets
620
- ("DDGame::landscape", core::mem::offset_of!(DDGame, landscape), 0x20),
621
- ("DDGame::game_state", core::mem::offset_of!(DDGame, game_state), 0x24),
622
- ("DDGame::arrow_sprites", core::mem::offset_of!(DDGame, arrow_sprites), 0x38),
623
- ("DDGame::arrow_gfxdirs", core::mem::offset_of!(DDGame, arrow_gfxdirs), 0xB8),
624
- ("DDGame::display_gfx", core::mem::offset_of!(DDGame, display_gfx), 0x138),
625
- ("DDGame::task_state_machine", core::mem::offset_of!(DDGame, task_state_machine), 0x380),
626
- ("DDGame::sprite_regions", core::mem::offset_of!(DDGame, sprite_regions), 0x46C),
627
- ("DDGame::coord_list", core::mem::offset_of!(DDGame, coord_list), 0x50C),
628
-
629
- // DDGameWrapper offsets
630
- ("DDGameWrapper::vtable", core::mem::offset_of!(DDGameWrapper, vtable), 0x00),
631
- ("DDGameWrapper::ddgame", core::mem::offset_of!(DDGameWrapper, ddgame), 0x488),
632
- ("DDGameWrapper::gfx_handler_0", core::mem::offset_of!(DDGameWrapper, gfx_handler_0), 0x4C0),
633
- ("DDGameWrapper::landscape", core::mem::offset_of!(DDGameWrapper, landscape), 0x4CC),
634
- ("DDGameWrapper::display", core::mem::offset_of!(DDGameWrapper, display), 0x4D0),
635
- ];
636
-
637
- for (name, actual, expected) in checks {
638
- result.check(
639
- &format!("offset_of {}", name),
640
- *actual == *expected,
641
- &format!("expected 0x{:X}, got 0x{:X}", expected, actual),
642
- );
643
- }
644
- }
645
- ```
646
-
647
- **Step 2: Call from run_validation, before the hook installation**
648
-
649
- Add after `validate_addresses`:
650
-
651
- ```rust
652
- let _ = log_line("\n--- Struct offset validation (compile-time layout) ---");
653
- validate_struct_offsets(&mut result);
654
- ```
655
-
656
- **Step 3: Verify it compiles**
657
-
658
- Run: `cargo build --target i686-pc-windows-msvc -p openwa-validator`
659
-
660
- **Step 4: Commit**
661
-
662
- ```
663
- feat(validator): add offset_of! checks for Rust struct layouts
664
- ```
665
-
666
- ---
667
-
668
- ## Deployment Checklist
669
-
670
- 1. `rustup target add i686-pc-windows-msvc`
671
- 2. `cargo build --target i686-pc-windows-msvc -p openwa-validator --release`
672
- 3. Copy DLL to game dir as `wkOpenWAValidator.dll`
673
- 4. Ensure WormKit HookLib is in game dir (or use alternative injection)
674
- 5. Launch game, start a match, exit
675
- 6. Read `OpenWA_validation.log`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/plans/2026-03-06-configuration-system-design.md DELETED
@@ -1,110 +0,0 @@
1
- # Port Configuration System to Rust
2
-
3
- ## Context
4
-
5
- The WA configuration system has three subsystems: theme file I/O, Windows Registry management, and game options loading. All are self-contained leaf functions with no graphics or game state dependencies beyond simple struct field writes.
6
-
7
- ## Scope — 7 Functions
8
-
9
- ### Theme File I/O (3 functions)
10
-
11
- | Function | Address | Convention | Params | What it does |
12
- |----------|---------|------------|--------|-------------|
13
- | `Theme__GetFileSize` | 0x44BA80 | cdecl | 0 | Opens `data\current.thm`, returns file length (0 if missing) |
14
- | `Theme__Load` | 0x44BB20 | stdcall | 1 (dest buffer) | Reads entire theme file into buffer; MessageBox on failure |
15
- | `Theme__Save` | 0x44BBC0 | stdcall | 2 (buffer, size) | Writes buffer to theme file; MessageBox on create failure |
16
-
17
- Replace MFC CFile with `std::fs`. Replace `AfxMessageBox` with `MessageBoxA` from the `windows` crate.
18
-
19
- ### Registry Management (2 functions)
20
-
21
- | Function | Address | Convention | Params | What it does |
22
- |----------|---------|------------|--------|-------------|
23
- | `Registry__DeleteKeyRecursive` | 0x4E4D10 | stdcall | 2 (hkey, subkey) | Recursively enumerates and deletes registry subkeys |
24
- | `Registry__CleanAll` | 0x4C90D0 | stdcall | 1 (struct ptr) | Deletes Data/Options/ExportVideo/VSyncAssist subsections + clears INI |
25
-
26
- Pure Win32 registry API. Original uses malloc/realloc for key name buffer — Rust `Vec<u8>` replaces this.
27
-
28
- ### Game Options (2 functions)
29
-
30
- | Function | Address | Convention | Params | What it does |
31
- |----------|---------|------------|--------|-------------|
32
- | `GameInfo__LoadOptions` | 0x460AC0 | stdcall | 1 (GameInfo ptr) | Reads ~12 registry values into GameInfo struct fields |
33
- | `Options__GetCrashReportURL` | 0x5A63F0 | cdecl | 0 | Reads CrashReportURL from Options registry key |
34
-
35
- LoadOptions reads from `HKCU\Software\Team17SoftwareLTD\WormsArmageddon\Options` via MFC `GetProfileIntW`. We replace with direct `RegOpenKeyExA` + `RegQueryValueExA`.
36
-
37
- Key value transformations in LoadOptions:
38
- - `BackgroundDebrisParallax`: clamp to i16 range, then `<< 16` (fixed-point 16.16)
39
- - `CameraUnlockMouseSpeed`: clamp to max 0xB504, then square
40
- - `InfoSpy`: stored as `!= 0` (bool)
41
- - All others: simple cast to byte or store as u32
42
-
43
- LoadOptions also copies various globals into the GameInfo struct and formats the speech path via sprintf.
44
-
45
- ## Dependencies
46
-
47
- ### `windows` crate
48
-
49
- Add to `openwa-lib` with features:
50
- - `Win32_System_Registry` — RegOpenKeyExA, RegQueryValueExA, RegEnumKeyExA, RegDeleteKeyA, RegCloseKey
51
- - `Win32_UI_WindowsAndMessaging` — MessageBoxA, WriteProfileSectionA
52
-
53
- Existing raw FFI in `resource.rs` and `rebase.rs` stays as-is for now — migration to `windows` crate is a separate future task.
54
-
55
- ### Registry helper in openwa-lib
56
-
57
- `read_profile_int(section: &str, key: &str, default: u32) -> u32`
58
-
59
- Opens `HKCU\Software\Team17SoftwareLTD\WormsArmageddon\{section}`, reads a DWORD via `RegQueryValueExA`, returns default on any failure. Matches MFC `GetProfileIntW` behavior for the registry-mode path.
60
-
61
- ## Hook Strategy
62
-
63
- All 7 functions are full Rust replacements — no trampolines needed. All conventions are plain cdecl or stdcall (confirmed via RET instruction analysis).
64
-
65
- ## File Layout
66
-
67
- | File | Change |
68
- |------|--------|
69
- | `crates/openwa-types/src/address.rs` | Add 7 address constants |
70
- | `crates/openwa-lib/Cargo.toml` | Add `windows` crate dependency |
71
- | `crates/openwa-lib/src/wa/registry.rs` (new) | `read_profile_int`, `delete_key_recursive` |
72
- | `crates/openwa-lib/src/wa/mod.rs` | Add `pub mod registry;` |
73
- | `crates/openwa-dll/src/replacements/config.rs` (new) | 7 hook functions + `install()` |
74
- | `crates/openwa-dll/src/replacements/mod.rs` | Add `pub mod config;` |
75
-
76
- ## Global Data References in LoadOptions
77
-
78
- | Global Address | Description | GameInfo Offset |
79
- |---------------|-------------|-----------------|
80
- | 0x0088E282 | Base directory string | +0xF404 (speech path) |
81
- | 0x0088DFF3 | 64 bytes copied verbatim | +0xF485 |
82
- | 0x0064DA58 | `"data\land.dat"` string (14 bytes) | +0xDAEC |
83
- | 0x007C0D38 | Unknown byte | +0xF3A0 |
84
- | 0x0088E39C..0x0088E3AC | 5 DWORDs | +0xF3B4..+0xF3D0 |
85
- | 0x0088C374 | Guard flag for conditional copies | — |
86
- | 0x0088E3B8..0x0088E3C4 | 4 DWORDs (conditional) | +0xF3F4..+0xF400 |
87
- | 0x0088E390 | DWORD | +0xDAE8 |
88
- | 0x0088E3B0..0x0088E3B4 | 2 DWORDs | +0xF3D4..+0xF3D8 |
89
- | 0x0088E400..0x0088E408 | 3 DWORDs | +0xF3C4..+0xF3CC |
90
- | 0x0088E44C | DWORD | +0xF3E4 |
91
-
92
- These are read via rebased pointers (`rb(addr)`) and written to the GameInfo struct at the given offsets.
93
-
94
- ## Verification
95
-
96
- 1. Build: `cargo build --release -p openwa-dll` — clean
97
- 2. Deploy wkOpenWA.dll to game directory
98
- 3. Launch game, check OpenWA.log for config hook messages
99
- 4. Verify game options persist (change Detail Level, restart, confirm it's saved)
100
- 5. Verify theme loads correctly (visual appearance unchanged)
101
- 6. Test Registry__CleanAll if reachable (it's called from a "reset all settings" path)
102
- 7. Check CrashReportURL is read correctly (visible in crash dialog if triggered)
103
-
104
- ## Decisions
105
-
106
- - **`windows` crate**: Add now, migrate existing FFI later
107
- - **Theme error messages**: Use `MessageBoxA` from `windows` crate (replaces AfxMessageBox)
108
- - **Registry reads**: Direct Win32 API, not MFC GetProfileIntW (Steam always uses registry mode)
109
- - **GameInfo struct**: Write to raw pointer at known offsets (don't type the full 0xF500-byte struct)
110
- - **std::fs over CFile**: For theme I/O
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/plans/2026-03-06-configuration-system-plan.md DELETED
@@ -1,877 +0,0 @@
1
- # Configuration System Port — Implementation Plan
2
-
3
- > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
-
5
- **Goal:** Port 7 WA.exe configuration functions (theme I/O, registry management, game options loading) to Rust.
6
-
7
- **Architecture:** New `config.rs` replacement module in openwa-dll hooks 7 functions. Registry helpers live in openwa-lib for reuse. Uses `windows` crate for Win32 registry/MessageBox APIs, `std::fs` for theme file I/O.
8
-
9
- **Tech Stack:** Rust, `windows` crate (Win32 registry + UI), MinHook inline hooks, `std::fs`
10
-
11
- ---
12
-
13
- ### Task 1: Add `windows` crate dependency to openwa-lib
14
-
15
- **Files:**
16
- - Modify: `crates/openwa-lib/Cargo.toml`
17
-
18
- **Step 1: Add the dependency**
19
-
20
- ```toml
21
- [dependencies]
22
- openwa-types = { path = "../openwa-types" }
23
-
24
- [target.'cfg(target_os = "windows")'.dependencies]
25
- windows-sys = { version = "0.59", features = [
26
- "Win32_System_Registry",
27
- "Win32_UI_WindowsAndMessaging",
28
- ] }
29
- ```
30
-
31
- Note: Use `windows-sys` (not `windows`) — it's the raw FFI bindings crate, already used by openwa-dll. Lower overhead, no COM wrappers.
32
-
33
- **Step 2: Build to verify**
34
-
35
- Run: `cargo build --release -p openwa-lib`
36
- Expected: clean build
37
-
38
- **Step 3: Commit**
39
-
40
- ```
41
- feat: add windows-sys registry + UI features to openwa-lib
42
- ```
43
-
44
- ---
45
-
46
- ### Task 2: Add address constants
47
-
48
- **Files:**
49
- - Modify: `crates/openwa-types/src/address.rs`
50
-
51
- **Step 1: Add constants**
52
-
53
- Add to the `va` module, in a new `// === Configuration / registry ===` section after the scheme section:
54
-
55
- ```rust
56
- // === Configuration / registry ===
57
-
58
- /// Theme file size check: cdecl() -> u32 (file length or 0)
59
- pub const THEME_GET_FILE_SIZE: u32 = 0x0044_BA80;
60
- /// Theme file load: stdcall(dest_buffer)
61
- pub const THEME_LOAD: u32 = 0x0044_BB20;
62
- /// Theme file save: stdcall(buffer, size)
63
- pub const THEME_SAVE: u32 = 0x0044_BBC0;
64
- /// Recursive registry key deletion: stdcall(hkey, subkey) -> i32
65
- pub const REGISTRY_DELETE_KEY_RECURSIVE: u32 = 0x004E_4D10;
66
- /// Registry cleanup — deletes all 4 subsections + clears INI: stdcall(struct_ptr)
67
- pub const REGISTRY_CLEAN_ALL: u32 = 0x004C_90D0;
68
- /// Loads game options from registry into GameInfo struct: stdcall(game_info_ptr)
69
- pub const GAMEINFO_LOAD_OPTIONS: u32 = 0x0046_0AC0;
70
- /// Reads CrashReportURL from Options registry key: cdecl() -> *const u8
71
- pub const OPTIONS_GET_CRASH_REPORT_URL: u32 = 0x005A_63F0;
72
- ```
73
-
74
- **Step 2: Build to verify**
75
-
76
- Run: `cargo build --release -p openwa-types`
77
- Expected: clean build
78
-
79
- **Step 3: Commit**
80
-
81
- ```
82
- feat: add configuration system address constants
83
- ```
84
-
85
- ---
86
-
87
- ### Task 3: Add registry helpers to openwa-lib
88
-
89
- **Files:**
90
- - Create: `crates/openwa-lib/src/wa/registry.rs`
91
- - Modify: `crates/openwa-lib/src/wa/mod.rs`
92
-
93
- **Step 1: Add `pub mod registry;` to mod.rs**
94
-
95
- After the existing `pub mod resource;` line:
96
-
97
- ```rust
98
- pub mod registry;
99
- ```
100
-
101
- **Step 2: Create registry.rs**
102
-
103
- ```rust
104
- //! Win32 registry helpers for WA configuration.
105
-
106
- use windows_sys::Win32::System::Registry::*;
107
-
108
- const WA_REGISTRY_PATH: &str = "Software\\Team17SoftwareLTD\\WormsArmageddon\\";
109
-
110
- /// Read an integer value from the WA registry.
111
- ///
112
- /// Opens `HKCU\Software\Team17SoftwareLTD\WormsArmageddon\{section}\{key}`
113
- /// and reads it as a DWORD. Returns `default` on any failure.
114
- ///
115
- /// This replaces MFC's `CWinApp::GetProfileIntW` for the registry-mode path.
116
- pub fn read_profile_int(section: &str, key: &str, default: u32) -> u32 {
117
- let mut full_path = String::with_capacity(WA_REGISTRY_PATH.len() + section.len() + 1);
118
- full_path.push_str(WA_REGISTRY_PATH);
119
- full_path.push_str(section);
120
- full_path.push('\0');
121
-
122
- let mut key_buf = String::with_capacity(key.len() + 1);
123
- key_buf.push_str(key);
124
- key_buf.push('\0');
125
-
126
- unsafe {
127
- let mut hkey: HKEY = 0;
128
- let status = RegOpenKeyExA(
129
- HKEY_CURRENT_USER,
130
- full_path.as_ptr(),
131
- 0,
132
- KEY_READ,
133
- &mut hkey,
134
- );
135
- if status != 0 {
136
- return default;
137
- }
138
-
139
- let mut value: u32 = 0;
140
- let mut value_size: u32 = 4;
141
- let mut value_type: u32 = 0;
142
- let status = RegQueryValueExA(
143
- hkey,
144
- key_buf.as_ptr(),
145
- core::ptr::null_mut(),
146
- &mut value_type,
147
- &mut value as *mut u32 as *mut u8,
148
- &mut value_size,
149
- );
150
- RegCloseKey(hkey);
151
-
152
- if status == 0 && value_type == REG_DWORD {
153
- value
154
- } else {
155
- default
156
- }
157
- }
158
- }
159
-
160
- /// Read a string value from the WA registry.
161
- ///
162
- /// Opens `HKCU\Software\Team17SoftwareLTD\WormsArmageddon\{section}\{key}`
163
- /// and reads it as a REG_SZ string into `buf`. Returns the number of bytes
164
- /// written (excluding null terminator), or 0 on failure.
165
- pub fn read_profile_string(section: &str, key: &str, buf: &mut [u8]) -> usize {
166
- let mut full_path = String::with_capacity(WA_REGISTRY_PATH.len() + section.len() + 1);
167
- full_path.push_str(WA_REGISTRY_PATH);
168
- full_path.push_str(section);
169
- full_path.push('\0');
170
-
171
- let mut key_buf = String::with_capacity(key.len() + 1);
172
- key_buf.push_str(key);
173
- key_buf.push('\0');
174
-
175
- unsafe {
176
- let mut hkey: HKEY = 0;
177
- let status = RegOpenKeyExA(
178
- HKEY_CURRENT_USER,
179
- full_path.as_ptr(),
180
- 0,
181
- KEY_READ,
182
- &mut hkey,
183
- );
184
- if status != 0 {
185
- return 0;
186
- }
187
-
188
- let mut value_size: u32 = buf.len() as u32;
189
- let mut value_type: u32 = 0;
190
- let status = RegQueryValueExA(
191
- hkey,
192
- key_buf.as_ptr(),
193
- core::ptr::null_mut(),
194
- &mut value_type,
195
- buf.as_mut_ptr(),
196
- &mut value_size,
197
- );
198
- RegCloseKey(hkey);
199
-
200
- if status == 0 && value_type == REG_SZ && value_size > 0 {
201
- // value_size includes null terminator
202
- (value_size - 1) as usize
203
- } else {
204
- 0
205
- }
206
- }
207
- }
208
-
209
- /// Recursively delete a registry key and all its subkeys.
210
- ///
211
- /// Replacement for WA's Registry__DeleteKeyRecursive (0x4E4D10).
212
- /// Returns 0 on success, Win32 error code on failure.
213
- pub unsafe fn delete_key_recursive(parent: HKEY, subkey: &str) -> i32 {
214
- if subkey.is_empty() {
215
- return 0x3F2; // ERROR_INVALID_PARAMETER equivalent used by original
216
- }
217
-
218
- let mut subkey_buf = String::with_capacity(subkey.len() + 1);
219
- subkey_buf.push_str(subkey);
220
- subkey_buf.push('\0');
221
-
222
- let mut hkey: HKEY = 0;
223
- let status = RegOpenKeyExA(
224
- parent,
225
- subkey_buf.as_ptr(),
226
- 0,
227
- KEY_READ | KEY_ENUMERATE_SUB_KEYS,
228
- &mut hkey,
229
- );
230
- if status != 0 {
231
- return status;
232
- }
233
-
234
- // Enumerate and recursively delete all child keys
235
- let mut name_buf = vec![0u8; 256];
236
- loop {
237
- let mut name_len = name_buf.len() as u32;
238
- let status = RegEnumKeyExA(
239
- hkey,
240
- 0, // always index 0 since we delete as we go
241
- name_buf.as_mut_ptr(),
242
- &mut name_len,
243
- core::ptr::null_mut(),
244
- core::ptr::null_mut(),
245
- core::ptr::null_mut(),
246
- core::ptr::null_mut(),
247
- );
248
-
249
- if status == 0x103 {
250
- // ERROR_NO_MORE_ITEMS — all children deleted
251
- break;
252
- }
253
- if status != 0 {
254
- RegCloseKey(hkey);
255
- return status;
256
- }
257
-
258
- if name_len as usize == name_buf.len() {
259
- // Name buffer too small, grow it
260
- name_buf.resize(name_buf.len() * 2, 0);
261
- continue;
262
- }
263
-
264
- // Null-terminate and recurse
265
- name_buf[name_len as usize] = 0;
266
- let child_name = core::str::from_utf8_unchecked(&name_buf[..name_len as usize]);
267
- let result = delete_key_recursive(hkey, child_name);
268
- if result != 0 {
269
- RegCloseKey(hkey);
270
- return result;
271
- }
272
- }
273
-
274
- // All children deleted, now delete the key itself
275
- let status = RegDeleteKeyA(parent, subkey_buf.as_ptr());
276
- RegCloseKey(hkey);
277
- status
278
- }
279
- ```
280
-
281
- **Step 3: Build to verify**
282
-
283
- Run: `cargo build --release -p openwa-lib`
284
- Expected: clean build
285
-
286
- **Step 4: Commit**
287
-
288
- ```
289
- feat: add Win32 registry helpers to openwa-lib
290
- ```
291
-
292
- ---
293
-
294
- ### Task 4: Create config.rs with Theme hooks
295
-
296
- **Files:**
297
- - Create: `crates/openwa-dll/src/replacements/config.rs`
298
- - Modify: `crates/openwa-dll/src/replacements/mod.rs`
299
-
300
- **Step 1: Add `mod config;` to mod.rs and call install**
301
-
302
- ```rust
303
- mod config;
304
- mod frontend;
305
- mod scheme;
306
-
307
- pub fn install_all() -> Result<(), String> {
308
- frontend::install()?;
309
- scheme::install()?;
310
- config::install()?;
311
- Ok(())
312
- }
313
- ```
314
-
315
- **Step 2: Create config.rs with theme functions**
316
-
317
- ```rust
318
- //! Configuration system hooks.
319
- //!
320
- //! Replaces WA.exe configuration functions with Rust implementations:
321
- //! - Theme__GetFileSize (0x44BA80): theme file size query
322
- //! - Theme__Load (0x44BB20): theme file read
323
- //! - Theme__Save (0x44BBC0): theme file write
324
- //! - Registry__DeleteKeyRecursive (0x4E4D10): recursive registry deletion
325
- //! - Registry__CleanAll (0x4C90D0): full registry cleanup
326
- //! - GameInfo__LoadOptions (0x460AC0): game options from registry
327
- //! - Options__GetCrashReportURL (0x5A63F0): crash report URL from registry
328
-
329
- use crate::log_line;
330
- use openwa_lib::rebase::rb;
331
- use openwa_types::address::va;
332
-
333
- const THEME_PATH: &str = "data\\current.thm";
334
-
335
- // ============================================================
336
- // Theme__GetFileSize replacement (0x44BA80)
337
- // ============================================================
338
-
339
- /// Rust replacement for Theme__GetFileSize.
340
- /// cdecl() -> u32 (file length, or 0 if missing)
341
- unsafe extern "cdecl" fn hook_theme_get_file_size() -> u32 {
342
- match std::fs::metadata(THEME_PATH) {
343
- Ok(m) => m.len() as u32,
344
- Err(_) => 0,
345
- }
346
- }
347
-
348
- // ============================================================
349
- // Theme__Load replacement (0x44BB20)
350
- // ============================================================
351
-
352
- /// Rust replacement for Theme__Load.
353
- /// stdcall(dest_buffer: *mut u8)
354
- unsafe extern "stdcall" fn hook_theme_load(dest: u32) {
355
- match std::fs::read(THEME_PATH) {
356
- Ok(data) => {
357
- core::ptr::copy_nonoverlapping(data.as_ptr(), dest as *mut u8, data.len());
358
- }
359
- Err(_) => {
360
- show_error_message("ERROR: NO CURRENT.THM FILE FOUND");
361
- }
362
- }
363
- }
364
-
365
- // ============================================================
366
- // Theme__Save replacement (0x44BBC0)
367
- // ============================================================
368
-
369
- /// Rust replacement for Theme__Save.
370
- /// stdcall(buffer: *const u8, size: u32)
371
- unsafe extern "stdcall" fn hook_theme_save(buffer: u32, size: u32) {
372
- let data = core::slice::from_raw_parts(buffer as *const u8, size as usize);
373
- if let Err(_) = std::fs::write(THEME_PATH, data) {
374
- show_error_message("ERROR: Could Not create CURRENT.THM File");
375
- }
376
- }
377
-
378
- /// Show an error message box, matching AfxMessageBox behavior.
379
- fn show_error_message(msg: &str) {
380
- use windows_sys::Win32::UI::WindowsAndMessaging::*;
381
- let mut msg_buf: Vec<u8> = msg.bytes().collect();
382
- msg_buf.push(0);
383
- unsafe {
384
- MessageBoxA(0, msg_buf.as_ptr(), core::ptr::null(), MB_OK);
385
- }
386
- }
387
-
388
- // ============================================================
389
- // Hook installation
390
- // ============================================================
391
-
392
- pub fn install() -> Result<(), String> {
393
- unsafe {
394
- let _ = crate::hook::install(
395
- "Theme__GetFileSize",
396
- va::THEME_GET_FILE_SIZE,
397
- hook_theme_get_file_size as *const (),
398
- )?;
399
-
400
- let _ = crate::hook::install(
401
- "Theme__Load",
402
- va::THEME_LOAD,
403
- hook_theme_load as *const (),
404
- )?;
405
-
406
- let _ = crate::hook::install(
407
- "Theme__Save",
408
- va::THEME_SAVE,
409
- hook_theme_save as *const (),
410
- )?;
411
- }
412
-
413
- Ok(())
414
- }
415
- ```
416
-
417
- **Step 3: Add windows-sys dependency to openwa-dll Cargo.toml**
418
-
419
- The `windows-sys` dep in openwa-dll needs the UI feature too. Update the existing entry:
420
-
421
- ```toml
422
- [target.'cfg(target_os = "windows")'.dependencies]
423
- windows-sys = { version = "0.59", features = [
424
- "Win32_System_Memory",
425
- "Win32_UI_WindowsAndMessaging",
426
- ] }
427
- ```
428
-
429
- **Step 4: Build to verify**
430
-
431
- Run: `cargo build --release -p openwa-dll`
432
- Expected: clean build
433
-
434
- **Step 5: Deploy + test theme**
435
-
436
- Copy DLL, launch game. Theme should load normally (visual appearance unchanged). Check OpenWA.log for hook messages.
437
-
438
- **Step 6: Commit**
439
-
440
- ```
441
- feat: add Theme file I/O hooks (GetFileSize, Load, Save)
442
- ```
443
-
444
- ---
445
-
446
- ### Task 5: Add Registry hooks
447
-
448
- **Files:**
449
- - Modify: `crates/openwa-dll/src/replacements/config.rs`
450
-
451
- **Step 1: Add registry hook functions**
452
-
453
- Add after the Theme section, before `install()`:
454
-
455
- ```rust
456
- // ============================================================
457
- // Registry__DeleteKeyRecursive replacement (0x4E4D10)
458
- // ============================================================
459
-
460
- /// Rust replacement for Registry__DeleteKeyRecursive.
461
- /// stdcall(hkey: HKEY, subkey: *const u8) -> i32
462
- unsafe extern "stdcall" fn hook_delete_key_recursive(hkey: u32, subkey: u32) -> i32 {
463
- use windows_sys::Win32::System::Registry::HKEY;
464
-
465
- let c_subkey = std::ffi::CStr::from_ptr(subkey as *const i8);
466
- let subkey_str = c_subkey.to_string_lossy();
467
-
468
- let _ = log_line(&format!("[Config] DeleteKeyRecursive: {subkey_str}"));
469
-
470
- let result = openwa_lib::wa::registry::delete_key_recursive(
471
- hkey as HKEY,
472
- &subkey_str,
473
- );
474
-
475
- let _ = log_line(&format!("[Config] DeleteKeyRecursive result: {result}"));
476
- result
477
- }
478
-
479
- // ============================================================
480
- // Registry__CleanAll replacement (0x4C90D0)
481
- // ============================================================
482
-
483
- /// Rust replacement for Registry__CleanAll.
484
- /// stdcall(struct_ptr: u32)
485
- unsafe extern "stdcall" fn hook_registry_clean_all(struct_ptr: u32) {
486
- use windows_sys::Win32::System::Registry::HKEY_CURRENT_USER;
487
- use windows_sys::Win32::UI::WindowsAndMessaging::*;
488
-
489
- let _ = log_line("[Config] CleanAll: deleting registry sections");
490
-
491
- let sections = [
492
- "Software\\Team17SoftwareLTD\\WormsArmageddon\\Data",
493
- "Software\\Team17SoftwareLTD\\WormsArmageddon\\Options",
494
- "Software\\Team17SoftwareLTD\\WormsArmageddon\\ExportVideo",
495
- "Software\\Team17SoftwareLTD\\WormsArmageddon\\VSyncAssist",
496
- ];
497
-
498
- for section in &sections {
499
- openwa_lib::wa::registry::delete_key_recursive(HKEY_CURRENT_USER, section);
500
- }
501
-
502
- // Clear the NetSettings INI section
503
- WriteProfileSectionA(b"NetSettings\0".as_ptr(), b"\0".as_ptr());
504
-
505
- // Set struct_ptr + 0xE0 = 0
506
- *((struct_ptr + 0xE0) as *mut u8) = 0;
507
-
508
- let _ = log_line("[Config] CleanAll completed");
509
- }
510
- ```
511
-
512
- **Step 2: Wire up in install()**
513
-
514
- Add before the closing `Ok(())`:
515
-
516
- ```rust
517
- let _ = crate::hook::install(
518
- "Registry__DeleteKeyRecursive",
519
- va::REGISTRY_DELETE_KEY_RECURSIVE,
520
- hook_delete_key_recursive as *const (),
521
- )?;
522
-
523
- let _ = crate::hook::install(
524
- "Registry__CleanAll",
525
- va::REGISTRY_CLEAN_ALL,
526
- hook_registry_clean_all as *const (),
527
- )?;
528
- ```
529
-
530
- **Step 3: Build to verify**
531
-
532
- Run: `cargo build --release -p openwa-dll`
533
- Expected: clean build
534
-
535
- **Step 4: Commit**
536
-
537
- ```
538
- feat: add Registry cleanup hooks (DeleteKeyRecursive, CleanAll)
539
- ```
540
-
541
- ---
542
-
543
- ### Task 6: Add GameInfo__LoadOptions hook
544
-
545
- **Files:**
546
- - Modify: `crates/openwa-dll/src/replacements/config.rs`
547
- - Modify: `crates/openwa-types/src/address.rs` (global data addresses)
548
-
549
- **Step 1: Add global data address constants**
550
-
551
- Add to `address.rs` in the `// === Global variables (in .data) ===` section:
552
-
553
- ```rust
554
- /// Base directory string (null-terminated)
555
- pub const G_BASE_DIR: u32 = 0x0088_E282;
556
- /// 64-byte data block copied into GameInfo+0xF485
557
- pub const G_GAMEINFO_BLOCK_F485: u32 = 0x0088_DFF3;
558
- /// "data\land.dat" string constant (14 bytes)
559
- pub const G_LAND_DAT_STRING: u32 = 0x0064_DA58;
560
- /// Unknown byte read into GameInfo+0xF3A0
561
- pub const G_CONFIG_BYTE_F3A0: u32 = 0x007C_0D38;
562
- /// 5 DWORDs: GameInfo offsets +0xF3B4..+0xF3D0
563
- pub const G_CONFIG_DWORDS_F3B4: u32 = 0x0088_E39C;
564
- /// Guard flag for conditional config copies
565
- pub const G_CONFIG_GUARD: u32 = 0x0088_C374;
566
- /// 4 DWORDs (conditional): GameInfo offsets +0xF3F4..+0xF400
567
- pub const G_CONFIG_DWORDS_F3F4: u32 = 0x0088_E3B8;
568
- /// DWORD → GameInfo+0xDAE8
569
- pub const G_CONFIG_DWORD_DAE8: u32 = 0x0088_E390;
570
- /// 2 DWORDs → GameInfo+0xF3D4, +0xF3D8
571
- pub const G_CONFIG_DWORDS_F3D4: u32 = 0x0088_E3B0;
572
- /// 3 DWORDs → GameInfo+0xF3C4..+0xF3CC
573
- pub const G_CONFIG_DWORDS_F3C4: u32 = 0x0088_E400;
574
- /// DWORD → GameInfo+0xF3E4
575
- pub const G_CONFIG_DWORD_F3E4: u32 = 0x0088_E44C;
576
- /// Streams directory path buffer
577
- pub const G_STREAMS_DIR: u32 = 0x0088_AE18;
578
- /// Random stream indices (16 entries)
579
- pub const G_STREAM_INDICES: u32 = 0x0088_AE9C;
580
- /// Stream index end sentinel
581
- pub const G_STREAM_INDICES_END: u32 = 0x0088_AEDC;
582
- /// DAT_0088E394 flag for stream volume
583
- pub const G_STREAM_FLAG: u32 = 0x0088_E394;
584
- /// Stream volume byte
585
- pub const G_STREAM_VOLUME: u32 = 0x0088_AEDD;
586
- ```
587
-
588
- **Step 2: Add LoadOptions hook function**
589
-
590
- Add to config.rs before `install()`:
591
-
592
- ```rust
593
- // ============================================================
594
- // GameInfo__LoadOptions replacement (0x460AC0)
595
- // ============================================================
596
-
597
- /// Rust replacement for GameInfo__LoadOptions.
598
- /// stdcall(game_info: u32)
599
- ///
600
- /// Reads game options from the Windows registry and copies various globals
601
- /// into the GameInfo struct at known offsets.
602
- unsafe extern "stdcall" fn hook_load_options(gi: u32) {
603
- use openwa_lib::wa::registry::read_profile_int;
604
-
605
- let _ = log_line("[Config] LoadOptions: loading game options from registry");
606
-
607
- // Format speech path: "%s\user\speech"
608
- let base_dir = rb(va::G_BASE_DIR) as *const u8;
609
- let speech_dest = (gi + 0xF404) as *mut u8;
610
- let base_str = std::ffi::CStr::from_ptr(base_dir as *const i8);
611
- let speech_path = format!("{}\\user\\speech\0", base_str.to_string_lossy());
612
- core::ptr::copy_nonoverlapping(
613
- speech_path.as_ptr(),
614
- speech_dest,
615
- speech_path.len(),
616
- );
617
-
618
- // Copy 64 bytes from global 0x88DFF3 → GameInfo+0xF485
619
- core::ptr::copy_nonoverlapping(
620
- rb(va::G_GAMEINFO_BLOCK_F485) as *const u8,
621
- (gi + 0xF485) as *mut u8,
622
- 64,
623
- );
624
-
625
- // Format streams directory and randomize stream indices
626
- let streams_dest = rb(va::G_STREAMS_DIR) as *mut u8;
627
- let streams_path = format!("{}\\streams\0", base_str.to_string_lossy());
628
- core::ptr::copy_nonoverlapping(
629
- streams_path.as_ptr(),
630
- streams_dest,
631
- streams_path.len(),
632
- );
633
-
634
- // Randomize stream indices (16 entries, each rand() % 11 + 1)
635
- let indices = rb(va::G_STREAM_INDICES) as *mut u32;
636
- let indices_end = rb(va::G_STREAM_INDICES_END) as usize;
637
- let mut ptr = indices;
638
- while (ptr as usize) < indices_end {
639
- extern "cdecl" { fn rand() -> i32; }
640
- *ptr = (rand() % 11 + 1) as u32;
641
- ptr = ptr.add(1);
642
- }
643
-
644
- // Stream volume: 0x10 if flag set, else 0
645
- let stream_vol_addr = rb(va::G_STREAM_INDICES_END) as *mut u8;
646
- *stream_vol_addr = if *(rb(va::G_STREAM_FLAG) as *const u32) != 0 { 0x10 } else { 0 };
647
- // Secondary volume byte
648
- *(rb(va::G_STREAM_VOLUME) as *mut u8) = 0x4B;
649
-
650
- // Copy "data\land.dat" string (14 bytes) → GameInfo+0xDAEC
651
- core::ptr::copy_nonoverlapping(
652
- rb(va::G_LAND_DAT_STRING) as *const u8,
653
- (gi + 0xDAEC) as *mut u8,
654
- 14,
655
- );
656
-
657
- // Copy byte from global → GameInfo+0xF3A0
658
- *((gi + 0xF3A0) as *mut u8) = *(rb(va::G_CONFIG_BYTE_F3A0) as *const u8);
659
-
660
- // Read registry values from "Options" section
661
- let detail = read_profile_int("Options", "DetailLevel", 5);
662
- *((gi + 0xF3A1) as *mut u8) = detail as u8;
663
-
664
- // Zero 2 bytes at +0xF3F0
665
- *((gi + 0xF3F0) as *mut u16) = 0;
666
-
667
- // Copy 5 DWORDs from globals → GameInfo+0xF3B4..+0xF3D0
668
- let src = rb(va::G_CONFIG_DWORDS_F3B4) as *const u32;
669
- for i in 0u32..5 {
670
- let offset = 0xF3B4 + i * 4;
671
- *((gi + offset) as *mut u32) = *src.add(i as usize);
672
- }
673
-
674
- // Conditional copy: 4 DWORDs if guard == 0
675
- if *(rb(va::G_CONFIG_GUARD) as *const u32) == 0 {
676
- let src = rb(va::G_CONFIG_DWORDS_F3F4) as *const u32;
677
- for i in 0u32..4 {
678
- let offset = 0xF3F4 + i * 4;
679
- *((gi + offset) as *mut u32) = *src.add(i as usize);
680
- }
681
- }
682
-
683
- // Single DWORDs from globals
684
- *((gi + 0xDAE8) as *mut u32) = *(rb(va::G_CONFIG_DWORD_DAE8) as *const u32);
685
-
686
- let src_d4 = rb(va::G_CONFIG_DWORDS_F3D4) as *const u32;
687
- *((gi + 0xF3D4) as *mut u32) = *src_d4;
688
- *((gi + 0xF3D8) as *mut u32) = *src_d4.add(1);
689
-
690
- // EnergyBar
691
- let energy = read_profile_int("Options", "EnergyBar", 1);
692
- *((gi + 0xF3A2) as *mut u8) = energy as u8;
693
-
694
- // 3 DWORDs from globals → +0xF3C4..+0xF3CC
695
- let src_c4 = rb(va::G_CONFIG_DWORDS_F3C4) as *const u32;
696
- for i in 0u32..3 {
697
- let offset = 0xF3C4 + i * 4;
698
- *((gi + offset) as *mut u32) = *src_c4.add(i as usize);
699
- }
700
-
701
- // Remaining registry values
702
- let info_trans = read_profile_int("Options", "InfoTransparency", 0);
703
- *((gi + 0xF3A3) as *mut u8) = info_trans as u8;
704
-
705
- let info_spy = read_profile_int("Options", "InfoSpy", 1);
706
- *((gi + 0xF3A4) as *mut u8) = if info_spy != 0 { 1 } else { 0 };
707
-
708
- let chat_pinned = read_profile_int("Options", "ChatPinned", 0);
709
- *((gi + 0xF3A5) as *mut u8) = chat_pinned as u8;
710
-
711
- let chat_lines = read_profile_int("Options", "ChatLines", 0);
712
- *((gi + 0xF3A8) as *mut u32) = chat_lines;
713
-
714
- let pinned_lines = read_profile_int("Options", "PinnedChatLines", 0xFFFFFFFF);
715
- *((gi + 0xF3AC) as *mut u32) = pinned_lines;
716
-
717
- let home_lock = read_profile_int("Options", "HomeLock", 0);
718
- *((gi + 0xF3B0) as *mut u8) = home_lock as u8;
719
-
720
- // BackgroundDebrisParallax: clamp to i16 range, then << 16
721
- let mut parallax = read_profile_int("Options", "BackgroundDebrisParallax", 0x50);
722
- let parallax_i32 = parallax as i32;
723
- if parallax_i32 < -0x8000 || parallax_i32 > 0x7FFF {
724
- // Clamp to i16 range
725
- if parallax_i32 < 0 {
726
- parallax = (-0x8000i32) as u32;
727
- } else {
728
- parallax = 0x7FFF;
729
- }
730
- }
731
- *((gi + 0xF3E8) as *mut u32) = parallax << 16;
732
-
733
- let onomatopoeia = read_profile_int("Options", "TopmostExplosionOnomatopoeia", 0);
734
- *((gi + 0xF3EC) as *mut u32) = onomatopoeia;
735
-
736
- let capture_png = read_profile_int("Options", "CaptureTransparentPNGs", 0);
737
- *((gi + 0xF3DC) as *mut u32) = capture_png;
738
-
739
- // CameraUnlockMouseSpeed: clamp to max 0xB504, then square
740
- let mut mouse_speed = read_profile_int("Options", "CameraUnlockMouseSpeed", 0x10);
741
- if mouse_speed > 0xB504 {
742
- if (mouse_speed as i32) < 0 {
743
- mouse_speed = 0; // negative → 0
744
- } else {
745
- mouse_speed = 0xB504;
746
- }
747
- }
748
- *((gi + 0xF3E0) as *mut u32) = mouse_speed * mouse_speed;
749
-
750
- // Final global DWORD
751
- *((gi + 0xF3E4) as *mut u32) = *(rb(va::G_CONFIG_DWORD_F3E4) as *const u32);
752
-
753
- let _ = log_line("[Config] LoadOptions completed (Rust)");
754
- }
755
- ```
756
-
757
- **Step 3: Wire up in install()**
758
-
759
- Add to the unsafe block in `install()`:
760
-
761
- ```rust
762
- let _ = crate::hook::install(
763
- "GameInfo__LoadOptions",
764
- va::GAMEINFO_LOAD_OPTIONS,
765
- hook_load_options as *const (),
766
- )?;
767
- ```
768
-
769
- **Step 4: Build to verify**
770
-
771
- Run: `cargo build --release -p openwa-dll`
772
- Expected: clean build
773
-
774
- **Step 5: Deploy + test options**
775
-
776
- Launch game, change Detail Level in options, restart, verify it persists. Check OpenWA.log.
777
-
778
- **Step 6: Commit**
779
-
780
- ```
781
- feat: add GameInfo__LoadOptions hook (registry reads)
782
- ```
783
-
784
- ---
785
-
786
- ### Task 7: Add Options__GetCrashReportURL hook
787
-
788
- **Files:**
789
- - Modify: `crates/openwa-dll/src/replacements/config.rs`
790
- - Modify: `crates/openwa-types/src/address.rs`
791
-
792
- **Step 1: Add global address for the URL buffer**
793
-
794
- Add to address.rs globals section:
795
-
796
- ```rust
797
- /// CrashReportURL static buffer (0x400 bytes)
798
- pub const G_CRASH_REPORT_URL: u32 = 0x0079_FFD8;
799
- ```
800
-
801
- **Step 2: Add hook function**
802
-
803
- Add to config.rs before `install()`:
804
-
805
- ```rust
806
- // ============================================================
807
- // Options__GetCrashReportURL replacement (0x5A63F0)
808
- // ============================================================
809
-
810
- /// Rust replacement for Options__GetCrashReportURL.
811
- /// cdecl() -> *const u8 (pointer to static buffer, or null)
812
- unsafe extern "cdecl" fn hook_get_crash_report_url() -> u32 {
813
- let buf = rb(va::G_CRASH_REPORT_URL) as *mut u8;
814
- let mut buf_slice = core::slice::from_raw_parts_mut(buf, 0x401);
815
-
816
- let len = openwa_lib::wa::registry::read_profile_string(
817
- "Options",
818
- "CrashReportURL",
819
- &mut buf_slice[..0x400],
820
- );
821
-
822
- if len > 0 {
823
- // Null-terminate
824
- *buf.add(len) = 0;
825
- buf as u32
826
- } else {
827
- 0 // null pointer = not found
828
- }
829
- }
830
- ```
831
-
832
- **Step 3: Wire up in install()**
833
-
834
- ```rust
835
- let _ = crate::hook::install(
836
- "Options__GetCrashReportURL",
837
- va::OPTIONS_GET_CRASH_REPORT_URL,
838
- hook_get_crash_report_url as *const (),
839
- )?;
840
- ```
841
-
842
- **Step 4: Build to verify**
843
-
844
- Run: `cargo build --release -p openwa-dll`
845
- Expected: clean build
846
-
847
- **Step 5: Commit**
848
-
849
- ```
850
- feat: add Options__GetCrashReportURL hook
851
- ```
852
-
853
- ---
854
-
855
- ### Task 8: Full integration test + final commit
856
-
857
- **Step 1: Build clean**
858
-
859
- Run: `cargo build --release -p openwa-dll`
860
- Expected: clean build, no warnings
861
-
862
- **Step 2: Deploy**
863
-
864
- ```bash
865
- cp target/i686-pc-windows-msvc/release/openwa.dll "I:/games/SteamLibrary/steamapps/common/Worms Armageddon/wkOpenWA.dll"
866
- ```
867
-
868
- **Step 3: In-game verification**
869
-
870
- 1. Launch game → main menu
871
- 2. Check OpenWA.log for all 7 `[REPLACE]` messages + `[Config] LoadOptions completed`
872
- 3. Verify theme loads correctly (visual appearance unchanged)
873
- 4. Go to Options, change Detail Level, accept
874
- 5. Restart game, verify Detail Level persists
875
- 6. Verify existing scheme functionality still works (quick game setup)
876
-
877
- **Step 4: Squash/rebase if desired, or leave incremental commits**
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/plans/2026-03-07-replay-testing-design.md DELETED
@@ -1,97 +0,0 @@
1
- # Replay-Based Automated Testing Design
2
-
3
- **Date:** 2026-03-07
4
- **Status:** Approved
5
-
6
- ## Problem
7
-
8
- The current validation workflow is manual: start WA.exe, navigate menus, start a game, press F9/F10 hotkeys, read logs. This is slow and non-reproducible. We need an automated way to launch the game into a known gameplay state for validation and RE discovery.
9
-
10
- ## Key Insight
11
-
12
- WA.exe accepts a replay file (`.WAgame`) as a command-line argument. It starts playing automatically, skipping menus, and closes when the replay finishes. This gives us a deterministic, automated path into gameplay state.
13
-
14
- ## Design
15
-
16
- ### Components
17
-
18
- #### 1. PowerShell script (`replay-test.ps1`)
19
-
20
- Orchestrates the full workflow:
21
-
22
- 1. Build both DLLs (`cargo build --release -p openwa-dll -p openwa-validator`)
23
- 2. Deploy to game directory (same as `deploy.ps1`)
24
- 3. Clear old `OpenWA_validation.log` and `OpenWA.log` in game directory
25
- 4. Set `OPENWA_REPLAY_TEST=1` in environment
26
- 5. Launch `WA.exe <replay-file>` and wait for process exit
27
- 6. Copy both log files to `testdata/logs/` in the project
28
- 7. Print summary (PASS/FAIL counts from log)
29
-
30
- Accepts one argument: replay file path. Defaults to `testdata/replays/bots.WAgame`.
31
-
32
- #### 2. Validator DLL changes (`openwa-validator`)
33
-
34
- At startup in `run_validation()`, check `std::env::var("OPENWA_REPLAY_TEST")`:
35
-
36
- - **Not set:** Current behavior (deferred timer threads, hotkey listener, runs indefinitely).
37
- - **Set:** Spawn a single "auto-capture" thread that:
38
- 1. Waits 30 seconds (enough for replay to reach gameplay)
39
- 2. Runs `deferred_global_validation()`
40
- 3. Runs `dump_team_blocks()`
41
- 4. Runs `dump_landscape()`
42
- 5. Logs `"--- Auto-capture complete, exiting ---"`
43
- 6. Calls `ExitProcess(0)` to terminate WA.exe
44
-
45
- No hotkey listener or timer threads in automated mode -- one clean capture sequence.
46
-
47
- #### 3. Claude Code skill (`replay-test`)
48
-
49
- A skill that:
50
- - Invokes `replay-test.ps1` via Bash
51
- - Reads the resulting log files from `testdata/logs/`
52
- - Presents results in the conversation
53
-
54
- ### Data Flow
55
-
56
- ```
57
- /replay-test
58
- -> Bash: powershell -File replay-test.ps1 testdata/replays/bots.WAgame
59
- -> cargo build --release
60
- -> deploy DLLs to game dir
61
- -> clear logs
62
- -> set OPENWA_REPLAY_TEST=1
63
- -> start WA.exe bots.WAgame (blocks until exit)
64
- -> validator DLL starts
65
- -> detects env var -> auto-capture mode
66
- -> waits 30s -> dumps -> ExitProcess(0)
67
- -> WA.exe exits
68
- -> copy logs to testdata/logs/
69
- -> print summary
70
- -> Skill reads testdata/logs/*.log
71
- -> presents to user
72
- ```
73
-
74
- ### File Locations
75
-
76
- | File | Location |
77
- |------|----------|
78
- | Orchestration script | `replay-test.ps1` (project root) |
79
- | Replay files | `testdata/replays/*.WAgame` |
80
- | Captured logs | `testdata/logs/` (gitignored) |
81
- | Skill definition | User's Claude Code skills directory |
82
-
83
- ### Environment Variable
84
-
85
- | Variable | Value | Effect |
86
- |----------|-------|--------|
87
- | `OPENWA_REPLAY_TEST` | `1` | Validator enters auto-capture mode: single 30s wait, dump all, ExitProcess |
88
- | (unset) | -- | Normal interactive mode with hotkeys and timer threads |
89
-
90
- ## Future Expansion (Not In Scope)
91
-
92
- - Configurable wait time or dump selection via env vars or config file
93
- - Event-based hooks (dump on CreateExplosion, WeaponRelease, etc.)
94
- - Frame-count triggers via ProcessFrame hook
95
- - Structured output (JSON) for programmatic diffing
96
- - Multiple replay files in one run
97
- - CI integration with exit codes based on PASS/FAIL counts
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/plans/2026-03-07-replay-testing-plan.md DELETED
@@ -1,332 +0,0 @@
1
- # Replay-Based Automated Testing Implementation Plan
2
-
3
- > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
-
5
- **Goal:** Enable automated validation and RE discovery by launching WA.exe with replay files, auto-capturing memory dumps, and presenting results via a Claude Code skill.
6
-
7
- **Architecture:** A PowerShell script orchestrates build/deploy/launch/log-copy. The validator DLL detects `OPENWA_REPLAY_TEST` env var and runs a single auto-capture sequence (wait 30s, dump all, ExitProcess). A Claude Code skill wraps the script and reads logs.
8
-
9
- **Tech Stack:** PowerShell, Rust (openwa-validator), Claude Code skills (markdown)
10
-
11
- ---
12
-
13
- ### Task 1: Add `testdata/logs/` to `.gitignore`
14
-
15
- **Files:**
16
- - Modify: `.gitignore`
17
-
18
- **Step 1: Add the gitignore entry**
19
-
20
- Append to `.gitignore`:
21
- ```
22
- testdata/logs/
23
- ```
24
-
25
- **Step 2: Commit**
26
-
27
- ```bash
28
- git add .gitignore
29
- git commit -m "chore: gitignore testdata/logs/"
30
- ```
31
-
32
- ---
33
-
34
- ### Task 2: Add auto-capture mode to validator DLL
35
-
36
- **Files:**
37
- - Modify: `crates/openwa-validator/Cargo.toml` (add `Win32_System_Threading` feature)
38
- - Modify: `crates/openwa-validator/src/lib.rs` (add auto-capture branch in `run_validation()`)
39
-
40
- **Step 1: Add `Win32_System_Threading` feature to Cargo.toml**
41
-
42
- In `crates/openwa-validator/Cargo.toml`, add `"Win32_System_Threading"` to the windows-sys features list. This is needed for `ExitProcess`.
43
-
44
- ```toml
45
- windows-sys = { version = "0.59", features = [
46
- "Win32_System_Memory",
47
- "Win32_System_LibraryLoader",
48
- "Win32_UI_Input_KeyboardAndMouse",
49
- "Win32_System_Threading",
50
- ] }
51
- ```
52
-
53
- **Step 2: Modify `run_validation()` in `crates/openwa-validator/src/lib.rs`**
54
-
55
- After the static validation and hook installation (after step 6 in current code, line ~814), add a branch that checks the env var. Replace the current deferred/hotkey thread spawning (lines 816-856) with:
56
-
57
- ```rust
58
- // 7. Mode-dependent: auto-capture vs interactive
59
- let auto_mode = std::env::var("OPENWA_REPLAY_TEST").is_ok();
60
-
61
- if auto_mode {
62
- let _ = log_line("");
63
- let _ = log_line("--- Auto-Capture Mode (OPENWA_REPLAY_TEST) ---");
64
- std::thread::spawn(move || {
65
- let _ = log_line(" Waiting 30s for replay to reach gameplay...");
66
- std::thread::sleep(std::time::Duration::from_secs(30));
67
-
68
- let _ = log_line(" Running deferred global validation...");
69
- deferred_global_validation();
70
-
71
- let _ = log_line(" Running team block dump...");
72
- dump_team_blocks();
73
-
74
- let _ = log_line(" Running landscape dump...");
75
- dump_landscape();
76
-
77
- let _ = log_line("");
78
- let _ = log_line("--- Auto-capture complete, exiting ---");
79
-
80
- unsafe {
81
- windows_sys::Win32::System::Threading::ExitProcess(0);
82
- }
83
- });
84
- } else {
85
- // Existing interactive mode: deferred timers + hotkey listener
86
- let _ = log_line("");
87
- let _ = log_line("--- Interactive Mode (deferred polling + hotkeys) ---");
88
- std::thread::spawn(move || {
89
- std::thread::sleep(std::time::Duration::from_secs(10));
90
- deferred_global_validation();
91
- });
92
- let _ = log_line(" Polling thread started (10s delay).");
93
-
94
- std::thread::spawn(move || {
95
- std::thread::sleep(std::time::Duration::from_secs(30));
96
- dump_team_blocks();
97
- });
98
- let _ = log_line(" Team block dump thread started (30s delay).");
99
-
100
- std::thread::spawn(move || {
101
- std::thread::sleep(std::time::Duration::from_secs(15));
102
- dump_landscape();
103
- });
104
- let _ = log_line(" Landscape dump thread started (15s delay).");
105
-
106
- std::thread::spawn(|| {
107
- const VK_F9: i32 = 0x78;
108
- const VK_F10: i32 = 0x79;
109
- let _ = log_line(" Hotkey listener started (F9=team blocks, F10=landscape).");
110
- loop {
111
- std::thread::sleep(std::time::Duration::from_millis(100));
112
- unsafe {
113
- if GetAsyncKeyState(VK_F9) & 1 != 0 {
114
- dump_team_blocks();
115
- }
116
- if GetAsyncKeyState(VK_F10) & 1 != 0 {
117
- dump_landscape();
118
- }
119
- }
120
- }
121
- });
122
- }
123
- ```
124
-
125
- **Step 3: Verify it compiles**
126
-
127
- Run: `cargo build --release -p openwa-validator`
128
- Expected: Compiles successfully.
129
-
130
- **Step 4: Commit**
131
-
132
- ```bash
133
- git add crates/openwa-validator/Cargo.toml crates/openwa-validator/src/lib.rs
134
- git commit -m "feat: add auto-capture mode to validator DLL via OPENWA_REPLAY_TEST env var"
135
- ```
136
-
137
- ---
138
-
139
- ### Task 3: Create the PowerShell orchestration script
140
-
141
- **Files:**
142
- - Create: `replay-test.ps1`
143
-
144
- **Step 1: Create `replay-test.ps1`**
145
-
146
- ```powershell
147
- # replay-test.ps1 - Automated replay-based testing
148
- # Usage: powershell -File replay-test.ps1 [replay-file]
149
-
150
- param(
151
- [string]$ReplayFile = "testdata\replays\bots.WAgame"
152
- )
153
-
154
- $ErrorActionPreference = "Stop"
155
-
156
- $gameDir = "I:\games\SteamLibrary\steamapps\common\Worms Armageddon"
157
- $waExe = "$gameDir\WA.exe"
158
- $src = "target\i686-pc-windows-msvc\release"
159
- $logDir = "testdata\logs"
160
-
161
- # Resolve replay file to absolute path
162
- if (-not [System.IO.Path]::IsPathRooted($ReplayFile)) {
163
- $ReplayFile = Join-Path (Get-Location) $ReplayFile
164
- }
165
-
166
- if (-not (Test-Path $ReplayFile)) {
167
- Write-Error "Replay file not found: $ReplayFile"
168
- exit 1
169
- }
170
-
171
- # 1. Build
172
- Write-Host "Building..." -ForegroundColor Cyan
173
- cargo build --release -p openwa-dll -p openwa-validator
174
- if ($LASTEXITCODE -ne 0) { exit 1 }
175
-
176
- # 2. Deploy
177
- Write-Host "Deploying to $gameDir..." -ForegroundColor Cyan
178
- Copy-Item "$src\openwa.dll" "$gameDir\wkOpenWA.dll"
179
- Copy-Item "$src\openwa_validator.dll" "$gameDir\wkOpenWAValidator.dll"
180
-
181
- # 3. Clear old logs
182
- Remove-Item "$gameDir\OpenWA.log" -ErrorAction SilentlyContinue
183
- Remove-Item "$gameDir\OpenWA_validation.log" -ErrorAction SilentlyContinue
184
-
185
- # 4. Launch WA.exe with replay, env var set
186
- Write-Host "Launching WA.exe with replay: $ReplayFile" -ForegroundColor Cyan
187
- Write-Host " (Auto-capture mode: will exit after 30s)" -ForegroundColor Yellow
188
-
189
- $env:OPENWA_REPLAY_TEST = "1"
190
- $proc = Start-Process -FilePath $waExe -ArgumentList "`"$ReplayFile`"" -PassThru
191
- $proc.WaitForExit()
192
- $env:OPENWA_REPLAY_TEST = $null
193
-
194
- Write-Host "WA.exe exited with code $($proc.ExitCode)" -ForegroundColor Cyan
195
-
196
- # 5. Copy logs back
197
- if (-not (Test-Path $logDir)) {
198
- New-Item -ItemType Directory -Path $logDir | Out-Null
199
- }
200
-
201
- $timestamp = Get-Date -Format "yyyy-MM-dd_HH-mm-ss"
202
-
203
- if (Test-Path "$gameDir\OpenWA_validation.log") {
204
- Copy-Item "$gameDir\OpenWA_validation.log" "$logDir\validation_$timestamp.log"
205
- Copy-Item "$gameDir\OpenWA_validation.log" "$logDir\validation_latest.log"
206
- Write-Host "Validation log copied." -ForegroundColor Green
207
- } else {
208
- Write-Host "WARNING: No validation log found!" -ForegroundColor Red
209
- }
210
-
211
- if (Test-Path "$gameDir\OpenWA.log") {
212
- Copy-Item "$gameDir\OpenWA.log" "$logDir\openwa_$timestamp.log"
213
- Copy-Item "$gameDir\OpenWA.log" "$logDir\openwa_latest.log"
214
- Write-Host "OpenWA log copied." -ForegroundColor Green
215
- }
216
-
217
- # 6. Print summary
218
- Write-Host ""
219
- Write-Host "=== Validation Summary ===" -ForegroundColor Cyan
220
- if (Test-Path "$logDir\validation_latest.log") {
221
- $content = Get-Content "$logDir\validation_latest.log" -Raw
222
- $passes = ([regex]::Matches($content, "\[PASS\]")).Count
223
- $fails = ([regex]::Matches($content, "\[FAIL\]")).Count
224
- Write-Host " PASS: $passes" -ForegroundColor Green
225
- Write-Host " FAIL: $fails" -ForegroundColor $(if ($fails -gt 0) { "Red" } else { "Green" })
226
-
227
- if ($content -match "Auto-capture complete") {
228
- Write-Host " Auto-capture: completed successfully" -ForegroundColor Green
229
- } else {
230
- Write-Host " Auto-capture: NOT found in log (may not have reached gameplay)" -ForegroundColor Yellow
231
- }
232
- }
233
- Write-Host " Logs: $logDir\" -ForegroundColor Gray
234
- ```
235
-
236
- **Step 2: Test the script manually**
237
-
238
- Run: `powershell -File replay-test.ps1`
239
-
240
- Expected: Builds, deploys, launches WA.exe with `bots.WAgame`, WA exits after ~30s, logs appear in `testdata/logs/`.
241
-
242
- **Step 3: Commit**
243
-
244
- ```bash
245
- git add replay-test.ps1
246
- git commit -m "feat: add replay-test.ps1 orchestration script"
247
- ```
248
-
249
- ---
250
-
251
- ### Task 4: Create the Claude Code skill
252
-
253
- **Files:**
254
- - Create: `.claude/skills/replay-test.md`
255
-
256
- **Step 1: Create skill directory and file**
257
-
258
- Create `.claude/skills/replay-test.md`:
259
-
260
- ```markdown
261
- ---
262
- name: replay-test
263
- description: Run automated replay-based testing - builds, deploys, launches WA.exe with a replay file, captures validation logs, and presents results. Use when you need to validate struct layouts, hooks, or game state against live WA.exe.
264
- user_invocable: true
265
- ---
266
-
267
- # Replay Test
268
-
269
- Run the replay-based automated testing pipeline.
270
-
271
- ## Steps
272
-
273
- 1. Run the replay test script:
274
-
275
- ```bash
276
- powershell -File replay-test.ps1
277
- ```
278
-
279
- This will:
280
- - Build openwa-dll and openwa-validator
281
- - Deploy DLLs to the WA game directory
282
- - Launch WA.exe with the default replay file (testdata/replays/bots.WAgame)
283
- - Wait for auto-capture (30s) and process exit
284
- - Copy logs to testdata/logs/
285
-
286
- 2. Read the validation log and present results:
287
-
288
- Read `testdata/logs/validation_latest.log` and summarize:
289
- - Total PASS/FAIL counts
290
- - Any [FAIL] lines (quote them exactly)
291
- - Whether auto-capture completed
292
- - Key data from dumps (team blocks, landscape) if present
293
-
294
- 3. If the user also has `testdata/logs/openwa_latest.log`, read it and note any errors or interesting hook activity.
295
-
296
- ## Notes
297
-
298
- - The script takes ~40 seconds total (build + 30s auto-capture wait)
299
- - If WA.exe doesn't exit, the validator DLL may not have loaded. Check that wkOpenWAValidator.dll exists in the game directory.
300
- - Replay file can be changed by editing the script invocation: `powershell -File replay-test.ps1 path\to\other.WAgame`
301
- ```
302
-
303
- **Step 2: Commit**
304
-
305
- ```bash
306
- git add .claude/skills/replay-test.md
307
- git commit -m "feat: add /replay-test Claude Code skill"
308
- ```
309
-
310
- ---
311
-
312
- ### Task 5: End-to-end test
313
-
314
- **Step 1: Run the skill**
315
-
316
- Invoke `/replay-test` and verify the full pipeline works:
317
- - Build succeeds
318
- - WA.exe launches with replay
319
- - WA.exe exits after ~30s
320
- - Logs are copied to `testdata/logs/`
321
- - Validation summary shows PASS/FAIL counts
322
- - Auto-capture complete message is present in log
323
-
324
- **Step 2: Review logs for correctness**
325
-
326
- Check that:
327
- - Static validation (vtables, prologues, struct offsets) all PASS
328
- - Deferred global validation ran (DDGameWrapper vtable check)
329
- - Team block dump shows team data (worm names, health, state)
330
- - Landscape dump shows level data (dimensions, layers)
331
-
332
- **Step 3: Fix any issues found, commit fixes**
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/plans/2026-03-08-replay-fastforward-design.md DELETED
@@ -1,61 +0,0 @@
1
- # Replay Fast-Forward Design
2
-
3
- ## Goal
4
-
5
- Automatically fast-forward through entire WA replays during `OPENWA_REPLAY_TEST=1` mode, enabling comprehensive testing beyond the current 5-second window.
6
-
7
- ## Background
8
-
9
- The current replay test captures only 5 seconds of gameplay — often not enough for a single worm turn. WA advances one turn per spacebar press during replay playback. The game detects key-DOWN transitions (not held state) and has no key repeat, so spacebar must be repeatedly pressed and released.
10
-
11
- WA.exe exits automatically when a replay finishes, so no explicit exit is needed if we can fast-forward through the whole thing.
12
-
13
- ## Approach: Hook GetAsyncKeyState
14
-
15
- Hook `GetAsyncKeyState` from `user32.dll` via MinHook. When the game queries `VK_SPACE` and fast-forward mode is active, return simulated key-down/key-up transitions.
16
-
17
- **Why this over keybd_event/SendInput:**
18
- - Works when WA.exe is minimized (no window focus required)
19
- - Intercepts at the API level regardless of how WA reads the result
20
- - No timing races with the game's message pump
21
- - Existing MinHook infrastructure
22
-
23
- ### Key simulation
24
-
25
- GetAsyncKeyState returns a SHORT:
26
- - Bit 15 (0x8000) = key currently down
27
- - Bit 0 (0x0001) = key pressed since last call
28
-
29
- Toggle between calls:
30
- - Even: `0x8001` (down + transition)
31
- - Odd: `0x0000` (up, no transition)
32
-
33
- All other virtual keys pass through to the real function.
34
-
35
- ### Activation
36
-
37
- 1. DLL loads, detects `OPENWA_REPLAY_TEST=1`
38
- 2. Spawns thread that waits ~3 seconds for game to reach gameplay
39
- 3. Sets atomic flag to enable fast-forward
40
- 4. GetAsyncKeyState hook checks flag on each VK_SPACE query
41
-
42
- ### Validation integration
43
-
44
- Current: thread waits 5s -> validate -> ExitProcess.
45
-
46
- New: thread waits 3s -> enable fast-forward -> wait up to 120s for natural exit. Validation dumps trigger on a timeout (60s) as a safety net. Normally the game exits on its own when the replay finishes.
47
-
48
- ### Script changes
49
-
50
- `replay-test.ps1`: add timeout to `WaitForExit()` (120s). Remove the implicit 5s assumption from comments.
51
-
52
- ## Future: Turn-Start Hook
53
-
54
- Hooking the function that runs at each turn start would enable per-turn assertions and proper unit-test-style validation during replay playback. This is out of scope for this design but is the natural next step.
55
-
56
- ## Files
57
-
58
- - `crates/openwa-dll/src/replacements/input.rs` (new) — GetAsyncKeyState hook
59
- - `crates/openwa-dll/src/validation/mod.rs` — Update auto-capture timeout
60
- - `crates/openwa-dll/src/lib.rs` — Register input module
61
- - `replay-test.ps1` — Add WaitForExit timeout
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/plans/2026-03-19-init-game-state-design.md DELETED
@@ -1,100 +0,0 @@
1
- # DDGame__InitGameState Porting Plan
2
-
3
- ## Overview
4
-
5
- Port `DDGame__InitGameState` (0x526500, 1226 decompiled lines) using the same
6
- incremental strategy as the DDGame constructor: port sub-functions individually
7
- as hooks, test each group, then eventually combine into a full Rust replacement.
8
-
9
- InitGameState is called once after the DDGame constructor. It creates all runtime
10
- game objects: HUD panel, weapon table, team arena, turn game task, camera state,
11
- scoring, alliance data, and more.
12
-
13
- ## Structure
14
-
15
- InitGameState is actually two merged functions:
16
- - Wrapper (0x526500-0x52657B): SEH setup, copies 2 scheme bytes, calls FUN_541620
17
- - Falls through into CTaskGameState__vmethod_7 (0x52657C, ~2000 instructions)
18
-
19
- `param_1` = DDGameWrapper. `param_1+0x488` = DDGame. `DDGame+0x24` = GameInfo/scheme.
20
-
21
- ## Phase 1: Port usercall functions to pure Rust
22
-
23
- All 7 usercall functions are small (<80 lines), mostly pure field writes, and
24
- have zero or minimal dependencies. Porting them eliminates naked asm bridges.
25
-
26
- ### Group A — Zero-dependency functions (port first, test)
27
-
28
- | Function | Address | Lines | What it does |
29
- |---|---|---|---|
30
- | FUN_541620 | 0x541620 | ~15 | Sprite/gfx table init (fastcall ECX,EDX, loop + 3 fields) |
31
- | FUN_541060 | 0x541060 | ~15 | Buffer alloc constructor (EAX=size, ESI=output, calls wa_malloc) |
32
-
33
- ### Group B — Team/scoring init (port, test)
34
-
35
- | Function | Address | Lines | What it does |
36
- |---|---|---|---|
37
- | InitTeamScoring | 0x528510 | ~65 | Pure field writes to CGameTask scoring arrays, zero calls |
38
- | InitAllianceData | 0x5262D0 | ~80 | Alliance bitmask computation, pure field writes + bitmath |
39
-
40
- ### Group C — Turn state + weapon availability (port, test)
41
-
42
- | Function | Address | Lines | What it does |
43
- |---|---|---|---|
44
- | InitTurnState | 0x528690 | ~80 | DDGame turn fields + camera center. Calls FUN_524700 (bridge) |
45
- | FUN_53FFC0 | 0x53FFC0 | ~50 | Weapon availability switch (port FUN_565960 ~20 lines too) |
46
- | FUN_528480 | 0x528480 | ~20 | Post-init: scheme flag + 1 vtable dispatch |
47
-
48
- ### Bridge (defer porting)
49
-
50
- | Function | Address | Lines | Why |
51
- |---|---|---|---|
52
- | FUN_524700 | 0x524700 | ~600 | Scheme-version feature flags. Pure computation but massive. |
53
-
54
- ## Phase 2: Hook stdcall constructors individually
55
-
56
- All verified via RET instruction. Hook each, test in isolation.
57
-
58
- ### Group D — Core game objects
59
-
60
- | Function | Address | RET | Params | Creates |
61
- |---|---|---|---|---|
62
- | HudPanel__Constructor | 0x524070 | 0x4 | 1 | DDGame+0x534 |
63
- | InitWeaponTable | 0x53CAB0 | 0x4 | 1 | Weapon table setup |
64
- | InitTeamsFromSetup | 0x5220B0 | 0x8 | 2 | DDGame+0x4628 team arena |
65
-
66
- ### Group E — Task objects
67
-
68
- | Function | Address | RET | Params | Creates |
69
- |---|---|---|---|---|
70
- | TeamManager__Constructor | 0x563D40 | 0x8 | 2 | DDGame+0x530 |
71
- | CTaskTurnGame__Constructor | 0x55B280 | 0x8 | 2 | Turn game task |
72
- | CTaskGameState__Constructor | 0x532330 | 0x8 | 2 | Game state task |
73
-
74
- ### Group F — Display objects
75
-
76
- | Function | Address | RET | Params | Creates |
77
- |---|---|---|---|---|
78
- | DisplayGfx__Constructor | 0x563FC0 | 0x14 | 5 | Full display gfx |
79
- | DDDisplay__ConstructTextbox | 0x4FAF00 | 0xC | 3 | Textbox |
80
- | FUN_567770 | 0x567770 | 0x4 | 1 | Final conditional display ctor |
81
-
82
- ### Group G — Buffer/stream objects
83
-
84
- | Function | Address | RET | Params | Creates |
85
- |---|---|---|---|---|
86
- | FUN_545FD0 | 0x545FD0 | 0xC | 3 | Buffer object (called twice) |
87
- | FUN_4FB490 | 0x4FB490 | 0x4 | 1 | GameStateStream sub-init |
88
-
89
- ## Testing Strategy
90
-
91
- - Run headful + headless replay tests after each group (A, B, C, D, E, F, G)
92
- - Headless MUST pass (deterministic output match) before proceeding
93
- - Headful validates no crashes, correct rendering, game completion
94
- - Name all functions in Ghidra as they're analyzed
95
-
96
- ## Naming Convention
97
-
98
- Functions ported to Rust: `init_sprite_table`, `init_team_scoring`, etc.
99
- Functions bridged via FFI: `wa_init_feature_flags` (for FUN_524700)
100
- Ghidra labels: `CGameTask__InitTeamScoring`, `CGameTask__InitAllianceData`, etc.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/re-notes/README.md ADDED
@@ -0,0 +1 @@
 
 
1
+ These are notes and documentation generated by Claude during the development of OpenWA. They are already outdated and inaccurate, but might contain something of value.
docs/superpowers/plans/2026-03-16-ddgame-constructor.md DELETED
@@ -1,553 +0,0 @@
1
- # DDGame Constructor Port — Implementation Plan
2
-
3
- > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
4
-
5
- **Goal:** Replace DDGame__Constructor (0x56E220) with a Rust `DDGame::new()` function that creates the 0x98B8-byte game state object field by field, bridging WA sub-calls initially.
6
-
7
- **Architecture:** The constructor allocates DDGame via `wa_malloc(0x98B8)`, zero-fills it, then runs ~35 initialization steps covering param storage, sub-object creation (GfxHandler, PCLandscape, SpriteRegions, CoordList, DisplayGfx objects), resource loading (sprites, gradients, WAVs), and configuration. We'll implement `DDGame::new()` in openwa-core, call it from `construct_ddgame_wrapper` in game_session.rs, and bridge complex sub-calls via typed function pointers.
8
-
9
- **Tech Stack:** Rust (i686-pc-windows-msvc), `wa_alloc` for WA heap, `core::ptr` for field writes, Ghidra MCP for decompilation reference.
10
-
11
- ---
12
-
13
- ## Chunk 1: Foundation — DDGame::new() with Parameter Storage
14
-
15
- ### Task 1: Add Missing Sub-Function Addresses
16
-
17
- **Files:**
18
- - Modify: `crates/openwa-core/src/address.rs`
19
-
20
- - [ ] **Step 1: Add addresses for all sub-functions called by the constructor**
21
-
22
- Add to `address.rs` in the appropriate section:
23
-
24
- ```rust
25
- // --- DDGame Constructor sub-calls ---
26
-
27
- /// DDGame__InitFields (0x526120): usercall(EDI=ddgame), plain RET.
28
- /// Zeroes stride-0x194 table, coordinate entries, calls FUN_00526080.
29
- pub const DDGAME_INIT_FIELDS: u32 = 0x0052_6120; // already exists
30
-
31
- /// FUN_00526080: usercall(ESI=ddgame), plain RET.
32
- /// Initializes RenderQueue index arrays at +0xC4..+0x4A2.
33
- pub const DDGAME_INIT_RENDER_INDICES: u32 = 0x0052_6080;
34
-
35
- /// FUN_00525BE0: stdcall(ddgame_wrapper), plain RET.
36
- /// Sets DDGame+0x7E2E/0x7E2F/0x7E3F flags based on game version/mode.
37
- pub const DDGAME_INIT_VERSION_FLAGS: u32 = 0x0052_5BE0;
38
-
39
- /// FUN_005411A0: unknown convention, called at start.
40
- /// Initializes some CRT / MFC state.
41
- pub const FUN_5411A0: u32 = 0x0054_11A0;
42
-
43
- /// GfxHandler constructor pattern: alloc 0x19C, memset 0, set vtable.
44
- /// vtable at GfxHandler__vtable global.
45
- pub const GFX_HANDLER_VTABLE: u32 = 0x0066_4308; // verify in Ghidra
46
-
47
- /// GfxHandler__LoadDir (called after fopen).
48
- pub const GFX_HANDLER_LOAD_DIR: u32 = 0x0057_0B50; // verify
49
-
50
- /// FUN_00570A90: called before display palette setup (non-headless).
51
- pub const FUN_570A90: u32 = 0x0057_0A90;
52
-
53
- /// FUN_00570E20: called after GfxHandler init.
54
- pub const FUN_570E20: u32 = 0x0057_0E20;
55
-
56
- /// FUN_00570F30: called before DSSound_LoadEffectWAVs.
57
- pub const FUN_570F30: u32 = 0x0057_0F30;
58
-
59
- /// PCLandscape__Constructor (0xB44 bytes).
60
- pub const PC_LANDSCAPE_CONSTRUCTOR: u32 = 0x0050_56F0; // verify
61
-
62
- /// SpriteRegion__Constructor (0x9C bytes).
63
- pub const SPRITE_REGION_CONSTRUCTOR: u32 = 0x0057_DB20;
64
-
65
- /// FUN_005717A0: weapon sprite loading.
66
- pub const LOAD_WEAPON_SPRITES: u32 = 0x0057_17A0;
67
-
68
- /// HUD_LoadWeaponSprites_Maybe.
69
- pub const HUD_LOAD_WEAPON_SPRITES: u32 = 0x0052_4070; // verify address
70
-
71
- /// FUN_004F6300: creates some graphics object, returns ptr or null.
72
- pub const FUN_4F6300: u32 = 0x004F_6300;
73
-
74
- /// FUN_004F6370: initializes TaskStateMachine-like object.
75
- pub const FUN_4F6370: u32 = 0x004F_6370;
76
-
77
- /// FUN_0056A830: called for non-headless display finalization.
78
- pub const FUN_56A830: u32 = 0x0056_A830;
79
-
80
- /// DSSound_LoadEffectWAVs (loads all sound effect WAV files).
81
- pub const DSSOUND_LOAD_EFFECT_WAVS: u32 = 0x0057_1530; // verify
82
-
83
- /// DSSound_LoadAllSpeechBanks.
84
- pub const DSSOUND_LOAD_ALL_SPEECH_BANKS: u32 = 0x0057_1A70;
85
-
86
- /// g_GameInfo global (0x7749A0).
87
- pub const G_GAME_INFO: u32 = 0x0077_49A0;
88
- ```
89
-
90
- - [ ] **Step 2: Verify addresses in Ghidra and label unnamed functions**
91
-
92
- Use Ghidra MCP `batch_create_labels` / `batch_rename_function_components` for all newly identified sub-functions. Cross-reference with the decompile to confirm each address.
93
-
94
- - [ ] **Step 3: Commit**
95
-
96
- ```bash
97
- git add crates/openwa-core/src/address.rs
98
- git commit -m "feat: add DDGame constructor sub-function addresses"
99
- ```
100
-
101
- ### Task 2: Add DDGameWrapper Fields Used by Constructor
102
-
103
- **Files:**
104
- - Modify: `crates/openwa-core/src/engine/ddgame_wrapper.rs`
105
-
106
- The constructor accesses DDGameWrapper at offsets +0x488 (ddgame ptr), +0x48C, +0x490, +0x4C0, +0x4C4, +0x4C8, +0x4CC, +0x4D0, +0x4D8, +0x4DC, +0x4E0. Many of these are sub-object pointers set during the DDGame construction.
107
-
108
- - [ ] **Step 1: Read current DDGameWrapper struct**
109
-
110
- Read `crates/openwa-core/src/engine/ddgame_wrapper.rs` and identify which offsets are already mapped.
111
-
112
- - [ ] **Step 2: Add missing DDGameWrapper fields**
113
-
114
- Add fields for offsets used by the constructor that aren't yet mapped:
115
- - +0x48C: Network-related object pointer (0x2C bytes, conditional)
116
- - +0x490: Network flag byte
117
- - +0x4C0: Primary GfxHandler pointer (0x19C bytes)
118
- - +0x4C4: Secondary GfxHandler pointer (0x19C bytes, conditional)
119
- - +0x4C8: GfxHandler index/flag (uint)
120
- - +0x4CC: PCLandscape pointer (0xB44 bytes)
121
- - +0x4D0: Display pointer (copied from DDGame display)
122
- - +0x4D8: Unknown (set to 0)
123
- - +0x4DC: Calculated height value
124
- - +0x4E0: Set to -100 (0xFFFFFF9C)
125
-
126
- - [ ] **Step 3: Verify with runtime dump**
127
-
128
- Use the F7/validator debug tools to confirm field values at these offsets match expectations.
129
-
130
- - [ ] **Step 4: Commit**
131
-
132
- ```bash
133
- git add crates/openwa-core/src/engine/ddgame_wrapper.rs
134
- git commit -m "feat: add DDGameWrapper fields used by DDGame constructor"
135
- ```
136
-
137
- ### Task 3: Implement DDGame::new() — Phase 1 (Trivial Inits + Param Storage)
138
-
139
- **Files:**
140
- - Modify: `crates/openwa-core/src/engine/ddgame.rs`
141
-
142
- This is the core of the port. Phase 1 covers: allocation, zero-fill, parameter storage, and simple field writes. Complex sub-calls are bridged.
143
-
144
- - [ ] **Step 1: Create DDGame::new() skeleton**
145
-
146
- ```rust
147
- /// Construct a new DDGame, matching DDGame__Constructor (0x56E220).
148
- ///
149
- /// # Safety
150
- /// All pointer params must be valid WA objects. `wrapper` must be
151
- /// a valid DDGameWrapper with fields initialized up to this point.
152
- pub unsafe fn new(
153
- wrapper: *mut DDGameWrapper,
154
- keyboard: *mut DDKeyboard,
155
- display: *mut DDDisplay,
156
- sound: *mut DSSound,
157
- palette: *mut Palette,
158
- music: *mut Music,
159
- param7: *mut u8, // timer? 0x1F4 observed
160
- net_game: *mut u8, // from GameSession
161
- game_info: *mut GameInfo,
162
- network_ecx: u32, // implicit ECX from caller
163
- ) -> *mut DDGame {
164
- // 1. Allocate and zero-fill
165
- let ddgame = wa_malloc(0x98B8) as *mut DDGame;
166
- if ddgame.is_null() {
167
- return core::ptr::null_mut();
168
- }
169
- core::ptr::write_bytes(ddgame as *mut u8, 0, 0x98B8);
170
-
171
- // 2. Call InitFields (usercall EDI=ddgame)
172
- call_init_fields(ddgame);
173
-
174
- // 3. Store at DDGameWrapper+0x488
175
- (*wrapper).ddgame = ddgame;
176
-
177
- // 4. Store constructor parameters
178
- (*ddgame).display = display;
179
- (*ddgame).sound = sound;
180
- (*ddgame).keyboard = keyboard;
181
- (*ddgame).palette = palette;
182
- (*ddgame).music = music;
183
- (*ddgame)._param_018 = param7;
184
- (*ddgame)._caller = network_ecx as *mut u8;
185
- (*ddgame).game_info = game_info;
186
- (*ddgame)._param_028 = net_game;
187
-
188
- // 5. Set global
189
- *(rb(va::G_GAME_INFO) as *mut *mut GameInfo) = game_info;
190
-
191
- // 6. Sound available + always-1 flags
192
- (*ddgame).sound_available =
193
- if (*(game_info as *const u8).add(0xF914) as *const i32).read() == 0 { 1 } else { 0 };
194
- (*ddgame)._field_7efc = 1;
195
-
196
- // ... (Phase 2 sub-calls added incrementally)
197
-
198
- ddgame
199
- }
200
- ```
201
-
202
- - [ ] **Step 2: Implement bridge for DDGame__InitFields**
203
-
204
- InitFields is `usercall(EDI=ddgame)`, plain RET. Create a naked bridge:
205
-
206
- ```rust
207
- /// Bridge to DDGame__InitFields (0x526120).
208
- /// Convention: usercall(EDI=ddgame), plain RET.
209
- #[unsafe(naked)]
210
- unsafe extern "C" fn call_init_fields(_ddgame: *mut DDGame) {
211
- core::arch::naked_asm!(
212
- "pushl %edi",
213
- "movl 8(%esp), %edi", // EDI = ddgame
214
- "calll *({addr})",
215
- "popl %edi",
216
- "retl",
217
- addr = sym INIT_FIELDS_ADDR,
218
- options(att_syntax),
219
- );
220
- }
221
- static mut INIT_FIELDS_ADDR: u32 = 0;
222
- ```
223
-
224
- - [ ] **Step 3: Implement bridge for FUN_00525BE0 (version flags)**
225
-
226
- This is `stdcall(ddgame_wrapper)` — simple transmute call:
227
-
228
- ```rust
229
- /// Bridge to FUN_00525BE0 — sets DDGame+0x7E2E/0x7E2F/0x7E3F flags.
230
- unsafe fn call_init_version_flags(wrapper: *mut DDGameWrapper) {
231
- let f: unsafe extern "stdcall" fn(*mut DDGameWrapper) =
232
- core::mem::transmute(rb(va::DDGAME_INIT_VERSION_FLAGS) as usize);
233
- f(wrapper);
234
- }
235
- ```
236
-
237
- - [ ] **Step 4: Commit**
238
-
239
- ```bash
240
- git add crates/openwa-core/src/engine/ddgame.rs
241
- git commit -m "feat: DDGame::new() phase 1 — allocation, params, trivial inits"
242
- ```
243
-
244
- ### Task 4: Wire DDGame::new() into game_session.rs
245
-
246
- **Files:**
247
- - Modify: `crates/openwa-dll/src/replacements/game_session.rs`
248
-
249
- - [ ] **Step 1: Replace ddgame_constructor_call with DDGame::new()**
250
-
251
- In `construct_ddgame_wrapper`, replace:
252
- ```rust
253
- DDGAME_CTOR_ECX = input_ctrl as u32;
254
- ddgame_constructor_call(
255
- this, display, sound, keyboard, palette, streaming_audio,
256
- timer_obj, net_game, game_info,
257
- );
258
- ```
259
-
260
- With:
261
- ```rust
262
- DDGame::new(
263
- this, keyboard, display, sound, palette, streaming_audio as *mut Music,
264
- timer_obj, net_game, game_info,
265
- input_ctrl as u32,
266
- );
267
- ```
268
-
269
- - [ ] **Step 2: Remove the naked ddgame_constructor_call bridge and DDGAME_CTOR_ECX/DDGAME_CTOR_ADDR statics**
270
-
271
- These are no longer needed. Clean up dead code.
272
-
273
- - [ ] **Step 3: Trap DDGame__Constructor**
274
-
275
- Add to `game_session::install()`:
276
- ```rust
277
- hook::install_trap!("DDGame__Constructor", va::CONSTRUCT_DD_GAME);
278
- ```
279
-
280
- - [ ] **Step 4: Build and test**
281
-
282
- Run: `cargo build --target i686-pc-windows-msvc -p openwa-dll --release`
283
-
284
- This will fail at runtime because DDGame::new() doesn't yet call all the sub-constructors. That's expected — the next tasks add them incrementally.
285
-
286
- - [ ] **Step 5: Commit**
287
-
288
- ```bash
289
- git add crates/openwa-dll/src/replacements/game_session.rs
290
- git commit -m "refactor: wire DDGame::new() into game_session, trap original"
291
- ```
292
-
293
- ---
294
-
295
- ## Chunk 2: Sub-Object Creation Bridges
296
-
297
- ### Task 5: Network Object Init (DDGameWrapper+0x48C)
298
-
299
- **Files:**
300
- - Modify: `crates/openwa-core/src/engine/ddgame.rs`
301
-
302
- - [ ] **Step 1: Add conditional network object creation**
303
-
304
- Condition: `game_info+0xD778 == -2`. Alloc 0x2C bytes, zero-fill, store DDGame ptr at [0], copy 2 config bytes from game_info. If `network_ecx != 0`, store at `network_ecx+0x18`.
305
-
306
- ```rust
307
- // Conditional network object (game_info+0xD778 == -2)
308
- let d778 = *((game_info as *const u8).add(0xD778) as *const i32);
309
- if d778 == -2 {
310
- let net_obj = wa_malloc(0x2C) as *mut u8;
311
- core::ptr::write_bytes(net_obj, 0, 0x2C);
312
- *(net_obj as *mut *mut DDGame) = ddgame;
313
- // piVar3[1..3] = 0, piVar3[6] = 0 (already zero from memset)
314
- *net_obj.add(0x28) = *((*(ddgame as *const u8).add(0x24) as *const *const u8)
315
- .read().add(0xD944));
316
- *net_obj.add(0x29) = *((*(ddgame as *const u8).add(0x24) as *const *const u8)
317
- .read().add(0xD946));
318
- // Store at DDGameWrapper+0x48C
319
- *(wrapper as *mut u8).add(0x48C).cast::<*mut u8>().write(net_obj);
320
- if network_ecx != 0 {
321
- *((network_ecx as *mut u8).add(0x18) as *mut *mut u8) = net_obj;
322
- }
323
- }
324
- ```
325
-
326
- - [ ] **Step 2: Test with replay test**
327
-
328
- Run headful replay test — game should not crash during init.
329
-
330
- - [ ] **Step 3: Commit**
331
-
332
- ### Task 6: GfxHandler Init + Gfx.dir Loading
333
-
334
- **Files:**
335
- - Modify: `crates/openwa-core/src/engine/ddgame.rs`
336
-
337
- This is a complex section that opens Gfx.dir files, tries multiple paths, and conditionally creates a second GfxHandler. For now, bridge entirely.
338
-
339
- - [ ] **Step 1: Create bridge for GfxHandler creation and Gfx.dir loading**
340
-
341
- Extract the entire GfxHandler init section (lines 151-250 of decompile) into a single bridged call. This section operates on DDGameWrapper fields (+0x4C0, +0x4C4, +0x4C8), not DDGame directly, so we can bridge it as one unit.
342
-
343
- Option A: Call the original constructor section via a calculated offset.
344
- Option B: Reimplement the file-open logic in Rust (fopen/GfxHandler__LoadDir are simple).
345
-
346
- Start with Option A (bridge) and convert later.
347
-
348
- - [ ] **Step 2: Test**
349
- - [ ] **Step 3: Commit**
350
-
351
- ### Task 7: Audio Init (Sound Effects + Speech + ActiveSoundTable)
352
-
353
- **Files:**
354
- - Modify: `crates/openwa-core/src/engine/ddgame.rs`
355
-
356
- - [ ] **Step 1: Add audio initialization**
357
-
358
- Condition: `game_info+0xF914 == 0` (not headless) AND `ddgame.sound != null`.
359
-
360
- ```rust
361
- if *((game_info as *const u8).add(0xF914) as *const i32) == 0 {
362
- call_fun_570f30(); // bridge
363
- if !(*ddgame).sound.is_null() {
364
- call_dssound_load_effect_wavs(); // bridge
365
- call_dssound_load_all_speech_banks(); // bridge
366
- // Allocate ActiveSoundTable (0x608 bytes)
367
- let ast = wa_malloc(0x608) as *mut ActiveSoundTable;
368
- (*ast).ddgame = ddgame;
369
- (*ast).counter = 0;
370
- core::ptr::write_bytes(ast as *mut u8, 0, 0x600); // zero entries only
371
- (*ddgame).active_sounds = ast;
372
- }
373
- }
374
- ```
375
-
376
- Note: DSSound_LoadEffectWAVs and DSSound_LoadAllSpeechBanks are already known addresses. The ActiveSoundTable allocation can be done in pure Rust since we have the struct.
377
-
378
- - [ ] **Step 2: Test with headful replay**
379
- - [ ] **Step 3: Commit**
380
-
381
- ### Task 8: PCLandscape + TaskStateMachine + SpriteRegions
382
-
383
- **Files:**
384
- - Modify: `crates/openwa-core/src/engine/ddgame.rs`
385
-
386
- - [ ] **Step 1: Bridge PCLandscape__Constructor**
387
-
388
- Alloc 0xB44, zero-fill, call PCLandscape constructor (complex, many params). Store at DDGameWrapper+0x4CC and DDGame+0x020.
389
-
390
- - [ ] **Step 2: Bridge TaskStateMachine creation**
391
-
392
- Alloc 0x2C, zero-fill, call FUN_004F6370 for init, set vtable. Store at DDGame+0x380.
393
-
394
- - [ ] **Step 3: Bridge 8× SpriteRegion creation**
395
-
396
- Each: alloc 0x9C, zero-fill, call SpriteRegion__Constructor with (param1, param2). Store at DDGame+0x46C..0x488 (note: these are offsets 0x46C, 0x470, 0x474, 0x478, 0x47C, 0x480, 0x484, 0x488 — the 8 sprite_regions).
397
-
398
- SpriteRegion params (from decompile):
399
- | DDGame offset | param1 | param2 |
400
- |---------------|--------|--------|
401
- | +0x474 | 0x24 | 0x2E |
402
- | +0x46C | 0x07 | 0x2D |
403
- | +0x470 | 0x0A | 0x0D |
404
- | +0x478 | 0x20 | 0x00 |
405
- | +0x47C | 0x00 | 0x00 |
406
- | +0x484 | 0x09 | 0x173 |
407
- | +0x488 | 0x07 | 0x1E5 |
408
- | +0x480 | 0x07 | 0x2D |
409
-
410
- - [ ] **Step 4: Set landscape-derived value at +0x468**
411
-
412
- ```rust
413
- // (*ddgame).landscape_val = landscape.vtable[0xB].call()
414
- let landscape = (*ddgame).landscape;
415
- let vtable = *(landscape as *const *const u32);
416
- let get_val: unsafe extern "thiscall" fn(*mut u8) -> u32 =
417
- core::mem::transmute(*vtable.add(0xB));
418
- (*ddgame)._landscape_val = get_val(landscape as *mut u8) as *mut u8;
419
- ```
420
-
421
- - [ ] **Step 5: Test**
422
- - [ ] **Step 6: Commit**
423
-
424
- ### Task 9: Arrow Sprites + Collision Regions (32 iterations)
425
-
426
- **Files:**
427
- - Modify: `crates/openwa-core/src/engine/ddgame.rs`
428
-
429
- - [ ] **Step 1: Bridge arrow sprite loading loop**
430
-
431
- The loop loads 32 `arrow##.img` files, creates DisplayGfx objects, and creates SpriteRegion collision regions. This is complex graphics code — bridge the entire loop initially.
432
-
433
- Create a helper that encapsulates the loop body using GfxHandler vtable calls.
434
-
435
- - [ ] **Step 2: Test**
436
- - [ ] **Step 3: Commit**
437
-
438
- ### Task 10: CoordList, DisplayGfx, Resource Loading
439
-
440
- **Files:**
441
- - Modify: `crates/openwa-core/src/engine/ddgame.rs`
442
-
443
- - [ ] **Step 1: Create CoordList in Rust**
444
-
445
- Pure Rust — this is simple allocation:
446
- ```rust
447
- // CoordList: 3 u32s (count, capacity, data_ptr) + 0x12C0 buffer
448
- let coord_list = wa_malloc(12) as *mut u32;
449
- *coord_list = 0; // count
450
- *coord_list.add(1) = 600; // capacity
451
- let coord_data = wa_malloc(0x12C0) as *mut u8;
452
- core::ptr::write_bytes(coord_data, 0, 0x12C0);
453
- *coord_list.add(2) = coord_data as u32;
454
- (*ddgame).coord_list = coord_list as *mut u8;
455
- ```
456
-
457
- - [ ] **Step 2: Bridge coordinate list population from landscape**
458
-
459
- The loop reads from landscape data and inserts unique entries.
460
-
461
- - [ ] **Step 3: Bridge DisplayGfx at +0x138**
462
-
463
- Alloc 0x2C, init via FUN_004F6370, set vtable to DisplayGfx__vtable.
464
-
465
- - [ ] **Step 4: Bridge weapon sprite loading (FUN_005717A0 ×2)**
466
- - [ ] **Step 5: Bridge gradient/layer/fill image loading**
467
- - [ ] **Step 6: Bridge HUD weapon sprite loading**
468
- - [ ] **Step 7: Set fill pixel at +0x7338**
469
- - [ ] **Step 8: Bridge display finalization calls**
470
- - [ ] **Step 9: Test with full headful replay**
471
- - [ ] **Step 10: Commit**
472
-
473
- ---
474
-
475
- ## Chunk 3: Integration and Verification
476
-
477
- ### Task 11: DDGameWrapper Field Updates
478
-
479
- **Files:**
480
- - Modify: `crates/openwa-core/src/engine/ddgame.rs`
481
-
482
- - [ ] **Step 1: Add DDGameWrapper field writes that happen during the constructor**
483
-
484
- The original constructor writes to many DDGameWrapper fields:
485
- - +0x4D8 = 0
486
- - +0x4DC = calculated from param8+0x44C: `byte_val * 0x38 + 0x7E + 0x2AD`
487
- - +0x4E0 = -100 (0xFFFFFF9C)
488
- - +0x6EE8 = 0 (speech name count)
489
-
490
- - [ ] **Step 2: Commit**
491
-
492
- ### Task 12: Full Integration Test
493
-
494
- - [ ] **Step 1: Run headful replay test**
495
-
496
- ```bash
497
- powershell -ExecutionPolicy Bypass -File replay-test.ps1
498
- ```
499
-
500
- Expected: Game plays through replay without crashes. Verify audio, visuals, and turn transitions.
501
-
502
- - [ ] **Step 2: Run headless replay test**
503
-
504
- ```bash
505
- powershell -ExecutionPolicy Bypass -File replay-test.ps1 -Headless
506
- ```
507
-
508
- Expected: Byte-identical log output compared to baseline.
509
-
510
- - [ ] **Step 3: Manual gameplay test**
511
-
512
- Start a local match, play 2-3 turns. Verify:
513
- - Worm sprites render correctly
514
- - Weapons fire and explode
515
- - Sound effects play
516
- - Turn order widget shows
517
- - Camera follows active worm
518
-
519
- - [ ] **Step 4: Update Ghidra labels for all newly identified functions**
520
-
521
- Use `batch_create_labels` for any remaining unnamed functions.
522
-
523
- - [ ] **Step 5: Final commit**
524
-
525
- ```bash
526
- git add -A
527
- git commit -m "feat: replace DDGame__Constructor with Rust DDGame::new()"
528
- ```
529
-
530
- ---
531
-
532
- ## Notes
533
-
534
- ### Bridging Strategy
535
-
536
- For sub-calls that use unusual calling conventions (usercall with EDI/ESI), use `#[unsafe(naked)]` bridge functions matching the pattern in `game_session.rs`. For plain stdcall/thiscall sub-calls, use `core::mem::transmute` to typed function pointers.
537
-
538
- ### Order Matters
539
-
540
- The init steps MUST execute in the exact order from the decompile. Some sub-calls depend on fields set by earlier steps (e.g., PCLandscape reads game_info from DDGame+0x24, GfxHandler is used by arrow sprite loading).
541
-
542
- ### Testing Cadence
543
-
544
- After each Task, run a headful replay test. If anything crashes, check ERRORLOG.TXT and compare against the decompile to find which field or sub-call was missed.
545
-
546
- ### Future: Bridge Elimination (Phase 3)
547
-
548
- Once all bridges are working, each can be independently converted to pure Rust:
549
- 1. InitFields → direct field writes (already have the offset list)
550
- 2. FUN_00526080 → Rust loop for render index arrays
551
- 3. ActiveSoundTable → already done in Task 7
552
- 4. CoordList → already done in Task 10
553
- 5. SpriteRegion / PCLandscape / GfxHandler → larger projects, future plans
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/superpowers/plans/2026-03-19-replay-loader.md DELETED
@@ -1,980 +0,0 @@
1
- # ReplayLoader Port Implementation Plan
2
-
3
- > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development
4
- > (if subagents available) or superpowers:executing-plans to implement this plan.
5
- > Steps use checkbox (`- [ ]`) syntax for tracking.
6
-
7
- **Goal:** Port ReplayLoader (0x462DF0) and ParseReplayPosition (0x4E3490) from
8
- WA.exe to Rust, enabling .WAgame file parsing in Rust code.
9
-
10
- **Architecture:** Hook-and-replace strategy. ReplayLoader is stdcall(2 params),
11
- ~1800 lines. We decompose it into: (1) a ReplayStream cursor abstraction porting
12
- the 6 usercall stream helpers, (2) ParseReplayPosition as a standalone pure
13
- function, (3) the main ReplayLoader function replacing incrementally — header
14
- first, then payload parsing, delegating unimplemented modes to the original via
15
- trampoline.
16
-
17
- **Tech Stack:** Rust (i686-pc-windows-msvc), MinHook, openwa-core types, WA.exe
18
- FFI bridges for complex sub-functions.
19
-
20
- **Spec:** `docs/superpowers/specs/2026-03-19-replay-loader-design.md`
21
-
22
- ---
23
-
24
- ## File Structure
25
-
26
- | File | Action | Responsibility |
27
- |------|--------|---------------|
28
- | `crates/openwa-core/src/engine/replay.rs` | Create | ReplayStream struct, ParseReplayPosition, replay data types |
29
- | `crates/openwa-core/src/engine/mod.rs` | Modify | Add `pub mod replay` |
30
- | `crates/openwa-core/src/address.rs` | Modify | Add replay global addresses |
31
- | `crates/openwa-dll/src/replacements/replay.rs` | Create | Hook installation, ReplayLoader replacement |
32
- | `crates/openwa-dll/src/replacements/mod.rs` | Modify | Add `mod replay` + `replay::install()?` |
33
-
34
- ---
35
-
36
- ## Task 1: Add Replay Addresses to address.rs
37
-
38
- **Files:**
39
- - Modify: `crates/openwa-core/src/address.rs`
40
-
41
- - [ ] **Step 1: Add replay global addresses**
42
-
43
- In the `va` module (top-level function addresses section), the addresses
44
- `REPLAY_LOADER` (0x462DF0) and `PARSE_REPLAY_POSITION` (0x4E3490) already exist.
45
- Add the remaining replay-related globals and helper function addresses:
46
-
47
- ```rust
48
- // In the va module, near the existing REPLAY_LOADER entries:
49
-
50
- /// Stream helper: read length-prefixed string. usercall(EDI=ctx) + stdcall(dest, max_len). RET 0x8.
51
- pub const REPLAY_READ_BYTES: u32 = 0x0046_1340;
52
- /// Stream helper: read byte with range validation. usercall(EAX=ctx) + stdcall(dest, min, max). RET 0xC.
53
- pub const REPLAY_READ_BYTE_VALIDATED: u32 = 0x0046_14D0;
54
- /// Stream helper: read byte with signed range validation. usercall(EAX=ctx) + stdcall(dest, min, max). RET 0xC.
55
- pub const REPLAY_READ_BYTE_RANGE: u32 = 0x0046_1540;
56
- /// Stream helper: read u16 with range validation. usercall(EAX=ctx) + stdcall(dest, min, max). RET 0xC.
57
- pub const REPLAY_READ_U16_VALIDATED: u32 = 0x0046_15B0;
58
- /// Stream helper: read worm name (0x11 bytes or length-prefixed). usercall(EAX=ctx) + thiscall(this, flag). RET 0x4.
59
- pub const REPLAY_READ_WORM_NAME: u32 = 0x0046_1620;
60
- /// Validate team type byte range. fastcall(ECX=type). Plain RET.
61
- pub const REPLAY_VALIDATE_TEAM_TYPE: u32 = 0x0046_1690;
62
- /// Post-process team color assignments. stdcall(1 param). RET 0x4.
63
- pub const REPLAY_PROCESS_TEAM_COLORS: u32 = 0x0046_6460;
64
- /// Apply scheme default values. No params (uses globals). Plain RET.
65
- pub const REPLAY_PROCESS_SCHEME_DEFAULTS: u32 = 0x0046_70F0;
66
- /// Process replay feature flags. No params (uses globals). Plain RET.
67
- pub const REPLAY_PROCESS_FLAGS: u32 = 0x0046_7280;
68
- /// Register observer team entry. stdcall(1 param). RET 0x4.
69
- pub const REPLAY_REGISTER_OBSERVER: u32 = 0x0046_7BC0;
70
- /// Process alliance/team setup. No params (uses globals). Plain RET.
71
- pub const REPLAY_PROCESS_ALLIANCE: u32 = 0x0046_8890;
72
- /// Validate team configuration. stdcall(1 param).
73
- pub const REPLAY_VALIDATE_TEAM_SETUP: u32 = 0x0046_5E10;
74
- ```
75
-
76
- Add replay-related global data addresses:
77
-
78
- ```rust
79
- // In a globals section (or near existing DAT_ references):
80
-
81
- /// Global game state struct passed to ReplayLoader. Always 0x87D3F8.
82
- pub const G_REPLAY_STATE: u32 = 0x0087_D3F8;
83
- /// Team header data buffer (0x5728 bytes), cleared by ReplayLoader.
84
- pub const G_TEAM_HEADER_DATA: u32 = 0x0087_79E4;
85
- /// Secondary team/game data buffer (0xD9DC bytes).
86
- pub const G_TEAM_SECONDARY_DATA: u32 = 0x0087_D438;
87
- /// XOR'd game ID (payload ^ 0xEF5B5C49).
88
- pub const G_REPLAY_GAME_ID: u32 = 0x0088_AF50;
89
- /// Replay sub-format flag.
90
- pub const G_REPLAY_SUB_FORMAT: u32 = 0x0088_AF54;
91
- /// Game version ID from replay.
92
- pub const G_REPLAY_VERSION_ID: u32 = 0x0088_ABB0;
93
- /// Scheme present flag (1 = has scheme data).
94
- pub const G_REPLAY_SCHEME_PRESENT: u32 = 0x0088_AE0C;
95
- /// Scheme defaults buffer (0x6E bytes).
96
- pub const G_SCHEME_DEFAULTS_BUF: u32 = 0x0088_DC04;
97
- /// Version/ArtClass counter. ReplayLoader fails if > 0x33.
98
- pub const G_ARTCLASS_COUNTER: u32 = 0x0088_C790;
99
- /// Random seed global.
100
- pub const G_RANDOM_SEED: u32 = 0x0088_D0B4;
101
- /// Saved random seed.
102
- pub const G_SAVED_RANDOM_SEED: u32 = 0x0088_ABAC;
103
- /// Replay filename buffer.
104
- pub const G_REPLAY_FILENAME: u32 = 0x0088_AF58;
105
- ```
106
-
107
- - [ ] **Step 2: Label all replay functions in Ghidra**
108
-
109
- Use `batch_rename_function_components` to name all replay-related functions:
110
- - `FUN_00461340` → `Replay__ReadPrefixedString`
111
- - `FUN_004614D0` → `Replay__ReadByteValidated`
112
- - `FUN_00461540` → `Replay__ReadByteRange`
113
- - `FUN_004615B0` → `Replay__ReadU16Validated`
114
- - `FUN_00461620` → `Replay__ReadWormName`
115
- - `FUN_00461690` → `Replay__ValidateTeamType`
116
- - `FUN_00466460` → `Replay__ProcessTeamColors`
117
- - `FUN_004670F0` → `Replay__ProcessSchemeDefaults`
118
- - `FUN_00467280` → `Replay__ProcessReplayFlags`
119
- - `FUN_00467BC0` → `Replay__RegisterObserver`
120
- - `FUN_00468890` → `Replay__ProcessAllianceData`
121
- - `FUN_00465E10` → `Replay__ValidateTeamSetup`
122
-
123
- - [ ] **Step 3: Commit**
124
-
125
- ```bash
126
- git add crates/openwa-core/src/address.rs
127
- git commit -m "feat: add replay system addresses for stream helpers and globals"
128
- ```
129
-
130
- ---
131
-
132
- ## Task 2: Create ReplayStream and Port Stream Helpers
133
-
134
- **Files:**
135
- - Create: `crates/openwa-core/src/engine/replay.rs`
136
- - Modify: `crates/openwa-core/src/engine/mod.rs`
137
-
138
- The 6 stream helper functions all operate on a shared 3-DWORD context:
139
- `[data_ptr: *const u8, total_size: u32, cursor: u32]`. Port them all as methods
140
- on a Rust `ReplayStream` struct.
141
-
142
- - [ ] **Step 1: Define ReplayStream struct and error type**
143
-
144
- ```rust
145
- // crates/openwa-core/src/engine/replay.rs
146
-
147
- /// Error codes returned by ReplayLoader.
148
- #[repr(i32)]
149
- #[derive(Debug, Clone, Copy, PartialEq, Eq)]
150
- pub enum ReplayError {
151
- FileNotFound = -1, // 0xFFFFFFFF
152
- InvalidFormat = -2, // 0xFFFFFFFE
153
- VersionTooNew = -3, // 0xFFFFFFFD
154
- MallocFailure = -4, // 0xFFFFFFFC
155
- MapLoadFailure = -5, // 0xFFFFFFFB
156
- ArtClassLimit = -6, // 0xFFFFFFFA
157
- RepairWithTeams = -7, // 0xFFFFFFF9
158
- NoScheme = -9, // 0xFFFFFFF7
159
- SchemeSaveFailure = -10, // 0xFFFFFFF6
160
- }
161
-
162
- /// Replay file magic: "WA" in little-endian.
163
- pub const REPLAY_MAGIC: u16 = 0x4157;
164
- /// Maximum supported replay version.
165
- pub const REPLAY_MAX_VERSION: u16 = 0x13;
166
- /// XOR key for game ID integrity check.
167
- pub const REPLAY_XOR_KEY: u32 = 0xEF5B_5C49;
168
-
169
- /// Cursor over a replay payload byte buffer.
170
- ///
171
- /// Mirrors the 3-DWORD stream context used by WA's stream helper functions:
172
- /// `[data_ptr, total_size, cursor_offset]`.
173
- pub struct ReplayStream<'a> {
174
- data: &'a [u8],
175
- cursor: usize,
176
- }
177
-
178
- impl<'a> ReplayStream<'a> {
179
- pub fn new(data: &'a [u8]) -> Self {
180
- Self { data, cursor: 0 }
181
- }
182
-
183
- pub fn remaining(&self) -> usize {
184
- self.data.len().saturating_sub(self.cursor)
185
- }
186
-
187
- pub fn cursor(&self) -> usize {
188
- self.cursor
189
- }
190
-
191
- /// Advance cursor by `n` bytes, returning the slice. Fails if not enough data.
192
- fn advance(&mut self, n: usize) -> Result<&'a [u8], ReplayError> {
193
- let end = self.cursor + n;
194
- if end > self.data.len() {
195
- return Err(ReplayError::InvalidFormat);
196
- }
197
- let slice = &self.data[self.cursor..end];
198
- self.cursor = end;
199
- Ok(slice)
200
- }
201
-
202
- /// Read a single byte. Port of parts of FUN_004614D0 / FUN_00461540.
203
- pub fn read_u8(&mut self) -> Result<u8, ReplayError> {
204
- let slice = self.advance(1)?;
205
- Ok(slice[0])
206
- }
207
-
208
- /// Read a single byte, validating it is in [min, max]. Port of FUN_004614D0.
209
- pub fn read_u8_validated(&mut self, min: u8, max: u8) -> Result<u8, ReplayError> {
210
- let val = self.read_u8()?;
211
- if val < min || val > max {
212
- return Err(ReplayError::InvalidFormat);
213
- }
214
- Ok(val)
215
- }
216
-
217
- /// Read a little-endian u16. Part of FUN_004615B0.
218
- pub fn read_u16(&mut self) -> Result<u16, ReplayError> {
219
- let slice = self.advance(2)?;
220
- Ok(u16::from_le_bytes([slice[0], slice[1]]))
221
- }
222
-
223
- /// Read a little-endian u16, validating range. Port of FUN_004615B0.
224
- pub fn read_u16_validated(&mut self, min: u16, max: u16) -> Result<u16, ReplayError> {
225
- let val = self.read_u16()?;
226
- if val < min || val > max {
227
- return Err(ReplayError::InvalidFormat);
228
- }
229
- Ok(val)
230
- }
231
-
232
- /// Read a little-endian u32.
233
- pub fn read_u32(&mut self) -> Result<u32, ReplayError> {
234
- let slice = self.advance(4)?;
235
- Ok(u32::from_le_bytes([slice[0], slice[1], slice[2], slice[3]]))
236
- }
237
-
238
- /// Read a little-endian i32.
239
- pub fn read_i32(&mut self) -> Result<i32, ReplayError> {
240
- Ok(self.read_u32()? as i32)
241
- }
242
-
243
- /// Read `n` bytes into a destination buffer. Fails if not enough data.
244
- pub fn read_into(&mut self, dest: &mut [u8]) -> Result<(), ReplayError> {
245
- let slice = self.advance(dest.len())?;
246
- dest.copy_from_slice(slice);
247
- Ok(())
248
- }
249
-
250
- /// Read a length-prefixed string into buffer. Port of FUN_00461340.
251
- ///
252
- /// Reads 1 byte (length), then `length` bytes of string data, null-terminates.
253
- /// `max_len` is the destination buffer capacity (excluding null).
254
- pub fn read_prefixed_string(&mut self, dest: &mut [u8]) -> Result<usize, ReplayError> {
255
- let len = self.read_u8()? as usize;
256
- if len > dest.len().saturating_sub(1) {
257
- return Err(ReplayError::InvalidFormat);
258
- }
259
- let slice = self.advance(len)?;
260
- dest[..len].copy_from_slice(slice);
261
- dest[len] = 0; // null-terminate
262
- Ok(len)
263
- }
264
-
265
- /// Read a worm name: either 0x11 fixed bytes or length-prefixed. Port of FUN_00461620.
266
- pub fn read_worm_name(&mut self, dest: &mut [u8; 0x11], use_fixed: bool) -> Result<(), ReplayError> {
267
- if use_fixed {
268
- let slice = self.advance(0x11)?;
269
- dest.copy_from_slice(slice);
270
- } else {
271
- dest.fill(0);
272
- self.read_prefixed_string(dest)?;
273
- }
274
- Ok(())
275
- }
276
-
277
- /// Skip `n` bytes.
278
- pub fn skip(&mut self, n: usize) -> Result<(), ReplayError> {
279
- self.advance(n)?;
280
- Ok(())
281
- }
282
- }
283
-
284
- /// Validate team type byte. Port of FUN_00461690.
285
- ///
286
- /// Returns true if the team type value is in a valid range:
287
- /// - Non-negative: 0..12 (0 through 12 exclusive → type < 13)
288
- /// - Negative: -100 or absolute value < 100
289
- pub fn validate_team_type(team_type: i8) -> bool {
290
- if team_type >= 0 {
291
- team_type < 13
292
- } else {
293
- team_type == -100 || -(team_type as i32) <= 100
294
- }
295
- }
296
- ```
297
-
298
- - [ ] **Step 2: Add module declaration**
299
-
300
- In `crates/openwa-core/src/engine/mod.rs`, add:
301
- ```rust
302
- pub mod replay;
303
- ```
304
-
305
- And in the re-exports section if desired (or leave access via `engine::replay::`).
306
-
307
- - [ ] **Step 3: Write unit tests for ReplayStream**
308
-
309
- Add tests at the bottom of `replay.rs`:
310
-
311
- ```rust
312
- #[cfg(test)]
313
- mod tests {
314
- use super::*;
315
-
316
- #[test]
317
- fn test_read_u8() {
318
- let data = [0x42, 0xFF];
319
- let mut s = ReplayStream::new(&data);
320
- assert_eq!(s.read_u8().unwrap(), 0x42);
321
- assert_eq!(s.read_u8().unwrap(), 0xFF);
322
- assert!(s.read_u8().is_err());
323
- }
324
-
325
- #[test]
326
- fn test_read_u8_validated() {
327
- let data = [5, 0, 20];
328
- let mut s = ReplayStream::new(&data);
329
- assert_eq!(s.read_u8_validated(0, 10).unwrap(), 5);
330
- assert_eq!(s.read_u8_validated(0, 10).unwrap(), 0);
331
- assert!(s.read_u8_validated(0, 10).is_err()); // 20 > 10
332
- }
333
-
334
- #[test]
335
- fn test_read_u16_le() {
336
- let data = [0x57, 0x41]; // "WA" = 0x4157
337
- let mut s = ReplayStream::new(&data);
338
- assert_eq!(s.read_u16().unwrap(), 0x4157);
339
- }
340
-
341
- #[test]
342
- fn test_read_u32_le() {
343
- let data = [0x78, 0x56, 0x34, 0x12];
344
- let mut s = ReplayStream::new(&data);
345
- assert_eq!(s.read_u32().unwrap(), 0x12345678);
346
- }
347
-
348
- #[test]
349
- fn test_read_prefixed_string() {
350
- let data = [3, b'f', b'o', b'o', 0, b'x']; // len=3, "foo"
351
- let mut s = ReplayStream::new(&data);
352
- let mut buf = [0u8; 16];
353
- let len = s.read_prefixed_string(&mut buf).unwrap();
354
- assert_eq!(len, 3);
355
- assert_eq!(&buf[..4], b"foo\0");
356
- assert_eq!(s.cursor(), 4);
357
- }
358
-
359
- #[test]
360
- fn test_read_worm_name_fixed() {
361
- let mut data = [0u8; 0x11];
362
- data[0] = b'W';
363
- data[1] = b'o';
364
- data[2] = b'r';
365
- data[3] = b'm';
366
- let mut s = ReplayStream::new(&data);
367
- let mut name = [0u8; 0x11];
368
- s.read_worm_name(&mut name, true).unwrap();
369
- assert_eq!(name[0], b'W');
370
- assert_eq!(s.cursor(), 0x11);
371
- }
372
-
373
- #[test]
374
- fn test_validate_team_type() {
375
- assert!(validate_team_type(0));
376
- assert!(validate_team_type(12));
377
- assert!(!validate_team_type(13));
378
- assert!(validate_team_type(-1));
379
- assert!(validate_team_type(-99));
380
- assert!(validate_team_type(-100));
381
- assert!(!validate_team_type(-101));
382
- }
383
- }
384
- ```
385
-
386
- - [ ] **Step 4: Run tests**
387
-
388
- ```bash
389
- cargo test -p openwa-core -- replay
390
- ```
391
-
392
- Expected: all tests pass.
393
-
394
- - [ ] **Step 5: Commit**
395
-
396
- ```bash
397
- git add crates/openwa-core/src/engine/replay.rs crates/openwa-core/src/engine/mod.rs
398
- git commit -m "feat: add ReplayStream cursor and stream helper ports"
399
- ```
400
-
401
- ---
402
-
403
- ## Task 3: Port ParseReplayPosition
404
-
405
- **Files:**
406
- - Modify: `crates/openwa-core/src/engine/replay.rs`
407
-
408
- Port the `ParseReplayPosition` function (0x4E3490) as pure Rust. This converts
409
- time strings like `"1:30.25"` to frame counts at 50fps.
410
-
411
- - [ ] **Step 1: Write failing tests**
412
-
413
- Add to the test module in `replay.rs`:
414
-
415
- ```rust
416
- #[test]
417
- fn test_parse_replay_position_seconds() {
418
- assert_eq!(parse_replay_position(b"30\0"), 30 * 50);
419
- }
420
-
421
- #[test]
422
- fn test_parse_replay_position_minutes_seconds() {
423
- assert_eq!(parse_replay_position(b"1:30\0"), (60 + 30) * 50);
424
- }
425
-
426
- #[test]
427
- fn test_parse_replay_position_with_frames() {
428
- assert_eq!(parse_replay_position(b"1:30.5\0"), (60 + 30) * 50 + 25);
429
- }
430
-
431
- #[test]
432
- fn test_parse_replay_position_zero() {
433
- assert_eq!(parse_replay_position(b"0\0"), 0);
434
- }
435
-
436
- #[test]
437
- fn test_parse_replay_position_invalid() {
438
- assert_eq!(parse_replay_position(b"abc\0"), -1);
439
- }
440
-
441
- #[test]
442
- fn test_parse_replay_position_seconds_over_59() {
443
- // After a colon, seconds > 59 is invalid
444
- assert_eq!(parse_replay_position(b"1:60\0"), -1);
445
- }
446
- ```
447
-
448
- - [ ] **Step 2: Run tests to verify they fail**
449
-
450
- ```bash
451
- cargo test -p openwa-core -- parse_replay
452
- ```
453
-
454
- Expected: FAIL (function not defined).
455
-
456
- - [ ] **Step 3: Implement parse_replay_position**
457
-
458
- Add to `replay.rs`:
459
-
460
- ```rust
461
- /// Parse a replay position time string to frame count (50fps).
462
- ///
463
- /// Format: `[MM:]SS[.FF]` where:
464
- /// - MM = minutes (multiplied by 60)
465
- /// - SS = seconds (max 59 after a colon)
466
- /// - FF = fractional (multiplied by 50 for frames, divided by power of 10)
467
- /// - Maximum 2 colons allowed
468
- ///
469
- /// Returns frame count, or -1 on parse error.
470
- /// Port of WA.exe ParseReplayPosition (0x4E3490).
471
- pub fn parse_replay_position(input: &[u8]) -> i32 {
472
- let mut colon_count: i32 = 0;
473
- let mut accumulated: i32 = 0;
474
- let mut current: i32 = 0;
475
- let mut digit_count: i32 = 0;
476
- let mut frac_divisor: i32 = 0; // 0 = integer part, >0 = fractional
477
- let mut i = 0;
478
-
479
- loop {
480
- if i >= input.len() {
481
- return -1;
482
- }
483
- let ch = input[i];
484
-
485
- // Process digit runs
486
- while i < input.len() && input[i] >= b'0' && input[i] <= b'9' {
487
- let max_digits = if colon_count < 1 { 4 } else { 2 };
488
- if digit_count >= max_digits {
489
- return -1;
490
- }
491
- if frac_divisor == 0 {
492
- current = current * 10 + (input[i] - b'0') as i32;
493
- digit_count += 1;
494
- } else {
495
- let frac_value = ((input[i] - b'0') as i32 * 50) / frac_divisor;
496
- frac_divisor *= 10;
497
- accumulated += frac_value;
498
- }
499
- i += 1;
500
- }
501
-
502
- // Check delimiter
503
- if i >= input.len() {
504
- return -1;
505
- }
506
- let delim = input[i];
507
-
508
- if delim != b':' && delim != b'.' && delim != 0 {
509
- return -1;
510
- }
511
-
512
- if frac_divisor == 0 {
513
- // Integer part complete
514
- if colon_count > 0 && current > 59 {
515
- return -1;
516
- }
517
- accumulated = current + accumulated * 60;
518
- current = 0;
519
- digit_count = 0;
520
-
521
- if delim == b':' {
522
- if colon_count >= 2 {
523
- return -1;
524
- }
525
- colon_count += 1;
526
- } else {
527
- // '.' or '\0' — convert seconds to frames
528
- accumulated *= 50;
529
- }
530
- } else {
531
- // Fractional part — ':' and '.' are invalid after '.'
532
- if delim == b':' || delim == b'.' {
533
- return -1;
534
- }
535
- }
536
-
537
- if delim == 0 {
538
- return accumulated;
539
- }
540
-
541
- if delim == b'.' {
542
- frac_divisor = 10;
543
- }
544
-
545
- i += 1;
546
- }
547
- }
548
- ```
549
-
550
- **Note:** This must match the original's behavior exactly. The decompilation shows
551
- the algorithm accumulates digits, multiplies by 60 at each colon, and by 50 at
552
- the final conversion. Fractional part: each digit contributes `(digit * 50) / 10^position`.
553
-
554
- - [ ] **Step 4: Run tests**
555
-
556
- ```bash
557
- cargo test -p openwa-core -- parse_replay
558
- ```
559
-
560
- Expected: all pass. If any fail, adjust to match the original's exact logic.
561
-
562
- - [ ] **Step 5: Commit**
563
-
564
- ```bash
565
- git add crates/openwa-core/src/engine/replay.rs
566
- git commit -m "feat: port ParseReplayPosition to Rust"
567
- ```
568
-
569
- ---
570
-
571
- ## Task 4: Hook Installation — Passthrough + ParseReplayPosition Replacement
572
-
573
- **Files:**
574
- - Create: `crates/openwa-dll/src/replacements/replay.rs`
575
- - Modify: `crates/openwa-dll/src/replacements/mod.rs`
576
-
577
- Install hooks for both functions. ReplayLoader starts as a passthrough (call
578
- original, log params). ParseReplayPosition is fully replaced.
579
-
580
- - [ ] **Step 1: Create replay.rs hook file**
581
-
582
- ```rust
583
- // crates/openwa-dll/src/replacements/replay.rs
584
-
585
- use crate::hook;
586
- use crate::log_line;
587
- use openwa_core::address::va;
588
- use openwa_core::engine::replay;
589
-
590
- static mut REPLAY_LOADER_ORIG: *const () = core::ptr::null();
591
- static mut PARSE_POSITION_ORIG: *const () = core::ptr::null();
592
-
593
- // ReplayLoader: stdcall(param_1: u32, mode: i32) -> u32. RET 0x8.
594
- unsafe extern "stdcall" fn hook_replay_loader(param_1: u32, mode: i32) -> u32 {
595
- let _ = log_line(&format!(
596
- "[Replay] ReplayLoader param_1=0x{param_1:08X} mode={mode}"
597
- ));
598
- let orig: unsafe extern "stdcall" fn(u32, i32) -> u32 =
599
- core::mem::transmute(REPLAY_LOADER_ORIG);
600
- let result = orig(param_1, mode);
601
- let _ = log_line(&format!(
602
- "[Replay] ReplayLoader returned 0x{result:08X}"
603
- ));
604
- result
605
- }
606
-
607
- // ParseReplayPosition: stdcall(input: *const u8) -> i32. RET 0x4.
608
- unsafe extern "stdcall" fn hook_parse_replay_position(input: *const u8) -> i32 {
609
- // Build a safe slice from the C string
610
- let mut len = 0usize;
611
- while *input.add(len) != 0 {
612
- len += 1;
613
- if len > 256 { break; } // safety limit
614
- }
615
- let slice = core::slice::from_raw_parts(input, len + 1); // include null
616
- replay::parse_replay_position(slice)
617
- }
618
-
619
- pub fn install() -> Result<(), String> {
620
- unsafe {
621
- REPLAY_LOADER_ORIG = hook::install(
622
- "ReplayLoader",
623
- va::REPLAY_LOADER,
624
- hook_replay_loader as *const (),
625
- )? as *const ();
626
-
627
- // ParseReplayPosition — full replacement, trampoline stored but unused
628
- PARSE_POSITION_ORIG = hook::install(
629
- "ParseReplayPosition",
630
- va::PARSE_REPLAY_POSITION,
631
- hook_parse_replay_position as *const (),
632
- )? as *const ();
633
- }
634
- Ok(())
635
- }
636
- ```
637
-
638
- - [ ] **Step 2: Register in mod.rs**
639
-
640
- In `crates/openwa-dll/src/replacements/mod.rs`, add:
641
- - `mod replay;` in the module list
642
- - `replay::install()?;` in `install_all()`
643
-
644
- - [ ] **Step 3: Build**
645
-
646
- ```bash
647
- cargo build --release -p openwa-dll
648
- ```
649
-
650
- Expected: compiles successfully.
651
-
652
- - [ ] **Step 4: Run headful replay test**
653
-
654
- ```bash
655
- powershell -ExecutionPolicy Bypass -File replay-test.ps1
656
- ```
657
-
658
- Expected: game loads replay, plays through. Check `testdata/logs/openwa_latest.log`
659
- for `[Replay] ReplayLoader` log lines confirming the hook fires and returns 0.
660
-
661
- - [ ] **Step 5: Run headless replay test**
662
-
663
- ```bash
664
- powershell -ExecutionPolicy Bypass -File replay-test.ps1 -Headless
665
- ```
666
-
667
- Expected: PASS (byte-identical output). This confirms ParseReplayPosition's Rust
668
- port produces the same frame numbers as the original.
669
-
670
- - [ ] **Step 6: Commit**
671
-
672
- ```bash
673
- git add crates/openwa-dll/src/replacements/replay.rs crates/openwa-dll/src/replacements/mod.rs
674
- git commit -m "feat: hook ReplayLoader (passthrough) and replace ParseReplayPosition"
675
- ```
676
-
677
- ---
678
-
679
- ## Task 5: ReplayLoader Skeleton — Mode Routing + Header Parsing
680
-
681
- **Files:**
682
- - Modify: `crates/openwa-dll/src/replacements/replay.rs`
683
- - Modify: `crates/openwa-core/src/engine/replay.rs`
684
-
685
- Replace the passthrough with a mode-routing skeleton. Mode 1 (play) enters Rust;
686
- other modes delegate to original. Start with header parsing only.
687
-
688
- **Resource cleanup:** The original uses SEH try/catch to ensure fclose + free on
689
- error. Our Rust replacement must use a guard pattern:
690
-
691
- ```rust
692
- /// RAII guard for file handle and malloc'd buffer cleanup.
693
- struct ReplayGuard {
694
- file: *mut FILE,
695
- payload: *mut u8,
696
- }
697
- impl Drop for ReplayGuard {
698
- fn drop(&mut self) {
699
- unsafe {
700
- if !self.payload.is_null() {
701
- free(self.payload as *mut core::ffi::c_void);
702
- }
703
- if !self.file.is_null() {
704
- fclose(self.file);
705
- }
706
- }
707
- }
708
- }
709
- ```
710
-
711
- - [ ] **Step 1: Add CRT FFI declarations and ReplayGuard**
712
-
713
- Add to `replay.rs` (wormkit crate) or a shared location:
714
- - `extern "cdecl"` declarations for `fopen`, `fread`, `fclose`, `fseek`,
715
- `malloc`, `free`, `memset`, `memcpy`, `SetCurrentDirectoryA`
716
- - `ReplayGuard` struct with `Drop` impl
717
-
718
- - [ ] **Step 2: Implement mode routing + header**
719
-
720
- ```rust
721
- unsafe extern "stdcall" fn hook_replay_loader(param_1: u32, mode: i32) -> u32 {
722
- // Modes 2, 3, 4: delegate to original
723
- if mode != 1 {
724
- let orig: unsafe extern "stdcall" fn(u32, i32) -> u32 =
725
- core::mem::transmute(REPLAY_LOADER_ORIG);
726
- return orig(param_1, mode);
727
- }
728
-
729
- // Mode 1 (play): Rust implementation
730
- match replay_loader_play(param_1) {
731
- Ok(()) => 0,
732
- Err(e) => e as u32,
733
- }
734
- }
735
- ```
736
-
737
- Header parsing in `replay_loader_play`:
738
- 1. Check `DAT_0088c790 > 0x33` → return ArtClassLimit
739
- 2. Set `param_1+0xDB48 = 1`, `param_1+0xEF60 = 0`
740
- 3. `SetCurrentDirectoryA` to game data dir
741
- 4. `fopen(param_1+0xDB60, "rb")` → create ReplayGuard
742
- 5. Read 4 bytes: validate magic `0x4157`, extract version from upper 16 bits
743
- 6. Validate version ≤ 0x13
744
- 7. Store version at `param_1+0xDB50`, `param_1+0xDB54`, set `param_1+0xDB58 = 0xFFFFFFFF`
745
- 8. Read 4-byte payload size, validate against file size
746
- 9. malloc payload, fread into it → store in ReplayGuard
747
-
748
- On any error, `ReplayGuard::drop` handles cleanup automatically.
749
-
750
- - [ ] **Step 3: Run headful replay test**
751
-
752
- ```bash
753
- powershell -ExecutionPolicy Bypass -File replay-test.ps1
754
- ```
755
-
756
- If game crashes, debug calling convention. If it loads but errors, check header
757
- field offsets.
758
-
759
- - [ ] **Step 4: Commit**
760
-
761
- ```bash
762
- git add crates/openwa-dll/src/replacements/replay.rs crates/openwa-core/src/engine/replay.rs
763
- git commit -m "feat: ReplayLoader mode routing + header parsing in Rust"
764
- ```
765
-
766
- ---
767
-
768
- ## Task 6: Version 2+ Payload Parsing — Scheme + Sub-version
769
-
770
- **Files:**
771
- - Modify: `crates/openwa-dll/src/replacements/replay.rs`
772
- - Modify: `crates/openwa-core/src/engine/replay.rs`
773
-
774
- Parse the first portion of the version 2+ payload: sub-version flags, game version
775
- ID, and scheme data. Version 1 (legacy) delegates to original.
776
-
777
- - [ ] **Step 1: Parse sub-version flags and format indicators**
778
-
779
- After reading the payload into a `ReplayStream`:
780
- 1. Read secondary payload size + data
781
- 2. Parse sub-version flags, set globals (`DAT_0088AF54`, `DAT_0088AF41` etc.)
782
- 3. Read game version ID → `DAT_0088ABB0`, validate < 0x1F8
783
- 4. Read scheme presence flag → `DAT_0088AE0C`
784
-
785
- - [ ] **Step 2: Parse scheme data**
786
-
787
- If scheme present:
788
- 1. Read `SCHM` magic (4 bytes = `0x4D484353`), validate
789
- 2. Read scheme version byte (1, 2, or 3)
790
- 3. Call scheme reader helper via FFI bridge (`FUN_004613D0`)
791
- 4. For version 3: call `Scheme__ValidateExtendedOptions` via existing bridge
792
- 5. Copy scheme defaults from `SCHEME_V3_DEFAULTS` where needed
793
-
794
- - [ ] **Step 3: Run headful + headless tests**
795
-
796
- ```bash
797
- powershell -ExecutionPolicy Bypass -File replay-test.ps1
798
- powershell -ExecutionPolicy Bypass -File replay-test.ps1 -Headless
799
- ```
800
-
801
- - [ ] **Step 4: Commit**
802
-
803
- ```bash
804
- git add crates/openwa-dll/src/replacements/replay.rs crates/openwa-core/src/engine/replay.rs
805
- git commit -m "feat: ReplayLoader version 2+ scheme parsing"
806
- ```
807
-
808
- ---
809
-
810
- ## Task 7: Version 2+ Payload Parsing — Team Data
811
-
812
- **Files:**
813
- - Modify: `crates/openwa-dll/src/replacements/replay.rs`
814
-
815
- Parse the team data section. This is the largest parsing section: 6 teams ×
816
- 0xD7B stride with per-worm names and weapon data blocks.
817
-
818
- - [ ] **Step 1: Observer team loop**
819
-
820
- Read observer/spectator player names (up to 0xD entries, stride 0x78) into global
821
- buffers. Uses `read_prefixed_string` for names.
822
-
823
- - [ ] **Step 2: Main team iteration**
824
-
825
- For each of 6 team slots (stride 0xD7B at `0x878120`):
826
- 1. Read team flag byte — if zero, skip (empty slot)
827
- 2. Read team type byte, validate via `validate_team_type`
828
- 3. Read 5 sub-fields via stream helpers
829
- 4. Read worm alliance byte
830
- 5. Per-worm names: 8 worms × 0x11 bytes (fixed for version ≥ 10, else prefixed)
831
- 6. Worm count (validate 1-8), color, flag, grave, soundbank bytes
832
- 7. Team name (0x40 bytes), config name (0x40 bytes)
833
- 8. Weapon data: `memcpy` 0x400 + 0x154 + 0x400 + 0x300 bytes into global buffers
834
-
835
- Write all data to the same global buffer addresses the original uses.
836
-
837
- - [ ] **Step 3: Post-team validation**
838
-
839
- 1. Validate team count matches header
840
- 2. XOR integrity: `DAT_0088AF50 = payload_dword ^ 0xEF5B5C49`
841
- 3. Call `ProcessTeamColors` (0x466460, stdcall 1 param) via FFI
842
- 4. Call `Scheme__CheckWeaponLimits` via existing bridge
843
-
844
- - [ ] **Step 4: Call remaining processing functions**
845
-
846
- Bridge calls to:
847
- - `ProcessSchemeDefaults` (0x4670F0, no params, plain RET)
848
- - `ProcessReplayFlags` (0x467280, no params, plain RET)
849
- - `ProcessAllianceData` (0x468890, no params, plain RET)
850
- - `ValidateTeamSetup` (0x465E10)
851
-
852
- - [ ] **Step 5: Run headful + headless tests**
853
-
854
- ```bash
855
- powershell -ExecutionPolicy Bypass -File replay-test.ps1
856
- powershell -ExecutionPolicy Bypass -File replay-test.ps1 -Headless
857
- ```
858
-
859
- Headful: game must load and play correctly.
860
- Headless: validates team/scheme data was written correctly.
861
-
862
- - [ ] **Step 6: Commit**
863
-
864
- ```bash
865
- git add crates/openwa-dll/src/replacements/replay.rs
866
- git commit -m "feat: ReplayLoader team data parsing"
867
- ```
868
-
869
- ---
870
-
871
- ## Task 8: Post-Parse + Map Loading (Section 4)
872
-
873
- **Files:**
874
- - Modify: `crates/openwa-dll/src/replacements/replay.rs`
875
-
876
- - [ ] **Step 1: Port post-parse logic**
877
-
878
- 1. Write payload to `data\playback.thm` via fopen/fwrite/fclose
879
- 2. Call map loader (`FUN_00490890`) via FFI bridge
880
- 3. Artclass index lookup via table at `0x6ACD38`
881
- 4. Version flag setup for compatibility
882
-
883
- - [ ] **Step 2: Run headful + headless tests**
884
-
885
- ```bash
886
- powershell -ExecutionPolicy Bypass -File replay-test.ps1
887
- powershell -ExecutionPolicy Bypass -File replay-test.ps1 -Headless
888
- ```
889
-
890
- - [ ] **Step 3: Commit**
891
-
892
- ```bash
893
- git add crates/openwa-dll/src/replacements/replay.rs
894
- git commit -m "feat: ReplayLoader post-parse and map loading"
895
- ```
896
-
897
- ---
898
-
899
- ## Task 9: Log Output (Section 5)
900
-
901
- **Files:**
902
- - Modify: `crates/openwa-dll/src/replacements/replay.rs`
903
-
904
- Port the /getlog formatted output (~600 lines). This must match byte-for-byte
905
- for headless replay testing.
906
-
907
- **Strategy:** Use WA's own string resource and formatting functions via FFI.
908
- Don't reimplement string resource loading or codepage conversion — bridge them.
909
-
910
- - [ ] **Step 1: Port version/game info header lines**
911
-
912
- 1. Date/time via `_gmtime64` (CRT)
913
- 2. Version string lookup from tables at `0x6AB480` / `0x6AB7A4`
914
- 3. Format "Game:" and "Replay:" lines using `fprintf` or WA's format helpers
915
- 4. WA version line ("3.8.1")
916
-
917
- - [ ] **Step 2: Port team listing**
918
-
919
- 1. Per-team line with flag letter (uppercased via codepage)
920
- 2. Team name in quotes, padded for alignment
921
- 3. Player name assignment
922
- 4. CPU teams: difficulty float
923
- 5. Human teams: host/first markers
924
- 6. Observer teams (negative team type)
925
-
926
- - [ ] **Step 3: Run headless test after each sub-section**
927
-
928
- ```bash
929
- powershell -ExecutionPolicy Bypass -File replay-test.ps1 -Headless
930
- ```
931
-
932
- Compare output byte-for-byte. Fix any differences.
933
-
934
- - [ ] **Step 4: Run full test suite**
935
-
936
- ```bash
937
- powershell -ExecutionPolicy Bypass -File replay-test.ps1
938
- powershell -ExecutionPolicy Bypass -File replay-test.ps1 -Headless
939
- ```
940
-
941
- Both must pass.
942
-
943
- - [ ] **Step 5: Commit**
944
-
945
- ```bash
946
- git add crates/openwa-dll/src/replacements/replay.rs
947
- git commit -m "feat: ReplayLoader log output formatting"
948
- ```
949
-
950
- ---
951
-
952
- ## Task 10: Version 1 Legacy Path (Optional/Deferred)
953
-
954
- **Files:**
955
- - Modify: `crates/openwa-dll/src/replacements/replay.rs`
956
-
957
- Version 1 replays are from pre-3.5 WA. If encountered, the current implementation
958
- would need to delegate to the original. This task ports the version 1 path.
959
-
960
- **Deferred:** Only implement if a version 1 test replay is available.
961
-
962
- - [ ] **Step 1: Find or create a version 1 test replay**
963
- - [ ] **Step 2: Port version 1 parsing path**
964
- - [ ] **Step 3: Test with version 1 replay**
965
- - [ ] **Step 4: Commit**
966
-
967
- ---
968
-
969
- ---
970
-
971
- ## Verification Checklist
972
-
973
- After Tasks 1-9:
974
- - [ ] Headful replay test passes (all validations)
975
- - [ ] Headless replay test passes (byte-identical output)
976
- - [ ] `cargo test -p openwa-core -- replay` passes (unit tests)
977
- - [ ] No regressions in existing tests
978
- - [ ] ReplayLoader uses Rust path for mode 1 (play)
979
- - [ ] ParseReplayPosition fully replaced (no trampoline calls)
980
- - [ ] All replay helper functions named in Ghidra
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/superpowers/plans/2026-04-01-sprite-parser.md DELETED
@@ -1,1041 +0,0 @@
1
- # Sprite Parser Implementation Plan
2
-
3
- > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
-
5
- **Goal:** Port WA's `.spr` sprite parser to Rust — a clean `ParsedSprite` type for tooling, plus hook replacements for `ConstructSprite` and `ProcessSprite`.
6
-
7
- **Architecture:** A shared `parse_spr_header()` function extracts metadata and data offsets from raw `.spr` bytes. `ParsedSprite::parse()` builds on it to produce an owned Rust struct with `Vec` data. The hook replacements use `parse_spr_header()` directly, operating on the raw buffer in-place for palette remapping and bitmap index translation.
8
-
9
- **Tech Stack:** Rust, `openwa-core` (types/parser), `openwa-dll` (hooks), `minhook` (hooking), WA.exe runtime (PaletteContext__MapColor)
10
-
11
- ---
12
-
13
- ## File Structure
14
-
15
- | File | Responsibility |
16
- |---|---|
17
- | `crates/openwa-core/src/render/spr.rs` (create) | `SprHeader`, `SprError`, `parse_spr_header()`, `ParsedSprite`, `ParsedSprite::parse()` |
18
- | `crates/openwa-core/src/render/mod.rs` (modify) | Add `pub mod spr;` and re-exports |
19
- | `crates/openwa-core/src/address.rs` (modify) | Add `PALETTE_CONTEXT_MAP_COLOR`, `DISPLAYGFX_VTABLE` |
20
- | `crates/openwa-dll/src/replacements/sprite.rs` (create) | Hook trampolines + `install()` for ConstructSprite, ProcessSprite |
21
- | `crates/openwa-dll/src/replacements/mod.rs` (modify) | Wire `sprite::install()` |
22
-
23
- ## Reference: .spr Binary Format
24
-
25
- Derived from Ghidra disassembly of ProcessSprite (0x4FAB80). All offsets relative to start of raw data.
26
-
27
- ```
28
- +0x00 u32 (unused / magic — not validated by ProcessSprite)
29
- +0x04 u32 data_size — total payload size, added to global counter
30
- +0x08 u16 header_flags — stored at sprite+0x14
31
- +0x0A u16 palette_entry_count
32
- +0x0C 3*N palette RGB entries (3 bytes each: R, G, B)
33
-
34
- [After palette, if header_flags & 0x4000:]
35
- u16 secondary_frame_count
36
- align cursor to 4-byte boundary (relative to data start)
37
- N*12 secondary SpriteFrame entries (12 bytes each)
38
-
39
- [Main frame header — 12 bytes:]
40
- u16 unknown_08 → sprite+0x08
41
- u16 fps → sprite+0x0A
42
- u16 flags → sprite+0x10
43
- u16 width → sprite+0x0C
44
- u16 height → sprite+0x0E
45
- u16 frame_count_raw → sprite+0x12/0x16 (negative = scaling)
46
-
47
- align cursor to 4-byte boundary (relative to data start)
48
- frame_count * 12 bytes of SpriteFrame metadata
49
- remaining bytes: bitmap pixel data (8-bit palette indices)
50
- ```
51
-
52
- **Scaling:** If frame_count_raw has bit 15 set (negative as i16):
53
- - `scale_x = ((frame_count_raw >> 8) & 0x7F) << 16 >> 5` (arithmetic shift)
54
- - `scale_y = (frame_count_raw & 0x7F) << 16 >> 5`
55
- - Actual frame_count = 1, is_scaled = true
56
-
57
- **Palette remapping (WA runtime only):**
58
- - `palette_data_ptr[0] = 0` (transparent index)
59
- - For each entry 1..N: `palette_data_ptr[i] = PaletteContext__MapColor(this=ECX, rgb_u32)`
60
- - Then ALL bitmap bytes remapped: `pixel = palette_data_ptr[pixel]`
61
- - Bitmap remapping only happens when header_flags & 0x4000 is NOT set
62
-
63
- ## Reference: Key WA Functions
64
-
65
- | Address | Convention | Description |
66
- |---|---|---|
67
- | 0x4FAA30 | usercall(EAX=sprite, ECX=context), plain RET | ConstructSprite |
68
- | 0x4FAB80 | usercall(EAX=sprite, ECX=palette_ctx) + 1 stack(raw_data), RET 0x4 | ProcessSprite |
69
- | 0x5412B0 | thiscall(ECX=palette_ctx, stack=rgb_u32), RET 0x4, returns u8 | PaletteContext__MapColor |
70
- | 0x66418C | data | Sprite vtable address |
71
- | 0x664144 | data | DisplayGfx vtable address |
72
-
73
- ---
74
-
75
- ### Task 1: Add Address Constants
76
-
77
- **Files:**
78
- - Modify: `crates/openwa-core/src/address.rs`
79
-
80
- - [ ] **Step 1: Add PALETTE_CONTEXT_MAP_COLOR and DISPLAYGFX_VTABLE**
81
-
82
- In `address.rs`, find the `class "Sprite"` block and add the DisplayGfx vtable. Find the DDDisplay section and add the palette function:
83
-
84
- ```rust
85
- // In the class "Sprite" block, after PROCESS_SPRITE:
86
- // (no change needed here, but add DisplayGfx vtable nearby)
87
-
88
- // Add to the DDDisplay section (near PALETTE_CONTEXT_INIT):
89
- /// PaletteContext__MapColor — thiscall(ECX=ctx, stack=rgb), RET 0x4, returns u8
90
- fn/Thiscall PALETTE_CONTEXT_MAP_COLOR = 0x0054_12B0;
91
-
92
- // Add DisplayGfx vtable as a standalone entry near SPRITE_VTABLE:
93
- // In the class "Sprite" block:
94
- /// DisplayGfx vtable (embedded in Sprite at +0x34)
95
- vtable DISPLAYGFX_VTABLE = 0x0066_4144;
96
- ```
97
-
98
- Specifically, in the `class "Sprite"` block after `fn/Usercall PROCESS_SPRITE`, add:
99
-
100
- ```rust
101
- /// DisplayGfx vtable (embedded in Sprite at +0x34)
102
- vtable DISPLAYGFX_VTABLE = 0x0066_4144;
103
- ```
104
-
105
- And near `PALETTE_CONTEXT_INIT` in the DDDisplay section, add:
106
-
107
- ```rust
108
- /// PaletteContext__MapColor — thiscall(palette_ctx, rgb_u32), returns nearest palette index
109
- fn/Thiscall PALETTE_CONTEXT_MAP_COLOR = 0x0054_12B0;
110
- ```
111
-
112
- - [ ] **Step 2: Verify it compiles**
113
-
114
- Run: `cargo build -p openwa-core 2>&1 | head -5`
115
- Expected: Compiles without errors.
116
-
117
- - [ ] **Step 3: Commit**
118
-
119
- ```bash
120
- git add crates/openwa-core/src/address.rs
121
- git commit -m "feat: add PALETTE_CONTEXT_MAP_COLOR and DISPLAYGFX_VTABLE addresses"
122
- ```
123
-
124
- ---
125
-
126
- ### Task 2: Create spr.rs — SprHeader and parse_spr_header()
127
-
128
- **Files:**
129
- - Create: `crates/openwa-core/src/render/spr.rs`
130
- - Modify: `crates/openwa-core/src/render/mod.rs`
131
-
132
- - [ ] **Step 1: Write the test for parse_spr_header**
133
-
134
- Create `crates/openwa-core/src/render/spr.rs` with the error type, header struct, and a test:
135
-
136
- ```rust
137
- //! `.spr` sprite format parser.
138
- //!
139
- //! WA's sprite format stores paletted animated sprites with per-frame metadata.
140
- //! This module provides both a clean Rust parser (`ParsedSprite`) for tooling
141
- //! and shared metadata extraction (`parse_spr_header`) used by WormKit hook
142
- //! replacements.
143
-
144
- use crate::render::sprite::SpriteFrame;
145
-
146
- /// Errors from `.spr` parsing.
147
- #[derive(Debug)]
148
- pub enum SprError {
149
- /// Data too short to contain required fields.
150
- TooShort {
151
- expected: usize,
152
- actual: usize,
153
- },
154
- /// Internal structure is inconsistent.
155
- InvalidData(&'static str),
156
- }
157
-
158
- impl core::fmt::Display for SprError {
159
- fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
160
- match self {
161
- SprError::TooShort { expected, actual } => {
162
- write!(f, "spr data too short: need {} bytes, got {}", expected, actual)
163
- }
164
- SprError::InvalidData(msg) => write!(f, "invalid spr data: {}", msg),
165
- }
166
- }
167
- }
168
-
169
- /// Parsed metadata and byte offsets into raw `.spr` data.
170
- ///
171
- /// Produced by `parse_spr_header`. Contains all scalar fields and the byte
172
- /// offsets needed to locate palette, frame, and bitmap data within the raw
173
- /// buffer. No heap allocation — just arithmetic on the input slice.
174
- #[derive(Debug, Clone)]
175
- pub struct SprHeader {
176
- /// Total payload size from `.spr` header (+0x04). Used for global counter.
177
- pub data_size: u32,
178
- /// Header flags from `.spr` (+0x08).
179
- pub header_flags: u16,
180
- /// Number of RGB palette entries.
181
- pub palette_count: u16,
182
- /// Unknown field stored at sprite+0x08.
183
- pub unknown_08: u16,
184
- /// Animation speed (frames per second).
185
- pub fps: u16,
186
- /// Sprite flags.
187
- pub flags: u16,
188
- /// Sprite width in pixels.
189
- pub width: u16,
190
- /// Sprite height in pixels.
191
- pub height: u16,
192
- /// Frame count (1 if scaled).
193
- pub frame_count: u16,
194
- /// Max frames (same as frame_count after processing).
195
- pub max_frames: u16,
196
- /// Scale X: `((raw >> 8 & 0x7F) << 16) >> 5`, or 0 if not scaled.
197
- pub scale_x: u32,
198
- /// Scale Y: `((raw & 0x7F) << 16) >> 5`, or 0 if not scaled.
199
- pub scale_y: u32,
200
- /// True if the frame_count word encoded scaling instead of a count.
201
- pub is_scaled: bool,
202
- /// Number of secondary frames (0 if header_flags & 0x4000 is clear).
203
- pub secondary_frame_count: u16,
204
- /// Byte offset of RGB palette data (3 bytes per entry).
205
- pub palette_offset: usize,
206
- /// Byte offset of secondary frame table (0 if none).
207
- pub secondary_frame_offset: usize,
208
- /// Byte offset of main SpriteFrame metadata array.
209
- pub frame_meta_offset: usize,
210
- /// Byte offset of bitmap pixel data.
211
- pub bitmap_offset: usize,
212
- /// Size of bitmap pixel data in bytes.
213
- pub bitmap_size: usize,
214
- }
215
-
216
- /// Align `offset` up to the next 4-byte boundary.
217
- fn align4(offset: usize) -> usize {
218
- (offset + 3) & !3
219
- }
220
-
221
- /// Parse `.spr` header and compute data region offsets.
222
- ///
223
- /// This is a pure function with no WA dependencies. Both `ParsedSprite::parse`
224
- /// and the WormKit ProcessSprite hook build on this.
225
- pub fn parse_spr_header(data: &[u8]) -> Result<SprHeader, SprError> {
226
- // Minimum: 4 (unused) + 4 (data_size) + 2 (header_flags) + 2 (palette_count) = 12
227
- if data.len() < 12 {
228
- return Err(SprError::TooShort { expected: 12, actual: data.len() });
229
- }
230
-
231
- let data_size = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
232
- let header_flags = u16::from_le_bytes([data[8], data[9]]);
233
- let palette_count = u16::from_le_bytes([data[10], data[11]]);
234
-
235
- let palette_offset = 12; // 0x0C
236
- let palette_end = palette_offset + palette_count as usize * 3;
237
-
238
- if data.len() < palette_end {
239
- return Err(SprError::TooShort { expected: palette_end, actual: data.len() });
240
- }
241
-
242
- let mut cursor = palette_end;
243
-
244
- // Secondary frame table (if header_flags & 0x4000)
245
- let mut secondary_frame_count: u16 = 0;
246
- let mut secondary_frame_offset: usize = 0;
247
-
248
- if header_flags & 0x4000 != 0 {
249
- if data.len() < cursor + 2 {
250
- return Err(SprError::TooShort { expected: cursor + 2, actual: data.len() });
251
- }
252
- secondary_frame_count = u16::from_le_bytes([data[cursor], data[cursor + 1]]);
253
- cursor = align4(cursor + 2);
254
- secondary_frame_offset = cursor;
255
- cursor += secondary_frame_count as usize * 12;
256
- }
257
-
258
- // Main frame header (12 bytes)
259
- if data.len() < cursor + 12 {
260
- return Err(SprError::TooShort { expected: cursor + 12, actual: data.len() });
261
- }
262
-
263
- let unknown_08 = u16::from_le_bytes([data[cursor], data[cursor + 1]]);
264
- let fps = u16::from_le_bytes([data[cursor + 2], data[cursor + 3]]);
265
- let flags = u16::from_le_bytes([data[cursor + 4], data[cursor + 5]]);
266
- let width = u16::from_le_bytes([data[cursor + 6], data[cursor + 7]]);
267
- let height = u16::from_le_bytes([data[cursor + 8], data[cursor + 9]]);
268
- let frame_count_raw = u16::from_le_bytes([data[cursor + 10], data[cursor + 11]]);
269
-
270
- cursor += 12;
271
-
272
- // Scale processing
273
- let (frame_count, max_frames, scale_x, scale_y, is_scaled) =
274
- if (frame_count_raw as i16) < 0 {
275
- let sx = (((frame_count_raw >> 8) & 0x7F) as u32) << 16 >> 5;
276
- let sy = ((frame_count_raw & 0x7F) as u32) << 16 >> 5;
277
- (1u16, 1u16, sx, sy, true)
278
- } else {
279
- (frame_count_raw, frame_count_raw, 0u32, 0u32, false)
280
- };
281
-
282
- // Frame metadata (aligned to 4 bytes)
283
- let frame_meta_offset = align4(cursor);
284
- let bitmap_offset = frame_meta_offset + frame_count as usize * 12;
285
-
286
- // bitmap_size: remaining bytes from data_size
287
- // ProcessSprite computes: data_size - (bitmap_offset - data_start)
288
- // But data_size is measured from offset +4, and data_start is offset 0.
289
- // Actually WA computes: bitmap_dword_count = (data_size - bitmap_ptr + raw_data_ptr + 3) / 4
290
- // which simplifies to: (data_size + 4 - bitmap_offset + 3) / 4 * 4
291
- // For our purposes: bitmap extends from bitmap_offset to the end of known data.
292
- let bitmap_size = if data.len() > bitmap_offset {
293
- data.len() - bitmap_offset
294
- } else {
295
- 0
296
- };
297
-
298
- Ok(SprHeader {
299
- data_size,
300
- header_flags,
301
- palette_count,
302
- unknown_08,
303
- fps,
304
- flags,
305
- width,
306
- height,
307
- frame_count,
308
- max_frames,
309
- scale_x,
310
- scale_y,
311
- is_scaled,
312
- secondary_frame_count,
313
- palette_offset,
314
- secondary_frame_offset,
315
- frame_meta_offset,
316
- bitmap_offset,
317
- bitmap_size,
318
- })
319
- }
320
-
321
- #[cfg(test)]
322
- mod tests {
323
- use super::*;
324
-
325
- /// Build a minimal single-frame .spr with given dimensions and palette.
326
- fn make_spr(width: u16, height: u16, palette_count: u16, frame_count: u16) -> Vec<u8> {
327
- let mut data = Vec::new();
328
-
329
- // +0x00: unused magic
330
- data.extend_from_slice(&0u32.to_le_bytes());
331
- // +0x04: data_size (we'll fix up later)
332
- let data_size_pos = data.len();
333
- data.extend_from_slice(&0u32.to_le_bytes());
334
- // +0x08: header_flags
335
- data.extend_from_slice(&0u16.to_le_bytes());
336
- // +0x0A: palette_entry_count
337
- data.extend_from_slice(&palette_count.to_le_bytes());
338
- // +0x0C: palette RGB entries (all zeros)
339
- for _ in 0..palette_count {
340
- data.extend_from_slice(&[0u8; 3]);
341
- }
342
-
343
- // Main frame header (12 bytes)
344
- data.extend_from_slice(&0u16.to_le_bytes()); // unknown_08
345
- data.extend_from_slice(&10u16.to_le_bytes()); // fps = 10
346
- data.extend_from_slice(&0u16.to_le_bytes()); // flags
347
- data.extend_from_slice(&width.to_le_bytes());
348
- data.extend_from_slice(&height.to_le_bytes());
349
- data.extend_from_slice(&frame_count.to_le_bytes());
350
-
351
- // Align to 4 bytes
352
- while data.len() % 4 != 0 {
353
- data.push(0);
354
- }
355
-
356
- // Frame metadata (12 bytes per frame)
357
- for i in 0..frame_count {
358
- let offset = i as u32 * width as u32 * height as u32;
359
- data.extend_from_slice(&offset.to_le_bytes()); // bitmap_offset
360
- data.extend_from_slice(&0u16.to_le_bytes()); // start_x
361
- data.extend_from_slice(&0u16.to_le_bytes()); // start_y
362
- data.extend_from_slice(&width.to_le_bytes()); // end_x
363
- data.extend_from_slice(&height.to_le_bytes()); // end_y
364
- }
365
-
366
- // Bitmap data (width * height * frame_count bytes)
367
- let bitmap_bytes = width as usize * height as usize * frame_count as usize;
368
- data.resize(data.len() + bitmap_bytes, 0);
369
-
370
- // Fix up data_size (everything from +4 onward)
371
- let total = (data.len() - 4) as u32;
372
- data[data_size_pos..data_size_pos + 4].copy_from_slice(&total.to_le_bytes());
373
-
374
- data
375
- }
376
-
377
- #[test]
378
- fn parse_single_frame() {
379
- let data = make_spr(32, 16, 4, 1);
380
- let hdr = parse_spr_header(&data).unwrap();
381
-
382
- assert_eq!(hdr.width, 32);
383
- assert_eq!(hdr.height, 16);
384
- assert_eq!(hdr.fps, 10);
385
- assert_eq!(hdr.palette_count, 4);
386
- assert_eq!(hdr.frame_count, 1);
387
- assert!(!hdr.is_scaled);
388
- assert_eq!(hdr.scale_x, 0);
389
- assert_eq!(hdr.scale_y, 0);
390
- assert_eq!(hdr.secondary_frame_count, 0);
391
- assert_eq!(hdr.palette_offset, 12);
392
- assert!(hdr.frame_meta_offset > 0);
393
- assert!(hdr.bitmap_offset > hdr.frame_meta_offset);
394
- }
395
-
396
- #[test]
397
- fn parse_multi_frame() {
398
- let data = make_spr(8, 8, 2, 5);
399
- let hdr = parse_spr_header(&data).unwrap();
400
-
401
- assert_eq!(hdr.frame_count, 5);
402
- assert_eq!(hdr.max_frames, 5);
403
- // 5 frames * 12 bytes each = 60 bytes of frame metadata
404
- assert_eq!(hdr.bitmap_offset - hdr.frame_meta_offset, 60);
405
- }
406
-
407
- #[test]
408
- fn parse_scaled_sprite() {
409
- // frame_count_raw = 0x8040: bit15 set, scale_x_raw = 0, scale_y_raw = 0x40
410
- // Actually: high byte = 0x80, (0x80 >> 0) & 0x7F for scale from bits 8..14
411
- // Let's use 0xA050: high byte 0xA0, low byte 0x50
412
- // scale_x_raw = (0xA050 >> 8) & 0x7F = 0xA0 & 0x7F = 0x20
413
- // scale_y_raw = 0xA050 & 0x7F = 0x50
414
- let mut data = make_spr(32, 32, 0, 1);
415
- // Overwrite frame_count_raw in the main frame header
416
- // palette_count = 0, so palette_end = 12
417
- // main frame header starts at 12, frame_count is at offset 10 within it = byte 22
418
- data[22] = 0x50; // low byte
419
- data[23] = 0xA0; // high byte (bit 15 set)
420
-
421
- let hdr = parse_spr_header(&data).unwrap();
422
-
423
- assert!(hdr.is_scaled);
424
- assert_eq!(hdr.frame_count, 1);
425
- assert_eq!(hdr.max_frames, 1);
426
- // scale_x = (0x20 << 16) >> 5 = 0x200000 >> 5 = 0x10000
427
- assert_eq!(hdr.scale_x, (0x20u32 << 16) >> 5);
428
- // scale_y = (0x50 << 16) >> 5 = 0x500000 >> 5 = 0x28000
429
- assert_eq!(hdr.scale_y, (0x50u32 << 16) >> 5);
430
- }
431
-
432
- #[test]
433
- fn parse_too_short() {
434
- assert!(parse_spr_header(&[0; 4]).is_err());
435
- }
436
- }
437
- ```
438
-
439
- - [ ] **Step 2: Add module to render/mod.rs**
440
-
441
- In `crates/openwa-core/src/render/mod.rs`, add:
442
-
443
- ```rust
444
- pub mod spr;
445
- ```
446
-
447
- And add re-exports:
448
-
449
- ```rust
450
- pub use spr::{ParsedSprite, SprError, SprHeader};
451
- ```
452
-
453
- (The `ParsedSprite` re-export will fail initially since we haven't defined it yet — that's fine, comment it out or add it in Task 3.)
454
-
455
- - [ ] **Step 3: Run tests**
456
-
457
- Run: `cargo test -p openwa-core -- spr::tests`
458
- Expected: All 4 tests pass.
459
-
460
- - [ ] **Step 4: Commit**
461
-
462
- ```bash
463
- git add crates/openwa-core/src/render/spr.rs crates/openwa-core/src/render/mod.rs
464
- git commit -m "feat: add .spr header parser with SprHeader and parse_spr_header()"
465
- ```
466
-
467
- ---
468
-
469
- ### Task 3: Add ParsedSprite and ParsedSprite::parse()
470
-
471
- **Files:**
472
- - Modify: `crates/openwa-core/src/render/spr.rs`
473
- - Modify: `crates/openwa-core/src/render/mod.rs`
474
-
475
- - [ ] **Step 1: Write failing test for ParsedSprite::parse**
476
-
477
- Add to the `tests` module in `spr.rs`:
478
-
479
- ```rust
480
- #[test]
481
- fn parsed_sprite_single_frame() {
482
- let mut data = make_spr(4, 4, 3, 1);
483
- // Fill palette with known RGB values
484
- data[12] = 0xFF; data[13] = 0x00; data[14] = 0x00; // entry 0: red
485
- data[15] = 0x00; data[16] = 0xFF; data[17] = 0x00; // entry 1: green
486
- data[18] = 0x00; data[19] = 0x00; data[20] = 0xFF; // entry 2: blue
487
-
488
- let parsed = ParsedSprite::parse(&data).unwrap();
489
- assert_eq!(parsed.width, 4);
490
- assert_eq!(parsed.height, 4);
491
- assert_eq!(parsed.palette.len(), 3);
492
- assert_eq!(parsed.palette[0], [0xFF, 0x00, 0x00]);
493
- assert_eq!(parsed.palette[1], [0x00, 0xFF, 0x00]);
494
- assert_eq!(parsed.palette[2], [0x00, 0x00, 0xFF]);
495
- assert_eq!(parsed.frames.len(), 1);
496
- assert_eq!(parsed.bitmap.len(), 16); // 4*4 pixels
497
- }
498
-
499
- #[test]
500
- fn parsed_sprite_secondary_frames() {
501
- let mut data = Vec::new();
502
-
503
- // Header
504
- data.extend_from_slice(&0u32.to_le_bytes()); // unused
505
- data.extend_from_slice(&0u32.to_le_bytes()); // data_size (fix later)
506
- data.extend_from_slice(&0x4000u16.to_le_bytes()); // header_flags: secondary frames
507
- data.extend_from_slice(&0u16.to_le_bytes()); // palette_count = 0
508
-
509
- // Secondary frame table
510
- data.extend_from_slice(&2u16.to_le_bytes()); // 2 secondary frames
511
- // Align to 4 bytes
512
- while data.len() % 4 != 0 { data.push(0); }
513
- // 2 secondary SpriteFrame entries (12 bytes each)
514
- for _ in 0..2 {
515
- data.extend_from_slice(&[0u8; 12]);
516
- }
517
-
518
- // Main frame header
519
- data.extend_from_slice(&0u16.to_le_bytes()); // unknown_08
520
- data.extend_from_slice(&15u16.to_le_bytes()); // fps
521
- data.extend_from_slice(&0u16.to_le_bytes()); // flags
522
- data.extend_from_slice(&8u16.to_le_bytes()); // width
523
- data.extend_from_slice(&8u16.to_le_bytes()); // height
524
- data.extend_from_slice(&1u16.to_le_bytes()); // frame_count
525
-
526
- while data.len() % 4 != 0 { data.push(0); }
527
- // 1 main SpriteFrame
528
- data.extend_from_slice(&0u32.to_le_bytes()); // bitmap_offset
529
- data.extend_from_slice(&0u16.to_le_bytes()); // start_x
530
- data.extend_from_slice(&0u16.to_le_bytes()); // start_y
531
- data.extend_from_slice(&8u16.to_le_bytes()); // end_x
532
- data.extend_from_slice(&8u16.to_le_bytes()); // end_y
533
-
534
- // Bitmap (8*8 = 64 bytes)
535
- data.resize(data.len() + 64, 0);
536
-
537
- // Fix data_size
538
- let total = (data.len() - 4) as u32;
539
- data[4..8].copy_from_slice(&total.to_le_bytes());
540
-
541
- let parsed = ParsedSprite::parse(&data).unwrap();
542
- assert_eq!(parsed.secondary_frames.len(), 2);
543
- assert_eq!(parsed.frames.len(), 1);
544
- assert_eq!(parsed.header_flags, 0x4000);
545
- }
546
- ```
547
-
548
- - [ ] **Step 2: Implement ParsedSprite**
549
-
550
- Add above the `#[cfg(test)]` block in `spr.rs`:
551
-
552
- ```rust
553
- /// A fully parsed `.spr` sprite with owned data.
554
- ///
555
- /// Contains raw RGB palette entries (not remapped to WA's display palette),
556
- /// raw bitmap pixel indices, and frame metadata. Suitable for standalone
557
- /// tooling, tests, and debugging.
558
- ///
559
- /// For WormKit hook replacements, `parse_spr_header` is used directly
560
- /// with in-place buffer operations instead.
561
- #[derive(Debug, Clone)]
562
- pub struct ParsedSprite {
563
- /// Unknown field at sprite+0x08.
564
- pub unknown_08: u16,
565
- /// Animation frames per second.
566
- pub fps: u16,
567
- /// Sprite flags.
568
- pub flags: u16,
569
- /// Header flags from `.spr` file.
570
- pub header_flags: u16,
571
- /// Sprite width in pixels.
572
- pub width: u16,
573
- /// Sprite height in pixels.
574
- pub height: u16,
575
- /// Number of animation frames.
576
- pub frame_count: u16,
577
- /// Maximum frame count.
578
- pub max_frames: u16,
579
- /// Scale X (`(raw << 16) >> 5`), or 0 if not scaled.
580
- pub scale_x: u32,
581
- /// Scale Y (`(raw << 16) >> 5`), or 0 if not scaled.
582
- pub scale_y: u32,
583
- /// Whether the sprite uses scaling instead of animation.
584
- pub is_scaled: bool,
585
- /// RGB palette entries (3 bytes each). NOT remapped to display palette.
586
- pub palette: Vec<[u8; 3]>,
587
- /// Per-frame metadata (bounding box + bitmap offset).
588
- pub frames: Vec<SpriteFrame>,
589
- /// Secondary frame table (non-empty iff header_flags & 0x4000).
590
- pub secondary_frames: Vec<SpriteFrame>,
591
- /// Bitmap pixel data — 8-bit indices into the sprite's local palette.
592
- pub bitmap: Vec<u8>,
593
- /// Total data size from `.spr` header (for global counter updates).
594
- pub data_size: u32,
595
- }
596
-
597
- impl ParsedSprite {
598
- /// Parse a `.spr` file from raw bytes.
599
- ///
600
- /// Returns owned data with no WA runtime dependencies.
601
- pub fn parse(data: &[u8]) -> Result<Self, SprError> {
602
- let hdr = parse_spr_header(data)?;
603
-
604
- // Extract palette RGB triples
605
- let mut palette = Vec::with_capacity(hdr.palette_count as usize);
606
- let mut offset = hdr.palette_offset;
607
- for _ in 0..hdr.palette_count {
608
- palette.push([data[offset], data[offset + 1], data[offset + 2]]);
609
- offset += 3;
610
- }
611
-
612
- // Extract secondary frames
613
- let mut secondary_frames = Vec::new();
614
- if hdr.secondary_frame_count > 0 {
615
- secondary_frames.reserve(hdr.secondary_frame_count as usize);
616
- let mut off = hdr.secondary_frame_offset;
617
- for _ in 0..hdr.secondary_frame_count {
618
- secondary_frames.push(read_sprite_frame(data, off));
619
- off += 12;
620
- }
621
- }
622
-
623
- // Extract main frames
624
- let mut frames = Vec::with_capacity(hdr.frame_count as usize);
625
- let mut off = hdr.frame_meta_offset;
626
- for _ in 0..hdr.frame_count {
627
- frames.push(read_sprite_frame(data, off));
628
- off += 12;
629
- }
630
-
631
- // Extract bitmap data
632
- let bitmap_end = data.len().min(hdr.bitmap_offset + hdr.bitmap_size);
633
- let bitmap = data[hdr.bitmap_offset..bitmap_end].to_vec();
634
-
635
- Ok(ParsedSprite {
636
- unknown_08: hdr.unknown_08,
637
- fps: hdr.fps,
638
- flags: hdr.flags,
639
- header_flags: hdr.header_flags,
640
- width: hdr.width,
641
- height: hdr.height,
642
- frame_count: hdr.frame_count,
643
- max_frames: hdr.max_frames,
644
- scale_x: hdr.scale_x,
645
- scale_y: hdr.scale_y,
646
- is_scaled: hdr.is_scaled,
647
- palette,
648
- frames,
649
- secondary_frames,
650
- bitmap,
651
- data_size: hdr.data_size,
652
- })
653
- }
654
- }
655
-
656
- /// Read a SpriteFrame from 12 bytes at the given offset.
657
- fn read_sprite_frame(data: &[u8], off: usize) -> SpriteFrame {
658
- SpriteFrame {
659
- bitmap_offset: u32::from_le_bytes([data[off], data[off+1], data[off+2], data[off+3]]),
660
- start_x: u16::from_le_bytes([data[off+4], data[off+5]]),
661
- start_y: u16::from_le_bytes([data[off+6], data[off+7]]),
662
- end_x: u16::from_le_bytes([data[off+8], data[off+9]]),
663
- end_y: u16::from_le_bytes([data[off+10], data[off+11]]),
664
- }
665
- }
666
- ```
667
-
668
- - [ ] **Step 3: Update render/mod.rs re-exports**
669
-
670
- ```rust
671
- pub use spr::{ParsedSprite, SprError, SprHeader};
672
- ```
673
-
674
- - [ ] **Step 4: Run tests**
675
-
676
- Run: `cargo test -p openwa-core -- spr::tests`
677
- Expected: All 6 tests pass (4 from Task 2 + 2 new).
678
-
679
- - [ ] **Step 5: Commit**
680
-
681
- ```bash
682
- git add crates/openwa-core/src/render/spr.rs crates/openwa-core/src/render/mod.rs
683
- git commit -m "feat: add ParsedSprite type with owned .spr parser"
684
- ```
685
-
686
- ---
687
-
688
- ### Task 4: ConstructSprite Hook Replacement
689
-
690
- **Files:**
691
- - Create: `crates/openwa-dll/src/replacements/sprite.rs`
692
-
693
- - [ ] **Step 1: Create sprite.rs with ConstructSprite hook**
694
-
695
- Create `crates/openwa-dll/src/replacements/sprite.rs`:
696
-
697
- ```rust
698
- //! Sprite loading hook replacements.
699
- //!
700
- //! Replaces ConstructSprite (0x4FAA30) and ProcessSprite (0x4FAB80) with
701
- //! Rust implementations. Uses `parse_spr_header` from openwa-core for
702
- //! format parsing.
703
-
704
- use openwa_core::address::va;
705
- use openwa_core::rebase::rb;
706
-
707
- use crate::hook::{self, usercall_trampoline};
708
-
709
- // ---------------------------------------------------------------------------
710
- // ConstructSprite (0x4FAA30) — usercall(EAX=sprite, ECX=context), plain RET
711
- // ---------------------------------------------------------------------------
712
-
713
- usercall_trampoline!(fn trampoline_construct_sprite; impl_fn = construct_sprite_impl;
714
- regs = [eax, ecx]);
715
-
716
- unsafe extern "cdecl" fn construct_sprite_impl(sprite: u32, context: u32) {
717
- let p = sprite as *mut u8;
718
-
719
- // Zero the entire 0x70-byte struct first
720
- core::ptr::write_bytes(p, 0, 0x70);
721
-
722
- // Vtable
723
- *(p as *mut u32) = rb(va::SPRITE_VTABLE);
724
- // Context pointer (+0x04)
725
- *(p.add(0x04) as *mut u32) = context;
726
- // DisplayGfx vtable (+0x34)
727
- *(p.add(0x34) as *mut u32) = rb(va::DISPLAYGFX_VTABLE);
728
- // _unknown_38 = 1 (+0x38)
729
- *(p.add(0x38) as *mut u32) = 1;
730
- // _unknown_40 = 8 (+0x40)
731
- *(p.add(0x40) as *mut u32) = 8;
732
- }
733
-
734
- // ---------------------------------------------------------------------------
735
- // Installation
736
- // ---------------------------------------------------------------------------
737
-
738
- pub fn install() -> Result<(), String> {
739
- unsafe {
740
- let _ = hook::install(
741
- "ConstructSprite",
742
- va::CONSTRUCT_SPRITE,
743
- trampoline_construct_sprite as *const (),
744
- )?;
745
- }
746
- Ok(())
747
- }
748
- ```
749
-
750
- - [ ] **Step 2: Verify it compiles**
751
-
752
- Run: `cargo build -p openwa-dll --release 2>&1 | tail -5`
753
- Expected: Compiles (sprite.rs is not yet in mod.rs, so it won't be included — this just checks syntax).
754
-
755
- Actually, it won't compile unless added to mod.rs. Skip to step 3.
756
-
757
- - [ ] **Step 3: Add to mod.rs and verify**
758
-
759
- In `crates/openwa-dll/src/replacements/mod.rs`, add `mod sprite;` and add `sprite::install()?;` in `install_all()` after `render::install()?;`.
760
-
761
- Run: `cargo build -p openwa-dll --release 2>&1 | tail -5`
762
- Expected: Compiles without errors.
763
-
764
- - [ ] **Step 4: Commit**
765
-
766
- ```bash
767
- git add crates/openwa-dll/src/replacements/sprite.rs crates/openwa-dll/src/replacements/mod.rs
768
- git commit -m "feat: add ConstructSprite hook replacement"
769
- ```
770
-
771
- ---
772
-
773
- ### Task 5: ProcessSprite Hook Replacement
774
-
775
- **Files:**
776
- - Modify: `crates/openwa-dll/src/replacements/sprite.rs`
777
-
778
- This is the most complex task. ProcessSprite:
779
- 1. Reads header metadata via `parse_spr_header`
780
- 2. Builds a palette lookup table in-place (calling PaletteContext__MapColor)
781
- 3. Sets all sprite struct fields + pointers into the raw buffer
782
- 4. Remaps bitmap pixels through the lookup table (when no secondary frames)
783
- 5. Computes and updates 4 global counters
784
-
785
- - [ ] **Step 1: Add PaletteContext__MapColor bridge**
786
-
787
- Add to `sprite.rs`, above the `install()` function:
788
-
789
- ```rust
790
- // ---------------------------------------------------------------------------
791
- // PaletteContext__MapColor bridge (0x5412B0)
792
- // thiscall(ECX=palette_ctx, stack=rgb_u32), RET 0x4, returns u8
793
- // ---------------------------------------------------------------------------
794
-
795
- static mut PALETTE_MAP_COLOR_ADDR: u32 = 0;
796
-
797
- /// Call WA's PaletteContext__MapColor to find the nearest display palette index.
798
- #[cfg(target_arch = "x86")]
799
- unsafe fn palette_map_color(palette_ctx: u32, rgb: u32) -> u8 {
800
- let f: unsafe extern "thiscall" fn(u32, u32) -> u32 =
801
- core::mem::transmute(PALETTE_MAP_COLOR_ADDR as usize);
802
- f(palette_ctx, rgb) as u8
803
- }
804
- ```
805
-
806
- Note: we use `-> u32` for the transmuted function since WA returns the value in EAX (full register), and we mask to u8. This avoids potential ABI issues with `-> u8` on MSVC x86.
807
-
808
- - [ ] **Step 2: Add ProcessSprite trampoline and implementation**
809
-
810
- Add to `sprite.rs`:
811
-
812
- ```rust
813
- // ---------------------------------------------------------------------------
814
- // ProcessSprite (0x4FAB80)
815
- // usercall(EAX=sprite, ECX=palette_ctx) + 1 stack(raw_data), RET 0x4
816
- // ---------------------------------------------------------------------------
817
-
818
- usercall_trampoline!(fn trampoline_process_sprite; impl_fn = process_sprite_impl;
819
- regs = [eax, ecx]; stack_params = 1; ret_bytes = "0x4");
820
-
821
- unsafe extern "cdecl" fn process_sprite_impl(
822
- sprite: u32,
823
- palette_ctx: u32,
824
- raw_data: u32,
825
- ) -> u32 {
826
- use openwa_core::render::spr::parse_spr_header;
827
-
828
- let p = sprite as *mut u8;
829
- let data_ptr = raw_data as *const u8;
830
-
831
- // We need the data as a slice. Use data_size from header to determine length.
832
- // data_size is at raw_data + 4.
833
- let data_size = *(data_ptr.add(4) as *const u32);
834
- // Total buffer: data_size covers from offset +4 onward, so total = data_size + 4.
835
- // But we also need the initial 4 unused bytes, so slice length = data_size + 4.
836
- // However, data_size might not include the bitmap — let's be safe and use
837
- // data_size + 4 as the slice length (this matches what WA allocated).
838
- let data_len = (data_size + 4) as usize;
839
- let data = core::slice::from_raw_parts(data_ptr, data_len);
840
-
841
- let hdr = match parse_spr_header(data) {
842
- Ok(h) => h,
843
- Err(e) => {
844
- // This should never happen with valid WA game data
845
- panic!("ProcessSprite: failed to parse .spr data: {}", e);
846
- }
847
- };
848
-
849
- // --- Update global counter: data_size ---
850
- let g_data_bytes = rb(va::G_SPRITE_DATA_BYTES) as *mut u32;
851
- *g_data_bytes = (*g_data_bytes).wrapping_add(data_size);
852
-
853
- // --- Store raw_frame_header_ptr (points to header_flags in raw buffer) ---
854
- *(p.add(0x60) as *mut *const u8) = data_ptr.add(8);
855
-
856
- // --- Store header_flags ---
857
- *(p.add(0x14) as *mut u16) = hdr.header_flags;
858
-
859
- // --- Build palette lookup table IN PLACE ---
860
- // palette_data_ptr points to raw_data + 0x0A (the palette_entry_count field).
861
- // WA overwrites this region with a 1-indexed palette index lookup table:
862
- // [0] = 0 (transparent)
863
- // [1..N] = PaletteContext__MapColor(rgb) for each entry
864
- let palette_base = data_ptr.add(0x0A) as *mut u8;
865
- *(p.add(0x68) as *mut *mut u8) = palette_base;
866
-
867
- // Update global counter: palette bytes
868
- let g_palette = rb(va::G_SPRITE_PALETTE_BYTES) as *mut u32;
869
- *g_palette = (*g_palette).wrapping_add(hdr.palette_count as u32 * 3);
870
-
871
- // Write transparent index
872
- *palette_base = 0;
873
-
874
- // Map each RGB entry to display palette index
875
- let rgb_start = data_ptr.add(hdr.palette_offset);
876
- for i in 0..hdr.palette_count as usize {
877
- // Read 4 bytes (3 RGB + 1 from next entry, matching WA behavior)
878
- let rgb_val = *(rgb_start.add(i * 3) as *const u32);
879
- let mapped = palette_map_color(palette_ctx, rgb_val);
880
- *palette_base.add(1 + i) = mapped;
881
- }
882
-
883
- // --- Secondary frame table (if header_flags & 0x4000) ---
884
- let has_secondary = hdr.header_flags & 0x4000 != 0;
885
- if has_secondary {
886
- *(p.add(0x30) as *mut u16) = hdr.secondary_frame_count;
887
- *(p.add(0x2C) as *mut *const u8) = data_ptr.add(hdr.secondary_frame_offset);
888
- }
889
-
890
- // --- Main frame header fields ---
891
- // Copy unknown_08 + fps as a single u32 (matching WA's 4-byte copy)
892
- let frame_header_ptr = if has_secondary {
893
- // After secondary frames
894
- data_ptr.add(hdr.secondary_frame_offset + hdr.secondary_frame_count as usize * 12)
895
- } else {
896
- data_ptr.add(hdr.palette_offset + hdr.palette_count as usize * 3)
897
- };
898
-
899
- // WA copies 4 bytes at once: *(u32*)(sprite+8) = *(u32*)(frame_header)
900
- *(p.add(0x08) as *mut u32) = *(frame_header_ptr as *const u32);
901
- *(p.add(0x10) as *mut u16) = hdr.flags;
902
- *(p.add(0x0C) as *mut u16) = hdr.width;
903
- *(p.add(0x0E) as *mut u16) = hdr.height;
904
- *(p.add(0x12) as *mut u16) = hdr.frame_count;
905
- *(p.add(0x16) as *mut u16) = hdr.max_frames;
906
-
907
- // --- Scale fields ---
908
- if hdr.is_scaled {
909
- *(p.add(0x1C) as *mut u32) = hdr.scale_x;
910
- *(p.add(0x20) as *mut u32) = hdr.scale_y;
911
- *(p.add(0x24) as *mut u32) = 1; // is_scaled
912
- } else {
913
- *(p.add(0x24) as *mut u32) = 0;
914
- }
915
-
916
- // --- Frame metadata and bitmap pointers ---
917
- let frame_meta_ptr = data_ptr.add(hdr.frame_meta_offset);
918
- let bitmap_ptr = data_ptr.add(hdr.bitmap_offset);
919
- *(p.add(0x28) as *mut *const u8) = frame_meta_ptr;
920
- *(p.add(0x64) as *mut *const u8) = bitmap_ptr;
921
-
922
- // --- Bitmap palette remapping (only when NO secondary frames) ---
923
- if !has_secondary {
924
- // Remap every bitmap byte: pixel = lookup_table[pixel]
925
- // WA processes (data_size - bitmap_offset_relative + 3) / 4 dwords = that many * 4 bytes
926
- // We compute the same count for exact WA behavior.
927
- let bitmap_start_relative = hdr.bitmap_offset;
928
- let bitmap_byte_count_raw = (data_size as usize + 4).saturating_sub(bitmap_start_relative);
929
- let dword_count = (bitmap_byte_count_raw + 3) / 4;
930
- let remap_byte_count = dword_count * 4;
931
-
932
- let bmp = bitmap_ptr as *mut u8;
933
- for i in 0..remap_byte_count {
934
- let idx = *bmp.add(i) as usize;
935
- *bmp.add(i) = *palette_base.add(idx);
936
- }
937
- }
938
-
939
- // --- Update global counters: pixel area and frame count ---
940
- if hdr.frame_count > 0 {
941
- let g_pixel_area = rb(va::G_SPRITE_PIXEL_AREA) as *mut u32;
942
- let frames_ptr = frame_meta_ptr as *const [u8; 12];
943
- for i in 0..hdr.frame_count as usize {
944
- let frame = &*frames_ptr.add(i);
945
- let start_x = i16::from_le_bytes([frame[4], frame[5]]);
946
- let start_y = i16::from_le_bytes([frame[6], frame[7]]);
947
- let end_x = i16::from_le_bytes([frame[8], frame[9]]);
948
- let end_y = i16::from_le_bytes([frame[10], frame[11]]);
949
- let area = (end_x as i32 - start_x as i32) * (end_y as i32 - start_y as i32);
950
- *g_pixel_area = (*g_pixel_area).wrapping_add(area as u32);
951
- }
952
- }
953
-
954
- let g_frame_count = rb(va::G_SPRITE_FRAME_COUNT) as *mut u32;
955
- *g_frame_count = (*g_frame_count).wrapping_add(hdr.frame_count as u32);
956
-
957
- 1 // success
958
- }
959
- ```
960
-
961
- - [ ] **Step 3: Update install() to include ProcessSprite hook**
962
-
963
- Update the `install()` function:
964
-
965
- ```rust
966
- pub fn install() -> Result<(), String> {
967
- unsafe {
968
- PALETTE_MAP_COLOR_ADDR = rb(va::PALETTE_CONTEXT_MAP_COLOR);
969
-
970
- let _ = hook::install(
971
- "ConstructSprite",
972
- va::CONSTRUCT_SPRITE,
973
- trampoline_construct_sprite as *const (),
974
- )?;
975
-
976
- let _ = hook::install(
977
- "ProcessSprite",
978
- va::PROCESS_SPRITE,
979
- trampoline_process_sprite as *const (),
980
- )?;
981
- }
982
- Ok(())
983
- }
984
- ```
985
-
986
- - [ ] **Step 4: Build and verify**
987
-
988
- Run: `cargo build -p openwa-dll --release 2>&1 | tail -5`
989
- Expected: Compiles without errors.
990
-
991
- - [ ] **Step 5: Commit**
992
-
993
- ```bash
994
- git add crates/openwa-dll/src/replacements/sprite.rs
995
- git commit -m "feat: add ProcessSprite hook replacement with palette remapping"
996
- ```
997
-
998
- ---
999
-
1000
- ### Task 6: Run Replay Tests
1001
-
1002
- **Files:** None (validation only)
1003
-
1004
- - [ ] **Step 1: Run all replay tests**
1005
-
1006
- Run: `powershell -ExecutionPolicy Bypass -File run-tests.ps1`
1007
- Expected: All tests pass. If any fail, the sprite loading replacement has a bug.
1008
-
1009
- - [ ] **Step 2: If tests fail, diagnose**
1010
-
1011
- If a test shows `FAIL` (log mismatch), the sprite loading diverges from WA. Common causes:
1012
- - Palette remapping producing different indices (check PaletteContext__MapColor bridge)
1013
- - Global counter update order or values wrong
1014
- - Bitmap remap byte count off by a few bytes
1015
- - Alignment computation wrong for frame metadata offset
1016
-
1017
- If a test shows `CRASH`, check for stack corruption (wrong calling convention).
1018
-
1019
- Use `trace-desync` to find the exact divergent frame if needed:
1020
- ```
1021
- powershell -ExecutionPolicy Bypass -File trace-desync.ps1 testdata/replays/<failing_replay>.WAgame
1022
- ```
1023
-
1024
- - [ ] **Step 3: Commit if all pass (final)**
1025
-
1026
- ```bash
1027
- git add -A
1028
- git commit -m "feat: sprite parser and hook replacements for ConstructSprite + ProcessSprite"
1029
- ```
1030
-
1031
- ---
1032
-
1033
- ## Known Risks
1034
-
1035
- 1. **Palette remapping byte count**: WA computes `(data_size - bitmap_relative_offset + 3) / 4` dwords, which may process up to 3 bytes past the actual bitmap data. The implementation matches this behavior to avoid any divergence.
1036
-
1037
- 2. **ECX = palette context**: ProcessSprite receives the palette context implicitly in ECX from LoadSpriteFromVfs. The `usercall_trampoline` with `regs = [eax, ecx]` captures both registers correctly.
1038
-
1039
- 3. **Reading 4 bytes for RGB**: PaletteContext__MapColor receives a u32 where only the low 3 bytes are RGB. WA reads 4 bytes at each 3-byte-strided position (the 4th byte is garbage from the next entry or padding). This is safe because the function only uses `& 0xFFFFFF`.
1040
-
1041
- 4. **Frame header location**: The main frame header's position depends on whether secondary frames exist. The code computes this position from `parse_spr_header`'s offsets, but the WA field copy (`*(u32*)(sprite+8) = *(u32*)(frame_header)`) needs the raw pointer to the frame header in the data buffer. This is computed by walking past palette + optional secondary frames.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/superpowers/specs/2026-03-15-ddgame-constructor-design.md DELETED
@@ -1,103 +0,0 @@
1
- # DDGame Constructor Port — Design Spec
2
-
3
- ## Context
4
-
5
- DDGame (0x98B8 bytes) is the central game state object. Its constructor at
6
- 0x56E220 is one of the largest remaining WA.exe dependencies — it allocates
7
- and initializes all gameplay subsystems (sprites, render queue, weapon table,
8
- coordinate lists, physics params, etc.).
9
-
10
- The init chain is already Rust up to this point:
11
- - `GameEngine__InitHardware` (hardware_init.rs) — creates display, sound, keyboard, palette
12
- - `DDGameWrapper__Constructor` (game_session.rs) — creates the wrapper, calls DDGame ctor via bridge
13
-
14
- The DDGame constructor is the last major bridge in the init path. Porting it
15
- to Rust means the entire game object creation is under our control.
16
-
17
- ## Goal
18
-
19
- Replace `DDGame__Constructor` (0x56E220, stdcall 9 params + implicit ECX)
20
- with a Rust `DDGame::new()` that creates the object field by field. Use an
21
- incremental approach: port trivial inits immediately, bridge sub-constructor
22
- calls initially, and eliminate bridges one at a time.
23
-
24
- ## Approach: Incremental Field-by-Field
25
-
26
- ### Phase 1 — Decompile & Map
27
-
28
- 1. Decompile DDGame__Constructor (0x56E220) fully in Ghidra
29
- 2. Map every field write to our DDGame struct (already partially mapped in ddgame.rs)
30
- 3. Categorize each initialization:
31
- - **Trivial**: zero-fill, constant, pointer copy from params → port immediately
32
- - **Sub-call**: allocates/inits a sub-object → bridge initially
33
- - **Complex**: loops, conditionals, computed values → understand then port
34
- 4. Add any missing struct fields discovered during decompilation
35
- 5. Label all sub-functions called by the constructor in Ghidra
36
-
37
- ### Phase 2 — Rust DDGame::new()
38
-
39
- 1. Create `DDGame::new(params...) -> *mut DDGame` in openwa-core
40
- - Allocates 0x98B8 bytes via `wa_malloc` (or `WABox<DDGame>`)
41
- - Performs all trivial field initializations in Rust
42
- - Calls WA sub-functions via typed bridge functions for complex parts
43
- 2. Replace the `ddgame_constructor_call` naked bridge in game_session.rs
44
- with a direct call to `DDGame::new()`
45
- 3. Trap the original DDGame__Constructor (it should no longer be called)
46
- 4. Verify with replay tests
47
-
48
- ### Phase 3 — Bridge Elimination (iterative)
49
-
50
- Convert each bridged sub-call to pure Rust, simplest first:
51
-
52
- | Priority | Sub-call | Description |
53
- |----------|----------|-------------|
54
- | 1 | Field zero-fill regions | memset-style clears of large unknown regions |
55
- | 2 | Sprite cache init | Pointer array zeroing / allocation |
56
- | 3 | Coordinate list init | FUN_004FB3A0 — alloc + init capacity |
57
- | 4 | Object pool init | DDGame+0x600..0x3600 permutation arrays |
58
- | 5 | Render queue setup | Alloc + init the RenderQueue object |
59
- | 6 | Weapon table init | Alloc + populate weapon data tables |
60
- | 7 | Game state init | Complex conditional logic, terrain hooks |
61
-
62
- Each bridge elimination is a self-contained commit: convert, test, move on.
63
-
64
- ## Parameters
65
-
66
- The constructor signature (from Ghidra + CLAUDE.md):
67
- ```
68
- DDGame__Constructor(
69
- this, // allocated 0x98B8 bytes
70
- keyboard, // *mut DDKeyboard
71
- display, // *mut DDDisplay
72
- sound, // *mut DSSound (or null)
73
- palette, // *mut Palette
74
- music, // *mut Music (or null)
75
- param7, // unknown (0x1F4 observed)
76
- caller, // parent/caller pointer (often null)
77
- game_info // *mut GameInfo
78
- )
79
- // Implicit: ECX = network pointer (DDNetGameWrapper or null)
80
- // stdcall, RET 0x24 (9 stack params = 36 bytes)
81
- ```
82
-
83
- ## Critical Files
84
-
85
- | File | Action |
86
- |------|--------|
87
- | `crates/openwa-core/src/engine/ddgame.rs` | Add `DDGame::new()`, fill unknown fields |
88
- | `crates/openwa-dll/src/replacements/game_session.rs` | Replace bridge with `DDGame::new()` |
89
- | `crates/openwa-core/src/address.rs` | Add addresses for sub-functions |
90
-
91
- ## Verification
92
-
93
- Each phase is verified independently:
94
- - **Headless replay test**: determinism preserved (byte-identical log output)
95
- - **Headful replay test**: game plays, no crashes, validation passes
96
- - **Manual gameplay**: start match, play a turn, verify no visual/audio glitches
97
- - **Struct assertions**: `size_of::<DDGame>() == 0x98B8` still holds
98
-
99
- ## Risks
100
-
101
- - **Unknown fields**: DDGame has large unmapped regions (0x550..0x2600, 0x2E00..0x45EC, etc.). The constructor may write to fields we haven't identified. Mitigation: zero-fill unknowns and compare behavior.
102
- - **Sub-call ordering**: Some sub-calls may depend on fields set by earlier sub-calls. Mitigation: preserve exact ordering from the decompile.
103
- - **Implicit state**: ECX (network pointer) and global state may affect initialization. Mitigation: capture all implicit params in the bridge.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/superpowers/specs/2026-03-19-replay-loader-design.md DELETED
@@ -1,263 +0,0 @@
1
- # ReplayLoader Port Design
2
-
3
- ## Summary
4
-
5
- Port `ReplayLoader` (0x462DF0) and `ParseReplayPosition` (0x4E3490) from WA.exe
6
- to Rust as hook-and-replace functions. ReplayLoader is a ~1800-line function that
7
- parses `.WAgame` replay files, validates their format, extracts team/scheme/map
8
- data, and produces `/getlog` formatted output. ParseReplayPosition is a small
9
- utility that converts `MM:SS.FF` time strings to frame counts at 50fps.
10
-
11
- ## Target functions
12
-
13
- ### ReplayLoader (0x462DF0)
14
-
15
- - **Signature:** `undefined4 ReplayLoader(uint param_1, int param_2)`
16
- - **Convention:** stdcall (`RET 0x8`, callee cleans 2 params). Also has a plain
17
- `RET` at 0x465BC4 which is the SEH exception cleanup path.
18
- - **param_1:** Global game state struct at `0x87D3F8` (NOT DDGame). Always the
19
- same global address — callers pass the literal constant `0x87D3F8`. The struct
20
- holds replay/team/scheme state; its relationship to DDGame is that DDGame reads
21
- from some of the same globals after ReplayLoader populates them.
22
- - **param_2:** Mode — 1=play, 2=getmap, 3=getscheme, 4=repair
23
- - **Returns:** 0 on success, negative error codes on failure
24
-
25
- Error codes:
26
- | Code | Meaning |
27
- |------|---------|
28
- | 0 | Success |
29
- | -1 (0xFFFFFFFF) | File not found / read error |
30
- | -2 (0xFFFFFFFE) | Invalid format / validation failure |
31
- | -3 (0xFFFFFFFD) | Version too new / unsupported scheme version |
32
- | -4 (0xFFFFFFFC) | Malloc failure |
33
- | -5 (0xFFFFFFFB) | Map load failure |
34
- | -6 (0xFFFFFFFA) | DAT_0088c790 > 0x33 |
35
- | -7 (0xFFFFFFF9) | Repair with teams present |
36
- | -9 (0xFFFFFFF7) | No scheme in file / game type mismatch |
37
- | -8 (0xFFFFFFF8) | Not used by this function (gap in error codes) |
38
- | -10 (0xFFFFFFF6) | Scheme save failure |
39
-
40
- ### ParseReplayPosition (0x4E3490)
41
-
42
- - **Signature:** `int ParseReplayPosition(char *param_1)`
43
- - **Convention:** stdcall (`RET 0x4`, callee cleans 1 param)
44
- - **Returns:** Frame count (50fps), or -1 on parse error
45
- - **Format:** `[H:]MM:SS[.FF]` — hours optional, fractional seconds optional
46
-
47
- ## Function structure
48
-
49
- ReplayLoader breaks into 5 logical sections:
50
-
51
- ### Section 1: Header & file I/O (~100 lines)
52
-
53
- 1. Guard: `DAT_0088c790 > 0x33` → return -6
54
- 2. Set `param_1+0xDB48 = 1` (replay active flag)
55
- 3. Set `param_1+0xEF60 = 0` (init counter)
56
- 4. `SetCurrentDirectoryA` to game data dir
57
- 5. `fopen(param_1+0xDB60, "rb")` — replay file path
58
- 6. Read 4-byte header: lower 16 bits must be `0x4157` ("WA")
59
- 7. Version = upper 16 bits; must be ≤ 0x13 (19)
60
- 8. Store version at `param_1+0xDB50`, `param_1+0xDB54`; set `param_1+0xDB58 = 0xFFFFFFFF`
61
- 9. Read 4-byte payload size; validate against file size
62
- 10. For modes 3/4: seek past payload. For modes 1/2: malloc + fread payload.
63
-
64
- ### Section 2: Version 1 legacy path (~200 lines)
65
-
66
- When `version == 1`:
67
- - Read 4-byte team count from file, then 0x5728 bytes of team header into `0x8779E4`
68
- - Validate team count (1-6) and team type bounds
69
- - Iterate teams via `FUN_00461690` (team data reader)
70
- - Copy scheme defaults from `SCHEME_V3_DEFAULTS`
71
- - Read 0xD5D0-byte game state block via malloc, validate fields
72
- - Copy ~20 field groups from game state block into `param_1` at various offsets
73
- (0x40, 0x44, 0x48C, 0x490, 0xD0FC, 0x4AF8, 0x4AFC, 0x4AFA, 0x8EFC, etc.)
74
- - Free temp buffer
75
-
76
- ### Section 3: Version 2+ parsing (~500 lines)
77
-
78
- When `version >= 2`:
79
- 1. Read second payload (team data) with size + content
80
- 2. Parse sub-version flags and format indicators
81
- 3. Read game version ID → `DAT_0088ABB0`; validate < 0x1F8
82
- 4. Read scheme presence flag → `DAT_0088AE0C`
83
- 5. If scheme present: read `SCHM` magic + version byte (1/2/3)
84
- - Version 1/2: call `FUN_004613D0` (scheme reader), maybe set defaults
85
- - Version 3: validate extended options via `Scheme__ValidateExtendedOptions`
86
- 6. Read map seed byte, random state
87
- 7. Read observer player names (up to 0xD teams, stride 0x78)
88
- 8. Team iteration (up to 6 teams, stride 0xD7B at `0x878120`):
89
- - Team flag byte, team type, alliance
90
- - 5 sub-field reads via `FUN_00461540`
91
- - Worm alliance byte
92
- - Per-worm names: 8 worms × 0x11 bytes (or auto-generated for old versions)
93
- - Worm count (validate 1-8), color, flag, grave, soundbank bytes
94
- - Team name (0x40), team config name (0x40)
95
- - Weapon data: 0x400 + 0x154 + 0x400 + 0x300 bytes
96
- 9. Validate team count matches expected
97
- 10. XOR integrity: `DAT_0088AF50 = payload_dword ^ 0xEF5B5C49`
98
-
99
- ### Section 4: Post-parse & map load (~200 lines)
100
-
101
- 1. If mode 1 (play): `Scheme__CheckWeaponLimits` validation
102
- 2. If mode 3 (getscheme): version compatibility fixups, `Scheme__SaveFile`
103
- 3. If mode 4 (repair): artclass name writing to replay file
104
- 4. Map loading: read `playback.thm`, call map loader (`FUN_00490890`)
105
- 5. Artclass index → terrain descriptor lookup via table at `0x6ACD38`
106
- 6. Close file
107
-
108
- ### Section 5: Log output (~600 lines)
109
-
110
- Generates `/getlog` formatted output to `DAT_0088C370` (log file handle):
111
- 1. Date/time header via `_gmtime64`
112
- 2. Version string lookup (negative IDs → hardcoded strings, positive → table at
113
- `0x6AB480` or `0x6AB7A4` for BoomRacing mode)
114
- 3. Format line: `"Game: <version> + <mods>..."` and `"Replay: <format>..."`
115
- 4. Current WA version line: `"3.8.1"`
116
- 5. Artclass line (if applicable)
117
- 6. Per-team listing with alignment padding:
118
- - Team flag letter (uppercased via codepage)
119
- - Team name with quotes and padding
120
- - Player name assignment
121
- - CPU teams: difficulty level float
122
- - Human teams: host/first markers
123
- 7. Observer teams (negative team type)
124
- 8. Final newline + flush
125
-
126
- **Critical:** This output must match byte-for-byte for headless replay testing.
127
-
128
- ## Helper functions
129
-
130
- Several internal helpers are called repeatedly. These need bridges or ports:
131
-
132
- | Address | Name | Purpose | Approach | Convention |
133
- |---------|------|---------|----------|------------|
134
- | `0x461340` | ReadBytes | Read N bytes from stream cursor into buffer | Port | Verify during impl |
135
- | `0x4614D0` | ReadBytesValidated | Read + validate bounds | Port | Verify during impl |
136
- | `0x4615B0` | ReadU32Validated | Read u32 from stream | Port | Verify during impl |
137
- | `0x461540` | ReadByteRange | Read byte with signed range validation | Port | Verify during impl |
138
- | `0x461620` | ReadTeamWormAlliance | Read worm alliance data | Port | Verify during impl |
139
- | `0x461690` | ReadTeamData_V1 | Read team data (version 1 format) | Port | Verify during impl |
140
- | `0x466460` | ProcessTeamColors | Post-process team color assignments | Bridge | Verify (check RET) |
141
- | `0x4670F0` | ProcessSchemeDefaults | Apply scheme default values | Bridge | Verify (check RET) |
142
- | `0x467280` | ProcessReplayFlags | Process replay feature flags | Bridge | Verify (check RET) |
143
- | `0x467BC0` | RegisterObserverTeam | Register observer team entry | Bridge | Verify (check RET) |
144
- | `0x468890` | ProcessAllianceData | Process alliance/team setup | Bridge | Verify (check RET) |
145
- | `0x465E10` | ValidateTeamSetup | Validate team configuration | Bridge | Verify (check RET) |
146
- | `0x593180` | LoadStringResource | Load MFC string resource by ID | Bridge | Verify (check RET) |
147
- | `0x5978A0` | FormatString | sprintf-like string formatter | Bridge | Verify (check RET) |
148
- | `0x5978F0` | FormatString2 | Another format variant | Bridge | Verify (check RET) |
149
-
150
- **Note:** All helper function calling conventions must be verified via `RET imm16`
151
- disassembly before implementation. Wrong conventions cause stack corruption.
152
-
153
- **Policy:** Small functions (especially usercall) should be ported to Rust rather
154
- than bridged. Bridging usercall functions requires naked asm trampolines which add
155
- complexity and risk. Only bridge functions that are large or have many dependencies.
156
-
157
- ## Global buffers written
158
-
159
- | Address | Size | Purpose |
160
- |---------|------|---------|
161
- | `0x87D3F8` | param_1 base | Game state struct (team/scheme/weapon data at various offsets) |
162
- | `0x8779E4` | 0x5728 | Team header data |
163
- | `0x87D438` | 0xD9DC | Secondary team/game data |
164
- | `0x88AF50` | 4 | XOR'd game ID |
165
- | `0x88AF54` | 4 | Replay sub-format flag |
166
- | `0x88ABB0` | 4 | Game version ID |
167
- | `0x88AE0C` | 4 | Scheme present flag |
168
- | `0x88DC04` | 0x6E | Scheme defaults buffer |
169
- | `0x88C790` | 4 | Version/ArtClass counter |
170
- | `0x88D0B4` | 4 | Random seed |
171
- | `0x88ABAC` | 4 | Saved random seed |
172
-
173
- ## Implementation plan
174
-
175
- ### Phase 0: Passthrough hook + ParseReplayPosition
176
-
177
- **Goal:** Validate calling convention, port the trivial function.
178
-
179
- 1. Add `va::REPLAY_LOADER` and `va::PARSE_REPLAY_POSITION` to `address.rs`
180
- 2. Add all replay-related global addresses to `address.rs`
181
- 3. Create `replacements/replay.rs` with passthrough hook for ReplayLoader
182
- 4. Port `ParseReplayPosition` as full Rust replacement
183
- 5. Register in `replacements/mod.rs`
184
- 6. Test: headful + headless replay
185
-
186
- ### Phase 1: Header parsing + early returns
187
-
188
- **Goal:** Replace the header validation and mode routing in Rust.
189
-
190
- 1. Port Section 1 (header & file I/O) to Rust
191
- 2. For modes 2 (getmap) and 4 (repair): delegate to original via trampoline
192
- 3. For mode 3 (getscheme): delegate to original initially
193
- 4. For mode 1 (play): continue to Rust Section 2/3
194
- 5. Test: headful replay with mode 1
195
-
196
- ### Phase 2: Payload parsing (version 2+)
197
-
198
- **Goal:** Replace the core team/scheme data parsing.
199
-
200
- Focus on version 2+ path (version 1 is legacy, unlikely to be encountered):
201
- 1. Port stream cursor helpers (ReadBytes, ReadU32, etc.) as Rust utilities
202
- 2. Port the team iteration loop (6 teams × 0xD7B stride)
203
- 3. Port scheme data reading (SCHM magic validation + delegation to existing bridges)
204
- 4. Port weapon data block copies
205
- 5. Bridge remaining sub-functions (ProcessTeamColors, ProcessSchemeDefaults, etc.)
206
- 6. Test: headless determinism — game must play identically
207
-
208
- ### Phase 3: Log output
209
-
210
- **Goal:** Reproduce /getlog formatted output byte-for-byte.
211
-
212
- This is the highest-risk phase. The output includes:
213
- - Codepage-aware character conversion
214
- - MFC string resources (localized strings)
215
- - Precise column alignment with padding
216
- - Version string table lookups
217
- - Date formatting via `_gmtime64`
218
-
219
- Approach:
220
- 1. Start with a passthrough to the original log section
221
- 2. Port incrementally, comparing output byte-for-byte via headless tests
222
- 3. Use WA's own string resource functions via FFI bridges
223
- 4. Test: headless diff must show zero differences
224
-
225
- ## Verification
226
-
227
- | Phase | Test | Pass criteria |
228
- |-------|------|---------------|
229
- | 0 | Headful + headless replay | All existing validations pass, headless log matches |
230
- | 1 | Headful replay | Game loads and plays replay correctly |
231
- | 2 | Headless replay | Output log byte-identical to expected |
232
- | 3 | Headless replay | Output log byte-identical to expected |
233
-
234
- ## Risks
235
-
236
- 1. **Calling convention**: Confirmed stdcall (`RET 0x8`). The plain `RET` at
237
- 0x465BC4 is the SEH exception cleanup path, not the normal return.
238
-
239
- 2. **C++ exception handling**: The version 2+ path uses `__CxxThrowException_8`
240
- with SEH (`FS:[0]` exception chain) for validation errors. The version 1 path
241
- uses direct returns. The SEH handler performs cleanup (freeing malloc'd buffers,
242
- closing file handles) before returning the error code. The Rust replacement must
243
- replicate this cleanup on every error path. Use a cleanup guard pattern (e.g., a
244
- struct with `Drop` impl, or explicit cleanup before each early return) to ensure
245
- no resource leaks.
246
-
247
- 3. **Log output fidelity**: The /getlog output must match byte-for-byte. Codepage
248
- conversion, string resources, and column alignment must be exact. This is the
249
- hardest part and may require falling back to WA's own formatting functions.
250
-
251
- 4. **Version 1 format**: Rarely encountered (pre-3.5 replays). Could be left as
252
- a bridge to original initially, ported later if needed.
253
-
254
- 5. **Helper function dependencies**: ~15 helper functions called. Trivial stream
255
- helpers should be ported; complex ones (string resources, scheme processing)
256
- should be bridged via FFI. All conventions must be verified before use.
257
-
258
- 6. **Stdcall trampoline delegation**: When delegating to the original function for
259
- unimplemented modes, the hook is `extern "stdcall"` and the MinHook trampoline
260
- preserves the original stdcall convention. The Rust hook should call the
261
- trampoline as a regular function pointer (the trampoline handles its own stack
262
- cleanup internally — MinHook trampolines are designed for this). This is the
263
- same pattern used by existing stdcall hooks in `game_state_hooks.rs`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/superpowers/specs/2026-03-25-vtable-derive-design.md DELETED
@@ -1,337 +0,0 @@
1
- # Design: `#[vtable(...)]` System for OpenWA
2
-
3
- ## Context
4
-
5
- OpenWA has 7+ manually-written vtable structs (`PaletteVtable`, `DisplayBaseVtable`, `DDDisplayVtable`, etc.) with hand-coded `#[repr(C)]` layouts, manual `impl` wrapper methods, and ad-hoc vtable patching. The existing `#[derive(FieldRegistry)]` macro and `inventory`-based registry provide a proven pattern for auto-generating struct metadata. This design extends that infrastructure to vtables, providing a single definition per vtable that handles introspection, calling, and replacement.
6
-
7
- **Constraints:**
8
- - A C++ class can have multiple vtables at different struct offsets (multiple inheritance)
9
- - The same vtable type can theoretically serve multiple classes
10
- - All vtable methods are `unsafe extern "thiscall"` (ECX = this)
11
- - Target is i686-pc-windows-msvc (32-bit, 4-byte pointers/fn pointers)
12
-
13
- ## Design
14
-
15
- ### 1. Vtable Definition (`#[vtable(...)]`)
16
-
17
- **Input syntax** — declare only known methods with `#[slot(N)]`:
18
-
19
- ```rust
20
- #[openwa_core::vtable(size = 5, va = 0x0066_A2E4, class = "Palette")]
21
- pub struct PaletteVtable {
22
- /// set_mode(this, mode) — called with mode=7 during hardware init
23
- #[slot(2)]
24
- pub set_mode: unsafe extern "thiscall" fn(*mut Palette, u32),
25
- /// init(this) — called during hardware init
26
- #[slot(3)]
27
- pub init: unsafe extern "thiscall" fn(*mut Palette),
28
- /// reset(this) — called first during hardware init
29
- #[slot(4)]
30
- pub reset: unsafe extern "thiscall" fn(*mut Palette),
31
- }
32
- ```
33
-
34
- **Generated output:**
35
-
36
- ```rust
37
- // 1. The actual #[repr(C)] struct with gap-fillers
38
- #[repr(C)]
39
- pub struct PaletteVtable {
40
- pub _slot_0: usize,
41
- pub _slot_1: usize,
42
- /// set_mode(this, mode) — called with mode=7 during hardware init
43
- pub set_mode: unsafe extern "thiscall" fn(*mut Palette, u32),
44
- /// init(this) — called during hardware init
45
- pub init: unsafe extern "thiscall" fn(*mut Palette),
46
- /// reset(this) — called first during hardware init
47
- pub reset: unsafe extern "thiscall" fn(*mut Palette),
48
- }
49
-
50
- // 2. Registry integration (introspection)
51
- impl HasVtableRegistry for PaletteVtable {
52
- fn vtable_info() -> &'static VtableInfo {
53
- static INFO: VtableInfo = VtableInfo {
54
- struct_name: "PaletteVtable",
55
- class_name: "Palette",
56
- ghidra_va: 0x0066_A2E4,
57
- slot_count: 5,
58
- slots: &[
59
- VtableSlotEntry { index: 2, name: "set_mode", doc: "set_mode(this, mode) ..." },
60
- VtableSlotEntry { index: 3, name: "init", doc: "init(this) ..." },
61
- VtableSlotEntry { index: 4, name: "reset", doc: "reset(this) ..." },
62
- ],
63
- };
64
- &INFO
65
- }
66
- }
67
-
68
- inventory::submit!(VtableRegistration {
69
- info: <PaletteVtable as HasVtableRegistry>::vtable_info(),
70
- });
71
-
72
- // 3. Address registry entry (replaces define_addresses! for this vtable)
73
- inventory::submit!(AddrEntry {
74
- va: 0x0066_A2E4,
75
- name: "PALETTE_VTABLE",
76
- kind: AddrKind::Vtable,
77
- class_name: Some("Palette"),
78
- calling_convention: None,
79
- });
80
-
81
- // 4. Companion binding macro (see Part 2)
82
- #[macro_export]
83
- macro_rules! bind_PaletteVtable { ... }
84
- ```
85
-
86
- **Proc macro implementation** in `openwa-derive/src/lib.rs`:
87
- - Parse `#[vtable(...)]` attributes for `size`, `va` (optional), `class` (optional)
88
- - Parse `#[slot(N)]` on each field to determine position
89
- - Validate: no duplicate slots, all slots < size, slot indices ascending
90
- - Generate gap-filler fields `_slot_N: usize` for missing indices
91
- - Preserve doc comments, visibility, and field types from input
92
- - Generate compile-time size assertion: `const _: () = assert!(size_of::<VtableName>() == size * 4);`
93
- - Parse fn pointer types to extract `this` type (`*mut T` or `*const T`), params, and return type
94
- - Generate `HasVtableRegistry` impl, inventory submissions, and binding macro
95
- - If `va` is provided: generate `pub const` and `AddrEntry` submission. Name derived from `class` attribute: `"Palette"` → `PALETTE_VTABLE`. If no `class`, derive from struct name: `PaletteVtable` → `PALETTE_VTABLE`
96
- - If `va` is omitted: no `AddrEntry`, no const — vtable exists purely as a type
97
-
98
- ### 2. Calling Wrappers (`bind_XxxVtable!`)
99
-
100
- The derive generates a companion macro named `bind_{VtableName}!` that creates method wrappers:
101
-
102
- ```rust
103
- // Generated by #[derive(Vtable)] on PaletteVtable:
104
- #[macro_export]
105
- macro_rules! bind_PaletteVtable {
106
- ($class:ty, $vtable_field:ident) => {
107
- impl $class {
108
- #[doc = "set_mode(this, mode) — called with mode=7 during hardware init"]
109
- pub unsafe fn set_mode(&mut self, mode: u32) {
110
- ((*self.$vtable_field).set_mode)(self as *mut Self, mode)
111
- }
112
- #[doc = "init(this) — called during hardware init"]
113
- pub unsafe fn init(&mut self) {
114
- ((*self.$vtable_field).init)(self as *mut Self)
115
- }
116
- #[doc = "reset(this) — called first during hardware init"]
117
- pub unsafe fn reset(&mut self) {
118
- ((*self.$vtable_field).reset)(self as *mut Self)
119
- }
120
- }
121
- };
122
- }
123
- ```
124
-
125
- **Usage:**
126
- ```rust
127
- // In palette.rs, after struct Palette { vtable: *const PaletteVtable, ... }
128
- bind_PaletteVtable!(Palette, vtable);
129
-
130
- // Now you can write:
131
- (*pal).set_mode(7); // instead of vcall!(pal, set_mode, 7)
132
- (*pal).init();
133
- (*pal).reset();
134
- ```
135
-
136
- **Multiple vtables (multiple inheritance):**
137
- ```rust
138
- // CGameTask has primary vtable at +0x00
139
- bind_CGameTaskVtable!(CGameTask, vtable);
140
-
141
- // SoundEmitter is a sub-object with its own vtable — bind on its own struct
142
- bind_SoundEmitterVTable!(SoundEmitter, vtable);
143
- ```
144
-
145
- **Method signature extraction:** The proc macro parses the fn pointer type as `syn::Type::BareFn` to extract:
146
- - First param is `this` — if `*const T`, generates `&self`; if `*mut T`, generates `&mut self`
147
- - Remaining params become the wrapper's named parameters (e.g., `p0: u32`, `p1: *mut u8`)
148
- - Return type is preserved (default `()` if absent)
149
- - The wrapper calls through `self.$vtable_field` and casts `self` to the correct pointer type
150
-
151
- **Type safety:** The binding macro passes `self as *mut Self` (or `*const Self`) as the `this` argument. The `$class` type passed to the macro must match the `this` pointer type in the vtable fn signatures. If mismatched, Rust's pointer cast rules apply silently — the macro does not enforce this, but the mismatch would be visible at the invocation site.
152
-
153
- **Note on `&mut self`:** This follows the existing hand-written pattern (e.g., `Palette::reset(&mut self)`). Using `&mut self` creates a mutable reference from what is often a raw pointer, which could theoretically violate aliasing rules. This is acceptable for this project since all callers are already in `unsafe` blocks working with raw game memory, and the `vcall!` macro remains available for raw-pointer-only dispatch.
154
-
155
- ### 3. Vtable Replacement (`vtable_replace!`)
156
-
157
- A declarative macro that expands to executable code for use inside `install()` functions:
158
-
159
- ```rust
160
- pub fn install() -> Result<(), String> {
161
- unsafe {
162
- vtable_replace!(PaletteVtable at va::PALETTE_VTABLE {
163
- slot set_mode => my_set_mode,
164
- slot init => my_init,
165
- })?;
166
- }
167
- Ok(())
168
- }
169
- ```
170
-
171
- **Expanded code** (for `slot 2 => my_set_mode` — pure replace, no original saved):
172
-
173
- ```rust
174
- {
175
- let vtable_ptr = $crate::rebase::rb(va::PALETTE_VTABLE) as *mut u32;
176
- let slot_count = core::mem::size_of::<PaletteVtable>() / 4;
177
- $crate::vtable::patch_vtable(vtable_ptr, slot_count, |vt| {
178
- // Slot 2: pure replace
179
- let slot = vt.add(2);
180
- *slot = my_set_mode as *const () as u32;
181
- })
182
- }
183
- ```
184
-
185
- **Expanded code** (for `slot 3 [originals::INIT] => my_init` — replace + save original):
186
-
187
- ```rust
188
- {
189
- let vtable_ptr = $crate::rebase::rb(va::PALETTE_VTABLE) as *mut u32;
190
- let slot_count = core::mem::size_of::<PaletteVtable>() / 4;
191
- $crate::vtable::patch_vtable(vtable_ptr, slot_count, |vt| {
192
- // Slot 3: save original, then replace
193
- let slot = vt.add(3);
194
- originals::INIT.store(*slot, core::sync::atomic::Ordering::Relaxed);
195
- *slot = my_init as *const () as u32;
196
- })
197
- }
198
- ```
199
-
200
- **Calling the original:** User defines statics in a visible scope and passes them via `[static]` syntax:
201
-
202
- ```rust
203
- mod originals {
204
- pub static INIT: core::sync::atomic::AtomicU32 =
205
- core::sync::atomic::AtomicU32::new(0);
206
- }
207
-
208
- vtable_replace!(PaletteVtable at va::PALETTE_VTABLE {
209
- slot 2 => my_set_mode, // no original needed
210
- slot 3 [originals::INIT] => my_init, // save original
211
- })?;
212
-
213
- // In the replacement:
214
- unsafe extern "thiscall" fn my_init(this: *mut Palette) {
215
- log!("init()");
216
- let orig: unsafe extern "thiscall" fn(*mut Palette) =
217
- core::mem::transmute(originals::INIT.load(Ordering::Relaxed));
218
- orig(this);
219
- }
220
- ```
221
-
222
- The `[static_name]` syntax is optional — if omitted, no original is saved (pure replacement, no call-through). The macro does NOT generate inaccessible statics.
223
-
224
- **Slot index resolution:** Slot indices are specified explicitly in the macro call. This is compile-time, simple, and the indices are directly visible in the vtable definition above.
225
-
226
- **Size derivation:** The `size` parameter is NOT required — the macro computes it as `size_of::<VtableType>() / 4` from the vtable struct type, which already encodes the correct size via `#[vtable(size = N)]`.
227
-
228
- Revised syntax:
229
- ```rust
230
- vtable_replace!(PaletteVtable at va::PALETTE_VTABLE {
231
- slot 2 => my_set_mode, // pure replace, original discarded
232
- slot 3 [originals::INIT] => my_init, // replace + save original to static
233
- })?;
234
- ```
235
-
236
- ### 4. New Types in `openwa-core::registry`
237
-
238
- ```rust
239
- /// Metadata for a single vtable slot
240
- pub struct VtableSlotEntry {
241
- pub index: u32,
242
- pub name: &'static str,
243
- pub doc: &'static str,
244
- }
245
-
246
- /// Metadata for a vtable struct
247
- pub struct VtableInfo {
248
- pub struct_name: &'static str,
249
- pub class_name: &'static str,
250
- pub ghidra_va: u32,
251
- pub slot_count: u32,
252
- pub slots: &'static [VtableSlotEntry],
253
- }
254
-
255
- impl VtableInfo {
256
- pub fn slot_by_name(&self, name: &str) -> Option<&VtableSlotEntry> { ... }
257
- pub fn slot_by_index(&self, index: u32) -> Option<&VtableSlotEntry> { ... }
258
- }
259
-
260
- /// Trait implemented by #[derive(Vtable)] structs
261
- pub trait HasVtableRegistry {
262
- fn vtable_info() -> &'static VtableInfo;
263
- }
264
-
265
- /// Inventory collection wrapper
266
- pub struct VtableRegistration {
267
- pub info: &'static VtableInfo,
268
- }
269
- inventory::collect!(VtableRegistration);
270
-
271
- /// Query API
272
- pub fn vtable_info_for(struct_name: &str) -> Option<&'static VtableInfo> { ... }
273
- pub fn all_vtable_info() -> impl Iterator<Item = &'static VtableInfo> { ... }
274
- ```
275
-
276
- ### 5. `vtable::patch_vtable` Helper
277
-
278
- Move/generalize the existing vtable patching logic from `display.rs` into a shared utility:
279
-
280
- ```rust
281
- // openwa-core::vtable (new module)
282
-
283
- /// Temporarily makes a vtable writable, calls the patcher, restores protection.
284
- /// Returns Err if VirtualProtect fails.
285
- pub unsafe fn patch_vtable(
286
- vtable_ptr: *mut u32,
287
- slot_count: usize,
288
- patcher: impl FnOnce(*mut u32),
289
- ) -> Result<(), String> {
290
- let size = slot_count * 4;
291
- let mut old_protect = 0u32;
292
- if VirtualProtect(vtable_ptr as _, size, PAGE_READWRITE, &mut old_protect) == 0 {
293
- return Err("VirtualProtect failed".into());
294
- }
295
- patcher(vtable_ptr);
296
- VirtualProtect(vtable_ptr as _, size, old_protect, &mut old_protect);
297
- Ok(())
298
- }
299
- ```
300
-
301
- This already exists in `display.rs` — factor it out.
302
-
303
- ## File Changes
304
-
305
- | File | Change |
306
- |------|--------|
307
- | `crates/openwa-derive/src/lib.rs` | Add `#[vtable(...)]` proc macro (~150 lines) |
308
- | `crates/openwa-core/src/registry.rs` | Add `VtableInfo`, `VtableSlotEntry`, `HasVtableRegistry`, `VtableRegistration`, query API (~60 lines) |
309
- | `crates/openwa-core/src/vtable.rs` | New module: `patch_vtable` helper (factored from display.rs) |
310
- | `crates/openwa-core/src/macros.rs` | Add `vtable_replace!` macro (~40 lines) |
311
- | `crates/openwa-core/src/lib.rs` | Add `pub mod vtable;`, re-export `Vtable` derive |
312
-
313
- ## Migration: PaletteVtable (first target)
314
-
315
- 1. Rewrite `PaletteVtable` in `display/palette.rs` to use `#[vtable(...)]`
316
- 2. Add `bind_PaletteVtable!(Palette, vtable)` — replaces 3 manual wrapper methods
317
- 3. Remove the `define_addresses!` entry for `PALETTE_VTABLE_MAYBE` (now in derive attribute)
318
- 4. Verify `vcall!(pal, set_mode, 7)` still works (struct layout identical)
319
- 5. Run replay tests
320
-
321
- ## Migration Order (after Palette)
322
-
323
- 1. `InputCtrlVtable` (1 slot) — trivial
324
- 2. `MapViewVtable` (2 slots) — trivial
325
- 3. `SoundEmitterVTable` (12 slots, 5 known)
326
- 4. `DisplayBaseVtable` (32 slots, all typed)
327
- 5. `DSSoundVtable` (24 slots)
328
- 6. `DDDisplayVtable` (38 slots)
329
- 7. `CTaskWormVTable` (20+ slots)
330
-
331
- ## Verification
332
-
333
- - `cargo build -p openwa-core` — types compile
334
- - `cargo test -p openwa-core` — existing tests pass
335
- - `cargo build -p openwa-dll --release` — DLL builds
336
- - `.\run-tests.ps1` — replay tests pass (no desync from layout changes)
337
- - Validate introspection: `vtable_info_for("PaletteVtable")` returns correct slot metadata
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/superpowers/specs/2026-04-01-sprite-parser-design.md DELETED
@@ -1,127 +0,0 @@
1
- # Sprite Parser Design
2
-
3
- Port WA's `.spr` sprite loading to a clean Rust parser with WormKit hook replacements.
4
-
5
- ## Goal
6
-
7
- 1. A pure Rust `.spr` parser (`ParsedSprite`) in `openwa-core` — no WA dependencies, usable in tests/tooling
8
- 2. Hook replacements for `ConstructSprite` (0x4FAA30) and `ProcessSprite` (0x4FAB80) — WA calls our code instead of its own
9
-
10
- ## .spr File Format
11
-
12
- Binary format parsed by ProcessSprite (0x4FAB80):
13
-
14
- ```
15
- Offset Size Field
16
- +0x00 4 (unused / magic)
17
- +0x04 4 (u32) data_size — accumulated in global counter at 0x7A0864
18
- +0x08 2 (u16) header_flags → sprite+0x14
19
- +0x0A 2 (u16) palette_entry_count
20
- +0x0C 3 * N palette entries (3 bytes each, RGB)
21
- ... varies frame metadata (SpriteFrame, 0x0C bytes each)
22
- ... varies bitmap pixel data (8-bit indexed)
23
- ```
24
-
25
- Special flags:
26
- - **0x4000** in header_flags: secondary frame table present before primary frames.
27
- First word after palette = secondary frame count, followed by 0x0C-byte entries.
28
- - **Negative frame count** (bit 15 set): scaling mode.
29
- High 7 bits = scale_x, low 7 bits = scale_y. Actual frame_count set to 1.
30
- Scale values stored as `(raw_value << 16) >> 5`.
31
-
32
- ## ParsedSprite Type
33
-
34
- New file `crates/openwa-core/src/render/spr.rs`:
35
-
36
- ```rust
37
- pub enum SprError {
38
- TooShort,
39
- InvalidData(&'static str),
40
- }
41
-
42
- pub struct ParsedSprite {
43
- pub fps: u16,
44
- pub width: u16,
45
- pub height: u16,
46
- pub flags: u16,
47
- pub header_flags: u16,
48
- pub frame_count: u16,
49
- pub max_frames: u16,
50
- pub scale_x: u32, // raw 7-bit value; 0 if not scaled
51
- pub scale_y: u32, // raw 7-bit value; 0 if not scaled
52
- pub is_scaled: bool,
53
- pub frames: Vec<SpriteFrame>,
54
- pub secondary_frames: Vec<SpriteFrame>, // non-empty iff header_flags & 0x4000
55
- pub bitmap: Vec<u8>, // 8-bit indexed pixels, same layout as WA
56
- pub palette: Vec<[u8; 3]>, // RGB triples
57
- pub data_size: u32, // from .spr +0x04, for global counter updates
58
- }
59
- ```
60
-
61
- `ParsedSprite::parse(data: &[u8]) -> Result<Self, SprError>` — pure function, no WA dependencies, no `#[cfg(target_arch)]`.
62
-
63
- ## Hook Adapter
64
-
65
- In `spr.rs`, gated by `#[cfg(target_arch = "x86")]`:
66
-
67
- ```rust
68
- pub unsafe fn populate_wa_sprite(sprite: *mut Sprite, parsed: &ParsedSprite) { ... }
69
- ```
70
-
71
- - Copies scalar fields (fps, width, height, flags, etc.) into the WA Sprite struct
72
- - `wa_malloc` + `memcpy` for frame metadata, bitmap data, palette data
73
- - Applies scale transform: `(raw_value << 16) >> 5` for scale_x/scale_y
74
- - Updates 4 global counters: `G_SPRITE_DATA_BYTES`, `G_SPRITE_FRAME_COUNT`, `G_SPRITE_PIXEL_AREA`, `G_SPRITE_PALETTE_BYTES`
75
-
76
- Pixel data is copied verbatim — no format conversion needed.
77
-
78
- ## Hook Replacements
79
-
80
- New file `crates/openwa-dll/src/replacements/sprite.rs`:
81
-
82
- ### ConstructSprite (0x4FAA30)
83
-
84
- Convention: `__usercall` — EAX=sprite, ECX=context.
85
-
86
- Replacement:
87
- - Set vtable to `rb(0x66418C)`
88
- - Set DisplayGfx vtable at +0x34 to `rb(0x664144)`
89
- - Zero all other fields
90
-
91
- Trampoline: `usercall_trampoline!(regs = [eax, ecx])`.
92
-
93
- ### ProcessSprite (0x4FAB80)
94
-
95
- Convention: `__usercall` — EAX=sprite, 1 stack param (raw data pointer).
96
-
97
- Replacement:
98
- 1. Determine raw data extent (bitmap data extends to end of allocation — length comes from context)
99
- 2. Call `ParsedSprite::parse(data)`
100
- 3. Call `populate_wa_sprite(sprite, &parsed)`
101
- 4. On parse error: log and panic (corrupt game data, should never happen)
102
-
103
- Trampoline: `usercall_trampoline!(reg = eax; stack_params = 1; ret_bytes = "0x4")`.
104
-
105
- ## Open Question: Data Length
106
-
107
- ProcessSprite receives a raw data pointer but no explicit length. The caller (`LoadSpriteFromVfs`) allocates the buffer and knows its size. Options:
108
- - Parse defensively using `data_size` from the header (+0x04) to bound reads
109
- - Hook `LoadSpriteFromVfs` to pass length through (out of scope for now)
110
- - The parser can derive bounds from internal fields (palette count, frame count, bitmap offsets)
111
-
112
- Recommended: use internal fields to derive bounds. The `data_size` header field gives total size. Validate reads against it.
113
-
114
- ## File Changes
115
-
116
- | File | Change |
117
- |---|---|
118
- | `crates/openwa-core/src/render/spr.rs` | New: `ParsedSprite`, `SprError`, `parse()`, `populate_wa_sprite()` |
119
- | `crates/openwa-core/src/render/mod.rs` | Add `pub mod spr;` |
120
- | `crates/openwa-dll/src/replacements/sprite.rs` | New: hook trampolines + `install()` |
121
- | `crates/openwa-dll/src/replacements/mod.rs` | Wire `sprite::install()` |
122
-
123
- ## Verification
124
-
125
- - **Unit tests** in `openwa-core`: synthetic `.spr` payloads (single frame, multi-frame, scaled, secondary frames)
126
- - **Replay tests**: `.\run-tests.ps1` — all existing replays must pass byte-for-byte (global counters and sprite state must match exactly)
127
- - If replays show desyncs, use `trace-desync` to identify the divergent frame