CVE-2022-4879: Other (NVD-CWE-Other)
value: A vulnerability was found in Forged Alliance Forever up to 3746. It has been declared as critical. Affected by this vulnerability is an unknown functionality of the component Vote Handler. The manipulation leads to improper authorization. Upgrading to version 3747 is able to address this issue. The patch is named 6880971bd3d73d942384aff62d53058c206ce644. It is recommended to upgrade the affected component. The associated identifier of this vulnerability is VDB-217555.
CWE description: NVD is only using a subset of CWE for mapping instead of the entire CWE, and the weakness type is not covered by that subset.
CVSS v3 base score: 7.5
Published: 2023-01-06T11:15Z
Repository: https://github.com/FAForever/fa
Language: Lua
Files changed: lua/SimSync.lua, lua/SimUtils.lua, lua/sim/Recall.lua, lua/ui/game/diplomacy.lua, lua/ui/game/recall.lua
Commit date: 2022-11-15 18:05:38 +0100
Commit message: Update recall feature (#4398)

Fix a few exploits that people could use to vote for another player
Diff: diff --git a/lua/SimSync.lua b/lua/SimSync.lua
index 92585fe78..14352dc6c 100644
--- a/lua/SimSync.lua
+++ b/lua/SimSync.lua
@@ -123,16 +123,16 @@ function OnPostLoad()
 end
 
 function NoteFocusArmyChanged(new, old)
     --LOG('NoteFocusArmyChanged(new=' .. repr(new) .. ', old=' .. repr(old) .. ')')
     import("/lua/simping.lua").OnArmyChange()
+    import("/lua/sim/recall.lua").OnArmyChange()
     for entityID, data in UnitData do
         if new == -1 or data.OwnerArmy == new then
             Sync.UnitData[entityID] = data.Data
         elseif old == -1 or data.OwnerArmy == old then
             Sync.ReleaseIds[entityID] = true
-        else
         end
     end
     SyncUnitEnhancements()
     Sync.FocusArmyChanged = {new = new, old = old}
 end
diff --git a/lua/SimUtils.lua b/lua/SimUtils.lua
index d178e728a..a227c968b 100644
--- a/lua/SimUtils.lua
+++ b/lua/SimUtils.lua
@@ -701,10 +701,11 @@ function BreakAlliance(data)
             Sync.BrokenAlliances = {}
         end
         table.insert(Sync.BrokenAlliances, { From = data.From, To = data.To })
     end
     import("/lua/simping.lua").OnAllianceChange()
+    import("/lua/sim/recall.lua").OnAllianceChange(data)
 end
 
 ---@param resultData {From: number, To: number, ResultValue: DiplomacyActionType}
 function OnAllianceResult(resultData)
     -- You cannot change alliances in a team game
diff --git a/lua/sim/Recall.lua b/lua/sim/Recall.lua
index 401a962e7..37477e7f9 100644
--- a/lua/sim/Recall.lua
+++ b/lua/sim/Recall.lua
@@ -1,110 +1,188 @@
 
+-- import recall parameters
 doscript "/lua/shared/RecallParams.lua"
 
----@alias CannotRecallReason "active" | "ai" | "gate" | "request" | "scenario" | "vote" | false
+local SyncAnnouncement = import("/lua/simdiplomacy.lua").SyncAnnouncement
+
+
+---@alias CannotRecallReason false
+---| "active"
+---| "ai"
+---| "gate"
+---| "request"
+---| "scenario"
+---| "vote"
+---| "observer"
+
 
 function init()
+    -- setup sim recall state in the brains
+    local playerCooldown = PlayerGateCooldown - PlayerRequestCooldown
+    local teamCooldown = PlayerGateCooldown - TeamVoteCooldown
     for _, brain in ArmyBrains do
-        brain.LastRecallRequestTime = PlayerGateCooldown - PlayerRequestCooldown
-        brain.LastRecallVoteTime = PlayerGateCooldown - TeamVoteCooldown
-    end
-    ForkThread(function()
-        if ScenarioInfo.RecallDisabled then
-            Sync.RecallRequest = {CannotRequest = "scenario"}
-        else
-            Sync.RecallRequest = {CannotRequest = "gate"}
-            WaitTicks(PlayerGateCooldown + 1)
-
-            local focus = GetFocusArmy()
-            local brain = GetArmyBrain(focus)
-            for index, brainWith in ArmyBrains do
-                if brain ~= brainWith then
-                    if  not brain:IsDefeated() and
-                        IsAlly(focus, index) and
-                        brainWith.BrainType ~= "Human" and
-                        not ArmyIsCivilian(index)
-                    then
-                        Sync.RecallRequest = {CannotRequest = "ai"}
-                        return
-                    end
+        brain.LastRecallRequestTime = playerCooldown
+        brain.LastRecallVoteTime = teamCooldown
+    end
+
+    -- setup user recall state notifier in this thread
+    SyncRecallStatus()
+end
+
+function OnArmyChange()
+    local focus = GetFocusArmy()
+    if focus == -1 then
+        SyncCancelRecallVote()
+        SyncRecallStatus()
+        return
+    end
+    local teamSize = 0
+    local accept, veto = 0, 0
+    local votingThreadBrain
+    for index, brain in ArmyBrains do
+        if IsAlly(focus, index) and not ArmyIsCivilian(index) then
+            -- Found a voting thread. We really do need a better way to handle team data...
+            teamSize = teamSize + 1
+            if brain.Vote ~= nil then
+                if brain.Vote then
+                    accept = accept + 1
+                else
+                    veto = veto + 1
                 end
             end
-            Sync.RecallRequest = {CannotRequest = false}
+            if brain.recallVotingThread then
+                votingThreadBrain = brain
+            end
+        end
+    end
+    if votingThreadBrain then
+        Sync.RecallRequest = {
+            StartTime = votingThreadBrain.RecallVoteStartTime,
+            Open = VoteTime * 0.1,
+            Blocks = teamSize,
+            Accept = accept,
+            Veto = veto,
+            CanVote = GetArmyBrain(focus).Vote ~= nil,
+        }
+    end
+    SyncRecallStatus()
+end
+
+---@param data {From: number, To: number}
+function OnAllianceChange(data)
+    local armyFrom, armyTo = data.From, data.To
+    local oldTeammates = 0
+    local oldTeam = {}
+    local votingThreadBrain
+    for index, ally in ArmyBrains do
+        if (IsAlly(armyFrom, index) or IsAlly(armyTo, index))
+            and not ally:IsDefeated()
+            and not ArmyIsCivilian(index)
+        then
+            oldTeammates = oldTeammates + 1
+            oldTeam[oldTeammates] = ally.Nickname
+            -- Found a voting thread. We really do need a better way to handle team data...
+            if ally.recallVotingThread then
+                votingThreadBrain = ally
+            end
+        end
+    end
+    if votingThreadBrain then
+        SPEW("Canceling recall voting for team " .. table.concat(oldTeam, ", ") .. " due to alliance break")
+        votingThreadBrain.VoteCancelled = true
+        coroutine.resume(votingThreadBrain.recallVotingThread)
+        if IsAlly(votingThreadBrain, GetFocusArmy()) then
+            SyncCancelRecallVote()
+            SyncRecallStatus()
         end
-    end)
+    end
 end
 
+
 ---@param lastTeamVote number
 ---@param lastPlayerRequest number
 ---@param playerGatein? number
----@return number cooldown
----@return CannotRecallReason reason
+---@return CannotRecallReason CannotRecallReason
+---@return number? cooldown
 function RecallRequestCooldown(lastTeamVote, lastPlayerRequest, playerGatein)
-    playerGatein = playerGatein or 0
+    -- note that this doesn't always return the reason that currently has the longest cooldown, it
+    -- returns the more "fundamental" one (i.e. the reason whose base cooldown is longest)
+    -- this is more useful in reporting the reason, and isn't a problem when put in a loop
     local gametime = GetGameTick()
-    local reqCooldown = lastPlayerRequest + PlayerRequestCooldown - gametime
-    local voteCooldown = lastTeamVote + TeamVoteCooldown - gametime
-    local gateCooldown = playerGatein + PlayerGateCooldown - gametime
-
-    local largest, reason = reqCooldown, "request"
-    if largest < voteCooldown then
-        largest, reason = voteCooldown, "vote"
+    local gateCooldown = (playerGatein or 0) + PlayerGateCooldown - gametime
+    if gateCooldown > 0 then
+        return "gate", gateCooldown
     end
-    if largest < gateCooldown then
-        largest, reason = gateCooldown, "gate"
+    local reqCooldown = lastPlayerRequest + PlayerRequestCooldown - gametime
+    if reqCooldown > 0 then
+        return "request", reqCooldown
     end
-    if largest < 0 then
-        return 0, false
+    local voteCooldown = lastTeamVote + TeamVoteCooldown - gametime
+    if voteCooldown > 0 then
+        return "vote", voteCooldown
     end
-    return largest, reason
+    return false
 end
 
---- Returns the recall request cooldown for an army
+--- Returns the current reason an army cannot request recall and the cooldown of that reason, or
+--- false
 ---@param army Army
----@return number cooldown
----@return string reason
+---@return CannotRecallReason
+---@return number? cooldown no timeout/cooldown if absent
 function ArmyRecallRequestCooldown(army)
+    if army == -1 then
+        return "observer"
+    end
     local brain = GetArmyBrain(army)
     if ScenarioInfo.RecallDisabled then
-        return 36000, "scenario"
+        return "scenario"
     end
     if brain.RecallVote ~= nil then
-        return 36000, "active"
+        return "active", VoteTime
     end
     local lastPlayerRequest = brain.LastRecallRequestTime
     local lastTeamVote
     army = brain.Army
     lastTeamVote = lastPlayerRequest
     for index, ally in ArmyBrains do
-        if not ally:IsDefeated() and IsAlly(army, index) and not ArmyIsCivilian(index) then
+        if IsAlly(army, index) and not ally:IsDefeated() and not ArmyIsCivilian(index) then
+            if ally.BrainType ~= "Human" then
+                return "ai"
+            end
             local allyTeamVote = ally.LastRecallVoteTime
             if allyTeamVote < lastTeamVote then
                 lastTeamVote = allyTeamVote
             end
         end
     end
+    -- if someone adds a feature that gates in commanders at different times, that time should be
+    -- added as an argument to this method call
     return RecallRequestCooldown(lastTeamVote, lastPlayerRequest)
 end
 
----@param reason CannotRecallReason
-local function SetCannotRequestRecall(reason)
-    local recallSync = Sync.RecallRequest
-    if not recallSync then
-        Sync.RecallRequest = {CannotRequest = reason}
-    else
-        recallSync.CannotRequest = reason
-    end
-end
-
+---@param requestingArmy number
 local function RecallVotingThread(requestingArmy)
-    WaitTicks(VoteTime + 1)
+    local requestingBrain = GetArmyBrain(requestingArmy)
+    requestingBrain.RecallVoteStartTime = GetGameTick()
+    WaitTicks(VoteTime) -- may be interrupted if the vote closes or is canceled by an alliance break
+
+    local focus = GetFocusArmy()
+    if requestingBrain.VoteCancelled then
+        if IsAlly(requestingArmy, focus) then
+            SyncCancelRecallVote()
+            SyncRecallStatus()
+        end
+        requestingBrain.VoteCancelled = nil
+        requestingBrain.RecallVoteStartTime = nil
+        requestingBrain.recallVotingThread = nil
+        return
+    end
 
     local gametick = GetGameTick()
     local recallAcceptance = 0
-    local team = {}
     local teammates = 0
+    local team = {}
     for index, brain in ArmyBrains do
         if not brain:IsDefeated() and IsAlly(requestingArmy, brain.Army) and not ArmyIsCivilian(index) then
             teammates = teammates + 1
             team[teammates] = brain
             if brain.RecallVote then
@@ -112,53 +190,51 @@ local function RecallVotingThread(requestingArmy)
             end
             brain.RecallVote = nil
             brain.LastRecallVoteTime = gametick
         end
     end
+    -- this function is found in the recall params file, for those looking
     local recallAccepted = RecallRequestAccepted(recallAcceptance, teammates)
-    local focus = GetFocusArmy()
-    local brain = GetArmyBrain(requestingArmy)
     if IsAlly(focus, requestingArmy) then
-        Sync.RecallRequest = {
-            Close = recallAccepted,
-        }
+        SyncCloseRecallVote(recallAccepted)
+        -- the recall UI will handle the announcement in this case
     elseif recallAccepted then
-        local dip = Sync.DiplomacyAnnouncement
-        if not dip then
-            dip = {}
-            Sync.DiplomacyAnnouncement = dip
-        end
-        table.insert(dip, {
+        -- in this case though, we need to handle the announcement
+        SyncAnnouncement {
             Action = "recall",
-            Team = brain.Nickname,
-        })
+            Team = requestingBrain.Nickname,
+        }
+    end
+    local listTeam = team[1].Nickname
+    for i = 2, table.getn(team) do
+        listTeam = listTeam .. ", " .. team[i].Nickname
     end
     if recallAccepted then
-        SPEW("Vote passed; recalling!")
-        for army, brain in team do
+        SPEW("Recalling team " .. listTeam .. " at the request of " .. requestingBrain.Nickname .. " (vote passed " .. recallAcceptance .. " to " .. (teammates - recallAcceptance ) .. ")")
+        for _, brain in team do
             brain:RecallAllCommanders()
         end
     else
-        brain.LastRecallRequestTime = gametick
-
+        SPEW("Not recalling team " .. listTeam .. " (vote failed " .. recallAcceptance .. " to " .. (teammates - recallAcceptance ) .. ")")
+        requestingBrain.LastRecallRequestTime = gametick
+    end
+    if IsAlly(requestingArmy, focus) then
         -- update UI once the cooldown dissipates
-        local cooldown, reason = ArmyRecallRequestCooldown(focus)
-        repeat
-            SetCannotRequestRecall(reason)
-            WaitTicks(cooldown + 1)
-
-            cooldown, reason = ArmyRecallRequestCooldown(focus)
-        until cooldown <= 0
-        SetCannotRequestRecall(false)
+        SyncRecallStatus()
     end
-    brain.recallVotingThread = nil
+    requestingBrain.RecallVoteStartTime = nil
+    requestingBrain.recallVotingThread = nil
 end
 
+---@param army number
+---@param vote boolean
+---@param lastVote boolean
+---@return boolean # if further user sync should happen
 local function ArmyVoteRecall(army, vote, lastVote)
     if lastVote then
         for index, ally in ArmyBrains do
-            if army ~= index and not ally:IsDefeated() and IsAlly(army, index) then
+            if army ~= index and IsAlly(army, index) and not ally:IsDefeated() then
                 local thread = ally.recallVotingThread
                 if thread then
                     -- end voting period
                     coroutine.resume(thread)
                     break
@@ -166,94 +242,175 @@ local function ArmyVoteRecall(army, vote, lastVote)
             end
         end
     end
 
     local focus = GetFocusArmy()
-    -- don't update the recall panel for votes we aren't a part of
-    if not IsAlly(focus, army) then
-        return false
-    end
-    local recallSync = Sync.RecallRequest
-    if not recallSync then
-        recallSync = {}
-        Sync.RecallRequest = recallSync
-    end
-    if vote then
-        local accept = recallSync.Accept or 0
-        recallSync.Accept = accept + 1
-    else
-        local veto = recallSync.Veto or 0
-        recallSync.Veto = veto + 1
+    if focus == -1 or not IsAlly(focus, army) then
+        return false -- don't update the recall panel for votes we aren't a part of or for observers
     end
+    SyncRecallVote(vote)
     if army == focus then
-        recallSync.CannotRequest = "active"
+        SyncCannotRequestRecall("active")
     end
     return true
 end
 
-local function ArmyRequestRecall(army, teammates, lastVote)
+---@param army number
+---@param teammates number
+local function ArmyRequestRecall(army, teammates)
+    local brain = GetArmyBrain(army)
     if teammates > 0 then
-        GetArmyBrain(army).recallVotingThread = ForkThread(RecallVotingThread, army)
-        if ArmyVoteRecall(army, true, lastVote) then
-            local recallSync = Sync.RecallRequest
-            recallSync.Open = VoteTime * 0.1
-            recallSync.CanVote = army ~= GetFocusArmy()
-            recallSync.Blocks = teammates + 1
+        brain.recallVotingThread = ForkThread(RecallVotingThread, army)
+        if ArmyVoteRecall(army, true, false) then
+            SyncOpenRecallVote(teammates + 1, army)
         end
-    elseif lastVote then
-        -- if we're the first and last vote, it's just us; recall our army
-        SPEW("Immediately recalling")
-        GetArmyBrain(army):RecallAllCommanders()
+    else
+        -- it's just us; recall our army
+        SPEW("Recalling " .. brain.Nickname)
+        brain:RecallAllCommanders()
+
     end
 end
 
 ---@param data {From: number, Vote: boolean}
 function SetRecallVote(data)
-    local focus = GetFocusArmy()
     local army = data.From
+    if not OkayToMessWithArmy(army) then
+        return
+    end
+    local focus = GetFocusArmy()
     if not ScenarioInfo.TeamGame then
         if army == focus then
-            SetCannotRequestRecall("scenario")
+            SyncCannotRequestRecall("scenario")
         end
         return
     end
     local vote = data.Vote and true or false
 
+    -- determine team voting status
     local isRequest = true
     local lastVote = true
     local teammates = 0
+    local team = {}
     for index, ally in ArmyBrains do
         if army ~= index and not ally:IsDefeated() and IsAlly(army, index) and not ArmyIsCivilian(index) then
             if ally.BrainType ~= "Human" then
                 if army == focus then
-                    SetCannotRequestRecall("ai")
+                    SyncCannotRequestRecall("ai")
                 end
                 return
             end
             local allyHasVoted = ally.RecallVote ~= nil
-            lastVote = lastVote and allyHasVoted
-            isRequest = isRequest and not allyHasVoted
+            lastVote = lastVote and allyHasVoted -- only the last vote if all allies have also voted
+            isRequest = isRequest and not allyHasVoted -- only the last vote if no allies have voted
             teammates = teammates + 1
+            team[teammates] = ally.Nickname
         end
     end
 
     local brain = GetArmyBrain(army)
     if isRequest then
         -- the player is making a recall request; this will reset their recall request cooldown
-        local cooldown, reason = ArmyRecallRequestCooldown(army)
-        if cooldown > 0 then
+        local reason = ArmyRecallRequestCooldown(army)
+        if reason then
             if army == focus then
-                SetCannotRequestRecall(reason)
+                SyncCannotRequestRecall(reason)
             end
             return
         end
-        SPEW("Army " .. tostring(army) .. " is requesting recall")
+        SPEW("Army " .. tostring(army) .. " is requesting recall for " .. table.concat(team, ','))
         brain.RecallVote = vote
-        ArmyRequestRecall(army, teammates, lastVote)
+        ArmyRequestRecall(army, teammates)
     else
         -- the player is responding to a recall request; we don't count this against their
         -- individual recall request cooldown
         SPEW("Army " .. tostring(army) .. " recall vote: " .. (vote and "accept" or "veto"))
         brain.RecallVote = vote
         ArmyVoteRecall(army, vote, lastVote)
     end
 end
+
+
+---@param reason CannotRecallReason
+function SyncCannotRequestRecall(reason)
+    local recallSync = Sync.RecallRequest
+    if not recallSync then
+        Sync.RecallRequest = {CannotRequest = reason}
+    else
+        recallSync.CannotRequest = reason
+    end
+end
+
+---@param result boolean
+function SyncCloseRecallVote(result)
+    local recallSync = Sync.RecallRequest
+    if not recallSync then
+        Sync.RecallRequest = {Close = result}
+    else
+        recallSync.Close = result
+    end
+end
+
+function SyncCancelRecallVote()
+    local recallSync = Sync.RecallRequest
+    if not recallSync then
+        Sync.RecallRequest = {Cancel = true}
+    else
+        recallSync.Cancel = true
+    end
+end
+
+---@param vote boolean
+function SyncRecallVote(vote)
+    local recallSync = Sync.RecallRequest
+    if not recallSync then
+        recallSync = {}
+        Sync.RecallRequest = recallSync
+    end
+    if vote then
+        recallSync.Accept = (recallSync.Accept or 0) + 1
+    else
+        recallSync.Veto = (recallSync.Veto or 0) + 1
+    end
+end
+
+---@param teamSize number
+---@param army number
+function SyncOpenRecallVote(teamSize, army)
+    local recallSync = Sync.RecallRequest
+    if not recallSync then
+        recallSync = {}
+        Sync.RecallRequest = recallSync
+    end
+    local focus = GetFocusArmy()
+    recallSync.Open = VoteTime * 0.1
+    recallSync.CanVote = focus ~= -1 and army ~= focus
+    recallSync.Blocks = teamSize
+end
+
+local UserRecallStatusThread
+
+local function SyncRecallStatusThread()
+    local reason, cooldown = ArmyRecallRequestCooldown(GetFocusArmy())
+    while reason do
+        SyncCannotRequestRecall(reason)
+        if not cooldown then
+            UserRecallStatusThread = nil
+            return
+        end
+        -- may be interrupted for various reasons, such as the focus army changing
+        -- this will be fine, we'll pick up the proper cooldown reason anyway and loop again
+        WaitTicks(cooldown)
+
+        reason, cooldown = ArmyRecallRequestCooldown(GetFocusArmy())
+    end
+    SyncCannotRequestRecall(false)
+    UserRecallStatusThread = nil
+end
+
+function SyncRecallStatus()
+    if UserRecallStatusThread then
+        coroutine.resume(UserRecallStatusThread) -- force update the existing thread
+    else
+        UserRecallStatusThread = ForkThread(SyncRecallStatusThread)
+    end
+end
diff --git a/lua/ui/game/diplomacy.lua b/lua/ui/game/diplomacy.lua
index 49ccfe356..0f7c1a2cb 100644
--- a/lua/ui/game/diplomacy.lua
+++ b/lua/ui/game/diplomacy.lua
@@ -34,18 +34,18 @@ local alliedVictory = true
 local dialogue = false
 local offerQueue = {}
 local drawOffered = false
 
 ---@type CannotRecallReason
-local cannotRequestRecall = false
+local CannotRequestRecallReason = false
 
-function CannotRequestRecall()
-    return cannotRequestRecall
+function GetCannotRequestRecallReason()
+    return CannotRequestRecallReason
 end
 
-function UpdateCannotRequestRecall(canRequest)
-    cannotRequestRecall = canRequest
+function SetCannotRequestRecallReason(canRequest)
+    CannotRequestRecallReason = canRequest
     if parent then
         BuildPlayerLines()
     end
 end
 
@@ -377,10 +377,16 @@ end
 
 
 function BuildPlayerLines()
     local sessionOptions = SessionGetScenarioInfo().Options
     local focusArmy = GetFocusArmy()
+    if focusArmy == -1 then
+        focusArmy = import("/lua/ui/game/gamemain.lua").OriginalFocusArmy
+        if focusArmy == -1 then
+            return
+        end
+    end
 
     local group = parent.personalGroup
     if group then
         group:Destroy()
     end
@@ -414,11 +420,12 @@ function BuildPlayerLines()
         end
     end
 
     local belowEntry = parent
 
-    if not import("/lua/ui/campaign/campaignmanager.lua").campaignMode then
+    local reason = GetCannotRequestRecallReason()
+    if not import("/lua/ui/campaign/campaignmanager.lua").campaignMode and reason ~= "observer" then
         local personalGroup = CreateBitmapStd(parent, "/game/options-diplomacy-panel/panel-recall")
         if belowEntry == parent then
             LayoutHelpers.AtLeftTopIn(personalGroup, belowEntry, 0, 8)
         else
             LayoutHelpers.Below(personalGroup, belowEntry, 8)
@@ -429,41 +436,46 @@ function BuildPlayerLines()
         Layouter(recallButton)
             :AtCenterIn(personalGroup)
             :Over(personalGroup, 5)
         personalGroup.button = recallButton
 
-        local reason = CannotRequestRecall()
         if reason then
             recallButton:Disable()
             Tooltip.AddButtonTooltip(recallButton, "dip_recall_request_dis_" .. reason)
         else
+            local function OnAcceptRecall()
+                SimCallback({
+                    Func = "SetRecallVote",
+                    Args = {
+                        From = GetFocusArmy(),
+                        Vote = true,
+                    },
+                })
+                -- preemptively expect the sim to accept our recall and disable the button so we
+                -- can't possiblely confuse the sim with more than one request
+                -- note that if--for some reason (*ahem* due to a mod maybe)--the sim *doesn't*
+                -- end up accepting it, then we'll be stuck until the sim sends a new update
+                recallButton:Disable()
+                SetCannotRequestRecallReason("active")
+                Tooltip.AddButtonTooltip(recallButton, "dip_recall_request_dis_active")
+                import("/lua/ui/game/tabs.lua").CollapseWindow()
+            end
             recallButton.OnClick = function(self, modifiers)
                 UIUtil.QuickDialog(GetFrame(0),
                     "<LOC diplomacy_0019>Are you sure you're ready to recall from battle? This will send a request to your team.",
                     "<LOC _Yes>",
-                    function()
-                        SimCallback({
-                            Func = "SetRecallVote",
-                            Args = {
-                                From = GetFocusArmy(),
-                                Vote = true,
-                            },
-                        })
-                        recallButton:Disable()
-                        cannotRequestRecall = "active"
-                        Tooltip.AddButtonTooltip(recallButton, "dip_recall_request_dis_active")
-                        import("/lua/ui/game/tabs.lua").CollapseWindow()
-                    end,
+                    OnAcceptRecall,
                     "<LOC _No>", nil, nil, nil, nil,
                     {worldCover = false, enterButton = 1, escapeButton = 2}
                 )
             end
             Tooltip.AddButtonTooltip(recallButton, "dip_recall_request")
         end
 
         local recallIcon = CreateBitmapStd(recallButton, "/game/recall-panel/icon-recall")
         Layouter(recallIcon)
+            --:DisableHitTest()
             :AtCenterIn(recallButton)
             :Over(recallButton, 5)
         recallButton.label = recallIcon
 
         belowEntry = personalGroup
diff --git a/lua/ui/game/recall.lua b/lua/ui/game/recall.lua
index 33c7f38cf..c092474df 100644
--- a/lua/ui/game/recall.lua
+++ b/lua/ui/game/recall.lua
@@ -56,21 +56,23 @@ function ToggleControl()
     end
 end
 
 function RequestHandler(data)
     if data.CannotRequest ~= nil then
-        import("/lua/ui/game/diplomacy.lua").UpdateCannotRequestRecall(data.CannotRequest)
+        import("/lua/ui/game/diplomacy.lua").SetCannotRequestRecallReason(data.CannotRequest)
     end
     if data.Open then
-        panel:StartVote(data.Blocks, data.Open, data.CanVote)
+        panel:StartVote(data.Blocks, data.Open, data.CanVote, data.StartTime)
     end
     local accept, veto = data.Accept, data.Veto
     if accept or veto then
         panel:AddVotes(accept, veto)
     end
     if data.Close ~= nil then
         panel:CloseVote(data.Close)
+    elseif data.Cancel then
+        panel:CancelVote()
     end
 end
 
 ---@class RecallPanel : NinePatch
 RecallPanel = Class(NinePatch.NinePatch) {
@@ -264,14 +266,13 @@ RecallPanel = Class(NinePatch.NinePatch) {
             buttonAccept:Hide()
             buttonVeto:Hide()
         end
     end;
 
-    StartVote = function(self, blocks, duration, canVote)
-        SPEW("Recall voting!")
+    StartVote = function(self, blocks, duration, canVote, startingTime)
         self.duration = duration
-        self.startTime:Set(GetGameTimeSeconds())
+        self.startTime:Set(startingTime or GetGameTimeSeconds())
         self:SetCanVote(canVote)
         self:LayoutBlocks(blocks) -- can depend on `canVote`, so put after it
         self.collapseArrow:Enable()
         self.collapseArrow:Show()
         self.collapseArrow:SetCheck(false)
@@ -295,10 +296,20 @@ RecallPanel = Class(NinePatch.NinePatch) {
                 self.reviewResultsThread = nil
             end, self)
         end
     end;
 
+    CancelVote = function(self)
+        self:SetCanVote(false)
+        self.startTime:Set(-9999) -- make sure the OnFrame animation ends
+        if self.reviewResultsThread then
+            KillThread(self.reviewResultsThread)
+            self.reviewResultsThread = nil
+        end
+        self:OnResultsReviewed()
+    end;
+
     CloseVote = function(self, accepted)
         self:SetCanVote(false)
         self.startTime:Set(-9999) -- make sure the OnFrame animation ends
         if self.reviewResultsThread then
             -- continue the OnSecond animation if it exists
@@ -384,10 +395,11 @@ RecallPanel = Class(NinePatch.NinePatch) {
             time = GetGameTimeSeconds() - time
             local nominalWidth = self.Width() - LayoutHelpers.ScaleNumber(16)
             if time >= dur then
                 self.startTime:Set(-9999)
                 self.progressBar.Width:Set(0)
+                self.progressBar:Hide()
             else
                 self.progressBar.Width:Set((1 - time / dur) * nominalWidth)
             end
             notAnimating = false
         end

Vulnerable code:         else
        brain.LastRecallRequestTime = PlayerGateCooldown - PlayerRequestCooldown
        brain.LastRecallVoteTime = PlayerGateCooldown - TeamVoteCooldown
    end
    ForkThread(function()
        if ScenarioInfo.RecallDisabled then
            Sync.RecallRequest = {CannotRequest = "scenario"}
        else
            Sync.RecallRequest = {CannotRequest = "gate"}
            WaitTicks(PlayerGateCooldown + 1)

            local focus = GetFocusArmy()
            local brain = GetArmyBrain(focus)
            for index, brainWith in ArmyBrains do
                if brain ~= brainWith then
                    if  not brain:IsDefeated() and
                        IsAlly(focus, index) and
                        brainWith.BrainType ~= "Human" and
                        not ArmyIsCivilian(index)
                    then
                        Sync.RecallRequest = {CannotRequest = "ai"}
                        return
                    end
            Sync.RecallRequest = {CannotRequest = false}
    end)
    playerGatein = playerGatein or 0
    local reqCooldown = lastPlayerRequest + PlayerRequestCooldown - gametime
    local voteCooldown = lastTeamVote + TeamVoteCooldown - gametime
    local gateCooldown = playerGatein + PlayerGateCooldown - gametime

    local largest, reason = reqCooldown, "request"
    if largest < voteCooldown then
        largest, reason = voteCooldown, "vote"
    if largest < gateCooldown then
        largest, reason = gateCooldown, "gate"
    if largest < 0 then
        return 0, false
    return largest, reason
        return 36000, "scenario"
        return 36000, "active"
        if not ally:IsDefeated() and IsAlly(army, index) and not ArmyIsCivilian(index) then
local function SetCannotRequestRecall(reason)
    local recallSync = Sync.RecallRequest
    if not recallSync then
        Sync.RecallRequest = {CannotRequest = reason}
    else
        recallSync.CannotRequest = reason
    end
end

    WaitTicks(VoteTime + 1)
    local team = {}
    local focus = GetFocusArmy()
    local brain = GetArmyBrain(requestingArmy)
        Sync.RecallRequest = {
            Close = recallAccepted,
        }
        local dip = Sync.DiplomacyAnnouncement
        if not dip then
            dip = {}
            Sync.DiplomacyAnnouncement = dip
        end
        table.insert(dip, {
            Team = brain.Nickname,
        })
        SPEW("Vote passed; recalling!")
        for army, brain in team do
        brain.LastRecallRequestTime = gametick

        local cooldown, reason = ArmyRecallRequestCooldown(focus)
        repeat
            SetCannotRequestRecall(reason)
            WaitTicks(cooldown + 1)

            cooldown, reason = ArmyRecallRequestCooldown(focus)
        until cooldown <= 0
        SetCannotRequestRecall(false)
    brain.recallVotingThread = nil
            if army ~= index and not ally:IsDefeated() and IsAlly(army, index) then
    -- don't update the recall panel for votes we aren't a part of
    if not IsAlly(focus, army) then
        return false
    end
    local recallSync = Sync.RecallRequest
    if not recallSync then
        recallSync = {}
        Sync.RecallRequest = recallSync
    end
    if vote then
        local accept = recallSync.Accept or 0
        recallSync.Accept = accept + 1
    else
        local veto = recallSync.Veto or 0
        recallSync.Veto = veto + 1
        recallSync.CannotRequest = "active"
local function ArmyRequestRecall(army, teammates, lastVote)
        GetArmyBrain(army).recallVotingThread = ForkThread(RecallVotingThread, army)
        if ArmyVoteRecall(army, true, lastVote) then
            local recallSync = Sync.RecallRequest
            recallSync.Open = VoteTime * 0.1
            recallSync.CanVote = army ~= GetFocusArmy()
            recallSync.Blocks = teammates + 1
    elseif lastVote then
        -- if we're the first and last vote, it's just us; recall our army
        SPEW("Immediately recalling")
        GetArmyBrain(army):RecallAllCommanders()
    local focus = GetFocusArmy()
            SetCannotRequestRecall("scenario")
                    SetCannotRequestRecall("ai")
            lastVote = lastVote and allyHasVoted
            isRequest = isRequest and not allyHasVoted
        local cooldown, reason = ArmyRecallRequestCooldown(army)
        if cooldown > 0 then
                SetCannotRequestRecall(reason)
        SPEW("Army " .. tostring(army) .. " is requesting recall")
        ArmyRequestRecall(army, teammates, lastVote)
local cannotRequestRecall = false
function CannotRequestRecall()
    return cannotRequestRecall
function UpdateCannotRequestRecall(canRequest)
    cannotRequestRecall = canRequest
    if not import("/lua/ui/campaign/campaignmanager.lua").campaignMode then
        local reason = CannotRequestRecall()
                    function()
                        SimCallback({
                            Func = "SetRecallVote",
                            Args = {
                                From = GetFocusArmy(),
                                Vote = true,
                            },
                        })
                        recallButton:Disable()
                        cannotRequestRecall = "active"
                        Tooltip.AddButtonTooltip(recallButton, "dip_recall_request_dis_active")
                        import("/lua/ui/game/tabs.lua").CollapseWindow()
                    end,
        import("/lua/ui/game/diplomacy.lua").UpdateCannotRequestRecall(data.CannotRequest)
        panel:StartVote(data.Blocks, data.Open, data.CanVote)
    StartVote = function(self, blocks, duration, canVote)
        SPEW("Recall voting!")
        self.startTime:Set(GetGameTimeSeconds())
Fixed code:     import("/lua/sim/recall.lua").OnArmyChange()
    import("/lua/sim/recall.lua").OnAllianceChange(data)
-- import recall parameters
local SyncAnnouncement = import("/lua/simdiplomacy.lua").SyncAnnouncement


---@alias CannotRecallReason false
---| "active"
---| "ai"
---| "gate"
---| "request"
---| "scenario"
---| "vote"
---| "observer"

    -- setup sim recall state in the brains
    local playerCooldown = PlayerGateCooldown - PlayerRequestCooldown
    local teamCooldown = PlayerGateCooldown - TeamVoteCooldown
        brain.LastRecallRequestTime = playerCooldown
        brain.LastRecallVoteTime = teamCooldown
    end

    -- setup user recall state notifier in this thread
    SyncRecallStatus()
end

function OnArmyChange()
    local focus = GetFocusArmy()
    if focus == -1 then
        SyncCancelRecallVote()
        SyncRecallStatus()
        return
    end
    local teamSize = 0
    local accept, veto = 0, 0
    local votingThreadBrain
    for index, brain in ArmyBrains do
        if IsAlly(focus, index) and not ArmyIsCivilian(index) then
            -- Found a voting thread. We really do need a better way to handle team data...
            teamSize = teamSize + 1
            if brain.Vote ~= nil then
                if brain.Vote then
                    accept = accept + 1
                else
                    veto = veto + 1
            if brain.recallVotingThread then
                votingThreadBrain = brain
            end
        end
    end
    if votingThreadBrain then
        Sync.RecallRequest = {
            StartTime = votingThreadBrain.RecallVoteStartTime,
            Open = VoteTime * 0.1,
            Blocks = teamSize,
            Accept = accept,
            Veto = veto,
            CanVote = GetArmyBrain(focus).Vote ~= nil,
        }
    end
    SyncRecallStatus()
end

---@param data {From: number, To: number}
function OnAllianceChange(data)
    local armyFrom, armyTo = data.From, data.To
    local oldTeammates = 0
    local oldTeam = {}
    local votingThreadBrain
    for index, ally in ArmyBrains do
        if (IsAlly(armyFrom, index) or IsAlly(armyTo, index))
            and not ally:IsDefeated()
            and not ArmyIsCivilian(index)
        then
            oldTeammates = oldTeammates + 1
            oldTeam[oldTeammates] = ally.Nickname
            -- Found a voting thread. We really do need a better way to handle team data...
            if ally.recallVotingThread then
                votingThreadBrain = ally
            end
        end
    end
    if votingThreadBrain then
        SPEW("Canceling recall voting for team " .. table.concat(oldTeam, ", ") .. " due to alliance break")
        votingThreadBrain.VoteCancelled = true
        coroutine.resume(votingThreadBrain.recallVotingThread)
        if IsAlly(votingThreadBrain, GetFocusArmy()) then
            SyncCancelRecallVote()
            SyncRecallStatus()
    end

---@return CannotRecallReason CannotRecallReason
---@return number? cooldown
    -- note that this doesn't always return the reason that currently has the longest cooldown, it
    -- returns the more "fundamental" one (i.e. the reason whose base cooldown is longest)
    -- this is more useful in reporting the reason, and isn't a problem when put in a loop
    local gateCooldown = (playerGatein or 0) + PlayerGateCooldown - gametime
    if gateCooldown > 0 then
        return "gate", gateCooldown
    local reqCooldown = lastPlayerRequest + PlayerRequestCooldown - gametime
    if reqCooldown > 0 then
        return "request", reqCooldown
    local voteCooldown = lastTeamVote + TeamVoteCooldown - gametime
    if voteCooldown > 0 then
        return "vote", voteCooldown
    return false
--- Returns the current reason an army cannot request recall and the cooldown of that reason, or
--- false
---@return CannotRecallReason
---@return number? cooldown no timeout/cooldown if absent
    if army == -1 then
        return "observer"
    end
        return "scenario"
        return "active", VoteTime
        if IsAlly(army, index) and not ally:IsDefeated() and not ArmyIsCivilian(index) then
            if ally.BrainType ~= "Human" then
                return "ai"
            end
    -- if someone adds a feature that gates in commanders at different times, that time should be
    -- added as an argument to this method call
---@param requestingArmy number
    local requestingBrain = GetArmyBrain(requestingArmy)
    requestingBrain.RecallVoteStartTime = GetGameTick()
    WaitTicks(VoteTime) -- may be interrupted if the vote closes or is canceled by an alliance break

    local focus = GetFocusArmy()
    if requestingBrain.VoteCancelled then
        if IsAlly(requestingArmy, focus) then
            SyncCancelRecallVote()
            SyncRecallStatus()
        end
        requestingBrain.VoteCancelled = nil
        requestingBrain.RecallVoteStartTime = nil
        requestingBrain.recallVotingThread = nil
        return
    end
    local team = {}
    -- this function is found in the recall params file, for those looking
        SyncCloseRecallVote(recallAccepted)
        -- the recall UI will handle the announcement in this case
        -- in this case though, we need to handle the announcement
        SyncAnnouncement {
            Team = requestingBrain.Nickname,
        }
    end
    local listTeam = team[1].Nickname
    for i = 2, table.getn(team) do
        listTeam = listTeam .. ", " .. team[i].Nickname
        SPEW("Recalling team " .. listTeam .. " at the request of " .. requestingBrain.Nickname .. " (vote passed " .. recallAcceptance .. " to " .. (teammates - recallAcceptance ) .. ")")
        for _, brain in team do
        SPEW("Not recalling team " .. listTeam .. " (vote failed " .. recallAcceptance .. " to " .. (teammates - recallAcceptance ) .. ")")
        requestingBrain.LastRecallRequestTime = gametick
    end
    if IsAlly(requestingArmy, focus) then
        SyncRecallStatus()
    requestingBrain.RecallVoteStartTime = nil
    requestingBrain.recallVotingThread = nil
---@param army number
---@param vote boolean
---@param lastVote boolean
---@return boolean # if further user sync should happen
            if army ~= index and IsAlly(army, index) and not ally:IsDefeated() then
    if focus == -1 or not IsAlly(focus, army) then
        return false -- don't update the recall panel for votes we aren't a part of or for observers
    SyncRecallVote(vote)
        SyncCannotRequestRecall("active")
---@param army number
---@param teammates number
local function ArmyRequestRecall(army, teammates)
    local brain = GetArmyBrain(army)
        brain.recallVotingThread = ForkThread(RecallVotingThread, army)
        if ArmyVoteRecall(army, true, false) then
            SyncOpenRecallVote(teammates + 1, army)
    else
        -- it's just us; recall our army
        SPEW("Recalling " .. brain.Nickname)
        brain:RecallAllCommanders()

    if not OkayToMessWithArmy(army) then
        return
    end
    local focus = GetFocusArmy()
            SyncCannotRequestRecall("scenario")
    -- determine team voting status
    local team = {}
                    SyncCannotRequestRecall("ai")
            lastVote = lastVote and allyHasVoted -- only the last vote if all allies have also voted
            isRequest = isRequest and not allyHasVoted -- only the last vote if no allies have voted
            team[teammates] = ally.Nickname
        local reason = ArmyRecallRequestCooldown(army)
        if reason then
                SyncCannotRequestRecall(reason)
        SPEW("Army " .. tostring(army) .. " is requesting recall for " .. table.concat(team, ','))
        ArmyRequestRecall(army, teammates)


---@param reason CannotRecallReason
function SyncCannotRequestRecall(reason)
    local recallSync = Sync.RecallRequest
    if not recallSync then
        Sync.RecallRequest = {CannotRequest = reason}
    else
        recallSync.CannotRequest = reason
    end
end

---@param result boolean
function SyncCloseRecallVote(result)
    local recallSync = Sync.RecallRequest
    if not recallSync then
        Sync.RecallRequest = {Close = result}
    else
        recallSync.Close = result
    end
end

function SyncCancelRecallVote()
    local recallSync = Sync.RecallRequest
    if not recallSync then
        Sync.RecallRequest = {Cancel = true}
    else
        recallSync.Cancel = true
    end
end

---@param vote boolean
function SyncRecallVote(vote)
    local recallSync = Sync.RecallRequest
    if not recallSync then
        recallSync = {}
        Sync.RecallRequest = recallSync
    end
    if vote then
        recallSync.Accept = (recallSync.Accept or 0) + 1
    else
        recallSync.Veto = (recallSync.Veto or 0) + 1
    end
end

---@param teamSize number
---@param army number
function SyncOpenRecallVote(teamSize, army)
    local recallSync = Sync.RecallRequest
    if not recallSync then
        recallSync = {}
        Sync.RecallRequest = recallSync
    end
    local focus = GetFocusArmy()
    recallSync.Open = VoteTime * 0.1
    recallSync.CanVote = focus ~= -1 and army ~= focus
    recallSync.Blocks = teamSize
end

local UserRecallStatusThread

local function SyncRecallStatusThread()
    local reason, cooldown = ArmyRecallRequestCooldown(GetFocusArmy())
    while reason do
        SyncCannotRequestRecall(reason)
        if not cooldown then
            UserRecallStatusThread = nil
            return
        end
        -- may be interrupted for various reasons, such as the focus army changing
        -- this will be fine, we'll pick up the proper cooldown reason anyway and loop again
        WaitTicks(cooldown)

        reason, cooldown = ArmyRecallRequestCooldown(GetFocusArmy())
    end
    SyncCannotRequestRecall(false)
    UserRecallStatusThread = nil
end

function SyncRecallStatus()
    if UserRecallStatusThread then
        coroutine.resume(UserRecallStatusThread) -- force update the existing thread
    else
        UserRecallStatusThread = ForkThread(SyncRecallStatusThread)
    end
end
local CannotRequestRecallReason = false
function GetCannotRequestRecallReason()
    return CannotRequestRecallReason
function SetCannotRequestRecallReason(canRequest)
    CannotRequestRecallReason = canRequest
    if focusArmy == -1 then
        focusArmy = import("/lua/ui/game/gamemain.lua").OriginalFocusArmy
        if focusArmy == -1 then
            return
        end
    end
    local reason = GetCannotRequestRecallReason()
    if not import("/lua/ui/campaign/campaignmanager.lua").campaignMode and reason ~= "observer" then
            local function OnAcceptRecall()
                SimCallback({
                    Func = "SetRecallVote",
                    Args = {
                        From = GetFocusArmy(),
                        Vote = true,
                    },
                })
                -- preemptively expect the sim to accept our recall and disable the button so we
                -- can't possiblely confuse the sim with more than one request
                -- note that if--for some reason (*ahem* due to a mod maybe)--the sim *doesn't*
                -- end up accepting it, then we'll be stuck until the sim sends a new update
                recallButton:Disable()
                SetCannotRequestRecallReason("active")
                Tooltip.AddButtonTooltip(recallButton, "dip_recall_request_dis_active")
                import("/lua/ui/game/tabs.lua").CollapseWindow()
            end
                    OnAcceptRecall,
            --:DisableHitTest()
        import("/lua/ui/game/diplomacy.lua").SetCannotRequestRecallReason(data.CannotRequest)
        panel:StartVote(data.Blocks, data.Open, data.CanVote, data.StartTime)
    elseif data.Cancel then
        panel:CancelVote()
    StartVote = function(self, blocks, duration, canVote, startingTime)
        self.startTime:Set(startingTime or GetGameTimeSeconds())
    CancelVote = function(self)
        self:SetCanVote(false)
        self.startTime:Set(-9999) -- make sure the OnFrame animation ends
        if self.reviewResultsThread then
            KillThread(self.reviewResultsThread)
            self.reviewResultsThread = nil
        end
        self:OnResultsReviewed()
    end;

                self.progressBar:Hide()