Commit graph

88 commits

Author SHA1 Message Date
WiseDev
a509529aef carry rpc over a binary frame, not json+base64
the inter-service payload is already raw bytes; postcard sends it as
length-prefixed binary instead of base64 stuffed into a json envelope.
drops the base64 module and the per-field serde shims.
2026-08-28 23:26:38 +03:00
WiseDev
f0f739c02c drop the commentary and run a workspace-wide format pass 2026-08-28 16:27:34 +03:00
WiseDev
a585b0c18d resolve the deploy birth tick and stop wedging the client's own commands
- a card lands one tick after its command fires, since the client
  hashes after LogicTime::increaseTick, not before
- answer RequestSectorState immediately except in the two ticks after
  a firing tick, where a full update would teleport the client past
  the command it just queued - the old blanket refusal left every
  card the player tapped silently deleted while it held
- carry both pending commands and queued deploys in the same delivery
  ledger so the quiet window covers what the player actually paid for
- add the regression tests for the birth tick and the deferred answer
2026-08-28 16:27:16 +03:00
WiseDev
07f0ee5427 converge combat timers, projectile references, pending damage, and the spawn ring
- replay the shooter's combat update a second time when it launches a
  projectile, matching the client's own component-pass re-entry
- carry the shooter's damage effect on every projectile and null a
  shot's target/source when either leaves the board
- derive pending physical damage from the shots actually in flight
  instead of an incremental total, so it can never go negative
- split the spawn ring's two angle registers so three- and five-unit
  cards land where the client puts them
- attribute a combat target left at NONE to the exact site that did it,
  gated to report each object once
- number and annotate every checksum field so a client-reported
  mismatch resolves straight to a name
2026-08-28 16:27:07 +03:00
WiseDev
c18ce37a42 gate the heartbeat off; it was resyncing the client every turn
the live log showed the client requesting a full sector state every
500ms - one per turn, in lockstep with the heartbeat. the heartbeat
verifies the client's predicted checksum against ours, and the sim is
not yet bit-exact, so most turns mismatch, flip the client out of sync,
and it re-downloads the whole state. that resync storm is the "86%,
70%..." loading flicker at battle start and it hard-resets the battle
scene every turn, so nothing on screen can settle.

the heartbeat now stays off unless SCROLL_HEARTBEAT=1. this returns the
client to the snapshot-only path (a hard reset every 4 ticks, but no
per-turn off-sync request). turn it back on once checksums reliably
agree.
2026-08-24 20:13:16 +03:00
WiseDev
447cc091bc spread multi-unit cards on the client's cos/sin ring
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).
2026-08-24 14:33:06 +03:00
WiseDev
ea9d9be4c4 cycle the summoner deck on play and draw, RNG-free
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.
2026-08-24 14:26:17 +03:00
WiseDev
7ead67d219 send the SectorHeartbeat the type-0 client is built around
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)).
2026-08-24 11:40:03 +03:00
WiseDev
2933d2ff7e maintain the combat timers the way the client does
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).
2026-08-24 09:54:07 +03:00
WiseDev
a9a2e048ee match the client's collision cap, move cap, and overtime mana
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.
2026-08-24 09:42:26 +03:00
WiseDev
7f78fd5104 count the deploy timer down and hold the troop still while it runs
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.
2026-08-24 09:39:13 +03:00
WiseDev
82f00ac1bc hash at the client's tick, and clear the dormant king's combat bit
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.
2026-08-24 09:32:44 +03:00
WiseDev
0451fcdf43 grant achievement progress and roll exp into levels
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.
2026-08-24 09:15:22 +03:00
WiseDev
dfdcc3072a stop a battle that has already been decided
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.
2026-08-23 18:10:25 +03:00
WiseDev
d13e8458ba deploy the bot in front of its own towers, nothing else
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.
2026-08-23 17:57:02 +03:00
WiseDev
14786cbc92 never take a leader off the board
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.
2026-08-23 17:37:02 +03:00
WiseDev
f5a8c39ab2 stop a crowd shoving troops out of the arena
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.
2026-08-23 17:29:40 +03:00
WiseDev
3d0b75b444 log the data reference, and what frida found
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.
2026-08-23 17:14:52 +03:00
WiseDev
97b7f8d968 drop the renumbering, it never earned its keep
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.
2026-08-23 16:52:47 +03:00
WiseDev
04fbabb7d2 count the deploy timer in milliseconds
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.
2026-08-23 16:43:30 +03:00
WiseDev
b8c12cd00b count a spawned character up to its deploy time
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.
2026-08-23 16:40:47 +03:00
WiseDev
f072c16244 make the snapshot interval settable to test the model theory
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.
2026-08-23 16:34:07 +03:00
WiseDev
73de62794a let the bot borrow the player's deck to split the model question
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.
2026-08-23 15:28:52 +03:00
WiseDev
81d6fe3922 take the checksum from before the closing checkpoint
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.
2026-08-23 15:04:51 +03:00
WiseDev
252277ec8d take one id per summoned character, not one per play
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.
2026-08-23 15:02:27 +03:00
WiseDev
1983b2f6ac log what actually goes into the snapshot when a unit spawns
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.
2026-08-23 15:00:11 +03:00
WiseDev
ffabe6e497 give everything but the summoners a new id once the screen is up
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.
2026-08-23 14:56:39 +03:00
WiseDev
0c4d0965a6 keep the two summoners in the opening state
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.
2026-08-23 14:53:41 +03:00
WiseDev
cb5b04f3ca keep the objects out of the state the client loads on
traced the whole path in the client rather than guessing at it again.

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

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

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

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

