DLC gating¶
How the game decides whether a player may obtain DLC-locked content. Two managers cooperate: DLCManager holds the local player's Steam entitlements, SharedDLCManager holds the union of every connected player's entitlements for the current session. Enforcement happens at a small number of acquisition sites; there is no enforcement at the point where DLC-derived content is applied to an existing object.
This page exists because a mod that hands the player DLC-derived content through a path the game does not gate silently bypasses the entitlement check. The gap documented in "Where the game does NOT check" is the one that matters for mod authors.
DLCType enum¶
[Flags] public enum DLCType, backing store is used as a ushort on the wire:
[Flags]
public enum DLCType
{
None = 0,
Zrilian = 1,
HemDroid = 2,
HumanCharacter = 4,
CountryOveralls = 8,
BobbleHeadEva = 0x10,
IcarusSuit = 0x20,
BobbleHeadHard = 0x40,
BobbleHeadMarine = 0x80,
MetallicPaints = 0x100
}
DLCManager.AllDLC is the OR of every named value:
public static readonly DLCType AllDLC = DLCType.Zrilian | DLCType.HemDroid | DLCType.HumanCharacter | DLCType.CountryOveralls | DLCType.BobbleHeadEva | DLCType.IcarusSuit | DLCType.BobbleHeadHard | DLCType.BobbleHeadMarine | DLCType.MetallicPaints;
Steam app IDs per DLC¶
DLCManager.FetchOwnershipFromSteam maps each flag to a Steam app ID via NetworkManager.CurrentTransport.IsDlcInstalled(uint). DLCManager.GetStorePageLink(DLCType) returns the matching store URL.
| DLCType | Steam app ID | Store link |
|---|---|---|
HemDroid |
1038500 | https://store.steampowered.com/app/1038500 |
Zrilian |
1038400 | https://store.steampowered.com/app/1038400 |
HumanCharacter |
2089290 | https://store.steampowered.com/app/2089290 |
CountryOveralls |
2542990 | https://store.steampowered.com/app/2542990 |
BobbleHeadMarine |
3196220 | https://store.steampowered.com/app/3196220 |
BobbleHeadEva |
3166330 | https://store.steampowered.com/app/3166330 |
BobbleHeadHard |
3196210 | https://store.steampowered.com/app/3196210 |
IcarusSuit |
1149460 | https://store.steampowered.com/app/1149460 |
MetallicPaints |
4842920 | https://store.steampowered.com/app/4842920 |
The fallback link for an unmatched DLCType is https://store.steampowered.com/dlc/544550/Stationeers/.
DLCManager.GrantFullOwnership() sets _ownedDLC = AllDLC. It exists in the class but has no call site in DLCManager.Initialize, which calls FetchDlcOwnership() and therefore FetchOwnershipFromSteam() only.
DLCManager: local entitlements¶
private static DLCType _ownedDLC;
public static DLCType GetOwnedDLC() => _ownedDLC;
public static void Initialize() => FetchDlcOwnership();
private static bool CheckAccess(DLCType dlcType)
{
if (dlcType == DLCType.None)
{
return true;
}
return (dlcType & _ownedDLC) != 0;
}
public static bool CheckAccess(KitItem kitItem)
{
if ((bool)kitItem)
{
return CheckAccess(kitItem.DlcType);
}
return true;
}
public static bool CheckAccess(Thing thing)
{
if (!thing)
{
return true;
}
return CheckAccess(thing.DLCType);
}
DLCType.None always passes. A null / destroyed Thing or KitItem always passes.
All three types live in the bare DLC namespace (DLC.DLCManager, DLC.SharedDLCManager, DLC.DLCType), NOT under Assets.Scripts where most game code sits. A mod needs using DLC;.
Initialization timing¶
DLCManager.Initialize() is called from a manager's private async void Start(), in a startup sequence alongside ControllerAxisItem.InitializeJoysticks(), InputMouse.Initialize(), Settings.Initialize(), and Stationpedia.Initialize(). Until it runs, _ownedDLC is 0 and every CheckAccess call for a non-None DLCType returns false.
This matters for BepInEx mods: plugin Awake() runs during the BepInEx chainloader, before Unity Start() on scene objects, so entitlement is not yet known at plugin Awake time. Any mod that wants to branch on DLC ownership must defer the read, for example to Prefab.OnPrefabsLoaded or to first use, rather than sampling it while binding config. A concrete consequence: StationeersLaunchPad's settings panel supports a Disabled tag for rendering a config entry read-only (see ../Patterns/StationeersLaunchPadSettingsGrouping.md), but the tag has to be supplied inside the ConfigDescription at Config.Bind time, which is too early to test ownership. Computing it there would grey the entry out for players who do own the DLC.
GameManager.IsInitialized is an exact "entitlement has been fetched" signal, which is the cheapest way to wait for the read above rather than guessing at a delay. DLCManager.Initialize() and IsInitialized = true are both statements in the same method, GameManager.Start() (Assembly-CSharp.decompiled.cs:205089, Assembly-CSharp.GameManager.decompiled.cs:1433), with the former many statements earlier and an awaited WorldManager.Initialize() between them. So IsInitialized == true strictly implies DLCManager.Initialize() has already run, and the converse guard is exact rather than a heuristic: while it is false, _ownedDLC is still 0 and any read or write of it is meaningless. In rig terms that boundary is the main menu, so "wait for the menu, then read entitlement" is sound.
SharedDLCManager: session-wide entitlement pool¶
Stationeers shares DLC across a multiplayer session: if any connected player owns a DLC, every player in that session can use its content. SharedDLCManager holds the union.
private static ushort _sharedDLC;
public static ushort SharedDLC
{
get { return _sharedDLC; }
set
{
_sharedDLC = value;
if (NetworkManager.IsServer && NetworkServer.HasClients())
{
NetworkUpdateFlags |= 256;
}
}
}
public static void AddSharedDLC(ushort clientOwnedDLC) => SharedDLC |= clientOwnedDLC;
public static void HostFinishedLoad()
{
if (!GameManager.IsBatchMode && GameManager.RunSimulation)
{
SharedDLC = (ushort)DLCManager.GetOwnedDLC();
}
}
public static void ClientFinishedLoad()
{
DLCType ownedDLC = DLCManager.GetOwnedDLC();
NetworkClient.SendToServer(new AvailableDLCMessage { DLCType = (ushort)ownedDLC });
}
public static bool CheckSharedAccess(DLCType dlcType)
{
DLCType sharedDLC = (DLCType)SharedDLC;
return CheckAccess(dlcType, sharedDLC);
}
private static bool CheckAccess(DLCType dlcType, DLCType ownedDlc)
{
if (dlcType == DLCType.None)
{
return true;
}
return (dlcType & ownedDlc) != 0;
}
public static void ClearAll() => SharedDLC = 0;
Lifecycle:
SharedDLCManager.ClearAll()runs on world teardown, resetting the pool to 0.HostFinishedLoad()seeds the pool from the host's own entitlements. Its sole call site is decompile line 268799, at the end of the world-load path, immediately afterWorld.OnLoadingFinished. Of the two guard terms only!IsBatchModecan fail on a server:GameManager.RunSimulationis=> !NetworkManager.IsClient(203945), which is always true on a server. So a dedicated server does NOT seed the pool from the server process; the pool starts empty and fills only from connecting clients. See "Dedicated server behavior" below for whatIsBatchModeactually keys off, which is broader than the-batchmodeflag. Note also that the new-world path never reaches that call site at all, so a freshly created single-player world starts with an empty pool even when the host owns the DLC; see "Single player: new world versus loaded world" below.ClientFinishedLoad()makes each client sendAvailableDLCMessageto the server with its ownDLCTypebitmask.AvailableDLCMessage.ProcesscallsSharedDLCManager.AddSharedDLC(DLCType), ORing the client's entitlements into the pool.- The pool syncs back to clients as delta state under network update bit 256.
Important consequence: the pool only grows during a session. A player who owns the DLC joining and then leaving leaves the pool with the bit still set until ClearAll() runs.
Exhaustive write-site list for the pool, from a whole-file search of the decompile:
192430: private static ushort _sharedDLC; (declaration)
192442: _sharedDLC = value; (setter body)
192452: SharedDLC |= clientOwnedDLC; (AddSharedDLC)
192459: SharedDLC = (ushort)DLCManager.GetOwnedDLC(); (HostFinishedLoad)
192493: SharedDLC = reader.ReadUInt16(); (DeserializeDeltaState, client side)
192499: SharedDLC = 0; (ClearAll)
There is no disconnect, leave, or player-removal path that clears or recomputes the pool, and no per-player subtraction is even possible because no per-player entitlement record exists anywhere (see "Not caller-scoped" below).
Single player: new world versus loaded world¶
HostFinishedLoad() is the only thing that ever seeds the pool from local entitlements, and its sole call site sits at the end of XmlSaveLoad.LoadWorld (268799). The new-world path does not reach it. An owning single-player host therefore gets an empty pool in a freshly created world, and a correctly seeded pool in that same world after a save and reload.
The two paths diverge in Assets.Scripts.Objects.World:
private static async UniTask NewAsync(string worldName, CancellationToken cancellationToken = default(CancellationToken))
{
...
GameManager.OnReadyToPlay();
...
WorldManager.StartWorld();
await GameManager.StartGame();
ImGuiLoadingScreen.SetActive(active: false);
}
public static void OnLoadingFinished(XmlSaveLoad.WorldData worldData)
{
HelperHintsManager.Initialize();
WorldManager.StartWorld();
GameManager.StartGame().Forget();
...
}
World.NewAsync (324921, reached from World.StartNewWorld at 324892) runs WorldManager.StartWorld() and GameManager.StartGame() itself and then returns. World.OnLoadingFinished (324961) runs the same two calls, but it is invoked from inside XmlSaveLoad.LoadWorld at 268797, which calls SharedDLCManager.HostFinishedLoad() two lines later at 268799. The seeding is attached to the load path, not to world startup.
Neither GameManager.StartGame() (204575) nor WorldManager.StartWorld() (60520) touches SharedDLCManager.
The gap is a missing call, not a failed guard. Both terms in HostFinishedLoad pass for a single-player host: GameManager.RunSimulation is => !NetworkManager.IsClient (203945), true when not a client, and IsBatchMode is false in a normal client build.
Confirmed at runtime in 0.2.6403.27689, in a programmatically created single-player world (difficulty Creative, world Lunar) on an install that owns Metallic Paints: SharedDLCManager.SharedDLC reads 0 and the dlc shared console command prints dlc: None.
Consequences:
- In a freshly created single-player world, the two vanilla in-world gates read an empty pool, so the four metallic spray cans cannot be spawned or fabricated even though the player owns the DLC. This is a vanilla defect, not a mod interaction.
- Saving and reloading that world routes through
XmlSaveLoad.LoadWorld, runsHostFinishedLoad(), and sets the pool to the owned bitmask. The same world then behaves correctly. - A mod that reproduces the vanilla gate with
CheckSharedAccessalone inherits the defect and will block owning players in freshly created single-player worlds.
DLCManager.CheckAccess(Thing) (192405) is the local-ownership counterpart. It is public, reads _ownedDLC directly, and has no caller anywhere in Assembly-CSharp. A mod that must accept both a locally entitled single-player host and a shared multiplayer pool should OR the two checks rather than relying on either alone, subject to the DLCManager.Initialize() timing constraint in "Initialization timing" above.
Dedicated server behavior¶
IsBatchMode keys off more than the -batchmode command-line flag. GameManager.SetMatchMode() (204290-204304) runs at AfterAssembliesLoaded, so it is settled long before any world load:
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
private static void SetMatchMode()
{
int isBatchMode;
if (!Application.isBatchMode)
{
RuntimePlatform platform = Application.platform;
isBatchMode = ((platform == RuntimePlatform.LinuxServer || platform == RuntimePlatform.WindowsServer) ? 1 : 0);
}
else
{
isBatchMode = 1;
}
IsBatchMode = (byte)isBatchMode != 0;
}
A dedicated-server build therefore has IsBatchMode == true from its platform alone, with or without -batchmode on the command line.
Trace for a dedicated server that owns nothing, with one connected client that owns a DLC:
- Server process starts.
_sharedDLCis 0 (static default). - World load completes and calls
SharedDLCManager.HostFinishedLoad()(268799).IsBatchModeis true, so the guard fails and the pool is not seeded. It stays 0. - Each connecting client, at the very end of its own join, calls
ClientFinishedLoad()and sends itsAvailableDLCMessage. Non-owning clients contribute 0. - The owning client's message lands. The server runs
AvailableDLCMessage.Process, which callsAddSharedDLC, which ORs the bit in. CheckSharedAccesson the server now returns true for that DLC.
Two windows follow from this, both worth knowing:
- Before the owning client is fully joined, the answer is false.
ClientFinishedLoad()is the last step before the client is announced ready: decompile 213241 sits immediately beforeUpdateHandshakeState(HandshakeType.ClientReady)at 213243. So during the entire join (world stream, thing processing, character request) the owning client is connected but has not yet contributed its bit. Any code sampling the pool on client-connect rather than client-ready reads false. - After the owning client disconnects, the answer stays true. Per the write-site list above, nothing removes a bit. The entitlement persists for the remaining lifetime of the loaded world, until
ClearAll()runs fromGameManager.ClearGameAll()(204810) on teardown.
Consequence for server operators and mod authors: while at least one owning client is fully joined, every connected player can fabricate and spawn that DLC's content, not just the owner. The pool is broadcast back to all clients under delta bit 256, so each client's local gate passes too. That is the designed shared-DLC behavior and it holds on a dedicated server that owns nothing itself.
One coupling to note: the dirty flag that triggers the broadcast is only raised when NetworkManager.IsServer && NetworkServer.HasClients() (192443). In practice HasClients() is true when a client's own message arrives over its own connection, but the pool can change without being marked dirty if that ever fails, in which case the server would allow the DLC while no client's local gate had been updated.
AddSharedDLC uses |=, which invokes the property setter even when the value does not change. Every later client's join message therefore re-dirties the pool and re-broadcasts it, which is how a client joining after the owner receives an already-populated pool. There is no SharedDLC field in the join package itself.
Not caller-scoped¶
There is no way to ask "does THIS player own it". Only "does anyone in the session own it, or has since world load".
CheckSharedAccess(DLCType)(192472) takes no player or caller argument.AvailableDLCMessage.Process(long hostId)(277481) receives the sender id and discards it, callingAddSharedDLC(DLCType)and nothing else.- The per-connection
Clienttype carries no DLC field, andThing._dlcTypedescribes the object, not the owner.
AvailableDLCMessage is a server-processed message. MessageBase.DeserializeReceivedData (39287-39308) carries a whitelist of message types allowed to process off the server, and AvailableDLCMessage is not on it. Note that the whitelist only controls an error print: messageProcessable.Process(hostId) at 39306 sits outside the if, so processing runs regardless.
For a mod that needs per-player entitlement on a dedicated server, the only point where the sender id and the bitmask coexist is inside AvailableDLCMessage.Process, so a Harmony prefix or postfix there is the single interception point. The mod would have to build and maintain its own player-to-DLCType map.
AvailableDLCMessage¶
public class AvailableDLCMessage : ProcessedMessage<AvailableDLCMessage>
{
public ushort DLCType;
public override void Process(long hostId)
{
SharedDLCManager.AddSharedDLC(DLCType);
}
public override void Deserialize(RocketBinaryReader reader) => DLCType = reader.ReadUInt16();
public override void Serialize(RocketBinaryWriter writer) => writer.WriteUInt16(DLCType);
}
The server accepts the client's claimed bitmask without verification. Entitlement is client-asserted, not server-validated.
Delta-state serialization¶
public static void SerializeDeltaState(RocketBinaryWriter writer)
{
writer.WriteUInt16(NetworkUpdateFlags);
if (IsNetworkUpdateRequired(256, NetworkUpdateFlags))
{
writer.WriteUInt16(SharedDLC);
}
NetworkUpdateFlags = 0;
}
public static void DeserializeDeltaState(RocketBinaryReader reader)
{
ushort networkUpdateType = reader.ReadUInt16();
if (IsNetworkUpdateRequired(256, networkUpdateType))
{
SharedDLC = reader.ReadUInt16();
}
}
dlc console command¶
DLCCommand (CommandScope.InGame | CommandScope.HostOrSinglePlayer) prints the session pool:
- Help text: "Provides DLC debug functions. Host or singleplayer only."
- Argument:
shared : print the shared (server-union) owned DLC HandleShared()returns((DLCType)SharedDLCManager.SharedDLC).ToString().
dlc shared is the fastest in-game way to read the current pool while testing entitlement behavior.
Thing.DLCType¶
Every Thing carries its DLC requirement as a serialized field set on the prefab:
Read-only at runtime (no public setter). DLCType.None on all non-DLC content. This is the single per-object source of truth the gates below consult.
Where the game checks¶
Three enforcement sites exist in Assembly-CSharp, all of them at the moment content is ACQUIRED:
- Console / creative spawn.
SpawnDynamicThingMaxStack(long parentId, string prefabName):
else if (!SharedDLCManager.CheckSharedAccess(dynamicThing.DLCType))
{
ConsoleWindow.PrintError("error DLC not owned for " + prefabName, suppressStacktrace: true);
}
This is the source of the in-game red console line error DLC not owned for ItemSprayCanMetallicObsidian.
- Fabrication. The manufactory / fabricator interaction path checks the recipe's product before allowing
Activate:
DynamicThing product = GetProduct(CurrentIndex);
if ((object)product != null && !SharedDLCManager.CheckSharedAccess(product.DLCType))
{
return delayedActionInstance.Fail(GameStrings.RequireDlcToFabricate);
}
A recipe may therefore exist in the data files for every player while remaining unfabricatable without the entitlement.
- Character customisation.
HasDLC(KitItem kitItem)uses the LOCAL check, not the shared pool:
private bool HasDLC(KitItem kitItem)
{
bool num = DLCManager.CheckAccess(kitItem);
if (!num)
{
_currentDlcLink = DLCManager.GetStorePageLink(kitItem.DlcType);
}
return num;
}
Cosmetic character content is gated on personal ownership (DLCManager.CheckAccess); in-world content is gated on the session pool (SharedDLCManager.CheckSharedAccess). The two are deliberately different and a mod should copy whichever matches the content it is handling.
Where the game does NOT check¶
There is no DLC check anywhere on the paint-application path. A full-text search of Assembly-CSharp for CheckSharedAccess and DLCManager.CheckAccess returns only the four call sites above (three enforcement sites plus the definition). None of the following consults DLCType:
Thing.SetCustomColor(int index, bool emissive = false)(321962). Its only guard isif (!GameManager.IsValidColor(index)) return;(321964), a pure bounds check.OnServer.SetCustomColor(Thing thing, int colorIndex)(39792)ISprayer.DoSpray(Thing thing, ISprayer sprayer, bool doAction)(354359). Its guards are paint material, same-color, tool on/off, and empty-can only; it ends atOnServer.SetCustomColor(thing, colorSwatch.Index)(354409).SprayCanin its entirety (354314-354346).OnUseItem(354340) only decrementsQuantityand emits pollution.ColorSwatchitself (the class has noDLCTypefield; see../GameClasses/ColorSwatch.md)GameManager.CustomColors, which holds every swatch regardless of entitlement.GetRandomColor(204163) draws from the same unfiltered list and can therefore return a DLC swatch.ThingColorMessage.Process(277590), which applies a client-supplied color index with no validation of any kind. See below.
The design is coherent for vanilla: the only way to reach a DLC paint color is to hold the matching spray can, and both routes to that can (console spawn, fabrication) are gated. Ownership of the color is expressed entirely through ownership of the item.
ThingColorMessage is unvalidated¶
The network message that carries a color application performs no checks at all:
public class ThingColorMessage : ProcessedMessage<ThingColorMessage>
{
public long ThingId;
public int ColorIndex;
public override void Process(long hostId)
{
Thing.Find<Thing>(ThingId).SetCustomColor(ColorIndex);
}
public override void Deserialize(RocketBinaryReader reader)
{
ThingId = reader.ReadInt64();
ColorIndex = reader.ReadInt32();
}
public override void Serialize(RocketBinaryWriter writer)
{
writer.WriteInt64(ThingId);
writer.WriteInt32(ColorIndex);
}
}
Process (277590-277593) has no entitlement check, no PaintOnly check, and no null guard on Thing.Find. It is sent from OnServer.SetCustomColor at 39799. Any client able to construct the message can set any color index on any Thing, so the vanilla wire protocol offers no backstop that a mod could lean on. Thing.SetCustomColor's own IsValidColor bounds check is the only thing standing between the wire and the paint.
The logic and IC10 color path is closed, but not by entitlement¶
Writing LogicType.Color from IC10 or a logic writer cannot reach a metallic color, but the predicate is visibility, not ownership:
case LogicType.Color:
{
int num = (int)value.Clamp(0.0, GameManager.ColorCount - 1);
if (GameManager.IsLogicSelectableColor(num))
{
OnServer.Interact(base.InteractColor, num);
}
break;
}
This appears in DynamicThing.SetLogicValue(LogicType, double) (299004-299012) and identically in Device.SetLogicValue(LogicType, double) (371134). GameManager.IsLogicSelectableColor(int) (204129-204136) returns !CustomColors[colorIndex].PaintOnly, and GenerateColorStrings() (204110-204127) skips PaintOnly entries at 204120 when building LogicColorIndices and ColorStrings.
The consequence for a mod author: the logic-driven paint route is already blocked in vanilla, so it is not a hole to close, but it is blocked for every player equally including owners of the DLC. A mod that unblocks it by bypassing IsLogicSelectableColor inherits no entitlement check whatsoever and must supply its own.
The gap this leaves for mods: GameManager.CustomColors is an ungated list, and any code that applies a color by index reaches DLC colors with no check. A mod that lets the player pick a color by index rather than by holding a can (a color cycler, a color picker UI, an eyedropper, a logic-driven paint writable) bypasses entitlement without touching any gated code path. Vanilla has no backstop to catch it.
Mod authors handling colors by index should reproduce the vanilla gate themselves. The check that matches vanilla in-world behavior is SharedDLCManager.CheckSharedAccess(dlcType). Reproducing it exactly also reproduces the single-player defect documented in "Single player: new world versus loaded world" above, which blocks owning players in freshly created worlds; ORing in DLCManager.CheckAccess(Thing) avoids that. Resolving a color index to a DLCType requires going through the spray can prefab that carries that color, because the swatch itself does not record one:
foreach (Thing thing in Prefab.AllPrefabs)
{
if (thing is SprayCan prefabCan && prefabCan.PaintMaterial != null)
{
// prefabCan.PaintMaterial identifies the swatch; prefabCan.DLCType is the gate
}
}
GameManager.GetColorSwatch(Material) is the game's own material-to-swatch lookup, used by ISprayer.DoSpray, so a per-swatch Normal material is a valid key for this mapping. Confirmed at runtime on 2026-07-25 in game version 0.2.6403.27689: all 16 swatch Normal materials are distinct assets and all 16 SprayCan prefabs resolve one-to-one onto them, yielding a complete and unambiguous color-index-to-DLCType map (indices 0-11 None, indices 12-15 MetallicPaints). Method, caveats, and the full table are on ../GameClasses/ColorSwatch.md under "Metallic swatch addition".
Both the swatches and the can prefabs are present regardless of entitlement, so the mapping can be built on any install, including one that does not own the DLC.
Metallic Paints DLC content¶
DLCType.MetallicPaints (0x100, Steam app 4842920) covers four spray cans. rocketstation_Data/StreamingAssets/Data/paints.xml lists Tool Manufactory recipes for all sixteen cans, twelve vanilla plus these four:
ItemSprayCanMetallicBronzeItemSprayCanMetallicGoldItemSprayCanMetallicObsidianItemSprayCanMetallicSilver
All four recipes are Time 5, Energy 500, Iron 1, identical to the vanilla cans. The recipes ship to every player; the fabricator gate in "Where the game checks" is what stops a non-owner from producing them.
rocketstation_Data/StreamingAssets/Language/english.xml carries the four keys with descriptions of the form "Metallic obsidian spray paint. Using it with a spray gun will extend the usage greatly."
The corresponding color swatches carry ColorSwatch.PaintOnly = true, which drives the metallic shader response (_MaskMetallic and _MaskSmoothness set to 0.85) and excludes them from logic color dropdowns. PaintOnly is a rendering and logic-selectability flag, not an entitlement flag: it happens to coincide with the DLC set today but carries no DLCType. See ../GameClasses/ColorSwatch.md.
The four swatches sit at CustomColors indices 12-15 in the order ColorObsidian, ColorSilver, ColorBronze, ColorGold, confirmed at runtime. Note the swatch names drop the Metallic prefix the prefab names carry, and the swatch order matches neither alphabetical order nor the paints.xml recipe order, so neither identifier nor index can be derived from the other.
Gate provenance: what 0.2.6402.27686 actually changed¶
The metallic colors did not ship as DLC. They arrived as ordinary trader-only content and were converted to DLC content later. The in-game changelog (StreamingAssets/version.ini, which is the game's own changelog; the changelog.txt in the install root belongs to BepInEx) records both steps verbatim.
[Version 0.2.6325.27252]:
- <color=green>Added</color> four new metallic spray can colors that are available only via the Cosmic Curiosities trader: Gold, Silver, Bronze, and Obsidian.
[Version 0.2.6402.27686]:
- <color=green>Added</color> the metallic paints DLC gate.
- <color=green>Added</color> some editor only DLC commands.
- <color=green>Updated</color> thumbnails for spray cans to all be consistent.
What "the metallic paints DLC gate" means in code is data-side wiring, not a new enforcement site. The exhaustive call-site search in "Where the game does NOT check" was run against 0.2.6403.27689, one build LATER, and still finds exactly three CheckSharedAccess occurrences (40154, 192472, 420505) and one external DLCManager.CheckAccess caller (194337). No call site was added in 6402. The change consisted of:
- Adding
DLCType.MetallicPaints = 0x100to the enum and toAllDLC, with the Steam app 4842920 mapping inFetchOwnershipFromSteamandGetStorePageLink. - Setting
_dlcTypetoMetallicPaintson the fourSprayCanprefabs, which is asset data rather than code. Runtime-confirmed on 2026-07-25 at 0.2.6403.27689: indices 12-15 resolve toDLCType.MetallicPaints. - Removing the trader route, which was the acquisition path the generic gates did not cover.
The trader removal is verifiable in the data files. StreamingAssets/Data/tradeables.xml contains no metallic spray can: the only spray cans in trade data are the two "Box of Spray Cans" entries (lines 1801 and 2083), both stocking ItemSprayCanRed, Blue, Green, Yellow, Black, White only. There is no Cosmic Curiosities trader left in tradeables.xml at all; the string Cosmic Curiosities survives only as an orphaned language entry in Language/english.xml (line 23280). The metallic prefab names appear in exactly two data files, Data/paints.xml and Language/english.xml, and in no trade table.
paints.xml carries no DLC attribute of any kind, so nothing about the gate is data-driven from XML. The recipes ship to every player and the fabricator gate at 420505 is what stops a non-owner producing them.
The net effect of 6402 is that the four cans moved from "ungated, trader-only" to "gated at acquisition like every other DLC item". It changed which items the existing gates apply to. It did not add a gate to any new part of the pipeline, and in particular it added nothing to the paint-application path.
On the second line, "editor only DLC commands": the shipped DLCCommand (97470) exposes only the shared argument documented above. Editor-only commands are compiled out of the shipped assembly, which is consistent with the shipped command surface being unchanged.
Verification history¶
- 2026-07-28: re-ran the exhaustive enforcement-site search and added three subsections. Trigger: a changelog sweep reported that the base game "added its own Metallic Paints DLC gate" in 0.2.6402.27686, which appeared to contradict this page's "Where the game does NOT check" claim verified at the later 0.2.6403.27689. No contradiction exists and no fresh-validator pass was required: the searches reproduce exactly,
CheckSharedAccessat 40154 / 192472 / 420505 andDLCManager.CheckAccesswith one external caller at 194337, so 6402 added no call site. The changelog entry is real and now quoted verbatim in the new "Gate provenance" subsection, along with[Version 0.2.6325.27252]showing the metallic colors originally shipped as Cosmic Curiosities trader-only content. The 6402 change is data-side: enum bit, Steam app mapping, prefab_dlcType, and removal of the trader route. Trader removal confirmed intradeables.xml, which stocks only the six basic cans in its two "Box of Spray Cans" entries (1801, 2083) and no longer contains a Cosmic Curiosities trader; the name survives only as an orphanedenglish.xmlstring at 23280.paints.xmlcarries no DLC attribute. Also added "ThingColorMessage is unvalidated" (277584-277606), a genuinely new finding:Processapplies a client-supplied color index with no entitlement,PaintOnly, or null check, so the vanilla wire protocol is not a backstop. Also added "The logic and IC10 color path is closed, but not by entitlement", recording thatDynamicThing.SetLogicValue(299004-299012) andDevice.SetLogicValue(371134) gateLogicType.ColoronIsLogicSelectableColor(204129), which readsPaintOnlyand notDLCType. Corroborating detail for the existingPaintOnlyclaim: the field's[Tooltip]at 295151 states outright that it is a spray-only and logic-visibility flag. All quotes re-read first-hand against the 0.2.6403.27689 decompile rather than taken from a sub-agent summary; one sub-agent claim that a "tablet colour scroll UI" iterated the unfiltered color list was checked and rejected, the code at 338885 beingAccessController : Cartridge(338824) building an access-bit color grid onColorSwatch.BitandHasAccess, which is not a paint path. - 2026-07-27: added the "Single player: new world versus loaded world" subsection. Additive finding, no existing claim contradicted: the page already stated that
HostFinishedLoad()'s sole call site is 268799 at the end of the world-load path, but did not note that the new-world path never reaches it.World.NewAsync(324921, fromWorld.StartNewWorldat 324892) callsWorldManager.StartWorld()thenGameManager.StartGame()and returns, whileWorld.OnLoadingFinished(324961) is invoked from insideXmlSaveLoad.LoadWorldat 268797 withSharedDLCManager.HostFinishedLoad()following at 268799. NeitherGameManager.StartGame()(204575) norWorldManager.StartWorld()(60520) touchesSharedDLCManager, so a freshly created single-player world leaves the pool at 0 for an owning host, and both vanilla in-world gates then refuse that host's own DLC content until the world is saved and reloaded. BothHostFinishedLoadguard terms pass in single player, so the cause is the missing call and not a failed condition. Corroborated by a runtime observation on an install owning Metallic Paints:SharedDLCreads 0 anddlc sharedprintsdlc: Nonein a programmatically created Creative Lunar world. Also cross-referenced the defect from the mod-author guidance in "Where the game does NOT check", since that section recommendsCheckSharedAccessas the gate to copy. ConfirmedDLCManager.CheckAccess(Thing)(192405) is public with no caller inAssembly-CSharp, making it the available local-ownership counterpart. Decompile ofDLC.SharedDLCManagerre-extracted from the 0.2.6403.27689 DLL and compared against the full-assembly decompile before quoting; the two agree. - 2026-07-27: added "Dedicated server behavior" and "Not caller-scoped" subsections, and widened the
HostFinishedLoadlifecycle bullet. Findings:GameManager.RunSimulationis!NetworkManager.IsClient(203945) and so always passes on a server, leaving!IsBatchModeas the only term that blocks self-seeding;IsBatchModeis set bySetMatchMode()(204290-204304) fromApplication.isBatchModeORRuntimePlatform.LinuxServer/WindowsServer, so a dedicated-server build sets it without the-batchmodeflag;HostFinishedLoad's sole call site is 268799; the client sends its bitmask at 213241, immediately beforeUpdateHandshakeState(HandshakeType.ClientReady)at 213243, so the pool is empty for the whole of an owning client's join;AvailableDLCMessageis absent from theMessageBase.DeserializeReceivedDatawhitelist (39302) thoughProcessat 39306 runs regardless of that check;ProcessdiscardshostIdand no per-player entitlement record exists, so the check cannot be made caller-scoped without a mod-maintained map;ClearAll's sole caller isGameManager.ClearGameAll(204756, call at 204810). Also resolved the second open question: it hypothesised that a dedicated server "grants DLC content only while an owning client is connected", which the exhaustive write-site list disproves, and which already contradicted the verified line in this section stating the pool only grows. Replaced with a narrower open question about live confirmation. All quotes re-read first-hand against the 0.2.6403.27689 decompile rather than taken from a sub-agent summary. - 2026-07-25: independent re-verification of the "Where the game does NOT check" claim against the 0.2.6403.27689 decompile.
CheckSharedAccessresolves to exactly three occurrences (console spawn gate at 40154, definition at 192472, fabricator gate at 420505).CheckAccessresolves to the definitions and internal calls at 192370 / 192396 / 192400 / 192405 / 192411 / 192475 / 192507 plus exactly one external caller at 194337 (DLCManager.CheckAccess(kitItem)insideHasDLC). No additional enforcement site exists, confirming that no DLC check runs on any paint-application path. - 2026-08-11: extended "Initialization timing" with
GameManager.IsInitializedas an exact "entitlement has been fetched" signal.DLCManager.Initialize()andIsInitialized = trueare statements in the same method with an awaitedWorldManager.Initialize()between them, so the flag strictly implies the fetch has run and the negative guard is exact rather than a heuristic. Additive; the existing claim that entitlement is unknown at pluginAwakeis unchanged, this just gives a precise point at which it becomes known. Found while building a per-process entitlement override for the client rig, which needs to refuse to run before that point or it silently no-ops. - 2026-07-25: corrected the namespace on all three types. The page was created citing
Assets.Scripts.DLCManager/SharedDLCManager/DLCType; they are actually in the bareDLCnamespace (decompile line 192302 opensnamespace DLC). Found while writing a mod against the page, which is exactly the sort of error that costs a later reader a build failure. Also added the "Initialization timing" subsection:DLCManager.Initialize()runs from a manager'sasync void Start(), so entitlement is still zero during BepInEx pluginAwake, which rules out testing ownership atConfig.Bindtime. - 2026-07-25: added runtime confirmation of the color-index-to-
DLCTypemap, gathered by thespp-color-swatch-probeScenarioRunner scenario on the headless dedicated server (fresh Mars2 world, game version 0.2.6403.27689). All 16 swatchNormalmaterials are distinct assets and all 16SprayCanprefabs resolve one-to-one onto them, so the prefab-derived gate described in "Where the game does NOT check" is implementable as written. Metallic swatches confirmed at indices 12-15 in the order Obsidian, Silver, Bronze, Gold. Swatches and prefabs confirmed present regardless of entitlement. Two open questions resolved and removed. Full table and method on../GameClasses/ColorSwatch.md. - 2026-07-25: page created. Decompile findings sourced from Assembly-CSharp.dll (
DLCManagerandDLCTypeat decompile line 192304-192427,SharedDLCManagerat 192428-192515,Thing._dlcType/Thing.DLCTypeat 316896 / 317376,SpawnDynamicThingMaxStackgate at 40154, fabricator gate at 420505,HasDLC(KitItem)at 194335,AvailableDLCMessageat 277477,DLCCommandat 97470). Data-file findings sourced fromStreamingAssets/Data/paints.xmlandStreamingAssets/Language/english.xml. The "Where the game does NOT check" claim rests on an exhaustive text search of the decompile forCheckSharedAccessandDLCManager.CheckAccess, which returns only those call sites.
Open questions¶
DLCManager.GrantFullOwnership()has no observed call site. Whether it is dead code, called via reflection, or reached from a build-conditional path has not been traced. One lead: the[Version 0.2.6402.27686]changelog line "Added some editor only DLC commands" implies DLC debug commands that exist in the editor build and are compiled out of the shipped assembly, which would explain a caller being absent here. Not confirmed, since editor-only code is not present in the shipped DLL to inspect.- The dedicated-server pool behavior in "Dedicated server behavior" is derived from code, not yet observed in a live session. The
dlc sharedconsole command is the intended runtime probe: its scope isCommandScope.InGame | CommandScope.HostOrSinglePlayer(97480), so it runs on the dedicated-server console, though reaching it from a connected admin client needsserverrun.