audit finding #13, the last one. summon() gave every unit of a
SummonNumber>1 card the identical command point, so goblins/archers
stacked on one spot and their positions - which are hashed - diverged
from the client, which places them on a ring.
ported LogicMath::sin/cos: SIN_TABLE (91 entries, sin(deg)*1024)
extracted verbatim from the binary, with the same quadrant folding, plus
sin/cos scaled helpers. then LogicBattle::getSpawnOffset: the per-count
ring (radius = 1000*collisionRadius / sin(180/n,1000), angle = base +
360*index/divisor + 90), with the bottom player's y mirrored. summon()
adds the offset per unit.
tests: sin(0/90/180/270)=0/1024/0/-1024, cos(0)=full scale, a single
unit gets (0,0) and a pair splits across x; goblins no longer share a
point.
residual: the per-unit deploy stagger (charData[+200]*index/count %
DeployTime) is not applied - all units still deploy_timer=DeployTime;
that field's source column is unidentified, left for later.
this closes the 17-item audit's actionable list. remaining known gaps
are the deploy stagger above and the heartbeat follow-up (bot commands
in-band + drop periodic snapshots).
audit finding #3 (deck), after verifying the RNG stream is not involved:
addSpellsInRandomOrder shuffles the INITIAL order with a separate
Mersenne Twister, but the server is authoritative (ships the deck in the
snapshot; the client decodes it, never reshuffles), and the in-match
cycle is fully deterministic. so no RNG matching is needed.
play: the command's slot is the DECK index (LogicDoSpellCommand::execute
maps it to a hand slot via getSpellIndex, rejecting if it is not in
hand). play_from_hand finds the hand slot holding that index, moves the
card to the used pile and empties the slot - useSpellFromHand. spawn is
untouched; it already reads the card by the deck index.
draw: cycle_summoner_decks decrements spell_cooldown each tick and, when
it hits 0 with an empty hand slot, slides the front of the draw pile in
(refilling draw from used IN ORDER when it empties) and resets the timer
to NEXT_SPELL_COOLDOWN_MILLISECONDS (2000 / _BOOST / _OVERTIME).
field_240 (reshuffle) just counts down and stays 0 in normal play.
harness: playing hand slot 0 empties it and banks the card in used;
ticking past the cooldown refills the slot with a different card.
audit finding #16. steady state was a full SectorState (21903) every
four ticks - a hard client reset each time - and the per-turn channel
the client is actually written around was never driven.
added SectorHeartbeatMessage (21902): wire is vint serverTurn, vint
checksum, with optional command and server-tick-data blocks the client's
decode treats as absent at end of stream. the session now emits one per
turn (every 10 ticks); the client reads serverTick as 10*serverTurn and
verifies its predicted checksum for that tick against ours via
LogicGameMode::endTurnReceivedFromServer. a match keeps it in sync with
no reset; a mismatch flips it out of sync, which makes it request a
sector state - which request_sector_state already serves. now that the
idle divergence is fixed the early-game turns verify cleanly.
format confirmed against SectorHeartbeatMessage::decode/encode and
getMessageType (21902) in the client. the full snapshots still go out
too; routing the bot's plays as commands inside the heartbeat and then
dropping the periodic snapshot is the next step, but this stands up the
channel and the checksum verification.
harness: the session emits heartbeats at ticks 10 and 20, and the first
decodes to (turn 1, checksum_at(10)).
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).
three more confirmed divergences, all in the sim math.
collision: the client normalises the averaged shove to a fixed 150
(updateMovementTowards), not to a fraction of the unit's speed. we had
capped it at speed/2 - a value i introduced to stop crowds shoving
troops off the map; 150 does the same job and is what the client hashes.
movement: the client steps min(speed, 250) toward the waypoint each
tick. we used raw speed with no per-tick cap, so any unit faster than
250 or buffed drifted ahead of the client. the 250 cap is in now; the
buff multiplier is a no-op until a buff system exists.
mana: past the match length the client flips to overtime - getSecondsLeft
adds the overtime length and getRegenRate switches to MANA_REGEN_MS
_OVERTIME. we never set is_on_overtime and had no overtime branch, so
mana regen diverged for the whole of overtime. it now flips the flag and
selects the overtime rate.
harness still green, crowd still cannot shove a troop past the towers.
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 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.
the models came back with the counter moving, but every troop sat in its
deploy pose: DeployTime is 1000 and it shares a table with HitSpeed 1100
and 1500, so it is milliseconds, not ticks. moving the counter by one a
tick stretched a one second deploy across a thousand ticks - most of the
match. it now moves a tick's worth at a time, so deploying takes the
twenty ticks it should.
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.
LogicGameMode::encode reads its return value out of getCheckSum() and
only then writes it:
v15 = ChecksumEncoder::getCheckSum(a2);
(...vptr+88)(a2, v15); // the checkpoint vint
return v15;
we were reading ours after that write, so the closing checkpoint was
folded into the number we compared. the two could never match, whatever
the simulation did - which is why tick 41 disagreed with six untouched
towers on the field.
write() now hands back the value it wrote, the way encode() does.
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.
a collision could shove a unit past the edge of the arena. the next path
search then turned that negative coordinate into a tile index, cast it to
usize and read far off the end of the grid - the panic killed the tokio
task running the battle, snapshots stopped, and the models the client had
already been told about were left frozen and unresolvable. that is the
invisible-unit symptom: the simulation was dead, not the rendering.
the push is clamped to the arena now, and find_path checks that the start
tile is on the map rather than only the goal.
LogicMath::sqrt is a 256-entry table of floor(16*sqrt(i)) with a seed
picked by magnitude and one or two newton steps on top, and it is not
exact: above 2147441940 the seed overshoots and the single correction
cannot pull it back, so it answers 46341 where the true root is 46340,
and 65535 for INT_MAX.
that matters because the checksum is computed over whatever it returns.
an honest square root would be a permanent, invisible disagreement, so
this one is transcribed branch for branch - including the early -1 for
negatives and the INT_MAX special case - and our own converging root is
gone. checked against the exact root across the low range and the
boundaries, where the two agree, and at the top, where they must not.
distances now go through the saturating helper before the root, the way
the client does it, rather than being squared in i64 on the way in.
collisions, from checkCollisions and checkCollision in the client.
a pair is considered when both are on the same plane - air with air,
ground with ground, decided by z. the radius is the unit's own
CollisionRadius, capped at 500 when the other side has no movement
component, which is what lets a unit squeeze past a building instead of
being shoved by it, plus the other's radius. the axis test comes before
the squared one, tangency counts as a hit, and two units standing exactly
on top of each other are separated along y by the owner's facing rather
than dividing by zero.
the push is clamp(sum - distance, 0, 300) scaled by the other's mass over
mine, plus one, capped at 300, spread along dx and dy over the distance.
Mass is clamped to one through twenty and a building counts as twenty.
the accumulator is drained the same tick it is filled, as it is in the
client.
what is still short of the client: the push is averaged over the pairs
rather than run through updateMovementTowards, avoidance steering is not
modelled, and LogicMath::sqrt is our exact root rather than the client's
table - which differs from the true root above 2147441940 and will have
to be reproduced bug for bug before checksums can agree.
newton's method in integers can settle into a two-value cycle rather
than a fixed point, and the loop guard was "the value changed", which
such a cycle satisfies for ever. it ran under the session lock inside a
spawned task, so the worker never reached a yield point and the runtime
could not shut down - which is why ctrl-c printed "shutdown requested"
and then hung.
the guard is now "stopped decreasing", which is the converging form.
checked against the exact integer square root across the small range and
the boundaries, including the 46340 saturation edge and INT_MAX.
the checksum goes out first and the tick second: setClientChecksum writes
[+0x50] and setClientTick writes [+0x54], and encode writes [+0x50]
before [+0x54]. we had them the other way about, which is why the log
showed a tick full of noise and a checksum climbing by forty-one a
message - the tick was being read as the checksum.
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.
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.
the leading vint of the game mode is not a field of its own. the client
loads it straight out of LogicTime at gameMode+0x60 and then encodes
LogicTime immediately after, so the same number goes out twice and the
two cannot drift. we kept a separate server_tick that the session never
advanced, so it stayed at zero while the clock ran - a divergence in the
checksum that had nothing to do with the simulation. the field is gone
and the tick comes from LogicTime.
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.
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.
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.
command type 1 is LogicDoSpellCommand - the card the player put on the
field. it carries the usual command header, the deck slot, an optional
LogicSpell and the drop position. type 2 is
LogicCompleteTutorialBattleCommand, which reads its global id before the
header the way the achievement command does.
both execute to Ignored for now: the home logic is stopped during the
battle, so nothing on the server acts on them yet. decoding them stops
EndClientTurnMessage from being thrown away whole, which is what the
"unknown command type 1" warnings were.
LogicSummoner::tick guards the whole hand-refill block on the first vint
of the deck block being at least 1 - it is the number of hand slots the
client scans for an empty one before pulling from the draw queue. we
sent zero, so the hand we dealt was the only one the player ever got.
named after what it is and set to four.
12951 is SendBattleEventMessage, the in-battle emotes and quick chat. it
carries a LogicBattleEvent: a type byte, the sender account and three
int lists - ticks, coordinate pairs and params. it decodes now instead
of being dropped with a warning. the client renders its own emote
locally, so nothing is echoed back yet; the relay belongs with a real
opponent.
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.
verify_snapshot still carried the stub from when both decks were always
absent, so the first battle that actually carried one was refused before
it reached the client. it reads them now: eight presence bits, then a
data reference, five vints and two booleans per filled slot.
while in there, the six battle booleans were all being discarded, which
hid the fact that the two score-change vints are only present when the
first of them is set. the verifier tracks it now, same as the encoder.
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 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.
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.
field order taken straight off LogicBattle::encode. the object manager is
columnar, not object major: 6 counters, a count, then all data refs, then all
global ids, then all objects, then four component passes. global ids must be
ascending, the client binary searches them.
LogicRandom moved out of the shop into its own module. its abs was
checked_abs().unwrap_or(0), the binary uses NEGS so i32::MIN stays i32::MIN.
one seed in four billion, but it would have desynced the card shop.
per object subclass encodes and the component payloads are still missing, so no
snapshot can be sent yet.
14303 AskForJoinableAlliancesList and 14107 CancelMatchmake are both genuinely
empty payloads. 516 UpdateLastShownLevelUp is header only, 537 StartMatchmake
carries a vint and a bool. none of them move the home checksum.
that clears the unknown type warnings out of the log.
a panic in a session task now kills that connection, tokio catches it and the
accept loop carries on. with abort it took the whole server down.
the data table RwLock is std so it poisons on panic, and every lookup went
through expect(). one panic would have bricked every later table read, which is
worse than the abort was. it reads the inner value now, the table is read only
once installed.
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.
500 LogicSwapSpellsCommand does the real work, 520 and 521 are no-ops in this
build so they just decode. 535 puts its data reference BEFORE the base header,
unlike every other command.
collection only shrinks when a card moves into an empty deck slot, that is the
one case where the home checksum moves.
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.
if the client presents an id we don't know, create it under that id and bump the sequence past it.
also chest offers match through BaseChest, so one silver row covers every arena.