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>
245 lines
11 KiB
Markdown
245 lines
11 KiB
Markdown
# 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<string, UiElement> _windows` plus:
|
||
|
||
```csharp
|
||
/// <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`/`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`.
|