merge: bring main (UN-7, #140 filing, D.2b UI rows) into A7 Fix D round-2 branch

Resolves the divergence-register conflict: kept the accurate per-VERTEX AP-35
(Fix A shipped per-vertex; main's row was the stale pre-Fix-A per-pixel text),
kept main's UI rows AP-37..AP-42, and renumbered this branch's torch-gate row
AP-37 -> AP-43 (AP-37 was taken by main's LayoutDesc row). AP count 41 -> 42.
Retargeted the AP-37 references in WbDrawDispatcher + the CHECKPOINT to AP-43.
Marked ISSUES #140 RESOLVED (b7d655b) with the corrected root cause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-20 09:29:53 +02:00
commit c83fd02642
94 changed files with 16216 additions and 199 deletions

View file

@ -0,0 +1,392 @@
# D.2b — Retail panel frame + live Vitals (Approach C: KSML-style engine) — Design
**Date:** 2026-06-14
**Status:** Design approved (brainstorm) + **re-grounded 2026-06-14** onto the existing `AcDream.App/UI/` retained-mode scaffold (see §0). Pending spec re-review → implementation plan.
**Phase:** D.2b — Custom retail-look UI backend ([roadmap:427](../../../docs/plans/2026-04-11-roadmap.md))
**Milestone:** M5 "Looks like retail" — **explicitly PARALLELIZABLE with M3/M4** ([milestones:378](../../../docs/plans/2026-05-12-milestones.md)). Opened as a parallel track while M1.5 is the active critical-path milestone; the M5 parallelizable flag is the milestone-discipline carve-out.
**Grounding:** read-only research workflow `wf_39a90d37-e5a` (7 readers + gap-critic) + a direct read of `src/AcDream.App/UI/`. Every binding fact cites `file:line` in `src/` or a named-retail symbol.
---
## 0. Re-grounding correction (read this first)
The first draft of this spec proposed building a `RetailPanelHost : IPanelHost` +
`RetailPanelRenderer : IPanelRenderer` and a retained-mode toolkit *from scratch*.
**That was wrong.** A direct read of `src/AcDream.App/UI/` found a **complete,
dormant retained-mode toolkit** — the 2026-04-17 scaffold the roadmap names as
"the implementation foundation here" ([roadmap:427](../../../docs/plans/2026-04-11-roadmap.md)):
- **`UiRoot`** ([UiRoot.cs](../../../src/AcDream.App/UI/UiRoot.cs)) — the hard
part is already built: mouse routing, keyboard focus, mouse capture, a full
drag-drop state machine, tooltip timer, modal handling, click/right-click
detection, world fall-through. Retail-faithful event codes in
[UiEvent.cs](../../../src/AcDream.App/UI/UiEvent.cs).
- **`UiElement`** (geometry/tree/hit-test), **`UiPanel`/`UiLabel`/`UiButton`**
([UiPanel.cs](../../../src/AcDream.App/UI/UiPanel.cs)), **`UiHost`**
([UiHost.cs](../../../src/AcDream.App/UI/UiHost.cs) — packages `UiRoot` +
`TextRenderer` + font, with `Tick`/`Draw`/`WireMouse`/`WireKeyboard`),
**`UiRenderContext`** ([UiRenderContext.cs](../../../src/AcDream.App/UI/UiRenderContext.cs)
— transform stack + `DrawRect`/`DrawString`).
`UiHost` is **dormant** — never instantiated in `GameWindow` (verified: `new
UiHost(` appears only in a doc-comment). And `UiPanel.cs` is the *exact file*
divergence row TS-30 points at: it draws a flat translucent rect *"until our
AcFont/UiSpriteBatch consumes [9-slice dat sprites] directly."*
**Consequence:** the retail UI is this existing `UiRoot` tree — a separate system
from the ImGui `IPanelRenderer` path, **not** an `IPanelRenderer` implementation.
Spec 1 *wires the dormant `UiHost`* and *adds the few missing pieces*, rather than
building a backend. This is strictly less code and more faithful. §4/§5/§8/§9/§10
below are written against the scaffold.
*(Process note: the grounding workflow's "UI" readers keyed on the ImGui/Abstractions
framing in their prompts and never globbed `src/AcDream.App/UI/`. Lesson: a
subsystem-discovery pass must glob by directory, not only by the framing the
parent already has in mind.)*
## 1. Context & goal
acdream needs a retail-faithful game UI. The shipped path (D.2a) is an ImGui
overlay gated on `ACDREAM_DEVTOOLS=1` — a debugger aesthetic, intentionally
temporary. D.2b stands up the *retail-look* UI (the dormant `UiHost` tree) that
draws retail's actual dat assets, while the ImGui devtools path stays untouched.
**The user's framing (2026-06-14):** AC's UI engine is Keystone — and Keystone
was *already* markup + stylesheet (KSML, an HTML-clone XML defined by `ksml.xsd`,
+ `controls.ini`, a CSS-like INI stylesheet). So "make it look + behave like
retail, but author it in a CSS/HTML-style way" re-expresses AC's own design in
its modern equivalent.
**Approach decision (Approach C).** Three integration families were weighed:
(A) embed a real web engine (Ultralight/CEF), (B) a native HTML/CSS-subset lib
(RmlUi), (C) our own KSML-style markup + stylesheet over a retained-mode toolkit
on Silk.NET. **C chosen** for: zero external deps (keeps the native-AOT goal
intact), lowest memory (~310 MB vs CEF's 150300 MB), full control, and maximal
faithfulness — it mirrors Keystone directly, and the retained-mode toolkit C
needs *already exists* (§0).
This spec covers **Spec 1**: wire the scaffold + add the markup/stylesheet/sprite
gaps, proven end-to-end on **one** panel — the universal window frame wrapping
the live Vitals bars.
## 2. Scope
**In Spec 1:**
- Wire the dormant **`UiHost`** into `GameWindow`, gated by a new
`RuntimeOptions.RetailUi` toggle (`ACDREAM_RETAIL_UI=1`). The ImGui devtools
path is untouched and may run simultaneously.
- Add dat-sprite drawing: `UiRenderContext.DrawSprite` (UV-rect) + a
`TextRenderer` textured-sprite path + a `ui_text.frag` `uUseTexture=2` branch.
- A **`UiNineSlicePanel : UiPanel`** that draws the 8-piece dat-sprite window
frame + center fill (upgrading the exact code TS-30 cites) — title bar
(`UiLabel`) + a close button (`UiButton`, which already exists).
- A **`UiMeter : UiElement`** vital bar bound to a `Func<float>` reading
`VitalsVM`.
- The XML markup format (mirrors `ElementDesc`) + a `MarkupDocument` parser that
**instantiates a `UiElement` subtree** + a minimal `controls.ini` stylesheet
loader.
- The plugin-facing contract: plugins contribute a `UiElement`/markup subtree
added to `UiRoot` (§9) — designed now, first consumer first-party.
**Deferred to later sub-phases (explicitly OUT):**
- **Wiring `UiHost`'s input** (`WireMouse`/`WireKeyboard`) into the existing
Phase-K `InputDispatcher`. The `UiRoot` input *machinery* exists; *integrating*
two input consumers (route unconsumed `WorldMouseFallThrough` back to the game)
is its own concern. Spec 1 is **render-only** (`Tick` + `Draw`), so the frame +
live bars show but the close button isn't clicked and the window isn't dragged.
- The dat A8 glyph font loader (`AcFont`) → numeric overlays.
- The full anchor solver (`StateDesc::UpdateSizeAndPosition` port).
- The `LayoutDesc` binary importer (sub-project 3).
- Reskinning Chat / Debug / Settings.
- Login / char-select / chargen screens (raw-JPEG backgrounds, sub-project 4).
## 3. Source-verified facts (do-not-trust list)
The grounding caught several load-bearing "facts" that were wrong/unverified.
These are binding:
| Claimed (memory / first draft) | Reality (source-verified) |
|---|---|
| Build a retained-mode toolkit + `RetailPanelHost`/`RetailPanelRenderer` | The toolkit **exists** in `src/AcDream.App/UI/` (§0); the retail UI is the `UiRoot` tree, not an `IPanelRenderer` backend |
| `VitalsVM` is `record VitalsVM(int HpCurrent, …)` | Sealed class: `HealthPercent` (float), `StaminaPercent`/`ManaPercent` (float?), `*Current`/`*Max` (uint?), ctor `(CombatState, LocalPlayerState?)`, `SetLocalPlayerGuid(uint)` — [VitalsVM.cs:35](../../../src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs) |
| Chrome sprite IDs `0x06004CC2`/`0x21000040`/`0x060074BF..C6` are known | **Unverified + contradictory.** `0x06001125` (cited elsewhere) is the char-select highlight. **No chrome ID is trusted — Step 0 dat prove-out resolves them empirically.** |
| `#FFDBD6A8` "parchment cream" is the panel background | It is `[editbox]`/`[treeview]` **text** color. Real frame tokens: `[title]` bg `#FFFFFFFF`, font `Verdana-10-bold`, height 19; `[body]` bg `#00000000` (transparent), `color_border=#FF4F657D` |
| `DatCollection` is NOT thread-safe | Concurrent **reads are safe** since v2.1.7 ([2026-06-09 investigation](../../../docs/research/2026-06-09-dat-reader-thread-safety-investigation.md)). No UI-specific lock. |
| KSML is the panel-layout language | KSML is **rich-text-in-text-regions**; the panel layout format is the binary `LayoutDesc`/`ElementDesc` tree ([acclient.h:33693](../../../docs/research/named-retail/acclient.h)). Our markup mirrors `ElementDesc`. |
## 4. Architecture & placement
The retail UI lives in **`src/AcDream.App/UI/`** (where the scaffold already is).
New widgets/parsers join it as dedicated files. Code-Structure Rule 1 is honored
(nothing substantial added to `GameWindow.cs` — only a few wiring lines); Rule 2
(Core stays GL-free) and Rule 3 (panels target `AcDream.UI.Abstractions`) are
unaffected because the retail UI is a *separate* tree, not an `IPanelRenderer`
panel.
```
┌──────────────────────────────────────────────────────────┐
│ retail dat (read-only fidelity source) │
│ controls.ini → style tokens · RenderSurface 0x06xxxxxx │
│ → sprites · Font 0x40xxxxxx → glyphs (deferred) │
└───────────────┬──────────────────────────────────────────┘
│ TextureCache.GetOrUpload(id) → Texture2D
┌───────────────▼──────────────────────────────────────────┐
│ src/AcDream.App/UI/ (scaffold EXISTS; + = new in Spec 1) │
│ UiHost (exists, dormant) ─ wire into GameWindow │
│ UiRoot/UiElement (exist) ─ input + tree + hit-test │
│ UiRenderContext (exists) + DrawSprite(UV-rect) │
│ UiPanel/UiLabel/UiButton (exist) │
│ + UiNineSlicePanel : UiPanel (8-piece dat chrome) │
│ + UiMeter : UiElement (vital bar) │
│ + MarkupDocument (XML → UiElement subtree) │
│ + ControlsIni (stylesheet loader) │
│ uses Rendering/TextRenderer (+ sprite path, + DepthMask) │
└───────────────┬──────────────────────────────────────────┘
│ UiMeter.Fill = () => vm.HealthPercent
┌───────────────▼──────────────────────────────────────────┐
│ AcDream.UI.Abstractions (exists) — VitalsVM (unchanged) │
│ ↑ ImGui IPanelHost/IPanelRenderer path stays for │
│ ACDREAM_DEVTOOLS, fully independent of the above │
└──────────────────────────────────────────────────────────┘
```
**Coexistence.** Two UI systems run side by side, independently:
`ACDREAM_DEVTOOLS=1` → the ImGui overlay (unchanged); `ACDREAM_RETAIL_UI=1`
the `UiHost` tree. The retail pass renders in the post-3D slot
([GameWindow.cs:8232 region](../../../src/AcDream.App/Rendering/GameWindow.cs))
with deterministic ordering relative to ImGui. `UiHost.Draw` already does
`TextRenderer.Begin → Root.Draw(ctx) → TextRenderer.Flush`
([UiHost.cs:58](../../../src/AcDream.App/UI/UiHost.cs)).
## 5. Render foundation — extend the existing 2D path
`UiHost` draws the `UiRoot` tree through a `UiRenderContext` backed by the shared
`TextRenderer` ([UiHost.cs:58-67](../../../src/AcDream.App/UI/UiHost.cs)). That
`TextRenderer` does solid rects + R8 text today but **not** textured RGBA sprites
([ui_text.frag:9](../../../src/AcDream.App/Rendering/Shaders/ui_text.frag),
[TextRenderer.cs](../../../src/AcDream.App/Rendering/TextRenderer.cs)). Spec 1
adds the sprite path:
- **`ui_text.frag`** += a `uUseTexture==2` branch: `FragColor = texture(uTex,
vUv) * vColor;` (the existing `0`=solid and `1`=R8-coverage branches are
untouched).
- **`TextRenderer`** += `DrawSprite(uint texture, float x,y,w,h, float
u0,v0,u1,v1, Vector4 tint)` accumulating into **per-texture** sprite buffers
(`Dictionary<uint, List<float>>`), and a `Flush` pass that, after rects+text,
draws each texture's batch with `uUseTexture=2`. Reuses the existing
`AppendQuad` (which already takes `u0,v0,u1,v1`) + `UploadBuffer` machinery.
- **`TextRenderer.Flush`** += explicit **`DepthMask(false)`** (queried + restored)
— it disables `DepthTest` today but never sets `DepthMask`
([TextRenderer.cs:171](../../../src/AcDream.App/Rendering/TextRenderer.cs)).
Per the project's "render self-contained GL state" rule.
- **`UiRenderContext`** += `DrawSprite(uint texture, float x,y,w,h, float
u0,v0,u1,v1, Vector4 tint)` that adds the current transform and forwards to
`TextRenderer.DrawSprite` (mirrors the existing `DrawRect` forwarder at
[UiRenderContext.cs:50](../../../src/AcDream.App/UI/UiRenderContext.cs)).
No new shader class, VAO, or batcher — we extend the proven path the scaffold
already uses. (`Shader` is the simple file-based class
[Shader.cs](../../../src/AcDream.App/Rendering/Shader.cs); `GLSLShader`'s bindless
machinery is not needed.)
## 6. Dat assets & the Step-0 prove-out gate
`TextureCache.GetOrUpload(uint surfaceId)` returns a conventional `Texture2D`
GL handle (1×1 magenta on failure) — exactly right for the UI batch
([TextureCache.cs:70](../../../src/AcDream.App/Rendering/TextureCache.cs)). The
decode chain + `PFID_*` formats already work
([SurfaceDecoder.cs:39](../../../src/AcDream.Core/Textures/SurfaceDecoder.cs)).
`GameWindow` already holds a `TextureCache`; `UiHost`/`UiNineSlicePanel` receive
it (or a `Func<uint,uint>` sprite-resolver) by injection.
**Step 0 is empirical and comes first.** Because no chrome sprite ID is verified,
the first implementation task draws each candidate ID
(`0x06004CC2`, `0x060074BF..C6`, `0x0600129C`, …) as a raw quad and visually
confirms which decode to frame-shaped art vs magenta vs the wrong sprite. The
confirmed IDs are recorded in code comments before any chrome layout is written.
**No ID is hardcoded on faith.**
The frame is **8 quads + a center fill** (4 corner + 4 edge sprites + center),
not one stretched 9-slice texture. Slice/edge metrics are a **documented stopgap
constant** (with a divergence row) until the `LayoutDesc` tree is parsed
(sub-project 3).
## 7. Markup + stylesheet model
**Markup** mirrors `ElementDesc` 1:1 ([acclient.h:33693](../../../docs/research/named-retail/acclient.h));
`MarkupDocument` parses it and **instantiates a `UiElement` subtree** (a
`UiNineSlicePanel` with child `UiLabel`/`UiMeter`/`UiButton`). Authoring shape:
```xml
<panel id="acdream.vitals" x="10" y="30" w="220" h="96" title="Vitals">
<meter id="health" x="8" y="24" w="200" h="13" fill="{HealthPercent}" color="#FF0000"/>
<meter id="stamina" x="8" y="44" w="200" h="13" fill="{StaminaPercent}" color="#D9A626"/>
<meter id="mana" x="8" y="64" w="200" h="13" fill="{ManaPercent}" color="#0000FF"/>
</panel>
```
This is the shape the future `LayoutDesc` importer will *emit*, so authoring and
imported formats converge. It is **not** KSML (rich-text, deferred). `{Binding}`
expressions resolve against a supplied binding object (the `VitalsVM`) via
reflection on the property name.
**Anchor codes** are *defined* now (0=fixed, 1=stretch, 2=proportional-translate,
3=center, 4=proportional-scale — from `StateDesc::UpdateSizeAndPosition`
@`0x0069BF20`) but the **solver is deferred**: the Vitals window is fixed-size
(placed via the existing `UiElement.Left/Top`), so Spec 1 needs no solver.
**Stylesheet.** A small INI loader parses `controls.ini` keyed by element-type
section, honoring `#AARRGGBB` (alpha-first) and `font://Face-Pt[-style]`. Cascade:
element-type defaults → per-element `class=` → inline attributes. **Optional**
(§10): absent AC install → source-verified `[title]`/`[body]` fallback tokens.
## 8. VM binding (the Vitals slice)
The vitals panel is a `UiNineSlicePanel` (chrome) containing a `UiLabel` (title)
and three `UiMeter`s. Each `UiMeter` holds a `Func<float?> Fill` bound to the
real `VitalsVM` ([VitalsVM.cs:67](../../../src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs)):
`() => vm.HealthPercent`, `() => vm.StaminaPercent`, `() => vm.ManaPercent`. The
VM already does all server plumbing, so we do **not** re-derive vitals from the
retail `gmVitalsUI`/`CACQualities` decomp.
`UiMeter.OnDraw` draws the empty bar (`ctx.DrawRect`), the filled portion as a
**partial-size rect** (`width = pct * Width`), and a centered `current/max` numeric
overlay (`Func<string?> Label`). **Retail's vitals ARE exactly this — three stacked
horizontal bars (confirmed against a live retail client 2026-06-14), NOT orbs.**
Colors: Health red, **Stamina gold** (the earlier `#10F0F0` cyan research note was
wrong), Mana blue. A `null` fill/label (pre-`PlayerDescription`) renders gracefully.
The remaining gap to pixel-retail is the **glassy gradient bar fill sprite** + the
**retail dat font** for the numbers (today the stub `BitmapFont` draws them) — both
polish, deferred to §15.
The `VitalsVM` is constructed and given the player GUID the same way as today
([GameWindow.cs:1330](../../../src/AcDream.App/Rendering/GameWindow.cs) ctor,
:1984 `SetLocalPlayerGuid`); the retail-UI build path reuses that same VM
instance.
## 9. Plugin contract (designed now, first consumer first-party)
The plugin API is a day-1 constraint; plugin authors must be able to add retail
UI. The natural unit is a **`UiElement`/markup subtree added to `UiRoot`** (not
`IPanel`/`IPanelRenderer`, which is the ImGui devtools path). Spec 1 adds:
- A small `IUiRegistry` (in `AcDream.Plugin.Abstractions`) — `void
AddMarkupPanel(string markupPath, object binding)` (and/or `void
AddElement(UiElement)` once a plugin-safe element surface is decided). For
Spec 1, `AddMarkupPanel` is enough.
- `IPluginHost` gains `IUiRegistry Ui { get; }`
([IPluginHost.cs:8](../../../src/AcDream.Plugin.Abstractions/IPluginHost.cs)
has none today); `AppPluginHost` implements it
([AppPluginHost.cs:5](../../../src/AcDream.App/Plugins/AppPluginHost.cs)).
- Because plugin `Enable()` runs in `Program.cs` **before** the GL window opens
([Program.cs:55-60](../../../src/AcDream.App/Program.cs)), `AddMarkupPanel`
**buffers** registrations into a list that `GameWindow` drains into `UiRoot`
after `UiHost` is constructed. The threading/timing concern lives in the host;
the plugin call is unconditional.
- The first consumer is the first-party vitals panel (built directly in
`GameWindow`, not through the registry). Wiring an actual plugin-supplied markup
panel end-to-end is exercised by a smoke test but is otherwise the thin
follow-up. This task group is the **last** in the plan so the visible vitals
slice can land first if it slips.
## 10. Confirmed decisions (approved 2026-06-14)
1. **Render-only first slice.** `Tick` + `Draw` only; the `UiHost` input wiring
(`WireMouse`/`WireKeyboard`) is **not** connected to the existing Phase-K
`InputDispatcher` yet, so the close button isn't clickable and the window
isn't draggable. Rationale (corrected): the `UiRoot` input *machinery* already
exists — what's deferred is *integrating two input consumers* (routing
unconsumed `WorldMouseFallThrough` back to the game's dispatcher), which is its
own sub-phase.
2. **`controls.ini` optional.** Assume `C:\Turbine\Asheron's Call\` may or may not
exist. Add an `ACDREAM_AC_DIR` `RuntimeOptions` field; when absent, fall back
to the source-verified `[title]`/`[body]` token values. The build never fails
on a missing AC install.
## 11. Build sequence
| Step | Deliverable | Proves |
|---|---|---|
| 0 | Dat prove-out harness: draw candidate chrome IDs, confirm the real ones | Resolves the chrome-ID contradiction empirically |
| 1 | `ui_text.frag` `uUseTexture=2` + `TextRenderer.DrawSprite` + `DepthMask` + `UiRenderContext.DrawSprite` | A dat sprite composites over the 3D scene |
| 2 | `UiNineSlicePanel` draws an empty titled frame from confirmed dat sprites (stopgap insets) | Retail-shaped chrome renders |
| 3 | `UiMeter` + a hand-built vitals `UiNineSlicePanel` subtree bound to `VitalsVM`, wired via `UiHost` under `ACDREAM_RETAIL_UI` (render-only) | End-to-end live data + the scaffold lights up |
| 4 | `ControlsIni` parser (TDD) feeding the panel's title/colors | Stylesheet cascade |
| 5 | `MarkupDocument` parser (TDD) → builds the same vitals subtree from `vitals.xml` | The Approach-C markup engine |
| 6 *(last)* | `IUiRegistry` on `IPluginHost` + buffered drain into `UiRoot` + smoke test | Plugin-ready |
## 12. Error handling & edge cases
- **Missing/undecodable sprite**`GetOrUpload` magenta fallback is visible;
Step 0 catches it. A null/zero DataID in markup logs a warning, draws nothing.
- **AC install absent**`controls.ini` load skipped, baked fallback tokens used.
- **Vitals null percents** → empty bar (`UiMeter.Fill` returns null).
- **Window resize**`UiHost.Draw` already sets `Root.Width/Height` to the
current screen size each frame ([UiHost.cs:61](../../../src/AcDream.App/UI/UiHost.cs));
fixed-coord panels stay put. No DPI scaling (known out-of-scope gap).
- **Both toggles on** → ImGui Vitals and retail Vitals may both show (fine in dev).
## 13. Testing
- **`ControlsIni` parser** (pure, no GL) — unit tests for `#AARRGGBB`, `font://`,
cascade order. Lives in `src/AcDream.App/UI/`, tested in `tests/AcDream.App.Tests/`
(App-layer, Rule 6).
- **`MarkupDocument` parser** — unit tests for XML → `UiElement` tree shape
(types, geometry) and `{Binding}` resolution against a fake binding object.
- **`UiMeter` fill geometry** — unit test that fill fraction → partial rect width
(pure math; `UiMeter.ComputeFillRect(pct, w, h)` as a static helper so it's
testable without GL).
- **`UiNineSlice` geometry** — unit test that a frame size + insets → the 9 dst
rects (`UiNineSlicePanel.ComputeSliceRects` static helper).
- **Plugin smoke** — a test plugin calls `host.Ui.AddMarkupPanel` and the buffered
registration is drained (assert the panel is added to `UiRoot`).
- **Visual acceptance** (user) — retail-shaped Vitals frame with live bars under
`ACDREAM_RETAIL_UI=1`; ImGui path unaffected under `ACDREAM_DEVTOOLS=1`.
- `dotnet build` + `dotnet test` green.
## 14. Bookkeeping
- **Phase D.2b**, Milestone **M5** (parallelizable; NOT on the M1.5 critical
path). The CLAUDE.md "Current state" line stays on M1.5.
- **Divergence register:** in the commit that ships `UiNineSlicePanel` rendering a
real dat sprite, **delete row TS-30** ([retail-divergence-register.md:166](../../../docs/architecture/retail-divergence-register.md))
— its cited file (`UiPanel.cs`) is upgraded by the subclass — and **add one**
new IA-row (Intentional Architecture; keystone.dll has no PDB/decomp) for the
markup/serialization layer. Assign the next sequential IA number at commit time.
Retail oracle: "LayoutDesc 0x21xxxxxx; controls.ini panel-property vocabulary;
keystone.dll layout evaluation (no PDB)". Do **not** duplicate IA-12 (UI
toolkit *behavioral* approximation). A second row for the stopgap slice insets
is added when they ship.
- **Spec file:** this document.
## 15. Open gaps & deferred sub-projects
- **Input integration** — connect `UiHost.WireMouse`/`WireKeyboard` to the Phase-K
`InputDispatcher`, routing unconsumed `WorldMouseFallThrough`/`WorldKeyFallThrough`
back to the game. Next sub-phase (lights up the close button + window drag that
`UiRoot` already supports).
- **`AcFont`** — dat A8 glyph loader (Font `0x40000xxx``ForegroundSurfaceDataId`
→ RenderSurface, upload as **R8** so `ui_text.frag`'s `.r`-coverage branch works
unchanged) → numeric overlays + retail fonts. (Today `UiLabel` uses the
stb_truetype `BitmapFont`.)
- **Anchor solver** — port `StateDesc::UpdateSizeAndPosition`; with the importer.
- **`LayoutDesc` binary importer** (sub-project 3) — bulk-transpile retail layouts
→ our markup, supplying real insets + coords. Symbols: `LayoutDesc::InqFullDesc`
@`0x0069A520`, `ElementDesc::Incorporate` @`0x0069B5A0`
([2026-05-08 pseudocode](../../../docs/research/2026-05-08-retail-ui-layout-resolution-pseudocode.md)).
- **`PFID_CUSTOM_RAW_JPEG`** decode + login/char-select/chargen (sub-project 4).
## 16. Acceptance criteria
- [ ] Step 0 prove-out done; real chrome sprite IDs confirmed + recorded in code.
- [ ] In `ACDREAM_RETAIL_UI=1`: a retail-shaped Vitals window renders via the wired
`UiHost``UiNineSlicePanel` 8-piece dat-sprite border + title + drawn close
button — with three `UiMeter` bars tracking HP/Stam/Mana live as the
character takes damage / regens.
- [ ] In `ACDREAM_DEVTOOLS=1`: ImGui Vitals/Chat/Debug/Settings unchanged.
- [ ] `controls.ini` loads when present, falls back cleanly when absent.
- [ ] `MarkupDocument` builds the vitals subtree from `vitals.xml`; pure parsers
unit-tested; plugin smoke test drains a buffered `AddMarkupPanel`.
- [ ] TS-30 deleted + one new IA-row added, same commit as the chrome.
- [ ] `dotnet build` green, `dotnet test` green.
- [ ] Visual verification by the user.

View file

@ -0,0 +1,267 @@
# D.2b — Chat-window re-drive (LayoutDesc importer, Plan 2 chat piece) — design
**Date:** 2026-06-15
**Branch:** `claude/hopeful-maxwell-214a12` (D.2b retail-UI track; lighting/M1.5 is a separate branch off main)
**Status:** design — approved scope, pending spec review
**Predecessor:** the LayoutDesc importer + the vitals re-drive
(`docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md`,
`docs/research/2026-06-15-layoutdesc-format.md`,
`claude-memory/project_d2b_retail_ui.md`).
**Handoff that opened this work:** `docs/research/2026-06-15-chat-window-redrive-handoff.md`.
---
## 1. Goal
Replace the hand-authored retail chat window (a `UiNineSlicePanel` hosting a
`UiChatView` at a guessed rect, built inline in `GameWindow.cs` under
`if (_options.RetailUi)`) with the **data-driven retail chat window** read from
the dat `LayoutDesc 0x21000006` (`gmMainChatUI`) via the existing `LayoutImporter`,
with **faithful behavioral widgets ported from the named retail decomp** and the
**dat font** — the same way the vitals window became data-driven.
**The code is modern. The behavior is retail.** Every widget algorithm is ported
from `docs/research/named-retail/acclient_2013_pseudo_c.txt` with a cited
`class::method @address`.
## 2. Approved scope
**In scope (faithful core):**
- Data-driven window frame from `0x21000006` (bg sprites, resize bar, grip chrome,
translucency).
- Transcript: dat-font `UIElement_Text` port — scrollable, bottom-pinned,
per-line chat-kind color, 10k-glyph behead cap.
- Scrollbar: right-side track + thumb + up/down buttons, **pixel-based** scroll,
`thumbRatio = view/content`, wheel = **1 line per notch**.
- Input: editable one-line field — caret, insert/delete, 100-entry command
history (up/down arrow), focus sprite, Enter→submit.
- Channel menu (`Chat ▸`): dropdown of channels; selection sets the active
outbound channel (the `ChatInputParser` default channel).
- Send button + max/min button.
- `ChatCommandRouter`: the shared submit pipeline, extracted from `ChatPanel`
so the ImGui devtools chat and the retail chat share one routing path.
**Deferred (each gets a `retail-divergence-register.md` row — these need *non-UI*
plumbing acdream lacks, they are NOT UI scope cuts):**
- **Numbered chat tabs (14) — switching + per-tab chat-type filtering.** The tab
*sprites* render (they come free from the importer), but clicking a tab to filter
which chat kinds show needs the per-tab `m_llTextTypeFilter` /
`m_chatNewNonVisibleTextIndicator` system.
- **Squelch toggle** (menu item 0) — needs a squelch subsystem.
- **Clickable name-tags** (`StartTell` on click) — needs `StringInfo`/`TextTag`
styled runs in `ChatLog`.
- **In-element word-wrap at panel width** — the transcript renders pre-split
`ChatLog` lines 1:1; faithful `GlyphList::Recalculate` wrap reworks the
selection/hit-test model (visual-row ≠ record). Highest-risk UI piece; deferred.
- **Per-glyph mixed-color runs / configurable font face+size** (`gmClient::sm_nFontFace`).
- **Active/inactive opacity switch** — a single default translucency is in scope;
the focused-brighter / unfocused-dimmer transition is deferred.
## 3. Retail reference (the port target)
`gmMainChatUI` (registered element class `0x10000041`, built from `LayoutDesc
0x21000006`) extends a base **`ChatInterface`**. `ChatInterface` owns the
transcript, input, inbound routing, submit, history, truncate and opacity;
`gmMainChatUI` adds the channel menu, squelch, max/min, tab visibility and
clickable name-tags.
### 3.1 Element → role map (`0x21000006`)
| Element | Type | Role | Decomp anchor |
|---|---|---|---|
| `0x1000000E` | `0x10000041` | window root (`gmMainChatUI`), bg `0x0600114D`, 800×100 authored | `gmMainChatUI::Register @0x4cd350` |
| `0x1000000F` | 9 Resizebar | top resize bar, img `0x06001125`, cursor `0x06005E66` | — |
| `0x1000046F` | 0 | max/min button (`Maximized 0x06005E64` / `Minimized 0x06005E65`) | `gmMainChatUI::HandleMaximizeButton @0x4cce50` |
| `0x10000010` | 3 Field | transcript panel bg `0x06001115` | — |
| **`0x10000011`** | 0 (UIElement_Text) | **transcript** — read-only, multiline, scrollable | `ChatInterface::PostInit @0x4f3e47` |
| `0x1000048c` | 0 | scroll **thumb** (child of transcript) | `ChatInterface::PostInit @0x4f3e79` |
| `0x10000012` | 0 | scrollbar **track** (right edge, 16×68) | `UIElement_Scrollable::GetScrollbarPointer_ @0x473ec0` |
| `0x10000013` | 3 Field | input bar bg `0x0600113A` | — |
| `0x10000014` | 6 Menu | **channel menu** (`Chat ▸`) — 14 items (squelch + 13 channels) | `gmMainChatUI::InitTalkFocusMenu @0x4cdc50`, `HandleSelection @0x4cd540` |
| **`0x10000016`** | 0 (UIElement_Text) | **input** — editable, one-line, focus sprite `0x060011AB` | `ChatInterface::PostInit @0x4f3e86` |
| `0x10000017/18` | 3 | input focus edges (sprite `0x06004D67`) | — |
| `0x10000019` | 0 | **Send** button (`0x06001915`/pressed `…16`/ghost `…34`) | `ChatInterface::ListenToElementMessage @0x4f51ea` |
| `0x10000522525` | 0 | **numbered chat tabs 14** (left strip; Normal `0x06006218`/Hi `0x06006219`) | `gmMainChatUI::RecvNotice_SetPanelVisibility @0x4ccd80` |
> **Screenshot correction (user-provided retail ground truth, 2026-06-15):** the
> four `0x10000522525` elements are the **left-edge numbered chat tabs**, NOT the
> "line/page scroll buttons" a research agent inferred from their 16×16 vertical
> geometry. The scrollbar (track + thumb + up/down) is on the **right**. The exact
> dat ids of the right-side scroll up/down buttons are located during Task D
> (likely children of track `0x10000012` not surfaced in the top-level dump).
> **BN field-name caveat:** the decomp's `m_chatEntry` / `m_chatLog` /
> `m_fCurrentOpacity` names are applied inconsistently across functions (a
> Binary-Ninja artifact). The roles above are fixed by the decisive evidence —
> the `Normal_focussed` sprite is on `0x10000016` (only an editable field gets a
> focus state) and the multiline geometry is `0x10000011` — corroborated by both
> surviving research agents. Port by **role**, not by the C++ member name.
### 3.2 Key retail algorithms (cited)
**Inbound** — `ChatInterface::RecvNotice_DisplayFinalStringInfo @0x4f4640`:
append `arg4` (prefix/timestamp) then `arg3` (body) to the transcript via
`UIElement_Text::AppendStringInfoWithFont` with per-chat-type color `arg2` (color
table built by `BuildChatColorLookupTable`). If glyph count > `0x2710` (10000),
`TruncateChatLog @0x4f4290` beheads from the top at a newline boundary. **Bottom-pin:**
capture `IsAtVerticalEnd` *before* appending; if it was true, `ScrollToPosition`
to the new end; else light the unread-text indicator.
**Submit** — `ChatInterface::HandleEnterKey @0x4f52d0` (fired by the *Accept*
input-action, not raw `\r` — one-line mode drops the `\r` char) → `ProcessCommand
@0x4f5100`: read input text, dispatch, push to `m_InputHistory` (cap 100, drop
index 0 when full), reset history cursor to `0xFFFFFFFF`, clear input. The Send
button (`0x10000019`) is an alternate trigger via `ListenToElementMessage`.
**Scroll** — `UIElement_Scrollable`: pixel offset `m_iScrollableY`, clamped to
`[0, contentHeight viewHeight]` (`SetScrollableXY @0x4740c0`). `thumbRatio =
view/content` clamped to 1, bar hidden when content ≤ view
(`UpdateScrollbarSize_ @0x4741a0`). `posRatio = scrollY/(contentview)`
(`UpdateScrollbarPosition_ @0x473f20`). Scroll quantum = one line-height
(`UIElement_Text::InqScrollDelta @0x4689b0`); page = view height; **wheel = 1 line
per notch** (`HandleMouseWheel @0x471450`).
**Input** — caret `m_nCursorPos` (glyph index); `GlyphList::FindPixelsFromPos
@0x472b40` = Σ glyph advances to the caret; midpoint-snap hit-test
`FindPosFromLineAndPixels @0x4732d0`; ~1 Hz blink. Glyph advance =
`HorizontalOffsetBefore + Width + HorizontalOffsetAfter` (signed bytes,
`Font::GetCharWidthA @0x4433f0`) — **already implemented** by
`UiDatFont.GlyphAdvance`. History: `SelectCommandFromHistory` (up=back, down=fwd),
sentinel `0xFFFFFFFF` = "not browsing".
**Channel menu** — `InitTalkFocusMenu @0x4cdc50` fills `UIElement_Menu 0x10000014`
with 14 items: item 0 = squelch toggle, items 113 = channels carrying attr
`0x1000000B` = channel enum (1=Say, 2=Tell/Target, 3=Emote, 4=Fellowship,
5=Patron, 6=Trade, 7=Allegiance, 80xD=area/custom). `HandleSelection @0x4cd540`
reads the enum, calls `SetTalkFocus(enum)`, updates the label, marks the item
selected.
## 4. Architecture (acdream)
Faithful structure: an importer builds the generic frame; a **controller**
(`ChatInterface`+`gmMainChatUI`::PostInit analogue) binds behavior by element id
and swaps the transcript/input placeholders for behavioral widgets. New classes
live in `src/AcDream.App/UI/` (widgets, GL-side) and `src/AcDream.UI.Abstractions/`
(the shared submit router).
| Component | Kind | Retail analogue | Responsibility |
|---|---|---|---|
| `ChatWindowController` | new (`App/UI/Layout/`) | `ChatInterface` + `gmMainChatUI` PostInit | import `0x21000006`; `FindElement(id)`; swap transcript/input widgets; wire scrollbar/menu/send/max-min; route in/outbound |
| `UiChatView` | **extend** (`App/UI/`) | `UIElement_Text` (transcript) | + `UiDatFont? DatFont`; dat-font measure/advance/draw; **1-line** wheel quantum; keep bottom-pin + drag-select + Ctrl+C |
| `UiChatInput` | new (`App/UI/`) | `UIElement_Text` (editable) | caret, insert/delete, 100-entry history, focus sprite swap, dat font, `Action<string>? OnSubmit` |
| `UiScrollable` | new (`App/UI/`) | `UIElement_Scrollable` | pixel scroll math: `ScrollY`, `ContentHeight`, `ViewHeight`, `ClampScroll`, `ThumbRatio`, `ThumbOffsetRatio`, line/page delta |
| `UiChatScrollbar` | new (`App/UI/`) | composed scrollbar | own the imported track/thumb/up-down sprites; size+place thumb from `UiScrollable`; clicks/drag → `UiScrollable` |
| `UiChannelMenu` | new (`App/UI/`) | `UIElement_Menu` | dropdown popup of channels; selection → active `ChatChannelKind`; label reflects selection |
| `ChatCommandRouter` | new (`UI.Abstractions/Panels/Chat/`) | `ProcessCommand` | shared submit: client-command intercept → unknown-verb guard → `ChatInputParser.Parse(text, channel, lastTell, lastOutgoing)``Publish(SendChatCmd)` |
| `UiDatFont` | no change | `Font` | already implements retail glyph advance |
**Why two widget classes (`UiChatView` + `UiChatInput`) when retail uses one
`UIElement_Text` with mode bits:** acdream's retained-mode widget layer predates
D.2b; the behavioral contract (read-only multiline scroll vs editable one-line) is
identical, only the class split differs. Accepted **ADAPTATION** divergence; both
classes share the `UiDatFont.GlyphAdvance` measure seam so geometry is consistent.
**Placeholder swap:** the transcript (`0x10000011`) and input (`0x10000016`)
render no background sprite of their own (bg comes from parent panels
`0x10000010` / `0x10000013`), so `ChatWindowController` reads each placeholder's
rect + anchors, instantiates `UiChatView` / `UiChatInput` there, adds it to the
placeholder's parent, and removes the placeholder. Mirrors `GetChildRecursive(id)`
binding in `ChatInterface::PostInit`.
## 5. Data flow
- **Inbound:** `ChatLog → ChatVM.RecentLinesDetailed()` (200-deep tail) →
`UiChatView.LinesProvider` (per-`ChatKind` color via `RetailChatColor`). Pipeline
unchanged. Bottom-pin + 10k cap are `UiChatView`/`UiScrollable` behavior.
- **Outbound:** `UiChatInput.OnSubmit(text)`
`ChatCommandRouter.Submit(text, vm, commandBus, activeChannel)``SendChatCmd`
`LiveCommandBus``WorldSession`. `activeChannel` comes from `UiChannelMenu`.
- **Channel:** `UiChannelMenu` selection → `ChatWindowController._activeChannel`
(→ `ChatInputParser` default channel) + menu label update.
- **Scroll:** transcript content height → `UiScrollable``UiChatScrollbar` thumb;
wheel/buttons/drag → `UiScrollable.ScrollY` → transcript draw offset.
## 6. Faithfulness decisions / divergence-register rows
Add on landing (category in parens):
1. **(Adaptation)** Transcript + input are two classes (`UiChatView`/`UiChatInput`)
not one mode-flagged `UIElement_Text`. Behavior identical.
2. **(Approximation)** Transcript renders pre-split `ChatLog` lines 1:1; no
in-element word-wrap at panel width. Symptom: long lines not re-wrapped on
horizontal resize. `file:line` = `UiChatView.cs`.
3. **(Approximation)** One color per display line, not per-glyph styled runs.
4. **(Stopgap)** Numbered chat tabs render but don't switch / filter chat kinds.
5. **(Stopgap)** Squelch toggle + clickable name-tags render/parse-absent.
6. **(Approximation)** Single default translucency; no focused/unfocused opacity
transition; default dat font face+size (no `sm_nFontFace` config).
Retire nothing (no existing register row is fixed by this work).
## 7. Build sequence (tasks for the plan)
Pipelineable where independent; `ChatWindowController` (G) and the `GameWindow`
cutover (H) are the integration barrier.
- **A. `ChatCommandRouter`** — extract the submit flow from `ChatPanel` into a
pure `UI.Abstractions` helper; `ChatPanel` calls it; tests for client-command /
unknown-verb / parse / publish parity. *(UI.Abstractions; no GL.)*
- **B. `UiChatView` dat-font seam** — add `UiDatFont? DatFont`; prefer it in draw +
`HitChar` advance + selection measure + `LineHeight`; change `WheelLines` 3→1;
keep `BitmapFont` fallback. Tests: advance/hit-test with a synthetic dat font.
- **C. `UiScrollable`** — port `UIElement_Scrollable` math (pixel clamp, thumb
ratio/offset, line/page delta). Pure, fully unit-tested (no GL).
- **D. `UiChatScrollbar`** — own imported track/thumb/up-down sprites; size+place
thumb from `UiScrollable`; wheel/button/drag → scroll. Locate the right-side
up/down button ids in the dat here.
- **E. `UiChatInput`** — editable one-line widget: caret (`MeasurePrefix` =
`UiDatFont.MeasureWidth(text[..caret])`), insert/delete, Home/End/arrows,
100-entry history with `1`=live sentinel, focus sprite swap, `OnSubmit`. Tests
for caret math + history.
- **F. `UiChannelMenu`** — channel dropdown (port `UIElement_Menu` minimally);
13 channels → `ChatChannelKind`; selection event + label.
- **G. `ChatWindowController`**`LayoutImporter.Import(0x21000006)`; bind by id;
swap transcript/input; wire scrollbar/menu/send/max-min; route inbound (ChatVM)
+ outbound (`ChatCommandRouter`); translucency.
- **H. `GameWindow` cutover** — replace the hand-authored
`UiNineSlicePanel`+`UiChatView` block with `ChatWindowController`; default
bottom-left position + resizable; remove dead code; add divergence rows;
`dotnet build` + `dotnet test` green.
## 8. Testing strategy
- **Pure/unit (no GL, no dats):** `ChatCommandRouter` parity; `UiScrollable`
clamp/thumb/delta golden values from the decomp; `UiChatInput` caret index ↔
pixel + history navigation; `UiChatView` dat-font advance/hit-test via the
`Func<char,FontCharDesc?>` seam.
- **Layout/import (dat-free fixture):** extend the importer fixture pattern with a
`chat_21000006.json` tree (via `ImportInfos`) asserting the element→role map and
rects.
- **Real-dat smoke:** `LayoutImporter.Import(0x21000006)` against the live dat
resolves the root + all bound ids before wiring (guarded, like the vitals smoke).
- **Visual acceptance (user):** launch live `ACDREAM_RETAIL_UI=1`; compare to the
retail screenshot — transcript scrolls, input types + sends, channel menu
switches, Send works, scrollbar drags, window moves/resizes, translucency.
## 9. Acceptance criteria
- [ ] Chat window is built from `LayoutDesc 0x21000006` via `LayoutImporter` — no
hand-authored chat rect remains in `GameWindow.cs`.
- [ ] Transcript renders inbound chat in the **dat font**, per-`ChatKind` color,
bottom-pinned, 10k-cap, mouse-wheel = 1 line/notch, drag-select + Ctrl+C kept.
- [ ] Right-side scrollbar: thumb sizes to content, drag + up/down scroll the
transcript.
- [ ] Input: type, caret moves, backspace/delete, up/down history, **Enter and the
Send button both submit** through `ChatCommandRouter` → wire.
- [ ] `Chat ▸` menu opens, lists channels, selection changes the outbound channel
+ updates the label.
- [ ] Max/min toggles window height; window moves + resizes; translucent frame.
- [ ] Every ported widget cites a `class::method @address`; every deferral has a
divergence-register row.
- [ ] `dotnet build` + `dotnet test` green; user visual sign-off.
## 10. Deferred / follow-ups (filed, not built)
In-element word-wrap (+ selection rework); numbered-tab switching + per-tab chat
filtering; squelch; clickable name-tags; per-glyph styled runs; configurable font
face/size; active/inactive opacity transition; the unidentified top-level Type-5
ListBox `0x1000001D` (not bound by `ChatInterface`; likely a floaty/options element).

View file

@ -0,0 +1,216 @@
# LayoutDesc Importer — Design
**Date:** 2026-06-15
**Status:** Approved (brainstorm) — pending spec review → implementation plan
**Track:** D.2b retail UI engine (next sub-phase; register the phase id in the roadmap before implementation)
**Supersedes nothing. Deletes nothing.** Coexists with the existing hand-authored path.
## Context
D.2b shipped a working retail vitals window and a scrollable chat window, but each was
built by **hand**: dump the dat `LayoutDesc`, transcribe sprite ids + rects into
`vitals.xml` / `UiMeter` / `UiNineSlicePanel`, then discover-and-patch missing details
(the bar fill model, the dat-font, the tiling, the resize-grip overlay) one at a time.
That archaeology does not scale to AC's dozens of windows, and it keeps *missing* details
that are already in the dat (the grip overlay was found only because the user spotted it).
The `LayoutDesc` dat is a **complete, declarative description of every window** — element
tree, positions, sizes, anchors, sprites per state, draw-modes, fonts, borders, grips,
meters, labels, inheritance. It is retail's "HTML for windows." The fix is to **render the
dat** with one faithful interpreter rather than transcribe it per window.
## Goal
Build a faithful `LayoutDesc` interpreter that reads a retail layout from the dat and
produces a `UiElement` tree the existing toolkit renders — so opening any retail window is
one call, with **no per-window graphics/layout code**. The only per-window code is live
**data wiring** (which is inherently per-window and tiny).
### Non-goals
- Re-porting Keystone's C++ framework (its own renderer, string/container classes, vtable
dispatch, D3D blits). We port retail's **render algorithms**, not its framework — that is
what Silk.NET + .NET already provide. (See "Decisions → Structure".)
- Deleting or rewriting the existing toolkit/widgets/markup. They are reused.
## Decisions (from brainstorm 2026-06-15)
1. **Proof target = re-drive vitals.** Point the importer at the vitals `LayoutDesc`
(`0x2100006C`) and make it reproduce the hand-built window. Known-good baseline → clean
pass/fail. The hand-authored vitals path stays as the reference until the importer matches.
2. **Scope = full faithful interpreter.** Interpret the *complete* `LayoutDesc` format
(every element type, full `BaseElement`/`BaseLayoutId` inheritance, all draw-modes,
states, properties) — not just the slice vitals uses. Matches the project's
"behavior is retail" ethos.
3. **Structure = hybrid (Approach C).** Port each element type's render **algorithm**
verbatim from the decomp, onto our modern draw primitives. A single generic renderer
handles the trivial "stamp the sprite per draw-mode" types (the long tail, including
types not yet catalogued); dedicated widgets handle types with real behavior (meter,
text, scrollbar/chat, button). The decomp's render method for each type *decides* which
bucket it falls in — we do not guess. Faithfulness comes from porting the algorithms;
the hybrid is only about C# packaging.
4. **Coexistence, don't-delete.** `MarkupDocument` stays as the path for plugin/custom
panels (no dat layout). The existing widgets (`UiMeter`, `UiNineSlicePanel`,
`UiChatView`, `UiDatFont`) and primitives (tiling, scissor-fill, dat-font, nine-slice)
become the importer's behavioral renderers.
## Architecture & data flow
```
RETAIL WINDOWS (data-driven from the dat)
client_portal.dat ─► LayoutImporter ─► UiElement tree ─► UiRoot ─► renderers ─► screen
(LayoutDesc 0x21..) │ (UiDatElement +
│ behavioral widgets)
├─ resolve BaseElement / BaseLayoutId inheritance
├─ walk ElementDesc tree → widget (hybrid factory)
└─ apply rect / anchors / states / media / props from the dat
per-window Controller ─► binds LIVE data to elements by id (mirrors retail gm*UI)
WindowManager ─► open/close by layout id, z-order, focus, position persistence
PLUGIN / CUSTOM PANELS (hand-authored, unchanged)
*.xml ─► MarkupDocument ─► UiElement tree ─► (same UiRoot + renderers)
```
Two input paths (dat importer for retail windows, markup for custom/plugin), one rendering
toolkit. Nothing in the bottom (`UiHost`/`UiRoot`/`UiElement`) or the render primitives
changes.
## Components
### 1. Format enumeration (Step 0 — foundational groundwork)
Because we chose "full faithful," the first deliverable is a **documented map** of the
complete format, not code. Sources, cross-checked against each other:
- **DatReaderWriter types**`ElementDesc`, `StateDesc`, `MediaDesc*` and their enums
(`Type`, `DrawMode`, media kinds, state keys). Reflect/inspect as `dump-vitals-layout`
already does (props **and** fields).
- **Retail decomp** — the `UIElement_*` class hierarchy + each type's render method; the
property-key meanings; the **KSML keyword registrations** (the parser registers every
property name — the canonical vocabulary, e.g. `KW_DRAWMODE`, `KW_DURATION`, …).
- **Real layouts** — scan a sample of `LayoutDesc`s to confirm which Types/properties
actually occur and catch anything the above missed.
Output: a reference doc mapping each `Type` → meaning + render method, each property key →
meaning, each `DrawMode` → behavior, and the inheritance rules. This doc drives every other
component and is committed alongside the importer.
### 2. `LayoutImporter`
Reads a `LayoutDesc` by id and returns a `UiElement` subtree:
- Walk the `ElementDesc` tree.
- For each element: resolve inheritance (§3), pick a widget via the factory (§4), set its
rect (`X/Y/W/H`), anchors (edge flags → `AnchorEdges`), z-order, states, media, and
properties from the (resolved) element.
- Recurse into children.
- Expose `FindElement(uint id)` on the result so controllers wire by id.
Depends on: `DatCollection` (read layouts), the factory, the inheritance resolver,
`TextureCache.GetOrUploadRenderSurface` (sprites), `UiDatFont` (text). No GL itself — it
builds `UiElement`s; rendering stays in the toolkit.
### 3. Inheritance resolution
An element with `BaseElement`/`BaseLayoutId` inherits the base element's properties / states
/ media; the derived element overrides. Resolve by loading the base layout, finding the base
element, and merging (base first, then derived overrides) **before** instantiating.
Required even for vitals: the number-text element inherits its font/style from base layout
`0x2100003F`. Cycle-guard the resolution.
### 4. Hybrid widget factory (`Type` → renderer)
- **Behavioral** types → dedicated widgets (verbatim-algorithm ports): meter → `UiMeter`,
text → dat-font label, scrollable/list region → `UiChatView`/list widget, button →
`UiButton`, resizable window root → `UiNineSlicePanel`.
- **Trivial** types (image, container, border piece, grip) → `UiDatElement` (generic).
- **Unknown** type → `UiDatElement` (faithful fallback — still draws its media).
The Step-0 enumeration assigns each `Type` to a bucket by reading its retail render method
(trivial blit → generic; real algorithm → widget).
### 5. `UiDatElement` (generic renderer)
A `UiElement` holding the resolved element's active-state media + draw-mode + rect. Its
`OnDraw` ports retail's base blit branch:
- `Normal`**tile** at native size (UV-repeat; the UI texture is `GL_REPEAT`-wrapped) —
the mechanism already proven for the bars + chrome.
- `Alphablend` → blended overlay.
- `Stretch` (if present) → scale.
- image → sprite; cursor → hover cursor.
Reuses the tiling, dat-font, nine-slice draw primitives.
### 6. Per-window controllers (live-data binding)
Mirror retail's `gm*UI` classes. A small controller per window grabs elements by id from the
imported tree and pushes live data in: `VitalsController` binds `HealthPercent` → meter fill,
`cur/max` → number text; `ChatController` binds the chat tail → the chat region. **This is
the only per-window code, and it is data wiring, not graphics.** Retail-faithful: e.g.
`gmVitalsUI::PostInit` grabs child meter elements by id and sets attribute `0x69` (fill).
### 7. `WindowManager`
`OpenWindow(layoutId, controller)` → import + attach to `UiRoot`, place at the dat's default
position (then persist user move/resize), manage z-order / focus / close. Orchestrates the
focus/drag/resize mechanics `UiRoot` already provides.
### 8. States / expand / hover
Each element carries its named states (`HideDetail`/`ShowDetail`, normal/hover/pressed) from
the dat; the active state selects which media draws. A click or hover flips the active state.
Click-to-expand and hover highlight fall out generically — no per-window code.
## Rollout order (milestones)
1. **Enumerate the format** (§1) → reference doc.
2. **`LayoutImporter`** + **inheritance resolution** (read + resolve + walk).
3. **`UiDatElement`** generic renderer (port the draw-mode blit branch).
4. **Hybrid factory** (Type → widget/generic).
5. **`VitalsController`** (bind by id).
6. **Re-drive vitals → diff against the current window.** ✅ conformance gate.
7. **`WindowManager`** (open/close/persist).
8. **Extend** to chat (`ChatController`), then new windows for free.
## Testing / conformance
- **Golden tree checks** — the importer-built vitals tree has the expected element rects,
resolved sprites, and active states (assert against the known `0x2100006C` values).
- **Inheritance unit tests** — base+override merge, cycle-guard.
- **Draw-mode unit tests** — the UV math for tile vs stretch vs the partial last tile.
- **Bind-by-id unit tests** — controller wires the right element.
- **Headless visual diff**`render-vitals-mockup` / a tree-render comparison vs the
hand-built reference (no live server needed).
- **Final** — in-client visual verification (the user) once the gate passes.
## Coexistence / don't-delete (restated)
- `MarkupDocument` + `*.xml` stay for plugin/custom panels.
- `UiMeter`, `UiNineSlicePanel`, `UiChatView`, `UiDatFont`, the tiling/scissor-fill/dat-font/
nine-slice primitives stay — reused as the importer's behavioral renderers.
- The hand-authored vitals path stays as the conformance reference until the importer
matches it; only then is vitals flipped to the importer.
## Risks & open questions
- **The format enumeration is the foundational unknown.** If a Type/property/draw-mode is
mis-mapped, faithfulness breaks. Mitigation: Step 0 cross-checks three sources + real
layouts; the vitals conformance gate catches regressions.
- **Some behavioral types may need new widgets** (list, scrollbar, edit box). These are
generic, written once — not per-window. The generic fallback means an un-widgeted type
still renders its sprites in the meantime.
- **Position persistence** scope (per-window saved rects) — minimal at first (dat default +
in-session move/resize); durable persistence can follow.
- **Phase id** — register this as the next D.2b sub-phase in the roadmap before implementing.
## Reference anchors
- **Dat layouts:** vitals (stacked) `0x2100006C`; floaty row `0x21000014`; horizontal row
`0x21000075`; vitals number-text base layout `0x2100003F`.
- **Decomp:** `gmVitalsUI::PostInit` @`0x4bfce0` (bind by id), `UIElement_Meter::DrawChildren`
@`0x46fbd0` (scissor-fill), `SurfaceWindow::DrawCharacter` @`0x442bd0` (dat-font),
`ImgTex::TileCSI` @`0x53e740` (tiling), `UIRegion::DrawHere` @`0x69fa30` (element draw order),
the KSML keyword registrations (~`0x71b540`+).
- **Tools:** `AcDream.Cli dump-vitals-layout` (reflective full tree dump),
`dump-sprite-sheet` (composite sprite ids), `render-vitals-mockup` (headless window render).
- **Memory:** `project_d2b_retail_ui.md` (the two-layout lesson, sprite ids, render model,
dat-font, tools).

View file

@ -0,0 +1,410 @@
# D.2b — Widget generalization (LayoutDesc importer, Plan 2 widget piece) — design
**Date:** 2026-06-16
**Branch:** `claude/hopeful-maxwell-214a12` (D.2b retail-UI track)
**Status:** design — approved scope ("full registry, vitals last & gated"), pending spec review
**Predecessor:** the LayoutDesc importer, the vitals re-drive, and the chat-window re-drive
(`docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md`,
`docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md`,
`docs/research/2026-06-15-layoutdesc-format.md`,
`claude-memory/project_d2b_retail_ui.md`).
**Opening context:** the "GENERALIZATION PASS — START-COLD CONTEXT" note in
`claude-memory/project_d2b_retail_ui.md`.
---
## 1. Goal
Refactor the hand-named chat widgets (`UiChatView` / `UiChatInput` /
`UiChatScrollbar` / `UiChannelMenu`) and the inline Send/Max-Min `UiDatElement`
click-wiring into **generic, Type-registered widgets** built by
`DatWidgetFactory`, so that `ChatWindowController` (and, as the final gated step,
`VitalsController`) collapses to a thin **find-widget-by-id → bind-data/behavior**
controller — the acdream analogue of retail `gm*UI::PostInit`.
**The code is modern. The behavior is retail.** This pass changes the
*construction path* of widgets, not their on-screen behavior. The chat window
must stay visually and behaviorally identical through every step except the final
(gated) vitals rewire.
### 1.1 Why this is mostly already done
The trace that opened this work (re-confirmed in this design session) established
two facts that make the generalization a *registration* task, not a new mechanism:
1. **The importer's base-chain Type resolution is already retail-faithful.**
`ElementReader.Merge` resolves a Type-0 placement element up its
`BaseElement`/`BaseLayoutId` chain to the base's real registered Type
(`ElementReader.cs:137-140`). Every chat/vitals element therefore already
resolves to the retail class it would instantiate.
2. **Type 12 is `UIElement_Text` — a real behavioral class, not a "style
prototype to skip."** Verified directly in the decomp:
`UIElement::RegisterElementClass(0xc, UIElement_Text::Create)`
(`docs/research/named-retail/acclient_2013_pseudo_c.txt:115655`). The
`Type==12 → return null` rule in `DatWidgetFactory` is a *vitals-only Plan-1
expedient* (AP-37: skip the vitals number elements so they render via
`UiMeter.Label`), **not** a structural truth.
So the "wrinkle" feared in the start-cold note (Type-0/12 elements hiding their
real widget type) **dissolves**: the resolved Type is already correct. The factory
just needs to *register* generic widgets for those Types instead of skipping them
or dropping to `UiDatElement`.
### 1.2 Why this matters beyond chat (the strategic purpose)
Chat is the **proving ground**, not the destination. The payoff is that every
future panel — **inventory, spell bar, vendor, character sheet, trade, skills**
becomes *assembled from dat data + a thin controller* instead of being hand-built
from scratch. That is exactly how retail did it (`gm*UI::PostInit` everywhere on a
shared `UIElement` toolkit), and it is the reason to do this pass carefully now.
**What this pass gives all future windows (the foundation):**
- The **generic widget toolkit**`UiButton`, `UiField`, `UiScrollbar`, `UiText`,
`UiMenu` — built automatically by `DatWidgetFactory` from the dat layout.
- The **thin-controller pattern** — find-widget-by-id → bind-live-data — proven and
cemented on chat. Inventory's controller, vendor's controller, etc. all take the
same shape.
**What those specific windows additionally need (out of scope here; cheap once the
pattern exists):**
- **A few more widget Types** — inventory/vendor lists want `UiListBox` (Type 5)
and `UiPanel` (Type 8); item slots want **drag-drop**, which retail builds into
`UIElement_Field` (the decomp shows `Field` has `CatchDroppedItem` /
`MouseOverTop` drag-drop hooks — so drag-drop rides on the Field widget this pass
already builds). Each gets *registered when that window needs it* — which is
exactly why §3 bounds "full registry" to the Types chat+vitals use today rather
than speculatively building all 14 retail classes.
- **The window manager** — open/close/z-order/persist, drag-bars (Type 2),
resize-grips (Type 9). This is the *other* half of Plan 2 — a sibling piece to
this one — and lands alongside, because pop-up/stackable windows (inventory,
vendor) need it.
- **Per-domain data plumbing** — item icons, live container contents, vendor stock
lists. Game-state work, separate from the UI toolkit.
This pass is therefore the **reusable toolkit + assembly pattern** that makes those
later windows mostly-free to build. It is the load-bearing first half of the road
to inventory/vendor/spell-bar, not the whole road.
---
## 2. Retail reference (the registry + the PostInit pattern)
### 2.1 The Type → class registry (`UIElement::RegisterElementClass`)
Confirmed verbatim from `acclient_2013_pseudo_c.txt` (line numbers cited):
| Type | Retail class | Reg. line | | Type | Retail class | Reg. line |
|---|---|---|---|---|---|---|
| 1 | `UIElement_Button` | :125828 | | 9 | `UIElement_Resizebar` | :118938 |
| 2 | `UIElement_Dragbar` | :119926 | | 0xb (11) | `UIElement_Scrollbar` | :124137 |
| 3 | `UIElement_Field` (editable) | :126190 | | 0xc (12) | `UIElement_Text` | :115655 |
| 5 | `UIElement_ListBox` | :121788 | | 0xd (13) | `UIElement_Viewport` | :119126 |
| 6 | `UIElement_Menu` | :120163 | | 0xe (14) | `UIElement_Browser` | :118718 |
| 7 | `UIElement_Meter` | :123316 | | 0x10/0x11 | `ColorPicker`/`GroupBox` | :118396/:118177 |
| 8 | `UIElement_Panel` | :119820 | | — | **Type 0 and 4: NOT registered** | — |
Type 0 has no class of its own — a Type-0 element is a placement/override that
inherits its class from its base. That is exactly what `ElementReader.Merge`
already does.
> **Implementation correction (2026-06-16, settled during execution).** Two of
> this design's registration assumptions changed once the empirical resolved
> Types were in hand (Task 1):
> 1. **The editable input `0x10000016` resolves to Type 12 (Text), not Type 3.**
> So the input is **Variant B** — the factory builds it as a `UiText`
> placeholder and `ChatWindowController` removes that and controller-places a
> `UiField` at its rect. (Confirmed by the chat golden fixture.)
> 2. **Type 3 is NOT registered → `UiField` in this pass.** In acdream's vitals
> (`0x2100006C`) and chat (`0x21000006`) layouts, Type-3 dat elements are
> sprite-bearing **chrome** (the 8-piece bevel corners/edges, e.g. vitals
> `0x10000633` → sprite `0x060074C3`) and the transcript/input **container**
> panels — NOT editable fields. Retail draws those as inert media-bearing
> Fields, which our generic `UiDatElement` reproduces pixel-for-pixel and
> without a spurious focus/edit affordance. Registering Type 3 → `UiField`
> (which draws no dat sprite) would blank the vitals bevel. So the factory
> switch registers **Button (1), Menu (6), Meter (7), Scrollbar (11), Text
> (12)**; Type 3 stays on the `UiDatElement` fallback. `UiField` still ships
> (the renamed editable widget) — it is just controller-placed, not
> factory-wired. Register Type 3 → `UiField` only when a window carries a
> factory-built editable Type-3 field (and `UiField` then grows a
> background-media draw + an opt-in editable flag). Guarded by
> `VitalsTree_ChromeCornerHasExpectedSprite` (asserts the corner stays a
> `UiDatElement` drawing its sprite).
### 2.2 The `gm*UI::PostInit` binding pattern (the controller target)
`gmVitalsUI::PostInit` (`acclient_2013_pseudo_c.txt:199170-199228`) and
`gmMainChatUI::PostInit` (`:212585-212636`) do, per child widget:
```
UIElement* e = UIElement::GetChildRecursive(this, 0x100000e6); // find by id
UIElement_Meter* m = e->vtable->DynamicCast(7); // cast to Type
this->m_pHealthMeter = m; // store
if (!m) { /* skip */ } // null-check
```
acdream analogue (already half-present in `ChatWindowController`):
```csharp
var send = layout.FindElement(SendId) as UiButton; // GetChildRecursive + DynamicCast
if (send is not null) send.OnClick = () => input.Submit(); // bind behavior
```
The faithful end-state is: **the factory builds every widget from the dat; the
controller only finds-by-id and binds data/callbacks** — it never constructs a
widget.
### 2.3 Empirically resolved Types of the chat elements (`LayoutDesc 0x21000006`)
Traced against the live dat (HIGH confidence; base ids in parentheses):
| Element | Resolves to | Retail class | Today |
|---|---|---|---|
| `0x10000014` channel menu | **6** (own Type 6, no base) | Menu | `UiDatElement` → controller replaces w/ `UiChannelMenu` |
| `0x10000012` scrollbar track | **11** (base `0x10000367` in `0x2100003E`) | Scrollbar | `UiDatElement` → controller replaces w/ `UiChatScrollbar` |
| `0x10000011` transcript | **12** (base `0x10000372` in `0x2100003F`) | Text | skipped → controller adds `UiChatView` |
| `0x10000016` input | **12** (base `0x10000372` in `0x2100003F`) | Text | skipped → controller adds `UiChatInput` |
| `0x10000019` send | **1** (base chain → `0x1000047F` Type 1) | Button | `UiDatElement` + `OnClick` |
| `0x1000046F` max/min | **1** (base `0x1000047F` Type 1 in `0x21000040`) | Button | `UiDatElement` + `OnClick` |
> **Plan-phase verification #1 (load-bearing):** the editable **input**
> `0x10000016` traced to **Type 12 (Text)**, the same base as the read-only
> transcript — surprising for an editable field (retail's editable text is
> Field=3). Element ids are layout-*local*, so the decomp's `ChatInterface`
> Field-id does **not** cross-map; re-dump `0x10000016`'s exact resolved Type and
> the `0x10000372` base prototype's Type before relying on it. The design is
> robust either way — see §4.3(a).
---
## 3. Approved scope
**Decision (this session):** *Full registry, chat-first, vitals rewire as the
final, separately-committed, separately-gated step.*
**In scope:**
- Register generic widgets for the Types the chat + vitals windows actually use:
**Button (1), Field (3), Menu (6), Scrollbar (11), Text (12)** — plus Meter (7)
already done.
- Delete the `Type==12 → return null` skip; Type 12 becomes `UiText`.
- Collapse `ChatWindowController.Bind` to a find-by-id binder (no widget
construction).
- **Final gated step:** rewire `VitalsController` to bind generic `UiText` for the
vitals numbers (retail-faithful: vitals numbers *are* `UIElement_Text`),
retiring `UiMeter.Label` for vitals.
**Explicitly NOT in scope ("full registry" is bounded to what these windows use):**
- The long tail retail also registers — `Panel` (8), `Dragbar` (2), `Resizebar`
(9), `ListBox` (5), `Viewport` (13), `Browser` (14), `ColorPicker` (16),
`GroupBox` (17). Those elements **continue to render correctly as
`UiDatElement`** (the universal fallback is non-negotiable). No
`UIElement_ColorPicker` port for a window that has no color picker. When a future
window needs one of these, it gets registered then.
- No new chat *features* (tabs/squelch/name-tags/word-wrap remain as the chat
re-drive deferred them — see that spec's §2).
- `UiMeter.Label` is **not deleted** — it stays for plugin/markup panels; vitals
simply stops using it.
---
## 4. Design
### 4.1 `DatWidgetFactory` — the faithful Type switch
`DatWidgetFactory.Create` grows from `{7 → UiMeter, _ → UiDatElement}` to:
```csharp
UiElement e = info.Type switch
{
1 => BuildButton(info, resolve, datFont), // UIElement_Button
3 => BuildField(info, resolve, datFont), // UIElement_Field (see §4.3a)
6 => BuildMenu(info, resolve, datFont), // UIElement_Menu
7 => BuildMeter(info, resolve, datFont), // UIElement_Meter (unchanged)
11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar
12 => BuildText(info, resolve, datFont), // UIElement_Text
_ => new UiDatElement(info, resolve), // generic fallback (unchanged)
};
```
The rect/anchor/z-order propagation at the bottom of `Create` is unchanged. The
`Type==12 && StateMedia.Count==0` skip is **removed** — but a *pure base
prototype* (Type 12 with no own geometry that is only referenced via
`BaseLayoutId`, never placed) must still not draw. In practice such prototypes are
never top-level placed elements in `0x21000006`/`0x2100006C`; the importer only
builds placed elements. **Plan-phase verification #2:** confirm no Type-12
prototype is double-built after the skip is removed (the chat/vitals golden
fixtures catch this).
Each `BuildX` extracts the widget's dat-derived data (sprite ids per state, label
font) the same way `BuildMeter` extracts its 3-slice grandchild sprites. The
controller binds providers/callbacks afterward.
### 4.2 The generic widgets
Each generic widget extends `UiElement`, is constructed by the factory from
`ElementInfo`, and exposes **data providers + callbacks** for the controller to
bind. The chat-specific knowledge moves *out* of the widgets and *into* the
controller (faithful: retail's `gmMainChatUI`, not `UIElement_Menu`, owns the
talk-focus channel list).
| Generic widget | Type | Derived from | Generic surface (dat-built + provider-bound) | Controller binds |
|---|---|---|---|---|
| `UiScrollbar` | 11 | `UiChatScrollbar` (already 100% generic) | track/thumb/cap/arrow sprite ids from dat; `Model : UiScrollable` | `Model = transcript.Scroll` |
| `UiButton` | 1 | `UiDatElement`+`OnClick` | state sprites (Normal/Pressed/Disabled), `Label`, `LabelFont`, `LabelColor`, `OnClick`, `NaturalWidth()` autosize | `OnClick`, caption |
| `UiMenu` | 6 | `UiChannelMenu` | popup toggle, 2-col layout, 8-piece bevel, row highlight; `Items : IReadOnlyList<(string label, bool enabled, object payload)>`, `OnSelect : Action<object>`, `Selected`, `NaturalButtonWidth()` | populate 14 channel `Items`; map payload↔`ChatChannelKind`; `AvailabilityProvider` |
| `UiText` | 12 | `UiChatView` | scrollable + selectable multi-color line list, clipboard, dat-font; `LinesProvider : Func<IReadOnlyList<(string,Vector4)>>`; shares `UiScrollable` (`Scroll`) | `LinesProvider` → ChatVM + per-kind colors |
| `UiField` | 3 | `UiChatInput` | editable one-line: caret/selection/clipboard/history/auto-repeat/focus-sprite-swap; `Text`, `OnSubmit`, `MaxCharacters` | `OnSubmit``ChatCommandRouter` |
**Placement.** The generic widgets live in `src/AcDream.App/UI/` alongside
`UiMeter` (toolkit widgets). The factory in `src/AcDream.App/UI/Layout/`
references them. This matches the current split (`UiMeter` in `UI/`,
`UiDatElement` in `UI/Layout/`).
**Naming.** `UiX` mirrors retail `UIElement_X`. The old chat-prefixed names are
removed (or kept as thin obsolete aliases only if needed mid-migration).
### 4.3 The two wrinkles
**(a) The editable input (Type 12 vs Type 3).** Robust to either resolution:
- If `0x10000016` resolves to **Type 3** → factory builds `UiField` directly; the
controller only binds `OnSubmit`.
- If it resolves to **Type 12** → the dat element is a display Text in this
layout; the controller *replaces* it with a controller-placed `UiField` at its
rect (today's pattern for the track/menu). `UiField` exists as a registered
generic widget regardless; only *who places it* differs.
Editing behavior (caret/clipboard/history) is never purely dat-derivable, so the
input is always provider-bound — the open question only affects whether the
factory or the controller *instantiates* it.
**(b) Vitals rewire — the final gated step.** Removing the Type-12 skip means the
vitals number elements (Type-0 → base Type-12 Text) *could* build as real
`UiText`. Today they are **meter children, consumed** (the importer does not
recurse a meter's children — `LayoutImporter.cs:113`), rendered via
`UiMeter.Label`. The faithful move: `VitalsController` constructs/binds a `UiText`
for each number (matching retail `UIElement_Text` vitals numbers) and drops
`UiMeter.Label` for vitals.
This is **step 7 — the last commit, separately gated**, with its own fixture
update and the user's visual sign-off, because vitals shipped pixel-identical and
is fixture-locked (`vitals_2100006C.json`). If the rewire risks the pixel-identical
result, we **stop and keep the meter-label path** for vitals — a smaller,
documented divergence (AP-37 narrowed, not retired). The decision to land step 7
is the user's, made on the running client.
### 4.4 The thin controller (after step 6)
`ChatWindowController.Bind` collapses to: for each known element id, `FindElement(id)
as UiX`, null-check, bind data/callback. The reflow/maximize/resize-bar-drop logic
(`ChatWindowController.cs:155-297`) stays — it is window-layout policy, not widget
construction. The `BuildLines` / `WrapText` / `RetailChatColor` helpers stay (chat
data shaping). What *leaves* the controller: the construction of `UiChatView`,
`UiChatInput`, `UiChatScrollbar`, `UiChannelMenu` (now factory-built) — the
controller binds them instead.
---
## 5. Migration sequence (one widget per commit; build + test green each step)
Ordered least-risk → most-risk; the chat window is fully generalized before vitals
is touched. Each step: `dotnet build` green, `dotnet test` (AcDream.App.Tests)
green, its own commit naming the widget; the live chat window stays visually
identical through steps 16.
1. **`UiScrollbar`** (Type 11) — promote `UiChatScrollbar` (already generic);
register; factory builds it; controller binds `Model`.
2. **`UiButton`** (Type 1) — extract from `UiDatElement`+`OnClick`; register; Send +
Max/Min build from the dat.
3. **`UiMenu`** (Type 6) — generalize `UiChannelMenu`; register; controller
populates channel `Items` + maps payload↔`ChatChannelKind`.
4. **`UiText`** (Type 12) — generalize `UiChatView`; register; **delete the Type-12
skip**; controller binds transcript lines. Guard: verify vitals still renders
(its numbers are meter-consumed → no auto-double-draw) via the vitals fixture +
a live launch.
5. **`UiField`** (Type 3) — generalize `UiChatInput`; register; wire the input per
§4.3(a) (verification #1 resolves factory-built vs controller-placed).
6. **Thin the controller** — collapse `ChatWindowController.Bind` to pure
find-by-id binding now that the factory builds everything.
7. **Vitals rewire (gated)**`VitalsController` binds `UiText` numbers; fixture
update + the user's visual sign-off. **Stop-and-confirm gate.**
---
## 6. Testing & conformance
- **Generic-widget unit tests** (pure, no GL/dat) — mostly *moved* from the
existing chat-widget tests, renamed to the generic widgets: caret↔pixel + history
(`UiField`), thumb ratio / page-delta (`UiScrollbar` via `UiScrollable`), menu
item-pick + availability (`UiMenu`), line wrap / selection / dat-font hit-test
(`UiText`).
- **Factory tests**`DatWidgetFactoryTests` grows one assert per newly registered
Type → correct widget class.
- **New chat-layout golden fixture** `tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json`
(peer of `vitals_2100006C.json`): the resolved chat tree — each element's id,
rect, resolved Type, sprite ids — asserting the factory builds the right widget
per element. This locks the generalization.
- **Vitals fixture `vitals_2100006C.json` stays green, untouched, through steps
16**; updated only at step 7, with visual sign-off.
- **Visual acceptance** — the user launches `ACDREAM_RETAIL_UI=1` and confirms the
chat window is unchanged through steps 16, and the vitals window is unchanged
after step 7.
---
## 7. Divergence-register impact
- **AP-37** (`DatWidgetFactory.cs`/`LayoutImporter.cs`: Type-0 text skipped + meter-
collapse + vitals numbers via `UiMeter.Label`): **amended as steps land** — the
"standalone Type-0 text elements are skipped / a dedicated dat-text widget is
Plan 2" clause is retired when `UiText` ships (step 4); the vitals-numbers-via-
`UiMeter.Label` clause is retired at step 7, or **narrowed** (not retired) if
step 7 is deferred. The meter-collapse clause (reuse `UiMeter` 3-slice vs porting
`UIElement_Meter::DrawChildren` over nested dat elements) **remains** — this pass
does not port `DrawChildren`.
- **IA-15** (the importer *is* the retail-UI render path): unchanged; reinforced
(more Types now data-driven).
- **AP-41** (scrollbar thumb single stretched sprite): **re-check at step 1** — the
controller already passes 3-slice cap ids (`ThumbTopSprite`/`ThumbBotSprite`); the
row may be retire-able when `UiScrollbar` lands.
- **New rows** only if a generic widget introduces a *new* approximation (e.g., a
`UiMenu` item model simpler than retail's hierarchical popup chain in
`UIElement_Menu::MakePopup`). Add the row in the same commit per register rule 1.
---
## 8. Acceptance criteria
- [ ] `DatWidgetFactory` registers Types 1, 3, 6, 11, 12 (+ 7) → generic widgets;
`_` still falls back to `UiDatElement`.
- [ ] The `Type==12 → null` skip is removed; no Type-12 element is double-built
(golden fixtures green).
- [ ] The four chat widgets are generic (no `ChatChannelKind` / chat-color /
command-routing knowledge inside a widget); `ChatWindowController` only finds-
by-id and binds.
- [ ] Chat window is visually + behaviorally identical to the shipped version
through steps 16 (user-confirmed).
- [ ] New `chat_21000006.json` golden fixture + moved generic-widget unit tests;
all green.
- [ ] Vitals window unchanged after step 7 (user-confirmed), or step 7 deferred
with AP-37 narrowed.
- [ ] Every generic widget cites its retail `UIElement_X` class + reg. line in a
code comment.
- [ ] Divergence register updated (AP-37 amended; AP-41 re-checked) in the same
commits.
- [ ] Roadmap / `claude-memory/project_d2b_retail_ui.md` updated when the pass lands.
---
## 9. Open items for the plan phase
1. **Verification #1 (load-bearing):** re-dump `0x10000016` (input) + the
`0x10000372` base prototype to confirm input resolved Type (3 vs 12) → decides
factory-built vs controller-placed `UiField` (§4.3a).
2. **Verification #2:** confirm no Type-12 base prototype double-builds once the
skip is removed (§4.1).
3. Confirm the `UiMenu` generic item model (`(label, enabled, payload)`) is enough
for the 14 talk-focus channels without losing the greyed/available distinction
the chat menu currently shows.
4. Decide whether to keep thin obsolete-aliases for the old chat widget names
during migration or rename in-place (prefer in-place; the names are internal).