Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/snippets/ai.7256.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Fix error when an army is defeated related to missing AI platoon functions (#7256).
8 changes: 6 additions & 2 deletions lua/SimUtils.lua
Original file line number Diff line number Diff line change
Expand Up @@ -1437,8 +1437,12 @@ function DisableAI(self)
if not unit.Dead then
local handle = unit.PlatoonHandle
if handle and self:PlatoonExists(handle) then
handle:Stop()
handle:PlatoonDisbandNoAssign()
if handle.Stop then
handle:Stop()
end
if handle.PlatoonDisbandNoAssign then
handle:PlatoonDisbandNoAssign()
end
Comment on lines +1440 to +1445

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.

I read a bit of the platoon code, and it seems like there's some manual setmetatable use that could be leaving platoons with an incomplete/wrong platoon class?
For example, AIPlatoonAdaptiveSilo has a typo using AIPlatoonAdaptiveSiloBehavior instead of AIPlatoonAdaptiveSilo, but it's not being used anywhere so nobody caught the issue.

setmetatable(platoon, AIPlatoonAdaptiveSiloBehavior)

Could you post the replay and maybe investigate the metatable of the platoon that fails at being destroyed (for example if it is a mod using the adaptive silo)? These functions should never be missing because they're part of the base class. Even if the platoon is from a mod.

@relent0r relent0r Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This was based off a replay for my own AI.
https://replay.faforever.com/27584457

I don't disagree that it may have something to do with the state machine platoon implementation. The platoons in question were otherwise functioning fine.

I thought this would be the safest change to make that would be low risk of unforeseen consequences.

I did a dump of the platoon table when troubleshooting as I thought maybe I'd accidentally set it to an empty table, but the resulting dump looked the same as any other platoon and had the c object attached so I was thinking that the platoon was mid was through its own destruction.

In my testing I only had the issue happen once in say 10 games but a player pointed out that it happened alot more often for them with the difference being that they had a much higher player count.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

If you have suggestions of I could better troubleshoot it then I'm happy to try find the core reason.

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.

I did some metatable trickery in this branch to track down tracebacks of what changes were done to the platoon that failed:
https://github.com/FAForever/fa/tree/investigate-ai-ondefeat-failures

It's useful so I'll make a PR for it on a more organized branch.

I have included the output log in the branch files. What stands out to me is this final assignment of the failing platoon's metatable to nil, which would cause it to not have the necessary commands.

"[283.745270] Assign \"<metatable>\" as nil
from stack traceback:
...\lua\globalinit.lua:18>
...\lua\system\class.lua(802): in function `ChangeState'
...\mods\rngai\lua\ai\statemachines\platoon-base-rng.lua(284): in function `ChangeStateExt'
...\mods\rngai\lua\ai\statemachineutilities.lua(1777): in function `cb'
...\lua\sim\unit.lua(4177): in function `DoUnitCallbacks'
...\lua\sim\unit.lua(4321): in function `DoOnFailedToBuildCallbacks'
...\lua\sim\unit.lua:2936>
...\lua\sim\units\constructionunit.lua:117>
[C]: in function `IssueClearCommands'
...\mods\rngai\lua\ai\statemachines\platoon-engineer-resource.lua(726): in function <...\mods\rngai\lua\ai\statemachines\platoon-engineer-resource.lua:416>"

After investigating RNGAI, I saw:

            unit.PlatoonHandle:ChangeStateExt(unit.PlatoonHandle.PerformBuildTask)

Where PerformBuildTask is a State.
But that State only exists in lua\AI\StateMachines\platoon-engineer-utility.lua AIPlatoonEngineerBehavior
Our engineer is clearly using lua\AI\StateMachines\platoon-engineer-resource.lua AIPlatoonEngineerBehavior,
Despite having the same name, that file's class does not have PerformBuildTask and does not inherit from the utility engineer class.
Therefore we change the unit to a nil State, which loses the Stop and PlatoonDisbandNoAssign methods.
ChangeState has no nil check, so your nil does go directly into the metatable:

fa/lua/system/class.lua

Lines 784 to 818 in 653be9b

--- Switches up the sate of a class instance by inserting the new state between the instance and its class
---@param instance table The current instance we want to switch states for
---@param newState State the state we want to insert between the instance and its base class
function ChangeState(instance, newState)
-- call on-exit function
if instance.OnExitState then
instance:OnExitState()
end
-- keep track of the original thread and forget about it inside the object
local old_main_thread = instance.__mainthread
instance.__mainthread = nil
-- change the state accordingly by switching up the meta tables:
-- - entity
-- - state <-- introduced as an intermediate, prevents a lot of duplicated values and tables
-- - class
setmetatable(instance, newState)
-- call on-enter function
if instance.OnEnterState then
instance:OnEnterState()
end
-- start the new main thread if it wasn't already created during an OnEnterState
if instance.Main and not instance.__mainthread then
instance.__mainthread = ForkThread(instance.Main, instance)
end
-- remove the old main thread, threads are de-allocated when they've completed their computation chain
if old_main_thread then
old_main_thread:Destroy()
end
end

end
IssueStop({ unit })
IssueToUnitClearCommands(unit)
Expand Down