Commit graph

11 commits

Author SHA1 Message Date
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
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