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

349 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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](../../../docs/plans/2026-04-11-roadmap.md))
**Milestone:** M5 "Looks like retail" — **explicitly PARALLELIZABLE with M3/M4** ([milestones:378](../../../docs/plans/2026-05-12-milestones.md)). 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](../../../src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs) |
| 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](../../../docs/research/2026-06-09-dat-reader-thread-safety-investigation.md)). 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](../../../docs/research/named-retail/acclient.h)). 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](../../../src/AcDream.App/Rendering/TextureCache.cs),
[TextRenderer.cs](../../../src/AcDream.App/Rendering/TextRenderer.cs),
[Shader.cs](../../../src/AcDream.App/Rendering/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](../../../src/AcDream.App/Rendering/GameWindow.cs)),
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](../../../src/AcDream.UI.Abstractions/IPanelRenderer.cs)).
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](../../../src/AcDream.App/Rendering/Shaders/ui_text.vert)),
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](../../../src/AcDream.App/Rendering/Shaders/ui_text.frag)). ~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](../../../src/AcDream.App/Rendering/TextRenderer.cs)),
`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](../../../src/AcDream.App/Rendering/TextureCache.cs)).
The decode chain (Surface → SurfaceTexture → RenderSurface → RGBA8) and the
`PFID_*` formats (incl. `PFID_A8`) already work ([SurfaceDecoder.cs:39](../../../src/AcDream.Core/Textures/SurfaceDecoder.cs)).
**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](../../../docs/research/named-retail/acclient.h)):
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:
```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="#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](../../../src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs)).
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](../../../src/AcDream.App/Rendering/GameWindow.cs)) —
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](../../../src/AcDream.Plugin.Abstractions/IPluginHost.cs)). 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](../../../src/AcDream.Core/Plugins/PluginAssemblyLoadContext.cs)).
- 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 sprite** → `GetOrUpload` 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 absent** → `controls.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](../../../src/AcDream.App/Rendering/GameWindow.cs)). 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](../../../docs/architecture/retail-divergence-register.md))
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 `0x40000xxx``ForegroundSurfaceDataId`
→ 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](../../../docs/research/2026-05-08-retail-ui-layout-resolution-pseudocode.md)).
- **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.