Skip to content

DynamicThingConstructor

Vanilla Assets.Scripts.Objects.Items.DynamicThingConstructor : Stackable, IConstructionKit, the kit whose use builds a portable (a DynamicThing: portable tanks, crates, the portable generator and so on) instead of placing a Structure. Constructor and MultiConstructor are the structure-kit counterparts (Constructor, MultiConstructor). It is a Stackable (Stackable), so the hand holds a stack of kits and each build consumes UsedQuantity of them.

Line numbers refer to the 0.2.6428.27798 decompile at .work/decomp/0.2.6428.27798/Assembly-CSharp.decompiled.cs.

The class

Verbatim (349658-349718):

public class DynamicThingConstructor : Stackable, IConstructionKit
{
    public DynamicThing ConstructedPrefab;

    public float ConstructionTime = 0.5f;

    public int UsedQuantity = 1;

    public override int ConstructingSoundHash => Animator.StringToHash("ItemKitConstructing");

    public override int FinishedConstructingSoundHash => 0;

    public override DelayedActionInstance OnUseSecondary(bool doAction = false, float actionCompletionRatio = 1f)
    {
        DelayedActionInstance result = new DelayedActionInstance
        {
            Duration = ConstructionTime,
            ActionMessage = ActionStrings.Construct,
            ActionSoundHash = ConstructingSoundHash,
            ActionCompleteSoundHash = FinishedConstructingSoundHash
        };
        if (doAction && actionCompletionRatio >= 1f)
        {
            OnUseItem(UsedQuantity, RootParent);
        }
        return result;
    }

    public override bool OnUseItem(float quantity, Thing useOnThing)
    {
        Quaternion rotation;
        Vector3 vector;
        if (RootParent is Entity entity)
        {
            rotation = entity.EntityRotation;
            vector = entity.EntityForward;
        }
        else
        {
            rotation = RootParent.ThingTransform.rotation;
            vector = RootParent.ThingTransform.forward;
        }
        Vector3 vector2 = RootParent.ThingTransformPosition + vector + (Vector3.up - Vector3.up * ConstructedPrefab.Bounds.center.y);
        if (base.Quantity < 1)
        {
            return false;
        }
        DynamicThing thing = OnServer.CreateOld(ConstructedPrefab, vector2, rotation, RootParent.OwnerClientId);
        if (PaintableMaterial != null && CustomColor.Normal != null)
        {
            OnServer.SetCustomColor(thing, CustomColor.Index);
        }
        quantity = Mathf.Min(quantity, base.Quantity);
        return base.OnUseItem(quantity, useOnThing);
    }

    public List<Thing> GetConstructedPrefabs()
    {
        return new List<Thing> { ConstructedPrefab };
    }
}

ConstructionTime is 0.5 s in C#, but each prefab sets its own value (1 s on the Mk II portable liquid tank kit, see "Kit prefabs in this build"). Within the class, OnUseItem is called only from OnUseSecondary, and the class has no OnUsePrimary override: the player's way to build is the secondary use.

Building is the kit's secondary use

Building a portable from a kit is the kit's secondary use: hold the right mouse button for at least the prefab's ConstructionTime. On a host or in single-player the path is:

  1. InventoryManager.NormalMode, on KeyManager.GetMouseDown("Secondary") (293943). The kit is an Item that is not a Constructor, MultiConstructor or AuthoringTool, so NormalMode calls item.OnUseSecondary() with its defaults (293954). With doAction == false that only returns the DelayedActionInstance, whose Duration is ConstructionTime. The duration is above zero, so NormalMode starts WaitUntilDone(UseSecondaryComplete, delayedActionInstance.Duration, ...) and stores it in ActionCoroutine (293957).
  2. The secondary WaitUntilDone overload (295112) waits out the duration. Releasing the button (KeyManager.GetButtonUp(KeyMap.SecondaryAction)), changing the active hand, or the kit leaving the hand cancels it with LastCompletedRatio = elapsedTime / timeToWait; finishing sets LastCompletedRatio = 1f. Both branches then call OnComplete(), which here is UseSecondaryComplete (294818).
  3. UseSecondaryComplete calls OnServer.UseItemSecondary(Parent, ActiveHand.SlotId, LastCompletedRatio) when GameManager.RunSimulation (294822). OnServer.UseItemSecondary (40491-40498), verbatim:
