audit findings #4/#7/#8. resolve_attacks only ever wrote hit_timer, and
with the wrong model; field_52 (load), field_60 (dash), field_64
(special index) were left at 0 forever. all four are hashed by
LogicCombatComponent::encode, so the checksum broke the instant anything
could shoot.
now, mirroring LogicCombatComponent::update and updateHitTimer:
- field_52 and field_60 decrement by 50 every tick, unconditionally, for
every combat component - the two lines at the top of update.
- hit_timer is seeded with LoadTime the first time, advances by the 50ms
step, and a shot lands on each HitSpeed boundary the accumulator
crosses (field18/HitSpeed rising past its previous quotient), instead
of "fire at load_time+hit_speed then reset". on a shot field_52 is set
back to LoadTime and field_64 cycles through SpecialAttackInterval.
still not bit-exact for combat: the state field (2 while attacking),
field_68 recovery, buff-scaled hit speed, and projectiles-as-objects are
their own ports. this closes the "timers never move" break; damage
lands under the new model (harness).
audit findings #8/#11. the encoded deploy field (LogicCharacter field47)
is the REMAINING deploy time: the client seeds it with DeployTime in
setState(5) and LogicCharacter::tick counts it down by 50 each tick,
returning early from the whole tick - no move, no retarget, no attack -
until it reaches 0, then setDefaultState flips the unit to moving(1) or
idle(0). our advance_deploy counted the opposite way (elapsed, 0 up to
DeployTime), so the encoded timer was DeployTime-minus-the-client's every
tick of every deploy, and the troop also moved and fought a full second
early.
now: a summoned troop spawns in state 5 with deploy_timer = DeployTime,
the timer decrements to 0, is_deploying() gates retarget/move/attack
while it is positive, and the state flips to moving/idle when it lands.
towers have DeployTime 0 and are unaffected.
harness: one tick in, the timer has dropped by 50 and the troop has not
moved; after the window it is at 0 and moving.
audit findings #1 and #2, the pair that makes the idle-board checksum
mean something again.
#1 the server hashed at its own free-running tick. sector_command calls
advance(client_tick) but advance_to is forward-only and the 50ms ticker
already ran the session past it, so it hashed at session.tick, not the
client's - and the tick is the first field in the checksum, so it could
never agree even on a bit-exact board. the session now records the
checksum of every tick it simulates and advance() returns the one for
the tick the client actually reported.
#2 the real idle divergence. the king is dormant at full health, and
LogicSummoner::updateCombatComponentState clears the combat bit of its
component mask every tick (13 -> 12) while field_256 <= KING_ACTIVATE
_TIME_MS; the mask is hashed for every object, so a server holding 13
disagreed on every tick and re-agreed only on the snapshot tick. the
sim now mirrors it: field_256 stays 0 while the king is unhurt and both
princess towers stand, ramps by 50/tick once it takes damage or loses a
tower, and the combat bit turns on only past KING_ACTIVATE_TIME_MS. the
king is also built with the bit already clear. princess towers are plain
characters and keep bit0 - verified against the client, which routes
them through the base updateCombatComponentState that sets it.
harness: dormant kings read mask 12, all four princess towers 13, and an
idle state hashes the same twice.
two separate breakages behind "no achievement, no level up", both
checked against the client in IDA.
level up: LogicClientAvatar::xpGainHelper adds a gain to exp_points -
which is progress WITHIN the level, not a running total - then rolls it
into levels: while exp_points >= ExpToNextLevel(level) it subtracts that
threshold and increments the level, calling levelUp for the deck slots
and diamond reward. our add_exp only grew exp_points, so the bar filled
past full and the level never moved. it now rolls over the same way,
against the exp_levels table.
achievement: the client only offers a claim when progress >= ActionCount
(LogicClientAvatar::isAchievementCompleted reads commodity 2), and the
claim command re-reads that same commodity - and build_avatar populated
none of it, so every claim came back "not completed". build_avatar now
serves real progress: findcard from the count of distinct cards owned,
reacharena from the arena reached. donate / jointeam / watchtv need
social features we do not have and stay at zero.
tests cover both and fail without the fixes: 25 exp reaches level 2 with
5 carried, and a completed findcard tier claims once, grants exp, and is
rejected the second time.
the hook showed a battle at tick 4177 - two hundred and eight seconds of
a hundred and eighty second match - sitting at three crowns to nil with
the player holding no towers at all. advance_to never asked whether the
battle was over, so the clock ran past the end, the bot kept playing
cards, and the snapshots kept coming. anything the player put down after
that walked into an army that had been piling up for a minute, which is
what "my units do not spawn" actually looked like.
is_end_condition_matched was right all along - it reads the leaders and
the clock and says so. nothing called it. now the tick loop breaks on
it, and the harness walks a match to its end and checks the tick stops
moving.
two things ruled out while looking. the account ids are right: the
client reads its own as 0-5, finds itself at index 0 and takes the
bottom avatar, and the 0-0 lookups in the trace are it resolving the
bot. and "visitor" is cosmetic - getHomeTeamIndex defaults to 1 when
both avatars share an arena, which ours do.
read the client's memory during a live battle and the board told the
story at a glance - a column of the bot's objects down one lane, x fixed
at 3500, y stepping by exactly two thousand:
23500, 21500, 19500, ... 3500, 1500, -500, -2500
two thousand is BOT_DEPLOY_AHEAD. the landmark was any building the bot
owned, so a hut it had just played became the reference for the next
card, that card became the reference for the one after, and they walked
up the lane and off the top of the map. every one of them had a movie
clip and sat exactly where its sprite said - they were never invisible,
they were marching into nowhere. the huts spawning from out there are
the 177688 addLogicGameObject calls the hook counted.
the landmark is now one of the two tower refs the battle already keeps
in leader_towers, and nothing else can become one.
the harness missed this because the bot had been given the player's
deck, which has no buildings in it. it now plays a cannon and a hut, and
asserts each deploy lands in front of a tower on the bot's own half -
the old code deploys on top of the king tower and fails.
the client crashed in LogicBattle::resetSimulatedManaTimers again, and
this time it was ours: remove_dead was dropping a king tower once it
fell, while battle.leaders still pointed at it. that function reads both
leaders straight out of the battle and calls a virtual on each without a
null check, so the next state to arrive killed the client.
the client's own rule is one line:
LogicCharacter::shouldDestruct() { if (this[209]) return 0; ... }
isLeader, never destructs. remove_dead now keeps them the same way, and
the harness watches every tick of a full match for a leader that has
gone missing - it caught this one at tick 693.
built a harness first, so the simulation could be run without the client
and the fault seen rather than argued about. it reproduces in twenty
milliseconds what took a battle to observe.
what it showed: a troop on its own walks at the enemy towers and stops
at its range, both sides, correctly. put a lane full of them together
and the ones behind get squeezed backwards past their own towers and
into the edge of the map, where they stand hitting nothing. that is the
y=250 and y=31750 frida read out of the client.
the collision pass was clamped only by the arena, so a troop in a crowd
took a shove every tick with nothing to bound it. it is now limited to
half a step, which is the property that matters: a crowd can slow a
troop but can never carry it backwards faster than it walks.
two smaller ones alongside. bot_play treated every object it owned as a
landmark, so once one of its own troops drifted it played the next card
on top of it and the one after further out again; only buildings count
now. and it deployed exactly on a tower's coordinates, leaving the
collision pass to dig the troop out of a building it was born inside -
it now stands in front. default_target falls back to any enemy when the
buildings on that side are gone.
the harness keeps all of it honest: drop the push bound and a troop is
out of the arena by tick 140.
hooked the client and read its own memory rather than guessing again.
what it settles:
the listener is installed, addGameObject runs, Character::Character
completes, and both sprites reach the render system at layers 9 and 11.
no Debugger::error, no warning. models are built. and the references we
send are right - asked the client's own tables and got (34,0) Knight,
(34,9) Barbarian, (35,0) KingTower, (35,1) PrincessTower.
then the object dump gave it away. the arena is 33000 tall and the king
towers sit at y=3000 and y=29000, but our troops are at y=250 and
y=31750 - past the enemy king tower, hard against the edge of the map.
they are not missing, they are in the corner. that is also why they were
"fighting air".
so the fault is ours after all, in target selection: a unit walks
through the tower it should stop at and keeps going to the boundary.
the princess towers do not draw either, red or blue, so what is on
screen is arena scenery and no object in the battle has ever had a
model. that makes the failure uniform rather than per character, and it
kills the last reason to hand objects new ids: renumbering was only ever
there to make them new again, it did not help, and it destroys and
rebuilds objects the hud holds pointers into.
what is verified and can stop being re-examined: LogicGameObject::decode
reads owner at +48, the component mask at +0x4c, the position at +52 and
z at +0x44, in that order, which is what we write. LogicCharacter's two
booleans are the destroyed flag at +208 and isLeader at +209, both
rightly false for a troop. LogicGameMode::decode reuses the existing
battle rather than rebuilding it, so the listener survives. and the
checksum agreeing byte for byte says every field we write lands where
the client expects it.
first, a correction: the towers do have models. the king tower shows its
cannon and the princess towers carry health bars, and health bars are
built inside Character::Character. so objects from the opening state get
their models after all, and the listener theory that sent me around the
houses was wrong. only troops are missing.
the difference is one field. LogicCharacter::getDeployT is
clamp(DeployTime - this[47], 0, DeployTime)
and field 47 is our deploy_timer, which we set to zero and never moved.
so every troop reads as still coming down, for the whole battle: it
keeps the spawn effect and the sound the user could hear, and never
gets a body. towers are unaffected - their deploy time is zero.
the counter now climbs a tick at a time, the way the client's own
simulation would, and a moving character starts in state 1 as
LogicCharacter::setDefaultState leaves it.
widening the push interval to ten seconds changed nothing, so the
tear-down theory is out too.
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 account ids check out end to end: the player's avatar carries the
profile account, the bot carries 0-0, LogicBattle::account_ids takes
both from the avatars, and the client's getAccountIndex therefore finds
the player at index 0 and makes them the bottom avatar. so
isOwnedByBottomPlayer is right, and it is right in a way that matters:
the player's units take the blue export names in Character::Character
and the bot's take the red ones. red draws, blue does not.
that leaves two candidates - an empty blue prefix on the row, or a data
reference pointing at the wrong row whose blue prefix happens to be
empty - and one run tells them apart. SCROLL_BOT_MIRRORS_DECK=1 hands
the bot the player's cards; if its knight draws and ours does not, the
rows are fine and the blue branch is the fault.
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.
type 0 turns out not to need a UDP transport. MessageManager::sendUdpMessage
checks for a socket and a valid connection, and falls straight back to
sendMessage when there is neither - we never send UdpConnectionInfoMessage,
so the client has no socket and the sector traffic arrives on the tcp
connection we already have.
so SectorCommandMessage, 12904, is decoded now: a client tick, a client
checksum and an optional command. a card played this way goes through the
same summon path as before. the client stops sending EndClientTurnMessage
in a battle - sendEndTurn asserts on isImmediateMessageExecution - so the
checksum comparison moves onto the sector command, which carries the same
two numbers.
with the type at 0 the client no longer simulates. it renders what the
snapshot says, which is why the snapshots start flowing again: the gate
on them was the battle type all along. the bot's cards reach the player
for the first time, because there is finally one simulation rather than
two arguing.
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.
BattleScreen::createBattleEndHUD takes a null result message and builds
the end screen from the local battle, then BattleScreen::sendGoHomeMessage
fires - so in this mode the client decides the battle is over and asks to
go home on its own. we now clear the stopped flag on that request instead
of leaving the session wedged until the next login.
battle turns are logged with their tick, checksum and command types while
the flag is up, so the traffic at the end of a battle is visible.
the card bar was empty because the hand lives on the king tower, not on
the battle. LogicSummoner::decode gates a whole block behind one boolean
- setEncodeDeckDataEnabled on the client side - and we always wrote it
false, so the client kept the hand and the draw queue it was born with,
which is nothing.
the block holds four deck indices for the hand, then two int lists: the
draw queue getNextSpell walks and the used pile reshuffleDeck folds back
into it. we now hand out the first four slots and queue the rest, leave
the used pile empty and keep last used at -1, which is what
getLastUsedSpell reads as "none".
starting mana comes from the START_MANA global instead of zero, and the
field after last-used is named after clearSpellCooldowns, which is the
only thing that writes it.
the decks were the visible half: LogicBattle carries one LogicSpellDeck
per player and we wrote both as absent, so the client cleared them and
the card bar came up empty. both sides get a real deck now - the player
from their profile, the bot from the fullest row of predefined_decks.
matchmaking no longer picks a row out of npcs.csv. it takes the location
from the arena's PvpLocation column and builds an opponent avatar with
its own name, arena and trophies, so the battle reads as a player match
rather than a trainer one. StartMissionMessage still goes through the
npc path unchanged.
the battle type stays 1 on purpose. LogicGameMode::isImmediateMessageExecution
is (type - 1) < 3, so 1, 2 and 3 let the client simulate locally while 0
makes it wait for the server to drive the sector - which needs the real
tick loop we do not have yet.
the client sends EndClientTurnMessage during the battle too, but its
tick and checksum belong to the battle, not to the home. we kept
comparing them against the home checksum and answered with
OutOfSyncMessage, which is the "Client and server are out of sync!"
dialog on tick 60.
HomeMode carries the stopped flag now and returns an empty turn result
while it is set, and sector_state_for raises it, so both the mission and
the matchmaking entry points are covered.
the base object writes owner index, component mask, position and z, in
that order. we had the first field unnamed and were writing the owner
into the z slot, so every object belonged to player 0, and the mask slot
carried a zero.
the mask matters: getHitpointComponent tests bit 2 of it before touching
components[2], so a zero mask made every tower report no hitpoints,
LogicCharacter::isAlive fell through to the z field, shouldDestruct went
true and the towers were destroyed on the first tick - which then hit
"cant find summoner tower" in LogicBattle::removeGameObjectReferences.
entries are built through LogicGameObjectEntry::new now so the mask is
derived from the component array and cannot drift from it.
also rename the first character flag after what sets it: kamikaze
death and morph both raise it right before the object is removed.
SectorManager::receiveSectorState reads one byte off the front of the
message body and branches on it: 1 goes to receiveCompressedSectorState,
anything else falls through to the plain decode. we never wrote that
byte, so the client ate the first byte of the snapshot as the flag and
then read every field one byte early - server tick 0, discard 11, and
the section sentinel landed on the LogicTime tick instead of 11.
drop the two bisect switches, they served their purpose: both settings
aborted identically, which is what ruled out the object payload.
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.
the client reads getTable(8)->getItemCount() entries there, table 8 is
damage_types with 5 rows. i was writing character_buffs, 12 rows, so every
snapshot carried 7 extra vints and 7 extra booleans and the stream slid.
that is what tripped the sentinel and aborted the client in
Debugger::error("Full update stream is corrupted!").
verified the rest field by field against the client decoders: LogicGameMode,
LogicBattle, LogicGameObjectManager, LogicGameObject, LogicCharacter,
LogicSummoner and LogicCombatComponent all match. the conditional character
fields stay off because ReloadAfterHits and ManaGenerateLimit are empty for both
tower rows.
537 only tells the client's ui to start searching, the handshake after it was
missing: server sends 24106 StopHomeLogic, client answers 14105, server sends
21903. that last step now builds the same snapshot the npc mission does, using
npcs row 0 as the opponent.
battle type stays 1 so it runs on the client's offline path. a real pvp battle
is type 0 and needs the udp sector channel, which does not exist here.
14107 CancelMatchmake now answers 24125 instead of being ignored.
npc missions used to get a ServerErrorMessage back. now StartMission builds a
LogicGameMode snapshot off the arena tilemap and answers 21903.
towers come from assets/locations/*.csv the way initDefaultSector does it: tile
coordinates times 500, leader index decided by which half of the map the tower
sits in. the two king towers must be there, the client dereferences them without
a null check. they live in buildings.csv, not characters.csv.
LogicCharacter puts the base object fields fourth, not first. the buff component
writes a fixed array sized by the character_buffs row count even with no buffs.
training_arena parses to 2 kings, 4 princess towers, 36x64 subtiles. snapshot is
602 bytes over 6 objects. the real client has not seen it yet.
RandomSpells / DifferentSpells / RareChance / EpicChance / MinGold / MaxGold all
come off the chest row now, gold is scaled by the player's arena. magic chest is
30 cards over 8 cards with 1 epic and 6 rares, like the client says it is.
arena chest rows inherit everything through BaseChest, so Free_Arena1 reads Free.
rounding is banded and the 5s band biases +3, so 22 goes to 25 not 20. numbers match the csv for arena 1 and 2.
free chest end timestamp needs migration 0002.