two results this round, both negative and both useful.
the account ids are right end to end, so isOwnedByBottomPlayer picks the
branch it should. and every character row carries the export names it
needs - knight, goblin and barbarian all have a filename, a blue and a
red prefix and UseAnimator set - so neither "empty prefix" nor "wrong
row" survives. Debugger::error is __noreturn, and the client does not
abort, so Character::Character is not failing to build animations
either: reached, it works.
with the checksum agreeing byte for byte, the objects in the client are
identical to ours. that leaves only the sprite never being made, or
being made and torn down again - and every push re-runs decode, which
destroys whatever it cannot match. five times a second nothing would
ever be seen. SCROLL_SNAPSHOT_INTERVAL_TICKS widens the gap so one run
can tell.
the spawn log caught it at once: two archers took 11 and 12, and the
goblins that followed started again at 12, then the knight landed on 13.
the counter moved by one per card while a card can summon several.
two objects sharing a global id are one object to the client -
getGameObjectIndex finds the first and reuses it - so the duplicates
never became objects, never got models, and left the client and the
server holding different sets, which no checksum can survive.
the bot's units get models and the player's do not, and both take the
same path through summon() - only owner and position differ, and
position for a player's card comes off the wire. so print the id, the
data row, the owner and the position at the moment the object joins the
battle, and compare the two sides instead of guessing again.
the models came back last run, washed out, and the card art went white
with them: BattleScreen::startResourceLoading walks the battle's objects
to decide what to load, and an opening state holding only two towers
left it nothing to load. art needs the objects in the first state; the
model needs them to arrive after the screen exists. both, not either.
so everything ships up front for the art, and everything except the two
summoners is renumbered once the client speaks, which makes it new to
LogicGameObjectManager::decode and gets it through addGameObject with
its art already in memory. the summoners stay put because the hud holds
them raw at [SpellButton+0x1d0].
mana spending from the last commit is confirmed working in the log.
LogicBattle::resetSimulatedManaTimers reads both leaders out of the
battle at +96 and +104 and calls a virtual on each without a null check,
so an opening state with no leaders segfaults inside LogicGameMode
::decode before anything else happens.
so the opening state carries the two king towers and nothing else. they
are the objects that cannot get a model - the battle screen, and with it
the listener, does not exist yet - and they are also the pair the hud
caches at [SpellButton+0x1d0], so they are exactly the objects that must
not be replaced later either. everything else, princess towers included,
now arrives after the screen is up.
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.
the client never asks for the state, so the objects it decoded before
the battle screen installed its listener were stuck without models for
the whole battle - and every later snapshot matched them by global id
and reused them, so the "newly created" flag the visual depends on was
never set again.
the first sector command is the client telling us it is up. at that
point every object is handed a fresh id: nothing matches, so the client
builds them all from scratch and they get models. the leaders and the
tower lists are remapped with them and the column is re-sorted, since
the client binary searches it.
the battle itself is complete as of this run: a princess tower fell and
the first crown was scored, towers (2, 1) and stars (1, 0) in the log.
RequestSectorStateMessage, 12903, one vint of client tick, sent through
sendUdpMessage and so arriving on tcp like everything else. we were
ignoring it.
it matters because of how the client builds models. the factory marks a
freshly created object at [obj+0x14], and only for those does
LogicGameObjectManager::decode call the listener at [mgr+0x28] that
builds the visual. every later snapshot matches the same object by
global id and reuses it, so the flag is never set again - an object that
was decoded before the battle screen installed its listener stays
invisible for the whole battle while still walking and fighting. that is
the tower archers and the invisible units; the knight shows because the
client creates that one itself, after the screen is up.
the client asks for the state when it is ready, and now it gets it.
the client matches the objects in a snapshot against the ones it already
has by global id, and anything it cannot find it builds from scratch.
our next id was the highest one in play plus one, recomputed at every
spawn - so as soon as a unit died its id came free and the next spawn
took it. at five snapshots a second the client was destroying and
rebuilding models faster than they could appear, which is why units
fought and shot while invisible.
the counter is per battle now and only ever goes up, starting past the
towers.
the bot's first card was due on tick one, so it was already walking
while the opening countdown was still on screen - reaching the bridge
and swinging at nothing before the battle had visibly started. it waits
six seconds now before opening, and keeps its four to eleven second
rhythm after that.
the server was simulating correctly all along - the log shows a goblin
covering 120 units a tick and a knight 60, exactly their Speed columns,
in a straight line with no jitter. the teleporting was the correction
rate: one snapshot a second, while the client runs its own prediction in
between and diverges from ours. a unit gets more than a tile out of step
before our state arrives and drags it back.
the snapshot now goes every four ticks and the gateway ticks every fifty
milliseconds rather than two hundred, so a correction moves a unit a
fifth as far.
this makes the symptom smaller, not absent. it goes away when the two
simulations agree, which is what the checksum comparison is for and what
collisions and projectiles are still missing for.
the tick was running A* over the whole 36x64 grid for every unit, every
tick. catching up ten seconds meant thousands of searches inside the
session lock, so the tick loop never finished, snapshots never went out,
and the client - which refuses to send a command while
isFullUpdatePending is true - sat there showing the connection icon and
would not spawn anything. ctrl-c looked like a hang for the same reason:
a task stuck in that loop.
the client does not do this either. LogicMovementComponent carries a
path array precisely so the route is found once and walked. we keep the
route and the goal it was found for, drop a node once we are within
250 units of it, and only search again when the goal moves or the route
runs out.
advance_to also refuses to simulate more than forty ticks in one call,
so a late tick can never turn into an unbounded loop under the lock.
the snapshot is built inside the tick loop but the clock was written
after it, so every pushed state went out carrying the tick from the
previous call. LogicGameMode::decode compares that number against the
last one it saw and ignores anything older, so a stale stamp is the
difference between a state being applied and being dropped on the floor.
the clock advances with the tick now.
the sector command log gains the summoner mana, how many snapshots have
gone out, and the type of the command that arrived, so the next run says
whether the push is happening at all rather than leaving it to inference.
at type 0 the client stopped working the elixir bar out for itself and
started reading it from the summoner in our snapshot - which never
moved, so the bar sat where the opening state left it and no card could
be afforded.
the rule from LogicSummoner::tick: an accumulator gains five thousand a
tick and one mana is granted for every MANA_REGEN_MS * 100 / MAX_MANA it
holds, the remainder carried rather than dropped. that works out at
2.8 seconds a mana with the shipped globals, and halves in the last
sixty seconds through MANA_REGEN_MS_END, which is the speed-up the game
has always had.
LogicGameMode::calculateChecksum runs the whole game mode through a
ChecksumEncoder - in a battle there is no client home, so it is the same
encode as the snapshot with the command manager left out. we compute the
same number now and log it beside the one the client sends in every turn
message, with whether they agree.
that comparison only means something because of what turned up while
reading the encoder: the two vints the decoder throws away are not
padding. the client writes getCheckSum() into both, once after the
server tick and once after the tutorial manager. we were writing zero,
which parses fine - the decoder discards them either way - but poisons
the running checksum, so the numbers could never have matched. they
carry the real running value now.
the two will not agree yet. the point is to see how far apart they are
and where, since the gap is what stands between this and battle type 0.
units stood still all battle. a unit only moved if it had a combat
target, and targets are only found inside SightRange - six thousand
units for a knight - while the enemy tower sits twenty thousand away. so
nothing ever walked, nothing was ever hit, no tower fell, crowns stayed
at nothing and the battle could only end on the clock.
a unit with no target now walks at the nearest enemy building, which is
the default target the client falls back to. once the tower comes inside
sight the ordinary search picks it up and the attack timer takes over.
the battle turn log carries the standing tower count per side so damage
is visible before it becomes a crown.
units now path. A* over the tilemap grid, water costing 800 against 1
for ground so the route runs to a bridge, or 20 when the unit flies and
crosses anywhere. bit 5 of a map cell is the water flag, which is what
makes 48 water and leaves the bridge cells - carrying the lane ids 1 and
2 - dry. the unit walks to the next node rather than at its target, and
still stops at Range.
also stops pushing snapshots while the battle type is 1. the client is
simulating the same battle itself there, and our state does not match it
yet, so every push yanked the units back to where the server thought
they were. the snapshots are still built and verified each second, ready
for the switch to type 0 once the two simulations agree; they are simply
not sent.
matchmaking now queues. the first player in waits, the second one to
arrive pairs with them, and both get the same battle: one shared session
keyed by both accounts, one snapshot, the client working out which side
it is from the account ids it already carries. if nobody turns up within
ten seconds the ticker polls the queue out and builds the bot battle
instead. cancelling or disconnecting leaves the queue.
also fixes the movement component tail: the extracted layout counts
"n + 18" vints including the path length itself, so seventeen follow the
path, not eighteen. the verifier read one too many and every snapshot
carrying a moving unit came apart after it - which is what the guard
caught and refused to send, rather than the client aborting on it.
every four to eleven seconds the bot draws a card from the deck we gave
it and puts it down on its own side, on the lane of whichever of its
towers stands furthest forward. the card goes through the same summon
factory the player's cards use, so it lands in the server simulation and
reaches the player inside the next snapshot rather than through any
special path.
placement comes from the bot's own tower positions rather than from the
tilemap, so the session does not need the map threaded into it.
a character with Speed gets a LogicMovementComponent on the client, and
until now we had no encoder for it, so any snapshot carrying a unit
would have desynced. the layout is four booleans, a path length, that
many path nodes, and eighteen more vints - one conditional, no data
driven ones. charge time defaults to the -1 the client uses when
ChargeRange is empty, which is every card but the Prince.
with that in place the session pushes a fresh SectorStateMessage once a
second down the battle ticker. every push runs through verify_snapshot
first and is dropped rather than sent if it does not read back, the same
guard that caught the decks.
the verifier learned the movement pass, and lost the leftover
SCROLL_BATTLE_SUMMONER switch that the builder had already shed.
crowns now move because the server finally hurts things. each tick it
retargets, moves, resolves attacks and buries the dead, in that order.
the numbers are the client's own. Speed is position units per tick, so a
knight at 60 covers 1.2 tiles a second; SightRange and Range are in the
same units with 1000 to a game tile, and the target's CollisionRadius is
added on the far side of both. distances compare squared with the
client's saturation rule - beyond 46340 on either axis, or on overflow,
the distance is INT_MAX rather than a wrapped negative. the attack timer
counts milliseconds fifty at a time and fires once LoadTime + HitSpeed
have passed, then rewinds to LoadTime. tower damage comes from
projectiles.csv, not from the Damage column of buildings.csv, which is
empty for them.
a princess tower leaving the board is struck from leader_towers, which is
what getStars reads, so crowns follow from the same list the client
keeps.
what is deliberately not modelled yet: pathfinding, so units walk
straight at their target instead of along the roads and over the bridges;
collision, pushback and avoidance; projectiles as travelling objects,
since the damage lands the moment the attack fires; and buffs. field_48
on the combat component is renamed hit_timer after what it holds.
the client never receives individual commands in a battle - SectorManager
has receiveSectorState, receiveCompressedSectorState and a heartbeat, and
nothing else. so everything the opponent does has to reach the player
inside server state, which means the server needs to be able to speak
first. it could not: the rpc only ever answered.
the gateway now runs a ticker for the length of a battle, calling a new
battle_tick on the service five times a second and writing whatever it
returns straight to the socket. the simulation stays in the service and
the socket stays in the gateway.
the first rider is emotes. the player's SendBattleEventMessage reaches
the service instead of being logged and dropped, and the bot answers with
a taunt of its own; it also sends one unprompted every twelve to thirty
seconds, drawn from the rows of taunts.csv that TauntMenu marks as
usable. the reply carries the opponent account so it renders on their
side of the arena.
commands carry tick_when_given and execute_tick, so a spawn no longer
lands the moment its turn message arrives. it is queued and released on
its own tick while advance_to steps the battle one tick at a time, which
is what makes the simulation reproducible from the command stream rather
than from when packets happened to arrive.
the per-tick object update goes in this loop next.
a LogicDoSpellCommand names a deck slot, so the card comes from the deck
we sent for that player, and the owner comes from matching the command's
executor account against the two the battle carries. the spell row's
SummonCharacter and SummonNumber say what and how many to place, at the
position the command carries.
the object factory behind the towers is now shared: hitpoints and the
combat/hitpoint/buff components are built the same way for a summoned
character as for a tower.
these objects are not encodable yet. a character with Speed gets a
LogicMovementComponent on the client, and we have no encoder for that
component, so the server copy must not be turned back into a snapshot
until it exists. nothing re-encodes it today - it feeds the clock, the
crowns and the end condition only.
LogicBattle::isEndConditionMatched, transcribed: the battle is over when
end_counter is positive, when either king is dead, when tick/20 seconds
reach MatchLength + OvertimeSeconds, or - once past MatchLength - when
the crowns differ. the divisor is the 0x66666667/2^35 multiply in the
client, which is a divide by twenty, so the battle runs at 20 ticks a
second.
crowns come from LogicSummoner::getStars: three when the enemy king is
down, otherwise two minus the enemy princess towers still standing,
which is exactly what leader_towers holds.
BattleRegistry keeps the LogicGameMode we built for each account and
advances it on the tick the client reports in its turn message, so the
server now tracks the clock, the crowns and whether the battle is over,
and drops the session when the player goes home. the two isSummoner
guards in the overtime branch are left out: they only fire for an object
that is not a summoner, which a king tower always is.