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.
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.
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.
Open questions¶
None at creation.