Skip to content

Let a cached route field notice the ground moved, wherever it moved - #232

Merged
genixpro merged 14 commits into
masterfrom
fix/rebuild-stale-building-gradient
Sep 12, 2026
Merged

Let a cached route field notice the ground moved, wherever it moved#232
genixpro merged 14 commits into
masterfrom
fix/rebuild-stale-building-gradient

Conversation

@Giszmo

@Giszmo Giszmo commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

A building's route field records which cells were obstacles when it was computed, and nothing reliably told it when that stopped being true.

Game::addBuilding — the one path every placement takes: player orders, queued build projects, map generators, the editor, story scripts — stamped the footprint onto the map and told nobody. A field built while the ground was open kept routing through it, units were hired for a building they could no longer reach, and they only gave up once one of them was stuck long enough to force a rebuild, at which point locked was set and the whole crew dropped the job at once.

Removal was never the problem: ORDER_DELETE reaches Team::syncStep, which dirties the rectangle and then calls dirtyGlobalGradient(), freeing every field of the team.

Why proximity was the wrong criterion

The first version of this branch dirtied the buildings whose tiles fell inside the changed footprint widened by GRADIENT_DIRTY_BORDER_TILES. Route fields span the map, so distance is not the question — a building sixty tiles away routes through the changed cell just the same. The same walk reads the building tile grid, which virtual buildings are never written into, so no flag's field was reachable by it at all. And ordinary demolition dirtied only the owner's buildings.

Thanks to @genixpro for the review that found all three, with a reproduction fixture rather than an argument.

The change

Map::topologyGeneration is bumped when a footprint is stamped or lifted and when a forbidden area is painted. Each cached field records the value it was built at; buildingGradient compares the two. Invalidation is O(1), reads no tiles, and covers distance, flags, other teams and any future way of moving a footprint by construction.

How much it is worth, and what it costs

@genixpro measured the thing the branch is actually for — pathfindBuilding's stuck-unit path calling updateGlobalGradient directly, i.e. a unit stuck long enough to force its own rebuild, which is the user-visible symptom:

fixture master this branch
Oazis, 4000 ticks 109 1
Oazis, 8000 ticks 195 1
gd-large-4ai, 6000 ticks 67 3

