Structure placement validation (CanConstruct / CanReplace)¶
How the game decides whether a structure may be placed at the cursor, and where a mod can hook to reject a placement with a player-visible message.
The placement-preview loop calls CanConstruct() every frame¶
In InventoryManager's placement-preview path (the per-frame loop that updates the build cursor), after positioning the cursor selection box. Verbatim head at 0.2.6403.27689 (288665-288726); both arms AND into flag2, which gates the primary click and UsePrimaryComplete():
CanConstructInfo canConstructInfo = ConstructionCursor.CanConstruct();
if (!canConstructInfo.CanConstruct)
{
if (!string.IsNullOrEmpty(tooltip.State))
{
tooltip.State += "\n";
}
ref string state = ref tooltip.State;
state = state + "<color=red>" + canConstructInfo.ErrorMessage + "</color>";
}
flag2 = flag2 && canConstructInfo.CanConstruct;
if (!IsAuthoringMode && ConstructionCursor is IGridMergeable gridMergeable)
{
Assets.Scripts.Objects.Item inactiveHandItem = Parent.Slots[InactiveHand.SlotId].Occupant as Assets.Scripts.Objects.Item;
CanConstructInfo canConstructInfo2 = gridMergeable.CanReplace(multiConstructor, inactiveHandItem);
if (flag2 && !canConstructInfo2.CanConstruct)
{
ref string state2 = ref tooltip.State;
state2 = state2 + "<color=red>" + canConstructInfo2.ErrorMessage + "</color>";
}
flag2 &= canConstructInfo2.CanConstruct;
}
...
if (KeyManager.GetMouseDown("Primary") && flag2)
{
_usePrimaryPosition = _cursorPosition;
_usePrimaryRotation = ConstructionCursor.ThingTransform.rotation;
if (ConstructionCursor.BuildPlacementTime > 0f)
{
...
ActionCoroutine = StartCoroutine(WaitUntilDone(UsePrimaryComplete, ConstructionCursor.BuildPlacementTime / num, ConstructionCursor));
}
else
{
UsePrimaryComplete();
}
return;
}
So a placement is allowed only when CanConstruct() returns CanConstruct == true AND (for IGridMergeable cursors, e.g. cables/pipes) CanReplace(...) returns CanConstruct == true. A false result writes ErrorMessage (red) into the cursor tooltip and tints the ghost red. This is the clean hook point for build-time rejection.
(The <color=red> markup here renders correctly because the cursor tooltip is a TextMeshPro surface; the F3 console is ImGui and does NOT render such tags, see ChatBroadcast, "Rendering constraints".)
CanConstructInfo¶
public static readonly CanConstructInfo ValidPlacement = new CanConstructInfo(canConstruct: true, string.Empty);
public bool CanConstruct { get; }
public string ErrorMessage { get; }
public CanConstructInfo(bool canConstruct, string errorMessage)
{
CanConstruct = canConstruct;
ErrorMessage = errorMessage;
}
public static CanConstructInfo InvalidPlacement(string error)
{
return new CanConstructInfo(canConstruct: false, error);
}
Structure.CanConstruct() base walks the structure's grid cells (CanConstructCell). Subclasses override and call base.CanConstruct(): Cable.CanConstruct() (umbilical check), Device.CanConstruct() (adjacent-device check), the rocket-tower / launch-pad / fuselage families, etc. A mod can postfix Cable.CanConstruct / Device.CanConstruct and, if __result.CanConstruct, inspect __instance.OpenEnds / Connected() / ConnectedCables(...) (the cursor has a valid GridController and OpenEnds at preview time) and replace __result with CanConstructInfo.InvalidPlacement("...").
Which method blocks a merge depends on the family: pipes via CanReplace, cables via _IsCollision¶
A merge onto an existing small-grid piece is gated for the two IGridMergeable families (Piping, Cable), but the gate lives in a different method for each. This matters for any mod that re-validates a planned placement before completing it: checking only one of CanConstruct() / CanReplace() catches one family and misses the other.
Pipes (Piping): blocked by CanReplace, not by _IsCollision. Piping._IsCollision treats a same-type, same-content pipe in the cell as NOT a collision (it is a merge candidate), so CanConstruct() returns valid for a pipe-over-pipe merge:
protected override bool _IsCollision(SmallGrid smallGrid)
{
if (smallGrid is Piping piping)
{
if (piping.PipeType == PipeType)
{
return piping.PipeContentType != base.PipeContentType;
}
return true;
}
return base._IsCollision(smallGrid);
}
The merge-onto-a-long-variant rejection is in Piping.CanReplace, via the DontAllowMergingWithWrench flag (public bool DontAllowMergingWithWrench; on Piping, the long Straight 3 / 5 / 10 variants set it):
if (DontAllowMergingWithWrench || smallCell.Pipe is Piping { DontAllowMergingWithWrench: not false })
{
return CanConstructInfo.InvalidPlacement(GameStrings.CannotMergeIMergeable.AsString(ToTooltip()));
}
So a pipe merge onto a long variant has CanConstruct().CanConstruct == true and CanReplace(...).CanConstruct == false.
Cables (Cable): blocked by _IsCollision (via CanConstruct), NOT by CanReplace. Cable.CanReplace does not reject a merge onto a long cable. For a same-CableType cell with the merge tool in the off hand it returns ValidPlacement:
if (multiMergeConstructor.ToolExit.PrefabHash == inactiveHandItem.PrefabHash || (inactiveHandItem.ReplacementOf != null && multiMergeConstructor.ToolExit.PrefabHash == inactiveHandItem.ReplacementOf.PrefabHash))
{
if (WillMergeWhenPlaced())
{
Cable.OnMerge?.Invoke(this);
}
if (smallCell.Cable.CableType != CableType)
{
return CanConstructInfo.InvalidPlacement(GameStrings.CannotMergeIMergeableOfDifferentType.AsString(smallCell.Cable.DisplayName));
}
return CanConstructInfo.ValidPlacement;
}
The cable rejection is in Cable._IsCollision (reached by CanConstruct()), via the BlockMergeWithOtherCables flag (public bool BlockMergeWithOtherCables; on Cable):
protected override bool _IsCollision(SmallGrid smallGrid)
{
if (!(smallGrid is Cable cable))
{
return base._IsCollision(smallGrid);
}
if (cable.CableType != CableType)
{
return true;
}
if (!cable.BlockMergeWithOtherCables)
{
return BlockMergeWithOtherCables;
}
return true;
}
So a cable merge that _IsCollision rejects has CanConstruct().CanConstruct == false, while CanReplace(...).CanConstruct == true. (BlockMergeWithOtherCables is also read by Cable.WillMergeWhenPlaced, a separate method that fires the Cable.OnMerge event; it is not part of CanReplace's return path.)
Consequence. The interactive placement loop checks both (flag2 = CanConstruct && (mergeable ? CanReplace : true)), so it blocks both families. Code that re-runs only one check does not: CanReplace alone blocks pipes but passes cables; CanConstruct alone blocks cables (when _IsCollision rejects) but passes pipes. To reproduce the interactive gate, run both. Whether a same-type cable merge is actually rejected by _IsCollision further depends on the runtime BlockMergeWithOtherCables value on the cable prefabs, a serialized field that is not visible in the decompiled C#.
SmallGrid.CanConstruct: the cursor-time occupancy chain, and the same-tier stacking gap¶
SmallGrid.CanConstruct (0.2.6403.27689 decompile 312669-312728), verbatim. This is the occupancy half of the cursor gate for cables/pipes/chutes: it walks the ghost's cells and consults _IsCollision per occupant slot:
public override CanConstructInfo CanConstruct()
{
Grid3[] array = (Grid3[])GridBounds.GetLocalSmallGrid(base.ThingTransformPosition, base.ThingTransformRotation);
for (int i = 0; i < array.Length; i++)
{
Grid3 localGrid = array[i];
SmallCell smallCell = base.GridController.GetSmallCell(localGrid);
if (this is IRocketInternals { StrictlyInternal: not false } && !(smallCell?.Owner is RocketNetwork))
{
return CanConstructInfo.InvalidPlacement(GameStrings.CannotPlaceOutsideRocket);
}
if (smallCell?.Owner == null && this is IRocketInternals)
{
Grid3 localGrid2 = new Grid3(localGrid.x, (float)localGrid.y + SmallGridSize * 10f, localGrid.z);
Grid3 localGrid3 = new Grid3(localGrid.x, (float)localGrid.y - SmallGridSize * 10f, localGrid.z);
SmallCell smallCell2 = base.GridController.GetSmallCell(localGrid2);
SmallCell smallCell3 = base.GridController.GetSmallCell(localGrid3);
if (smallCell2?.Owner != null || smallCell3?.Owner != null)
{
return CanConstructInfo.InvalidPlacement(GameStrings.PlacementConnectingNeedsFuselage.DisplayString);
}
}
if (smallCell != null)
{
if ((bool)smallCell.Cable && _IsCollision(smallCell.Cable))
{
return CanConstructInfo.InvalidPlacement(GameStrings.PlacementBlockedBySmallGrid.AsString(smallCell.Cable.DisplayName));
}
if ((bool)smallCell.Device && _IsCollision(smallCell.Device))
{
return CanConstructInfo.InvalidPlacement(GameStrings.PlacementBlockedBySmallGrid.AsString(smallCell.Device.DisplayName));
}
if ((bool)smallCell.Pipe && _IsCollision(smallCell.Pipe))
{
return CanConstructInfo.InvalidPlacement(GameStrings.PlacementBlockedBySmallGrid.AsString(smallCell.Pipe.DisplayName));
}
if ((bool)smallCell.Chute && _IsCollision(smallCell.Chute))
{
return CanConstructInfo.InvalidPlacement(GameStrings.PlacementBlockedBySmallGrid.AsString(smallCell.Chute.DisplayName));
}
if (smallCell.Rail != null && _IsCollision((SmallGrid)smallCell.Rail))
{
return CanConstructInfo.InvalidPlacement(GameStrings.PlacementBlockedBySmallGrid.AsString(smallCell.Rail.DisplayName));
}
if ((bool)smallCell.Other && _IsCollision(smallCell.Other))
{
return CanConstructInfo.InvalidPlacement(GameStrings.PlacementBlockedBySmallGrid.AsString(smallCell.Other.DisplayName));
}
if (smallCell.Owner != null && smallCell.Owner.IsCollision(this, smallCell.SmallGrid))
{
return CanConstructInfo.InvalidPlacement(GameStrings.PlacementConnectingNeedsFuselage.DisplayString);
}
}
}
if (!DualRegister)
{
return CanConstructInfo.ValidPlacement;
}
return base.CanConstruct();
}
(Cables are not DualRegister, so Structure.CanConstruct (314919-314941, large-grid Cell occupancy via CanConstructCell) never runs for them.)
The SmallGrid._IsCollision base + helper (312645-312667):
public virtual bool IsPipeEndCollision(SmallGrid smallGrid)
{
foreach (Connection openEnd in OpenEnds)
{
foreach (Connection openEnd2 in smallGrid.OpenEnds)
{
if ((openEnd.Transform.position - openEnd2.Transform.position).sqrMagnitude < 0.010000001f)
{
return true;
}
}
}
return false;
}
protected virtual bool _IsCollision(SmallGrid smallGrid)
{
if ((smallGrid.SmallCollisionType & SmallCollisionType) != SmallGridBlock.None)
{
return true;
}
return IsPipeEndCollision(smallGrid);
}
0.2.6403.27689 line refs for the cable-family overrides (full verbatim bodies on Cable): Cable._IsCollision 392588-392603, Cable.CanConstruct 392625-392632 (umbilical check then base), Cable.CanReplace 392634-392672, Cable.WillMergeWhenPlaced 392674-392694.
The same-tier stacking gap. For a cable ghost over an existing SAME-tier cable (neither BlockMergeWithOtherCables flag set), Cable._IsCollision returns false, so SmallGrid.CanConstruct PASSES. The thing that still blocks the click is the second arm of the cursor gate, IGridMergeable.CanReplace, which demands the merge tool. For a DIFFERENT-tier cable, _IsCollision returns true and CanConstruct fails with PlacementBlockedBySmallGrid. Consequence: a programmatic caller that honors CanConstruct but skips CanReplace can still stack same-tier cables into one cell; the registration path itself has NO occupancy check of any kind (_IsCollision call-site census and the register chain on StructureRegistration), and the resulting stacked-pair misbehavior is on SmallCell. Code that re-validates a planned placement must run BOTH arms to reproduce the interactive gate (consistent with the per-family section above).
The gate is client-side preview only, not server-authoritative¶
public static Structure SpawnConstruct(CreateStructureInstance instance) // on Constructor
{
if (GameManager.RunSimulation)
{
Structure structure = Thing.Create<Structure>(instance.Prefab, instance.WorldPosition, instance.WorldRotation, 0L);
structure.SetStructureData(instance.LocalRotation, instance.OwnerClientId, instance.LocalGrid, instance.CustomColor);
return structure;
}
if (Assets.Scripts.Networking.NetworkManager.IsClient)
new ConstructionCreationMessage(instance).SendToServer();
return null;
}
SpawnConstruct (server side, GameManager.RunSimulation) does NOT re-run CanConstruct; it just Thing.Creates the structure. So a CanConstruct/CanReplace postfix stops a vanilla/modded client from triggering placement and gives a clear message, but it is not anti-cheat against a hand-crafted ConstructionCreationMessage. That is fine for a balance / lore restriction; for a hard rule, add a server-side check (e.g. in an OnRegistered postfix that severs / errors the structure) as well.
Re-confirmed at 0.2.6403.27689: the whole Thing.Create -> Register -> AddSmallGridStructure chain contains no CanConstruct, no _IsCollision, no occupancy test of any kind (call-site census on StructureRegistration; registration-time interception options on Cable, "Registration-time guard").
(Authoring mode bypasses item cost but, per Constructor, not the placement gates -- and the placement loop above explicitly skips the IGridMergeable.CanReplace branch only when IsAuthoringMode.)
Verification history¶
- 2026-05-12: page created. Sourced from a phase 3 research dive (planned mod "Power Grid Plus") into
.work/decomp/0.2.6228.27061/Assembly-CSharp.decompiled.cslines ~270692-270760, 276780-276796, 276940-276953, 371598-371645; verbatim excerpts of the placement-previewCanConstruct/CanReplaceblock,CanConstructInfo,Constructor.SpawnConstruct, andCable.CanConstruct/CanReplace. Consistent with Constructor ("authoring mode bypasses item-cost only, NOT placement gates") and AllowedRotations (the same loop auto-corrects the cursor face). - 2026-05-31: added "Which method blocks a merge depends on the family" section. Sourced from
.work/decomp/0.2.6228.27061/Assembly-CSharp.decompiled.csPiping._IsCollision(363943-363954),Piping.CanReplace(363956-, theDontAllowMergingWithWrenchbranch at 363974-363977),Cable._IsCollision(371561-371576),Cable.CanReplace(371607-371645), and the field declarationsPiping.DontAllowMergingWithWrench(363936) /Cable.BlockMergeWithOtherCables(371306). Finding: the merge-block is inCanReplacefor pipes but in_IsCollision(viaCanConstruct) for cables, so a mod re-checking only one method catches one family. Surfaced while validating a ZoopMod upstream fix for the long-variant merge crash (see MultiMergeConstructor). - 2026-07-14: refreshed against the 0.2.6403.27689 decompile during the mixed-tier cable network guard research pass. Restamped "The placement-preview loop" with the 0.2.6403 verbatim head (288665-288726, now including the primary-click gate that runs
UsePrimaryComplete/WaitUntilDone) and noted the tooltip's<color=red>renders because the cursor tooltip is TextMeshPro, unlike the ImGui console. Added the "SmallGrid.CanConstruct: the cursor-time occupancy chain, and the same-tier stacking gap" section:SmallGrid.CanConstructverbatim (312669-312728),SmallGrid._IsCollision+IsPipeEndCollisionverbatim (312645-312667), the non-DualRegister note (Structure.CanConstruct314919-314941 never runs for cables), refreshed 0.2.6403 line refs for the cable-family methods (_IsCollision392588-392603,CanConstruct392625-392632,CanReplace392634-392672,WillMergeWhenPlaced392674-392694), and the same-tier stacking gap: same-tier cable-over-cable passesCanConstruct(the click is blocked only by theCanReplacemerge-tool arm inInventoryManager), so a programmatic caller honoringCanConstructbut skippingCanReplacecan still stack same-tier cables. Restamped "The gate is client-side preview only" with the 0.2.6403 re-confirmation that the Create -> Register chain performs no occupancy re-validation (census on StructureRegistration). The per-family merge-block section keeps its 0.2.6228.27061 stamp (the pipe half was not re-read this pass; the cable half's re-verified refs live in the new section). No prior claim contradicted: the new same-tier note covers the no-merge-tool case, which is disjoint from the 2026-05-31 finding thatCanReplacepasses cables WITH the tool.
Open questions¶
None at creation.