From 35152248f1ba80b104164e12789d9bff0840fd26 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 14:21:56 +0200 Subject: [PATCH] =?UTF-8?q?docs(D.2b):=20implementation=20plan=20=E2=80=94?= =?UTF-8?q?=20retail=20panel=20frame=20+=20live=20Vitals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 9-task TDD plan against the re-grounded spec, building on the existing AcDream.App/UI scaffold: RuntimeOptions toggles, textured-sprite path in TextRenderer (+ frag uUseTexture=2, + TextureCache size overload), Step-0 chrome prove-out, UiNineSlicePanel + UiMeter widgets, wire UiHost + live Vitals (render-only) retiring TS-30, controls.ini loader, MarkupDocument (XML -> UiElement tree), and the IUiRegistry plugin surface. Exact code per step; pure parsers TDD'd in AcDream.App.Tests, GL/visual bits user-verified. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-14-d2b-retail-panel-frame-plan.md | 1322 +++++++++++++++++ 1 file changed, 1322 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md diff --git a/docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md b/docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md new file mode 100644 index 00000000..5fff7b20 --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md @@ -0,0 +1,1322 @@ +# 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).