Skip to content

AI trainer support: refactor, infrastructure, and one desync fix - #129

Merged
kylelutze merged 429 commits into
masterfrom
feat/ai-trainer-support
Sep 6, 2026
Merged

AI trainer support: refactor, infrastructure, and one desync fix#129
kylelutze merged 429 commits into
masterfrom
feat/ai-trainer-support

Conversation

@kylelutze

@kylelutze kylelutze commented May 7, 2026

Copy link
Copy Markdown
Contributor

Summary

This branch is mostly a long-running refactor + infrastructure pass on the C++ codebase to make it portable, testable, and ready for the Rust port and AI-trainer work. The great majority are behavior-preserving cleanups validated against the deterministic replay-diff harness.

Broadly:

  • Code organization — split the largest TUs into per-concern files; relocated sources under src/{ai,team,unit,building,map,gui,net,render,yog}/; #pragma once everywhere; lowercased directory names.
  • Modernization — boost → std (shared_ptr, lexical_cast, tuple, threads); C++20 build; SPDX headers replacing GPL boilerplate.
  • Dead-code removallogFile members + the fprintfs feeding them (game/team/unit/building/AICastor); unused MapLog; orphaned text-loader infra; verbose pathfinding printfs.
  • Static per-type tables — Units, Buildings, Resources, UnitsSkins moved off file loaders to compile-time tables.
  • Pathfinding rewrite — gradient computation swapped from BFS to a chamfer distance transform (see breaking-change section scons BoolOption() deprecated #2 below). Fixed a longstanding Uint8 underflow in war/guard/clearing flag seeding along the way.
  • Test infrastructure — deterministic replay-diff regression harness (tests/baselines/cpp-refactor.replay); new four-game gradient corpus (tests/baselines/gradient/gd-*.{game,replay}) covering map-size/team-count/AI-mix variation; MapQuery characterization suite; ChecksumSidecar per-tick checksum dump for cross-replay debugging.
  • Headless / scripting tooling for the AI trainerDatasetWriter for (state, action) records; --save-game-as, --map, --matchup, --ai-types; GLOB2_GAME_END summary line; GLOB2_REPLAY_PATH env var; absolute-path handling in FileManager / ReplayWriter.
  • Bug fixes incidentally found — typo in tryToBuildingSiteRoom (setBuilding(decLeft, decLeft)), unguarded get_height < dist in farming AI, AIWarrush wrap typo, Game::save mutating live mapHeader, MapGenerator dead-code + 4 bugs, latent farming AI fixes. All validated against the replay baseline.

⚠️ Breaking changes called out explicitly

Two commits on this branch deliberately change simulation behavior. Both are pre-existing-bug fixes that canonicalize previously-incorrect or implementation-defined behavior; everything else is behavior-preserving.

1. team: stable gid tiebreak in prioritize_building (419eb4b)

std::sort is unstable. When two buildings tied on every comparator field (priority, type/level bucket, unit ratio, ressource ratio), their relative order in Team::buildingsNeedingUnits was implementation-defined. Different binaries — different libstdc++/libc++ versions, optimization levels, ABIs (32-bit vs 64-bit size_t) — could put tied buildings in different positions, then call subscribeToBringRessourcesStep() in different orders, which consumes syncRand() in different orders, which desyncs lockstep multiplayer. This is a longstanding source of the multiplayer desyncs that have plagued the game for years.

The fix adds gid as a final stable tiebreaker (gid is unique within a team and identical across all clients).

Consequences:

  • Old replays from the previous binary will not deterministically replay against this build past whatever tick the first tie occurs. This is unavoidable for any fix that removes implementation-defined ordering.

2. map/gradient: chamfer DT replaces BFS, fixes Uint8 underflow in flag seeding (1e228ce487027a)

The pathfinding gradient algorithm was swapped from a BFS work-queue approach (updateGlobalGradientVersionSimple/Simon) to a chamfer distance transform (forward+backward raster sweeps until stable).

The bug: war/guard/clearing flag seeding pushed every cell within unitStayRange into listedAddr at value 255, but the obstacle scan that ran next could overwrite some of those cells back to 0 without removing them from listedAddr. BFS later popped a stale entry, computed gradient[addr] - 1 = 255 (Uint8 underflow), and propagated phantom 255s outward from obstacle cells. Visible in-game as warriors/guards/clearers loitering one cell outside the real flag zone near obstacles. The fix compacts listedAddr after the obstacle scan so only true sources reach the propagation pass — fully deterministic, removes a Uint8 underflow that produced different gradient values depending on which cells the obstacle scan happened to overwrite.

Consequences:

  • Old replays will not deterministically replay past the first tick where a war/guard/clearing flag's gradient is consulted near an obstacle.

Test plan

  • scons release=1 -j16 clean build on macOS arm64 (Darwin 25.4)
  • ./build/src/glob2 --nox games/G2.game 0 1 runs to completion (126,950 ticks / 84 min sim time, 4 AI players: Warrush + 2× Castor + Nicowar)
  • Replay-diff harness: 4 sequential headless runs of G2.game produce byte-identical last_game.replay (= tests/baselines/cpp-refactor.replay)
  • Gradient corpus: all four games (gd-small-2ai, gd-large-4ai, gd-archipelago, gd-bigarena-long) byte-equal against tests/baselines/gradient/*.replay after the chamfer cleanup commit
  • MapQuery characterization tests pass
  • Reviewer: spot-check the prioritize_building change at src/team/team_step.cpp and confirm the gid tiebreaker reasoning
  • Reviewer: spot-check the chamfer body in src/map/gradient/MapGradientGlobal.cpp and the listedAddr underflow fix in 1e228ce0
  • Reviewer: 4-player live multiplayer match — desync should no longer reproduce on the scenarios where it previously did

@stephanemagnenat

Copy link
Copy Markdown
Contributor

One question: why files that are split (e.g. GraphicContext) broke the naming convention?

@kylelutze

Copy link
Copy Markdown
Contributor Author

One question: why files that are split (e.g. GraphicContext) broke the naming convention?

that has been rectified

@stephanemagnenat

Copy link
Copy Markdown
Contributor

@kylelutze please ping when you want a review, and point to the highest value review targets to address first.

@stephanemagnenat

Copy link
Copy Markdown
Contributor

There is a commit with WIP in its title.

@stephanemagnenat

stephanemagnenat commented May 8, 2026

Copy link
Copy Markdown
Contributor

Should we use git lfs for the big binary .game and .replay files?

@stephanemagnenat

Copy link
Copy Markdown
Contributor

Why the building/unit default stats files were removed? Is there a replacement?

@kylelutze

Copy link
Copy Markdown
Contributor Author

@kylelutze please ping when you want a review, and point to the highest value review targets to address first.

@stephanemagnenat it'll be a while before it's ready, that's why I set it for draft mode. don't worry about reviewing it right now. I'm so used to reviewing PRs that it's the easiest way for me to review a large batch of changes to see how it's looking. My plan was to send an email to the mailing list when it is ready but you're fast on noticing this.

I'll have to check out git lfs. I haven't used it before but that is an excellent idea for those files.

The entity stats are now directly in code, such as in race.cpp with the consts in UnitConsts.h. I'm not sure I like that design quite yet, but we've never changed the stats between versions and it allowed the removal of quite a bit of loader code and simplified things.

This work is for two experiments for me.

  1. can I get an AI to port this whole code base to rust.
  2. can I train an AI through reinforcement learning.

For 1, I found that initial code base cleanup is required for a port to be successful so I've been having an AI go do all of this work in a way that will improve port quality. My first port attempt did not go well. We don't ever need to merge it if it isn't wanted though.

@stephanemagnenat

stephanemagnenat commented May 8, 2026

Copy link
Copy Markdown
Contributor

I'll be happy to see code quality improve, and even more happy to see a Rust port. So in principle I do not have problem merging that, but I'll like to review it a little bit. 70k lines feels a lot though, but there is quite some mechanical work. I might be able to use an AI to extract a small subset of non-trivial changes to review.

An alternative would be to directly work on the Rust port. I am anyway doing a lot of Rust and no C++ nowadays.

Ah, and once we have Globulation 2 in Rust, my new scripting language Ferlium might prove handy: https://ferlium.dev

@kylelutze

Copy link
Copy Markdown
Contributor Author

why that instead of Lua?

I did start with directly doing a Rust port. I even made a lot of progress, but there were a lot of issues trying to have an AI do a Rust port with the cpp code base as it is that it couldn't get past. Claude just couldn't navigate the logic with how some of it was linked together. It kept getting lost on some sneaky things in the code base. So far the biggest helps:

  • code files must be under 500 lines. That alone makes a tremendous difference
  • putting labels on fixed values helps massively in it figuring out the purpose of logic blocks
  • When doing cpp -> cpp work it does a good job of recognizing what is old cpp practices vs new cpp practices. When porting it goes "I don't understand why this is the way it is, fingers crossed I port it correctly", so modernizing it makes a decent difference too.

@stephanemagnenat

stephanemagnenat commented May 8, 2026

Copy link
Copy Markdown
Contributor

Because Ferlium is statistically typed with type inference, and can in theory compile to native code through a Just In Time compiler (we are working towards WASM compilation right now). Think of it as a Rust script, with a bit of the Haskell type system. Also, Ferlium is designed to interface seamlessly with Rust (i.e. Rust functions can be called directly).

Lua is interpreted with bytecode, has a syntax far away from Rust, and needs some adaptation to interface with Rust native types.

@stephanemagnenat

Copy link
Copy Markdown
Contributor

Very interesting, thank you for the port attempt report!

@stephanemagnenat

Copy link
Copy Markdown
Contributor

Ah, btw, once it is in Rust, should we have a Web-based Globulation?

@kylelutze

Copy link
Copy Markdown
Contributor Author

I was going to work on mobile first

@stephanemagnenat

Copy link
Copy Markdown
Contributor

Going through the web might address both desktop and mobile. That is what we do with Candli and, while not perfect, works quite well.

kylelutze added 16 commits May 24, 2026 08:57
The inline parser walked message[i] looking for a space without bounding i
by message.size(), so typing "/a" or "/help" (no space) ran the loop past
the string end. Replace with a small parseSlashCommand helper that pivots
on a single find(' '), handles the no-body case explicitly, and returns
nullopt for non-slash or empty input. Mask resolution at the call site is
unchanged.
Team::syncStep was conditionally nulling game->selectedUnit and
game->selectedBuilding directly. Those fields are per-client GameGUI
state. The NULL write is identical across clients, so it does not
desync today, but the predicate (game->selectedUnit == u) is a per-
client read inside the deterministic sim path — any future extension
of the branch with sim-touching code would silently diverge.

Route the clear through GameGUI::onUnitDestroyed / onBuildingDestroyed
so the sim never reads GUI state. cpp-refactor.replay is byte-equal.
…on table

directionFromMinigrad scored eight compass directions by copy-paste — each
direction inlined its centre cell and three or five neighbour cells as
literal col+row*5 indices, with the diagonal-vs-cardinal asymmetry visible
only by counting macro calls. Replace the 82-line body with a constexpr
minigradDirections[8] table and a scoreMinigradDirection helper. Preserve
the original diagonals-first / cardinals-second iteration order because the
scoring loop uses `<=` for ties (later wins) — reordering would shift
tie-breaks and diverge replays.

Fold the related index magic in the two directionByMinigrad overloads into
the same pass: 2+2*5, +12, rx+ry*5, and the loop bound 5 become
MINIGRAD_CENTER_INDEX, MINIGRAD_CENTER_COORD, MINIGRAD_W, and a
minigradIndex(rx, ry) helper.

Verified byte-equal against cpp-refactor.replay and all four gradient
corpus baselines (gd-small-2ai, gd-large-4ai, gd-archipelago,
gd-bigarena-long).
…rior flag candidates

The candidate-selection loop computed timeLeft once, squared it
unconditionally, and reused the squared value across two comparisons
that use different distance metrics:

  - Explorer flag: warpDistSquare returns squared Euclidean distance,
    so squaring timeLeft is correct.
  - Worker / warrior flag: buildingAvailable returns a linear gradient
    distance (max ~254). Squared timeLeft (~10k-160k) inflated the "too
    far" threshold by ~400x, so the rejection effectively never fired.

Workers and warriors were being dispatched to flags they could not
reach before starving: they'd start walking, hit trigHungry, divert to
find food, then get re-picked by the same flag on the next 33-tick
cycle. subscribeToBringRessourcesStep used the correct unsquared
comparison all along, so building-driven tasks were unaffected — the
bug was specific to flag-driven tasks on maps large enough to span
beyond timeLeft.

Split the candidate filter into three per-zonable helpers
(considerUnitForExplorerFlag, ...WorkerFlag, ...WarriorFlag), each
owning its own distance metric. Explorer keeps the squared comparison
via a named timeLeftSquared local; worker and warrior compare linear
timeLeft against the linear gradient distance.

Refactor and fix verified in two stages: extracting the helpers while
preserving the squaring produced a byte-identical cpp-refactor.replay;
removing the squaring on worker/warrior diverged it (game length
71948 -> 109710 ticks) and required rebaselining. gd-small-2ai is
unchanged because SmallForTwo geography never positions a flag far
enough from a healthy unit to exercise the corrected rejection.
Factor the 5 same-shape WALK/SWIM/BUILD/HARVEST/ATTACK_SPEED rows of
the right-side unit-info panel into a small file-local helper. The
helper takes the already-computed display level so the SWIM asymmetry
(stored 1-based with 0 = "can't swim", per Step.cpp:116) stays explicit
at the call site rather than being buried in a longer FormatableString.
Behaviour-preserving.
Clicks in the right panel below the panel-button row, while a single
unit was selected, would toggle Unit::verbose on the local sim object
and dump 10+ printf lines to stdout per click. Pure dev debug with no
UI affordance and no network broadcast — currently harmless only
because verbose is not in any save/replay/checksum surface, but a
latent desync footgun if that ever changed. Drop the whole block; do
not port. Replay output unchanged (cpp-refactor.replay byte-equal).
The class accumulated per-tick CPU samples whose only consumer (format())
had its body commented out since 2007 — output was a logs/<user>CPU.log
file that never actually got written. Following the "logging is dead, do
not restore" rule in glob2/CLAUDE.md, the whole class goes rather than
patching a latent div-by-zero in code that no one would ever uncomment.

Also drops the now-unused readyNow parameter from frameTimingAndDraw,
which only existed to gate the cpuStats accumulator.

Replay byte-equal against tests/baselines/cpp-refactor.replay.
…eption-path leak

The previous code allocated the BinaryInputStream with raw new and freed
it with an explicit delete after the load path. If MapHeader::load,
FileManager::mtime, or Text::setText threw, control jumped to the catch
block and skipped the delete, leaking the stream and the StreamBackend
file handle it owns. Browsing a directory of malformed maps would slowly
exhaust file descriptors.
… streams

A malformed .game/.replay/save file or hostile network packet could set
BasePlayer::teamNumber to a value outside [0, Team::MAX_COUNT). Game::setGameHeader
then did teams[teamNumber]->numberOfPlayer+=1 against a 12-slot pointer array
where slots beyond mapHeader.getNumberOfTeams() are NULL — NULL deref crash; out-of-range
indices read OOB on the fixed-size array. Same hazard on the Player::setBasePlayer path.

Validate at the input layer in BasePlayer::load so every consumer (GameHeader::load,
Player::load, ...) inherits the check. GameHeader::load now propagates the per-player
load failure and rejects the whole header. Game::setGameHeader gets a defensive assert
that teams[tn] is non-null and tn < numberOfTeams — catches the residual case of a stale
header paired with a smaller-team map.

Replaced four //TODO: Explain comments on number/numberMask/teamNumber/teamNumberMask
with brief range docs noting the valid bounds, so the Rust port doesn't have to rediscover them.

Verified: cpp-refactor.replay byte-identical after the change — validation only rejects
malformed inputs; well-formed paths are unchanged.
…exed table

The if/else chain mapped UnitCantAccessFruit to "too far from resource" and
UnitTooFarFromResource to "can't access fruit" — each pair displayed the other's
label in the building inspector. Replace the chain with a static kReasonKey[]
table indexed by Building::UnitCantWorkReason, with a static_assert pinning it
to the enum size so future reasons can't silently shift indices off the end.

The "building" → "flag" rewording for virtual buildings keeps a small two-row
override in the same helper; the renderer collapses to one drawString site.
…ix cross-game timer leak

The two music timers (war / building event) were function-local statics
in GameGUI::musicStep, so they kept their values across games in the
same process: finishing one game mid-decay and starting another from
the menu would fire a spurious in-game track switch a few ticks in.

Move the state machine into a dedicated GameMusicController owned by
value on GameGUI, reset from init() at every game load. While here,
add a MusicTrack enum used by SoundMixer and migrate every call site
(Engine, GlobalContainer, the controller) off bare track-ID ints, and
name the 220-tick timeout constant. The controller is a pure
events-in / optional-track-out function with no SDL / Team / globalContainer
dependency, so GameMusicControllerTest links it standalone in TestsRunner
(7 new cases covering the original musicStep ordering quirks).
… and fix unreachable flag-range +1 arrow

The flag-range slider's right-arrow click never produced a +1 increment.
The middle-zone cutoff in the click handler was lmx<RIGHT_MENU_WIDTH-18
(=142) while the outer guard capped lmx<128, so the proportional-set
formula always fired in the right-arrow zone and the increment else branch
was unreachable. Clicking the "+" arrow snapped the range to the value
the proportional formula yielded at the bar's right edge — for typical
maxUnitStayRange values that's max, but at the rightmost pixels it
overshoots to max+1, which then drops back to max on the next click as
the pointer landed a pixel left. Two sibling sliders (worker count and
swarm ratio) used the correct 128-18 constant, masking the drift as
widget convention rather than a defect localized to one site.

Extract a pure helper, interpretScrollBoxClick(lmx, current, max), that
returns the requested new value (std::optional<int>, nullopt when the
click would not change anything because the arrow is at a clamp). Route
all three slider sites through it; each call site keeps its own
pendingFor / order-construction / side-effect lines, since those genuinely
differ (worker also updates defaultAssign, swarm mutates the
ratioLocal[] array directly, flag-range writes pendingUnitStayRange).
SCROLLBOX_BAR_WIDTH and SCROLLBOX_ARROW_WIDTH replace the hand-typed
128/18/36/92 magic numbers throughout. Drop the stray (unsigned) cast
on maxUnitStayRange and the stale TODO that lived inside the (formerly
unreachable) flag-range +1 branch.

While here, replace the bare 20 in flushScrollWheelOrders with
MAX_UNIT_WORKING — same widget, same clamp.

Verified: cpp-refactor.replay byte-identical after the change — the
fix only affects interactive click handling, not the AI-only --nox
test path used to produce the baseline.
Replace raw literals with named constexpr constants across the team slice
and the cross-slice swim-variant arrays.

Cross-slice: every per-building gradient/lock/resource array dimensioned
[2] is indexed by canSwim (0 = no-swim path, 1 = swim path); pre-computed
so units of either swim-class can read a ready gradient. Naming the
dimension SWIM_VARIANT_COUNT and the swim slot SWIM_VARIANT_CAN_SWIM
removes the ambiguity at the call sites (Building.h decls, Lifecycle
init loops, TypeSteps clearing-flag loop, TeamStep gradient-free loops,
swarm corn-check at TeamStep.cpp:236).

Team slice: COLOR_CHANNEL_MAX for HSV->RGB scaling, GRADIENT_DIRTY_SIZE_OFFSET
derived from GRADIENT_DIRTY_PADDING (=2*pad-1 = 31), UPGRADE_SCORE_NONE
sentinel for "no candidate" upgrade scoring, Q8_FIXED_POINT_SHIFT at the
upgrade-distance shift, and FILE_FORMAT_VERSION_RACE_FIELD (=73) for the
race-field load gate.

Behavior-preserving: all replacements are pure constant substitutions
with identical numeric values; no logic, no checksums, no save format
changes.
…ering offset

The arrow position queued for a hilighted right-panel building/flag passed
decX (the horizontal sprite-centering offset) as the Y nudge, producing a
vertical misalignment that grew with the sprite's width. Use decY, matching
every other HilightArrowPosition call site.
The choice panel's sprite grid was drawn at YPOS_BASE_BUILDING (190)
while the mouse hit grid in pickChoiceUnderMouse and the click handlers
in GameGUIInputMenuClick used the caller's panelTopY (185 for both
YPOS_BASE_CONSTRUCTION and YPOS_BASE_FLAG). The 5px gap meant the
bottom 5px of every choice icon hovered/selected the row below.

drawChoiceSprites and drawChoiceHighlight now take panelTopY and use
it as the sprite Y origin, so layout and hit-test share one source of
truth per call.

The duplicated row/column hit-test math in handleMenuClick's
construction-view and flag-view branches is replaced with calls to
pickChoiceUnderMouse, so the click path and the hover-preview path
cannot drift apart again.

Visual change: the choice grid moves up 5px in both panels.
Behavior change in clicks: cell coordinates come from the mouseX/mouseY
members (the same values the hover preview uses) instead of raw SDL
event coords. SDL fires motion before button-down so these match in
normal use; the upside is the click now always selects whatever the
hover preview was showing.

No simulation impact; cpp-refactor.replay is byte-equal.
The BUILDING_SELECTION branch read selBuild->gid before its assert(selBuild)
null check, so a NULL selection cache (selection.building can stale past a
building's destruction — onBuildingDestroyed only clears game.selectedBuilding,
and checkSelection runs at draw time, not before the next keypress) crashed
on the deref. Asserts also aren't stripped in glob2 release builds but would
abort() rather than no-op the keypress; replace both asserts with an early
return so a stale selection cycle is a silent no-op.
Giszmo pushed a commit that referenced this pull request Sep 5, 2026
placeBuildingAt computed the finished-building default-assign row as
getDefaultAssignedUnits(typeNum+1). The +1 relies on the buildingsTypes
table being laid out as consecutive [site, finished] pairs, which holds
by accident for normal buildings but breaks for flags: flags have no site
variant, so the placeable typeNum is already the finished entry and +1
walks into the next building's row (e.g. an exploration flag reads the
war flag's default assign). Look the finished type up by name instead,
matching the other call sites in this file. No-op for normal buildings;
corrects the flag case.

Ported from PR #129 (feat/ai-trainer-support), original commit
5993b37 by kylelutze, adapted to
master's BuildingsTypes API (getTypeNum(name, level, isBuildingSite)
instead of the branch's getFinishedTypeNum(name) helper, which doesn't
exist yet on master).
@Giszmo

Giszmo commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Guys, I'm a bit impatient. @kylelutze you started getting stuff merged to master in smaller pieces and I just created a ton of PRs in the same vain, too but it feels so pointless. Can't we just merge this and #132 and go on from there? What could possibly go wrong? I know on my end master is broken as is.

@kylelutze
kylelutze marked this pull request as ready for review September 6, 2026 00:45
…port

# Conflicts:
#	src/GameGUI.cpp
#	src/gui/GameGUIToolManager.cpp
@kylelutze
kylelutze merged commit 2dcfc78 into master Sep 6, 2026
3 checks passed
@kylelutze
kylelutze deleted the feat/ai-trainer-support branch September 6, 2026 01:07
genixpro pushed a commit that referenced this pull request Sep 6, 2026
…om (#149)

When a building enters WAITING_FOR_CONSTRUCTION_ROOM for an upgrade,
addForbiddenZoneToUpgradeArea stamps the owning team's forbidden bit over
the would-be upgraded footprint. The two normal exits from that state
(tryToBuildingSiteRoom finding room, cancelConstruction) remove the zone,
but Building::kill() -- reached when the building is shot or beaten to
0 hp mid-wait -- did not, leaving the forbidden bits set forever and
corrupting the owning team's pathfinding over that area.

kill() now calls removeForbiddenZoneFromUpgradeArea() for that exact
state before clearing the building's tiles, so the subsequent gradient
rebuilds in kill() see the corrected zone. The zone is only ever stamped
for UPGRADE (never REPAIR), so the narrow conditional mirrors the add
site exactly.

Ported from PR #129 (feat/ai-trainer-support), original commit 7a61c29, adapted to master's file layout.

Co-authored-by: Junior <junior-agent@noreply>
genixpro pushed a commit that referenced this pull request Sep 6, 2026
…ingSiteRoom (#150)

The clear-old-footprint call in `tryToBuildingSiteRoom` passed
`type->decLeft, type->decLeft` for the rectangle's width and height.
`decLeft` is a negative centering offset (-1 or -2 in current data),
so `setBuilding`'s loops `for (i=y; i<y+(-1); i++)` ran zero
iterations — the call was a silent no-op.

The bug never manifested because the immediately following call
stamps the new (larger or same-sized) footprint with the same gid,
and every level of every current building is calibrated so the new
footprint is a centered superset of the old. Repair targets the
same-size building site (no shrink), upgrades grow outward from a
shared midpoint. Either way the would-be-stale tiles get re-stamped
correctly by line 323.

Match the pattern used in `cancelConstruction` (Construction.cpp:227)
and `kill` (Misc.cpp:85): pass `type->width, type->height`. Removes a
footgun for any future asymmetric upgrade or modder-added building
with a non-centered shape.

Behavior bit-identical for current data; checksum unchanged.

Ported from PR #129 (feat/ai-trainer-support), original commit 89f342e, adapted to master's file layout.

Co-authored-by: Junior <junior-agent@noreply>
genixpro pushed a commit that referenced this pull request Sep 6, 2026
* iterateSelection: guard stale selection.building before deref

The BUILDING_SELECTION branch read selBuild->gid before its assert(selBuild)
null check, so a NULL selection cache (selection.building can stale past a
building's destruction -- onBuildingDestroyed only clears
game.selectedBuilding, and checkSelection runs at draw time, not before the
next keypress) crashed on the deref. Asserts also aren't stripped in glob2
release builds but would abort() rather than no-op the keypress; replace
both asserts with an early return so a stale selection cycle is a silent
no-op.

Ported from PR #129 (feat/ai-trainer-support), original commit
0249dd3 by Kyle.

* Validate live selection before cycling cached entities

* Link selection regression against real client code

* Clarify selection harness build instructions

---------

Co-authored-by: Junior <junior-agent@noreply>
Co-authored-by: kylelutze <4561737+kylelutze@users.noreply.github.com>
genixpro added a commit that referenced this pull request Sep 6, 2026
…ws (#140)

* Map editor: only remove ressources the painted terrain no longer allows

Painting sand or water cleared every ressource in a square around the
cell, three wide for sand and five for water. A tile is drawn from the
undermap corners at (x..x+1, y..y+1), so the cell painted at (x, y)
changes the tiles from (x-1, y-1) to (x, y); the square, centred on the
cell, also cleared the column east and the row south of the new sand,
which stayed grass and lost their trees.

The terrain is painted first and Map::removeUnallowedRessources then
drops, over the tiles a stroke can change, only the ressources whose
terrain no longer matches, the same test setRessource applies when the
editor places one. Grass stays the brush that clears: it bares the
tiles the cell touches, and no longer the column and row beyond them.

* iterateSelection: guard stale selection.building before deref

The BUILDING_SELECTION branch read selBuild->gid before its assert(selBuild)
null check, so a NULL selection cache (selection.building can stale past a
building's destruction -- onBuildingDestroyed only clears
game.selectedBuilding, and checkSelection runs at draw time, not before the
next keypress) crashed on the deref. Asserts also aren't stripped in glob2
release builds but would abort() rather than no-op the keypress; replace
both asserts with an early return so a stale selection cycle is a silent
no-op.

Ported from PR #129 (feat/ai-trainer-support), original commit
0249dd3 by Kyle.

* Validate live selection before cycling cached entities

* Link selection regression against real client code

* Exercise terrain resource preservation across brush overlaps and wrapped map edges

* Keep the merged harness instructions once and document both top-level targets

---------

Co-authored-by: Bob <bob-agent@noreply>
Co-authored-by: Junior <junior-agent@noreply>
Co-authored-by: kylelutze <4561737+kylelutze@users.noreply.github.com>
Co-authored-by: Bradley Arsenault <brad@bradleyarsenault.me>
genixpro pushed a commit that referenced this pull request Sep 6, 2026
The BUILDING_SELECTION branch read selBuild->gid before its assert(selBuild)
null check, so a NULL selection cache (selection.building can stale past a
building's destruction -- onBuildingDestroyed only clears
game.selectedBuilding, and checkSelection runs at draw time, not before the
next keypress) crashed on the deref. Asserts also aren't stripped in glob2
release builds but would abort() rather than no-op the keypress; replace
both asserts with an early return so a stale selection cycle is a silent
no-op.

Ported from PR #129 (feat/ai-trainer-support), original commit
0249dd3 by Kyle.
genixpro added a commit that referenced this pull request Sep 6, 2026
* Map editor: only remove ressources the painted terrain no longer allows

Painting sand or water cleared every ressource in a square around the
cell, three wide for sand and five for water. A tile is drawn from the
undermap corners at (x..x+1, y..y+1), so the cell painted at (x, y)
changes the tiles from (x-1, y-1) to (x, y); the square, centred on the
cell, also cleared the column east and the row south of the new sand,
which stayed grass and lost their trees.

The terrain is painted first and Map::removeUnallowedRessources then
drops, over the tiles a stroke can change, only the ressources whose
terrain no longer matches, the same test setRessource applies when the
editor places one. Grass stays the brush that clears: it bares the
tiles the cell touches, and no longer the column and row beyond them.

* NetGamePlayerManager: fix wrong-direction shift in slot compaction

removePlayer's manual slot-compaction was setting numberMask via
`1u >> bp.number` (right-shift), which always yields 0. The canonical
setter at BasePlayer::setNumber uses `1 << number`. Route through
setNumber instead of duplicating the bit-twiddling, so the same typo
can't reappear.

Effect was benign in practice — numberMask is never consumed as a
real bitmask anywhere; its only consumer is BasePlayer::checkSum,
and the wrong value was consistent across all peers (server-
authoritative GameHeader). But the field travels in save files and
the network checksum, and a future use of numberMask as an actual
mask would have silently observed 0 for any compacted slot.

Also drops an unused #include "YOGServerGame.h" flagged by clangd.

Ported from PR #129 (feat/ai-trainer-support), original commit 44947ad, adapted to master's file layout.

* iterateSelection: guard stale selection.building before deref

The BUILDING_SELECTION branch read selBuild->gid before its assert(selBuild)
null check, so a NULL selection cache (selection.building can stale past a
building's destruction -- onBuildingDestroyed only clears
game.selectedBuilding, and checkSelection runs at draw time, not before the
next keypress) crashed on the deref. Asserts also aren't stripped in glob2
release builds but would abort() rather than no-op the keypress; replace
both asserts with an early return so a stale selection cycle is a silent
no-op.

Ported from PR #129 (feat/ai-trainer-support), original commit
0249dd3 by Kyle.

* Validate live selection before cycling cached entities

* Link selection regression against real client code

* Exercise terrain resource preservation across brush overlaps and wrapped map edges

* Keep the merged harness instructions once and document both top-level targets

* Preserve survivor readiness when compacting lobby player slots

* Keep encoded AI player types valid under enum sanitizers

---------

Co-authored-by: Bob <bob-agent@noreply>
Co-authored-by: Junior <junior-agent@noreply>
Co-authored-by: kylelutze <4561737+kylelutze@users.noreply.github.com>
Co-authored-by: Bradley Arsenault <brad@bradleyarsenault.me>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants