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.
This commit is contained in:
parent
fcb07bba64
commit
e984aa7d15
4 changed files with 328 additions and 10 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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::<Vec<_>>().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 {
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
278
crates/logic/src/battle/verify.rs
Normal file
278
crates/logic/src/battle/verify.rs
Normal file
|
|
@ -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<String>,
|
||||
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<LogicDataRef> {
|
||||
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<Option<(i32, i32)>> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue