Skip to content

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 after World.OnLoadingFinished. Of the two guard terms only !IsBatchMode can fail on a server: GameManager.RunSimulation is => !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 what IsBatchMode actually keys off, which is broader than the -batchmode flag. 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 send AvailableDLCMessage to the server with its own DLCType bitmask.
  • AvailableDLCMessage.Process calls SharedDLCManager.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, runs HostFinishedLoad(), and sets the pool to the owned bitmask. The same world then behaves correctly.
  • A mod that reproduces the vanilla gate with CheckSharedAccess alone 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:

  1. Server process starts. _sharedDLC is 0 (static default).
  2. World load completes and calls SharedDLCManager.HostFinishedLoad() (268799). IsBatchMode is true, so the guard fails and the pool is not seeded. It stays 0.
  3. Each connecting client, at the very end of its own join, calls ClientFinishedLoad() and sends its AvailableDLCMessage. Non-owning clients contribute 0.
  4. The owning client's message lands. The server runs AvailableDLCMessage.Process, which calls AddSharedDLC, which ORs the bit in.
  5. CheckSharedAccess on 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 before UpdateHandshakeState(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 from GameManager.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, calling AddSharedDLC(DLCType) and nothing else.
  • The per-connection Client type carries no DLC field, and Thing._dlcType describes 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:

[SerializeField]
private DLCType _dlcType;

public DLCType DLCType => _dlcType;

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:

  1. 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.

  1. 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.

  1. 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 is if (!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 at OnServer.SetCustomColor(thing, colorSwatch.Index) (354409).
  • SprayCan in its entirety (354314-354346). OnUseItem (354340) only decrements Quantity and emits pollution.
  • ColorSwatch itself (the class has no DLCType field; 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:

  • ItemSprayCanMetallicBronze
  • ItemSprayCanMetallicGold
  • ItemSprayCanMetallicObsidian
  • ItemSprayCanMetallicSilver

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 = 0x100 to the enum and to AllDLC, with the Steam app 4842920 mapping in FetchOwnershipFromSteam and GetStorePageLink.
  • Setting _dlcType to MetallicPaints on the four SprayCan prefabs, which is asset data rather than code. Runtime-confirmed on 2026-07-25 at 0.2.6403.27689: indices 12-15 resolve to DLCType.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, CheckSharedAccess at 40154 / 192472 / 420505 and DLCManager.CheckAccess with 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 in tradeables.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 orphaned english.xml string at 23280. paints.xml carries no DLC attribute. Also added "ThingColorMessage is unvalidated" (277584-277606), a genuinely new finding: Process applies 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 that DynamicThing.SetLogicValue (299004-299012) and Device.SetLogicValue (371134) gate LogicType.Color on IsLogicSelectableColor (204129), which reads PaintOnly and not DLCType. Corroborating detail for the existing PaintOnly claim: 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 being AccessController : Cartridge (338824) building an access-bit color grid on ColorSwatch.Bit and HasAccess, 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, from World.StartNewWorld at 324892) calls WorldManager.StartWorld() then GameManager.StartGame() and returns, while World.OnLoadingFinished (324961) is invoked from inside XmlSaveLoad.LoadWorld at 268797 with SharedDLCManager.HostFinishedLoad() following at 268799. Neither GameManager.StartGame() (204575) nor WorldManager.StartWorld() (60520) touches SharedDLCManager, 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. Both HostFinishedLoad guard 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: SharedDLC reads 0 and dlc shared prints dlc: None in 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 recommends CheckSharedAccess as the gate to copy. Confirmed DLCManager.CheckAccess(Thing) (192405) is public with no caller in Assembly-CSharp, making it the available local-ownership counterpart. Decompile of DLC.SharedDLCManager re-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 HostFinishedLoad lifecycle bullet. Findings: GameManager.RunSimulation is !NetworkManager.IsClient (203945) and so always passes on a server, leaving !IsBatchMode as the only term that blocks self-seeding; IsBatchMode is set by SetMatchMode() (204290-204304) from Application.isBatchMode OR RuntimePlatform.LinuxServer/WindowsServer, so a dedicated-server build sets it without the -batchmode flag; HostFinishedLoad's sole call site is 268799; the client sends its bitmask at 213241, immediately before UpdateHandshakeState(HandshakeType.ClientReady) at 213243, so the pool is empty for the whole of an owning client's join; AvailableDLCMessage is absent from the MessageBase.DeserializeReceivedData whitelist (39302) though Process at 39306 runs regardless of that check; Process discards hostId and no per-player entitlement record exists, so the check cannot be made caller-scoped without a mod-maintained map; ClearAll's sole caller is GameManager.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. CheckSharedAccess resolves to exactly three occurrences (console spawn gate at 40154, definition at 192472, fabricator gate at 420505). CheckAccess resolves to the definitions and internal calls at 192370 / 192396 / 192400 / 192405 / 192411 / 192475 / 192507 plus exactly one external caller at 194337 (DLCManager.CheckAccess(kitItem) inside HasDLC). No additional enforcement site exists, confirming that no DLC check runs on any paint-application path.
  • 2026-08-11: extended "Initialization timing" with GameManager.IsInitialized as an exact "entitlement has been fetched" signal. DLCManager.Initialize() and IsInitialized = true are statements in the same method with an awaited WorldManager.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 plugin Awake is 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 bare DLC namespace (decompile line 192302 opens namespace 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's async void Start(), so entitlement is still zero during BepInEx plugin Awake, which rules out testing ownership at Config.Bind time.
  • 2026-07-25: added runtime confirmation of the color-index-to-DLCType map, gathered by the spp-color-swatch-probe ScenarioRunner scenario on the headless dedicated server (fresh Mars2 world, 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, 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 (DLCManager and DLCType at decompile line 192304-192427, SharedDLCManager at 192428-192515, Thing._dlcType / Thing.DLCType at 316896 / 317376, SpawnDynamicThingMaxStack gate at 40154, fabricator gate at 420505, HasDLC(KitItem) at 194335, AvailableDLCMessage at 277477, DLCCommand at 97470). Data-file findings sourced from StreamingAssets/Data/paints.xml and StreamingAssets/Language/english.xml. The "Where the game does NOT check" claim rests on an exhaustive text search of the decompile for CheckSharedAccess and DLCManager.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 shared console command is the intended runtime probe: its scope is CommandScope.InGame | CommandScope.HostOrSinglePlayer (97480), so it runs on the dedicated-server console, though reaching it from a connected admin client needs serverrun.