docs: add ReplayLoader port design spec and implementation plan
Browse filesSpec covers ReplayLoader (0x462DF0, stdcall, ~1800 lines) and
ParseReplayPosition (0x4E3490, stdcall). Plan has 10 tasks:
addresses, ReplayStream, ParseReplayPosition, hook install,
header parsing, scheme parsing, team data, post-parse, log output,
and optional version 1 legacy path.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
docs/superpowers/plans/2026-03-19-replay-loader.md
ADDED
|
@@ -0,0 +1,980 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-wormkit/src/replacements/replay.rs` | Create | Hook installation, ReplayLoader replacement |
|
| 32 |
+
| `crates/openwa-wormkit/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-wormkit/src/replacements/replay.rs`
|
| 575 |
+
- Modify: `crates/openwa-wormkit/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-wormkit/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-wormkit/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-wormkit
|
| 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-wormkit/src/replacements/replay.rs crates/openwa-wormkit/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-wormkit/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-wormkit/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-wormkit/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-wormkit/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-wormkit/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-wormkit/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-wormkit/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-wormkit/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-wormkit/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-wormkit/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-wormkit/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/specs/2026-03-19-replay-loader-design.md
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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`.
|