DynamicThing¶
Assets.Scripts.Objects.DynamicThing is the Thing subclass for objects that can
be carried and can live in a slot. Two things are recorded here so far: a breaking
change (SetSlotTypes was removed), and how an item goes into a slot (MoveToSlot
and the container hooks it calls).
SetSlotTypes(Slot.Class) was REMOVED in 0.2.6420.27780¶
Verified 2026-08-12 in 0.2.6420.27780, re-confirmed 2026-08-13 in 0.2.6428.27798
(the same metadata string scan, with DynamicThing still passing as the control).
The removal has held across two game builds, so treat it as permanent rather than as
an accident of one release. The method
void Assets.Scripts.Objects.DynamicThing.SetSlotTypes(Assets.Scripts.Objects.Slot.Class)
existed in 0.2.6403.27689 and does not exist in 0.2.6420.27780.
Evidence, two independent kinds:
1. Metadata. The string SetSlotTypes does not occur anywhere in the new
Assembly-CSharp.dll, while DynamicThing does (control). .NET stores method names
in the #Strings heap, so a method whose name is absent from the assembly's bytes
does not exist in it. The same scan finds SetSlotTypes present in
ScriptedScreens.dll, which was compiled against the older build.
2. Runtime. A client with mods that call it throws
MissingMethodException: Method not found: void Assets.Scripts.Objects.DynamicThing.SetSlotTypes(Assets.Scripts.Objects.Slot/Class)
from two different call sites, in this order:
# a mod's own entrypoint
StationeersLaunchPad.Entrypoints.StationeersModsEntrypoint.Initialize (LoadedMod mod)
StationeersLaunchPad.Loading.LoadedMod.LoadEntrypoints ()
StationeersLaunchPad.Loading.LoadStrategyLinearSerial.LoadEntryPoints ()
# and the GAME'S OWN prefab load, once the first caller is disabled
Assets.Scripts.Objects.Prefab.LoadAll ()
WorldManager.LoadGameDataAsync ()
WorldManager.Initialize ()
Assets.Scripts.GameManager.Start ()
Why this matters beyond the mod that calls it¶
The second stack is the important one. When the throwing call happens inside
Prefab.LoadAll(), it propagates through WorldManager.Initialize() and out of
GameManager.Start(), which never completes. The client then sits at
phase == "menu" with gameInitialized == false for ever, rendering normally at
full frame rate, with every mod apparently loaded. One third-party mod calling a
removed method is enough to stop the client reaching a usable main menu, and the
symptom does not name the mod: the BepInEx LogOutput.log stops early and records
nothing about it. Only the Unity player log carries the exception.
That failure is easy to misread as StationeersLaunchPad's documented Workshop park, which is a different condition with a different signature:
| plugin count | gameInitialized | cause | |
|---|---|---|---|
| Workshop park | <= 2 |
false | a failed Steam Workshop query |
| this | full count (36-37 observed) | false | a removed method thrown out of Prefab.LoadAll |
Known callers: two, for one day¶
Scanning every Workshop assembly under steamapps\workshop\content\544550 for the
string on 2026-08-12 found exactly two, both by the same author, and both failed on
that build:
ScriptedScreens.dll(Workshop 3666779631), version 0.9.5.0StationeersLua.dll(Workshop 3659911735), version 0.9.5.0
Disabling only the first was not enough: the second then threw the same exception
from Prefab.LoadAll instead of from its own entrypoint, so both had to be disabled
for the client to boot.
Both shipped 1.0.0.0 on 2026-08-13 and neither calls the method any more. The
same metadata string scan against the updated assemblies returns SetSlotTypes
absent from both, with DynamicThing still present in each as the control. The
window in which this stopped a client from booting was about one day wide, and the
"disable these two" instruction that briefly lived in this repository's rig notes is
retired. As of 2026-08-13 the scan finds no caller in any installed Workshop
assembly.
| 0.9.5.0 (2026-08-12) | 1.0.0.0 (2026-08-13) | |
|---|---|---|
ScriptedScreens.dll calls SetSlotTypes |
yes | no |
StationeersLua.dll calls SetSlotTypes |
yes | no |
| Client reaches a usable menu with both enabled | no | yes |
No replacement method was identified, and none was needed by either caller.
SetSlotType (singular) is also absent from 0.2.6420.27780 and from
0.2.6428.27798, so this was never a rename to the obvious neighbouring name.
MoveToSlot and the container hooks¶
Read in the 0.2.6428.27798 client and dedicated server decompiles. Every body cited in this section is textually identical in the two (each was extracted from both files and compared), so client line numbers come first and the dedicated server's follow in brackets where they were pinned.
DynamicThing.MoveToSlot (306632-306697 [293025-293090]) is how an item goes into a slot. Verbatim:
public virtual bool MoveToSlot(Slot destinationSlot, Thing originThing, bool forced = false)
{
if (base.IsBeingDestroyed || destinationSlot == null)
{
return false;
}
if (!CanEnter(destinationSlot))
{
return false;
}
if ((bool)destinationSlot.Occupant)
{
return false;
}
foreach (MachineInputTrigger currentMachineInputTrigger in CurrentMachineInputTriggers)
{
currentMachineInputTrigger.ParentMachine.OnInputTriggerExit(this, currentMachineInputTrigger);
}
if (IsChild)
{
Thing parent = ParentSlot.Parent;
Slot parentSlot = ParentSlot;
ParentSlot = null;
parentSlot.Empty();
parent.OnChildExitInventory(this);
parentSlot.RefreshSlotDisplay();
OnExitInventory(parent);
parentSlot.OnExitEvent();
HandleCollisionWith(parentSlot.Parent, ignore: false);
if ((bool)Joint)
{
UnityEngine.Object.Destroy(Joint);
RigidBody.mass = _startMass;
if (parent.HasAuthority)
{
RigidBody.interpolation = RigidbodyInterpolation.Interpolate;
}
}
}
else
{
base.WorldGrid = new WorldGrid(CenterPosition);
base.GridController.AlertGridWatchers(base.WorldGrid, this, GridEvent.GridEventType.Leave);
}
if (IsForceKinematic)
{
ForceKinematicClient(kinematic: false, localCheck: true);
}
destinationSlot.Take(this);
ParentSlot = destinationSlot;
Transform parent2 = (destinationSlot.Location ? destinationSlot.Location : destinationSlot.Parent.ThingTransform);
SetPhysics(on: false);
ThingTransform.SetParent(parent2);
base.ThingTransformLocalPosition = Vector3.zero;
base.ThingTransformLocalRotation = Quaternion.identity;
ParentSlot.RefreshSlotDisplay();
ParentSlot.Parent.OnChildEnterInventory(this);
OnEnterInventory(ParentSlot.Parent);
ParentSlot.OnEnterEvent();
HandleCollisionWith(ParentSlot.Parent);
if (!(this is Human))
{
LodManager.EnqueueRequesterToRemove(this);
}
return true;
}
Refusals, in order: a Thing being destroyed or a null slot (306634), a failed CanEnter (306638 [293031]), an occupied slot (306642 [293035]). originThing and forced are not read in the body. Six classes override MoveToSlot (Entity, Ice, BodyArmor, Clothing, Suit, SuitBase; see SlotInsertionBlock), and each override calls base.MoveToSlot once.
The exit half runs only for an item that is already in a slot (if (IsChild), 306650). The item forgets its slot (ParentSlot = null;, 306654), the old slot is emptied (parentSlot.Empty();, 306655 [293048]), and only then does the old container hear of it (parent.OnChildExitInventory(this);, 306656 [293049]). The old slot's display, the item's own OnExitInventory and the slot's OnExitEvent follow (306657-306659).
The enter half: destinationSlot.Take(this); (306680 [293073]) and ParentSlot = destinationSlot; (306681), the transform parenting (306682-306686), ParentSlot.RefreshSlotDisplay(); (306687 [293080]), then the container hook ParentSlot.Parent.OnChildEnterInventory(this); (306688 [293081]), then the item's own OnEnterInventory(ParentSlot.Parent); (306689 [293082]) and the slot's ParentSlot.OnEnterEvent(); (306690 [293083]). So when a container's OnChildEnterInventory runs, the item is already in the slot and the slot's display has been refreshed, and the item's own enter hook has not run yet. Slot.Take (317067-317085 [303451-303469]) raises the slot's own OnOccupantChange event when the occupant changes, earlier still:
public void Take([JetBrains.Annotations.NotNull] DynamicThing child)
{
if (Assets.Scripts.Networking.NetworkManager.IsServer)
{
if ((bool)child)
{
child.NetworkUpdateFlags |= 1;
}
if (child != _dynamicThing && (bool)_dynamicThing)
{
_dynamicThing.NetworkUpdateFlags |= 1;
}
}
if (!(_dynamicThing == child))
{
_dynamicThing = child;
OnOccupantChange?.Invoke();
}
}
OnChildEnterInventory has one caller. Thing declares it, public virtual void OnChildEnterInventory(DynamicThing newChild) (327135 [313519]), and apart from the overrides' own base.OnChildEnterInventory calls, the line in MoveToSlot above is the only call of it in either build. 81 classes override it, and every one of those overrides calls base.OnChildEnterInventory exactly once, unconditionally, so a Harmony postfix on Thing.OnChildEnterInventory runs once for every item MoveToSlot puts into any container. SprayGun, Tool, ItemRenamable, Item and DynamicThing declare neither OnChildEnterInventory nor OnChildExitInventory, so a TargetMethod that looks the name up through any of them gets Thing's own method (see HarmonyInheritedMethods). Thing's body (327135-327152) sets the child's scale, raises the container's OnSlot event through CheckOnSlot() and calls SetSlotOccupantTransformData(newChild).
OnChildExitInventory has three callers, all in DynamicThing, and each runs after the slot is emptied. Thing declares it, public virtual void OnChildExitInventory(DynamicThing previousChild) (327163 [313547]); 73 classes override it, and all of them except RoboticArmDockCollector (176092) call base. The callers:
MoveToSlot, above (306656 [293049], afterparentSlot.Empty();at 306655).MoveToWorld(Vector3 worldPosition, Quaternion worldRotation, Vector3 velocity, Vector3 angularVelocity, float force = 0f)(306769-306860 [293162-293253]):ParentSlot?.Empty();(306830),ParentSlot = null;(306831),parent.OnChildExitInventory(this);(306832 [293225]). The parameterlessMoveToWorld(float force = 0f)(306877-306900) ends in a call to it (306899).OnDestroy(306492-306531 [292885-292924]):ParentSlot.Empty();(306520),ParentSlot = null;(306521),parent.OnChildExitInventory(this);(306526 [292919]).OnDestroycallsMoveToWorld()first (306505), and its own call sits behindif (ParentSlot != null)(306516), so it runs only for an item that is still in a slot after that. When the game is quitting (306494) orGameManager.GameStateisNone(306500),OnDestroyreturns before either.
The paths that restore or mirror a placement all go through MoveToSlot. Apart from the overrides' base calls, the client decompile has seven direct calls of it: OnServer.MoveToSlot (39546), MoveToSlotMessage.Process (282161, deferred until the InventoryManager exists; otherwise it uses OnServer.MoveToSlot, 282166), CryoTube.OnChildExitInventory returning its mask (376072), and the four load, join and update call sites in the list below (305538, 305583, 307211, 307271). Almost every placement a player, an interaction or a machine makes goes through OnServer.MoveToSlot, which is called from 151 places. The ones that put a saved or remote item into its slot:
- a save load:
DynamicThing.MoveToParent(MoveToSlot(thing.Slots[parentSlotId], thing);, 305538 [291931]), orMoveToParentWhenReadywhen the container is not registered yet (305583 [291976]); see SaveLoadOrdering; - a multiplayer join:
DynamicThing.DeserializeOnJoin(MoveToSlot(thing.Slots[index], thing);, 307211 [293604]); - a Thing a server sends mid-session:
Thing.DeserializeNew(328208-328217 [314592-314601]), which isCreate<Thing>followed bything.DeserializeOnJoin(reader);(328215); - a client applying the server's parent change:
DynamicThing.ProcessUpdateTransform(307245-307277 [293638-293670]). It sends any other occupant of the target slot to the world first (slot.Occupant?.MoveToWorld();, 307261), then callsMoveToSlot(slot, thing);(307271 [293664]); a parent id of 0 sends the item to the world instead (307275). Only a client reaches it:NetworkManager.ReceiveEventshands aNetworkChannel.StateTickpacket toFragmentHandler.ReceiveStateonlyif (NetworkRole == NetworkRole.Client)(279287-279291), and from thereApplyState(218234-218240) callsReadStateImmediate(218119), which callsThing.DeserializeDeltaState(328248-328267), whosething.ProcessUpdate(reader, networkUpdateType);(328265) callsProcessUpdateTransform(reader);when update bit 1 is set (328370-328373); - a normal insertion on the authority:
OnServer.MoveToSlot(39540-39557 [39536-39553]) callschildThing.MoveToSlot(slot, slot.Parent);(39546 [39542]) whenGameManager.RunSimulation, and otherwise sends aMoveToSlotMessageto the server.
A dragged item does not go through MoveToSlot. DynamicThing.DragInSlot (305907-305933 [292300-292326]) is taken for a saved item whose record says it was dragged (305552, 305579), for a join record with the drag flag (307207), and in ProcessUpdateTransform for a DraggableThing or Entity moving into an Entity (307263-307268). It takes the slot (destinationSlot.Take(this);, 305928) and calls CheckOnSlot() (305931), and calls neither container hook.
A swap empties one slot before it fills the other. OnServer.SwapSlotsAsync (40123-40168 [40119-40164]) runs on the authority; on a client, OnServer.SwapSlots sends a SwapSlotsMessage instead (40005-40013). For two slots it calls the OnServer helpers in this order:
MoveToWorld(dynamicThing); // 40152
MoveToSlot(dynamicThing2, slot); // 40153
MoveToSlot(dynamicThing, slot2); // 40154
On the authority OnServer.MoveToWorld(DynamicThing, float) (39744-39758) calls dynamicThing.MoveToWorld(force) and OnServer.MoveToSlot calls childThing.MoveToSlot(slot, slot.Parent). Each container therefore sees an exit and then an enter, and the first slot is briefly empty. When the first item has a Joint (40142-40149), only the first two moves are made.
Verification History¶
- 0.2.6420.27780, 2026-08-12:
SetSlotTypesconfirmed absent by metadata string scan (withDynamicThingas a passing control) and by two distinct runtimeMissingMethodExceptionstacks. Callers enumerated across all installed Workshop assemblies. ThegameInitialized-never-true consequence observed directly on two separate boots of a rig instance. - 0.2.6428.27798, 2026-08-13: re-verified. The method is still absent from
Assembly-CSharp.dllon the newer build, so the removal held across the update. Additive finding, contradicting nothing above: both known callers shipped 1.0.0.0 on 2026-08-13 (file timestamps 15:50,<Version>1.0.0.0</Version>in eachAbout.xml) and the string scan now returns zero hits in either assembly. The caller list for this build is therefore empty, and the "both must be disabled" consequence no longer applies to any installed mod. The removal itself, its two runtime stacks and thePrefab.LoadAllblast radius are unchanged and still correct for any assembly compiled against the older API. - 0.2.6428.27798, 2026-09-27: added "
MoveToSlotand the container hooks", read in the client and dedicated server decompiles with every cited member extracted from both and compared (textually identical): the fullMoveToSlotbody, its refusal order and its exit and enter halves;Slot.TakeraisingOnOccupantChange;OnChildEnterInventorycalled only fromMoveToSlot(81 overrides, each callingbaseonce, unconditionally); the threeOnChildExitInventorycallers, each after the slot is emptied (73 overrides, all butRoboticArmDockCollectorcallingbase); the load, join,DeserializeNew, client-update andOnServer.MoveToSlotpaths intoMoveToSlot, with the client-onlyStateTickchain toProcessUpdateTransform;DragInSlottaking a slot without either hook; andOnServer.SwapSlotsAsyncmoving one item to the world before the other goes in. Raised by the Spray Paint Plus can-slot fix, whose decompile read (.work/2026-09-27-phase3-rig/M4-RESULT.mdsection 5) listed these points; each was re-read here, and the caller counts and theDragInSlotexception were added. Additive; theSetSlotTypessection was not re-read. Intro reworded to name both topics; an Open questions section added, which the page lacked.
Open questions¶
None.