Skip to content

Prevent hatched Megalith eggs from being transferred - #7378

Open
lL1l1 wants to merge 3 commits into
developfrom
fix/megalith-egg-transfer
Open

lL1l1 wants to merge 3 commits into
developfrom
fix/megalith-egg-transfer

Conversation

@lL1l1

@lL1l1 lL1l1 commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Description of the proposed changes

Fixes the "spawning mass" Megalith exploit (https://youtu.be/4TkXWnxNBxE).

Cause. When a Megalith egg (xrl0002-xrl0005, CConstructionEggUnit) finishes, OnStopBeingBuilt spawns its unit right away, then plays the open and sink animations and destroys the egg. During those animations the egg is still a selectable, fully built unit, so it can be given to an ally. ChangeUnitArmy replaces it with a new, fully built egg, whose OnStopBeingBuilt spawns the unit a second time. Each transfer during the animation gives a free unit.

Fix.

  • Units get a CanBeGiven field, true by default on the Unit class like CanBeKilled and CanTakeDamage. The coop mod already uses this name. A hatched egg sets it to false in OnStopBeingBuilt, before it spawns the unit.
  • TransferUnitsOwnership skips units with CanBeGiven set to false. Every transfer goes through it: manual gifts, full share on defeat, captures.
  • The ChangeUnitArmy hook in SimHooks.lua refuses those units too, even with noRestrictions. This covers the callers that skip TransferUnitsOwnership: MergeArmyPair in simInit.lua, ScenarioFramework.GiveUnitToArmy and the Sorian AI.
  • Hatched eggs are also unselectable and uncapturable, so players can't select them for a gift and engineers can't target them for capture.
  • ScenarioFramework.GiveUnitToArmy no longer calls OnGiven with a nil unit when ChangeUnitArmy refuses the transfer. This also applied to command units before.

Choices made:

  • On full share, the hatched egg stays with the defeated army and is killed with its other units. Its spawned unit is a separate unit and is shared as normal. OnKilled already leaves no wreck for an egg that has spawned its unit.
  • Unfinished eggs are unchanged: TransferUnitsOwnership already skipped units under 100% complete, and on defeat they're rebuilt for the receiver by TransferUnfinishedUnitsAfterDeath. They hatch once there.
  • CanBeGiven is a general unit field, so other units can opt out of transfers the same way. The coop mod's own CanBeGiven hook stays compatible: it sets the same field to true in OnCreate.

Testing done on the proposed changes

Tested on 3e295e3 with the faf-re dev engine. Repro on develop (69a6628) first.

Setup: openworldsix.v0002 with no AI, so army 2 is the civilian ARMY_17. The tests make it an ally, so the units don't fight. With the mouse near your ACU, run this. It builds an egg with a Megalith, tries to give it at 50% and after it finishes, then gives an egg on the tick it's created, then calls ChangeUnitArmy(egg, 2, true) the way army merges do. It logs T3 Engineer (url0309, what the xrl0002 egg hatches) counts per army after each step:

ConExecute[[SimLua local TransferUnitsOwnership = import('/lua/simutils.lua').TransferUnitsOwnership
local me = GetArmyBrain(1)
SetAlliance(1, 2, 'Ally') SetAlliance(2, 1, 'Ally')
local at = me:GetListOfUnits(categories.COMMAND, false)[1]:GetPosition()
at = { at[1] + 20, at[2], at[3] }
local function CountCrabs(tag)
    LOG('EGGTEST ', tag, ' crabs mine=', table.getn(me:GetListOfUnits(categories.url0309, false)), ' ally=', table.getn(ArmyBrains[2]:GetListOfUnits(categories.url0309, false)))
end
ForkThread(function()
    me:GiveStorage('MASS', 100000) me:GiveResource('MASS', 100000)
    me:GiveStorage('ENERGY', 1000000) me:GiveResource('ENERGY', 1000000)
    local mega = CreateUnitHPR('xrl0403', 1, at[1], at[2], at[3], 0, 0, 0)
    IssueBuildMobile({ mega }, { at[1] + 12, at[2], at[3] }, 'xrl0002', {})
    local egg
    while not egg do WaitTicks(1) egg = me:GetListOfUnits(categories.CRABEGG, false)[1] end
    while egg:GetFractionComplete() < 0.5 do WaitTicks(1) end
    TransferUnitsOwnership({ egg }, 2)
    LOG('EGGTEST unfinished egg army after transfer=', egg.Army)
    while not egg.Dead and egg:GetFractionComplete() < 1 do WaitTicks(1) end
    WaitTicks(2)
    CountCrabs('1 built egg finished')
    LOG('EGGTEST built egg selectable=', not egg:IsUnitState('UnSelectable'))
    TransferUnitsOwnership({ egg }, 2)
    WaitSeconds(15)
    CountCrabs('1 built egg after transfer')
    local egg2 = CreateUnitHPR('xrl0002', 1, at[1] - 12, at[2], at[3], 0, 0, 0)
    TransferUnitsOwnership({ egg2 }, 2)
    WaitSeconds(15)
    CountCrabs('2 same tick after transfer')
    local egg3 = CreateUnitHPR('xrl0002', 1, at[1], at[2], at[3] + 12, 0, 0, 0)
    WaitTicks(1)
    ChangeUnitArmy(egg3, 2, true)
    WaitSeconds(15)
    CountCrabs('3 ChangeUnitArmy after transfer')
end)]]
  1. On develop
    • The unfinished egg stays in army 1 (TransferUnitsOwnership skips units under 100%)
    • Each of the 3 transfers of a finished egg adds a T3 Engineer to the ally, besides the one each egg spawns for you: ally engineers go 0 → 1 → 2 → 3
  2. On this branch, the same snippet
    • The unfinished egg still stays in army 1
    • The finished egg is unselectable (selectable= false)
    • The ally gets no T3 Engineers at any step (0, 0, 0). You get one per egg, as normal
  3. Manual gift and full share on defeat, in a new session: give a hatching egg and its engineer with GiveUnitsToPlayer, then share another hatching egg with TransferUnitsToHighestBrain (the defeat path). On develop this gives the ally 2 engineers and then 4, one extra per egg:
    ConExecute[[SimLua local SU = import('/lua/simutils.lua')
    local me = GetArmyBrain(1)
    SetAlliance(1, 2, 'Ally') SetAlliance(2, 1, 'Ally')
    local acu = me:GetListOfUnits(categories.COMMAND, false)[1]:GetPosition()
    local function c(b) return table.getn(ArmyBrains[b]:GetListOfUnits(categories.url0309, false)) end
    ForkThread(function()
        local egg = CreateUnitHPR('xrl0002', 1, acu[1] + 10, acu[2], acu[3], 0, 0, 0)
        local units = me:GetListOfUnits(categories.url0309, false)
        table.insert(units, egg)
        SU.GiveUnitsToPlayer({ To = 2 }, units)
        WaitSeconds(12)
        LOG('GIVE2 manual give: mine=', c(1), ' ally=', c(2))
        local egg2 = CreateUnitHPR('xrl0002', 1, acu[1] - 10, acu[2], acu[3], 0, 0, 0)
        WaitTicks(1)
        SU.TransferUnitsToHighestBrain(me, { ArmyBrains[2] }, true, categories.ALLUNITS - categories.WALL - categories.COMMAND, 'FullShare')
        WaitSeconds(12)
        LOG('GIVE2 full share: mine=', c(1), ' ally=', c(2), ' egg2 dead=', tostring(egg2.Dead))
    end)]]
    • Manual give: the engineer moves to the ally and no extra engineer appears (mine= 0 ally= 1)
    • Full share: the second egg's engineer is shared and no extra engineer appears (mine= 0 ally= 2). The egg stays in army 1 and finishes sinking (egg2 dead= true)
  4. Log
    • No Lua errors in the session, and no warnings that develop doesn't also log

Not tested in a campaign (ScenarioFramework.GiveUnitToArmy) or with MergeArmyPair: both call ChangeUnitArmy, and step 2 covers the hook with noRestrictions.

Checklist

  • Changes are annotated, including comments where useful
  • Changelog snippet added

🤖 Generated with Claude Code

lL1l1 and others added 2 commits November 8, 2026 11:32
Changing a hatched egg's army creates a new egg whose OnStopBeingBuilt
spawns the egg's unit again. Hatched eggs now set `NoTransfer`, which
TransferUnitsOwnership skips and the ChangeUnitArmy hook refuses even
with noRestrictions. They also become unselectable and uncapturable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor
Pre-merge checks: Inconclusive 2

Failed Failed checks: 2 inconclusive

Check name Status Explanation Resolution
Title check Pending This assessment has not completed. The check will be retried on the next pre-merge check run.
Description check Pending This assessment has not completed. The check will be retried on the next pre-merge check run.
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@speed2CZ

speed2CZ commented Nov 8, 2026

Copy link
Copy Markdown
Member

Can you rename the flag to CanBeGiven ? Its implemented like that in coop already (I wanted to merge it here eventually) and its more in line with the other flags that currently exist like CanBeKilled, CanTakeDamage, etc.

Matches the flag the coop mod already uses and the existing CanBeKilled
and CanTakeDamage flags: it defaults to true on the Unit class, and false
blocks the transfer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@lL1l1

lL1l1 commented Nov 11, 2026

Copy link
Copy Markdown
Contributor Author

Renamed to CanBeGiven in 3e295e3. It defaults to true on the Unit class next to CanBeKilled/CanTakeDamage, and the egg sets it to false. Both test snippets from the description pass again on that commit.

This branch has not been deployed

No deployments
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.

2 participants