diff --git a/crates/game-service/src/battle.rs b/crates/game-service/src/battle.rs index 5733fc1..c1f8c09 100644 --- a/crates/game-service/src/battle.rs +++ b/crates/game-service/src/battle.rs @@ -24,6 +24,14 @@ pub struct TowerSpec { pub struct BattleBuilder { root: PathBuf, buff_type_count: usize, + summoner_bodies: bool, + princess_towers: bool, +} +fn env_flag(key: &str) -> bool { + !matches!( + std::env::var(key).unwrap_or_default().as_str(), + "0" | "false" | "off" + ) } fn tile_to_units(tile: i32) -> i32 { tile.saturating_mul(SUBTILE_UNITS) @@ -45,9 +53,22 @@ impl BattleBuilder { .table(BUFF_ARRAY_TABLE) .map(|rows| rows.count()) .unwrap_or(0); + tracing::info!( + buff_type_count, + summoner_bodies = env_flag("SCROLL_BATTLE_SUMMONER"), + princess_towers = env_flag("SCROLL_BATTLE_PRINCESS"), + damage_types = LogicDataTables::instance().table(table::DAMAGE_TYPES).map(|t| t.count()).unwrap_or(0), + character_buffs = LogicDataTables::instance().table(table::CHARACTER_BUFFS).map(|t| t.count()).unwrap_or(0), + king = %character_data(CHARACTER_KING_TOWER), + king_id = ?character_data(CHARACTER_KING_TOWER).global_id(), + princess_id = ?character_data(CHARACTER_PRINCESS_TOWER).global_id(), + "battle builder ready" + ); Self { root: root.as_ref().to_path_buf(), buff_type_count, + summoner_bodies: env_flag("SCROLL_BATTLE_SUMMONER"), + princess_towers: env_flag("SCROLL_BATTLE_PRINCESS"), } } pub fn buff_type_count(&self) -> usize { @@ -151,13 +172,13 @@ impl BattleBuilder { owner: owner as i32, level_index: 0, is_leader: true, - summoner: true, + summoner: self.summoner_bodies, }); leaders[owner] = entry.global_id; objects.push(entry); instance += 1; } - if !princess.is_none() { + if !princess.is_none() && self.princess_towers { for tile in tilemap.princess_towers() { let owner = usize::from(tile_to_units(tile.1) >= middle); let entry = self.tower(TowerSpec { diff --git a/crates/game-service/src/service.rs b/crates/game-service/src/service.rs index 50b67b5..94e979c 100644 --- a/crates/game-service/src/service.rs +++ b/crates/game-service/src/service.rs @@ -101,14 +101,31 @@ impl GameService { let snapshot = battle .snapshot() .map_err(|error| RpcError::Rejected(error.to_string()))?; - tracing::info!( - %account, - npc = %npc, - location = %location, - objects = battle.battle.objects.objects.len(), - bytes = snapshot.len(), - "sending a battle sector state" - ); + tracing::debug!(head = %snapshot.iter().take(48).map(|b| format!("{b:02x}")).collect::>().join(" "), "snapshot head"); + let report = logic::battle::verify_snapshot(&snapshot); + if report.is_ok() { + tracing::debug!(steps = ?report.steps, "snapshot sections"); + tracing::info!( + %account, + npc = %npc, + location = %location, + objects = battle.battle.objects.objects.len(), + bytes = snapshot.len(), + "sending a battle sector state" + ); + } else { + tracing::error!( + %account, + bytes = snapshot.len(), + trailing = report.trailing, + error = report.error.as_deref().unwrap_or("-"), + steps = ?report.steps, + "the battle snapshot does not read back, refusing to send it" + ); + return Err(RpcError::Rejected(report.error.unwrap_or_else(|| { + format!("{} trailing byte(s)", report.trailing) + }))); + } Ok(vec![encode(&SectorStateMessage::new(snapshot))?]) } fn bot_opponent(&self) -> LogicDataRef { diff --git a/crates/logic/src/battle/mod.rs b/crates/logic/src/battle/mod.rs index 08272a8..e086e59 100644 --- a/crates/logic/src/battle/mod.rs +++ b/crates/logic/src/battle/mod.rs @@ -8,6 +8,7 @@ mod logic_game_object_ref; mod logic_tilemap; mod logic_time; mod logic_tutorial_manager; +mod verify; pub use logic_battle::{ LogicBattle, BATTLE_INT_ARRAY, BATTLE_TRAILING_INTS, BATTLE_TYPE_NPC, BATTLE_TYPE_PVP, BATTLE_TYPE_REPLAY, @@ -29,3 +30,4 @@ pub use logic_game_object_ref::LogicGameObjectRef; pub use logic_tilemap::{LogicTilemap, OBJECT_KING_TOWER, OBJECT_PRINCESS_TOWER, SUBTILE_UNITS}; pub use logic_time::LogicTime; pub use logic_tutorial_manager::LogicTutorialManager; +pub use verify::{verify_snapshot, SnapshotReport}; diff --git a/crates/logic/src/battle/verify.rs b/crates/logic/src/battle/verify.rs new file mode 100644 index 0000000..34cf61e --- /dev/null +++ b/crates/logic/src/battle/verify.rs @@ -0,0 +1,278 @@ +use titan::{ByteStreamReader, Result}; +use crate::battle::logic_game_mode::{SECTION_BATTLE, SECTION_TUTORIAL}; +use crate::data::{table, LogicDataRef, LogicDataTables}; +pub const BUFF_ARRAY_TABLE: i32 = table::DAMAGE_TYPES; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SnapshotReport { + pub steps: Vec<(String, usize)>, + pub error: Option, + pub trailing: usize, +} +impl SnapshotReport { + pub fn is_ok(&self) -> bool { + self.error.is_none() && self.trailing == 0 + } +} +struct Verifier<'a, 'b> { + reader: &'a mut ByteStreamReader<'b>, + steps: Vec<(String, usize)>, +} +impl<'a, 'b> Verifier<'a, 'b> { + fn mark(&mut self, what: &str) { + let at = self.reader.offset(); + self.steps.push((what.to_owned(), at)); + } + fn data_ref(&mut self) -> Result { + let class_id = self.reader.read_vint()?; + if class_id == 0 { + return Ok(LogicDataRef::None); + } + let instance_id = self.reader.read_vint()?; + Ok(LogicDataRef::of(class_id, instance_id)) + } + fn global_id(&mut self) -> Result> { + let class_id = self.reader.read_vint()?; + if class_id == 0 { + return Ok(None); + } + Ok(Some((class_id, self.reader.read_vint()?))) + } + fn vints(&mut self, count: usize) -> Result<()> { + for _ in 0..count { + self.reader.read_vint()?; + } + Ok(()) + } + fn character(&mut self, data: &LogicDataRef) -> Result<()> { + self.reader.read_vint()?; + self.reader.read_boolean()?; + self.reader.read_boolean()?; + self.vints(2)?; + self.vints(2)?; + self.reader.read_vint()?; + self.vints(2)?; + self.vints(12)?; + let row = data.data(); + let mana_limit = row.map(|r| r.int("ManaGenerateLimit")).unwrap_or(0); + if mana_limit >= 1 { + self.reader.read_vint()?; + } + let reload = row.map(|r| r.int("ReloadAfterHits")).unwrap_or(0); + if reload >= 1 { + self.vints(2)?; + } + Ok(()) + } + fn summoner(&mut self, data: &LogicDataRef) -> Result<()> { + self.character(data)?; + if self.reader.read_boolean()? { + self.reader.read_vint()?; + self.vints(4)?; + let draw = self.reader.read_vint()?.max(0) as usize; + self.vints(draw)?; + let used = self.reader.read_vint()?.max(0) as usize; + self.vints(used)?; + self.vints(3)?; + } + self.vints(4)?; + Ok(()) + } + fn combat(&mut self) -> Result<()> { + self.reader.read_boolean()?; + self.reader.read_boolean()?; + self.vints(5)?; + let attackers = self.reader.read_vint()?.max(0) as usize; + let observers = self.reader.read_vint()?.max(0) as usize; + self.global_id()?; + for _ in 0..attackers { + self.global_id()?; + self.reader.read_vint()?; + } + for _ in 0..observers { + self.global_id()?; + } + Ok(()) + } + fn hitpoint(&mut self) -> Result<()> { + let hitpoints = self.reader.read_vint()?; + self.reader.read_vint()?; + let lifetime = self.reader.read_vint()?; + if lifetime != 0 { + self.reader.read_vint()?; + } + if hitpoints <= 0 { + self.reader.read_vint()?; + } + Ok(()) + } + fn buff(&mut self, buff_rows: usize) -> Result<()> { + let first = self.reader.read_vint()?.max(0) as usize; + let second = self.reader.read_vint()?.max(0) as usize; + if first != 0 || second != 0 { + return Err(titan::Error::Unsupported("buff arrays are not empty")); + } + self.vints(3)?; + self.vints(buff_rows)?; + for _ in 0..buff_rows { + self.reader.read_boolean()?; + } + self.vints(6)?; + Ok(()) + } + fn avatar(&mut self) -> Result<()> { + self.vints(2)?; + self.vints(2)?; + self.vints(2)?; + self.reader.read_string_reference()?; + self.reader.read_boolean()?; + self.reader.read_vint()?; + self.data_ref()?; + self.reader.read_vint()?; + self.reader.read_boolean()?; + let kinds = self.reader.read_vint()?; + if kinds != 6 { + return Err(titan::Error::Unsupported("commodity kind count is not 6")); + } + for _ in 0..6 { + let slots = self.reader.read_vint()?.max(0) as usize; + for _ in 0..slots { + self.data_ref()?; + self.reader.read_vint()?; + } + } + self.vints(5)?; + if self.reader.read_boolean()? { + self.vints(2)?; + self.reader.read_string_reference()?; + self.data_ref()?; + self.reader.read_vint()?; + } + self.vints(6)?; + self.reader.read_boolean()?; + Ok(()) + } + fn run(&mut self) -> Result<()> { + self.mark("server_tick"); + self.reader.read_vint()?; + self.reader.read_vint()?; + self.mark("sentinel_11"); + let marker = self.reader.read_vint()?; + if marker != SECTION_BATTLE { + return Err(titan::Error::Unsupported("sentinel 11 mismatch")); + } + self.mark("time"); + self.reader.read_vint()?; + self.mark("random"); + self.reader.read_int()?; + self.reader.read_vint()?; + self.mark("battle"); + self.data_ref()?; + self.data_ref()?; + self.data_ref()?; + self.vints(4)?; + self.vints(2)?; + self.vints(8)?; + self.vints(3)?; + for _ in 0..6 { + self.reader.read_boolean()?; + } + self.mark("objects"); + self.vints(6)?; + let count = self.reader.read_vint()?.max(0) as usize; + let mut data = Vec::with_capacity(count); + for _ in 0..count { + data.push(self.data_ref()?); + } + for _ in 0..count { + self.global_id()?; + } + let summoner_bodies = !matches!( + std::env::var("SCROLL_BATTLE_SUMMONER") + .unwrap_or_default() + .as_str(), + "0" | "false" | "off" + ); + let summoner = LogicDataTables::instance() + .data_by_name(table::CHARACTERS_COMBINED, "KingTower") + .map(|row| row.global_id()) + .filter(|_| summoner_bodies); + self.mark("object_bodies"); + for entry in &data { + let is_summoner = entry.global_id().is_some() && entry.global_id() == summoner; + let entry = entry.clone(); + if is_summoner { + self.summoner(&entry)?; + } else { + self.character(&entry)?; + } + } + let buff_rows = LogicDataTables::instance() + .table(BUFF_ARRAY_TABLE) + .map(|rows| rows.count()) + .unwrap_or(0); + self.mark("components"); + for pass in 0..4 { + for _ in &data { + match pass { + 0 => self.combat()?, + 2 => self.hitpoint()?, + 3 => self.buff(buff_rows)?, + _ => {} + } + } + } + self.mark("decks"); + for _ in 0..2 { + if self.reader.read_boolean()? { + return Err(titan::Error::Unsupported("decks are not expected yet")); + } + } + self.mark("leaders"); + self.global_id()?; + self.global_id()?; + for _ in 0..2 { + let towers = self.reader.read_vint()?.max(0) as usize; + for _ in 0..towers { + self.global_id()?; + } + } + self.mark("battle_tail"); + self.vints(6)?; + self.mark("avatars"); + for _ in 0..2 { + self.avatar()?; + } + self.mark("sentinel_12"); + let marker = self.reader.read_vint()?; + if marker != SECTION_TUTORIAL { + return Err(titan::Error::Unsupported("sentinel 12 mismatch")); + } + self.mark("tutorial"); + self.data_ref()?; + self.reader.read_vint()?; + self.global_id()?; + self.reader.read_vint()?; + self.mark("commands"); + let commands = self.reader.read_vint()?; + if commands != 0 { + return Err(titan::Error::Unsupported("commands are not expected yet")); + } + self.mark("end"); + Ok(()) + } +} +pub fn verify_snapshot(snapshot: &[u8]) -> SnapshotReport { + let mut reader = ByteStreamReader::new(snapshot); + let mut verifier = Verifier { + reader: &mut reader, + steps: Vec::new(), + }; + let outcome = verifier.run(); + let steps = verifier.steps.clone(); + let error = outcome.err().map(|error| error.to_string()); + SnapshotReport { + trailing: reader.remaining(), + steps, + error, + } +}