ThingColorMessage¶
Vanilla ProcessedMessage<ThingColorMessage> at Assets.Scripts.Networking.ThingColorMessage (decompile line 283375). A client-to-server request carrying a Thing's new colour index: OnServer.SetCustomColor sends it only when it runs on a remote client, and its Process runs on the server. It is not how colour reaches clients; the server sends colour to clients in the flag 32 block of the per-tick delta state and in the per-Thing join and new-Thing record (see "Server-to-client colour sync" below). SprayPaintPlus piggybacks a second channel on Thing.NetworkUpdateFlags bit 12 for its own per-can color-index sync rather than extending this message.
Line numbers on this page are from the 0.2.6428.27798 decompile unless a passage says otherwise.
Wire format¶
public class ThingColorMessage : ProcessedMessage<ThingColorMessage>
{
public long ThingId; // 8 bytes
public int ColorIndex; // 4 bytes
}
Class declaration at decompile line 283375 (259874 at 0.2.6228.27061). Serialization body at lines 283386-283396 (259884-259891 at 0.2.6228.27061):
Deserialize: ThingId = reader.ReadInt64(); ColorIndex = reader.ReadInt32();
Serialize: writer.WriteInt64(ThingId); writer.WriteInt32(ColorIndex);
12 bytes plus the message header. No bit packing, no reserved bytes, no unused slots.
Sender¶
OnServer.SetCustomColor(Thing thing, int colorIndex) (39796-39810) is the only code that constructs a ThingColorMessage (39803), and it sends one only when it runs on a remote client:
public static void SetCustomColor(Thing thing, int colorIndex)
{
if (!thing.HasColorState)
{
thing.SetCustomColor(colorIndex);
if (Assets.Scripts.Networking.NetworkManager.IsClient)
{
NetworkClient.SendToServer(new ThingColorMessage
{
ThingId = thing.netId,
ColorIndex = thing.CustomColor.Index
});
}
}
}
NetworkManager.IsClient is NetworkRole == NetworkRole.Client (278324), so on a host or in single-player nothing is sent: the colour is applied locally and reaches clients by the paths in "Server-to-client colour sync". On a remote client the colour is applied locally first, and the index the client then holds goes to the server through NetworkClient.SendToServer (218657). A Thing with its own colour interactable (HasColorState) is skipped entirely. Which callers of OnServer.SetCustomColor run on a remote client is not traced on this page.
Inside Thing.SetCustomColor(int index, bool emissive = false) (327900-327952), vanilla:
- Validates via
GameManager.IsValidColor(index)(327902-327905), which is0 <= index < CustomColors.Count(209714-209721). - On valid, sets
CustomColor = GameManager.GetColorSwatch(index)(327906) and, only whenGameManager.RunSimulation, raisesNetworkUpdateFlags |= 32(bit 5) (327907-327910). - On invalid, returns early without setting the color and without raising an error (silent no-op).
Receiver¶
ThingColorMessage.Process(long hostId) (283381-283384) runs where the message arrives, which is the server. It calls:
Same Thing.SetCustomColor entry as any other colour write, so the server's own call raises flag 32 and the new colour reaches every client, the sender included, through the delta state. Process checks nothing else: there is no null test on the Find result, no HasColorState or IsPaintable test, and no entitlement test (see DLCGating). The silent-no-op-on-invalid-index behavior applies on receive as well: attempts to piggyback extra bits inside ColorIndex are dropped by IsValidColor. The dispatcher NetworkBase.DeserializeReceivedData (39291-39311) calls Process on whichever peer receives a message, and prints Messages should only be processed on the server when a non-server processes one outside a short allowlist; ThingColorMessage is not on that allowlist (39306-39309).
Server-to-client colour sync¶
Clients get colour from the server by two paths, and neither is this message:
- The per-tick delta state, flag
32. The game names the bitNetworkUpdateType.Thing.CustomColour(286626).Thing.BuildUpdatewritesGameManager.GetColorIndex(CustomColor)as anInt32and thenCustomNameunder it (328305-328310), and a client'sThing.ProcessUpdatereads them back and callsSetCustomColor(index)andRenameThing(newName)(328391-328397). The bit is raised byThing.SetCustomColor(int, bool)whenGameManager.RunSimulation(327907-327910) and by theCustomNamesetter on the server (323578-323581). It is not colour-only:Plantraises it as well, in itsPlanterNamesetter (363678-363681) and on stack changes, andPlant.BuildUpdate/Plant.ProcessUpdateappendPlanterNameunder the same bit (364043-364046, 364117-364120). The server sends the delta state fromNetworkServer.NetworkUpdate(219609-219647) throughFragmentHandler.Send. Bit map: NetworkUpdateFlags. - The per-Thing record. A joining client, or a client that sees a Thing created while it is connected, gets the colour from the record
Thing.SerializeOnJoinwrites andThing.DeserializeOnJoinapplies (328071-328141). It carries-1for a Thing still in its prefab colour and holds the index in one signed byte; details on Thing, "The same sentinel crosses the network".
Backing field on Thing¶
Thing.CustomColor (322976; 297959 at 0.2.6228.27061) is typed ColorSwatch (object reference). The canonical int identifier is CustomColor.Index. ColorSwatch exposes Normal (Material), Emissive (Material), Index (int), Light (Color), DisplayName (string); the full member list is on ColorSwatch.
Save-side: ThingSaveData.CustomColorIndex (329560-329561; 304515 at 0.2.6228.27061) is an [XmlElement] int initialized to -1. -1 means "no custom color; use prefab default". Thing.DeserializeSave calls SetCustomColor(saveData.CustomColorIndex) only when the value is >= 0 (327298-327301). The 0.2.6228.27061 reading named the load method Thing.LoadSimData (decompile line 302262-302287); no method of that name exists at 0.2.6428.27798.
Emissive parameter and its transience¶
Thing.SetCustomColor(int index, bool emissive = false) accepts an optional emissive flag that, when true, swaps the renderer's material to ColorSwatch.Emissive (if non-null) via CustomColorMapping.SetEmissive. It also sets Thing.EmissionColor to Color.white and hands that to every ThingRenderer as _EmissionColor, but the value reaches no material, so the visible glow is the material swap alone (see ColorSwatch, "Normal vs Emissive selection"). A second overload, SetCustomColor(bool emissive) (327954-327974), does only the material swap. The full body is documented in ../GameClasses/ColorSwatch.md.
This wire format does NOT carry the emissive flag, and no other colour path does either. At 0.2.6428.27798 every path calls SetCustomColor with the default emissive: false:
OnServer.SetCustomColor(39796-39810) applies the color viathing.SetCustomColor(colorIndex)without passing an emissive value and then, on a remote client only, sendsThingColorMessage { ThingId, ColorIndex }to the server.ThingColorMessage.Process(283381-283384) callsThing.Find<Thing>(ThingId).SetCustomColor(ColorIndex)on the server, also without an emissive value.- A client applies the server's colour through
Thing.ProcessUpdate(flag32,SetCustomColor(index)at 328394) and throughThing.DeserializeOnJoin(SetCustomColor(b)at 328124), neither with an emissive value. ThingSaveDatapersists onlyCustomColorIndex; on load,Thing.DeserializeSavecallsSetCustomColor(saveData.CustomColorIndex)when the index is>= 0(327298-327301), defaulting toemissive: false. The 0.2.6228.27061 reading named this methodThing.LoadSimData(line 302262-302287), which no longer exists.
Each of these runs the base method with emissive: false: every mapped renderer not tagged NotPaintable goes back through CustomColorMapping.SetColor, and EmissionColor becomes UnityEngine.Color.white * 0f, all four channels zero rather than Color.black (327915-327951). That clears the emission unless the Thing's class puts it back. WallLight (333693-333701), Beacon (346853-346861), PortableLight (359277-359285), PortablePortal (359346-359354) and StackableLight (360277-360286, so also ChemLight and RoadFlare) override SetCustomColor(int, bool) to call SetCustomColor(OnOff && Powered) after the base call; LandingPadModular (188461-188468) passes OnOff && _flash to the base instead of the argument; GrowLight (403764-403767) passes false to the base and then calls SetBulbMaterial(OnOff && Powered). Subclasses that do not override again inherit these.
Vanilla classes that glow work around the clearing by re-applying emission from their own state, either in a SetCustomColor(int, bool) override (above) or from their interaction and animation handlers. ChemLight.OnInteractableUpdated (348410) and RoadFlare.OnInteractableUpdated (360342), at decompile lines 322433 and 334170 in 0.2.6228.27061, call SetCustomColor(emissive: true) when the light is switched on. Every site that can pass emissive: true is listed on ColorSwatch, "Vanilla callers of SetCustomColor with emissive: true".
No painted structure (wall, pipe, cable, frame) ever receives emissive: true in vanilla. Mods that want glow to persist and sync must store their own IsGlowing flag per Thing and re-apply emissive after every SetCustomColor entry. See ../GameSystems/RenderingPipelineAndGlow.md for the full rendering approach and ../GameClasses/ColorSwatch.md for swatches whose Emissive material is null.
Side-channel options for extra paint metadata¶
This message cannot carry anything to clients, since it only travels client to server, and the paths that do reach clients have no slack either: the flag 32 block carries the index as an Int32 next to CustomName, the per-Thing record carries it in one signed byte, and the base Thing.SetCustomColor drops an invalid index on every receiver. Mods that need to carry per-paint metadata (for example, a "glow" flag) to clients therefore cannot piggyback inside a colour index or extend a vanilla colour path without breaking version compatibility. Two documented alternatives:
- Claim a free bit in
Thing.NetworkUpdateFlags(see../GameSystems/NetworkUpdateFlags.md) and append a field via postfix patches onThing.BuildUpdate/Thing.ProcessUpdate/Thing.SerializeOnJoin/Thing.DeserializeOnJoin. Spray Paint Plus does this twice: for spray can colour onConsumable(ConsumableSyncPatch.cs, bit0x1000) and for glow on everyThing(ThingGlowSyncPatches.cs, bit0x2000), so its glow field is carried by painted structures (walls, pipes, cables) as well as by spray cans. See../Patterns/BinaryStreamSafety.mdfor the no-try-catch constraint. - Ship a dedicated LaunchPadBooster
INetworkMessage(see./LaunchPadBoosterNetworking.md) that carries the metadata independently. Costs one extra message per paint event.
Verification history¶
- 2026-04-21: page created. Decompile findings sourced from Assembly-CSharp.dll (ThingColorMessage at line 259874-259891, OnServer.SetCustomColor sender at line 39453-39459, Thing.SetCustomColor receiver at line 302860 with NetworkUpdateFlags |= 32 at line 302873-302874, Thing.CustomColor field at line 297959, ThingSaveData.CustomColorIndex at line 304515, Thing.LoadSimData at line 302262-302287).
- 2026-04-21: added "Emissive parameter and its transience" section documenting that the
emissiveflag onThing.SetCustomColoris not stored, saved, or synced, and pointing at the two vanilla callers (ChemLight.OnInteractableUpdatedat line 322433 andRoadFlare.OnInteractableUpdatedat line 334170). Additive; no prior claim changed. - 2026-09-21 (game version 0.2.6428.27798): conflict on "which way this message travels". Previous claims: "Server-to-client broadcast of a
Thing's custom-color change. Authoritative channel for custom-color sync in vanilla" (intro); "OnServer.SetCustomColor(Thing thing, int colorIndex)sendsThingColorMessageat decompile line 39453-39459 after applying the color change server-side. The message fans out to all connected clients." (Sender); andProcessrunning "on the receiving client" (Emissive section). New finding: the message travels client to server only;OnServer.SetCustomColorsends it only whenNetworkManager.IsClient,Processruns on the server, and the server reaches clients through flag32in the delta state and through the per-Thing record. Fresh validator verdict: B is correct, quoting theOnServer.SetCustomColorbody (39796-39810) with itsif (Assets.Scripts.Networking.NetworkManager.IsClient)send. It found the only construction at 39803,NetworkClient.SendToServerrefusing to run on the server, no relay inProcessedMessage<T>or on the receive path, the game's own name for the bit (NetworkUpdateType.Thing.CustomColour = 32, 286626) andPlant's use of the same bit forPlanterName. Result: intro, "Sender" and "Receiver" rewritten; new section "Server-to-client colour sync"; the side-channel premise rewritten for the real direction; the Emissive bullets corrected. - 2026-09-21 (game version 0.2.6428.27798): conflict on two identifiers. Previous:
Assets.Scripts.Networking.Messages.ThingColorMessage(intro and sources) andThing.LoadSimDataas the save-load method ("Backing field on Thing" and the Emissive bullets). New finding: the class isAssets.Scripts.Networking.ThingColorMessage, noNetworking.Messagesnamespace exists, noLoadSimDataexists anywhere in the assembly, and the colour load isThing.DeserializeSave(327298-327301). Fresh validator verdict: B is correct, quotingif (saveData.CustomColorIndex >= 0) { SetCustomColor(saveData.CustomColorIndex); }fromThing.DeserializeSave, confirmed against the IL of the installed DLL. It also found thatOnServeris in the global namespace, so the sources pointerAssets.Scripts.OnServer.SetCustomColoris nowOnServer.SetCustomColor. The 0.2.6228.27061 decompile is no longer on disk, so whether those names were right for that build was not checked; the old names are kept beside the new ones on the page. - 2026-09-21 (game version 0.2.6428.27798): conflict on "whether every colour re-entry clears the emissive state". Previous claim: "Every re-entry therefore clears the emissive state." New finding: the base method clears it and sets
EmissionColortoColor.white * 0f(all four channels zero, notColor.black), butWallLight,Beacon,PortableLight,PortablePortal,StackableLight,LandingPadModularandGrowLightre-apply emission after the base call. Fresh validator verdict: B is correct, quotingWallLight.SetCustomColor(333693-333701). Result: the sentence replaced by the paragraph listing the overrides, under the same inline 0.2.6428.27798 stamp as the corrected bullets. - 2026-09-21: resolved the Open question "verify whether bit 5 is the exclusive trigger for a color-message send or whether it is a generic "dirty" marker consumed by multiple senders". Neither: bit 5 (value 32) is not connected to this message at all. It marks the colour-and-name block of the delta state, which
Thing.BuildUpdate/ProcessUpdateread, andPlantaddsPlanterNameunder it; covered by the direction verdict above and now in "Server-to-client colour sync". Restamps: intro, "Wire format", "Sender", "Receiver", "Backing field on Thing" and "Side-channel options" re-verified against 0.2.6428.27798 and restamped, their line numbers moved to that decompile with the 0.2.6228.27061 numbers kept where the passage cites them (class 259874 to 283375, serializers 259884-259891 to 283386-283396,OnServer.SetCustomColor39453-39459 to 39796-39810,Thing.SetCustomColor302860 to 327900 and its flag write 302873-302874 to 327907-327910,Process259881 to 283381,Thing.CustomColor297959 to 322976,ThingSaveData.CustomColorIndex304515 to 329560-329561). The Sender now carries the verbatimOnServer.SetCustomColorbody and theRunSimulationgate on the flag write, and the Receiver the checksProcessdoes not make and the dispatcher's server-only warning. The side-channel example now names the two sync patches Spray Paint Plus ships (ConsumableSyncPatch.cson bit0x1000,ThingGlowSyncPatches.cson bit0x2000), and "Incurs a second round-trip per paint event" now reads "Costs one extra message per paint event", since the option adds one one-way message. "Emissive parameter and its transience" keeps its 0.2.6228.27061 stamp: its opening paragraph, its two-caller list and its closing paragraph were not re-verified. Frontmatter: verified_in / verified_at bumped, the two source pointers corrected, a decompile sources line and a Spray Paint Plus sources line added, ColorSwatch and DLCGating added to related. Occasion: correction pass after the 2026-09-21 Thing and OnServer conflicts. - 2026-09-21 (third pass, game version 0.2.6428.27798): "Emissive parameter and its transience" said
SetCustomColor(int, bool)"writesColor.whiteto the_EmissionColorshader property on everyThingRenderer", and listedChemLightandRoadFlareas the callers that re-applyemissive: true. Both went to fresh validators whose verdicts are recorded in full on ColorSwatch: the_EmissionColorvalue reaches no material at 0.2.6428.27798 (B is correct), and many classes can passemissive: truethrough either overload (Neither, with a corrected census). Result: the opening paragraph now says the glow is the material swap alone and names theSetCustomColor(bool)overload, and the two-caller list became a pointer to the census with the currentChemLightandRoadFlarelines (348410, 360342). With those parts re-verified and the painted-structure sentence confirmed by the census, the whole section is restamped 0.2.6428.27798 @ 2026-09-21 and its inline stamp removed, so every section of this page now stands at 0.2.6428.27798. Added a sources line.
Open questions¶
None.