InventoryManager¶
Vanilla game class routing per-frame primary-use clicks against held items. The correct hook point for intercepting click-based item interactions, because neither SensorLenses nor AdvancedTablet set AllowSelfUse = true.
HandlePrimaryUse sequence¶
Source: F0116.
Called every frame from input processing. Sequence for a held item:
1. If CursorManager.CursorThing is targetable and the held item supports AttackWith on it, call AttackWith (item-on-thing interaction).
2. Otherwise call UseItemOnSelf(item), which early-returns when item.AllowSelfUse == false.
3. For AllowSelfUse == true items, UseItemOnSelf dispatches to item.OnUsePrimary().
AdvancedTablet.AllowSelfUse == false, so patching Item.OnUsePrimary never fires for a plain tablet. InventoryManager.HandlePrimaryUse is the correct hook for tablet-level click intercepts because it runs regardless of AllowSelfUse. This is why the pre-refactor CartridgeCyclePatch on Item.OnUsePrimary never worked in practice, and why the current ClickCyclePatch targets InventoryManager.HandlePrimaryUse.
KeyManager.GetMouseDown("Primary") is a thin forwarder to Input.GetKeyDown(KeyMap.PrimaryAction), frame-stable, idempotent across multiple reads within a single Update tick. Multiple patches can read it in the same frame without interference.
ClickCyclePatch class header (F0334)¶
Source comment from ClickCyclePatch.cs:14-30:
/// <summary>
/// Ctrl+left-click while holding a SensorLenses or AdvancedTablet cycles
/// to the next loaded chip / cartridge.
///
/// We patch <c>InventoryManager.HandlePrimaryUse</c> because neither
/// vanilla prefab sets <c>AllowSelfUse = true</c>, so clicks never reach
/// <c>Item.OnUsePrimary</c> via <c>UseItemOnSelf</c>. HandlePrimaryUse is
/// the per-frame click handler and runs on the clicking peer regardless
/// of the item's AttackWithEvent. Detecting modifier + click there fires
/// identically in single-player, on the host, and on a remote client --
/// no server-side dispatch concerns, no modifier-state mirroring needed.
///
/// When the cycle fires we return <c>false</c> to suppress the rest of
/// HandlePrimaryUse; vanilla would otherwise try an AttackWith on the
/// cursor target with our item, which for the tablet/lenses does nothing
/// useful anyway but we don't want the side effect.
/// </summary>
Secondary-use (right-click) routing¶
InventoryManager.NormalMode handles the secondary-action (right-click) path inline, NOT via a dedicated HandleSecondaryUse method (unlike the primary path which factors out into HandlePrimaryUse). Decompile line ~2159 (Assets.Scripts.Inventory.InventoryManager.NormalMode):
if ((bool)ActiveHand.Slot.Occupant && KeyManager.GetMouseDown("Secondary"))
{
DynamicThing occupant2 = ActiveHand.Slot.Occupant;
if (!(occupant2 is Constructor constructorItemPlacement))
{
if (!(occupant2 is MultiConstructor multiConstructorItemPlacement))
{
if (!(occupant2 is AuthoringTool))
{
if (occupant2 is Item item)
{
Thing.DelayedActionInstance delayedActionInstance = item.OnUseSecondary();
if (delayedActionInstance != null && delayedActionInstance.Duration > 0f)
{
ActionCoroutine = StartCoroutine(WaitUntilDone(...));
}
else if (GameManager.RunSimulation)
{
OnServer.UseItemSecondary(Parent, ActiveHand.SlotId, LastCompletedRatio);
}
else
{
NetworkClient.UseItemSecondary(Parent, ActiveHand.SlotId, LastCompletedRatio);
}
}
}
else { /* AuthoringTool placement path */ }
}
else { SetMultiConstructorItemPlacement(multiConstructorItemPlacement); }
}
else { /* Constructor placement path */ }
}
Notable differences from the primary path:
- No
CursorManager.CursorThingbranch: secondary-use does NOT route throughAttackWithon the cursor target.OnUseSecondary()is invoked directly on the held item with no target argument, and noAllowSelfUsegate exists for secondary-use. Item.OnUseSecondary(bool doAction = false, float actionCompletedRatio = 1f)(decompile line ~399 ofAssets.Scripts.Objects.Item) returnsnullby default. Any item that wants to react to right-click must override it; otherwise the wholeif (occupant2 is Item item) { ... }block exits with no action dispatched.SprayCan : Consumable, ISprayer, IUsedAmount, IUseddoes NOT overrideOnUseSecondary. Vanilla right-click on a held spray can is a no-op.Constructor,MultiConstructor, andAuthoringToolare intercepted BEFORE theItembranch and get placement-mode behavior instead ofOnUseSecondary.KeyManager.GetMouseDown("Secondary")is the frame-stable secondary-click read, analogous toGetMouseDown("Primary")inHandlePrimaryUse.
Re-read at 0.2.6428.27798 on 2026-09-21: the block sits at lines 293943-293968 of the full-assembly decompile and is unchanged apart from the decompiler's local name (dynamicThing for occupant2). Item.OnUseSecondary (311322) still returns null, and SprayCan (361018) still overrides only ConstructingSoundHash, FinishedConstructingSoundHash, UseDefaultUiUsingSounds and OnUseItem. One kit type that does override OnUseSecondary is DynamicThingConstructor: its right-click hold builds a portable (DynamicThingConstructor). The secondary branch assigns ActionCoroutine without testing it first; see "Timed actions" below.
Hook choices for a right-click intercept on a specific item type:
- Patch
Item.OnUseSecondarywith a filter likeif (!(__instance is SprayCan can)) return true;. The virtual returnsnull, so a postfix that sets__resultto a non-nullDelayedActionInstancealso flows through theduration > 0f ? coroutine : OnServer.UseItemSecondary / NetworkClient.UseItemSecondarybranch; a prefix that returnsfalseafter setting__result = nullsuppresses that dispatch entirely. This is the clean choice when the intercept is per-item-type and doesn't need to inspect the cursor target before the check order described above. - Patch
InventoryManager.NormalMode(same target the existingColorCyclerPatchuses) to inspectKeyManager.GetMouseDown("Secondary")before the vanilla block fires. This is the correct choice when the intercept needsCursorManager.CursorThing(secondary routing itself does not supply a target), needs to run regardless of item type, or needs to suppress the vanilla secondary-use dispatch for types that already overrideOnUseSecondary.
For the spray-can eyedropper, patching at the InventoryManager.NormalMode layer mirrors the existing ColorCyclerPatch and keeps all client-side spray-can input polling in one place. Reading CursorManager.CursorThing at that layer gives the looked-at Thing client-side with no server round-trip, consistent with the attack-resolve path (OnServer.AttackWith receives a resolved Thing, but the client already knows the target via CursorThing in HandlePrimaryUse; the secondary path does not auto-resolve a target, so a client-side read of CursorThing is both the only and the correct source).
Parallel secondary-action dispatch via KeyManager¶
Right-click has a SECOND dispatch edge independent of NormalMode. KeyManager.cs line 141: KeyMap._SecondaryAction.Bind(InputPhase.Up, ToggleActiveHandTool, KeyInputState.Game). The mouse-UP edge triggers HumanHandsBehaviour.ToggleActiveHandTool (also bound at line 140 to KeyMap._ToggleHandPower).
public void ToggleActiveHandTool()
{
if (!_human.IsUnresponsive && !_human.IsSleeping && !InputMouse.IsMouseControl && !InputWindowBase.IsInputWindow)
{
Slot slot = ActiveHand.Slot;
if ((bool)slot.Occupant && slot.Occupant.CheckTogglePower() && slot.Occupant.ShouldToggleOn())
{
int state = ((!slot.Occupant.OnOff) ? 1 : 0);
slot.Occupant.Interact(InteractableType.OnOff, state);
PanelHands.Instance.HandlePowerOnSwitch(slot.Display.SlotDisplayButton.IsLeftHand);
}
}
}
DynamicThing.CheckTogglePower() (line 1085) defaults to return CanTogglePower;. Tool.CheckTogglePower() (line 50) overrides with return base.CanTogglePower;. Consumable has no override. SprayCan : Consumable therefore resolves to the DynamicThing default (returns CanTogglePower); on the can prefab CanTogglePower is false, so ToggleActiveHandTool early-returns at the CheckTogglePower() gate without calling Interact. The mouse-up edge is therefore a no-op for a bare can in vanilla. The gun sets the prefab flag true, which is why its right-click toggle fires (see SprayGun.md "OnOff state, toggle, label").
Timing: NormalMode fires on GetMouseDown (edge-triggered) at line 2159; the KeyManager binding fires on InputPhase.Up (edge-triggered on release). Both edges are reachable in a single right-click gesture; they do not conflict because they fire at different phases, and both are no-ops for a bare can in vanilla. A mod that intercepts NormalMode's mouse-down edge for the can does not interact with the ToggleActiveHandTool mouse-up edge.
Timed actions: ActionCoroutine and the WaitUntilDone coroutines¶
Line numbers in this section refer to the 0.2.6428.27798 decompile. Every timed hand action runs as a coroutine, and one field records that an action is in progress:
(292057). Five WaitUntilDone overloads are the only code that sets it back to null: mining (294898), interactable interaction (294937), structure placement (294993), primary tool use on a Thing (295047) and secondary use (295112). Each clears it on its cancel branch and on its completion branch. Nothing else in the assembly writes it, and nothing calls StopCoroutine on it.
Who checks it. Two starters test it before starting a coroutine: terrain mining (293886 for the tooltip, 293900 for the start) and the primary tool path in HandlePrimaryUse (294029), which NormalMode calls on every frame the primary button is held (293921-293926). The gate, verbatim (294049-294080):
Thing.DelayedActionInstance delayedActionInstance = ((CanAttackWith(CursorManager.Instance.CursorTargetCollider) || item is AuthoringTool) ? CursorManager.CursorThing.AttackWith(attack, doAction: false) : null);
if (ActionCoroutine == null && delayedActionInstance != null && !delayedActionInstance.IsDisabled)
{
if (CursorManager.CursorThing.ReferenceId != _lastActionedID)
{
if (delayedActionInstance.Duration > 0f)
{
if ((bool)item)
{
ActionCoroutine = StartCoroutine(WaitUntilDone(UseItemComplete, delayedActionInstance, item.ConstructingSoundHash, item.FinishedConstructingSoundHash));
}
else
{
ActionCoroutine = StartCoroutine(WaitUntilDone(UseItemComplete, delayedActionInstance, 0, 0));
}
}
else
{
LastCompletedRatio = 1f;
UseItemComplete();
}
_lastActionedID = CursorManager.CursorThing.ReferenceId;
}
else
{
_lastActionedID = 0L;
}
}
else if ((bool)item)
{
UseItemOnSelf(item);
}
The other starters overwrite the field without looking at it: the interactable path (293793-293798), secondary use (293943-293957) and structure placement (294581-294593).
The completion order. Four of the five overloads call the completion callback before they clear the field. Primary tool use (295095-295099):
_primaryAvailable = true;
LastCompletedRatio = 1f;
_uiProgressBarPanel.SetActive(active: false);
OnComplete();
ActionCoroutine = null;
Secondary use (295144-295156):
LastCompletedRatio = 1f;
_uiProgressBarPanel.SetActive(active: false);
StopWaitUntilDoneSound(success: true, playUiSounds: false);
if (actionCompleteSoundHash != 0)
{
ParentHuman.PlayNetworkSound(actionCompleteSoundHash);
}
if (onFinished != null)
{
OnComplete();
}
ActionCoroutine = null;
_parentAnimator.SetBool(MovementController.CastingHash, value: false);
Mining (294931, then 294932) and structure placement (295041, then 295042) have the same order. The interactable overload is the exception: it clears the field (294972) before it calls OnServer.InteractWith (294985). Every cancel branch clears the field before its callback (for example 295075 before 295083, and 295130 before 295134).
One exception locks primary tool use. When OnComplete() throws on a completion branch, the exception leaves the coroutine's MoveNext (Unity logs it from SetupCoroutine.InvokeMoveNext), the statements after OnComplete() never run, and ActionCoroutine keeps a non-null reference to a coroutine that has ended. From then on HandlePrimaryUse fails the ActionCoroutine == null test on every frame and takes the else branch, UseItemOnSelf(item) (294079), which returns at once unless the held item's prefab sets AllowSelfUse (294107-294112). No held tool can be used on any Thing and no terrain can be mined, and nothing is logged, until something clears the field.
Observed 2026-09-21 on a TestRig listen host with Spray Paint Plus v1.10.1 loaded, whose prefix on OnServer.SetCustomColor throws on every call against this game version (StaleModReferenceJitCrash). A spray-can hold on a cable threw out of the primary coroutine (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.ISprayer.DoSpray (Assets.Scripts.Objects.Thing thing, Assets.Scripts.Objects.Items.ISprayer sprayer, System.Boolean doAction) [0x001c9] in <a5819ca3c3f4425ba9eae35fe9c21567>:0
at (wrapper dynamic-method) Assets.Scripts.Objects.Thing.DMD<Assets.Scripts.Objects.Thing::AttackWith>(Assets.Scripts.Objects.Thing,Assets.Scripts.Objects.Attack,bool)
at Assets.Scripts.Objects.Structure.AttackWith (Assets.Scripts.Objects.Attack attack, System.Boolean doAction) [0x00a10] in <a5819ca3c3f4425ba9eae35fe9c21567>:0
at Assets.Scripts.Objects.Electrical.Cable.AttackWith (Assets.Scripts.Objects.Attack attack, System.Boolean doAction) [0x0009c] in <a5819ca3c3f4425ba9eae35fe9c21567>:0
at (wrapper dynamic-method) OnServer.DMD<OnServer::AttackWith>(Assets.Scripts.Objects.Thing,byte,byte,long,UnityEngine.Vector3,single,bool,bool)
at (wrapper dynamic-method) Assets.Scripts.Inventory.InventoryManager.DMD<Assets.Scripts.Inventory.InventoryManager::UseItemComplete>(Assets.Scripts.Inventory.InventoryManager)
at Assets.Scripts.Inventory.InventoryManager+<WaitUntilDone>d__236.MoveNext () [0x002ab] in <a5819ca3c3f4425ba9eae35fe9c21567>:0
at UnityEngine.SetupCoroutine.InvokeMoveNext (System.Collections.IEnumerator enumerator, System.IntPtr returnValueAddress) [0x00026] in <c39a522eee05469b8171a6cfeb646c59>:0
The next spray-can hold in the same session, on a pipe (spray can armed, cursor forced onto the pipe, POST /input/mouse button 0 held for 3.5 s, input reported consumed by the game), logged nothing and left the pipe's colour unchanged. Because that prefix throws on every call that reaches it, the silence shows the stroke never got as far as OnServer.SetCustomColor. After a process restart the identical hold on the same pipe threw again. Between the two holds, three portable-kit builds (DynamicThingConstructor) still worked, because the secondary starter does not test the field; each of them also threw out of its own completion branch (<WaitUntilDone>d__237), so each left the field non-null in turn. Two direct OnServer.AttackWith calls from the rig (POST /player/use) in the same window reached the throwing prefix too: they do not go through InventoryManager at all.
What clears it, from the code (not exercised at runtime). A starter that does not test the field replaces it with a live coroutine, and that coroutine clears it on its own cancel or completion branch: a timed interactable interaction (duration above zero), a secondary action that completes without throwing or is released early, or a structure placement that completes or is abandoned. An instant interaction or an instant secondary action never touches the field.
The lock is local to the process whose coroutine threw. For a spray stroke from a remote client, the client's UseItemComplete calls OnServer.AttackWith, which on a client runs the target's AttackWith with doAction false and then sends AttackWithMessage; the colour write runs on the server inside AttackWithMessage.Process (283200-283211), so a throw there does not pass through the client's coroutine. See OnServer ("Related colour-write facts").
Verification history¶
- 2026-04-20: page created from the Research migration; verbatim content lifted from F0116, F0334. No conflicts.
- 2026-04-22: added "Secondary-use (right-click) routing" section. Additive only; no existing content changed. Source: decompile of
Assets.Scripts.Inventory.InventoryManager.NormalMode(line ~2159) andAssets.Scripts.Objects.Item.OnUseSecondary(line ~399) in game version 0.2.6228.27061. ConfirmedSprayCandoes not overrideOnUseSecondaryby decompilingAssets.Scripts.Objects.Items.SprayCan(noOnUseSecondary/AllowSelfUsemembers declared). - 2026-04-22: added "Parallel secondary-action dispatch via KeyManager" subsection documenting the second right-click edge via
KeyManager.cs:141(KeyMap._SecondaryAction.Bind(InputPhase.Up, ToggleActiveHandTool)) and theCheckTogglePower/ShouldToggleOngates (DynamicThing.cs:1085,Tool.cs:50,DynamicThing.cs:1179) that make the mouse-up edge a no-op for a bareSprayCanbecauseConsumableinherits theCanTogglePower == falsedefault. Additive. - 2026-09-21: added "Timed actions: ActionCoroutine and the WaitUntilDone coroutines" at 0.2.6428.27798: the busy flag (292057), the two starters that test it and the three that overwrite it, the callback-before-clear order in four of the five
WaitUntilDoneoverloads, and the resulting lock on primary tool use, observed on a TestRig listen host with Spray Paint Plus v1.10.1 and read from the code. The clearing paths are from the code only. Re-read "Secondary-use (right-click) routing" against 0.2.6428.27798 (293943-293968, 311322, 361018), found its claims unchanged, and restamped it with the new line references and theDynamicThingConstructoroverride. "HandlePrimaryUse sequence", "ClickCyclePatch class header" and "Parallel secondary-action dispatch via KeyManager" were not re-read and keep their 0.2.6228.27061 stamps. Additive; no existing claim changed.
Open questions¶
None at creation.