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>
392 lines
25 KiB
Markdown
392 lines
25 KiB
Markdown
# 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](../../../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) + 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](../../../docs/plans/2026-04-11-roadmap.md)):
|
||
|
||
- **`UiRoot`** ([UiRoot.cs](../../../src/AcDream.App/UI/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](../../../src/AcDream.App/UI/UiEvent.cs).
|
||
- **`UiElement`** (geometry/tree/hit-test), **`UiPanel`/`UiLabel`/`UiButton`**
|
||
([UiPanel.cs](../../../src/AcDream.App/UI/UiPanel.cs)), **`UiHost`**
|
||
([UiHost.cs](../../../src/AcDream.App/UI/UiHost.cs) — packages `UiRoot` +
|
||
`TextRenderer` + font, with `Tick`/`Draw`/`WireMouse`/`WireKeyboard`),
|
||
**`UiRenderContext`** ([UiRenderContext.cs](../../../src/AcDream.App/UI/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 **`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](../../../src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs) |
|
||
| 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](../../../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`. |
|
||
|
||
## 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](../../../src/AcDream.App/Rendering/GameWindow.cs))
|
||
with deterministic ordering relative to ImGui. `UiHost.Draw` already does
|
||
`TextRenderer.Begin → Root.Draw(ctx) → TextRenderer.Flush`
|
||
([UiHost.cs:58](../../../src/AcDream.App/UI/UiHost.cs)).
|
||
|
||
## 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](../../../src/AcDream.App/UI/UiHost.cs)). That
|
||
`TextRenderer` does solid rects + R8 text today but **not** textured RGBA sprites
|
||
([ui_text.frag:9](../../../src/AcDream.App/Rendering/Shaders/ui_text.frag),
|
||
[TextRenderer.cs](../../../src/AcDream.App/Rendering/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](../../../src/AcDream.App/Rendering/TextRenderer.cs)).
|
||
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](../../../src/AcDream.App/UI/UiRenderContext.cs)).
|
||
|
||
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](../../../src/AcDream.App/Rendering/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](../../../src/AcDream.App/Rendering/TextureCache.cs)). The
|
||
decode chain + `PFID_*` formats already work
|
||
([SurfaceDecoder.cs:39](../../../src/AcDream.Core/Textures/SurfaceDecoder.cs)).
|
||
`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](../../../docs/research/named-retail/acclient.h));
|
||
`MarkupDocument` parses it and **instantiates a `UiElement` subtree** (a
|
||
`UiNineSlicePanel` with child `UiLabel`/`UiMeter`/`UiButton`). 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="#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 `UiMeter`s. Each `UiMeter` holds a `Func<float?> Fill` bound to the
|
||
real `VitalsVM` ([VitalsVM.cs:67](../../../src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs)):
|
||
`() => 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](../../../src/AcDream.App/Rendering/GameWindow.cs) 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](../../../src/AcDream.Plugin.Abstractions/IPluginHost.cs)
|
||
has none today); `AppPluginHost` implements it
|
||
([AppPluginHost.cs:5](../../../src/AcDream.App/Plugins/AppPluginHost.cs)).
|
||
- Because plugin `Enable()` runs in `Program.cs` **before** the GL window opens
|
||
([Program.cs:55-60](../../../src/AcDream.App/Program.cs)), `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 sprite** → `GetOrUpload` magenta fallback is visible;
|
||
Step 0 catches it. A null/zero DataID in markup logs a warning, draws nothing.
|
||
- **AC install absent** → `controls.ini` load skipped, baked fallback tokens used.
|
||
- **Vitals null percents** → empty bar (`UiMeter.Fill` returns null).
|
||
- **Window resize** → `UiHost.Draw` already sets `Root.Width/Height` to the
|
||
current screen size each frame ([UiHost.cs:61](../../../src/AcDream.App/UI/UiHost.cs));
|
||
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](../../../docs/architecture/retail-divergence-register.md))
|
||
— 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 `0x40000xxx` → `ForegroundSurfaceDataId`
|
||
→ 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](../../../docs/research/2026-05-08-retail-ui-layout-resolution-pseudocode.md)).
|
||
- **`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
|
||
`UiHost` — `UiNineSlicePanel` 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.
|