public static void UseItemSecondary(Thing parent, int slotId, float completedRatio)
{
    Assets.Scripts.Objects.Item item = parent.Slots[slotId].Get<Assets.Scripts.Objects.Item>();
    if ((bool)item)
    {
        item.OnUseSecondary(doAction: true, completedRatio);
    }
}
  1. OnUseSecondary(doAction: true, completedRatio) builds only when actionCompletionRatio >= 1f (349679-349681), by calling OnUseItem(UsedQuantity, RootParent).

A release before ConstructionTime has elapsed still reaches step 4, with a ratio below 1, and builds nothing.

On a remote client, steps 1 and 2 run locally and UseSecondaryComplete takes its other branch: NetworkClient.UseItemSecondary (219150) sends RequestUseItemToServer (282973), whose Process (282993-282996) calls OnServer.UseItemSecondary(Thing.Find<Thing>(ParentReferenceId), ActiveHandSlotId, CompletedRation). The build, the colour write and the stack decrement therefore all run on the server. The ratio travels as one byte, (byte)Mathf.Floor(value * 100f), and is read back as hundredths.

The secondary branch of NormalMode does not test ActionCoroutine before it starts its coroutine; the primary tool path does. That is why kit builds kept working in a session whose primary tool use was locked by an earlier exception. See "Timed actions" on InventoryManager.

Runtime confirmation, 2026-09-21: on a listen host with Spray Paint Plus v1.10.1 loaded, every build's exception carried exactly this path. Unity log, verbatim apart from the developer path:

