acdream/docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md
Erik 35152248f1 docs(D.2b): implementation plan — retail panel frame + live Vitals
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>
2026-06-14 14:21:56 +02:00

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.csUiPanel subclass drawing the 8-piece dat-sprite frame + center fill.
  • src/AcDream.App/UI/UiMeter.csUiElement 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:

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 compileRetailUi / 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 FromEnvironmentParse 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 (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:

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 Ui to 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 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.