acdream/docs/research/2026-07-02-ui-architecture-review.md
Erik 59ea3252cd docs: UI architecture review report + follow-up issues #159-#165
Full 6-dimension review (34 confirmed findings, 0 refuted) in
docs/research/2026-07-02-ui-architecture-review.md. Verdict: widget
toolkit / importer / controller-binding / cursor seams are sound; rot
concentrates in GameWindow composition growth, state-write ownership,
input-stack arbitration, and design-doc drift.

New issues: #159 retail-chat ChatVM providers, #160 window-close
notification + NoLongerViewingContents, #161 target-mode click
chokepoint, #162 world-hover cursor, #163 raise ledger, #164
RetailWindowFrame migration, #165 UI doc reconciliation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 09:18:52 +02:00

212 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# UI System Architecture Review — 2026-07-02
**Scope:** the D.2b retail UI engine (`src/AcDream.App/UI/` widgets + `UI/Layout/`
importer + controllers), `AcDream.UI.Abstractions`, the ImGui backend, the new
uncommitted item-interaction + cursor slice, Studio/MockupDesktop, and the
GameWindow composition root — reviewed against the CLAUDE.md Code Structure Rules
and the three-layer UI strategy (`docs/plans/2026-04-24-ui-framework.md`).
**Method:** 6 parallel dimension reviewers (layering / widget-tree / importer /
controllers / cursor / abstractions-fit), every medium+high finding adversarially
verified by an independent agent reading the cited code (40 agents total).
Result: **34 findings confirmed, 0 refuted** (2 high, 32 medium), plus 11 low.
Five load-bearing findings were additionally spot-checked by hand in this session
(GameWindow +490 diff, raw property writes, dead fall-through seam, dual ChatVM,
dual useability truth) — all held.
**Worktree state reviewed:** branch `claude/peaceful-visvesvaraya-e0a196` at
`e3fc7ac5` + the uncommitted D.2b item-interaction/cursor diff (51 modified files,
19 new files) copied from the codex worktree.
---
## Verdict
**The middle of the UI system is architecturally sound. The edges are not.**
The widget toolkit's core abstractions, the importer's two-layer split, the
controller binding pattern, the cursor semantic/presentation split, and the
ViewModel reuse are all genuinely good — GL-free, testable, single-responsibility,
with the Studio proving the bindings run headless. The structural rot concentrates
in four places:
1. **GameWindow as composition root keeps absorbing feature bodies** — the
uncommitted work alone added +490 lines (now 14,214), most of it a character-sheet
data provider that belongs in its own class. Growth is linear in window count.
2. **State ownership around the character sheet is violated** — the UI writes raw
property dictionaries, bypassing both designed change-event chokepoints, with no
rollback contract (item moves have one; raises don't).
3. **Three input stacks coexist without an arbiter** — InputDispatcher (Phase K),
UiRoot (retail), ImGui — glued by order-dependent boolean gates; the designed
fall-through seam is dead code.
4. **The design SSOT has silently drifted** — the ui-framework plan and architecture
doc still promise an `IPanelRenderer` backend swap that the shipped D.2b pivoted
away from; the plugin UI contract fractured along the same line.
None of this is load-bearing-broken *today* — the app works — but every finding is
a compounding tax on the next window, the next input consumer, the next plugin.
---
## What is sound (keep doing this)
- **Widget layer is GL-free and testable**: sprites/fonts arrive via injected
`Func<uint,(tex,w,h)>` resolve delegates; headless tests exist at the right layer.
- **Draw vs game-state mutation separated**: widget draw paths only render; wire
actions flow through injected controller delegates.
- **Drag policy split is right**: UiRoot owns only the pointer state machine
(item-agnostic via `GetDragPayload`/`GetDragGhost` virtuals); semantics live in
controllers.
- **Two-pass overlay draw** reproduces retail's root-level MakePopup semantics
without reparenting.
- **Importer two-layer split is clean**: pure layer (`Build`/`BuildFromInfos` over
`ElementInfo` POCOs) is GL-free and dat-free; `DatReaderWriter` types never leak
past the importer — which is exactly why the same path runs in Studio.
- **`DatWidgetFactory.Create` is a single localized type-registration switch** —
adding a retail widget type is one case + one widget class.
- **SSOT largely holds for items**: controllers read `ClientObjectTable` live via
its events and keep only view state; controller-to-controller coupling is
mediated (events + injected services), never direct.
- **ItemInteractionController is a well-shaped seam**: constructor-injected
delegates (player guid, clock, wire sends, toast) — unit-testable without GL or
session.
- **Cursor split is real**: `CursorFeedbackController.ResolveKind` is the single
priority chain for cursor MEANING (pure over a snapshot record, tested);
EnumIDMap resolution is dat-faithful with a decomp citation
(`ClientUISystem::UpdateCursorState` 0x00564630), cached, lock-disciplined;
`RetailCursorManager` dedupes OS writes. Its GameWindow wiring is exemplary
rule-1 compliance (2 fields + 7-line helper).
- **The ViewModel layer survived the D.2b pivot**: ChatWindowController consumes
`ChatVM`/`ChatCommandRouter`/`ChatInputParser`; VitalsVM reused via thin Func
adapters; VitalsController is pure data binding by dat element id.
- **D.2a boundary discipline held for two years**: no Abstractions panel imports a
backend namespace; exactly one IPanelRenderer implementation, isolated in
AcDream.UI.ImGui.
---
## Findings by theme
Severities are post-verification. Every row was confirmed by an independent
adversarial reader at the cited location.
### T1 — GameWindow god-object growth (the dominant theme; both HIGHs)
| Sev | Finding | Where |
|---|---|---|
| **HIGH** | ~430 lines of character-sheet feature body added directly to GameWindow (uncommitted): `CurrentCharacterSheet`, `ComputeLevelXp`, `BuildAttributeRaiseCosts`, `BuildLiveCharacterSkills`, `RaiseCostFromXpCurve`, raise send/apply flow. Pure dat-table domain math trapped untestable in a GL-bound 14K-line class. Two bare `catch {}` swallows in `LoadCharacterExperienceTable`; `IsUsableUntrained` hardcodes skill ids 18/3740. File grew 13,719 → 14,214 in this stream; committed-only delta was +13 — all growth is this feature. | [GameWindow.cs:11766](src/AcDream.App/Rendering/GameWindow.cs:11766) |
| **HIGH** | The whole retail-UI composition block in `OnLoad` is ~590 lines of real layout logic (per-window mount recipes: nine-slice frame construction, anchor rewrites, layout constants `Left=540`, `MaxHeight=760`). Each new retail window adds another mount block — growth is linear in the D.2b window count. | [GameWindow.cs:1834](src/AcDream.App/Rendering/GameWindow.cs:1834) |
| MED | Per-window mount recipes duplicated verbatim between GameWindow and Studio's MockupDesktop (same element-id anchor rewrites in two files). | [MockupDesktop.cs:184](src/AcDream.App/Studio/MockupDesktop.cs:184), [GameWindow.cs:2161](src/AcDream.App/Rendering/GameWindow.cs:2161) |
| MED | GameWindow reaches back into raw LayoutDesc structures after import, duplicating the importer's property parsing inline. | [GameWindow.cs:2013](src/AcDream.App/Rendering/GameWindow.cs:2013) |
| MED | Studio's `RenderBootstrap` is a hand-maintained copy of GameWindow.OnLoad wiring — already drifting. | [RenderBootstrap.cs:147](src/AcDream.App/Rendering/RenderBootstrap.cs:147) |
### T2 — State ownership / event flow
| Sev | Finding | Where |
|---|---|---|
| MED | Single-owner-state violation: GameWindow writes raw `PropertyBundle` dictionaries (`props.Int64s[2u]`, `props.Ints[...]` with a 0x18→0xC0→0xB5 guess-chain), bypassing BOTH designed change-event chokepoints (`ClientObjectTable.UpdateIntProperty` fires ObjectUpdated; `LocalPlayerState` mutators fire CharacterChanged). The only raw writes into live bundles outside ClientObjectTable, repo-wide. Same bug class as `feedback_single_owner_state_per_entity_write`. | [GameWindow.cs:12106](src/AcDream.App/Rendering/GameWindow.cs:12106) |
| MED | Optimistic-mutation contract inconsistent: item moves have the `RecordPending`/`ConfirmMove`/`RollbackMove` ledger (reconciled on 0x0022/0x0023/0x00A0); character raises apply immediately with no pending snapshot and no rollback — a rejected raise leaves the sheet permanently wrong until an unrelated full refresh. | [GameWindow.cs:12080](src/AcDream.App/Rendering/GameWindow.cs:12080) |
| MED | Two parallel "use an item" stacks with two useability truths: world double-click keys off GameWindow-private `_lastSpawnByGuid` (24 refs); the retail UI path keys off `ClientObjectTable`. | [GameWindow.cs:13324](src/AcDream.App/Rendering/GameWindow.cs:13324) |
| MED | `LocalPlayerState.CharacterChanged` has zero subscribers while the character sheet polls a full sheet rebuild via ~30 per-widget closures per frame. `Properties` returns the internal mutable reference despite its "snapshot" doc. | [LocalPlayerState.cs:134](src/AcDream.Core/Player/LocalPlayerState.cs:134) |
| MED | Window open/close state has no notification channel to controllers: inventory close button routes straight to GameWindow's `CloseRetailWindow`, so `NoLongerViewingContents` is never sent for the viewed container and `_openContainer` goes stale. Toolbar-button sync is manual push at every mutation site. | [InventoryController.cs:404](src/AcDream.App/UI/Layout/InventoryController.cs:404) |
| LOW | Window-visibility/toolbar sync manual push, no event seam on UiHost/UiRoot. | [GameWindow.cs:12023](src/AcDream.App/Rendering/GameWindow.cs:12023) |
| LOW | `ShortcutStore` (session-scoped state) trapped as a private field of ToolbarController. | [ToolbarController.cs:70](src/AcDream.App/UI/Layout/ToolbarController.cs:70) |
### T3 — Input arbitration (three stacks, no arbiter)
| Sev | Finding | Where |
|---|---|---|
| MED | Retail UI is a parallel Silk input subscription arbitrated by order-dependent boolean gates; the designed `WorldMouseFallThrough`/`WorldKeyFallThrough` seam exists only in a doc comment — dead code. | [UiHost.cs:78](src/AcDream.App/UI/UiHost.cs:78) |
| MED | UiRoot bypasses the Phase-K InputDispatcher scope stack and hardcodes its own chords. | [UiRoot.cs:463](src/AcDream.App/UI/UiRoot.cs:463) |
| MED | No arbitration between ImGui and UiRoot input stacks — both subscribe raw Silk devices; neither yields to the other's capture. | [UiHost.cs:78](src/AcDream.App/UI/UiHost.cs:78) |
| MED | Target-mode click interception is per-call-site opt-in: Toolbar/Paperdoll/main-pack consult ItemInteractionController first; InventoryController grid cells don't (`Clicked = () => SelectItem(guid)`), while the cursor shows TargetValid over those same cells — the first observable drift. | [InventoryController.cs:308](src/AcDream.App/UI/Layout/InventoryController.cs:308) |
### T4 — Widget engine internals
| Sev | Finding | Where |
|---|---|---|
| MED | Layout runs inside the draw traversal with no invalidation model; hit-testing/input consume draw-produced geometry (stale rects between frames; hidden subtrees' layout frozen). Already forced the `Anchors=None` exemption hack, repeated in UiScrollablePanel. | [UiElement.cs:323](src/AcDream.App/UI/UiElement.cs:323) |
| MED | Hit-testing allocates + sorts per node per call (`_children.ToArray()` + `Array.Sort` at every level), hidden behind property getters composed multiplicatively — the cursor controller added ~5 full walks/frame without touching UiRoot. | [UiRoot.cs:66](src/AcDream.App/UI/UiRoot.cs:66) |
| MED | Pointer-intent disambiguation is an accreting five-flag boolean lattice, hand-duplicated between press-time (`OnMouseDown`) and hover-time (`HoverWindowMove`) — drift shows as "cursor says move but press does drag". | [UiRoot.cs:288](src/AcDream.App/UI/UiRoot.cs:288) |
| LOW | Dead Device seams: `RegisterTimerEvent` accumulates timers that never fire; `FireEvent` has no callers. | [UiRoot.cs:675](src/AcDream.App/UI/UiRoot.cs:675) |
| LOW | `ItemDragPayload` carries a live widget back-ref no consumer reads, while grids rebuild mid-drag. | [ItemDragPayload.cs:24](src/AcDream.App/UI/ItemDragPayload.cs:24) |
### T5 — Importer policy scatter
| Sev | Finding | Where |
|---|---|---|
| MED | "Which dat children become live widgets" policy has three owners: class-level `ConsumesDatChildren` flag, an importer carve-out naming `UiMeter` concretely, and controllers re-creating swallowed children from hardcoded sprite ids (CharacterStatController tab sprites 0x06005D92 etc.) — a dat-fidelity seam regression. | [LayoutImporter.cs:133](src/AcDream.App/UI/Layout/LayoutImporter.cs:133) |
| MED | `FindElement`'s last-wins `byId` table breaks on duplicate dat ids; the workaround is a second, type-incomplete lookup (per-widget `ElementId` switch returning 0 for four widget types) duplicating the base `UiElement.DatElementId` that already exists. Three parallel id-lookup mechanisms coexist. | [LayoutImporter.cs:116](src/AcDream.App/UI/Layout/LayoutImporter.cs:116) |
| MED | Scrollbar/menu chrome sprites hardcoded in three controllers although the dat carries them and the importer already resolves the inheritance chain that delivers them. | [DatWidgetFactory.cs:82](src/AcDream.App/UI/Layout/DatWidgetFactory.cs:82) |
| MED | Sub-window mount + slot-size cascade are structural heuristics tuned to the windows shipped so far, accreting inline patches. | [LayoutImporter.cs:300](src/AcDream.App/UI/Layout/LayoutImporter.cs:300) |
| LOW | Synthetic multi-top-level root leaks a hidden contract (consumers pluck the real window; rely on AddChild re-parenting to orphan stray siblings). | [LayoutImporter.cs:206](src/AcDream.App/UI/Layout/LayoutImporter.cs:206) |
| LOW | WindowChromeController accumulates per-window button ids in a shared static, binds by whole-tree sweep, and binds ALL ids (incl. Max/Min) to close — while ChatWindowController binds the same MaxMin id with a different meaning. | [WindowChromeController.cs:13](src/AcDream.App/UI/Layout/WindowChromeController.cs:13) |
### T6 — Controllers
| Sev | Finding | Where |
|---|---|---|
| MED | CharacterStatController is five controllers welded into one 1,959-line **static** `Bind` with closure-boxed state (`new[] { -1 }` mutable-array boxes threaded through ~33 static helpers). No instance → no teardown/rebind path, tab/selection state untestable. The god-object pattern relocated from GameWindow into a controller. | [CharacterStatController.cs:247](src/AcDream.App/UI/Layout/CharacterStatController.cs:247) |
| MED | Controller lifecycle contract inconsistent: ToolbarController subscribes with inline lambdas + no Dispose (unremovable by construction); Inventory/Paperdoll declare unsubscribing `Dispose()` but don't implement `IDisposable` and nothing ever calls them. Any relog/rebind or Studio hot-reload double-subscribes. | [ToolbarController.cs:152](src/AcDream.App/UI/Layout/ToolbarController.cs:152) |
| MED | Equip-rule logic split across two App-layer files (`ItemEquipRules` + ItemInteractionController's coverage scans) and sits below its natural Core home. | [ItemEquipRules.cs:5](src/AcDream.App/UI/ItemEquipRules.cs:5) |
| LOW | ItemInteractionController is pure game-rule logic living in the App.UI namespace (Core candidate). | [ItemInteractionController.cs:11](src/AcDream.App/UI/ItemInteractionController.cs:11) |
### T7 — Cursor pipeline (new slice)
| Sev | Finding | Where |
|---|---|---|
| MED | Cursor-art precedence is an implicit contract split across two layers; generic widget hover art shadows target-mode global cursors. | [CursorFeedbackController.cs:154](src/AcDream.App/UI/CursorFeedbackController.cs:154) |
| MED | OS cursor has no single arbiter: RetailCursorManager writes Type/Image/Hotspot across ALL mice; GameWindow independently writes `CursorMode` from three sites on the FIRST mouse only. Works only because the writers touch disjoint properties — an undocumented contract. | [RetailCursorManager.cs:28](src/AcDream.App/Rendering/RetailCursorManager.cs:28) |
| MED | Target-mode cursor cannot see world hover: `HoverTargetGuid` is built exclusively from the UI tree, so hovering a drudge with an active health kit shows pending crosshair instead of valid/invalid — retail keys 0x28/0x29 off the SmartBox target (a world object) per the catalog's own citation. | [CursorFeedbackController.cs:67](src/AcDream.App/UI/CursorFeedbackController.cs:67) |
| MED | Semantic controller hardcodes dat LayoutDesc state-name priority lists as string literals. | [CursorFeedbackController.cs:165](src/AcDream.App/UI/CursorFeedbackController.cs:165) |
| LOW | Silent permanent negative caches + OS-cursor fallback mask dat resolution failures; the fallback deviation has **no divergence-register row** (register rule breach). | [RetailCursorResolver.cs:47](src/AcDream.App/Rendering/RetailCursorResolver.cs:47) |
| LOW | Per-frame cursor update runs ~4 full UI hit-tests (property-getter recomputation). | [CursorFeedbackController.cs:57](src/AcDream.App/UI/CursorFeedbackController.cs:57) |
| LOW | OS-application half of the seam untested; the golden dat test silently passes when dats are absent. | [RetailCursorCatalogTests.cs:34](tests/AcDream.App.Tests/UI/RetailCursorCatalogTests.cs:34) |
### T8 — Strategy drift (three-layer plan vs reality)
| Sev | Finding | Where |
|---|---|---|
| MED | Shipped D.2b is a parallel fourth stack, not the Abstractions backend the design SSOT promises: ui-framework.md still says D.2b "remains design-only" / "AcDream.UI.Retail implements IPanelRenderer"; the architecture doc still shows the swappable-backend diagram. The pivot is acknowledged only in the 2026-06-14 panel-frame spec §0. The real stable seam is now the **ViewModels**, not IPanel/IPanelRenderer. | [ui-framework.md:8](docs/plans/2026-04-24-ui-framework.md) |
| MED | Plugin UI contract fractured: plugin surface exposes only `AddMarkupPanel`; markup panels render only under `ACDREAM_RETAIL_UI=1`, IPanel panels only under devtools. Plugin.Abstractions has zero project references — no backend-agnostic IPanel registration exists despite both docs promising it. | [IUiRegistry.cs:13](src/AcDream.Plugin.Abstractions/IUiRegistry.cs:13) |
| MED | Two live ChatVM instances with asymmetric wiring: the retail ChatVM lacks Fps/Position providers (init-only, can't be wired later) → `/framerate` and `/loc` silently degrade in the retail chat window — the surface meant to replace the ImGui one. | [GameWindow.cs:1937](src/AcDream.App/Rendering/GameWindow.cs:1937) |
| MED | Vivid Target Indicator (retail gameplay UI, decomp 0x004d6165) is implemented on the ImGui backend inside the devtools gate — in the end-state (RETAIL_UI=1, DEVTOOLS=0), selection has no visual indicator at all. | [TargetIndicatorPanel.cs:4](src/AcDream.App/UI/TargetIndicatorPanel.cs:4) |
| LOW | WindowChromeController hardwires a global close-button id list; binds Max/Min to close for every window. | [WindowChromeController.cs:52](src/AcDream.App/UI/Layout/WindowChromeController.cs:52) |
---
## Recommended remediation sequence (awaiting approval — report only)
Ordered by leverage; R1 is time-sensitive because the breach is still uncommitted.
- **R1 — Extract the character-sheet provider BEFORE committing this work.** Move
`CurrentCharacterSheet` + XP-curve/raise-cost math + raise send/apply into a
`CharacterSheetProvider` (App/UI/Layout; the pure curve math is Core-eligible),
and the repeated window-mount block into a small `RetailWindowMounter` shared
with MockupDesktop. This addresses both HIGHs + the Studio duplication at once,
and avoids cementing a rule-1 breach into history.
- **R2 — State-write chokepoints.** Route XP/credit debits through eventful APIs
(`ClientObjectTable.UpdateIntProperty` + an Int64 twin + a `LocalPlayerState`
spend API firing CharacterChanged); adopt the pending/confirm/rollback ledger
for raises; make `LocalPlayerState.Properties` read-only.
- **R3 — Input arbitration.** Decide the ownership model (single Silk owner
fanning out, or explicit documented gate order); delete or wire the dead
fall-through seam; route target-mode click interception through one chokepoint
(UiItemSlot/UiRoot offers the click to ItemInteractionController first).
- **R4 — Doc + register reconciliation.** One commit: ui-framework.md status
header + architecture-doc UI section rewritten to the real shape (two coexisting
stacks, VMs shared, retail UI = UiRoot tree); divergence-register row for the
cursor OS-fallback; decide the plugin markup-vs-IPanel contract question.
- **R5 — Structural debt (phase-scale, schedule separately).** CharacterStatController
→ instance controller; importer policy consolidation (per-child consume predicate,
duplicate-aware FindElement); layout pass out of draw; single ChatVM; Vivid Target
Indicator onto the retail path; controller IDisposable standardization.
## What this is NOT
This is NOT an assessment of the reported **terrain regression** — none of the 70
copied files touch terrain/streaming/rendering-world code (the diff is confined to
UI, Core.Net messages, and Core item/player state), so that remains a separate
investigation on its own evidence. It is also NOT a retail-fidelity gap list — the
cursor EnumIDMap chain, equip-mask semantics, and useability gates reviewed here
are dat/decomp-faithful in approach; the issues are structural, not behavioral.