From b18403da028b0d2ac188bf2337b509cb9e72d236 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 16:56:57 +0200 Subject: [PATCH] feat(D.2b): wire UiHost + live retail Vitals panel (render-only); retire TS-30 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../retail-divergence-register.md | 6 +- ...026-06-14-d2b-retail-panel-frame-design.md | 17 +++-- src/AcDream.App/Rendering/GameWindow.cs | 65 +++++++++++++++++++ src/AcDream.App/UI/UiMeter.cs | 29 +++++++-- 4 files changed, 102 insertions(+), 15 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 91bde7ea..5a7c7b05 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -37,7 +37,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 1. Intentional architecture (IA) — 14 rows +## 1. Intentional architecture (IA) — 15 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -55,6 +55,7 @@ accepted-divergence entries (#96, #49, #50). | IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms) | `src/AcDream.App/UI/README.md:3` | keystone.dll has no PDB/decomp; semantics reconstructed from the six `docs/research/retail-ui/` deep-dives, keeping retail's event-type constants so panel switch-cases transplant cleanly | Edge-case input semantics the research under-specified (drag threshold, tooltip timing, focus hand-off, capture corners) differ silently with no oracle to diff against | keystone.dll Device DAT_00837ff4; docs/research/retail-ui/04-input-events.md | | IA-13 | GameEventType registry deliberately omits event types retail ignores; unknown events fall through unhandled | `src/AcDream.Core.Net/Messages/GameEventType.cs:11` | Retail also ignores them — dropping matches retail by construction | If the "retail ignores X" judgment is wrong for any opcode (or a server mod uses one), the event is silently dropped with no diagnostic pointing at the omission | retail GameEvent dispatch (ignored-event set) | | IA-14 | Rendering + dat-handling base is WorldBuilder's tested port, not a fresh retail-decomp port (Phase N.4/O design stance) | `docs/architecture/worldbuilder-inventory.md` (code at `src/AcDream.{Core,App}/Rendering/Wb/`) | WB visually verified on the AC world, MIT, same stack; known WB↔retail deltas resolved case-by-case — terrain split kept retail `FSplitNESW` (**#51**, pinned by `SplitFormulaDivergenceTest`), scenery drift accepted (AP-31) | A WB-upstream divergence not yet caught ships silently as "our" behavior; guard = the inventory doc's 🟢/🔴 split + per-formula divergence tests | retail decomp per algorithm; `tests/.../SplitFormulaDivergenceTest.cs` | +| IA-15 | D.2b retail UI is our own UiHost/UiElement retained-mode tree drawing an 8-piece dat-sprite window frame (later: XML markup + controls.ini stylesheet), not a byte-port of keystone.dll's LayoutDesc binary tree | `src/AcDream.App/UI/UiNineSlicePanel.cs` + `RetailChromeSprites.cs` | keystone.dll has no PDB/decomp so a byte-port is impossible by definition; we mirror retail's ElementDesc field model + controls.ini tokens, and the chrome sprites ARE the real dat RenderSurfaces (Step-0 prove-out 2026-06-14 confirmed 0x06004CC2 center + 0x060074BF..C6 bevel) | The 8-piece edge/corner→position mapping is a guess until the LayoutDesc 0x21000040 parse; anchor resolution at non-800x600 + controls.ini cascade corners differ silently with no oracle | LayoutDesc 0x21000040; controls.ini tokens; keystone.dll layout eval (no PDB) | --- @@ -130,7 +131,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 4. Temporary stopgap (TS) — 30 rows +## 4. Temporary stopgap (TS) — 29 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -163,7 +164,6 @@ accepted-divergence entries (#96, #49, #50). | TS-27 | Retransmit handling absent: `RetransmitRequests`/`RejectRetransmit` parsed, but nothing re-sends lost outbound or requests missing inbound sequences (class-doc gap list otherwise stale — ack/position/chat exist) | `src/AcDream.Core.Net/WorldSession.cs:29` | Deferred since the one-shot test harness; dev loop is loopback (no loss) | On any lossy link a dropped fragment is gone forever — entities never spawn, chat vanishes, reassembly stalls; server retransmit requests ignored until session timeout. Stale doc list also misleads readers | PacketHeaderFlags RequestRetransmit 0x1000 / Retransmission 0x1 | | TS-28 | LoginComplete sent on PlayerCreate (0xF746) arrival; retail sends it after the portal-space transition animation finishes (no such animation exists yet) | `src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs:30` | acdream has no portal-space animation; "InWorld" phrasing in the file is slightly stale (trigger is PlayerCreate) | Server flips the character out of the loading state and pushes initial updates while the client may still be streaming — server logic assuming retail's load-screen duration fires against a half-initialized client | retail post-EnterWorld flow (holtburger messages.rs:391-422) | | TS-29 | Background music (MIDI) + ambient loops not ported: PlayMusic/StopMusic no-op; StartAmbient reserves a handle that never plays | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:331` | Explicitly outside R5 audio-phase scope; a landblock-attached ambient system is planned separately | Silent world where retail has music/atmosphere; code trusting StartAmbient's handle to mean "playing" is already subtly wrong (StopAmbient looks up a never-created source) | retail MIDI + ambient system (r05) | -| TS-30 | UI panels drawn as flat translucent rectangles + 1 px border; retail composes 9-slice dat sprite backgrounds via LayoutDesc trees | `src/AcDream.App/UI/UiPanel.cs:10` | Development visibility until the D.2b retail-look toolkit consumes the dat assets | Purely visual until D.2b — but pixel-position assumptions built against the placeholder (hit regions, layout constants) may not survive the swap to retail sprite metrics | RenderSurface 0x06xxxxxx 9-slice; LayoutDesc 0x21xxxxxx | --- diff --git a/docs/superpowers/specs/2026-06-14-d2b-retail-panel-frame-design.md b/docs/superpowers/specs/2026-06-14-d2b-retail-panel-frame-design.md index 8ee41349..70b8e20f 100644 --- a/docs/superpowers/specs/2026-06-14-d2b-retail-panel-frame-design.md +++ b/docs/superpowers/specs/2026-06-14-d2b-retail-panel-frame-design.md @@ -220,7 +220,7 @@ constant** (with a divergence row) until the `LayoutDesc` tree is parsed ```xml - + ``` @@ -249,12 +249,15 @@ real `VitalsVM` ([VitalsVM.cs:67](../../../src/AcDream.UI.Abstractions/Panels/Vi 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`) then the filled portion as a -**partial-size rect** (`width = pct * Width`) in the bar color — Health `#FF0000`, -Stamina `#10F0F0`, Mana `#0000FF`. (For rectangular solid bars this is equivalent -to retail's orb scissor-fill and avoids per-quad scissor state inside the batch; -scissor/UV-crop comes when the actual orb *sprite* is drawn, later.) A `null` -fill (stamina/mana pre-`PlayerDescription`) draws an empty bar. +`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 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, diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 59f0f83c..efa13627 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -612,6 +612,8 @@ public sealed class GameWindow : IDisposable // when no selection. Spec: docs/superpowers/specs/2026-05-15-phase-b7-target-indicator-design.md private AcDream.App.UI.TargetIndicatorPanel? _targetIndicator; private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm; + // Phase D.2b — retail-look UI tree (dormant UiHost wired here). Null unless ACDREAM_RETAIL_UI=1. + private AcDream.App.UI.UiHost? _uiHost; // Phase I.2: ImGui debug panel ViewModel. Lives for as long as // _panelHost does. Self-subscribes to CombatState in its ctor, so // disposing isn't required (panel host holds the only ref). @@ -1729,6 +1731,58 @@ public sealed class GameWindow : IDisposable // references/WorldBuilder/Chorizite.OpenGLSDLBackend/OpenGLGraphicsDevice.cs:115-132. _samplerCache = new SamplerCache(_gl); + // Phase D.2b — retail-look UI (ACDREAM_RETAIL_UI=1). Wires the existing + // UiHost retained-mode tree (dormant until now) + a first vitals panel. + // Render-only: UiHost input is NOT yet bridged to the InputDispatcher + // (next sub-phase), so the close button + window drag are inert. Coexists + // with the ImGui devtools path (ACDREAM_DEVTOOLS=1), which is unchanged. + if (_options.RetailUi) + { + _vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer); + _uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont); + + var cache = _textureCache!; + (uint, int, int) ResolveChrome(uint id) + { + uint t = cache.GetOrUploadRenderSurface(id, out int w, out int h); + return (t, w, h); + } + + var panel = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome) + { Left = 10, Top = 30, Width = 220, Height = 96 }; + panel.AddChild(new AcDream.App.UI.UiLabel + { + Text = "Vitals", Left = 8, Top = 4, + TextColor = new System.Numerics.Vector4(1f, 1f, 1f, 1f), + }); + + var vm = _vitalsVm!; + panel.AddChild(new AcDream.App.UI.UiMeter + { + Left = 8, Top = 24, Width = 200, Height = 14, + BarColor = new System.Numerics.Vector4(0.78f, 0.05f, 0.05f, 1f), // health red + Fill = () => vm.HealthPercent, + Label = () => (vm.HealthCurrent, vm.HealthMax) is (uint c, uint m) ? $"{c}/{m}" : null, + }); + panel.AddChild(new AcDream.App.UI.UiMeter + { + Left = 8, Top = 44, Width = 200, Height = 14, + BarColor = new System.Numerics.Vector4(0.83f, 0.62f, 0.12f, 1f), // stamina gold (retail; not cyan) + Fill = () => vm.StaminaPercent, + Label = () => (vm.StaminaCurrent, vm.StaminaMax) is (uint c, uint m) ? $"{c}/{m}" : null, + }); + panel.AddChild(new AcDream.App.UI.UiMeter + { + Left = 8, Top = 64, Width = 200, Height = 14, + BarColor = new System.Numerics.Vector4(0.12f, 0.20f, 0.85f, 1f), // mana blue + Fill = () => vm.ManaPercent, + Label = () => (vm.ManaCurrent, vm.ManaMax) is (uint c, uint m) ? $"{c}/{m}" : null, + }); + + _uiHost.Root.AddChild(panel); + Console.WriteLine("[D.2b] retail UI active — vitals panel wired (render-only)."); + } + // Phase N.4+N.5 — WB rendering pipeline foundation. The modern path is // mandatory as of N.5 ship amendment: WbMeshAdapter + WbDrawDispatcher // always construct. @@ -8150,6 +8204,16 @@ public sealed class GameWindow : IDisposable SkipWorldGeometry: ; } + // Phase D.2b — retail-look UI tree (render-only; input integration deferred). + // Self-contained 2D pass: UiHost.Draw → TextRenderer.Flush sets its own + // blend/depth state and restores. Drawn before ImGui so the devtools + // overlay composites on top during development. + if (_options.RetailUi && _uiHost is not null) + { + _uiHost.Tick(deltaSeconds); + _uiHost.Draw(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y)); + } + // Phase D.2a — end ImGui frame. Runs AFTER all scene + debug draws // so ImGui composites on top. ImGuiController save/restores the // GL state it touches (blend, scissor, VAO, shader, texture); any @@ -12040,6 +12104,7 @@ public sealed class GameWindow : IDisposable _sceneLightingUbo?.Dispose(); _particleRenderer?.Dispose(); _debugLines?.Dispose(); + _uiHost?.Dispose(); _textRenderer?.Dispose(); _debugFont?.Dispose(); _dats?.Dispose(); diff --git a/src/AcDream.App/UI/UiMeter.cs b/src/AcDream.App/UI/UiMeter.cs index 9fcdd5d6..ef2883c2 100644 --- a/src/AcDream.App/UI/UiMeter.cs +++ b/src/AcDream.App/UI/UiMeter.cs @@ -3,17 +3,26 @@ using System.Numerics; namespace AcDream.App.UI; /// -/// A horizontal vital bar: an empty background rect with a partial-width -/// fill. returns 0..1 (or null = no data → empty bar). -/// Solid-color for Spec 1; the retail orb sprite + scissor crop is a later -/// sub-phase. +/// A horizontal vital bar (retail HP/Stamina/Mana style): a background rect, a +/// partial-width solid fill, and an optional centered "current/max" numeric +/// overlay. returns 0..1 (null = no data → empty bar); +/// returns the overlay text (null = no number). +/// +/// +/// Solid-color fill + debug font for Spec 1. The retail gradient bar sprite +/// (glassy center highlight) and the retail dat font are a later polish pass — +/// retail's vitals are bars exactly like this, just sprited. +/// /// public sealed class UiMeter : UiElement { /// Fill fraction provider; a null result draws an empty bar. public Func Fill { get; set; } = () => 0f; + /// Centered overlay text provider (e.g. "291/291"); null = none. + public Func Label { get; set; } = () => null; public Vector4 BarColor { get; set; } = new(1f, 0f, 0f, 1f); - public Vector4 BgColor { get; set; } = new(0f, 0f, 0f, 0.5f); + public Vector4 BgColor { get; set; } = new(0f, 0f, 0f, 0.5f); + public Vector4 LabelColor { get; set; } = new(1f, 1f, 1f, 1f); public UiMeter() { ClickThrough = true; } @@ -30,11 +39,21 @@ public sealed class UiMeter : UiElement protected override void OnDraw(UiRenderContext ctx) { ctx.DrawRect(0, 0, Width, Height, BgColor); + float? pct = Fill(); if (pct is float p) { var (fx, fy, fw, fh) = ComputeFillRect(p, Width, Height); if (fw > 0f) ctx.DrawRect(fx, fy, fw, fh, BarColor); } + + string? label = Label(); + if (!string.IsNullOrEmpty(label) && ctx.DefaultFont is { } font) + { + float tw = font.MeasureWidth(label); + float tx = (Width - tw) * 0.5f; + float ty = (Height - font.LineHeight) * 0.5f; + ctx.DrawString(label, tx, ty, LabelColor); + } } }