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

20 KiB
Raw Blame History

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
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

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.