SprayCan¶
Vanilla game class representing a spray-paint can consumable. The game ships one SprayCan prefab per color.
Declaration¶
public class SprayCan : Consumable, ISprayer, IUsedAmount, IUsed
{
[Header("Spray Can")]
public Material PaintMaterial;
private static readonly GasMixture PollutionMixture = new GasMixture(new Mole(Chemistry.GasType.Pollutant, new MoleQuantity(0.009999999776482582), new MoleEnergy(73.0)));
public override int ConstructingSoundHash => Animator.StringToHash("SprayPaintLong");
public override int FinishedConstructingSoundHash => Animator.StringToHash("SprayPaintFinished");
public float TimeToUse() => 0.5f;
public Material GetPaintMaterial() => PaintMaterial;
public override bool UseDefaultUiUsingSounds() => false;
public override bool OnUseItem(float quantity, Thing onUseThing)
{
base.Quantity -= quantity;
AtmosphericEventInstance.CloneGlobalAddGasMix(base.WorldGrid, PollutionMixture);
return true;
}
}
OnUseItem is the whole of the can's per-use behavior: decrement quantity, emit one PollutionMixture (0.01 mol Pollutant at 73 J) into the can's WorldGrid. It always returns true.
The pollution dose does not scale with quantity. PollutionMixture is a static readonly constant and CloneGlobalAddGasMix is called unconditionally, so a caller that passes quantity = 0 still emits the full 0.01 mol. Only the base.Quantity line reads the argument. That matters to any caller that zeroes the quantity to make a can infinite: doing so stops the consumption and leaves the pollution exactly as it was.
OnUseItem's return value gates the paint, not just the consumption. ISprayer.DoSpray calls it and returns immediately when it answers false, BEFORE reaching OnServer.SetCustomColor, so anything that replaces this method has to answer true or the stroke silently paints nothing. See ./ISprayer.md.
The emission goes through AtmosphericEventInstance.CloneGlobalAddGasMix(WorldGrid, GasMixture), which enqueues onto a private static Queue<AtmosphericEventInstance> (201161) drained by HandleNetworkChangedEvents each game tick, and lands in the atmosphere at the can's cell: the world atmosphere there, a new one created for it, or, when the cell is inside a crew module, the module's internal atmosphere, which is a Thing-mode atmosphere and not a world one. No Thing member reaches the queue, and nothing marks a use as pending: the grid-addressed constructor this event uses sets no awaiting flag and no atmosphere (201202-201212). The atmosphere is reachable, with conditions, through DynamicThing.WorldAtmosphere on the can and on its holder, but that cached reference can also be the planet's shared read-only reading, an internal atmosphere, another cell's atmosphere, or null (see "Where the pollution lands, and how to read it"). What a use leaves on objects is the can's lower Quantity (361046) and, when the stroke paints, the target's colour (OnServer.SetCustomColor, 361113); neither records the pollution.
Where the pollution lands, and how to read it¶
Read in the 0.2.6428.27798 decompile. The 0.01 mol goes into the atmosphere at the grid cell the can reported when it was used, on the authority only, the next time the atmospheric event queue is drained, which happens early in each game tick on a thread-pool thread.
Only the authority queues it. CloneGlobalAddGasMix (201373-201379) does nothing where GameManager.RunSimulation is false. RunSimulation is !NetworkManager.IsClient (209421), so single player, a listen host and a dedicated server queue it and a joined client does not (ISprayer.DoSpray only calls OnUseItem inside the same gate anyway, 361103-361105):
public static void CloneGlobalAddGasMix(WorldGrid grid, GasMixture gasMixture, bool spark = false)
{
if (GameManager.RunSimulation)
{
AddEvent(new AtmosphericEventInstance(AtmosphericEventAction.CloneGlobalAddGas, grid, gasMixture, spark));
}
}
The event keeps its own copy of the grid (WorldGrid is a readonly struct, 297195, stored by the constructor at 201202-201212), so a holder who moves between the use and the drain does not take the gas along. AddEvent and TryDequeue both lock the queue (201269-201283).
The queue is drained early in each game tick, on a thread-pool thread. GameManager.GameTick (209864) waits while the world is paused, runs a few main-thread steps, then switches to the thread pool and, on the authority, calls AtmosphericsController.HandleMainThreadEvents whatever its name says (209893-209902):
// GameManager.GameTick, excerpt
ThingFire.UpdateFlames();
GlobalAtmosphereLiquid.UpdateLiquid();
await UniTask.SwitchToThreadPool();
Thread.CurrentThread.Priority = Settings.NonFrameCriticalThreadPriority;
ImGuiProfiler.Begin("GameTick");
try
{
if (RunSimulation)
{
AtmosphericsController.HandleMainThreadEvents();
UniTask.SwitchToThreadPool() (UniTask decompile, 19619-19622) returns an awaiter whose IsCompleted is false and whose OnCompleted and UnsafeOnCompleted hand the continuation to ThreadPool.QueueUserWorkItem and ThreadPool.UnsafeQueueUserWorkItem (20446-20473), so the tick body runs off the main thread until the tick's own await UniTask.SwitchToMainThread(cancellationToken) (209976).
public static void HandleMainThreadEvents()
{
NetworkAtmosphereEvent.HandleNetworkChangedEvents();
AtmosphericEventInstance.HandleNetworkChangedEvents();
AtmosphericEventInstance.ClearAwaiting();
}
public static void HandleNetworkChangedEvents()
{
AtmosphericEventInstance next;
while (TryDequeue(out next))
{
next.Apply();
}
}
No game tick runs while the world is paused (the loop waits on WorldManager.IsGamePaused || GameTickPaused before it does anything, 209871-209885), so a use made then waits in the queue. The only other drain is a save: PrepareToSave pauses the tick, waits until it has stopped, switches to the thread pool and calls AtmosphericsController.PreSaveCleanup() (270593-270600), whose body is HandleMainThreadEvents() (202005-202008).
It lands in the atmosphere at that cell, created if there is none. Apply sends the event to CloneGlobalAddGas (dispatch 201471-201472, body 201553-201561):
private void CloneGlobalAddGas()
{
Atmosphere atmosphere = GridController.World.AtmosphericsController.CloneGlobalAtmosphere(_worldGrid, 0L);
if (atmosphere != null)
{
atmosphere.Add(_gasMixture);
atmosphere.IsAwaitingEvent = false;
}
}
and AtmosphericsController.CloneGlobalAtmosphere (201931-201955) returns the local atmosphere at the cell, or creates a world atmosphere there. A new world atmosphere registers itself at once, into AtmosphericsManager.AllAtmospheres and the cell map (Init calls AtmosphericsManager.Register, 443370-443373 and 446620-446630), so the next lookup of that cell finds it. It is seeded with the planet's global mix scaled to its volume (PlanetaryAtmosphereSimulation.CloneGlobalGasMix, 221552-221570), except at or above the space height, where the seed returns early (221554-221557), and except when a structure blocking or releasing that cell has been queued since the last drain (IsGridAwaiting, 201412-201422: only StructureBlockingGrid and StructureReleaseGrid add a grid, 201389-201410, and every drain ends with ClearAwaiting, 202002). It refuses a client outright:
public Atmosphere CloneGlobalAtmosphere(WorldGrid worldGrid, long referenceId = 0L, bool setStateActive = true, bool allowCrewModules = true)
{
if (Assets.Scripts.Networking.NetworkManager.IsClient)
{
throw new System.Exception("Clone Global Atmosphere called on Client");
}
Atmosphere atmosphere = GetAtmosphereLocal(worldGrid, allowCrewModules);
if (referenceId > 0 && atmosphere != null && atmosphere.ReferenceId != referenceId)
{
ConsoleWindow.PrintError("Tried to Create a new World Atmosphere with reference Id '" + StringManager.Get((int)referenceId) + "' at '" + StringManager.GetGridString(worldGrid) + "' Where one already exists");
}
if ((atmosphere != null) & setStateActive)
{
atmosphere.AtmosLifeState = AtmosLifeState.Active;
}
if (atmosphere == null)
{
atmosphere = new Atmosphere(worldGrid, referenceId);
if (!AtmosphericEventInstance.IsGridAwaiting(worldGrid))
{
PlanetaryAtmosphereSimulation.CloneGlobalGasMix(atmosphere);
}
}
return atmosphere;
}
GetAtmosphereLocal (201966-201973) is where a crew module comes in: a cell inside one answers the module's own internal atmosphere, so a can used inside a crew module pollutes the module's air. That is an 8000 litre Thing-mode atmosphere (CrewModule.InitInternalAtmosphere, 152001; the Thing constructor sets the mode, 443303), not a world one:
public Atmosphere GetAtmosphereLocal(WorldGrid worldGrid, bool allowCrewModules = true)
{
if (allowCrewModules && Cell.IsInCrewModule(worldGrid, out var crewModule))
{
return crewModule.InternalAtmosphere;
}
return AtmosphericsManager.Find(worldGrid);
}
The can's cell is its holder's. SprayCan.OnUseItem passes base.WorldGrid. For a dynamic thing in a slot, OcclusionManager's dynamic-thing pass sets that grid to the root parent's centre and then refreshes the thing's cached atmosphere (220806-220818). DynamicThing.RootParent climbs through dynamic parents to the top one (304606-304626), so a can in a player's hand, or in a spray gun in a player's hand, sits in the player's cell. A seated player's cell is its seat's root parent's in turn: a player still sitting in the lander capsule it arrived in, in any mode, sprays into the capsule's cell wherever its body has been moved (measured in single player and on a listen host; see RemotePlayerPosition, "The authority's own seated player: the body moves, its seat and its cell do not"). These lines come after the pass's if (GameManager.RunSimulation) block has closed (220791-220805), so every peer runs them, not only the authority, and the dedicated server's copy of the delegate is textually identical (dedicated server decompile 208669-208764):
// OcclusionDynamicThingAction, excerpt
WorldGrid worldGrid = dynamicThing.WorldGrid;
if ((object)parentSlot?.Parent != null)
{
Thing rootParent = parentSlot.Parent.RootParent;
if (rootParent is DynamicThing { WorldCenterOfMass: var worldCenterOfMass })
{
dynamicThing.Position = worldCenterOfMass;
dynamicThing.WorldCenterOfMass = worldCenterOfMass;
}
dynamicThing.WorldGrid = new WorldGrid(rootParent.CenterPosition);
dynamicThing.SetWorldAtmosphere();
CheckForRoom(dynamicThing);
}
else
{
dynamicThing.WorldGrid = new WorldGrid(centerPosition);
The pass is OcclusionManager.ThreadedWork -> ThreadedWorkDynamicThingSync -> AllDynamicThings.ForEach(OcclusionDynamicThingAction) (221230-221277). It runs on the manager's own thread (ThreadedManager.ThreadedLoop, 223497-223514), while the game state is Running, at most once per rendered frame (221233-221245), over every DynamicThing (each registers itself in Awake, 306443), and it stops while the world is paused (WorldManager.SetGamePause pauses the manager, 60952-60967).
What a reading finds. On the authority, once the drain has run, AtmosphericsController.World.GetAtmosphereLocal(grid) for the grid the use captured is the atmosphere that received the 0.01 mol, together with the planet's air when the use created it. It is found through that cell, not through any field of the painted Thing. The same tick then goes on to mix atmospheres (DoAtmosphereMixJobs, 209929) and, at its end, to step each atmosphere's life state and drop the ones marked for removal (AtmosphericProcessing and ProcessMarkedForRemoval, 209961-209963). A world atmosphere outside a room whose pressure is close to the planet's is not "live" unless it is awaiting an event or the structure at its cell's centre always keeps one (IsLive, 443421-443445), and a world atmosphere that is not live goes from Active to Passive to MarkedForRemoval on successive passes (446430-446455). So a reading taken after the tick is not simply the old value plus 0.01 mol; how much has moved to neighbouring cells by then, and how long an outdoor atmosphere lasts, was not measured (see Open questions). A reader on the main thread can also be handed a cached quantity; see Atmosphere on Mole.Quantity.
Reading it through a Thing's WorldAtmosphere¶
The atmosphere can also be reached from the can and from its holder, with conditions. Both are DynamicThings (SprayCan : Consumable, 361018; Consumable : Item, 180052; Item : DynamicThing, 310851; Human : Entity, 366125; Entity : DynamicThing, 307678), and a DynamicThing keeps a cached reference to an atmosphere, WorldAtmosphere (304574-304592; see Atmosphere, "A dynamic thing's cached atmosphere"). The occlusion pass above sets it through SetWorldAtmosphere (305415-305453): a slotted can whose parent is a dynamic thing takes the parent's WorldAtmosphere (305430-305433), so a can in a hand, or in a gun in a hand, copies the holder's, and a holder in no slot takes its crew module's air or SampleGlobalAtmosphere at its own cell (305440). Other code writes a holder's WorldAtmosphere too; the Atmosphere page lists it.
- Every peer keeps one. The pass is not gated on
RunSimulation, so on a joined client the reference is that client's own copy of an atmosphere, never the object the authority's drain wrote to. - It is only as fresh as the last pass. The pass rewrites it at most once per rendered frame, and a slotted can copies its holder's cached value, so depending on the order of the pool it can lag the holder by one more pass.
- It can point somewhere other than where the gas landed. At the planet's shared read-only reading, which
SampleGlobalAtmosphereanswers when the cell has no atmosphere (201963): that is exactly the case in which the use creates one, and the reference moves to the new atmosphere only on a pass after the drain. At an internal atmosphere, either a rocket interior's crew module (305420-305424) or the parent'sInternalAtmospherewhen the slot hasUseInternalAtmosphere(305425-305428), while the gas still goes to the grid. At another cell, when the holder moved between the use and the drain. Or at nothing: the getter drops a world atmosphere that is no longer registered at its grid (IsInvalidWorld, 443474-443485;AtmosphericsManager.DeregistersetsRegisteredWorldGridtoWorldGrid.INVALID, 446650).
A reading that has to be current, and has to be the object the gas was added to, looks up the captured grid on the authority instead.
Fields¶
Source: F0015, re-confirmed against 0.2.6403.27689.
PaintMaterial/PaintableMaterial: TheMaterialrepresenting the can's current color. The game has oneSprayCanprefab per color.GameManager.GetColorSwatch(Material)is the game's own reverse lookup from this material to theColorSwatch, used byISprayer.DoSpray.Thumbnail: TheSpriteshown in the inventory slot. Tied to the prefab, so switching color requires updating it manually.Quantity: Decremented on each use. Setting it to 0 before vanilla runs effectively makes the can infinite.DLCType(inherited fromThing,[SerializeField] private DLCType _dlcTypewith a getter-onlypublic DLCType DLCType): the entitlement required to obtain this can.DLCType.Noneon the twelve vanilla cans;DLCType.MetallicPaintson the four metallic cans. This is the ONLY place a paint color's DLC requirement is recorded, becauseColorSwatchhas noDLCTypefield. See../GameSystems/DLCGating.md.
One prefab per color, and the metallic set¶
rocketstation_Data/StreamingAssets/Data/paints.xml lists a Tool Manufactory recipe per can. Sixteen cans ship at 0.2.6403.27689: twelve vanilla plus four from the Metallic Paints DLC.
Vanilla twelve: ItemSprayCanBlack, ItemSprayCanBlue, ItemSprayCanBrown, ItemSprayCanGreen, ItemSprayCanGrey, ItemSprayCanKhaki, ItemSprayCanOrange, ItemSprayCanPink, ItemSprayCanPurple, ItemSprayCanRed, ItemSprayCanWhite, ItemSprayCanYellow.
Metallic four: ItemSprayCanMetallicBronze, ItemSprayCanMetallicGold, ItemSprayCanMetallicObsidian, ItemSprayCanMetallicSilver.
Every recipe, vanilla and metallic alike, is Time 5, Energy 500, Iron 1. The recipes ship to all players; the DLC gate in the fabricator path is what stops a non-owner producing the metallic four.
Note the prefab name uses Grey while the swatch name is ColorGray (see ./ColorSwatch.md). Do not derive one from the other by string manipulation.
Runtime enumeration on 2026-07-25 in game version 0.2.6403.27689 confirmed all 16 prefabs are present in Prefab.AllPrefabs regardless of entitlement, each with a non-null PaintMaterial, each resolving to exactly one CustomColors swatch by material reference, with the twelve vanilla cans carrying DLCType.None and the four metallic cans DLCType.MetallicPaints. The full index map is on ./ColorSwatch.md under "Metallic swatch addition"; the method and its threading caveats are recorded there too.
Because SprayCan prefabs are the only carrier of the color-to-DLC mapping, walking Prefab.AllPrefabs for SprayCan instances is how a mod resolves which CustomColors entries are entitlement-gated:
foreach (Thing thing in Prefab.AllPrefabs)
{
if (thing is SprayCan prefabCan && prefabCan.PaintMaterial != null)
{
// prefabCan.PaintMaterial identifies the swatch, prefabCan.DLCType is the gate
}
}
Verification history¶
- 2026-04-20: page created from the Research migration; verbatim content lifted from F0015. No conflicts.
- 2026-07-25: re-read against 0.2.6403.27689. Added the verbatim class declaration, the
PollutionMixtureconstants, and the inheritedDLCTypefield. Added "One prefab per color, and the metallic set" listing all sixteen prefab names frompaints.xmland the identical recipe cost. Addedrelatedlinks and the cross-reference to the new../GameSystems/DLCGating.md. No existing claim was contradicted; all prior content was additive-confirmed. - 2026-09-23: Declaration re-read against 0.2.6428.27798 and unchanged, so that section and the frontmatter are restamped. Three consequences of the same four lines added, none of them contradicting anything already here: the pollution dose is constant and does not scale with
quantity, so zeroing the quantity to make a can infinite leaves the emission intact; the return value gatesOnServer.SetCustomColorinsideISprayer.DoSprayand not merely the consumption; and the emission path ends in a worldAtmospherethat noThingreading reaches, which is why a spray leaves no per-object trace of having polluted. Found while coveringSprayCanUsePatch's four setting combinations in the Spray Paint Plus playtest suite. -
2026-09-24: the three consequences added on 2026-09-23 had no source citations of their own, so both mechanisms behind them were re-read against the 0.2.6428.27798 decompile before this page was released for commit. Both hold verbatim.
ISprayer.DoSpraycallsif (!sprayer.OnUseItem(sprayer.GetUseAmount(), thing)) { return delayedActionInstance; }and only then reachesOnServer.SetCustomColor(thing, colorSwatch.Index), both inside theif (GameManager.RunSimulation)block (361103-361114), so a false return does stop the paint and not merely the consumption.AtmosphericEventInstance.CloneGlobalAddGasMix(201373) and theHandleNetworkChangedEventsthat drains its queue (201251, called from the tick at 202001) are on the class the page names. No claim changed; the section stamps are unchanged and the frontmatter gains a sources line for the two mechanisms plus theOnUseItemcall site (302333). Nothing on this page describes unreleased or uncommitted work. -
2026-09-27: added "Where the pollution lands, and how to read it" and its subsection "Reading it through a Thing's WorldAtmosphere", from the 0.2.6428.27798 decompile plus a decompile of the game's own
UniTask.dllfor the thread-pool switch. The use queues on the authority only; the queue is drained early in each game tick on a thread-pool thread, or by a save, and never while paused;CloneGlobalAddGasadds the mixture toCloneGlobalAtmosphere(cell), which is the local atmosphere, a crew module'sThing-mode air inside one, or a new world atmosphere that registers at once and is seeded from the planet unless the cell is at or above the space height or has a structure event queued; a slotted can's cell is its root parent's, refreshed by the occlusion pass on every peer; and the same tick mixes and steps life states after the drain, so a later reading is not simply plus 0.01 mol. Drafted while building a test-rig atmosphere reader and re-read line by line before landing. Three draft claims were wrong and were corrected before landing: that the slotted thing's grid and atmosphere refresh runs only on the authority (it runs on every peer), that the cell holds exactly 0.01 mol more once a tick has run, and that a new atmosphere is always seeded. The draft's sentence about the test rig's reader was dropped; the page describes the game only. Frontmatter sources: the call site cited as 302333 on 2026-09-24 isDynamicComposter's;SprayCan.OnUseItem's is 361047, and the pointer was corrected. The claim it supports is unchanged. - 2026-09-27: conflict on "whether the pollution's queue or atmosphere is reachable from a
Thing". Previous claim ("Declaration", added 2026-09-23, re-read 2026-09-24): the emission lands in a worldAtmosphere, and neither the queue nor the resulting atmosphere is reachable from aThing, so there is no per-object reading that shows a spray happened. New finding:DynamicThing.WorldAtmosphereis a cached reference to the atmosphere at a dynamic thing's cell (a can's, via its holder), stale until the next update. Fresh validator verdict: neither is correct. The queue is unreachable (private static, and the grid-addressed event sets no awaiting flag). The atmosphere is reachable, conditionally, throughWorldAtmosphereon the can and on its holder: every peer refreshes it through the occlusion pass, at most once per frame, and it can instead be the planet's shared read-only reading (exactly when the use creates the atmosphere), an internal atmosphere, another cell's, or null. Inside a crew module the gas lands in the module'sThing-mode atmosphere, not a world one. A use does leave per-object traces: the can'sQuantityand the target's colour. Result: the last paragraph of "Declaration" reworded to match and the section restamped; the conditions are spelled out in "Reading it through a Thing's WorldAtmosphere". - 2026-09-27: one sentence added to "Where the pollution lands, and how to read it", after "The can's cell is its holder's": a seated holder's cell is its seat's root parent's, so a player still in its arrival capsule sprays into the capsule's cell wherever its body was moved. From the same occlusion code the paragraph already quotes and the single-player and listen-host measurement now on RemotePlayerPosition (the Human's
WorldGridread the capsule's cell while its teleported body stood 74 to 80 m lower). Additive; the section's stamp stands.
Open questions¶
- How much of the 0.01 mol an outdoor atmosphere keeps, and for how long. After the drain the same tick mixes it toward neighbouring cells, and an outdoor atmosphere near the planet's pressure is stepped toward removal (see "What a reading finds"). What
ProcessMarkedForRemoval's removal fromAtmosphericsManager.AllAtmospheresdoes with such an atmosphere's gas and its cell-map entry was not traced:AtmosphericsManager.Deregister(446632-446661) hands a world atmosphere's gas to the planet and drops its map entry, but whether this path reaches it was not followed. Resolve by reading the atmosphere at the captured grid on the authority at intervals after a use, outdoors and in a sealed room, and by followingDensePool.RemoveWhere.
The distinct-material question raised at page revision time was resolved the same day by runtime enumeration: all 16 cans map one-to-one onto 16 distinct swatch materials. See ./ColorSwatch.md.