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) <noreply@anthropic.com>
50 KiB
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.
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—UiPanelsubclass drawing the 8-piece dat-sprite frame + center fill.src/AcDream.App/UI/UiMeter.cs—UiElementvital 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 →UiElementsubtree 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— buffersAddMarkupPaneluntilUiHostexists.tests/AcDream.App.Tests/UI/ControlsIniTests.cs,MarkupDocumentTests.cs,UiMeterTests.cs,UiNineSlicePanelTests.cstests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cstests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs
Modified files:
src/AcDream.App/RuntimeOptions.cs— addRetailUi,AcDir.src/AcDream.App/Rendering/Shaders/ui_text.frag— adduUseTexture==2RGBA branch.src/AcDream.App/Rendering/TextRenderer.cs— addDrawSprite+ per-texture batch +DepthMask.src/AcDream.App/Rendering/TextureCache.cs— addGetOrUpload(id, out w, out h)size overload.src/AcDream.App/UI/UiRenderContext.cs— addDrawSpriteforwarder.src/AcDream.App/Rendering/GameWindow.cs— wireUiHost+ vitals subtree (render-only).src/AcDream.Plugin.Abstractions/IPluginHost.cs+src/AcDream.App/Plugins/AppPluginHost.cs— addUi.src/AcDream.App/Program.cs— constructBufferedUiRegistry, 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:
using System.Collections.Generic;
using AcDream.App;
namespace AcDream.App.Tests;
public class RuntimeOptionsRetailUiTests
{
[Fact]
public void Parse_ReadsRetailUiAndAcDir()
{
var env = new Dictionary<string, string?>
{
["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):
int? LegacyStreamRadius,
bool RetailUi,
string? AcDir)
And in Parse (after the LegacyStreamRadius: line, before the closing );):
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
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:
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):
private readonly Dictionary<uint, (int w, int h)> _sizeBySurfaceId = new();
And add this method directly after GetOrUpload(uint surfaceId) (after line 81):
/// <summary>
/// Like <see cref="GetOrUpload(uint)"/> but also returns the decoded
/// pixel dimensions. UI 9-slice geometry needs the source size to
/// compute slice UVs. Cached alongside the handle.
/// </summary>
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):
private readonly Dictionary<uint, List<float>> _spriteBufs = new();
Clear it in Begin (inside the existing Begin, after _rectBuf.Clear();):
foreach (var b in _spriteBufs.Values) b.Clear();
Add the public draw method (after DrawString, ~line 130):
/// <summary>
/// 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).
/// </summary>
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<float>(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:
if (_textVerts == 0 && _rectVerts == 0) return;
with:
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:
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):
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"):
// 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:
_gl.DepthMask(true);
- Step 4: Add the DrawSprite forwarder to UiRenderContext
In src/AcDream.App/UI/UiRenderContext.cs, after the DrawRectOutline forwarder (line 54):
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
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:
namespace AcDream.App.UI;
/// <summary>
/// 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.
/// </summary>
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) ===
/// <summary>The single 9-sliceable frame sprite (or the body/center fill).</summary>
public static uint FrameSurfaceId = 0; // TODO Step 0: set to confirmed id
/// <summary>Corner inset in pixels (left/top/right/bottom assumed equal until LayoutDesc parse).</summary>
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:
// 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):
$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
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:
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:
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// A <see cref="UiPanel"/> 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
/// <see cref="RetailChromeSprites"/> until the LayoutDesc importer supplies
/// per-panel metrics.
/// </summary>
public sealed class UiNineSlicePanel : UiPanel
{
/// <summary>One slice patch: destination rect (local px) + source UVs (0..1).</summary>
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<uint, (uint tex, int w, int h)> _resolve;
private readonly uint _surfaceId;
private readonly int _inset;
/// <param name="resolve">Surface id → (GL handle, decoded width, height).
/// In production: <c>id => { var t = cache.GetOrUpload(id, out var w, out var h); return (t, w, h); }</c>.</param>
public UiNineSlicePanel(System.Func<uint, (uint, int, int)> resolve,
uint surfaceId, int inset)
{
_resolve = resolve;
_surfaceId = surfaceId;
_inset = inset;
BackgroundColor = Vector4.Zero; // suppress the base flat-rect fill
BorderColor = Vector4.Zero;
}
/// <summary>
/// Compute the 9 patches for a frame of <paramref name="frameW"/> x
/// <paramref name="frameH"/> from a <paramref name="texW"/> x
/// <paramref name="texH"/> source with a uniform <paramref name="inset"/>.
/// Order: TL, TC, TR, ML, MC, MR, BL, BC, BR (index 4 = center).
/// </summary>
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
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:
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:
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// A horizontal vital bar: an empty background rect with a partial-width
/// fill. <see cref="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.
/// </summary>
public sealed class UiMeter : UiElement
{
/// <summary>Fill fraction provider; null result draws an empty bar.</summary>
public System.Func<float?> 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; }
/// <summary>Clamp <paramref name="pct"/> to [0,1] and return the fill
/// rect (local px) for a bar of <paramref name="w"/> x <paramref name="h"/>.</summary>
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
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):
// 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:
_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):
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):
// 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):
_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
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:
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:
using System.Collections.Generic;
using System.Globalization;
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// Minimal reader for retail's <c>controls.ini</c> — a flat INI with one
/// <c>[section]</c> per element type. Colors are <c>#AARRGGBB</c> (alpha
/// first). Optional: a missing file yields an empty sheet (callers fall
/// back to hardcoded defaults). See spec §7.
/// </summary>
public sealed class ControlsIni
{
private readonly Dictionary<string, Dictionary<string, string>> _sections;
private ControlsIni(Dictionary<string, Dictionary<string, string>> s) => _sections = s;
/// <summary>Load from disk; returns an empty sheet if the file is absent.</summary>
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<string, Dictionary<string, string>>(System.StringComparer.OrdinalIgnoreCase);
Dictionary<string, string>? 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<string, string>(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;
/// <summary>Parse a <c>#AARRGGBB</c> token into an RGBA <see cref="Vector4"/>.</summary>
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:
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
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(copyUI/assets/*.xmlto 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:
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 =
"<panel id=\"acdream.vitals\" x=\"10\" y=\"30\" w=\"220\" h=\"96\" title=\"Vitals\">" +
" <meter id=\"health\" x=\"8\" y=\"24\" w=\"200\" h=\"13\" fill=\"{HealthPercent}\" color=\"#FFFF0000\"/>" +
"</panel>";
var resolve = (uint id) => ((uint)1, 32, 32);
var panel = MarkupDocument.Build(xml, new FakeBinding(), resolve,
frameSurfaceId: 0x06000000, inset: 8);
Assert.IsType<UiNineSlicePanel>(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<UiMeter>(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 =
"<panel id=\"v\" x=\"0\" y=\"0\" w=\"10\" h=\"10\" title=\"V\">" +
" <meter id=\"mana\" x=\"0\" y=\"0\" w=\"10\" h=\"2\" fill=\"{ManaPercent}\" color=\"#FF0000FF\"/>" +
"</panel>";
var panel = MarkupDocument.Build(xml, new FakeBinding(),
id => ((uint)1, 32, 32), 0x06000000, 8);
var meter = Assert.IsType<UiMeter>(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:
using System;
using System.Globalization;
using System.Numerics;
using System.Xml.Linq;
namespace AcDream.App.UI;
/// <summary>
/// Parses our KSML-style panel markup (mirrors retail's ElementDesc fields)
/// into a live <see cref="UiElement"/> subtree. <c>{Binding}</c> attribute
/// values resolve against a supplied object by property name (reflection).
/// This is the format the future LayoutDesc importer will emit. See spec §7.
/// </summary>
public static class MarkupDocument
{
/// <param name="resolve">Surface id → (GL handle, width, height).</param>
public static UiNineSlicePanel Build(
string xml, object binding, Func<uint, (uint, int, int)> 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 <panel>, 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;
}
/// <summary>Resolve "{Prop}" to a live getter against the binding; "" → constant 0.</summary>
private static Func<float?> 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:
<panel id="acdream.vitals" x="10" y="30" w="220" h="96" title="Vitals">
<meter id="health" x="8" y="24" w="200" h="13" fill="{HealthPercent}" color="#FFFF0000"/>
<meter id="stamina" x="8" y="44" w="200" h="13" fill="{StaminaPercent}" color="#FF10F0F0"/>
<meter id="mana" x="8" y="64" w="200" h="13" fill="{ManaPercent}" color="#FF0000FF"/>
</panel>
In src/AcDream.App/AcDream.App.csproj, add an ItemGroup to copy UI assets to output:
<ItemGroup>
<None Include="UI\assets\**\*.xml" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
- 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:
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).
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:
namespace AcDream.Plugin.Abstractions;
/// <summary>
/// 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 <c>Enable()</c>. Registrations made before the GL
/// window opens are buffered and drained once the UI host exists.
/// </summary>
public interface IUiRegistry
{
/// <param name="markupPath">Absolute path to the plugin's panel markup.</param>
/// <param name="binding">Object whose properties the markup's {Bindings} read.</param>
void AddMarkupPanel(string markupPath, object binding);
}
- Step 2: Add
Uito IPluginHost
In src/AcDream.Plugin.Abstractions/IPluginHost.cs:
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:
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:
using System.Collections.Generic;
using AcDream.Plugin.Abstractions;
namespace AcDream.App.Plugins;
/// <summary>
/// Buffers plugin <see cref="IUiRegistry.AddMarkupPanel"/> calls (which run in
/// Program.cs before the GL window opens) until GameWindow drains them into
/// the UiHost tree after construction.
/// </summary>
public sealed class BufferedUiRegistry : IUiRegistry
{
public readonly record struct Pending(string MarkupPath, object Binding);
private readonly List<Pending> _pending = new();
public void AddMarkupPanel(string markupPath, object binding)
=> _pending.Add(new Pending(markupPath, binding));
/// <summary>Return + clear all buffered registrations.</summary>
public IReadOnlyList<Pending> 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:
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):
var uiRegistry = new AcDream.App.Plugins.BufferedUiRegistry();
var host = new AppPluginHost(new SerilogAdapter(Log.Logger), worldGameState, worldEvents, uiRegistry);
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:
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
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 buildgreen (whole solution:dotnet build AcDream.slnx).dotnet testgreen (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.