paavohtl Claude Opus 4.6 (1M context) commited on
Commit
cbe376a
·
1 Parent(s): d187b82

feat: add ReplayStream, ParseReplayPosition, and stream helper ports

Browse files

- ReplayStream: cursor over byte buffer with read_u8, read_u16, read_u32,
read_prefixed_string, read_worm_name, skip methods
- parse_replay_position: converts "MM:SS.FF" time strings to frame count
- validate_team_type: validates team type byte range
- 18 unit tests covering all methods and edge cases

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

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,401 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 slice. Fails if not enough data.
52
+ fn advance(&mut self, n: usize) -> Result<&'a [u8], ReplayError> {
53
+ let end = self.cursor + n;
54
+ if end > self.data.len() {
55
+ return Err(ReplayError::InvalidFormat);
56
+ }
57
+ let slice = &self.data[self.cursor..end];
58
+ self.cursor = end;
59
+ Ok(slice)
60
+ }
61
+
62
+ /// Read a single byte. Port of parts of Replay__ReadByteValidated (0x4614D0).
63
+ pub fn read_u8(&mut self) -> Result<u8, ReplayError> {
64
+ let slice = self.advance(1)?;
65
+ Ok(slice[0])
66
+ }
67
+
68
+ /// Read a single byte, validating it is in [min, max].
69
+ /// Port of Replay__ReadByteValidated (0x4614D0).
70
+ pub fn read_u8_validated(&mut self, min: u8, max: u8) -> Result<u8, ReplayError> {
71
+ let val = self.read_u8()?;
72
+ if val < min || val > max {
73
+ return Err(ReplayError::InvalidFormat);
74
+ }
75
+ Ok(val)
76
+ }
77
+
78
+ /// Read a little-endian u16. Part of Replay__ReadU16Validated (0x4615B0).
79
+ pub fn read_u16(&mut self) -> Result<u16, ReplayError> {
80
+ let slice = self.advance(2)?;
81
+ Ok(u16::from_le_bytes([slice[0], slice[1]]))
82
+ }
83
+
84
+ /// Read a little-endian u16, validating range.
85
+ /// Port of Replay__ReadU16Validated (0x4615B0).
86
+ pub fn read_u16_validated(&mut self, min: u16, max: u16) -> Result<u16, ReplayError> {
87
+ let val = self.read_u16()?;
88
+ if val < min || val > max {
89
+ return Err(ReplayError::InvalidFormat);
90
+ }
91
+ Ok(val)
92
+ }
93
+
94
+ /// Read a little-endian u32.
95
+ pub fn read_u32(&mut self) -> Result<u32, ReplayError> {
96
+ let slice = self.advance(4)?;
97
+ Ok(u32::from_le_bytes([slice[0], slice[1], slice[2], slice[3]]))
98
+ }
99
+
100
+ /// Read a little-endian i32.
101
+ pub fn read_i32(&mut self) -> Result<i32, ReplayError> {
102
+ Ok(self.read_u32()? as i32)
103
+ }
104
+
105
+ /// Read `n` bytes into a destination buffer. Fails if not enough data.
106
+ pub fn read_into(&mut self, dest: &mut [u8]) -> Result<(), ReplayError> {
107
+ let slice = self.advance(dest.len())?;
108
+ dest.copy_from_slice(slice);
109
+ Ok(())
110
+ }
111
+
112
+ /// Read a length-prefixed string into buffer.
113
+ /// Port of Replay__ReadPrefixedString (0x461340).
114
+ ///
115
+ /// Reads 1 byte (length), then `length` bytes of string data, null-terminates.
116
+ pub fn read_prefixed_string(&mut self, dest: &mut [u8]) -> Result<usize, ReplayError> {
117
+ let len = self.read_u8()? as usize;
118
+ if len > dest.len().saturating_sub(1) {
119
+ return Err(ReplayError::InvalidFormat);
120
+ }
121
+ let slice = self.advance(len)?;
122
+ dest[..len].copy_from_slice(slice);
123
+ dest[len] = 0; // null-terminate
124
+ Ok(len)
125
+ }
126
+
127
+ /// Read a worm name: either 0x11 fixed bytes or length-prefixed.
128
+ /// Port of Replay__ReadWormName (0x461620).
129
+ pub fn read_worm_name(
130
+ &mut self,
131
+ dest: &mut [u8; 0x11],
132
+ use_fixed: bool,
133
+ ) -> Result<(), ReplayError> {
134
+ if use_fixed {
135
+ let slice = self.advance(0x11)?;
136
+ dest.copy_from_slice(slice);
137
+ } else {
138
+ dest.fill(0);
139
+ self.read_prefixed_string(dest)?;
140
+ }
141
+ Ok(())
142
+ }
143
+
144
+ /// Skip `n` bytes.
145
+ pub fn skip(&mut self, n: usize) -> Result<(), ReplayError> {
146
+ self.advance(n)?;
147
+ Ok(())
148
+ }
149
+ }
150
+
151
+ /// Validate team type byte. Port of Replay__ValidateTeamType (0x461690).
152
+ ///
153
+ /// Returns true if the team type value is in a valid range:
154
+ /// - Non-negative: type < 13
155
+ /// - Negative: -100 or absolute value <= 100 (SETLE in disassembly)
156
+ pub fn validate_team_type(team_type: i8) -> bool {
157
+ if team_type >= 0 {
158
+ team_type < 13
159
+ } else {
160
+ team_type == -100 || -(team_type as i32) <= 100
161
+ }
162
+ }
163
+
164
+ /// Parse a replay position time string to frame count (50fps).
165
+ ///
166
+ /// Format: `[MM:]SS[.FF]` where:
167
+ /// - MM = minutes (multiplied by 60)
168
+ /// - SS = seconds (max 59 after a colon)
169
+ /// - FF = fractional (multiplied by 50 for frames, divided by power of 10)
170
+ /// - Maximum 2 colons allowed
171
+ ///
172
+ /// Returns frame count, or -1 on parse error.
173
+ /// Port of WA.exe ParseReplayPosition (0x4E3490).
174
+ pub fn parse_replay_position(input: &[u8]) -> i32 {
175
+ let mut colon_count: i32 = 0;
176
+ let mut accumulated: i32 = 0;
177
+ let mut current: i32 = 0;
178
+ let mut digit_count: i32 = 0;
179
+ let mut frac_divisor: i32 = 0; // 0 = integer part, >0 = fractional
180
+ let mut i = 0;
181
+
182
+ loop {
183
+ if i >= input.len() {
184
+ return -1;
185
+ }
186
+
187
+ // Process digit runs
188
+ while i < input.len() && input[i] >= b'0' && input[i] <= b'9' {
189
+ let max_digits = if colon_count < 1 { 4 } else { 2 };
190
+ if digit_count >= max_digits {
191
+ return -1;
192
+ }
193
+ if frac_divisor == 0 {
194
+ current = current * 10 + (input[i] - b'0') as i32;
195
+ digit_count += 1;
196
+ } else {
197
+ let frac_value = ((input[i] - b'0') as i32 * 50) / frac_divisor;
198
+ frac_divisor *= 10;
199
+ accumulated += frac_value;
200
+ }
201
+ i += 1;
202
+ }
203
+
204
+ // Check delimiter
205
+ if i >= input.len() {
206
+ return -1;
207
+ }
208
+ let delim = input[i];
209
+
210
+ if delim != b':' && delim != b'.' && delim != 0 {
211
+ return -1;
212
+ }
213
+
214
+ if frac_divisor == 0 {
215
+ // Integer part complete
216
+ if colon_count > 0 && current > 59 {
217
+ return -1;
218
+ }
219
+ accumulated = current + accumulated * 60;
220
+ current = 0;
221
+ digit_count = 0;
222
+
223
+ if delim == b':' {
224
+ if colon_count >= 2 {
225
+ return -1;
226
+ }
227
+ colon_count += 1;
228
+ } else {
229
+ // '.' or '\0' — convert seconds to frames
230
+ accumulated *= 50;
231
+ }
232
+ } else {
233
+ // Fractional part — ':' and '.' are invalid after '.'
234
+ if delim == b':' || delim == b'.' {
235
+ return -1;
236
+ }
237
+ }
238
+
239
+ if delim == 0 {
240
+ return accumulated;
241
+ }
242
+
243
+ if delim == b'.' {
244
+ frac_divisor = 10;
245
+ }
246
+
247
+ i += 1;
248
+ }
249
+ }
250
+
251
+ #[cfg(test)]
252
+ mod tests {
253
+ use super::*;
254
+
255
+ // --- ReplayStream tests ---
256
+
257
+ #[test]
258
+ fn test_read_u8() {
259
+ let data = [0x42, 0xFF];
260
+ let mut s = ReplayStream::new(&data);
261
+ assert_eq!(s.read_u8().unwrap(), 0x42);
262
+ assert_eq!(s.read_u8().unwrap(), 0xFF);
263
+ assert!(s.read_u8().is_err());
264
+ }
265
+
266
+ #[test]
267
+ fn test_read_u8_validated() {
268
+ let data = [5, 0, 20];
269
+ let mut s = ReplayStream::new(&data);
270
+ assert_eq!(s.read_u8_validated(0, 10).unwrap(), 5);
271
+ assert_eq!(s.read_u8_validated(0, 10).unwrap(), 0);
272
+ assert!(s.read_u8_validated(0, 10).is_err()); // 20 > 10
273
+ }
274
+
275
+ #[test]
276
+ fn test_read_u16_le() {
277
+ let data = [0x57, 0x41]; // "WA" = 0x4157
278
+ let mut s = ReplayStream::new(&data);
279
+ assert_eq!(s.read_u16().unwrap(), 0x4157);
280
+ }
281
+
282
+ #[test]
283
+ fn test_read_u32_le() {
284
+ let data = [0x78, 0x56, 0x34, 0x12];
285
+ let mut s = ReplayStream::new(&data);
286
+ assert_eq!(s.read_u32().unwrap(), 0x12345678);
287
+ }
288
+
289
+ #[test]
290
+ fn test_read_prefixed_string() {
291
+ let data = [3, b'f', b'o', b'o', 0, b'x']; // len=3, "foo"
292
+ let mut s = ReplayStream::new(&data);
293
+ let mut buf = [0u8; 16];
294
+ let len = s.read_prefixed_string(&mut buf).unwrap();
295
+ assert_eq!(len, 3);
296
+ assert_eq!(&buf[..4], b"foo\0");
297
+ assert_eq!(s.cursor(), 4);
298
+ }
299
+
300
+ #[test]
301
+ fn test_read_prefixed_string_overflow() {
302
+ let data = [10, b'a', b'b']; // claims len=10 but only 2 bytes
303
+ let mut s = ReplayStream::new(&data);
304
+ let mut buf = [0u8; 4];
305
+ assert!(s.read_prefixed_string(&mut buf).is_err());
306
+ }
307
+
308
+ #[test]
309
+ fn test_read_worm_name_fixed() {
310
+ let mut data = [0u8; 0x11];
311
+ data[0] = b'W';
312
+ data[1] = b'o';
313
+ data[2] = b'r';
314
+ data[3] = b'm';
315
+ let mut s = ReplayStream::new(&data);
316
+ let mut name = [0u8; 0x11];
317
+ s.read_worm_name(&mut name, true).unwrap();
318
+ assert_eq!(name[0], b'W');
319
+ assert_eq!(s.cursor(), 0x11);
320
+ }
321
+
322
+ #[test]
323
+ fn test_read_worm_name_prefixed() {
324
+ let data = [4, b'W', b'o', b'r', b'm'];
325
+ let mut s = ReplayStream::new(&data);
326
+ let mut name = [0u8; 0x11];
327
+ s.read_worm_name(&mut name, false).unwrap();
328
+ assert_eq!(&name[..5], b"Worm\0");
329
+ }
330
+
331
+ #[test]
332
+ fn test_read_into() {
333
+ let data = [1, 2, 3, 4, 5];
334
+ let mut s = ReplayStream::new(&data);
335
+ let mut buf = [0u8; 3];
336
+ s.read_into(&mut buf).unwrap();
337
+ assert_eq!(buf, [1, 2, 3]);
338
+ assert_eq!(s.cursor(), 3);
339
+ }
340
+
341
+ #[test]
342
+ fn test_skip() {
343
+ let data = [0u8; 10];
344
+ let mut s = ReplayStream::new(&data);
345
+ s.skip(5).unwrap();
346
+ assert_eq!(s.cursor(), 5);
347
+ assert_eq!(s.remaining(), 5);
348
+ assert!(s.skip(6).is_err());
349
+ }
350
+
351
+ // --- validate_team_type tests ---
352
+
353
+ #[test]
354
+ fn test_validate_team_type() {
355
+ assert!(validate_team_type(0));
356
+ assert!(validate_team_type(12));
357
+ assert!(!validate_team_type(13));
358
+ assert!(validate_team_type(-1));
359
+ assert!(validate_team_type(-99));
360
+ assert!(validate_team_type(-100));
361
+ assert!(!validate_team_type(-101));
362
+ }
363
+
364
+ // --- ParseReplayPosition tests ---
365
+
366
+ #[test]
367
+ fn test_parse_replay_position_seconds() {
368
+ assert_eq!(parse_replay_position(b"30\0"), 30 * 50);
369
+ }
370
+
371
+ #[test]
372
+ fn test_parse_replay_position_minutes_seconds() {
373
+ assert_eq!(parse_replay_position(b"1:30\0"), (60 + 30) * 50);
374
+ }
375
+
376
+ #[test]
377
+ fn test_parse_replay_position_with_frames() {
378
+ assert_eq!(parse_replay_position(b"1:30.5\0"), (60 + 30) * 50 + 25);
379
+ }
380
+
381
+ #[test]
382
+ fn test_parse_replay_position_zero() {
383
+ assert_eq!(parse_replay_position(b"0\0"), 0);
384
+ }
385
+
386
+ #[test]
387
+ fn test_parse_replay_position_invalid() {
388
+ assert_eq!(parse_replay_position(b"abc\0"), -1);
389
+ }
390
+
391
+ #[test]
392
+ fn test_parse_replay_position_seconds_over_59() {
393
+ assert_eq!(parse_replay_position(b"1:60\0"), -1);
394
+ }
395
+
396
+ #[test]
397
+ fn test_parse_replay_position_no_null() {
398
+ // No null terminator — should return -1
399
+ assert_eq!(parse_replay_position(b"30"), -1);
400
+ }
401
+ }