Skip to content

bugfix(pathfind): Survive open and closed list cells that lost their PathfindCellInfo - #1

Open
wh1ter0se69 wants to merge 1 commit into
mainfrom
fix/pathfind-checkpathcost-unwind
Open

bugfix(pathfind): Survive open and closed list cells that lost their PathfindCellInfo#1
wh1ter0se69 wants to merge 1 commit into
mainfrom
fix/pathfind-checkpathcost-unwind

Conversation

@wh1ter0se69

@wh1ter0se69 wh1ter0se69 commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Addresses #2799 and #2637.

Opened against my own fork rather than upstream, so it can be read as a diff without adding noise to the upstream queue.

This replaces an earlier version of this PR that was wrong. That version fixed the writer — it removed the s_useFixedPathfinding gate around the cleanup in Pathfinder::checkPathCost. Mauller's review was that it missed the mark by breaking retail compatibility, and he was right. Rationale and measurements for the correction are below, and the earlier reasoning is left in the history rather than quietly deleted.

What is actually broken

Retail leaves cells linked into the open and closed lists after their PathfindCellInfo is gone. PathfindCell::releaseInfo then refuses to reclaim those infos for as long as m_open or m_closed are set (AIPathfind.cpp:1437, "better leak than crash. jba."), so the flags stay true on cells that are on no live list.

A later search reaches such a cell through a list link. removeFromOpenList / removeFromClosedList infer "am I the head?" from the cell's own m_prevOpen and publish list.m_head = getNextOpen() (1886, 1997), so a cell from the abandoned chain becomes the head of the live list. Once the live search releases that chain, the survivors still reference the freed infos, and getNextOpen() — which is m_info->m_nextOpen->m_cell — names a cell whose m_info is now null.

The next reader dereferences it. That is both reported crashes:

The writer that strands the cells is Pathfinder::checkPathCost, which returns on allocateInfo failure without unwinding its lists. That is already corrected for the fixed pathfinding path by #994; retail compatible mode deliberately keeps the retail behaviour there.

Why this fixes the readers and not the writer

RETAIL_COMPATIBLE_PATHFINDING defaults to 1 with no build override (GameDefines.h:99-101), s_useFixedPathfinding initialises to false (AIPathfind.cpp:1113) and is re-zeroed on every map load (4102-4105), and checkPathCost is on the deterministic simulation path. So retail mode is what every default build runs, and its contract is to reproduce retail bit-exactly including the bug.

Repairing the writer changes simulation state from the moment the pool is exhausted, which is hundreds of frames before retail actually faults, and desyncs against an unpatched peer. Guarding the readers changes nothing until a cell reachable from a list has a null m_info — which is precisely the instant retail dies, and therefore the earliest point at which there is no retail behaviour left to match.

The change

Core/GameEngine/Source/GameLogic/AI/AIPathfind.cpp | 29 ++++++++++++++++++++--
  • The retail insertion sort tests currentCell->hasInfo() in its walk, and on stopping at a cell without info discards the unusable remainder instead of dereferencing it. Appending now distinguishes "ran off the end" from "the head itself was unusable".
  • putOnClosedList discards a head that has lost its info.
  • Both set s_forceCleanCells, so the infos are reclaimed by forceCleanCells() at the existing safe consumption point in cleanOpenAndClosedLists, not mid-search.

Both then fail over exactly the way the existing detections in releaseOpenList / releaseClosedList do — setting s_useFixedPathfinding and s_forceCleanCells, so forceCleanCells() runs at the safe consumption point in cleanOpenAndClosedLists rather than mid-search.

An earlier revision set only s_forceCleanCells, reasoning that flipping the mode mid-search could route the rest of that search into reverseInsertionSort with an m_tail the retail sort never maintains. Measurement settled it against that reasoning — see the table below. Whether the m_tail question is real is a pre-existing property of the other detections, not something this change introduces.

Measurements

Windows 11, VS2022 BuildTools 17.14, preset win32-vcpkg Release, base dc6c595e8, headless, the three repro replays on the !!!pathfinding_testing map.

Before, on unmodified main, all three crash with 0xC0000005 — at frames 6220, 9520 and 12640 by a logging-only frame marker.

The guard fires exactly once per replay, at the frame retail faults, and the sites match the two reports:

replay guard fires site issue
repro 01a frame 6238 insertion sort walk TheSuperHackers#2799
repro 01b frame 9532 insertion sort walk TheSuperHackers#2799
repro 02 frame 12659 closed list head TheSuperHackers#2637

