SprayPaintPlus Networking¶
Spray Paint Plus registers five custom LaunchPadBooster INetworkMessage types: two client-to-server, three server-to-client. Two server-authoritative sync flows layer on top of them, plus a join-time state push and a separate join-payload path for settings.
Parts of this page are not in a release yet. Two of the five messages,
GlowStateMessageandSprayCanColorSyncMessage, and theJoinStatePushpath, landed in commit0b1c05a5(2026-09-24) and are targeted at v1.13.0. The published Workshop build is v1.12.1, where glow and spray can colour instead ride bits 13 and 12 ofThing.NetworkUpdateFlags;PluginVersionstill reads1.12.1at commit6c0b7af7, so the bump has not happened either. Two later changes are unreleased as well. The server's check of who sent aPaintModifierMessagelanded in commitdf406e15; v1.12.1 stores a mask under whatever Human it names. The request numbers on the spray can colour messages landed in commit6c0b7af7; in v1.12.1 a colour request carries no number and a client applies every colour it reads. The client's report of its preference mask on the first frame of every session is in commitfdcffdc5on master; in v1.12.1, after a server restart, the server reads a rejoining player as allowing everything until they press Shift or Ctrl. The Glow Paint refusal notice skips a spray gun that holds a can from commit0b70e3e6on master; v1.12.1 sends it for a loaded gun too, although that stroke paints. Every such claim is marked again where it appears. The shipped arrangement is recorded on../GameSystems/NetworkUpdateFlags.md.
Message types¶
Five types, registered in Plugin.cs (178-189). Direction is established by the send site and by what each Process refuses to do, not by the name: SprayCanColorMessage and SprayCanColorSyncMessage are near-homographs travelling in opposite directions.
| # | Type | Direction | Payload | Purpose |
|---|---|---|---|---|
| 1 | SprayCanColorMessage |
client to server | Int64 SprayCanId, Int32 ColorIndex, then Int32 Request (the request number is not in a release yet, commit 6c0b7af7; v1.12.1 sends the first two fields only) |
a client asking the server to change a can's colour |
| 2 | PaintModifierMessage |
client to server | UInt16 Modifiers, Int64 PlayerHumanId |
the acting player's preference mask: the two modifier keys and the client halves of 13 paired settings |
| 3 | SettingBlockedNotice |
server to one client | String Function |
a function the player had enabled was refused by the server's half of the setting |
| 4 | GlowStateMessage |
server to clients | Int32 count, then Int64 id + Boolean glowing per entry |
per-Thing glow state, any number of Things per message |
| 5 | SprayCanColorSyncMessage |
server to clients | Int32 count, then Int64 id + Int32 colorIndex + UInt64 requestedBy + Int32 request per entry (the last two since commit 6c0b7af7) |
the authority telling clients what a can's colour now is, and which player's request, if any, that answers |
-
SprayCanColorMessage, client to server, sent byColorCyclerPatchwhen a client scrolls or eyedrops to change colour, and only on a remote client. In v1.12.1 both send sites readif (NetworkManager.IsClient && !NetworkManager.IsServer) { ... SendToHost(); }. Not in a release yet: since commit6c0b7af7both callRequestColourunder the same test instead, which numbers the request per can (Request) before sending it; see "Request numbers" under the colour-change flow. The server finds theSprayCanbyReferenceId, validates the index range, the DLC entitlement and its own half of the colour cycling setting (CannotChange,WithinFamily), and applies. Its four reject paths re-assert the can's current colour rather than staying silent, which is how an optimistic client that already moved its own visual gets snapped back. Since6c0b7af7every answer, the accepted colour and each snap-back alike, names the request it answers. -
PaintModifierMessage, client to server. A remote client sends it fromColorCyclerPatch.SendModifierStateIfChanged, which runs from the mod's prefix onInventoryManager.NormalModewhile that prefix'smodifier key syncguard site is on, so only on a frame when the game runsNormalMode, the active hand holds a spray can or a spray gun, andInventoryManager.ParentHumanis set. As shipped it goes out only when the packed mask differs from the client's ownSprayPaintHelpers.PlayerModifiersrow for its Human'sReferenceId, or there is no row: on the first such frame of a world where the client has no row, and after that only when the mask changes. The client writes that row before it sends and never removes it, so a rejoin, or any later session in the same process, under the same HumanReferenceIdsends nothing until the mask differs, and a send that throws is not tried again. Not in a release yet: commitfdcffdc5on master, also sends it on the first such frame of every session, so a restarted server gets the mask back; in v1.12.1, after a server restart, the server reads a rejoining player as allowing everything until they press Shift or Ctrl. See "A restarted server gets the mask back" below. The server stores it inSprayPaintHelpers.PlayerModifiers[PlayerHumanId](as shipped; not in a release yet: commitdf406e15first checks that the Human belongs to the sender, see "The server checks who sent a preference mask" below), read duringNetworkPainterPatch.Prefixto decide single, network or checkered mode.Modifiersis aUInt16, not a byte: the mask has outgrown one byte. Bit 0 is the single-item intent (Shift, inverted when Paint Single Item By Default is on), bit 1 is Ctrl (checkered), and bits 2 to 14 carry the client halves of 13 of the 15 paired settings: Network Painting, its ten per-type toggles, Glow Paint and Unlimited Spray Paint Uses (SettingsMerge.PlayerPrefs.PackLocal). The client halves of Color Cycling and Color Picking are not in the mask. -
SettingBlockedNotice, server to one client, added in v1.11.0 (b81d271c) and shipped. It exists because the network-type classification runs server-side, so for the server-evaluated functions (network painting and its ten types, glow paint, unlimited uses) a client cannot know a suppression happened, andConsoleWindowis process-local so a print on the server reaches nobody. The three-per-function-per-session cap is enforced on both sides:WarningNotifiercaps what the receiving player sees, andNotifyBlockedkeeps its own per-player, per-function count on the send side, because the block is detected once per stroke and painting repeats several times a second. Not in a release yet: from commit0b70e3e6on master (targeted at v1.13.0), the Glow Paint notice for a spray gun stroke that the server's half of Glow Paint refused ([Spray Paint Plus] Glow Paint is turned off on this server, so it had no effect.) goes out only when the gun holds no can.GlowRefusalNotice.Tells(changesGlow, gunHoldsCan, madeByAPlayer, playersOwnHalfOn)ischangesGlow && !gunHoldsCan && madeByAPlayer && playersOwnHalfOn, andThingAttackWithGunPatchpassesgunHoldsCan: gun.SprayCan != null, the game's ownSlots[0].Occupant as SprayCan(GlowPaintPatches.cs522-555). The mod hands a refused stroke back to the base game, which paints with a loaded gun's can (ISprayer.DoSpray), so for that stroke "it had no effect" was false. v1.12.1, and master up tofdcffdc5, test the other three facts only, so a loaded gun is told too: on the test rig on 2026-09-27, on a build without this change (Release DLL sha25656763957), the checkwith Glow Paint off the spray gun is a vanilla gunmade one stroke with a can in the gun, on a server with glow paint off and the player's own half on, and it painted the cable and printed the notice once, in single player, on a listen host and on the dedicated server. A loaded gun whose stroke paints nothing (a can with no paint left, a gun switched off, a can already the target's colour) is not told either. None of the strokes the notice is still sent for comes from a player's own click on 0.2.6428.27798: the game sends a stroke only when the player's own preview of it was enabled when the click started it (see OnServer, "Who calls AttackWith: one caller, behind the player's own preview"); on a machine whose merged Glow Paint is off the mod's prefix leaves that preview to the base game (GlowPaintPatches.cs502,SettingsMerge.EffectiveGlowPaint); and the base game disables it for a gun with no can (NotEnoughSprayPaint), as it does for the three loaded strokes that paint nothing, so no click sends those either. On a glow-off server that holds for every machine with the server's half, which a joiner receives with the join settings (see "The other join path: settings" below; until thenSettingsMerge.ServerHalffalls back to the joiner's own copy of the server setting). So in play the notice is no longer shown; it still goes out for a stroke from a caller that skips the preview, such as the test rig's/player/use. The rule is covered offline byTestRig/src/TestRig.Tests/Mods/GlowRefusalNoticeTests.cs, which compiles the shipped file, and the check above now asserts no notice for the loaded stroke and exactly one once the can is taken out of the gun; that check had not run on the rig with this change when this was written, on 2026-09-28. -
GlowStateMessage, server to clients. Not in a release yet (0b1c05a5, targeted at v1.13.0). Batched: one message covers any number of Things, so a network flood that lights 169 rails is one message and not 169.GlowSyncSender.Recordaccumulates andThingAttackWithGunPatchflushes at the end of the gun stroke, which is the only window in which glow state can change at all. The batch is emptied whether the send succeeded or not, so a failed send cannot re-send stale ids on the next stroke, andCleanupPatchesresets it when the session ends mid-stroke. -
SprayCanColorSyncMessage, server to clients. Not in a release yet (0b1c05a5, targeted at v1.13.0; the per-entryRequestedByandRequestsince6c0b7af7). Unbatched on the live path, because a colour change is one can at a time; the join snapshot is the one caller that sends more than one entry. An entry that answers aSprayCanColorMessagecarries the requester'sClientId, which the server takes from the connection the request arrived on, and the request's number; the join snapshot and a host's own scroll or pick carry 0 for both, and every client applies those. A receiving client skips an entry with a negativecolorIndex, leaving that can as it is, but no send path writes one:CanColourSyncSender.Send1returns without sending whencolorIndex < 0, and the join snapshot skips negative rows.CanColourSyncSender.NoColour(-1) is declared and used nowhere; writing it when a can's colour could not be read was what the v1.12.1 stream sync did (CanColourSyncGuard.ColorIndexFor). The receive side deliberately does not consultDlcPaintGate: the server already validated the colour, so re-checking on the receiving client would test that client's entitlement instead of the session's and make a legitimately obsidian can render differently per player.
Both server-to-client sync messages refuse to apply anything on the authority (if (!NetworkManager.IsActive || NetworkManager.IsServer) return; in ProcessCore), so a modified client cannot push glow or a colour into a session by sending one the wrong way up the wire.
The server checks who sent a preference mask¶
Not in a release yet. This check landed in commit df406e15 (Mods/SprayPaintPlus/SprayPaintPlus/PaintModifierMessage.cs, 2026-09-27) and is targeted at v1.13.0. The published build, v1.12.1, and every commit before it store any mask under whatever PlayerHumanId it names; their ProcessCore() is if (PlayerHumanId == 0) return; SprayPaintHelpers.PlayerModifiers[PlayerHumanId] = Modifiers;. On those builds a modified client can overwrite another player's row, and with it that player's single-item and checkered keys, network painting toggles, glow and unlimited uses.
In df406e15, Process(long hostId) hands LaunchPadBooster's id to ProcessCore(long hostId) (135-169), which keeps the PlayerHumanId == 0 early return and then stores the mask only when the named Human belongs to the connection the message arrived on:
Client sender = Client.Find(hostId);hostIdis along, so this is the connection-id overload, which is the kind of id LaunchPadBooster passes. No suchClient: rejected.sender.ClientId == 0UL: rejected. Every unowned Human carries owner 0, so a zero id must never count as a match.Thing.Find(PlayerHumanId) as Humanis null: rejected.claimed.OwnerClientId != sender.ClientId: rejected.- Otherwise
SprayPaintHelpers.PlayerModifiers[PlayerHumanId] = Modifiers;as before.
A rejected mask is logged and never applied, through SprayPaintPlusPlugin.Log?.LogWarning in the form Rejected preference mask from host <connection id>: <reason>, ignoring. Each connection gets at most PatchGuard.MaxReportsPerSite (3) such lines, counted in a static Dictionary<long, int> keyed by connection id; the third ends , ignoring. No more notices about rejected preference masks from this connection. and later rejects from that connection are silent. The four reasons:
no connected player is on that connectionthe sender has no client idit names <id>, which is not a player character hereit names Human <id>, which belongs to client <owner>, not to the sender, client <sender>
Why the check holds, all verified against game 0.2.6428.27798 and LaunchPadBooster 0.2.0:
- LaunchPadBooster passes
Processthe id of the connection the packet arrived on, only after it has marked that connectionReady, and for an honest joiner that id is theconnectionIdof the joiner'sClientrecord. See LaunchPadBoosterNetworking, "WhereProcess's id comes from, and why a vanilla message has none", and Client, "From a connection to a player's character". A vanilla message could not do this: on a server itshostIdis always -1. Client.Find(long)(Assembly-CSharp 217760-217774) checksNetworkManager.HostClientand thenNetworkBase.Clients, and a joiner enters that list only whenNetworkServer.VerifyConnectionaccepts it.- The ownership comparison is the game's own pairing:
Human.CreateCharactersetsOwnerClientIdfrom the client id the joiner presented (Human, "Ownership: OwnerClientId, CreateCharacter and respawn"). - It compares on the claimed Human instead of looking up the sender's Human with
Human.Find(sender.ClientId), because a player who has respawned owns more than one Human until the old body decays, andHuman.Findreturns the dead one first. That lookup would reject an honest respawned player. - The host's own mask never goes through the message.
ColorCyclerPatch(275-292 atdf406e15, 293-310 on master at559f8d86, 297-319 atfdcffdc5) writes the local player's row intoPlayerModifierson the player's own peer and sends the message only whenNetworkManager.IsClient && !NetworkManager.IsServer, so a host's or single player's row is written directly.
What it does not cover:
- It is exactly as strong as the join. A client that presents another player's
ClientIdat the handshake is that player to the server, and this check cannot tell them apart. - Painting still trusts the message body. Vanilla
AttackWithMessage.Process(Assembly-CSharp 283200-283211) acts forThing.Find(AttackParentId)without checking who sent it, andPaintAttackerTracker_Remoterecords that id as the painter (NetworkPainterPatch.cs:122). A modified client can therefore still paint as another player, with that player's hands and that player's stored mask. What the check stops is changing the mask the other player's own strokes use.
A restarted server gets the mask back¶
Not in a release yet. This is in commit fdcffdc5 on master (Mods/SprayPaintPlus/SprayPaintPlus/PreferenceMaskReports.cs, and ColorCyclerPatch.cs 265-320), and is targeted at v1.13.0. In v1.12.1, and on master up to 559f8d86, after a server restart the server reads a rejoining player as allowing everything until they press Shift or Ctrl.
On those builds ColorCyclerPatch.SendModifierStateIfChanged returns early when the player's own SprayPaintHelpers.PlayerModifiers[localHuman.ReferenceId] row equals the packed mask (item 2 above), and a client never drops that row. A server whose process restarted (a dedicated server into its save, a listen host relaunched) starts with an empty PlayerModifiers. The save gives the returning player the same Human ReferenceId (see PlayerIdentityAcrossRejoin, "When the server's process restarts under a remote player"), the client's row still matches, and nothing is sent. SettingsMerge.PlayerPrefs.Has then reads each of that player's client-half bits as allowed (an absent row returns true), and NetworkPainterPatch reads its modifier bits as 0 (TryGetValue leaves modifiers at 0). So Paint Single Item By Default and every client-half opt-out are ignored until the player presses Shift or Ctrl, which changes the mask, or respawns, which gives them a new Human id. Measured on the rig on 2026-09-27, on a build without fdcffdc5 (Release DLL sha256 56763957), in the dedicated-server cell of removing the last glow leaves no side-car (lock owner d718eb1f), which went inconclusive for want of the mask: before the restart the joiner and the server both held 32760 for Human 509126; after it the joiner still held 32760 for 509126, and through 15 s of reads that began 3 s after a spray gun was put in the joiner's hand, the server held no row. The server's log has no Rejected preference mask line, so no mask arrived and was refused. A listen host that relaunches has the same hole by the same code, measured on 2026-09-28 on the same build by the check named below, in its JLH cell (lock owner f2e73443): the joiner went to its menu, the host relaunched into the save, and the joiner joined again as the same Human, 509317. Before the restart both held 32509; after it the joiner still held 32509 and the host held no row, and still none after 30 s with a can in the joiner's hand, while the joiner's InventoryManager.NormalMode was shown running. The check's JDS cell on that build (lock owner 92eb3089) read the same across a dedicated-server restart: Human 509119 kept, 32509 on the joiner, no row on the server after 30 s.
In fdcffdc5 the early return asks PreferenceMaskReports.IsDue(localHuman, humanId, prefs) instead. It is true when the mask or the Human id differs from the last report this process made, or when the controlled Human object is not the object that report was made for (held through a WeakReference). PreferenceMaskReports.Reported records the report only after SendToHost returns, so a send that threw is tried again on the next frame. Every join builds a new Human object on the client, so the first frame of every session with a spray can or spray gun in hand reports; after it, only a change does, as before. The local row is still written before the send, LeaveGameResetPatch does not reset the new record (see GameManager, "Leaving a session: LeaveGame returns before the world is gone", for why a reset there could be spent before the old world is gone), and nothing on the server changed: the sender check above stands, and a row is still not pruned when its player disconnects. Covered offline by TestRig/src/TestRig.Tests/Mods/PreferenceMaskReportsTests.cs, which compiles the shipped file, and by the playtest a joiner's own paint preferences still apply after the server restarts (Mods/SprayPaintPlus/playtests/JoinerPreferencesSurviveAServerRestart.cs, cells JLH and JDS). Driven offline against a scripted model of both builds (ServerRestartPreferenceCheckTests), that check fails on the earlier one at the authority's row and passes on fdcffdc5, and the rig agrees: it failed in both cells on the earlier build, as above, and passed in both on fdcffdc5's build (Release DLL sha256 c0c00c18) on 2026-09-28. In JLH (lock owner 0b96f6c2) the relaunched host held no row for the returning Human 509313 at the first read after the rejoin, and 32509, the joiner's mask, at the next read 6 s later, the first with a can in the joiner's hand. In JDS (lock owner a10f3992) the restarted server already held 32509 for Human 509123 at the first read, 1 s after the rejoin. In both cells the strokes that read the row then passed: a single-item stroke painted one ceiling panel of room 35 and none of the other 24, and once the preference was cleared a stroke flooded all 25. The dedicated-server cell of removing the last glow leaves no side-car, inconclusive on the earlier build, passed on c0c00c18 the same day (lock owner bb72b340): its joiner kept Human 509126 across both of the cell's server restarts, and after the first both processes held 32760 for it, read within 3 s of a spray gun being put in the joiner's hand.
Sync flow for colour changes¶
Steps 1, 2 and 4 are as shipped. Steps 5 and 6 changed in 0b1c05a5, and steps 3, 5 and 6 gained request numbers in 6c0b7af7; none of that is in a release yet, and the v1.12.1 behaviour is given after the list.
- Client detects scroll in
ColorCyclerPatch.Prefix(itsWheelStage; an eyedropper pick,HandleEyedropper, joins the same flow at step 2). - Client updates the spray can's visual locally (immediate feedback).
- Client sends
SprayCanColorMessageto the server. Since6c0b7af7the message carries this player's next request number for that can. - Server validates, applies the colour via
SprayPaintHelpers.UpdateSprayCanServer, which sets the visual on the server. A refusal instead re-applies the can's current colour the same way. UpdateSprayCanServercallsCanColourSyncSender.Send1, which broadcasts a one-entrySprayCanColorSyncMessageto every connected client. Since6c0b7af7the entry names the request it answers: the requester'sClientIdand the request's number.- Each client's
SprayCanColorSyncMessage.Processapplies the colour throughUpdateSprayCanVisual. Since6c0b7af7the client that asked skips an answer when it has sent a newer request for that can since.
In v1.12.1, step 3's message carried no number, and steps 5 and 6 were instead: UpdateSprayCanServer raised NetworkUpdateFlags |= 0x1000; on the next tick Consumable.BuildUpdate fired and ConsumableBuildUpdatePatch appended the colour index to the stream; every client's ConsumableProcessUpdatePatch read it back and applied it, with nothing to say who had asked.
There is deliberately no same-colour guard on the broadcast. An unchanged index still goes out whenever sync is running, because SprayCanColorMessage's reject paths depend on re-asserting a can's current colour to snap an optimistic client back. Adding the guard would silently break rejection.
Before the request numbers: the sender applied its own broadcast, and a fast scroll lost notches¶
Measured on unreleased builds: the message-based sync of commit 0b1c05a5 (steps 5 and 6 above) through df406e15, the last commit to change the mod's code before the fix. Commit 6c0b7af7 fixes it, also unreleased; see the next section. Whether v1.12.1's NetworkUpdateFlags path loses notches the same way was not measured.
SprayCanColorSyncMessage goes out with SendAll(0L) (CanColourSyncSender.SendCore), which excludes nobody, so the client that asked for the colour receives it back too, and on those builds ProcessCore applied every entry through UpdateSprayCanVisual, which writes SprayPaintHelpers.SprayCanColors[can]. The sender has already applied that colour optimistically in step 2, and ColorCyclerPatch.WheelStage computes each notch from the row it holds at that moment (previous = GetSprayCanColorIndex(sprayCan), then NextColorInCycle(previous, ...)). So when notches came faster than the round trip to the server, the echo of an earlier notch landed after a later one and put the row back, and the next notch stepped from the stale value: those notches were lost, on the sender and, because every notch sends its absolute result, on the server as well.
Measured on the dedicated server, a joiner scrolling 12 notches one frame each with 3 frames between them, from ColorBlue (swatch 0), with nothing gated (the session pool held Metallic Paints, colour cycling AllColors on both halves):
| Arrangement | Client frame rate, one notch every | Where the can ended, joiner and server alike |
|---|---|---|
| A fresh world, the joiner standing, 49 s or more after its join | about 150 fps, about 27 ms | 8, 9 (two runs), and 9 again seated in its capsule 25 s after the join |
| The same, first seconds after the join, seated in the capsule | about 150 fps, about 27 ms | 9 |
| Two playtest cells of that shape, run twice each | about 150 fps | 9, 8, 9, 8 |
| The committed fixture world, standing | about 50 fps, about 80 ms | 12, four runs out of four |
| A fresh world, 6, 10 and 20 frames between notches | about 150 fps, 47 ms and slower | 12, one run each |
A fresh world, 3 frames between notches, with the joiner's SprayCanColorSyncMessage.ProcessCore made to throw (the rig's fault injection) |
about 150 fps, about 27 ms | 12, two runs out of two; the fault fired 24 times, one echo per notch |
The last row is the proof of mechanism: with nothing else changed, stopping the sender from applying the echoes of its own requests is what makes all twelve notches land. The seat, the moment in the join and the join-time state do not matter: seated and standing lose the same notches at the same cadence, and a joiner long past its join loses them too.
A player's wheel generates notches at that kind of rate on a quick spin, and the round trip to a real server is longer than the rig's loopback, so the loss was not confined to test input. Nor to the dedicated server: the check in the next section, run on a build from before the fix, lost a notch on a joiner of a listen host as well.
Request numbers: the player who asked skips the answers it has overtaken¶
Not in a release yet. The request numbers landed in commit 6c0b7af7 (2026-09-27) and are targeted at v1.13.0, on top of the message-based sync of 0b1c05a5, which is unreleased as well. The published v1.12.1 has none of this: its SprayCanColorMessage carries SprayCanId and ColorIndex only, the server answers by raising NetworkUpdateFlags bit 12 on the can, and a client's ConsumableProcessUpdatePatch.Read applies whatever colour it reads through CanColourSyncGuard.Apply. Whether v1.12.1 loses notches is the first open question below.
Every colour request a remote player sends carries a number, and every answer the server gives names the request it answers, so the player who asked can tell the answer to its latest request from the answers to the ones before it, and skip those. Nothing else about the flow above changed.
On the wire. SprayCanColorMessage gains an Int32 Request after ColorIndex: { SprayCanId: Int64, ColorIndex: Int32, Request: Int32 }. Every SprayCanColorSyncMessage entry gains a UInt64 requester and an Int32 request number after its colour: { Count: Int32, then Count x { ReferenceId: Int64, ColorIndex: Int32, RequestedBy: UInt64, Request: Int32 } }. An entry that answers a request carries the requester's ClientId and the request's number; an entry no request caused carries 0 for both (CanColourRequests.Unnumbered is 0). The message holds the two as lists paired by index with Ids, RequestedBy (List<ulong>) and Requests (List<int>), filled by Add(long id, int colorIndex, ulong requestedBy = 0UL, int request = CanColourRequests.Unnumbered). SerializeCore writes 0 for an entry past the end of either list (RequestedByAt, RequestAt), so a caller that fills only Ids and ColorIndexes sends unnumbered entries rather than a shorter message.
Numbering, on the player's side. CanColourRequests (CanColourRequests.cs, no game types, client-side state only) keeps Dictionary<long, int> Latest, the number of the latest request this player has sent for each spray can ReferenceId. The wheel (WheelStage) and the eyedropper (HandleEyedropper) both ask through one helper, under the same if (NetworkManager.IsClient && !NetworkManager.IsServer) as before:
private static void RequestColour(SprayCan sprayCan, int colorIndex)
{
long canId = sprayCan.ReferenceId;
int request = CanColourRequests.Next(canId);
new SprayCanColorMessage
{
SprayCanId = canId,
ColorIndex = colorIndex,
Request = request,
}.SendToHost();
// Recorded once the message is out. A send that threw put nothing on the wire, so
// there will never be an answer to it, and the answers to the requests before it
// must still count.
CanColourRequests.Sent(canId, request);
}
Next is the latest number plus one, so a can's first request is 1; it saturates at int.MaxValue rather than wrapping, and every request past that would share the top number, whose answers all apply. Sent ignores a number at or below Unnumbered and only ever raises the record. A can's count is dropped when the can is destroyed (ThingDestroyCleanupPatch, a postfix on Thing.OnDestroy, calls CanColourRequests.Forget), and every count when this machine leaves the world (LeaveGameResetPatch on GameManager.LeaveGame calls CanColourRequests.Reset()), so no count carries into another world, where it would have the player skip the first answers about a can that happens to reuse the reference id.
Answering, on the server. SprayCanColorMessage.ProcessCore(long hostId) works out who asked right after it has found the can and before any gate, from the connection and never from the message body:
ulong requestedBy = 0UL;
int request = CanColourRequests.Unnumbered;
if (Request > CanColourRequests.Unnumbered)
{
Client sender = Client.Find(hostId);
if (sender != null && sender.ClientId != 0UL)
{
requestedBy = sender.ClientId;
request = Request;
}
}
hostId is a long, so this is the connection-id overload of Client.Find, the same pairing the preference mask check under "Message types" relies on. A request with no number (which the mod's own client never sends), or from a connection the server cannot place, is answered unnumbered, and every receiver applies that, as every answer was applied before the numbers existed. Every answer carries the pair: the accepted colour through SprayPaintHelpers.UpdateSprayCanServer(sprayCan, ColorIndex, requestedBy, request), and each of the four reject paths (index out of range, the DLC gate, a server on CannotChange, a server on WithinFamily asked for another family) through Reassert(sprayCan, requestedBy, request), which re-broadcasts the can's current server colour under the refused request's number. The four-argument UpdateSprayCanServer hands both to CanColourSyncSender.Send1, which still broadcasts with SendAll(0L). Nothing else is numbered: the two-argument UpdateSprayCanServer, which a host's or a single player's own scroll and pick use, forwards 0UL, CanColourRequests.Unnumbered, and the join snapshot adds its entries with message.Add(row.Key, row.Value).
Receiving: the one entry a client skips. SprayCanColorSyncMessage.ProcessCore still refuses on the authority and still skips a negative index; then, before it resolves the can, it skips every entry for which CanColourRequests.IsStale(Ids[i], RequestedByAt(i), RequestAt(i), self) holds, with ulong self = NetworkManager.LocalClientId:
internal static bool IsStale(long canId, ulong requestedBy, int request, ulong self)
{
// A change no request caused, or one from a sender the server could not place.
if (request <= Unnumbered || requestedBy == 0UL)
return false;
// Somebody else's request: the filter is only ever for the player who asked.
if (requestedBy != self)
return false;
// No request of ours on record for this can (a can this player never asked about, or
// one it asked about before it last left a world): nothing to have overtaken.
if (!Latest.TryGetValue(canId, out int latest))
return false;
return request < latest;
}
The player who asked skips the answers to its own requests that are older than its latest request for that can, and applies the answer to its latest one (request == latest is not stale). Every other client applies every entry, and so does the requester for an unnumbered one. The two ids agree for an honest client: NetworkManager.LocalClientId is Cookie?.ClientId ?? 0 (Assembly-CSharp 278358), the id a joiner presents in VerifyPlayer, and NetworkServer.VerifyConnection (219435-219437) builds the server's Client for that connection from it (new Client(hostId, msg.OwnerConnectionId, msg.ClientId, msg.Name, msg.ClientConnectionMethod)). See Client, "From a connection to a player's character", step 2. Like the preference mask check, it is exactly as strong as the join.
Why that is enough. On the RakNet transport a player's requests and the server's answers are LaunchPadBooster messages, ReliableOrdered on one ordering channel (LaunchPadBoosterNetworking, "Wire format: TypeID, channel, compression, size ceiling, and the shared writer"). So the server judges one player's requests in the order they were sent and broadcasts each answer as it judges the request, and the answers reach that player in the same order: the answer to the latest request arrives last, is never stale, and is applied, whether it accepts the colour or snaps the can back. The player and the server therefore end on the same colour, and with nothing refused it is the colour of the last notch.
A refusal followed by newer notches. A snap-back carries the refused request's number like any answer, so a player that has made a newer request for the can by the time it arrives skips it, and its wheel keeps stepping from the colour it was showing rather than from the colour the server kept. That cannot put a colour past the server: each later request is judged again against the server's own current colour (under WithinFamily, DlcPaintGate.SameFamily(currentIndex, ColorIndex) with currentIndex read on the server), and the player ends on the server's answer to its last notch, the colour the server accepted or the colour it kept. A refused last notch still snaps back, because its answer is the answer to the latest request. Every other player skips nothing, and sees each snap-back and each accepted colour in turn.
What did not change. SendAll(0L) still reaches every client, the player who asked included, which is what lets the answer to its latest request snap it back after a refusal. There is still no same-colour guard. The receive side still consults neither DlcPaintGate nor any setting. A host and a single player still apply their own scroll and pick directly, send no request, and broadcast unnumbered.
Evidence. The playtest check a remote player's fast scroll lands on the last notch (Mods/SprayPaintPlus/playtests/RemoteFastScrollLandsOnTheLastNotch.cs) puts a fresh ColorBlue can (swatch 0) in a remote player's hand, scrolls it eleven one-frame notches three frames apart, and requires SprayPaintHelpers.SprayCanColors to read 11 on the authority, on the scroller and, on a dedicated server, on a second joiner watching. Eleven rather than twelve keeps every colour on the way in the base family, so neither the entitlement pool nor either permissive cycling mode can move the answer. A run whose notches came 47 ms apart or slower ends inconclusive instead of passing, because the build before the fix landed those too. The check runs where the scroller is remote, as a joiner of a listen host and as a joiner of a dedicated server; a listen host's own scroll sends no request, so that session is skipped. On the rig, 2026-09-27:
| Build | Scroller | One notch every | Readings |
|---|---|---|---|
before the fix (Release DLL sha256 38571c5c) |
joiner of a listen host | 25.7 ms | listen host 10: fail |
before the fix (38571c5c) |
joiner of a dedicated server | 26.8 ms | server 8: fail |
6c0b7af7 (Release DLL sha256 9d30be4f) |
joiner of a listen host | 26.7 ms | listen host 11, scroller 11: pass |
6c0b7af7 (9d30be4f) |
joiner of a dedicated server | 27.1 ms | server 11, scroller 11, watching joiner 11: pass |
The two entitlement checks that had failed on this defect while appearing to blame entitlement, a non-owner reaches metallic while the owner is connected and the entitlement outlives the owner (twelve notches landing on 9 and 8 in their dedicated-server cells), now scroll one notch at a time, twenty frames apart, so they measure entitlement alone; both pass on the dedicated server on 6c0b7af7.
The rule itself is tested offline in the rig suite, which compiles the shipped CanColourRequests.cs (TestRig/src/TestRig.Tests/Mods/CanColourRequestsTests.cs): each branch of Next, Sent, IsStale, Forget and Reset; simulated sessions over 16 swatches with links of 15 to 25 ms each way, where twelve notches 27 ms apart land on 12 for the scroller, the server and a watcher; the refusal cases above, from Pink (swatch 10) against a server that refuses a change of family, where six notches (11 taken, 12 to 15 refused, 0 taken) end on 0 for everyone and three notches (11 taken, 12 and 13 refused), 27 or 80 ms apart, end on 11 for everyone; a seeded fuzz of 2,000 sessions in which every player ends on the server's colour; and the three wrong forms of the rule (never skip, skip the latest answer too, skip by number whoever asked), each shown landing where the shipped rule does not. A fast scroll through a refusal has not been run in the game as of 2026-09-27; only the simulated sessions cover it.
Known limits.
- It relies on in-order delivery. That holds on RakNet (above). Whether the Steam P2P transport delivers a player's mod messages in order has not been verified, and no playtest has reached it: the playtest harness joins by address and port over loopback, and the connection ids such joins produce are RakNet ids (Client, "
connectionIdis a RakNet id and does not fit anint"). If a player's requests could arrive out of order, the server could end on an older colour while the player skips that older answer and keeps showing the newer one. See Open questions. - The join snapshot answers no request, so it can still undo notches. Its entries carry no number, so a joiner applies them over anything it has asked for.
NetworkClient.ProcessJoinDataawaitsRequestCharacterAsync(218877), which ends inTakeControl, where the joiner takes control of its character (playerBrain.TakeControl(setPhysics: false), 218955-218983), and it setsGameManager.GameState = GameState.Running(218882) before it sends the ready report (UpdateHandshakeState(HandshakeType.ClientReady), 218890). So a joiner already controls its character when it reportsClientReady. The report goes out throughSendToServer, whose default channel isGeneralTraffic(218657-218664, 218920-218931), and on RakNet that shares ordering channel 0 with the mod's own messages, so every request the joiner sends after the report reaches the server after it. The server builds the snapshot while handling the report, so it holds the colours from before any of those requests, and it reaches the joiner ahead of their answers. A joiner that scrolls an already recoloured can in that round trip therefore has its optimistic colour put back to the snapshot's. If it stops there, the answer to its latest request arrives after the snapshot and settles the can. If it scrolls on before that answer arrives, its next notch steps from the snapshot's colour, and every notch it made in the round trip is lost, on the joiner and the server alike. That is at most one round trip of notches, once per join, and only on a can that was already recoloured; it is one notch when the round trip holds only one, and more when the scroll is faster. Traced from the source, not measured; the rig suite's simulated sessions do not model a join. Left as it is by decision.
Sync flow for painting¶
- Client attacks a structure with a spray can (vanilla input).
- Vanilla sends
AttackWithMessageto the server. - Server-side tracker prefix captures the Human ReferenceId.
- Vanilla calls
OnServer.SetCustomColorfor the targeted item. NetworkPainterPatch.Prefixintercepts, looks up modifiers for the painter, and paints the network/room/grid.- Each painted item's
SetCustomColorsets its ownNetworkUpdateFlags, broadcasting the color change to all clients through vanilla's update tick.
Step 6 is vanilla's own colour sync on bit 32 (CustomColor plus CustomName) and is unaffected by the mod's move off bits 12 and 13. Glow, which the same stroke can change, is what moved: see the join push below and ../GameSystems/NetworkUpdateFlags.md.
Join-time state push¶
Not in a release yet (0b1c05a5, targeted at v1.13.0).
NetworkServerHandshakeJoinStatePatch, a Harmony postfix on NetworkServer.Handshake, fires when handshake.HandshakeState == HandshakeType.ClientReady and pushes two point-to-point snapshots to the joining client with SendToClient: every glowing Thing's reference id (PushGlow), and every spray can whose colour the mod has changed (PushCanColours). Each is skipped when its feature failed its start-up check (SyncVerdict.GlowSync / SyncVerdict.CanColourSync), and the whole push is skipped for the host's own client.
Two design points, both load-bearing:
- Why the handshake and not the join payload. LaunchPadBooster's
IJoinSuffixSerializerwas the obvious home and is the wrong one, because the join payload is packaged once, atNetworkServer.PackageJoinData, and a large world then takes seconds to compress, send, decompress and rebuild. Glow painted inside that window would be missing from the snapshot and missed by the broadcast, because a client that has not finished joining is not inNetworkBase.ClientsandSendAllskips it.HandshakeType.ClientReadyis sent at the very end ofNetworkClient.ProcessJoinData, afterProcessThings, after the character request, afterGameManager.OnReadyToPlayand afterUpdateThingsOnGameStarthas calledOnFinishedLoadon every Thing, so by then the client has every Thing the snapshot can name. - The old arrangement did not have that hole, and only by accident.
Thing.SerializeOnJoinraisesNetworkUpdateFlags |= ushort.MaxValue, so the first delta tick after a join re-sent every Thing's full state, mod bytes included, and quietly healed anything missed. A mod moving to its own message type loses that accident and has to push join state deliberately. See../GameSystems/NetworkUpdateFlags.md, "Every bit is set on every Thing after every join".
A can with no row in SprayCanColors is deliberately left out of the snapshot rather than sent: with no row both peers fall back to the can's own PaintMaterial through GetSprayCanColorIndex and reach the same answer. The old join postfix wrote a value for every SprayCan in the world whether it had ever been recoloured or not.
The glow snapshot is unbounded on purpose. LaunchPadBooster compresses above 960 bytes and splits into as many packets as it takes up to its 1 MiB ceiling, which this payload would need roughly 116,000 glowing objects to reach; past that the writer throws inside Serialize, the throw reaches the entry point's catch, and the joiner gets no glow snapshot rather than a truncated one.
The other join path: settings¶
Separate from the state push and not to be conflated with it: SettingsConfigSync.Instance is assigned to MOD.Networking.JoinSuffixSerializer (Plugin.cs:197) and carries the fifteen server-half settings to a joining client through LaunchPadBooster's IJoinSuffixSerializer, a different interface on a different part of the join. Settings are fixed for the session (the StationeersLaunchPad panel opens only at the main menu), which is exactly why the payload is the right place for them and the wrong place for glow.
Wire compatibility¶
Not in a release yet (0b1c05a5 and 6c0b7af7, targeted at v1.13.0).
0b1c05a5 is a declared wire break: a client on that build and one on v1.12.1 cannot play together. MOD.Networking.Required only rejects a mismatched version string, so the release carrying this MUST bump PluginVersion, which still reads 1.12.1 at commit 6c0b7af7. Tracked in the mod's TODO.md and PLAYTEST.md.
6c0b7af7 changes both colour messages again, inside the same break: the request grows by 4 bytes (Int32 Request) and every sync entry by 12 (UInt64 requester, Int32 request number), from 12 bytes to 24, under unchanged TypeIDs. Builds on either side of that commit therefore do not share a colour message layout either, and since both report 1.12.1, nothing at the join tells them apart. Neither side is released, and the one version bump covers both changes.
Adding messages is safe in a way renaming them is not. A LaunchPadBooster message's wire id is TypeID(mod.Hash, Animator.StringToHash(typeof(T).FullName)), computed from the type's full name rather than from registration order, so appending types 4 and 5 cannot renumber types 1 to 3. Renaming any of the five, or moving one to another namespace, is a silent wire break. See ./LaunchPadBoosterNetworking.md.
Verification history¶
- 2026-04-20: page created from the Research migration. Sources: F0018 (message types), F0019 (color-change sync flow), F0020 (painting sync flow).
- 2026-09-24 (game version 0.2.6428.27798): conflict on "how many
INetworkMessagetypes Spray Paint Plus registers and in which direction they travel". Previous claim, in "Message types": "SprayPaintPlus ships two custom LaunchPadBoosterINetworkMessagetypes (client-to-server)", listingSprayCanColorMessageandPaintModifierMessage, "both client-to-server". New finding: five types, two client-to-server and three server-to-client. Fresh validator verdict: B is correct, quotingPlugin.cs:178-180and:188-189(the fiveMOD.Networking.RegisterMessage<T>()calls) and establishing each direction from its send site rather than its name, includingColorCyclerPatch.cs:175-181(SendToHostforSprayCanColorMessage) andSettingBlockedNotice.cs:176(SendToClient). It confirmed that a grep forINetworkMessageacross the mod folder returns exactly those five classes. Result: the section is now a five-row table plus per-type notes. Two of the five predate this page's only prior verification pass and were simply never recorded:SettingBlockedNoticeshipped in v1.11.0 (b81d271c, 2026-07-27), andPaintModifierMessage.Modifiersis aUInt16where the page said a byte. The other two,GlowStateMessageandSprayCanColorSyncMessage, are new in0b1c05a5and unreleased, and carry the marker. - 2026-09-24 (same pass, additive): added "Join-time state push", "The other join path: settings" and "Wire compatibility"; corrected steps 5 and 6 of the colour-change flow and kept the v1.12.1 behaviour beside them, since v1.12.1 is what players are running; annotated step 6 of the painting flow to say that the bit it relies on is vanilla's own bit 32 and did not move. Bumped
verified_into 0.2.6428.27798 andverified_atto 2026-09-24, added two sources lines and arelatedlink to../GameClasses/SprayCan.md. The "Sync flow for painting" stamp stays at 0.2.6228.27061: its six steps were not re-verified against the current source this pass, only annotated. - 2026-09-25 (game version 0.2.6428.27798): added "The server checks who sent a preference mask" under "Message types", describing the sender check on
PaintModifierMessagethat exists only in the working tree (uncommitted, as of 2026-09-25), with the "Not in a release yet" marker at the section, a marker in item 2 of the list, and a sentence in the page-top marker. The shipped form was read from tagmods/SprayPaintPlus/v1.12.1, andgit diffshows the file unchanged between that tag and HEAD. The game and LaunchPadBooster facts the check relies on were verified in the same pass and are recorded on LaunchPadBoosterNetworking, Client and Human;AttackWithMessage.Process(283200-283211) was read to confirm the remaining gap. Additive: the shipped behaviour the list describes is unchanged, so no fresh validator; the "Message types" stamp is not moved because its own content was only annotated. Remove the markers, and record the removal here, when a release carries the check. - 2026-09-27: the preference-mask sender check was committed as
df406e15after a 76-of-76 playtest pass on that build; the markers now name the commit instead of the working tree. Still not in a release (targeted at v1.13.0). No content re-verified. - 2026-09-27 (game version 0.2.6428.27798): added "The sender applies its own broadcast, and a fast scroll loses notches" under "Sync flow for colour changes", from the mod source at
66a77132and hand-driven and playtest runs on the dedicated server the same day, with the unreleased-build marker at the section; added an open question about v1.12.1. Additive: nothing the page already said changes, the section describes a consequence of steps 5 and 6. Found while triaging two failing dedicated-server cells ('a non-owner reaches metallic while the owner is connected' and 'the entitlement outlives the owner'), which scroll 12 notches and landed on 9 and 8. - 2026-09-27:
relatedlisted../GameClasses/Consumable.md, a page that has never existed (the entry dates from the 2026-04-20 migration, and no commit ever added that file). Replaced with../Patterns/BinaryStreamSafety.md, the page that documents the append after the vanillaConsumabledata inBuildUpdate/ProcessUpdatethat the v1.12.1 colour sync used (see "Sync flow for colour changes"). TheConsumablebit usage itself is onNetworkUpdateFlags.md, already listed. Link fix only; no stamp changed. - 2026-09-27 (game version 0.2.6428.27798): the fast-scroll notch loss is fixed in commit
6c0b7af7, which is not in a release either. Read from the committed source at6c0b7af7with a clean working tree:CanColourRequests.cs,SprayCanColorMessage.cs,SprayCanColorSyncMessage.csand their callers inColorCyclerPatch.cs,SprayPaintHelpers.cs,JoinStatePush.csandCleanupPatches.cs; the shipped forms from tagmods/SprayPaintPlus/v1.12.1; and, in the 0.2.6428.27798 decompile, the ClientId pairing (LocalClientId278358,VerifyConnection219435-219437), the join's ready report (ProcessJoinData218877-218890,TakeControl218955-218983,SendHandshakeMessageandSendToServeronGeneralTraffic) and the Steam P2P send (279630-279646, with Facepunch'sSendP2PPacketdefaults at 12420). Added "Request numbers: the player who asked skips the answers it has overtaken", with the marker at the section, covering the two message changes, the numbering and its lifetime, the server taking the requester from the connection, theIsStalerule verbatim, why ordered delivery makes it sufficient, the refusal case, what did not change, the evidence (the new check failed in both of its sessions on the build before the fix, a listen host reading 10 of 11 and a dedicated server 8, and passes in both on6c0b7af7; the entitlement checks pass with one notch at a time; the rig suite'sCanColourRequestsTests) and two known limits. Retitled the defect section to the past tense and named the commits it describes (0b1c05a5throughdf406e15); its measurements and table are unchanged. Updated rows 1 and 5 and items 1 and 5 of "Message types", the lead and steps 3 to 6 of "Sync flow for colour changes", "Wire compatibility" (both messages grew, same break, same bump) and the page-top marker. Re-read the rest of those three sections against the same source (the five registrations and codecs, the glow batch and its flush and reset, both blocked-notice budgets, both authority refusals, the no-same-colour-guard paragraph, v1.12.1's bit 12 path) and restamped them. Two corrections while there: the page-top marker still said thePaintModifierMessagesender check existed only in the working tree, although the entry above records it as committed; it now namesdf406e15. And the join-snapshot limit is bounded by a round trip of notches, not by one notch as first drafted: traced through the source, a joiner already controls its character when it reports ready, and on RakNet the snapshot is built before, and arrives ahead of the answers to, every request sent after the report, so a joiner scrolling on through that round trip loses every notch it made in it, one or more. Added an open question on Steam P2P ordering and a sentence on the v1.12.1 question. The fix itself is additive: the pre-fix builds still behave as the defect section says, so no fresh validator for it. - 2026-09-27: conflict on "whether the send side of
SprayCanColorSyncMessageever writes a negativecolorIndex". Previous claim, item 5 of "Message types": a negative index "is what the send side writes when it cannot read a can's colour". New finding: no send path writes one, at0b1c05a5and at6c0b7af7alike;Send1returns whencolorIndex < 0, the join snapshot skips negative rows, andCanColourSyncSender.NoColouris declared and never referenced; writingNoColourwas what the v1.12.1 stream sync did. Fresh validator verdict: B is correct, quotingSprayCanColorSyncMessage.csat6c0b7af7, lines 219-222 (if (Send.Off || referenceId == 0 || colorIndex < 0) return;, the same guard at 154-157 in0b1c05a5), with none of the four callers ofUpdateSprayCanServerable to pass a negative index. It traced the old wording to two comments left over from v1.12.1 in the mod source, the doc comment onNoColourand the comment inSprayCanColorSyncMessage.ProcessCore. Result: item 5 now says a receiver skips a negative index and no send path writes one. - 2026-09-27: conflict on "when a remote client sends
PaintModifierMessage, and what its mask carries". Previous claim, item 2 of "Message types": "sent when modifier key state changes", with table row 2 calling the payload "the acting player's modifier-key mask" and item 2 saying the mask "carries the client half of the paired settings as well as the two modifier keys". New finding (from the Spray Paint Plus mask fix's source read,.work/2026-09-27-phase3-rig/M5-RESULT.mdsection 7.3 (b)): sent from a Harmony prefix onInventoryManager.NormalMode, only on a frame with a spray can or spray gun in the active hand, and only when the packed mask (the two keys, the single-item bit inverted by Paint Single Item By Default, and the client half of the paired settings) differs from the client's ownPlayerModifiersrow for its Human, or there is no row; so the first such frame with no row sends, then only a change does, and a rejoin under the same HumanReferenceIdsends nothing until the mask changes. Fresh validator verdict: "Neither, correct answer is" the new finding with two fixes, quotingColorCyclerPatch.cslines 274-289 at tagmods/SprayPaintPlus/v1.12.1(the dedupe, the row written before the send, the send only whenNetworkManager.IsClient && !NetworkManager.IsServer). The mask carries the client halves of 13 of the 15 paired settings, not all of them (Color Cycling and Color Picking are left out,SettingsMerge.cs171-190), and the send also needsInventoryManager.ParentHumanto be set; it added thatNormalModeitself runs only underInventoryManager.ManagerUpdate's gates, that a send that throws is not retried because the row is written first, and that the send logic is unchanged at HEAD. It traced the old wording to the migration-era design (commit39b89f9a), which had only the two key bits and a_lastSentModifiersfield forcing an initial send. Result: item 2 now gives the send conditions and the mask's bits from the verdict, and table row 2 describes the mask as the two keys and 13 client halves; section restamped. The validator also noted two stale comments in the mod source (SprayPaintHelpers.cssays rows are removed on disconnect;CleanupPatches.cssays clearing the table would resend every frame) and the same simplification in the mod's ownRESEARCH.md, all outsideResearch/and left alone. - 2026-09-27 (game version 0.2.6428.27798): added "A restarted server gets the mask back" under "Message types", with the not-in-a-release-yet marker at the section, in item 2 and in the page-top marker: the report the client owes on the first frame of every session, in commit
b9fce88don the local development branchphase3-m5, not yet on master. Read from the mod source atb9fce88d, at559f8d86and at tagmods/SprayPaintPlus/v1.12.1(SettingsMerge.PlayerPrefs.Hasreturningtruefor an absent row, andNetworkPainterPatchreading the modifier bits as 0), and from the rig run of 'removing the last glow leaves no side-car' in its dedicated-server cell (lock owner d718eb1f), where the joiner kept its mask row across the server restart while the server held none for 15 s. Drafted inM5-RESULT.mdsection 7.3 (b); changed while checking it: a respawn, which gives the player a new Human id, also ends the silence; the playtest's result on the earlier build is from an offline run against a scripted model of both builds, not from the rig; and the host bullet's line range now names three commits. Additive to the shipped behaviour, which item 2 describes. - 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): added to item 3 of "Message types" (
SettingBlockedNotice), under a not-in-a-release-yet marker, that from commit0b70e3e6on master the Glow Paint refusal notice skips a spray gun that holds a can (GlowRefusalNotice), with the rig reading that showed v1.12.1's rule telling a loaded gun whose stroke painted, and why no player's own click can send the stroke the notice is still sent for; added a sentence to the page-top marker. Drafted in.work/2026-09-28-landing/M6-RESULT.mdagainst the branch commitd869bee7; the mod source is identical at0b70e3e6, and it was re-read there, with the old condition read at tagmods/SprayPaintPlus/v1.12.1, at559f8d86and atfdcffdc5. Changed while checking it: the marker names0b70e3e6; the 2026-09-27 rig reading was on the Release build with sha25656763957, not on v1.12.1 itself, and says so; "a player's own click cannot send it" now names the mod's part of that (EffectiveGlowPainthands the preview to the base game on a machine that holds the server's half, and a joiner uses its own copy until the join settings arrive), which the draft left implicit; and the loaded strokes that paint nothing are named as not told. The game side of the claim is on OnServer, "Who calls AttackWith: one caller, behind the player's own preview", added the same day. Additive: the shipped notice the item describes is unchanged. Section restamped. The notice check has not run on the rig with0b70e3e6. - 2026-09-28 (game version 0.2.6428.27798): "A restarted server gets the mask back" now carries the rig results that replace "it had not run on the rig as of 2026-09-27" and "that was not measured". On a build without
fdcffdc5(Release DLL sha25656763957) the checka joiner's own paint preferences still apply after the server restartsfailed inJLHandJDSon 2026-09-28, the restarted server holding no row for the returning Human after 30 s with a can in the joiner's hand, which also measures the listen-host relaunch the section had called unmeasured; the side-car cell of 2026-09-27 is now named as the inconclusive run it was. Onfdcffdc5's build (Release DLL sha256c0c00c18) the same check passed in both cells on 2026-09-28, with the server holding the joiner's32509by the first read with a can in hand and the single-item and control strokes after it passing, and the side-car cell passed with both processes holding32760after the restart. Read from the evidence of those runs (restart.txt,reloads.txt, thePlayerModifiersreads and each cell's verdict). Additive; the source reading is unchanged. Section restamped; the markers stay, since neither fix is in a release.
Open questions¶
- Whether v1.12.1, which carried the can colour on
NetworkUpdateFlagsbit 12 through the game's delta tick, loses fast-scroll notches the same way. Neither measured nor read: whether the sender applied the delta for the can in its own hand was not checked. The answer decides whether the release that carries the request numbers of6c0b7af7fixes something players have met, or only something the unreleased message-based sync introduced. - Whether the Steam P2P transport delivers one player's mod messages in order, which the request numbers of
6c0b7af7rely on (see "Request numbers", under Known limits). It is verified for RakNet only. On Steam P2P,NetworkManager.SendDataToSteamP2PConnection(Assembly-CSharp 279630-279646) writes the channel into the payload and calls Facepunch'sSteamNetworking.SendP2PPacket(steamId, memoryStream.ToArray())with no channel and no send type, so Facepunch's defaults apply:SendP2PPacket(SteamId steamid, byte[] data, int length = -1, int nChannel = 0, P2PSend sendType = P2PSend.Reliable)(Facepunch.Steamworks.Win64 12420). Whether Steam keeps one peer'sReliableP2P packets in order was not verified, and no playtest joins over Steam P2P. - Whether
SprayCanColorSyncMessage's live path should batch the way glow does. It sends one can per message today, which is correct for a scroll notch but not obviously correct for an eyedropper sweep or any future bulk recolour. Not a defect, and not measured either way.