keep the objects out of the state the client loads on

traced the whole path in the client rather than guessing at it again.

GameObjectManager::addGameObject is the only thing that builds a model,
and LogicGameObjectManager::decode calls it through the listener at
[mgr+0x28], only for objects whose "newly created" flag is still set -
and it clears that flag on the way out. setListener has exactly two
callers: the BattleScreen constructor and its destructor. GameMode
::updateLoading builds that screen only after isFullUpdateReceived, ie
after the first sector state has already been decoded.

so every object in the opening state is created against the do-nothing
base listener, loses its flag, and can never get a model afterwards -
later snapshots match it by global id and reuse it. that is the missing
archers, and it was never about the data or the ids.

the opening state now carries no objects and no leaders. the towers
arrive on the next snapshot, once the client has said it is up, and are
new by then.

renumbering, which the last commit did, is gone: SpellButton caches the
summoner at [this+0x1d0] when the hud is built, so replacing the objects
under it left a dangling pointer, which is the getOwnerAccountId crash.

also spend mana when a card is played - it was only ever regenerating.
This commit is contained in:
WiseDev 2026-08-23 14:51:29 +03:00
parent eb0e59f6f4
commit cb5b04f3ca
2 changed files with 48 additions and 70 deletions

View file

@ -4,8 +4,7 @@ pub const SNAPSHOT_INTERVAL_TICKS: i32 = 4;
pub const MAX_CATCH_UP_TICKS: i32 = 40; pub const MAX_CATCH_UP_TICKS: i32 = 40;
use std::time::Instant; use std::time::Instant;
use logic::battle::{ use logic::battle::{
LogicGameMode, LogicGameObjectEntry, LogicGameObjectRef, LogicVector2, LogicGameMode, LogicGameObjectEntry, LogicVector2, BATTLE_TICKS_PER_SECOND, BATTLE_TYPE_PVP,
BATTLE_TICKS_PER_SECOND, BATTLE_TYPE_PVP, CHARACTER_OBJECT_TYPE,
}; };
use logic::battle::{verify_snapshot, LogicBattleEvent}; use logic::battle::{verify_snapshot, LogicBattleEvent};
use logic::SectorStateMessage; use logic::SectorStateMessage;
@ -59,38 +58,31 @@ impl BattleSession {
pending_bot_play: None, pending_bot_play: None,
} }
} }
pub fn reannounce(&mut self) { fn opening(&self) -> LogicGameMode {
let mut remap: Vec<(LogicGameObjectRef, LogicGameObjectRef)> = Vec::new(); let mut mode = self.mode.clone();
for entry in self.mode.battle.objects.objects.iter_mut() { mode.battle.objects.objects.clear();
let fresh = LogicGameObjectRef::of(CHARACTER_OBJECT_TYPE + 1, self.next_instance); mode.battle.leaders = Default::default();
self.next_instance += 1; mode.battle.leader_towers = Default::default();
remap.push((entry.global_id, fresh)); mode
entry.global_id = fresh; }
pub fn announce(&mut self) -> bool {
if self.announced {
return false;
} }
let translate = |old: &LogicGameObjectRef| -> Option<LogicGameObjectRef> { self.announced = true;
remap
.iter()
.find(|(was, _)| was == old)
.map(|(_, now)| *now)
};
for leader in self.mode.battle.leaders.iter_mut() {
if let Some(now) = translate(leader) {
*leader = now;
}
}
for towers in self.mode.battle.leader_towers.iter_mut() {
for tower in towers.iter_mut() {
if let Some(now) = translate(tower) {
*tower = now;
}
}
}
self.mode
.battle
.objects
.objects
.sort_by_key(|entry| entry.global_id.0.map(|id| (id.class_id, id.instance_id)));
self.next_snapshot = self.tick; self.next_snapshot = self.tick;
true
}
pub fn spend_mana(&mut self, owner: i32, cost: i32) {
for entry in self.mode.battle.objects.objects.iter_mut() {
if entry.owner_index() != owner {
continue;
}
if let logic::battle::LogicObjectBody::Summoner(summoner) = &mut entry.body {
summoner.mana = (summoner.mana - cost).max(0);
return;
}
}
} }
pub fn take_bot_play(&mut self) -> Option<(LogicDataRef, LogicVector2, i32)> { pub fn take_bot_play(&mut self) -> Option<(LogicDataRef, LogicVector2, i32)> {
self.pending_bot_play.take() self.pending_bot_play.take()
@ -151,7 +143,11 @@ impl BattleSession {
}) })
} }
pub fn snapshot_message(&mut self) -> Option<WireMessage> { pub fn snapshot_message(&mut self) -> Option<WireMessage> {
let snapshot = self.mode.snapshot().ok()?; let snapshot = if self.announced {
self.mode.snapshot().ok()?
} else {
self.opening().snapshot().ok()?
};
let report = verify_snapshot(&snapshot); let report = verify_snapshot(&snapshot);
let unit = self let unit = self
.mode .mode
@ -383,7 +379,6 @@ impl BattleRegistry {
pub async fn resend(&self, account: AccountRef) -> Option<WireMessage> { pub async fn resend(&self, account: AccountRef) -> Option<WireMessage> {
let handle = self.session(account).await?; let handle = self.session(account).await?;
let mut session = handle.lock().await; let mut session = handle.lock().await;
session.reannounce();
session.snapshot_message() session.snapshot_message()
} }
pub async fn announce_once(&self, account: AccountRef) -> bool { pub async fn announce_once(&self, account: AccountRef) -> bool {
@ -391,12 +386,7 @@ impl BattleRegistry {
return false; return false;
}; };
let mut session = handle.lock().await; let mut session = handle.lock().await;
if session.announced { session.announce()
return false;
}
session.announced = true;
session.reannounce();
true
} }
pub async fn send(&self, account: AccountRef, message: WireMessage) { pub async fn send(&self, account: AccountRef, message: WireMessage) {
if let Some(handle) = self.session(account).await { if let Some(handle) = self.session(account).await {
@ -440,6 +430,8 @@ impl BattleRegistry {
let Some(card) = session.deck_card(owner, slot) else { let Some(card) = session.deck_card(owner, slot) else {
return 0; return 0;
}; };
let cost = card.data().map(|row| row.int("ManaCost")).unwrap_or(0);
session.spend_mana(owner, cost);
let entries = summon(&card, position, owner, session.next_instance()); let entries = summon(&card, position, owner, session.next_instance());
let spawned = entries.len(); let spawned = entries.len();
session.queue(at_tick, entries); session.queue(at_tick, entries);

View file

@ -2,7 +2,7 @@ use std::sync::Arc;
use logic::table; use logic::table;
use logic::{ use logic::{
AvailableServerCommandMessage, EndClientTurnMessage, LogicCommandManager, LogicDataRef, AvailableServerCommandMessage, EndClientTurnMessage, LogicCommandManager, LogicDataRef,
LogicShopSeedChangedCommand, OutOfSyncMessage, OwnHomeDataMessage, SectorStateMessage, LogicShopSeedChangedCommand, OutOfSyncMessage, OwnHomeDataMessage,
SectorCommandMessage, SendBattleEventMessage, SectorCommandMessage, SendBattleEventMessage,
StartMissionMessage, StopHomeLogicMessage, StartMissionMessage, StopHomeLogicMessage,
}; };
@ -146,38 +146,24 @@ impl GameService {
self.config.random_seed, self.config.random_seed,
) )
.map_err(|error| RpcError::Rejected(error.to_string()))?; .map_err(|error| RpcError::Rejected(error.to_string()))?;
let snapshot = battle let objects = battle.battle.objects.objects.len();
.snapshot()
.map_err(|error| RpcError::Rejected(error.to_string()))?;
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,
opponent = %opponent_name,
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)
})));
}
self.running_battles self.running_battles
.start(&players, battle, crate::bot::menu_taunts()) .start(&players, battle, crate::bot::menu_taunts())
.await; .await;
Ok(vec![encode(&SectorStateMessage::new(snapshot))?]) let Some(message) = self.running_battles.resend(account).await else {
self.running_battles.finish(account).await;
return Err(RpcError::Rejected(
"the opening sector state does not read back".to_owned(),
));
};
tracing::info!(
%account,
opponent = %opponent_name,
location = %location,
objects,
"sending the opening sector state, objects follow once the client is up"
);
Ok(vec![message])
} }
async fn roller(&self, account: AccountRef) -> RpcResult<RewardRoller> { async fn roller(&self, account: AccountRef) -> RpcResult<RewardRoller> {
let profile = self.profile(account).await?; let profile = self.profile(account).await?;