AI trainer support: refactor, infrastructure, and one desync fix#129
Draft
kylelutze wants to merge 196 commits into
Draft
AI trainer support: refactor, infrastructure, and one desync fix#129kylelutze wants to merge 196 commits into
kylelutze wants to merge 196 commits into
Conversation
…ents Migrates three widely-used Boost libraries to their standard C++11 replacements, eliminating these dependencies entirely: - boost::shared_ptr/weak_ptr → std::shared_ptr/weak_ptr (~140 files) - boost::lexical_cast → std::to_string/std::stoi (14 files) - boost::tuple → std::tuple with std::get<N>() syntax (13 files) Removes configure checks for boost/shared_ptr.hpp, boost/tuple, and boost/lexical_cast.hpp from SConstruct. Remaining Boost deps are date_time, tribool, random, integer, and functional.
Carried forward from the feat/modernization working tree onto the
ai-trainer-support branch. Adds a binary sidecar writer that records
per-tick, per-entity checksum field vectors, gated by the
GLOB2_CHECKSUM_SIDECAR env var. Used by the Rust port's cross-replay
test to pinpoint field-level divergence between C++ and Rust simulation.
Includes the new ChecksumSidecar.{h,cpp} module, SCons wiring,
init/teardown hooks in Engine.cpp, and accompanying notes in
docs/headless-replays.md and docs/duplicate-functions.md.
Previously the replay writer always emitted to a hardcoded "replays/last_game.replay", overwriting on every game. The AI-trainer pipeline (and any external tool driving headless games for data collection) needs distinct per-game output paths and the ability to run concurrent instances without clobbering each other. When GLOB2_REPLAY_PATH is set, it replaces the hardcoded path for both the ReplayWriter and the checksum-sidecar base. Default behavior is unchanged. Documented alongside GLOB2_CHECKSUM_SIDECAR in docs/headless-replays.md.
After the existing "automaticEndingGame ended" log, print a single machine-parseable line with the final tick count, the winner team (first team with hasWon set, or -1 on timeout), and the list of team/player-type pairs. Lets the AI-trainer pipeline label and filter generated replays by outcome and matchup without re-parsing the replay header. Format: GLOB2_GAME_END ticks=2483 winner_team=1 players=team0:local,team1:Nicowar,team2:Warrush Documented in docs/headless-replays.md.
createRandomGame previously drew uniformly from NUMBI..NICOWAR for every AI slot. This makes generated -test-games-nox replays diversity-balanced but heavy on the weaker AIs, which dilutes the training signal for the AI-trainer pipeline. The new --ai-types flag accepts a comma-separated, case-insensitive list of AI names (numbi, castor, warrush, reachtoinfinity, nicowar, toubib). When set, createRandomGame draws uniformly from that list instead. Empty / unset preserves legacy behavior. Examples: ./glob2 -test-games-nox 100 --ai-types nicowar,warrush ./glob2 -test-games-nox 50 --ai-types nicowar # self-play Documented in docs/headless-replays.md and in --help.
Toolkit::getFileManager()->openFP() unconditionally prepends each
search dir from its dirList onto the requested filename. For an
absolute path like "/tmp/foo.replay", the result becomes
"~/.glob2//tmp/foo.replay" (and similar for the other search dirs),
none of which exist — fopen returns NULL and the init() assertion
fires.
When GLOB2_REPLAY_PATH is set to an absolute path (the natural use
case for the AI-trainer pipeline writing per-game replays to an
arbitrary directory), we want the path used as-given. Detect the
leading '/' and call fopen directly in that case; relative paths
keep their existing FileManager-resolved behavior.
Verified end-to-end with:
GLOB2_REPLAY_PATH=/tmp/test.replay \
./build/src/glob2 -test-games-nox 1 --ai-types nicowar
Replay (860 KB) landed at the requested absolute path; previously
this crashed in ReplayWriter::init.
Three additional sidecar fields the AI-trainer pipeline can consume
without re-parsing the replay binary:
seed=<u32> from GameHeader::getRandomSeed() — for reproducibility
and dedup across runs
map="<name>" from MapHeader::getMapName() — quoted to handle map
names with spaces (e.g. "Auto save")
orders=<u32> from a new ReplayWriter::getOrderCount() — useful for
filtering out low-activity / early-timeout games at the
bash/jq level before paying the cost of parsing the
replay binary
ReplayWriter now tracks ordersWritten in pushOrder, incremented after
the existing voice/null early-returns so the count matches what is
actually persisted.
Updated example in docs/headless-replays.md. End-to-end verified:
GLOB2_GAME_END ticks=50594 winner_team=4 seed=1777220686
map="Auto save" orders=9547 players=team0:local,team1:Nicowar,...
Lets the AI-trainer pipeline drive curated matchups (specific AI per
team on a specific map) instead of the legacy fully-random
-test-games-nox flow. Random behavior is preserved as default; nothing
existing breaks.
New flags:
--map <name> Pin the map. Bare filename, no .map extension;
resolved as maps/<name>.map. Fails fast on missing
file (the legacy retry loop in createRandomGame()
would otherwise spin forever on a typo'd name).
--matchup <list> Comma-separated AI names; matchup[k] plays team k.
Validated against the loaded map's
getNumberOfTeams() before launch. Requires --map
(we need the team count to validate). Mutually
exclusive with --ai-types (pool vs. exact).
Pre-refactor: extracted AINames::parseAIName() so --ai-types and
--matchup share one canonical name->id table. The hardcoded table is
now in AINames.cpp next to getAIText/getAIDescription.
Implementation notes:
- chooseRandomMap() short-circuits to loadMapHeader("maps/<map>.map")
when --map is set.
- createRandomGame() (parameterless) skips the legacy retry loop on
--map and detects load failure via numberOfTeams==0 (loadMapHeader
doesn't throw; it logs and returns a default-constructed header).
- createRandomGame(int) uses matchup[teamColor] when set; the existing
i==0 P_LOCAL placeholder stays so engine code paths that depend on
gui.getLocalTeam() still work — the local player is a passive
watcher and matchup[0]'s AI plays team 0 via the wrap-around at
i==numberOfTeams.
Validation tested end-to-end:
- --matchup nicowar,warrush,numbi --map A_big_pond → 3-team game,
AIs assigned correctly to teams 0/1/2.
- --map ThisDoesNotExist → exit 1 with "cannot load" message.
- --matchup nicowar,warrush --map (3-team map) → exit 1 with
team-count mismatch message.
- --matchup ... --ai-types ... → exit 1 with mutually-exclusive
message at parseArgs.
- --matchup bogus → exit 1 with "unknown AI" message.
Documented in docs/headless-replays.md.
macOS Finder writes .DS_Store metadata files into every directory that's been browsed. They shouldn't be tracked.
Game::save() unconditionally called
mapHeader.setMapName(name) and setIsSavedGame(!fileIsAMap) to shape
the on-disk record, then never restored them. Every save() therefore
clobbered the live mapHeader's mapName — observable in two places:
- ReplayWriter::init writes the initial state header via
gui.save(buffer, "replayHeader") at game start, leaving live
mapName = "replayHeader" for the rest of the game.
- GameGUI::syncStep auto-saves every 256 ticks via
save(stream, "[auto save]"), leaving live mapName = "Auto save"
after the first auto-save.
The second clobber landed in the AI-trainer pipeline's
GLOB2_GAME_END "map=" field — every game reported map="Auto save"
regardless of what was actually loaded.
Fix: snapshot mapName + isSavedGame at the top of Game::save and
restore them at the bottom. The on-disk header still has the
intended name; only the in-memory state is left untouched.
MapEdit::save() relied on the post-save mutation to keep its
"current map name" UI updated (LoadSaveScreen reads
mapHeader.getMapName() to populate the dialog default). Re-apply
the rename explicitly there to preserve the editor UX.
End-to-end verified:
GLOB2_GAME_END ticks=34050 winner_team=0 seed=1777223357
map="A big pond" orders=3741 players=...
(Previously map="Auto save" in this exact scenario.)
Triggered by GLOB2_DATASET_PATH env var (mirrors GLOB2_REPLAY_PATH and
GLOB2_CHECKSUM_SIDECAR). Writes one binary record per executed action
order, alongside the .replay. The trainer pipeline consumes these
directly without having to re-simulate the replay (the Rust port's
simulator isn't yet complete enough to drive a re-simulation pass).
Format (little-endian, see docs/headless-replays.md):
HEADER: magic "GDS1", u32 format_version, u32 num_records,
u32 flags
RECORD: u32 tick, u8 sender, u8 order_type, u16 padding,
u32 state_blob_len + bytes, u32 payload_len + bytes
State blob is empty in format version 0 — the schema is wired up but
observation features are intentionally deferred to a follow-up commit.
The trainer-side parser rejects unknown versions explicitly so v1
(features online) won't silently produce junk.
writeRecord skips ORDER_NULL and ORDER_VOICE_DATA matching
ReplayWriter::pushOrder's filter — without that, every per-tick
"do nothing" order would be logged and num_records would dwarf the
actual decision count (4 players × 30k ticks ≈ 120k records, of
which only ~4k are real AI decisions). With the skip, num_records
matches the GLOB2_GAME_END `orders=` field exactly.
End-to-end verified:
GLOB2_DATASET_PATH=/tmp/g.dataset GLOB2_REPLAY_PATH=/tmp/g.replay \
./glob2 -test-games-nox 1 --map A_big_pond --matchup nicowar,warrush,numbi
→ /tmp/g.dataset header.num_records = 4533, matches orders=4533
Hooked in Game::executeOrder alongside the existing ReplayWriter
push, initialised in Engine::initGame next to the checksum sidecar,
torn down at game end. Absolute paths bypass FileManager (matching
ReplayWriter's prior fix).
These were defensive-engineering for a migration scenario that doesn't
exist: there's a single producer (this writer) and a single consumer
(the trainer's dataset.rs parser), and regenerating any dataset takes
seconds. We never need to support multiple wire formats in flight.
Header shrinks from 16 bytes to 8:
HEADER (8 bytes)
[4B] magic "GDS1"
[4B] u32 num_records (patched at close)
Per-record drops the 2-byte alignment-cosmetic padding.
If the schema ever changes wire-incompatibly, bump the magic to GDS2
and parsers reject by magic mismatch — that's the actual safety net,
not a version field.
Verified end-to-end: 3739-record dataset, parses cleanly, sidecar
shows dataset_records=3739 matching orders=3739.
writeRecord now takes Game& and serializes the sender's view of the world before the action half: bot-team scalars (prestige, alive/won/lost flags, teamRessources, unit/building counts by type) plus a FOW-filtered 32x32x7 spatial grid downsampled from the live map. Per-cell channels are terrain, resource amount, my/enemy unit counts, my/enemy building type, and discovery state (unknown / previously seen / currently visible). Vision is masked through team.me | sharedVisionExchange | sharedVisionFood | sharedVisionOther so the supervision signal matches what the source AI saw when it made the decision. Records grow from action-only to ~7.3 KB; verified against A_big_pond with nicowar/warrush/numbi (4056 records, discovery channel evolves from mostly-unknown to fully-explored as expected). Layout doc lives in DatasetWriter.h.
Map.cpp is too large to navigate or port piecemeal. Split it into behaviour-grouped translation units (lifecycle, IO, step, terrain, resources, query, view, gradient global/local/building/area, minigrad, pathfind ressource/building/area, misc, log) so each file stays under ~500 lines, matching the porting tracker's chunking and the Rust port's file-size convention. Map.h is unchanged — this is purely an implementation-side split. Two new private headers: - MapInternal.h — extern const direction tables (deltaOne, tabClose, tabFar), UPDATE_MAX, fill<>, clip_0_31 inline. Shared across the Map*.cpp family. - MapGradientImpl.h — bodies of updateGlobalGradient<T> and the VersionSimple/Simon/Kai helpers. Forbidden/guard/clear/building gradient templates (in three separate .cpp files) call into these templates, so the bodies must be visible at every instantiation site. Side cleanup: dropped dead helper fillGradientCircle (defined in the original Map.cpp but never called anywhere in the codebase). Open follow-up: MapLog.cpp is 602 lines because Map::logAtClear is one 567-line function. Decomposing it into per-stat-category helpers is a separate refactor and not done here.
Splits the monolithic GameGUI.cpp along behavioral boundaries: - GameGUIDraw.cpp frame-level draw + small primitives - GameGUIDrawPanels.cpp right-panel content (drawBuildingInfos, etc.) - GameGUIInput.cpp processEvent, handleKey, handleMenuClick, etc. - GameGUISelection.cpp selection mode + iterate/center - GameGUIParticles.cpp particle generation/movement/draw - GameGUIScript.cpp script API surface (enable/disable, hilights) - GameGUIInternal.h shared #defines + InGameTextInput declaration GameGUI.cpp keeps lifecycle/core (ctor, init, step, sync, save/load, executeOrder, addMessage). No header changes; all methods remain GameGUI members so the split is purely physical. Also removes dead bookkeeping the split surfaced: - handleKey: 'modifier' int set to +1/-1 but never read. - drawBuildingInfos / handleMenuClick: 'j' counters that walked alongside ypos but were never consulted (vestiges of an older drawValueAlignedRight pattern still used in the upgrade preview).
Splits the monolithic Game.cpp along behavioral boundaries:
- Game_orders.cpp executeOrder (the per-order-type switch)
- Game_io.cpp load, save, integrity,
checkBuildingsDoNotOverlapAndHealMissing, checkSum
- Game_sync.cpp syncStep + buildProject/won/script/prestige sync,
dirtyWarFlagGradient
- Game_editor.cpp queries (isTeamAlive, unitsCount, ...) and editor
utilities (addTeam, addUnit, addBuilding,
checkRoomForBuilding, removeUnit..., getUnit)
- Game_render.cpp drawPointBar, drawUnit, drawMap*, drawUnitPathLine*,
drawUnitOffScreen, drawMap
Game.cpp keeps lifecycle/core (ctor/dtor, init, clearGame, set*Header,
setAlliances, setWaitingOnMask, dumpAllData, prestige getters). No
header changes; all methods remain Game members so the split is purely
physical. Bodies are byte-identical slices of the original Game.cpp.
src/SConscript updated to add the 5 new TUs to source_files. The
server build is unaffected (Game.cpp was not in server_source_files).
Reduces clutter in src/ (116 YOG files among 426). SConscript paths prefixed with yog/; SConstruct CPPPATH gains #src and #src/yog so existing #include "YOG..." (and YOG files' own includes of sibling headers) keep resolving with no source edits. Also guards the macOS CFBundle chdir block in Glob2.cpp main() with !YOG_SERVER_ONLY, matching the existing pattern in the file. Fixes a pre-existing `scons server=1` link error on macOS arm64 where CoreFoundation wasn't pulled in for the server target.
Mirrors the earlier YOG move (cbace7e4). Adds #src/AI to CPPPATH so existing flat #include "AIEcho.h" / "AI.h" etc. across ~25 consumers keep working unchanged. SConscript, vcproj/vcxproj, and debian/copyright updated for new paths. Sets up for splitting AIEcho.cpp (5931 lines) and other large AIs into per-namespace TUs under src/AI/AIEcho/, etc.
Pure extraction along existing namespace boundaries — no content changes, only the 44-line prelude (copyright + includes + using directives) duplicated into each file: Management.cpp (1247) ManagementOrder subclasses + RessourceTracker Construction.cpp (1006) Constraints + BuildingOrder + FlagMap + BuildingRegister Conditions.cpp (989) Condition + BuildingCondition subclasses ReachToInfinity.cpp (915) ReachToInfinity EchoAI implementation Echo.cpp (680) signature_*, MapInfo, Echo class Entities.cpp (604) Entity factory + 8 Entity subclasses Gradient.cpp (437) GradientInfo, Gradient, GradientManager SearchTools.cpp (361) Building/team/enemy iterators + BuildingSearch AIEcho.h is unchanged; #include "AIEcho.h" still works for all consumers. SConscript and vcxproj updated for the new source list.
Pure extraction along internal responsibility boundaries — no content
changes, only the 38-line prelude (copyright + includes + AI_FILE_*
defines + using directive) duplicated into each file:
Maps.cpp (770) all compute*Map routines: obstacle/space/
neighbour/work/hydratation/notGrass/wheat/enemy
Projects.cpp (585) addProject + addProjects + continueProject
Control.cpp (577) controlSwarms/expandFood/controlFood/
controlUpgrades/controlStrikes
Placement.cpp (557) findGoodBuilding + computeRessourcesCluster
+ updateGlobalGradient[NoObstacle]
Lifecycle.cpp (381) Project + Strategy + ctors + init + dtor +
load + save
GetOrder.cpp (366) getOrder + defineStrategy
State.cpp (242) enoughFreeWorkers + computeCanSwim/NeedSwim/
BuildingSum/WarLevel
AICastor.h is unchanged; #include "AICastor.h" still works for all
consumers. SConscript, vcxproj, and vcproj updated for the new source
list.
`Gradient::get_height` returns negative sentinels for non-distance states (-1 for obstacle, -2 for BFS-unreached). Callers using the `< dist` pattern silently treated those tiles as "in zone" — meaning the AINicowar farming heuristic and ReachToInfinity's farm-spot selector were placing farms on obstacle/unreached tiles in addition to genuinely-near-water tiles. On waterless maps this caused every wood/wheat tile to be classified as a farm spot. Add `Gradient::within_dist(x, y, max)` — a guarded wrapper that returns `0 <= get_height < max`. Replace the 11 buggy call sites in AINicowar.cpp (farming zone + cardinal expansions) and ReachToInfinity.cpp (farm placement). Other call sites were already correctly guarded with explicit `>= 0` checks (see e.g. AINicowar.cpp:1996-2039), confirming the original author knew about the negative sentinels — these 11 sites were just oversights. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pure extraction along internal responsibility boundaries — no content
changes, only the 35-line prelude (copyright + includes + using
directives) duplicated into each file:
Buildings.cpp (806) queue_buildings + queue_inns/swarms/racetracks/
swimmingpools/schools/barracks/hospitals +
order_buildings + order_regular_* +
manage_buildings/manage_inn/manage_swarm
Flags.cpp (495) compute_defense_flag_positioning + modify_points +
compute_explorer_flag_attack_positioning
Lifecycle.cpp (383) NewNicowar ctor + load + save + tick +
handle_message + selectStrategy + initialize
Attack.cpp (318) choose_building_to_attack + attack_building +
control_attacks + choose_enemy_target +
dig_out_enemy
Upgrade.cpp (213) choose_building_upgrade_type_level1/2 +
choose_building_upgrade_type +
choose_building_for_upgrade + upgrade_buildings
Phases.cpp (204) check_phases
Farming.cpp (179) update_farming + update_fruit_flags +
update_fruit_alliances
Strategy.cpp (123) NicowarStrategy + NicowarStrategyLoader
AINicowar.h is unchanged; #include "AINicowar.h" still works for all
consumers. SConscript, vcxproj, and vcproj updated for the new source
list.
| File | Lines | Contents | |-------------------------------|------:|--------------------------------------------------------------------------------------------------------| | src/Building/Misc.cpp | 539 | kill, unit/ressource management, exits, level, flag stats, eatOnce, happiness, conversion, integrity, checkSum | | src/Building/Step.cpp | 525 | step, subscribeToBringRessourcesStep, subscribeForFlagingStep, subscribeUnitForInside | | src/Building/Lifecycle.cpp | 527 | constructors, destructor, freeGradients, load/save, loadCrossRef/saveCrossRef | | src/Building/Update.cpp | 470 | building-site / units / general updates, upgrade-area forbidden zones, isHardSpaceForBuildingSite | | src/Building/Construction.cpp | 415 | resource needs/wishes, launch/cancel construction & delete, updateCallLists, updateConstructionState | | src/Building/TypeSteps.cpp | 382 | swarmStep, turretStep, clearingFlagStep | Pure extraction: every line is a byte-exact copy of the original. Each TU duplicates the original 38-line prelude (copyright + includes). The mid-file `#include "GameGUI.h"` (was line 1333) travels with Update.cpp. Building.h is unchanged.
- Add anonymous-namespace templates countUnitsIf/findUnitIf and countBuildingsIf/findBuildingIf; rewrite the 9 query methods as one-liners (drops ~150 lines of for-loop boilerplate). - Fix bitwise-and where modulo was intended in placeGuardAreas: `(b->posY+y) & map->getH()` -> `% map->getH()`. Every other place in the codebase uses `& map->wMask`/`& map->hMask`; this was the only `& getH()` and is inconsistent with the `% getW()` next to it. - Drop duplicate `#include "Building.h"` and unused `<valarray>`.
- Move the 8-neighbor BFS expansion out of Gradient::recalculate
into a new private method Gradient::expand_bfs(queue<position>&).
Definition lives in a new GradientBFS.cpp (along with the small
Gradient/GradientInfo ctors) so tests can link without dragging
in Map / Game / GlobalContainer.
- Replace the 8 near-identical neighbor branches with a single loop
over a fixed-order 8-element offset table. Push order is preserved
exactly -- lockstep networking depends on it; flagged in a comment.
- Delete dead commented blocks at Gradient.cpp:251-253 (old malloc
pattern, superseded by gradient.resize()) and ReachToInfinity.cpp
:255-257 (stale BuildingSearch snippet replaced by team_stats).
Tests (test/GradientBFSTest.{h,cpp}) drive expand_bfs directly on a
real Gradient instance via friend access, asserting against toroidal
8-connected Chebyshev distance. Covers: empty queue, single source,
wrap-around, obstacle preservation, multi-source minimum distance,
fully-enclosed unreachable cell, queue-drained postcondition.
test/SConstruct gains a darwin branch with the include paths needed
to compile against AIEcho.h transitively (SDL2, libgag, glob2 root,
HAVE_CONFIG_H). Standard switched to gnu++14 so PerlinNoise.cpp's
`register` keyword stays a warning rather than a c++17 error.
Bug fixes: - Lifecycle.cpp: fix `seenByMaskk` typo on load; `save` writes "seenByMask" but `load` was reading "seenByMaskk", which silently dropped the field on TextStream loads (BinaryStream ignores the name, so binary saves were unaffected). - Misc.cpp: log message in `kill` labeled "unitsWorking" but printed `unitsInside.size()` (copy-paste from preceding line). - Misc.cpp: guard divide-by-zero in REPAIR construction state when `totRessources == 0`. - Misc.cpp: renumber `checkSum` index comments to close the [6] gap (was [0..5], [7..25]; now [0..24]). Documentation: Add `// PORT:` comments at four sites so the Rust port doesn't miss the non-obvious `clearingFlagStep` timer behavior: - Building/TypeSteps.cpp:406-407 — pointer to where the timer actually resets, plus the +=16 escalation channel. - Building/TypeSteps.cpp:414-415 — open question on whether `standardRandomActivity()` does proper unit detachment. - MapGradientBuilding.cpp:275-277 — flags this as the sole reset point, reachable on both return paths. - MapPathfindRessource.cpp:189-191 — flags the 125/128 threshold mismatch and the escalation pattern.
Reset all Team::MAX_COUNT player slots up front before packing active selectors. Pre-fix, disabling a middle selector (e.g. 0/2/3 active, 1 disabled) left slots [count..lastActiveIndex] holding stale BasePlayer data from a prior updatePlayers() call. Slots beyond numberOfPlayers are unused by the simulation but GameHeader::save / savePlayerInfo iterate MAX_COUNT_ON_DISK and serialize the stale bytes into .game files and network packets. Also name the magic ally-team IDs (HUMAN_ALLY_TEAM / ENEMY_ALLY_TEAM) and extract aiSelectorName() so the active branch reads cleanly. cpp-refactor.replay byte-equal.
`OrderAlterateArea::{getData,setData}` declared maxX/maxY as Sint16 but
serialized them with addUint16/getUint16, while the symmetric minX/minY
used addSint16/getSint16. The wire bytes were observably identical
(both helpers compile to the same SDL_SwapBE16 + 16-bit store/load in
Marshaling.h), so this was latent on the C++ side — but the asymmetric
codec calls would mislead the Rust port into encoding/decoding the
bounds inconsistently.
Switch maxX/maxY to addSint16/getSint16 so all four bounds use the same
codec, and tighten the wire-format doc-comment in Order.h to spell out
each field's type explicitly.
Verified byte-equal cpp-refactor.replay diff after the change.
…ameScreen Two related fixes in one file pair. BH-170 off-by-one. `par1 > COLOR_BUTTONS` dropped the host's slot-0 team change. Slot 0's ColorButton fires BUTTON_STATE_CHANGED with par1 == COLOR_BUTTONS (32), failing the strict inequality, so changeTeam(0, ...) was never called. Slots 1..N-1 (par1 33..) worked because the comparison was strictly greater. Replace with the same bounded form used by the kick-button dispatch immediately above: `par1 >= COLOR_BUTTONS && par1 < COLOR_BUTTONS + Team::MAX_COUNT`. BH-047 follow-up. Commit dd7a980 tightened Team::MAX_COUNT to 12 but missed MultiplayerGameScreen, whose local MAX_NUMBER_OF_PLAYERS=16 loops called `gh.getBasePlayer(i)` for i=0..15. The screen aborted on GameHeader.h:64's `n<Team::MAX_COUNT` assert at i==12 as soon as it was constructed (updateJoinedPlayers runs from the ctor), crashing every host-LAN and host-YOG flow. Drop the local enum, size the four widget arrays at Team::MAX_COUNT, and route all five loop/bound sites through Team::MAX_COUNT — matching the same pattern CustomGameScreen already uses. Determinism: none. All sites are UI dispatch / widget construction / visibility updates; no orders sent, no sim state touched. G2.game replay byte-equal to tests/baselines/cpp-refactor.replay.
Previously syncRand() % maps.size() invoked UB (SIGFPE on x86) when the maps/ directory was empty or unreadable. The surrounding retry loop in createRandomGame() only catches std::ios_base::failure, so the FPE bypassed it and terminated the process. Now chooseRandomMap() returns std::nullopt without consuming RNG state when the listing is empty; createRandomGame() surfaces this as a clear stderr message + exit(1) instead of crashing or spinning. Success-path syncRand() consumption is unchanged, so cpp-refactor.replay is byte-equal.
Decompose the SettingsScreen ctor (~294 lines) into eight named build helpers
covering each tab section, with the four near-identical building-defaults
loops broken out as buildCompletedBuildingsGroup / buildNewConstructionGroup
/ buildUpgradesGroup / buildFlagsGroup so their differing filters and
row-wrap thresholds are visible in the call signatures rather than buried
in flat code. Decompose onAction (~256 lines) into one handler per event
kind (handleButtonAction, flushDefaultsToSettings, handleListSelected,
handleValueChanged, handleButtonStateChanged) plus retranslateUiStrings
for the post-language-change re-localization pass.
Also: replace magic group IDs 1/2/3/4 with named constants
(kBuildingGroupCompleted/NewConstruction/Upgrades/Flags); name the per-group
column-wrap thresholds; add brief doc comments on addDefaultUnitAssignmentWidget,
addDefaultFlagRadiusWidget, and the level-parity rule in
getDefaultUnitAssignmentText.
CS-417: delete stray translation-unit-scoped 'std::string value;' at end
of file (copy/paste artifact, never read or written).
CS-418: move PRESSEDSELECTOR from =15 (colliding with SECONDKEY=15) to =23.
Both widgets emitted BUTTON_RELEASED with par1==15; the empty
'else if (par1==PRESSEDSELECTOR){}' branch silently absorbed both. After
the renumber, par1==15 (SECONDKEY) falls through (was a no-op anyway,
since key_2_active dispatches via source==key_2_active in
BUTTON_STATE_CHANGED) and par1==23 (PRESSEDSELECTOR) hits the empty
branch. Same observable behavior, no longer relying on the value
collision.
Verified byte-equal against tests/baselines/cpp-refactor.replay.
Carve the per-tab construction code out of SettingsScreen.cpp so each
file fits the 500-line per-file guideline:
SettingsScreen.cpp 388 — coordination layer (ctor, onAction
dispatch + per-event-kind handlers,
retranslate, gfx/audio plumbing,
onGroupActivated, menu)
SettingsScreenGeneral.cpp 153 — General tab build helpers
(OK/Cancel, language, display,
graphics toggles, username, audio)
SettingsScreenBuildings.cpp 394 — Building Defaults tab + the four
sub-group builders, the unit/flag
widget helpers, the parity-rule text
formatter, and flushDefaultsToSettings
SettingsScreenKeyboard.cpp 248 — Keyboard Shortcuts tab + per-shortcut
list / action / key plumbing
Promote the four kBuildingGroup* IDs from the anonymous namespace in
the .cpp to static constexpr members of SettingsScreen so all three
.cpps share the same definition. The kRows* column-wrap thresholds stay
in the Buildings .cpp anon namespace since they're only used there.
Move addNumbersFor to SettingsScreenBuildings.cpp — its only callers
(addDefaultUnitAssignmentWidget / addDefaultFlagRadiusWidget) live in
that file.
Pure extraction — same widget construction order, same handler
behaviour. Verified byte-equal against tests/baselines/cpp-refactor.replay.
setMultiLine read ninput[ninput.length()-1] without first checking the string was non-empty. On empty input, length()-1 wraps to SIZE_MAX and std::string::operator[](SIZE_MAX) is UB — reachable via the SGSL script-text path (GameGUIDraw.cpp:437, GameGUI.cpp:420), which calls setMultiLine(game.sgslScript.textShown, ...) whenever isTextShown is true, including frames where textShown is still empty. Guard the trailing-space append with !empty() && back() != ' '. With empty input the wrap loop is skipped naturally and no lines are pushed, matching what the function was trying to do. Also expand the header doc comment to spell out the wrap semantics (font-pixel width, indent on continuation, empty-in => empty-out) so the Rust port reader doesn't have to re-derive them from the body.
drawRessourceInfos called clearSelection() when it noticed the selected resource tile had been emptied. That mixed GUI-state mutation into a draw function. Move the stale-resource check into checkSelection(), which drawPanel() already calls before dispatching to per-mode draws, alongside the equivalent building and unit staleness checks. drawRessourceInfos becomes a pure draw with a defensive early-return guard. Replay byte-equal to cpp-refactor baseline; selectionMode is GUI state and never enters the simulation/network/save path.
Split the 140-line drawChoice into drawChoiceSprites, drawChoiceHighlight, pickChoiceUnderMouse, and drawChoiceInfoPanel, coordinated by a thin top-level drawChoice. Replace the in-loop sel side-effect and the later std::find with a shared findChoiceIndex helper returning std::optional<size_t>. Name the layout magic numbers (row height, sprite clip width, highlight sprite IDs, panel clip/info Y). Behavior-preserving: the sprite-Y vs hit-grid-Y mismatch, the arrow y-6+decX typo, and the YPOS_BASE_BUILDING vs caller-pos divergence are all left exactly as they were, with code comments pointing future cleanup at the right spot. Replay against tests/baselines/cpp-refactor.replay is byte-identical.
Building had four "Local" fields — posXLocal, posYLocal, maxUnitWorkingLocal,
unitStayRangeLocal — that were the GUI's optimistic shadow of pending orders
("show the flag at the drag-target while the OrderMoveFlag is still in
flight"). They lived on the sim object only because there was no other place
to put them; only GUI/render code ever read them, and the writes from
sim/AI/SGSL were dead.
Move them into a GameGUI-owned BuildingGuiState struct keyed by GID. GUI
mutators set the pending optionals; the post-executeOrder reconciliation
pass clears them when the matching order arrives (matching the original
sender!=localPlayer || replaying rule so the local user's newer drag intent
isn't clobbered by an older order landing). Display goes through
displayed{Pos,MaxUnitWorking,UnitStayRange} accessors that fall back to the
authoritative Building field when no pending shadow is set.
Replay byte-equal against tests/baselines/cpp-refactor.replay.
The 450-line drawBuildingInfos with 6-deep nesting becomes a ~40-line coordinator that calls per-section helpers in vertical order. Each helper internally checks its own ally / owner / canExchange gating, so the outer conditionals fall away. Extracted: drawBuildingHeader, drawBuildingIcon, drawBuildingHP, drawBuildingInsideStats, drawBuildingFlagInfo, drawBuildingWorkingControls, drawBuildingPriorityControls, drawBuildingRangeControls, drawBuildingCombatStats, drawBuildingExchange, drawBuildingResources, drawBuildingSwarmRatios, drawBuildingFailureReasons, drawBuildingActionButtons. These sit alongside the previously extracted drawBuildingTimeToLeaveBar / drawBuildingFlagControls / drawBuildingUpgradePreview. Also names seven previously-anonymous magic numbers in GameGUIInternal.h: YOFFSET_RESSOURCE_LINE, YOFFSET_RESSOURCE_SECTION_PAD, YOFFSET_SWARM_PROGRESS_BAR, YOFFSET_SWARM_RATIO_LINE, BOTTOM_BUTTON_PRIMARY_YOFFSET, BOTTOM_BUTTON_SECONDARY_YOFFSET, BOTTOM_BUTTON_HEIGHT. Behavior-preserving: cpp-refactor.replay diff is byte-equal.
Pulls executeOrder + reconcileBuildingGuiState into GameGUIOrders.cpp, load/save into GameGUIPersistence.cpp, and step/moveFlag/dragStep/ syncStep/musicStep/checkWonConditions/showEndOfReplayScreen/ flushScrollWheelOrders into GameGUIStep.cpp. GameGUI.cpp shrinks by 708 lines. Also fixes a "changable" typo in GameGUIDrawBuildingHelpers.
setVolume and stopMusic mutated musicVolume / voiceVolume / mode from the UI thread while mixaudio() reads those same fields from the SDL audio thread. Every other public mutator in this class (loadTrack, setNextTrack, addVoiceData, isPlayerTransmittingVoice) already brackets shared-state access with SDL_LockAudio / SDL_UnlockAudio; these two were the odd ones out. A torn read of the MusicMode enum could cause mixaudio to skip the MODE_STOP fade-out / SDL_PauseAudio(1) path. setVolume's nested if/else collapsed to one guarded write block so the locking shows up once instead of three times. Doc comments added on both functions noting the locking contract for the Rust port.
loadTrack heap-allocated an OggVorbis_File before calling ov_open and returned -2 on decode failure without deleting it, leaking one struct per failed load. Hold the allocation in std::unique_ptr and release() into the tracks vector only after ownership transfer succeeds, so the failure path frees it via RAII. Also expand the header comment to document the -1 / -2 return sentinels and that the OggVorbis_File takes ownership of the underlying FILE* on success.
The swarm-ratio scrollbox was passing 0 as the actualBar argument to drawScrollBox, so the darker "sim-confirmed" channel never rendered. Users only saw their local pending input (ratioLocal), with no visual cue that the order was still in flight. The sibling working-units scrollbox correctly passes the live count as actualBar; the ratio call site was a copy-paste-from-template oversight. Pass selBuild->ratio[i] as the actualBar so the two channels overlay when in sync and diverge briefly while OrderModifySwarm round-trips. While here, drop drawScrollBox's unused value parameter (silently discarded by all three call sites; the misleading naming had been a source of confusion that probably caused this bug to begin with), replace the hard-coded 128/7 dimensions of the production-timeout progress bar with named SWARM_PROGRESS_BAR_WIDTH/HEIGHT constants, and add a doc comment on drawBuildingSwarmRatios explaining the local-vs-actual two-channel rendering pattern so it isn't lost in the Rust port. Pure UI change; cpp-refactor.replay is byte-equal against baseline.
decodeData read an attacker- or corruption-supplied Uint32 size and called `new Uint8[size]` with no validation; a multi-GB value threw std::bad_alloc which escapes every caller (ReplayReader::loadReplay, retrieveOrder, NetMessage::getNetMessage all catch only ios_base::failure), terminating the process instead of the documented "return false / drop message" behavior. Cap the size at MAX_NET_SEND_ORDER_SIZE (1 MiB — three orders of magnitude above the largest legitimate Order, OrderVoiceData at 2 KiB) and throw ios_base::failure with a message citing the cap before any allocation happens. Switch the raw new[]/delete[] buffer to std::vector<Uint8> so any in-decode throw can't leak it. While here: name the matching 1 MiB cap in BinaryInputStream::readText as MAX_BINARY_STRING_LENGTH so the two caps are linked by name. Add test/NetSendOrderDecodeTest, a standalone harness that exercises both the oversized-size rejection and a valid-envelope round-trip. Error-path-only change; cpp-refactor.replay is byte-equal.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
src/{ai,team,unit,building,map,gui,net,render,yog}/;#pragma onceeverywhere; lowercased directory names.shared_ptr,lexical_cast,tuple, threads); C++20 build; SPDX headers replacing GPL boilerplate.logFilemembers + thefprintfs feeding them (game/team/unit/building/AICastor); unused MapLog; orphaned text-loader infra; verbose pathfinding printfs.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;ChecksumSidecarper-tick checksum dump for cross-replay debugging.DatasetWriterfor (state, action) records;--save-game-as,--map,--matchup,--ai-types;GLOB2_GAME_ENDsummary line;GLOB2_REPLAY_PATHenv var; absolute-path handling in FileManager / ReplayWriter.tryToBuildingSiteRoom(setBuilding(decLeft, decLeft)), unguardedget_height < distin farming AI, AIWarrush wrap typo,Game::savemutating livemapHeader, MapGenerator dead-code + 4 bugs, latent farming AI fixes. All validated against the replay baseline.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::sortis unstable. When two buildings tied on every comparator field (priority, type/level bucket, unit ratio, ressource ratio), their relative order inTeam::buildingsNeedingUnitswas implementation-defined. Different binaries — different libstdc++/libc++ versions, optimization levels, ABIs (32-bit vs 64-bitsize_t) — could put tied buildings in different positions, then callsubscribeToBringRessourcesStep()in different orders, which consumessyncRand()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
gidas a final stable tiebreaker (gidis unique within a team and identical across all clients).Consequences:
2.
map/gradient: chamfer DT replaces BFS, fixes Uint8 underflow in flag seeding (1e228ce → 487027a)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
unitStayRangeintolistedAddrat 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, computedgradient[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 compactslistedAddrafter 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:
Test plan
scons release=1 -j16clean build on macOS arm64 (Darwin 25.4)./build/src/glob2 --nox games/G2.game 0 1runs to completion (126,950 ticks / 84 min sim time, 4 AI players: Warrush + 2× Castor + Nicowar)G2.gameproduce byte-identicallast_game.replay(=tests/baselines/cpp-refactor.replay)gd-small-2ai,gd-large-4ai,gd-archipelago,gd-bigarena-long) byte-equal againsttests/baselines/gradient/*.replayafter the chamfer cleanup commitprioritize_buildingchange atsrc/team/team_step.cppand confirm the gid tiebreaker reasoningsrc/map/gradient/MapGradientGlobal.cppand the listedAddr underflow fix in1e228ce0