ISprayer¶
Vanilla game interface at Assets.Scripts.Objects.Items.ISprayer. Marker interface for tools that apply a ColorSwatch to a Thing. Hosts a static DoSpray helper that is the convergence point for every paint application from an ISprayer tool.
Implementors¶
Two vanilla classes implement ISprayer:
SprayCan : Consumable, ISprayer, IUsedAmount, IUsed(seeSprayCan.md).SprayGun : Tool, ISprayer, IUsedAmount, IUsed(seeSprayGun.md).
DoSpray convergence point¶
The static method ISprayer.DoSpray(Thing thing, ISprayer sprayer, bool doAction) (0.2.6428.27798 client decompile 361063-361119, dedicated server 347431-347487; recorded at 0.2.6228.27061 as decompile line 334659-334715 in the monolithic dump, per-class decompile at Assets/Scripts/Objects/Items/ISprayer.cs) is where every paint application from every ISprayer tool converges. It extracts the color index from the sprayer's active ColorSwatch and calls:
at decompile line 361113 (0.2.6228.27061: line 334711).
What the cursor does with the return value. DoSpray returns a Thing.DelayedActionInstance and never returns null: every path hands back the instance it built, either as is or after Fail(...), and all five Fail overloads set IsDisabled = true (322704-322740). The targeting cursor, InventoryManager.NormalModeThing (293727-293851), builds its attack preview with CursorManager.CursorThing.AttackWith(attack, doAction: false) (293733-293738), which is this instance whenever Thing.AttackWith reaches its paint branch, and colours the highlight from it:
color = ((!delayedActionInstance.IsDisabled) ? ((delayedActionInstance.Duration > 0f) ? UnityEngine.Color.yellow : UnityEngine.Color.green) : UnityEngine.Color.red);
(293812): red when the preview is disabled, yellow when it is not and Duration > 0, green otherwise. DoSpray sets Duration = sprayer.TimeToUse(), which is 0.5f for a SprayCan and 0f for a SprayGun, so a paint the can would apply previews yellow and one the gun would apply previews green. A null preview does not mean red: with nothing else to colour, NormalModeThing draws no highlight at all (the else if at 293826 shows only a passive tooltip, and the else at 293831 calls CursorManager.SetSelectionVisibility(isVisible: false)). Red survives a null attack preview only through Tooltip.SetColorForItemAction's red branch (260104-260106) for an Item target, or as the red default (293759) when an interactable's own preview is also null and the target is not an Item. On the DoSpray path neither case arises, because the preview is never null. Full walk-through: SprayGun.md, "Targeting cursor colour".
Who calls whom. DoSpray calls the sprayer's OnUseItem, not the other way round:
if (GameManager.RunSimulation)
{
if (!sprayer.OnUseItem(sprayer.GetUseAmount(), thing))
{
return delayedActionInstance;
}
(361103-361105; dedicated server 347471-347473). The call is reached only for doAction under RunSimulation, after gates 1 to 4 below. Neither OnUseItem calls DoSpray:
SprayCan.OnUseItem(361044-361049; recorded at 0.2.6228.27061 as line 334636-334641) subtracts the quantity, adds itsPollutionMixture(about 0.01 mol of pollutant) to the atmosphere at its grid and returnstrue.SprayGun.OnUseItem(361212-361219; recorded at 0.2.6228.27061 as line 334806-334812) returnsfalseunless the gunIsOperable, then callsSprayCan.OnUseItem(quantity * (1f / Efficiency), useOnThing)on the loaded can.- Neither calls
base, soConsumable.OnUseItem(180214-180220, which clamps the amount to the quantity left before calling its own base) andItem.OnUseItem(311161-311175, which setsItem.LastUsedItemand raisesItem.OnUseItemEvent) do not run on the paint path. Nothing inAssembly-CSharpreadsLastUsedItemor subscribes toOnUseItemEvent, so this matters only to a mod that does.
DoSpray's only caller is the paint branch of Thing.AttackWith(Attack, bool) (327590-327673; recorded at 0.2.6228.27061 as line 302593): if (IsPaintable && sourceItem is ISprayer sprayer && !HasColorState) (327636), then return ISprayer.DoSpray(this, sprayer, doAction); (327638; dedicated server 314022). The name DoSpray occurs exactly twice in each build, the declaration and this call. The whole chain is Thing.AttackWith -> ISprayer.DoSpray -> sprayer.OnUseItem -> (spray gun only) SprayCan.OnUseItem. Every member cited here is textually identical in the dedicated-server decompile (diffed).
DoSpray validity gates¶
Full body of ISprayer.DoSpray captured from the per-class decompile. Eight sequential gates, any of which can reject the paint:
static Thing.DelayedActionInstance DoSpray(Thing thing, ISprayer sprayer, bool doAction)
{
var instance = new Thing.DelayedActionInstance
{
Duration = sprayer.TimeToUse(),
ActionMessage = ActionStrings.Paint
};
// Gate 1
if (sprayer.GetPaintMaterial() == null)
return instance.Fail(GameStrings.NotEnoughSprayPaint, sprayer.ToTooltip());
// Gate 2: same-color check
if ((thing.CustomColor.Normal != null && thing.CustomColor.Normal != sprayer.GetPaintMaterial())
|| (thing.CustomColor.Normal == null && thing.PaintableMaterial != sprayer.GetPaintMaterial()))
{
ColorSwatch colorSwatch = GameManager.GetColorSwatch(sprayer.GetPaintMaterial());
// Gate 3: tool off
if (sprayer is Tool { OnOff: false } tool)
return instance.Fail(GameStrings.CannotUseWhenNotOn, tool.ToTooltip());
// Gate 4: empty
if (sprayer.IsEmpty)
return instance.Fail(GameStrings.NotEnoughSprayPaint, sprayer.ToTooltip());
// Extended message (preview tooltip)
var sb = new StringBuilder();
sb.AppendLine(GameStrings.ThingWillBeSprayed.AsString(thing.ToTooltip(), colorSwatch.ToTooltip()));
if (sprayer is Consumable c)
sb.AppendLine(GameStrings.ItemInSlotValue.AsString(c.ToTooltip(), c.GetQuantityText()));
else if (sprayer is SprayGun { SprayCan: not null } gun)
sb.AppendLine(GameStrings.ItemInSlotValue.AsString(gun.SprayCan.ToTooltip(), gun.SprayCan.GetQuantityText()));
instance.ExtendedMessage = sb.ToString();
if (!doAction) return instance;
if (GameManager.RunSimulation)
{
if (!sprayer.OnUseItem(sprayer.GetUseAmount(), thing))
return instance;
if (sprayer.TimeToUse() <= float.Epsilon && sprayer is Thing parent)
AudioEvent.Create(parent, SprayPaintSoundHash);
OnServer.SetCustomColor(thing, colorSwatch.Index);
}
return instance;
}
// Gate 5: same colour -> fail with CantPaintSameColour
ColorSwatch colorSwatch2 = GameManager.GetColorSwatch(sprayer.GetPaintMaterial());
return instance.Fail(GameStrings.CantPaintSameColour, thing.ToTooltip(), colorSwatch2.ToTooltip());
}
The relevant game-string citations:
GameStrings.NotEnoughSprayPaint("Not enough paint in {LOCAL:SprayCanister}") atAssets/Scripts/Localization2/GameStrings.csline 837. Used by gates 1 and 4.GameStrings.CannotUseWhenNotOn: used by gate 3 when the sprayer is aToolwhoseOnOffis false.GameStrings.CantPaintSameColour("The {LOCAL:Thing} is already painted {LOCAL:Color}") at line 839. Used by gate 5.GameStrings.ThingWillBeSprayedat line 841. Preview tooltip for the green-cursor state.
sprayer.OnUseItem(sprayer.GetUseAmount(), thing) is called at the innermost point; if it returns false the paint aborts without calling OnServer.SetCustomColor. For SprayGun, OnUseItem delegates to the loaded SprayCan.OnUseItem. A SprayGun with no loaded can fails gate 1 (null GetPaintMaterial) before ever reaching this line.
Implication for mods that want an ammo-less SprayGun: a SprayGun.OnUseItem Harmony patch is not sufficient, because gates 1-4 reject the paint inside DoSpray before OnUseItem is called. The cursor-level mod must intercept at ISprayer.DoSpray itself (prefix on the static interface method) OR at Thing.AttackWith upstream. SprayPaintPlus v1.4.0 uses the ISprayer.DoSpray prefix approach.
Tool-identity visibility¶
Inside DoSpray, the concrete tool type is available via the sprayer parameter (sprayer is SprayGun, sprayer is SprayCan, or sprayer.GetType()).
By the time execution reaches OnServer.SetCustomColor, the sprayer reference is no longer on the argument list; only (Thing, int) are passed. Any downstream logic that needs to know "which tool applied this paint?" must either hook DoSpray upstream, or capture the tool identity via a side-channel before SetCustomColor runs.
The SprayPaintPlus mod does not hook DoSpray as of version 1.x. Its PaintAttackerTracker_Local / PaintAttackerTracker_Remote patches (on OnServer.AttackWith / AttackWithMessage.Process) capture the painting Human.ReferenceId only; tool identity is not captured. See ../../Mods/SprayPaintPlus/RESEARCH.md section 3.5.
Tool-discrimination hook point¶
Harmony patches that need to differentiate "paint from the gun" from "paint from the bare can" should hook the static ISprayer.DoSpray rather than OnServer.SetCustomColor.
A prefix that sets a static flag based on sprayer is SprayGun and a postfix that clears it scopes the flag to the duration of one DoSpray call. Any re-entrant SetCustomColor calls inside that event (for example, flood-fills applied by SprayPaintPlus.NetworkPainterPatch) see the flag as still set, because they run inside the same outer DoSpray frame on the same thread.
Verification history¶
- 2026-04-21: page created. Findings sourced from Assembly-CSharp.dll decompile (DoSpray at line 334659-334715, SetCustomColor call at line 334711, SprayCan.OnUseItem at line 334636-334641, SprayGun.OnUseItem at line 334806-334812).
- 2026-04-21: added "DoSpray validity gates" section with the full body of
ISprayer.DoSpraycaptured from a per-class decompile pass. Documents all 8 sequential reject paths (null PaintMaterial, same-colour block, Tool-off, IsEmpty, and the OnUseItem abort at the innermost point), the game-string citations for each error, and the key consequence for mods:SprayGun.OnUseItemalone is an insufficient hook because gates 1-4 inDoSprayreject the paint beforeOnUseItemis ever called. Additive; no prior claim changed. - 2026-09-27: conflict on "whether
SprayCan.OnUseItemandSprayGun.OnUseItemreachDoSpray". Previous claim (section "DoSpray convergence point", 0.2.6228.27061): "BothSprayCan.OnUseItem(line 334636-334641) andSprayGun.OnUseItem(line 334806-334812) reachDoSpray.DoSprayis also called directly fromThing.AttackWithat decompile line 302593 when the target isIsPaintableand the sourceItem isISprayerand!HasColorState." New finding (0.2.6428.27798, client and dedicated-server decompiles): the direction is reversed;DoSpraycallssprayer.OnUseItem(sprayer.GetUseAmount(), thing)(361105), neitherOnUseItemcallsDoSpray, andDoSpray's only caller is the paint branch ofThing.AttackWith(327638). Fresh validator verdict: B is correct; theThing.AttackWithsentence holds and theOnUseItemsentence is false; the two builds are identical; neither override callsbase, soConsumable.OnUseItemandItem.OnUseItemdo not run on the paint path; the 0.2.6228.27061 line numbers could not be checked (that decompile is no longer on disk). Result: the paragraph was rewritten as "Who calls whom" with the call excerpt, the chain and current line numbers; the old line numbers are kept, labelled with their version. The note onLastUsedItem/OnUseItemEventwas read directly (311161-311175, and a whole-file search finds no other reference to either). - 2026-09-27: conflict on "what the targeting cursor does with
DoSpray's return value". Previous claim (same section, 0.2.6228.27061): "The signature returnsThing.DelayedActionInstancewhich the cursor UI reads to decide red (null / fail) vs. green (success) cursor color." New finding (0.2.6428.27798):InventoryManager.NormalModeThingpaints the highlight red when the attack preview is disabled, yellow when it is not andDuration > 0, green otherwise (293812), and a null preview with nothing else to colour draws no highlight (293826-293845). Fresh validator verdict: the binding 2026-09-25 verdict recorded onSprayGun.md("Targeting cursor colour"), which put this same claim to a fresh validator: B is correct, with the scope note that 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. Re-read at 0.2.6428.27798 on 2026-09-27, including the new point thatDoSpraynever returns null. Result: the sentence was replaced by "What the cursor does with the return value"; the section is restamped to 0.2.6428.27798. - 2026-09-27: removed a duplicated, empty "Tool-identity visibility" heading. Cosmetic; no stamp changed.
Open questions¶
- Do any non-tool code paths (debug console, save-restore, Stationpedia preview, mod-created UI) reach
OnServer.SetCustomColordirectly, bypassingDoSpray? If so, a tool-discrimination flag driven fromDoSpraydefaults to "no tool" on those paths, which may or may not match intended behavior. - Can Harmony patch the static interface method
ISprayer.DoSprayat all in this game's runtime? "Tool-discrimination hook point" recommends a prefix there, and "DoSpray validity gates" says Spray Paint Plus v1.4.0 used one. Spray Paint Plus's own source disagrees: the comment aboveThingAttackWithGunPatchinMods/SprayPaintPlus/SprayPaintPlus/GlowPaintPatches.cs(read 2026-09-27) says Harmony "cannot patch static methods on interfaces ("Owner can't be an array or an interface")" and patchesThing.AttackWith,DoSpray's only caller, instead, and "Tool-identity visibility" also says the mod does not hookDoSpray. The error text does not occur in any decompile under.work/decomp/0.2.6428.27798/(it would come from the runtime'smscorlib, which is not decompiled there). Not put to a validator; the three statements are left as they are until it is.