After, all three play to the end with no CRC mismatch at any checkpoint.

Four variants through the identical harness, same base c090d2882, same script, only the patch differing:

variant diff repro 01a repro 01b repro 02
unmodified main crash 0xC0000005 crash crash
Mauller's branch (Mauller/fix-retail-pathfinder-crashes) +18/-0 CRC diverges @6300 @9600 @12700
this PR without the failover (guards only) +27/-2 CRC diverges @6300 @9600 @12700
this PR +31/-2 none none none

Removing only the two failover lines from this PR reproduces Mauller's results exactly, so the null guards are equivalent and the recovery action is the whole difference. Adding s_useFixedPathfinding alongside s_forceCleanCells — i.e. the idiom already used by releaseOpenList / releaseClosedList — is what makes all three replays match their recording end to end.

The likely reason, stated as a hypothesis rather than a finding: these repros were recorded on a build that already had that failover, so a build which fails over the same way at the same frame stays in step, while one that only severs a pointer does not. It is worth being precise about what this does and does not show — after the fault there is no retail behaviour left to match, since retail crashes there. It is evidence that the recovery matches the one the project already uses, not evidence of retail 1.04 CRC compatibility.

For contrast, the writer-side version this replaces produced mismatches at frames 5900, 7900 and 11600 — 300 to 1000+ frames before the fault. That gap is what made it wrong.

Runs were gated on the build's own exit code plus a confirmed relink. The replay runner stops at the first CRC mismatch, which for the earlier version landed before the crash frame, so the harness was locally patched — identically on every build compared here — to play through. Numbers above were reproduced on a build with no diagnostic code compiled in.

Not addressed

  • The writer in checkPathCost still strands cells in retail mode. That is intentional per the above; it is already fixed for the fixed pathfinding path. If it is wanted for retail too, it belongs behind the failover rather than in the normal path, and is a separate change.
  • Two other places strand the retail start cell the same way: Pathfinder::patchPath at 10660-10671 (fixed mode) and 10676-10684 (both modes), and Pathfinder::findAttackPath at 10880 and 10883, which return between startPathfind and m_openList.reset(parentCell). All of them leave a cell with m_open set and m_costSoFar == 0, so the cost checks at 6237 / 6329 / 7418 / 8664 always reject it. The effect is a permanently unusable cell and a leaked PathfindCellInfo, not this crash. Separate change, and the leak is relevant to pool pressure.
  • Retail CRC compatibility against the VC6 reference is not verified. GeneralsReplays/GeneralsZH/1.04 mismatches at frame 110 on the unmodified baseline in this environment too, because TESTING.md requires a VC6 build and this was MSVC 2022. The three repro replays above are same-build comparisons, which is what makes them meaningful here. A VC6 replay check is still owed.

On the closed AI PR

#2934 guards the same reader sites, which is the right shape. Its stated mechanism is:

Retail's startPathfind marks the start cell's m_open=TRUE without linking it into the open/closed list; when such a cell is later "removed" from a list it was never actually on, removeFromOpenList/removeFromClosedList splice dangling m_nextOpen/m_prevOpen pointers into otherwise-live lists.

The first half is correct, and an earlier revision of this PR wrongly implied it was not. startPathfind does set m_info->m_open = TRUE in retail mode without touching m_openList (1309-1316), and there is a real path that leaves a cell in exactly that state: Pathfinder::findAttackPath calls startPathfind(nullptr) at 10871, then returns at 10880 (!goalCell) or 10883 (goalCell->allocateInfo failed) — both before m_openList.reset(parentCell) at 10889. On those exits the start cell is left open, unlinked, and still holding a PathfindCellInfo.

The second half is what does not follow. startPathfind also sets m_costSoFar = 0 (1304), and every route to a remover is gated on cost: examineCellsCallback:6237, examineNeighboringCells:6329, groundCellsCallback:7418, checkPathCost:8664. newCostSoFar is from->getCostSoFar() + 0.5f*COST_ORTHOGONAL with COST_ORTHOGONAL = 10 (6214, 2061), so it is never below 5, and a zero-cost cell fails getCostSoFar() <= newCostSoFar every time. Nothing writes a non-zero cost while m_open is still set, because doing so requires passing those same gates first. So such a cell never reaches removeFromOpenList / removeFromClosedList and cannot produce these crashes.

