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.
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;
}
}
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).
Open questions¶
None.