bugfix(ai): Do not rebuild the locomotor set of a dead aircraft - #3072
bugfix(ai): Do not rebuild the locomotor set of a dead aircraft#3072wh1ter0se69 wants to merge 3 commits into
Conversation
A slow death module disables flight by mutating the current Locomotor instance - JetSlowDeathBehavior applies a negative maxLift and a zero maxTurnRate. A dead aircraft's AI keeps running, and when it changes locomotor set, chooseLocomotorSetExplicit() clears the set (deleting every Locomotor) and rebuilds from template, whose constructor resets both back to BIGNUM. The wreck regains full lift and turn rate and circles forever, since DestructionDelay is effectively infinite. This lives in AIUpdateInterface because every aircraft reaches it, which is why the issue is reported for jets and helicopters alike. Verified on a VC6 release build: the captured wreck falls to the ground in ~3 seconds instead of circling, and the 10-replay 1.04 corpus still completes 10/10 with zero CRC errors, byte-identical to the unfixed run.
This changes simulation behaviour, so per the policy in GameDefines.h it must not be active in a retail compatible build. It is compiled out by default and becomes live when the project flips RETAIL_COMPATIBLE_CRC.
| // TheSuperHackers @bugfix Do not rebuild the locomotor set of a dead aircraft. | ||
| // | ||
| // A slow death module disables flight by mutating the CURRENT Locomotor instance - | ||
| // JetSlowDeathBehavior::beginSlowDeath() applies a negative maxLift and a zero | ||
| // maxTurnRate to getCurLocomotor(). Rebuilding the set here deleteInstance()s that | ||
| // Locomotor and constructs replacements from template, and the Locomotor constructor | ||
| // resets m_maxLift and m_maxTurnRate to BIGNUM. The wreck therefore regains full lift | ||
| // and full turn rate and keeps flying, circling forever because DestructionDelay is | ||
| // effectively infinite. | ||
| // | ||
| // This lives in AIUpdateInterface rather than in a slow death module because every | ||
| // aircraft reaches it, which is why the symptom is reported for jets and helicopters | ||
| // alike. |
There was a problem hiding this comment.
Try to keep the comment to two or three lines. It's fine if the comment doesn't hold all information, that's what the PR description (or later posts) are for.
Do not rebuild the locomotor set of a dead aircraft.
This is good. Please describe the rationale in one sentence as well.
There was a problem hiding this comment.
Cut to three lines in 18df0e2, with the rationale in the second sentence:
// TheSuperHackers @bugfix wh1ter0se69 10/08/2026 Do not rebuild the locomotor set of a dead
// aircraft. Rebuilding discards the Locomotor instance that a slow death module mutated to
// ground it, so the wreck regains full lift from template and keeps flying.The mechanism detail moved into the PR description.
|
How about fixing this higher up in the call stack? if (!jet->isEffectivelyDead())
{
chooseLocomotorSet(d->m_returningLoco);
} |
Keeps the comment to three lines with the rationale in one sentence, per review. Also adds m_curLocomotorSet != LOCOMOTORSET_INVALID so the guard refuses a rebuild but never the initial build - AIUpdateInterface:: loadPostProcess() deliberately sets the current set to INVALID before re-choosing it when loading a pre-version-4 save, and without this a dead aircraft in such a save would be left with no locomotor set at all.
|
Checked this properly rather than answering from my own description, and the description turned out to be partly wrong. Corrected in 18df0e2. Where you're right. Why I'd still keep the guard at the funnel. Three reasons:
One more change while I was in here: the guard now refuses rebuilds only, not the initial build. |
What's wrong
JetSlowDeathBehavior::beginSlowDeath()grounds a dying aircraft by mutating the currentLocomotorinstance:A dead aircraft's AI keeps running, and when it changes locomotor set,
chooseLocomotorSetExplicit()throws that instance away and rebuilds from template:Locomotor::Locomotor(const LocomotorTemplate*)starts fresh —m_maxLift = BIGNUM,m_maxTurnRate = BIGNUM. The wreck silently regains full lift and turn rate. It is no longerfalling; it is a flying aircraft with no pilot, and
DestructionDelay = 99999999means nothingremoves it. It circles until the match ends.
The fix refuses the rebuild for an effectively-dead aircraft, so the slow-death locomotor survives
and the wreck falls.
Why this layer
HelicopterSlowDeathUpdatemutates the current locomotor the same way(
setMaxLift+setMaxBraking), so the exposure is not jets-only. Counting retail templates thatcarry a jet or helicopter slow-death module: 77 total, of which 45 never run
JetAIUpdate—27 use
DeliverPayloadAIUpdate, 10 useChinookAIUpdate(Chinook, Helix), 8 use plainAIUpdateInterface. A guard in a single AI module cannot cover them;chooseLocomotorSetExplicitis the one non-virtual funnel every module ends up in.
Retail already tried the per-call-site approach.
ChinookAIUpdate.cpprefuses to restore lift on adead helicopter, with the right comment attached:
...and then six lines later calls
ai->chooseLocomotorSet(LOCOMOTORSET_TAXIING), which rebuilds theset and restores
BIGNUManyway. Every direct lift restore is guarded; the indirect one throughthe set rebuild is not. That is the bug.
Evidence
VC6 Release build (
RTS_BUILD_OPTION_DEBUG=OFF), with a temporary probe logging any locomotor-setrebuild on an effectively-dead aircraft. Captured live (USA Superweapon, Auroras guarded then killed
airborne):
newset=7isLOCOMOTORSET_SLUGGISH;hadLoco=1confirms the slow-death locomotor was present andabout to be discarded;
airborne=1confirms it was still in the air.A/B on that recording, same binary, fix toggled by env var so playback is identical up to the
first block:
airborne=1, circles indefinitelyairborne=0at frame 18894 — falls in ~3 secondsThe CRC mismatch that follows in the fixed run is expected: the wreck now hits the ground, so the
simulation legitimately diverges from a recording of the broken behaviour.
No regression, against the full
GeneralsReplays/GeneralsZH/1.04CI corpus — 10 replays,roughly four hours of game time:
Both passes produced byte-identical stdout. The corpus never reaches the guarded state, which is why
it is unaffected either way.
Gating
The guard changes simulation state on a path retail reaches, so it sits behind
#if !RETAIL_COMPATIBLE_CRClike the other 85 blocks in the tree. That macro defaults to1andnothing in the build system overrides it, so this ships disabled today and becomes live when the
project drops retail CRC compatibility. If maintainers would rather have it enabled sooner, the
paired
PRESERVE_*form used elsewhere inGameDefines.his the mechanism — that is a call for theproject, not for this PR.
Known limits
newset=7is not recoverable from this probe. It logswstfrominside
chooseLocomotorSetExplicit, i.e. afterJetAIUpdate::chooseLocomotorSethas alreadyrewritten the requested set to
m_returningLoco.LOCOMOTORSET_SLUGGISHappears in no.cppatall, so the value can only have come from
ReturnForAmmoLocomotorType = SET_SLUGGISH, which inretail is set on the five Aurora variants and nothing else. An earlier revision of this
description attributed it to a Guard order; that attribution is not supported by the probe output
and has been removed.
JetAIUpdatecallssetMaxLift()directly in several places for landing/takeoff sequencing.Those bypass the locomotor set change entirely, so this fix does not intercept them. Whether they
are reachable on a dead jet is not established here.
above). Harmless and cheap, but it indicates the state machine is spinning on a transition it can
never complete. Not driving a dead aircraft's AI at all may be the better long-term shape, and is
a much bigger behavioural change than this one.