SprayGun¶
Vanilla game class at Assets.Scripts.Objects.Items.SprayGun. A Tool that paints targets by delegating to a loaded SprayCan. Like SprayCan, it implements ISprayer and reaches OnServer.SetCustomColor through the shared ISprayer.DoSpray static helper.
Declaration¶
namespace Assets.Scripts.Objects.Items
{
public class SprayGun : Tool, ISprayer, IUsedAmount, IUsed
{
}
}
Tool is the base. The three additional interfaces (ISprayer, IUsedAmount, IUsed) are the same set SprayCan implements, so either class is interchangeable as the sprayer argument to ISprayer.DoSpray.
Unchanged at 0.2.6428.27798 (client decompile line 361126, dedicated server decompile line 347494). The chain above it: Tool : ItemRenamable, IShowBuildStateTooltip (362823), ItemRenamable : Item, IRepairable (346217), Item : DynamicThing, IPerishable, IDensePoolable, ITradable, IEvaluable (310851), DynamicThing : Thing, ... (304205), Thing : MonoBehaviour, ... (322656). SprayCan is SprayCan : Consumable, ISprayer, IUsedAmount, IUsed (361018). Nothing derives from SprayGun.
The whole class at 0.2.6428.27798, verbatim (361126-361220):
public class SprayGun : Tool, ISprayer, IUsedAmount, IUsed
{
public float Efficiency = 4f;
private static readonly Vector3 LeftHandRot = new Vector3(-75f, 127f, -126f);
private static readonly Vector3 LeftHandPos = new Vector3(-0.056f, 0.0231f, -0.072f);
private static readonly Vector3 RightHandRot = new Vector3(67f, -119f, -128f);
private static readonly Vector3 RightHandPos = new Vector3(-0.0524f, 0.0263f, 0.0566f);
public override bool IsOperable
{
get
{
if (!IsEmpty)
{
return OnOff;
}
return false;
}
}
public bool IsEmpty
{
get
{
if ((object)SprayCan != null)
{
return SprayCan.Quantity <= float.Epsilon;
}
return true;
}
}
public override int ConstructingSoundHash => Animator.StringToHash("SprayPaintLong");
public override int FinishedConstructingSoundHash => Animator.StringToHash("SprayPaintFinished");
public SprayCan SprayCan => Slots[0].Occupant as SprayCan;
public Material GetPaintMaterial()
{
if (!IsEmpty)
{
return SprayCan.PaintMaterial;
}
return null;
}
public float TimeToUse()
{
return 0f;
}
public float GetUseAmount()
{
if (!IsEmpty)
{
return SprayCan.UseAmount * (1f / Efficiency);
}
return 0f;
}
public override bool CheckTogglePower()
{
return true;
}
public override void SetHandPosition(bool leftHand)
{
if (leftHand)
{
LocalRotationInHand = LeftHandRot;
LocalOffSetInHand = LeftHandPos;
}
else
{
LocalRotationInHand = RightHandRot;
LocalOffSetInHand = RightHandPos;
}
base.ThingTransformLocalPosition = LocalOffSetInHand;
base.ThingTransformLocalRotationEuler = LocalRotationInHand;
}
public override bool OnUseItem(float quantity, Thing useOnThing)
{
if (!IsOperable)
{
return false;
}
return SprayCan.OnUseItem(quantity * (1f / Efficiency), useOnThing);
}
}
TimeToUse() returns 0f, so the preview DoSpray builds for the gun has Duration = 0, and GetUseAmount() is the loaded can's UseAmount * (1f / Efficiency), a quarter of it at the default Efficiency = 4f. SprayCan is read from Slots[0] on every call, so it is null whenever the slot is empty.
OnUseItem¶
OnUseItem(float quantity, Thing useOnThing) overrides Item.OnUseItem (public virtual bool OnUseItem(float quantity, Thing useOnThing), 311161) and is also the gun's IUsed.OnUseItem (363134). At 0.2.6428.27798 (client decompile 361212-361219; recorded at 0.2.6228.27061 at decompile line 334806-334812), verbatim:
public override bool OnUseItem(float quantity, Thing useOnThing)
{
if (!IsOperable)
{
return false;
}
return SprayCan.OnUseItem(quantity * (1f / Efficiency), useOnThing);
}
It returns false unless the gun is operable (a can with paint in it and the gun switched on, see "IsOperable: declaration and every reader"), then scales quantity by 1f / Efficiency and delegates to SprayCan.OnUseItem on the loaded can (361044-361049):
public override bool OnUseItem(float quantity, Thing onUseThing)
{
base.Quantity -= quantity;
AtmosphericEventInstance.CloneGlobalAddGasMix(base.WorldGrid, PollutionMixture);
return true;
}
The IsOperable gate is also what keeps an empty slot away from the SprayCan.OnUseItem call: with no can, SprayCan is null.
Does not call OnServer.SetCustomColor directly; paint application runs through ISprayer.DoSpray (see ISprayer.md), which calls sprayer.OnUseItem(sprayer.GetUseAmount(), thing) at 361105 and OnServer.SetCustomColor(thing, colorSwatch.Index) at 361113, the second only when the first returned true.
On the paint path the gun's Efficiency is applied twice. The amount DoSpray passes is the gun's own GetUseAmount(), which is already SprayCan.UseAmount * (1f / Efficiency) (361186), and OnUseItem multiplies it by 1f / Efficiency again before SprayCan.OnUseItem subtracts it (361218). One gun paint therefore takes UseAmount / (Efficiency * Efficiency) from the loaded can, where the same can painting by hand loses UseAmount (Consumable.GetUseAmount, 180109-180112, returns the field, whose C# default is 0.1f at 180056): a sixteenth as much at the C# default Efficiency = 4f. The prefab's serialized Efficiency and the cans' UseAmount were not read, so the shipped ratio may differ, and nothing in the code says whether the double scaling is intended. Neither OnUseItem calls base, so Consumable.OnUseItem and Item.OnUseItem do not run on this path (see ISprayer.md, "Who calls whom").
Contrast with SprayCan¶
| Aspect | SprayCan |
SprayGun |
|---|---|---|
| Base class | Consumable |
Tool |
| Ammo model | Self-contained charges | Loaded SprayCan supplies charges |
OnUseItem path |
Direct paint via ISprayer.DoSpray |
Scale quantity, delegate to loaded SprayCan.OnUseItem |
ISprayer |
Yes | Yes |
Both reach OnServer.SetCustomColor(thing, colorSwatch.Index) via ISprayer.DoSpray line 334711. By the time execution is inside SetCustomColor, the tool identity is no longer on the argument list; distinguishing the two requires hooking upstream at DoSpray.
IsOperable: declaration and every reader¶
Line numbers in this section are from the 0.2.6428.27798 client decompile. The dedicated server's Assembly-CSharp has the same members with byte-identical bodies; its line numbers are in the last subsection.
Declared on Tool, not Thing¶
IsOperable is not a Thing member. The virtual that SprayGun overrides is declared on Tool, where it defaults to true (the first lines of the class, 362823-362831):
public class Tool : ItemRenamable, IShowBuildStateTooltip
{
[Header("Tool")]
[SerializeField]
private float manuallyAuthoredToolSpeedOffset;
private readonly float _suitlessAddedSpeed = 0.2f;
public virtual bool IsOperable => true;
Thing, DynamicThing, Item and ItemRenamable declare no IsOperable. The override (361138-361148) and the IsEmpty it reads (361150-361160), verbatim:
public override bool IsOperable
{
get
{
if (!IsEmpty)
{
return OnOff;
}
return false;
}
}
public bool IsEmpty
{
get
{
if ((object)SprayCan != null)
{
return SprayCan.Quantity <= float.Epsilon;
}
return true;
}
}
This is the same logic as the compact excerpt recorded at 0.2.6228.27061:
So the gun counts as operable only with a can holding more than float.Epsilon of paint and the gun switched on. IsEmpty is also the gun's IUsed.IsEmpty (363132), which is the property ISprayer.DoSpray tests.
Overrides of the Tool slot: PneumaticMiningDrill (181137), ProjectileLauncher (331189), PowerTool (359638) and, below it, Defibrillator (349371), LiquidVacuum (355996) and Laptop (404910), SprayGun (361138), WeldingTorch (363189). The other classes that derive from Tool directly (Pickaxe, TerrainEditor, AuthoringTool, ManualTool) keep the default true.
Several unrelated declarations share the name and not the slot: Device (protected virtual bool IsOperable, 377140) and its many overrides, PowerItem (public virtual bool IsOperable, 359534), Motherboard (public virtual bool IsOperable => LinkedDevices.Count > 0;, 356815) and its overrides, the methods PortableAtmosphericsPowered.IsOperable() (313754), Appliance.IsOperable() (439260) and DynamicGenerator.IsOperable() (303539), the non-virtual properties RobotMining.IsOperable (314630) and SurvivalToolbelt.IsOperable (322218), and the interface property IConstructor.IsOperable (363126-363129). IConstructor is implemented by WeldingTorch, AngleGrinder and ArcWeldingTool (WeldingTorch and ArcWeldingTool also through IWelder : IConstructor, IUsed), and not by SprayGun or anything above it, so no interface call reaches a spray gun.
Every read that can reach a spray gun¶
The client decompile has 437 IsOperable tokens: 120 declarations and 317 uses. None is a string, nameof or reflection reference, and no other of the 146 managed assemblies in rocketstation_Data/Managed contains the name get_IsOperable. A use reaches SprayGun.get_IsOperable only when its receiver can hold a spray gun at run time, which needs a static type of Tool or SprayGun; no interface qualifies. Seven uses have such a receiver:
| Use | Where | What has to hold before a spray gun is the receiver |
|---|---|---|
if (!IsOperable) return false; |
SprayGun.OnUseItem, 361214 |
A call to OnUseItem on a gun: from ISprayer.DoSpray (361105), or from the tool paths in the rows below |
if (IsOperable) return true;, else IsDisabled = true plus the ToolNotCurrentlyOperableForTask message |
Tool.CanUseTool(ref DelayedActionInstance), 362875 |
Its only caller, Structure.HandleToolUse (321265), which runs from the repair (321011), upgrade (321037) and construct (321101) branches of Structure.AttackWith, each behind ToolBasic.IsToolEntry(item) |
tool2.IsOperable |
Structure.AttackWith, 320983 |
A broken build state whose Tool.IsToolExit(item) is true (320975) |
tool3.IsOperable |
Structure.AttackWith, 321143 |
The current build state's Tool.IsToolExit(item) (321140) |
tool.IsOperable |
DynamicThing.AttackWith, 305626 |
The target's ExitTool.IsToolExit(tool) (305624) |
tool4.IsOperable |
Structure.AttackWith, 321205 |
tool4 is IConstructor (321201), which a spray gun never is |
tool.IsOperable |
Ice.AttackWith, 353825 |
attack.SourceItem is IWelder (353815), which a spray gun never is |
Tool.CanUseTool in full (362873-362882):
public bool CanUseTool(ref DelayedActionInstance actionInstance)
{
if (IsOperable)
{
return true;
}
actionInstance.IsDisabled = true;
actionInstance.AppendStateMessage(GameStrings.ToolNotCurrentlyOperableForTask, DisplayName);
return false;
}
The other 50 uses with an explicit receiver are typed to something a spray gun is not: Labeller (24), ItemRemoteDetonator (5), WeldingTorch (2), IWelder (2, both in ManualHatch), Crowbar (AreaPowerControl), Defibrillator and NightVisionGoggles (both in Human), Tablet (OreScanner, through Cartridge.Tablet), or base.IsOperable inside another class's override (13). The remaining uses are implicit this reads, and apart from Tool.CanUseTool and SprayGun.OnUseItem they all sit in classes a spray gun is not.
The 69 OnUseItem call sites were sorted the same way. A spray gun can be the receiver only at ISprayer.DoSpray (361105, typed ISprayer) and at the Tool-typed calls that follow the reads above: DynamicThing.AttackWith (305677), Structure.AttackWith (320991, 321168) and Structure.HandleToolUse (321285); the tool4 call (321221) sits behind the IConstructor test. DoSpray has one caller, Thing.AttackWith (327638).
The tool gates never admit a spray gun in the shipped data¶
ToolBasic.IsToolEntry and IsToolEntry2 (321979-322003) and ToolUse.IsToolExit (322125-322136) compare prefab hashes, of the item or of the item it stands in for:
public bool IsToolExit(Item tool)
{
if (ToolExit == null || tool == null)
{
return false;
}
if ((bool)tool.ReplacementOf && tool.ReplacementOf.PrefabHash == ToolExit.PrefabHash)
{
return true;
}
return tool.PrefabHash == ToolExit.PrefabHash;
}
Item.ReplacementOf (311002-311015) is the item's ReplacementTool component's PrefabToReplace when it has one, and otherwise an entry registered by Item.RegisterItemReplacement (311079-311123) from the game data's ItemReplacements, which the base game ships in StreamingAssets/Data/toolreplacements.xml.
Read out of the serialized prefabs on 2026-09-25 (UnityPy with a type tree generated from the game's managed assemblies, over the client's rocketstation_Data and the dedicated server's rocketstation_DedicatedServer_Data, same result in both):
- 3,496 tool references on 825 prefabs, in
BuildStates[].Tool,BrokenBuildStates[].BuildState.Tool,RepairTools,UpgradePrefab.UpgradeTools,ExitTooland the bareToolExitfield, every one resolved to a prefab. None isItemSprayGun.ToolExitnames six items only:ItemAngleGrinder(475),ItemDrill(454),ItemWrench(279),ItemCrowbar(123),ItemWireCutters(95),ItemScrewdriver(11).ToolEntrynames 259 distinct items andToolEntry218. - The match is on
PrefabHash, a serialized field (public int PrefabHash;, 322883), not on the object: theItemSprayGuncomponent carries 1289723966, and none of the 267 distinct items named in any of those tool fields has that value (client data). - No
ReplacementToolcomponent exists anywhere in the data, and theItemSprayGuncomponent'sReplacementToolreference is null. toolreplacements.xmlhas 16 entries, allEmergency,MKIIor arc-welder stand-ins for the welding torch, angle grinder, crowbar, drill, screwdriver, wire cutters, wrench and duct tape. None mentions the spray gun.- Of the 19,617 MonoBehaviours in the client data (19,614 in the server's), the 12 (13) that yield no type tree are 32-byte missing-script stubs with no serialized fields, on terrain objects,
TradingListItemand, in the server's data,Splash Controller.
So in the base game the four tool-gated reads never see a spray gun, and SprayGun.IsOperable is reached only through SprayGun.OnUseItem, called from ISprayer.DoSpray. Content that used the spray gun as a build, repair, upgrade or deconstruct tool, or registered it as a stand-in for one, would open those paths; none exists in the base game.
Inside DoSpray the getter cannot change the outcome¶
DoSpray (quoted in "Targeting cursor colour") calls sprayer.OnUseItem (361105) only after every gate has passed: GetPaintMaterial() is not null, which for the gun means !IsEmpty (361070-361073, 361168-361175); sprayer is Tool { OnOff: false } did not match (361077-361080); sprayer.IsEmpty is false (361081-361084); doAction is true (361099-361102); and GameManager.RunSimulation is true (361103). With IsEmpty false and OnOff true, IsOperable is true whether its IsEmpty term is kept or dropped, so a getter that returns OnOff alone gives the same answer on this path.
Dedicated server¶
Assembly-CSharp.DedicatedServer.decompiled.cs (0.2.6428.27798) gives the same enumeration, identical apart from line numbers: 437 tokens and 120 declarations, the same 55 explicit-receiver reads, the same 69 OnUseItem call sites, and DoSpray's only caller Thing.AttackWith at 314022; of the server's 144 managed assemblies only Assembly-CSharp contains the name get_IsOperable. Bodies compared and byte-identical: ISprayer and SprayGun 347419-347588, Tool 349191-349284, Thing.AttackWith 313974-314057, Structure.AttackWith and HandleToolUse 307304-307674, DynamicThing.AttackWith 292004-292076, ToolBasic and ToolUse 308347-308564, InventoryManager.NormalModeThing 280120-280233. Key lines there: Tool.IsOperable 349199, SprayGun.IsOperable 347506, the read in SprayGun.OnUseItem 347582, the read in Tool.CanUseTool 349243 (its caller 307649), Structure.AttackWith reads 307367, 307527 and 307589, DynamicThing.AttackWith read 292019, Ice.AttackWith read 340193, IConstructor 349494-349497.
Targeting cursor colour¶
The highlight colour is decided every frame in InventoryManager.NormalModeThing() (293727-293850), after NormalMode has hidden the previous selection through ClearCursor() (called at 293876, body 293853-293861). It is built from DelayedActionInstance previews and never reads IsOperable. The attack preview is the target's AttackWith with doAction: false (293733-293738):
Thing.DelayedActionInstance delayedActionInstance = null;
if (CanAttackWith(cursorTargetCollider))
{
Attack attack = new Attack(ActiveHand.Slot, InactiveHand.Slot, CursorManager.CursorHit.point, CursorManager.CursorThing, 0f, cursorTargetCollider, (bool)(ActiveHand.Slot.Occupant as AuthoringTool) && Input.GetKey(KeyMap.QuantityModifier), (bool)(ActiveHand.Slot.Occupant as AuthoringTool) && Input.GetKey(KeyCode.F));
delayedActionInstance = CursorManager.CursorThing.AttackWith(attack, doAction: false);
}
and the colour comes from whichever preview applies (293759-293845):
UnityEngine.Color color = UnityEngine.Color.red;
Assets.Scripts.Objects.Item item = CursorManager.CursorThing as Assets.Scripts.Objects.Item;
if ((item != null && !item.IsChild) || interactable != null || delayedActionInstance != null || delayedActionInstance3 != null)
{
if (MyGraphicsRaycaster.ShouldCast && (bool)item)
{
if (interactable == null && delayedActionInstance == null)
{
return delayedActionInstance3 != null;
}
return true;
}
SelectionInstance selection = CursorManager.CursorThing.GetSelection();
if (interactable != null && delayedActionInstance == null)
{
selection = interactable.GetSelection();
}
else if (delayedActionInstance != null && delayedActionInstance.Selection != null)
{
selection = delayedActionInstance.Selection;
}
if (delayedActionInstance3 != null)
{
color = ((!delayedActionInstance3.IsDisabled) ? ((delayedActionInstance3.Duration > 0f) ? UnityEngine.Color.yellow : UnityEngine.Color.green) : UnityEngine.Color.red);
PassiveTooltip cursorPassiveTooltip3 = new PassiveTooltip(delayedActionInstance3, string.Empty, CursorManager.CursorThing);
cursorPassiveTooltip3.color = color;
TooltipRef.HandleToolTipDisplay(cursorPassiveTooltip3);
}
else if (interactable != null && delayedActionInstance2 != null && delayedActionInstance == null)
{
PassiveTooltip cursorPassiveTooltip4 = new PassiveTooltip(delayedActionInstance2, string.Empty, CursorManager.CursorThing);
Tooltip.SetValuesForInteractable(ref cursorPassiveTooltip4, CursorManager.CursorThing, interactable);
color = ((delayedActionInstance2.IsDisabled || !CursorManager.CursorThing.AllowInteraction) ? UnityEngine.Color.red : ((!WillStackFromInteractable(interactable)) ? ((delayedActionInstance2.Duration > 0f) ? UnityEngine.Color.yellow : UnityEngine.Color.green) : UnityEngine.Color.yellow));
TooltipRef.HandleToolTipDisplay(cursorPassiveTooltip4);
if (KeyManager.GetMouseDown("Primary") && delayedActionInstance2.Duration >= 0f && CursorManager.CursorThing.AllowInteraction)
{
_canDoHandlePrimaryUse = false;
if (delayedActionInstance2.Duration > 0f && !delayedActionInstance2.IsDisabled)
{
ActionCoroutine = StartCoroutine(WaitUntilDone(interactable, delayedActionInstance2, interaction));
}
else if (GameManager.RunSimulation)
{
OnServer.InteractWith(interactable, interaction);
}
else
{
NetworkClient.InteractWith(interactable, interaction);
}
}
}
else if (delayedActionInstance != null)
{
color = ((!delayedActionInstance.IsDisabled) ? ((delayedActionInstance.Duration > 0f) ? UnityEngine.Color.yellow : UnityEngine.Color.green) : UnityEngine.Color.red);
PassiveTooltip cursorPassiveTooltip5 = new PassiveTooltip(delayedActionInstance, string.Empty, CursorManager.CursorThing);
cursorPassiveTooltip5.color = color;
TooltipRef.HandleToolTipDisplay(cursorPassiveTooltip5);
}
else if (item != null)
{
color = (cursorPassiveTooltip.color = Tooltip.SetColorForItemAction(ref cursorPassiveTooltip, item));
TooltipRef.HandleToolTipDisplay(cursorPassiveTooltip);
}
color.a = CursorAlphaInteractable;
CursorManager.SetSelection(selection, color);
CursorManager.SetSelectionVisibility(ShowUi);
}
else if (!cursorPassiveTooltip2.Title.Equals(string.Empty))
{
TooltipRef.HandleToolTipDisplay(cursorPassiveTooltip2);
CursorManager.ClearLastSelectionId();
}
else
{
CursorManager.SetSelectionVisibility(isVisible: false);
CursorManager.ClearLastSelectionId();
if (cursorPassiveTooltip.Title != string.Empty && CursorManager.CursorThing.RootParent != Parent && !IsLookingAtParent())
{
TooltipRef.Mode = TooltipMode.ActionLast;
TooltipRef.HandleToolTipDisplay(cursorPassiveTooltip);
if (interactable == null)
{
return delayedActionInstance != null;
}
return true;
}
}
In order:
- A drag preview (
Human.DragInto, when the target is a draggableHuman): red whenIsDisabled, yellow whenDuration > 0, green otherwise (293782). - Otherwise an interactable's
InteractWithpreview, used only when the attack preview is null: red when disabled or when the target does notAllowInteraction, yellow when the click would stack orDuration > 0, green otherwise (293787-293791). - Otherwise the attack preview: red when
IsDisabled, yellow whenDuration > 0, green otherwise (293810-293816). - Otherwise, when the target is an
Item,Tooltip.SetColorForItemAction(260088-260109): yellow when the held stack can take it, green when the hand is empty and it can be picked up, red otherwise.
public static UnityEngine.Color SetColorForItemAction(ref PassiveTooltip tooltip, Thing cursorItem)
{
if (!(cursorItem is Assets.Scripts.Objects.Item))
{
return tooltip.color;
}
if (InventoryManager.ActiveHandSlot.Contains<IMergeable>(out var occupant) && cursorItem is IMergeable mergeable && occupant.GetPrefabHash() == mergeable.GetPrefabHash() && !occupant.IsStackFull && occupant.CanStack(mergeable))
{
tooltip.color = UnityEngine.Color.yellow;
tooltip.Action = ActionStrings.Collect;
}
else if (InventoryManager.ActiveHandSlot.IsEmpty() && cursorItem.CanPickup)
{
tooltip.color = UnityEngine.Color.green;
tooltip.Action = ActionStrings.Pickup;
}
else
{
tooltip.color = UnityEngine.Color.red;
}
return tooltip.color;
}
When the attack preview is null and there is no interactable, no loose item (IsChild is ParentSlot?.Parent, 304652) and no drag preview, the condition at 293761 is false and SetSelection is never called: the collider's passive tooltip is shown (293826-293830), or else the selection is hidden and the Thing's own passive tooltip, if it has one, is shown (293831-293845). A null AttackWith result therefore draws no coloured highlight by itself. Two fallbacks can still end red with a null attack preview: the last branch of SetColorForItemAction (260104-260106) for an Item target, and the red default (293759), which survives when the target has an interactable whose own preview is also null and the target is not an Item.
DelayedActionInstance.Fail(...) sets IsDisabled before recording its message, for example (322718-322724):
public DelayedActionInstance Fail(Assets.Scripts.Localization2.GameString failureMessage)
{
IsDisabled = true;
ClearStateMessage();
AppendStateMessage(failureMessage.DisplayString);
return this;
}
so every failed preview is red, and a successful one is yellow or green by its Duration. The click follows the same preview: HandlePrimaryUse starts the action only when the preview is non-null and not disabled (see InventoryManager, "Timed actions: ActionCoroutine and the WaitUntilDone coroutines").
No field keeps a preview. delayedActionInstance, delayedActionInstance2 and delayedActionInstance3 are locals of NormalModeThing, built again on every call, and InventoryManager (291744-295166) declares no field of type Thing.DelayedActionInstance. A preview outlives the call only as the argument of a running WaitUntilDone coroutine (294937 for an interaction, 295047 for an attack): NormalModeThing starts one with the interaction preview when a primary click begins a timed interaction (293793-293798), and HandlePrimaryUse starts one with an attack preview of its own, built afresh (294048-294049), when the action is timed (294054-294062). From the attack preview that colours the highlight, NormalModeThing hands on its Selection and the colour it decides, both to CursorManager.SetSelection, and a PassiveTooltip. That is a struct (313076) with no field for IsDisabled or Duration (313078-313108), whose constructor from a preview copies the title, action, state and extended text, color and Slider (313140-313160):
public PassiveTooltip(Thing.DelayedActionInstance actionInstance, string actionOverride, Thing cursorThing)
{
string title = ((actionInstance.OverrideTitle != string.Empty) ? actionInstance.OverrideTitle : cursorThing.DisplayName);
string action = (string.IsNullOrEmpty(actionOverride) ? actionInstance.ActionMessage : actionOverride);
Title = title;
Action = action;
State = actionInstance.GetStateMessage();
Extended = actionInstance.GetExtendedText();
RepairString = string.Empty;
DeconstructString = string.Empty;
ConstructString = string.Empty;
PlacementString = string.Empty;
ShowRotate = false;
ShowScroll = false;
ShowConstructionRotate = false;
ShowAction = true;
FollowMouseMovement = false;
color = actionInstance.color;
Slider = actionInstance.Slider;
BuildStateIndexMessage = string.Empty;
}
So reading a preview means asking the target again, AttackWith(attack, doAction: false), with an Attack (310760-310797) built the way the caller of interest builds it. The hover's carries the cursor's hit point and collider (293736). OnServer.AttackWith, which runs the stroke itself, builds its Attack with the position it was handed and no collider (39642) and calls thing.AttackWith(attack, runSimulation) (39643).
Spray gun on a paintable target¶
Thing.AttackWith hands the preview to ISprayer.DoSpray (327636-327639; recorded at 0.2.6228.27061 at decompile line 302593 with the same text):
if (IsPaintable && sourceItem is ISprayer sprayer && !HasColorState)
{
return ISprayer.DoSpray(this, sprayer, doAction);
}
A target's own AttackWith override runs first. Structure.AttackWith and DynamicThing.AttackWith only act on the held item when it matches one of the target's tool entries or exits, which a spray gun never does in the shipped data (see "IsOperable: declaration and every reader"), and otherwise fall through to base.AttackWith. DoSpray in full (361063-361119):
static Thing.DelayedActionInstance DoSpray(Thing thing, ISprayer sprayer, bool doAction)
{
Thing.DelayedActionInstance delayedActionInstance = new Thing.DelayedActionInstance
{
Duration = sprayer.TimeToUse(),
ActionMessage = ActionStrings.Paint
};
if (sprayer.GetPaintMaterial() == null)
{
return delayedActionInstance.Fail(GameStrings.NotEnoughSprayPaint, sprayer.ToTooltip());
}
if ((thing.CustomColor.Normal != null && thing.CustomColor.Normal != sprayer.GetPaintMaterial()) || (thing.CustomColor.Normal == null && thing.PaintableMaterial != sprayer.GetPaintMaterial()))
{
ColorSwatch colorSwatch = GameManager.GetColorSwatch(sprayer.GetPaintMaterial());
if (sprayer is Tool { OnOff: false } tool)
{
return delayedActionInstance.Fail(GameStrings.CannotUseWhenNotOn, tool.ToTooltip());
}
if (sprayer.IsEmpty)
{
return delayedActionInstance.Fail(GameStrings.NotEnoughSprayPaint, sprayer.ToTooltip());
}
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.AppendLine(GameStrings.ThingWillBeSprayed.AsString(thing.ToTooltip(), colorSwatch.ToTooltip()));
if (!(sprayer is Consumable consumable))
{
if (sprayer is SprayGun { SprayCan: not null } sprayGun)
{
stringBuilder.AppendLine(GameStrings.ItemInSlotValue.AsString(sprayGun.SprayCan.ToTooltip(), sprayGun.SprayCan.GetQuantityText()));
}
}
else
{
stringBuilder.AppendLine(GameStrings.ItemInSlotValue.AsString(consumable.ToTooltip(), consumable.GetQuantityText()));
}
delayedActionInstance.ExtendedMessage = stringBuilder.ToString();
if (!doAction)
{
return delayedActionInstance;
}
if (GameManager.RunSimulation)
{
if (!sprayer.OnUseItem(sprayer.GetUseAmount(), thing))
{
return delayedActionInstance;
}
if (sprayer.TimeToUse() <= float.Epsilon && sprayer is Thing parent)
{
AudioEvent.Create(parent, SprayPaintSoundHash);
}
OnServer.SetCustomColor(thing, colorSwatch.Index);
}
return delayedActionInstance;
}
ColorSwatch colorSwatch2 = GameManager.GetColorSwatch(sprayer.GetPaintMaterial());
return delayedActionInstance.Fail(GameStrings.CantPaintSameColour, thing.ToTooltip(), colorSwatch2.ToTooltip());
}
For a spray gun this gives:
| Gun and target | Preview | Colour |
|---|---|---|
No can, or a can at Quantity <= float.Epsilon |
Fail(NotEnoughSprayPaint) at the first gate, because GetPaintMaterial() returns null while IsEmpty (361070-361073, 361168-361175) |
red |
| Loaded, target already the can's colour | Fail(CantPaintSameColour) (361117-361118) |
red |
| Loaded, different colour, gun off | Fail(CannotUseWhenNotOn) (361077-361080) |
red |
| Loaded, different colour, gun on | Duration = TimeToUse(), which is 0f for the gun (361177-361180) |
green |
None of these reads IsOperable: the preview returns at if (!doAction) (361099-361102), and the only read, in SprayGun.OnUseItem, comes later, on the authority (GameManager.RunSimulation, 361103-361105), for the real stroke.
So an empty gun is red because DoSpray has no paint material to offer, not because the gun is inoperable. An IsOperable that ignores IsEmpty changes neither the colour nor the outcome: the first gate still fails, and on the authority the stroke fails the same way (OnServer.AttackWith calls thing.AttackWith(attack, runSimulation), 39643). A gun meant to act without a can needs its preview and its apply supplied before DoSpray runs, and Thing.AttackWith is DoSpray's only caller (327638). Spray Paint Plus does it with a prefix on Thing.AttackWith (ThingAttackWithGunPatch).
OnOff state, toggle, label¶
Right-click on a held gun flips its on/off state. Inheritance: Thing.OnOff is the virtual bool property at decompile line 299160 that all Things share; SprayGun does not override it. Reads the animator's OnOffState integer (or the transient InteractOnOff.State when no animator is present).
Right-click dispatch: HumanHandsBehaviour.ToggleActiveHandTool at decompile line 271917 calls slot.Occupant.Interact(InteractableType.OnOff, state). The resulting Thing.InteractWith(Interactable, Interaction, bool) body at decompile line 302420 handles the InteractableType.OnOff case:
case InteractableType.OnOff:
...
OnServer.Interact(interactable, (!OnOff) ? 1 : 0);
return delayedActionInstance.Succeed();
OnServer.Interact is the server-authoritative path and broadcasts the new state to all clients via the Interactable.State field + animator parameter. The state persists across save/load because Interactable.State is part of the Thing's normal save data.
Contextual label ("On" / "Off" hint) comes from Thing.GetContextualName(Interactable) at decompile line 300637 (re-verified unchanged at game version 0.2.6403.27689: still public virtual, same InteractableType.OnOff case body, new decompile line 319699):
HUD display path: Interactable.ContextualName property at decompile line 286007 (=> Parent.GetContextualName(this)), polled from InventoryManager.NormalModeThing() around line 269971. Mods can postfix Thing.GetContextualName and filter by __instance is SprayGun && interactable.Action == InteractableType.OnOff to relabel per-class without affecting other on/off-togglable items.
SprayPaintPlus v1.4.0 uses this path to rebrand the gun's on/off as "Add Glow" / "Remove Glow": the gun's on/off animation becomes the feature's mode-toggle UX, and firing reads __instance.OnOff to decide add vs. remove glow.
The can slot (Slots[0])¶
Read live through the TestRig control plane (GET /thing?refIds=<gun>&fields=<path>) on a listen host and on a joined client at game 0.2.6428.27798, on an ItemSprayGun spawned into the host's hand by /inventory/arm (route OnServer.SpawnDynamicThingMaxStack + OnServer.MoveToSlot):
| Member | Kind, declared by, type | Spawned gun | Prefab |
|---|---|---|---|
Slots[0].Type |
field on Assets.Scripts.Objects.Slot, type Assets.Scripts.Objects.Slot+Class |
Bottle (numeric 25) |
Bottle (25) |
Slots[0].IsInteractable |
field on Assets.Scripts.Objects.Slot, type System.Boolean |
True |
True |
Slots[0].Occupant |
property on Assets.Scripts.Objects.Slot, type Assets.Scripts.Objects.DynamicThing |
null | null |
So the gun's can slot, Slots[0], is a Slot.Class.Bottle slot and interactable, and a gun spawned this way carries no can. The other slots, if any, were not read.
Slot.Type is kept per machine. In the same second the host read Bottle and the joiner read Blocked for the same ReferenceId (Spray Paint Plus had set Slot.Class.Blocked, with IsInteractable false, on the joiner to hide the slot), and under Spray Paint Plus v1.12.0, which has nothing that gives the slot back on a joiner, the joiner's copy still read Blocked after 16 seconds of connected play. The host does not push its slot state onto a client.
The serialized prefab agrees and settles the other slots. The ItemSprayGun component in the client's resources.assets (read 2026-09-25 through UnityPy with a type tree generated from the managed assemblies) declares exactly one slot: Type 25 (Bottle), IsInteractable 1, StringKey SprayCan. In both the client's and the dedicated server's data its ReplacementTool reference is null and its ExitTool names no tool.
Under Spray Paint Plus with glow paint on, an empty gun's slot is hidden from Awake. The mod's SprayGunSlotHiderPatch, a postfix on the gun's inherited Awake, sets Slots[0].Type to Blocked and IsInteractable to False, and swaps in the Blocked icon, when the slot is empty; that is the joiner's Blocked reading above. At Awake the slot is always empty on a load and on a join, because the game puts a saved can back into its gun afterwards, from the can's side, through DynamicThing.MoveToSlot, and Thing.CanEnter does not look at Slot.Type for an item (see SaveLoadOrdering, "Awake runs before a Thing's saved contents are placed, on load and on join"). So a can saved in the gun comes back into the blocked slot. Through v1.12.1, the released build, it stays there after a load with glow paint on: the can is still the gun's SprayCan, but the slot has no inventory button and world interaction refuses it, so the player cannot take the can out until a load with glow paint off (the details, including the join cases, are in SlotInsertionBlock, "Legacy-state handling"). Read from the v1.12.1 source and the 0.2.6428.27798 decompile, and seen on the test rig on 2026-09-27 on a build without the fix below (Release DLL sha256 56763957), by the check a spray gun saved with a can in it keeps the can visible and removable. Straight after a glow-on load of the committed fixture save, whose gun 185905 holds can 509031, that gun's Slots[0] read Blocked and False with the can in it (the can's parent 185905): on the authority in single player and on a listen host, on the dedicated server's own copy, and on a joiner of that server that joined guessing glow on. A joiner that joined guessing glow off read it Bottle and True, the join fix-up's occupied-slot branch. The empty gun 185906 read Blocked and False in every one of those. The load with glow paint off that gives the slot back was not part of the run.
Not in a release yet. From commit fdcffdc5 on master (it ships in Spray Paint Plus v1.13.0), a can that lands in the slot gives it back: SprayGunCanArrivalPatch, a postfix on Thing.OnChildEnterInventory, which DynamicThing.MoveToSlot calls once the can is in the slot, applies the mod's rule that the slot is hidden exactly when glow paint is on and the slot is empty. The slot then reads Bottle and True again, the values the mod captured from the first unblocked gun it saw, with the can in it. v1.12.1 has no such hook, and after a load with glow paint on, its can stays in a hidden, blocked slot. Taking the can out does not hide the slot again until the next load. Read from the mod source at fdcffdc5, and measured on the test rig on 2026-09-28 by the same check (Mods/SprayPaintPlus/playtests/LoadedGunKeepsItsCanReachable.cs) on fdcffdc5's build (Release DLL sha256 c0c00c18), where it passed in all three sessions: after the same load gun 185905 read Bottle and True with can 509031 in it, on the authority in single player and on a listen host, on the dedicated server's own copy, and on its joiner whether it joined guessing glow on or off, while the empty gun 185906 read Blocked and False throughout. The check reads these two members and does not take the can out, so that taking the can out leaves the slot shown until the next load is still read from the source only. The check a spray gun slot on a joiner follows the server glow setting, the source of the readings at the top of this section, passed on c0c00c18 as well, in both of its sessions (a listen host, and the dedicated server).
Verification history¶
- 2026-04-21: page created. Decompile findings sourced from Assembly-CSharp.dll line 334806-334812 for
OnUseItem; declaration and interface list from the class header. - 2026-04-21: added "Operability gate (cursor validity)" documenting
SprayGun.IsOperable(returns!IsEmpty && OnOff) and its role in painting the cursor red on an empty gun viaThing.AttackWith(decompile line 302593) returning null; noted the red-cursor UI handler at line 269962. Added "OnOff state, toggle, label" documenting the inheritedThing.OnOffproperty (line 299160), the right-click dispatch viaHumanHandsBehaviour.ToggleActiveHandTool(line 271917) andThing.InteractWith(line 302420), the server-syncedInteractable.Statepath, and the label path viaThing.GetContextualName(line 300637) andInteractable.ContextualName(line 286007). Additive only; no prior claim changed. - 2026-07-02: spot re-verified
Thing.GetContextualName(Interactable)against the 0.2.6403.27689 decompile: stillpublic virtual,InteractableType.OnOffcase body identical (ActionStrings.On/ActionStrings.Off), new line 319699. Confirmation noted inline; the rest of the "OnOff state, toggle, label" section was not re-read this pass, so its 0.2.6228.27061 stamp stands. The postfix-relabel recipe (SprayPaintPlus "Add Glow" / "Remove Glow") therefore remains valid at the current game version. - 2026-09-22: added "The can slot (Slots[0])" at game 0.2.6428.27798, from live
/thingreadings on the TestRig client half during the Spray Paint Plus v1.12.1 slot check (host and joiner, v1.12.0 and v1.12.1 runs; readings, member kinds and declared types reproduced in the section). Additive; the other sections were not re-read and keep their 0.2.6228.27061 stamps. The top-levelverified_inmoved to 0.2.6428.27798 for this section only. - 2026-09-25: conflict on "what
IsOperableoverrides, what colours the targeting cursor, and whetherIsOperablegates the paint path". Previous claim (section "Operability gate (cursor validity)", 0.2.6228.27061):SprayGun.IsOperableoverridesThing.IsOperable; an empty gun's non-operable state paints the targeting cursor red and blocks use;Thing.AttackWithreturningnulltriggers the red cursor (UI line 269962), and a failedIsOperableon the source item is one path to thatnull; a mod that wants an ammo-less gun overridesIsOperableto ignoreIsEmpty. New finding (0.2.6428.27798): the virtual is declared onTool(public virtual bool IsOperable => true;, 362831) andThingdeclares none;InventoryManager.NormalModeThingcolours the selection from theAttackWith(doAction: false)preview (red whenIsDisabled, yellow whenDuration > 0, green otherwise), and a null preview with nothing else to colour draws no highlight; the paint path readsIsOperableonly insideSprayGun.OnUseItem, afterDoSpray'sOnOffandIsEmptygates, and an empty gun is red becauseDoSprayfails atGetPaintMaterial() == null; overridingIsOperablechanges neither the colour nor whether an ammo-less gun paints. Fresh validator verdict: B is correct on all four parts, with one scope note on the cursor: a null attack preview can still end red throughTooltip.SetColorForItemAction's red branch (260104-260106) for anItemtarget, or through the red default kept when an interactable's own preview is also null and the target is not anItem. Result: "Operability gate (cursor validity)" replaced by "IsOperable: declaration and every reader" and "Targeting cursor colour", which carry the scope note; the verbatim getter excerpt and theThing.AttackWithpaint-branch excerpt carried forward with their 0.2.6228.27061 provenance. The 0.2.6228.27061 decompile is no longer on disk, so whether the old claims held for that build cannot be re-checked. - 2026-09-25: re-read against the 0.2.6428.27798 client and dedicated server decompiles, driven by the question whether Spray Paint Plus's
SprayGunIsOperablePatchchanges anything. Added the full class body to "Declaration"; rewrote "OnUseItem" with the current body, which shows theIsOperablegate (old line reference kept); added the reader enumeration (437IsOperabletokens, 120 of them declarations, 55 explicit-receiver reads classified by receiver type, 69OnUseItemcall sites, the soleDoSpraycaller, no other managed assembly referencingget_IsOperable; the dedicated server identical apart from line numbers), the prefab-data check (UnityPy over both installs: 3,496 tool references on 825 prefabs, none namingItemSprayGunand none of their 267 distinct targets sharing its serializedPrefabHash1289723966, noReplacementToolcomponent, the 12 client and 13 server MonoBehaviours without a type tree being 32-byte missing-script stubs) andtoolreplacements.xml(16 entries, no spray gun). Added the prefab slot reading to "The can slot (Slots[0])". Restamped the intro, "Declaration", "OnUseItem" and "The can slot (Slots[0])". "Contrast with SprayCan" and "OnOff state, toggle, label" were not re-read and keep their 0.2.6228.27061 stamps. - 2026-09-27: added to "OnUseItem" that the gun's
Efficiencyis applied twice on the paint path (once inGetUseAmount, 361186, and again inOnUseItem, 361218), so a gun paint takesUseAmount / (Efficiency * Efficiency)from the can againstUseAmountfor the can by hand, and that neitherOnUseItemcallsbase. Raised as a side finding by the fresh validator that settledISprayer.md'sOnUseItemdirection the same day, then re-read here in both decompiles (SprayGunandConsumabletextually identical). Additive; no existing claim changed. The prefab values ofEfficiencyandUseAmountwere not read. Section restamped. - 2026-09-27: added to "Targeting cursor colour" that no field keeps a preview (
InventoryManagerdeclares none of typeThing.DelayedActionInstance; the three previews are locals ofNormalModeThing), that a preview outlives the call only as the argument of a runningWaitUntilDonecoroutine for a timed interaction or a timed attack, that thePassiveTooltipbuilt from one has noIsDisabledorDurationfield and its constructor copies the texts, colour and slider (constructor quoted), and that reading a preview means asking the target again with anAttackbuilt as the hover builds it (hit point and collider) or asOnServer.AttackWithdoes (the position it was handed, no collider). Read in the 0.2.6428.27798 decompile. Drafted while building a test-rig cursor preview reader and re-read before landing: the draft's "none of the three previews outlives the frame" was wrong for the interaction preview a timed click hands toWaitUntilDoneand was corrected, and its sentence about the test rig's reader was dropped; the page describes the game only. Additive; section restamped. - 2026-09-27: added to "The can slot (Slots[0])" what Spray Paint Plus does to the slot. With glow paint on, an empty gun's
Slots[0]isBlockedand not interactable fromAwake; a can saved in the gun comes back into that slot afterAwakethroughDynamicThing.MoveToSlot; through v1.12.1 (read at tagmods/SprayPaintPlus/v1.12.1) it stays there, hidden, after a glow-on load; and, under a not-in-a-release-yet marker, from commit72b2db56on the local development branchphase3-b72(not yet on master; ships in v1.13.0) a can that lands gives the slot back asBottleandTrue. Read from the mod source and the 0.2.6428.27798 decompile; not measured, and the rig check that will measure it is named. Raised by.work/2026-09-27-phase3-rig/M4-RESULT.mdsection 5 item 3. Additive; the existing readings are unchanged. Section restamped;slotstag added. - 2026-09-28: the Spray Paint Plus fix this page marks landed on master as
fdcffdc5; the cited files are unchanged from the branch commits named in the entries above, so only the markers and sources were updated to cite it. Still not in a release (v1.12.1 is the published build). - 2026-09-28 (game version 0.2.6428.27798): the last two paragraphs of "The can slot (Slots[0])" said the Spray Paint Plus behaviour was read from source and not measured; they now carry the rig readings of the check
a spray gun saved with a can in it keeps the can visible and removable. On a build without the fix (Release DLL sha25656763957, 2026-09-27) it failed in single player, on a listen host and on the dedicated server, with the loaded gun's slotBlockedandFalseand the can in it everywhere except on a joiner that guessed glow off; onfdcffdc5's build (c0c00c18, 2026-09-28) it passed in all three, with that slotBottleandTrueand the can in it everywhere. Named what the runs did not cover: a later load with glow paint off, and taking the can out. Added thata spray gun slot on a joiner follows the server glow settingpassed onc0c00c18. Read from each cell'sloaded-spray-gun.txtand verdict. Additive; the source reading is unchanged. Section restamped; the marker stays, since the fix is not in a release.
Open questions¶
- What
EfficiencytheItemSprayGunprefab serializes, and whatUseAmountthe spray can prefabs serialize. With the C# defaults (4fand0.1f) a gun paint takes a sixteenth of the paint a hand paint does (see "OnUseItem"); the shipped values decide the real ratio. Resolve by reading the prefabs with UnityPy, as was done for the slot data on 2026-09-25, or with an InspectorPlus snapshot, request types=[SprayGun, SprayCan], fields=[Efficiency, UseAmount].