Brainstormed design for the D.2b retail-look UI backend: our own KSML-style markup + controls.ini stylesheet + retained-mode toolkit on Silk.NET (no embedded browser, zero external deps — Approach C, chosen over Ultralight/CEF and RmlUi for memory/dep-weight/faithfulness). Spec 1 scope: an 8-piece dat-sprite window frame + live Vitals bars bound to the existing VitalsVM, gated behind ACDREAM_RETAIL_UI=1, rendered via a reused TextRenderer batch. Render-only (input/hit-test, AcFont glyphs, anchor solver, LayoutDesc importer all deferred). Grounded by a read-only research workflow (7 readers + gap-critic). The critic corrected several stale memory/plan-doc facts now baked into the spec's do-not-trust list: VitalsVM is a sealed class (not the old record); chrome sprite IDs are unverified (Step-0 dat prove-out resolves them empirically); controls.ini exists and #FFDBD6A8 is editbox text not a bg; DatCollection reads are thread-safe; KSML is rich-text not the layout language (we mirror ElementDesc). Phase D.2b / Milestone M5 (parallelizable with M3/M4 — opened as a parallel track while M1.5 stays the active critical-path milestone). Retires divergence row TS-30 + adds one IA row when the chrome ships. Also gitignores the /.superpowers/ visual-companion scratch dir. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
22 KiB
D.2b — Retail panel frame + live Vitals (Approach C: KSML-style engine) — Design
Date: 2026-06-14
Status: Design approved (brainstorm), pending spec 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, 2026-06-14). Every binding fact below cites file:line in src/ or a named-retail symbol; nothing rests on a memory note alone.
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 replaces the visual layer with our own toolkit that draws
retail's actual dat assets and matches retail's look, while the stable
AcDream.UI.Abstractions contracts (ViewModels, Commands, IPanel) stay
unchanged underneath.
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" is not a foreign graft — it 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 distribution goal intact), lowest memory (~3–10 MB vs CEF's 150–300 MB), full control, and maximal architectural faithfulness — it mirrors Keystone directly. The cost (most code to write) is acceptable because the engine is ours forever and the plugin API (a day-1 core constraint) gets a clean markup authoring surface.
This spec covers Spec 1: the engine skeleton + the plugin-facing markup contract, proven end-to-end on one real panel — the universal window frame wrapping the live Vitals bars.
2. Scope
In Spec 1:
- A new retail-look backend in
src/AcDream.App/UI/Retail/implementingIPanelHost+IPanelRendererfromAcDream.UI.Abstractions. - The 8-piece dat-sprite window frame (4 corners + 4 edges + center fill), a title bar, and a drawn close button.
- Three live vital bars bound to the existing
VitalsVM. - The XML markup format (mirrors
ElementDesc) + a minimalcontrols.inistylesheet loader. - The plugin-facing contract:
IPanelRegistryonIPluginHost+ aMarkupPanelshim, so the engine is plugin-ready by construction. - A new
RuntimeOptions.RetailUitoggle (ACDREAM_RETAIL_UI=1).
Deferred to later sub-phases (explicitly OUT):
- Input / hit-testing (window drag, working close-click). Spec 1 is render-only.
- The dat A8 glyph font loader (
AcFont) → numeric overlays ("182/210"). - The full anchor solver (
StateDesc::UpdateSizeAndPositionport). - The
LayoutDescbinary importer (sub-project 3). - Reskinning Chat / Debug / Settings panels.
- Login / char-select / chargen screens (raw-JPEG backgrounds, sub-project 4).
- Extraction into a standalone
src/AcDream.UI.Retail/project (see §4).
3. What the grounding corrected (do-not-trust list)
The research caught several load-bearing "facts" that were wrong or unverified. These are binding:
| Claimed (memory / plan doc) | Reality (source-verified) |
|---|---|
VitalsVM is record VitalsVM(int HpCurrent, …) |
It is a 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. A second reader cited 0x06001125 etc. from a non-existent file; 0x06001125 is actually 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 the [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 (1.1M-read hammer test, 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, not KSML. |
4. Architecture & placement
The docs name a future src/AcDream.UI.Retail/ project, but the three pieces we
must reuse — TextureCache, TextRenderer, Shader — all live in
AcDream.App (TextureCache.cs:70,
TextRenderer.cs,
Shader.cs). A separate project
cannot reach them without first extracting a shared rendering-primitives project
(a large, unrelated refactor). Unlike AcDream.UI.ImGui (which needs only the
ImGui packages), the retail backend needs dat sprites, which are App-resident.
Decision: Spec 1 builds the backend in src/AcDream.App/UI/Retail/ as
dedicated classes. This honors Code-Structure Rule 1 (nothing substantial added
to GameWindow.cs's body — only a few wiring lines), Rule 2 (Core stays
GL-free), and Rule 3 (panels still target AcDream.UI.Abstractions; the backend
implements the host/renderer contracts). The clean AcDream.UI.Retail project
extraction is a follow-up, gated on a rendering-primitives home existing.
┌──────────────────────────────────────────────────────────┐
│ retail dat (read-only fidelity source) │
│ controls.ini → style tokens · RenderSurface 0x06xxxxxx │
│ → sprites · Font 0x40xxxxxx → glyphs (deferred) │
└───────────────┬──────────────────────────────────────────┘
│ assets via TextureCache.GetOrUpload
┌───────────────▼──────────────────────────────────────────┐
│ NEW: src/AcDream.App/UI/Retail/ │
│ RetailPanelHost : IPanelHost │
│ RetailPanelRenderer : IPanelRenderer (+ chrome) │
│ UiSpriteBatch (wraps TextRenderer + UV-rect quads) │
│ NineSlice (8 pieces + center) · ControlsIni (parser) │
│ MarkupDocument (XML → ElementDesc-shaped tree) │
└───────────────┬──────────────────────────────────────────┘
│ VMs out / Commands in (unchanged)
┌───────────────▼──────────────────────────────────────────┐
│ AcDream.UI.Abstractions (exists) — IPanel/IPanelHost/ │
│ IPanelRenderer/ICommandBus/PanelContext/VitalsVM │
└───────────────┬──────────────────────────────────────────┘
│
┌───────────────▼──────────────────────────────────────────┐
│ game state (unchanged) — CombatState, LocalPlayerState │
└──────────────────────────────────────────────────────────┘
Coexistence with ImGui. The retail pass renders in the same post-3D slot as
ImGui's Render() (GameWindow.cs:8232),
with deterministic ordering. ACDREAM_RETAIL_UI=1 activates the retail Vitals
panel; ACDREAM_DEVTOOLS=1 keeps the ImGui overlay (Chat/Debug/Settings) working
with no regression. Both may be on at once during development.
5. Render foundation — reuse, don't rebuild
IPanelRenderer is a 34-method, ImGui-shaped immediate-mode API; Begin(string title) carries no position/size/sprite/style (IPanelRenderer.cs:23).
It is structurally incompatible with positioned, chrome-decorated retail
windows, so the markup engine does not route chrome through it. Instead:
UiSpriteBatchwrapsTextRenderer— which already provides pixel→NDC conversion (ui_text.vert:12), dynamic VBO growth, and a save/restore pattern. We add a source-UV-rect parameter to its quad path so one sprite can be sliced into 8 border pieces.- Extend
ui_text.fragwithuUseTexture=2(RGBA sampling) for dat sprites; it currently does only solid-color (0) and R8 coverage (1) (ui_text.frag:9). ~3-line edit. - Use the simple
Shaderclass, notGLSLShader— no bindless promotion, uniform cache, orQueueGLActionteardown is needed for a synchronous main-thread 2D pass. - Self-contained GL state (project rule [feedback_render_self_contained_gl_state]):
the pass explicitly sets blend (
SrcAlpha/OneMinusSrcAlpha),DepthTestoff,DepthMask(false)(TextRenderer omits this today — TextRenderer.cs:171),CullFaceoff, scissor — and restores them. It must not inherit state from the 3D pass or ImGui.
6. Dat assets & the Step-0 prove-out gate
TextureCache.GetOrUpload(uint surfaceId) returns a conventional Texture2D
handle (not the bindless Texture2DArray the world MDI path uses) — exactly
right for the UI batch (TextureCache.cs:70).
The decode chain (Surface → SurfaceTexture → RenderSurface → RGBA8) and the
PFID_* formats (incl. PFID_A8) already work (SurfaceDecoder.cs:39).
Step 0 is empirical and comes first. Because no chrome sprite ID is verified,
the first implementation task loads each candidate ID (0x06004CC2,
0x060074BF..C6, 0x0600129C, …) via GetOrUpload, draws each 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 the spec's follow-up and in code
comments before any layout code is written. No ID is hardcoded on faith.
The frame is 8 quads + a center fill, not one stretched 9-slice texture: 4
corner sprites, 4 edge sprites (tiled or stretched along their axis), and a
separate center-fill sprite. Slice/edge metrics are a documented stopgap
constant (with a divergence row) until the LayoutDesc tree is parsed
(sub-project 3) to supply the real insets.
7. Markup + stylesheet model
Markup mirrors ElementDesc 1:1 (acclient.h:33693):
an element has a type, id, x/y/w/h/z, the four anchor edge-codes, a
defaultState, and a media list (sprite DataIDs). Example 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="#10F0F0"/>
<meter id="mana" x="8" y="64" w="200" h="13" fill="{ManaPercent}" color="#0000FF"/>
</panel>
This is deliberately the shape the future LayoutDesc importer will emit, so
the authoring format and the imported format converge. It is not KSML —
KSML is reserved for rich-text content inside text regions (deferred).
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, so Spec 1 places
it at fixed pixel coords. Building the full solver now would be gold-plating
(gap-critic risk #7).
Stylesheet. A small INI loader parses controls.ini keyed by element-type
section, honoring the #AARRGGBB color format (alpha-first) and the
font://Face-Pt[-style] font URI. The cascade is: element-type defaults from
ini → per-element class= section → inline attributes. controls.ini is
optional (see §10): if the AC install is absent, the real [title]/[body]
token values are baked as fallback.
8. VM binding (the Vitals slice)
Bind to the real VitalsVM — HealthPercent / StaminaPercent /
ManaPercent (VitalsVM.cs:67).
The VM already does all server plumbing (CombatState + LocalPlayerState, updated
from the wire), so we do not re-derive vitals from the retail
gmVitalsUI/CACQualities decomp.
Each bar uses the retail scissor-fill technique: draw the empty background
rect, set scissor to the bottom pct * height pixels, draw the filled rect.
Colors Health #FF0000, Stamina #10F0F0, Mana #0000FF. This uses only the
solid-color shader path (uUseTexture=0) — no dat font needed. The
StaminaPercent/ManaPercent nullable case (null until PlayerDescription
arrives) renders an empty bar.
The Vitals panel is constructed and registered the same way as today — built in
the live-session path and given the player GUID at EnterWorld via
SetLocalPlayerGuid (GameWindow.cs:1984) —
but registered into RetailPanelHost instead of ImGuiPanelHost when
ACDREAM_RETAIL_UI=1.
9. Plugin contract (designed now, first consumer first-party)
IPluginHost exposes only Log/State/Events today — no UI surface
(IPluginHost.cs:9). Spec 1
adds:
IPanelRegistry Panels { get; }onIPluginHost— a one-methodvoid Register(IPanel)wrapper overIPanelHost.Register(does not exposeRenderAllto plugins).- A
MarkupPanel(string id, string title, string markupPath, object binding)IPanelimplementation: owns a parsedMarkupDocument+ a binding object whose properties the{Binding}expressions resolve against. - ALC note: if
AcDream.UI.Abstractionstypes cross the plugin boundary, add it to the host-shared exclusion set alongsideAcDream.Plugin.Abstractions(PluginAssemblyLoadContext.cs:13). - Registrations from
IAcDreamPlugin.Enable()(main thread, before the GL window opens) buffer into a list the host drains intoRetailPanelHostafter init — the threading concern lives in the host, the plugin call is unconditional.
The first consumer is the first-party Vitals panel, but the contract lands here so the markup format is designed against a real plugin path rather than retrofitted. Wiring an actual plugin-supplied panel end-to-end is a thin follow-up.
10. Confirmed decisions (approved 2026-06-14)
- Render-only first slice. Frame + live (updating) bars; the close button is
drawn-not-clickable and the window is not draggable. Input/hit-testing is its
own sub-phase — neither
IPanelHostnorIPanelRenderercarries a hit-test or bounds contract today, and building it up front is scope creep. controls.inioptional. AssumeC:\Turbine\Asheron's Call\may or may not exist. Add anACDREAM_AC_DIRRuntimeOptionsfield for when it's present; when absent, fall back to the source-verified[title]/[body]token values. The build never fails on a missing AC install. (Chrome is sprite-based, socontrols.iniis barely load-bearing for Spec 1 anyway.)
11. Build sequence
| Step | Deliverable | Proves |
|---|---|---|
| 0 | Dat prove-out: load candidate chrome IDs, render raw quads, confirm real IDs | Resolves the chrome-ID contradiction empirically |
| 1 | One decoded dat sprite drawn at fixed coords (shader uUseTexture=2, self-contained GL state) |
A dat sprite composites correctly over the 3D scene |
| 2 | 8-piece border + center → an empty titled frame (UV-rect quads, stopgap insets) | The frame renders |
| 3 | Three scissor-fill bars bound to real VitalsVM (solid-color path) |
End-to-end data binding, no font needed |
| 4 | RetailPanelHost wired into the frame loop, gated by RuntimeOptions.RetailUi; ImGui unaffected |
Backend slots under the seam; no ACDREAM_DEVTOOLS regression |
| 5 (deferred) | AcFont dat-glyph loader → numeric overlays |
Only if numbers are wanted in this slice |
12. Error handling & edge cases
- Missing/undecodable sprite →
GetOrUploadmagenta fallback is visible by design; Step 0 catches it. A null/zero DataID in markup logs a warning and draws nothing (no throw). - AC install absent →
controls.iniload is skipped, baked fallback tokens used (no throw). - Vitals null percents (pre-
PlayerDescription) → empty bar. - Window resize → fixed-coord placement re-clamps to stay on-screen via the
existing
OnFramebufferResizepanel-layout reset (GameWindow.cs:10375). No DPI scaling (a known, out-of-scope gap —_window.Sizeis treated as framebuffer size). - Both toggles on → both UIs render; the retail Vitals and the ImGui Vitals may both show (acceptable in dev).
13. Testing
ControlsIniparser (pure, no GL) — unit tests for#AARRGGBBparsing,font://URI parsing, the cascade order. Since the parsers live insrc/AcDream.App/UI/Retail/(per §4), their tests go intests/AcDream.App.Tests/(App-layer, Rule 6). If/when the backend is extracted to a standaloneAcDream.UI.Retailproject, the tests move with it totests/AcDream.UI.Retail.Tests/(registered inAcDream.slnx).MarkupDocumentparser — unit tests for the XML → element-tree mapping and{Binding}resolution against a fake binding object.NineSlicegeometry — unit test that 8 pieces + center tile to the right rects for a given frame size + insets.- Visual acceptance (user) — the Vitals frame renders retail-shaped with live
bars in
ACDREAM_RETAIL_UI=1; ImGui panels unaffected inACDREAM_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 — this is a parallel track, not a milestone flip.
- Divergence register: in the commit that ships the first real dat-sprite chrome, delete row TS-30 (retail-divergence-register.md:166) and add one new IA-row (Intentional Architecture — keystone.dll has no PDB/decomp, a byte-port is impossible by definition) 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 (which already covers the UI toolkit's behavioral approximation). A second row for the stopgap slice insets is added if/when they ship.
- Spec file: this document,
docs/superpowers/specs/2026-06-14-d2b-retail-panel-frame-design.md.
15. Open gaps & deferred sub-projects
- Input/hit-testing contract — neither
IPanelHostnorIPanelreports bounds; required before drag/close-click. Next sub-phase. AcFont— dat A8 glyph loader (Font0x40000xxx→ForegroundSurfaceDataId→ RenderSurface, upload as R8 soui_text.frag's.r-coverage branch works unchanged). Sub-phase for numeric overlays.- Anchor solver — port
StateDesc::UpdateSizeAndPosition. With theLayoutDescimporter. LayoutDescbinary importer (sub-project 3) — bulk-transpile retail's layouts → our markup, supplying real slice insets + coords. Resolver symbols:LayoutDesc::InqFullDesc@0x0069A520,ElementDesc::Incorporate@0x0069B5A0(algorithm captured in 2026-05-08 pseudocode).- Standalone
AcDream.UI.Retailproject — after a rendering-primitives home is extracted. 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
ACDREAM_RETAIL_UI=1: a retail-shaped Vitals window renders with an 8-piece dat-sprite border + title bar + drawn close button, and three scissor-fill bars that track HP/Stam/Mana live as the character takes damage / regens. - In
ACDREAM_DEVTOOLS=1: ImGui Vitals/Chat/Debug/Settings unchanged (no regression). controls.iniloads when present, falls back cleanly when absent.IPanelRegistryonIPluginHost; aMarkupPanelexists and is unit-tested against a fake binding.- TS-30 deleted + one new IA-row added, same commit as the chrome.
dotnet buildgreen,dotnet testgreen.- Visual verification by the user.