acdream/docs/superpowers/specs/2026-06-14-d2b-retail-panel-frame-design.md
Erik de9229eed5 docs(D.2b): design spec — retail panel frame + live Vitals (Approach C)
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>
2026-06-14 14:00:14 +02:00

22 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), 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 (~310 MB vs CEF's 150300 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/ implementing IPanelHost + IPanelRenderer from AcDream.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 minimal controls.ini stylesheet loader.
  • The plugin-facing contract: IPanelRegistry on IPluginHost + a MarkupPanel shim, so the engine is plugin-ready by construction.
  • A new RuntimeOptions.RetailUi toggle (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::UpdateSizeAndPosition port).
  • The LayoutDesc binary 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:

  • UiSpriteBatch wraps TextRenderer — 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.frag with uUseTexture=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 Shader class, not GLSLShader — no bindless promotion, uniform cache, or QueueGLAction teardown 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), DepthTest off, DepthMask(false) (TextRenderer omits this today — TextRenderer.cs:171), CullFace off, 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 VitalsVMHealthPercent / 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; } on IPluginHost — a one-method void Register(IPanel) wrapper over IPanelHost.Register (does not expose RenderAll to plugins).
  • A MarkupPanel(string id, string title, string markupPath, object binding) IPanel implementation: owns a parsed MarkupDocument + a binding object whose properties the {Binding} expressions resolve against.
  • ALC note: if AcDream.UI.Abstractions types cross the plugin boundary, add it to the host-shared exclusion set alongside AcDream.Plugin.Abstractions (PluginAssemblyLoadContext.cs:13).
  • Registrations from IAcDreamPlugin.Enable() (main thread, before the GL window opens) buffer into a list the host drains into RetailPanelHost after 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)

  1. 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 IPanelHost nor IPanelRenderer carries a hit-test or bounds contract today, and building it up front is scope creep.
  2. controls.ini optional. Assume C:\Turbine\Asheron's Call\ may or may not exist. Add an ACDREAM_AC_DIR RuntimeOptions field 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, so controls.ini is 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 spriteGetOrUpload magenta 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 absentcontrols.ini load 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 OnFramebufferResize panel-layout reset (GameWindow.cs:10375). No DPI scaling (a known, out-of-scope gap — _window.Size is 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

  • ControlsIni parser (pure, no GL) — unit tests for #AARRGGBB parsing, font:// URI parsing, the cascade order. Since the parsers live in src/AcDream.App/UI/Retail/ (per §4), their tests go in tests/AcDream.App.Tests/ (App-layer, Rule 6). If/when the backend is extracted to a standalone AcDream.UI.Retail project, the tests move with it to tests/AcDream.UI.Retail.Tests/ (registered in AcDream.slnx).
  • MarkupDocument parser — unit tests for the XML → element-tree mapping and {Binding} resolution against a fake binding object.
  • NineSlice geometry — 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 in 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 — 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 IPanelHost nor IPanel reports bounds; required before drag/close-click. Next sub-phase.
  • AcFont — dat A8 glyph loader (Font 0x40000xxxForegroundSurfaceDataId → RenderSurface, upload as R8 so ui_text.frag's .r-coverage branch works unchanged). Sub-phase for numeric overlays.
  • Anchor solver — port StateDesc::UpdateSizeAndPosition. With the LayoutDesc importer.
  • LayoutDesc binary 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.Retail project — after a rendering-primitives home is extracted.
  • 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 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.ini loads when present, falls back cleanly when absent.
  • IPanelRegistry on IPluginHost; a MarkupPanel exists and is unit-tested against a fake binding.
  • TS-30 deleted + one new IA-row added, same commit as the chrome.
  • dotnet build green, dotnet test green.
  • Visual verification by the user.