paavohtl Claude Opus 4.6 (1M context) commited on
Commit
d6be19f
Β·
2 Parent(s): 46948eefec8645

feat: port ReplayLoader and ParseReplayPosition to Rust

Browse files

Fully replaces ReplayLoader (0x462DF0, ~1800 lines) and
ParseReplayPosition (0x4E3490) with Rust implementations.

ReplayLoader mode 1 (play):
- Header: file I/O, magic 0x4157, version 1-20, playback.thm
- Payload: ReplayStream cursor parses 6800-byte settings chunk
- Globals: scheme data, team entries (6 slots Γ— 0xD7B), observers
- Processing bridges: ProcessTeamColors, FUN_0045D640,
ProcessSchemeDefaults (naked asm ESI), ValidateTeamSetup
- Map loading: alloc(0x29628), construct, load, copy info, release
- Log output: date, version strings, team listing for /getlog
- Random seed: stream value replaces current (not save/restore)

ParseReplayPosition: pure Rust "MM:SS.FF" β†’ frame count parser.

ReplayStream: 7 methods porting 6 WA stream helper functions,
18 unit tests. 56 new addresses, 12 Ghidra labels.

Both headful (133/0) and headless (byte-identical) tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

crates/openwa-core/src/address.rs CHANGED
@@ -158,6 +158,37 @@ pub mod va {
158
  pub const REPLAY_LOADER: u32 = 0x0046_2DF0;
159
  /// Parses "MM:SS.FF" time string β†’ frame number. Returns -1 on failure.
160
  pub const PARSE_REPLAY_POSITION: u32 = 0x004E_3490;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  /// Routes game messages through the task handler tree.
162
  pub const GAME_MESSAGE_ROUTER: u32 = 0x0055_3BD0;
163
  /// TurnGame message dispatcher. Case 2=FrameFinish, Case 4=ProcessInput, Case 0x28=SkipGo.
@@ -846,6 +877,31 @@ pub mod va {
846
  /// Total palette entry bytes loaded (entry_count Γ— 3)
847
  pub const G_SPRITE_PALETTE_BYTES: u32 = 0x007A_0870;
848
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
849
  // === Configuration globals (for GameInfo__LoadOptions) ===
850
 
851
  /// Base directory string (null-terminated)
 
158
  pub const REPLAY_LOADER: u32 = 0x0046_2DF0;
159
  /// Parses "MM:SS.FF" time string β†’ frame number. Returns -1 on failure.
160
  pub const PARSE_REPLAY_POSITION: u32 = 0x004E_3490;
161
+
162
+ // --- Replay stream helpers (ported to Rust, addresses for reference) ---
163
+
164
+ /// Read length-prefixed string. usercall(EDI=ctx) + stdcall(dest, max_len). RET 0x8.
165
+ pub const REPLAY_READ_PREFIXED_STRING: u32 = 0x0046_1340;
166
+ /// Read byte with range validation. usercall(EAX=ctx) + stdcall(dest, min, max). RET 0xC.
167
+ pub const REPLAY_READ_BYTE_VALIDATED: u32 = 0x0046_14D0;
168
+ /// Read byte with signed range validation. usercall(EAX=ctx) + stdcall(dest, min, max). RET 0xC.
169
+ pub const REPLAY_READ_BYTE_RANGE: u32 = 0x0046_1540;
170
+ /// Read u16 with range validation. usercall(EAX=ctx) + stdcall(dest, min, max). RET 0xC.
171
+ pub const REPLAY_READ_U16_VALIDATED: u32 = 0x0046_15B0;
172
+ /// Read worm name (0x11 fixed or length-prefixed). usercall(EAX=ctx) + thiscall(this, flag). RET 0x4.
173
+ pub const REPLAY_READ_WORM_NAME: u32 = 0x0046_1620;
174
+ /// Validate team type byte range. fastcall(ECX=type). Plain RET.
175
+ pub const REPLAY_VALIDATE_TEAM_TYPE: u32 = 0x0046_1690;
176
+
177
+ // --- Replay processing functions (bridged via FFI) ---
178
+
179
+ /// Post-process team color assignments. stdcall(1 param). RET 0x4.
180
+ pub const REPLAY_PROCESS_TEAM_COLORS: u32 = 0x0046_6460;
181
+ /// Apply scheme default values. No params (uses globals). Plain RET.
182
+ pub const REPLAY_PROCESS_SCHEME_DEFAULTS: u32 = 0x0046_70F0;
183
+ /// Process replay feature flags. No params (uses globals). Plain RET.
184
+ pub const REPLAY_PROCESS_FLAGS: u32 = 0x0046_7280;
185
+ /// Register observer team entry. stdcall(1 param). RET 0x4.
186
+ pub const REPLAY_REGISTER_OBSERVER: u32 = 0x0046_7BC0;
187
+ /// Process alliance/team setup. No params (uses globals). Plain RET.
188
+ pub const REPLAY_PROCESS_ALLIANCE: u32 = 0x0046_8890;
189
+ /// Validate team configuration. stdcall(1 param).
190
+ pub const REPLAY_VALIDATE_TEAM_SETUP: u32 = 0x0046_5E10;
191
+
192
  /// Routes game messages through the task handler tree.
193
  pub const GAME_MESSAGE_ROUTER: u32 = 0x0055_3BD0;
194
  /// TurnGame message dispatcher. Case 2=FrameFinish, Case 4=ProcessInput, Case 0x28=SkipGo.
 
877
  /// Total palette entry bytes loaded (entry_count Γ— 3)
878
  pub const G_SPRITE_PALETTE_BYTES: u32 = 0x007A_0870;
879
 
880
+ // === Replay globals ===
881
+
882
+ /// Global game state struct passed to ReplayLoader. Always 0x87D3F8.
883
+ pub const G_REPLAY_STATE: u32 = 0x0087_D3F8;
884
+ /// Team header data buffer (0x5728 bytes), cleared by ReplayLoader.
885
+ pub const G_TEAM_HEADER_DATA: u32 = 0x0087_79E4;
886
+ /// Secondary team/game data buffer (0xD9DC bytes).
887
+ pub const G_TEAM_SECONDARY_DATA: u32 = 0x0087_D438;
888
+ /// XOR'd game ID (payload ^ 0xEF5B5C49).
889
+ pub const G_REPLAY_GAME_ID: u32 = 0x0088_AF50;
890
+ /// Replay sub-format flag.
891
+ pub const G_REPLAY_SUB_FORMAT: u32 = 0x0088_AF54;
892
+ /// Game version ID from replay.
893
+ pub const G_REPLAY_VERSION_ID: u32 = 0x0088_ABB0;
894
+ /// Scheme present flag (1 = has scheme data).
895
+ pub const G_REPLAY_SCHEME_PRESENT: u32 = 0x0088_AE0C;
896
+ /// Version/ArtClass counter. ReplayLoader fails if > 0x33.
897
+ pub const G_ARTCLASS_COUNTER: u32 = 0x0088_C790;
898
+ /// Random seed global.
899
+ pub const G_RANDOM_SEED: u32 = 0x0088_D0B4;
900
+ /// Saved random seed.
901
+ pub const G_SAVED_RANDOM_SEED: u32 = 0x0088_ABAC;
902
+ /// Replay filename buffer.
903
+ pub const G_REPLAY_FILENAME: u32 = 0x0088_AF58;
904
+
905
  // === Configuration globals (for GameInfo__LoadOptions) ===
906
 
907
  /// Base directory string (null-terminated)
crates/openwa-core/src/engine/mod.rs CHANGED
@@ -1,4 +1,5 @@
1
  pub mod ddgame;
 
2
  pub mod ddgame_wrapper;
3
  pub mod game_info;
4
  pub mod game_session;
 
1
  pub mod ddgame;
2
+ pub mod replay;
3
  pub mod ddgame_wrapper;
4
  pub mod game_info;
5
  pub mod game_session;
crates/openwa-core/src/engine/replay.rs ADDED
@@ -0,0 +1,406 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// Replay file parsing: ReplayStream cursor, ParseReplayPosition, and types.
2
+ ///
3
+ /// Ports the WA.exe stream helper functions (0x461340..0x461690) as methods on
4
+ /// `ReplayStream`, and `ParseReplayPosition` (0x4E3490) as a standalone function.
5
+
6
+ /// Error codes returned by ReplayLoader (0x462DF0).
7
+ #[repr(i32)]
8
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
9
+ pub enum ReplayError {
10
+ FileNotFound = -1, // 0xFFFFFFFF
11
+ InvalidFormat = -2, // 0xFFFFFFFE
12
+ VersionTooNew = -3, // 0xFFFFFFFD
13
+ MallocFailure = -4, // 0xFFFFFFFC
14
+ MapLoadFailure = -5, // 0xFFFFFFFB
15
+ ArtClassLimit = -6, // 0xFFFFFFFA
16
+ RepairWithTeams = -7, // 0xFFFFFFF9
17
+ // -8 is unused (gap in error codes)
18
+ NoScheme = -9, // 0xFFFFFFF7
19
+ SchemeSaveFailure = -10, // 0xFFFFFFF6
20
+ }
21
+
22
+ /// Replay file magic: "WA" in little-endian.
23
+ pub const REPLAY_MAGIC: u16 = 0x4157;
24
+ /// Maximum supported replay version.
25
+ pub const REPLAY_MAX_VERSION: u16 = 0x13;
26
+ /// XOR key for game ID integrity check.
27
+ pub const REPLAY_XOR_KEY: u32 = 0xEF5B_5C49;
28
+
29
+ /// Cursor over a replay payload byte buffer.
30
+ ///
31
+ /// Mirrors the 3-DWORD stream context used by WA's stream helper functions:
32
+ /// `[data_ptr, total_size, cursor_offset]`.
33
+ pub struct ReplayStream<'a> {
34
+ data: &'a [u8],
35
+ cursor: usize,
36
+ }
37
+
38
+ impl<'a> ReplayStream<'a> {
39
+ pub fn new(data: &'a [u8]) -> Self {
40
+ Self { data, cursor: 0 }
41
+ }
42
+
43
+ pub fn remaining(&self) -> usize {
44
+ self.data.len().saturating_sub(self.cursor)
45
+ }
46
+
47
+ pub fn cursor(&self) -> usize {
48
+ self.cursor
49
+ }
50
+
51
+ /// Advance cursor by `n` bytes, returning the raw slice. Public for memcpy to globals.
52
+ pub fn advance_raw(&mut self, n: usize) -> Result<&'a [u8], ReplayError> {
53
+ self.advance(n)
54
+ }
55
+
56
+ /// Advance cursor by `n` bytes, returning the slice. Fails if not enough data.
57
+ fn advance(&mut self, n: usize) -> Result<&'a [u8], ReplayError> {
58
+ let end = self.cursor + n;
59
+ if end > self.data.len() {
60
+ return Err(ReplayError::InvalidFormat);
61
+ }
62
+ let slice = &self.data[self.cursor..end];
63
+ self.cursor = end;
64
+ Ok(slice)
65
+ }
66
+
67
+ /// Read a single byte. Port of parts of Replay__ReadByteValidated (0x4614D0).
68
+ pub fn read_u8(&mut self) -> Result<u8, ReplayError> {
69
+ let slice = self.advance(1)?;
70
+ Ok(slice[0])
71
+ }
72
+
73
+ /// Read a single byte, validating it is in [min, max].
74
+ /// Port of Replay__ReadByteValidated (0x4614D0).
75
+ pub fn read_u8_validated(&mut self, min: u8, max: u8) -> Result<u8, ReplayError> {
76
+ let val = self.read_u8()?;
77
+ if val < min || val > max {
78
+ return Err(ReplayError::InvalidFormat);
79
+ }
80
+ Ok(val)
81
+ }
82
+
83
+ /// Read a little-endian u16. Part of Replay__ReadU16Validated (0x4615B0).
84
+ pub fn read_u16(&mut self) -> Result<u16, ReplayError> {
85
+ let slice = self.advance(2)?;
86
+ Ok(u16::from_le_bytes([slice[0], slice[1]]))
87
+ }
88
+
89
+ /// Read a little-endian u16, validating range.
90
+ /// Port of Replay__ReadU16Validated (0x4615B0).
91
+ pub fn read_u16_validated(&mut self, min: u16, max: u16) -> Result<u16, ReplayError> {
92
+ let val = self.read_u16()?;
93
+ if val < min || val > max {
94
+ return Err(ReplayError::InvalidFormat);
95
+ }
96
+ Ok(val)
97
+ }
98
+
99
+ /// Read a little-endian u32.
100
+ pub fn read_u32(&mut self) -> Result<u32, ReplayError> {
101
+ let slice = self.advance(4)?;
102
+ Ok(u32::from_le_bytes([slice[0], slice[1], slice[2], slice[3]]))
103
+ }
104
+
105
+ /// Read a little-endian i32.
106
+ pub fn read_i32(&mut self) -> Result<i32, ReplayError> {
107
+ Ok(self.read_u32()? as i32)
108
+ }
109
+
110
+ /// Read `n` bytes into a destination buffer. Fails if not enough data.
111
+ pub fn read_into(&mut self, dest: &mut [u8]) -> Result<(), ReplayError> {
112
+ let slice = self.advance(dest.len())?;
113
+ dest.copy_from_slice(slice);
114
+ Ok(())
115
+ }
116
+
117
+ /// Read a length-prefixed string into buffer.
118
+ /// Port of Replay__ReadPrefixedString (0x461340).
119
+ ///
120
+ /// Reads 1 byte (length), then `length` bytes of string data, null-terminates.
121
+ pub fn read_prefixed_string(&mut self, dest: &mut [u8]) -> Result<usize, ReplayError> {
122
+ let len = self.read_u8()? as usize;
123
+ if len > dest.len().saturating_sub(1) {
124
+ return Err(ReplayError::InvalidFormat);
125
+ }
126
+ let slice = self.advance(len)?;
127
+ dest[..len].copy_from_slice(slice);
128
+ dest[len] = 0; // null-terminate
129
+ Ok(len)
130
+ }
131
+
132
+ /// Read a worm name: either 0x11 fixed bytes or length-prefixed.
133
+ /// Port of Replay__ReadWormName (0x461620).
134
+ pub fn read_worm_name(
135
+ &mut self,
136
+ dest: &mut [u8; 0x11],
137
+ use_fixed: bool,
138
+ ) -> Result<(), ReplayError> {
139
+ if use_fixed {
140
+ let slice = self.advance(0x11)?;
141
+ dest.copy_from_slice(slice);
142
+ } else {
143
+ dest.fill(0);
144
+ self.read_prefixed_string(dest)?;
145
+ }
146
+ Ok(())
147
+ }
148
+
149
+ /// Skip `n` bytes.
150
+ pub fn skip(&mut self, n: usize) -> Result<(), ReplayError> {
151
+ self.advance(n)?;
152
+ Ok(())
153
+ }
154
+ }
155
+
156
+ /// Validate team type byte. Port of Replay__ValidateTeamType (0x461690).
157
+ ///
158
+ /// Returns true if the team type value is in a valid range:
159
+ /// - Non-negative: type < 13
160
+ /// - Negative: -100 or absolute value <= 100 (SETLE in disassembly)
161
+ pub fn validate_team_type(team_type: i8) -> bool {
162
+ if team_type >= 0 {
163
+ team_type < 13
164
+ } else {
165
+ team_type == -100 || -(team_type as i32) <= 100
166
+ }
167
+ }
168
+
169
+ /// Parse a replay position time string to frame count (50fps).
170
+ ///
171
+ /// Format: `[MM:]SS[.FF]` where:
172
+ /// - MM = minutes (multiplied by 60)
173
+ /// - SS = seconds (max 59 after a colon)
174
+ /// - FF = fractional (multiplied by 50 for frames, divided by power of 10)
175
+ /// - Maximum 2 colons allowed
176
+ ///
177
+ /// Returns frame count, or -1 on parse error.
178
+ /// Port of WA.exe ParseReplayPosition (0x4E3490).
179
+ pub fn parse_replay_position(input: &[u8]) -> i32 {
180
+ let mut colon_count: i32 = 0;
181
+ let mut accumulated: i32 = 0;
182
+ let mut current: i32 = 0;
183
+ let mut digit_count: i32 = 0;
184
+ let mut frac_divisor: i32 = 0; // 0 = integer part, >0 = fractional
185
+ let mut i = 0;
186
+
187
+ loop {
188
+ if i >= input.len() {
189
+ return -1;
190
+ }
191
+
192
+ // Process digit runs
193
+ while i < input.len() && input[i] >= b'0' && input[i] <= b'9' {
194
+ let max_digits = if colon_count < 1 { 4 } else { 2 };
195
+ if digit_count >= max_digits {
196
+ return -1;
197
+ }
198
+ if frac_divisor == 0 {
199
+ current = current * 10 + (input[i] - b'0') as i32;
200
+ digit_count += 1;
201
+ } else {
202
+ let frac_value = ((input[i] - b'0') as i32 * 50) / frac_divisor;
203
+ frac_divisor *= 10;
204
+ accumulated += frac_value;
205
+ }
206
+ i += 1;
207
+ }
208
+
209
+ // Check delimiter
210
+ if i >= input.len() {
211
+ return -1;
212
+ }
213
+ let delim = input[i];
214
+
215
+ if delim != b':' && delim != b'.' && delim != 0 {
216
+ return -1;
217
+ }
218
+
219
+ if frac_divisor == 0 {
220
+ // Integer part complete
221
+ if colon_count > 0 && current > 59 {
222
+ return -1;
223
+ }
224
+ accumulated = current + accumulated * 60;
225
+ current = 0;
226
+ digit_count = 0;
227
+
228
+ if delim == b':' {
229
+ if colon_count >= 2 {
230
+ return -1;
231
+ }
232
+ colon_count += 1;
233
+ } else {
234
+ // '.' or '\0' β€” convert seconds to frames
235
+ accumulated *= 50;
236
+ }
237
+ } else {
238
+ // Fractional part β€” ':' and '.' are invalid after '.'
239
+ if delim == b':' || delim == b'.' {
240
+ return -1;
241
+ }
242
+ }
243
+
244
+ if delim == 0 {
245
+ return accumulated;
246
+ }
247
+
248
+ if delim == b'.' {
249
+ frac_divisor = 10;
250
+ }
251
+
252
+ i += 1;
253
+ }
254
+ }
255
+
256
+ #[cfg(test)]
257
+ mod tests {
258
+ use super::*;
259
+
260
+ // --- ReplayStream tests ---
261
+
262
+ #[test]
263
+ fn test_read_u8() {
264
+ let data = [0x42, 0xFF];
265
+ let mut s = ReplayStream::new(&data);
266
+ assert_eq!(s.read_u8().unwrap(), 0x42);
267
+ assert_eq!(s.read_u8().unwrap(), 0xFF);
268
+ assert!(s.read_u8().is_err());
269
+ }
270
+
271
+ #[test]
272
+ fn test_read_u8_validated() {
273
+ let data = [5, 0, 20];
274
+ let mut s = ReplayStream::new(&data);
275
+ assert_eq!(s.read_u8_validated(0, 10).unwrap(), 5);
276
+ assert_eq!(s.read_u8_validated(0, 10).unwrap(), 0);
277
+ assert!(s.read_u8_validated(0, 10).is_err()); // 20 > 10
278
+ }
279
+
280
+ #[test]
281
+ fn test_read_u16_le() {
282
+ let data = [0x57, 0x41]; // "WA" = 0x4157
283
+ let mut s = ReplayStream::new(&data);
284
+ assert_eq!(s.read_u16().unwrap(), 0x4157);
285
+ }
286
+
287
+ #[test]
288
+ fn test_read_u32_le() {
289
+ let data = [0x78, 0x56, 0x34, 0x12];
290
+ let mut s = ReplayStream::new(&data);
291
+ assert_eq!(s.read_u32().unwrap(), 0x12345678);
292
+ }
293
+
294
+ #[test]
295
+ fn test_read_prefixed_string() {
296
+ let data = [3, b'f', b'o', b'o', 0, b'x']; // len=3, "foo"
297
+ let mut s = ReplayStream::new(&data);
298
+ let mut buf = [0u8; 16];
299
+ let len = s.read_prefixed_string(&mut buf).unwrap();
300
+ assert_eq!(len, 3);
301
+ assert_eq!(&buf[..4], b"foo\0");
302
+ assert_eq!(s.cursor(), 4);
303
+ }
304
+
305
+ #[test]
306
+ fn test_read_prefixed_string_overflow() {
307
+ let data = [10, b'a', b'b']; // claims len=10 but only 2 bytes
308
+ let mut s = ReplayStream::new(&data);
309
+ let mut buf = [0u8; 4];
310
+ assert!(s.read_prefixed_string(&mut buf).is_err());
311
+ }
312
+
313
+ #[test]
314
+ fn test_read_worm_name_fixed() {
315
+ let mut data = [0u8; 0x11];
316
+ data[0] = b'W';
317
+ data[1] = b'o';
318
+ data[2] = b'r';
319
+ data[3] = b'm';
320
+ let mut s = ReplayStream::new(&data);
321
+ let mut name = [0u8; 0x11];
322
+ s.read_worm_name(&mut name, true).unwrap();
323
+ assert_eq!(name[0], b'W');
324
+ assert_eq!(s.cursor(), 0x11);
325
+ }
326
+
327
+ #[test]
328
+ fn test_read_worm_name_prefixed() {
329
+ let data = [4, b'W', b'o', b'r', b'm'];
330
+ let mut s = ReplayStream::new(&data);
331
+ let mut name = [0u8; 0x11];
332
+ s.read_worm_name(&mut name, false).unwrap();
333
+ assert_eq!(&name[..5], b"Worm\0");
334
+ }
335
+
336
+ #[test]
337
+ fn test_read_into() {
338
+ let data = [1, 2, 3, 4, 5];
339
+ let mut s = ReplayStream::new(&data);
340
+ let mut buf = [0u8; 3];
341
+ s.read_into(&mut buf).unwrap();
342
+ assert_eq!(buf, [1, 2, 3]);
343
+ assert_eq!(s.cursor(), 3);
344
+ }
345
+
346
+ #[test]
347
+ fn test_skip() {
348
+ let data = [0u8; 10];
349
+ let mut s = ReplayStream::new(&data);
350
+ s.skip(5).unwrap();
351
+ assert_eq!(s.cursor(), 5);
352
+ assert_eq!(s.remaining(), 5);
353
+ assert!(s.skip(6).is_err());
354
+ }
355
+
356
+ // --- validate_team_type tests ---
357
+
358
+ #[test]
359
+ fn test_validate_team_type() {
360
+ assert!(validate_team_type(0));
361
+ assert!(validate_team_type(12));
362
+ assert!(!validate_team_type(13));
363
+ assert!(validate_team_type(-1));
364
+ assert!(validate_team_type(-99));
365
+ assert!(validate_team_type(-100));
366
+ assert!(!validate_team_type(-101));
367
+ }
368
+
369
+ // --- ParseReplayPosition tests ---
370
+
371
+ #[test]
372
+ fn test_parse_replay_position_seconds() {
373
+ assert_eq!(parse_replay_position(b"30\0"), 30 * 50);
374
+ }
375
+
376
+ #[test]
377
+ fn test_parse_replay_position_minutes_seconds() {
378
+ assert_eq!(parse_replay_position(b"1:30\0"), (60 + 30) * 50);
379
+ }
380
+
381
+ #[test]
382
+ fn test_parse_replay_position_with_frames() {
383
+ assert_eq!(parse_replay_position(b"1:30.5\0"), (60 + 30) * 50 + 25);
384
+ }
385
+
386
+ #[test]
387
+ fn test_parse_replay_position_zero() {
388
+ assert_eq!(parse_replay_position(b"0\0"), 0);
389
+ }
390
+
391
+ #[test]
392
+ fn test_parse_replay_position_invalid() {
393
+ assert_eq!(parse_replay_position(b"abc\0"), -1);
394
+ }
395
+
396
+ #[test]
397
+ fn test_parse_replay_position_seconds_over_59() {
398
+ assert_eq!(parse_replay_position(b"1:60\0"), -1);
399
+ }
400
+
401
+ #[test]
402
+ fn test_parse_replay_position_no_null() {
403
+ // No null terminator β€” should return -1
404
+ assert_eq!(parse_replay_position(b"30"), -1);
405
+ }
406
+ }
crates/openwa-wormkit/src/replacements/mod.rs CHANGED
@@ -8,6 +8,7 @@ mod hardware_init;
8
  mod headless;