MissingFieldException: Field not found: System.Collections.Generic.List`1<Networks.INetworkedStructure> Networks.StructureNetwork.StructureList Due to: Could not find field in class
  at SprayPaintPlus.NetworkPainterPatch.Prefix (Assets.Scripts.Objects.Thing thing, System.Int32 colorIndex) [0x00055] in <REPO_CLONE_PATH>\Mods\SprayPaintPlus\SprayPaintPlus\NetworkPainterPatch.cs:107
  at (wrapper dynamic-method) OnServer.DMD<OnServer::SetCustomColor>(Assets.Scripts.Objects.Thing,int)
  at Assets.Scripts.Objects.Items.DynamicThingConstructor.OnUseItem (System.Single quantity, Assets.Scripts.Objects.Thing useOnThing) [0x000d5] in <a5819ca3c3f4425ba9eae35fe9c21567>:0
  at Assets.Scripts.Objects.Items.DynamicThingConstructor.OnUseSecondary (System.Boolean doAction, System.Single actionCompletionRatio) [0x0004d] in <a5819ca3c3f4425ba9eae35fe9c21567>:0
  at OnServer.UseItemSecondary (Assets.Scripts.Objects.Thing parent, System.Int32 slotId, System.Single completedRatio) [0x0001a] in <a5819ca3c3f4425ba9eae35fe9c21567>:0
  at Assets.Scripts.Inventory.InventoryManager.UseSecondaryComplete () [0x0000c] in <a5819ca3c3f4425ba9eae35fe9c21567>:0
  at Assets.Scripts.Inventory.InventoryManager+<WaitUntilDone>d__237.MoveNext () [0x001e2] in <a5819ca3c3f4425ba9eae35fe9c21567>:0
  at UnityEngine.SetupCoroutine.InvokeMoveNext (System.Collections.IEnumerator enumerator, System.IntPtr returnValueAddress) [0x00026] in <c39a522eee05469b8171a6cfeb646c59>:0

OnUseItem: create, colour, then consume

OnUseItem (349686-349712) does three things in a fixed order, with no try around any of them:

  1. Create: OnServer.CreateOld(ConstructedPrefab, vector2, rotation, RootParent.OwnerClientId) (349705), in front of the builder (vector2 = RootParent.ThingTransformPosition + vector + (Vector3.up - Vector3.up * ConstructedPrefab.Bounds.center.y), 349700).
  2. Colour: OnServer.SetCustomColor(thing, CustomColor.Index) when PaintableMaterial != null && CustomColor.Normal != null (349706-349709).
  3. Consume: base.OnUseItem(quantity, useOnThing) (349711), which is Stackable.OnUseItem (361838) and does Quantity -= (int)quantity (361845). See Stackable.

The colour gate passes for a kit that was never painted. Thing.Awake seeds CustomColor from PaintableMaterial (Thing, "Initial CustomColor by spawn path"), and a freshly spawned ItemKitDynamicMKIILiquidCanister read PaintableMaterial ColorBlue, CustomColor.Normal ColorBlue and CustomColor.Index 0 at runtime (table below). So every build from a paintable kit calls OnServer.SetCustomColor, whether the kit was painted or not.

Because the consume step comes last, an exception that escapes the colour step leaves the portable built and the stack untouched, and every further build does the same. Verified 2026-09-21 on a listen host with Spray Paint Plus v1.10.1, whose Harmony prefix on OnServer.SetCustomColor throws MissingFieldException on every call against this game version (StaleModReferenceJitCrash):

Moment Kit stack Quantity DynamicMKIILiquidCanisterEmpty within 40 m
before build 1 5 0
after build 1 5 1
after build 2 5 2
after build 3 5 3

Each build was a right-mouse hold of 3 to 4 s, and each one logged the exception above. Control on the same save, with a Spray Paint Plus development build that does not throw there: the same three holds built three tanks, took the stack 5, 4, 3, 2, and logged nothing. The counts come from GET /nearby?radius=40 scans filtered on LiquidCanister or MKIILiquidCanister, and from GET /thing?refId=<kit>&fields=Quantity, before and after each hold.

The same create-colour-consume order, and its contrast with the consume-first Constructor and MultiConstructor kits, is on OnServer ("Build a portable from a kit").

Kit prefabs in this build

A runtime prefab scan on 2026-09-21 (GET /prefabs?type=DynamicThingConstructor, 2,130 prefabs scanned) found 18 kits of this type:

ItemDynamicAirCon
ItemDynamicScrubber
ItemKitAIMeE
ItemKitCrate
ItemKitCrateMkII
ItemKitDynamicAFrameStripes
ItemKitDynamicAFrameWIP1
ItemKitDynamicBarrier
ItemKitDynamicCanister
ItemKitDynamicChannelizer
ItemKitDynamicGasTankAdvanced
ItemKitDynamicGenerator
ItemKitDynamicHydroponics
ItemKitDynamicLiquidCanister
ItemKitDynamicMKIILiquidCanister
ItemKitDynamicWorkCone
ItemKitOrbitalLaunchMountPayload
ItemKitPayloadDeliveryContainer

The Mk II portable liquid tank kit is ItemKitDynamicMKIILiquidCanister, display name "Kit (Portable Liquid Tank Mk II)", and its ConstructedPrefab is DynamicMKIILiquidCanisterEmpty (a DynamicGasCanister). There is no prefab named ItemKitLiquidTankMkII in this build: GET /prefabs?contains=KitLiquidTank returns only ItemKitLiquidTank and ItemKitLiquidTankInsulated, both MultiConstructor kits for the structure tanks.

Values of a stack spawned into the hand (POST /inventory/arm, which spawns through OnServer.SpawnDynamicThingMaxStack), read with GET /thing?refId=<kit>&fields=Quantity,MaxQuantity,PaintableMaterial,CustomColor,CustomColor.Normal,CustomColor.Index,ConstructedPrefab.PrefabName,UsedQuantity,ConstructionTime,HasColorState,IsPaintable. The prefab column is the untouched prefab asset as the rig reports it; its CustomColor has not been through Awake.

Member Spawned stack Prefab
Quantity 5 (10 was requested) 1
MaxQuantity 5 5
ConstructionTime 1 1
UsedQuantity 1 1
ConstructedPrefab.PrefabName DynamicMKIILiquidCanisterEmpty DynamicMKIILiquidCanisterEmpty
PaintableMaterial ColorBlue ColorBlue
CustomColor.Normal ColorBlue null
CustomColor.Index 0 -1
HasColorState false false
IsPaintable true true

Driving it from the test rig

POST /player/use cannot build from a kit. It calls OnServer.AttackWith on a target Thing (UseOnTarget in TestRig/dev-plugins/TestRig/TestRig/Routes/Routes.Player.cs), which is the primary tool path, and no route in the plugin calls OnUseSecondary or OnServer.UseItemSecondary. What worked on 2026-09-21: POST /inventory/arm {"prefab":"ItemKitDynamicMKIILiquidCanister","hand":"activeHand","quantity":10,"replace":true}, then POST /input/mouse {"button":1,"mode":"down"}, a wait longer than ConstructionTime, and POST /input/mouse {"button":1,"mode":"up"}. That input goes through InventoryManager.NormalMode exactly as a player's does.

Verification history

  • 2026-09-21: page created at 0.2.6428.27798 from the Assembly-CSharp decompile (the lines cited above) and a TestRig reproduction on a listen host: the three-build duplication with Spray Paint Plus v1.10.1, the control run with a development build, the two prefab scans and the kit's instance values. The create-colour-consume ordering was already on OnServer ("Build a portable from a kit"); this page adds the input path, the remote-client route, the runtime confirmation and the prefab facts. No conflict with existing content.

Open questions

None at creation.