# D.2b Retail Panel Frame + Live Vitals — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Render a retail-shaped Vitals window (8-piece dat-sprite frame + live HP/Stam/Mana bars) by wiring the dormant `AcDream.App/UI` retained-mode toolkit and adding a markup/stylesheet/sprite layer, gated behind `ACDREAM_RETAIL_UI=1`. **Architecture:** The retail UI is the **existing `UiRoot`/`UiElement` tree** driven by `UiHost` (dormant today) — a separate system from the ImGui devtools path. Spec 1 wires `UiHost` into `GameWindow`, extends the shared `TextRenderer` with a textured-sprite path, adds `UiNineSlicePanel` (chrome) + `UiMeter` (bar) widgets, a `MarkupDocument` that instantiates a `UiElement` subtree from XML, and a `controls.ini` stylesheet loader. Render-only (input integration deferred). Spec: [`docs/superpowers/specs/2026-06-14-d2b-retail-panel-frame-design.md`](../specs/2026-06-14-d2b-retail-panel-frame-design.md). **Tech Stack:** C# / .NET 10, Silk.NET OpenGL, xUnit 2.9.3. Dat assets via the existing `TextureCache` + `SurfaceDecoder`. --- ## File Structure **New files:** - `src/AcDream.App/UI/UiNineSlicePanel.cs` — `UiPanel` subclass drawing the 8-piece dat-sprite frame + center fill. - `src/AcDream.App/UI/UiMeter.cs` — `UiElement` vital bar (bg + partial fill). - `src/AcDream.App/UI/RetailChromeSprites.cs` — confirmed chrome sprite DataIDs + sizes + insets (filled by Step 0). - `src/AcDream.App/UI/ControlsIni.cs` — flat INI stylesheet parser (`#AARRGGBB`, `font://`). - `src/AcDream.App/UI/MarkupDocument.cs` — XML → `UiElement` subtree builder + `{Binding}` resolution. - `src/AcDream.App/UI/assets/vitals.xml` — the first-party vitals markup (copied to output). - `src/AcDream.Plugin.Abstractions/IUiRegistry.cs` — plugin-facing UI registration surface. - `src/AcDream.App/Plugins/BufferedUiRegistry.cs` — buffers `AddMarkupPanel` until `UiHost` exists. - `tests/AcDream.App.Tests/UI/ControlsIniTests.cs`, `MarkupDocumentTests.cs`, `UiMeterTests.cs`, `UiNineSlicePanelTests.cs` - `tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs` - `tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs` **Modified files:** - `src/AcDream.App/RuntimeOptions.cs` — add `RetailUi`, `AcDir`. - `src/AcDream.App/Rendering/Shaders/ui_text.frag` — add `uUseTexture==2` RGBA branch. - `src/AcDream.App/Rendering/TextRenderer.cs` — add `DrawSprite` + per-texture batch + `DepthMask`. - `src/AcDream.App/Rendering/TextureCache.cs` — add `GetOrUpload(id, out w, out h)` size overload. - `src/AcDream.App/UI/UiRenderContext.cs` — add `DrawSprite` forwarder. - `src/AcDream.App/Rendering/GameWindow.cs` — wire `UiHost` + vitals subtree (render-only). - `src/AcDream.Plugin.Abstractions/IPluginHost.cs` + `src/AcDream.App/Plugins/AppPluginHost.cs` — add `Ui`. - `src/AcDream.App/Program.cs` — construct `BufferedUiRegistry`, pass to host + window. - `docs/architecture/retail-divergence-register.md` — delete TS-30, add IA row (in the chrome commit). --- ## Task 1: RuntimeOptions — add RetailUi + AcDir toggles **Files:** - Modify: `src/AcDream.App/RuntimeOptions.cs` - Test: `tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs` - [ ] **Step 1: Write the failing test** Create `tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs`: ```csharp using System.Collections.Generic; using AcDream.App; namespace AcDream.App.Tests; public class RuntimeOptionsRetailUiTests { [Fact] public void Parse_ReadsRetailUiAndAcDir() { var env = new Dictionary { ["ACDREAM_RETAIL_UI"] = "1", ["ACDREAM_AC_DIR"] = @"C:\Turbine\Asheron's Call", }; var opts = RuntimeOptions.Parse("dats", k => env.GetValueOrDefault(k)); Assert.True(opts.RetailUi); Assert.Equal(@"C:\Turbine\Asheron's Call", opts.AcDir); } [Fact] public void Parse_DefaultsRetailUiOffAndAcDirNull() { var opts = RuntimeOptions.Parse("dats", _ => null); Assert.False(opts.RetailUi); Assert.Null(opts.AcDir); } } ``` - [ ] **Step 2: Run the test to verify it fails** Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter RuntimeOptionsRetailUiTests` Expected: FAIL to **compile** — `RetailUi` / `AcDir` are not members of `RuntimeOptions`. - [ ] **Step 3: Add the fields** In `src/AcDream.App/RuntimeOptions.cs`, add two parameters at the **end** of the record (line 42, after `int? LegacyStreamRadius`): ```csharp int? LegacyStreamRadius, bool RetailUi, string? AcDir) ``` And in `Parse` (after the `LegacyStreamRadius:` line, before the closing `);`): ```csharp LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")), RetailUi: IsExactlyOne(env("ACDREAM_RETAIL_UI")), AcDir: NullIfEmpty(env("ACDREAM_AC_DIR"))); ``` - [ ] **Step 4: Fix any positional construction sites** Run: `dotnet build src/AcDream.App/AcDream.App.csproj` If any `new RuntimeOptions(...)` positional call site fails to compile (missing 2 args), append `, RetailUi: false, AcDir: null` to it. (`Program.cs` uses `FromEnvironment`→`Parse` with named args and is unaffected.) - [ ] **Step 5: Run the test to verify it passes** Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter RuntimeOptionsRetailUiTests` Expected: PASS (2 tests). - [ ] **Step 6: Commit** ```bash git add src/AcDream.App/RuntimeOptions.cs tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs git commit -m "feat(D.2b): RuntimeOptions.RetailUi + AcDir toggles" ``` --- ## Task 2: Dat-sprite render capability GL code — verified by build + the Step-3 visual, not unit tests. **Files:** - Modify: `src/AcDream.App/Rendering/Shaders/ui_text.frag` - Modify: `src/AcDream.App/Rendering/TextRenderer.cs` - Modify: `src/AcDream.App/Rendering/TextureCache.cs` - Modify: `src/AcDream.App/UI/UiRenderContext.cs` - [ ] **Step 1: Add the RGBA branch to the fragment shader** In `src/AcDream.App/Rendering/Shaders/ui_text.frag`, replace the `main()` body's branch: ```glsl void main() { if (uUseTexture == 1) { // Font atlas is a single-channel R8 texture; red = coverage alpha. float coverage = texture(uTex, vUv).r; FragColor = vec4(vColor.rgb, vColor.a * coverage); } else if (uUseTexture == 2) { // RGBA dat sprite (decoded to RGBA8); modulate by tint/alpha. FragColor = texture(uTex, vUv) * vColor; } else { FragColor = vColor; } if (FragColor.a < 0.005) discard; } ``` - [ ] **Step 2: Add a size-returning overload to TextureCache** In `src/AcDream.App/Rendering/TextureCache.cs`, add a size cache field next to `_handlesBySurfaceId` (top-of-class field region): ```csharp private readonly Dictionary _sizeBySurfaceId = new(); ``` And add this method directly after `GetOrUpload(uint surfaceId)` (after line 81): ```csharp /// /// Like but also returns the decoded /// pixel dimensions. UI 9-slice geometry needs the source size to /// compute slice UVs. Cached alongside the handle. /// public uint GetOrUpload(uint surfaceId, out int width, out int height) { if (_handlesBySurfaceId.TryGetValue(surfaceId, out var existing) && _sizeBySurfaceId.TryGetValue(surfaceId, out var sz)) { width = sz.w; height = sz.h; return existing; } var decoded = DecodeFromDats(surfaceId, origTextureOverride: null, paletteOverride: null); uint h = UploadRgba8(decoded); _handlesBySurfaceId[surfaceId] = h; _sizeBySurfaceId[surfaceId] = (decoded.Width, decoded.Height); width = decoded.Width; height = decoded.Height; return h; } ``` - [ ] **Step 3: Add the textured-sprite path to TextRenderer** In `src/AcDream.App/Rendering/TextRenderer.cs`, add a per-texture sprite buffer field (next to `_textBuf`/`_rectBuf`, ~line 31): ```csharp private readonly Dictionary> _spriteBufs = new(); ``` Clear it in `Begin` (inside the existing `Begin`, after `_rectBuf.Clear();`): ```csharp foreach (var b in _spriteBufs.Values) b.Clear(); ``` Add the public draw method (after `DrawString`, ~line 130): ```csharp /// /// Draw a textured sprite quad in screen pixel space with an explicit /// source-UV rectangle (for 9-slice / atlas sub-regions). Batched per /// GL texture handle; flushed with uUseTexture=2 (RGBA modulate). /// public void DrawSprite(uint texture, float x, float y, float w, float h, float u0, float v0, float u1, float v1, Vector4 tint) { if (!_spriteBufs.TryGetValue(texture, out var buf)) { buf = new List(256); _spriteBufs[texture] = buf; } AppendQuad(buf, x, y, w, h, u0, v0, u1, v1, tint); } ``` In `Flush`, (a) change the early-out so sprites alone still draw, (b) set `DepthMask(false)` + restore, (c) draw the sprite batches. Replace the existing `Flush` body's guard and state block down through the text draw: Replace: ```csharp if (_textVerts == 0 && _rectVerts == 0) return; ``` with: ```csharp bool hasSprites = false; foreach (var b in _spriteBufs.Values) if (b.Count > 0) { hasSprites = true; break; } if (_textVerts == 0 && _rectVerts == 0 && !hasSprites) return; ``` Replace the state-save block: ```csharp bool wasDepth = _gl.IsEnabled(EnableCap.DepthTest); bool wasBlend = _gl.IsEnabled(EnableCap.Blend); bool wasCull = _gl.IsEnabled(EnableCap.CullFace); _gl.Disable(EnableCap.DepthTest); _gl.Disable(EnableCap.CullFace); _gl.Enable(EnableCap.Blend); _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); ``` with (adds DepthMask off; restored to true below): ```csharp bool wasDepth = _gl.IsEnabled(EnableCap.DepthTest); bool wasBlend = _gl.IsEnabled(EnableCap.Blend); bool wasCull = _gl.IsEnabled(EnableCap.CullFace); _gl.Disable(EnableCap.DepthTest); _gl.Disable(EnableCap.CullFace); _gl.DepthMask(false); _gl.Enable(EnableCap.Blend); _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); ``` Add the sprite-draw block immediately **after** the text-glyph block (after the `if (_textVerts > 0 && font is not null) { ... }` block, before "Restore GL state"): ```csharp // RGBA dat sprites — one draw call per distinct GL texture. if (hasSprites) { _shader.SetInt("uUseTexture", 2); _gl.ActiveTexture(TextureUnit.Texture0); _shader.SetInt("uTex", 0); foreach (var kv in _spriteBufs) { if (kv.Value.Count == 0) continue; _gl.BindTexture(TextureTarget.Texture2D, kv.Key); UploadBuffer(kv.Value); _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)(kv.Value.Count / FloatsPerVertex)); } } ``` Add DepthMask restore in the "Restore GL state" block (after the existing three restores). Restore to `true` — the next frame's depth *clear* requires depth writes enabled, so `true` is the correct (and only safe) post-UI value: ```csharp _gl.DepthMask(true); ``` - [ ] **Step 4: Add the DrawSprite forwarder to UiRenderContext** In `src/AcDream.App/UI/UiRenderContext.cs`, after the `DrawRectOutline` forwarder (line 54): ```csharp public void DrawSprite(uint texture, float x, float y, float w, float h, float u0, float v0, float u1, float v1, Vector4 tint) => TextRenderer.DrawSprite(texture, _current.X + x, _current.Y + y, w, h, u0, v0, u1, v1, tint); ``` - [ ] **Step 5: Build** Run: `dotnet build src/AcDream.App/AcDream.App.csproj` Expected: build succeeds. - [ ] **Step 6: Commit** ```bash git add src/AcDream.App/Rendering/Shaders/ui_text.frag src/AcDream.App/Rendering/TextRenderer.cs src/AcDream.App/Rendering/TextureCache.cs src/AcDream.App/UI/UiRenderContext.cs git commit -m "feat(D.2b): textured-sprite path in TextRenderer + UV-rect DrawSprite" ``` --- ## Task 3: Step-0 chrome sprite prove-out (HUMAN-IN-THE-LOOP) Resolves the unverified chrome sprite IDs empirically (spec §6). Requires the user to run the client and eyeball candidates. **Files:** - Create: `src/AcDream.App/UI/RetailChromeSprites.cs` - Modify: `src/AcDream.App/Rendering/GameWindow.cs` (temporary prove-out block) - [ ] **Step 1: Create the constants file (empty placeholders to be filled by the run)** Create `src/AcDream.App/UI/RetailChromeSprites.cs`: ```csharp namespace AcDream.App.UI; /// /// Confirmed retail window-chrome RenderSurface DataIDs + decoded sizes + /// 9-slice insets. Values are filled by the Step-0 prove-out run (see /// docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md, Task 3) /// — do NOT trust pre-run values. Candidates dumped by the prove-out harness. /// public static class RetailChromeSprites { // Candidate IDs to try in the Step-0 prove-out. Edit this list as needed. public static readonly uint[] Candidates = { 0x06004CC2, 0x060074BF, 0x060074C0, 0x060074C1, 0x060074C2, 0x060074C3, 0x060074C4, 0x060074C5, 0x060074C6, 0x0600129C, }; // === FILLED BY STEP 0 (placeholder = magenta until confirmed) === /// The single 9-sliceable frame sprite (or the body/center fill). public static uint FrameSurfaceId = 0; // TODO Step 0: set to confirmed id /// Corner inset in pixels (left/top/right/bottom assumed equal until LayoutDesc parse). public static int Inset = 6; // TODO Step 0: tune to the real bevel } ``` - [ ] **Step 2: Add a temporary prove-out block to OnRender** In `src/AcDream.App/Rendering/GameWindow.cs`, in `OnRender` after the 3D passes (just before the ImGui block at ~line 8158), add: ```csharp // Step-0 prove-out (D.2b Task 3): draw candidate chrome sprites in a // labelled row so we can eyeball which decode to frame art. Gated by // ACDREAM_RETAIL_UI_PROVEOUT=1. TEMPORARY — delete after Step 0. if (System.Environment.GetEnvironmentVariable("ACDREAM_RETAIL_UI_PROVEOUT") == "1" && _textureCache is not null && _textRenderer is not null) { _textRenderer.Begin(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y)); float px = 20f; foreach (var id in AcDream.App.UI.RetailChromeSprites.Candidates) { uint tex = _textureCache.GetOrUpload(id, out int tw, out int th); _textRenderer.DrawSprite(tex, px, 60f, 96f, 96f, 0, 0, 1, 1, System.Numerics.Vector4.One); if (_debugFont is not null) _textRenderer.DrawString(_debugFont, $"0x{id:X8}\n{tw}x{th}", px, 160f, System.Numerics.Vector4.One); px += 110f; } _textRenderer.Flush(_debugFont); } ``` - [ ] **Step 3: Build + run the prove-out (manual)** Run: `dotnet build src/AcDream.App/AcDream.App.csproj` Then launch with the prove-out flag (PowerShell): ```powershell $env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" $env:ACDREAM_RETAIL_UI_PROVEOUT = "1" dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath proveout.log ``` **Manual:** the user reports which candidate IDs render as frame/border art (vs magenta vs unrelated sprites) and their printed sizes. If the frame is a single 9-sliceable sprite, note that ID + size. If it's separate corner/edge sprites, note each. Tune `Candidates` and re-run if none match (widen the `0x0600xxxx` range near `0x060074xx`). - [ ] **Step 4: Record the confirmed values** Edit `RetailChromeSprites.cs`: set `FrameSurfaceId` to the confirmed id and `Inset` to the eyeballed bevel thickness. Add a comment with the decoded `WxH` and the date. - [ ] **Step 5: Remove the temporary prove-out block** Delete the `ACDREAM_RETAIL_UI_PROVEOUT` block from `GameWindow.cs` (it was scaffolding). - [ ] **Step 6: Commit** ```bash git add src/AcDream.App/UI/RetailChromeSprites.cs src/AcDream.App/Rendering/GameWindow.cs git commit -m "feat(D.2b): Step-0 chrome sprite prove-out + confirmed RetailChromeSprites ids" ``` --- ## Task 4: UiNineSlicePanel **Files:** - Create: `src/AcDream.App/UI/UiNineSlicePanel.cs` - Test: `tests/AcDream.App.Tests/UI/UiNineSlicePanelTests.cs` - [ ] **Step 1: Write the failing geometry test** Create `tests/AcDream.App.Tests/UI/UiNineSlicePanelTests.cs`: ```csharp using AcDream.App.UI; namespace AcDream.App.Tests.UI; public class UiNineSlicePanelTests { [Fact] public void ComputeSliceRects_ProducesNinePatchesCoveringTheFrame() { // 100x80 frame, 32x32 source texture, 8px inset. var rects = UiNineSlicePanel.ComputeSliceRects( frameW: 100, frameH: 80, texW: 32, texH: 32, inset: 8); Assert.Equal(9, rects.Length); // Top-left corner: dst (0,0,8,8); src uv (0,0)-(8/32, 8/32). var tl = rects[0]; Assert.Equal(0f, tl.dstX); Assert.Equal(0f, tl.dstY); Assert.Equal(8f, tl.dstW); Assert.Equal(8f, tl.dstH); Assert.Equal(0f, tl.u0); Assert.Equal(0f, tl.v0); Assert.Equal(8f / 32f, tl.u1, 5); Assert.Equal(8f / 32f, tl.v1, 5); // Center: dst (8,8, 100-16, 80-16); src uv inset..(tex-inset). var center = rects[4]; Assert.Equal(8f, center.dstX); Assert.Equal(8f, center.dstY); Assert.Equal(84f, center.dstW); Assert.Equal(64f, center.dstH); Assert.Equal(8f / 32f, center.u0, 5); Assert.Equal(24f / 32f, center.u1, 5); // Bottom-right corner dst origin at (100-8, 80-8). var br = rects[8]; Assert.Equal(92f, br.dstX); Assert.Equal(72f, br.dstY); Assert.Equal(8f, br.dstW); Assert.Equal(8f, br.dstH); } } ``` - [ ] **Step 2: Run the test to verify it fails** Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter UiNineSlicePanelTests` Expected: FAIL to compile — `UiNineSlicePanel` does not exist. - [ ] **Step 3: Implement UiNineSlicePanel** Create `src/AcDream.App/UI/UiNineSlicePanel.cs`: ```csharp using System.Numerics; namespace AcDream.App.UI; /// /// A whose background is a 9-sliced dat RenderSurface: /// 4 fixed corners, 4 stretched edges, 1 stretched center. Retires the flat /// translucent rect (divergence row TS-30). Insets come from /// until the LayoutDesc importer supplies /// per-panel metrics. /// public sealed class UiNineSlicePanel : UiPanel { /// One slice patch: destination rect (local px) + source UVs (0..1). public readonly record struct Slice( float dstX, float dstY, float dstW, float dstH, float u0, float v0, float u1, float v1); private readonly System.Func _resolve; private readonly uint _surfaceId; private readonly int _inset; /// Surface id → (GL handle, decoded width, height). /// In production: id => { var t = cache.GetOrUpload(id, out var w, out var h); return (t, w, h); }. public UiNineSlicePanel(System.Func resolve, uint surfaceId, int inset) { _resolve = resolve; _surfaceId = surfaceId; _inset = inset; BackgroundColor = Vector4.Zero; // suppress the base flat-rect fill BorderColor = Vector4.Zero; } /// /// Compute the 9 patches for a frame of x /// from a x /// source with a uniform . /// Order: TL, TC, TR, ML, MC, MR, BL, BC, BR (index 4 = center). /// public static Slice[] ComputeSliceRects( float frameW, float frameH, int texW, int texH, int inset) { float i = inset; // destination column/row edges float[] dx = { 0, i, frameW - i, frameW }; float[] dy = { 0, i, frameH - i, frameH }; // source UV column/row edges (0..1) float[] ux = { 0, i / texW, (texW - i) / texW, 1f }; float[] uy = { 0, i / texH, (texH - i) / texH, 1f }; var slices = new Slice[9]; int n = 0; for (int row = 0; row < 3; row++) for (int col = 0; col < 3; col++) slices[n++] = new Slice( dx[col], dy[row], dx[col + 1] - dx[col], dy[row + 1] - dy[row], ux[col], uy[row], ux[col + 1], uy[row + 1]); return slices; } protected override void OnDraw(UiRenderContext ctx) { var (tex, tw, th) = _resolve(_surfaceId); if (tex == 0 || tw == 0 || th == 0) return; foreach (var s in ComputeSliceRects(Width, Height, tw, th, _inset)) ctx.DrawSprite(tex, s.dstX, s.dstY, s.dstW, s.dstH, s.u0, s.v0, s.u1, s.v1, Vector4.One); } } ``` - [ ] **Step 4: Run the test to verify it passes** Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter UiNineSlicePanelTests` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/AcDream.App/UI/UiNineSlicePanel.cs tests/AcDream.App.Tests/UI/UiNineSlicePanelTests.cs git commit -m "feat(D.2b): UiNineSlicePanel (9-slice dat chrome) + geometry tests" ``` --- ## Task 5: UiMeter **Files:** - Create: `src/AcDream.App/UI/UiMeter.cs` - Test: `tests/AcDream.App.Tests/UI/UiMeterTests.cs` - [ ] **Step 1: Write the failing fill-geometry test** Create `tests/AcDream.App.Tests/UI/UiMeterTests.cs`: ```csharp using AcDream.App.UI; namespace AcDream.App.Tests.UI; public class UiMeterTests { [Fact] public void ComputeFillRect_HalfFillIsHalfWidth() { var (x, y, w, h) = UiMeter.ComputeFillRect(0.5f, 200f, 12f); Assert.Equal(0f, x); Assert.Equal(0f, y); Assert.Equal(100f, w); Assert.Equal(12f, h); } [Theory] [InlineData(-1f, 0f)] // clamps below 0 [InlineData(2f, 200f)] // clamps above 1 [InlineData(0f, 0f)] [InlineData(1f, 200f)] public void ComputeFillRect_ClampsFraction(float pct, float expectedW) { var (_, _, w, _) = UiMeter.ComputeFillRect(pct, 200f, 12f); Assert.Equal(expectedW, w); } } ``` - [ ] **Step 2: Run the test to verify it fails** Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter UiMeterTests` Expected: FAIL to compile — `UiMeter` does not exist. - [ ] **Step 3: Implement UiMeter** Create `src/AcDream.App/UI/UiMeter.cs`: ```csharp using System.Numerics; namespace AcDream.App.UI; /// /// A horizontal vital bar: an empty background rect with a partial-width /// fill. returns 0..1 (or null = no data → empty bar). /// Solid-color for Spec 1; the retail orb sprite + scissor crop is a later /// sub-phase. /// public sealed class UiMeter : UiElement { /// Fill fraction provider; null result draws an empty bar. public System.Func Fill { get; set; } = () => 0f; public Vector4 BarColor { get; set; } = new(1f, 0f, 0f, 1f); public Vector4 BgColor { get; set; } = new(0f, 0f, 0f, 0.5f); public UiMeter() { ClickThrough = true; } /// Clamp to [0,1] and return the fill /// rect (local px) for a bar of x . public static (float x, float y, float w, float h) ComputeFillRect( float pct, float w, float h) { if (pct < 0f) pct = 0f; if (pct > 1f) pct = 1f; return (0f, 0f, w * pct, h); } protected override void OnDraw(UiRenderContext ctx) { ctx.DrawRect(0, 0, Width, Height, BgColor); float? pct = Fill(); if (pct is float p) { var (fx, fy, fw, fh) = ComputeFillRect(p, Width, Height); if (fw > 0f) ctx.DrawRect(fx, fy, fw, fh, BarColor); } } } ``` - [ ] **Step 4: Run the test to verify it passes** Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter UiMeterTests` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/AcDream.App/UI/UiMeter.cs tests/AcDream.App.Tests/UI/UiMeterTests.cs git commit -m "feat(D.2b): UiMeter vital bar + fill-geometry tests" ``` --- ## Task 6: Wire UiHost + hand-built vitals subtree (render-only) + retire TS-30 Visual-acceptance task. First on-screen retail panel. **Files:** - Modify: `src/AcDream.App/Rendering/GameWindow.cs` - Modify: `docs/architecture/retail-divergence-register.md` - [ ] **Step 1: Add the UiHost field** In `GameWindow.cs`, next to `_vitalsVm` (~line 614): ```csharp // Phase D.2b — retail-look UI tree. Null unless ACDREAM_RETAIL_UI=1. private AcDream.App.UI.UiHost? _uiHost; ``` - [ ] **Step 2: Construct UiHost + the vitals subtree in OnLoad** In `GameWindow.cs` OnLoad, **after** `_textureCache` is constructed (after line 1724) and after `_vitalsVm` is available, add. Note: `_vitalsVm` is built today only inside the DevTools block (line 1330). Hoist its construction so it exists for the retail path too — change line 1330's block so the VM is created when `DevToolsEnabled || _options.RetailUi`. Concretely, ensure this runs regardless of DevTools: ```csharp _vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer); ``` **Also ungate the GUID setter:** the `_vitalsVm.SetLocalPlayerGuid(...)` call at EnterWorld (~line 1984) must run whenever `_vitalsVm` is non-null — not only under DevTools — or retail-only mode reads HP=1.0 forever. Change any `if (DevToolsEnabled)` guard around that call to `if (_vitalsVm is not null)` (use the null-conditional `_vitalsVm?.SetLocalPlayerGuid(guid);` if simpler). Verify the exact guard at the call site before editing. Then add the retail wiring (after `_textureCache` exists): ```csharp if (_options.RetailUi) { string shadersDir = Path.Combine(AppContext.BaseDirectory, "Rendering", "Shaders"); _uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont); var cache = _textureCache!; (uint, int, int) Resolve(uint id) { uint t = cache.GetOrUpload(id, out int w, out int h); return (t, w, h); } var panel = new AcDream.App.UI.UiNineSlicePanel( Resolve, AcDream.App.UI.RetailChromeSprites.FrameSurfaceId, AcDream.App.UI.RetailChromeSprites.Inset) { Left = 10, Top = 30, Width = 220, Height = 96 }; var title = new AcDream.App.UI.UiLabel { Text = "Vitals", Left = 8, Top = 4, TextColor = new System.Numerics.Vector4(1, 1, 1, 1) }; panel.AddChild(title); var vm = _vitalsVm!; panel.AddChild(new AcDream.App.UI.UiMeter { Left = 8, Top = 24, Width = 200, Height = 13, BarColor = new System.Numerics.Vector4(1f, 0f, 0f, 1f), Fill = () => vm.HealthPercent }); panel.AddChild(new AcDream.App.UI.UiMeter { Left = 8, Top = 44, Width = 200, Height = 13, BarColor = new System.Numerics.Vector4(0.063f, 0.94f, 0.94f, 1f), Fill = () => vm.StaminaPercent }); panel.AddChild(new AcDream.App.UI.UiMeter { Left = 8, Top = 64, Width = 200, Height = 13, BarColor = new System.Numerics.Vector4(0f, 0f, 1f, 1f), Fill = () => vm.ManaPercent }); _uiHost.Root.AddChild(panel); } ``` (`UiLabel` draws via the stb `BitmapFont` `_debugFont`; if `_debugFont` is null the title simply doesn't draw — acceptable for Spec 1.) - [ ] **Step 3: Draw the retail UI each frame** In `GameWindow.cs` OnRender, after the 3D passes and near the ImGui block (~line 8233, after `_imguiBootstrap` block or before it — order is deterministic either way; place it just before the ImGui `if` at line 8158 so ImGui composites on top in dev): ```csharp // Phase D.2b — retail-look UI tree (render-only; input integration deferred). if (_options.RetailUi && _uiHost is not null) { _uiHost.Tick(deltaSeconds); _uiHost.Draw(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y)); } ``` - [ ] **Step 4: Dispose UiHost on shutdown** In `GameWindow.cs`'s dispose/shutdown path (near where `_textRenderer`/`_debugFont` are disposed, ~line 12043): ```csharp _uiHost?.Dispose(); ``` - [ ] **Step 5: Build + visual verify (manual)** Run: `dotnet build src/AcDream.App/AcDream.App.csproj` Launch with `ACDREAM_RETAIL_UI=1` (+ the live-connection env from CLAUDE.md). **User confirms:** the Vitals window renders with the dat-sprite frame + three bars that track HP/Stam/Mana as the character takes damage/regens. Also launch with `ACDREAM_DEVTOOLS=1` (retail off) and confirm the ImGui panels are unchanged. - [ ] **Step 6: Retire TS-30 + add the IA row** In `docs/architecture/retail-divergence-register.md`: delete the **TS-30** row (line ~166). Add one new **IA** row (next sequential IA number) for the markup/serialization layer: ``` | IA-NN | D.2b retail UI is our own UiRoot tree + XML markup + controls.ini stylesheet, not a byte-port of keystone.dll's LayoutDesc binary tree (keystone.dll has no PDB/decomp) | src/AcDream.App/UI/UiNineSlicePanel.cs + MarkupDocument.cs | keystone.dll is outside decomp coverage — a byte-port is impossible by definition; we mirror retail's LayoutDesc/ElementDesc field model + controls.ini token vocabulary | Layout semantics the research under-specifies (anchor resolution at non-800x600, controls.ini cascade corners) differ silently with no oracle | LayoutDesc 0x21xxxxxx; controls.ini panel-property vocabulary; keystone.dll layout evaluation (no PDB) | ``` (Replace `IA-NN` with the actual next number; verify against the register head — there were 14 IA rows at the 2026-06-12 count, so likely `IA-15`.) - [ ] **Step 7: Commit** ```bash git add src/AcDream.App/Rendering/GameWindow.cs docs/architecture/retail-divergence-register.md git commit -m "feat(D.2b): wire UiHost + live Vitals panel (render-only); retire TS-30, add IA row" ``` --- ## Task 7: controls.ini stylesheet loader **Files:** - Create: `src/AcDream.App/UI/ControlsIni.cs` - Test: `tests/AcDream.App.Tests/UI/ControlsIniTests.cs` - Modify: `src/AcDream.App/Rendering/GameWindow.cs` (apply title color/font tokens) - [ ] **Step 1: Write the failing parser tests** Create `tests/AcDream.App.Tests/UI/ControlsIniTests.cs`: ```csharp using System.Numerics; using AcDream.App.UI; namespace AcDream.App.Tests.UI; public class ControlsIniTests { [Fact] public void Parse_ReadsSectionTokens() { var ini = ControlsIni.Parse( "[title]\nheight=19\ncolor=#FFFFFFFF\nfont=font://Verdana-10-bold\n" + "[body]\nbgcolor=#00000000\ncolor_border=#FF4F657D\n"); Assert.Equal("19", ini.Get("title", "height")); Assert.Equal("font://Verdana-10-bold", ini.Get("title", "font")); Assert.Null(ini.Get("title", "missing")); Assert.Null(ini.Get("nosuch", "height")); } [Fact] public void TryColor_ParsesAlphaFirstHex() { var ini = ControlsIni.Parse("[body]\ncolor_border=#FF4F657D\n"); Assert.True(ini.TryColor("body", "color_border", out Vector4 c)); Assert.Equal(0xFF / 255f, c.W, 5); // alpha Assert.Equal(0x4F / 255f, c.X, 5); // red Assert.Equal(0x65 / 255f, c.Y, 5); // green Assert.Equal(0x7D / 255f, c.Z, 5); // blue } [Fact] public void Parse_MissingFileReturnsEmptyNotThrow() { var ini = ControlsIni.Load(@"Z:\does\not\exist\controls.ini"); Assert.Null(ini.Get("title", "height")); // empty, no throw } } ``` - [ ] **Step 2: Run the tests to verify they fail** Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter ControlsIniTests` Expected: FAIL to compile — `ControlsIni` does not exist. - [ ] **Step 3: Implement ControlsIni** Create `src/AcDream.App/UI/ControlsIni.cs`: ```csharp using System.Collections.Generic; using System.Globalization; using System.Numerics; namespace AcDream.App.UI; /// /// Minimal reader for retail's controls.ini — a flat INI with one /// [section] per element type. Colors are #AARRGGBB (alpha /// first). Optional: a missing file yields an empty sheet (callers fall /// back to hardcoded defaults). See spec §7. /// public sealed class ControlsIni { private readonly Dictionary> _sections; private ControlsIni(Dictionary> s) => _sections = s; /// Load from disk; returns an empty sheet if the file is absent. public static ControlsIni Load(string path) => System.IO.File.Exists(path) ? Parse(System.IO.File.ReadAllText(path)) : new ControlsIni(new()); public static ControlsIni Parse(string text) { var sections = new Dictionary>(System.StringComparer.OrdinalIgnoreCase); Dictionary? cur = null; foreach (var raw in text.Split('\n')) { var line = raw.Trim(); if (line.Length == 0 || line[0] == ';' || line[0] == '#') continue; if (line[0] == '[' && line[^1] == ']') { var name = line[1..^1].Trim(); cur = new Dictionary(System.StringComparer.OrdinalIgnoreCase); sections[name] = cur; continue; } int eq = line.IndexOf('='); if (eq <= 0 || cur is null) continue; cur[line[..eq].Trim()] = line[(eq + 1)..].Trim(); } return new ControlsIni(sections); } public string? Get(string section, string key) => _sections.TryGetValue(section, out var s) && s.TryGetValue(key, out var v) ? v : null; /// Parse a #AARRGGBB token into an RGBA . public bool TryColor(string section, string key, out Vector4 color) { color = default; var v = Get(section, key); if (v is null || v.Length != 9 || v[0] != '#') return false; if (!uint.TryParse(v.AsSpan(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out uint argb)) return false; float a = ((argb >> 24) & 0xFF) / 255f; float r = ((argb >> 16) & 0xFF) / 255f; float g = ((argb >> 8) & 0xFF) / 255f; float b = (argb & 0xFF) / 255f; color = new Vector4(r, g, b, a); return true; } } ``` - [ ] **Step 4: Run the tests to verify they pass** Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter ControlsIniTests` Expected: PASS (3 tests). - [ ] **Step 5: Apply the stylesheet to the title label** In `GameWindow.cs`'s retail wiring (Task 6 Step 2), before building `title`, load the sheet and use the `[title]` color with a fallback: ```csharp string? acDir = _options.AcDir; var controls = acDir is not null ? AcDream.App.UI.ControlsIni.Load(Path.Combine(acDir, "controls", "controls.ini")) : AcDream.App.UI.ControlsIni.Parse(string.Empty); var titleColor = controls.TryColor("title", "color", out var tc) ? tc : new System.Numerics.Vector4(1, 1, 1, 1); ``` Then set `TextColor = titleColor` on the `title` label. - [ ] **Step 6: Build + commit** ```bash dotnet build src/AcDream.App/AcDream.App.csproj git add src/AcDream.App/UI/ControlsIni.cs tests/AcDream.App.Tests/UI/ControlsIniTests.cs src/AcDream.App/Rendering/GameWindow.cs git commit -m "feat(D.2b): controls.ini stylesheet loader (optional) + apply title color" ``` --- ## Task 8: MarkupDocument — XML → UiElement subtree **Files:** - Create: `src/AcDream.App/UI/MarkupDocument.cs` - Create: `src/AcDream.App/UI/assets/vitals.xml` - Test: `tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs` - Modify: `src/AcDream.App/AcDream.App.csproj` (copy `UI/assets/*.xml` to output) - Modify: `src/AcDream.App/Rendering/GameWindow.cs` (build the subtree from markup) - [ ] **Step 1: Write the failing parser test** Create `tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs`: ```csharp using AcDream.App.UI; namespace AcDream.App.Tests.UI; public class MarkupDocumentTests { private sealed class FakeBinding { public float HealthPercent => 0.5f; public float? ManaPercent => null; } [Fact] public void Build_CreatesPanelWithMeterChildrenAndGeometry() { const string xml = "" + " " + ""; var resolve = (uint id) => ((uint)1, 32, 32); var panel = MarkupDocument.Build(xml, new FakeBinding(), resolve, frameSurfaceId: 0x06000000, inset: 8); Assert.IsType(panel); Assert.Equal(10f, panel.Left); Assert.Equal(220f, panel.Width); // One UiMeter child whose fill resolves to the binding's 0.5. Assert.Single(panel.Children); var meter = Assert.IsType(panel.Children[0]); Assert.Equal(8f, meter.Left); Assert.Equal(200f, meter.Width); Assert.Equal(0.5f, meter.Fill()); } [Fact] public void Build_NullBindingPropertyYieldsNullFill() { const string xml = "" + " " + ""; var panel = MarkupDocument.Build(xml, new FakeBinding(), id => ((uint)1, 32, 32), 0x06000000, 8); var meter = Assert.IsType(panel.Children[0]); Assert.Null(meter.Fill()); } } ``` - [ ] **Step 2: Run the test to verify it fails** Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter MarkupDocumentTests` Expected: FAIL to compile — `MarkupDocument` does not exist. - [ ] **Step 3: Implement MarkupDocument** Create `src/AcDream.App/UI/MarkupDocument.cs`: ```csharp using System; using System.Globalization; using System.Numerics; using System.Xml.Linq; namespace AcDream.App.UI; /// /// Parses our KSML-style panel markup (mirrors retail's ElementDesc fields) /// into a live subtree. {Binding} attribute /// values resolve against a supplied object by property name (reflection). /// This is the format the future LayoutDesc importer will emit. See spec §7. /// public static class MarkupDocument { /// Surface id → (GL handle, width, height). public static UiNineSlicePanel Build( string xml, object binding, Func resolve, uint frameSurfaceId, int inset) { var root = XDocument.Parse(xml).Root ?? throw new FormatException("empty markup"); if (root.Name.LocalName != "panel") throw new FormatException($"root must be , got <{root.Name.LocalName}>"); var panel = new UiNineSlicePanel(resolve, frameSurfaceId, inset) { Left = F(root, "x"), Top = F(root, "y"), Width = F(root, "w"), Height = F(root, "h"), }; string? title = (string?)root.Attribute("title"); if (!string.IsNullOrEmpty(title)) panel.AddChild(new UiLabel { Text = title, Left = 8, Top = 4 }); foreach (var el in root.Elements()) { switch (el.Name.LocalName) { case "meter": panel.AddChild(new UiMeter { Left = F(el, "x"), Top = F(el, "y"), Width = F(el, "w"), Height = F(el, "h"), BarColor = Color((string?)el.Attribute("color")), Fill = BindFloat((string?)el.Attribute("fill"), binding), }); break; // future: case "label", "button", "image" ... } } return panel; } private static float F(XElement e, string attr) => float.TryParse((string?)e.Attribute(attr), NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : 0f; private static Vector4 Color(string? hex) { if (hex is { Length: 9 } && hex[0] == '#' && uint.TryParse(hex.AsSpan(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out uint argb)) { return new Vector4( ((argb >> 16) & 0xFF) / 255f, ((argb >> 8) & 0xFF) / 255f, (argb & 0xFF) / 255f, ((argb >> 24) & 0xFF) / 255f); } return Vector4.One; } /// Resolve "{Prop}" to a live getter against the binding; "" → constant 0. private static Func BindFloat(string? expr, object binding) { if (expr is null || expr.Length < 3 || expr[0] != '{' || expr[^1] != '}') return () => 0f; string prop = expr[1..^1]; var pi = binding.GetType().GetProperty(prop); if (pi is null) return () => null; return () => { object? v = pi.GetValue(binding); return v switch { float f => f, null => (float?)null, _ => Convert.ToSingle(v, CultureInfo.InvariantCulture), }; }; } } ``` - [ ] **Step 4: Run the test to verify it passes** Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter MarkupDocumentTests` Expected: PASS (2 tests). - [ ] **Step 5: Add the vitals markup asset + copy-to-output** Create `src/AcDream.App/UI/assets/vitals.xml`: ```xml ``` In `src/AcDream.App/AcDream.App.csproj`, add an `ItemGroup` to copy UI assets to output: ```xml ``` - [ ] **Step 6: Replace the hand-built subtree with the markup build** In `GameWindow.cs`'s retail wiring (Task 6 Step 2), replace the hand-built `panel`/`title`/`UiMeter` block with: ```csharp string vitalsXmlPath = Path.Combine(AppContext.BaseDirectory, "UI", "assets", "vitals.xml"); var panel = AcDream.App.UI.MarkupDocument.Build( System.IO.File.ReadAllText(vitalsXmlPath), _vitalsVm!, Resolve, AcDream.App.UI.RetailChromeSprites.FrameSurfaceId, AcDream.App.UI.RetailChromeSprites.Inset); _uiHost.Root.AddChild(panel); ``` (The `controls.ini` title color from Task 7 can be applied by setting the title-`UiLabel`'s color after the build, or deferred — the markup path owns the title now.) - [ ] **Step 7: Build + visual verify + commit** Run: `dotnet build src/AcDream.App/AcDream.App.csproj` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` Launch with `ACDREAM_RETAIL_UI=1`; **user confirms** the markup-built panel renders identically to the hand-built one (frame + 3 live bars). ```bash git add src/AcDream.App/UI/MarkupDocument.cs src/AcDream.App/UI/assets/vitals.xml src/AcDream.App/AcDream.App.csproj tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs src/AcDream.App/Rendering/GameWindow.cs git commit -m "feat(D.2b): MarkupDocument (XML -> UiElement tree) + vitals.xml; build panel from markup" ``` --- ## Task 9: Plugin UI registry (capstone — designed-now, first consumer first-party) **Files:** - Create: `src/AcDream.Plugin.Abstractions/IUiRegistry.cs` - Modify: `src/AcDream.Plugin.Abstractions/IPluginHost.cs` - Create: `src/AcDream.App/Plugins/BufferedUiRegistry.cs` - Modify: `src/AcDream.App/Plugins/AppPluginHost.cs`, `src/AcDream.App/Program.cs`, `src/AcDream.App/Rendering/GameWindow.cs` - Test: `tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs` - [ ] **Step 1: Define the registry interface** Create `src/AcDream.Plugin.Abstractions/IUiRegistry.cs`: ```csharp namespace AcDream.Plugin.Abstractions; /// /// Plugin-facing UI registration. A plugin ships a markup file (KSML-style) /// + a binding object exposing the data properties the markup binds to, and /// registers it here from Enable(). Registrations made before the GL /// window opens are buffered and drained once the UI host exists. /// public interface IUiRegistry { /// Absolute path to the plugin's panel markup. /// Object whose properties the markup's {Bindings} read. void AddMarkupPanel(string markupPath, object binding); } ``` - [ ] **Step 2: Add `Ui` to IPluginHost** In `src/AcDream.Plugin.Abstractions/IPluginHost.cs`: ```csharp public interface IPluginHost { IPluginLogger Log { get; } IGameState State { get; } IEvents Events { get; } IUiRegistry Ui { get; } } ``` - [ ] **Step 3: Write the failing buffered-registry test** Create `tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs`: ```csharp using AcDream.App.Plugins; namespace AcDream.App.Tests.Plugins; public class BufferedUiRegistryTests { [Fact] public void Drain_YieldsBufferedRegistrationsOnce() { var reg = new BufferedUiRegistry(); reg.AddMarkupPanel("a.xml", new object()); reg.AddMarkupPanel("b.xml", new object()); var drained = reg.Drain(); Assert.Equal(2, drained.Count); Assert.Equal("a.xml", drained[0].MarkupPath); // Second drain is empty (consumed). Assert.Empty(reg.Drain()); } } ``` - [ ] **Step 4: Run the test to verify it fails** Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter BufferedUiRegistryTests` Expected: FAIL to compile — `BufferedUiRegistry` does not exist. - [ ] **Step 5: Implement BufferedUiRegistry** Create `src/AcDream.App/Plugins/BufferedUiRegistry.cs`: ```csharp using System.Collections.Generic; using AcDream.Plugin.Abstractions; namespace AcDream.App.Plugins; /// /// Buffers plugin calls (which run in /// Program.cs before the GL window opens) until GameWindow drains them into /// the UiHost tree after construction. /// public sealed class BufferedUiRegistry : IUiRegistry { public readonly record struct Pending(string MarkupPath, object Binding); private readonly List _pending = new(); public void AddMarkupPanel(string markupPath, object binding) => _pending.Add(new Pending(markupPath, binding)); /// Return + clear all buffered registrations. public IReadOnlyList Drain() { var copy = _pending.ToArray(); _pending.Clear(); return copy; } } ``` - [ ] **Step 6: Wire it through AppPluginHost + Program + GameWindow** `src/AcDream.App/Plugins/AppPluginHost.cs` — add the `Ui` member: ```csharp public AppPluginHost(IPluginLogger log, IGameState state, IEvents events, IUiRegistry ui) { Log = log; State = state; Events = events; Ui = ui; } public IPluginLogger Log { get; } public IGameState State { get; } public IEvents Events { get; } public IUiRegistry Ui { get; } ``` `src/AcDream.App/Program.cs` — construct the registry and pass it to host + window (replace lines 26 + 59): ```csharp var uiRegistry = new AcDream.App.Plugins.BufferedUiRegistry(); var host = new AppPluginHost(new SerilogAdapter(Log.Logger), worldGameState, worldEvents, uiRegistry); ``` ```csharp using var window = new GameWindow(runtimeOptions, worldGameState, worldEvents, uiRegistry); ``` `GameWindow` — add a constructor parameter `AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null`, store it in a field, and in the retail wiring (after `_uiHost.Root.AddChild(panel)`), drain it: ```csharp if (_uiRegistry is not null) { foreach (var p in _uiRegistry.Drain()) { var pluginPanel = AcDream.App.UI.MarkupDocument.Build( System.IO.File.ReadAllText(p.MarkupPath), p.Binding, Resolve, AcDream.App.UI.RetailChromeSprites.FrameSurfaceId, AcDream.App.UI.RetailChromeSprites.Inset); _uiHost.Root.AddChild(pluginPanel); } } ``` (Fix the `StubHost` in `tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs:28` to implement the new `Ui` member — return a throwaway `BufferedUiRegistry` or a stub.) - [ ] **Step 7: Run tests + build** Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter BufferedUiRegistryTests` Expected: PASS. Fix any compile breaks in plugin-host implementors surfaced by the new interface member. - [ ] **Step 8: Commit** ```bash git add src/AcDream.Plugin.Abstractions/IUiRegistry.cs src/AcDream.Plugin.Abstractions/IPluginHost.cs src/AcDream.App/Plugins/BufferedUiRegistry.cs src/AcDream.App/Plugins/AppPluginHost.cs src/AcDream.App/Program.cs src/AcDream.App/Rendering/GameWindow.cs tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs git commit -m "feat(D.2b): IUiRegistry plugin UI surface + buffered drain into UiHost" ``` --- ## Final verification - [ ] `dotnet build` green (whole solution: `dotnet build AcDream.slnx`). - [ ] `dotnet test` green (all test projects). - [ ] `ACDREAM_RETAIL_UI=1`: retail Vitals window (frame + 3 live bars) renders; bars track damage/regen. - [ ] `ACDREAM_DEVTOOLS=1` (retail off): ImGui panels unchanged. - [ ] TS-30 deleted; one new IA row present. - [ ] Update the roadmap: mark D.2b Spec 1 (retail panel frame + vitals) shipped in [`docs/plans/2026-04-11-roadmap.md`](../../plans/2026-04-11-roadmap.md).