# 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 (~3–10 MB vs CEF's 150–300 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` 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>`), 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` 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 ``` 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 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 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.