InventoryWindow¶
Vanilla game class (Assets.Scripts.UI.InventoryWindow : DraggableWindow) rendering the inventory window for a held multi-slot item (tablet, lenses), and in general the window that shows the slots of whatever occupies one slot: a slot of the local player's character, or a slot inside another window (see "Window lifecycle"). Instantiates SlotDisplayButton widgets only at SetSlots time, when the window is built or rebuilt, based on each slot's IsInteractable at that instant.
Widget staleness¶
Source: F0121.
InventoryWindow.SetSlots only instantiates SlotDisplayButton widgets for slots where IsInteractable == true at the time it runs. Later changes to IsInteractable do not create or remove widgets. Must force HandleOccupantChange after any slot state change for the widget list to match.
When the window is closed (GameObject inactive), FindObjectsOfType<InventoryWindow>() skips it, so rebuilds during close are silently lost. WindowOpenRefreshPatch catches the re-open and forces a rebuild.
ToggleVisibility stale widgets comment (F0343)¶
Source comment from WindowOpenRefreshPatch.cs:10-30:
/// <summary>
/// InventoryWindow.ToggleVisibility only flips the canvas on/off; it does
/// not re-run HandleOccupantChange or SetSlots. Widgets the user sees are
/// whatever SetSlots built during the last Assign (when the lens/tablet
/// first went into its parent slot) or the last time the parent slot's
/// OnOccupantChange fired.
///
/// While the window is closed, our OnChildEnterInventory Postfix does
/// normalise dynamic-slot state, but its <c>RebuildInventoryWindowFor</c>
/// uses <c>FindObjectsOfType<InventoryWindow>()</c> which skips
/// inactive GameObjects, so a closed window never receives the rebuild.
/// When the user later opens the window it shows the stale widget set:
/// if FixingTheControls "Clear Hands" filled two chip slots while closed,
/// the user sees one slot less than the number of chips present.
///
/// Hook ToggleVisibility as a Postfix so that the moment the window
/// becomes visible, we normalise slot state and force a full
/// HandleOccupantChange to rebuild the widget list against the
/// current slots. HandleOccupantChange is private; reached via
/// AccessTools reflection.
/// </summary>
The comment's last sentence is wrong at 0.2.6428.27798. HandleOccupantChange is declared public void HandleOccupantChange() (247432 [234544]) on the public, top-level class Assets.Scripts.UI.InventoryWindow, and the installed client and dedicated server Assembly-CSharp.dll both carry it as Public, HideBySig. A mod calls it directly, as Equipment Plus's own DynamicSlots.RebuildInventoryWindowFor does (w.HandleOccupantChange();, Mods/EquipmentPlus/EquipmentPlus/DynamicSlots.cs:269); AccessTools.Method finds it too, but is not needed. Settled by a fresh validator on 2026-09-27 (see Verification history). That direct call was already in the commit that added this comment (ee6d798e, 2026-04-19), so the sentence was very likely wrong at 0.2.6228.27061 as well; that build's DLL is no longer on disk to confirm it. The comment is quoted as it still stands in Mods/EquipmentPlus/EquipmentPlus/WindowOpenRefreshPatch.cs:10-30.
The comment's point about ToggleVisibility still holds at 0.2.6428.27798: InventoryWindow.ToggleVisibility (247256-247262 [234368-234374]) only calls DraggableWindow.ToggleVisibility (243520-243527), which calls SetVisible(!IsVisible). That switches the canvas and the layout group and puts the window's buttons into or out of the scroll list (247481-247544 [234593-234656]), and nothing on the path runs HandleOccupantChange or SetSlots, so the buttons a window shows are the ones its last HandleOccupantChange built, from Assign or from its parent slot's OnOccupantChange (see "Window lifecycle"). Not re-checked: that a closed window's GameObject is inactive, which is what makes FindObjectsOfType<InventoryWindow>() skip it (see Open questions).
Window lifecycle¶
Read in the 0.2.6428.27798 client and dedicated server decompiles. InventoryWindow (247106-247597 [234218-234709]), InventoryWindowManager (247598-248108 [234710-235220]), SlotDisplay (295189-295534 [281582-281927]), InventoryManager.Initialize(Entity) and Human.SetupReferencesWhenReady and Human.TakeControl are textually identical in the two, so client line numbers come first and the dedicated server's follow in brackets where they were pinned.
What a window shows. A window belongs to one slot, its ParentSlot, and shows the slots of whatever occupies that slot: public DynamicThing Parent => ParentSlot.Occupant; (247161 [234273]). A mod that looks for "the window showing container X" therefore matches window.Parent against X. IsVisible is canvas.enabled (247149-247159 [234261-234271]).
Every window is made in SlotDisplay.SetupWindow (295331-295346 [281724-281739]). It is the only code in Assembly-CSharp that instantiates the window prefab (InventoryWindowManager.Instance.WindowPrefab, 295335 [281728]), and it has two callers, one for each kind of window:
public void InitialiseRoot()
{
if (!GameManager.IsBatchMode && Slot != null && Slot.IsInteractable && Slot.Parent.HasSlots)
{
SetupWindow(isRoot: true);
}
}
private void SetupWindow(bool isRoot = false)
{
if (!(_localInventoryWindow != null))
{
_localInventoryWindow = UnityEngine.Object.Instantiate(InventoryWindowManager.Instance.WindowPrefab, InventoryWindowManager.Instance.WindowGrid.transform, worldPositionStays: false);
_localInventoryWindow.Assign(Slot);
if (isRoot)
{
InventoryWindowManager.Instance.Windows.Add(_localInventoryWindow);
return;
}
ParentWindow.RegisterChildWindow(_localInventoryWindow);
_localInventoryWindow.name = ParentWindow.name + "_" + Slot.GetSafeName();
_localInventoryWindow.SetVisible(isVisble: true);
}
}
private void OnPlayerInteract()
{
if (!(_localInventoryWindow != null) && Slot != null && !(Slot.Occupant == null) && Slot.IsInteractable && (Slot.Occupant.HasSlots || Slot.Occupant.HasKeyInteractions))
{
SetupWindow();
Slot.OnExit += OnOccupantExit;
}
}
private void OnOccupantExit()
{
Slot.OnExit -= OnOccupantExit;
if (!(_localInventoryWindow == null))
{
_localInventoryWindow.Unassign();
_localInventoryWindow.SetVisible(isVisble: false);
Slot.OnEnter += OnOccupantEnter;
}
}
private void OnOccupantEnter()
{
Slot.OnEnter -= OnOccupantEnter;
if (!(_localInventoryWindow == null))
{
_localInventoryWindow.Assign(Slot);
Slot.OnExit += OnOccupantExit;
}
}
- Root windows (295314-295320 [281707-281713], 295339 [281732]) go straight into
InventoryWindowManager.Instance.Windows. They are built byInventoryWindowManager.AssignParent(Entity)(247896-247922 [235008-235034]), which destroys the windows it held, clears the list, and callsslot.Display.InitialiseRoot()for every slot of the entity that has a display (247917 [235029]).InitialiseRootbuilds one only outside batch mode, for an interactable slot whose parentHasSlots. - Child windows open on demand when the player clicks a slot button (
OnPlayerInteract, 295348-295355 [281741-281748]), for an occupied, interactable slot whose occupantHasSlots || HasKeyInteractions.RegisterChildWindow(247245-247254 [234357-234366]) records the child with its parent window and adds it to the same manager list (InventoryWindowManager.Instance.Windows.Add(window);, 247252 [234364]). A child window survives its occupant leaving:OnOccupantExitunassigns and hides it, andOnOccupantEnterassigns it to the next occupant (295357-295376 [281750-281769]).
So InventoryWindowManager.Instance.Windows (public List<InventoryWindow> Windows, 247624 [234736]) holds every window, root and child, open or closed. A window leaves it only through its own OnDestroy (InventoryWindowManager.Instance.Windows.Remove(this);, 247276 [234388]) or the Windows.Clear() in AssignParent (247911) and in InventoryWindowManager.Destroy (247933), which run when the entity the windows belong to is replaced or destroyed. A mod that walks this list reaches closed windows as well as open ones, which FindObjectsOfType<InventoryWindow>() does not promise (see "Widget staleness").
When the root windows are built. AssignParent has one caller, InventoryManager.Initialize(Entity) (292583-292638 [278976-279031]), which skips it in batch mode (if (!GameManager.IsBatchMode), 292627 [279020]; InventoryWindowManager.AssignParent(Parent);, 292629 [279022]). Initialize(Entity) has one caller, Human.SetupReferencesWhenReady (368428-368442 [354796-354810]), which waits for the game to be running first:
private async UniTaskVoid SetupReferencesWhenReady()
{
while (GameManager.GameState != GameState.Running)
{
await UniTask.NextFrame();
}
InventoryManager.Instance.Initialize(this);
and it is started only from Human.TakeControl (SetupReferencesWhenReady().Forget();, 368950 [355318]). On a save load and on a join, GameState becomes Running only after every saved item is back in its slot: on a load after LoadWorld has waited out DynamicThing.AwaitingMoveWhenReady, on a join after ProcessThings (see SaveLoadOrdering, "Consequences for a mod"). So no window exists while those items arrive, and a window can be drawn before a slot changes only when the change comes later in the session.
Assign and rebuild. Assign(Slot parentSlot) (247167-247190 [234279-234302]) builds the buttons at once (HandleOccupantChange();, 247176 [234288]), subscribes parentSlot.OnOccupantChange += HandleOccupantChange; (247177 [234289]), starts hidden (SetVisible(isVisble: false);, 247180 [234292]) and destroys the window with the parent slot's owner (parentSlot.Parent.OnDestroyed += Destroy;, 247189 [234301]). Slot.Take raises OnOccupantChange whenever the slot's occupant changes (317083 [303467]; see DynamicThing, "MoveToSlot and the container hooks"). Apart from Assign itself (which SlotDisplay.OnOccupantEnter also calls when a child window's slot gets a new occupant) and AssignParent building the root windows afresh, that event is the only thing in the game that rebuilds a window. Slot.IsInteractable is a plain field (public bool IsInteractable = true;, 316871 [303255]), so changing it rebuilds nothing, and a change inside the occupant's own slots raises the event of those slots, not of the window's ParentSlot.
HandleOccupantChange (247432-247452 [234544-234564]) is public:
public void HandleOccupantChange()
{
ClearSlots();
ClearInteractions();
ClearChildWindows();
UpdateTitle();
if ((bool)PanelHands.Instance && ParentDisplayButton != null && ParentDisplayButton.IsHandSlot)
{
PanelHands.Instance.SetUpHandImages(ParentSlot.Occupant, ParentDisplayButton.IsLeftHand);
}
if (!Parent)
{
SetVisible(isVisble: false);
InventoryWindowManager.RefreshSize();
return;
}
SetSlots();
SetInteractions();
ButtonSort.SetVisible(IsSortable(ParentSlot));
InventoryWindowManager.RefreshSize();
}
It destroys the window's child windows (ClearChildWindows, 247305-247312) along with its buttons. SetSlots (247387-247430 [234499-234542]) makes a SlotDisplayButton only if (slot.IsInteractable) (247392 [234504]) and registers each new one with InventoryWindowManager.Register (247410 [234522]), which adds it to AllButtons only (247737-247745 [234849-234857]). ClearSlots (247281-247291) takes each old button out with InventoryWindowManager.Deregister (247287 [234399]), which removes it from AllButtons and from _visibleSlots (247747-247755 [234859-234867]), the list plain scrolling cycles through. The only thing that adds a window's buttons to _visibleSlots is RegisterVisibleSlots (247702-247707 [234814-234819]), and its only caller is InventoryWindow.SetVisible(true) (247536-247539 [234648-234651]), which returns before reaching it unless GameState is Running (247503-247506 [234615-234618]). So after HandleOccupantChange on a visible window, the new buttons are out of the scroll cycle until RegisterVisibleSlots runs for that window again. ScrollInputHandling, "HandleOccupantChange teardown semantics", records the same asymmetry at 0.2.6228.27061.
ToggleVisibility does nothing for a window with no slot buttons and no interaction buttons (247256-247262 [234368-234374]):
public override void ToggleVisibility()
{
if (DisplayedSlots.Count != 0 || DisplayedInteractions.Count != 0)
{
base.ToggleVisibility();
}
}
Rebuilding a window after a slot changes. A mod that changes a slot's IsInteractable while a window may be showing the container has to rebuild that window itself: walk InventoryWindowManager.Instance.Windows, call HandleOccupantChange() on each window whose Parent is the container, and call InventoryWindowManager.Instance.RegisterVisibleSlots(window) for one that IsVisible. Equipment Plus rebuilds through FindObjectsOfType<InventoryWindow>() in DynamicSlots.RebuildInventoryWindowFor, and re-registers the scroll list on reopen in WindowOpenRefreshPatch. Not in a release yet: Spray Paint Plus's GlowPaintPatches.RefreshWindowsShowing walks Windows exactly as above when a can's arrival gives a spray gun's can slot back. It is in commit fdcffdc5 on master, and ships in v1.13.0. The released v1.12.1 has no arrival hook and no window rebuild: after a load with glow paint on, its can stays in a hidden, blocked slot. The arrival that calls the rebuild was measured on the test rig on 2026-09-28, on fdcffdc5's build, giving the slot back on a load and on a join (see SlotInsertionBlock, "Legacy-state handling"). That run read the slot, not a window, and on a load or a join the rebuild finds no window, for the reason above, so a rebuild of a window has not run in game.
Verification history¶
- 2026-04-20: page created from the Research migration; verbatim content lifted from F0121, F0343. No conflicts.
- 2026-09-27: conflict on "whether
InventoryWindow.HandleOccupantChangeis private". Previous claim (the F0343 comment quoted in "ToggleVisibility stale widgets comment (F0343)", 0.2.6228.27061 @ 2026-04-20): "HandleOccupantChange is private; reached via AccessTools reflection." New finding (0.2.6428.27798, from the decompile read behind the Spray Paint Plus can-slot fix,.work/2026-09-27-phase3-rig/M4-RESULT.mdsection 5 item 4): it is public (client decompile 247432, dedicated server 234544), and Equipment Plus's ownDynamicSlotscalls it directly. Fresh validator verdict: B is correct. It readpublic void HandleOccupantChange()inside the public, top-level classInventoryWindow(namespaceAssets.Scripts.UI) in both decompiles, read the method's metadata asPublic, HideBySigin both installedAssembly-CSharp.dlls (client and dedicated server, both 0.2.6428.27798), found no inherited member of that name, and confirmed thatMods/EquipmentPlus/EquipmentPlus/DynamicSlots.cs:269calls it directly against the plain game DLL, with no publicizer in the repo and a compiled reference to it in the built Equipment Plus DLL. It inferred, without being able to check that build, that the claim was very likely wrong at 0.2.6228.27061 as well, because the comment and the direct call arrived in the same commit (ee6d798e, 2026-04-19; the commit was re-checked here). Result: the quote kept verbatim, with a correction under it naming the public declaration; the section restamped 0.2.6428.27798 after its other claims about the game were re-read (ToggleVisibilityruns neitherHandleOccupantChangenorSetSlots). Its premise that a closed window's GameObject is inactive was not re-checked and is listed under Open questions. - 2026-09-27: added "Window lifecycle" at 0.2.6428.27798, read in the client and dedicated server decompiles with
InventoryWindow,InventoryWindowManager,SlotDisplay,InventoryManager.Initialize(Entity),Human.SetupReferencesWhenReadyandHuman.TakeControlextracted from both and compared (textually identical). It coversSlotDisplay.SetupWindowas the only place a window is instantiated, root windows fromAssignParentthroughInitialiseRootand child windows fromOnPlayerInteract,InventoryWindowManager.Windowsholding every window open or closed and what removes one, root windows built only onceGameStateisRunning, theAssignsubscription, theHandleOccupantChangebody and its scroll-list asymmetry, and the empty-window guard inToggleVisibility. Spray Paint Plus's window rebuild is marked as not in a release yet (commit72b2db56on the local development branchphase3-b72, not yet on master). The first paragraph of "Widget staleness" was re-read and still holds (a button only for a slot interactable whenSetSlotsruns, and no rebuild on a laterIsInteractablechange); its second paragraph rests on the open GameObject question, so that section keeps its 0.2.6228.27061 stamp. Intro widened from held multi-slot items to every window. Top-levelverified_inmoved to 0.2.6428.27798. - 2026-09-28: the Spray Paint Plus fix this page marks landed on master as
fdcffdc5; the cited files are unchanged from the branch commits named in the entries above, so only the markers and sources were updated to cite it. Still not in a release (v1.12.1 is the published build). - 2026-09-28 (game version 0.2.6428.27798): the Spray Paint Plus sentence at the end of "Window lifecycle" said the rebuild had not run in game. The arrival that calls it now has: the check
a spray gun saved with a can in it keeps the can visible and removablepassed onfdcffdc5's build (Release DLL sha256c0c00c18) in single player, on a listen host and on the dedicated server, with the loaded gun's slot given back on a load and on a join. The check reads the slot and opens no window, so the sentence now says what was measured and keeps "a rebuild of a window has not run in game". The game facts of the section were not re-read in this pass; they stand as verified on 2026-09-27 at the same game version. Section restamped for the changed sentence; the marker stays, since the fix is not in a release.
Open questions¶
- Whether closing an
InventoryWindowdeactivates its GameObject at 0.2.6428.27798, which "Widget staleness" and the F0343 comment rely on when they sayFindObjectsOfType<InventoryWindow>()skips a closed window.InventoryWindow.SetVisible(false)disables the canvas and the layout group (247494-247501) and then callsbase.SetVisible; down that chainUserInterfaceBase.SetVisible(260440-260450) hands the call to the window'sUiComponentRendererwhen it has one, and otherwiseGameBase.SetVisible(35365-35371) callsGameObject.SetActive(false). Whether the window prefab carries aUiComponentRendereris prefab data that a decompile cannot show. Resolve by reading a closed window'sgameObject.activeSelfin a running client, or by reading the window prefab with UnityPy. Separately,InventoryWindowManager.ToggleWindows(bool)(247885-247894) sets every listed window's GameObject active or inactive.