The 4,096-Unit Cap That Wasn't
A batch size cap in processDestroyList leaked dangling modules on heavy-destruction frames, triggering _purecall mid-match.
Video length target: ~5-6 minutes. Recording setup: Editor with file tree and Visual Studio debugger screenshots. In-game replay for the cold open. Prereqs for viewer: C++, basic understanding of vtables and virtual dispatch.
Cold open (0:00 – 0:30)
[VISUAL: In-game replay. USA Mission 1. Massive late-game engagement. A GLA nuke siren wails. The mushroom cloud lands on a dense infantry mob. Hundreds of units, trees, and projectiles all flash to dust in one frame. Screen keeps moving for a beat. Then: Windows error dialog. "Fatal program exit requested." Black.]
Say:
"That crash happened 91 frames after the nuke. Not during. Not because of damage math. Ninety-one frames later, on a completely unrelated update call. And the stack trace was one word: _purecall."
Say:
"A pure virtual call on an object that already died. Let's find out why."
Act 1 — What processDestroyList does (0:30 – 1:30)
[VISUAL: Open GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp. Jump to line 2612.]
Say:
"When an object dies, it doesn't get deleted on the spot. It gets queued. GameLogic runs a destroy list at the end of every frame."
[VISUAL: Highlight the comment block at lines 2606-2609.]
"The object list is the same at the start of the update as it is at the end of the update."
Say:
"That's the contract. A tank that gets hit in frame N still exists when the AI scans for targets in frame N. It only actually gets deleted between frames, in
processDestroyList. Every destroyed object this frame, drained in one pass."
[VISUAL: Scroll down to line 2671. The final deletion loop.]
for (ObjectPointerListIterator iterator = m_objectsToDestroy.begin(); iterator != m_objectsToDestroy.end(); iterator++) { Object* currentObject = (*iterator); currentObject->removeFromList(&m_objList); removeObjectFromLookupTable(currentObject); Object::friend_deleteInstance(currentObject); // actual delete } m_objectsToDestroy.clear();
Say:
"That's fine. That loop has no cap. Every queued object gets deleted. The bug isn't here."
Act 2 — The cap (1:30 – 2:30)
[VISUAL: Scroll back up to line 2642. Zoom in.]
Say:
"The bug is one scope above. Before we delete the objects, we have to clean up their modules. Every Object owns a pile of
UpdateModulepointers — AI, weapons, logic, death animations — and GameLogic holds a flat vector of every live sleepy-update in the game. We need to remove the dying objects' modules from that vector before we free them."
const Int MAX_SUO = 4096; UpdateModulePtr sleepyUpdatesToErase[MAX_SUO]; Int numSUO = 0; for (auto it2 = m_sleepyUpdates.begin(); it2 != m_sleepyUpdates.end(); ++it2) { const Object* modObj = (*it2)->friend_getObject(); for (auto dit = m_objectsToDestroy.begin(); dit != m_objectsToDestroy.end(); ++dit) { if (*dit == modObj) { if (numSUO < MAX_SUO) sleepyUpdatesToErase[numSUO++] = *it2; break; } } }
Say:
"Stack array. Fixed size 4096. Single pass. Looks fine. Looks fast. It replaced an older O(n times m) scan — one sweep per dying object — with one sweep total. A legitimate performance win."
[VISUAL: Circle the if (numSUO < MAX_SUO) check.]
Say:
"But look at the guard. If we hit the cap, we silently stop collecting. No assert. No log. No second pass. And right after this loop finishes, we go on and delete every object in
m_objectsToDestroy. All of them. Uncapped."
Act 3 — The leak (2:30 – 3:30)
[VISUAL: Diagram. Two columns. Left: "m_objectsToDestroy" with 5000 entries. Right: "sleepyUpdatesToErase" stopped at 4096. Draw arrows from the first 4096 destroyed objects to their modules in the erase list. The last 904? No arrow. Red X.]
Say:
"Here's what happens on a heavy frame. A GLA nuke kills hundreds of infantry, plus the dozens of trees the blast flattened, plus the debris projectiles, plus the scatter fire still in flight. Every one of those has multiple update modules. Body module, AI module, contain module, physics. The sleepy-updates vector can easily have more than 4096 entries belonging to dying objects."
[VISUAL: Back to the code. Highlight the delete loop at line 2671 again.]
Say:
"When the cap trips, we silently drop the overflow from
sleepyUpdatesToErase. But the delete loop right below still walks the fullm_objectsToDestroyand callsfriend_deleteInstanceon every single one. The Objects die. Their modules die with them. Freed memory."
Say:
"And back on
m_sleepyUpdates? The pointers to those freed modules are still there. Dangling. Waiting."
Act 4 — The debug (3:30 – 4:30)
[VISUAL: Visual Studio debugger screenshot. Exception dialog, "R6025 pure virtual function call." Call stack panel open.]
Say:
"Next frame, GameLogic::update iterates
m_sleepyUpdatesand callsupdate()on each one. The interesting frames from the crash:"
ucrtbase.dll!abort()
ucrtbase.dll!terminate()
vcruntime140.dll!_purecall()
GameEngine.exe!GameLogic::update() Line 3912
sleepLen = u->update();
Say:
"There's no derived class frame between
_purecallandu->update(). That's the tell. The vtable pointer on the object pointed at byuresolved to the abstract base —UpdateModuleInterface— whoseupdate()is pure virtual. The compiler planted_purecall_handlerthere as a poison pill. If you ever dispatch to it, something is catastrophically wrong."
[VISUAL: Debugger watch window. u->friend_getObject() expanded. objId = 0. Template name: GLAInfantryRebel.]
Say:
"The owning Object has
objId = 0. That's the giveaway. A live Rebel always has a nonzero ID. Zero means the Object slot was already freed and its memory recycled or scrubbed. The module's owner is gone, but the module is still on the sleepy-updates heap getting dispatched every frame. It had to be a bookkeeping leak in the destroy path. And the only bookkeeping done there is the erase loop."
Say:
"Count the kills on that frame. Over 4096 module entries belonged to dying objects. The cap was hit."
Act 5 — The fix (4:30 – 5:15)
[VISUAL: Back to processDestroyList. Replace the fixed array with a vector, and the nested linear scan with an unordered_set.]
std::unordered_set<const Object*> destroying; destroying.reserve(numToDestroy); for (auto& p : m_objectsToDestroy) destroying.insert(p); std::vector<UpdateModulePtr> sleepyUpdatesToErase; sleepyUpdatesToErase.reserve(numToDestroy * 2); for (auto& u : m_sleepyUpdates) { if (destroying.count(u->friend_getObject())) sleepyUpdatesToErase.push_back(u); }
Say:
"Two changes. Drop the cap — vector, no ceiling. Replace the nested linear search with a hash set — O(1) membership instead of O(m) per module. Faster on big frames and correct for all frame sizes."
Say:
"The original concern with an unbounded list was a spike: a runaway mass-destruction event blowing out the frame budget. That concern was real. But the answer to it is never partial cleanup. A thirty millisecond hitch once in a nuke is a player sighing. A dangling vtable is a crash to desktop."
Outro (5:15 – 5:45)
[VISUAL: Cut back to gameplay. Nuke lands. Screen shakes. Dust clears. The game keeps running.]
Say:
"The lesson here is about cleanup paths, not batch sizes. A performance cap on a work queue is fine — you can always do the rest next frame. A performance cap on a cleanup pass is a trap. Cleanup has to be atomic. Either every dying object is fully unwound, or you've torn the graph."
Say:
"Half cleanup that ships on time is worse than full cleanup that hitches. Because the hitch you feel. The half cleanup you don't feel, until the next frame walks the wreckage and crashes on a pure virtual call."
Say:
"If you see a fixed-size stack array in a teardown path in your own codebase — in any engine — go audit it. Ask what happens when the real world exceeds the number. In Generals, the answer was
_purecall, ninety-one frames later, in the middle of a match that was otherwise going fine."
Takeaway
- Cleanup must be atomic. A batch cap on a teardown loop is latent data corruption, not backpressure.
- Dangling entries survive across frames. The crash site will be far from the cause.
_purecallwith no derived-class frame above it almost always means a freed object still on a dispatch list. - When replacing an O(n·m) cleanup with a batched version, the hot invariant is that every dying object is fully represented — not just the first K.
- If you must bound per-frame work, bound the input (cap how many objects can be queued for destruction per frame), not the output (the erase list), and make the policy explicit.