From 7f1246b625cc3fc0509a757915f0abcd81c77d4f Mon Sep 17 00:00:00 2001 From: Henry Liou Date: Thu, 19 Mar 2026 23:02:36 -0700 Subject: [PATCH 1/4] Enable AI replay parsing and improve robustness --- crates/aoe2rec/src/lib.rs | 96 ++++++++++++++++++++++++++++++++++--- crates/aoe2rec/src/tests.rs | 63 ++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 6 deletions(-) diff --git a/crates/aoe2rec/src/lib.rs b/crates/aoe2rec/src/lib.rs index eb8e4e7..7527036 100644 --- a/crates/aoe2rec/src/lib.rs +++ b/crates/aoe2rec/src/lib.rs @@ -4,7 +4,6 @@ pub mod minimal; pub mod summary; mod tests; -use binrw::helpers::until_eof; use binrw::io::{BufReader, Cursor, SeekFrom}; use binrw::{binrw, BinReaderExt, BinResult, BinWriterExt, NullString}; use header::{decompress, RecHeader}; @@ -22,7 +21,7 @@ pub struct Savegame { pub zheader: RecHeader, pub log_version: u32, pub meta: Meta, - #[br(parse_with = until_eof, args(zheader.version_major))] + #[br(parse_with = parse_operations, args(zheader.version_major))] pub operations: Vec, } @@ -61,11 +60,17 @@ pub enum Operation { #[br(magic = 1u32)] Action { length: u32, - #[br(pad_size_to = length, args(length, major))] - action_data: actions::ActionData, + #[br(temp, count = length)] + #[bw(ignore)] + data: Vec, + #[br(calc = { + let mut reader = Cursor::new(&data); + reader.read_le_args::((length, major)).ok() + })] + action_data: Option, world_time: u32, #[serde(skip_serializing)] - #[br(if(matches!(action_data, actions::ActionData::Chapter { player_id: _, action_length: _ })))] + #[br(if(matches!(action_data, Some(actions::ActionData::Chapter { player_id: _, action_length: _ }))))] chap: Option, }, #[br(magic = 2u32)] @@ -99,6 +104,48 @@ pub enum Operation { #[br(magic = b"\xce\xa4\x59\xb1\x05\xdb\x7b\x43")] end_bit: (), }, + #[br(magic = 7u32)] + Ai { + length: u32, + #[br(count = length)] + data: Vec, + }, + #[br(magic = 8u32)] + MapNote { + length: u32, + #[br(count = length)] + data: Vec, + }, + #[br(magic = 9u32)] + InitialState { + length: u32, + #[br(count = length)] + data: Vec, + }, + #[br(magic = 10u32)] + Op10 { + length: u32, + #[br(count = length)] + data: Vec, + }, + #[br(magic = 11u32)] + Op11 { + length: u32, + #[br(count = length)] + data: Vec, + }, + #[br(magic = 12u32)] + Op12 { + length: u32, + #[br(count = length)] + data: Vec, + }, + #[br(magic = 13u32)] + AiScript { + length: u32, + #[br(count = length)] + data: Vec, + }, } #[binrw] @@ -351,7 +398,7 @@ impl Savegame { .iter() .map(|operation| match operation { Operation::Action { action_data, .. } => match action_data { - actions::ActionData::Resign { player_id, .. } => *player_id, + Some(actions::ActionData::Resign { player_id, .. }) => *player_id, _ => 100, }, _ => 100, @@ -392,6 +439,43 @@ fn read_strings_of_length() -> BinResult> { Ok(strings) } +fn parse_operations( + reader: &mut R, + endian: binrw::Endian, + args: (u16,), +) -> binrw::BinResult> { + let mut operations = Vec::new(); + loop { + let magic_res: binrw::BinResult = reader.read_type(endian); + if let Err(e) = magic_res { + if matches!(e, binrw::Error::Io(ref io_err) if io_err.kind() == std::io::ErrorKind::UnexpectedEof) { + break; + } + return Err(e); + } + let magic = magic_res.unwrap(); + if magic == 0 || magic > 100 { + reader.seek(std::io::SeekFrom::Current(-3))?; + continue; + } + reader.seek(std::io::SeekFrom::Current(-4))?; + let res: binrw::BinResult = reader.read_type_args(endian, (args.0,)); + match res { + Ok(op) => { + operations.push(op); + if magic == 6 { + break; + } + } + Err(_) => { + // Skip one byte and try to re-sync if parsing failed + reader.seek(std::io::SeekFrom::Current(-3))?; + } + } + } + Ok(operations) +} + #[binrw::writer(writer, endian)] fn write_len_and_string(strings: &Vec) -> BinResult<()> { for string in strings { diff --git a/crates/aoe2rec/src/tests.rs b/crates/aoe2rec/src/tests.rs index 52fa433..4de2e83 100644 --- a/crates/aoe2rec/src/tests.rs +++ b/crates/aoe2rec/src/tests.rs @@ -83,3 +83,66 @@ mod tests { result.write(&mut file).unwrap(); } } + +#[cfg(test)] +mod robust_tests { + use std::io::Cursor; + use binrw::BinReaderExt; + use crate::{Operation, parse_operations}; + + #[test] + fn test_parse_ai_operation() { + let mut data = Cursor::new(vec![ + 0x07, 0x00, 0x00, 0x00, // Magic 7 (AI) + 0x04, 0x00, 0x00, 0x00, // Length 4 + 0xDE, 0xAD, 0xBE, 0xEF, // AI Data + ]); + let op: Operation = data.read_le_args((1u16,)).unwrap(); + if let Operation::Ai { length, data } = op { + assert_eq!(length, 4); + assert_eq!(data, vec![0xDE, 0xAD, 0xBE, 0xEF]); + } else { + panic!("Expected AI operation"); + } + } + + #[test] + fn test_parse_operations_resync() { + // parse_operations stops at magic 6. + let mut data = Cursor::new(vec![ + 0x99, 0x99, 0x99, // 3 bytes of garbage + 0x08, 0x00, 0x00, 0x00, // Magic 8 (MapNote) + 0x02, 0x00, 0x00, 0x00, // Length 2 + 0x01, 0x02, // Data + 0x06, 0x00, 0x00, 0x00, // Magic 6 (PostGame) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // Padding for PostGame seeks + 0xCE, 0xA4, 0x59, 0xB1, 0x05, 0xDB, 0x7B, 0x43, // End bit + ]); + + let ops = parse_operations(&mut data, binrw::Endian::Little, (1u16,)).unwrap(); + assert_eq!(ops.len(), 2); + if let Operation::MapNote { length, .. } = &ops[0] { + assert_eq!(*length, 2); + } else { + panic!("Expected MapNote operation"); + } + } + + #[test] + fn test_parse_action_with_failed_data() { + // Action magic = 1 + // Action header: length (u32), data (Vec), world_time (u32) + let mut data = Cursor::new(vec![ + 0x01, 0x00, 0x00, 0x00, // Magic 1 (Action) + 0x04, 0x00, 0x00, 0x00, // Length 4 + 0x00, 0x00, 0x00, 0x00, // Garbage action data + 0x00, 0x00, 0x00, 0x00, // World time + ]); + let op: Operation = data.read_le_args((1u16,)).unwrap(); + if let Operation::Action { action_data, .. } = op { + assert!(action_data.is_none()); + } else { + panic!("Expected Action operation"); + } + } +} From 1878f432e2848376056afcb06b9e9d6c5b17b918 Mon Sep 17 00:00:00 2001 From: Henry Liou Date: Sat, 11 Apr 2026 16:28:14 -0700 Subject: [PATCH 2/4] Parse initial objects --- crates/aoe2rec/src/header/mod.rs | 213 ++++++++++++++++++++++++++++++- 1 file changed, 211 insertions(+), 2 deletions(-) diff --git a/crates/aoe2rec/src/header/mod.rs b/crates/aoe2rec/src/header/mod.rs index d4280c4..7a70606 100644 --- a/crates/aoe2rec/src/header/mod.rs +++ b/crates/aoe2rec/src/header/mod.rs @@ -34,7 +34,7 @@ pub struct RecHeader { pub ai_config: AIConfig, pub replay: Replay, pub map_info: MapInfo, - #[br(args(replay.num_players, version_major))] + #[br(args(replay.num_players, version_major, map_info.size_x, map_info.size_y))] pub initial: Initial, } @@ -391,7 +391,7 @@ pub struct EmptySlot { #[binrw] #[derive(Serialize, Debug)] -#[br(import(num_players: u8, major: u16))] +#[br(import(num_players: u8, major: u16, map_size_x: u32, map_size_y: u32))] pub struct Initial { pub restore_time: u32, pub num_particles: u32, @@ -404,6 +404,32 @@ pub struct Initial { pub players: Vec, #[serde(skip_serializing)] pub unknown1: [u8; 21], + #[serde(flatten)] + #[bw(ignore)] + #[br(parse_with = parse_initial_tail, args(num_players, map_size_x, map_size_y))] + pub initial_tail: InitialTail, +} + +#[derive(Serialize, Debug)] +pub struct InitialObjectsList { + pub count: u32, + pub object_ids: Vec, +} + +#[derive(Serialize, Debug)] +pub struct InitialObjectInstance { + pub object_id: u32, + pub object_type_id: u16, + pub object_kind: u8, + pub player_id: u8, + pub x: f32, + pub y: f32, +} + +#[derive(Serialize, Debug)] +pub struct InitialTail { + pub initial_objects: Vec, + pub initial_object_instances: Vec, } #[binrw] #[derive(Serialize, Debug, Default)] @@ -522,3 +548,186 @@ pub struct Location { pub x: u16, pub y: u16, } + +#[binrw::parser(reader, endian)] +fn parse_initial_tail( + num_players: u8, + map_size_x: u32, + map_size_y: u32, +) -> binrw::BinResult { + let _ = endian; + let mut rest = Vec::new(); + reader.read_to_end(&mut rest)?; + + let initial_objects = parse_initial_objects_from_bytes(&rest, num_players); + let initial_object_instances = + parse_initial_object_instances(&rest, num_players, map_size_x, map_size_y); + + Ok(InitialTail { + initial_objects, + initial_object_instances, + }) +} + +fn parse_initial_objects_from_bytes( + rest: &[u8], + num_players: u8, +) -> Vec { + let mut results: Vec = Vec::new(); + let max_lists = usize::from(num_players) + 1; // include Gaia if present + let mut i = 0usize; + + while i + 5 < rest.len() && results.len() < max_lists { + if rest[i] != 0x0B { + i += 1; + continue; + } + + let count = u32::from_le_bytes([ + rest[i + 1], + rest[i + 2], + rest[i + 3], + rest[i + 4], + ]) as usize; + + if count < 50 || count > 10_000 { + i += 1; + continue; + } + + let end = i + 1 + 4 + count * 4; + if end >= rest.len() { + i += 1; + continue; + } + + if rest[end] != 0x0B { + i += 1; + continue; + } + + let mut object_ids = Vec::with_capacity(count); + let mut cursor = i + 1 + 4; + for _ in 0..count { + let val = u32::from_le_bytes([ + rest[cursor], + rest[cursor + 1], + rest[cursor + 2], + rest[cursor + 3], + ]); + object_ids.push(val); + cursor += 4; + } + + results.push(InitialObjectsList { + count: count as u32, + object_ids, + }); + + i = end + 1; + } + + results +} + +fn parse_initial_object_instances( + rest: &[u8], + num_players: u8, + map_size_x: u32, + map_size_y: u32, +) -> Vec { + let mut results = Vec::new(); + + use std::collections::HashSet; + let mut seen_ids: HashSet = HashSet::new(); + + let mut cursor = 0usize; + while cursor + 40 < rest.len() { + let instance = + parse_object_instance_at(rest, cursor, num_players, map_size_x, map_size_y); + if let Some(instance) = instance { + if seen_ids.insert(instance.object_id) { + results.push(instance); + } + cursor += 1; + } else { + cursor += 1; + } + } + + results +} + +fn parse_object_instance_at( + rest: &[u8], + cursor: usize, + num_players: u8, + map_size_x: u32, + map_size_y: u32, +) -> Option { + let obj_type = rest[cursor]; + if !matches!(obj_type, 10 | 20 | 25 | 30 | 40 | 70 | 80 | 90) { + return None; + } + if cursor + 35 >= rest.len() { + return None; + } + let player_id = rest[cursor + 1]; + if player_id > num_players { + return None; + } + let object_type_id = u16::from_le_bytes([rest[cursor + 2], rest[cursor + 3]]); + + let object_id = u32::from_le_bytes([ + rest[cursor + 18], + rest[cursor + 19], + rest[cursor + 20], + rest[cursor + 21], + ]); + if object_id == 0 || object_id > 5_000_000 { + return None; + } + + let x = f32::from_le_bytes([ + rest[cursor + 23], + rest[cursor + 24], + rest[cursor + 25], + rest[cursor + 26], + ]); + let y = f32::from_le_bytes([ + rest[cursor + 27], + rest[cursor + 28], + rest[cursor + 29], + rest[cursor + 30], + ]); + // Validate z coordinate at offset +31 + let z = f32::from_le_bytes([ + rest[cursor + 31], + rest[cursor + 32], + rest[cursor + 33], + rest[cursor + 34], + ]); + + if !x.is_finite() || !y.is_finite() || !z.is_finite() { + return None; + } + if z < -10.0 || z > 100.0 { + return None; + } + if map_size_x > 0 && map_size_y > 0 { + let max_x = map_size_x as f32 + 8.0; + let max_y = map_size_y as f32 + 8.0; + if x < 0.1 || y < 0.1 || x > max_x || y > max_y { + return None; + } + } + + Some(InitialObjectInstance { + object_id, + object_type_id, + object_kind: obj_type, + player_id, + x, + y, + }) +} From fdb279cde19d1e448e3dfa46ac12e6cfb9fd8af9 Mon Sep 17 00:00:00 2001 From: Henry Liou Date: Sun, 19 Apr 2026 17:24:01 -0700 Subject: [PATCH 3/4] Parse AI research actions --- README.md | 14 +++++- crates/aoe2rec/src/actions.rs | 8 +-- crates/aoe2rec/src/tests.rs | 92 +++++++++++++++++++++++++++++++++-- 3 files changed, 104 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 996f967..2cc2d65 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,19 @@ aoe2rec = "0.1" ## Example -```rust - ``` +cargo run --package aoe2js -- "replay.aoe2record" +``` + +## Build the WASM package + +``` +cargo install wasm-pack +cd crates/aoe2rec-js +wasm-pack build --target bundler +``` + +This will create a `pkg` directory inside `crates/aoe2rec-js/`. ## License diff --git a/crates/aoe2rec/src/actions.rs b/crates/aoe2rec/src/actions.rs index 8f47f55..02e1090 100644 --- a/crates/aoe2rec/src/actions.rs +++ b/crates/aoe2rec/src/actions.rs @@ -228,12 +228,12 @@ pub enum ActionData { Research { player_id: u8, action_length: u16, - building_id: i32, - selected: i16, + building_id: u32, + selected: u16, technology_type: u16, unknown1: [u8; 5], - #[br(count = if selected > -1 { selected } else { 0 })] - building_ids: Vec, + #[br(count = (action_length.saturating_sub(13) / 4) as usize)] + building_ids: Vec, }, #[br(magic = 102u8)] Build { diff --git a/crates/aoe2rec/src/tests.rs b/crates/aoe2rec/src/tests.rs index 4de2e83..5a4edbe 100644 --- a/crates/aoe2rec/src/tests.rs +++ b/crates/aoe2rec/src/tests.rs @@ -86,9 +86,9 @@ mod tests { #[cfg(test)] mod robust_tests { - use std::io::Cursor; + use crate::{actions::ActionData, parse_operations, Operation}; use binrw::BinReaderExt; - use crate::{Operation, parse_operations}; + use std::io::Cursor; #[test] fn test_parse_ai_operation() { @@ -113,9 +113,10 @@ mod robust_tests { 0x99, 0x99, 0x99, // 3 bytes of garbage 0x08, 0x00, 0x00, 0x00, // Magic 8 (MapNote) 0x02, 0x00, 0x00, 0x00, // Length 2 - 0x01, 0x02, // Data + 0x01, 0x02, // Data 0x06, 0x00, 0x00, 0x00, // Magic 6 (PostGame) - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // Padding for PostGame seeks + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, // Padding for PostGame seeks 0xCE, 0xA4, 0x59, 0xB1, 0x05, 0xDB, 0x7B, 0x43, // End bit ]); @@ -145,4 +146,87 @@ mod robust_tests { panic!("Expected Action operation"); } } + + #[test] + fn test_parse_ai_research_action_without_selected_buildings() { + let mut data = Cursor::new(vec![ + 0x01, 0x00, 0x00, 0x00, // Magic 1 (Action) + 0x11, 0x00, 0x00, 0x00, // Payload length 17 + 0x65, // Action type 101 (Research) + 0x02, // Player 2 + 0x0d, 0x00, // Action length 13 + 0x84, 0x0b, 0x00, 0x00, // Building ID 2948 + 0x01, 0x00, // Selected 1 + 0x16, 0x00, // Technology 22 + 0xff, 0xff, 0xff, 0xff, 0x00, // Unknown trailer + 0xbd, 0x6a, 0x02, 0x00, // World time 158189 + ]); + + let op: Operation = data.read_le_args((66u16,)).unwrap(); + match op { + Operation::Action { + action_data: + Some(ActionData::Research { + player_id, + action_length, + building_id, + selected, + technology_type, + building_ids, + .. + }), + .. + } => { + assert_eq!(player_id, 2); + assert_eq!(action_length, 13); + assert_eq!(building_id, 2948); + assert_eq!(selected, 1); + assert_eq!(technology_type, 22); + assert!(building_ids.is_empty()); + } + _ => panic!("Expected parsed AI research action"), + } + } + + #[test] + fn test_parse_research_action_with_selected_buildings() { + let mut data = Cursor::new(vec![ + 0x01, 0x00, 0x00, 0x00, // Magic 1 (Action) + 0x15, 0x00, 0x00, 0x00, // Payload length 21 + 0x65, // Action type 101 (Research) + 0x01, // Player 1 + 0x11, 0x00, // Action length 17 + 0x7d, 0x0b, 0x00, 0x00, // Building ID 2941 + 0x01, 0x00, // Selected 1 + 0x16, 0x00, // Technology 22 + 0xff, 0xff, 0xff, 0xff, 0x00, // Unknown trailer + 0x7d, 0x0b, 0x00, 0x00, // Selected building ID + 0xce, 0xbe, 0x07, 0x00, // World time 507470 + ]); + + let op: Operation = data.read_le_args((66u16,)).unwrap(); + match op { + Operation::Action { + action_data: + Some(ActionData::Research { + player_id, + action_length, + building_id, + selected, + technology_type, + building_ids, + .. + }), + .. + } => { + assert_eq!(player_id, 1); + assert_eq!(action_length, 17); + assert_eq!(building_id, 2941); + assert_eq!(selected, 1); + assert_eq!(technology_type, 22); + assert_eq!(building_ids, vec![2941]); + } + _ => panic!("Expected parsed research action with selected buildings"), + } + } } From 9af45b0897d0e62cafdef0ff157c9026e872303b Mon Sep 17 00:00:00 2001 From: Henry Liou <1654183+liouh@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:20:06 -0700 Subject: [PATCH 4/4] More robust parsing logic for resumed replays --- crates/aoe2rec/src/header/ai.rs | 41 ++++++++++++++++++++++---------- crates/aoe2rec/src/header/mod.rs | 4 +++- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/crates/aoe2rec/src/header/ai.rs b/crates/aoe2rec/src/header/ai.rs index fb8e8d8..fc36e8d 100644 --- a/crates/aoe2rec/src/header/ai.rs +++ b/crates/aoe2rec/src/header/ai.rs @@ -5,8 +5,9 @@ use crate::DeString; #[binrw] #[derive(Serialize, Debug)] +#[br(import(speed: f32, n_players: u32))] pub struct AIInfo { - #[br(parse_with = skip_ai)] + #[br(parse_with = skip_ai, args(speed, n_players))] skip: (), // #[br(dbg)] // max_strings: u16, // #[br(dbg)] @@ -76,18 +77,34 @@ pub struct AIFile { } #[binrw::parser(reader, endian)] -fn skip_ai() -> BinResult<()> { - let mut null_count = 0; +fn skip_ai(speed: f32, n_players: u32) -> BinResult<()> { + use binrw::io::SeekFrom; + let expected_players = (n_players + 1) as u8; + + let mut window = [0u8; 60]; + for b in &mut window { + *b = reader.read_type(endian)?; + } + loop { - let next_byte: u8 = reader.read_type(endian)?; - if next_byte == 0 { - null_count += 1 - } else { - null_count = 0; - } - if null_count == 4096 { - break; + let candidate_speed = f32::from_le_bytes(window[24..28].try_into().unwrap()); + let candidate_players = window[47]; + let temp_pause = window[28]; + let instant_build = window[48]; + let cheats = window[49]; + + if (candidate_speed - speed).abs() < 0.001 + && candidate_players == expected_players + && (temp_pause == 0 || temp_pause == 1) + && (instant_build == 0 || instant_build == 1) + && (cheats == 0 || cheats == 1) + { + reader.seek(SeekFrom::Current(-60))?; + return Ok(()); } + + let next_byte: u8 = reader.read_type(endian)?; + window.copy_within(1..60, 0); + window[59] = next_byte; } - Ok(()) } diff --git a/crates/aoe2rec/src/header/mod.rs b/crates/aoe2rec/src/header/mod.rs index 7a70606..a475904 100644 --- a/crates/aoe2rec/src/header/mod.rs +++ b/crates/aoe2rec/src/header/mod.rs @@ -31,6 +31,7 @@ pub struct RecHeader { pub interval_version: [u16; 2], #[br(args(version_major))] pub game_settings: GameSettings, + #[br(args(game_settings.speed, game_settings.n_players))] pub ai_config: AIConfig, pub replay: Replay, pub map_info: MapInfo, @@ -292,9 +293,10 @@ pub struct GameSettings { #[binrw] #[derive(Serialize, Debug)] +#[br(import(speed: f32, n_players: u32))] pub enum AIConfig { #[br(magic = 1u32)] - WithAI(AIInfo), + WithAI(#[br(args(speed, n_players))] AIInfo), #[br(magic = 0u32)] WithoutAI {}, }