feat: port ReplayLoader and ParseReplayPosition to Rust
Browse filesFully 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>
|
@@ -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)
|
|
@@ -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;
|
|
@@ -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 |
+
}
|
|
@@ -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 |
}
|
|
@@ -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 |
+
}
|