Skip to content

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 (see SprayCan.md).
  • SprayGun : Tool, ISprayer, IUsedAmount, IUsed (see SprayGun.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:

OnServer.SetCustomColor(thing, colorSwatch.Index);

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 its PollutionMixture (about 0.01 mol of pollutant) to the atmosphere at its grid and returns true.
  • SprayGun.OnUseItem (361212-361219; recorded at 0.2.6228.27061 as line 334806-334812) returns false unless the gun IsOperable, then calls SprayCan.OnUseItem(quantity * (1f / Efficiency), useOnThing) on the loaded can.
  • Neither calls base, so Consumable.OnUseItem (180214-180220, which clamps the amount to the quantity left before calling its own base) and Item.OnUseItem (311161-311175, which sets Item.LastUsedItem and raises Item.OnUseItemEvent) do not run on the paint path. Nothing in Assembly-CSharp reads LastUsedItem or subscribes to OnUseItemEvent, 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}") at Assets/Scripts/Localization2/GameStrings.cs line 837. Used by gates 1 and 4.
  • GameStrings.CannotUseWhenNotOn: used by gate 3 when the sprayer is a Tool whose OnOff is false.
  • GameStrings.CantPaintSameColour ("The {LOCAL:Thing} is already painted {LOCAL:Color}") at line 839. Used by gate 5.
  • GameStrings.ThingWillBeSprayed at 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.DoSpray captured 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.OnUseItem alone is an insufficient hook because gates 1-4 in DoSpray reject the paint before OnUseItem is ever called. Additive; no prior claim changed.
  • 2026-09-27: conflict on "whether SprayCan.OnUseItem and SprayGun.OnUseItem reach DoSpray". Previous claim (section "DoSpray convergence point", 0.2.6228.27061): "Both SprayCan.OnUseItem (line 334636-334641) and SprayGun.OnUseItem (line 334806-334812) reach DoSpray. DoSpray is also called directly from Thing.AttackWith at decompile line 302593 when the target is IsPaintable and the sourceItem is ISprayer and !HasColorState." New finding (0.2.6428.27798, client and dedicated-server decompiles): the direction is reversed; DoSpray calls sprayer.OnUseItem(sprayer.GetUseAmount(), thing) (361105), neither OnUseItem calls DoSpray, and DoSpray's only caller is the paint branch of Thing.AttackWith (327638). Fresh validator verdict: B is correct; the Thing.AttackWith sentence holds and the OnUseItem sentence is false; the two builds are identical; neither override calls base, so Consumable.OnUseItem and Item.OnUseItem do 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 on LastUsedItem / OnUseItemEvent was 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 returns Thing.DelayedActionInstance which the cursor UI reads to decide red (null / fail) vs. green (success) cursor color." New finding (0.2.6428.27798): InventoryManager.NormalModeThing paints the highlight red when the attack preview is disabled, yellow when it is not and Duration > 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 on SprayGun.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 through Tooltip.SetColorForItemAction's red branch (260104-260106) for an Item target, or through the red default kept when an interactable's own preview is also null and the target is not an Item. Re-read at 0.2.6428.27798 on 2026-09-27, including the new point that DoSpray never 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.SetCustomColor directly, bypassing DoSpray? If so, a tool-discrimination flag driven from DoSpray defaults to "no tool" on those paths, which may or may not match intended behavior.
  • Can Harmony patch the static interface method ISprayer.DoSpray at 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 above ThingAttackWithGunPatch in Mods/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 patches Thing.AttackWith, DoSpray's only caller, instead, and "Tool-identity visibility" also says the mod does not hook DoSpray. The error text does not occur in any decompile under .work/decomp/0.2.6428.27798/ (it would come from the runtime's mscorlib, which is not decompiled there). Not put to a validator; the three statements are left as they are until it is.