The cost is one number: GRADIENT_DIRTY_REBUILD_TICKS, how long an invalidated field may be reused before it is rebuilt. At the original 25 the branch cost +42.6% simulation time on Oazis over 8000 ticks; at 100 that is +10.3% with the symptom still fully suppressed, and the fix erodes past ~200 (57 stuck at 200, 76 at 400, converging back on master's 195). 100 sits inside the safe range rather than at its edge. Worst-case staleness is 100 ticks, about 4 s at normal speed, and it can only ever cost a detour: Map::directionByGradient re-tests live passability on every candidate step, and STUCK_REBUILD_TICKS remains as a backstop.

The constant now lives in src/EngineTiming.h beside the other tick cadences, including the CLEARING_FLAG_REFRESH_TICKS that the same function already reads from there, and test/BuildingGradientInvalidationHarness.cpp reads it rather than keeping a copy. That copy is why 8f4fca1 as pushed failed the regression it was raising the constant under: the harness advanced 25 ticks, no rebuild could have happened, and it read the stale field it exists to reject.

Deliberately not covered

Resources and immobile units. They also change what a field may route through. Counting every passability change over one 8-player match:

building=552   forbidden=458   resource=10152   immobile=50594

A glob stepping into a building is 82% of them. Bumping on everything cost 47% of simulation time across three seeds. Neither has ever invalidated a field, so leaving them out keeps the behaviour that was already there rather than opening a new hole, and directionByGradient rechecks live passability, so the exclusion cannot walk a unit into a wall.

Round-trip fields. They are children of the field this invalidates — Map::updateRoundTripGradient seeds every resource tile from building->globalGradient[swimClass] — and a parent generation bump does not propagate to them; they refresh only on their own ROUND_TRIP_REFRESH_TICKS timer, and fetchers walk them. What goes stale is the carry cost, not reachability: the child takes its obstacle marks from the resource gradient, so the worst case is a detour. Stamping the parent's generation on the child would rebuild every live child on every structural change, one full-map pass each, which is the wrong side of the trade on these beds. Now written down at MapPathfindBuilding.cpp.

Load-time healing. Game::checkBuildingsDoNotOverlapAndHealMissing writes into the building tile grid directly, so it does not bump the generation — and bumping it there would not work, because integrity() runs before Map::loadRuntimeState, which restores the saved generation over anything the heal set. It fires only for saves whose grid was already inconsistent, and master has the same gap today: it restores cached fields after the heal without dirtying them. A fix has to record that the heal touched a cell and bump after loadRuntimeState. Out of scope here, now written down at Game_io.cpp.

The proximity path is gone

Map::dirtyBuildingGradients is deleted. Instrumentation found no rebuild across three fixtures that its dirty flag caused and the generation would not have — and none on master either, so it was already inert before this branch existed. It is also redundant by construction: each of its five callers pairs it with a Team::dirtyGlobalGradient() on the same team in the same path, which frees every field of that team through resetPathfindGradients. Building::dirtyGradient stays; Map::pathfindBuilding still sets it for a clearing flag standing where its resource used to be.

Map::setForbidden is deleted with it — no caller under src/. addForbidden / removeForbidden now bump only when the tile's bit actually changes, so repainting an already-forbidden tile no longer throws away every cached field in the game.

Save format

FILE_FORMAT_VERSION_TOPOLOGY_GENERATION = 97, named in src/FileFormatVersions.h beside master's FILE_FORMAT_VERSION_ROUND_TRIP_FIELDS, and both MapIO.cpp gates read it. VERSION_MINOR and REPLAY_MINIMUM_VERSION_MINOR go to 97. 94 (#223), 95 (#244) and 96 (#253) all landed while this was open and none of them wrote these fields, so a lower gate would consume the next values in the stream and fail the load.

Verification

Debian x86_64, g++ 15.3.0, release=1 server=0, each harness with a disposable GLOB2_USER_DIR, on this head.

Regressions from build.yml that this change can reach: 20/20 passbuilding-gradient-invalidation, immobile-unit-gradient, building-footprint (plain and --load), entering-unit-save (plain and --load), trapped-unit, building-expel, hiring-bucket, resource-fetch-target, round-trip-hunger-gate, inn-swap, savegame-safety, buffered-file, team-stats-save (plain and both legacy fixtures), global-gradient, terrain-resources, selection-lifetime. test/TestsRunner is OK (187 tests) and every harness under test/ passes. The client, the software client and the YOG server all build clean.

The harness now covers what the description claims. test/BuildingGradientInvalidationHarness.cpp gained two scenarios on the same fixture: ring-other-team builds the ring as team 1 around team 0's site, and ring-flag puts an exploration flag with its goal disc inside the ring at the centre. Those are the two cases a footprint walk structurally could not reach.

Negative control. With gradientGeneration[swimClass] != topologyGeneration removed from Map::buildingGradient and nothing else changed:

ring-before       PASS   (passes either way: nothing is cached before the ring exists)
ring-after        FAIL   a site walled in by new construction is no longer offered to a unit outside
ring-other-team   FAIL   a site walled in by another team is no longer offered to a unit outside
ring-flag         FAIL   a flag walled in by new construction is no longer offered to a unit outside

So the regression tests the fix, and the two new scenarios are not passing on scaffolding.

Not covered by me: Windows/MinGW, the render, UI, LAN and server-run CI steps, and a cross-architecture checksum comparison. The arm64 and macOS side is in the reviews above.

Not included

Nothing. The CI step for the new harness is in .github/workflows/build.yml in this branch.

🤖 Generated with Claude Code

@Giszmo
Giszmo marked this pull request as draft September 9, 2026 21:06
@Giszmo Giszmo changed the title Rebuild a building's gradient when nobody can reach it Tell a building's neighbours when the ground under them changes Sep 9, 2026
@genixpro

genixpro commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Reviewed 5e8c1057fd20aab26a2f0c15396bf36214a78bfb and reproduced three missing invalidations with the real engine on macOS. I would keep this draft until these are covered. Nearby combat destruction works as a positive control.

Reproduction code: probe and detailed instructions, C++ harness. This separate branch adds only the probe, build target, and documentation on top of the reviewed PR head; it does not change this PR or fix production behavior.

git fetch origin codex/review-building-invalidation
git worktree add --detach ../glob2-pr232-repro bfb0db9e93611b1fc0c3f05161a82c5612f114e1
cd ../glob2-pr232-repro
scons -j6 release=1 server=0 invalidation-review
python3 test/run-savegame-safety-tests.py --check-preferences build/src/BuildingInvalidationReview .

Normal client build dependencies are required. On a Homebrew Mac, prefix the final command with DYLD_LIBRARY_PATH=/opt/homebrew/lib if SDL needs it. No display or existing game profile is needed.

Each case starts with a fresh 128×128 grass map, two teams, and a team-0 inn at (8,8). Cache its walking field at tick 100. Change a team-1 obstacle inn's footprint, advance to tick 130 (past the dirty-rebuild throttle), and compare the normal lookup with an explicitly dirtied/rebuilt field at the obstacle's origin.

Change Target marked dirty? Normal cached result Fresh rebuild
Place inn at (12,8), including normal post-placement update No Walkable Blocked
kill() inn at (12,8), positive control Yes Walkable Walkable
kill() inn at (64,8) No Blocked Walkable
launchDelete() inn at (12,8), then owner's syncStep() No Blocked Walkable

The corresponding gaps are:

  1. Normal placement never calls the helper. Game::addBuilding writes the footprint but does not invalidate existing fields. The subsequent addToStaticAbilitiesLists/update calls from the create-order path do not compensate for it.
  2. The 16-tile footprint scan does not cover full-map fields. dirtyBuildingGradientsAround misses a target farther away, even when its cached field includes the changed tile. Separately, the footprint scan cannot discover virtual flags, which are not stored in the building tile grid; that additional case is from code inspection, not this fixture.
  3. Ordinary demolition follows a different path from kill(). Team::syncStep still uses owner-only invalidation. The nearby other-team field remains stale even though the new combat-destruction hook handles that geometry correctly.

Actual output:

place obstacle x=12: dirty=0 initial=65505 cached=65505 fresh=0 STALE
remove obstacle x=12: dirty=1 initial=0 cached=65505 fresh=65505 CORRECT
remove obstacle x=64: dirty=0 initial=0 cached=0 fresh=64985 STALE
demolish obstacle x=12: dirty=0 initial=0 cached=0 fresh=65505 STALE
PASS: disposable profile preferences unchanged

The probe asserts this known PR-head behavior: exit 0 means the reproduction succeeded, not that the stale cases are correct. In a final regression suite those three cases should require cached == fresh. These are cache-correctness reproductions, not a claim about measured starvation or unfinished construction in a full match.

About savestates: at this PR head, Building::load calls freeGradients(), so saving/reloading loses the stale cache that demonstrates the problem. The in-process fixture is the reliable reproducer here.

Suggested next step: cover placement and both destruction paths, and make invalidation account for all affected cached building/flag fields across teams rather than proximity alone. A topology generation checked lazily by caches is one possible approach, but rebuild costs would need checking. The eventual simulation change also needs the appropriate replay-version bump after rebasing onto current master.

@Giszmo Giszmo changed the title Tell a building's neighbours when the ground under them changes Dirty the route fields around a building the moment it is placed Sep 9, 2026
@Giszmo Giszmo changed the title Dirty the route fields around a building the moment it is placed Let a cached route field notice the ground moved, wherever it moved Sep 10, 2026
@Giszmo
Giszmo marked this pull request as ready for review September 10, 2026 10:01
@Giszmo
Giszmo requested a review from genixpro September 10, 2026 10:07

@genixpro genixpro left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings from review of #232:

  1. Blocking: topology invalidation can be bypassed during load-time healing in src/Game_io.cpp (Game::load).

The new caching work relies on Map::topologyGeneration changes when topology is mutated, but the load-time repair path still writes directly into case storage (for example setting map.getCase(...).building = gid and clearing path state directly). This can leave route/gradient caches with stale generation values after a repair/reload and reintroduce incorrect route field reuse.

Please route map mutations that affect topology through existing invalidation paths (or otherwise increment topologyGeneration) in load to force correct cache invalidation/rebuild.

  1. Follow-up: review terrain mutation paths in this PR (notably Map::setTerrain) to confirm they should also trigger topology invalidation if terrain edits can occur after cached fields are created.

If you want, I can convert this into a request-changes review and split into line-level comments.

@genixpro

Copy link
Copy Markdown
Contributor

I merged current master (4e00c2b17) into this branch locally and validated the result on macOS and Linux. I'm also correcting my review from yesterday.

Merging master needs more than the textual conflicts

Master took save-format 94 (#223) and 95 (#244) while this was open. The conflicts in Version.h, ReplayReader.h, Lifecycle.cpp and build.yml are mechanical:

  • VERSION_MINOR and REPLAY_MINIMUM_VERSION_MINOR go to 96.
  • resetPathfindGradients keeps both the round-trip cleanup and the generation reset.
  • CI keeps both new steps.

MapIO.cpp merges cleanly but stays wrong. Its two versionMinor>=94 gates now match saves from #223 and #244 that never wrote these fields. I moved them to a named FILE_FORMAT_VERSION_TOPOLOGY_GENERATION = 96 next to master's FILE_FORMAT_VERSION_ROUND_TRIP_FIELDS. To check it, I wrote a version 95 save with master (GLOB2_TEST_SEED=7 glob2 -test-games-nox 1 --map SmallForTwo --matchup nicowar,warrush --save-game-as …) and loaded it with --nox <save> 1000 1:

Build Result
master loads, runs 1000 ticks
this branch + master, gates at 96 loads, runs 1000 ticks
same, gates left at 94 Failed to load the map: exception received., exit 1

I can push that merge as a branch if it saves you the resolution.

Results on the merged tree

Environment: macOS 26.6.2, Apple M3, Apple clang 21.0.0, release=1 server=0.

Simulation and save regressions from build.yml: all 13 invocations pass. They are building-gradient-invalidation, immobile-unit-gradient, building-footprint (plain and --load), entering-unit-save (plain and --load), trapped-unit, building-expel, hiring-bucket, resource-fetch-target, round-trip-hunger-gate, inn-swap and savegame-safety. Each ran with a disposable GLOB2_USER_DIR. I didn't run the render, UI, LAN or server steps.

Negative control. With the gradientGeneration != topologyGeneration test removed from Map::buildingGradient, BuildingGradientInvalidationHarness fails with FAIL: a site walled in by new construction is no longer offered to a unit outside. Restored, it passes.

Determinism. I ran --nox with GLOB2_CHECKSUM_SIDECAR=1 on three fixtures already in the repo, then compared the sidecars tick by tick. Every entry below matched on every tick; each fingerprint is the first 16 hex digits of the sidecar's SHA-256.

Fixture Ticks macOS run 1 vs run 2 macOS vs Linux (Ubuntu 24.04, g++ 13.3, aarch64) Sidecar SHA-256
games/cross-replay.game (SmallForTwo, 2 AI) 10000 identical identical 2d8a1d6ab994bfca
games/gd-archipelago.game (5 × Nicowar) 6000 identical identical cb152bef911e2d72
games/gd-bigarena-long.game (Oazis, 11 teams) 4000 identical identical 2669130e11bd4bc3

Each sidecar regenerates in a few seconds with GLOB2_CHECKSUM_SIDECAR=1 GLOB2_REPLAY_PATH=<file> glob2 --nox games/<fixture>.game <ticks> 1.

Not covered: x86_64, and a Windows/MinGW checksum comparison. Both builds were arm64.

Against master, as expected for an intentional simulation change: the first differing tick is 116 on SmallForTwo, 259 on Archipelago and 71 on Oazis.

Cost after #223/#244. Whole-process CPU time, including load, on the same fixtures. Median of 3 runs alternating with master. The trajectories diverge early, so these are not identical games.

Fixture master this + master Δ
Oazis, 11 teams, 4000 ticks 7.51 s 9.23 s +23.0%
Archipelago, 5 teams, 15000 ticks 4.86 s 5.10 s +4.9%
SmallForTwo, 15000 ticks 1.32 s 1.49 s +12.5%

Run-to-run spread was under 1%. On Oazis that is roughly 1.9 → 2.3 ms/tick against the 40 ms budget. The +18% in the description was measured before #223/#244 landed; I haven't profiled where the difference comes from.

Correction to my review

  1. Load-time healing is not a blocker, and the suggested fix would not work. checkBuildingsDoNotOverlapAndHealMissing runs inside integrity(), before map.loadRuntimeState. That call then restores the saved topologyGeneration and per-field generations, overwriting any bump made while healing. The heal only fires for saves whose building grid was already inconsistent. Master has the same gap today, since it restores cached fields after the heal without dirtying them. If it's worth covering, a follow-up could bump the generation after loadRuntimeState when the heal changed a cell. I withdraw the "blocking" label.
  2. setTerrain has no in-game caller. It is only called by map generation (MapTerrain.cpp regeneration, MapHomogen). Every in-game footprint or forbidden change goes through setBuilding, addForbidden or removeForbidden, which bump the generation. The only direct tile writes left are the raw map load and the heal above.

Small, non-blocking notes

  • The new harness runs in CI but has no section in test/README.md; the other harnesses do.
  • Round-trip gradients: send workers to the resource nearest for the whole trip #244's round-trip fields don't compare generations. That looks intentional, since they lag their parents by up to ROUND_TRIP_REFRESH_TICKS (120) and only price trips. A sentence saying so would help the next reader.
  • The description's version and cost figures predate the merge.

@Giszmo

Giszmo commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Disclosure, so you can discount the right parts. The topology-generation design
in this PR is mine — 250095ada, plus the earlier proximity commits fc64ab2d3
and 5e8c1057f. Bob wrote the consolidation (449da6c4d), the save-format
commit (ab3aa094c) and the merge (015c95413). So the verdict paragraph below
and finding 1, which is about the save format for my own design, are me judging
my own work. Findings 2, 3, 4 and 5 are on code I had no hand in.

Verdict

Changes requested — one blocking item, the rest are notes.

Taking the disclosure seriously: I wrote the generation design, so read this
paragraph as the author's view, not a reviewer's. A monotonic
Map::topologyGeneration compared lazily in Map::buildingGradient is O(1),
reads no tiles, and covers distance, virtual flags and other teams by
construction, which the proximity walk could not. The deliberate exclusion of
resources and immobile units is argued from counts, and Map::directionByGradient
rechecks live passability, so that exclusion cannot walk a unit into a wall.
Someone other than me should be the one to say the trade is right.

What blocks it is the rebase, not the idea.


1. BLOCKING — the format version has to be 96, and both gates with it

Independently confirmed, same conclusion genixpro reached.

origin/master is now VERSION_MINOR 95 (src/Version.h:9): 62fdaed03 (#223)
took 94 and cb7e39f4e (#244) took 95 while this branch was open. The branch
claims 94 — ab3aa094c wrote the gates at 93 and the merge 015c95413 moved
them to 94, both before #223 and #244 landed.

src/map/io/MapIO.cpp merges without a textual conflict and stays wrong — after
merging master, the two gates read:

if (versionMinor>=94)
    topologyGeneration=stream->readUint32("topologyGeneration");
...
building->gradientGeneration[sw]=versionMinor>=94 ? stream->readUint32("generation") : topologyGeneration;

A save written by master at 94 or 95 never wrote either field, so those reads
consume the next values in the stream — the load desynchronises and fails.

Evidence. I resolved the merge myself (four conflicts: Version.h,
ReplayReader.h, building/Lifecycle.cpp, .github/workflows/build.yml) and
moved both gates to a named constant beside master's existing one:

//! The map's topology generation and each cached field's generation stamp.
static constexpr int FILE_FORMAT_VERSION_TOPOLOGY_GENERATION = 96;  // src/FileFormatVersions.h

Confirmed both directions on x86_64 Linux (the last review's runs were arm64
only). A format-95 save written by master 4e00c2b17:

GLOB2_TEST_SEED=7 glob2 -test-games-nox 1 --map SmallForTwo \
    --matchup nicowar,warrush --save-game-as master95.game

loaded with --nox master95.game 1000 1:

Build Result
master 4e00c2b17 loads, runs 1000 ticks, exit 0
this branch + master, gates at FILE_FORMAT_VERSION_TOPOLOGY_GENERATION = 96 loads, runs 1000 ticks, exit 0
same tree, the two gates changed back to 94 Failed to load the map: exception received., exit 1

The third row is the same binary with a one-line difference in MapIO.cpp, so
the failure is the gate and nothing else.

REPLAY_MINIMUM_VERSION_MINOR goes to 96 for the same reason.

2. The round-trip fields are derived from the field this invalidates, and they route

Correcting the note in the last review that they "only price trips". They do not.

  • Map::updateRoundTripGradient (src/map/gradient/MapGradientBuilding.cpp:139)
    seeds every resource tile from building->globalGradient[swimClass]. The
    round-trip field is a child of exactly the field this PR invalidates.
  • Map::roundTripGradient (src/map/pathfind/MapPathfindBuilding.cpp:86)
    refreshes only on its own ROUND_TRIP_REFRESH_TICKS = 120 timer. Nothing
    compares generations there.
  • It is walked, not just read for a number: Map::pathfindResource
    (src/map/pathfind/MapPathfindRessource.cpp:39-41) steps a fetcher along it in
    preference to the plain resource gradient, and Unit::handleMovementGoingToResource
    (src/unit/UnitMovement.cpp:576) re-ascends it to pick a target.

So after a footprint change the parent rebuilds within DIRTY_REBUILD_TICKS (25)
and its child keeps the pre-change parent's values for up to 120 ticks, while
fetchers walk it.

To be precise about what is and is not stale: roundTripGradient calls
buildingGradient first, so the parent is rebuilt on a generation mismatch —
only the child is not. And the child takes its obstacle marks from the resource
gradient, not from the parent (updateRoundTripGradient writes FORBIDDEN /
UNREACHABLE straight from toResource, and uses toBuilding only for the
value carried). So what goes stale is the carry cost, not reachability.

How bad: bounded, and not a new hole. Map::directionByGradient
(src/map/gradient/MapGradientField.cpp:176) tests isFreeForGroundUnit against
the live map for every candidate neighbour. Worst case is a fetcher taking a
worse route or stalling for up to 120 ticks, never one walking into a wall. It is
pre-existing in #244 and this PR does not make it worse.

What I would ask for, cheapest first. Master already documents the lag at
src/map/pathfind/MapPathfindBuilding.cpp:22-25 — "the resource gradient stays
authoritative for reachability, so staleness only costs a detour". That is
accurate as far as it goes; what it does not say is that a parent generation
bump does not propagate to the child
, which is new with this PR. Adding that
sentence there costs nothing and is my actual ask.

If you want the behaviour rather than the comment, stamping the parent's
generation on the child and comparing it in roundTripGradient is a few lines —
but it is not free: every generation bump would then rebuild every live
round-trip child on next use, one full-map pass each, on top of the 2862 parent
rebuilds counted on Oazis below. On that bed that is the wrong side of the trade.
I would take the comment.

3. The new harness does not test the three things the description claims

The description says the generation "covers distance, flags, other teams and any
future way of moving a footprint". test/BuildingGradientInvalidationHarness.cpp
builds a world with a single team (game.addTeam(0), line 47) and only real
buildings. It covers placement-around-a-cached-field and placement-inside-a-ring,
both for team 0.

Those are the cases Leo hit, so they are the right ones to pin. But the gap the
previous review reproduced with its fixture (another team's cached field) and the
one it argued from code inspection (a virtual flag, never written into the
building tile grid) are the two with no regression test.
They pass by
construction now; nothing stops a later change from re-narrowing the invalidation
with only those cases breaking.

Bob confirms there was no reason for the omission — the harness was written to
answer the two questions Leo asked that night, and the other cases were never
added. Cheap: game.addTeam(1), place the ring as team 1 around a team-0 centre,
and one clearing flag whose field spans the changed cell. Same fixture, ~20 lines.

4. Load-time healing — genixpro's first review flagged this as blocking

Noting it because it is easy to miss: it is in a review (2026-09-10 23:15
UTC), not in the issue comments, so it does not show up alongside the other two.
genixpro's 2026-09-11 comment explicitly withdraws the blocking label, so this is
not a blocker — but the underlying gap is real and nobody has filed it.

Game::checkBuildingsDoNotOverlapAndHealMissing (src/Game_io.cpp:315) writes
map.getTile(xi, yi).building = gid directly at :349, bypassing
Map::setBuilding and so the generation bump.

The ordering matters, and it is the opposite of what you might expect. Inside
Game::load, integrity() — which calls the heal — runs at src/Game_io.cpp:235,
before map.loadRuntimeState(stream, versionMinor) at :287. So even making
the heal bump the generation would not work: loadRuntimeState then restores
topologyGeneration from the stream and overwrites it. genixpro's own correction
says the same, and it is why his originally suggested fix would not have helped.

Net effect: a save whose building grid was already inconsistent gets healed, and
its restored fields are then treated as current against a map the heal changed.
Valid saves never reach it, and master has the same gap today — it restores
cached fields after the heal without dirtying them. A working fix has to record
that the heal changed a cell and bump after loadRuntimeState.

I am not asking for it in this PR. I am asking for one sentence saying it is
known and out of scope, so the next reader does not rediscover it as a blocker.

5. Save/load round trip — checked, no finding

ab3aa094c is Bob's, written before and independently of my bisect of the same
red, so this is a genuinely independent read. The generation is written before the
cells (MapIO.cpp:349) and read back in the same position (:451); per-field
generations follow their lastUpdate inside the same section; and the pre-96
fallback gradientGeneration[sw] = topologyGeneration is the conservative
direction — an old save's restored fields are treated as current, which is what
master does today. Correct as far as I can tell, and the four
savegame-safety continuation checks below exercise it.

What I ran

All on the merge I resolved (branch carol/review-232-merge, 3e8d9a83f:
015c95413 + origin/master 4e00c2b17), Debian x86_64, g++ 15.3.0,
release=1 server=0, each with a disposable GLOB2_USER_DIR.

Check Result
building-gradient-invalidation PASS (both scenarios)
immobile-unit-gradient PASS (3 scenarios)
building-footprint PASS
building-footprint --load test/fixtures/wrapped-building/reproducer.game PASS
savegame-safety via run-savegame-safety-tests.py 12/12 PASS, including all four "continues RNG and 300 simulation steps" continuations

Negative control. With the gradientGeneration != topologyGeneration test
taken out of Map::buildingGradient and nothing else changed, the harness fails
on exactly the assertion the last review named:

PASS a site placed inside a ring is unreachable until the ring is cleared
FAIL: a site walled in by new construction is no longer offered to a unit outside
exit=1

Restored, it passes. So the new regression does test the fix, not the scaffolding
around it.

Not covered by me: Windows/MinGW, the render, UI, LAN and server CI steps, and
a cross-architecture checksum comparison. The arm64 side of that is in the
previous review.

Cost

I reproduced the last review's numbers on x86_64 Linux and then measured where
they come from, which was left open.

Timing. Wall clock for the whole process including load, median of 3 runs
alternating master 4e00c2b17 with this branch merged onto it. Debian
forky/sid, x86_64, g++ 15.3.0, release=1 server=0. The trajectories diverge early, so
these are not identical games.

Fixture master this + master Δ
gd-bigarena-long (Oazis, 11 teams), 4000 ticks 9.15 s 11.06 s +20.9%
gd-archipelago (5 × Nicowar), 6000 ticks 2.05 s 2.12 s +3.4%
cross-replay (SmallForTwo, 2 AI), 10000 ticks 0.78 s 0.82 s +5.1%

Same shape as the arm64 numbers: the 11-team map is the expensive case, the rest
is low single digits. My spread is worse than the arm64 run's — Oazis ranged
8.72-9.29 s on master and 10.71-12.09 s on the branch, so read that +20.9% as
roughly 20%, not to three figures. On Oazis it is 2.29 -> 2.77 ms/tick against
the 40 ms budget.

Where it goes. I counted Map::buildingGradient calls and what each rebuild
was attributed to, in a throwaway instrumented build of the branch:

Attribution is checked dirty-flag-first, so the "generation mismatch" column
counts only rebuilds the dirty flag would not have caught — the work this
branch adds.

Fixture calls served first allocation dirty flag generation mismatch
gd-bigarena-long, 4000 ticks 39081 934 0 2862
gd-archipelago, 6000 ticks 23052 178 0 268
cross-replay, 10000 ticks 113840 526 0 690

Two things fall out of that.

  1. The generation is the whole cost. On Oazis it forces 2862 extra full-map
    BFS rebuilds over 4000 ticks — about 0.7 per tick, and the 0.48 ms/tick the
    timing shows works out to roughly 0.7 ms each, which is what a full-map
    propagation on that map costs. That is the +20%, accounted for. Nothing else
    in the diff is hot.

  2. dirtyGradient never fires any more. Across all three fixtures, not one
    rebuild was reached with the dirty flag set and the generation matching. Every
    rebuild that was not a first allocation came from the generation. The
    proximity dirty path and Map::dirtyBuildingGradients are still compiled and
    still called; on these beds they are now dead weight. Worth a look before
    merge: either they are genuinely subsumed and can go, or they cover something
    these three fixtures do not reach, and that case deserves a sentence.

The cost is inherent to the guarantee — one placement anywhere invalidates every
field of every team, which is exactly what makes distance, flags and other teams
correct by construction. Narrowing it per team would be wrong, since another
team's building is what blocks your route. Whether 2.29 -> 2.77 ms/tick against
40 ms is the right price for that is Leo's call, not mine; I am reporting the
number, not arguing against the design.

Nits, none blocking

  • Map::setForbidden (src/map/Map.h:347) now bumps the generation and has no
    caller anywhere under src/. (The setForbidden in test/MapQueryTest.cpp is
    a different method on that file's own test double, not this one.) Dead either
    way; worth deleting rather than maintaining a bump inside it.
  • addForbidden / removeForbidden bump unconditionally, including when the
    team's bit was already in the state being written. Every bump throws away every
    cached field in the game. The paths that matter (executeCreate,
    buildProjectSyncStep, modifyForbiddenZoneForUpgradeArea) always make a real
    change, so this is not a measured problem — but guarding on an actual change is
    three lines and removes a whole class of surprise from the forbidden brush.
  • Map::pathfindBuilding (src/map/pathfind/MapPathfindBuilding.cpp:121) passes
    building->globalGradient[swimClass] raw to pathfindForbidden, bypassing
    buildingGradient and so the generation check. Impact is genuinely small — the
    pointer is only a tie-breaker on the forbidden-escape gradient and NULL is
    handled (MapPathfindArea.cpp:33) — so a stale field can only mis-break a tie
    between two equally good escape directions. Mentioning it because this PR is
    what makes the forbidden path bump the generation at all.
  • No test/README.md section for the new harness; every other harness has one.
  • The description's version and cost figures predate the merge.

@genixpro

Copy link
Copy Markdown
Contributor

I measured this branch rather than argue about it, and the headline is that the design is right but the constant was wrong. I've pushed one commit (8f4fca1) raising DIRTY_REBUILD_TICKS from 25 to 100, which keeps the fix fully intact and removes about three quarters of the cost. With that commit in, this is approved by me.

Everything below is macOS/arm64, release=1 server=0, master 4e00c2b17 merged into 015c9541 locally, per-cause counters compiled in for measurement only (not committed).

First: your numbers reproduce exactly

Independent instrumentation, same fixtures: generation-caused rebuilds came out 2862 / 268 / 690 on gd-bigarena-long / gd-archipelago / cross-replay — matching your reported figures to the digit. Clean-binary wall clock also lands where you and I both had it: +21.2% on Oazis at 4000 ticks (9.02s → 10.93s), +8.0% on archipelago, and no measurable change on cross-replay.

I also confirmed the attribution claim: the cost is entirely the BFS. On Oazis bfs_s goes 0.895 → 2.721 (+1.83s) against a +1.91s wall-clock delta. Nothing else in the diff is hot. Under this branch ~25% of all simulation time on Oazis is route-field rebuilding, versus ~10% on master.

The finding that changes the picture: cost scales superlinearly with game length

Oazis, doubling the horizon:

ticks simulated generation-caused rebuilds wall vs master
4000 2862 +22.2%
8000 9755 +42.6%

Buildings accumulate, so every bump invalidates more live fields — cost grows with the product of placements and fields. The +20% we both measured was a 4000-tick figure and understates it. Late game is both where this is most expensive and where people fast-forward. Worth knowing before picking a number.

The severity number nobody had

Rebuilds don't sum to the attributed causes; the remainder is pathfindBuilding's stuck-unit path calling updateGlobalGradient directly — i.e. a unit stuck long enough to force a rebuild, the actual user-visible symptom. That is a direct measure of the bug:

  • Oazis 4000 ticks: master 109 → branch 1
  • Oazis 8000 ticks: master 195 → branch 1
  • gd-large-4ai 6000 ticks: master 67 → branch 3

The bug is real, frequent, and this branch essentially eliminates it. That is the strongest evidence in favour of merging, and it wasn't in the PR.

Your dead-weight finding is right, and stronger than you stated

dirtyOnly = 0 and both = 0 across every fixture — but also dirtyOnly = 0 on master. The proximity path doesn't just lose to the generation; it never fires at all, on either branch. It was already inert, which is plausibly why the bug existed. Deleting it removes no coverage.

The throttle sweep, and why 100

Oazis, 8000 ticks ("stuck" = the symptom above):

config rebuilds generation-caused stuck bfs time wall
master 2906 0 195 2.091s 16.73s
ticks=25 (as proposed) 12603 9755 1 9.183s 23.86s (+42.6%)
ticks=100 (pushed) 5296 2480 0 3.860s 18.46s (+10.3%)
ticks=200 4178 1222 57 3.058s 18.24s
ticks=400 1222 181 76 0.879s

The cost curve flattens almost exactly where the correctness curve breaks. At 100 the fix is fully retained; past ~200 stuck rebuilds climb back toward master and the branch stops earning its cost. Same shape at 4000 ticks and on gd-large-4ai (master 67 → 25:3 → 100:6 → 200:49).

Worst-case staleness becomes 100 ticks, 4s at normal speed, typically about half that, and it only ever costs a detour — directionByGradient re-tests live passability on every candidate step, and STUCK_REBUILD_TICKS stays as a backstop.

Two optimisations I tried that failed

Reporting the negative results so nobody repeats them:

  1. Split the counter by change class — bump promptly only for placements that can actually disconnect something (footprint touching an existing obstacle), defer the rest, since a building dropped in open ground perturbs distances but changes no reachability. I measured the ceiling by suppressing the bump entirely for isolated stamps, which is more aggressive than any shippable version: rebuilds moved only 2862 → 2429 (−15%). Isolated stamps are 57% of placements on Oazis, but they aren't what drives rebuilds — the touching ones invalidate on nearly the same cadence. Not worth the complexity.
  2. O(1) early-out — skip the rebuild when the changed cell is already unreachable in a field. Doesn't work: a global counter carries no cell information. It would need a log of changed cells per generation with an overflow fallback. Possible, but not the freebie it looks like.

The boring constant beat both comprehensively.

Still outstanding (not from me)

  • Version renumber: Save the Echo AI's building-order id (savegame-resumed AI games were not reproducible) #253 has since merged and taken 96, so this needs 97, and MapIO.cpp's gates need to move with it — I hit exactly the failure you predicted when merging locally, and the v95/96 fixtures won't load until they're behind a named FILE_FORMAT_VERSION_TOPOLOGY_GENERATION.
  • The harness still doesn't cover the other-team and virtual-flag cases the description claims (~20 lines).
  • The redundant proximity path — given dirtyOnly = 0 on master too, I'd delete it rather than document it.

Caveats

Every config produces a different game (all checksums differ), so these are comparisons across workloads, not a controlled A/B — the trends are monotonic and replicate across three maps and two horizons, but individual figures shouldn't be over-read. The stuck counter is noisy at the low end (1/5/0 for ticks 25/50/100). I did not pin the boundary between 100 and 200 precisely. macOS/arm64 only; no Windows or cross-architecture checksum comparison.

Giszmo and others added 14 commits September 12, 2026 10:33
A 3x3 block of inn sites: when the centre is placed first, its cached
route field is never told that the ring closed around it, so units
outside are still offered the site (ring-after fails on master). When
the ring stands first, the centre's field is built after the wall and
is locked at once (ring-before passes). Clearing the ring restores the
site in both cases through Team::syncStep's existing invalidation.

Opus 5 helped authoring this commit.
A building's global gradient is recomputed only when it is missing or has
been marked dirty, and dirtyBuildingGradients fires on destruction and on
flag and area orders. Nothing marks it dirty when the ground around the
building opens up, so a field computed while the site was walled off stays
frozen: on a four-player FourSquares game a swarm construction site went
unreachable to all 28 workers of its colony at tick 35328, its gradient
covering 882 cells and ageing past 25000 ticks without a rebuild, and the
site never finished.

pathfindBuilding already rebuilds a gradient older than STUCK_REBUILD_TICKS
when a unit cannot make progress, but that only runs for a unit already
walking to the building, and a building nobody can reach is offered no
worker. The only path that could unstick it required it not to be stuck.

Give buildingAvailable the same rebuild at the same rate limit. Over 26000
ticks of that game it takes the buildings no worker can reach from 13 to 0,
for about a second of extra work.

Opus 5 helped authoring this commit.
Replaces the timed rebuild with the invalidation that was missing.

Map::setBuilding writes a footprint into the tile grid and nothing else
happens. Every building whose gradient routes through those tiles still
believes they are walkable, and a building that has just been placed is
computed against neighbours that do not know about it. dirtyBuildingGradients
was only ever called for flag ranges, area orders and demolition, so
placement, completion and the upgrade that moves a footprint were all silent.

dirtyBuildingGradientsAround marks every affected field, for every team - a
building blocks tiles for everyone, and the existing helper only ever dirtied
the caller's own team. It is called from the four places in the simulation
that move a footprint. Not from setBuilding itself: that also runs while a map
is loading, before the teams exist.

The timed rebuild this replaces was justified by a count of buildings whose
field reported them unreachable, and that count is not a measure of the bug: a
stale field is a generous field, so correcting one *raises* it. On the same
six-seed bed the invalidation leaves the number of construction sites that
stop progressing unchanged, peaking at one either way - the AI does not pack
buildings tightly enough to reproduce what a human player hits. It is offered
as a correctness fix: a field that has not been told about a building is
wrong, whatever the count says.

Also says what buildingAvailable's nine-cell probe is for, which the previous
comment did not.

test/TestsRunner: 170 tests pass.

Opus 5 helped authoring this commit.
Game::addBuilding is the one path every placement takes: player orders,
queued build projects, map generators, the editor and story scripts.
It stamped the footprint on the map and told nobody, so a field built
before the site went up kept routing through it and units were hired
for a building they could no longer reach, until one of them got stuck
long enough to force a rebuild.

The call has to come after the building is stored in myBuildings: the
dirty walk looks the fresh gid up there, and a null slot crashes it.
That is what made an earlier attempt from inside Map::setBuilding fail.

Opus 5 helped authoring this commit.
Invalidation walked the tiles inside the changed footprint widened by
sixteen, and dirtied whatever buildings it found there. Route fields span
the map, so proximity was never the right question: a building sixty tiles
away routes through the changed cell just the same and was never told.
The same walk reads the building tile grid, which virtual buildings are
never written into, so no flag's field was reachable by it at all, and
ordinary demolition dirtied only the owner's buildings.

Map::topologyGeneration is bumped when a footprint is stamped or lifted and
when a forbidden area is painted. Each field records the value it was built
at, and buildingGradient compares the two. Distance, flags, other teams and
any future way of moving a footprint are covered by construction, and
nothing has to remember to call anything.

Resources growing or being cleared, and units becoming immobile, also change
what a field may route through and are deliberately left out. Counting the
changes over one 8-player match: 50594 immobile and 10152 resource against
1010 structural. Bumping on all of them costs 47% of simulation time, against
18% for the structural ones alone - 0.6 ms per tick becoming 0.7 against a
40 ms budget. Neither has ever invalidated a field, so leaving them out is
the behaviour that was already there.

Verified against the review probe from codex/review-building-invalidation: the
placement, distant-removal and demolition cases all read CORRECT, the last two
with no dirty flag set at all. building-gradient-invalidation-test,
immobile-unit-gradient-test, building-footprint-test and TestsRunner (178) pass.

Opus 5 helped authoring this commit.
Every footprint change already reaches Map::setBuilding, and every forbidden
mask change now reaches the three Map setters, so the counter sees all of
them. The proximity walk that dirtied buildings within sixteen tiles of a
footprint is gone, together with its five call sites: it flagged a subset
of what the generation rebuilds anyway.

removeForbidden bumps like its two siblings; lifting a forbidden area opens
ground a field may have marked impassable. The build-project and area
orders wrote the forbidden mask straight into the tile array, bypassing the
setters and therefore the counter; they use the setters now.

The two rebuild conditions in buildingGradient shared the same throttle and
are one condition.

Opus 5 helped authoring this commit.
Opus 5 helped authoring this commit.
The runtime routing state kept each field's dirty flag and last-update
tick but neither the map's generation nor the value a field was built
at. A loaded game therefore saw every restored field as stale, rebuilt
it at its next use, and drifted from the uninterrupted run from that
tick on. The savegame safety harness caught it in its continuation
check on every CI job.

Both values now travel with the routing state under
FILE_FORMAT_VERSION_TOPOLOGY_GENERATION, which is 97: master took 94,
95 and 96 for other formats while this branch was open, and a save
written by any of them never wrote these fields. Reading them behind a
lower gate would consume the next values in the stream and fail the
load. A save from before 97 restores its fields as current, which is
what it did.

Opus 5 helped authoring this commit.
The topology generation invalidates every cached route field of every team on any
structural map change. That is what makes it correct by construction, and it is
also why the branch is expensive: DIRTY_REBUILD_TICKS, not the bump itself, is what
bounds the resulting rebuild load.

Measured with per-cause counters on gd-bigarena-long (Oazis, 11 teams), master vs
this branch, macOS/arm64. "stuck" counts rebuilds forced by a unit that could not
make progress -- the symptom this branch exists to remove.

  8000 ticks     rebuilds  generation-caused  stuck   bfs time    wall
  master             2906                  0    195     2.091s  16.73s
  ticks=25          12603               9755      1     9.183s  23.86s  (+42.6%)
  ticks=100          5296               2480      0     3.860s  18.46s  (+10.3%)
  ticks=200          4178               1222     57     3.058s  18.24s

Raising the throttle to 100 keeps the fix intact and cuts the regression by roughly
three quarters. Past 200 the fix erodes as stuck rebuilds climb back toward master.
The same shape holds at 4000 ticks and on gd-large-4ai (master 67 stuck, 25 -> 3,
100 -> 6, 200 -> 49).

Worst-case staleness becomes 100 ticks (4s at normal speed), and only ever costs a
detour: Map::directionByGradient re-tests live passability on every candidate step,
and STUCK_REBUILD_TICKS remains as a backstop. Simulation results change, but this
branch already bumps the save/replay version for that reason.

Claude-Session: https://claude.ai/code/session_01A5GEG5t9ithd47SFm6PTDZ

Opus 5 helped authoring this commit.
Raising DIRTY_REBUILD_TICKS from 25 to 100 left behind a copy of the old
value in the regression that exists to catch this class of bug.
BuildingGradientInvalidationHarness advances 25 ticks and then asks
whether a walled-in site is still offered; with the engine rebuilding no
sooner than 100 ticks after a field was built, no rebuild can have
happened yet, so the harness reads the stale field it is there to reject
and fails:

    FAIL: a site walled in by new construction is no longer offered to a
    unit outside

The interval moves to EngineTiming.h, whose whole purpose is that
anything expressed in ticks lives in one place "to avoid drift between
Engine, Map, Team, Building, Unit, and AI subsystems". The gradient
cadence beside it, CLEARING_FLAG_REFRESH_TICKS, is already there and
already read from MapPathfindBuilding.cpp. The harness now reads the
same constant, so the next person to tune it cannot silently stop
covering the interval.

The measurement that chose 100 travels with the constant.

Opus 5 helped authoring this commit.
The description claims the generation covers distance, flags and other
teams. The harness covered none of that: one team, real buildings only,
both scenarios placement-around-a-cached-field for team 0. Those are the
cases Leo hit, so they are the right ones to pin, but the two gaps the
review found are exactly the two that pass by construction today and
would break silently if invalidation were ever re-narrowed.

ring-other-team builds the ring as team 1 around team 0's site. The walk
this replaced dirtied only the buildings of the team that made the
change, so another team's wall went unnoticed. It also pins a real
asymmetry: Team::syncStep frees the demolishing team's fields, so when
the rival clears the ring the owner's field is invalidated but not
freed, and comes back on the next rebuild the interval allows rather
than on the next lookup.

ring-flag puts an exploration flag at the centre, with its attraction
radius set to 1 so its goal disc stays inside the ring. A flag is never
written into the building tile grid, so a walk over the changed
footprint could not discover its field at any distance at all.

Both fail with the generation test removed from Map::buildingGradient,
as does ring-after; ring-before passes either way, since nothing is
cached before the ring exists. That is what makes it insufficient alone.

test/README.md gains the section every other harness has.

Opus 5 helped authoring this commit.
Map::dirtyBuildingGradients walks a rectangle around a change, looks up
the building gid on each tile, and marks that building's fields dirty.
Instrumentation across gd-bigarena-long, gd-archipelago and cross-replay
found no rebuild anywhere that the dirty flag caused and the generation
would not have - not on this branch, and not on master either. It was
already inert before this branch existed, which is plausibly why the
bug it was meant to prevent was there at all.

It is redundant by construction, not just by measurement. Each of its
five callers pairs it with a Team::dirtyGlobalGradient() on the same
team in the same path, and that frees every field of every building of
that team through resetPathfindGradients. Marking a subset of the same
team's buildings dirty afterwards cannot change an outcome:

  executeModifyFlag       dirtyGlobalGradient() immediately before
  executeMoveFlag         dirtyGlobalGradient() under the same condition
  executeAlterForbidden   dirtyGlobalGradient() immediately before
  executeDelete           dirtyGlobalGradient() immediately before
  Team::syncStep          isDirtyGlobalGradient, applied after the loop

Two of them are flag paths, where the walk could only ever have found
real buildings anyway: a virtual flag is not in the building tile grid.

Building::dirtyGradient stays. Map::pathfindBuilding still sets it when
a clearing flag is standing on a resource that is gone, which is the one
remaining producer, and it is still saved and restored.

GRADIENT_DIRTY_BORDER_TILES goes with its only caller.

Opus 5 helped authoring this commit.
Both were found in review, both are out of scope here, and both are the
kind of thing the next reader rediscovers as a blocker.

Round-trip fields are children of the walking field this invalidates:
Map::updateRoundTripGradient seeds every resource tile from
building->globalGradient[swimClass]. A generation bump rebuilds the
parent on its next use and does not propagate to the child, which
refreshes only on its own 120-tick timer, and fetchers walk it. What
goes stale is the carry cost, not reachability - the child takes its
obstacle marks from the resource gradient, and directionByGradient
re-tests live passability on every candidate step - so the worst case is
a detour. Stamping the parent's generation on the child would rebuild
every live child on every structural change, one full-map pass each, on
top of the parent rebuilds already measured. On these beds that is the
wrong side of the trade.

Game::checkBuildingsDoNotOverlapAndHealMissing writes into the building
tile grid directly, so it does not bump the generation - and bumping it
there would not help, because integrity() runs before
Map::loadRuntimeState, which restores the saved generation and the
per-field stamps over anything the heal set. It fires only for saves
whose grid was already inconsistent, and the same gap predates this
change: fields were restored after the heal without being dirtied
before it too. A fix has to record that the heal touched a cell and bump
after loadRuntimeState.

Opus 5 helped authoring this commit.
addForbidden and removeForbidden bumped unconditionally, including when
the team's bit was already in the state being written. A bump throws
away every cached route field of every team in the game, so repainting
a tile that was already forbidden cost a full rebuild of everything and
bought nothing.

The three callers that matter - executeCreate, buildProjectSyncStep and
executeAlterForbidden - always make a real change on the tiles they
touch, so this is not a measured regression. It is a whole class of
surprise removed from the forbidden brush, which is the one place a
player can repaint the same tile as fast as they can drag.

Map::setForbidden goes: it has no caller anywhere under src/, and the
setForbidden in test/MapQueryTest.cpp is a different method on that
file's own test double. Leaving it would mean maintaining a bump inside
a function nothing calls.

Opus 5 helped authoring this commit.
@genixpro

Copy link
Copy Markdown
Contributor

Status update, since master has moved a long way under this branch today — #252, #253, #250, #259 and #262 have all landed.

The blocking item from the last round has resolved itself favourably. master is now at VERSION_MINOR 96, and this branch bumps 96 → 97, so the number it claims is currently free and the gates are consistent with it. The 94-vs-96 problem you diagnosed is gone; what remains is an ordinary rebase.

One thing to watch while doing it: #256 also claims 97. Whichever of the two lands second needs 98, along with every gate that references it. #257 claims 98 today, so it will need to move as well. Three branches are currently converging on the same two numbers.

The FileFormatVersions.h named-constant approach you landed on here is the right one and worth the other two adopting — a bare versionMinor>=97 at each read site is precisely what made this hard to spot the first time.

The design question you raised for someone other than you to answer — whether excluding resource changes and immobile units from topology-generation bumps is the right trade — I'd answer yes. Map::directionByGradient rechecks live passability, so the exclusion cannot walk a unit into a wall; the cost measurements are on the record; and leaving them out preserves behaviour that already existed rather than opening a new hole. The 82%-of-changes figure for a glob stepping into a building makes the case on its own.

So from my side this is rebase-and-merge, not rethink.

@genixpro
genixpro merged commit 95c6a60 into master Sep 12, 2026
3 checks passed
@genixpro
genixpro deleted the fix/rebuild-stale-building-gradient branch September 12, 2026 16:33
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.

2 participants