acdream/docs/superpowers/specs/2026-06-14-d2b-retail-panel-frame-design.md
Erik b18403da02 feat(D.2b): wire UiHost + live retail Vitals panel (render-only); retire TS-30
Wires the dormant AcDream.App/UI retained-mode tree into GameWindow under
ACDREAM_RETAIL_UI=1: an 8-piece dat-sprite UiNineSlicePanel framing three
UiMeter vital bars bound to the existing VitalsVM. Render-only (UiHost input not
yet bridged to the InputDispatcher — next sub-phase). Coexists with the ImGui
devtools path; no regression there.

Visually verified against a live retail client: the bars match retail's vitals
structure (three stacked horizontal bars, current/max numbers centered) — so the
earlier "orbs" assumption was wrong (retail vitals ARE bars), and stamina is GOLD
not cyan (the #10F0F0 research note was wrong). UiMeter gains a centered numeric
Label (stub debug font for now). Spec §8 + the markup example corrected to match.

Bookkeeping: retired divergence row TS-30 (flat-rect panels -> real dat chrome)
and added IA-15 (our UiHost/markup engine vs keystone.dll's LayoutDesc tree).

Remaining polish (filed, §15): glassy gradient bar fill sprite + the retail dat
font for the numbers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:56:57 +02:00

25 KiB
Raw Blame History

D.2b — Retail panel frame + live Vitals (Approach C: KSML-style engine) — Design

Date: 2026-06-14 Status: Design approved (brainstorm) + re-grounded 2026-06-14 onto the existing AcDream.App/UI/ retained-mode scaffold (see §0). Pending spec re-review → implementation plan. Phase: D.2b — Custom retail-look UI backend (roadmap:427) Milestone: M5 "Looks like retail" — explicitly PARALLELIZABLE with M3/M4 (milestones:378). Opened as a parallel track while M1.5 is the active critical-path milestone; the M5 parallelizable flag is the milestone-discipline carve-out. Grounding: read-only research workflow wf_39a90d37-e5a (7 readers + gap-critic) + a direct read of src/AcDream.App/UI/. Every binding fact cites file:line in src/ or a named-retail symbol.


0. Re-grounding correction (read this first)

The first draft of this spec proposed building a RetailPanelHost : IPanelHost + RetailPanelRenderer : IPanelRenderer and a retained-mode toolkit from scratch. That was wrong. A direct read of src/AcDream.App/UI/ found a complete, dormant retained-mode toolkit — the 2026-04-17 scaffold the roadmap names as "the implementation foundation here" (roadmap:427):

  • UiRoot (UiRoot.cs) — the hard part is already built: mouse routing, keyboard focus, mouse capture, a full drag-drop state machine, tooltip timer, modal handling, click/right-click detection, world fall-through. Retail-faithful event codes in UiEvent.cs.
  • UiElement (geometry/tree/hit-test), UiPanel/UiLabel/UiButton (UiPanel.cs), UiHost (UiHost.cs — packages UiRoot + TextRenderer + font, with Tick/Draw/WireMouse/WireKeyboard), UiRenderContext (UiRenderContext.cs — transform stack + DrawRect/DrawString).

UiHost is dormant — never instantiated in GameWindow (verified: new UiHost( appears only in a doc-comment). And UiPanel.cs is the exact file divergence row TS-30 points at: it draws a flat translucent rect "until our AcFont/UiSpriteBatch consumes [9-slice dat sprites] directly."

Consequence: the retail UI is this existing UiRoot tree — a separate system from the ImGui IPanelRenderer path, not an IPanelRenderer implementation. Spec 1 wires the dormant UiHost and adds the few missing pieces, rather than building a backend. This is strictly less code and more faithful. §4/§5/§8/§9/§10 below are written against the scaffold.

(Process note: the grounding workflow's "UI" readers keyed on the ImGui/Abstractions framing in their prompts and never globbed src/AcDream.App/UI/. Lesson: a subsystem-discovery pass must glob by directory, not only by the framing the parent already has in mind.)

1. Context & goal

acdream needs a retail-faithful game UI. The shipped path (D.2a) is an ImGui overlay gated on ACDREAM_DEVTOOLS=1 — a debugger aesthetic, intentionally temporary. D.2b stands up the retail-look UI (the dormant UiHost tree) that draws retail's actual dat assets, while the ImGui devtools path stays untouched.

The user's framing (2026-06-14): AC's UI engine is Keystone — and Keystone was already markup + stylesheet (KSML, an HTML-clone XML defined by ksml.xsd,

  • controls.ini, a CSS-like INI stylesheet). So "make it look + behave like retail, but author it in a CSS/HTML-style way" re-expresses AC's own design in its modern equivalent.

Approach decision (Approach C). Three integration families were weighed: (A) embed a real web engine (Ultralight/CEF), (B) a native HTML/CSS-subset lib (RmlUi), (C) our own KSML-style markup + stylesheet over a retained-mode toolkit on Silk.NET. C chosen for: zero external deps (keeps the native-AOT goal intact), lowest memory (~310 MB vs CEF's 150300 MB), full control, and maximal faithfulness — it mirrors Keystone directly, and the retained-mode toolkit C needs already exists (§0).

This spec covers Spec 1: wire the scaffold + add the markup/stylesheet/sprite gaps, proven end-to-end on one panel — the universal window frame wrapping the live Vitals bars.

2. Scope

In Spec 1:

  • Wire the dormant UiHost into GameWindow, gated by a new RuntimeOptions.RetailUi toggle (ACDREAM_RETAIL_UI=1). The ImGui devtools path is untouched and may run simultaneously.
  • Add dat-sprite drawing: UiRenderContext.DrawSprite (UV-rect) + a TextRenderer textured-sprite path + a ui_text.frag uUseTexture=2 branch.
  • A UiNineSlicePanel : UiPanel that draws the 8-piece dat-sprite window frame + center fill (upgrading the exact code TS-30 cites) — title bar (UiLabel) + a close button (UiButton, which already exists).
  • A UiMeter : UiElement vital bar bound to a Func<float> reading VitalsVM.
  • The XML markup format (mirrors ElementDesc) + a MarkupDocument parser that instantiates a UiElement subtree + a minimal controls.ini stylesheet loader.
  • The plugin-facing contract: plugins contribute a UiElement/markup subtree added to UiRoot (§9) — designed now, first consumer first-party.

Deferred to later sub-phases (explicitly OUT):

  • Wiring UiHost's input (WireMouse/WireKeyboard) into the existing Phase-K InputDispatcher. The UiRoot input machinery exists; integrating two input consumers (route unconsumed WorldMouseFallThrough back to the game) is its own concern. Spec 1 is render-only (Tick + Draw), so the frame + live bars show but the close button isn't clicked and the window isn't dragged.
  • The dat A8 glyph font loader (AcFont) → numeric overlays.
  • The full anchor solver (StateDesc::UpdateSizeAndPosition port).
  • The LayoutDesc binary importer (sub-project 3).
  • Reskinning Chat / Debug / Settings.
  • Login / char-select / chargen screens (raw-JPEG backgrounds, sub-project 4).

3. Source-verified facts (do-not-trust list)

The grounding caught several load-bearing "facts" that were wrong/unverified. These are binding:

Claimed (memory / first draft) Reality (source-verified)
Build a retained-mode toolkit + RetailPanelHost/RetailPanelRenderer The toolkit exists in src/AcDream.App/UI/ (§0); the retail UI is the UiRoot tree, not an IPanelRenderer backend
VitalsVM is record VitalsVM(int HpCurrent, …) Sealed class: HealthPercent (float), StaminaPercent/ManaPercent (float?), *Current/*Max (uint?), ctor (CombatState, LocalPlayerState?), SetLocalPlayerGuid(uint)VitalsVM.cs:35
Chrome sprite IDs 0x06004CC2/0x21000040/0x060074BF..C6 are known Unverified + contradictory. 0x06001125 (cited elsewhere) is the char-select highlight. No chrome ID is trusted — Step 0 dat prove-out resolves them empirically.
#FFDBD6A8 "parchment cream" is the panel background It is [editbox]/[treeview] text color. Real frame tokens: [title] bg #FFFFFFFF, font Verdana-10-bold, height 19; [body] bg #00000000 (transparent), color_border=#FF4F657D
DatCollection is NOT thread-safe Concurrent reads are safe since v2.1.7 (2026-06-09 investigation). No UI-specific lock.
KSML is the panel-layout language KSML is rich-text-in-text-regions; the panel layout format is the binary LayoutDesc/ElementDesc tree (acclient.h:33693). Our markup mirrors ElementDesc.

4. Architecture & placement

The retail UI lives in src/AcDream.App/UI/ (where the scaffold already is). New widgets/parsers join it as dedicated files. Code-Structure Rule 1 is honored (nothing substantial added to GameWindow.cs — only a few wiring lines); Rule 2 (Core stays GL-free) and Rule 3 (panels target AcDream.UI.Abstractions) are unaffected because the retail UI is a separate tree, not an IPanelRenderer panel.

┌──────────────────────────────────────────────────────────┐
│ retail dat (read-only fidelity source)                    │
│   controls.ini → style tokens · RenderSurface 0x06xxxxxx  │
│   → sprites · Font 0x40xxxxxx → glyphs (deferred)         │
└───────────────┬──────────────────────────────────────────┘
                │ TextureCache.GetOrUpload(id) → Texture2D
┌───────────────▼──────────────────────────────────────────┐
│ src/AcDream.App/UI/  (scaffold EXISTS; + = new in Spec 1) │
│   UiHost (exists, dormant)  ─ wire into GameWindow         │
│   UiRoot/UiElement (exist)  ─ input + tree + hit-test      │
│   UiRenderContext (exists)  + DrawSprite(UV-rect)          │
│   UiPanel/UiLabel/UiButton (exist)                         │
│   + UiNineSlicePanel : UiPanel   (8-piece dat chrome)      │
│   + UiMeter : UiElement          (vital bar)               │
│   + MarkupDocument  (XML → UiElement subtree)             │
│   + ControlsIni     (stylesheet loader)                    │
│   uses Rendering/TextRenderer (+ sprite path, + DepthMask) │
└───────────────┬──────────────────────────────────────────┘
                │ UiMeter.Fill = () => vm.HealthPercent
┌───────────────▼──────────────────────────────────────────┐
│ AcDream.UI.Abstractions (exists) — VitalsVM (unchanged)   │
│   ↑ ImGui IPanelHost/IPanelRenderer path stays for        │
│     ACDREAM_DEVTOOLS, fully independent of the above       │
└──────────────────────────────────────────────────────────┘

Coexistence. Two UI systems run side by side, independently: ACDREAM_DEVTOOLS=1 → the ImGui overlay (unchanged); ACDREAM_RETAIL_UI=1 → the UiHost tree. The retail pass renders in the post-3D slot (GameWindow.cs:8232 region) with deterministic ordering relative to ImGui. UiHost.Draw already does TextRenderer.Begin → Root.Draw(ctx) → TextRenderer.Flush (UiHost.cs:58).

5. Render foundation — extend the existing 2D path

UiHost draws the UiRoot tree through a UiRenderContext backed by the shared TextRenderer (UiHost.cs:58-67). That TextRenderer does solid rects + R8 text today but not textured RGBA sprites (ui_text.frag:9, TextRenderer.cs). Spec 1 adds the sprite path:

  • ui_text.frag += a uUseTexture==2 branch: FragColor = texture(uTex, vUv) * vColor; (the existing 0=solid and 1=R8-coverage branches are untouched).
  • TextRenderer += DrawSprite(uint texture, float x,y,w,h, float u0,v0,u1,v1, Vector4 tint) accumulating into per-texture sprite buffers (Dictionary<uint, List<float>>), and a Flush pass that, after rects+text, draws each texture's batch with uUseTexture=2. Reuses the existing AppendQuad (which already takes u0,v0,u1,v1) + UploadBuffer machinery.
  • TextRenderer.Flush += explicit DepthMask(false) (queried + restored) — it disables DepthTest today but never sets DepthMask (TextRenderer.cs:171). Per the project's "render self-contained GL state" rule.
  • UiRenderContext += DrawSprite(uint texture, float x,y,w,h, float u0,v0,u1,v1, Vector4 tint) that adds the current transform and forwards to TextRenderer.DrawSprite (mirrors the existing DrawRect forwarder at UiRenderContext.cs:50).

No new shader class, VAO, or batcher — we extend the proven path the scaffold already uses. (Shader is the simple file-based class Shader.cs; GLSLShader's bindless machinery is not needed.)

6. Dat assets & the Step-0 prove-out gate

TextureCache.GetOrUpload(uint surfaceId) returns a conventional Texture2D GL handle (1×1 magenta on failure) — exactly right for the UI batch (TextureCache.cs:70). The decode chain + PFID_* formats already work (SurfaceDecoder.cs:39). GameWindow already holds a TextureCache; UiHost/UiNineSlicePanel receive it (or a Func<uint,uint> sprite-resolver) by injection.

Step 0 is empirical and comes first. Because no chrome sprite ID is verified, the first implementation task draws each candidate ID (0x06004CC2, 0x060074BF..C6, 0x0600129C, …) as a raw quad and visually confirms which decode to frame-shaped art vs magenta vs the wrong sprite. The confirmed IDs are recorded in code comments before any chrome layout is written. No ID is hardcoded on faith.

The frame is 8 quads + a center fill (4 corner + 4 edge sprites + center), not one stretched 9-slice texture. Slice/edge metrics are a documented stopgap constant (with a divergence row) until the LayoutDesc tree is parsed (sub-project 3).

7. Markup + stylesheet model

Markup mirrors ElementDesc 1:1 (acclient.h:33693); MarkupDocument parses it and instantiates a UiElement subtree (a UiNineSlicePanel with child UiLabel/UiMeter/UiButton). Authoring shape:

<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="#FF0000"/>
  <meter id="stamina" x="8" y="44" w="200" h="13" fill="{StaminaPercent}" color="#D9A626"/>
  <meter id="mana"    x="8" y="64" w="200" h="13" fill="{ManaPercent}"    color="#0000FF"/>
</panel>

This is the shape the future LayoutDesc importer will emit, so authoring and imported formats converge. It is not KSML (rich-text, deferred). {Binding} expressions resolve against a supplied binding object (the VitalsVM) via reflection on the property name.

Anchor codes are defined now (0=fixed, 1=stretch, 2=proportional-translate, 3=center, 4=proportional-scale — from StateDesc::UpdateSizeAndPosition @0x0069BF20) but the solver is deferred: the Vitals window is fixed-size (placed via the existing UiElement.Left/Top), so Spec 1 needs no solver.

Stylesheet. A small INI loader parses controls.ini keyed by element-type section, honoring #AARRGGBB (alpha-first) and font://Face-Pt[-style]. Cascade: element-type defaults → per-element class= → inline attributes. Optional (§10): absent AC install → source-verified [title]/[body] fallback tokens.

8. VM binding (the Vitals slice)

The vitals panel is a UiNineSlicePanel (chrome) containing a UiLabel (title) and three UiMeters. Each UiMeter holds a Func<float?> Fill bound to the real VitalsVM (VitalsVM.cs:67): () => vm.HealthPercent, () => vm.StaminaPercent, () => vm.ManaPercent. The VM already does all server plumbing, so we do not re-derive vitals from the retail gmVitalsUI/CACQualities decomp.

UiMeter.OnDraw draws the empty bar (ctx.DrawRect), the filled portion as a partial-size rect (width = pct * Width), and a centered current/max numeric overlay (Func<string?> Label). Retail's vitals ARE exactly this — three stacked horizontal bars (confirmed against a live retail client 2026-06-14), NOT orbs. Colors: Health red, Stamina gold (the earlier #10F0F0 cyan research note was wrong), Mana blue. A null fill/label (pre-PlayerDescription) renders gracefully. The remaining gap to pixel-retail is the glassy gradient bar fill sprite + the retail dat font for the numbers (today the stub BitmapFont draws them) — both polish, deferred to §15.

The VitalsVM is constructed and given the player GUID the same way as today (GameWindow.cs:1330 ctor, :1984 SetLocalPlayerGuid); the retail-UI build path reuses that same VM instance.

9. Plugin contract (designed now, first consumer first-party)

The plugin API is a day-1 constraint; plugin authors must be able to add retail UI. The natural unit is a UiElement/markup subtree added to UiRoot (not IPanel/IPanelRenderer, which is the ImGui devtools path). Spec 1 adds:

  • A small IUiRegistry (in AcDream.Plugin.Abstractions) — void AddMarkupPanel(string markupPath, object binding) (and/or void AddElement(UiElement) once a plugin-safe element surface is decided). For Spec 1, AddMarkupPanel is enough.
  • IPluginHost gains IUiRegistry Ui { get; } (IPluginHost.cs:8 has none today); AppPluginHost implements it (AppPluginHost.cs:5).
  • Because plugin Enable() runs in Program.cs before the GL window opens (Program.cs:55-60), AddMarkupPanel buffers registrations into a list that GameWindow drains into UiRoot after UiHost is constructed. The threading/timing concern lives in the host; the plugin call is unconditional.
  • The first consumer is the first-party vitals panel (built directly in GameWindow, not through the registry). Wiring an actual plugin-supplied markup panel end-to-end is exercised by a smoke test but is otherwise the thin follow-up. This task group is the last in the plan so the visible vitals slice can land first if it slips.

10. Confirmed decisions (approved 2026-06-14)

  1. Render-only first slice. Tick + Draw only; the UiHost input wiring (WireMouse/WireKeyboard) is not connected to the existing Phase-K InputDispatcher yet, so the close button isn't clickable and the window isn't draggable. Rationale (corrected): the UiRoot input machinery already exists — what's deferred is integrating two input consumers (routing unconsumed WorldMouseFallThrough back to the game's dispatcher), which is its own sub-phase.
  2. controls.ini optional. Assume C:\Turbine\Asheron's Call\ may or may not exist. Add an ACDREAM_AC_DIR RuntimeOptions field; when absent, fall back to the source-verified [title]/[body] token values. The build never fails on a missing AC install.

11. Build sequence

Step Deliverable Proves
0 Dat prove-out harness: draw candidate chrome IDs, confirm the real ones Resolves the chrome-ID contradiction empirically
1 ui_text.frag uUseTexture=2 + TextRenderer.DrawSprite + DepthMask + UiRenderContext.DrawSprite A dat sprite composites over the 3D scene
2 UiNineSlicePanel draws an empty titled frame from confirmed dat sprites (stopgap insets) Retail-shaped chrome renders
3 UiMeter + a hand-built vitals UiNineSlicePanel subtree bound to VitalsVM, wired via UiHost under ACDREAM_RETAIL_UI (render-only) End-to-end live data + the scaffold lights up
4 ControlsIni parser (TDD) feeding the panel's title/colors Stylesheet cascade
5 MarkupDocument parser (TDD) → builds the same vitals subtree from vitals.xml The Approach-C markup engine
6 (last) IUiRegistry on IPluginHost + buffered drain into UiRoot + smoke test Plugin-ready

12. Error handling & edge cases

  • Missing/undecodable spriteGetOrUpload magenta fallback is visible; Step 0 catches it. A null/zero DataID in markup logs a warning, draws nothing.
  • AC install absentcontrols.ini load skipped, baked fallback tokens used.
  • Vitals null percents → empty bar (UiMeter.Fill returns null).
  • Window resizeUiHost.Draw already sets Root.Width/Height to the current screen size each frame (UiHost.cs:61); fixed-coord panels stay put. No DPI scaling (known out-of-scope gap).
  • Both toggles on → ImGui Vitals and retail Vitals may both show (fine in dev).

13. Testing

  • ControlsIni parser (pure, no GL) — unit tests for #AARRGGBB, font://, cascade order. Lives in src/AcDream.App/UI/, tested in tests/AcDream.App.Tests/ (App-layer, Rule 6).
  • MarkupDocument parser — unit tests for XML → UiElement tree shape (types, geometry) and {Binding} resolution against a fake binding object.
  • UiMeter fill geometry — unit test that fill fraction → partial rect width (pure math; UiMeter.ComputeFillRect(pct, w, h) as a static helper so it's testable without GL).
  • UiNineSlice geometry — unit test that a frame size + insets → the 9 dst rects (UiNineSlicePanel.ComputeSliceRects static helper).
  • Plugin smoke — a test plugin calls host.Ui.AddMarkupPanel and the buffered registration is drained (assert the panel is added to UiRoot).
  • Visual acceptance (user) — retail-shaped Vitals frame with live bars under ACDREAM_RETAIL_UI=1; ImGui path unaffected under ACDREAM_DEVTOOLS=1.
  • dotnet build + dotnet test green.

14. Bookkeeping

  • Phase D.2b, Milestone M5 (parallelizable; NOT on the M1.5 critical path). The CLAUDE.md "Current state" line stays on M1.5.
  • Divergence register: in the commit that ships UiNineSlicePanel rendering a real dat sprite, delete row TS-30 (retail-divergence-register.md:166) — its cited file (UiPanel.cs) is upgraded by the subclass — and add one new IA-row (Intentional Architecture; keystone.dll has no PDB/decomp) for the markup/serialization layer. Assign the next sequential IA number at commit time. Retail oracle: "LayoutDesc 0x21xxxxxx; controls.ini panel-property vocabulary; keystone.dll layout evaluation (no PDB)". Do not duplicate IA-12 (UI toolkit behavioral approximation). A second row for the stopgap slice insets is added when they ship.
  • Spec file: this document.

15. Open gaps & deferred sub-projects

  • Input integration — connect UiHost.WireMouse/WireKeyboard to the Phase-K InputDispatcher, routing unconsumed WorldMouseFallThrough/WorldKeyFallThrough back to the game. Next sub-phase (lights up the close button + window drag that UiRoot already supports).
  • AcFont — dat A8 glyph loader (Font 0x40000xxxForegroundSurfaceDataId → RenderSurface, upload as R8 so ui_text.frag's .r-coverage branch works unchanged) → numeric overlays + retail fonts. (Today UiLabel uses the stb_truetype BitmapFont.)
  • Anchor solver — port StateDesc::UpdateSizeAndPosition; with the importer.
  • LayoutDesc binary importer (sub-project 3) — bulk-transpile retail layouts → our markup, supplying real insets + coords. Symbols: LayoutDesc::InqFullDesc @0x0069A520, ElementDesc::Incorporate @0x0069B5A0 (2026-05-08 pseudocode).
  • PFID_CUSTOM_RAW_JPEG decode + login/char-select/chargen (sub-project 4).

16. Acceptance criteria

  • Step 0 prove-out done; real chrome sprite IDs confirmed + recorded in code.
  • In ACDREAM_RETAIL_UI=1: a retail-shaped Vitals window renders via the wired UiHostUiNineSlicePanel 8-piece dat-sprite border + title + drawn close button — with three UiMeter bars tracking HP/Stam/Mana live as the character takes damage / regens.
  • In ACDREAM_DEVTOOLS=1: ImGui Vitals/Chat/Debug/Settings unchanged.
  • controls.ini loads when present, falls back cleanly when absent.
  • MarkupDocument builds the vitals subtree from vitals.xml; pure parsers unit-tested; plugin smoke test drains a buffered AddMarkupPanel.
  • TS-30 deleted + one new IA-row added, same commit as the chrome.
  • dotnet build green, dotnet test green.
  • Visual verification by the user.