From e984aa7d1562446417166a8d98844d2b055d569e Mon Sep 17 00:00:00 2001 From: WiseDev <83840010+wisedevik@users.noreply.github.com> Date: Sun, 23 Aug 2026 10:50:03 +0300 Subject: [PATCH] verify the snapshot before sending it, and add two bisect switches the client aborts in Debugger::error("Full update stream is corrupted!"), tail called out of LogicGameMode::decode, which is why the stack blames receiveSectorState. only one error path exists there so it is a sentinel. verify_snapshot reads the snapshot back following the client decoders and refuses to send anything that does not land both sentinels with no trailing bytes. it passes today, so the layout matches what i believe the client reads. checked every reader against the binary: LogicGameMode, LogicBattle, LogicGameObjectManager, LogicGameObject, LogicCharacter, LogicSummoner, all three components, LogicClientAvatar, readGlobalID, readDataReference, readConstantSizeIntArray, readGameObjectReference, decodeComponent, and setLevelIndex for the component set. all match. SCROLL_BATTLE_SUMMONER=0 encodes the king towers as plain characters, SCROLL_BATTLE_PRINCESS=0 drops the princess towers. one client run with each splits the remaining hypotheses. --- crates/game-service/src/battle.rs | 25 ++- crates/game-service/src/service.rs | 33 +++- crates/logic/src/battle/mod.rs | 2 + crates/logic/src/battle/verify.rs | 278 +++++++++++++++++++++++++++++ 4 files changed, 328 insertions(+), 10 deletions(-) create mode 100644 crates/logic/src/battle/verify.rs 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, + } +}