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>
25 KiB
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 — packagesUiRoot+TextRenderer+ font, withTick/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 (~3–10 MB vs CEF's 150–300 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
UiHostintoGameWindow, gated by a newRuntimeOptions.RetailUitoggle (ACDREAM_RETAIL_UI=1). The ImGui devtools path is untouched and may run simultaneously. - Add dat-sprite drawing:
UiRenderContext.DrawSprite(UV-rect) + aTextRenderertextured-sprite path + aui_text.fraguUseTexture=2branch. - A
UiNineSlicePanel : UiPanelthat 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 : UiElementvital bar bound to aFunc<float>readingVitalsVM. - The XML markup format (mirrors
ElementDesc) + aMarkupDocumentparser that instantiates aUiElementsubtree + a minimalcontrols.inistylesheet loader. - The plugin-facing contract: plugins contribute a
UiElement/markup subtree added toUiRoot(§9) — designed now, first consumer first-party.
Deferred to later sub-phases (explicitly OUT):
- Wiring
UiHost's input (WireMouse/WireKeyboard) into the existing Phase-KInputDispatcher. TheUiRootinput machinery exists; integrating two input consumers (route unconsumedWorldMouseFallThroughback 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::UpdateSizeAndPositionport). - The
LayoutDescbinary 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+= auUseTexture==2branch:FragColor = texture(uTex, vUv) * vColor;(the existing0=solid and1=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 aFlushpass that, after rects+text, draws each texture's batch withuUseTexture=2. Reuses the existingAppendQuad(which already takesu0,v0,u1,v1) +UploadBuffermachinery.TextRenderer.Flush+= explicitDepthMask(false)(queried + restored) — it disablesDepthTesttoday but never setsDepthMask(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 toTextRenderer.DrawSprite(mirrors the existingDrawRectforwarder 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(inAcDream.Plugin.Abstractions) —void AddMarkupPanel(string markupPath, object binding)(and/orvoid AddElement(UiElement)once a plugin-safe element surface is decided). For Spec 1,AddMarkupPanelis enough. IPluginHostgainsIUiRegistry Ui { get; }(IPluginHost.cs:8 has none today);AppPluginHostimplements it (AppPluginHost.cs:5).- Because plugin
Enable()runs inProgram.csbefore the GL window opens (Program.cs:55-60),AddMarkupPanelbuffers registrations into a list thatGameWindowdrains intoUiRootafterUiHostis 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)
- Render-only first slice.
Tick+Drawonly; theUiHostinput wiring (WireMouse/WireKeyboard) is not connected to the existing Phase-KInputDispatcheryet, so the close button isn't clickable and the window isn't draggable. Rationale (corrected): theUiRootinput machinery already exists — what's deferred is integrating two input consumers (routing unconsumedWorldMouseFallThroughback to the game's dispatcher), which is its own sub-phase. controls.inioptional. AssumeC:\Turbine\Asheron's Call\may or may not exist. Add anACDREAM_AC_DIRRuntimeOptionsfield; 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 sprite →
GetOrUploadmagenta fallback is visible; Step 0 catches it. A null/zero DataID in markup logs a warning, draws nothing. - AC install absent →
controls.iniload skipped, baked fallback tokens used. - Vitals null percents → empty bar (
UiMeter.Fillreturns null). - Window resize →
UiHost.Drawalready setsRoot.Width/Heightto 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
ControlsIniparser (pure, no GL) — unit tests for#AARRGGBB,font://, cascade order. Lives insrc/AcDream.App/UI/, tested intests/AcDream.App.Tests/(App-layer, Rule 6).MarkupDocumentparser — unit tests for XML →UiElementtree shape (types, geometry) and{Binding}resolution against a fake binding object.UiMeterfill 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).UiNineSlicegeometry — unit test that a frame size + insets → the 9 dst rects (UiNineSlicePanel.ComputeSliceRectsstatic helper).- Plugin smoke — a test plugin calls
host.Ui.AddMarkupPaneland the buffered registration is drained (assert the panel is added toUiRoot). - Visual acceptance (user) — retail-shaped Vitals frame with live bars under
ACDREAM_RETAIL_UI=1; ImGui path unaffected underACDREAM_DEVTOOLS=1. dotnet build+dotnet testgreen.
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
UiNineSlicePanelrendering 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/WireKeyboardto the Phase-KInputDispatcher, routing unconsumedWorldMouseFallThrough/WorldKeyFallThroughback to the game. Next sub-phase (lights up the close button + window drag thatUiRootalready supports). AcFont— dat A8 glyph loader (Font0x40000xxx→ForegroundSurfaceDataId→ RenderSurface, upload as R8 soui_text.frag's.r-coverage branch works unchanged) → numeric overlays + retail fonts. (TodayUiLabeluses the stb_truetypeBitmapFont.)- Anchor solver — port
StateDesc::UpdateSizeAndPosition; with the importer. LayoutDescbinary 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_JPEGdecode + 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 wiredUiHost—UiNineSlicePanel8-piece dat-sprite border + title + drawn close button — with threeUiMeterbars tracking HP/Stam/Mana live as the character takes damage / regens. - In
ACDREAM_DEVTOOLS=1: ImGui Vitals/Chat/Debug/Settings unchanged. controls.iniloads when present, falls back cleanly when absent.MarkupDocumentbuilds the vitals subtree fromvitals.xml; pure parsers unit-tested; plugin smoke test drains a bufferedAddMarkupPanel.- TS-30 deleted + one new IA-row added, same commit as the chrome.
dotnet buildgreen,dotnet testgreen.- Visual verification by the user.