9
  pub(crate) mod input;
10
  mod render;
 
11
  mod scheme;
12
  mod sound;
13
  mod speech;
@@ -30,5 +31,6 @@ pub fn install_all() -> Result<(), String> {
30
  input::install()?;
31
  ddgame_init::install()?;
32
  game_state_hooks::install()?;
 
33
  Ok(())
34
  }
 
8
  mod headless;
9
  pub(crate) mod input;
10
  mod render;
11
+ mod replay;
12
  mod scheme;
13
  mod sound;
14
  mod speech;
 
31
  input::install()?;
32
  ddgame_init::install()?;
33
  game_state_hooks::install()?;
34
+ replay::install()?;
35
  Ok(())
36
  }
crates/openwa-wormkit/src/replacements/replay.rs ADDED
@@ -0,0 +1,928 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Hooks for ReplayLoader and ParseReplayPosition.
2
+ //!
3
+ //! ReplayLoader (0x462DF0): stdcall(state, mode), RET 0x8.
4
+ //! ParseReplayPosition (0x4E3490): stdcall(input), RET 0x4.
5
+
6
+ use crate::hook;
7
+ use crate::log_line;
8
+ use openwa_core::address::va;
9
+ use openwa_core::engine::replay::{self, ReplayError, ReplayStream};
10
+ use openwa_core::rebase::rb;
11
+
12
+ use core::ffi::c_void;
13
+ use core::ptr;
14
+
15
+ // ─── WA CRT wrappers (rebased addresses, NOT Rust's UCRT) ───────────────────
16
+
17
+ #[allow(non_camel_case_types)]
18
+ type FILE = c_void;
19
+
20
+ unsafe fn wa_fopen(f: *const u8, m: *const u8) -> *mut FILE {
21
+ core::mem::transmute::<_, unsafe extern "cdecl" fn(*const u8, *const u8) -> *mut FILE>(rb(0x5D3271))(f, m)
22
+ }
23
+ unsafe fn wa_fclose(s: *mut FILE) {
24
+ core::mem::transmute::<_, unsafe extern "cdecl" fn(*mut FILE)>(rb(0x5D399B))(s);
25
+ }
26
+ unsafe fn wa_fread(b: *mut c_void, sz: u32, c: u32, f: *mut FILE) -> u32 {
27
+ core::mem::transmute::<_, unsafe extern "cdecl" fn(*mut c_void, u32, u32, *mut FILE) -> u32>(rb(0x5D4531))(b, sz, c, f)
28
+ }
29
+ unsafe fn wa_fwrite(b: *const c_void, sz: u32, c: u32, f: *mut FILE) -> u32 {
30
+ core::mem::transmute::<_, unsafe extern "cdecl" fn(*const c_void, u32, u32, *mut FILE) -> u32>(rb(0x5D3B76))(b, sz, c, f)
31
+ }
32
+ unsafe fn wa_malloc(sz: u32) -> *mut u8 {
33
+ core::mem::transmute::<_, unsafe extern "cdecl" fn(u32) -> *mut u8>(rb(0x5D0F65))(sz)
34
+ }
35
+ unsafe fn wa_free(p: *mut u8) {
36
+ core::mem::transmute::<_, unsafe extern "cdecl" fn(*mut u8)>(rb(0x5D0D2B))(p);
37
+ }
38
+ unsafe fn wa_fileno(s: *mut FILE) -> i32 {
39
+ core::mem::transmute::<_, unsafe extern "cdecl" fn(*mut FILE) -> i32>(rb(0x5D5155))(s)
40
+ }
41
+ unsafe fn wa_filelengthi64(fd: i32) -> i64 {
42
+ core::mem::transmute::<_, unsafe extern "cdecl" fn(i32) -> i64>(rb(0x5D4FE1))(fd)
43
+ }
44
+
45
+ extern "stdcall" { fn SetCurrentDirectoryA(path: *const u8) -> i32; }
46
+
47
+ // ─── Trampoline storage ─────────────────────────────────────────────────────
48
+
49
+ static mut REPLAY_LOADER_ORIG: *const () = core::ptr::null();
50
+ #[allow(dead_code)]
51
+ static mut PARSE_POSITION_ORIG: *const () = core::ptr::null();
52
+ /// Replay timestamp extracted during parsing (time_t as u32).
53
+ static mut REPLAY_TIMESTAMP: u32 = 0;
54
+
55
+ // ─── RAII cleanup guard ─────────────────────────────────────────────────────
56
+
57
+ struct ReplayGuard { file: *mut FILE, payload: *mut u8 }
58
+ impl ReplayGuard { fn new() -> Self { Self { file: ptr::null_mut(), payload: ptr::null_mut() } } }
59
+ impl Drop for ReplayGuard {
60
+ fn drop(&mut self) {
61
+ unsafe {
62
+ if !self.payload.is_null() { wa_free(self.payload); }
63
+ if !self.file.is_null() { wa_fclose(self.file); }
64
+ }
65
+ }
66
+ }
67
+
68
+ // ─── ReplayLoader hook ──────────────────────────────────────────────────────
69
+
70
+ unsafe extern "stdcall" fn hook_replay_loader(state: u32, mode: i32) -> u32 {
71
+ if mode != 1 {
72
+ let orig: unsafe extern "stdcall" fn(u32, i32) -> u32 =
73
+ core::mem::transmute(REPLAY_LOADER_ORIG);
74
+ return orig(state, mode);
75
+ }
76
+ match replay_loader_play(state) {
77
+ Ok(()) => 0u32,
78
+ Err(e) => e as i32 as u32,
79
+ }
80
+ }
81
+
82
+ // ─── Main replay loader (mode 1) ────────────────────────────────────────────
83
+
84
+ unsafe fn replay_loader_play(state: u32) -> Result<(), ReplayError> {
85
+ let artclass_counter = *(rb(va::G_ARTCLASS_COUNTER) as *const i32);
86
+ if artclass_counter >= 0x34 { return Err(ReplayError::ArtClassLimit); }
87
+
88
+ let s = state as *mut u8;
89
+ *s.add(0xDB48) = 1;
90
+ *(s.add(0xEF60) as *mut u32) = 0;
91
+
92
+ // Open replay file
93
+ SetCurrentDirectoryA(rb(0x88E078) as *const u8);
94
+ let file = wa_fopen(s.add(0xDB60), b"rb\0".as_ptr());
95
+ SetCurrentDirectoryA(rb(0x88E17D) as *const u8);
96
+ if file.is_null() { return Err(ReplayError::FileNotFound); }
97
+
98
+ let mut guard = ReplayGuard::new();
99
+ guard.file = file;
100
+ let fd = wa_fileno(file);
101
+ let file_size = wa_filelengthi64(fd) as u64;
102
+
103
+ // Header
104
+ let mut header: u32 = 0;
105
+ if wa_fread(&mut header as *mut u32 as *mut c_void, 4, 1, file) == 0 { return Err(ReplayError::InvalidFormat); }
106
+ if (header & 0xFFFF) != replay::REPLAY_MAGIC as u32 { return Err(ReplayError::InvalidFormat); }
107
+ let version = header >> 16;
108
+ if version == 0 || version > 20 { return Err(ReplayError::VersionTooNew); }
109
+
110
+ *(s.add(0xDB50) as *mut u32) = version;
111
+ *(s.add(0xDB54) as *mut u32) = version;
112
+ *(s.add(0xDB58) as *mut u32) = 0xFFFFFFFF;
113
+
114
+ // First payload
115
+ let mut payload_size: u32 = 0;
116
+ if wa_fread(&mut payload_size as *mut u32 as *mut c_void, 4, 1, file) == 0 { return Err(ReplayError::InvalidFormat); }
117
+ if (payload_size as u64 + 8) > file_size { return Err(ReplayError::InvalidFormat); }
118
+ let payload = wa_malloc(payload_size);
119
+ if payload.is_null() { return Err(ReplayError::MallocFailure); }
120
+ guard.payload = payload;
121
+ if wa_fread(payload as *mut c_void, payload_size, 1, file) == 0 { return Err(ReplayError::FileNotFound); }
122
+
123
+ let first_dword = *(payload as *const i32);
124
+ *(s.add(0xDB1C) as *mut i32) = first_dword;
125
+ if first_dword >= 1 {
126
+ let thm = wa_fopen(b"data\\playback.thm\0".as_ptr(), b"wb\0".as_ptr());
127
+ if !thm.is_null() { wa_fwrite(payload as *const c_void, 1, payload_size, thm); wa_fclose(thm); }
128
+ } else {
129
+ *(s.add(0xDB20) as *mut i32) = *(payload.add(4) as *const i32);
130
+ if first_dword >= -4 && first_dword < -2 {
131
+ *(s.add(0xDB24) as *mut i32) = *(payload.add(8) as *const i32);
132
+ } else if first_dword == -2 {
133
+ let n = payload_size.saturating_sub(8) as usize;
134
+ if n > 0x20 { return Err(ReplayError::InvalidFormat); }
135
+ ptr::copy_nonoverlapping(payload.add(8), s.add(0xDB24), n);
136
+ *s.add(0xDB1C + payload_size as usize) = 0;
137
+ }
138
+ }
139
+ wa_free(payload); guard.payload = ptr::null_mut();
140
+
141
+ // Clear global buffers
142
+ ptr::write_bytes(rb(va::G_TEAM_HEADER_DATA) as *mut u8, 0, 0x5728);
143
+ ptr::write_bytes(rb(va::G_TEAM_SECONDARY_DATA) as *mut u8, 0, 0xD9DC);
144
+
145
+ let _ = log_line(&format!("[Replay] Header: ver={version} payload={payload_size} sub={first_dword}"));
146
+
147
+ if version == 1 {
148
+ return parse_version1(state, file, &mut guard);
149
+ }
150
+
151
+ // ─── Version 2+: read second payload ─────────────────────────────────
152
+
153
+ let mut second_size: u32 = 0;
154
+ if wa_fread(&mut second_size as *mut u32 as *mut c_void, 4, 1, file) == 0 { return Err(ReplayError::InvalidFormat); }
155
+ if (4u64 + 4 + payload_size as u64 + 4 + second_size as u64) > file_size { return Err(ReplayError::InvalidFormat); }
156
+ let p2 = wa_malloc(second_size);
157
+ if p2.is_null() { return Err(ReplayError::MallocFailure); }
158
+ guard.payload = p2;
159
+ if wa_fread(p2 as *mut c_void, second_size, 1, file) == 0 { return Err(ReplayError::InvalidFormat); }
160
+ wa_fclose(file); guard.file = ptr::null_mut();
161
+
162
+ // Parse and write to globals
163
+ let data = core::slice::from_raw_parts(p2, second_size as usize);
164
+ let result = parse_and_write_v2plus(state, data, version);
165
+ wa_free(p2); guard.payload = ptr::null_mut();
166
+
167
+ match result {
168
+ Ok(()) => {
169
+ let _ = log_line("[Replay] about to call ProcessReplayFlags");
170
+ call_usercall_eax(state, rb(va::REPLAY_PROCESS_FLAGS));
171
+ let _ = log_line("[Replay] ProcessReplayFlags done");
172
+ let log_file = *(rb(0x88C370) as *const *mut FILE);
173
+ if !log_file.is_null() {
174
+ let _ = log_line("[Replay] writing log output");
175
+ write_replay_log(state, log_file)?;
176
+ let _ = log_line("[Replay] log output done");
177
+ }
178
+ let _ = log_line("[Replay] Rust replay loading complete");
179
+ Ok(())
180
+ }
181
+ Err(e) => {
182
+ let _ = log_line(&format!("[Replay] Parse failed ({e:?}), falling back to original"));
183
+ delegate_to_original(state)
184
+ }
185
+ }
186
+ }
187
+
188
+ unsafe fn parse_version1(state: u32, _file: *mut FILE, guard: &mut ReplayGuard) -> Result<(), ReplayError> {
189
+ wa_fclose(guard.file); guard.file = ptr::null_mut();
190
+ delegate_to_original(state)
191
+ }
192
+
193
+ unsafe fn delegate_to_original(state: u32) -> Result<(), ReplayError> {
194
+ let orig: unsafe extern "stdcall" fn(u32, i32) -> u32 = core::mem::transmute(REPLAY_LOADER_ORIG);
195
+ let result = orig(state, 1);
196
+ if result == 0 { Ok(()) } else { Err(ReplayError::FileNotFound) }
197
+ }
198
+
199
+ // ─── Version 2+ parser that writes to globals ───────────────────────────────
200
+
201
+ /// Helper: write byte to rebased global address.
202
+ #[inline]
203
+ unsafe fn wb(addr: u32, val: u8) { *(rb(addr) as *mut u8) = val; }
204
+ /// Helper: write u32 to rebased global address.
205
+ #[inline]
206
+ unsafe fn wd(addr: u32, val: u32) { *(rb(addr) as *mut u32) = val; }
207
+
208
+ unsafe fn parse_and_write_v2plus(
209
+ state: u32, data: &[u8], version: u32,
210
+ ) -> Result<(), ReplayError> {
211
+ let mut s = ReplayStream::new(data);
212
+
213
+ // ── Sub-format flags ─────────────────────────────────────────────────
214
+ let ver_gt7 = (version > 7) as u8;
215
+ wb(0x88AF42, ver_gt7);
216
+ wb(0x88AF43, ver_gt7);
217
+ wd(va::G_REPLAY_SUB_FORMAT, 0);
218
+
219
+ let mut obs_count: u16 = 0;
220
+
221
+ if version >= 10 {
222
+ let sub_format = s.read_u16()?;
223
+ wd(va::G_REPLAY_SUB_FORMAT, sub_format as u32);
224
+ if sub_format != 0 { return Err(ReplayError::VersionTooNew); }
225
+
226
+ if version >= 12 {
227
+ if version < 18 {
228
+ let mode = s.read_u8_validated(0, 2)?;
229
+ wb(0x88AF44, mode);
230
+ } else {
231
+ let raw = s.read_u8_validated(0, 3)?;
232
+ if raw >= 2 && raw <= 3 {
233
+ wb(0x88AF44, raw - 1);
234
+ wb(0x88AF42, 1); wb(0x88AF43, 1);
235
+ } else {
236
+ wb(0x88AF42, (raw != 0) as u8);
237
+ wb(0x88AF44, 0);
238
+ wb(0x88AF43, (raw != 0) as u8);
239
+ }
240
+ }
241
+ }
242
+
243
+ obs_count = s.read_u16_validated(1, version as u16)?;
244
+ wd(0x88AF4C, obs_count as u32);
245
+
246
+ // FUN_0053ee00: cleanup observer array. usercall(ESI=0x88C35C). Plain RET.
247
+ call_usercall_esi(rb(0x88C35C), rb(0x53EE00));
248
+
249
+ // Observer team loop: register each observer via naked bridge
250
+ loop {
251
+ let team_id = s.read_u32()?;
252
+ let obs_type = s.read_u8_validated(0, 2)?;
253
+ // Build 4-DWORD struct: [team_id, 0, obs_type_as_u32, ???]
254
+ // Original: aiStack_260[0x2d]=team_id, [0x2e]=0, [0x2f]=obs_type
255
+ let obs_data: [u32; 4] = [team_id, 0, obs_type as u32, 0];
256
+ // RegisterObserver: usercall(ESI=0x88C35C) + stdcall(1 param = &data). RET 0x4.
257
+ call_register_observer(
258
+ rb(0x88C35C), obs_data.as_ptr() as u32, rb(va::REPLAY_REGISTER_OBSERVER),
259
+ );
260
+ if obs_type == 0 {
261
+ // The terminating entry's team_id is the replay timestamp (time_t)
262
+ REPLAY_TIMESTAMP = team_id;
263
+ break;
264
+ }
265
+ }
266
+ }
267
+
268
+ let game_version_id = s.read_i32()?;
269
+ wd(va::G_REPLAY_VERSION_ID, game_version_id as u32);
270
+ if (game_version_id.wrapping_add(4) as u32) > 0x1F8 { return Err(ReplayError::VersionTooNew); }
271
+ let use_fixed_names = game_version_id < 10;
272
+
273
+ // ── Scheme presence ──────────────────────────────────────────────────
274
+ let scheme_present = s.read_u8_validated(1, 3)?;
275
+ wd(va::G_REPLAY_SCHEME_PRESENT, scheme_present as u32);
276
+
277
+ // Extra field for version 7-9 only
278
+ if version >= 7 && version <= 9 {
279
+ let _extra = s.read_u32()?;
280
+ }
281
+
282
+ let mut scheme_version: u8 = 0;
283
+
284
+ if scheme_present == 1 {
285
+ // Scheme header byte
286
+ if obs_count >= 3 {
287
+ let header_byte = s.read_u8()?;
288
+ wb(0x88DAD4, header_byte);
289
+ // If header >= 0 (signed): load built-in scheme from resources
290
+ if (header_byte as i8) >= 0 {
291
+ // FUN_004D4840: stdcall(2 params). RET 0x8.
292
+ let f: unsafe extern "stdcall" fn(u32, i32) =
293
+ core::mem::transmute(rb(0x4D4840));
294
+ f(rb(0x88DACC), header_byte as i32);
295
+ }
296
+ }
297
+
298
+ // Scheme size indicator
299
+ let mut scheme_size_indicator: u32 = 0;
300
+ if obs_count >= 0x14 {
301
+ scheme_size_indicator = s.read_u32()?;
302
+ }
303
+
304
+ // SCHM magic + version
305
+ let magic = s.read_u32()?;
306
+ scheme_version = s.read_u8()?;
307
+ if magic != 0x4D484353 { return Err(ReplayError::InvalidFormat); }
308
+
309
+ let scheme_data_size = match scheme_version {
310
+ 1 => 0xD8_usize,
311
+ 2 => 0x124,
312
+ 3 => {
313
+ if scheme_size_indicator < 0x12A || scheme_size_indicator > 0x197 {
314
+ return Err(ReplayError::InvalidFormat);
315
+ }
316
+ // Copy defaults first for v3
317
+ ptr::copy_nonoverlapping(
318
+ rb(va::SCHEME_V3_DEFAULTS) as *const u8,
319
+ rb(0x88DC04) as *mut u8, 0x6E,
320
+ );
321
+ (scheme_size_indicator as usize) - 5
322
+ }
323
+ _ => return Err(ReplayError::VersionTooNew),
324
+ };
325
+
326
+ // Copy scheme data from stream to global 0x88DAE0
327
+ let scheme_slice = s.advance_raw(scheme_data_size)?;
328
+ ptr::copy_nonoverlapping(scheme_slice.as_ptr(), rb(0x88DAE0) as *mut u8, scheme_data_size);
329
+
330
+ // If scheme_header < 0 (signed) and v1/v2: clear + defaults
331
+ if scheme_version <= 2 && (*(rb(0x88DAD4) as *const i8)) < 0 {
332
+ ptr::write_bytes(rb(0x88DBB8) as *mut u8, 0, 0x4C);
333
+ ptr::copy_nonoverlapping(
334
+ rb(va::SCHEME_V3_DEFAULTS) as *const u8,
335
+ rb(0x88DC04) as *mut u8, 0x6E,
336
+ );
337
+ }
338
+
339
+ // Validate extended options for v3
340
+ if scheme_version == 3 {
341
+ let validate: unsafe extern "cdecl" fn() -> i32 =
342
+ core::mem::transmute(rb(0x4D5110));
343
+ let r = validate();
344
+ if r != 0 { return Err(ReplayError::InvalidFormat); }
345
+ }
346
+
347
+ // Random seed: the stream value REPLACES the current seed.
348
+ // The decompiler misleadingly shows save/restore, but ReadU32
349
+ // overwrites the save destination (same EBP+0x48 local).
350
+ // Assembly: MOV [0x88D0B4], stream_value
351
+ let seed_from_stream = s.read_u32()?;
352
+ wd(va::G_RANDOM_SEED, seed_from_stream);
353
+ } else {
354
+ // No scheme path β€” fall back to delegation for now
355
+ // (ProcessAllianceData reads from stream via usercall EAX)
356
+ let _ = log_line("[Replay] No-scheme path: delegating");
357
+ return Err(ReplayError::InvalidFormat); // triggers fallback
358
+ }
359
+
360
+ let map_byte1 = s.read_u8()?;
361
+ let map_byte2 = s.read_u8()?;
362
+ wb(0x87250C, map_byte1);
363
+ wb(0x872508, map_byte2);
364
+
365
+ let mut replay_name = [0u8; 0x29];
366
+ s.read_prefixed_string(&mut replay_name)?;
367
+ ptr::copy_nonoverlapping(replay_name.as_ptr(), rb(0x87D0E1) as *mut u8, 0x29);
368
+
369
+ if version >= 9 {
370
+ let host = s.read_u8()?;
371
+ // Store as low byte of DAT_008779E0 (CONCAT31 pattern)
372
+ let current = *(rb(0x8779E0) as *const u32);
373
+ wd(0x8779E0, (current & 0xFFFFFF00) | host as u32);
374
+ } else {
375
+ wd(0x8779E0, 0xFFFFFFFF);
376
+ }
377
+
378
+ let mut player_count: u8 = 0;
379
+ for i in 0..13u32 {
380
+ let base = 0x877A58 + i * 0x78;
381
+ let flag = s.read_u8()?;
382
+ wb(base, flag);
383
+ if flag == 0 { continue; }
384
+
385
+ if i as u32 == *(rb(0x8779E0) as *const u32) {
386
+ // This is the host player β€” set local_11 flag
387
+ }
388
+ player_count += 1;
389
+
390
+ let mut name = [0u8; 0x11];
391
+ s.read_prefixed_string(&mut name)?;
392
+ ptr::copy_nonoverlapping(name.as_ptr(), rb(0x8779E4 + i * 0x78) as *mut u8, 0x11);
393
+
394
+ let mut display = [0u8; 0x31];
395
+ s.read_prefixed_string(&mut display)?;
396
+ ptr::copy_nonoverlapping(display.as_ptr(), rb(0x8779F5 + i * 0x78) as *mut u8, 0x31);
397
+
398
+ let mut config = [0u8; 0x29];
399
+ s.read_prefixed_string(&mut config)?;
400
+ ptr::copy_nonoverlapping(config.as_ptr(), rb(0x877A26 + i * 0x78) as *mut u8, 0x29);
401
+
402
+ let u16_val = s.read_u16()?;
403
+ *(rb(0x877A50 + i * 0x3C) as *mut u16) = u16_val;
404
+
405
+ let byte1 = s.read_u8()?;
406
+ wb(0x877A52 + i * 0x78, byte1);
407
+
408
+ let u32_val = s.read_u32()?;
409
+ wd(0x877A54 + i * 0x1E, u32_val);
410
+
411
+ let byte2 = s.read_u8()?;
412
+ wb(0x877A5B + i * 0x78, byte2);
413
+ }
414
+ wb(0x87D0DE, player_count);
415
+
416
+ if obs_count >= 16 {
417
+ let xor_a = s.read_u32()?;
418
+ let _xor_b = s.read_u32()?;
419
+ wd(va::G_REPLAY_GAME_ID, xor_a ^ replay::REPLAY_XOR_KEY);
420
+ }
421
+
422
+ let mut team_count: u8 = 0;
423
+ for team_idx in 0..6u32 {
424
+ let tb = 0x877FFC + team_idx * 0xD7B; // per-team base
425
+ let team_flag = s.read_u8()?;
426
+ wb(0x878120 + team_idx * 0xD7B, team_flag);
427
+ if team_flag == 0 { continue; }
428
+ team_count += 1;
429
+
430
+ let team_type = s.read_u8()? as i8;
431
+ if !replay::validate_team_type(team_type) { return Err(ReplayError::InvalidFormat); }
432
+ wb(tb, team_type as u8);
433
+
434
+ let alliance = s.read_u8_validated(0, 5)?;
435
+ wb(tb + 1, alliance);
436
+
437
+ let unk = s.read_u8()?;
438
+ wb(tb + 2, unk);
439
+
440
+ // Pre-loop worm name (config abbreviation)
441
+ // Pre-loop worm name at +0x03 from per-team base
442
+ // DIFF showed: 0x877FFF = team_base + 0x03 for team 0
443
+ let pre_name_dest = rb(tb + 3) as *mut u8;
444
+ let mut pre_name = [0u8; 0x11];
445
+ s.read_worm_name(&mut pre_name, use_fixed_names)?;
446
+ ptr::copy_nonoverlapping(pre_name.as_ptr(), pre_name_dest, 0x11);
447
+
448
+ // 8 worm names
449
+ for worm_idx in 0..8u32 {
450
+ let name_off = ((team_idx as usize) * 0xCB + worm_idx as usize) * 0x11;
451
+ let dest = rb(0x878097) as *mut u8;
452
+ if use_fixed_names {
453
+ let slice = s.advance_raw(0x11)?;
454
+ ptr::copy_nonoverlapping(slice.as_ptr(), dest.add(name_off), 0x11);
455
+ } else {
456
+ let mut name = [0u8; 0x11];
457
+ s.read_prefixed_string(&mut name)?;
458
+ ptr::copy_nonoverlapping(name.as_ptr(), dest.add(name_off), 0x11);
459
+ }
460
+ }
461
+
462
+ // Worm count (unvalidated)
463
+ let worm_count_raw = s.read_u8()?;
464
+ wb(0x878092 + team_idx * 0xD7B, worm_count_raw);
465
+
466
+ // Team name
467
+ let mut team_name = [0u8; 0x41];
468
+ s.read_prefixed_string(&mut team_name)?;
469
+ ptr::copy_nonoverlapping(team_name.as_ptr(), rb(0x878010 + team_idx * 0xD7B) as *mut u8, 0x41);
470
+
471
+ // Extra byte if obs_count > 13
472
+ if obs_count > 13 {
473
+ let extra = s.read_u8()?;
474
+ wb(0x878093 + team_idx * 0xD7B, extra);
475
+ }
476
+
477
+ // Config name
478
+ let mut config_name = [0u8; 0x41];
479
+ s.read_prefixed_string(&mut config_name)?;
480
+ ptr::copy_nonoverlapping(config_name.as_ptr(), rb(0x878051 + team_idx * 0xD7B) as *mut u8, 0x41);
481
+
482
+ // Worm count (validated 1-8)
483
+ let worm_count = s.read_u8()?;
484
+ if worm_count == 0 || worm_count > 8 { return Err(ReplayError::InvalidFormat); }
485
+ wb(0x878094 + team_idx * 0xD7B, worm_count);
486
+
487
+ // Color, flag, grave, soundbank
488
+ wb(0x878095 + team_idx * 0xD7B, s.read_u8()?);
489
+ wb(0x878096 + team_idx * 0xD7B, s.read_u8()?);
490
+ wb(0x87811F + team_idx * 0xD7B, s.read_u8()?);
491
+ wb(0x878121 + team_idx * 0xD7B, s.read_u8()?);
492
+ wb(0x878122 + team_idx * 0xD7B, s.read_u8()?);
493
+
494
+ // Weapon data blocks
495
+ let weapons_dest = rb(0x878123 + team_idx * 0xD7B) as *mut u8;
496
+ let w1 = s.advance_raw(0x400)?;
497
+ ptr::copy_nonoverlapping(w1.as_ptr(), weapons_dest, 0x400);
498
+ let w2 = s.advance_raw(0x154)?;
499
+ ptr::copy_nonoverlapping(w2.as_ptr(), weapons_dest.add(0x400), 0x154);
500
+ let w3 = s.advance_raw(0x400)?;
501
+ ptr::copy_nonoverlapping(w3.as_ptr(), weapons_dest.add(0x554), 0x400);
502
+ let w4 = s.advance_raw(0x300)?;
503
+ ptr::copy_nonoverlapping(w4.as_ptr(), weapons_dest.add(0x954), 0x300);
504
+ }
505
+
506
+ if team_count == 0 { return Err(ReplayError::InvalidFormat); }
507
+
508
+ wb(0x87D0E0, team_count);
509
+
510
+ // ProcessTeamColors: stdcall(1 param = state). RET 0x4.
511
+ let process_colors: unsafe extern "stdcall" fn(u32) =
512
+ core::mem::transmute(rb(va::REPLAY_PROCESS_TEAM_COLORS));
513
+ process_colors(rb(va::G_REPLAY_STATE));
514
+
515
+ let map_seed = s.read_u16()?;
516
+ wd(0x87D430, map_seed as u32);
517
+
518
+ // FUN_0045d640: stdcall(1 param = state). 1032-line function.
519
+ let fun_45d640: unsafe extern "stdcall" fn(u32) =
520
+ core::mem::transmute(rb(0x45D640));
521
+ fun_45d640(rb(va::G_REPLAY_STATE));
522
+
523
+ if map_seed == 0 || map_seed == 0xFFFF {
524
+ call_process_scheme_defaults(rb(va::G_REPLAY_STATE), rb(va::REPLAY_PROCESS_SCHEME_DEFAULTS));
525
+ }
526
+ // TODO: else branch β€” per-team weapon config reads from stream
527
+
528
+ // ValidateTeamSetup: reads [ESP+0xBC] in prologue = stdcall(1 param = state)
529
+ let validate_setup: unsafe extern "stdcall" fn(u32) =
530
+ core::mem::transmute(rb(va::REPLAY_VALIDATE_TEAM_SETUP));
531
+ validate_setup(rb(va::G_REPLAY_STATE));
532
+
533
+ // Assembly: MOV ESI,[seed]; PUSH ESI; CALL srand; rand(); SHL<<16; rand(); ADD
534
+ let current_seed = *(rb(va::G_RANDOM_SEED) as *const u32);
535
+ let srand: unsafe extern "cdecl" fn(u32) = core::mem::transmute(rb(0x5D293E));
536
+ let rand_fn: unsafe extern "cdecl" fn() -> i32 = core::mem::transmute(rb(0x5D294B));
537
+ srand(current_seed);
538
+ let r1 = rand_fn() as u32;
539
+ let r2 = rand_fn() as u32;
540
+ wd(va::G_RANDOM_SEED, r2 + (r1 << 16)); // new seed
541
+ wd(va::G_SAVED_RANDOM_SEED, current_seed); // save old seed
542
+
543
+ let ver = *(rb(va::G_REPLAY_VERSION_ID) as *const i32);
544
+ if ver != 0x22 && !(ver >= 0x29 && ver <= 0x2A) && ver < 0x2D {
545
+ let check: unsafe extern "cdecl" fn() -> i32 =
546
+ core::mem::transmute(rb(0x4D50E0));
547
+ check();
548
+ }
549
+ // The map was already written to playback.thm in the header section.
550
+ // The original loads it here via FUN_00447e80 + FUN_0044a9a0.
551
+ // For positive sub-version (our test case), we need to:
552
+ // Map loading: construct map object, load playback.thm, copy info, release.
553
+ if *(state as *const i32).byte_add(0xDB1C) >= 1 {
554
+ // Alloc: PUSH 0x29628; CALL 005C0AB8; ADD ESP,4 β€” cdecl(size)
555
+ let alloc: unsafe extern "cdecl" fn(u32) -> u32 =
556
+ core::mem::transmute(rb(0x5C0AB8));
557
+ let buf = alloc(0x29628);
558
+ let map_obj = if buf != 0 {
559
+ // Construct: PUSH 1; PUSH buf; CALL 00447E80 β€” stdcall(buf, 1)
560
+ let construct: unsafe extern "stdcall" fn(u32, i32) -> *mut u32 =
561
+ core::mem::transmute(rb(0x447E80));
562
+ construct(buf, 1)
563
+ } else {
564
+ ptr::null_mut()
565
+ };
566
+
567
+ // Load: PUSH 0; PUSH path; PUSH map_obj; CALL 0044A9A0 β€” stdcall(3)
568
+ let load: unsafe extern "stdcall" fn(*mut u32, *const u8, i32) -> i32 =
569
+ core::mem::transmute(rb(0x44A9A0));
570
+ let ok = load(map_obj, b"data\\playback.thm\0".as_ptr(), 0);
571
+
572
+ if ok == 0 {
573
+ // Load failed β€” release map_obj and return error
574
+ if !map_obj.is_null() {
575
+ let vtable = *map_obj;
576
+ let release: unsafe extern "thiscall" fn(*mut u32, i32) =
577
+ core::mem::transmute(*(vtable as *const u32).add(1));
578
+ release(map_obj, 1);
579
+ }
580
+ return Err(ReplayError::MapLoadFailure);
581
+ }
582
+
583
+ // FUN_00449B60: usercall(ESI=map_obj). Copies map info to game state.
584
+ call_usercall_esi(map_obj as u32, rb(0x449B60));
585
+
586
+ // Terrain flag: CMP [ESI+0x29618],0; SETZ
587
+ *(state as *mut u8).add(0xD98B) =
588
+ (*(map_obj as *const u8).add(0x29618) == 0) as u8;
589
+
590
+ // Release: PUSH 1; MOV ECX,ESI; CALL [vtable+4] β€” thiscall(this, 1)
591
+ let vtable = *map_obj;
592
+ let release: unsafe extern "thiscall" fn(*mut u32, i32) =
593
+ core::mem::transmute(*(vtable as *const u32).add(1));
594
+ release(map_obj, 1);
595
+ }
596
+
597
+ // Log output is written by the caller after map loading completes.
598
+ Ok(())
599
+ }
600
+
601
+ // ─── Naked asm bridge for ProcessSchemeDefaults (usercall ESI=state) ─────────
602
+
603
+ // ─── Log output formatting ──────────────────────────────────────────────────
604
+
605
+ /// Load a string resource by ID using WA's string table.
606
+ unsafe fn wa_load_string(id: u32) -> *const u8 {
607
+ let f: unsafe extern "stdcall" fn(u32) -> *const u8 =
608
+ core::mem::transmute(rb(0x593180));
609
+ f(id)
610
+ }
611
+
612
+ /// Write a C string to the log file via WA's CRT fputs.
613
+ unsafe fn wa_fputs(s: *const u8, file: *mut FILE) {
614
+ let fputs: unsafe extern "cdecl" fn(*const u8, *mut FILE) -> i32 =
615
+ core::mem::transmute(*(rb(0x649468) as *const u32));
616
+ fputs(s, file);
617
+ }
618
+
619
+ /// Write the /getlog replay header to the log file.
620
+ unsafe fn write_replay_log(state: u32, log_file: *mut FILE) -> Result<(), ReplayError> {
621
+ let s = state as *const u8;
622
+
623
+ // ── Date/time line ───────────────────────────────────────────────────
624
+ // "Game Started at YYYY-MM-DD HH:MM:SS GMT"
625
+ // Only if DAT_0088C36C != 0 (recording timestamp available)
626
+ // Date/time line: uses FUN_467780 (stdcall+usercall ESI=output)
627
+ // and FUN_4677B0 + gmtime64. Complex calling convention.
628
+ // For now, use the original's date formatting via a bridge call.
629
+ // Date/time line: timestamp from observer array, formatted as GMT.
630
+ // The observer array at 0x88C35C contains the replay date as a time_t
631
+ // in its first registered entry. Use WA's gmtime64 for conversion.
632
+ if *(rb(0x88C36C) as *const u32) != 0 && REPLAY_TIMESTAMP != 0 {
633
+ // Format the replay date using the timestamp saved during observer parsing.
634
+ let ts = REPLAY_TIMESTAMP as i64;
635
+ let gmtime: unsafe extern "cdecl" fn(*const i64) -> *const [i32; 9] =
636
+ core::mem::transmute(rb(0x5D34C0));
637
+ let tm = gmtime(&ts);
638
+ if !tm.is_null() {
639
+ let tm = &*tm;
640
+ let mut buf = [0u8; 128];
641
+ let mut w = SliceFmtWriter(&mut buf, 0);
642
+ let _ = core::fmt::write(&mut w, format_args!(
643
+ "Game Started at {:04}-{:02}-{:02} {:02}:{:02}:{:02} GMT\n",
644
+ tm[5] + 1900, tm[4] + 1, tm[3], tm[2], tm[1], tm[0]
645
+ ));
646
+ wa_fputs(buf.as_ptr(), log_file);
647
+ }
648
+ }
649
+
650
+ // ── Version lines ────────────────────────────────────────────────────
651
+ // Line 1: "Game Engine Version: <version_string>"
652
+ // Line 2: "File Format Version: <format_version>"
653
+
654
+ let label_game_engine = wa_load_string(0x6E7);
655
+ let label_file_format = wa_load_string(0x6E8); // "File Format Version"
656
+
657
+ // File format version from version table
658
+ let game_ver_id = *(rb(va::G_REPLAY_VERSION_ID) as *const i32);
659
+ let version_str = if game_ver_id < 0 {
660
+ match game_ver_id {
661
+ -4 => rb(0x650EAC) as *const u8, // "1.0"
662
+ -2 => rb(0x650EB0) as *const u8, // "3.0"
663
+ -1 => rb(0x650EB4) as *const u8, // "3.5 Beta 1"
664
+ _ => wa_load_string(0x0F), // generic "Unknown"
665
+ }
666
+ } else {
667
+ let table = rb(0x6AB480) as *const u32;
668
+ let ptr = *table.add(game_ver_id as usize);
669
+ ptr as *const u8
670
+ };
671
+
672
+ let replay_ver = *(s.add(0xDB50) as *const u32);
673
+ let format_ver_str = *(rb(0x6AC624 + replay_ver * 4) as *const u32) as *const u8;
674
+
675
+ {
676
+ let mut buf = [0u8; 512];
677
+ let mut w = SliceFmtWriter(&mut buf, 0);
678
+ // Game Engine Version line
679
+ write_cstr(&mut w, label_game_engine);
680
+ let _ = core::fmt::write(&mut w, format_args!(": "));
681
+ write_cstr(&mut w, version_str);
682
+ // Append mod/compat suffixes (empty for standard replays)
683
+ let _ = core::fmt::write(&mut w, format_args!("\n"));
684
+ // File Format Version line
685
+ write_cstr(&mut w, label_file_format);
686
+ let _ = core::fmt::write(&mut w, format_args!(": "));
687
+ write_cstr(&mut w, format_ver_str);
688
+ let _ = core::fmt::write(&mut w, format_args!("\n"));
689
+ wa_fputs(buf.as_ptr(), log_file);
690
+ }
691
+
692
+ // ── "Exported with Version" line ─────────────────────────────────────
693
+ {
694
+ let label_exported = wa_load_string(0x6E9); // "Exported with Version"
695
+ // Current WA version from version table: 0x699814[DAT_00697702]
696
+ let ver_byte = *(rb(0x697702) as *const u8) as u32;
697
+ // "3.8.1" literal at 0x641C60 + suffix from version table
698
+ let ver_literal = rb(0x641C60) as *const u8; // "3.8.1"
699
+ let ver_suffix = *(rb(0x699814 + ver_byte * 4) as *const u32) as *const u8; // " (Steam)"
700
+
701
+ let mut buf = [0u8; 256];
702
+ let mut w = SliceFmtWriter(&mut buf, 0);
703
+ write_cstr(&mut w, label_exported);
704
+ let _ = core::fmt::write(&mut w, format_args!(": "));
705
+ write_cstr(&mut w, ver_literal);
706
+ write_cstr(&mut w, ver_suffix);
707
+ let _ = core::fmt::write(&mut w, format_args!("\n\n"));
708
+ wa_fputs(buf.as_ptr(), log_file);
709
+ }
710
+
711
+
712
+ // ── Team listing ─────────────────────────────────────────────────────
713
+ // For each team: "Color: "TeamName" [CPU X.XX]\n" or similar
714
+
715
+ let color_names: [*const u8; 6] = [
716
+ wa_load_string(0x20),
717
+ wa_load_string(0x21),
718
+ wa_load_string(0x22),
719
+ wa_load_string(0x23),
720
+ wa_load_string(0x24),
721
+ wa_load_string(0x25),
722
+ ];
723
+
724
+ // Find max color name length for alignment β€” only active teams
725
+ let mut max_color_len = 0usize;
726
+ for slot in 0..6u32 {
727
+ let flag = *(rb(0x878120 + slot * 0xD7B) as *const u8);
728
+ if flag == 0 { continue; }
729
+ let ci = *(rb(0x877FFC + slot * 0xD7B + 1) as *const u8) as usize;
730
+ if ci < 6 {
731
+ let len = c_strlen(color_names[ci]);
732
+ if len > max_color_len { max_color_len = len; }
733
+ }
734
+ }
735
+
736
+ // Team data is in the team header buffer at 0x877FFC + i*0xD7B.
737
+ // Per-team: +0x00=type, +0x01=alliance(color), +0x14=team_name(prefixed)
738
+ let team_count = *(rb(0x87D0E0) as *const u8) as u32;
739
+
740
+ let mut team_idx = 0u32;
741
+ for slot in 0..6u32 {
742
+ let tb = rb(0x877FFC + slot * 0xD7B) as *const u8;
743
+ let flag = *(rb(0x878120 + slot * 0xD7B) as *const u8);
744
+ if flag == 0 { continue; }
745
+
746
+ let team_type = *tb as i8;
747
+ let color_idx = *tb.add(1) as usize;
748
+
749
+ let color = if color_idx < 6 { color_names[color_idx] } else { color_names[0] };
750
+ let clen = c_strlen(color);
751
+
752
+ // The displayed team name comes from the pre-loop name field at +0x03
753
+ // (e.g., "CPU 2"), not the custom team name at +0x14 ("thrombosis1").
754
+ let team_name = tb.add(0x03);
755
+ let tn_len = c_strlen(team_name);
756
+
757
+ let color_len = clen;
758
+
759
+ let mut buf = [0u8; 256];
760
+ let mut w = SliceFmtWriter(&mut buf, 0);
761
+
762
+ // Color name: capitalize first letter (original uses CharUpperA)
763
+ if clen > 0 {
764
+ let first = (*color as char).to_ascii_uppercase();
765
+ let _ = core::fmt::write(&mut w, format_args!("{first}"));
766
+ let rest = color.add(1);
767
+ write_cstr(&mut w, rest);
768
+ }
769
+ let _ = core::fmt::write(&mut w, format_args!(":"));
770
+ for _ in 0..(max_color_len - color_len + 1) {
771
+ let _ = core::fmt::write(&mut w, format_args!(" "));
772
+ }
773
+
774
+ // Quoted team name
775
+ let _ = core::fmt::write(&mut w, format_args!("\""));
776
+ write_cstr(&mut w, team_name);
777
+ let _ = core::fmt::write(&mut w, format_args!("\""));
778
+
779
+ // Team type info
780
+ if (team_type as i32) < 0 {
781
+ // CPU team: difficulty = abs(team_type) / 20
782
+ let abs_type = -(team_type as i32) as u32;
783
+ let whole = abs_type / 20;
784
+ let frac = (abs_type % 20) * 5; // * 100 / 20 = * 5
785
+ let cpu_label = wa_load_string(0x6ED); // "CPU" (was 0x6F0 = wrong)
786
+ let _ = core::fmt::write(&mut w, format_args!(" ["));
787
+ write_cstr(&mut w, cpu_label);
788
+ let _ = core::fmt::write(&mut w, format_args!(" {whole}.{frac:02}]"));
789
+ }
790
+
791
+ let _ = core::fmt::write(&mut w, format_args!("\n"));
792
+ wa_fputs(buf.as_ptr(), log_file);
793
+ }
794
+
795
+ wa_fputs(b"\n\0".as_ptr(), log_file);
796
+
797
+ // Flush β€” use WA's CRT fflush
798
+ let fflush: unsafe extern "cdecl" fn(*mut FILE) -> i32 =
799
+ core::mem::transmute(rb(0x5D3792));
800
+ fflush(log_file);
801
+
802
+ Ok(())
803
+ }
804
+
805
+ /// Helper: write a C string (null-terminated) into a SliceFmtWriter.
806
+ unsafe fn write_cstr(w: &mut SliceFmtWriter, s: *const u8) {
807
+ let mut p = s;
808
+ while *p != 0 {
809
+ let _ = core::fmt::write(w, format_args!("{}", *p as char));
810
+ p = p.add(1);
811
+ }
812
+ }
813
+
814
+ /// Helper: length of a null-terminated C string.
815
+ unsafe fn c_strlen(s: *const u8) -> usize {
816
+ let mut len = 0;
817
+ while *s.add(len) != 0 { len += 1; }
818
+ len
819
+ }
820
+
821
+ /// A fmt::Write adapter for writing into a fixed-size byte slice with null termination.
822
+ struct SliceFmtWriter<'a>(&'a mut [u8], usize);
823
+
824
+ impl<'a> core::fmt::Write for SliceFmtWriter<'a> {
825
+ fn write_str(&mut self, s: &str) -> core::fmt::Result {
826
+ let bytes = s.as_bytes();
827
+ let remaining = self.0.len().saturating_sub(self.1 + 1); // leave room for null
828
+ let n = bytes.len().min(remaining);
829
+ self.0[self.1..self.1 + n].copy_from_slice(&bytes[..n]);
830
+ self.1 += n;
831
+ self.0[self.1] = 0; // null terminate
832
+ Ok(())
833
+ }
834
+ }
835
+
836
+ // ─── Naked asm bridges ──────────────────────────────────────────────────────
837
+
838
+ /// Bridge for usercall(ESI=val) + stdcall(1 param). cdecl(esi_val, param, func_addr).
839
+ #[unsafe(naked)]
840
+ unsafe extern "cdecl" fn call_usercall_esi_stdcall1(
841
+ _esi_val: u32, _param: u32, _func: u32,
842
+ ) {
843
+ // Stack on entry: [ESP+0]=ret [ESP+4]=esi_val [ESP+8]=param [ESP+12]=func
844
+ core::arch::naked_asm!(
845
+ "push esi",
846
+ "push edi",
847
+ // Now: [ESP+0]=edi [ESP+4]=esi [ESP+8]=ret [ESP+12]=esi_val [ESP+16]=param [ESP+20]=func
848
+ "mov esi, [esp+12]", // esi_val
849
+ "push [esp+16]", // push param for stdcall
850
+ // Now: [ESP+0]=param [ESP+4]=edi [ESP+8]=esi [ESP+12]=ret [ESP+16]=esi_val [ESP+20]=param_orig [ESP+24]=func
851
+ "mov eax, [esp+24]", // func_addr
852
+ "call eax", // stdcall cleans 1 param (4 bytes)
853
+ "pop edi",
854
+ "pop esi",
855
+ "ret",
856
+ );
857
+ }
858
+
859
+ /// Bridge for usercall(EAX=value) + plain call. cdecl(eax_val, func_addr).
860
+ #[unsafe(naked)]
861
+ unsafe extern "cdecl" fn call_usercall_eax(_eax_val: u32, _func: u32) {
862
+ core::arch::naked_asm!(
863
+ "mov eax, [esp+4]", // eax_val
864
+ "mov ecx, [esp+8]", // func_addr
865
+ "call ecx",
866
+ "ret",
867
+ );
868
+ }
869
+
870
+ /// Bridge for usercall(ESI=value) + plain call. cdecl(esi_val, func_addr).
871
+ #[unsafe(naked)]
872
+ unsafe extern "cdecl" fn call_usercall_esi(_esi_val: u32, _func: u32) {
873
+ core::arch::naked_asm!(
874
+ "push esi",
875
+ "push edi",
876
+ "mov esi, [esp+12]", // esi_val
877
+ "mov eax, [esp+16]", // func_addr
878
+ "call eax",
879
+ "pop edi",
880
+ "pop esi",
881
+ "ret",
882
+ );
883
+ }
884
+
885
+ /// Bridge for RegisterObserver: usercall(ESI=array) + stdcall(1 param=data_ptr).
886
+ /// cdecl(esi_val, data_ptr, func_addr).
887
+ #[unsafe(naked)]
888
+ unsafe extern "cdecl" fn call_register_observer(
889
+ _esi_val: u32, _data_ptr: u32, _func: u32,
890
+ ) {
891
+ core::arch::naked_asm!(
892
+ "push esi",
893
+ "push edi",
894
+ "mov esi, [esp+12]", // esi_val (0x88C35C rebased)
895
+ "push [esp+16]", // data_ptr β†’ stdcall param
896
+ "mov eax, [esp+24]", // func_addr (shifted by push)
897
+ "call eax", // stdcall cleans 1 param (4 bytes)
898
+ "pop edi",
899
+ "pop esi",
900
+ "ret",
901
+ );
902
+ }
903
+
904
+ /// Bridge for ProcessSchemeDefaults: usercall(ESI=state).
905
+ /// Reuses call_usercall_esi.
906
+ #[inline]
907
+ unsafe fn call_process_scheme_defaults(state: u32, func: u32) {
908
+ call_usercall_esi(state, func);
909
+ }
910
+
911
+ // ─── ParseReplayPosition ─────────────────────────────────────────────────────
912
+
913
+ unsafe extern "stdcall" fn hook_parse_replay_position(input: *const u8) -> i32 {
914
+ let mut len = 0usize;
915
+ while *input.add(len) != 0 { len += 1; if len > 256 { break; } }
916
+ let slice = core::slice::from_raw_parts(input, len + 1);
917
+ replay::parse_replay_position(slice)
918
+ }
919
+
920
+ // ─── Hook installation ──────────────────────────────────────────────────────
921
+
922
+ pub fn install() -> Result<(), String> {
923
+ unsafe {
924
+ REPLAY_LOADER_ORIG = hook::install("ReplayLoader", va::REPLAY_LOADER, hook_replay_loader as *const ())? as *const ();
925
+ PARSE_POSITION_ORIG = hook::install("ParseReplayPosition", va::PARSE_REPLAY_POSITION, hook_parse_replay_position as *const ())? as *const ();
926
+ }
927
+ Ok(())
928
+ }