Sub-phase A of the window-manager → inventory → paperdoll arc. A named- window registry on UiRoot (Show/Hide/Toggle/BringToFront) + raise-on-click + wiring the EXISTING InputAction.ToggleInventoryPanel (already F12-bound, retail-faithful) through OnInputAction, toggling a throwaway placeholder inventory window that Sub-phase B replaces with the real gmInventoryUI. Cross-check correction vs the handoff: retail's inventory key is F12, not I (I = Laugh emote per retail-default.keymap.txt:246). Reuses the existing action, so no new keybind — rebindable via the Settings panel. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
11 KiB
Design — D.2b window manager (open/close + F12 inventory toggle)
Date: 2026-06-20
Phase: D.2b retail-UI arc, Sub-phase A (window manager) — the first of three
(A window manager → B inventory window → C paperdoll). Handoff:
docs/research/2026-06-20-window-manager-inventory-handoff.md §3.
Status: approved design, pre-implementation.
1. Context & goal
Every retail-UI window today is always-on at a hardcoded position: vitals, chat, and
the toolbar are added to _uiHost.Root unconditionally at startup in the _options.RetailUi
block of GameWindow.OnLoad (grep _uiHost.Root.AddChild). UiHost/UiRoot have no
open/close API — UiRoot exposes the widget tree, input routing, focus, capture, modal,
and drag-drop, but nothing to show/hide/raise a named top-level window.
Goal: the minimal shared infrastructure to open/close a top-level window and a keybind to toggle the inventory window. This is the prerequisite for Sub-phase B (the inventory window) and C (paperdoll) — both need a window that starts hidden and is summoned on demand.
Non-goal: the inventory window's contents. Sub-phase A toggles a throwaway
placeholder window; Sub-phase B replaces its body with the real gmInventoryUI
layout. See §9.
2. Scope
In scope (A):
- A named-window registry on
UiRootwithShow/Hide/Toggle/BringToFront. - Raise-on-click for top-level windows (retail-faithful window stacking).
- F12 → toggle the inventory window, via the existing
InputAction.ToggleInventoryPanel. - A minimal placeholder inventory window (default-hidden) so A is visually verifiable and gives B a concrete mount point.
- Unit tests for the registry logic; divergence-register bookkeeping.
Out of scope (deferred):
- Inventory window contents, grid, sub-window mount, wire gaps,
InventoryController→ B. - Paperdoll /
UiViewport→ C. - Disk persistence of open-state + window positions → handoff "Plan-2".
- A toolbar inventory button (radar/menu buttons) — F12 only for now; the button can be a later cheap addition once the inventory window exists.
- Spell bar — explicitly deferred by the handoff.
3. Architecture decision — registry on UiRoot
UiRoot already owns exactly the state a window manager manipulates: the top-level
Children, ZOrder (drives both paint order in DrawSelfAndChildren and hit-test order in
HitTestTopDown), keyboard focus, mouse capture, and the modal overlay. "Show / hide / raise
a top-level window" is the job it already does — so the registry lives there.
This mirrors retail, where the Keystone root (DAT_00870c2c) owns the widget tree and window
stacking. (Keystone is an external DLL with no decomp, so the manager is toolkit-defined,
not a byte-faithful port — see §7.)
Alternatives considered and rejected:
- Dedicated
WindowManagerclass wrappingUiRoot— cleaner single-responsibility on paper, but it must reach intoUiRoot.Children/ZOrderto mount + raise, so it just borrowsUiRoot's internals through a new seam. Ceremony for ~30 lines of logic. - Thin API on
UiHost—UiHostis the facadeGameWindowtalks to, but the tree andZOrderlive inUiRoot; this would be a pure forwarding layer with no added value.
UiHost gets two convenience forwarders (ToggleWindow, RegisterWindow) that delegate
to Root, because GameWindow holds a UiHost, not a UiRoot, at the call sites.
4. Components & interfaces
4.1 UiRoot window registry
A private Dictionary<string, UiElement> _windows plus:
/// <summary>Register a top-level window under a name for Show/Hide/Toggle.
/// Idempotent (re-register replaces). The dict is the source of truth —
/// independent of UiElement.Name, which is init-only and not reassigned here.</summary>
public void RegisterWindow(string name, UiElement window);
/// <summary>Make the named window visible and raise it to the front.
/// No-op if unknown. Returns true if a window was shown.</summary>
public bool ShowWindow(string name);
/// <summary>Hide the named window (Visible = false). No-op if unknown.</summary>
public bool HideWindow(string name);
/// <summary>Flip the named window's visibility; Show (raise) if it was hidden,
/// Hide if it was visible. Returns the new IsVisible state.</summary>
public bool ToggleWindow(string name);
/// <summary>Set element.ZOrder to one above the current max among top-level
/// children, so it paints + hit-tests above its peers.</summary>
public void BringToFront(UiElement window);
Show/Hideonly flipUiElement.Visible;Visiblealready gates Draw, Tick, and HitTest (all early-return when false), so a hidden window is fully inert — no draw, no input, no tooltip timer.RegisterWindowdoes not add the element to the tree — the caller mounts it withAddChild(so registration and tree-membership stay independent and the caller controls initialVisible). Registration only records the name→element mapping.BringToFront:window.ZOrder = 1 + max(child.ZOrder for child in Children). The existing default windows mount atZOrder = 0, so an opened/clicked window rises above them.
4.2 Raise-on-click
In UiRoot.OnMouseDown, after the existing FindWindow(target) resolves the top-level
window under the press, call BringToFront(window) so the clicked window comes to the top of
the stack (retail raises clicked windows). This is the existing FindWindow result reused —
~3 lines, no new traversal. Applies to any top-level window (vitals, chat, toolbar,
inventory), not just registered ones; the registry is only for named Show/Hide/Toggle.
4.3 UiHost forwarders
public void RegisterWindow(string name, UiElement window) => Root.RegisterWindow(name, window);
public bool ToggleWindow(string name) => Root.ToggleWindow(name);
4.4 F12 wiring — OnInputAction
InputAction.ToggleInventoryPanel already exists, is already bound to F12 in
KeyBindings.RetailDefaults() (matching docs/research/named-retail/retail-default.keymap.txt
line 149: ToggleInventoryPanel [ "" [ 0 DIK_F12 ] ]), and is already listed in the Settings
rebind UI — so it's user-rebindable with no new wiring. Today nothing consumes it.
Add one arm to the switch (action) in GameWindow.OnInputAction (the InputDispatcher.Fired
multicast subscriber; the switch begins at GameWindow.cs:11421):
case AcDream.UI.Abstractions.Input.InputAction.ToggleInventoryPanel:
_uiHost?.ToggleWindow(WindowNames.Inventory);
break;
- Gating is already correct: the dispatcher is suppressed by
WantsKeyboardwhen the chat input holds focus, so F12 cannot toggle inventory while you're typing. WindowNames.Inventoryis aconst string "inventory"(a smallWindowNamesholder inAcDream.App.UI), so the mount, the registry, and the toggle agree on one literal.
4.5 Placeholder inventory window mount
In the _options.RetailUi block of GameWindow.OnLoad, alongside the vitals/chat/toolbar
mounts, build a minimal framed window:
var inventoryWindow = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome)
{
Left = 200, Top = 120, Width = 320, Height = 400,
Visible = false, // starts hidden; F12 reveals it
Draggable = true,
Anchors = AcDream.App.UI.AnchorEdges.None,
};
_uiHost.Root.AddChild(inventoryWindow);
_uiHost.RegisterWindow(AcDream.App.UI.WindowNames.Inventory, inventoryWindow);
This uses the same UiNineSlicePanel(ResolveChrome) + RetailChromeSprites.Border chrome
the vitals/chat windows use, so the placeholder already has the retail beveled frame. It is
throwaway scaffolding: Sub-phase B replaces the body (and likely the whole construction)
with the real gmInventoryUI 0x21000023 nested layout, keeping the same registry name so the
F12 wiring is untouched.
5. Persistence
In-memory for the session only. Open/closed state lives in UiElement.Visible; window
positions are the hardcoded mount positions (already reset each session). No disk persistence
— deferred to the handoff's Plan-2.
6. Testing
Unit tests in tests/AcDream.App.Tests/UI/UiRootInputTests.cs (or a sibling
UiRootWindowManagerTests.cs) — the registry logic is pure (no GL), so it tests directly:
RegisterWindow+ToggleWindowflipsVisiblefalse→true→false and returns the new state.ShowWindowraisesZOrderabove all peers;HideWindowsetsVisible=falsewithout touchingZOrder.BringToFronton the lowest window makes itsZOrderstrictly greatest among siblings.ToggleWindow/ShowWindow/HideWindowon an unknown name are no-ops returning false.- Raise-on-click: an
OnMouseDownon a registered window with a peer in front leaves the clicked window with the greatestZOrder(reuses the existingUiRootInputTestsharness pattern for synthesizing mouse events).
dotnet build + dotnet test green (full suite, currently 2752+).
7. Divergence register
The window manager is toolkit-defined — retail's window stacking lives in keystone.dll
(no decomp), so this is the existing IA-12 / IA-15 toolkit-adaptation umbrella, not a
byte-faithful port. Confirm whether an existing register row covers
"retail-UI windows are toolkit-managed, not Keystone-ported"; if not, add a one-line row in
docs/architecture/retail-divergence-register.md in the implementation commit. F12 itself
is retail-faithful (matches the retail default keymap) — no divergence for the keybind.
8. Acceptance criteria
dotnet buildgreen;dotnet testgreen.- With
ACDREAM_RETAIL_UI=1, pressing F12 shows the placeholder inventory window above vitals/chat/toolbar; pressing F12 again hides it. - Clicking any window (vitals, chat, toolbar, inventory) raises it above the others.
- F12 does nothing while the chat input is focused (write mode).
- Window manager logic is unit-tested; divergence-register row confirmed/added.
- Visual verification by the user (the toggle + raise behavior).
9. Seam for Sub-phase B
A leaves B a clean handoff:
- The registry name
WindowNames.Inventoryand the F12 →ToggleWindowwiring are stable; B keeps both. - B replaces the placeholder
UiNineSlicePanelwith the realgmInventoryUI 0x21000023nested layout (paperdoll + backpack + 3D-items), re-registering it under the same name. - B adds the dat close button →
HideWindow(WindowNames.Inventory)wiring (the manager already exposesHideWindow; A leaves the placeholder closable only via F12). - B wires the inventory cell as a drag source, completing B.2's drag-from-inventory via
the
SourceKind == Inventorybranch inToolbarController.HandleDropRelease.
10. References
- Handoff:
docs/research/2026-06-20-window-manager-inventory-handoff.md§3 (Sub-phase A). - Retail keymap:
docs/research/named-retail/retail-default.keymap.txtlines 139–153 (panel-toggle commands; inventory = F12) + line 246 (Laugh=DIK_I). - Toolkit:
src/AcDream.App/UI/UiRoot.cs,UiElement.cs,UiHost.cs,UiNineSlicePanel.cs;claude-memory/project_d2b_retail_ui.md. - Input pipeline:
OnInputActionswitch atsrc/AcDream.App/Rendering/GameWindow.cs:11421;InputAction.ToggleInventoryPanel;KeyBindings.cs:205;claude-memory/project_input_pipeline.md. - Object model (for B):
claude-memory/project_object_item_model.md,src/AcDream.Core/Items/ClientObjectTable.cs.