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>
20 KiB
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:
- 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.
- 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).
- 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.
- The design SSOT has silently drifted — the ui-framework plan and architecture
doc still promise an
IPanelRendererbackend 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/GetDragGhostvirtuals); 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/BuildFromInfosoverElementInfoPOCOs) is GL-free and dat-free;DatReaderWritertypes never leak past the importer — which is exactly why the same path runs in Studio. DatWidgetFactory.Createis a single localized type-registration switch — adding a retail widget type is one case + one widget class.- SSOT largely holds for items: controllers read
ClientObjectTablelive 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.ResolveKindis the single priority chain for cursor MEANING (pure over a snapshot record, tested); EnumIDMap resolution is dat-faithful with a decomp citation (ClientUISystem::UpdateCursorState0x00564630), cached, lock-disciplined;RetailCursorManagerdedupes 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/37–40. File grew 13,719 → 14,214 in this stream; committed-only delta was +13 — all growth is this feature. |
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 |
| MED | Per-window mount recipes duplicated verbatim between GameWindow and Studio's MockupDesktop (same element-id anchor rewrites in two files). | MockupDesktop.cs:184, GameWindow.cs:2161 |
| MED | GameWindow reaches back into raw LayoutDesc structures after import, duplicating the importer's property parsing inline. | GameWindow.cs:2013 |
| MED | Studio's RenderBootstrap is a hand-maintained copy of GameWindow.OnLoad wiring — already drifting. |
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 |
| 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 |
| 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 |
| 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 |
| 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 |
| LOW | Window-visibility/toolbar sync manual push, no event seam on UiHost/UiRoot. | GameWindow.cs:12023 |
| LOW | ShortcutStore (session-scoped state) trapped as a private field of ToolbarController. |
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 |
| MED | UiRoot bypasses the Phase-K InputDispatcher scope stack and hardcodes its own chords. | 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 |
| 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 |
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 |
| 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 |
| 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 |
| LOW | Dead Device seams: RegisterTimerEvent accumulates timers that never fire; FireEvent has no callers. |
UiRoot.cs:675 |
| LOW | ItemDragPayload carries a live widget back-ref no consumer reads, while grids rebuild mid-drag. |
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 |
| 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 |
| 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 |
| MED | Sub-window mount + slot-size cascade are structural heuristics tuned to the windows shipped so far, accreting inline patches. | 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 |
| 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 |
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 |
| 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 |
| 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 |
| LOW | ItemInteractionController is pure game-rule logic living in the App.UI namespace (Core candidate). | 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 |
| 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 |
| 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 |
| MED | Semantic controller hardcodes dat LayoutDesc state-name priority lists as string literals. | 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 |
| LOW | Per-frame cursor update runs ~4 full UI hit-tests (property-getter recomputation). | CursorFeedbackController.cs:57 |
| LOW | OS-application half of the seam untested; the golden dat test silently passes when dats are absent. | 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 |
| 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 |
| 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 |
| 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 |
| LOW | WindowChromeController hardwires a global close-button id list; binds Max/Min to close for every window. | 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 aCharacterSheetProvider(App/UI/Layout; the pure curve math is Core-eligible), and the repeated window-mount block into a smallRetailWindowMountershared 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 + aLocalPlayerStatespend API firing CharacterChanged); adopt the pending/confirm/rollback ledger for raises; makeLocalPlayerState.Propertiesread-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.