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.
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.