# 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 `UiRoot` with `Show` / `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 `WindowManager` class wrapping `UiRoot`** — cleaner single-responsibility on paper, but it must reach into `UiRoot.Children` / `ZOrder` to mount + raise, so it just borrows `UiRoot`'s internals through a new seam. Ceremony for ~30 lines of logic. - **Thin API on `UiHost`** — `UiHost` is the facade `GameWindow` talks to, but the tree and `ZOrder` live in `UiRoot`; 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 _windows` plus: ```csharp /// 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. public void RegisterWindow(string name, UiElement window); /// Make the named window visible and raise it to the front. /// No-op if unknown. Returns true if a window was shown. public bool ShowWindow(string name); /// Hide the named window (Visible = false). No-op if unknown. public bool HideWindow(string name); /// Flip the named window's visibility; Show (raise) if it was hidden, /// Hide if it was visible. Returns the new IsVisible state. public bool ToggleWindow(string name); /// Set element.ZOrder to one above the current max among top-level /// children, so it paints + hit-tests above its peers. public void BringToFront(UiElement window); ``` - `Show`/`Hide` only flip `UiElement.Visible`; `Visible` already gates Draw, Tick, and HitTest (all early-return when false), so a hidden window is fully inert — no draw, no input, no tooltip timer. - `RegisterWindow` does **not** add the element to the tree — the caller mounts it with `AddChild` (so registration and tree-membership stay independent and the caller controls initial `Visible`). Registration only records the name→element mapping. - `BringToFront`: `window.ZOrder = 1 + max(child.ZOrder for child in Children)`. The existing default windows mount at `ZOrder = 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 ```csharp 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): ```csharp case AcDream.UI.Abstractions.Input.InputAction.ToggleInventoryPanel: _uiHost?.ToggleWindow(WindowNames.Inventory); break; ``` - Gating is already correct: the dispatcher is suppressed by `WantsKeyboard` when the chat input holds focus, so F12 cannot toggle inventory while you're typing. - `WindowNames.Inventory` is a `const string "inventory"` (a small `WindowNames` holder in `AcDream.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: ```csharp 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` + `ToggleWindow` flips `Visible` false→true→false and returns the new state. - `ShowWindow` raises `ZOrder` above all peers; `HideWindow` sets `Visible=false` without touching `ZOrder`. - `BringToFront` on the lowest window makes its `ZOrder` strictly greatest among siblings. - `ToggleWindow`/`ShowWindow`/`HideWindow` on an unknown name are no-ops returning false. - Raise-on-click: an `OnMouseDown` on a registered window with a peer in front leaves the clicked window with the greatest `ZOrder` (reuses the existing `UiRootInputTests` harness 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 build` green; `dotnet test` green. - 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.Inventory` and the F12 → `ToggleWindow` wiring are stable; B keeps both. - B replaces the placeholder `UiNineSlicePanel` with the real `gmInventoryUI 0x21000023` nested 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 exposes `HideWindow`; 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 == Inventory` branch in `ToolbarController.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.txt` lines 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: `OnInputAction` switch at `src/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`.