Structure¶
Vanilla game class for player-built, fixed-position game objects. Subclass of Thing. Covers walls, frames, pipes, cables, and devices.
NotImplementedException on batched structures¶
Source: F0029e.
Some structures use structureRenderMode != Standard and share a combined mesh. SetCustomColor throws NotImplementedException on these. PaintSafe catches the exception per-item so one unpaintable structure does not abort the rest of the network.
structureRenderMode is never assigned in code, so no page can name the prefabs that are Batched¶
(Assembly-CSharp 0.2.6428.27798, line 45079. Two values, and Standard is 0, so an unset field is Standard.)
The field is declared public StructureRenderMode structureRenderMode; at line 319767 and there is no assignment to it anywhere in Assembly-CSharp: a grep for structureRenderMode = finds six hits and all six are == comparisons (320448 in SetCustomColor, 320459 in Awake, 320559, 320760, 321662, 321714). It is a plain public serialized field, so the only writer is the Unity prefab asset, and which prefabs carry Batched cannot be answered from a decompile at all. It has to be read off a live instance or a prefab, per-prefab, through /thing?fields=structureRenderMode or an equivalent reflection read.
The four comparison sites say what Batched costs, which is the closest the code gets to naming the family:
SetCustomColor(320446-320454) forwards tobase.SetCustomColoronStandardand throwsNotImplementedExceptionotherwise. This is the throwPaintSafecatches.Awake(320456-320465) allocates aMaterialPropertyBlockand destroys theMeshRendereroutright, which is why there is no per-instance material left to recolour.UpdateStateVisualizer(320760) registersCurrentBuildState.RendererInstancewithRenderBatch.
IBatchable (line 45084: Mesh SharedMesh, Thing GetParent, ShadowCastingMode ShadowCastingMode) has exactly one implementor in Assembly-CSharp, ThingRenderer (line 329165), so the interface is the renderer's contract rather than a marker on the batched structure classes, and it cannot be used to enumerate them either.
PaintSafe catch comment (F0322)¶
Source comment from NetworkPainterPatch.cs:320-328:
/// <summary>
/// Individual SetCustomColor calls can throw. Most notably,
/// Structure.SetCustomColor throws NotImplementedException on any
/// structure whose structureRenderMode != Standard (batched-render
/// structures share a combined mesh and can't be recolored per
/// instance). A destroyed-mid-paint item can also trip a null deref.
/// Without the catch, one unpaintable or stale item would abort
/// painting the rest of the network.
/// </summary>
IsBroken property¶
Source: $(StationeersPath)\rocketstation_Data\Managed\Assembly-CSharp.dll :: Assets.Scripts.Objects.Structure and Assets.Scripts.Objects.Thing.
Structure has a public IsBroken property, overriding the base on Thing.
Base (Thing.IsBroken):
public virtual bool IsBroken
{
get
{
if (DamageState != null)
return DamageState.Total >= DamageState.MaxDamage;
return false;
}
}
Override (Structure.IsBroken):
public override bool IsBroken
{
get
{
if (!base.IsBroken)
return CurrentBuildStateIndex < 0;
return true;
}
}
A Structure is IsBroken when either:
- Its
DamageState.Total >= DamageState.MaxDamage(fully damage-destroyed), OR - Its
CurrentBuildStateIndex < 0(deconstructed past the first build stage, which is how the game models a wreckage / half-torn-down state).
Read-only property; no setter. Use it verbatim as thing.IsBroken to detect "is this structure currently wreckage / destroyed." For detecting structures that have broken build states in their prefab definition (not the runtime state), use Structure.HasBrokenBuildStates (getter tests BrokenBuildStates?.Count > 0).
Build-state model and the destruction path¶
Structure : Thing. Subclass tree of interest: Structure -> LargeStructure -> Wall / Frame (and Geyser, StructureFuselage, LaunchMount); Structure -> SmallGrid (attached devices / small wall-mounted machines), Cladding, Stairs, RoverFrame. The player-facing "structure frame" is Frame : LargeStructure, a trivial subclass; there is no class literally named StructureFrame. (MultiConstructor is the kit item you build frames from, not the structure.)
Ladders sit on the SmallGrid branch, not LargeStructure: Ladder : SmallGrid, ISmartRotatable and LadderEnd : Ladder. The contrast is LadderPlatform : Floor : Wall : LargeStructure -- a ladder platform is a LargeStructure, a plain ladder is not. Code that branches on is LargeStructure (a grid-walk / flood-fill, for example) therefore catches ladder platforms but skips ladders and ladder ends. (Stairs : Structure directly, also not LargeStructure.)
Construction is genuinely stage-by-stage. A Structure carries List<BuildState> BuildStates, List<BrokenBuildState> BrokenBuildStates, int CurrentBuildStateIndex (setter clamps, fires OnBuildState, sets NetworkUpdateFlags |= 64), BuildState CurrentBuildState, bool IsStructureCompleted, bool HasBrokenMesh => BrokenBuildStates?.Count > 0, plus grid registration (LocalGrid, BlockingGrids). Structure.AttackWith with the matching tool moves CurrentBuildStateIndex up (construct) or down (deconstruct) one step; deconstructing build state 0 is what removes the object (via BuildStates[0].Tool.Deconstruct(eventInstance) then OnServer.Destroy). A Structure can also be removed in one shot by Thing.Delete / OnServer.Destroy (the engine path), bypassing the build-stage walk and the tool/ingot requirements.
When DamageState maxes out¶
When DamageState.Total >= MaxDamage, ThingDamageState.OnDamageUpdated() schedules ThingDamageState.Destroy():
private async UniTask Destroy()
{
await UniTask.SwitchToMainThread();
await UniTask.DelayFrame(1);
Structure asStructure = Parent.AsStructure;
if ((bool)asStructure)
{
if (!_isDestroyed && asStructure.IsBroken && asStructure.HasBrokenMesh)
{
asStructure.UpdateBuildStateAndVisualizer(asStructure.GetBrokenState(), _particlesOnDestroy);
asStructure.OnStructureBroken();
HealAll(); // reset HP so the wreck persists
_isDestroyed = true;
return; // NOT removed -- left as a wreck/damaged shell
}
EffectManager.CreateDeconstructionEffect(asStructure, _particlesOnDestroy);
if ((bool)Parent) Parent.OnDamageDestroyed();
}
if ((bool)Parent) Parent.OnDamageDestroyed();
}
Pitfall: a structure that has a broken-mesh build state (most walls and frames do; check HasBrokenMesh / HasBrokenBuildStates) does NOT despawn when damage maxes out -- it converts to its wreck visual and stays alive in an IsBroken state with HP reset. A mod that wants guaranteed removal should call OnServer.Destroy(thing) or Thing.Delete(...) directly rather than cranking damage. See ./Thing.md and ./OnServer.md.
OnDamageDestroyed and StructureDestroyed¶
Structure.OnDamageDestroyed() is the "fully demolish, drop wreckage, despawn" path -- it skips the broken-mesh conversion:
public override void OnDamageDestroyed()
{
base.OnDamageDestroyed(); // Thing.OnDamageDestroyed: IsBurning = false
if (GameManager.RunSimulation && !base.Indestructable)
{
ConstructionEventInstance eventInstance = new ConstructionEventInstance { Parent = this, Position = ..., Rotation = ..., SteamId = OwnerClientId, OtherHandSlot = null };
StructureDestroyed(eventInstance, destroyedFromDamage: true);
if (this is IWreckage wreckage) wreckage.SpawnWreckage();
OnServer.Destroy(this);
}
}
StructureDestroyed(eventInstance, destroyedFromDamage) unregisters from the grid / atmospheres, handles WorldParticleEffect grid bookkeeping; if destroyedFromDamage && BrokenBuildStates.Count > 0 it marks the broken build state, otherwise it runs BuildStates[0].Tool.Deconstruct(eventInstance) and moves slot occupants to the world; then for each AttachedDevices entry it deconstructs through all build states and OnServer.Destroys it. IWreckage.SpawnWreckage() is what drops the broken-frame / wreckage debris item; not every structure implements IWreckage (Frame and walls generally do). OnServer.Destroy(this) is the actual removal. Indestructable is checked here (the damage path); Thing.Delete / OnServer.Destroy themselves do not check it. All server-authoritative; destruction replicates via the normal DestroyEvent / construction-event networking.
Tool deconstruct branch of AttackWith¶
Entry: Structure.AttackWith(Attack attack, bool doAction) (starts at 0.2.6403.27689 decompile line 314994). The deconstruct branch for the current build state (315205-315262), verbatim:
else if (CurrentBuildStateIndex >= 0 && CurrentBuildStateIndex < BuildStates.Count)
{
BuildState buildState2 = BuildStates[CurrentBuildStateIndex];
DelayedActionInstance delayedActionInstance3 = new DelayedActionInstance
{
Duration = buildState2.Tool.ExitTime / value,
ActionMessage = ActionStrings.Deconstruct
};
Tool tool3 = sourceItem as Tool;
if ((bool)buildState2.Tool.ToolExit && buildState2.Tool.IsToolExit(item))
{
BuildState buildState3 = BuildStates[CurrentBuildStateIndex];
if ((bool)tool3 && !tool3.IsOperable)
{
return InoperableToolResult(delayedActionInstance3, tool3, doAction);
}
CanConstructInfo canConstructInfo = CanDeconstruct();
if (tool3 != null && !canConstructInfo.CanConstruct)
{
return delayedActionInstance3.Fail(canConstructInfo.ErrorMessage);
}
if (!doAction)
{
return delayedActionInstance3;
}
if (CurrentBuildStateIndex > 0 && GameManager.RunSimulation)
{
ConstructionEventInstance eventInstance3 = new ConstructionEventInstance
{
Parent = this,
Position = attack.Position,
Rotation = ThingTransform.rotation,
SteamId = base.OwnerClientId,
OtherHandSlot = attack.OtherHand
};
buildState3.Tool.Deconstruct(eventInstance3);
}
if ((bool)tool3 && !tool3.OnUseItem(buildState2.Tool.ExitQuantity, this))
{
return null;
}
CurrentBuildStateIndex--;
if (CurrentBuildStateIndex < 0)
{
ConstructionEventInstance eventInstance4 = new ConstructionEventInstance
{
Parent = this,
Position = attack.Position,
Rotation = ThingTransform.rotation,
SteamId = base.OwnerClientId,
OtherHandSlot = attack.OtherHand
};
StructureDestroyed(eventInstance4);
OnServer.Destroy(this);
return delayedActionInstance3;
}
UpdateStateVisualizer();
}
}
CanDeconstruct() gates the branch. Base Structure.CanDeconstruct (315366-315369) returns CanConstructInfo.ValidPlacement; Cable.CanDeconstruct (392393-392405) refuses while AttachedDevices is non-empty. A structure with a single build state (index 0), such as a cable, goes straight to StructureDestroyed(eventInstance4); OnServer.Destroy(this);.
The kit-refund portion of StructureDestroyed (excerpt of 315390-315491, key lines; the refund does NOT run when destroyedFromDamage):
else if ((bool)BuildStates[0].Tool.ToolEntry && !destroyedFromDamage)
{
BuildStates[0].Tool.Deconstruct(eventInstance);
if (Slots.Count > 0)
{
foreach (Slot slot in Slots)
{
if ((bool)slot.Occupant && GameManager.RunSimulation)
{
OnServer.MoveToWorld(slot.Occupant);
}
}
}
}
Related: Structure.UpgradeStructureServer (315307-315315) is the destroy-then-SpawnConstruct pattern used by build-state upgrades; the same shape a refuse-and-replace mod would use if it ever swapped a piece instead of refunding.
Which kit item: ToolBasic / ToolUse serialized prefab data¶
The kit identity is serialized prefab data: BuildState.Tool is a ToolUse : ToolBasic and the refunded item is its ToolEntry (plus optional ToolEntry2), quantity EntryQuantity (EntryQuantity2). ToolBasic fields (316015-316027):
public class ToolBasic
{
[Header("Construction")]
public Item ToolEntry;
public Item ToolEntry2;
[Range(0f, 60f)]
public float EntryTime = 2f;
public int EntryQuantity;
public int EntryQuantity2;
ToolUse adds the deconstruction side (316130-316143):
[Serializable]
public class ToolUse : ToolBasic
{
[Tooltip("If true the entry tool will always be shown to player, regardless of if they have a tool or not")]
public ToolUseType ToolUseType;
[Header("Deconstruction")]
public Item ToolExit;
[Range(0f, 60f)]
public float ExitTime = 2f;
public int ExitQuantity;
For a cable, BuildStates[0].Tool.ToolEntry is the cable coil item (ItemCableCoil / ItemCableCoilHeavy / ItemCableCoilSuperHeavy, per the electronics.xml recipe names on Cable) and EntryQuantity is 1 for the 1-cell straight (per the resources.assets extraction table on MultiMergeConstructor; long variants ¾/7-8). A mod refund should read __instance.BuildStates[0].Tool.ToolEntry and EntryQuantity from the live instance rather than hardcoding prefab names; that automatically matches variant cost (junctions cost 2-3).
ToolUse.SpawnItem: the canonical drop-spawn pattern¶
ToolUse.Deconstruct + SpawnItem (316169-316234), verbatim; this is BOTH the "which item" and the "server-side spawn a drop" pattern (try to stack into the other hand first, else OnServer.Create<Item> at position + SetQuantity, with paint-color carry-over for construction kits):
private void SpawnItem(ConstructionEventInstance eventInstance, Item tool)
{
if (tool == null)
{
return;
}
int num = ((tool == ToolEntry) ? EntryQuantity : EntryQuantity2);
if (tool is Tool || num == 0)
{
return;
}
Stackable stackable = tool as Stackable;
Slot slot = null;
if (eventInstance.OtherHandSlot != null)
{
if (!eventInstance.OtherHandSlot.Occupant)
{
slot = eventInstance.OtherHandSlot;
}
else if ((bool)stackable)
{
Stackable stackable2 = eventInstance.OtherHandSlot.Occupant as Stackable;
if ((bool)stackable2 && stackable2.CanStack(stackable))
{
int num2 = Mathf.Min(stackable2.MaxQuantity - stackable2.Quantity, num);
if (num2 > 0)
{
stackable2.AddQuantity(num2);
num -= num2;
}
}
}
}
if (num <= 0)
{
return;
}
Item item = OnServer.Create<Item>(tool, eventInstance.Position, eventInstance.Rotation);
if ((bool)item)
{
if (slot != null)
{
OnServer.MoveToSlot(item, slot);
}
Stackable stackable3 = item as Stackable;
if ((bool)stackable3)
{
stackable3.SetQuantity(num);
}
Consumable consumable = item as Consumable;
if ((bool)consumable)
{
consumable.Quantity = num;
}
if (item is IConstructionKit && (eventInstance.Parent is DraggableThing || eventInstance.Parent is Structure { CurrentBuildStateIndex: <=0 }) && (object)eventInstance.Parent.PaintableMaterial != null && eventInstance.Parent.CustomColor.Index != item.CustomColor.Index)
{
OnServer.SetCustomColor(item, eventInstance.Parent.CustomColor.Index);
}
}
}
public void Deconstruct(ConstructionEventInstance eventInstance)
{
SpawnItem(eventInstance, ToolEntry);
SpawnItem(eventInstance, ToolEntry2);
}
OnServer.Create<T> (39866-39876) wraps Thing.Create (registration chain on StructureRegistration):
public static T Create<T>(Thing prefab, Vector3 position, Quaternion rotation) where T : Thing
{
T val = Thing.Create<T>(prefab, position, rotation, 0L);
if ((object)val != null)
{
T result = val;
_ = val;
return result;
}
throw new NullReferenceException();
}
OnServer.Destroy (39914-39926), verbatim. Note the SOFT non-simulation guard: called on a client it logs an error but still destroys the local GameObject:
public static void Destroy(Thing thing)
{
if (!GameManager.RunSimulation && GameManager.GameState == GameState.Running)
{
string text = (thing ? thing.DisplayName : "unknown");
ConsoleWindow.PrintError("OnServer.Destroy called on client for " + text);
}
if ((bool)thing && (bool)thing.GameObject)
{
thing.GameObject.DestroyGameObject();
thing.BeingDestroyed = true;
}
}
Refund failures in 0.2.6428.27798, and what a structure left at index -1 becomes¶
Line numbers in this subsection refer to the 0.2.6428.27798 decompile; the subsections above were read at 0.2.6403.27689.
The refund is now caught. In the 0.2.6403.27689 excerpts above, neither the deconstruct branch of AttackWith nor the refund block of StructureDestroyed has a try. At 0.2.6428.27798 the refund and the slot emptying each sit in their own try/catch that prints the failure instead of propagating it (321374-321401, verbatim):
else if ((bool)BuildStates[0].Tool.ToolEntry && !destroyedFromDamage)
{
try
{
BuildStates[0].Tool.Deconstruct(eventInstance);
}
catch (System.Exception ex)
{
ConsoleWindow.PrintError($"{DisplayName} #{base.ReferenceId} failed to return its parts on deconstruct: {ex.Message}");
}
try
{
if (Slots.Count > 0)
{
foreach (Slot slot in Slots)
{
if ((bool)slot.Occupant && GameManager.RunSimulation)
{
OnServer.MoveToWorld(slot.Occupant);
}
}
}
}
catch (System.Exception ex2)
{
ConsoleWindow.PrintError($"{DisplayName} #{base.ReferenceId} failed to empty its slots on deconstruct: {ex2.Message}");
}
}
The attached-device loop gets the same pair of blocks (321414-321441). The deconstruct branch of AttackWith keeps its order: the exit tool's OnUseItem (321168), CurrentBuildStateIndex-- (321172), StructureDestroyed(eventInstance4) (321183), OnServer.Destroy(this) (321184). An exception from the refund therefore no longer skips the destroy. The 0.2.6428.27798 update notes (rocketstation_Data/StreamingAssets/version.ini, UPDATEDATE=Thu 13/08/2026) carry two lines on this path, verbatim apart from the colour markup: Fixed slot contents being destroyed when a deconstruct failed to return its kit and Fixed a failed parts refund leaving a structure stuck in the world instead of deconstructing. A worked case, a painted pipe whose kit-colour transfer throws, is on OnServer ("Deconstruct: the returned kit inherits the parent's colour").
A structure left at index -1. Any exception that leaves AttackWith after CurrentBuildStateIndex-- and before OnServer.Destroy(this) leaves the structure in the world, not destroyed, with its index at -1; when the exception came from the refund's colour transfer, the refunded kit already exists. In the 0.2.6403.27689 shape nothing between the two caught an exception from the refund. What the game does with an index of -1:
IsBrokenis true (Structure.IsBroken, 319816-319826, returnsCurrentBuildStateIndex < 0when the damage state is not broken), andCurrentBuildStatereturnsnullunless the prefab has broken build states (319979-319998).- The setter does not redraw. It calls
OnBuildStateUpdated, which is empty inStructure(320209-320211), and on the server setsNetworkUpdateFlags |= 64. - Clients connected at the time redraw at once.
BuildUpdatewrites the index as a signed byte andProcessUpdatehands it toUpdateBuildStateAndVisualizer, which sets it and callsUpdateStateVisualizer()(320067-320084, 320534-320537). - A client that joins later does not see -1.
SerializeOnJoinwrites(byte)CurrentBuildStateIndex, so -1 travels as 255, and the setter's clamp (a value aboveBuildStates.Count - 1becomes 0, quoted above) lands it at index 0 (320092-320107). - A save keeps -1.
InitialiseSaveDatastoresstructureSaveData.CurrentBuildState = CurrentBuildStateIndex(320300), andDeserializeSaveassigns it back and callsUpdateStateVisualizer()(320279, 320292). UpdateStateVisualizer(320553) with a negative index andIsBrokentakes its broken branch (320563-320593, verbatim below). It deactivates every direct child GameObject of the structure, disables every build state'sVisualizer, everyThingRendererand theMeshRenderer, and setsIsOccluded = trueandAllowInteraction = false. It then re-enablesBrokenBuildStates[0]'s linked objects and colliders only if the prefab has broken build states. Without them,CurrentBuildStateisnulland the method returns (320740-320744) before re-enabling any state.
int num = Mathf.Abs(CurrentBuildStateIndex + 1);
if (CurrentBuildStateIndex < 0 && (IsBroken || num < BrokenBuildStates.Count))
{
foreach (Transform item2 in base.transform)
{
if (!(item2 == base.transform))
{
item2.gameObject.SetActive(value: false);
}
}
foreach (BuildState buildState2 in BuildStates)
{
if (buildState2 != null && !(buildState2.Visualizer == null))
{
buildState2.Visualizer.enabled = false;
}
}
foreach (ThingRenderer renderer in Renderers)
{
if (renderer != null)
{
renderer.Enabled = false;
}
}
MeshRenderer component = GetComponent<MeshRenderer>();
if (component != null)
{
component.enabled = false;
}
IsOccluded = true;
base.AllowInteraction = false;
So a structure whose prefab has no broken build states, once left at -1, draws nothing and has AllowInteraction false on the clients that were connected when it happened and on the host after a reload, although it has not been destroyed; a client that joins later sees it at index 0.
Measured: what the caught refund failure costs, and what it does not¶
The try/catch above was exercised on a live listen host on 2026-09-22 at game 0.2.6428.27798, with the published Spray Paint Plus v1.10.1 loaded. That build's Harmony prefix on OnServer.SetCustomColor throws MissingFieldException on every call, whatever the target, because of a stale field reference (the whole-method Mono JIT failure on StaleModReferenceJitCrash). It is therefore a probe for one question vanilla cannot be made to ask on its own: what a deconstruct does when the colour step inside it fails.
The subject. StructurePipeCorner #66825 (Assets.Scripts.Objects.Pipes.Piping) at world position -1292, 204, -690.5, read before the stroke: customColorIndex 1, CurrentBuildStateIndex 0, IsBroken false, Slots.Count 0, BuildStates[0].Tool.ToolEntry = ItemKitPipe, BuildStates[0].Tool.ToolEntry2 null, BuildStates[0].Tool.EntryQuantity 1, BuildStates[0].Tool.ToolExit = ItemWrench. A sibling of the same prefab read PaintableMaterial = ColorYellow, so 1 (ColorGray) is a painted value, not the prefab's. The stroke was one ItemWrench use on that reference id, delivered through OnServer.AttackWith on the host.
Four results, all read back through the control plane:
- The call succeeded from the caller's side. The
OnServer.AttackWithinvocation returned normally. That is the observable difference thecatchmakes: the same mod's failure on a spray-can stroke, where nothing between the throw and the caller catches, comes back to the caller asAttackWith failed: Field not found: ...(StaleModReferenceJitCrash, "Whole-method failure, measured"). - The in-game console printed one red line, verbatim:
Pipe (Corner) #66825 failed to return its parts on deconstruct: Field not found: System.Collections.Generic.List`1<Networks.INetworkedStructure> Networks.StructureNetwork.StructureList Due to: Could not find field in class
which is the ConsoleWindow.PrintError at 321382 with DisplayName = "Pipe (Corner)", ReferenceId = 66825 and ex.Message filled in. The Gray line PrintError emits beside it is its own Environment.StackTrace, not the exception's, so it reads from the catch site outward: System.Environment.get_StackTrace, ConsoleWindow.PrintError, Assets.Scripts.Objects.Structure.StructureDestroyed, Assets.Scripts.Objects.Structure.AttackWith, OnServer.DMD<OnServer::AttackWith>. That places the catch inside StructureDestroyed with AttackWith still on the stack below it, and it names nothing from the mod; see StaleModReferenceJitCrash, "The same shape in the shipped game".
3. The pipe was destroyed. A Thing lookup on reference id 66825 after the stroke found nothing on the host. OnServer.Destroy(this) at 321184 ran, as the unchanged statement order predicts.
4. The refunded kit came back carrying the PREFAB's colour, not the structure's. One new loose ItemKitPipe #509638 appeared at -1291.99353, 204.187012, -690.9479, in no slot, reading customColorIndex 5, against the pipe's 1. SpawnItem spawns at eventInstance.Position, which is the attack point (-1292, 204, -690.5); the reading is where it had settled. Nothing else was lost: this prefab's refund is a single entry (ToolEntry ItemKitPipe x EntryQuantity 1, ToolEntry2 null) and exactly one kit appeared, taking the kits in scan range from four to five.
Two controls pin the colour result to the failing step.
- What 5 is. A fresh
ItemKitPipespawned throughOnServer.Createin the same session readcustomColorIndex5 andQuantity1. So 5 is the kit prefab's own colour (ColorYellow), which is whatOnServer.CreateOrStackat 322149 gives the new item before the transfer at 322162 runs. The refund kept exactly the value it was created with. - What the failing step costs. The same deconstruct on a sibling
StructurePipeCorner#67333, paintedcustomColorIndex8, run with the mod's "Paint Single Item By Default" setting on so its prefix returns before the failing walk, printed nothing to the console and returned anItemKitPipe#509341 readingcustomColorIndex8, the pipe's own colour. So the colour transfer at 322162 is the whole of what the caught failure costs on this prefab.
What this run did NOT measure, and none of it should be read out of the above:
- Slot contents.
Slots.Countwas 0 on this structure, so the secondtry(321384-321401,OnServer.MoveToWorldper occupant) had no work to do and itscatchnever came into play. The update-note lineFixed slot contents being destroyed when a deconstruct failed to return its kitwas not exercised. - A second refund entry.
ToolEntry2was null, soSpawnItem(eventInstance, ToolEntry2)(322169) returned at its own null check (322142). Whether a throw from the first entry's colour transfer costs a second entry's item, which the code says it does because bothSpawnItemcalls sit inside the onetry, was not observed. - The attached-device loop (321402-321444, with its own
try/catchpair). This pipe had no attached devices. - The pre-
tryshape. 0.2.6403.27689 is not installed, so the "structure left at index -1" outcome above remains a reading of that decompile, not something reproduced.
Construction completion: the tool stroke has no network side effects¶
The construct branch of Structure.AttackWith completes a build state with exactly two statements (0.2.6403.27689 decompile lines 315202-315203): CurrentBuildStateIndex++; then UpdateStateVisualizer();. No cable-network, grid, or device-list operation runs on completion; the structure was already registered on the grid and in its network's DeviceList since placement (see StructureRegistration). What changes on completion is only what reads the index afterwards.
IsStructureCompleted (313965-313978) is CurrentBuildStateIndex == BuildStates.Count - 1, with one carve-out: a structure whose current build state has CanManufacture set also reports completed at that middle state.
The base Device.GetUsedPower(CableNetwork) (371510-371521) returns -1 when the queried network is not one of the device's own power networks, and 0 unless OnOff && IsStructureCompleted. Consequence: an under-construction device sits on the network with zero demand, and its demand appears the moment the completing tool stroke lands, with no other side effect.
Programmatic spawns land INCOMPLETE for multi-state structures: Thing.Create / OnServer.Create instances arrive at CurrentBuildStateIndex = 0 (live-observed on StructureConsole, 2 build states, spawned via OnServer.Create<Structure> on the dedicated server at 0.2.6403.27689: 24 electricity ticks on a live network with row demand 0, then demand appeared on the tick after CurrentBuildStateIndex was advanced to final through the real completion write). A single-state structure (a cable) spawns complete because state 0 IS its final state.
Live verification 2026-07-15 via a ScenarioRunner per-tick trace on the dedicated server (scenario pgp-fresh-device-trace: RTG feeding a small transformer feeding consumers; logged per electricity tick: snapshot demand rows, transformer grants, net verdicts, device IsStructureCompleted / OnOff / Powered): a spawn-complete device's demand and its funding both appear on the same first tick that snapshots it, and a build-state completion likewise lands demand and funding on the same tick. The registration and completion boundaries are tick-coherent in vanilla.
IsStructureCompleted exact semantics; the index setter clamps overshoot to 0¶
The exact body behind the summary above (313965-313979), verbatim:
public bool IsStructureCompleted
{
get
{
if (CurrentBuildStateIndex != BuildStates.Count - 1)
{
if (CurrentBuildStateIndex > 0 && CurrentBuildStateIndex < BuildStates.Count)
{
return BuildStates[CurrentBuildStateIndex].CanManufacture;
}
return false;
}
return true;
}
}
Three consequences pinned down:
CurrentBuildStateIndex == BuildStates.Count - 1is unconditionally completed, so a single-state prefab (a cable) is completed at index 0: state 0 IS its last state.- A non-last index counts as completed only when ALL of
CurrentBuildStateIndex > 0,CurrentBuildStateIndex < BuildStates.Count, andBuildStates[CurrentBuildStateIndex].CanManufacturehold. TheCanManufacturecarve-out never applies to index 0. - Index 0 of a multi-state ladder is therefore NEVER completed: it is neither the last index nor
> 0.
The CurrentBuildStateIndex setter (314029-314062), verbatim:
public int CurrentBuildStateIndex
{
get
{
return _currentBuildState;
}
set
{
if (value > BuildStates.Count - 1)
{
value = 0;
}
if (value < _currentBuildState)
{
UpdateSoundsOnBuildState(construct: false);
}
int currentBuildState = _currentBuildState;
_currentBuildState = value;
if (this.OnBuildState != null)
{
this.OnBuildState();
}
if (_currentBuildState > currentBuildState)
{
UpdateSoundsOnBuildState(construct: true);
}
base.GridController?.UpdateAirState(this);
OnBuildStateUpdated(value, currentBuildState);
if (Assets.Scripts.Networking.NetworkManager.IsServer)
{
base.NetworkUpdateFlags |= 64;
}
}
}
The first statement is the clamp: a value above BuildStates.Count - 1 silently becomes 0. A saved or written index that overshoots the prefab's ladder (a prefab whose state count shrank across game versions, or bad mod data) lands at the BOTTOM of the ladder, not the top.
Network and port registration is never completion-gated¶
Build state never controls which networks a device belongs to; it only shapes the watt numbers the device reports afterwards. Two verbatim proofs at 0.2.6403.27689:
Device.InitializeDevice (371775-371815) registers the device into EVERY adjacent cable, pipe, and chute network, with no build-state term anywhere:
public virtual void InitializeDevice()
{
Span<SmallCellRef> span = stackalloc SmallCellRef[32];
int count = 0;
FillConnected<Cable>(span, ref count);
Span<SmallCellRef> span2 = span;
Span<SmallCellRef> span3 = span2.Slice(0, count);
for (int i = 0; i < span3.Length; i++)
{
SmallCellRef smallCellRef = span3[i];
if (smallCellRef.TryGet<Cable>(out var found))
{
found.CableNetwork.AddDevice(found, this);
}
}
count = 0;
FillConnected<INetworkedPipe>(span, ref count);
span2 = span;
span3 = span2.Slice(0, count);
for (int i = 0; i < span3.Length; i++)
{
SmallCellRef smallCellRef2 = span3[i];
if (smallCellRef2.TryGet<INetworkedPipe>(out var found2))
{
found2.PipeNetwork.AddDevice(found2, this);
}
}
count = 0;
FillConnected<INetworkedChute>(span, ref count);
span2 = span;
span3 = span2.Slice(0, count);
for (int i = 0; i < span3.Length; i++)
{
SmallCellRef smallCellRef3 = span3[i];
if (smallCellRef3.TryGet<INetworkedChute>(out var found3))
{
found3.ChuteNetwork.AddDevice(found3, this);
}
}
CheckConnections();
}
On world load it runs for every device regardless of build state: XmlSaveLoad.LoadWorld calls Device.AllDevices.ForEach(Device.InitializeDeviceAction); (268725; the action at 370383-370390 skips only null / IsBeingDestroyed devices before calling device.InitializeDevice() and device.InitializeDataConnection()).
CableNetwork.AddDevice(Cable, Device) (270966-270992) is pure membership bookkeeping, also with no completion check:
public void AddDevice(Cable cable, Device device)
{
HashSet<Cable> deviceRegistration = GetDeviceRegistration(device);
if (deviceRegistration == null)
{
deviceRegistration = new HashSet<Cable> { cable };
lock (DeviceList)
{
DeviceList.Add(device);
}
DeviceRegister.Add(device, deviceRegistration);
Battery battery = device as Battery;
if (battery != null)
{
BatteryList.Add(battery);
}
device.ConnectedCableNetworks.Add(this);
RefreshNetworkDevice(device);
}
else if (!deviceRegistration.Contains(cable))
{
deviceRegistration.Add(cable);
DeviceRegister[device] = deviceRegistration;
RefreshNetworkDevice(device);
}
DirtyPowerAndDataDeviceLists();
}
What completion DOES gate is per-class query behavior after registration: the base Device.GetUsedPower (371510-371521, quoted in "Construction completion" above) returns 0 unless OnOff && base.IsStructureCompleted, so an incomplete device sits on the network with zero demand. IMPORTANT caveat: that gating is per-CLASS, applied inside individual method overrides, and an override can drop it entirely. AreaPowerControl is the proven example: its GetUsedPower replaces the base without any completion term, and its IsOperable output-network fallback keeps Error at 0, so an APC generates output power at any CurrentBuildStateIndex while a valid output network exists (see AreaPowerControl, "IsOperable: a valid output network bypasses completion, broken, and short-circuit gating"). Per-method gate census for the base class: Device, "GetUsedPower gates: what makes the base override report zero demand".
Multiplayer flow: the destructive interaction runs host-side¶
OnServer.AttackWith (39630-39654) executes thing.AttackWith(attack, runSimulation) locally with doAction = GameManager.RunSimulation; on a remote client that is a preview-only call, and the client forwards an AttackWithMessage to the server. AttackWithMessage.Process (277409-277420) reconstructs the Attack and calls thing2.AttackWith(attack) with the default doAction = true. So the kit spawn and destroy above only ever execute where RunSimulation is true. AttackWithMessage is MessageFactory index 63 (191267; see GameMessageFactory).
public static void AttackWith(Thing attackParent, byte activeHandSlotId, byte offHandSlotId, long targetId, Vector3 attackPosition, float completedRatio, bool isDestroy, bool isCopy)
{
bool runSimulation = GameManager.RunSimulation;
Slot activeHand = attackParent.Slots[activeHandSlotId];
Thing thing = Thing.Find(targetId);
if ((bool)thing)
{
Slot otherHand = attackParent.Slots[offHandSlotId];
Attack attack = new Attack(activeHand, otherHand, attackPosition, thing, completedRatio, null, isDestroy, isCopy);
if (thing.AttackWith(attack, runSimulation) != null && Assets.Scripts.Networking.NetworkManager.IsClient)
{
NetworkClient.SendToServer(new AttackWithMessage { ... }); // fields at 39641-39651
}
}
}
What syncs automatically once a mod acts host-side: item spawns replicate via Thing.NewToSend (added inside Thing.Create; clients construct in Thing.DeserializeNew 322270); destruction replicates via Thing.DestroyToSend (Thing.OnDestroy 320984-320999 adds DestroyEvent.Create(this) when IsServer && HasClients); cable-network splits replicate via RebuildCableNetworkEvent (271214-271217); quantity on a spawned stack rides the normal delta state (the Stackable.Quantity setter sets NetworkUpdateFlags |= 1024).
Therefore a mod's refuse-and-drop needs exactly: run on GameManager.RunSimulation only; OnServer.Create<Item>(BuildStates[0].Tool.ToolEntry, position, rotation); SetQuantity(EntryQuantity); OnServer.Destroy(structure). Nothing manual to send. Mimic ToolUse.SpawnItem's order (spawn the refund, then destroy) and prefer Item.SetQuantity (as used at 316216) over the raw Quantity setter.
Verification history¶
- 2026-04-20: page created from the Research migration; verbatim content lifted from F0029e, F0322. No conflicts.
- 2026-04-21: added "IsBroken property" section from direct decompile of
Assets.Scripts.Objects.StructureandAssets.Scripts.Objects.Thing. Additive only; no existing content changed. Game version 0.2.6228.27061. - 2026-04-29: added "Build-state model and the destruction path" section (with "When DamageState maxes out" and "OnDamageDestroyed and StructureDestroyed" subsections) from a research pass on the explosion / structure-destruction system. Additive; no existing content changed. Sources:
Assets.Scripts.Objects.Structure(BuildStates,BrokenBuildStates,CurrentBuildStateIndex,HasBrokenMesh,AttackWith,OnDamageDestroyed,StructureDestroyed,OnDestroy),ThingDamageState.OnDamageUpdated/Destroy,IWreckage(all inAssembly-CSharp, game version 0.2.6228.27061). - 2026-06-19: extended the subclass tree with the ladder family (
Ladder : SmallGrid, ISmartRotatable,LadderEnd : Ladder,LadderPlatform : Floor : Wall : LargeStructure) and aStairs : Structurenote. Additive; no existing content changed. Sources:Assets.Scripts.Objects.Ladder/LadderEnd/LadderPlatform,SmallGrid,LargeStructure,Wall,Floor,Stairs(all inAssembly-CSharp, game version 0.2.6228.27061). - 2026-07-14: added four subsections to "Build-state model and the destruction path" from the mixed-tier cable network guard research pass against the 0.2.6403.27689 decompile: the tool-deconstruct branch of
Structure.AttackWithverbatim (315205-315262, with theCanDeconstructgate at 315366-315369 andCable.CanDeconstruct'sAttachedDevicesrefusal at 392393-392405, and theStructureDestroyedkit-refund excerpt gated on!destroyedFromDamage), theToolBasic/ToolUsefield blocks (316015-316027 / 316130-316143) with the cable-coil kit-identity note (readBuildStates[0].Tool.ToolEntryxEntryQuantityfrom the live instance),ToolUse.Deconstruct+SpawnItemverbatim (316169-316234) as the canonical drop-spawn pattern plusOnServer.Create<T>(39866-39876) andOnServer.Destroy(39914-39926) with its soft non-simulation guard (logs an error on a client, still destroys locally), and the multiplayer flow (OnServer.AttackWith39630-39654 runs withdoAction = RunSimulation;AttackWithMessage.Process277409-277420 re-runs host-side withdoAction = true;AttackWithMessageis MessageFactory index 63 at 191267; spawns replicate viaNewToSend, destroys viaDestroyToSendperThing.OnDestroy320984-320999, stack quantity viaNetworkUpdateFlags |= 1024). Also recordedStructure.UpgradeStructureServer(315307-315315) as the destroy-then-SpawnConstructupgrade pattern. Additive; the existing prose summary of the deconstruct path ("deconstructing build state 0 removes the object viaBuildStates[0].Tool.DeconstructthenOnServer.Destroy") is refined, not contradicted, by the exact branch mechanics. - 2026-07-15: added the "Construction completion: the tool stroke has no network side effects" subsection from the fresh-device power investigation against the 0.2.6403.27689 decompile plus a live dedicated-server trace: the construct branch's completion write is
CurrentBuildStateIndex+++UpdateStateVisualizer()only (315202-315203);IsStructureCompletedis last-index with theCanManufacturecarve-out (313965-313978); baseDevice.GetUsedPowerreturns -1 for a foreign network and gates demand onOnOff && IsStructureCompleted(371510-371521);Thing.Create/OnServer.Createspawns of multi-state structures arrive atCurrentBuildStateIndex = 0(live-observed on StructureConsole). Live verification via a ScenarioRunner per-tick trace (scenario pgp-fresh-device-trace on the dedicated server): demand and funding land on the same electricity tick at both the spawn-complete and the build-completion boundaries. Additive; no existing claim contradicted. - 2026-07-18: added two subsections to "Build-state model and the destruction path" from the build-state completion boundary research pass; the findings were produced and independently adversarially verified against the 0.2.6403.27689 decompile this session. "IsStructureCompleted exact semantics" (verbatim 313965-313979: last index unconditionally completed, so 1-state prefabs are complete at index 0; a non-last index requires index > 0 AND < Count AND CanManufacture; index 0 of a multi-state ladder is never completed; plus the CurrentBuildStateIndex setter 314029-314062, whose first statement clamps any value above Count - 1 to 0). "Network and port registration is never completion-gated" (Device.InitializeDevice 371775-371815 registers into every adjacent cable / pipe / chute network with no build-state term, invoked for all devices from XmlSaveLoad.LoadWorld at 268725 via InitializeDeviceAction 370383-370390; CableNetwork.AddDevice 270966-270992 has no completion check; completion shapes only per-class query methods afterwards, with the per-class caveat recorded and AreaPowerControl cited as the proven override that drops the gate). Additive; the existing "Construction completion" summary is refined, not contradicted (its "middle state" carve-out phrasing is the index > 0 case, now spelled out).
-
2026-09-21: added "Refund failures in 0.2.6428.27798, and what a structure left at index -1 becomes" from the 0.2.6428.27798 decompile, while answering a Workshop report that deconstructing painted pipes under Spray Paint Plus v1.10.1 left invisible objects: the refund and slot-emptying
try/catchblocks now inStructureDestroyed(321374-321401, and 321414-321441 for attached devices), the unchanged decrement-before-destroy order inAttackWith(321168-321184), the two matching lines of the 0.2.6428.27798 update notes, and how an index of -1 propagates and renders:IsBrokentrue, no redraw from the setter, signed-byte sync to connected clients, unsigned-byte join sync that the setter clamps to 0, kept across save and load, and the broken branch ofUpdateStateVisualizerthat draws nothing for a prefab without broken build states. The existing subsections keep their 0.2.6403.27689 stamps and are not contradicted: the new subsection compares against their excerpts rather than replacing them. Frontmatter moved to 0.2.6428.27798 for the new subsection. -
2026-09-23: added "Measured: what the caught refund failure costs, and what it does not" from a TestRig listen-host run of 2026-09-22 at game 0.2.6428.27798 with the published Spray Paint Plus v1.10.1, whose prefix on
OnServer.SetCustomColorthrows on every call and so serves as a probe for a failing colour step inside a longer sequence. Measured on a wrench deconstruct of a paintedStructurePipeCorner(#66825,customColorIndex1,Slots.Count0, one refund entry): theOnServer.AttackWithcall returned normally, the console printed the 321382PrintErrorline verbatim with a stack trace namingStructureDestroyedthenAttackWith, the pipe was gone afterwards, and the refundedItemKitPipearrived at the kit prefab's own colour 5 rather than the pipe's 1. Two controls: a freshOnServer.CreateItemKitPipereads 5, and the same deconstruct with the mod's failing walk skipped returned the kit at the structure's own colour (8 from a pipe at 8). Recorded explicitly as not measured: slot contents (none on this prefab), a second refund entry (ToolEntry2null), the attached-device loop (no attached devices), and the pre-try0.2.6403.27689 shape (not installed). Additive and confirming: every existing claim in "Refund failures in 0.2.6428.27798" held, none was changed, and no fresh validator was needed. The earlier subsections keep their 0.2.6403.27689 and earlier stamps. Bumped frontmatterverified_at; added two sources lines, the../Patterns/StaleModReferenceJitCrash.mdrelated link and thediagnosticstag. -
2026-09-24: added "
structureRenderModeis never assigned in code, so no page can name the prefabs that are Batched" under "NotImplementedException on batched structures", from the 0.2.6428.27798 decompile while looking for a batched-mesh prefab to put into a Spray Paint Plus playtest fixture. Additive and confirming: the enum (45079), the field declaration (319767), all six comparison sites (320448, 320459, 320559, 320760, 321662, 321714), theMeshRendererdestruction inAwake, andIBatchable's single implementorThingRenderer(329165). The existing prose claim that "some structures usestructureRenderMode != Standard" is unchanged and still correct; what the new subsection adds is that the set of those structures is prefab data and is not answerable from source, so a fixture needs a live per-prefab read. No fresh validator was needed.
Open questions¶
-
Which vanilla prefabs actually ship with
structureRenderMode == Batchedis unanswered. It is a serialized prefab field with no writer inAssembly-CSharp, so a decompile cannot say; settling it needs a per-prefab read off a running game (/thing?fields=structureRenderModeon a spawned instance of each candidate, or a reflective sweep over the prefab catalogue). Every prefab anybody has sampled so far readsStandard: every cladding instance on a loaded world (./Cladding.md), all four Steel Frame shapes (./Thing.md), and the rail family (./RoboticArmRail.md). So theNotImplementedExceptionbranch ofStructure.SetCustomColorhas never been observed firing on a vanilla prefab, andPaintSafe's catch for it is uncovered in every test mode. -
INFERRED, not established: that the 13 August 2026 update is what added the refund
try/catch, and that the player-reported "structures left stuck in the world" were a mod exception at this point. What is measured: at 0.2.6403.27689 neitherStructure.AttackWithnor the refund block ofStructureDestroyedcarried atry(the verbatim excerpts in "Tool deconstruct branch of AttackWith" above); at 0.2.6428.27798 the refund block, the slot-emptying block and the attached-device loop each carry one; the installedrocketstation_Data/StreamingAssets/version.inireadsUPDATEVERSION=Update 0.2.6428.27798andUPDATEDATE=Thu 13/08/2026, and that version's block (lines 4-13 of the file) carries three lines on this path, verbatim apart from the colour markup:Fixed slot contents being destroyed when a deconstruct failed to return its kit,Fixed endless null reference spam when looking at a part-deconstructed structure with a toolandFixed a failed parts refund leaving a structure stuck in the world instead of deconstructing; and a mod exception raised in the refund does produce exactly the reported outcome in the pre-tryshape, per "A structure left at index -1" above. What is not established: the builds between 0.2.6403.27689 and 0.2.6428.27798 were never decompiled, so which one introduced thetryis unknown; and no player report on this page is tied to any particular game build or mod version, so the match between the report and the mechanism is a fit, not a causal finding. Settling it needs either a decompile of an intermediate build or a report with a build stamp.