also spend mana when a card is played - it was only ever regenerating.
2026-08-23 14:51:29 +03:00
WiseDev
eb0e59f6f4 give every object a new id once the client is live
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.
2026-08-23 14:36:16 +03:00
WiseDev
8699a6884d answer the client when it asks for the sector state
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.
2026-08-23 14:32:20 +03:00
WiseDev
e9894d9838 keep units on the map so the pathfinder cannot panic
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.
2026-08-23 14:23:22 +03:00
WiseDev
9f3ac3f0b9 use the client's square root, wrong answers and all
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.
2026-08-23 14:21:57 +03:00
WiseDev
cc92831d06 never reuse a global id
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.
2026-08-23 14:17:50 +03:00
WiseDev
64a457545d name the collision body instead of a five-tuple
fixes the previous commit, which named the type but never defined it and
did not build.
2026-08-23 14:16:38 +03:00
WiseDev
8a4a81057b push units apart when they overlap
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.
2026-08-23 14:15:32 +03:00
WiseDev
a355b77514 hold the bot back until the intro is over
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.
2026-08-23 14:13:27 +03:00
WiseDev
bab24f2295 send the battle state five times a second
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.
2026-08-23 14:10:55 +03:00
WiseDev
4239680f53 stop the integer square root from spinning forever
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.
2026-08-23 14:07:03 +03:00
WiseDev
9f1f9a7cef read the sector command fields the way round they are sent
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.
2026-08-23 14:05:43 +03:00
WiseDev
9230a212ef keep the path instead of finding it again every tick
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.
2026-08-23 14:03:26 +03:00
WiseDev
f51d76f780 stamp the tick on the snapshot that carries it
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.
2026-08-23 13:58:48 +03:00
WiseDev
dbbd162bed regenerate mana on the server
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.
2026-08-23 13:53:41 +03:00
WiseDev
498f2cb62d switch the battle to type 0 and drive it from the server
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.
2026-08-23 13:51:01 +03:00
WiseDev
ade049b29d write the tick the client writes
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.
2026-08-23 13:47:34 +03:00
WiseDev
438e2c6491 compare the server checksum against the client's
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.
2026-08-23 13:44:51 +03:00
WiseDev
d1cc829623 send units at the nearest tower when nothing is in sight
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.
2026-08-23 13:28:23 +03:00
WiseDev
9084163f71 walk units around the river instead of through it
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.
2026-08-23 13:23:58 +03:00
WiseDev
ba235a9b4c matchmake two players before falling back to a bot
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.
2026-08-23 13:19:38 +03:00
WiseDev
3f0cf3d1d4 let the bot play cards
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.
2026-08-23 13:15:47 +03:00