docs: remove outdated plans, add re-notes disclaimer
Browse files- docs/plans/2026-03-05-offline-harness-design.md +0 -101
- docs/plans/2026-03-05-openwa-foundation-design.md +0 -37
- docs/plans/2026-03-05-runtime-validator-design.md +0 -98
- docs/plans/2026-03-05-runtime-validator-plan.md +0 -675
- docs/plans/2026-03-06-configuration-system-design.md +0 -110
- docs/plans/2026-03-06-configuration-system-plan.md +0 -877
- docs/plans/2026-03-07-replay-testing-design.md +0 -97
- docs/plans/2026-03-07-replay-testing-plan.md +0 -332
- docs/plans/2026-03-08-replay-fastforward-design.md +0 -61
- docs/plans/2026-03-19-init-game-state-design.md +0 -100
- docs/re-notes/README.md +1 -0
- docs/superpowers/plans/2026-03-16-ddgame-constructor.md +0 -553
- docs/superpowers/plans/2026-03-19-replay-loader.md +0 -980
- docs/superpowers/plans/2026-04-01-sprite-parser.md +0 -1041
- docs/superpowers/specs/2026-03-15-ddgame-constructor-design.md +0 -103
- docs/superpowers/specs/2026-03-19-replay-loader-design.md +0 -263
- docs/superpowers/specs/2026-03-25-vtable-derive-design.md +0 -337
- docs/superpowers/specs/2026-04-01-sprite-parser-design.md +0 -127
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 §ions {
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|