OnServer¶
Vanilla static facade for server-side mutation entry points. Callers funnel gameplay actions (paint, damage, attack) through its methods so the server remains the authoritative simulator.
SetCustomColor and AttackWith paths¶
Source: F0029b.
OnServer.SetCustomColor(...) is called when a player paints something. OnServer.AttackWith(attackParent, ...) is the local path (host or single-player). AttackWithMessage.Process(hostId) is the remote client path. Both eventually reach OnServer.SetCustomColor if the attack involves a spray can.
Who calls AttackWith: one caller, behind the player's own preview¶
OnServer.AttackWith (39634-39658) has exactly one caller in Assembly-CSharp: InventoryManager.UseItemComplete (294830-294848), which calls it at 294842 when the held item's AttackWithEvent is Server, and otherwise runs the target's AttackWith(attack) locally (294845-294846). UseItemComplete is reached from InventoryManager.HandlePrimaryUse (294029-294091) only: called directly for an action whose preview has Duration 0 (294067-294068), and handed as the callback to the WaitUntilDone coroutine for a timed one (294058, 294062). Both sit inside if (ActionCoroutine == null && delayedActionInstance != null && !delayedActionInstance.IsDisabled) (294050), where delayedActionInstance is the target's own AttackWith(attack, doAction: false) preview (294049; the gate is quoted on InventoryManager, "Timed actions: ActionCoroutine and the WaitUntilDone coroutines"). AttackWithMessage is built in one place, inside OnServer.AttackWith (39645); the type's other mentions are its declaration (283182) and its two registrations in MessageFactory (196507, 196660). The dedicated server's build has the same single caller and the same bodies (OnServer.AttackWith 39630-39654, HandlePrimaryUse 280422-280484, UseItemComplete 281223-281241, the call at 281235).
So every stroke a player's input sends began with a click whose preview, on that player's own machine, was not disabled. Only the preview taken when the action starts gates it. For an action with Duration 0 the send follows in the same call, and a spray gun's base-game preview is one (SprayGun.TimeToUse returns 0f, 361177-361180). A timed action is not checked against a preview again when it ends: its coroutine calls UseItemComplete when the wait completes (295098) and also when it is cancelled (the button released, the active hand switched, the cursor on another Thing or the target gone, another item in the hand; 295070-295084), and UseItemComplete acts on whatever CursorManager.CursorThing is at that moment. A cancelled one arrives with LastCompletedRatio below 1 and returns at once unless that Thing's AttackWithAllowIncomplete is true (294832); Thing declares it false (323709), and ItemRenamable, Human, Suit and SuitBase override it to true.
OnServer.AttackWith does not look at IsDisabled itself. On a client it sends AttackWithMessage whenever the local AttackWith(attack, runSimulation) returns non-null (39643), and a failed preview is non-null: every DelayedActionInstance.Fail overload sets IsDisabled = true and returns this (322704-322740). A caller that skips HandlePrimaryUse therefore reaches the server with a stroke no player's click could have sent: another mod, a modified client, or a test harness calling OnServer.AttackWith directly. Worked case: a spray gun with no can. Its base-game preview is disabled (ISprayer.DoSpray returns Fail(NotEnoughSprayPaint) when GetPaintMaterial() is null, 361070-361073, and SprayGun.GetPaintMaterial is null while IsEmpty, which a gun with no can always is, 361150-361175; see SprayGun, "Spray gun on a paintable target"), so no player's click sends that stroke. Spray Paint Plus's refused-glow notice depends on this; see SprayPaintPlusNetworking, "Message types", item 3.
SetCustomColor: full call-site census and the non-atomic sequences it sits inside¶
Painting a Thing by hand is only one of the things that call OnServer.SetCustomColor: at 0.2.6428.27798 there are eleven call lines in ten methods. The others are construction, deconstruction and world-spawn colour transfers, a remote detonator passing its colour on to the explosives linked to it, and stack splits colouring the new stack. Every one of them except ISprayer.DoSpray runs the colour write in the middle of a multi-step sequence that has no transaction around it, and DoSpray runs it last, after the paint is already consumed. (ToolUse.SpawnItem colours as the last statement of its own body; it sits in the middle of the deconstruct that calls it.) Anything that can throw out of OnServer.SetCustomColor (a Harmony prefix, a stale member reference, a mod exception) therefore does not just fail to paint: it aborts the rest of the enclosing sequence and leaves the world in a state vanilla never produces.
The method itself is short and carries one gate that is easy to miss:
public static void SetCustomColor(Thing thing, int colorIndex)
{
if (!thing.HasColorState)
{
thing.SetCustomColor(colorIndex);
if (Assets.Scripts.Networking.NetworkManager.IsClient)
{
NetworkClient.SendToServer(new ThingColorMessage
{
ThingId = thing.netId,
ColorIndex = thing.CustomColor.Index
});
}
}
}
HasColorState is a plain bool field on Thing (323205) computed once in Thing.OnPrefabLoad as Interactables.Exists((Interactable i) => i.Action == InteractableType.Color) (327734). A Thing that carries its own in-game colour dropdown is excluded from spray painting entirely, both here and in the Thing.AttackWith paint branch.
The call sites¶
| Call site | Line | Sequence position | Protected? |
|---|---|---|---|
ISprayer.DoSpray |
361113 | after sprayer.OnUseItem(...) at 361105 has already consumed the paint |
no |
DynamicThingConstructor.OnUseItem |
349708 | after the built Thing exists (349705), before the kit stack is decremented (349711) | no |
ToolUse.SpawnItem (from DynamicThing.AttackWith) |
322162 | after the returned kit exists, before OnServer.Destroy(this) |
no |
ToolUse.SpawnItem (from Structure.StructureDestroyed) |
322162 | same, but the caller wraps Deconstruct in try/catch |
yes |
DynamicThingData.Spawn / InventoryData.Spawn |
59382, 59463, 59470 | world and starting-loadout spawn colouring | no |
ItemRemoteDetonator.SetCustomColor (an override of Thing.SetCustomColor(int, bool)) |
354788 | in a loop over _linkedExplosives (null entries skipped), after the detonator itself is coloured by the base call (354783), before colourIndex and emissive are stored (354791-354792) |
no |
Stackable.SplitStack(Interaction, int) |
361740 | after the source stack's Quantity is reduced (361731) and the new stack is created (361735), before the new stack's Quantity, damage copy, OnSplitStack and SplitIntoHand (361742-361745); runs only when RunSimulation, the split fits and there is a root human (361722-361730) |
no |
Stackable.SplitStack(int, Slot) |
361759 | after the source stack is reduced (361751) and the new stack is created in the slot (361752), before the new stack's Quantity, damage copy and OnSplitStack (361761-361763) |
no |
Plant.SplitStack(Interaction, int) |
365059 | the Stackable shape again (the override does not call it), with the gene-collection transfer and, on the server, a flag 32 raise still to come (365065-365071) |
no |
Plant.SplitStack(int, Slot) |
365085 | the same, with the gene-collection transfer and, on the server, flag 32 still to come (365090-365096) |
no |
The four SplitStack calls colour only when CustomColor.IsSet (361738, 361757, 365057, 365083). Stackable.SplitStack(Interaction, int) is reached from Stackable.InteractWith (361699, 361713), and Stackable.SplitStack(int, Slot) from ProjectileLauncher.Fire, Stacker.TryExportStack and VendingMachineRefrigerated.FindFreeSlot (331267, 430238, 433028). The detonator row nests: OnServer.SetCustomColor on a detonator reaches its override through the virtual thing.SetCustomColor (39800), which calls OnServer.SetCustomColor again for each linked explosive. None of the eleven calls sits inside a try/catch in its own method; the only protection is in a caller, Structure.StructureDestroyed, which wraps its Deconstruct calls in try/catch (321376-321383, 321414-321424).
Build a portable from a kit: create, colour, then consume¶
DynamicThingConstructor (349658) is the kit type for portables and other DynamicThing products (as opposed to Constructor / MultiConstructor, which build Structures). Its OnUseItem override is the construction:
public override bool OnUseItem(float quantity, Thing useOnThing)
{
...
if (base.Quantity < 1)
{
return false;
}
DynamicThing thing = OnServer.CreateOld(ConstructedPrefab, vector2, rotation, RootParent.OwnerClientId);
if (PaintableMaterial != null && CustomColor.Normal != null)
{
OnServer.SetCustomColor(thing, CustomColor.Index);
}
quantity = Mathf.Min(quantity, base.Quantity);
return base.OnUseItem(quantity, useOnThing);
}
The stack decrement is base.OnUseItem (Stackable.OnUseItem, 361838), and it is the last statement. A throw out of OnServer.SetCustomColor skips it, so the player keeps the object and keeps the kit. Repeating the action duplicates indefinitely, and the colour-transfer branch fires whenever the kit itself carries a colour material, which is the common case for a painted kit.
Runtime confirmation, 2026-09-21, and the gate covers more than painted kits: a freshly spawned, never-painted ItemKitDynamicMKIILiquidCanister already reads PaintableMaterial ColorBlue, CustomColor.Normal ColorBlue and CustomColor.Index 0, so every build from a paintable kit reaches OnServer.SetCustomColor. With Spray Paint Plus v1.10.1 throwing from its prefix there, three builds made three DynamicMKIILiquidCanisterEmpty and left the stack at 5. The build is the kit's secondary use, a right-mouse hold of at least the prefab's ConstructionTime; the input path, the remote-client route, the kit prefab names and the full runtime record are on DynamicThingConstructor.
Note the contrast with the Structure kits: Constructor.Construct (301157) and MultiConstructor.Construct (312740) both consume first (if (authoringMode || OnUseItem(...))) and then spawn, and the colour is carried on CreateStructureInstance.CustomColor and applied by Structure.SetStructureData (321934) through the virtual Thing.SetCustomColor, not through OnServer.SetCustomColor:
public void SetStructureData(Quaternion localRotation, ulong ownerClientId, Grid3 localGrid, int customColourIndex)
{
Direction = localRotation;
base.OwnerClientId = ownerClientId;
RegisteredLocalGrid = localGrid;
if (customColourIndex >= 0 && PaintableMaterial != null && customColourIndex != CustomColor.Index)
{
SetCustomColor(customColourIndex);
}
}
So structure builds and portable builds reach the colour write by two different routes and have opposite consume/create ordering. A patch on OnServer.SetCustomColor sees only the portable route; a patch on Thing.SetCustomColor sees only the structure route.
Deconstruct: the returned kit inherits the parent's colour¶
ToolUse.SpawnItem (322138; ToolUse : ToolBasic is declared at 322100) is the only place a deconstructed object's colour reaches the kit it returns:
private void SpawnItem(ConstructionEventInstance eventInstance, Item tool)
{
if (tool == null) return;
int num = ((tool == ToolEntry) ? EntryQuantity : EntryQuantity2);
if (tool is Tool || num == 0) return;
Item item = OnServer.CreateOrStack(tool, num, eventInstance.Position, eventInstance.Rotation, eventInstance.OtherHandSlot);
if (!item) return;
if (item.ParentSlot == null && eventInstance.OtherHandSlot?.Parent is Human human) { ... }
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);
}
}
Three conditions gate it, and all three matter when reasoning about a failure: the spawned item must be a kit, the parent must be a DraggableThing or a Structure already past its last build state, and the parent's colour must actually differ from the kit's. An unpainted object never reaches the call at all.
The two callers protect it differently, and this is the asymmetry worth remembering:
DynamicThing.AttackWith(305611), the draggable / portable deconstruct, is unprotected. The order isExitTool.Deconstruct(eventInstance)(305672) thenOnServer.Destroy(this)(305674), with notry. A throw insideDeconstructskips the destroy, so the kit is returned and the original object survives.Structure.StructureDestroyed(321316) is protected. It wrapsBuildStates[0].Tool.Deconstruct(eventInstance)intry/catch (System.Exception ex)and prints"<name> #<id> failed to return its parts on deconstruct: <message>"to the console (321376-321383).Structure.AttackWiththen still callsOnServer.Destroy(this)(321184), so the structure does disappear; the only loss is the kit's colour and any second component fromSpawnItem(eventInstance, ToolEntry2).
The intermediate structure deconstruct step at 321156 (if (CurrentBuildStateIndex > 0 && GameManager.RunSimulation) buildState3.Tool.Deconstruct(eventInstance3);) is unprotected, but it can never reach the colour transfer: the Structure { CurrentBuildStateIndex: <=0 } pattern in SpawnItem is false by construction there.
Worked case: deconstructing a painted pipe. Piping (391638) derives from Pipe (390310), SmallSingleGrid (307338) and SmallGrid (317942), and none of them overrides AttackWith, so a pipe's deconstruct runs in Structure.AttackWith (320920); a runtime spray-stroke trace on a pipe shows the same chain, OnServer.AttackWith straight into Structure.AttackWith. On the last build state the order is:
- the exit tool's
OnUseItem(buildState2.Tool.ExitQuantity, this)(321168); CurrentBuildStateIndex--(321172);StructureDestroyed(eventInstance4)(321183), whosetry(321376-321379) runsBuildStates[0].Tool.Deconstruct(eventInstance)(321378). That isToolUse.Deconstruct(322166-322170):SpawnItem(eventInstance, ToolEntry)followed bySpawnItem(eventInstance, ToolEntry2), and inside the firstSpawnItemthe returned kit is created or stacked byOnServer.CreateOrStack(322149) before the colour transferOnServer.SetCustomColor(item, eventInstance.Parent.CustomColor.Index)(322162);OnServer.Destroy(this)(321184).
So for a painted pipe the colour write sits after the returned kit exists and before the pipe is destroyed. A throw from it lands in the catch (321380-321383): the kit keeps the colour it was created with, the ToolEntry2 refund is skipped, the console gets <DisplayName> #<ReferenceId> failed to return its parts on deconstruct: <message>, the slot-emptying block (its own try/catch, 321384-321400) and the attached-device loop (321402-321444) still run, and step 4 removes the pipe. In 0.2.6428.27798 no half-deconstructed pipe is left behind.
Runtime confirmation, 2026-09-22, on a listen host with the same v1.10.1 build: a wrench deconstruct of a painted StructurePipeCorner at customColorIndex 1 printed that console line verbatim with a stack trace naming Structure.StructureDestroyed then Structure.AttackWith, removed the pipe, and returned one ItemKitPipe at customColorIndex 5, which a control spawn confirmed is the kit prefab's own colour. The same deconstruct with the mod's failing walk skipped returned the kit at the structure's colour instead. So the colour transfer at 322162 is the whole of what the caught failure costs. The slot-emptying block and the attached-device loop were not exercised (Slots.Count 0, no attached devices) and ToolEntry2 was null, so the skipped-second-entry half of the paragraph above is still decompile-only. Full readings, controls and the list of what was not measured: Structure, "Measured: what the caught refund failure costs, and what it does not".
The 0.2.6428.27798 update notes (rocketstation_Data/StreamingAssets/version.ini, UPDATEDATE=Thu 13/08/2026) include two lines about failed deconstructs, 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. At 0.2.6403.27689 neither Structure.AttackWith nor the refund block of StructureDestroyed had a try (verbatim on Structure, "Tool deconstruct branch of AttackWith"), and in both versions CurrentBuildStateIndex is decremented before StructureDestroyed runs and OnServer.Destroy(this) is reached only after it returns. So in the 0.2.6403.27689 shape a throw from the colour transfer would have left the pipe in the world at index -1, with its kit already spawned. What a structure left at -1 becomes (drawn nowhere and with AllowInteraction false on the clients connected at the time and on the host after a reload, when the prefab has no broken build states) is on Structure, "Refund failures in 0.2.6428.27798, and what a structure left at index -1 becomes". The builds between 0.2.6403.27689 and 0.2.6428.27798 were not decompiled.
Related colour-write facts established in the same pass¶
Structure.SetCustomColor(320446) throwsNotImplementedExceptionunconditionally whenstructureRenderMode != StructureRenderMode.Standard. Batched structures render through aMaterialPropertyBlockand have theirMeshRendererdestroyed inStructure.Awake, so there is no per-instance material to swap.CustomColorMapping.SetEmissive(Material)(301512) andSetColor(Material, int)(301527) both no-op on a null material. AColorSwatchwith a nullEmissivecannot blank a renderer, so a missing emissive material cannot produce an invisible object.Thing.AttackWith(327590) routes to painting atif (IsPaintable && sourceItem is ISprayer sprayer && !HasColorState) return ISprayer.DoSpray(this, sprayer, doAction);.Thing.IsPaintable(324390) isPaintableMaterial != null || HasPaintableMaskMaterial. A dropped kit is aStackable:Constructor(301144),MultiConstructor(312705) andDynamicThingConstructor(349658) all derive fromStackable(361438), which derives fromItem(310851) and so fromDynamicThing(304205), and none of those classes overridesAttackWithorCanDeconstruct. A stroke on a loose kit therefore runsDynamicThing.AttackWith(305611-305683), whose early-outif (!sourceItem || !CanDeconstruct()) return base.AttackWith(attack, doAction);(305614-305617) does not fire:DynamicThing.CanDeconstruct()(305699-305702) isreturn true;, and its only override,DynamicGasCanister.CanDeconstruct(302983), is not on a kit's chain. The stroke gets through at the tool test instead.Tool tool = sourceItem as Tool;(305623) is null for aSprayCan(SprayCan : Consumableat 361018 andConsumable : Itemat 180052, whileTool : ItemRenamableis at 362823), so the deconstruct block behindif ((bool)tool && (bool)ExitTool.ToolExit && ExitTool.IsToolExit(tool))(305624) is skipped and the method ends atreturn base.AttackWith(attack, doAction);(305682), which isThing.AttackWithand its paint branch. A loose kit is therefore a normal paint target. ASprayGun(361126) is aTooland passes the first test, but still falls through unless it is the kit's exit tool:ToolUse.IsToolExit(322125-322136) matches only onPrefabHash(the tool's own, or itsReplacementOf's) againstToolExit.PrefabHash. One step rests on Unity rather than the decompile:ExitTool.ExitTimeis read at 305620, before the tool test, from the[Serializable]ToolUsefieldExitTool(304310), which no code in the assembly assigns; the run below shows that read does not throw for a kit. Runtime confirmation, 2026-09-21, game version 0.2.6428.27798: the TestRig checkLooseKitTakesPaint(Mods/SprayPaintPlus/playtests/LooseKitTakesPaint.cs) passed in the Spray Paint Plus v1.12.0 release run. On a listen host, anItemKitDynamicMKIILiquidCanister(aDynamicThingConstructor,IsPaintabletrue) spawned loose byOnServer.Create, in no slot, went from colour index 0 to 4 on oneOnServer.AttackWithstroke ofItemSprayCanRed(the rig'sPOST /player/use), and the can'sQuantityfell from 1 to 0.95 against aUseAmountof 0.05. Spray Paint Plus was loaded, but itsThing.AttackWithprefix returns to vanilla for any source that is not aSprayGun, and it does not patchDynamicThing.AttackWith. The rig'sonGroundflag only means "in no slot", and the kit's height was still dropping through the check, so the run shows a loose kit taking paint, not one at rest.ISprayer.DoSpray(361064) consumes before it paints:sprayer.OnUseItem(sprayer.GetUseAmount(), thing)runs first andOnServer.SetCustomColor(thing, colorSwatch.Index)last. A throw from the colour write costs the paint and leaves the target unpainted.- A spray stroke from a remote client runs that consume-and-colour block on the server only.
OnServer.AttackWith(39634) callsthing.AttackWith(attack, runSimulation), so on a clientdoActionis false andDoSprayreturns atif (!doAction)(361099) before consuming anything; the client then sendsAttackWithMessage, whoseProcess(283200-283211) callsthing2.AttackWith(attack)with the defaultdoAction: trueon the server. A throw from the colour write is therefore raised on the server. On a host or in single-player it is raised inside the painting player's ownInventoryManagercoroutine, where it also leaves primary tool use disabled until something clearsActionCoroutine(InventoryManager, "Timed actions").
Destroy(Thing) -- the canonical low-level removal¶
public static void Destroy(Thing thing)
{
if (!GameManager.RunSimulation && GameManager.GameState == GameState.Running)
ConsoleWindow.PrintError("OnServer.Destroy called on client for " + (thing ? thing.DisplayName : "unknown"));
if ((bool)thing && (bool)thing.GameObject)
{
thing.GameObject.DestroyGameObject(); // Unity Destroy on the GameObject
thing.BeingDestroyed = true;
}
}
DestroyGameObject() destroys the Unity GameObject, which fires Thing.OnDestroy() (and Structure.OnDestroy() for structures). OnServer.Destroy itself does no item-dropping and no networking; Thing.OnDestroy() does the cleanup: sets BeingDestroyed, stops audio, queues a DestroyEvent for clients when NetworkManager.IsServer && NetworkServer.HasClients(), deregisters from every manager (ElectricityManager, AtmosphericsManager, OcclusionManager, LightManager, transmitters, etc.), ReleaseThing() / OnReleaseReagents(), removes colliders from Thing._colliderLookup, and handles slot occupants -- with the default Thing.DestroyChildrenOnDead == true, children are destroyed with the parent (DestroyChildren(transform)); when set false, occupants are moved to the world via OnServer.MoveToWorld. Structure.OnDestroy() additionally does GridController.Deregister(this), removes from GridController.AllServerTickStructures, and cleans up the batched renderer.
Server-authoritative: it logs an error if called on a client while the game is running. Safe for a mod to call directly on any Thing (including Structure, Frame, Wall, SmallGrid, machines) on the server side only -- it bypasses the build-stage walk and the broken-mesh wreckage early-return, so structures with a broken-mesh build state actually disappear rather than convert to wreckage. There is no type gate and no Indestructable check inside Destroy itself (the Indestructable check lives in Structure.OnDamageDestroyed, the damage path, not here). A threaded caller should use Thing.DestroyFromThread() (it does await UniTask.SwitchToMainThread() then OnServer.Destroy). Related: Thing.Delete(Thing) (see ./Thing.md) is the higher-level "a tool deleted me" path that recursively Deletes slot occupants and, on a pure client, sends a DestroyThingRequest to the server instead of calling Destroy; Structure.OnDamageDestroyed() (see ./Structure.md) is the "damage maxed out" path that adds wreckage spawning and the construction-event broadcast before ending in OnServer.Destroy.
Interact: RunSimulation gate, worker-thread queueing, and the destroyed-parent silent no-op¶
OnServer is a static class (decompile 39504 at 0.2.6403.27689). The Interact family is the funnel every replicated interactable-state write goes through (OnOff, Powered, Error, Open, Mode, ...; the whole-game Powered writer census is on Device). Verbatim (39664-39723):
public static void Interact(Thing thing, InteractableType interactableType, int state, bool skipAnimation = false) // 39664
{
if ((object)thing == null)
{
return;
}
Interactable interactable = null;
foreach (Interactable interactable2 in thing.Interactables)
{
if (interactable2.Action == interactableType)
{
interactable = interactable2;
break;
}
}
if (interactable != null)
{
Interact(interactable, state, skipAnimation);
}
}
public static void Interact(InteractionInstance interactionInstance) // 39685
{
Interact(interactionInstance.Thing, interactionInstance.Action, interactionInstance.State, interactionInstance.SkipAnimation);
}
public static void Interact(Interactable interactable, int state, bool skipAnimation = false) // 39690
{
if (!GameManager.RunSimulation || interactable == null)
{
return;
}
if (ThreadedManager.IsThread)
{
lock (Interactable.QueuedInteractions)
{
Interactable.QueuedInteractions.Enqueue(new InteractionInstance(interactable.Parent, interactable.Action, state, skipAnimation));
return;
}
}
if (GameManager.GameState != GameState.Running || (object)interactable.Parent == null)
{
return;
}
if (skipAnimation && interactable.State != state)
{
interactable.Interact(state);
}
else if ((bool)interactable.Parent && interactable.Parent.isActiveAndEnabled)
{
if (interactable.Parent.AllowInteraction)
{
interactable.Interact(state, skipAnimation);
}
else
{
interactable.InteractWhenReady(state, skipAnimation);
}
}
}
Gate by gate:
- Clients: the whole funnel is inert.
!GameManager.RunSimulationreturns first (RunSimulation => !NetworkManager.IsClient, 203945), consistent with the server-authoritative framing in the page intro. - Worker threads: deferred at least one main-thread frame via a queue. When called with
ThreadedManager.IsThreadtrue (any thread but the Unity main thread, 217769), the call enqueues anInteractionInstancecapturinginteractable.Parentplus action/state/skipAnimation, and returns. The queue isInteractable.QueuedInteractions(static Queue<InteractionInstance>, 304374);Interactable.DoQueuedInteractions(304777-304785) drains it under the same lock, callingOnServer.Interact(instance)per entry, which re-resolves the interactable off the captured Thing through the 39664 overload. The single drain site isGameManager.Update, gated onRunSimulation && !WorldManager.IsGamePaused(205154-205170). This queue is one of vanilla's two marshaling mechanisms; the other is the per-methodawait UniTask.SwitchToMainThread()wrappers (Device.SetPowerFromThread371648-371652,Thing.DestroyFromThread,Transformer.SetKnobFromThread), which land on the main thread first and then enter this method withIsThreadfalse. - Destroyed or missing parent: silent no-op. On the main-thread path, a reference-null
Parentreturns at 39704-39707 (as does anyGameState != Running, so interactions during load and teardown windows are dropped). A Unity-DESTROYED parent is reference-non-null but fails the alive check(bool)interactable.Parentat 39712 (Unity fake-null), and an inactive one failsisActiveAndEnabled; either way the state write is silently discarded. Note the asymmetry: theskipAnimation && interactable.State != statebranch (39708-39711) runs BEFORE the alive check and is guarded only by the reference-null test, so a skip-animation interact can still reachinteractable.Interact(state)against a fake-null parent.
Consequence for marshaled Powered writes: PowerTick.ApplyState decides power flips on the game-tick worker and marshals them via Device.SetPowerFromThread -> await UniTask.SwitchToMainThread() -> SetPower -> OnServer.Interact(InteractPowered, 0|1) (call sites 271933 / 271938; bodies 371648-371652 and 371640-371646; skipAnimation defaults to false). If the device is deconstructed between the worker-thread decision and the main-thread continuation, the write lands in the destroyed-parent no-op above and is dropped; a late Powered write cannot act on a dead device. Queued interactions from other worker-thread callers get the same protection one frame later: the queue holds the Thing reference across the frame, and the 39664 overload plus these gates drop it safely if the Thing died meanwhile.
Verification history¶
- 2026-04-20: page created from the Research migration; verbatim content lifted from F0029b. No conflicts.
- 2026-04-29: added "Destroy(Thing) -- the canonical low-level removal" section from a direct decompile of
OnServer.DestroyandThing.OnDestroy/Structure.OnDestroy(game version 0.2.6228.27061). Additive only; no existing content changed. - 2026-07-13: added "Interact: RunSimulation gate, worker-thread queueing, and the destroyed-parent silent no-op" (game version 0.2.6403.27689) from a fresh-validation pass on a decompile-claim audit. Verbatim
Interactoverloads (39664-39723) with the gate ladder: client early-out viaRunSimulation(203945); worker-thread enqueue intoInteractable.QueuedInteractions(304374) under lock, drained once per frame byInteractable.DoQueuedInteractions(304777-304785) fromGameManager.Update(205154-205170);GameState/ reference-null return (39704-39707); Unity-alive check(bool)Parent && isActiveAndEnabled(39712) guarding only the animated branch. Recorded the marshaled-Powered-write consequence (SetPowerFromThreadcontinuations dropped silently when the device died in between). Additive; no existing content changed. Cross-links the Powered writer census on Device. - 2026-09-21: added "SetCustomColor: full call-site census and the non-atomic sequences it sits inside" (game version 0.2.6428.27798) while investigating three Workshop bug reports against Spray Paint Plus. Verbatim
OnServer.SetCustomColorbody (39796-39810) with theHasColorStategate; the five call sites and their position inside the enclosing sequence;DynamicThingConstructor.OnUseItemcreate-colour-consume ordering (349686-349711) againstConstructor.Construct/MultiConstructor.Constructconsume-then-spawn ordering (301157-301168, 312740-312752);ToolBasic.SpawnItemcolour transfer and its three gates (322138-322162); the protection asymmetry between the unprotectedDynamicThing.AttackWithdeconstruct (305672-305674) and thetry/catch-wrappedStructure.StructureDestroyed(321374-321381); plusStructure.SetStructureDatareaching colour through the virtualThing.SetCustomColorrather than theOnServerfacade (321934-321942),Structure.SetCustomColor'sNotImplementedExceptionon non-Standard render modes (320446),CustomColorMapping's null-material no-ops (301512, 301527), and theThing.AttackWithpaint-branch gate plusHasColorStateinitialisation (327636, 327734). Additive; no existing content changed. - 2026-09-21: conflict on "the class that declares SpawnItem and Deconstruct". Previous claim:
ToolBasic.SpawnItem(322138), with the sources listingToolBasic.SpawnItem + Deconstruct. New finding: both methods are declared onToolUse : ToolBasic(322100);ToolBasic(321963) ends at 322098 and declares neither, andBuildState.Tool(319298) andDynamicThing.ExitTool(304310) are typedToolUse. Fresh validator verdict: B is correct, quoting322100 public class ToolUse : ToolBasic,322138 private void SpawnItem(ConstructionEventInstance eventInstance, Item tool)and322166 public void Deconstruct(ConstructionEventInstance eventInstance); it also confirmed that everyDeconstructcall goes through aToolUsefield (305672, 321166, 321378, 321418). Result: the two table rows, the lead sentence of "Deconstruct: the returned kit inherits the parent's colour" and the sources line now nameToolUse; the behaviour described is unchanged. The 2026-09-21 entry above keeps its original wording. - 2026-09-21 (second pass, game version 0.2.6428.27798): resolved the Open question on Mono's failure granularity: the whole method fails. A cable stroke under Spray Paint Plus v1.10.1 throws the same
MissingFieldExceptionas a pipe stroke although the cable branch never executes a stale read; details on StaleModReferenceJitCrash. The Workshop statement of 2026-08-12 that cable painting kept working is therefore not explained by instruction-level failure. Added to "SetCustomColor: full call-site census": the runtime confirmation of the portable-kit duplication and of the colour gate passing for a never-painted kit, linked to the new DynamicThingConstructor; a worked painted-pipe deconstruct with the exact statement order (321168, 321172, 321183, 321184, 321378, 322149, 322162), the two matching lines of the 0.2.6428.27798 update notes and the comparison with the 0.2.6403.27689 excerpt on Structure; and the remote-client split forDoSpray(OnServer.AttackWith39634,AttackWithMessage.Process283200). Citation fixes, no claim changed:OnServer.Destroy(this)inStructure.AttackWithis at 321184 (was 321182),StructureDestroyedstarts at 321316 (was 321315), and its refundtry/catchspans 321376-321383 (was 321374-321381). - 2026-09-21 (third pass, game version 0.2.6428.27798): conflict on "why a loose kit is a paint target". Previous claim, in "Related colour-write facts established in the same pass": "A dropped kit is a
Stackable, whoseDynamicThing.AttackWithoverride falls through tobase.AttackWithwhenCanDeconstruct()is false, so a kit lying on the ground is a normal paint target." New finding:DynamicThing.CanDeconstruct()returnstrue(305699-305702) and no kit class overrides it, so that early-out never fires for a kit; a spray-can stroke reachesThing.AttackWiththrough the closingreturn base.AttackWith(attack, doAction);(305682), becauseSprayCanis aConsumable, not aTool, and the deconstruct block needs aToolthatExitTool.IsToolExitaccepts. Fresh validator verdict: B is correct, quotingpublic virtual bool CanDeconstruct() { return true; }(305699-305702). It found the onlybool CanDeconstruct()override in the assembly onDynamicGasCanister(302983), neitherAttackWithnorCanDeconstructonItem,Stackable,DynamicThingConstructor,ConstructororMultiConstructor, and no path fromSprayCantoTool, and it raised two limits that are now on the page: the read ofExitTool.ExitTimeat 305620 comes before the tool test and a non-nullExitToolrests on Unity serialization rather than on the decompile, and aSprayGun, being aTool, enters the deconstruct block for a kit whose exit tool it is. Result: the bullet keeps itsThing.AttackWithandIsPaintableexcerpts and its conclusion, gives the real route, and cites the passing 2026-09-21 run of theLooseKitTakesPaintcheck for the behaviour. The section stamp was already 0.2.6428.27798 @ 2026-09-21 and stands, as do the frontmatter dates; a sources line was added for the decompile ranges and one for the check. Occasion: found while writing Spray Paint Plus playtests. - 2026-09-21 (fourth pass, game version 0.2.6428.27798): conflict on "how many callers
OnServer.SetCustomColorhas". Previous claim, in "SetCustomColor: full call-site census and the non-atomic sequences it sits inside": "Painting a Thing by hand is only one of five things that callOnServer.SetCustomColor. The other four are construction, deconstruction and world-spawn colour transfers, and three of them run the colour write in the middle of a multi-step sequence that has no transaction around it." and a five-row table under "The five call sites". New finding: eleven call lines in ten methods; the table missedItemRemoteDetonator.SetCustomColorand bothSplitStackoverloads onStackableand onPlant. Fresh validator verdict: B is correct, quoting theItemRemoteDetonator.SetCustomColoroverride (354781-354793). It confirmed the original five rows, found no other caller in the client or dedicated-server decompile, and noted thatToolUse.SpawnItemcolours as the last statement of its own method, so "in the middle" holds for the deconstruct around it rather than for the method. Result: the lead paragraph rewritten, the table retitled "The call sites" with five new rows, and a paragraph added on the split gates and callers, the detonator's nesting and the onetry/catchthat protects a call. The section stamp was already 0.2.6428.27798 @ 2026-09-21 and stands. Added a sources line. - 2026-09-21 (fifth pass): added two Open questions that the fourth pass's fresh validator reported and that were not checked. No content and no stamp changed.
- 2026-09-23 (game version 0.2.6428.27798): added one paragraph of runtime confirmation to "Deconstruct: the returned kit inherits the parent's colour", under its own inline stamp, from a listen-host run of 2026-09-22 with the published Spray Paint Plus v1.10.1. It confirms the worked painted-pipe walk end to end: the console line, the destroy, and a refunded
ItemKitPipeat the kit prefab's colour 5 against a pipe at 1, with a same-session control deconstruct that skipped the failing walk returning the kit at 8 from a pipe at 8. It also narrows what the paragraph above can claim from measurement: the slot-emptying block, the attached-device loop and the skippedToolEntry2refund were all unexercised on this prefab and stay decompile-only. Additive and confirming; no existing claim changed and no fresh validator was needed. The H2 section stamp stays at 0.2.6428.27798 @ 2026-09-21, matching the convention the earlier passes on this page set; bumped frontmatterverified_atand added a sources line. - 2026-09-28 (game version 0.2.6428.27798): added "Who calls AttackWith: one caller, behind the player's own preview" under "SetCustomColor and AttackWith paths", with its own stamp: a census of the callers of
OnServer.AttackWith(one,InventoryManager.UseItemComplete, 294842) and ofnew AttackWithMessage(one, insideOnServer.AttackWith, 39645), the preview gate inHandlePrimaryUsein front of the one caller, what a timed action does not check again (its coroutine callsUseItemCompleteon completion and on cancel, and a cancel acts only on a Thing whoseAttackWithAllowIncompleteis true), whyOnServer.AttackWithitself sends a failed preview (Failreturns the instance), and the empty spray gun as the worked case. Drafted in.work/2026-09-28-landing/M6-RESULT.mdbehind the Spray Paint Plus change in commit0b70e3e6, and re-read here in the client and dedicated server decompiles, whose bodies are textually identical. Changed while checking it:HandlePrimaryUseends at 294091, not 294085; the draft's "no player input sends a stroke whose preview on that player's own machine is disabled" now says that only the preview taken when the action starts gates it, since a timed action is not checked again when it completes or is cancelled; and the type's other three mentions are named. Read from the code, not measured in game. Additive: the paragraph above it is not changed, and the remote-client bullet under "Related colour-write facts" already records that a client runsOnServer.AttackWithtoo. Added a sources line and tworelatedlinks.
Open questions¶
- Reported by a fresh validator on 2026-09-21 during the call-site census correction, not checked:
Plant.SplitStackIntoEmptySlot(365024-365037) splits a plant stack without copying its colour, which would make it the one split path that drops the colour. - Reported by the same validator, not checked:
OnServer.CreateOrStack(39882-39932) returns null when the whole refund fits into the stack already in hand, soToolUse.SpawnItemreturns before its colour transfer and no colour is copied in that case. ItemRemoteDetonator.emissive(decompile 354507) is set at 354792 and, per a validator in the 2026-09-21 correction pass, never read, saved or sent to clients. Unchecked.