What it does do is leak: the cell becomes permanently invisible to pathfinding, and releaseInfo will not reclaim its info while m_open is set. That is one more contributor to the pool pressure which is the precondition for the crash this PR fixes. So TheSuperHackers#2934 looks to have found a real defect by a mechanism that is not the one that faults — worth its own issue rather than dismissal.

AI usage disclosure

Per the AI code generation guidelines: this was produced with LLM assistance (Claude) — the root cause investigation, the instrumentation used to locate the faulting sites, the diff, and a draft of this description. The shipped diff is 29 added and 2 removed lines in one file, 11 of the additions being comment. Every measurement above was executed and observed on real builds rather than asserted, and the first version of this PR was wrong in exactly the way a reviewer would expect an LLM-authored change to be wrong, which is why the correction and its reasoning are stated here in full rather than silently force-pushed.

@wh1ter0se69
wh1ter0se69 force-pushed the fix/pathfind-checkpathcost-unwind branch from 8995a54 to a8d85a7 Compare August 4, 2026 18:32
@wh1ter0se69 wh1ter0se69 changed the title bugfix(pathfind): Fix crash from open and closed lists left dangling when the cell pool is exhausted bugfix(pathfind): Survive open and closed list cells that lost their PathfindCellInfo Aug 4, 2026
@wh1ter0se69

Copy link
Copy Markdown
Owner Author

Replaced the fix after review feedback from Mauller.

