# Project: UI architecture — three-layer stable pattern **Agreed 2026-04-24.** Read once per session. Full design: [`docs/plans/2026-04-24-ui-framework.md`](../docs/plans/2026-04-24-ui-framework.md). ## The separation ``` ┌─────────────────────────────────────────┐ │ UI backend (ImGui today / custom later)│ ← swappable ├─────────────────────────────────────────┤ │ ViewModels + Commands (per-panel) │ ← stable contracts ├─────────────────────────────────────────┤ │ Game state + events + net (unchanged) │ ← game logic └─────────────────────────────────────────┘ ``` - **UI backend** (bottom swap axis): **`ImGui.NET` + `Silk.NET.OpenGL.Extensions.ImGui`** for Phase D.2a (short-term, debugger-look, validates game logic fast). Custom retail-look toolkit for Phase D.2b (long-term, uses dat assets). ImGui stays **forever** as the `ACDREAM_DEVTOOLS=1` overlay. (Pivoted from `Hexa.NET.ImGui` on 2026-04-25 — Hexa's native OpenGL3 backend resolves GL via GLFW/SDL internally and crashed `0xC0000005` against Silk.NET; the Silk.NET extension is purpose-built for this stack.) - **ViewModels + Commands** (the stable contract): per-panel data records (`VitalsVM`, `InventoryVM`, `ChatVM`, …) and action records (`UseItemCmd`, `SendChatCmd`, `CastSpellCmd`, …). Lives in `AcDream.UI.Abstractions`. **Does not change across the backend swap.** - **Game state + events + net**: existing `IGameState`, `IEvents`, `WorldSession`, etc. Unchanged. UI only reads/subscribes; never touches these. ## Module layout (future) - `src/AcDream.UI.Abstractions/` — `IPanel`, `IPanelHost`, `IPanelRenderer`, `ICommandBus`, all ViewModels + Commands. Backend- agnostic. - `src/AcDream.UI.ImGui/` — `ImGui.NET` + `Silk.NET.OpenGL.Extensions.ImGui` implementation of `IPanelRenderer` + ImGui bootstrap. Phase D.2a. `ImGuiController` (from the Silk.NET extension) handles GL backend + input event subscription; `ImGuiBootstrapper` is a thin IDisposable wrapper; `ImGuiPanelRenderer` wraps the widgets `IPanelRenderer` needs. - `src/AcDream.UI.Retail/` (later) — custom retained-mode toolkit using dat assets, same `IPanelRenderer` contract. Phase D.2b. ## Hard rules 1. **No panel references a backend namespace.** If a panel imports `ImGuiNET` / `Silk.NET.OpenGL.Extensions.ImGui` or a custom-toolkit widget class directly, it's a bug — extend `IPanelRenderer` instead. 2. **Plugin API targets the abstraction layer only.** Plugins define `IPanel` instances; they never see which backend draws them. 3. **Features that only ImGui can express → not in `IPanelRenderer`.** If a feature can't be expressed by a future retained-mode toolkit with dat-sourced sprites/fonts, don't expose it in the abstraction. 4. **D.3 AcFont / D.4 dat sprites / D.7 cursor manager are D.2b dependencies** — they plug into the custom backend only. 5. **D.5 core panels / D.6 HUD ship against the abstraction**, not against a specific backend. They render via ImGui first, reskin under custom later. ## Why staged (not pure custom from day one) - Gets the whole game-logic loop validated in weeks: chat-send, inventory-click, vitals-bar-read-from-real-state. Requires zero widget art. - Forces the abstraction design to pass a real backend test before we commit to the custom toolkit. If `IPanelRenderer` is wrong, we discover it during Phase D.2a, not after Phase D.2b lands with a dozen broken panels. - Custom retail-look toolkit then delivers the aesthetic layer without the plugin API or game logic needing to change. ## Links - Full design: `docs/plans/2026-04-24-ui-framework.md` - Roadmap entry: `docs/plans/2026-04-11-roadmap.md` Phase D (see the 2026-04-24 callout block). - Custom-backend research (applies to D.2b, NOT D.2a): `docs/research/retail-ui/00-master-synthesis.md` + slices 01-06. ## 2026-07-10 retained radar update The older "D.6 ships through IPanelRenderer/ImGui first" rule above is superseded for screen-space retail HUD windows. The production retained toolkit now lives in `src/AcDream.App/UI`; imported `LayoutDesc` windows bind through dedicated `gm*UI` controllers and App-layer snapshot providers. ImGui remains devtools only. `gmRadarUI` is the reference pattern: Core owns exact colors/shapes/projection/ compass/coordinates, `RadarSnapshotProvider` joins the existing `WorldEntity` + `ClientObjectTable` two-table model, `RadarController` binds LayoutDesc 0x21000074, and `UiRadar` only draws/handles retained interaction. `GameWindow` contains mount and delegate wiring, not the feature body. See `docs/research/2026-07-10-retail-radar-pseudocode.md`. ## 2026-07-10 retained window ownership Every production retained window is registered through `RetailWindowManager` and owned by a typed `RetailWindowHandle`. Controllers implement `IRetainedPanelController`; windows with multiple focused controllers use `RetainedPanelControllerGroup`, which forwards lifecycle in construction order and disposes in reverse order exactly once. A controller created after a hidden mount (inventory) is attached through `RetailWindowManager.AttachController`. Controller event subscriptions must use removable named handlers or paired subscribe/unsubscribe delegates. `UiHost.Dispose` first removes Silk mouse/keyboard subscriptions, then disposes the manager/controllers, then the GL text renderer. `GameWindow.OnClosing` disposes layout persistence and `UiHost` before live session or game-state sources. This ordering is the recreate/relogin double-subscription guard; do not reintroduce anonymous lifetime-bound event lambdas. ## 2026-07-11 composition boundary `RetailUiRuntime` now owns production retained composition. Its focused binding records receive state/action delegates plus DAT/font/sprite resolvers; `GameWindow.OnLoad` retains only resolver/service creation and one declarative `RetailUiRuntime.Mount` call. All LayoutDesc imports, controller construction, window mounts, toolbar digit and inventory-cell asset lookup, plugin mounts, cursor feedback, persistence, automation, and retained tick/draw/restore/dispose paths live behind the runtime. The live Debug gate passed across two graceful restart cycles. Do not add panel mount bodies back to `GameWindow`. ## 2026-07-11 selection and interaction ownership `AcDream.Core.Selection.SelectionState` is the only selected-object truth. It ports retail `ACCWeenieObject::SetSelectedObject`: deduplicate the current guid, remember previous and previous-valid guids, commit before firing one typed transition, and clear with the removed object retained as previous. World pick, radar, combat targeting, inventory, paperdoll slots, toolbar selected-object presentation, target indicator, use/pickup, PreviousSelection, and plugins all read or write this same owner. Plugins use `IPluginHost.Selection`; plugin event failures are isolated from the client and other plugins. Retail `CM_UI::SendNotice_SelectionChanged @ 0x00479F50` carries no object-id payload. Notice consumers such as `gmToolbarUI::HandleSelectionChanged` read the current global selection when invoked. Preserve that rule in retained UI: a handler registered earlier can retarget reentrantly (Auto Target on a death clear), so applying the captured outer transition afterward produces stale UI even though `SelectionState` is correct. Selected-object presentation must read `SelectionState.SelectedObjectId` at notification time. Issue #205, 2026-07-12. `AcDream.App.UI.InteractionState` separately owns temporary pointer orchestration: `None`, `Use`, `Examine`, or `UseItemOnTarget(sourceGuid)`. Item target mode is a projection of this state, not a second selected-object field. Keep selection in Core and interaction modes in App when completing Wave 3. The 2026-07-11 Release gate passed for world/radar/inventory/paperdoll selection synchronization, toolbar/target/radar presentation, PreviousSelection, and the movement/door/armor/window regression tour. ## 2026-07-11 item-interaction policy boundary `AcDream.Core.Items.ItemInteractionPolicy` owns the pure, ordered retail rules from `DetermineUseResult`, `UseObject`, target compatibility, and `AttemptPlaceIn3D`. Do not put vendor/trade/confirmation/stack/ground legality back into widgets or `GameWindow`. `ItemInteractionController` snapshots mutable objects, applies the 200 ms pre-legality throttle, balances busy state against Core.Net `UseDone`, manages target mode, and dispatches typed actions. PWD flags and `CombatUse` are retained from CreateObject; `IsComponentPack` is resolved against portal.dat's `SpellComponentTable`. Matching-executable x86 pins the two operands lost by Binary Ninja: `DetermineUseResult @ 0x005884B6` tests `BF_REQUIRES_PACKSLOT (0x00800000)`, and `AttemptPlaceIn3D @ 0x00588752` tests `BF_VENDOR (0x00000200)`. Preserve the retail asymmetry that `UseObject` early-classifies results 2..7 but excludes 8, and preserve placement's action list separately from its boolean return (vendor sale and secure-trade attempts act while returning false). The 2026-07-11 non-debug Release gate passed ordinary use/container activation, equip synchronization, grounded world drop, and targeted healing. ACE `/ci 632` provisioned a peerless kit and `/harmself` created a real health deficit; main-pack and paperdoll self-targets both healed successfully. ## 2026-07-11 primary-click ownership Every primary click that can name an object must first call `ItemInteractionController.OfferPrimaryClick`. Its typed result is the contract: `NotActive` permits the surface's ordinary select/open/use fallback; `ConsumedSuccess` and `ConsumedRejected` are both terminal. Never let a rejected target click fall through. Inventory cells and bags, main pack, paperdoll slots and body, toolbar, radar, and world picking share this router; widgets continue to expose callbacks without importing controllers. The router retains a consumed target through the toolkit's 500 ms double-click window; otherwise UiRoot's second Click plus DoubleClick could run ordinary activation after the first click cleared target mode. The 2026-07-11 non-debug Release fallback gate passed for inventory, paperdoll, toolbar, radar, and world primary clicks with target mode inactive, and the active healing-kit target flow passed through the main pack and 3-D doll. The production DAT resolves `0x100001D6`, the full-height paperdoll hit/drag mask, as a `UiButton` over `UiViewport 0x100001D5`. Bind target behavior to the mask, not only the obscured viewport, and hide/show both together with the Slots toggle. For local vitals, `PrivateUpdateVital`/`PrivateUpdateVitalCurrent` in `LocalPlayerState` are authoritative. `VitalsVM.HealthPercent` must prefer that private health snapshot over the world-facing `CombatState` percentage, using the latter only before private state arrives. ACE `/harmself` exposed the stale owner because it sends private vital deltas without changing that combat cache. ## ACE command forwarding `ChatCommandRouter` is the single submit path for retained and developer chat. Known client/chat verbs resolve locally; unknown letter verbs are server commands. They are always published as `ChatChannelKind.Say`, regardless of the selected channel, because commands must ride `GameAction Talk (0x0015)`. `ChatInputParser` preserves `@verb` and canonicalizes `/verb` to `@verb` on the wire: ACE's `GameActionTalk` checks only the at-sign form before invoking its `CommandManager`. Do not add a parallel admin-command transport. For live test-item provisioning, ACE `@ci` accepts a weenie class id/classname plus optional stack size, palette, and shade. Healing kit WCIDs are 628 crude, 629 plain, 630 good, 631 excellent, and 632 peerless. The `+Acdream` developer character can therefore create the best kit with either `/ci 632` or `@ci 632`; both forms must produce canonical `@ci 632` Talk text.