paavohtl Claude Opus 4.6 (1M context) commited on
Commit
46948ee
·
1 Parent(s): 3b3c953

docs: add ReplayLoader port design spec and implementation plan

Browse files

Spec 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`.