The first version removed the s_useFixedPathfinding gate around the cleanup in Pathfinder::checkPathCost. That was wrong, for a reason I should have drawn myself:

  • 70e72925b (bugfix(pathfinder): Fix various crashes in Pathfinder due to inadequate cleanup of pathfinding resources TheSuperHackers/GeneralsGameCode#994) is authored by Mauller, and the gating was a deliberate design decision. My write-up described it as having "left the original defect live", which misrepresented it.
  • RETAIL_COMPATIBLE_PATHFINDING defaults to 1 with no build override, s_useFixedPathfinding starts false and is re-zeroed on every map load, and checkPathCost is on the deterministic sim path — so the change altered retail behaviour for every default build.
  • I filed the resulting CRC divergence at frames 5900/7900/11600 under "known limitations" and argued it was unavoidable. In a mode whose contract is to reproduce retail bit-exactly including its bugs, that divergence is the defect, and it desyncs against an unpatched peer.

What survived review: the root-cause chain, the reachability analysis (one caller, checkForAdjust:5403, via AIGroup.cpp:546), and the replay measurements.

The correction guards the two readers instead, so nothing observable changes until the frame retail actually faults. Locating those precisely mattered — my original probes checked only the list heads and fired zero times, because TheSuperHackers#2799's fault is at a non-head cell mid-walk. Instrumenting every list-link dereference found it: the guard fires exactly once per replay, at 6238 / 9532 / 12659, and those are the frames the unmodified build crashes at.

Result: two of the three replays now complete with no CRC mismatch at all, and the third's only mismatch is at frame 9600, after its fault at 9532.

Two corrections to my earlier write-up, both confirmed: only the closed list is guaranteed non-empty at the checkPathCost bail-out — my own trace recorded openHead=00000000 — and "returns the infos to the pool" overstated releaseInfo, which bails early for CELL_OBSTACLE, m_flags != NO_UNITS and m_aircraftGoal.

@wh1ter0se69

Copy link
Copy Markdown
Owner Author

Correcting my own characterisation of TheSuperHackers#2934, which was unfair.

I wrote that its stated root cause "does not hold up". That overstated it — the observation is literally true and I was denying the premise rather than the inference.

startPathfind does set m_info->m_open = TRUE in retail mode without touching m_openList (1309-1316). And there is a real path that leaves a cell in exactly that state, which I had missed: Pathfinder::findAttackPath calls startPathfind(nullptr) at 10871, then returns at 10880 (!goalCell) and 10883 (goalCell->allocateInfo failed) — both before m_openList.reset(parentCell) at 10889. On those exits the start cell is left open, unlinked, and still holding a PathfindCellInfo.

My earlier audit missed it because I scanned for returns between m_openList.reset(...) and the main loop; here the hazardous window is between startPathfind and the reset, which my window excluded.

What still holds is the inference, not the premise. startPathfind also sets m_costSoFar = 0 (1304), and every route to a remover is cost-gated — 6237, 6329, 7418, 8664 — with newCostSoFar never below 5 (6214, COST_ORTHOGONAL = 10 at 2061). A zero-cost cell fails getCostSoFar() <= newCostSoFar every time, and nothing can write a non-zero cost while m_open is set without first passing those same gates. So it never reaches removeFromOpenList/removeFromClosedList and cannot produce these crashes.

But it isn't harmless: the cell is permanently invisible to pathfinding and its info is never reclaimed, since releaseInfo refuses while m_open is set. That is a genuine leak, and it feeds the pool pressure that is the precondition for the crash this PR fixes. So TheSuperHackers#2934 seems to have found a real defect via a mechanism that isn't the faulting one — that deserves its own issue rather than the dismissal I gave it.

PR body updated accordingly, and findAttackPath added alongside patchPath in the not-addressed list.

…PathfindCellInfo

Retail strands cells on the open and closed lists when the
PathfindCellInfo pool runs dry, and PathfindCell::releaseInfo then
refuses to reclaim those infos while m_open or m_closed are still set.
A later search can reach such a cell through a list link and publish it
as a list head, after which the cell it names has a null m_info. The
next reader dereferences it.

Guard the two readers rather than the writer. The retail insertion sort
now stops when the walk reaches a cell without info and discards the
unusable remainder, and putOnClosedList discards a head that has lost
its info. Both then fail over the same way the existing detections in
releaseOpenList and releaseClosedList do, by setting
s_useFixedPathfinding and s_forceCleanCells, so the cells are cleaned up
by forceCleanCells at the safe consumption point in
cleanOpenAndClosedLists rather than mid-search.

This is deliberately a fix at the point of use. The writer side is
Pathfinder::checkPathCost returning on allocateInfo failure without
unwinding its lists, which was already corrected for the fixed
pathfinding path in TheSuperHackers#994; retail compatible mode intentionally keeps the
retail behaviour there, so the only correct action in that mode is to
survive the consequence. Repairing the writer instead changes simulation
state from the moment the pool is exhausted, hundreds of frames before
retail crashes, and would desync against an unpatched peer.

The added conditions only change behaviour once a cell reachable from a
list has a null m_info, which is the point at which retail faults, so
nothing observable to the simulation changes before then. Measured
against the three repro replays, all three now run to completion with no
CRC mismatch at any checkpoint.

Addresses the crashes reported in issues 2799
(forwardInsertionSortRetailCompatible) and 2637 (putOnClosedList).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@wh1ter0se69
wh1ter0se69 force-pushed the fix/pathfind-checkpathcost-unwind branch from a8d85a7 to b9d99d4 Compare August 4, 2026 18:59
@wh1ter0se69

Copy link
Copy Markdown
Owner Author

Ran your branch through the same harness, Mauller — Mauller/fix-retail-pathfinder-crashes, both commits, same base c090d2882, same script, only the patch differing.

variant diff repro 01a repro 01b repro 02
unmodified main crash 0xC0000005 crash crash
your branch +18/-0 CRC @6300 @9600 @12700
mine, guards only +27/-2 CRC @6300 @9600 @12700
mine + failover +31/-2 none none none

Your fix is simpler and I'd concede that outright — you check the next link before stepping and sever it, which repairs the dangling pointer at source and means a bad head can't be produced by a later list.m_head = getNextOpen(). That's a nicer property than my check-current approach, and it's why you don't need the head-null case I added.

The interesting part is that the guards are not what separates the two results. Deleting only the two failover lines from mine reproduces your numbers exactly on all three replays — so the null-handling is equivalent and the recovery action is the entire difference.

Adding s_useFixedPathfinding = true next to s_forceCleanCells = true — the idiom releaseOpenList / releaseClosedList already use — makes all three replays run to completion with no CRC mismatch at any checkpoint. My previous revision deliberately left s_useFixedPathfinding out because I was worried that flipping mid-search routes the rest of that search into reverseInsertionSort with an m_tail the retail sort never maintains. The measurement went against that reasoning, so I've adopted the idiom; and if the m_tail concern is real it's pre-existing in your two detections, not introduced here.

My guess at the mechanism, offered as a hypothesis rather than a finding: the repros were recorded on a build that already performed that failover, so failing over the same way at the same frame stays in step with the recording, while severing a pointer and continuing does not. You'd know better than I would whether that's how they were captured.

Worth being clear about what this does not show: after the fault there is no retail behaviour to match, since retail crashes there. It's evidence the recovery matches the one the project already uses, not evidence of retail 1.04 CRC compatibility — that still needs the VC6 replay check, which I can't run (no VC6 here, and the fork can't reach the CI game data).

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.

1 participant