From de9229eed5b5e3d636d44155d06588838c161ca9 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 14:00:14 +0200 Subject: [PATCH 01/99] =?UTF-8?q?docs(D.2b):=20design=20spec=20=E2=80=94?= =?UTF-8?q?=20retail=20panel=20frame=20+=20live=20Vitals=20(Approach=20C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 2 + ...026-06-14-d2b-retail-panel-frame-design.md | 349 ++++++++++++++++++ 2 files changed, 351 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-14-d2b-retail-panel-frame-design.md diff --git a/.gitignore b/.gitignore index 357fded9..ca2f9cf2 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,8 @@ references/* # Claude Code session state .claude/ +# Superpowers brainstorm visual-companion scratch (mockups regenerate; not source) +/.superpowers/ launch.log launch-*.log launch.utf8.log 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 new file mode 100644 index 00000000..4884b0ff --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-d2b-retail-panel-frame-design.md @@ -0,0 +1,349 @@ +# 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 (~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/` 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 + + + + + +``` + +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. From d50023f6d962c478493b80c6bf17da071b0b38a8 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 14:13:30 +0200 Subject: [PATCH 02/99] docs(D.2b): re-ground spec onto existing AcDream.App/UI scaffold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A direct read of src/AcDream.App/UI/ found a complete (dormant) retained-mode toolkit the grounding workflow missed: UiRoot (input routing, focus, capture, drag-drop, tooltip, click detection, world fall-through), UiElement, UiPanel/UiLabel/UiButton, UiHost (Tick/Draw + WireMouse/WireKeyboard), UiRenderContext, retail-faithful UiEvent codes. It's never wired into GameWindow, and UiPanel.cs is the exact file divergence row TS-30 cites. So the retail UI is this existing UiRoot tree — NOT an IPanelHost/IPanelRenderer backend. Rewrote the architecture sections: Spec 1 now WIRES the dormant UiHost and adds only the gaps (DrawSprite + frag uUseTexture=2, UiNineSlicePanel, UiMeter, MarkupDocument that builds a UiElement subtree, ControlsIni). Input machinery already exists in UiRoot; deferring it is now about integrating two input consumers, not a missing contract. Plugin contract becomes a UiElement/ markup subtree added to UiRoot (IUiRegistry on IPluginHost), not IPanel. Net: strictly less new code, more faithful, retires TS-30 by subclassing the file it cites. Added §0 documenting the correction + the process lesson (subsystem-discovery must glob by directory, not by the parent's framing). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...026-06-14-d2b-retail-panel-frame-design.md | 498 ++++++++++-------- 1 file changed, 269 insertions(+), 229 deletions(-) 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 4884b0ff..8ee41349 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 @@ -1,95 +1,127 @@ # 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 +**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, 2026-06-14). Every binding fact below cites `file:line` in `src/` or a named-retail symbol; nothing rests on a memory note alone. +**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 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. +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" is not a foreign graft — it -re-expresses AC's own design in its modern equivalent. +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 -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. +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**: 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. +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:** -- 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`). +- 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` 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):** -- Input / hit-testing (window drag, working close-click). Spec 1 is **render-only**. -- The dat A8 glyph font loader (`AcFont`) → numeric overlays ("182/210"). +- **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 panels. +- Reskinning Chat / Debug / Settings. - 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) +## 3. Source-verified facts (do-not-trust list) -The research caught several load-bearing "facts" that were wrong or unverified. +The grounding caught several load-bearing "facts" that were wrong/unverified. These are binding: -| Claimed (memory / plan doc) | Reality (source-verified) | +| Claimed (memory / first draft) | 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. | +| 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 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. +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. ``` ┌──────────────────────────────────────────────────────────┐ @@ -97,82 +129,93 @@ extraction is a follow-up, gated on a rendering-primitives home existing. │ controls.ini → style tokens · RenderSurface 0x06xxxxxx │ │ → sprites · Font 0x40xxxxxx → glyphs (deferred) │ └───────────────┬──────────────────────────────────────────┘ - │ assets via TextureCache.GetOrUpload + │ TextureCache.GetOrUpload(id) → Texture2D ┌───────────────▼──────────────────────────────────────────┐ -│ 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) │ +│ 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) │ └───────────────┬──────────────────────────────────────────┘ - │ VMs out / Commands in (unchanged) + │ UiMeter.Fill = () => vm.HealthPercent ┌───────────────▼──────────────────────────────────────────┐ -│ AcDream.UI.Abstractions (exists) — IPanel/IPanelHost/ │ -│ IPanelRenderer/ICommandBus/PanelContext/VitalsVM │ -└───────────────┬──────────────────────────────────────────┘ - │ -┌───────────────▼──────────────────────────────────────────┐ -│ game state (unchanged) — CombatState, LocalPlayerState │ +│ AcDream.UI.Abstractions (exists) — VitalsVM (unchanged) │ +│ ↑ ImGui IPanelHost/IPanelRenderer path stays for │ +│ ACDREAM_DEVTOOLS, fully independent of the above │ └──────────────────────────────────────────────────────────┘ ``` -**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. +**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 — reuse, don't rebuild +## 5. Render foundation — extend the existing 2D path -`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: +`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: -- **`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. +- **`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>`), 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` -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)). +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` sprite-resolver) by injection. **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 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**, 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 +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) to supply the real insets. +(sub-project 3). ## 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: +**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 @@ -182,168 +225,165 @@ an element has a type, id, `x/y/w/h/z`, the four anchor edge-codes, a ``` -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). +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, so Spec 1 places -it at fixed pixel coords. Building the full solver now would be gold-plating -(gap-critic risk #7). +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 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. +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) -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. +The vitals panel is a `UiNineSlicePanel` (chrome) containing a `UiLabel` (title) +and three `UiMeter`s. Each `UiMeter` holds a `Func 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. -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. +`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. -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`. +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) -`IPluginHost` exposes only `Log`/`State`/`Events` today — no UI surface -([IPluginHost.cs:9](../../../src/AcDream.Plugin.Abstractions/IPluginHost.cs)). Spec 1 -adds: +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: -- `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. +- 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.** 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. +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 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.) + 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: 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 | +| 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 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). +- **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` 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`. +- **`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 — 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`. + 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/hit-testing contract** — neither `IPanelHost` nor `IPanel` reports - bounds; required before drag/close-click. Next sub-phase. +- **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). 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. + 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 `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). +- [ ] 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. -- [ ] `IPanelRegistry` on `IPluginHost`; a `MarkupPanel` exists and is unit-tested - against a fake binding. +- [ ] `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. From 35152248f1ba80b104164e12789d9bff0840fd26 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 14:21:56 +0200 Subject: [PATCH 03/99] =?UTF-8?q?docs(D.2b):=20implementation=20plan=20?= =?UTF-8?q?=E2=80=94=20retail=20panel=20frame=20+=20live=20Vitals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 9-task TDD plan against the re-grounded spec, building on the existing AcDream.App/UI scaffold: RuntimeOptions toggles, textured-sprite path in TextRenderer (+ frag uUseTexture=2, + TextureCache size overload), Step-0 chrome prove-out, UiNineSlicePanel + UiMeter widgets, wire UiHost + live Vitals (render-only) retiring TS-30, controls.ini loader, MarkupDocument (XML -> UiElement tree), and the IUiRegistry plugin surface. Exact code per step; pure parsers TDD'd in AcDream.App.Tests, GL/visual bits user-verified. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-14-d2b-retail-panel-frame-plan.md | 1322 +++++++++++++++++ 1 file changed, 1322 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md diff --git a/docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md b/docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md new file mode 100644 index 00000000..5fff7b20 --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md @@ -0,0 +1,1322 @@ +# D.2b Retail Panel Frame + Live Vitals — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Render a retail-shaped Vitals window (8-piece dat-sprite frame + live HP/Stam/Mana bars) by wiring the dormant `AcDream.App/UI` retained-mode toolkit and adding a markup/stylesheet/sprite layer, gated behind `ACDREAM_RETAIL_UI=1`. + +**Architecture:** The retail UI is the **existing `UiRoot`/`UiElement` tree** driven by `UiHost` (dormant today) — a separate system from the ImGui devtools path. Spec 1 wires `UiHost` into `GameWindow`, extends the shared `TextRenderer` with a textured-sprite path, adds `UiNineSlicePanel` (chrome) + `UiMeter` (bar) widgets, a `MarkupDocument` that instantiates a `UiElement` subtree from XML, and a `controls.ini` stylesheet loader. Render-only (input integration deferred). Spec: [`docs/superpowers/specs/2026-06-14-d2b-retail-panel-frame-design.md`](../specs/2026-06-14-d2b-retail-panel-frame-design.md). + +**Tech Stack:** C# / .NET 10, Silk.NET OpenGL, xUnit 2.9.3. Dat assets via the existing `TextureCache` + `SurfaceDecoder`. + +--- + +## File Structure + +**New files:** +- `src/AcDream.App/UI/UiNineSlicePanel.cs` — `UiPanel` subclass drawing the 8-piece dat-sprite frame + center fill. +- `src/AcDream.App/UI/UiMeter.cs` — `UiElement` vital bar (bg + partial fill). +- `src/AcDream.App/UI/RetailChromeSprites.cs` — confirmed chrome sprite DataIDs + sizes + insets (filled by Step 0). +- `src/AcDream.App/UI/ControlsIni.cs` — flat INI stylesheet parser (`#AARRGGBB`, `font://`). +- `src/AcDream.App/UI/MarkupDocument.cs` — XML → `UiElement` subtree builder + `{Binding}` resolution. +- `src/AcDream.App/UI/assets/vitals.xml` — the first-party vitals markup (copied to output). +- `src/AcDream.Plugin.Abstractions/IUiRegistry.cs` — plugin-facing UI registration surface. +- `src/AcDream.App/Plugins/BufferedUiRegistry.cs` — buffers `AddMarkupPanel` until `UiHost` exists. +- `tests/AcDream.App.Tests/UI/ControlsIniTests.cs`, `MarkupDocumentTests.cs`, `UiMeterTests.cs`, `UiNineSlicePanelTests.cs` +- `tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs` +- `tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs` + +**Modified files:** +- `src/AcDream.App/RuntimeOptions.cs` — add `RetailUi`, `AcDir`. +- `src/AcDream.App/Rendering/Shaders/ui_text.frag` — add `uUseTexture==2` RGBA branch. +- `src/AcDream.App/Rendering/TextRenderer.cs` — add `DrawSprite` + per-texture batch + `DepthMask`. +- `src/AcDream.App/Rendering/TextureCache.cs` — add `GetOrUpload(id, out w, out h)` size overload. +- `src/AcDream.App/UI/UiRenderContext.cs` — add `DrawSprite` forwarder. +- `src/AcDream.App/Rendering/GameWindow.cs` — wire `UiHost` + vitals subtree (render-only). +- `src/AcDream.Plugin.Abstractions/IPluginHost.cs` + `src/AcDream.App/Plugins/AppPluginHost.cs` — add `Ui`. +- `src/AcDream.App/Program.cs` — construct `BufferedUiRegistry`, pass to host + window. +- `docs/architecture/retail-divergence-register.md` — delete TS-30, add IA row (in the chrome commit). + +--- + +## Task 1: RuntimeOptions — add RetailUi + AcDir toggles + +**Files:** +- Modify: `src/AcDream.App/RuntimeOptions.cs` +- Test: `tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs`: + +```csharp +using System.Collections.Generic; +using AcDream.App; + +namespace AcDream.App.Tests; + +public class RuntimeOptionsRetailUiTests +{ + [Fact] + public void Parse_ReadsRetailUiAndAcDir() + { + var env = new Dictionary + { + ["ACDREAM_RETAIL_UI"] = "1", + ["ACDREAM_AC_DIR"] = @"C:\Turbine\Asheron's Call", + }; + var opts = RuntimeOptions.Parse("dats", k => env.GetValueOrDefault(k)); + Assert.True(opts.RetailUi); + Assert.Equal(@"C:\Turbine\Asheron's Call", opts.AcDir); + } + + [Fact] + public void Parse_DefaultsRetailUiOffAndAcDirNull() + { + var opts = RuntimeOptions.Parse("dats", _ => null); + Assert.False(opts.RetailUi); + Assert.Null(opts.AcDir); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter RuntimeOptionsRetailUiTests` +Expected: FAIL to **compile** — `RetailUi` / `AcDir` are not members of `RuntimeOptions`. + +- [ ] **Step 3: Add the fields** + +In `src/AcDream.App/RuntimeOptions.cs`, add two parameters at the **end** of the record (line 42, after `int? LegacyStreamRadius`): + +```csharp + int? LegacyStreamRadius, + bool RetailUi, + string? AcDir) +``` + +And in `Parse` (after the `LegacyStreamRadius:` line, before the closing `);`): + +```csharp + LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")), + RetailUi: IsExactlyOne(env("ACDREAM_RETAIL_UI")), + AcDir: NullIfEmpty(env("ACDREAM_AC_DIR"))); +``` + +- [ ] **Step 4: Fix any positional construction sites** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj` +If any `new RuntimeOptions(...)` positional call site fails to compile (missing 2 args), append `, RetailUi: false, AcDir: null` to it. (`Program.cs` uses `FromEnvironment`→`Parse` with named args and is unaffected.) + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter RuntimeOptionsRetailUiTests` +Expected: PASS (2 tests). + +- [ ] **Step 6: Commit** + +```bash +git add src/AcDream.App/RuntimeOptions.cs tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs +git commit -m "feat(D.2b): RuntimeOptions.RetailUi + AcDir toggles" +``` + +--- + +## Task 2: Dat-sprite render capability + +GL code — verified by build + the Step-3 visual, not unit tests. + +**Files:** +- Modify: `src/AcDream.App/Rendering/Shaders/ui_text.frag` +- Modify: `src/AcDream.App/Rendering/TextRenderer.cs` +- Modify: `src/AcDream.App/Rendering/TextureCache.cs` +- Modify: `src/AcDream.App/UI/UiRenderContext.cs` + +- [ ] **Step 1: Add the RGBA branch to the fragment shader** + +In `src/AcDream.App/Rendering/Shaders/ui_text.frag`, replace the `main()` body's branch: + +```glsl +void main() { + if (uUseTexture == 1) { + // Font atlas is a single-channel R8 texture; red = coverage alpha. + float coverage = texture(uTex, vUv).r; + FragColor = vec4(vColor.rgb, vColor.a * coverage); + } else if (uUseTexture == 2) { + // RGBA dat sprite (decoded to RGBA8); modulate by tint/alpha. + FragColor = texture(uTex, vUv) * vColor; + } else { + FragColor = vColor; + } + if (FragColor.a < 0.005) discard; +} +``` + +- [ ] **Step 2: Add a size-returning overload to TextureCache** + +In `src/AcDream.App/Rendering/TextureCache.cs`, add a size cache field next to `_handlesBySurfaceId` (top-of-class field region): + +```csharp + private readonly Dictionary _sizeBySurfaceId = new(); +``` + +And add this method directly after `GetOrUpload(uint surfaceId)` (after line 81): + +```csharp + /// + /// Like but also returns the decoded + /// pixel dimensions. UI 9-slice geometry needs the source size to + /// compute slice UVs. Cached alongside the handle. + /// + public uint GetOrUpload(uint surfaceId, out int width, out int height) + { + if (_handlesBySurfaceId.TryGetValue(surfaceId, out var existing) + && _sizeBySurfaceId.TryGetValue(surfaceId, out var sz)) + { + width = sz.w; height = sz.h; + return existing; + } + + var decoded = DecodeFromDats(surfaceId, origTextureOverride: null, paletteOverride: null); + uint h = UploadRgba8(decoded); + _handlesBySurfaceId[surfaceId] = h; + _sizeBySurfaceId[surfaceId] = (decoded.Width, decoded.Height); + width = decoded.Width; height = decoded.Height; + return h; + } +``` + +- [ ] **Step 3: Add the textured-sprite path to TextRenderer** + +In `src/AcDream.App/Rendering/TextRenderer.cs`, add a per-texture sprite buffer field (next to `_textBuf`/`_rectBuf`, ~line 31): + +```csharp + private readonly Dictionary> _spriteBufs = new(); +``` + +Clear it in `Begin` (inside the existing `Begin`, after `_rectBuf.Clear();`): + +```csharp + foreach (var b in _spriteBufs.Values) b.Clear(); +``` + +Add the public draw method (after `DrawString`, ~line 130): + +```csharp + /// + /// Draw a textured sprite quad in screen pixel space with an explicit + /// source-UV rectangle (for 9-slice / atlas sub-regions). Batched per + /// GL texture handle; flushed with uUseTexture=2 (RGBA modulate). + /// + public void DrawSprite(uint texture, float x, float y, float w, float h, + float u0, float v0, float u1, float v1, Vector4 tint) + { + if (!_spriteBufs.TryGetValue(texture, out var buf)) + { + buf = new List(256); + _spriteBufs[texture] = buf; + } + AppendQuad(buf, x, y, w, h, u0, v0, u1, v1, tint); + } +``` + +In `Flush`, (a) change the early-out so sprites alone still draw, (b) set `DepthMask(false)` + restore, (c) draw the sprite batches. Replace the existing `Flush` body's guard and state block down through the text draw: + +Replace: +```csharp + if (_textVerts == 0 && _rectVerts == 0) return; +``` +with: +```csharp + bool hasSprites = false; + foreach (var b in _spriteBufs.Values) if (b.Count > 0) { hasSprites = true; break; } + if (_textVerts == 0 && _rectVerts == 0 && !hasSprites) return; +``` + +Replace the state-save block: +```csharp + bool wasDepth = _gl.IsEnabled(EnableCap.DepthTest); + bool wasBlend = _gl.IsEnabled(EnableCap.Blend); + bool wasCull = _gl.IsEnabled(EnableCap.CullFace); + _gl.Disable(EnableCap.DepthTest); + _gl.Disable(EnableCap.CullFace); + _gl.Enable(EnableCap.Blend); + _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); +``` +with (adds DepthMask off; restored to true below): +```csharp + bool wasDepth = _gl.IsEnabled(EnableCap.DepthTest); + bool wasBlend = _gl.IsEnabled(EnableCap.Blend); + bool wasCull = _gl.IsEnabled(EnableCap.CullFace); + _gl.Disable(EnableCap.DepthTest); + _gl.Disable(EnableCap.CullFace); + _gl.DepthMask(false); + _gl.Enable(EnableCap.Blend); + _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); +``` + +Add the sprite-draw block immediately **after** the text-glyph block (after the `if (_textVerts > 0 && font is not null) { ... }` block, before "Restore GL state"): + +```csharp + // RGBA dat sprites — one draw call per distinct GL texture. + if (hasSprites) + { + _shader.SetInt("uUseTexture", 2); + _gl.ActiveTexture(TextureUnit.Texture0); + _shader.SetInt("uTex", 0); + foreach (var kv in _spriteBufs) + { + if (kv.Value.Count == 0) continue; + _gl.BindTexture(TextureTarget.Texture2D, kv.Key); + UploadBuffer(kv.Value); + _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)(kv.Value.Count / FloatsPerVertex)); + } + } +``` + +Add DepthMask restore in the "Restore GL state" block (after the existing three restores). Restore to `true` — the next frame's depth *clear* requires depth writes enabled, so `true` is the correct (and only safe) post-UI value: +```csharp + _gl.DepthMask(true); +``` + +- [ ] **Step 4: Add the DrawSprite forwarder to UiRenderContext** + +In `src/AcDream.App/UI/UiRenderContext.cs`, after the `DrawRectOutline` forwarder (line 54): + +```csharp + public void DrawSprite(uint texture, float x, float y, float w, float h, + float u0, float v0, float u1, float v1, Vector4 tint) + => TextRenderer.DrawSprite(texture, + _current.X + x, _current.Y + y, w, h, u0, v0, u1, v1, tint); +``` + +- [ ] **Step 5: Build** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj` +Expected: build succeeds. + +- [ ] **Step 6: Commit** + +```bash +git add src/AcDream.App/Rendering/Shaders/ui_text.frag src/AcDream.App/Rendering/TextRenderer.cs src/AcDream.App/Rendering/TextureCache.cs src/AcDream.App/UI/UiRenderContext.cs +git commit -m "feat(D.2b): textured-sprite path in TextRenderer + UV-rect DrawSprite" +``` + +--- + +## Task 3: Step-0 chrome sprite prove-out (HUMAN-IN-THE-LOOP) + +Resolves the unverified chrome sprite IDs empirically (spec §6). Requires the user to run the client and eyeball candidates. + +**Files:** +- Create: `src/AcDream.App/UI/RetailChromeSprites.cs` +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (temporary prove-out block) + +- [ ] **Step 1: Create the constants file (empty placeholders to be filled by the run)** + +Create `src/AcDream.App/UI/RetailChromeSprites.cs`: + +```csharp +namespace AcDream.App.UI; + +/// +/// Confirmed retail window-chrome RenderSurface DataIDs + decoded sizes + +/// 9-slice insets. Values are filled by the Step-0 prove-out run (see +/// docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md, Task 3) +/// — do NOT trust pre-run values. Candidates dumped by the prove-out harness. +/// +public static class RetailChromeSprites +{ + // Candidate IDs to try in the Step-0 prove-out. Edit this list as needed. + public static readonly uint[] Candidates = + { + 0x06004CC2, 0x060074BF, 0x060074C0, 0x060074C1, 0x060074C2, + 0x060074C3, 0x060074C4, 0x060074C5, 0x060074C6, 0x0600129C, + }; + + // === FILLED BY STEP 0 (placeholder = magenta until confirmed) === + /// The single 9-sliceable frame sprite (or the body/center fill). + public static uint FrameSurfaceId = 0; // TODO Step 0: set to confirmed id + /// Corner inset in pixels (left/top/right/bottom assumed equal until LayoutDesc parse). + public static int Inset = 6; // TODO Step 0: tune to the real bevel +} +``` + +- [ ] **Step 2: Add a temporary prove-out block to OnRender** + +In `src/AcDream.App/Rendering/GameWindow.cs`, in `OnRender` after the 3D passes (just before the ImGui block at ~line 8158), add: + +```csharp + // Step-0 prove-out (D.2b Task 3): draw candidate chrome sprites in a + // labelled row so we can eyeball which decode to frame art. Gated by + // ACDREAM_RETAIL_UI_PROVEOUT=1. TEMPORARY — delete after Step 0. + if (System.Environment.GetEnvironmentVariable("ACDREAM_RETAIL_UI_PROVEOUT") == "1" + && _textureCache is not null && _textRenderer is not null) + { + _textRenderer.Begin(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y)); + float px = 20f; + foreach (var id in AcDream.App.UI.RetailChromeSprites.Candidates) + { + uint tex = _textureCache.GetOrUpload(id, out int tw, out int th); + _textRenderer.DrawSprite(tex, px, 60f, 96f, 96f, 0, 0, 1, 1, + System.Numerics.Vector4.One); + if (_debugFont is not null) + _textRenderer.DrawString(_debugFont, $"0x{id:X8}\n{tw}x{th}", px, 160f, + System.Numerics.Vector4.One); + px += 110f; + } + _textRenderer.Flush(_debugFont); + } +``` + +- [ ] **Step 3: Build + run the prove-out (manual)** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj` +Then launch with the prove-out flag (PowerShell): + +```powershell +$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" +$env:ACDREAM_RETAIL_UI_PROVEOUT = "1" +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath proveout.log +``` + +**Manual:** the user reports which candidate IDs render as frame/border art (vs magenta vs unrelated sprites) and their printed sizes. If the frame is a single 9-sliceable sprite, note that ID + size. If it's separate corner/edge sprites, note each. Tune `Candidates` and re-run if none match (widen the `0x0600xxxx` range near `0x060074xx`). + +- [ ] **Step 4: Record the confirmed values** + +Edit `RetailChromeSprites.cs`: set `FrameSurfaceId` to the confirmed id and `Inset` to the eyeballed bevel thickness. Add a comment with the decoded `WxH` and the date. + +- [ ] **Step 5: Remove the temporary prove-out block** + +Delete the `ACDREAM_RETAIL_UI_PROVEOUT` block from `GameWindow.cs` (it was scaffolding). + +- [ ] **Step 6: Commit** + +```bash +git add src/AcDream.App/UI/RetailChromeSprites.cs src/AcDream.App/Rendering/GameWindow.cs +git commit -m "feat(D.2b): Step-0 chrome sprite prove-out + confirmed RetailChromeSprites ids" +``` + +--- + +## Task 4: UiNineSlicePanel + +**Files:** +- Create: `src/AcDream.App/UI/UiNineSlicePanel.cs` +- Test: `tests/AcDream.App.Tests/UI/UiNineSlicePanelTests.cs` + +- [ ] **Step 1: Write the failing geometry test** + +Create `tests/AcDream.App.Tests/UI/UiNineSlicePanelTests.cs`: + +```csharp +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public class UiNineSlicePanelTests +{ + [Fact] + public void ComputeSliceRects_ProducesNinePatchesCoveringTheFrame() + { + // 100x80 frame, 32x32 source texture, 8px inset. + var rects = UiNineSlicePanel.ComputeSliceRects( + frameW: 100, frameH: 80, texW: 32, texH: 32, inset: 8); + + Assert.Equal(9, rects.Length); + + // Top-left corner: dst (0,0,8,8); src uv (0,0)-(8/32, 8/32). + var tl = rects[0]; + Assert.Equal(0f, tl.dstX); Assert.Equal(0f, tl.dstY); + Assert.Equal(8f, tl.dstW); Assert.Equal(8f, tl.dstH); + Assert.Equal(0f, tl.u0); Assert.Equal(0f, tl.v0); + Assert.Equal(8f / 32f, tl.u1, 5); Assert.Equal(8f / 32f, tl.v1, 5); + + // Center: dst (8,8, 100-16, 80-16); src uv inset..(tex-inset). + var center = rects[4]; + Assert.Equal(8f, center.dstX); Assert.Equal(8f, center.dstY); + Assert.Equal(84f, center.dstW); Assert.Equal(64f, center.dstH); + Assert.Equal(8f / 32f, center.u0, 5); + Assert.Equal(24f / 32f, center.u1, 5); + + // Bottom-right corner dst origin at (100-8, 80-8). + var br = rects[8]; + Assert.Equal(92f, br.dstX); Assert.Equal(72f, br.dstY); + Assert.Equal(8f, br.dstW); Assert.Equal(8f, br.dstH); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter UiNineSlicePanelTests` +Expected: FAIL to compile — `UiNineSlicePanel` does not exist. + +- [ ] **Step 3: Implement UiNineSlicePanel** + +Create `src/AcDream.App/UI/UiNineSlicePanel.cs`: + +```csharp +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// A whose background is a 9-sliced dat RenderSurface: +/// 4 fixed corners, 4 stretched edges, 1 stretched center. Retires the flat +/// translucent rect (divergence row TS-30). Insets come from +/// until the LayoutDesc importer supplies +/// per-panel metrics. +/// +public sealed class UiNineSlicePanel : UiPanel +{ + /// One slice patch: destination rect (local px) + source UVs (0..1). + public readonly record struct Slice( + float dstX, float dstY, float dstW, float dstH, + float u0, float v0, float u1, float v1); + + private readonly System.Func _resolve; + private readonly uint _surfaceId; + private readonly int _inset; + + /// Surface id → (GL handle, decoded width, height). + /// In production: id => { var t = cache.GetOrUpload(id, out var w, out var h); return (t, w, h); }. + public UiNineSlicePanel(System.Func resolve, + uint surfaceId, int inset) + { + _resolve = resolve; + _surfaceId = surfaceId; + _inset = inset; + BackgroundColor = Vector4.Zero; // suppress the base flat-rect fill + BorderColor = Vector4.Zero; + } + + /// + /// Compute the 9 patches for a frame of x + /// from a x + /// source with a uniform . + /// Order: TL, TC, TR, ML, MC, MR, BL, BC, BR (index 4 = center). + /// + public static Slice[] ComputeSliceRects( + float frameW, float frameH, int texW, int texH, int inset) + { + float i = inset; + // destination column/row edges + float[] dx = { 0, i, frameW - i, frameW }; + float[] dy = { 0, i, frameH - i, frameH }; + // source UV column/row edges (0..1) + float[] ux = { 0, i / texW, (texW - i) / texW, 1f }; + float[] uy = { 0, i / texH, (texH - i) / texH, 1f }; + + var slices = new Slice[9]; + int n = 0; + for (int row = 0; row < 3; row++) + for (int col = 0; col < 3; col++) + slices[n++] = new Slice( + dx[col], dy[row], dx[col + 1] - dx[col], dy[row + 1] - dy[row], + ux[col], uy[row], ux[col + 1], uy[row + 1]); + return slices; + } + + protected override void OnDraw(UiRenderContext ctx) + { + var (tex, tw, th) = _resolve(_surfaceId); + if (tex == 0 || tw == 0 || th == 0) return; + foreach (var s in ComputeSliceRects(Width, Height, tw, th, _inset)) + ctx.DrawSprite(tex, s.dstX, s.dstY, s.dstW, s.dstH, + s.u0, s.v0, s.u1, s.v1, Vector4.One); + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter UiNineSlicePanelTests` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.App/UI/UiNineSlicePanel.cs tests/AcDream.App.Tests/UI/UiNineSlicePanelTests.cs +git commit -m "feat(D.2b): UiNineSlicePanel (9-slice dat chrome) + geometry tests" +``` + +--- + +## Task 5: UiMeter + +**Files:** +- Create: `src/AcDream.App/UI/UiMeter.cs` +- Test: `tests/AcDream.App.Tests/UI/UiMeterTests.cs` + +- [ ] **Step 1: Write the failing fill-geometry test** + +Create `tests/AcDream.App.Tests/UI/UiMeterTests.cs`: + +```csharp +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public class UiMeterTests +{ + [Fact] + public void ComputeFillRect_HalfFillIsHalfWidth() + { + var (x, y, w, h) = UiMeter.ComputeFillRect(0.5f, 200f, 12f); + Assert.Equal(0f, x); Assert.Equal(0f, y); + Assert.Equal(100f, w); Assert.Equal(12f, h); + } + + [Theory] + [InlineData(-1f, 0f)] // clamps below 0 + [InlineData(2f, 200f)] // clamps above 1 + [InlineData(0f, 0f)] + [InlineData(1f, 200f)] + public void ComputeFillRect_ClampsFraction(float pct, float expectedW) + { + var (_, _, w, _) = UiMeter.ComputeFillRect(pct, 200f, 12f); + Assert.Equal(expectedW, w); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter UiMeterTests` +Expected: FAIL to compile — `UiMeter` does not exist. + +- [ ] **Step 3: Implement UiMeter** + +Create `src/AcDream.App/UI/UiMeter.cs`: + +```csharp +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. +/// +public sealed class UiMeter : UiElement +{ + /// Fill fraction provider; null result draws an empty bar. + public System.Func Fill { get; set; } = () => 0f; + public Vector4 BarColor { get; set; } = new(1f, 0f, 0f, 1f); + public Vector4 BgColor { get; set; } = new(0f, 0f, 0f, 0.5f); + + public UiMeter() { ClickThrough = true; } + + /// Clamp to [0,1] and return the fill + /// rect (local px) for a bar of x . + public static (float x, float y, float w, float h) ComputeFillRect( + float pct, float w, float h) + { + if (pct < 0f) pct = 0f; + if (pct > 1f) pct = 1f; + return (0f, 0f, w * pct, h); + } + + 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); + } + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter UiMeterTests` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.App/UI/UiMeter.cs tests/AcDream.App.Tests/UI/UiMeterTests.cs +git commit -m "feat(D.2b): UiMeter vital bar + fill-geometry tests" +``` + +--- + +## Task 6: Wire UiHost + hand-built vitals subtree (render-only) + retire TS-30 + +Visual-acceptance task. First on-screen retail panel. + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` +- Modify: `docs/architecture/retail-divergence-register.md` + +- [ ] **Step 1: Add the UiHost field** + +In `GameWindow.cs`, next to `_vitalsVm` (~line 614): + +```csharp + // Phase D.2b — retail-look UI tree. Null unless ACDREAM_RETAIL_UI=1. + private AcDream.App.UI.UiHost? _uiHost; +``` + +- [ ] **Step 2: Construct UiHost + the vitals subtree in OnLoad** + +In `GameWindow.cs` OnLoad, **after** `_textureCache` is constructed (after line 1724) and after `_vitalsVm` is available, add. Note: `_vitalsVm` is built today only inside the DevTools block (line 1330). Hoist its construction so it exists for the retail path too — change line 1330's block so the VM is created when `DevToolsEnabled || _options.RetailUi`. Concretely, ensure this runs regardless of DevTools: + +```csharp + _vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer); +``` + +**Also ungate the GUID setter:** the `_vitalsVm.SetLocalPlayerGuid(...)` call at EnterWorld (~line 1984) must run whenever `_vitalsVm` is non-null — not only under DevTools — or retail-only mode reads HP=1.0 forever. Change any `if (DevToolsEnabled)` guard around that call to `if (_vitalsVm is not null)` (use the null-conditional `_vitalsVm?.SetLocalPlayerGuid(guid);` if simpler). Verify the exact guard at the call site before editing. + +Then add the retail wiring (after `_textureCache` exists): + +```csharp + if (_options.RetailUi) + { + string shadersDir = Path.Combine(AppContext.BaseDirectory, "Rendering", "Shaders"); + _uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont); + + var cache = _textureCache!; + (uint, int, int) Resolve(uint id) + { + uint t = cache.GetOrUpload(id, out int w, out int h); + return (t, w, h); + } + + var panel = new AcDream.App.UI.UiNineSlicePanel( + Resolve, + AcDream.App.UI.RetailChromeSprites.FrameSurfaceId, + AcDream.App.UI.RetailChromeSprites.Inset) + { Left = 10, Top = 30, Width = 220, Height = 96 }; + + var title = new AcDream.App.UI.UiLabel + { Text = "Vitals", Left = 8, Top = 4, + TextColor = new System.Numerics.Vector4(1, 1, 1, 1) }; + panel.AddChild(title); + + var vm = _vitalsVm!; + panel.AddChild(new AcDream.App.UI.UiMeter + { Left = 8, Top = 24, Width = 200, Height = 13, + BarColor = new System.Numerics.Vector4(1f, 0f, 0f, 1f), + Fill = () => vm.HealthPercent }); + panel.AddChild(new AcDream.App.UI.UiMeter + { Left = 8, Top = 44, Width = 200, Height = 13, + BarColor = new System.Numerics.Vector4(0.063f, 0.94f, 0.94f, 1f), + Fill = () => vm.StaminaPercent }); + panel.AddChild(new AcDream.App.UI.UiMeter + { Left = 8, Top = 64, Width = 200, Height = 13, + BarColor = new System.Numerics.Vector4(0f, 0f, 1f, 1f), + Fill = () => vm.ManaPercent }); + + _uiHost.Root.AddChild(panel); + } +``` + +(`UiLabel` draws via the stb `BitmapFont` `_debugFont`; if `_debugFont` is null the title simply doesn't draw — acceptable for Spec 1.) + +- [ ] **Step 3: Draw the retail UI each frame** + +In `GameWindow.cs` OnRender, after the 3D passes and near the ImGui block (~line 8233, after `_imguiBootstrap` block or before it — order is deterministic either way; place it just before the ImGui `if` at line 8158 so ImGui composites on top in dev): + +```csharp + // Phase D.2b — retail-look UI tree (render-only; input integration deferred). + if (_options.RetailUi && _uiHost is not null) + { + _uiHost.Tick(deltaSeconds); + _uiHost.Draw(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y)); + } +``` + +- [ ] **Step 4: Dispose UiHost on shutdown** + +In `GameWindow.cs`'s dispose/shutdown path (near where `_textRenderer`/`_debugFont` are disposed, ~line 12043): + +```csharp + _uiHost?.Dispose(); +``` + +- [ ] **Step 5: Build + visual verify (manual)** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj` +Launch with `ACDREAM_RETAIL_UI=1` (+ the live-connection env from CLAUDE.md). **User confirms:** the Vitals window renders with the dat-sprite frame + three bars that track HP/Stam/Mana as the character takes damage/regens. Also launch with `ACDREAM_DEVTOOLS=1` (retail off) and confirm the ImGui panels are unchanged. + +- [ ] **Step 6: Retire TS-30 + add the IA row** + +In `docs/architecture/retail-divergence-register.md`: delete the **TS-30** row (line ~166). Add one new **IA** row (next sequential IA number) for the markup/serialization layer: + +``` +| IA-NN | D.2b retail UI is our own UiRoot tree + XML markup + controls.ini stylesheet, not a byte-port of keystone.dll's LayoutDesc binary tree (keystone.dll has no PDB/decomp) | src/AcDream.App/UI/UiNineSlicePanel.cs + MarkupDocument.cs | keystone.dll is outside decomp coverage — a byte-port is impossible by definition; we mirror retail's LayoutDesc/ElementDesc field model + controls.ini token vocabulary | Layout semantics the research under-specifies (anchor resolution at non-800x600, controls.ini cascade corners) differ silently with no oracle | LayoutDesc 0x21xxxxxx; controls.ini panel-property vocabulary; keystone.dll layout evaluation (no PDB) | +``` + +(Replace `IA-NN` with the actual next number; verify against the register head — there were 14 IA rows at the 2026-06-12 count, so likely `IA-15`.) + +- [ ] **Step 7: Commit** + +```bash +git add src/AcDream.App/Rendering/GameWindow.cs docs/architecture/retail-divergence-register.md +git commit -m "feat(D.2b): wire UiHost + live Vitals panel (render-only); retire TS-30, add IA row" +``` + +--- + +## Task 7: controls.ini stylesheet loader + +**Files:** +- Create: `src/AcDream.App/UI/ControlsIni.cs` +- Test: `tests/AcDream.App.Tests/UI/ControlsIniTests.cs` +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (apply title color/font tokens) + +- [ ] **Step 1: Write the failing parser tests** + +Create `tests/AcDream.App.Tests/UI/ControlsIniTests.cs`: + +```csharp +using System.Numerics; +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public class ControlsIniTests +{ + [Fact] + public void Parse_ReadsSectionTokens() + { + var ini = ControlsIni.Parse( + "[title]\nheight=19\ncolor=#FFFFFFFF\nfont=font://Verdana-10-bold\n" + + "[body]\nbgcolor=#00000000\ncolor_border=#FF4F657D\n"); + + Assert.Equal("19", ini.Get("title", "height")); + Assert.Equal("font://Verdana-10-bold", ini.Get("title", "font")); + Assert.Null(ini.Get("title", "missing")); + Assert.Null(ini.Get("nosuch", "height")); + } + + [Fact] + public void TryColor_ParsesAlphaFirstHex() + { + var ini = ControlsIni.Parse("[body]\ncolor_border=#FF4F657D\n"); + Assert.True(ini.TryColor("body", "color_border", out Vector4 c)); + Assert.Equal(0xFF / 255f, c.W, 5); // alpha + Assert.Equal(0x4F / 255f, c.X, 5); // red + Assert.Equal(0x65 / 255f, c.Y, 5); // green + Assert.Equal(0x7D / 255f, c.Z, 5); // blue + } + + [Fact] + public void Parse_MissingFileReturnsEmptyNotThrow() + { + var ini = ControlsIni.Load(@"Z:\does\not\exist\controls.ini"); + Assert.Null(ini.Get("title", "height")); // empty, no throw + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter ControlsIniTests` +Expected: FAIL to compile — `ControlsIni` does not exist. + +- [ ] **Step 3: Implement ControlsIni** + +Create `src/AcDream.App/UI/ControlsIni.cs`: + +```csharp +using System.Collections.Generic; +using System.Globalization; +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// Minimal reader for retail's controls.ini — a flat INI with one +/// [section] per element type. Colors are #AARRGGBB (alpha +/// first). Optional: a missing file yields an empty sheet (callers fall +/// back to hardcoded defaults). See spec §7. +/// +public sealed class ControlsIni +{ + private readonly Dictionary> _sections; + + private ControlsIni(Dictionary> s) => _sections = s; + + /// Load from disk; returns an empty sheet if the file is absent. + public static ControlsIni Load(string path) + => System.IO.File.Exists(path) + ? Parse(System.IO.File.ReadAllText(path)) + : new ControlsIni(new()); + + public static ControlsIni Parse(string text) + { + var sections = new Dictionary>(System.StringComparer.OrdinalIgnoreCase); + Dictionary? cur = null; + foreach (var raw in text.Split('\n')) + { + var line = raw.Trim(); + if (line.Length == 0 || line[0] == ';' || line[0] == '#') continue; + if (line[0] == '[' && line[^1] == ']') + { + var name = line[1..^1].Trim(); + cur = new Dictionary(System.StringComparer.OrdinalIgnoreCase); + sections[name] = cur; + continue; + } + int eq = line.IndexOf('='); + if (eq <= 0 || cur is null) continue; + cur[line[..eq].Trim()] = line[(eq + 1)..].Trim(); + } + return new ControlsIni(sections); + } + + public string? Get(string section, string key) + => _sections.TryGetValue(section, out var s) && s.TryGetValue(key, out var v) ? v : null; + + /// Parse a #AARRGGBB token into an RGBA . + public bool TryColor(string section, string key, out Vector4 color) + { + color = default; + var v = Get(section, key); + if (v is null || v.Length != 9 || v[0] != '#') return false; + if (!uint.TryParse(v.AsSpan(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out uint argb)) + return false; + float a = ((argb >> 24) & 0xFF) / 255f; + float r = ((argb >> 16) & 0xFF) / 255f; + float g = ((argb >> 8) & 0xFF) / 255f; + float b = (argb & 0xFF) / 255f; + color = new Vector4(r, g, b, a); + return true; + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter ControlsIniTests` +Expected: PASS (3 tests). + +- [ ] **Step 5: Apply the stylesheet to the title label** + +In `GameWindow.cs`'s retail wiring (Task 6 Step 2), before building `title`, load the sheet and use the `[title]` color with a fallback: + +```csharp + string? acDir = _options.AcDir; + var controls = acDir is not null + ? AcDream.App.UI.ControlsIni.Load(Path.Combine(acDir, "controls", "controls.ini")) + : AcDream.App.UI.ControlsIni.Parse(string.Empty); + var titleColor = controls.TryColor("title", "color", out var tc) + ? tc : new System.Numerics.Vector4(1, 1, 1, 1); +``` + +Then set `TextColor = titleColor` on the `title` label. + +- [ ] **Step 6: Build + commit** + +```bash +dotnet build src/AcDream.App/AcDream.App.csproj +git add src/AcDream.App/UI/ControlsIni.cs tests/AcDream.App.Tests/UI/ControlsIniTests.cs src/AcDream.App/Rendering/GameWindow.cs +git commit -m "feat(D.2b): controls.ini stylesheet loader (optional) + apply title color" +``` + +--- + +## Task 8: MarkupDocument — XML → UiElement subtree + +**Files:** +- Create: `src/AcDream.App/UI/MarkupDocument.cs` +- Create: `src/AcDream.App/UI/assets/vitals.xml` +- Test: `tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs` +- Modify: `src/AcDream.App/AcDream.App.csproj` (copy `UI/assets/*.xml` to output) +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (build the subtree from markup) + +- [ ] **Step 1: Write the failing parser test** + +Create `tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs`: + +```csharp +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public class MarkupDocumentTests +{ + private sealed class FakeBinding + { + public float HealthPercent => 0.5f; + public float? ManaPercent => null; + } + + [Fact] + public void Build_CreatesPanelWithMeterChildrenAndGeometry() + { + const string xml = + "" + + " " + + ""; + + var resolve = (uint id) => ((uint)1, 32, 32); + var panel = MarkupDocument.Build(xml, new FakeBinding(), resolve, + frameSurfaceId: 0x06000000, inset: 8); + + Assert.IsType(panel); + Assert.Equal(10f, panel.Left); + Assert.Equal(220f, panel.Width); + + // One UiMeter child whose fill resolves to the binding's 0.5. + Assert.Single(panel.Children); + var meter = Assert.IsType(panel.Children[0]); + Assert.Equal(8f, meter.Left); + Assert.Equal(200f, meter.Width); + Assert.Equal(0.5f, meter.Fill()); + } + + [Fact] + public void Build_NullBindingPropertyYieldsNullFill() + { + const string xml = + "" + + " " + + ""; + var panel = MarkupDocument.Build(xml, new FakeBinding(), + id => ((uint)1, 32, 32), 0x06000000, 8); + var meter = Assert.IsType(panel.Children[0]); + Assert.Null(meter.Fill()); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter MarkupDocumentTests` +Expected: FAIL to compile — `MarkupDocument` does not exist. + +- [ ] **Step 3: Implement MarkupDocument** + +Create `src/AcDream.App/UI/MarkupDocument.cs`: + +```csharp +using System; +using System.Globalization; +using System.Numerics; +using System.Xml.Linq; + +namespace AcDream.App.UI; + +/// +/// Parses our KSML-style panel markup (mirrors retail's ElementDesc fields) +/// into a live subtree. {Binding} attribute +/// values resolve against a supplied object by property name (reflection). +/// This is the format the future LayoutDesc importer will emit. See spec §7. +/// +public static class MarkupDocument +{ + /// Surface id → (GL handle, width, height). + public static UiNineSlicePanel Build( + string xml, object binding, Func resolve, + uint frameSurfaceId, int inset) + { + var root = XDocument.Parse(xml).Root + ?? throw new FormatException("empty markup"); + if (root.Name.LocalName != "panel") + throw new FormatException($"root must be , got <{root.Name.LocalName}>"); + + var panel = new UiNineSlicePanel(resolve, frameSurfaceId, inset) + { + Left = F(root, "x"), Top = F(root, "y"), + Width = F(root, "w"), Height = F(root, "h"), + }; + + string? title = (string?)root.Attribute("title"); + if (!string.IsNullOrEmpty(title)) + panel.AddChild(new UiLabel { Text = title, Left = 8, Top = 4 }); + + foreach (var el in root.Elements()) + { + switch (el.Name.LocalName) + { + case "meter": + panel.AddChild(new UiMeter + { + Left = F(el, "x"), Top = F(el, "y"), + Width = F(el, "w"), Height = F(el, "h"), + BarColor = Color((string?)el.Attribute("color")), + Fill = BindFloat((string?)el.Attribute("fill"), binding), + }); + break; + // future: case "label", "button", "image" ... + } + } + return panel; + } + + private static float F(XElement e, string attr) + => float.TryParse((string?)e.Attribute(attr), NumberStyles.Float, + CultureInfo.InvariantCulture, out var v) ? v : 0f; + + private static Vector4 Color(string? hex) + { + if (hex is { Length: 9 } && hex[0] == '#' + && uint.TryParse(hex.AsSpan(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out uint argb)) + { + return new Vector4( + ((argb >> 16) & 0xFF) / 255f, ((argb >> 8) & 0xFF) / 255f, + (argb & 0xFF) / 255f, ((argb >> 24) & 0xFF) / 255f); + } + return Vector4.One; + } + + /// Resolve "{Prop}" to a live getter against the binding; "" → constant 0. + private static Func BindFloat(string? expr, object binding) + { + if (expr is null || expr.Length < 3 || expr[0] != '{' || expr[^1] != '}') + return () => 0f; + string prop = expr[1..^1]; + var pi = binding.GetType().GetProperty(prop); + if (pi is null) return () => null; + return () => + { + object? v = pi.GetValue(binding); + return v switch + { + float f => f, + null => (float?)null, + _ => Convert.ToSingle(v, CultureInfo.InvariantCulture), + }; + }; + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter MarkupDocumentTests` +Expected: PASS (2 tests). + +- [ ] **Step 5: Add the vitals markup asset + copy-to-output** + +Create `src/AcDream.App/UI/assets/vitals.xml`: + +```xml + + + + + +``` + +In `src/AcDream.App/AcDream.App.csproj`, add an `ItemGroup` to copy UI assets to output: + +```xml + + + +``` + +- [ ] **Step 6: Replace the hand-built subtree with the markup build** + +In `GameWindow.cs`'s retail wiring (Task 6 Step 2), replace the hand-built `panel`/`title`/`UiMeter` block with: + +```csharp + string vitalsXmlPath = Path.Combine(AppContext.BaseDirectory, "UI", "assets", "vitals.xml"); + var panel = AcDream.App.UI.MarkupDocument.Build( + System.IO.File.ReadAllText(vitalsXmlPath), + _vitalsVm!, Resolve, + AcDream.App.UI.RetailChromeSprites.FrameSurfaceId, + AcDream.App.UI.RetailChromeSprites.Inset); + _uiHost.Root.AddChild(panel); +``` + +(The `controls.ini` title color from Task 7 can be applied by setting the title-`UiLabel`'s color after the build, or deferred — the markup path owns the title now.) + +- [ ] **Step 7: Build + visual verify + commit** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` +Launch with `ACDREAM_RETAIL_UI=1`; **user confirms** the markup-built panel renders identically to the hand-built one (frame + 3 live bars). + +```bash +git add src/AcDream.App/UI/MarkupDocument.cs src/AcDream.App/UI/assets/vitals.xml src/AcDream.App/AcDream.App.csproj tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs src/AcDream.App/Rendering/GameWindow.cs +git commit -m "feat(D.2b): MarkupDocument (XML -> UiElement tree) + vitals.xml; build panel from markup" +``` + +--- + +## Task 9: Plugin UI registry (capstone — designed-now, first consumer first-party) + +**Files:** +- Create: `src/AcDream.Plugin.Abstractions/IUiRegistry.cs` +- Modify: `src/AcDream.Plugin.Abstractions/IPluginHost.cs` +- Create: `src/AcDream.App/Plugins/BufferedUiRegistry.cs` +- Modify: `src/AcDream.App/Plugins/AppPluginHost.cs`, `src/AcDream.App/Program.cs`, `src/AcDream.App/Rendering/GameWindow.cs` +- Test: `tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs` + +- [ ] **Step 1: Define the registry interface** + +Create `src/AcDream.Plugin.Abstractions/IUiRegistry.cs`: + +```csharp +namespace AcDream.Plugin.Abstractions; + +/// +/// Plugin-facing UI registration. A plugin ships a markup file (KSML-style) +/// + a binding object exposing the data properties the markup binds to, and +/// registers it here from Enable(). Registrations made before the GL +/// window opens are buffered and drained once the UI host exists. +/// +public interface IUiRegistry +{ + /// Absolute path to the plugin's panel markup. + /// Object whose properties the markup's {Bindings} read. + void AddMarkupPanel(string markupPath, object binding); +} +``` + +- [ ] **Step 2: Add `Ui` to IPluginHost** + +In `src/AcDream.Plugin.Abstractions/IPluginHost.cs`: + +```csharp +public interface IPluginHost +{ + IPluginLogger Log { get; } + IGameState State { get; } + IEvents Events { get; } + IUiRegistry Ui { get; } +} +``` + +- [ ] **Step 3: Write the failing buffered-registry test** + +Create `tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs`: + +```csharp +using AcDream.App.Plugins; + +namespace AcDream.App.Tests.Plugins; + +public class BufferedUiRegistryTests +{ + [Fact] + public void Drain_YieldsBufferedRegistrationsOnce() + { + var reg = new BufferedUiRegistry(); + reg.AddMarkupPanel("a.xml", new object()); + reg.AddMarkupPanel("b.xml", new object()); + + var drained = reg.Drain(); + Assert.Equal(2, drained.Count); + Assert.Equal("a.xml", drained[0].MarkupPath); + + // Second drain is empty (consumed). + Assert.Empty(reg.Drain()); + } +} +``` + +- [ ] **Step 4: Run the test to verify it fails** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter BufferedUiRegistryTests` +Expected: FAIL to compile — `BufferedUiRegistry` does not exist. + +- [ ] **Step 5: Implement BufferedUiRegistry** + +Create `src/AcDream.App/Plugins/BufferedUiRegistry.cs`: + +```csharp +using System.Collections.Generic; +using AcDream.Plugin.Abstractions; + +namespace AcDream.App.Plugins; + +/// +/// Buffers plugin calls (which run in +/// Program.cs before the GL window opens) until GameWindow drains them into +/// the UiHost tree after construction. +/// +public sealed class BufferedUiRegistry : IUiRegistry +{ + public readonly record struct Pending(string MarkupPath, object Binding); + + private readonly List _pending = new(); + + public void AddMarkupPanel(string markupPath, object binding) + => _pending.Add(new Pending(markupPath, binding)); + + /// Return + clear all buffered registrations. + public IReadOnlyList Drain() + { + var copy = _pending.ToArray(); + _pending.Clear(); + return copy; + } +} +``` + +- [ ] **Step 6: Wire it through AppPluginHost + Program + GameWindow** + +`src/AcDream.App/Plugins/AppPluginHost.cs` — add the `Ui` member: + +```csharp + public AppPluginHost(IPluginLogger log, IGameState state, IEvents events, IUiRegistry ui) + { + Log = log; State = state; Events = events; Ui = ui; + } + + public IPluginLogger Log { get; } + public IGameState State { get; } + public IEvents Events { get; } + public IUiRegistry Ui { get; } +``` + +`src/AcDream.App/Program.cs` — construct the registry and pass it to host + window (replace lines 26 + 59): + +```csharp +var uiRegistry = new AcDream.App.Plugins.BufferedUiRegistry(); +var host = new AppPluginHost(new SerilogAdapter(Log.Logger), worldGameState, worldEvents, uiRegistry); +``` +```csharp + using var window = new GameWindow(runtimeOptions, worldGameState, worldEvents, uiRegistry); +``` + +`GameWindow` — add a constructor parameter `AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null`, store it in a field, and in the retail wiring (after `_uiHost.Root.AddChild(panel)`), drain it: + +```csharp + if (_uiRegistry is not null) + { + foreach (var p in _uiRegistry.Drain()) + { + var pluginPanel = AcDream.App.UI.MarkupDocument.Build( + System.IO.File.ReadAllText(p.MarkupPath), p.Binding, Resolve, + AcDream.App.UI.RetailChromeSprites.FrameSurfaceId, + AcDream.App.UI.RetailChromeSprites.Inset); + _uiHost.Root.AddChild(pluginPanel); + } + } +``` + +(Fix the `StubHost` in `tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs:28` to implement the new `Ui` member — return a throwaway `BufferedUiRegistry` or a stub.) + +- [ ] **Step 7: Run tests + build** + +Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter BufferedUiRegistryTests` +Expected: PASS. Fix any compile breaks in plugin-host implementors surfaced by the new interface member. + +- [ ] **Step 8: Commit** + +```bash +git add src/AcDream.Plugin.Abstractions/IUiRegistry.cs src/AcDream.Plugin.Abstractions/IPluginHost.cs src/AcDream.App/Plugins/BufferedUiRegistry.cs src/AcDream.App/Plugins/AppPluginHost.cs src/AcDream.App/Program.cs src/AcDream.App/Rendering/GameWindow.cs tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs +git commit -m "feat(D.2b): IUiRegistry plugin UI surface + buffered drain into UiHost" +``` + +--- + +## Final verification + +- [ ] `dotnet build` green (whole solution: `dotnet build AcDream.slnx`). +- [ ] `dotnet test` green (all test projects). +- [ ] `ACDREAM_RETAIL_UI=1`: retail Vitals window (frame + 3 live bars) renders; bars track damage/regen. +- [ ] `ACDREAM_DEVTOOLS=1` (retail off): ImGui panels unchanged. +- [ ] TS-30 deleted; one new IA row present. +- [ ] Update the roadmap: mark D.2b Spec 1 (retail panel frame + vitals) shipped in [`docs/plans/2026-04-11-roadmap.md`](../../plans/2026-04-11-roadmap.md). From 626d06ebc189d92ea216ed0d259883eb603ca222 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 14:25:21 +0200 Subject: [PATCH 04/99] feat(D.2b): RuntimeOptions.RetailUi + AcDir toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two startup-time env toggles that Phase D.2b's retail-UI panel frame will read: - ACDREAM_RETAIL_UI=1 → opts.RetailUi (bool, default false) - ACDREAM_AC_DIR= → opts.AcDir (string?, default null) Both follow the existing helper conventions (IsExactlyOne / NullIfEmpty). No call sites broke because the only construction site in RuntimeOptions.cs already uses named arguments. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/RuntimeOptions.cs | 8 ++++-- .../RuntimeOptionsRetailUiTests.cs | 28 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index a1ceb4db..9be7601d 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -39,7 +39,9 @@ public sealed record RuntimeOptions( bool RetailCloseDegrades, bool DumpSceneryZ, bool DumpLiveSpawns, - int? LegacyStreamRadius) + int? LegacyStreamRadius, + bool RetailUi, + string? AcDir) { /// /// Build options from the process environment. Used by @@ -81,7 +83,9 @@ public sealed record RuntimeOptions( DumpLiveSpawns: IsExactlyOne(env("ACDREAM_DUMP_LIVE_SPAWNS")), // Legacy override for ACDREAM_STREAM_RADIUS. Caller applies it on // top of the quality preset's radii. Null when unset or invalid. - LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS"))); + LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")), + RetailUi: IsExactlyOne(env("ACDREAM_RETAIL_UI")), + AcDir: NullIfEmpty(env("ACDREAM_AC_DIR"))); } /// True iff live-mode credentials are present and valid for connecting. diff --git a/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs new file mode 100644 index 00000000..b18590ae --- /dev/null +++ b/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using AcDream.App; + +namespace AcDream.App.Tests; + +public class RuntimeOptionsRetailUiTests +{ + [Fact] + public void Parse_ReadsRetailUiAndAcDir() + { + var env = new Dictionary + { + ["ACDREAM_RETAIL_UI"] = "1", + ["ACDREAM_AC_DIR"] = @"C:\Turbine\Asheron's Call", + }; + var opts = RuntimeOptions.Parse("dats", k => env.GetValueOrDefault(k)); + Assert.True(opts.RetailUi); + Assert.Equal(@"C:\Turbine\Asheron's Call", opts.AcDir); + } + + [Fact] + public void Parse_DefaultsRetailUiOffAndAcDirNull() + { + var opts = RuntimeOptions.Parse("dats", _ => null); + Assert.False(opts.RetailUi); + Assert.Null(opts.AcDir); + } +} From c9eef1d7cd20de74da74fda7232e8233eb6adf49 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 14:28:29 +0200 Subject: [PATCH 05/99] feat(D.2b): textured-sprite path in TextRenderer + UV-rect DrawSprite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add uUseTexture==2 (RGBA modulate) branch to ui_text.frag so dat sprites can be drawn through the existing 2D batcher without touching the font path. TextRenderer gains _spriteBufs (per-GL-handle List), DrawSprite(), and a Flush block that issues one draw call per distinct texture with uUseTexture=2. Also adds DepthMask(false) in the state-save block (restored to true after) to prevent the transparent-quad pass from writing depth and corrupting the 3D scene if the UI is flushed mid-frame. TextureCache gains GetOrUpload(surfaceId, out width, out height) — caches pixel dimensions alongside the GL handle so UI 9-slice geometry can compute slice UVs from the source image size without a second decode. UiRenderContext gains a DrawSprite forwarder that applies the current 2D translate stack, matching the DrawRect / DrawRectOutline pattern. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Rendering/Shaders/ui_text.frag | 5 ++- src/AcDream.App/Rendering/TextRenderer.cs | 39 ++++++++++++++++++- src/AcDream.App/Rendering/TextureCache.cs | 23 +++++++++++ src/AcDream.App/UI/UiRenderContext.cs | 5 +++ 4 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/AcDream.App/Rendering/Shaders/ui_text.frag b/src/AcDream.App/Rendering/Shaders/ui_text.frag index 7740ea11..75c9cd3d 100644 --- a/src/AcDream.App/Rendering/Shaders/ui_text.frag +++ b/src/AcDream.App/Rendering/Shaders/ui_text.frag @@ -7,10 +7,13 @@ uniform sampler2D uTex; uniform int uUseTexture; void main() { - if (uUseTexture != 0) { + if (uUseTexture == 1) { // Font atlas is a single-channel R8 texture; red = coverage alpha. float coverage = texture(uTex, vUv).r; FragColor = vec4(vColor.rgb, vColor.a * coverage); + } else if (uUseTexture == 2) { + // RGBA dat sprite (decoded to RGBA8); modulate by tint/alpha. + FragColor = texture(uTex, vUv) * vColor; } else { FragColor = vColor; } diff --git a/src/AcDream.App/Rendering/TextRenderer.cs b/src/AcDream.App/Rendering/TextRenderer.cs index ad04da1a..b07a9d40 100644 --- a/src/AcDream.App/Rendering/TextRenderer.cs +++ b/src/AcDream.App/Rendering/TextRenderer.cs @@ -29,6 +29,7 @@ public sealed unsafe class TextRenderer : IDisposable private readonly List _textBuf = new(8192); private readonly List _rectBuf = new(1024); + private readonly Dictionary> _spriteBufs = new(); private int _textVerts; private int _rectVerts; private Vector2 _screenSize; @@ -64,6 +65,7 @@ public sealed unsafe class TextRenderer : IDisposable _screenSize = screenSize; _textBuf.Clear(); _rectBuf.Clear(); + foreach (var b in _spriteBufs.Values) b.Clear(); _textVerts = 0; _rectVerts = 0; } @@ -129,6 +131,22 @@ public sealed unsafe class TextRenderer : IDisposable } } + /// + /// Draw a textured sprite quad in screen pixel space with an explicit + /// source-UV rectangle (for 9-slice / atlas sub-regions). Batched per + /// GL texture handle; flushed with uUseTexture=2 (RGBA modulate). + /// + public void DrawSprite(uint texture, float x, float y, float w, float h, + float u0, float v0, float u1, float v1, Vector4 tint) + { + if (!_spriteBufs.TryGetValue(texture, out var buf)) + { + buf = new List(256); + _spriteBufs[texture] = buf; + } + AppendQuad(buf, x, y, w, h, u0, v0, u1, v1, tint); + } + private static void AppendQuad(List buf, float x, float y, float w, float h, float u0, float v0, float u1, float v1, Vector4 color) @@ -159,7 +177,9 @@ public sealed unsafe class TextRenderer : IDisposable /// Upload + draw accumulated rects + text. font may be null if only DrawRect was used. public void Flush(BitmapFont? font) { - if (_textVerts == 0 && _rectVerts == 0) return; + bool hasSprites = false; + foreach (var b in _spriteBufs.Values) if (b.Count > 0) { hasSprites = true; break; } + if (_textVerts == 0 && _rectVerts == 0 && !hasSprites) return; _shader.Use(); _shader.SetVec2("uScreenSize", _screenSize); @@ -173,6 +193,7 @@ public sealed unsafe class TextRenderer : IDisposable bool wasCull = _gl.IsEnabled(EnableCap.CullFace); _gl.Disable(EnableCap.DepthTest); _gl.Disable(EnableCap.CullFace); + _gl.DepthMask(false); _gl.Enable(EnableCap.Blend); _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); @@ -195,7 +216,23 @@ public sealed unsafe class TextRenderer : IDisposable _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_textVerts); } + // RGBA dat sprites — one draw call per distinct GL texture. + if (hasSprites) + { + _shader.SetInt("uUseTexture", 2); + _gl.ActiveTexture(TextureUnit.Texture0); + _shader.SetInt("uTex", 0); + foreach (var kv in _spriteBufs) + { + if (kv.Value.Count == 0) continue; + _gl.BindTexture(TextureTarget.Texture2D, kv.Key); + UploadBuffer(kv.Value); + _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)(kv.Value.Count / FloatsPerVertex)); + } + } + // Restore GL state. + _gl.DepthMask(true); if (!wasBlend) _gl.Disable(EnableCap.Blend); if (wasCull) _gl.Enable(EnableCap.CullFace); if (wasDepth) _gl.Enable(EnableCap.DepthTest); diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs index 056ec01f..efefbf0b 100644 --- a/src/AcDream.App/Rendering/TextureCache.cs +++ b/src/AcDream.App/Rendering/TextureCache.cs @@ -14,6 +14,7 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab private readonly GL _gl; private readonly DatCollection _dats; private readonly Dictionary _handlesBySurfaceId = new(); + private readonly Dictionary _sizeBySurfaceId = new(); /// /// Composite cache for surface-with-override-origtex entries (Phase 5 /// TextureChanges). Key = (baseSurfaceId, overrideOrigTextureId), @@ -80,6 +81,28 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab return h; } + /// + /// Like but also returns the decoded + /// pixel dimensions. UI 9-slice geometry needs the source size to + /// compute slice UVs. Cached alongside the handle. + /// + public uint GetOrUpload(uint surfaceId, out int width, out int height) + { + if (_handlesBySurfaceId.TryGetValue(surfaceId, out var existing) + && _sizeBySurfaceId.TryGetValue(surfaceId, out var sz)) + { + width = sz.w; height = sz.h; + return existing; + } + + var decoded = DecodeFromDats(surfaceId, origTextureOverride: null, paletteOverride: null); + uint h = UploadRgba8(decoded); + _handlesBySurfaceId[surfaceId] = h; + _sizeBySurfaceId[surfaceId] = (decoded.Width, decoded.Height); + width = decoded.Width; height = decoded.Height; + return h; + } + /// /// Alpha-channel histogram for one decoded texture. Used to diagnose /// "why are clouds not transparent" — if cloud textures come out with diff --git a/src/AcDream.App/UI/UiRenderContext.cs b/src/AcDream.App/UI/UiRenderContext.cs index 51ce7b83..01d81277 100644 --- a/src/AcDream.App/UI/UiRenderContext.cs +++ b/src/AcDream.App/UI/UiRenderContext.cs @@ -53,6 +53,11 @@ public sealed class UiRenderContext public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f) => TextRenderer.DrawRectOutline(_current.X + x, _current.Y + y, w, h, color, thickness); + public void DrawSprite(uint texture, float x, float y, float w, float h, + float u0, float v0, float u1, float v1, Vector4 tint) + => TextRenderer.DrawSprite(texture, + _current.X + x, _current.Y + y, w, h, u0, v0, u1, v1, tint); + public void DrawString(string text, float x, float y, Vector4 color, BitmapFont? font = null) { var f = font ?? DefaultFont; From 66888d2c8e34434ec7c97d6f749e735e17f93c5c Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 14:36:07 +0200 Subject: [PATCH 06/99] fix(textures): DecodeSolidColor null-safe against null ColorValue A Base1Solid (or OrigTextureId==0) Surface can carry a null ColorValue; DecodeSolidColor dereferenced it (color.Alpha) and threw NullReferenceException. It is called directly from TextureCache.DecodeFromDats, OUTSIDE DecodeRenderSurface's try/catch, so the NRE crashed the whole client. Surfaced by the D.2b chrome prove-out feeding UI surface ids. Guard null -> Magenta (the decoder's existing "undecodable" sentinel). Test added. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core/Textures/SurfaceDecoder.cs | 5 +++++ .../Textures/SurfaceDecoderSolidColorTests.cs | 17 +++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 tests/AcDream.Core.Tests/Textures/SurfaceDecoderSolidColorTests.cs diff --git a/src/AcDream.Core/Textures/SurfaceDecoder.cs b/src/AcDream.Core/Textures/SurfaceDecoder.cs index 49cfe199..f727a59c 100644 --- a/src/AcDream.Core/Textures/SurfaceDecoder.cs +++ b/src/AcDream.Core/Textures/SurfaceDecoder.cs @@ -80,6 +80,11 @@ public static class SurfaceDecoder /// public static DecodedTexture DecodeSolidColor(DatReaderWriter.Types.ColorARGB color, float translucency) { + // Malformed Base1Solid (or OrigTextureId==0) surface with no color value: + // signal undecodable (Magenta) instead of NRE. This method is called + // directly from TextureCache.DecodeFromDats, OUTSIDE DecodeRenderSurface's + // try/catch, so it must be null-safe itself. + if (color is null) return DecodedTexture.Magenta; float opacity = Math.Clamp(1f - translucency, 0f, 1f); byte alpha = (byte)Math.Clamp(color.Alpha * opacity, 0f, 255f); return new DecodedTexture( diff --git a/tests/AcDream.Core.Tests/Textures/SurfaceDecoderSolidColorTests.cs b/tests/AcDream.Core.Tests/Textures/SurfaceDecoderSolidColorTests.cs new file mode 100644 index 00000000..ffb4b427 --- /dev/null +++ b/tests/AcDream.Core.Tests/Textures/SurfaceDecoderSolidColorTests.cs @@ -0,0 +1,17 @@ +using AcDream.Core.Textures; +using Xunit; + +namespace AcDream.Core.Tests.Textures; + +public class SurfaceDecoderSolidColorTests +{ + [Fact] + public void DecodeSolidColor_NullColor_ReturnsMagenta_DoesNotThrow() + { + // A malformed Base1Solid surface can carry a null ColorValue. DecodeSolidColor + // is called outside DecodeRenderSurface's try/catch (from TextureCache), so it + // must be null-safe itself — return the undecodable sentinel, never NRE. + var result = SurfaceDecoder.DecodeSolidColor(null!, 0f); + Assert.Equal(DecodedTexture.Magenta, result); + } +} From 8e91805206f3537ab5d7130fddb8a4afdf9871b7 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 16:32:27 +0200 Subject: [PATCH 07/99] feat(D.2b): Step-0 chrome sprites confirmed + direct-RenderSurface upload path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step-0 prove-out result: retail UI chrome sprites are RenderSurface objects (0x06xxxxxx) that must be decoded DIRECTLY, not via the Surface->SurfaceTexture chain GetOrUpload uses for world materials (which produced 1x1 magenta/garbage). Added TextureCache.GetOrUploadRenderSurface(id, out w, out h) — Portal/HighRes TryGet -> DecodeRenderSurface(palette:null) -> upload, separately cached. This is the path UI chrome + (later) dat fonts use. Confirmed the universal floating-window bevel is an 8-piece border + center fill: center 0x06004CC2 (48x48) edges 0x060074BF/C1 (10x5 horiz) 0x060074C0/C2 (5x10 vert) corners 0x060074C3..C6 (5x5) Recorded in RetailChromeSprites.cs (edge/corner->position mapping is a best guess pending the LayoutDesc 0x21000040 parse; visually confirmed at panel render). The memory-note ids were right; only the decode path was wrong. Temporary prove-out harness (added to GameWindow.OnRender) removed. proveout*.log gitignored. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + src/AcDream.App/Rendering/TextureCache.cs | 43 ++++++++++++++++++++ src/AcDream.App/UI/RetailChromeSprites.cs | 48 +++++++++++++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 src/AcDream.App/UI/RetailChromeSprites.cs diff --git a/.gitignore b/.gitignore index ca2f9cf2..215c618b 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ references/* /.superpowers/ launch.log launch-*.log +proveout*.log launch.utf8.log n4-verify*.log diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs index efefbf0b..1fbf0817 100644 --- a/src/AcDream.App/Rendering/TextureCache.cs +++ b/src/AcDream.App/Rendering/TextureCache.cs @@ -31,6 +31,12 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab private readonly Dictionary<(uint surfaceId, uint origTexOverride, ulong paletteHash), uint> _handlesByPalette = new(); private uint _magentaHandle; + // Direct-RenderSurface caches for UI sprites: 0x06xxxxxx RenderSurface ids + // decoded directly (Portal/HighRes → DecodeRenderSurface), bypassing the + // Surface→SurfaceTexture chain that GetOrUpload uses for world materials. + private readonly Dictionary _handlesByRenderSurfaceId = new(); + private readonly Dictionary _rsSizeById = new(); + private readonly Wb.BindlessSupport? _bindless; // Bindless / Texture2DArray parallel caches. Keys mirror the legacy three @@ -103,6 +109,43 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab return h; } + /// + /// Upload a UI sprite by its RenderSurface DataId (0x06xxxxxx), decoded + /// DIRECTLY (Portal/HighRes → DecodeRenderSurface) rather than through the + /// Surface→SurfaceTexture chain that uses + /// for world-geometry materials. This is the correct path for retail UI + /// chrome + font glyph sheets, which reference RenderSurface directly. + /// Palette is null for now (a paletted INDEX16/P8 UI sprite would return + /// Magenta — wire a UI palette when one is actually encountered). Returns a + /// 1x1 magenta handle on miss. + /// + public uint GetOrUploadRenderSurface(uint renderSurfaceId, out int width, out int height) + { + if (_handlesByRenderSurfaceId.TryGetValue(renderSurfaceId, out var existing) + && _rsSizeById.TryGetValue(renderSurfaceId, out var sz)) + { + width = sz.w; height = sz.h; + return existing; + } + + DecodedTexture decoded; + if (_dats.Portal.TryGet(renderSurfaceId, out var rs) + || _dats.HighRes.TryGet(renderSurfaceId, out rs)) + { + decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette: null); + } + else + { + decoded = DecodedTexture.Magenta; + } + + uint h = UploadRgba8(decoded); + _handlesByRenderSurfaceId[renderSurfaceId] = h; + _rsSizeById[renderSurfaceId] = (decoded.Width, decoded.Height); + width = decoded.Width; height = decoded.Height; + return h; + } + /// /// Alpha-channel histogram for one decoded texture. Used to diagnose /// "why are clouds not transparent" — if cloud textures come out with diff --git a/src/AcDream.App/UI/RetailChromeSprites.cs b/src/AcDream.App/UI/RetailChromeSprites.cs new file mode 100644 index 00000000..70a8cb4e --- /dev/null +++ b/src/AcDream.App/UI/RetailChromeSprites.cs @@ -0,0 +1,48 @@ +namespace AcDream.App.UI; + +/// +/// Retail window-chrome RenderSurface DataIds, CONFIRMED via the D.2b Step-0 +/// prove-out (2026-06-14). These are RenderSurface objects (0x06xxxxxx) decoded +/// DIRECTLY (), NOT +/// through the Surface→SurfaceTexture chain. +/// +/// +/// The universal floating-window bevel is an 8-piece border (4 corners +/// 5×5 + 4 edges) drawn around a tiled center fill — it is NOT a single +/// 9-slice texture. Decoded sizes are in the comments (from the prove-out). +/// +/// +/// +/// The edge/corner → position mapping below is a reasonable guess pending the +/// LayoutDesc 0x21000040 parse (sub-project 3) and is confirmed visually in the +/// first vitals-panel render. If a corner's bevel highlight looks wrong, swap +/// the four corner constants; if top/bottom or left/right look inverted, swap +/// those edge pairs. +/// +/// +public static class RetailChromeSprites +{ + /// Tiled interior fill — the shared panel background (48×48). + public const uint CenterFill = 0x06004CC2; + + /// Horizontal top edge (10×5, tiled across the top span). + public const uint TopEdge = 0x060074BF; + /// Horizontal bottom edge (10×5). + public const uint BottomEdge = 0x060074C1; + /// Vertical left edge (5×10). + public const uint LeftEdge = 0x060074C0; + /// Vertical right edge (5×10). + public const uint RightEdge = 0x060074C2; + + /// Top-left corner (5×5). + public const uint CornerTL = 0x060074C3; + /// Top-right corner (5×5). + public const uint CornerTR = 0x060074C4; + /// Bottom-left corner (5×5). + public const uint CornerBL = 0x060074C5; + /// Bottom-right corner (5×5). + public const uint CornerBR = 0x060074C6; + + /// Border thickness in pixels = the corner/edge sprite size (5px). + public const int Border = 5; +} From 0bf790c8bf48825fb788825ae315f436791c17d0 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 16:36:11 +0200 Subject: [PATCH 08/99] =?UTF-8?q?feat(D.2b):=20UiNineSlicePanel=20?= =?UTF-8?q?=E2=80=94=208-piece=20retail=20window=20frame=20+=20geometry=20?= =?UTF-8?q?test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the retail floating-window bevel as a UiPanel subclass using RetailChromeSprites: 4 tiled edges + 4 stretched corners + tiled center fill, matching the 8-piece border layout confirmed by the D.2b Step-0 prove-out. Resolver delegate keeps GL out of unit tests. Geometry verified by ComputeFrameRects_PlacesCornersEdgesAndCenter (1/1 pass). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/UiNineSlicePanel.cs | 85 +++++++++++++++++++ .../UI/UiNineSlicePanelTests.cs | 27 ++++++ 2 files changed, 112 insertions(+) create mode 100644 src/AcDream.App/UI/UiNineSlicePanel.cs create mode 100644 tests/AcDream.App.Tests/UI/UiNineSlicePanelTests.cs diff --git a/src/AcDream.App/UI/UiNineSlicePanel.cs b/src/AcDream.App/UI/UiNineSlicePanel.cs new file mode 100644 index 00000000..2f04229a --- /dev/null +++ b/src/AcDream.App/UI/UiNineSlicePanel.cs @@ -0,0 +1,85 @@ +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// A whose background is the retail 8-piece window bevel +/// (): 4 corners + 4 edges around a tiled +/// center fill. Retires the flat translucent rect (divergence row TS-30). +/// Sprites resolve to (GL handle, width, height) via an injected delegate so +/// the widget is testable without GL. In production: +/// id => { var t = cache.GetOrUploadRenderSurface(id, out var w, out var h); return (t, w, h); }. +/// +public sealed class UiNineSlicePanel : UiPanel +{ + /// A placed chrome piece: destination rect in local pixel space. + public readonly record struct Rect(float X, float Y, float W, float H); + + /// The nine destination rects for an 8-piece border + center. + public readonly record struct FrameRects( + Rect Center, Rect Top, Rect Bottom, Rect Left, Rect Right, + Rect TL, Rect TR, Rect BL, Rect BR); + + private readonly System.Func _resolve; + + public UiNineSlicePanel(System.Func resolve) + { + _resolve = resolve; + BackgroundColor = Vector4.Zero; // suppress the base flat-rect fill + BorderColor = Vector4.Zero; + } + + /// + /// Destination rects (local px) for a frame of (, + /// ) with border thickness : + /// b×b corners, top/bottom edges spanning the interior width at height b, + /// left/right edges spanning the interior height at width b, center fills + /// the interior. + /// + public static FrameRects ComputeFrameRects(float w, float h, int b) + { + float innerW = w - 2 * b; + float innerH = h - 2 * b; + return new FrameRects( + Center: new Rect(b, b, innerW, innerH), + Top: new Rect(b, 0, innerW, b), + Bottom: new Rect(b, h - b, innerW, b), + Left: new Rect(0, b, b, innerH), + Right: new Rect(w - b, b, b, innerH), + TL: new Rect(0, 0, b, b), + TR: new Rect(w - b, 0, b, b), + BL: new Rect(0, h - b, b, b), + BR: new Rect(w - b, h - b, b, b)); + } + + protected override void OnDraw(UiRenderContext ctx) + { + var r = ComputeFrameRects(Width, Height, RetailChromeSprites.Border); + // center + edges tile (UV repeat); corners stretch 1:1. + DrawTiled(ctx, RetailChromeSprites.CenterFill, r.Center); + DrawTiled(ctx, RetailChromeSprites.TopEdge, r.Top); + DrawTiled(ctx, RetailChromeSprites.BottomEdge, r.Bottom); + DrawTiled(ctx, RetailChromeSprites.LeftEdge, r.Left); + DrawTiled(ctx, RetailChromeSprites.RightEdge, r.Right); + DrawStretched(ctx, RetailChromeSprites.CornerTL, r.TL); + DrawStretched(ctx, RetailChromeSprites.CornerTR, r.TR); + DrawStretched(ctx, RetailChromeSprites.CornerBL, r.BL); + DrawStretched(ctx, RetailChromeSprites.CornerBR, r.BR); + } + + private void DrawTiled(UiRenderContext ctx, uint id, Rect d) + { + if (d.W <= 0 || d.H <= 0) return; + var (tex, tw, th) = _resolve(id); + if (tex == 0 || tw == 0 || th == 0) return; + ctx.DrawSprite(tex, d.X, d.Y, d.W, d.H, 0, 0, d.W / tw, d.H / th, Vector4.One); + } + + private void DrawStretched(UiRenderContext ctx, uint id, Rect d) + { + if (d.W <= 0 || d.H <= 0) return; + var (tex, _, _) = _resolve(id); + if (tex == 0) return; + ctx.DrawSprite(tex, d.X, d.Y, d.W, d.H, 0, 0, 1, 1, Vector4.One); + } +} diff --git a/tests/AcDream.App.Tests/UI/UiNineSlicePanelTests.cs b/tests/AcDream.App.Tests/UI/UiNineSlicePanelTests.cs new file mode 100644 index 00000000..8a2b3d0a --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiNineSlicePanelTests.cs @@ -0,0 +1,27 @@ +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public class UiNineSlicePanelTests +{ + [Fact] + public void ComputeFrameRects_PlacesCornersEdgesAndCenter() + { + var r = UiNineSlicePanel.ComputeFrameRects(100, 80, 5); + + // 5x5 corners at the four corners + Assert.Equal(new UiNineSlicePanel.Rect(0, 0, 5, 5), r.TL); + Assert.Equal(new UiNineSlicePanel.Rect(95, 0, 5, 5), r.TR); + Assert.Equal(new UiNineSlicePanel.Rect(0, 75, 5, 5), r.BL); + Assert.Equal(new UiNineSlicePanel.Rect(95, 75, 5, 5), r.BR); + + // edges span the interior (100-2*5 = 90 wide, 80-2*5 = 70 tall) + Assert.Equal(new UiNineSlicePanel.Rect(5, 0, 90, 5), r.Top); + Assert.Equal(new UiNineSlicePanel.Rect(5, 75, 90, 5), r.Bottom); + Assert.Equal(new UiNineSlicePanel.Rect(0, 5, 5, 70), r.Left); + Assert.Equal(new UiNineSlicePanel.Rect(95, 5, 5, 70), r.Right); + + // center fills the interior + Assert.Equal(new UiNineSlicePanel.Rect(5, 5, 90, 70), r.Center); + } +} From 064ef41ce4f5a77cfc01295746db374a52aff1e1 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 16:38:07 +0200 Subject: [PATCH 09/99] feat(D.2b): UiMeter vital bar + fill-geometry tests Adds UiMeter, the horizontal vital-bar widget for the D.2b retail-look UI toolkit. Solid-color fill for Spec 1; the retail orb sprite + scissor crop path is reserved for a later sub-phase. Five unit tests (1 Fact + 4 Theory) cover half-fill geometry and clamping at -1/0/1/2 fractions. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/UiMeter.cs | 40 ++++++++++++++++++++++ tests/AcDream.App.Tests/UI/UiMeterTests.cs | 25 ++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 src/AcDream.App/UI/UiMeter.cs create mode 100644 tests/AcDream.App.Tests/UI/UiMeterTests.cs diff --git a/src/AcDream.App/UI/UiMeter.cs b/src/AcDream.App/UI/UiMeter.cs new file mode 100644 index 00000000..9fcdd5d6 --- /dev/null +++ b/src/AcDream.App/UI/UiMeter.cs @@ -0,0 +1,40 @@ +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. +/// +public sealed class UiMeter : UiElement +{ + /// Fill fraction provider; a null result draws an empty bar. + public Func Fill { get; set; } = () => 0f; + public Vector4 BarColor { get; set; } = new(1f, 0f, 0f, 1f); + public Vector4 BgColor { get; set; } = new(0f, 0f, 0f, 0.5f); + + public UiMeter() { ClickThrough = true; } + + /// Clamp to [0,1] and return the fill rect + /// (local px) for a bar of x . + public static (float x, float y, float w, float h) ComputeFillRect( + float pct, float w, float h) + { + if (pct < 0f) pct = 0f; + if (pct > 1f) pct = 1f; + return (0f, 0f, w * pct, h); + } + + 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); + } + } +} diff --git a/tests/AcDream.App.Tests/UI/UiMeterTests.cs b/tests/AcDream.App.Tests/UI/UiMeterTests.cs new file mode 100644 index 00000000..9e7637e9 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiMeterTests.cs @@ -0,0 +1,25 @@ +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public class UiMeterTests +{ + [Fact] + public void ComputeFillRect_HalfFillIsHalfWidth() + { + var (x, y, w, h) = UiMeter.ComputeFillRect(0.5f, 200f, 12f); + Assert.Equal(0f, x); Assert.Equal(0f, y); + Assert.Equal(100f, w); Assert.Equal(12f, h); + } + + [Theory] + [InlineData(-1f, 0f)] // clamps below 0 + [InlineData(2f, 200f)] // clamps above 1 + [InlineData(0f, 0f)] + [InlineData(1f, 200f)] + public void ComputeFillRect_ClampsFraction(float pct, float expectedW) + { + var (_, _, w, _) = UiMeter.ComputeFillRect(pct, 200f, 12f); + Assert.Equal(expectedW, w); + } +} From b18403da028b0d2ac188bf2337b509cb9e72d236 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 16:56:57 +0200 Subject: [PATCH 10/99] 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); + } } } From 97bd1d2f090d0673622724e3af4168a2edf4c030 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 17:31:55 +0200 Subject: [PATCH 11/99] feat(D.2b): controls.ini stylesheet loader + apply title color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ControlsIni — a minimal flat-INI reader for retail's controls.ini (#AARRGGBB alpha-first color tokens; case-insensitive section/key lookup; missing file returns an empty sheet with no throw). Wires the [title] color token into the vitals panel's UiLabel in GameWindow.OnLoad, with hardcoded white as the fallback. Visually a no-op (retail's [title] color is white), but proves the stylesheet plumbing end-to-end (D.2b §7). Three unit tests cover section parsing, #AARRGGBB decode, and graceful missing-file handling. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 10 ++- src/AcDream.App/UI/ControlsIni.cs | 65 +++++++++++++++++++ .../AcDream.App.Tests/UI/ControlsIniTests.cs | 38 +++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 src/AcDream.App/UI/ControlsIni.cs create mode 100644 tests/AcDream.App.Tests/UI/ControlsIniTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index efa13627..b8bdbd66 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1748,12 +1748,20 @@ public sealed class GameWindow : IDisposable return (t, w, h); } + // Phase D.2b — optional retail stylesheet. controls.ini lives under + // the AC install (ACDREAM_AC_DIR); absent → source-verified fallback. + var controls = _options.AcDir is { } acDir + ? AcDream.App.UI.ControlsIni.Load(System.IO.Path.Combine(acDir, "controls", "controls.ini")) + : AcDream.App.UI.ControlsIni.Parse(string.Empty); + var titleColor = controls.TryColor("title", "color", out var tc) + ? tc : new System.Numerics.Vector4(1f, 1f, 1f, 1f); + 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), + TextColor = titleColor, }); var vm = _vitalsVm!; diff --git a/src/AcDream.App/UI/ControlsIni.cs b/src/AcDream.App/UI/ControlsIni.cs new file mode 100644 index 00000000..2812d696 --- /dev/null +++ b/src/AcDream.App/UI/ControlsIni.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// Minimal reader for retail's controls.ini — a flat INI with one +/// [section] per element type. Colors are #AARRGGBB (alpha +/// first). Optional: a missing file yields an empty sheet (callers fall back +/// to hardcoded defaults). See the D.2b spec §7. +/// +public sealed class ControlsIni +{ + private readonly Dictionary> _sections; + + private ControlsIni(Dictionary> s) => _sections = s; + + /// Load from disk; returns an empty sheet if the file is absent. + public static ControlsIni Load(string path) + => System.IO.File.Exists(path) + ? Parse(System.IO.File.ReadAllText(path)) + : new ControlsIni(new()); + + public static ControlsIni Parse(string text) + { + var sections = new Dictionary>(System.StringComparer.OrdinalIgnoreCase); + Dictionary? cur = null; + foreach (var raw in text.Split('\n')) + { + var line = raw.Trim(); + if (line.Length == 0 || line[0] == ';' || line[0] == '#') continue; + if (line[0] == '[' && line[^1] == ']') + { + var name = line[1..^1].Trim(); + cur = new Dictionary(System.StringComparer.OrdinalIgnoreCase); + sections[name] = cur; + continue; + } + int eq = line.IndexOf('='); + if (eq <= 0 || cur is null) continue; + cur[line[..eq].Trim()] = line[(eq + 1)..].Trim(); + } + return new ControlsIni(sections); + } + + public string? Get(string section, string key) + => _sections.TryGetValue(section, out var s) && s.TryGetValue(key, out var v) ? v : null; + + /// Parse a #AARRGGBB token into an RGBA . + public bool TryColor(string section, string key, out Vector4 color) + { + color = default; + var v = Get(section, key); + if (v is null || v.Length != 9 || v[0] != '#') return false; + if (!uint.TryParse(v.AsSpan(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out uint argb)) + return false; + float a = ((argb >> 24) & 0xFF) / 255f; + float r = ((argb >> 16) & 0xFF) / 255f; + float g = ((argb >> 8) & 0xFF) / 255f; + float b = (argb & 0xFF) / 255f; + color = new Vector4(r, g, b, a); + return true; + } +} diff --git a/tests/AcDream.App.Tests/UI/ControlsIniTests.cs b/tests/AcDream.App.Tests/UI/ControlsIniTests.cs new file mode 100644 index 00000000..d4802e27 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/ControlsIniTests.cs @@ -0,0 +1,38 @@ +using System.Numerics; +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public class ControlsIniTests +{ + [Fact] + public void Parse_ReadsSectionTokens() + { + var ini = ControlsIni.Parse( + "[title]\nheight=19\ncolor=#FFFFFFFF\nfont=font://Verdana-10-bold\n" + + "[body]\nbgcolor=#00000000\ncolor_border=#FF4F657D\n"); + + Assert.Equal("19", ini.Get("title", "height")); + Assert.Equal("font://Verdana-10-bold", ini.Get("title", "font")); + Assert.Null(ini.Get("title", "missing")); + Assert.Null(ini.Get("nosuch", "height")); + } + + [Fact] + public void TryColor_ParsesAlphaFirstHex() + { + var ini = ControlsIni.Parse("[body]\ncolor_border=#FF4F657D\n"); + Assert.True(ini.TryColor("body", "color_border", out Vector4 c)); + Assert.Equal(0xFF / 255f, c.W, 5); // alpha + Assert.Equal(0x4F / 255f, c.X, 5); // red + Assert.Equal(0x65 / 255f, c.Y, 5); // green + Assert.Equal(0x7D / 255f, c.Z, 5); // blue + } + + [Fact] + public void Load_MissingFileReturnsEmptyNotThrow() + { + var ini = ControlsIni.Load(@"Z:\does\not\exist\controls.ini"); + Assert.Null(ini.Get("title", "height")); // empty, no throw + } +} From 07bf6cbf600c692babd48a40d9d4cf4ad7542809 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 17:38:07 +0200 Subject: [PATCH 12/99] feat(D.2b): MarkupDocument (XML -> UiElement tree); vitals panel from vitals.xml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Task 8 of the D.2b retail-UI plan. MarkupDocument.Build() parses KSML-style panel markup into a live UiNineSlicePanel subtree, resolving {Binding} attribute expressions against a supplied object via reflection. Color format is #AARRGGBB (alpha-first, matching controls.ini). Handles root (geometry + optional title label) and children (fill, label, bar color). Future element kinds (label, button, image) extend the switch without touching existing code. vitals.xml encodes the just-approved vitals panel layout (health red #FFC70D0D, stamina gold #FFD49E1F, mana blue #FF1F33D9); ships next to the binary via PreserveNewest csproj rule. GameWindow.cs drops the 35-line hand-built panel block in favour of a 4-line File.ReadAllText + MarkupDocument.Build call — identical tree, identical render, now data-driven. 2 new tests (Build_CreatesPanelWithMeterFillLabelAndGeometry, Build_NullBindingValuesYieldNullFillAndLabel) + 11 total targeted green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/AcDream.App.csproj | 5 + src/AcDream.App/Rendering/GameWindow.cs | 39 +----- src/AcDream.App/UI/MarkupDocument.cs | 118 ++++++++++++++++++ src/AcDream.App/UI/assets/vitals.xml | 5 + .../UI/MarkupDocumentTests.cs | 50 ++++++++ 5 files changed, 182 insertions(+), 35 deletions(-) create mode 100644 src/AcDream.App/UI/MarkupDocument.cs create mode 100644 src/AcDream.App/UI/assets/vitals.xml create mode 100644 tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj index d50c6b46..64eac77a 100644 --- a/src/AcDream.App/AcDream.App.csproj +++ b/src/AcDream.App/AcDream.App.csproj @@ -50,6 +50,11 @@ PreserveNewest + + + PreserveNewest + diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index b8bdbd66..74b8a5d5 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1753,42 +1753,11 @@ public sealed class GameWindow : IDisposable var controls = _options.AcDir is { } acDir ? AcDream.App.UI.ControlsIni.Load(System.IO.Path.Combine(acDir, "controls", "controls.ini")) : AcDream.App.UI.ControlsIni.Parse(string.Empty); - var titleColor = controls.TryColor("title", "color", out var tc) - ? tc : new System.Numerics.Vector4(1f, 1f, 1f, 1f); - - 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 = titleColor, - }); - - 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, - }); - + string vitalsXml = System.IO.File.ReadAllText( + System.IO.Path.Combine(AppContext.BaseDirectory, "UI", "assets", "vitals.xml")); + var panel = AcDream.App.UI.MarkupDocument.Build(vitalsXml, _vitalsVm!, ResolveChrome, controls); _uiHost.Root.AddChild(panel); - Console.WriteLine("[D.2b] retail UI active — vitals panel wired (render-only)."); + Console.WriteLine("[D.2b] retail UI active — vitals panel from vitals.xml markup."); } // Phase N.4+N.5 — WB rendering pipeline foundation. The modern path is diff --git a/src/AcDream.App/UI/MarkupDocument.cs b/src/AcDream.App/UI/MarkupDocument.cs new file mode 100644 index 00000000..d4b0cb42 --- /dev/null +++ b/src/AcDream.App/UI/MarkupDocument.cs @@ -0,0 +1,118 @@ +using System; +using System.Globalization; +using System.Numerics; +using System.Reflection; +using System.Xml.Linq; + +namespace AcDream.App.UI; + +/// +/// Parses our KSML-style panel markup (mirrors retail's ElementDesc fields) +/// into a live subtree. {Binding} attribute +/// values resolve against a supplied object by property name (reflection). +/// This is the format the future LayoutDesc importer will emit. See D.2b spec §7. +/// +public static class MarkupDocument +{ + /// Raw XML markup for a single panel. + /// Object whose public properties are bound to {PropName} attributes. + /// Surface id → (GL handle, width, height) for chrome sprites. + /// Optional controls.ini stylesheet for the title color. + public static UiNineSlicePanel Build( + string xml, object binding, Func resolve, + ControlsIni? style = null) + { + var root = XDocument.Parse(xml).Root ?? throw new FormatException("empty markup"); + if (root.Name.LocalName != "panel") + throw new FormatException($"root must be , got <{root.Name.LocalName}>"); + + var panel = new UiNineSlicePanel(resolve) + { + Left = F(root, "x"), + Top = F(root, "y"), + Width = F(root, "w"), + Height = F(root, "h"), + }; + + string? title = (string?)root.Attribute("title"); + if (!string.IsNullOrEmpty(title)) + { + Vector4 tc = style is not null && style.TryColor("title", "color", out var c) ? c : Vector4.One; + panel.AddChild(new UiLabel { Text = title, Left = 8, Top = 4, TextColor = tc }); + } + + foreach (var el in root.Elements()) + { + switch (el.Name.LocalName) + { + case "meter": + var cur = BindUint((string?)el.Attribute("cur"), binding); + var max = BindUint((string?)el.Attribute("max"), binding); + panel.AddChild(new UiMeter + { + Left = F(el, "x"), + Top = F(el, "y"), + Width = F(el, "w"), + Height = F(el, "h"), + BarColor = Color((string?)el.Attribute("color")), + Fill = BindFloat((string?)el.Attribute("fill"), binding), + Label = () => (cur(), max()) is (uint c, uint m) ? $"{c}/{m}" : null, + }); + break; + // future element kinds (label, button, image) added here + } + } + return panel; + } + + private static float F(XElement e, string attr) + => float.TryParse((string?)e.Attribute(attr), NumberStyles.Float, + CultureInfo.InvariantCulture, out var v) ? v : 0f; + + /// + /// Parses #AARRGGBB → RGBA (alpha first, matching + /// controls.ini convention). Falls back to opaque white on bad input. + /// + private static Vector4 Color(string? hex) + { + if (hex is { Length: 9 } && hex[0] == '#' + && uint.TryParse(hex.AsSpan(1), NumberStyles.HexNumber, + CultureInfo.InvariantCulture, out uint argb)) + return new Vector4( + ((argb >> 16) & 0xFF) / 255f, + ((argb >> 8) & 0xFF) / 255f, + (argb & 0xFF) / 255f, + ((argb >> 24) & 0xFF) / 255f); + return Vector4.One; + } + + private static Func BindFloat(string? expr, object binding) + { + var pi = Prop(expr, binding); + if (pi is null) return () => 0f; + return () => pi.GetValue(binding) switch + { + float f => f, + null => (float?)null, + var v => Convert.ToSingle(v, CultureInfo.InvariantCulture), + }; + } + + private static Func BindUint(string? expr, object binding) + { + var pi = Prop(expr, binding); + if (pi is null) return () => null; + return () => pi.GetValue(binding) switch + { + uint u => u, + null => (uint?)null, + var v => Convert.ToUInt32(v, CultureInfo.InvariantCulture), + }; + } + + private static PropertyInfo? Prop(string? expr, object binding) + { + if (expr is null || expr.Length < 3 || expr[0] != '{' || expr[^1] != '}') return null; + return binding.GetType().GetProperty(expr[1..^1]); + } +} diff --git a/src/AcDream.App/UI/assets/vitals.xml b/src/AcDream.App/UI/assets/vitals.xml new file mode 100644 index 00000000..868926d4 --- /dev/null +++ b/src/AcDream.App/UI/assets/vitals.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs new file mode 100644 index 00000000..8ba52d27 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs @@ -0,0 +1,50 @@ +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public class MarkupDocumentTests +{ + private sealed class FakeBinding + { + public float HealthPercent => 0.5f; + public uint? HealthCurrent => 109; + public uint? HealthMax => 218; + public float? ManaPercent => null; + public uint? ManaCurrent => null; + public uint? ManaMax => null; + } + + [Fact] + public void Build_CreatesPanelWithMeterFillLabelAndGeometry() + { + const string xml = + "" + + " " + + ""; + + var panel = MarkupDocument.Build(xml, new FakeBinding(), _ => ((uint)1, 32, 32)); + + Assert.IsType(panel); + Assert.Equal(10f, panel.Left); + Assert.Equal(220f, panel.Width); + Assert.Equal(2, panel.Children.Count); // title UiLabel + 1 meter + var meter = Assert.IsType(panel.Children[1]); + Assert.Equal(8f, meter.Left); + Assert.Equal(200f, meter.Width); + Assert.Equal(0.5f, meter.Fill()); + Assert.Equal("109/218", meter.Label()); + } + + [Fact] + public void Build_NullBindingValuesYieldNullFillAndLabel() + { + const string xml = + "" + + " " + + ""; + var panel = MarkupDocument.Build(xml, new FakeBinding(), _ => ((uint)1, 32, 32)); + var meter = Assert.IsType(panel.Children[1]); + Assert.Null(meter.Fill()); + Assert.Null(meter.Label()); + } +} From 019350fa3132669769f11071e183f3dbdaa55704 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 17:46:37 +0200 Subject: [PATCH 13/99] feat(D.2b): IUiRegistry plugin UI surface + buffered drain into UiHost Adds the plugin-facing UI registration surface (Task 9, final D.2b task). Plugins call host.Ui.AddMarkupPanel(path, binding) from Enable(); calls are buffered in BufferedUiRegistry before the GL window opens, then drained into UiHost.Root in GameWindow.OnLoad inside the RetailUi block after the first- party vitals panel. Faulty plugin markup is isolated (try/catch per panel, logged + skipped). IPluginHost.Ui added; AppPluginHost wired; StubHost in Core.Tests updated; BufferedUiRegistryTests confirms drain-once semantics. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Plugins/AppPluginHost.cs | 4 ++- src/AcDream.App/Plugins/BufferedUiRegistry.cs | 27 ++++++++++++++++++ src/AcDream.App/Program.cs | 5 ++-- src/AcDream.App/Rendering/GameWindow.cs | 28 ++++++++++++++++++- .../IPluginHost.cs | 1 + .../IUiRegistry.cs | 14 ++++++++++ .../Plugins/BufferedUiRegistryTests.cs | 21 ++++++++++++++ .../Plugins/PluginLoaderTests.cs | 6 ++++ 8 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 src/AcDream.App/Plugins/BufferedUiRegistry.cs create mode 100644 src/AcDream.Plugin.Abstractions/IUiRegistry.cs create mode 100644 tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs diff --git a/src/AcDream.App/Plugins/AppPluginHost.cs b/src/AcDream.App/Plugins/AppPluginHost.cs index 2916724e..5b06e67e 100644 --- a/src/AcDream.App/Plugins/AppPluginHost.cs +++ b/src/AcDream.App/Plugins/AppPluginHost.cs @@ -4,14 +4,16 @@ namespace AcDream.App.Plugins; public sealed class AppPluginHost : IPluginHost { - public AppPluginHost(IPluginLogger log, IGameState state, IEvents events) + public AppPluginHost(IPluginLogger log, IGameState state, IEvents events, IUiRegistry ui) { Log = log; State = state; Events = events; + Ui = ui; } public IPluginLogger Log { get; } public IGameState State { get; } public IEvents Events { get; } + public IUiRegistry Ui { get; } } diff --git a/src/AcDream.App/Plugins/BufferedUiRegistry.cs b/src/AcDream.App/Plugins/BufferedUiRegistry.cs new file mode 100644 index 00000000..bcab04fb --- /dev/null +++ b/src/AcDream.App/Plugins/BufferedUiRegistry.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using AcDream.Plugin.Abstractions; + +namespace AcDream.App.Plugins; + +/// +/// Buffers plugin calls (which run in +/// Program.cs before the GL window opens) until GameWindow drains them into the +/// UiHost tree after construction. +/// +public sealed class BufferedUiRegistry : IUiRegistry +{ + public readonly record struct Pending(string MarkupPath, object Binding); + + private readonly List _pending = new(); + + public void AddMarkupPanel(string markupPath, object binding) + => _pending.Add(new Pending(markupPath, binding)); + + /// Return + clear all buffered registrations. + public IReadOnlyList Drain() + { + var copy = _pending.ToArray(); + _pending.Clear(); + return copy; + } +} diff --git a/src/AcDream.App/Program.cs b/src/AcDream.App/Program.cs index bc43997b..b3aebd5a 100644 --- a/src/AcDream.App/Program.cs +++ b/src/AcDream.App/Program.cs @@ -23,7 +23,8 @@ var runtimeOptions = RuntimeOptions.FromEnvironment(datDir); var worldGameState = new AcDream.Core.Plugins.WorldGameState(); var worldEvents = new AcDream.Core.Plugins.WorldEvents(); -var host = new AppPluginHost(new SerilogAdapter(Log.Logger), worldGameState, worldEvents); +var uiRegistry = new AcDream.App.Plugins.BufferedUiRegistry(); +var host = new AppPluginHost(new SerilogAdapter(Log.Logger), worldGameState, worldEvents, uiRegistry); var pluginsDir = Path.Combine(AppContext.BaseDirectory, "plugins"); Log.Information("scanning plugins in {PluginsDir}", pluginsDir); @@ -56,7 +57,7 @@ try catch (Exception ex) { Log.Error(ex, "plugin enable failed: {Id}", plugin.Manifest.Id); } } - using var window = new GameWindow(runtimeOptions, worldGameState, worldEvents); + using var window = new GameWindow(runtimeOptions, worldGameState, worldEvents, uiRegistry); window.Run(); } finally diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 74b8a5d5..bea54e36 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -614,6 +614,8 @@ public sealed class GameWindow : IDisposable 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 D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad. + private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry; // 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). @@ -864,12 +866,14 @@ public sealed class GameWindow : IDisposable private int _liveAnimRejectSingleFrame; private int _liveAnimRejectPartFrames; - public GameWindow(AcDream.App.RuntimeOptions options, WorldGameState worldGameState, WorldEvents worldEvents) + public GameWindow(AcDream.App.RuntimeOptions options, WorldGameState worldGameState, WorldEvents worldEvents, + AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null) { _options = options ?? throw new System.ArgumentNullException(nameof(options)); _datDir = options.DatDir; _worldGameState = worldGameState; _worldEvents = worldEvents; + _uiRegistry = uiRegistry; SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable); LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook); } @@ -1758,6 +1762,28 @@ public sealed class GameWindow : IDisposable var panel = AcDream.App.UI.MarkupDocument.Build(vitalsXml, _vitalsVm!, ResolveChrome, controls); _uiHost.Root.AddChild(panel); Console.WriteLine("[D.2b] retail UI active — vitals panel from vitals.xml markup."); + + // Drain plugin-registered markup panels (buffered before the GL + // window opened) into the same UiRoot tree. A faulty plugin markup + // file is isolated — logged + skipped, never crashes the client. + if (_uiRegistry is not null) + { + foreach (var p in _uiRegistry.Drain()) + { + try + { + string pluginXml = System.IO.File.ReadAllText(p.MarkupPath); + var pluginPanel = AcDream.App.UI.MarkupDocument.Build( + pluginXml, p.Binding, ResolveChrome, controls); + _uiHost.Root.AddChild(pluginPanel); + Console.WriteLine($"[D.2b] plugin UI panel loaded: {p.MarkupPath}"); + } + catch (Exception ex) + { + Console.WriteLine($"[D.2b] plugin UI panel '{p.MarkupPath}' failed to load: {ex.Message}"); + } + } + } } // Phase N.4+N.5 — WB rendering pipeline foundation. The modern path is diff --git a/src/AcDream.Plugin.Abstractions/IPluginHost.cs b/src/AcDream.Plugin.Abstractions/IPluginHost.cs index 7374ea91..dca64d7b 100644 --- a/src/AcDream.Plugin.Abstractions/IPluginHost.cs +++ b/src/AcDream.Plugin.Abstractions/IPluginHost.cs @@ -10,4 +10,5 @@ public interface IPluginHost IPluginLogger Log { get; } IGameState State { get; } IEvents Events { get; } + IUiRegistry Ui { get; } } diff --git a/src/AcDream.Plugin.Abstractions/IUiRegistry.cs b/src/AcDream.Plugin.Abstractions/IUiRegistry.cs new file mode 100644 index 00000000..1b724f1a --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/IUiRegistry.cs @@ -0,0 +1,14 @@ +namespace AcDream.Plugin.Abstractions; + +/// +/// Plugin-facing UI registration. A plugin ships a markup file (KSML-style) + +/// a binding object exposing the data properties the markup binds to, and +/// registers it from Enable(). Calls made before the GL window opens are +/// buffered and drained once the UI host exists. +/// +public interface IUiRegistry +{ + /// Absolute path to the plugin's panel markup file. + /// Object whose properties the markup's {Bindings} resolve against. + void AddMarkupPanel(string markupPath, object binding); +} diff --git a/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs b/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs new file mode 100644 index 00000000..6e22e17f --- /dev/null +++ b/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs @@ -0,0 +1,21 @@ +using AcDream.App.Plugins; + +namespace AcDream.App.Tests.Plugins; + +public class BufferedUiRegistryTests +{ + [Fact] + public void Drain_YieldsBufferedRegistrationsOnceThenEmpty() + { + var reg = new BufferedUiRegistry(); + reg.AddMarkupPanel("a.xml", new object()); + reg.AddMarkupPanel("b.xml", new object()); + + var drained = reg.Drain(); + Assert.Equal(2, drained.Count); + Assert.Equal("a.xml", drained[0].MarkupPath); + Assert.Equal("b.xml", drained[1].MarkupPath); + + Assert.Empty(reg.Drain()); // consumed + } +} diff --git a/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs b/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs index 2fdafc97..da508aab 100644 --- a/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs +++ b/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs @@ -30,6 +30,12 @@ public class PluginLoaderTests public IPluginLogger Log { get; } = new StubLogger(); public IGameState State { get; } = new StubState(); public IEvents Events { get; } = new StubEvents(); + public IUiRegistry Ui { get; } = new StubUiRegistry(); + } + + private sealed class StubUiRegistry : IUiRegistry + { + public void AddMarkupPanel(string markupPath, object binding) { } } private sealed class StubLogger : IPluginLogger From 2f4520ee129926b80e6c60de1faf0dbf37de41f5 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 17:50:42 +0200 Subject: [PATCH 14/99] =?UTF-8?q?docs(D.2b):=20mark=20D.2b=20+=20D.4=20shi?= =?UTF-8?q?pped=20(Spec=201=20=E2=80=94=20markup=20engine=20+=20retail=20v?= =?UTF-8?q?itals)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roadmap: D.2b (custom retail-look backend) and D.4 (dat sprites + 9-slice + DrawSprite) both shipped this session via the Spec-1 work — the UiHost-based markup engine (MarkupDocument + ControlsIni + IUiRegistry) rendering a markup-driven retail Vitals panel (8-piece dat chrome + red/gold/blue bars). Records the direct-RenderSurface decode finding + the confirmed chrome sprite ids. Remaining D.2b polish (gradient bar sprite, AcFont/D.3, input integration, LayoutDesc importer, D.5 panels) noted inline. Full suite green (2413 passed / 0 failed / 3 pre-existing skips). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/plans/2026-04-11-roadmap.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 79c7f4e5..560b150e 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -424,9 +424,9 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. **Sub-pieces:** - **D.1 — 2D ortho overlay + font rendering.** ✅ SHIPPED 2026-04-17 as the dev-facing debug overlay (StbTrueTypeSharp system-font atlas + `TextRenderer` + `DebugOverlay`). - **✓ SHIPPED — D.2a — ImGui scaffold + `AcDream.UI.Abstractions` layer.** Shipped 2026-04-25. Wires ImGui as the short-term backend behind `ACDREAM_DEVTOOLS=1`. Defines `IPanel` / `IPanelHost` / `IPanelRenderer` / `ICommandBus` + the first ViewModel (`VitalsVM`) in the new `AcDream.UI.Abstractions` project. First real panel: `VitalsPanel` reading HP from `CombatState.GetHealthPercent`. **Backend pivoted Hexa.NET.ImGui → ImGui.NET + `Silk.NET.OpenGL.Extensions.ImGui` during integration** — Hexa's native OpenGL3 backend does its own GL function resolution via GLFW/SDL and crashed with `0xC0000005` in `ImGuiImplOpenGL3.InitNative` against Silk.NET (no GLFW/SDL present). The Silk.NET extension is purpose-built for this scenario and is the `ImGui.NET` mitigation path that `docs/plans/2026-04-24-ui-framework.md` already called out as a "one-morning operation". Stam/Mana return `float?` null in D.2a because absolute values need `LocalPlayerState` + `PlayerDescription (0x0013)` parsing (filed post-D.2a). 11 new `AcDream.UI.Abstractions.Tests` green. -- **D.2b — Custom retail-look backend.** Implements the same `IPanel` / `IPanelRenderer` contracts using a custom retained-mode toolkit sourced from retail dat assets. Requires D.2a shipped. Panels get reskinned one at a time; ImGui stays as the `ACDREAM_DEVTOOLS=1` overlay forever. The original 2026-04-17 scaffold research (`UiRoot` / `UiElement` / `UiPanel` / `UiHost` + retail event codes + focus / drag-drop state machine + `WorldMouseFallThrough`) is the implementation foundation here — see `docs/research/retail-ui/`. +- **✓ SHIPPED — D.2b — Custom retail-look backend (Spec 1: markup engine + first panel).** Shipped 2026-06-14 (`626d06e`→`019350f`). Wired the dormant `UiHost`/`UiElement` tree into `GameWindow` (`ACDREAM_RETAIL_UI=1`) and built the **Approach-C markup engine**: `MarkupDocument` (XML → `UiElement` subtree, `{Binding}` reflection) + `ControlsIni` stylesheet loader + an `IUiRegistry` plugin UI surface (plugins ship markup + a binding, drained into the same `UiRoot`). First retail-faithful panel: a markup-driven Vitals window (`vitals.xml`) — 8-piece dat-sprite frame (`UiNineSlicePanel`) + three `UiMeter` bars (red/gold/blue + cur/max numbers) bound to `VitalsVM`, rendering live over the 3D world and coexisting with the ImGui devtools path. Retired divergence TS-30, added IA-15. Spec `docs/superpowers/specs/2026-06-14-d2b-retail-panel-frame-design.md`; plan `docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md`. NOTE the prove-out corrected two stale "facts": retail vitals are **bars not orbs**, stamina is **gold not cyan**. **Remaining: D.3 (AcFont — using the stb stopgap for now) + the glassy gradient bar sprite / brightness tune (polish); input integration (movable/clickable windows — the `UiRoot` drag/click machinery exists but isn't bridged to the Phase-K dispatcher yet); the `LayoutDesc 0x21000040` importer; and the rest of the panels (D.5).** - **D.3 — AcFont from portal.dat.** Replace stb_truetype system font with retail `Font` DBObjs (`0x40000000..0x40000FFF`) baked from `RenderSurface` source sheets — see research slice 03 §4. Preserves retail visual identity. **(D.2b dependency — needs the custom renderer.)** -- **D.4 — Dat sprites + 9-slice panel backgrounds.** Load `RenderSurface` (`0x06xxxxxx`) as GL textures; add `DrawSprite` to `UiRenderContext`. Enables retail panel art. **(D.2b dependency.)** +- **✓ SHIPPED — D.4 — Dat sprites + 9-slice panel backgrounds.** Shipped 2026-06-14 with D.2b. `TextureCache.GetOrUploadRenderSurface(id, out w, out h)` decodes `RenderSurface` (`0x06xxxxxx`) **directly** (not via the Surface→SurfaceTexture chain — the prove-out finding) → GL `Texture2D`; `DrawSprite` (explicit UV-rect, per-texture batch) added to `TextRenderer` + `UiRenderContext` + a `uUseTexture=2` RGBA frag branch; `UiNineSlicePanel` composes the universal 8-piece bevel (corners `0x060074C3..C6`, edges `0x060074BF/C0/C1/C2`, center `0x06004CC2`). Remaining art polish: the glassy gradient bar fill sprite (D.2b polish). - **D.5 — Core panels.** Attributes (`chunk_00470000.c:FUN_0047ba70`), Skills (same), Paperdoll (`chunk_004A0000.c:FUN_004A5200`), Inventory, Spellbook (`chunk_004C0000.c`), Fellowship, Allegiance. Each uses the port sketches in slice 05. **(Targets `AcDream.UI.Abstractions` — ships with D.2a using ImGui-rendered widgets; reskinned by D.2b.)** The *chat* panel originally listed under D.5 shipped early in Phase I (I.4 input + I.7 combat translator superseded the chat-panel design here); this entry now covers Attributes / Skills / Paperdoll / Inventory / Spellbook / Fellowship / Allegiance only. - **D.6 — HUD.** Vital orbs (scissor-rect partial fill, dat sprites `0x060013B2`), radar (`0x06001388` / `0x06004CC1`, 1.18× range factor), compass strip (scrolling U), target name plate, damage floaters, selection indicator. See slice 06. **(Targets `AcDream.UI.Abstractions` — ships with D.2a; reskinned by D.2b.)** Phase I.2 retired the StbTrueTypeSharp `DebugOverlay` but kept `TextRenderer` + `BitmapFont` alive specifically for D.6's world-space HUD elements (damage floaters, name plates) — they need raw GL text drawing that ImGui can't reach into the 3D scene. - **D.7 — Cursor manager.** OS + dat-sourced custom cursors (`FUN_0043c1c0` GDI HCURSOR builder pattern from slice 03). **(D.2b dependency.)** From 4acecffcd62c8e8a1322cc0f9f9c576afc7e5f92 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 18:02:27 +0200 Subject: [PATCH 15/99] feat(D.2b): wire UiHost input + moveable windows (UiRoot window-drag + WantCapture gate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UiElement: add Draggable flag; left-drag on a draggable element repositions it as a floating window instead of starting a drag-drop sequence. - UiRoot: add WantsMouse/WantsKeyboard properties (mirrors ImGui's WantCaptureMouse pattern); add FindDraggable helper; inject _windowDragTarget state machine into OnMouseDown/OnMouseMove/OnMouseUp so draggable windows track the pointer offset. - UiNineSlicePanel: set Draggable=true so retail window frames are movable by default. - GameWindow: OR _uiHost?.Root.WantsMouse|WantsKeyboard into the SilkMouseSource wantCaptureMouse/wantCaptureKeyboard delegates and the direct MouseMove gate so game actions (movement, world-pick) are suppressed while the pointer is over a retail window — no double-handling with the InputDispatcher. - GameWindow: wire all Silk Mice/Keyboards to UiHost after construction so the UiRoot tree receives live input. - Tests: 3 new UiRootInputTests covering WantsMouse hit-test, window-drag reposition, and non-draggable panel immobility. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 16 +++++- src/AcDream.App/UI/UiElement.cs | 5 ++ src/AcDream.App/UI/UiNineSlicePanel.cs | 1 + src/AcDream.App/UI/UiRoot.cs | 55 ++++++++++++++++++- .../AcDream.App.Tests/UI/UiRootInputTests.cs | 52 ++++++++++++++++++ 5 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 tests/AcDream.App.Tests/UI/UiRootInputTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index bea54e36..ca649ec2 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -978,8 +978,10 @@ public sealed class GameWindow : IDisposable _kbSource = new AcDream.App.Input.SilkKeyboardSource(firstKb); _mouseSource = new AcDream.App.Input.SilkMouseSource( firstMouse, - wantCaptureMouse: () => DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse, - wantCaptureKeyboard: () => DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard); + wantCaptureMouse: () => (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse) + || (_uiHost?.Root.WantsMouse ?? false), + wantCaptureKeyboard: () => (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard) + || (_uiHost?.Root.WantsKeyboard ?? false)); _mouseSource.ModifierProbe = () => _kbSource.CurrentModifiers; _inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher( _kbSource, _mouseSource, _keyBindings); @@ -1045,7 +1047,8 @@ public sealed class GameWindow : IDisposable // K.1b §E: explicit WantCaptureMouse defense-in-depth on the // surviving direct-mouse handler. Suppresses RMB orbit / // FlyCamera look while ImGui has the mouse focus. - if (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse) + if ((DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse) + || (_uiHost?.Root.WantsMouse ?? false)) { _lastMouseX = pos.X; _lastMouseY = pos.Y; @@ -1745,6 +1748,13 @@ public sealed class GameWindow : IDisposable _vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer); _uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont); + // Feed Silk input to the UiRoot tree so windows drag / close / select. + // UiRoot consumes UI events; the game InputDispatcher (subscribed to the + // same devices) is gated off via WantCaptureMouse/Keyboard above when the + // pointer is over a widget — no double-handling. + foreach (var m in _input!.Mice) _uiHost.WireMouse(m); + foreach (var kb in _input!.Keyboards) _uiHost.WireKeyboard(kb); + var cache = _textureCache!; (uint, int, int) ResolveChrome(uint id) { diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index ae9a0a7c..1989ce7c 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -88,6 +88,11 @@ public abstract class UiElement /// Painter's-algorithm z-order within siblings. Higher = on top. public int ZOrder { get; set; } + /// If true, a left-drag on this element (or a non-draggable child of + /// it) repositions it as a movable window. Intended for top-level panels, + /// whose Left/Top are screen coordinates (Root sits at the origin). + public bool Draggable { get; set; } + // ── Tree structure ────────────────────────────────────────────────── public UiElement? Parent { get; private set; } diff --git a/src/AcDream.App/UI/UiNineSlicePanel.cs b/src/AcDream.App/UI/UiNineSlicePanel.cs index 2f04229a..f1bbd2d1 100644 --- a/src/AcDream.App/UI/UiNineSlicePanel.cs +++ b/src/AcDream.App/UI/UiNineSlicePanel.cs @@ -27,6 +27,7 @@ public sealed class UiNineSlicePanel : UiPanel _resolve = resolve; BackgroundColor = Vector4.Zero; // suppress the base flat-rect fill BorderColor = Vector4.Zero; + Draggable = true; // retail windows are movable } /// diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index 7df41739..523f5cff 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -49,12 +49,25 @@ public sealed class UiRoot : UiElement /// Widget with mouse capture (during click-drag). public UiElement? Captured { get; private set; } + /// + /// True when the pointer is over a widget OR a widget holds mouse capture. + /// The host ORs this into the InputDispatcher's WantCaptureMouse gate so game + /// actions (movement, world-pick) are suppressed while the user interacts with + /// a retail window — mirrors ImGui's WantCaptureMouse. + /// + public bool WantsMouse => Captured is not null || HitTestTopDown(MouseX, MouseY).element is not null; + + /// True when a widget holds keyboard focus (e.g. a focused chat input). + public bool WantsKeyboard => KeyboardFocus is not null; + /// Current drag source (set between drag-begin and drop/cancel). public UiElement? DragSource { get; private set; } public object? DragPayload { get; private set; } private UiElement? _lastDragHoverTarget; private int _pressX, _pressY; private bool _dragCandidate; + private UiElement? _windowDragTarget; + private int _windowDragOffX, _windowDragOffY; private const int DragDistanceThreshold = 3; // pixels, retail-observed // Hover / tooltip tracking. @@ -120,6 +133,14 @@ public sealed class UiRoot : UiElement MouseX = x; MouseY = y; + // Window-move drag takes precedence over drag-drop / hover / fall-through. + if (_windowDragTarget is not null) + { + _windowDragTarget.Left = x - _windowDragOffX; + _windowDragTarget.Top = y - _windowDragOffY; + return; + } + // If we have capture, deliver MouseMove to the captured widget // AND drive drag state machine; do NOT fall through. if (Captured is not null) @@ -165,9 +186,22 @@ public sealed class UiRoot : UiElement // Set keyboard focus if target accepts it. if (target.AcceptsFocus) SetKeyboardFocus(target); - // Capture + arm drag candidate (drag promotes on subsequent MouseMove > threshold). SetCapture(target); - _dragCandidate = true; + + // Window-move: if the target or an ancestor is Draggable, a left-drag + // repositions that window instead of starting a drag-drop. + var draggable = FindDraggable(target); + if (btn == UiMouseButton.Left && draggable is not null) + { + _windowDragTarget = draggable; + _windowDragOffX = x - (int)draggable.Left; + _windowDragOffY = y - (int)draggable.Top; + _dragCandidate = false; + } + else + { + _dragCandidate = true; + } // Dispatch raw MouseDown event (retail uses WM_LBUTTONDOWN = 0x201). int rawType = btn switch @@ -187,6 +221,13 @@ public sealed class UiRoot : UiElement MouseX = x; MouseY = y; UpdateButtonFlag(btn, down: false); + if (_windowDragTarget is not null) + { + _windowDragTarget = null; + ReleaseCapture(); + return; + } + if (DragSource is not null) { FinishDrag(x, y); @@ -436,6 +477,16 @@ public sealed class UiRoot : UiElement return (null, 0, 0); } + private static UiElement? FindDraggable(UiElement? e) + { + while (e is not null) + { + if (e.Draggable) return e; + e = e.Parent; + } + return null; + } + private static bool ContainsAbsolute(UiElement e, int x, int y) { var sp = e.ScreenPosition; diff --git a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs new file mode 100644 index 00000000..31bb0bca --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs @@ -0,0 +1,52 @@ +using System.Numerics; +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public class UiRootInputTests +{ + [Fact] + public void WantsMouse_TrueOverWidget_FalseOverEmptySpace() + { + var root = new UiRoot { Width = 800, Height = 600 }; + var panel = new UiPanel { Left = 10, Top = 10, Width = 100, Height = 50 }; + root.AddChild(panel); + + root.OnMouseMove(50, 30); // inside the panel + Assert.True(root.WantsMouse); + + root.OnMouseMove(500, 400); // empty space + Assert.False(root.WantsMouse); + } + + [Fact] + public void WindowDrag_RepositionsDraggablePanel_StopsOnRelease() + { + var root = new UiRoot { Width = 800, Height = 600 }; + var panel = new UiPanel { Left = 10, Top = 10, Width = 100, Height = 50, Draggable = true }; + root.AddChild(panel); + + root.OnMouseDown(UiMouseButton.Left, 20, 20); // grab at (10,10) into the panel + root.OnMouseMove(120, 90); // drag + Assert.Equal(110f, panel.Left); // 120 - 10 + Assert.Equal(80f, panel.Top); // 90 - 10 + + root.OnMouseUp(UiMouseButton.Left, 120, 90); + root.OnMouseMove(300, 300); // released — must not move + Assert.Equal(110f, panel.Left); + Assert.Equal(80f, panel.Top); + } + + [Fact] + public void NonDraggablePanel_DoesNotMoveOnDrag() + { + var root = new UiRoot { Width = 800, Height = 600 }; + var panel = new UiPanel { Left = 10, Top = 10, Width = 100, Height = 50 }; // Draggable defaults false + root.AddChild(panel); + + root.OnMouseDown(UiMouseButton.Left, 20, 20); + root.OnMouseMove(120, 90); + Assert.Equal(10f, panel.Left); + Assert.Equal(10f, panel.Top); + } +} From de4f0167ef8f5f81822fcc07a4ed08c417952191 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 18:27:57 +0200 Subject: [PATCH 16/99] feat(D.2b): window resize (UiRoot edge-grip resize-drag mode) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add parallel resize mode to the UiRoot retained-mode input state machine. A left-drag starting within ResizeGrip=5px of a Resizable window's edge or corner resizes it (min-size clamped); interior drags on a Draggable window still reposition it. Changes: - UiElement: Resizable, MinWidth, MinHeight properties - UiRoot: ResizeEdges flags enum; _resizeTarget state fields; FindWindow (replaces FindDraggable, matches Draggable||Resizable); HitEdges (static, internal, testable); ResizeRect (static, public, testable); OnMouseDown checks edge-grip before move; OnMouseMove resize branch precedes move; OnMouseUp clears _resizeTarget - UiNineSlicePanel: Resizable = true (retail windows are resizable) - UiRootInputTests: 4 new tests — ResizeRect_RightBottom, ResizeRect_LeftTop (min-clamp + origin shift), HitEdges_DetectsCornerAndInteriorNone, EdgeDrag_ResizesPanel_InteriorDragMoves (full integration path) Note on test coordinate: right-edge grab uses x=298 (2px inside the panel's hit-test boundary) rather than x=300 (exactly at edge, misses OnHitTest's strict `<` check). This is intentional — the grip zone extends inward from the edge boundary, so a click 2px inside correctly lands in both the hit-test rect AND the resize-grip zone. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/UiElement.cs | 8 ++ src/AcDream.App/UI/UiNineSlicePanel.cs | 1 + src/AcDream.App/UI/UiRoot.cs | 93 +++++++++++++++++-- .../AcDream.App.Tests/UI/UiRootInputTests.cs | 53 +++++++++++ 4 files changed, 145 insertions(+), 10 deletions(-) diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index 1989ce7c..30c4b260 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -93,6 +93,14 @@ public abstract class UiElement /// whose Left/Top are screen coordinates (Root sits at the origin). public bool Draggable { get; set; } + /// If true, a left-drag starting near this element's edge/corner + /// resizes it (window resize). Intended for top-level panels. + public bool Resizable { get; set; } + + /// Minimum size enforced while resizing. + public float MinWidth { get; set; } = 40f; + public float MinHeight { get; set; } = 40f; + // ── Tree structure ────────────────────────────────────────────────── public UiElement? Parent { get; private set; } diff --git a/src/AcDream.App/UI/UiNineSlicePanel.cs b/src/AcDream.App/UI/UiNineSlicePanel.cs index f1bbd2d1..576da3e1 100644 --- a/src/AcDream.App/UI/UiNineSlicePanel.cs +++ b/src/AcDream.App/UI/UiNineSlicePanel.cs @@ -28,6 +28,7 @@ public sealed class UiNineSlicePanel : UiPanel BackgroundColor = Vector4.Zero; // suppress the base flat-rect fill BorderColor = Vector4.Zero; Draggable = true; // retail windows are movable + Resizable = true; // retail windows are resizable } /// diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index 523f5cff..1b72ec9f 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -4,6 +4,10 @@ using System.Numerics; namespace AcDream.App.UI; +/// Which edges of a window a resize-drag is affecting (corners combine two). +[System.Flags] +public enum ResizeEdges { None = 0, Left = 1, Right = 2, Top = 4, Bottom = 8 } + /// /// Top-level UI container. Implements the retail "Device" responsibilities /// (mouse cursor tracking, keyboard focus, modal overlay, mouse capture, @@ -68,6 +72,11 @@ public sealed class UiRoot : UiElement private bool _dragCandidate; private UiElement? _windowDragTarget; private int _windowDragOffX, _windowDragOffY; + private UiElement? _resizeTarget; + private ResizeEdges _resizeEdges; + private float _resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH; + private int _resizeMouseX, _resizeMouseY; + private const int ResizeGrip = 5; // px proximity to an edge to start a resize private const int DragDistanceThreshold = 3; // pixels, retail-observed // Hover / tooltip tracking. @@ -133,6 +142,18 @@ public sealed class UiRoot : UiElement MouseX = x; MouseY = y; + // Window resize takes precedence over move / drag-drop / hover. + if (_resizeTarget is not null) + { + var (nx, ny, nw, nh) = ResizeRect( + _resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH, + _resizeEdges, x - _resizeMouseX, y - _resizeMouseY, + _resizeTarget.MinWidth, _resizeTarget.MinHeight); + _resizeTarget.Left = nx; _resizeTarget.Top = ny; + _resizeTarget.Width = nw; _resizeTarget.Height = nh; + return; + } + // Window-move drag takes precedence over drag-drop / hover / fall-through. if (_windowDragTarget is not null) { @@ -188,15 +209,30 @@ public sealed class UiRoot : UiElement SetCapture(target); - // Window-move: if the target or an ancestor is Draggable, a left-drag - // repositions that window instead of starting a drag-drop. - var draggable = FindDraggable(target); - if (btn == UiMouseButton.Left && draggable is not null) + // Window resize / move: find the window (Draggable or Resizable ancestor). + // A left-drag starting near an edge resizes; interior drag repositions; + // otherwise it's a normal drag-drop candidate. + var window = FindWindow(target); + if (btn == UiMouseButton.Left && window is not null) { - _windowDragTarget = draggable; - _windowDragOffX = x - (int)draggable.Left; - _windowDragOffY = y - (int)draggable.Top; - _dragCandidate = false; + var edges = window.Resizable ? HitEdges(window, x, y, ResizeGrip) : ResizeEdges.None; + if (edges != ResizeEdges.None) + { + _resizeTarget = window; + _resizeEdges = edges; + _resizeStartX = window.Left; _resizeStartY = window.Top; + _resizeStartW = window.Width; _resizeStartH = window.Height; + _resizeMouseX = x; _resizeMouseY = y; + _dragCandidate = false; + } + else if (window.Draggable) + { + _windowDragTarget = window; + _windowDragOffX = x - (int)window.Left; + _windowDragOffY = y - (int)window.Top; + _dragCandidate = false; + } + else { _dragCandidate = true; } } else { @@ -221,6 +257,13 @@ public sealed class UiRoot : UiElement MouseX = x; MouseY = y; UpdateButtonFlag(btn, down: false); + if (_resizeTarget is not null) + { + _resizeTarget = null; + ReleaseCapture(); + return; + } + if (_windowDragTarget is not null) { _windowDragTarget = null; @@ -477,16 +520,46 @@ public sealed class UiRoot : UiElement return (null, 0, 0); } - private static UiElement? FindDraggable(UiElement? e) + private static UiElement? FindWindow(UiElement? e) { while (e is not null) { - if (e.Draggable) return e; + if (e.Draggable || e.Resizable) return e; e = e.Parent; } return null; } + /// Which edges of 's screen rect the point + /// (,) is within px of. + /// None if the point is outside the grip-expanded box entirely. + internal static ResizeEdges HitEdges(UiElement w, int x, int y, int grip) + { + float l = w.Left, t = w.Top, r = w.Left + w.Width, b = w.Top + w.Height; + if (x < l - grip || x > r + grip || y < t - grip || y > b + grip) return ResizeEdges.None; + var e = ResizeEdges.None; + if (System.Math.Abs(x - l) <= grip) e |= ResizeEdges.Left; + if (System.Math.Abs(x - r) <= grip) e |= ResizeEdges.Right; + if (System.Math.Abs(y - t) <= grip) e |= ResizeEdges.Top; + if (System.Math.Abs(y - b) <= grip) e |= ResizeEdges.Bottom; + return e; + } + + /// Compute a resized rect from a start rect + drag delta + which edges, + /// clamping to (,). Left/Top edges + /// move the origin so the opposite edge stays put. + public static (float x, float y, float w, float h) ResizeRect( + float startX, float startY, float startW, float startH, + ResizeEdges edges, float dx, float dy, float minW, float minH) + { + float x = startX, y = startY, w = startW, h = startH; + if ((edges & ResizeEdges.Right) != 0) w = System.Math.Max(minW, startW + dx); + if ((edges & ResizeEdges.Bottom) != 0) h = System.Math.Max(minH, startH + dy); + if ((edges & ResizeEdges.Left) != 0) { float nw = System.Math.Max(minW, startW - dx); x = startX + (startW - nw); w = nw; } + if ((edges & ResizeEdges.Top) != 0) { float nh = System.Math.Max(minH, startH - dy); y = startY + (startH - nh); h = nh; } + return (x, y, w, h); + } + private static bool ContainsAbsolute(UiElement e, int x, int y) { var sp = e.ScreenPosition; diff --git a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs index 31bb0bca..6ea9e317 100644 --- a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs +++ b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs @@ -49,4 +49,57 @@ public class UiRootInputTests Assert.Equal(10f, panel.Left); Assert.Equal(10f, panel.Top); } + + [Fact] + public void ResizeRect_RightBottom_GrowsSizeOnly() + { + var (x, y, w, h) = UiRoot.ResizeRect(10, 20, 100, 50, + ResizeEdges.Right | ResizeEdges.Bottom, dx: 30, dy: 15, minW: 40, minH: 40); + Assert.Equal(10f, x); Assert.Equal(20f, y); + Assert.Equal(130f, w); Assert.Equal(65f, h); + } + + [Fact] + public void ResizeRect_LeftTop_MovesOriginAndClampsToMin() + { + // Drag left edge right by 80 on a 100-wide / min-40 window: width clamps to 40, + // origin shifts so the RIGHT edge (110) stays put → x = 70. + var (x, _, w, _) = UiRoot.ResizeRect(10, 20, 100, 50, + ResizeEdges.Left, dx: 80, dy: 0, minW: 40, minH: 40); + Assert.Equal(40f, w); + Assert.Equal(70f, x); + } + + [Fact] + public void HitEdges_DetectsCornerAndInteriorNone() + { + var panel = new UiPanel { Left = 100, Top = 100, Width = 200, Height = 100 }; + // bottom-right corner (300,200) + Assert.Equal(ResizeEdges.Right | ResizeEdges.Bottom, UiRoot.HitEdges(panel, 300, 200, 5)); + // deep interior → no edges + Assert.Equal(ResizeEdges.None, UiRoot.HitEdges(panel, 200, 150, 5)); + } + + [Fact] + public void EdgeDrag_ResizesPanel_InteriorDragMoves() + { + var root = new UiRoot { Width = 800, Height = 600 }; + var panel = new UiPanel { Left = 100, Top = 100, Width = 200, Height = 100, + Draggable = true, Resizable = true, MinWidth = 40, MinHeight = 40 }; + root.AddChild(panel); + + // grab just inside the right edge (x=298, within ResizeGrip=5 of x=300) and drag right → wider, same origin + root.OnMouseDown(UiMouseButton.Left, 298, 150); + root.OnMouseMove(338, 150); + Assert.Equal(240f, panel.Width); + Assert.Equal(100f, panel.Left); + root.OnMouseUp(UiMouseButton.Left, 338, 150); + + // grab the interior and drag → moves + root.OnMouseDown(UiMouseButton.Left, 200, 150); + root.OnMouseMove(220, 170); + Assert.Equal(120f, panel.Left); + Assert.Equal(120f, panel.Top); + root.OnMouseUp(UiMouseButton.Left, 220, 170); + } } From 0500646f082260a0438b0175e79c98073aac6384 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 18:49:52 +0200 Subject: [PATCH 17/99] fix(D.2b): draw UI chrome behind content (TextRenderer Flush layer order) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TextRenderer.Flush batched by primitive type and flushed rects -> text -> sprites LAST, so the 8-piece chrome (incl. the center fill) painted OVER the vital bars + numbers ("the window is drawn in front of the bars"). Reorder to sprites -> rects -> text so chrome composites behind widget fills + text. Correct while bars are solid rects; when bars become gradient SPRITES this must move to true submission/painter order (sprite-on-sprite z) — noted inline as the D.2b follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/TextRenderer.cs | 46 +++++++++++++---------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/src/AcDream.App/Rendering/TextRenderer.cs b/src/AcDream.App/Rendering/TextRenderer.cs index b07a9d40..a0252518 100644 --- a/src/AcDream.App/Rendering/TextRenderer.cs +++ b/src/AcDream.App/Rendering/TextRenderer.cs @@ -197,26 +197,15 @@ public sealed unsafe class TextRenderer : IDisposable _gl.Enable(EnableCap.Blend); _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); - // Untextured rects first — they form panel backgrounds. - if (_rectVerts > 0) - { - _shader.SetInt("uUseTexture", 0); - UploadBuffer(_rectBuf); - _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_rectVerts); - } + // LAYERED compositing for the UI (background → fill → text): + // 1. RGBA dat sprites — window chrome / panel backgrounds (behind) + // 2. Untextured rects — widget fills (e.g. vital bars) on the chrome + // 3. Text glyphs — on top + // NOTE: this type-bucketed order is correct while bars are solid rects. + // When bars become gradient SPRITES, this must move to true submission + // (painter) order so sprite-on-sprite z is preserved (D.2b follow-up). - // Textured text glyphs. - if (_textVerts > 0 && font is not null) - { - _shader.SetInt("uUseTexture", 1); - _gl.ActiveTexture(TextureUnit.Texture0); - _gl.BindTexture(TextureTarget.Texture2D, font.TextureId); - _shader.SetInt("uTex", 0); - UploadBuffer(_textBuf); - _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_textVerts); - } - - // RGBA dat sprites — one draw call per distinct GL texture. + // 1. RGBA dat sprites first — one draw call per distinct GL texture. if (hasSprites) { _shader.SetInt("uUseTexture", 2); @@ -231,6 +220,25 @@ public sealed unsafe class TextRenderer : IDisposable } } + // 2. Untextured rects — widget fills on top of the chrome. + if (_rectVerts > 0) + { + _shader.SetInt("uUseTexture", 0); + UploadBuffer(_rectBuf); + _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_rectVerts); + } + + // 3. Textured text glyphs on top. + if (_textVerts > 0 && font is not null) + { + _shader.SetInt("uUseTexture", 1); + _gl.ActiveTexture(TextureUnit.Texture0); + _gl.BindTexture(TextureTarget.Texture2D, font.TextureId); + _shader.SetInt("uTex", 0); + UploadBuffer(_textBuf); + _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_textVerts); + } + // Restore GL state. _gl.DepthMask(true); if (!wasBlend) _gl.Disable(EnableCap.Blend); From af91b8432a72f47a4a42592645c6de0696fe685c Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 18:51:56 +0200 Subject: [PATCH 18/99] feat(D.2b): per-window resize-axis lock; vitals window is X-only (retail) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ResizeX/ResizeY bool properties to UiElement (both true by default). HitEdges() in UiRoot masks out locked axes after edge detection, so a locked edge falls through to window-move behaviour — matching retail, where the vitals bar height is fixed and only widens. MarkupDocument.Build() parses an optional resize="x|y|both|none" attribute on ; vitals.xml gets resize="x" to enforce the horizontal-only constraint in all instances of the panel. Two new tests: HitEdges_RespectsResizeAxisLock (UiRootInputTests) and Build_ResizeAttrX_SetsHorizontalOnly (MarkupDocumentTests). 11/11 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/MarkupDocument.cs | 8 ++++++++ src/AcDream.App/UI/UiElement.cs | 5 +++++ src/AcDream.App/UI/UiRoot.cs | 2 ++ src/AcDream.App/UI/assets/vitals.xml | 2 +- tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs | 9 +++++++++ tests/AcDream.App.Tests/UI/UiRootInputTests.cs | 10 ++++++++++ 6 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/AcDream.App/UI/MarkupDocument.cs b/src/AcDream.App/UI/MarkupDocument.cs index d4b0cb42..e27cd294 100644 --- a/src/AcDream.App/UI/MarkupDocument.cs +++ b/src/AcDream.App/UI/MarkupDocument.cs @@ -34,6 +34,14 @@ public static class MarkupDocument Height = F(root, "h"), }; + // Optional per-window resize-axis lock: resize="x" | "y" | "both" | "none". + string? resize = (string?)root.Attribute("resize"); + if (resize is not null) + { + panel.ResizeX = resize is "x" or "both"; + panel.ResizeY = resize is "y" or "both"; + } + string? title = (string?)root.Attribute("title"); if (!string.IsNullOrEmpty(title)) { diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index 30c4b260..48e1955b 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -101,6 +101,11 @@ public abstract class UiElement public float MinWidth { get; set; } = 40f; public float MinHeight { get; set; } = 40f; + /// Allow horizontal (width) resize. Ignored unless . + public bool ResizeX { get; set; } = true; + /// Allow vertical (height) resize. Ignored unless . + public bool ResizeY { get; set; } = true; + // ── Tree structure ────────────────────────────────────────────────── public UiElement? Parent { get; private set; } diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index 1b72ec9f..6f836253 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -542,6 +542,8 @@ public sealed class UiRoot : UiElement if (System.Math.Abs(x - r) <= grip) e |= ResizeEdges.Right; if (System.Math.Abs(y - t) <= grip) e |= ResizeEdges.Top; if (System.Math.Abs(y - b) <= grip) e |= ResizeEdges.Bottom; + if (!w.ResizeX) e &= ~(ResizeEdges.Left | ResizeEdges.Right); + if (!w.ResizeY) e &= ~(ResizeEdges.Top | ResizeEdges.Bottom); return e; } diff --git a/src/AcDream.App/UI/assets/vitals.xml b/src/AcDream.App/UI/assets/vitals.xml index 868926d4..83d59c3b 100644 --- a/src/AcDream.App/UI/assets/vitals.xml +++ b/src/AcDream.App/UI/assets/vitals.xml @@ -1,4 +1,4 @@ - + diff --git a/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs index 8ba52d27..5e76ab95 100644 --- a/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs +++ b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs @@ -47,4 +47,13 @@ public class MarkupDocumentTests Assert.Null(meter.Fill()); Assert.Null(meter.Label()); } + + [Fact] + public void Build_ResizeAttrX_SetsHorizontalOnly() + { + const string xml = ""; + var panel = MarkupDocument.Build(xml, new object(), _ => ((uint)1, 32, 32)); + Assert.True(panel.ResizeX); + Assert.False(panel.ResizeY); + } } diff --git a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs index 6ea9e317..9ba7dae5 100644 --- a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs +++ b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs @@ -102,4 +102,14 @@ public class UiRootInputTests Assert.Equal(120f, panel.Top); root.OnMouseUp(UiMouseButton.Left, 220, 170); } + + [Fact] + public void HitEdges_RespectsResizeAxisLock() + { + var panel = new UiPanel { Left = 100, Top = 100, Width = 200, Height = 100, ResizeY = false }; + // right edge still detected (X allowed) + Assert.True((UiRoot.HitEdges(panel, 300, 150, 5) & ResizeEdges.Right) != 0); + // bottom edge masked out (Y locked) + Assert.True((UiRoot.HitEdges(panel, 200, 200, 5) & ResizeEdges.Bottom) == 0); + } } From f911b5f0af61c99ce9f8d27fef9f94f168e32167 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 18:58:58 +0200 Subject: [PATCH 19/99] =?UTF-8?q?feat(D.2b):=20anchor=20layout=20=E2=80=94?= =?UTF-8?q?=20vital=20bars=20stretch=20with=20window;=20drop=20Vitals=20he?= =?UTF-8?q?ading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add AnchorEdges [Flags] enum and Anchors property (default Left|Top, so all existing elements are unchanged) to UiElement. ApplyAnchor() captures the design-time margins on first call then recomputes Left/Top/Width/Height each frame; DrawSelfAndChildren drives it for every child before painting. ComputeAnchoredRect is public + static so it can be unit-tested without a running frame loop. MarkupDocument.Build gains a private Anchor() CSV parser and threads it into the initializer via the anchor= attribute. vitals.xml: remove title="Vitals" (retail vitals has no heading) and add anchor="left,top,right" to all three meter bars so they stretch when the panel is dragged wider. Two new xUnit tests in UiRootInputTests: Left+Right stretches width; Left+Top only keeps fixed size. All 19 App.Tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/MarkupDocument.cs | 17 ++++++ src/AcDream.App/UI/UiElement.cs | 60 +++++++++++++++++++ src/AcDream.App/UI/assets/vitals.xml | 8 +-- .../AcDream.App.Tests/UI/UiRootInputTests.cs | 21 +++++++ 4 files changed, 102 insertions(+), 4 deletions(-) diff --git a/src/AcDream.App/UI/MarkupDocument.cs b/src/AcDream.App/UI/MarkupDocument.cs index e27cd294..3be8a555 100644 --- a/src/AcDream.App/UI/MarkupDocument.cs +++ b/src/AcDream.App/UI/MarkupDocument.cs @@ -65,6 +65,7 @@ public static class MarkupDocument BarColor = Color((string?)el.Attribute("color")), Fill = BindFloat((string?)el.Attribute("fill"), binding), Label = () => (cur(), max()) is (uint c, uint m) ? $"{c}/{m}" : null, + Anchors = Anchor((string?)el.Attribute("anchor")), }); break; // future element kinds (label, button, image) added here @@ -123,4 +124,20 @@ public static class MarkupDocument if (expr is null || expr.Length < 3 || expr[0] != '{' || expr[^1] != '}') return null; return binding.GetType().GetProperty(expr[1..^1]); } + + private static AnchorEdges Anchor(string? csv) + { + if (string.IsNullOrWhiteSpace(csv)) return AnchorEdges.Left | AnchorEdges.Top; + var a = AnchorEdges.None; + foreach (var part in csv.Split(',', System.StringSplitOptions.TrimEntries | System.StringSplitOptions.RemoveEmptyEntries)) + a |= part.ToLowerInvariant() switch + { + "left" => AnchorEdges.Left, + "top" => AnchorEdges.Top, + "right" => AnchorEdges.Right, + "bottom" => AnchorEdges.Bottom, + _ => AnchorEdges.None, + }; + return a == AnchorEdges.None ? AnchorEdges.Left | AnchorEdges.Top : a; + } } diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index 48e1955b..e16c888f 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -4,6 +4,11 @@ using System.Numerics; namespace AcDream.App.UI; +/// Which parent edges a child keeps a fixed margin to on resize. +/// Left+Right ⇒ width stretches; Top+Bottom ⇒ height stretches. +[System.Flags] +public enum AnchorEdges { None = 0, Left = 1, Top = 2, Right = 4, Bottom = 8 } + /// /// Base class for every UI widget in the retained-mode tree. /// @@ -106,6 +111,10 @@ public abstract class UiElement /// Allow vertical (height) resize. Ignored unless . public bool ResizeY { get; set; } = true; + /// Edges this element anchors to in its parent. Default Left|Top + /// (pinned top-left, fixed size — no reflow). Left|Right stretches width. + public AnchorEdges Anchors { get; set; } = AnchorEdges.Left | AnchorEdges.Top; + // ── Tree structure ────────────────────────────────────────────────── public UiElement? Parent { get; private set; } @@ -170,6 +179,10 @@ public abstract class UiElement { OnDraw(ctx); + // Anchor layout: reflow children to this element's current size. + for (int i = 0; i < _children.Count; i++) + _children[i].ApplyAnchor(Width, Height); + // Children painted back-to-front (lowest ZOrder first). if (_children.Count > 0) { @@ -218,4 +231,51 @@ public abstract class UiElement return OnHitTest(localX, localY) ? this : null; } + + // ── Anchor layout ──────────────────────────────────────────────────── + + private bool _anchorCaptured; + private float _amL, _amT, _amR, _amB, _aw0, _ah0; + + /// Reposition/resize this element per , keeping + /// the margins captured (at first layout / design size) to each anchored edge. + /// Called by the parent each frame before drawing children. + internal void ApplyAnchor(float parentW, float parentH) + { + if (Anchors == AnchorEdges.None) return; + if (!_anchorCaptured) + { + _amL = Left; _amT = Top; + _amR = parentW - (Left + Width); + _amB = parentH - (Top + Height); + _aw0 = Width; _ah0 = Height; + _anchorCaptured = true; + } + var (x, y, w, h) = ComputeAnchoredRect(Anchors, _amL, _amT, _amR, _amB, _aw0, _ah0, parentW, parentH); + Left = x; Top = y; Width = w; Height = h; + } + + /// Compute an anchored child rect. Left&Right ⇒ stretch width + /// (keep both margins); Right only ⇒ pin to right at fixed width; otherwise + /// pin left at fixed width. Same logic vertically. + public static (float x, float y, float w, float h) ComputeAnchoredRect( + AnchorEdges a, float mL, float mT, float mR, float mB, + float w0, float h0, float parentW, float parentH) + { + bool l = (a & AnchorEdges.Left) != 0, r = (a & AnchorEdges.Right) != 0; + float x, w; + if (l && r) { x = mL; w = parentW - mR - mL; } + else if (r) { w = w0; x = parentW - mR - w0; } + else { x = mL; w = w0; } + + bool t = (a & AnchorEdges.Top) != 0, b = (a & AnchorEdges.Bottom) != 0; + float y, h; + if (t && b) { y = mT; h = parentH - mB - mT; } + else if (b) { h = h0; y = parentH - mB - h0; } + else { y = mT; h = h0; } + + if (w < 0) w = 0; + if (h < 0) h = 0; + return (x, y, w, h); + } } diff --git a/src/AcDream.App/UI/assets/vitals.xml b/src/AcDream.App/UI/assets/vitals.xml index 83d59c3b..08e065d6 100644 --- a/src/AcDream.App/UI/assets/vitals.xml +++ b/src/AcDream.App/UI/assets/vitals.xml @@ -1,5 +1,5 @@ - - - - + + + + diff --git a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs index 9ba7dae5..d3b3cc0b 100644 --- a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs +++ b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs @@ -112,4 +112,25 @@ public class UiRootInputTests // bottom edge masked out (Y locked) Assert.True((UiRoot.HitEdges(panel, 200, 200, 5) & ResizeEdges.Bottom) == 0); } + + [Fact] + public void ComputeAnchoredRect_LeftRight_StretchesWidth() + { + // bar at x=8,w=200 in a 220-wide parent (right margin 12). Parent grows to 300. + var (x, _, w, _) = UiElement.ComputeAnchoredRect( + AnchorEdges.Left | AnchorEdges.Right | AnchorEdges.Top, + mL: 8, mT: 24, mR: 12, mB: 58, w0: 200, h0: 14, parentW: 300, parentH: 96); + Assert.Equal(8f, x); + Assert.Equal(280f, w); // 300 - 12 - 8 + } + + [Fact] + public void ComputeAnchoredRect_LeftTopOnly_KeepsFixedSizeAndOrigin() + { + var (x, y, w, h) = UiElement.ComputeAnchoredRect( + AnchorEdges.Left | AnchorEdges.Top, + mL: 8, mT: 24, mR: 12, mB: 58, w0: 200, h0: 14, parentW: 300, parentH: 96); + Assert.Equal(8f, x); Assert.Equal(24f, y); + Assert.Equal(200f, w); Assert.Equal(14f, h); + } } From b303baf4a16a20981fd5a936c80dc58b975d9da2 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 19:06:58 +0200 Subject: [PATCH 20/99] fix(D.2b): windows not anchor-managed (regression: move/resize was reset each frame) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The anchor pass added in f911b5f runs on every element's children — including UiRoot's children, which are the top-level WINDOWS. With the default Left|Top anchor, ApplyAnchor reset each window's Left/Top/Width/Height back to its captured design rect EVERY frame, so user move/resize was undone instantly ("I can't resize or move it"). A window is user-positioned, so it must not be anchor-managed by its parent: set UiNineSlicePanel.Anchors = None. Children INSIDE the window still anchor to it (the bars keep stretching with width). Regression tests: UiNineSlicePanel.Anchors == None; ApplyAnchor(None) is a no-op. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/UiNineSlicePanel.cs | 5 +++++ .../AcDream.App.Tests/UI/UiRootInputTests.cs | 20 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/AcDream.App/UI/UiNineSlicePanel.cs b/src/AcDream.App/UI/UiNineSlicePanel.cs index 576da3e1..2e4465a1 100644 --- a/src/AcDream.App/UI/UiNineSlicePanel.cs +++ b/src/AcDream.App/UI/UiNineSlicePanel.cs @@ -29,6 +29,11 @@ public sealed class UiNineSlicePanel : UiPanel BorderColor = Vector4.Zero; Draggable = true; // retail windows are movable Resizable = true; // retail windows are resizable + // A top-level window is USER-positioned: it must NOT be anchor-managed + // by its parent (UiRoot), or the per-frame anchor pass would reset its + // Left/Top/Width/Height every frame and undo move/resize. Children + // INSIDE the window still anchor to it (the bars stretch with width). + Anchors = AnchorEdges.None; } /// diff --git a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs index d3b3cc0b..1adbffcd 100644 --- a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs +++ b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs @@ -5,6 +5,26 @@ namespace AcDream.App.Tests.UI; public class UiRootInputTests { + [Fact] + public void UiNineSlicePanel_IsNotAnchorManaged_SoUserMoveResizeSticks() + { + // Regression: the per-frame anchor pass must NOT reset a window's rect, + // or move/resize get undone every frame. Windows are user-positioned. + var panel = new UiNineSlicePanel(_ => ((uint)1, 32, 32)); + Assert.Equal(AnchorEdges.None, panel.Anchors); + } + + [Fact] + public void ApplyAnchor_None_IsNoOp() + { + var e = new UiPanel { Left = 50, Top = 60, Width = 100, Height = 40, Anchors = AnchorEdges.None }; + e.ApplyAnchor(800, 600); + Assert.Equal(50f, e.Left); + Assert.Equal(60f, e.Top); + Assert.Equal(100f, e.Width); + Assert.Equal(40f, e.Height); + } + [Fact] public void WantsMouse_TrueOverWidget_FalseOverEmptySpace() { From 56ee5eff60190ffc03f487861fdf32df18c23b2b Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 19:39:33 +0200 Subject: [PATCH 21/99] =?UTF-8?q?chore(D.2b):=20CLI=20dump-vitals-bars=20?= =?UTF-8?q?=E2=80=94=20read=20vitals=20LayoutDesc=20meter=20sprites?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `AcDream.Cli dump-vitals-bars ` subcommand that: - Scans all 101 LayoutDesc objects in client_local_English.dat - Finds the vitals window layout (0x21000014) by locating the Health meter element id 0x100000E6 (from gmVitalsUI::PostInit decomp) - Walks each meter's sub-element tree (typed access via ElementDesc.Children, ElementDesc.States, ElementDesc.StateDesc, StateDesc.Media, MediaDescImage.File) - Prints every RenderSurface DataId (0x06xxxxxx) per vital Authoritative output: HEALTH (0x100000E6): front-bar fill 0x06005F3D / track fill 0x06005F3C E8/E9/EA pieces: 0x06001131/32/33, 0x06001141/40/3F STAMINA (0x100000EC): front-bar fill 0x06005F3F / track fill 0x06005F3E E8/E9/EA pieces: 0x06001137/38/39, 0x06001147/46/45 MANA (0x100000EE): front-bar fill 0x06005F41 / track fill 0x06005F40 E8/E9/EA pieces: 0x06001134/35/36, 0x06001144/43/42 LayoutDesc shape discovered: Fields Width, Height, Elements (HashTable). ElementDesc shape: ElementId, Type, BaseElement, BaseLayoutId, DefaultState, X/Y/Width/Height/ZLevel, LeftEdge/TopEdge/RightEdge/BottomEdge, States (Dictionary), Children (Dictionary), StateDesc (direct single state). StateDesc shape: StateId, PassToChildren, IncorporationFlags, Properties (Dictionary), Media (List). MediaDescImage shape: File (uint DataId), DrawMode. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Cli/Program.cs | 156 +++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/src/AcDream.Cli/Program.cs b/src/AcDream.Cli/Program.cs index a4c290ee..c4ad9e71 100644 --- a/src/AcDream.Cli/Program.cs +++ b/src/AcDream.Cli/Program.cs @@ -3,8 +3,21 @@ using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; using DatReaderWriter.Options; +using DatReaderWriter.Types; using Env = System.Environment; +// ─── subcommand dispatch ──────────────────────────────────────────────────── +if (args.Length >= 1 && args[0] == "dump-vitals-bars") +{ + string? dvbDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + if (string.IsNullOrWhiteSpace(dvbDatDir)) + { + Console.Error.WriteLine("usage: AcDream.Cli dump-vitals-bars "); + return 2; + } + return DumpVitalsBars(dvbDatDir); +} + // Phase 0: open the four AC dat files and print how many of each asset type live in them. // This proves DatReaderWriter works on our retail dats and gives us a baseline inventory // to compare against what a future renderer needs. @@ -160,3 +173,146 @@ static (string Name, Func Count)[] CountCellByLow16(DatCollection dats) ("Region", () => dats.GetAllIdsOfType().Count()), }; } + +/// +/// dump-vitals-bars: find the vitals window LayoutDesc (0x21000014) and print the +/// RenderSurface DataIds (0x06xxxxxx) used by the Health, Stamina, and Mana meter +/// bars. Each meter element (E6/EC/EE) has two child sub-groups per bar visual +/// (front-bar and back-bar/track), each containing: +/// - elem 0x100004A9 (ShowDetail state image = Alphablend fill sprite) +/// - elem 0x100000E8 (DirectStateDesc = left-edge sprite) +/// - elem 0x100000E9 (DirectStateDesc = fill-tile sprite) +/// - elem 0x100000EA (DirectStateDesc = right-edge sprite) +/// +/// Based on the Sept 2013 EoR retail dat, vitals layout id = 0x21000014. +/// Element ids from gmVitalsUI::PostInit in acclient_2013_pseudo_c.txt. +/// +static int DumpVitalsBars(string dvbDatDir) +{ + const uint HEALTH_ELEM_ID = 0x100000E6u; + const uint STAMINA_ELEM_ID = 0x100000ECu; + const uint MANA_ELEM_ID = 0x100000EEu; + + if (!Directory.Exists(dvbDatDir)) + { + Console.Error.WriteLine($"error: directory not found: {dvbDatDir}"); + return 2; + } + + using var dats = new DatCollection(dvbDatDir, DatAccessType.Read); + + // Find the vitals layout: scan all LayoutDescs for one containing the health meter element. + Console.WriteLine("Scanning LayoutDescs for vitals window (element 0x100000E6 = Health meter)..."); + uint? vitalsId = null; + LayoutDesc? vitalsLayout = null; + foreach (var id in dats.GetAllIdsOfType()) + { + var ld = dats.Get(id); + if (ld is null) continue; + if (VbContainsElementId(ld, HEALTH_ELEM_ID)) { vitalsId = id; vitalsLayout = ld; break; } + } + + if (vitalsLayout is null) + { + Console.Error.WriteLine("ERROR: no LayoutDesc contains element 0x100000E6 (Health meter)."); + return 1; + } + Console.WriteLine($"Found vitals layout: 0x{vitalsId!.Value:X8}"); + Console.WriteLine(); + + // For each vital meter, collect all MediaDescImage DataIds from its sub-tree. + var meters = new[] { (HEALTH_ELEM_ID, "HEALTH"), (STAMINA_ELEM_ID, "STAMINA"), (MANA_ELEM_ID, "MANA") }; + foreach (var (eid, vitalName) in meters) + { + Console.WriteLine($"{vitalName} meter (element 0x{eid:X8}) in layout 0x{vitalsId!.Value:X8}:"); + var meterElem = VbFindElement(vitalsLayout!, eid); + if (meterElem is null) { Console.WriteLine(" "); continue; } + + var sprites = new List<(string Role, uint DataId, string DrawMode)>(); + VbCollectSprites(meterElem, sprites, 0); + + if (sprites.Count == 0) + { + Console.WriteLine(" "); + } + else + { + foreach (var (role, dataId, drawMode) in sprites) + Console.WriteLine($" {role,-35} 0x{dataId:X8} ({drawMode})"); + } + Console.WriteLine(); + } + + return 0; +} + +// ─── dump-vitals-bars helpers ─────────────────────────────────────────────── + +static bool VbContainsElementId(LayoutDesc ld, uint targetId) +{ + var elems = ld.Elements; + foreach (var kvp in elems) + { + if (kvp.Key == targetId) return true; + if (VbChildContains(kvp.Value, targetId)) return true; + } + return false; +} + +static bool VbChildContains(ElementDesc elem, uint targetId) +{ + foreach (var kvp in elem.Children) + { + if (kvp.Key == targetId) return true; + if (VbChildContains(kvp.Value, targetId)) return true; + } + return false; +} + +static ElementDesc? VbFindElement(LayoutDesc ld, uint targetId) +{ + foreach (var kvp in ld.Elements) + { + if (kvp.Key == targetId) return kvp.Value; + var found = VbFindChild(kvp.Value, targetId); + if (found is not null) return found; + } + return null; +} + +static ElementDesc? VbFindChild(ElementDesc elem, uint targetId) +{ + foreach (var kvp in elem.Children) + { + if (kvp.Key == targetId) return kvp.Value; + var found = VbFindChild(kvp.Value, targetId); + if (found is not null) return found; + } + return null; +} + +static void VbCollectSprites(ElementDesc elem, List<(string, uint, string)> out_, int depth) +{ + string indent = new string(' ', depth * 2); + + // Check the element's direct StateDesc + if (elem.StateDesc is not null) + VbExtractMedia(elem.StateDesc, $"{indent}elem_0x{elem.ElementId:X8}.DirectState", out_); + + // Check each named state + foreach (var kvp in elem.States) + VbExtractMedia(kvp.Value, $"{indent}elem_0x{elem.ElementId:X8}.{kvp.Key}", out_); + + // Recurse into children + foreach (var kvp in elem.Children) + VbCollectSprites(kvp.Value, out_, depth + 1); +} + +static void VbExtractMedia(StateDesc sd, string role, List<(string, uint, string)> out_) +{ + foreach (var m in sd.Media) + { + if (m is MediaDescImage img && img.File != 0) + out_.Add((role, img.File, img.DrawMode.ToString())); + } +} From 84630517e3027356e14b8d77847054e880befd47 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 19:45:54 +0200 Subject: [PATCH 22/99] feat(D.2b): vital bars use retail dat sprites (back track + fill-cropped front) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UiMeter gains SpriteResolve/BackSpriteId/FrontSpriteId; when both are set, OnDraw draws the empty-track sprite full-width then the colored-fill sprite UV-cropped to the live fill fraction (left-to-right drain). Falls back to solid rects when sprite ids are absent, keeping existing behavior and tests intact. MarkupDocument.Build() parses `back`/`front` hex attrs on and passes `resolve` into every UiMeter. vitals.xml wires the authoritative LayoutDesc 0x21000014 sprites (Health 0x06005F3C/3D, Stamina 3E/3F, Mana 40/41). The bar prove-out block in GameWindow.cs was already gone. If the sprites decode as 1x1 magenta at runtime they are paletted (INDEX16/P8) — the solid-color fallback will display instead and can be investigated separately. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/MarkupDocument.cs | 28 ++++++++++---- src/AcDream.App/UI/UiMeter.cs | 37 ++++++++++++++++--- src/AcDream.App/UI/assets/vitals.xml | 6 +-- .../UI/MarkupDocumentTests.cs | 13 +++++++ 4 files changed, 68 insertions(+), 16 deletions(-) diff --git a/src/AcDream.App/UI/MarkupDocument.cs b/src/AcDream.App/UI/MarkupDocument.cs index 3be8a555..5c7baaa3 100644 --- a/src/AcDream.App/UI/MarkupDocument.cs +++ b/src/AcDream.App/UI/MarkupDocument.cs @@ -58,14 +58,17 @@ public static class MarkupDocument var max = BindUint((string?)el.Attribute("max"), binding); panel.AddChild(new UiMeter { - Left = F(el, "x"), - Top = F(el, "y"), - Width = F(el, "w"), - Height = F(el, "h"), - BarColor = Color((string?)el.Attribute("color")), - Fill = BindFloat((string?)el.Attribute("fill"), binding), - Label = () => (cur(), max()) is (uint c, uint m) ? $"{c}/{m}" : null, - Anchors = Anchor((string?)el.Attribute("anchor")), + Left = F(el, "x"), + Top = F(el, "y"), + Width = F(el, "w"), + Height = F(el, "h"), + BarColor = Color((string?)el.Attribute("color")), + Fill = BindFloat((string?)el.Attribute("fill"), binding), + Label = () => (cur(), max()) is (uint c, uint m) ? $"{c}/{m}" : null, + Anchors = Anchor((string?)el.Attribute("anchor")), + SpriteResolve = resolve, + BackSpriteId = Hex((string?)el.Attribute("back")), + FrontSpriteId = Hex((string?)el.Attribute("front")), }); break; // future element kinds (label, button, image) added here @@ -125,6 +128,15 @@ public static class MarkupDocument return binding.GetType().GetProperty(expr[1..^1]); } + private static uint Hex(string? s) + { + if (string.IsNullOrWhiteSpace(s)) return 0; + var t = s.Trim(); + if (t.StartsWith("0x", System.StringComparison.OrdinalIgnoreCase)) t = t[2..]; + return uint.TryParse(t, System.Globalization.NumberStyles.HexNumber, + System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : 0u; + } + private static AnchorEdges Anchor(string? csv) { if (string.IsNullOrWhiteSpace(csv)) return AnchorEdges.Left | AnchorEdges.Top; diff --git a/src/AcDream.App/UI/UiMeter.cs b/src/AcDream.App/UI/UiMeter.cs index ef2883c2..48911c14 100644 --- a/src/AcDream.App/UI/UiMeter.cs +++ b/src/AcDream.App/UI/UiMeter.cs @@ -24,6 +24,14 @@ public sealed class UiMeter : UiElement public Vector4 BgColor { get; set; } = new(0f, 0f, 0f, 0.5f); public Vector4 LabelColor { get; set; } = new(1f, 1f, 1f, 1f); + /// Resolver from a RenderSurface DataId to (GL handle, w, h). When set + /// with Back/Front sprite ids, the bar draws the retail sprites instead of solid color. + public Func? SpriteResolve { get; set; } + /// Empty-track sprite (drawn full width). 0 = none. + public uint BackSpriteId { get; set; } + /// Colored-fill sprite (drawn cropped to the fill fraction). 0 = none. + public uint FrontSpriteId { get; set; } + public UiMeter() { ClickThrough = true; } /// Clamp to [0,1] and return the fill rect @@ -38,13 +46,32 @@ 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) + float p = pct is float pf ? (pf < 0f ? 0f : pf > 1f ? 1f : pf) : 0f; + + if (SpriteResolve is { } resolve && (BackSpriteId != 0 || FrontSpriteId != 0)) { - var (fx, fy, fw, fh) = ComputeFillRect(p, Width, Height); - if (fw > 0f) ctx.DrawRect(fx, fy, fw, fh, BarColor); + // Retail bar: empty track full width, colored fill cropped to p (left→right). + if (BackSpriteId != 0) + { + var (bt, _, _) = resolve(BackSpriteId); + if (bt != 0) ctx.DrawSprite(bt, 0, 0, Width, Height, 0, 0, 1, 1, Vector4.One); + } + if (FrontSpriteId != 0 && pct is not null && p > 0f) + { + var (ft, _, _) = resolve(FrontSpriteId); + if (ft != 0) ctx.DrawSprite(ft, 0, 0, Width * p, Height, 0, 0, p, 1, Vector4.One); + } + } + else + { + // Placeholder solid-color fallback. + ctx.DrawRect(0, 0, Width, Height, BgColor); + if (pct is not null && p > 0f) + { + var (fx, fy, fw, fh) = ComputeFillRect(p, Width, Height); + if (fw > 0f) ctx.DrawRect(fx, fy, fw, fh, BarColor); + } } string? label = Label(); diff --git a/src/AcDream.App/UI/assets/vitals.xml b/src/AcDream.App/UI/assets/vitals.xml index 08e065d6..2f7292e5 100644 --- a/src/AcDream.App/UI/assets/vitals.xml +++ b/src/AcDream.App/UI/assets/vitals.xml @@ -1,5 +1,5 @@ - - - + + + diff --git a/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs index 5e76ab95..ed717bbd 100644 --- a/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs +++ b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs @@ -56,4 +56,17 @@ public class MarkupDocumentTests Assert.True(panel.ResizeX); Assert.False(panel.ResizeY); } + + [Fact] + public void Build_ParsesBackFrontSpriteIds() + { + const string xml = "" + + "" + + ""; + var panel = MarkupDocument.Build(xml, new FakeBinding(), _ => ((uint)7, 32, 32)); + var meter = Assert.IsType(panel.Children[1]); + Assert.Equal(0x06005F3Cu, meter.BackSpriteId); + Assert.Equal(0x06005F3Du, meter.FrontSpriteId); + Assert.NotNull(meter.SpriteResolve); + } } From 1453ff7da249067fd86803119fa273e51dccb1dc Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 21:40:11 +0200 Subject: [PATCH 23/99] feat(D.2b): retail 3-slice vital bars + headless mockup verifier Render each vital bar as a horizontal 3-slice from the real retail RenderSurface sprites (authoritative ids from the vitals LayoutDesc 0x21000014 via dump-vitals-bars): a fixed-width bevelled left-cap, a stretched glassy-gradient middle, and a fixed-width right-cap. The empty back track draws full width; the coloured front fill grows from the left to the value (the track owns the right end, so the fill omits its own right-cap). Replaces the flat single-sprite Alphablend overlay that read as the old UI - this is the bordered gradient look from the retail screenshot (red HP / gold stamina / blue mana). UiMeter gains the six 9-slice ids (BackLeft/Tile/Right + FrontLeft/Tile/Right) and a DrawHBar helper; MarkupDocument parses the backleft/backtile/backright/frontleft/fronttile/frontright attrs; vitals.xml carries the 18 per-vital ids. The temporary ACDREAM_BAR_PROVEOUT component grid is removed. Adds AcDream.Cli render-vitals-mockup: a headless ImageSharp composite that assembles the bars with the SAME DrawHBar logic, so the sprite assembly can be verified by eye (Read the PNG) without launching the client + server - the fast UI-iteration loop the user asked for. export-ui-sprite dumps a single RenderSurface to PNG for HTML mockups. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/MarkupDocument.cs | 8 +- src/AcDream.App/UI/UiMeter.cs | 74 +++++++--- src/AcDream.App/UI/assets/vitals.xml | 9 +- src/AcDream.Cli/AcDream.Cli.csproj | 8 ++ src/AcDream.Cli/Program.cs | 26 ++++ src/AcDream.Cli/VitalsMockup.cs | 129 ++++++++++++++++++ .../UI/MarkupDocumentTests.cs | 14 +- 7 files changed, 242 insertions(+), 26 deletions(-) create mode 100644 src/AcDream.Cli/VitalsMockup.cs diff --git a/src/AcDream.App/UI/MarkupDocument.cs b/src/AcDream.App/UI/MarkupDocument.cs index 5c7baaa3..1132479b 100644 --- a/src/AcDream.App/UI/MarkupDocument.cs +++ b/src/AcDream.App/UI/MarkupDocument.cs @@ -67,8 +67,12 @@ public static class MarkupDocument Label = () => (cur(), max()) is (uint c, uint m) ? $"{c}/{m}" : null, Anchors = Anchor((string?)el.Attribute("anchor")), SpriteResolve = resolve, - BackSpriteId = Hex((string?)el.Attribute("back")), - FrontSpriteId = Hex((string?)el.Attribute("front")), + BackLeft = Hex((string?)el.Attribute("backleft")), + BackTile = Hex((string?)el.Attribute("backtile")), + BackRight = Hex((string?)el.Attribute("backright")), + FrontLeft = Hex((string?)el.Attribute("frontleft")), + FrontTile = Hex((string?)el.Attribute("fronttile")), + FrontRight = Hex((string?)el.Attribute("frontright")), }); break; // future element kinds (label, button, image) added here diff --git a/src/AcDream.App/UI/UiMeter.cs b/src/AcDream.App/UI/UiMeter.cs index 48911c14..de97aff4 100644 --- a/src/AcDream.App/UI/UiMeter.cs +++ b/src/AcDream.App/UI/UiMeter.cs @@ -25,12 +25,27 @@ public sealed class UiMeter : UiElement public Vector4 LabelColor { get; set; } = new(1f, 1f, 1f, 1f); /// Resolver from a RenderSurface DataId to (GL handle, w, h). When set - /// with Back/Front sprite ids, the bar draws the retail sprites instead of solid color. + /// with the 9-slice ids below, the bar draws the retail sprites instead of solid color. public Func? SpriteResolve { get; set; } - /// Empty-track sprite (drawn full width). 0 = none. - public uint BackSpriteId { get; set; } - /// Colored-fill sprite (drawn cropped to the fill fraction). 0 = none. - public uint FrontSpriteId { get; set; } + + // Retail vital bars are a horizontal 3-slice: a fixed-width bevelled left-cap, + // a stretched gradient middle, and a fixed-width right-cap. The "back" slice is + // the empty track (drawn full width); the "front" slice is the coloured fill + // (drawn from the left, grown to the fill fraction — the track owns the right + // end, so the fill omits its own right-cap). Ids come from the vitals LayoutDesc + // (0x21000014) via tools/dump-vitals-bars; 0 = none. + /// Empty-track left-cap RenderSurface id. + public uint BackLeft { get; set; } + /// Empty-track middle (stretched gradient) RenderSurface id. + public uint BackTile { get; set; } + /// Empty-track right-cap RenderSurface id. + public uint BackRight { get; set; } + /// Coloured-fill left-cap RenderSurface id. + public uint FrontLeft { get; set; } + /// Coloured-fill middle (stretched gradient) RenderSurface id. + public uint FrontTile { get; set; } + /// Coloured-fill right-cap RenderSurface id. + public uint FrontRight { get; set; } public UiMeter() { ClickThrough = true; } @@ -49,19 +64,13 @@ public sealed class UiMeter : UiElement float? pct = Fill(); float p = pct is float pf ? (pf < 0f ? 0f : pf > 1f ? 1f : pf) : 0f; - if (SpriteResolve is { } resolve && (BackSpriteId != 0 || FrontSpriteId != 0)) + if (SpriteResolve is { } resolve && (BackLeft != 0 || BackTile != 0 || FrontTile != 0)) { - // Retail bar: empty track full width, colored fill cropped to p (left→right). - if (BackSpriteId != 0) - { - var (bt, _, _) = resolve(BackSpriteId); - if (bt != 0) ctx.DrawSprite(bt, 0, 0, Width, Height, 0, 0, 1, 1, Vector4.One); - } - if (FrontSpriteId != 0 && pct is not null && p > 0f) - { - var (ft, _, _) = resolve(FrontSpriteId); - if (ft != 0) ctx.DrawSprite(ft, 0, 0, Width * p, Height, 0, 0, p, 1, Vector4.One); - } + // Empty track: full-width 3-slice (left-cap + stretched gradient + right-cap). + DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, 0, 0, Width, Height, withRightCap: true); + // Coloured fill: grows from the left to the value, no right-cap of its own. + if (pct is not null && p > 0f) + DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, 0, 0, Width * p, Height, withRightCap: false); } else { @@ -83,4 +92,35 @@ public sealed class UiMeter : UiElement ctx.DrawString(label, tx, ty, LabelColor); } } + + /// + /// Draws a horizontal 3-slice into x at + /// (,): a native-width left-cap, a stretched + /// middle, and (when ) a native-width right-cap. Caps + /// are clamped so a narrow bar never overdraws. A 0 id skips that slice. + /// + private static void DrawHBar( + UiRenderContext ctx, Func resolve, + uint leftId, uint tileId, uint rightId, + float x, float y, float w, float h, bool withRightCap) + { + if (w <= 0f) return; + var (lt, lw, _) = resolve(leftId); + var (tt, _, _) = resolve(tileId); + var (rt, rw, _) = resolve(rightId); + + float rcap = withRightCap && rt != 0 ? MathF.Min(rw, w) : 0f; + float lcap = lt != 0 ? MathF.Min(lw, w - rcap) : 0f; + + if (lt != 0 && lcap > 0f) + ctx.DrawSprite(lt, x, y, lcap, h, 0, 0, 1, 1, Vector4.One); + + float midX = x + lcap; + float midW = w - lcap - rcap; + if (tt != 0 && midW > 0f) + ctx.DrawSprite(tt, midX, y, midW, h, 0, 0, 1, 1, Vector4.One); + + if (rcap > 0f) + ctx.DrawSprite(rt, x + w - rcap, y, rcap, h, 0, 0, 1, 1, Vector4.One); + } } diff --git a/src/AcDream.App/UI/assets/vitals.xml b/src/AcDream.App/UI/assets/vitals.xml index 2f7292e5..ca7e665f 100644 --- a/src/AcDream.App/UI/assets/vitals.xml +++ b/src/AcDream.App/UI/assets/vitals.xml @@ -1,5 +1,8 @@ - - - + + + diff --git a/src/AcDream.Cli/AcDream.Cli.csproj b/src/AcDream.Cli/AcDream.Cli.csproj index 7d30223e..e964e5cb 100644 --- a/src/AcDream.Cli/AcDream.Cli.csproj +++ b/src/AcDream.Cli/AcDream.Cli.csproj @@ -9,6 +9,14 @@ + + + + + + diff --git a/src/AcDream.Cli/Program.cs b/src/AcDream.Cli/Program.cs index c4ad9e71..1eef5eb1 100644 --- a/src/AcDream.Cli/Program.cs +++ b/src/AcDream.Cli/Program.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using AcDream.Cli; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; @@ -18,6 +19,31 @@ if (args.Length >= 1 && args[0] == "dump-vitals-bars") return DumpVitalsBars(dvbDatDir); } +if (args.Length >= 1 && args[0] == "render-vitals-mockup") +{ + string? rvmDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + string rvmOut = args.ElementAtOrDefault(2) ?? "vitals-mockup.png"; + if (string.IsNullOrWhiteSpace(rvmDatDir)) + { + Console.Error.WriteLine("usage: AcDream.Cli render-vitals-mockup [out.png]"); + return 2; + } + return VitalsMockup.Render(rvmDatDir, rvmOut); +} + +if (args.Length >= 1 && args[0] == "export-ui-sprite") +{ + string? eusDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + string? eusId = args.ElementAtOrDefault(2); + string eusOut = args.ElementAtOrDefault(3) ?? "sprite.png"; + if (string.IsNullOrWhiteSpace(eusDatDir) || string.IsNullOrWhiteSpace(eusId)) + { + Console.Error.WriteLine("usage: AcDream.Cli export-ui-sprite <0xId> [out.png]"); + return 2; + } + return VitalsMockup.ExportSprite(eusDatDir, eusId, eusOut); +} + // Phase 0: open the four AC dat files and print how many of each asset type live in them. // This proves DatReaderWriter works on our retail dats and gives us a baseline inventory // to compare against what a future renderer needs. diff --git a/src/AcDream.Cli/VitalsMockup.cs b/src/AcDream.Cli/VitalsMockup.cs new file mode 100644 index 00000000..9d4dbe72 --- /dev/null +++ b/src/AcDream.Cli/VitalsMockup.cs @@ -0,0 +1,129 @@ +using AcDream.Core.Textures; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +namespace AcDream.Cli; + +/// +/// Headless PNG preview of the retail vital bars. Loads the real RenderSurface +/// sprites from the dats and composites them with the SAME horizontal 3-slice +/// logic the in-client UiMeter.DrawHBar uses (fixed-width bevelled caps + +/// a stretched gradient middle; the empty "back" track full width, the coloured +/// "front" fill grown from the left to the value). This lets the bar assembly be +/// verified by eye without launching the client + connecting to the server. +/// Bar sprite ids come from the vitals LayoutDesc (0x21000014) via dump-vitals-bars. +/// +public static class VitalsMockup +{ + private readonly record struct Vital( + string Name, uint BackL, uint BackT, uint BackR, uint FrontL, uint FrontT, uint FrontR); + + private static readonly Vital[] Vitals = + { + new("health", 0x06001141, 0x06001140, 0x0600113F, 0x06001131, 0x06001132, 0x06001133), + new("stamina", 0x06001147, 0x06001146, 0x06001145, 0x06001137, 0x06001138, 0x06001139), + new("mana", 0x06001144, 0x06001143, 0x06001142, 0x06001134, 0x06001135, 0x06001136), + }; + + private static readonly float[] Fills = { 1.0f, 0.6f, 0.25f }; + + private const int BarW = 200, BarH = 14, PadX = 10, PadY = 10, GapY = 10, ColGap = 16, Zoom = 3; + + public static int Render(string datDir, string outPath) + { + if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: directory not found: {datDir}"); return 2; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + + int cols = Fills.Length; + int canvasW = PadX * 2 + cols * BarW + (cols - 1) * ColGap; + int canvasH = PadY * 2 + Vitals.Length * BarH + (Vitals.Length - 1) * GapY; + + // Retail vitals window backdrop is a dark translucent panel; pick a neutral + // dark gray so the bevels + gradient read clearly. + using var canvas = new Image(canvasW, canvasH, new Rgba32(38, 38, 44, 255)); + + for (int vi = 0; vi < Vitals.Length; vi++) + { + var v = Vitals[vi]; + using var bl = Load(dats, v.BackL); + using var bt = Load(dats, v.BackT); + using var br = Load(dats, v.BackR); + using var fl = Load(dats, v.FrontL); + using var ft = Load(dats, v.FrontT); + using var fr = Load(dats, v.FrontR); + + Console.WriteLine($"{v.Name,-8} back[{bl.Width}x{bl.Height} {bt.Width}x{bt.Height} {br.Width}x{br.Height}] " + + $"front[{fl.Width}x{fl.Height} {ft.Width}x{ft.Height} {fr.Width}x{fr.Height}]"); + + int y = PadY + vi * (BarH + GapY); + for (int ci = 0; ci < Fills.Length; ci++) + { + int x = PadX + ci * (BarW + ColGap); + DrawHBar(canvas, bl, bt, br, x, y, BarW, BarH, withRightCap: true); + int fw = (int)(BarW * Fills[ci]); + if (fw > 0) + DrawHBar(canvas, fl, ft, fr, x, y, fw, BarH, withRightCap: false); + } + } + + canvas.Mutate(c => c.Resize(canvasW * Zoom, canvasH * Zoom, KnownResamplers.NearestNeighbor)); + canvas.SaveAsPng(outPath); + Console.WriteLine($"wrote {outPath} ({canvasW * Zoom}x{canvasH * Zoom}; rows=vitals, cols=100%/60%/25%)"); + return 0; + } + + public static int ExportSprite(string datDir, string idText, string outPath) + { + if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: directory not found: {datDir}"); return 2; } + uint id = ParseHex(idText); + if (id == 0) { Console.Error.WriteLine($"error: bad id '{idText}'"); return 2; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var img = Load(dats, id); + img.SaveAsPng(outPath); + Console.WriteLine($"wrote {outPath} (0x{id:X8} {img.Width}x{img.Height})"); + return 0; + } + + /// Replicates UiMeter.DrawHBar: native-width left-cap, stretched middle, + /// optional native-width right-cap; caps clamped so a narrow bar never overdraws. + private static void DrawHBar( + Image canvas, Image left, Image tile, Image right, + int x, int y, int w, int h, bool withRightCap) + { + if (w <= 0) return; + int rcap = withRightCap ? Math.Min(right.Width, w) : 0; + int lcap = Math.Min(left.Width, w - rcap); + + if (lcap > 0) Blit(canvas, left, x, y, lcap, h); + int midX = x + lcap, midW = w - lcap - rcap; + if (midW > 0) Blit(canvas, tile, midX, y, midW, h); + if (rcap > 0) Blit(canvas, right, x + w - rcap, y, rcap, h); + } + + private static void Blit(Image canvas, Image src, int x, int y, int dw, int dh) + { + if (dw <= 0 || dh <= 0) return; + using var s = src.Clone(c => c.Resize(dw, dh)); + canvas.Mutate(c => c.DrawImage(s, new Point(x, y), 1f)); + } + + private static Image Load(DatCollection dats, uint id) + { + var rs = dats.Get(id); + if (rs is null) { Console.Error.WriteLine($" missing RenderSurface 0x{id:X8}"); return new Image(1, 1); } + var dt = SurfaceDecoder.DecodeRenderSurface(rs); + return Image.LoadPixelData(dt.Rgba8, dt.Width, dt.Height); + } + + private static uint ParseHex(string s) + { + s = s.Trim(); + if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) s = s[2..]; + return uint.TryParse(s, System.Globalization.NumberStyles.HexNumber, + System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : 0u; + } +} diff --git a/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs index ed717bbd..d45aa374 100644 --- a/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs +++ b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs @@ -58,15 +58,21 @@ public class MarkupDocumentTests } [Fact] - public void Build_ParsesBackFrontSpriteIds() + public void Build_ParsesNineSliceBarSpriteIds() { const string xml = "" + - "" + + "" + ""; var panel = MarkupDocument.Build(xml, new FakeBinding(), _ => ((uint)7, 32, 32)); var meter = Assert.IsType(panel.Children[1]); - Assert.Equal(0x06005F3Cu, meter.BackSpriteId); - Assert.Equal(0x06005F3Du, meter.FrontSpriteId); + Assert.Equal(0x06001141u, meter.BackLeft); + Assert.Equal(0x06001140u, meter.BackTile); + Assert.Equal(0x0600113Fu, meter.BackRight); + Assert.Equal(0x06001131u, meter.FrontLeft); + Assert.Equal(0x06001132u, meter.FrontTile); + Assert.Equal(0x06001133u, meter.FrontRight); Assert.NotNull(meter.SpriteResolve); } } From ada863980c742c1ec0e4066bcb45eb214444c5ee Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 22:12:12 +0200 Subject: [PATCH 24/99] feat(D.2b): scrollable retail chat window (read-only foundation) Add UiChatView, a transcript widget for the retail-look UI: renders the ChatVM tail bottom-pinned (newest at the bottom, like retail) with mouse-wheel scrollback and whole-line vertical clipping so text stays inside the frame. Hosted in a draggable/resizable UiNineSlicePanel and wired into the UiHost next to the vitals window, fed by a dedicated ChatVM (200-line tail) over the same live ChatLog. Per-ChatKind colour palette (speech white, tells magenta, channels blue, system yellow, emotes grey, combat orange). This is the read-only foundation. The next sub-step adds glScissor clipping + word-wrap, drag-to-select, and Ctrl+C copy -- the last needs a CapturesPointerDrag opt-out on UiElement so an interior drag selects text instead of moving the window (today an interior drag still moves the window, same as the vitals panel). Tests: UiChatView.ClampScroll (pin-to-bottom, cap-at-overflow, never-negative). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 50 ++++++++++ src/AcDream.App/UI/UiChatView.cs | 91 +++++++++++++++++++ tests/AcDream.App.Tests/UI/UiChatViewTests.cs | 28 ++++++ 3 files changed, 169 insertions(+) create mode 100644 src/AcDream.App/UI/UiChatView.cs create mode 100644 tests/AcDream.App.Tests/UI/UiChatViewTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index ca649ec2..64289724 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1773,6 +1773,56 @@ public sealed class GameWindow : IDisposable _uiHost.Root.AddChild(panel); Console.WriteLine("[D.2b] retail UI active — vitals panel from vitals.xml markup."); + // Retail chat window — a draggable/resizable nine-slice frame hosting a + // scrollable transcript (UiChatView). Read-only + wheel-scroll for now; + // drag-select + Ctrl+C copy land in the next D.2b sub-step. A dedicated + // ChatVM with a deeper tail (200) feeds the scrollback; it shares the + // same live ChatLog (Chat) as the ImGui panel. + var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200); + var chatWindow = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome) + { + Left = 10, Top = 432, Width = 440, Height = 184, + MinWidth = 180, MinHeight = 80, + }; + var chatView = new AcDream.App.UI.UiChatView + { + Left = 8, Top = 8, Width = 424, Height = 168, + Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top + | AcDream.App.UI.AnchorEdges.Right | AcDream.App.UI.AnchorEdges.Bottom, + Font = _debugFont, + LinesProvider = () => BuildRetailChatLines(retailChatVm), + }; + chatWindow.AddChild(chatView); + _uiHost.Root.AddChild(chatWindow); + + // Map the VM's formatted tail into coloured view lines. Per-ChatKind + // palette (retail-ish): speech white, tells magenta, channels blue, + // system yellow, emotes grey, combat orange. Refined later if needed. + static System.Collections.Generic.IReadOnlyList BuildRetailChatLines( + AcDream.UI.Abstractions.Panels.Chat.ChatVM vm) + { + var detailed = vm.RecentLinesDetailed(); + var result = new AcDream.App.UI.UiChatView.Line[detailed.Count]; + for (int i = 0; i < detailed.Count; i++) + result[i] = new AcDream.App.UI.UiChatView.Line( + detailed[i].Text, RetailChatColor(detailed[i].Kind)); + return result; + } + + static System.Numerics.Vector4 RetailChatColor(AcDream.Core.Chat.ChatKind kind) => kind switch + { + AcDream.Core.Chat.ChatKind.LocalSpeech => new(1f, 1f, 1f, 1f), + AcDream.Core.Chat.ChatKind.RangedSpeech => new(1f, 0.95f, 0.8f, 1f), + AcDream.Core.Chat.ChatKind.Channel => new(0.6f, 0.8f, 1f, 1f), + AcDream.Core.Chat.ChatKind.Tell => new(1f, 0.5f, 1f, 1f), + AcDream.Core.Chat.ChatKind.System => new(1f, 1f, 0.45f, 1f), + AcDream.Core.Chat.ChatKind.Popup => new(1f, 0.85f, 0.4f, 1f), + AcDream.Core.Chat.ChatKind.Emote => new(0.8f, 0.8f, 0.7f, 1f), + AcDream.Core.Chat.ChatKind.SoulEmote => new(0.8f, 0.8f, 0.7f, 1f), + AcDream.Core.Chat.ChatKind.Combat => new(1f, 0.6f, 0.25f, 1f), + _ => new(0.9f, 0.9f, 0.9f, 1f), + }; + // Drain plugin-registered markup panels (buffered before the GL // window opened) into the same UiRoot tree. A faulty plugin markup // file is isolated — logged + skipped, never crashes the client. diff --git a/src/AcDream.App/UI/UiChatView.cs b/src/AcDream.App/UI/UiChatView.cs new file mode 100644 index 00000000..5cf9a96b --- /dev/null +++ b/src/AcDream.App/UI/UiChatView.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering; + +namespace AcDream.App.UI; + +/// +/// Scrollable chat transcript for the retail-look chat window. Renders the +/// lines from bottom-pinned (newest at the bottom, +/// like retail) with mouse-wheel scrollback. Whole-line vertical clipping keeps +/// text inside the window. +/// +/// +/// This is the read-only foundation. A follow-up sub-step adds glScissor-based +/// clipping + word-wrap, drag-to-select, and Ctrl+C copy (which needs the +/// opt-out so an interior drag +/// selects text instead of moving the window). +/// +/// +public sealed class UiChatView : UiElement +{ + /// One display line: pre-formatted text + its colour. + public readonly record struct Line(string Text, Vector4 Color); + + /// Provider of the lines to show, oldest-first. Polled each frame. + public Func> LinesProvider { get; set; } = static () => Array.Empty(); + + /// Font for the transcript; falls back to the context default. + public BitmapFont? Font { get; set; } + + /// Backing fill behind the text (retail chat is a dark translucent box). + public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0.35f); + + /// Inner text inset from the view edges, px. + public float Padding { get; set; } = 4f; + + // Pixels the transcript is scrolled UP from the newest line (0 = pinned to bottom). + private float _scroll; + private const float WheelLines = 3f; // lines advanced per wheel notch + + /// + /// Clamp a scroll offset to [0, max] where max = content-height - view-height + /// (never negative — when everything fits, scroll is pinned to 0). Exposed for tests. + /// + public static float ClampScroll(float scroll, float contentHeight, float viewHeight) + { + float max = Math.Max(0f, contentHeight - viewHeight); + if (scroll < 0f) return 0f; + return scroll > max ? max : scroll; + } + + protected override void OnDraw(UiRenderContext ctx) + { + ctx.DrawRect(0, 0, Width, Height, BackgroundColor); + + var font = Font ?? ctx.DefaultFont; + if (font is null) return; + + var lines = LinesProvider(); + if (lines.Count == 0) return; + + float lh = font.LineHeight; + float top = Padding, bottom = Height - Padding; + float innerH = bottom - top; + float contentH = lines.Count * lh; + _scroll = ClampScroll(_scroll, contentH, innerH); + + // Bottom-pin: with _scroll==0 the LAST line ends at `bottom`; scrolling up + // shifts the whole block down so older lines are revealed at the top. + float baseY = bottom - contentH + _scroll; + for (int i = 0; i < lines.Count; i++) + { + float y = baseY + i * lh; + if (y < top || y + lh > bottom) continue; // whole-line vertical clip (no scissor yet) + ctx.DrawString(lines[i].Text, Padding, y, lines[i].Color, font); + } + } + + public override bool OnEvent(in UiEvent e) + { + if (e.Type == UiEventType.Scroll) + { + float lh = Font?.LineHeight ?? 16f; + // Silk wheel +Y = scroll up = reveal older = shift content down = larger _scroll. + _scroll += e.Data0 * WheelLines * lh; // re-clamped next OnDraw against live content + return true; + } + return false; + } +} diff --git a/tests/AcDream.App.Tests/UI/UiChatViewTests.cs b/tests/AcDream.App.Tests/UI/UiChatViewTests.cs new file mode 100644 index 00000000..6dc9f22a --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiChatViewTests.cs @@ -0,0 +1,28 @@ +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public class UiChatViewTests +{ + [Fact] + public void ClampScroll_PinsToZero_WhenContentFitsView() + { + // 5 lines of content in a taller view → nothing to scroll, pinned at 0. + Assert.Equal(0f, UiChatView.ClampScroll(50f, contentHeight: 80f, viewHeight: 200f)); + Assert.Equal(0f, UiChatView.ClampScroll(0f, contentHeight: 80f, viewHeight: 200f)); + } + + [Fact] + public void ClampScroll_CapsAtContentMinusView_WhenOverflowing() + { + // Content 500, view 200 → max scrollback is 300px (oldest line at top). + Assert.Equal(300f, UiChatView.ClampScroll(1000f, contentHeight: 500f, viewHeight: 200f)); + Assert.Equal(120f, UiChatView.ClampScroll(120f, contentHeight: 500f, viewHeight: 200f)); + } + + [Fact] + public void ClampScroll_NeverNegative() + { + Assert.Equal(0f, UiChatView.ClampScroll(-50f, contentHeight: 500f, viewHeight: 200f)); + } +} From ff29787f12ac278c37bc920e36ee207c5556a1ac Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 22:50:17 +0200 Subject: [PATCH 25/99] fix(D.2b): vitals from the real stacked-window LayoutDesc (0x2100006C) The vitals bars were rendered from the WRONG layout. The ids in vitals.xml (0x0600113x) belong to LayoutDesc 0x21000014 -- the 800x28 floaty side-vitals ROW. The stacked vitals window the user sees is LayoutDesc 0x2100006C (160x58), which uses a different sprite set and geometry. Dumped the real tree (new dump-vitals-layout CLI, reflective) and ported it: - Sprites (#2): the stacked-window set 0x0600747E-0x0600748F (health/stamina/ mana, each back+front 3-slice; caps 10px, mid 130px). - Right cap (#1) + fill model: retail UIElement_Meter::DrawChildren draws the back 3-slice full then the front 3-slice CLIPPED to the fill fraction (its own right-cap shows at 100%, the back's shows through when partial). UiMeter now clips the front per-slice (UV-crop) instead of growing a capless slice. - Spacing (#5): three flush 150x16 bars at y=5/21/37 in a 160x58 window (16px pitch, zero gap), per the dat rects -- not the old 20px-apart guess. - Border (#3): the window is the 8-piece chrome frame (corners 0x060074C3-C6, edges 0x060074BF-C2, 5px) -- dat-confirmed identical to RetailChromeSprites. The headless render-vitals-mockup now composites this exact window (0x2100006C) from the real sprites with the same clipped-fill model, so the look was verified before launch. Font (#4, dat Font 0x40000000) is the next commit. Decomp refs: gmVitalsUI::PostInit @0x4bfce0; UIElement_Meter::DrawChildren @0x46fbd0 (scissor-fill); geometry from LayoutDesc 0x2100006C. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/UiMeter.cs | 61 ++++++----- src/AcDream.App/UI/assets/vitals.xml | 19 ++-- src/AcDream.Cli/Program.cs | 12 +++ src/AcDream.Cli/VitalsLayoutDump.cs | 152 +++++++++++++++++++++++++++ src/AcDream.Cli/VitalsMockup.cs | 138 ++++++++++++++---------- 5 files changed, 293 insertions(+), 89 deletions(-) create mode 100644 src/AcDream.Cli/VitalsLayoutDump.cs diff --git a/src/AcDream.App/UI/UiMeter.cs b/src/AcDream.App/UI/UiMeter.cs index de97aff4..5baec4a7 100644 --- a/src/AcDream.App/UI/UiMeter.cs +++ b/src/AcDream.App/UI/UiMeter.cs @@ -66,11 +66,14 @@ public sealed class UiMeter : UiElement if (SpriteResolve is { } resolve && (BackLeft != 0 || BackTile != 0 || FrontTile != 0)) { - // Empty track: full-width 3-slice (left-cap + stretched gradient + right-cap). - DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, 0, 0, Width, Height, withRightCap: true); - // Coloured fill: grows from the left to the value, no right-cap of its own. + // Retail meter (UIElement_Meter::DrawChildren): the BACK 3-slice is the + // empty track, drawn full width; the FRONT 3-slice is the coloured fill, + // drawn at FULL width too but horizontally CLIPPED to the fill fraction. + // The front carries its own right-cap (shown at 100%); clipping below 100% + // removes it and reveals the back track's right-cap — retail's scissor-fill. + DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, Width); if (pct is not null && p > 0f) - DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, 0, 0, Width * p, Height, withRightCap: false); + DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, Width * p); } else { @@ -94,33 +97,43 @@ public sealed class UiMeter : UiElement } /// - /// Draws a horizontal 3-slice into x at - /// (,): a native-width left-cap, a stretched - /// middle, and (when ) a native-width right-cap. Caps - /// are clamped so a narrow bar never overdraws. A 0 id skips that slice. + /// Draws the full-width horizontal 3-slice (native-width left-cap, stretched + /// middle, native-width right-cap) over this meter's rect, horizontally CLIPPED + /// so nothing past (local px from the left) is drawn. + /// The back track passes clipW = Width; the front fill passes + /// clipW = Width * fraction. Clipping UV-crops each slice proportionally, + /// so the fill ends cleanly and the back's right-cap shows through when partial. + /// A 0 id skips that slice. /// - private static void DrawHBar( + private void DrawHBar( UiRenderContext ctx, Func resolve, - uint leftId, uint tileId, uint rightId, - float x, float y, float w, float h, bool withRightCap) + uint leftId, uint midId, uint rightId, float clipW) { - if (w <= 0f) return; + if (clipW <= 0f) return; + float w = Width, h = Height; var (lt, lw, _) = resolve(leftId); - var (tt, _, _) = resolve(tileId); + var (mt, _, _) = resolve(midId); var (rt, rw, _) = resolve(rightId); - float rcap = withRightCap && rt != 0 ? MathF.Min(rw, w) : 0f; - float lcap = lt != 0 ? MathF.Min(lw, w - rcap) : 0f; + float capL = lt != 0 ? MathF.Min(lw, w) : 0f; + float capR = rt != 0 ? MathF.Min(rw, w - capL) : 0f; + float midW = w - capL - capR; - if (lt != 0 && lcap > 0f) - ctx.DrawSprite(lt, x, y, lcap, h, 0, 0, 1, 1, Vector4.One); + DrawPiece(ctx, lt, 0f, capL, h, clipW); + DrawPiece(ctx, mt, capL, midW, h, clipW); + DrawPiece(ctx, rt, w - capR, capR, h, clipW); + } - float midX = x + lcap; - float midW = w - lcap - rcap; - if (tt != 0 && midW > 0f) - ctx.DrawSprite(tt, midX, y, midW, h, 0, 0, 1, 1, Vector4.One); - - if (rcap > 0f) - ctx.DrawSprite(rt, x + w - rcap, y, rcap, h, 0, 0, 1, 1, Vector4.One); + /// Draw one slice spanning local [, + /// pieceX+], UV-cropped so nothing past + /// shows. + private static void DrawPiece( + UiRenderContext ctx, uint tex, float pieceX, float pieceW, float h, float clipW) + { + if (tex == 0 || pieceW <= 0f) return; + float visibleW = MathF.Min(pieceW, clipW - pieceX); + if (visibleW <= 0f) return; + float u1 = visibleW / pieceW; // crop the texture horizontally + ctx.DrawSprite(tex, pieceX, 0f, visibleW, h, 0f, 0f, u1, 1f, Vector4.One); } } diff --git a/src/AcDream.App/UI/assets/vitals.xml b/src/AcDream.App/UI/assets/vitals.xml index ca7e665f..eb8dfcbd 100644 --- a/src/AcDream.App/UI/assets/vitals.xml +++ b/src/AcDream.App/UI/assets/vitals.xml @@ -1,8 +1,13 @@ - - - - + + + + + diff --git a/src/AcDream.Cli/Program.cs b/src/AcDream.Cli/Program.cs index 1eef5eb1..0fdad988 100644 --- a/src/AcDream.Cli/Program.cs +++ b/src/AcDream.Cli/Program.cs @@ -19,6 +19,18 @@ if (args.Length >= 1 && args[0] == "dump-vitals-bars") return DumpVitalsBars(dvbDatDir); } +if (args.Length >= 1 && args[0] == "dump-vitals-layout") +{ + string? dvlDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + string? dvlLayout = args.ElementAtOrDefault(2); + if (string.IsNullOrWhiteSpace(dvlDatDir)) + { + Console.Error.WriteLine("usage: AcDream.Cli dump-vitals-layout [0xLayoutId]"); + return 2; + } + return VitalsLayoutDump.Run(dvlDatDir, dvlLayout); +} + if (args.Length >= 1 && args[0] == "render-vitals-mockup") { string? rvmDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR"); diff --git a/src/AcDream.Cli/VitalsLayoutDump.cs b/src/AcDream.Cli/VitalsLayoutDump.cs new file mode 100644 index 00000000..675f671b --- /dev/null +++ b/src/AcDream.Cli/VitalsLayoutDump.cs @@ -0,0 +1,152 @@ +using System.Collections; +using System.Reflection; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; +using DatReaderWriter.Types; + +namespace AcDream.Cli; + +/// +/// Full reflective dump of a vitals LayoutDesc element tree: every scalar +/// property (position/size/flags) of each ElementDesc + its state sprites, +/// so the real bar rects + spacing + window size can be read from the dat +/// instead of guessed. Uses reflection so it doesn't depend on knowing the +/// DatReaderWriter property names ahead of time. +/// +public static class VitalsLayoutDump +{ + public static int Run(string datDir, string? layoutIdText) + { + if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + + // Default to the vitals layout dump-vitals-bars found; allow override. + uint layoutId = 0x21000014u; + if (!string.IsNullOrWhiteSpace(layoutIdText)) + { + var t = layoutIdText.Trim(); + if (t.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) t = t[2..]; + uint.TryParse(t, System.Globalization.NumberStyles.HexNumber, null, out layoutId); + } + + // First: scan ALL LayoutDescs that contain a vitals meter element, with root size, + // so we can tell whether 0x21000014 is the one the user sees (row vs stacked). + Console.WriteLine("=== LayoutDescs containing a vitals meter element (0x100000E6/EC/EE) ==="); + foreach (var id in dats.GetAllIdsOfType()) + { + var l = dats.Get(id); + if (l is null) continue; + if (!ContainsAny(l, 0x100000E6u, 0x100000ECu, 0x100000EEu)) continue; + Console.WriteLine($" 0x{id:X8} {RootSizeSummary(l)}"); + } + Console.WriteLine(); + + var ld = dats.Get(layoutId); + if (ld is null) { Console.Error.WriteLine($"layout 0x{layoutId:X8} not found"); return 1; } + + Console.WriteLine($"=== FULL DUMP layout 0x{layoutId:X8} ==="); + DumpScalars("LayoutDesc", ld, 0); + foreach (var kv in ld.Elements) + DumpElement(kv.Value, 1); + return 0; + } + + private static bool ContainsAny(LayoutDesc l, params uint[] ids) + { + foreach (var kv in l.Elements) + if (ElemContains(kv.Value, ids)) return true; + return false; + } + + private static bool ElemContains(ElementDesc e, uint[] ids) + { + if (Array.IndexOf(ids, e.ElementId) >= 0) return true; + foreach (var kv in e.Children) + if (ElemContains(kv.Value, ids)) return true; + return false; + } + + private static string RootSizeSummary(LayoutDesc l) + { + // Print any LayoutDesc-level scalar that looks like a size. + var sb = new System.Text.StringBuilder(); + foreach (var p in l.GetType().GetProperties()) + { + if (p.GetIndexParameters().Length > 0) continue; + if (p.Name is "Elements") continue; + object? v; try { v = p.GetValue(l); } catch { continue; } + if (v is null) continue; + if (IsScalar(v)) sb.Append($"{p.Name}={v} "); + } + return sb.ToString().Trim(); + } + + private static void DumpElement(ElementDesc e, int depth) + { + string ind = new string(' ', depth * 2); + Console.WriteLine($"{ind}element 0x{e.ElementId:X8}"); + DumpScalars(ind + " ", e, depth); + + if (e.StateDesc is not null) DumpMedia(ind + " [DirectState]", e.StateDesc); + foreach (var s in e.States) + DumpMedia($"{ind} [state {s.Key}]", s.Value); + + foreach (var c in e.Children) + DumpElement(c.Value, depth + 1); + } + + private static readonly HashSet Skip = new() { "Children", "States", "StateDesc", "Elements", "Media" }; + + private static void DumpScalars(string label, object o, int depth) + { + foreach (var (name, val) in Members(o)) + { + if (Skip.Contains(name)) continue; + if (IsScalar(val)) + Console.WriteLine($"{label} {name} = {Fmt(name, val)}"); + } + } + + private static void DumpMedia(string label, StateDesc sd) + { + foreach (var m in sd.Media) + { + var sb = new System.Text.StringBuilder(); + foreach (var (name, val) in Members(m)) + if (IsScalar(val)) sb.Append($"{name}={Fmt(name, val)} "); + Console.WriteLine($"{label} {m.GetType().Name}: {sb.ToString().Trim()}"); + } + } + + /// Enumerate public properties AND public fields (the DatReaderWriter + /// generated types expose geometry/file ids as fields, not properties). + private static IEnumerable<(string name, object val)> Members(object o) + { + var t = o.GetType(); + foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (p.GetIndexParameters().Length > 0) continue; + object? v; try { v = p.GetValue(o); } catch { continue; } + if (v is not null) yield return (p.Name, v); + } + foreach (var f in t.GetFields(BindingFlags.Public | BindingFlags.Instance)) + { + object? v; try { v = f.GetValue(o); } catch { continue; } + if (v is not null) yield return (f.Name, v); + } + } + + private static string Fmt(string name, object v) => + name.Contains("File", StringComparison.OrdinalIgnoreCase) && v is uint u ? $"0x{u:X8}" : v.ToString() ?? ""; + + private static bool IsScalar(object v) + { + var t = v.GetType(); + if (v is string) return true; + if (t.IsPrimitive || t.IsEnum) return true; + if (v is IEnumerable) return false; + // value-type structs (Rectangle/Point/etc.) — print via ToString + return t.IsValueType; + } +} diff --git a/src/AcDream.Cli/VitalsMockup.cs b/src/AcDream.Cli/VitalsMockup.cs index 9d4dbe72..b53d8f4f 100644 --- a/src/AcDream.Cli/VitalsMockup.cs +++ b/src/AcDream.Cli/VitalsMockup.cs @@ -9,76 +9,84 @@ using SixLabors.ImageSharp.Processing; namespace AcDream.Cli; /// -/// Headless PNG preview of the retail vital bars. Loads the real RenderSurface -/// sprites from the dats and composites them with the SAME horizontal 3-slice -/// logic the in-client UiMeter.DrawHBar uses (fixed-width bevelled caps + -/// a stretched gradient middle; the empty "back" track full width, the coloured -/// "front" fill grown from the left to the value). This lets the bar assembly be -/// verified by eye without launching the client + connecting to the server. -/// Bar sprite ids come from the vitals LayoutDesc (0x21000014) via dump-vitals-bars. +/// Headless PNG preview of the retail STACKED vitals window (LayoutDesc +/// 0x2100006C, 160x58), composited with the SAME model the in-client UiMeter +/// uses: an 8-piece chrome border, then three flush-stacked 150x16 bars, each +/// drawn as a BACK 3-slice (empty track, full width) + a FRONT 3-slice +/// (coloured fill) horizontally CLIPPED to the fill fraction — so the front's +/// own right-cap shows at full, and clipping reveals the back's right-cap when +/// partial (matching retail's scissor-fill). All ids are dat-verified from +/// 0x2100006C via dump-vitals-layout. /// public static class VitalsMockup { - private readonly record struct Vital( - string Name, uint BackL, uint BackT, uint BackR, uint FrontL, uint FrontT, uint FrontR); + // 8-piece chrome border (RetailChromeSprites; 5px), dat-verified in 0x2100006C. + private const uint TL = 0x060074C3, TOP = 0x060074BF, TR = 0x060074C4; + private const uint LEFT = 0x060074C0, RIGHT = 0x060074C2; + private const uint BL = 0x060074C5, BOT = 0x060074C1, BR = 0x060074C6; + private readonly record struct Vital( + string Name, float Frac, + uint BackL, uint BackM, uint BackR, uint FrontL, uint FrontM, uint FrontR); + + // Stacked-window (0x2100006C) sprite ids — NOT the floaty-row 0x0600113x set. private static readonly Vital[] Vitals = { - new("health", 0x06001141, 0x06001140, 0x0600113F, 0x06001131, 0x06001132, 0x06001133), - new("stamina", 0x06001147, 0x06001146, 0x06001145, 0x06001137, 0x06001138, 0x06001139), - new("mana", 0x06001144, 0x06001143, 0x06001142, 0x06001134, 0x06001135, 0x06001136), + new("health", 0.80f, 0x0600747E, 0x0600747F, 0x06007480, 0x06007481, 0x06007482, 0x06007483), + new("stamina", 0.50f, 0x06007484, 0x06007485, 0x06007486, 0x06007487, 0x06007488, 0x06007489), + new("mana", 0.65f, 0x0600748A, 0x0600748B, 0x0600748C, 0x0600748D, 0x0600748E, 0x0600748F), }; - private static readonly float[] Fills = { 1.0f, 0.6f, 0.25f }; - - private const int BarW = 200, BarH = 14, PadX = 10, PadY = 10, GapY = 10, ColGap = 16, Zoom = 3; + // Window geometry from 0x2100006C: 160x58, 5px border, bars at x=5 y=5/21/37, 150x16. + private const int WinW = 160, WinH = 58, Border = 5, BarX = 5, BarW = 150, BarH = 16; + private static readonly int[] BarY = { 5, 21, 37 }; + private const int Zoom = 5; public static int Render(string datDir, string outPath) { - if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: directory not found: {datDir}"); return 2; } + if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; } using var dats = new DatCollection(datDir, DatAccessType.Read); - int cols = Fills.Length; - int canvasW = PadX * 2 + cols * BarW + (cols - 1) * ColGap; - int canvasH = PadY * 2 + Vitals.Length * BarH + (Vitals.Length - 1) * GapY; + using var canvas = new Image(WinW, WinH, new Rgba32(0, 0, 0, 0)); - // Retail vitals window backdrop is a dark translucent panel; pick a neutral - // dark gray so the bevels + gradient read clearly. - using var canvas = new Image(canvasW, canvasH, new Rgba32(38, 38, 44, 255)); - - for (int vi = 0; vi < Vitals.Length; vi++) + // 8-piece chrome border. + using (var tl = Load(dats, TL)) using (var top = Load(dats, TOP)) using (var tr = Load(dats, TR)) + using (var le = Load(dats, LEFT)) using (var ri = Load(dats, RIGHT)) + using (var bl = Load(dats, BL)) using (var bo = Load(dats, BOT)) using (var br = Load(dats, BR)) { - var v = Vitals[vi]; - using var bl = Load(dats, v.BackL); - using var bt = Load(dats, v.BackT); - using var br = Load(dats, v.BackR); - using var fl = Load(dats, v.FrontL); - using var ft = Load(dats, v.FrontT); - using var fr = Load(dats, v.FrontR); - - Console.WriteLine($"{v.Name,-8} back[{bl.Width}x{bl.Height} {bt.Width}x{bt.Height} {br.Width}x{br.Height}] " + - $"front[{fl.Width}x{fl.Height} {ft.Width}x{ft.Height} {fr.Width}x{fr.Height}]"); - - int y = PadY + vi * (BarH + GapY); - for (int ci = 0; ci < Fills.Length; ci++) - { - int x = PadX + ci * (BarW + ColGap); - DrawHBar(canvas, bl, bt, br, x, y, BarW, BarH, withRightCap: true); - int fw = (int)(BarW * Fills[ci]); - if (fw > 0) - DrawHBar(canvas, fl, ft, fr, x, y, fw, BarH, withRightCap: false); - } + Blit(canvas, tl, 0, 0, Border, Border); + Blit(canvas, top, Border, 0, WinW - 2 * Border, Border); + Blit(canvas, tr, WinW - Border, 0, Border, Border); + Blit(canvas, le, 0, Border, Border, WinH - 2 * Border); + Blit(canvas, ri, WinW - Border, Border, Border, WinH - 2 * Border); + Blit(canvas, bl, 0, WinH - Border, Border, Border); + Blit(canvas, bo, Border, WinH - Border, WinW - 2 * Border, Border); + Blit(canvas, br, WinW - Border, WinH - Border, Border, Border); } - canvas.Mutate(c => c.Resize(canvasW * Zoom, canvasH * Zoom, KnownResamplers.NearestNeighbor)); + for (int i = 0; i < Vitals.Length; i++) + { + var v = Vitals[i]; + int y = BarY[i]; + using var bl_ = Load(dats, v.BackL); using var bm = Load(dats, v.BackM); using var br_ = Load(dats, v.BackR); + using var fl = Load(dats, v.FrontL); using var fm = Load(dats, v.FrontM); using var fr = Load(dats, v.FrontR); + Console.WriteLine($"{v.Name,-8} back[{bl_.Width}x{bl_.Height} {bm.Width}x{bm.Height} {br_.Width}x{br_.Height}] " + + $"front[{fl.Width}x{fl.Height} {fm.Width}x{fm.Height} {fr.Width}x{fr.Height}] frac={v.Frac}"); + // Back track: full width. + DrawHBar(canvas, bl_, bm, br_, BarX, y, BarW, BarH, clipW: BarW); + // Front fill: full 3-slice clipped to the fraction. + DrawHBar(canvas, fl, fm, fr, BarX, y, BarW, BarH, clipW: (int)MathF.Round(BarW * v.Frac)); + } + + canvas.Mutate(c => c.Resize(WinW * Zoom, WinH * Zoom, KnownResamplers.NearestNeighbor)); canvas.SaveAsPng(outPath); - Console.WriteLine($"wrote {outPath} ({canvasW * Zoom}x{canvasH * Zoom}; rows=vitals, cols=100%/60%/25%)"); + Console.WriteLine($"wrote {outPath} ({WinW * Zoom}x{WinH * Zoom}; stacked window 0x2100006C, fracs h/s/m={Vitals[0].Frac}/{Vitals[1].Frac}/{Vitals[2].Frac})"); return 0; } public static int ExportSprite(string datDir, string idText, string outPath) { - if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: directory not found: {datDir}"); return 2; } + if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; } uint id = ParseHex(idText); if (id == 0) { Console.Error.WriteLine($"error: bad id '{idText}'"); return 2; } using var dats = new DatCollection(datDir, DatAccessType.Read); @@ -88,20 +96,34 @@ public static class VitalsMockup return 0; } - /// Replicates UiMeter.DrawHBar: native-width left-cap, stretched middle, - /// optional native-width right-cap; caps clamped so a narrow bar never overdraws. + /// Horizontal 3-slice (native-width left-cap, stretched middle, native-width + /// right-cap) clipped so nothing past (bar-local px) draws. + /// Mirrors the in-client UiMeter: back uses clipW=full, front uses clipW=frac*width. private static void DrawHBar( - Image canvas, Image left, Image tile, Image right, - int x, int y, int w, int h, bool withRightCap) + Image canvas, Image left, Image mid, Image right, + int x, int y, int w, int h, int clipW) { - if (w <= 0) return; - int rcap = withRightCap ? Math.Min(right.Width, w) : 0; - int lcap = Math.Min(left.Width, w - rcap); + if (w <= 0 || clipW <= 0) return; + int capL = Math.Min(left.Width, w); + int capR = Math.Min(right.Width, w - capL); + int midW = w - capL - capR; + DrawClippedPiece(canvas, left, x, y, 0, capL, h, clipW); + DrawClippedPiece(canvas, mid, x, y, capL, midW, h, clipW); + DrawClippedPiece(canvas, right, x, y, w - capR, capR, h, clipW); + } - if (lcap > 0) Blit(canvas, left, x, y, lcap, h); - int midX = x + lcap, midW = w - lcap - rcap; - if (midW > 0) Blit(canvas, tile, midX, y, midW, h); - if (rcap > 0) Blit(canvas, right, x + w - rcap, y, rcap, h); + /// Draw one slice spanning bar-local [pieceLocalX, pieceLocalX+pieceW], cropped + /// horizontally so nothing past clipW shows (UV-cropping the texture proportionally). + private static void DrawClippedPiece( + Image canvas, Image src, int x, int y, int pieceLocalX, int pieceW, int h, int clipW) + { + if (pieceW <= 0) return; + int visibleW = Math.Min(pieceW, clipW - pieceLocalX); + if (visibleW <= 0) return; + int srcCropW = Math.Max(1, (int)MathF.Round(src.Width * (visibleW / (float)pieceW))); + srcCropW = Math.Min(srcCropW, src.Width); + using var piece = src.Clone(c => c.Crop(new Rectangle(0, 0, srcCropW, src.Height)).Resize(visibleW, h)); + canvas.Mutate(c => c.DrawImage(piece, new Point(x + pieceLocalX, y), 1f)); } private static void Blit(Image canvas, Image src, int x, int y, int dw, int dh) From 36bd3522f42497ab5a19343a9b9580a5b2fe97c5 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 23:02:35 +0200 Subject: [PATCH 26/99] feat(D.2b): retail dat-font (Font 0x40000000) for vitals numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vitals cur/max overlay rendered with the consola TTF debug font, which is wrong for the retail look. Port the retail dat-font render path so the numbers use Font 0x40000000 (Latin-1, 16px, with outline atlas) — the same font retail draws on the vitals window. UiDatFont (new): loads the Font DBObj from the DatCollection and uploads its two RenderSurface atlases (foreground glyph pixels 0x06005EE5 + background outline 0x06005EE6) through TextureCache.GetOrUploadRenderSurface — the same direct-RenderSurface path the D.2b chrome sprites use. Builds a char->FontCharDesc lookup and exposes MeasureWidth + LineHeight. The per-glyph advance (HorizontalOffsetBefore + Width + HorizontalOffsetAfter) is a pure static so the pen math is unit-testable without GL or the dat. UiRenderContext.DrawStringDat (new): two-pass per-glyph blit mirroring SurfaceWindow::DrawCharacter (acclient 0x00442bd0) — the BACKGROUND atlas sub-rect tinted black (outline) first, then the FOREGROUND sub-rect tinted the text color (fill), with the pen accumulating the retail advance the way the string loop does at 0x00467ed4. Respects the UI transform stack. Skips the outline pass for fonts with no background atlas. No shader change was needed: the foreground atlas decodes A8 -> (255,255,255,a), and ui_text.frag's RGBA-sprite path already MULTIPLIES the texel by the per-vertex tint (texture(uTex,vUv)*vColor), so tinting white+alpha by a color gives color+alpha (black outline, text-color fill). UiMeter: new DatFont property; the label renders via DrawStringDat (centered with DatFont.MeasureWidth) when set, falling back to the debug BitmapFont when null. GameWindow: loads one UiDatFont for the vitals panel (under _datLock) and assigns it to each UiMeter child; logs + falls back to the debug font if the Font fails to load (never crashes). Tests: 6 pure-logic UiDatFontTests for GlyphAdvance + MeasureWidth (synthetic glyphs, negative bearings, missing chars, empty/null). Full App UI suite green (84 passed). DatReaderWriter member names verified via reflection on the 2.1.7 package: Font.{MaxCharHeight,BaselineOffset,ForegroundSurfaceDataId, BackgroundSurfaceDataId,CharDescs} and FontCharDesc.{Unicode,OffsetX, OffsetY,Width,Height,HorizontalOffsetBefore,HorizontalOffsetAfter, VerticalOffsetBefore}. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 21 +++ src/AcDream.App/UI/UiDatFont.cs | 160 +++++++++++++++++++ src/AcDream.App/UI/UiMeter.cs | 28 +++- src/AcDream.App/UI/UiRenderContext.cs | 73 +++++++++ tests/AcDream.App.Tests/UI/UiDatFontTests.cs | 84 ++++++++++ 5 files changed, 361 insertions(+), 5 deletions(-) create mode 100644 src/AcDream.App/UI/UiDatFont.cs create mode 100644 tests/AcDream.App.Tests/UI/UiDatFontTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 64289724..ce0989f8 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1770,6 +1770,27 @@ public sealed class GameWindow : IDisposable string vitalsXml = System.IO.File.ReadAllText( System.IO.Path.Combine(AppContext.BaseDirectory, "UI", "assets", "vitals.xml")); var panel = AcDream.App.UI.MarkupDocument.Build(vitalsXml, _vitalsVm!, ResolveChrome, controls); + + // Phase D.2b — retail dat-font for the vitals numbers. Font 0x40000000 + // (Latin-1, 16px, outline atlas). The consola TTF debug font is wrong + // for retail look; the meter falls back to it only if the dat font fails + // to load. Loaded under _datLock for consistency with other dat reads + // (no streaming worker is active during OnLoad, but the lock is cheap). + AcDream.App.UI.UiDatFont? vitalsDatFont; + lock (_datLock) + vitalsDatFont = AcDream.App.UI.UiDatFont.Load(_dats!, _textureCache!); + if (vitalsDatFont is not null) + { + foreach (var child in panel.Children) + if (child is AcDream.App.UI.UiMeter meter) + meter.DatFont = vitalsDatFont; + Console.WriteLine("[D.2b] vitals dat-font 0x40000000 loaded for numeric overlay."); + } + else + { + Console.WriteLine("[D.2b] vitals dat-font 0x40000000 unavailable — falling back to debug font."); + } + _uiHost.Root.AddChild(panel); Console.WriteLine("[D.2b] retail UI active — vitals panel from vitals.xml markup."); diff --git a/src/AcDream.App/UI/UiDatFont.cs b/src/AcDream.App/UI/UiDatFont.cs new file mode 100644 index 00000000..c08e20de --- /dev/null +++ b/src/AcDream.App/UI/UiDatFont.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.App.UI; + +/// +/// A retail dat-font (DB_TYPE_FONT, id range 0x40000000-0x40000FFF) ready for +/// 2D drawing. Holds the two GL atlas textures (foreground glyph pixels + +/// background outline/shadow), the per-glyph descriptor table, and the line +/// metrics, so can blit each glyph +/// as two textured quads exactly the way the retail client does. +/// +/// +/// Retail render model — SurfaceWindow::DrawCharacter +/// (acclient 0x00442bd0, Font::GetCharDesc + the two SurfaceWindow blits): for +/// each glyph it copies the BACKGROUND atlas sub-rect first, tinted with the +/// outline color (black), then the FOREGROUND atlas sub-rect, tinted with the +/// requested text color. The pen advances by +/// HorizontalOffsetBefore + Width + HorizontalOffsetAfter (the function's +/// return value, accumulated by the string loop at 0x00467ed4 +/// edi_3 += var_98), and each glyph is drawn starting at +/// penX + HorizontalOffsetBefore. +/// +/// +/// +/// Atlas format: the foreground atlas (0x06005EE5 for Font 0x40000000) is +/// PFID_A8 — alpha-only. Our SurfaceDecoder expands A8 to RGBA as +/// (255,255,255, alpha). The UI sprite shader path (ui_text.frag, +/// uUseTexture==2) MULTIPLIES the sampled texel by the per-vertex tint +/// (texture(uTex,vUv) * vColor), so tinting a white+alpha glyph by a +/// color gives that color with the glyph's alpha — black for the outline pass, +/// text color for the fill pass. No shader change was needed. +/// +/// +public sealed class UiDatFont +{ + /// Retail UI font id (Latin-1, 16x16 max, with outline atlas). + public const uint DefaultFontId = 0x40000000u; + + /// Foreground (glyph pixels) GL texture handle + atlas pixel size. + public uint ForegroundTexture { get; } + public int ForegroundWidth { get; } + public int ForegroundHeight { get; } + + /// Background (outline/shadow) GL texture handle + atlas pixel size. + /// 0 when the font has no background atlas (then the outline pass is skipped). + public uint BackgroundTexture { get; } + public int BackgroundWidth { get; } + public int BackgroundHeight { get; } + + /// Vertical advance between lines (retail MaxCharHeight). + public float LineHeight { get; } + + /// Distance from a line's top to its baseline (retail BaselineOffset). + public float BaselineOffset { get; } + + private readonly Dictionary _glyphs; + + private UiDatFont( + uint fgTex, int fgW, int fgH, + uint bgTex, int bgW, int bgH, + float lineHeight, float baselineOffset, + Dictionary glyphs) + { + ForegroundTexture = fgTex; ForegroundWidth = fgW; ForegroundHeight = fgH; + BackgroundTexture = bgTex; BackgroundWidth = bgW; BackgroundHeight = bgH; + LineHeight = lineHeight; + BaselineOffset = baselineOffset; + _glyphs = glyphs; + } + + /// True if this font carries a separate outline/shadow atlas + /// (retail's m_pBackgroundSurface). When false the outline pass is + /// skipped and only the foreground (fill) glyphs are drawn. + public bool HasBackground => BackgroundTexture != 0; + + /// Look up a glyph descriptor for a character. Returns false for + /// characters not present in the font's table (callers skip them). + public bool TryGetGlyph(char c, out FontCharDesc glyph) => _glyphs.TryGetValue(c, out glyph!); + + /// + /// Load Font from the dat collection and upload + /// both atlases through the texture cache (the same direct-RenderSurface + /// path the D.2b chrome sprites use). Returns null if the Font DBObj is + /// missing — callers fall back to the debug bitmap font. + /// + public static UiDatFont? Load(DatCollection dats, TextureCache cache, uint fontId = DefaultFontId) + { + ArgumentNullException.ThrowIfNull(dats); + ArgumentNullException.ThrowIfNull(cache); + + if (!dats.TryGet(fontId, out var font) || font is null) + return null; + + // Foreground atlas is required; without it there are no glyph pixels. + if (font.ForegroundSurfaceDataId == 0) + return null; + + uint fgTex = cache.GetOrUploadRenderSurface(font.ForegroundSurfaceDataId, out int fgW, out int fgH); + + uint bgTex = 0; int bgW = 0, bgH = 0; + if (font.BackgroundSurfaceDataId != 0) + bgTex = cache.GetOrUploadRenderSurface(font.BackgroundSurfaceDataId, out bgW, out bgH); + + // Build the char->descriptor lookup. FontCharDesc.Unicode is the code + // point; for Latin-1 fonts this is a direct char cast. Last write wins + // on the rare duplicate (retail's Font::GetCharDesc does a linear scan + // and returns the first match, but the dat tables have no duplicates). + var glyphs = new Dictionary(font.CharDescs.Count); + foreach (var cd in font.CharDescs) + glyphs[(char)cd.Unicode] = cd; + + return new UiDatFont( + fgTex, fgW, fgH, + bgTex, bgW, bgH, + lineHeight: font.MaxCharHeight, + baselineOffset: font.BaselineOffset, + glyphs); + } + + /// + /// Total pen advance (in pixels) for , summing each + /// glyph's retail advance. Characters not in the font contribute nothing. + /// + public float MeasureWidth(string text) + => MeasureWidth(text, c => _glyphs.TryGetValue(c, out var g) ? g : null); + + /// + /// Pure pen-advance summation seam: total width of + /// given a that maps each char to its descriptor + /// (null = not in the font → contributes nothing). Lets the advance math be + /// unit-tested with synthetic glyphs, with no GL or dat dependency. + /// + public static float MeasureWidth(string? text, Func lookup) + { + ArgumentNullException.ThrowIfNull(lookup); + if (string.IsNullOrEmpty(text)) return 0f; + float w = 0f; + for (int i = 0; i < text.Length; i++) + if (lookup(text[i]) is { } g) + w += GlyphAdvance(g); + return w; + } + + /// + /// The retail per-glyph horizontal advance: + /// HorizontalOffsetBefore + Width + HorizontalOffsetAfter. This is the + /// value SurfaceWindow::DrawCharacter returns for proportional text + /// (flag bit 0x10 set, acclient 0x00442c3a) and the string loop accumulates + /// into the pen. Pulled out as a pure static so the math is unit-testable + /// without GL or the dat. + /// + public static float GlyphAdvance(FontCharDesc g) + => g.HorizontalOffsetBefore + g.Width + g.HorizontalOffsetAfter; +} diff --git a/src/AcDream.App/UI/UiMeter.cs b/src/AcDream.App/UI/UiMeter.cs index 5baec4a7..f2b44f50 100644 --- a/src/AcDream.App/UI/UiMeter.cs +++ b/src/AcDream.App/UI/UiMeter.cs @@ -24,6 +24,12 @@ public sealed class UiMeter : UiElement public Vector4 BgColor { get; set; } = new(0f, 0f, 0f, 0.5f); public Vector4 LabelColor { get; set; } = new(1f, 1f, 1f, 1f); + /// Retail dat font (Font 0x40000000) for the "cur/max" overlay. When + /// set, the label renders through the dat-font two-pass blit (outline + fill); + /// when null, the debug bitmap font + /// is used instead. Set by the host when the retail UI is active. + public UiDatFont? DatFont { get; set; } + /// Resolver from a RenderSurface DataId to (GL handle, w, h). When set /// with the 9-slice ids below, the bar draws the retail sprites instead of solid color. public Func? SpriteResolve { get; set; } @@ -87,12 +93,24 @@ public sealed class UiMeter : UiElement } string? label = Label(); - if (!string.IsNullOrEmpty(label) && ctx.DefaultFont is { } font) + if (!string.IsNullOrEmpty(label)) { - float tw = font.MeasureWidth(label); - float tx = (Width - tw) * 0.5f; - float ty = (Height - font.LineHeight) * 0.5f; - ctx.DrawString(label, tx, ty, LabelColor); + if (DatFont is { } datFont) + { + // Retail path: centered cur/max via the dat font's two-pass blit. + float tw = datFont.MeasureWidth(label); + float tx = (Width - tw) * 0.5f; + float ty = (Height - datFont.LineHeight) * 0.5f; + ctx.DrawStringDat(datFont, label, tx, ty, LabelColor); + } + else if (ctx.DefaultFont is { } font) + { + // Fallback: debug bitmap font (no dat font available). + float tw = font.MeasureWidth(label); + float tx = (Width - tw) * 0.5f; + float ty = (Height - font.LineHeight) * 0.5f; + ctx.DrawString(label, tx, ty, LabelColor); + } } } diff --git a/src/AcDream.App/UI/UiRenderContext.cs b/src/AcDream.App/UI/UiRenderContext.cs index 01d81277..39727a0d 100644 --- a/src/AcDream.App/UI/UiRenderContext.cs +++ b/src/AcDream.App/UI/UiRenderContext.cs @@ -64,4 +64,77 @@ public sealed class UiRenderContext if (f is null) return; TextRenderer.DrawString(f, text, _current.X + x, _current.Y + y, color); } + + /// + /// Draw a single line of text with a retail dat font (), + /// at , = the top-left of the + /// typographic block (in this element's local space). Mirrors retail's + /// SurfaceWindow::DrawCharacter (acclient 0x00442bd0): for each glyph + /// the BACKGROUND atlas sub-rect is blitted first tinted black (the outline), + /// then the FOREGROUND atlas sub-rect tinted (the + /// fill). The pen advances by + /// HorizontalOffsetBefore + Width + HorizontalOffsetAfter and each + /// glyph is positioned at pen + HorizontalOffsetBefore on the X axis + /// and at baseline + VerticalOffsetBefore - (BaselineOffset) via the + /// glyph's OffsetY into the atlas. If the font has no background atlas the + /// outline pass is skipped. + /// + public void DrawStringDat(UiDatFont font, string text, float x, float y, Vector4 color) + { + if (font is null || string.IsNullOrEmpty(text)) return; + + // Baseline of this line in local space; retail draws glyphs whose + // descriptor OffsetY already places them relative to the line top, so we + // anchor each glyph's quad at the line top (y) plus its VerticalOffsetBefore. + float originX = _current.X + x; + float originY = _current.Y + y; + float pen = originX; + + var outline = new Vector4(0f, 0f, 0f, color.W); + + for (int i = 0; i < text.Length; i++) + { + if (!font.TryGetGlyph(text[i], out var g)) + continue; + + float gx = pen + g.HorizontalOffsetBefore; + float gy = originY + g.VerticalOffsetBefore; + float gw = g.Width; + float gh = g.Height; + + if (gw > 0f && gh > 0f) + { + // Background (outline) atlas pass, tinted black — drawn behind. + if (font.BackgroundTexture != 0) + { + var (bu0, bv0, bu1, bv1) = AtlasUv( + g.OffsetX, g.OffsetY, g.Width, g.Height, + font.BackgroundWidth, font.BackgroundHeight); + TextRenderer.DrawSprite(font.BackgroundTexture, gx, gy, gw, gh, bu0, bv0, bu1, bv1, outline); + } + + // Foreground (fill) atlas pass, tinted with the requested color. + var (fu0, fv0, fu1, fv1) = AtlasUv( + g.OffsetX, g.OffsetY, g.Width, g.Height, + font.ForegroundWidth, font.ForegroundHeight); + TextRenderer.DrawSprite(font.ForegroundTexture, gx, gy, gw, gh, fu0, fv0, fu1, fv1, color); + } + + pen += UiDatFont.GlyphAdvance(g); + } + } + + /// Convert an (OffsetX,OffsetY,Width,Height) atlas pixel sub-rect to + /// normalized UVs for an atlas of x + /// . Guards against a zero-sized atlas. + private static (float u0, float v0, float u1, float v1) AtlasUv( + int offsetX, int offsetY, int width, int height, int atlasW, int atlasH) + { + if (atlasW <= 0 || atlasH <= 0) return (0f, 0f, 0f, 0f); + float u0 = offsetX / (float)atlasW; + float v0 = offsetY / (float)atlasH; + float u1 = (offsetX + width) / (float)atlasW; + float v1 = (offsetY + height) / (float)atlasH; + return (u0, v0, u1, v1); + } } diff --git a/tests/AcDream.App.Tests/UI/UiDatFontTests.cs b/tests/AcDream.App.Tests/UI/UiDatFontTests.cs new file mode 100644 index 00000000..55a6457a --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiDatFontTests.cs @@ -0,0 +1,84 @@ +using System.Collections.Generic; +using AcDream.App.UI; +using DatReaderWriter.Types; + +namespace AcDream.App.Tests.UI; + +/// +/// Pure pen-advance / MeasureWidth math for the retail dat font (no GL, no dat). +/// The advance per glyph is the retail +/// HorizontalOffsetBefore + Width + HorizontalOffsetAfter +/// (SurfaceWindow::DrawCharacter, acclient 0x00442c3a), accumulated across the +/// string the way the retail string loop does (0x00467ed4 edi_3 += var_98). +/// +public class UiDatFontTests +{ + private static FontCharDesc Glyph( + ushort unicode, byte width, + sbyte before = 0, sbyte after = 0, + ushort offsetX = 0, ushort offsetY = 0, byte height = 16, sbyte vBefore = 0) + => new() + { + Unicode = unicode, + Width = width, + Height = height, + OffsetX = offsetX, + OffsetY = offsetY, + HorizontalOffsetBefore = before, + HorizontalOffsetAfter = after, + VerticalOffsetBefore = vBefore, + }; + + [Fact] + public void GlyphAdvance_SumsBeforeWidthAfter() + { + var g = Glyph('A', width: 8, before: 1, after: 2); + Assert.Equal(11f, UiDatFont.GlyphAdvance(g)); + } + + [Fact] + public void GlyphAdvance_HandlesNegativeBearings() + { + // Kerned glyph: a negative left-bearing pulls it leftward; the advance + // still nets out to before + width + after. + var g = Glyph('j', width: 4, before: -1, after: 0); + Assert.Equal(3f, UiDatFont.GlyphAdvance(g)); + } + + [Fact] + public void MeasureWidth_SumsEachGlyphAdvance() + { + var table = new Dictionary + { + ['2'] = Glyph('2', width: 7, before: 1, after: 1), // advance 9 + ['9'] = Glyph('9', width: 7, before: 1, after: 1), // advance 9 + ['1'] = Glyph('1', width: 3, before: 2, after: 1), // advance 6 + ['/'] = Glyph('/', width: 4, before: 0, after: 1), // advance 5 + }; + FontCharDesc? Lookup(char c) => table.TryGetValue(c, out var g) ? g : null; + + // "291/291" = 9 + 9 + 6 + 5 + 9 + 9 + 6 = 53 + Assert.Equal(53f, UiDatFont.MeasureWidth("291/291", Lookup)); + } + + [Fact] + public void MeasureWidth_SkipsCharactersNotInFont() + { + var table = new Dictionary + { + ['5'] = Glyph('5', width: 6, before: 1, after: 1), // advance 8 + }; + FontCharDesc? Lookup(char c) => table.TryGetValue(c, out var g) ? g : null; + + // 'X' has no glyph → contributes nothing; only the two '5's count. + Assert.Equal(16f, UiDatFont.MeasureWidth("5X5", Lookup)); + } + + [Fact] + public void MeasureWidth_EmptyOrNullIsZero() + { + FontCharDesc? Lookup(char c) => null; + Assert.Equal(0f, UiDatFont.MeasureWidth("", Lookup)); + Assert.Equal(0f, UiDatFont.MeasureWidth(null, Lookup)); + } +} From 4e60c03a74dc08398e0625ec6cdd51b8433c5602 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 23:21:28 +0200 Subject: [PATCH 27/99] feat(D.2b): chat text selection + Ctrl-C copy Windows-like selection in the retail chat window: left-click-drag selects characters, Ctrl-C copies, Ctrl-A selects all. The selected span paints a translucent highlight behind the text. - UiElement.CapturesPointerDrag: a per-element opt-out so an interior drag is delivered to the widget (text selection) instead of moving/resizing the host window. UiRoot.OnMouseDown honours it AFTER edge-resize (a resizable window is still resizable from its frame) and BEFORE window-move. - UiChatView: AcceptsFocus + IsEditControl + CapturesPointerDrag; caches the OnDraw layout so OnEvent hit-tests the same geometry; HitChar maps a local point to (line,col) with glyph-midpoint caret snapping; SelectedText joins a multi-line span with \n; Ctrl-C writes to IKeyboard.ClipboardText (only when non-empty, so an empty copy never clobbers the clipboard). - UiHost exposes the wired IKeyboard (clipboard + Ctrl modifier state). Adversarial-review fix (the 99 tests would have stayed green without it): a coordinate-frame mismatch between MouseDown and MouseMove. UiRoot.OnMouseDown dispatched HitTestTopDown's coords, which are relative to the TOP-LEVEL child, while MouseMove/MouseUp use target.ScreenPosition. For the chat view inset at (8,8) inside its window the anchor landed ~8px off the click. OnMouseDown now delivers target-LOCAL coords like the other mouse events. Added a UiRoot regression test asserting MouseDown and MouseMove share the target-local frame for a nested child. Decomp ref: SurfaceWindow text/selection model; clipboard via Silk.NET IKeyboard.ClipboardText. Built with the chat-select-copy implement->review workflow. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 3 + src/AcDream.App/UI/UiChatView.cs | 280 +++++++++++++++++- src/AcDream.App/UI/UiElement.cs | 6 + src/AcDream.App/UI/UiHost.cs | 8 + src/AcDream.App/UI/UiRoot.cs | 24 +- tests/AcDream.App.Tests/UI/UiChatViewTests.cs | 115 +++++++ .../AcDream.App.Tests/UI/UiRootInputTests.cs | 83 ++++++ 7 files changed, 507 insertions(+), 12 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index ce0989f8..2e26a360 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1812,6 +1812,9 @@ public sealed class GameWindow : IDisposable | AcDream.App.UI.AnchorEdges.Right | AcDream.App.UI.AnchorEdges.Bottom, Font = _debugFont, LinesProvider = () => BuildRetailChatLines(retailChatVm), + // Drag-select + Ctrl+C copy need the keyboard for clipboard + + // modifier state. UiHost.Keyboard is set during WireKeyboard above. + Keyboard = _uiHost.Keyboard, }; chatWindow.AddChild(chatView); _uiHost.Root.AddChild(chatWindow); diff --git a/src/AcDream.App/UI/UiChatView.cs b/src/AcDream.App/UI/UiChatView.cs index 5cf9a96b..a2039c08 100644 --- a/src/AcDream.App/UI/UiChatView.cs +++ b/src/AcDream.App/UI/UiChatView.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Numerics; +using System.Text; using AcDream.App.Rendering; namespace AcDream.App.UI; @@ -12,10 +13,10 @@ namespace AcDream.App.UI; /// text inside the window. /// /// -/// This is the read-only foundation. A follow-up sub-step adds glScissor-based -/// clipping + word-wrap, drag-to-select, and Ctrl+C copy (which needs the -/// opt-out so an interior drag -/// selects text instead of moving the window). +/// Supports Windows-like text selection: a left-click-drag inside the transcript +/// selects characters (the opt-out +/// stops that interior drag from moving the host window), and Ctrl+C copies the +/// selected span to the clipboard. Ctrl+A selects everything. /// /// public sealed class UiChatView : UiElement @@ -23,15 +24,26 @@ public sealed class UiChatView : UiElement /// One display line: pre-formatted text + its colour. public readonly record struct Line(string Text, Vector4 Color); + /// A caret position: a line index into the cached line list plus a + /// character index (0..line.Text.Length, i.e. a caret slot between glyphs). + public readonly record struct Pos(int Line, int Col); + /// Provider of the lines to show, oldest-first. Polled each frame. public Func> LinesProvider { get; set; } = static () => Array.Empty(); /// Font for the transcript; falls back to the context default. public BitmapFont? Font { get; set; } + /// Keyboard device for clipboard (Ctrl+C) + modifier state. Wired by + /// the host from . + public Silk.NET.Input.IKeyboard? Keyboard { get; set; } + /// Backing fill behind the text (retail chat is a dark translucent box). public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0.35f); + /// Highlight colour painted behind a selected character span. + public Vector4 SelectionColor { get; set; } = new(0.25f, 0.45f, 0.85f, 0.5f); + /// Inner text inset from the view edges, px. public float Padding { get; set; } = 4f; @@ -39,6 +51,25 @@ public sealed class UiChatView : UiElement private float _scroll; private const float WheelLines = 3f; // lines advanced per wheel notch + // ── Cached layout from the last OnDraw, so OnEvent hit-tests the SAME geometry ── + private IReadOnlyList _lastLines = Array.Empty(); + private BitmapFont? _lastFont; + private float _lastLineHeight = 16f; + private float _lastBaseY; // top Y of line 0 in local space + private float _lastPadding = 4f; + + // ── Selection state ────────────────────────────────────────────────── + private Pos? _selAnchor; // where the drag started + private Pos? _selCaret; // where the drag currently is + private bool _selecting; + + public UiChatView() + { + AcceptsFocus = true; + IsEditControl = true; // absorb keys (Ctrl+C) while focused + CapturesPointerDrag = true; // interior drag selects, doesn't move the window + } + /// /// Clamp a scroll offset to [0, max] where max = content-height - view-height /// (never negative — when everything fits, scroll is pinned to 0). Exposed for tests. @@ -58,6 +89,14 @@ public sealed class UiChatView : UiElement if (font is null) return; var lines = LinesProvider(); + + // Cache the geometry OnEvent will hit-test against. Even when there are no + // lines we record the font/padding so a stray hit-test is harmless. + _lastLines = lines; + _lastFont = font; + _lastLineHeight = font.LineHeight; + _lastPadding = Padding; + if (lines.Count == 0) return; float lh = font.LineHeight; @@ -69,23 +108,244 @@ public sealed class UiChatView : UiElement // Bottom-pin: with _scroll==0 the LAST line ends at `bottom`; scrolling up // shifts the whole block down so older lines are revealed at the top. float baseY = bottom - contentH + _scroll; + _lastBaseY = baseY; + + // Normalised selection span (start <= end), if any. + bool hasSel = TryGetOrderedSelection(out Pos selStart, out Pos selEnd); + for (int i = 0; i < lines.Count; i++) { float y = baseY + i * lh; if (y < top || y + lh > bottom) continue; // whole-line vertical clip (no scissor yet) - ctx.DrawString(lines[i].Text, Padding, y, lines[i].Color, font); + + string text = lines[i].Text; + + // Selection highlight behind this line's selected character span. + if (hasSel && i >= selStart.Line && i <= selEnd.Line) + { + int c0 = i == selStart.Line ? selStart.Col : 0; + int c1 = i == selEnd.Line ? selEnd.Col : text.Length; + c0 = Math.Clamp(c0, 0, text.Length); + c1 = Math.Clamp(c1, 0, text.Length); + if (c1 > c0) + { + float hx = Padding + font.MeasureWidth(text.Substring(0, c0)); + float hw = font.MeasureWidth(text.Substring(c0, c1 - c0)); + ctx.DrawRect(hx, y, hw, lh, SelectionColor); + } + } + + ctx.DrawString(text, Padding, y, lines[i].Color, font); } } public override bool OnEvent(in UiEvent e) { - if (e.Type == UiEventType.Scroll) + switch (e.Type) { - float lh = Font?.LineHeight ?? 16f; - // Silk wheel +Y = scroll up = reveal older = shift content down = larger _scroll. - _scroll += e.Data0 * WheelLines * lh; // re-clamped next OnDraw against live content - return true; + case UiEventType.Scroll: + { + float lh = (Font ?? _lastFont)?.LineHeight ?? 16f; + // Silk wheel +Y = scroll up = reveal older = shift content down = larger _scroll. + _scroll += e.Data0 * WheelLines * lh; // re-clamped next OnDraw against live content + return true; + } + + case UiEventType.MouseDown: + { + // Data1/Data2 = local-to-target coords (UiRoot.OnMouseDown). + var p = HitChar(e.Data1, e.Data2); + _selAnchor = p; + _selCaret = p; + _selecting = true; + return true; + } + + case UiEventType.MouseMove: + { + if (_selecting) + { + // Data1/Data2 = local-to-target coords (DispatchMouseMove). + _selCaret = HitChar(e.Data1, e.Data2); + return true; + } + return false; + } + + case UiEventType.MouseUp: + { + _selecting = false; + return true; + } + + case UiEventType.KeyDown: + { + var key = (Silk.NET.Input.Key)e.Data0; + bool ctrl = Keyboard is not null + && (Keyboard.IsKeyPressed(Silk.NET.Input.Key.ControlLeft) + || Keyboard.IsKeyPressed(Silk.NET.Input.Key.ControlRight)); + if (ctrl && key == Silk.NET.Input.Key.C) + { + // Only touch the clipboard when there's a selection — an empty + // copy must NOT clobber what the user previously copied. + if (Keyboard is not null) + { + string sel = SelectedText(); + if (sel.Length > 0) Keyboard.ClipboardText = sel; + } + return true; + } + if (ctrl && key == Silk.NET.Input.Key.A) + { + SelectAll(); + return true; + } + return false; + } } return false; } + + // ── Selection helpers ──────────────────────────────────────────────── + + /// Select the entire cached transcript (Ctrl+A). + private void SelectAll() + { + var lines = _lastLines; + if (lines.Count == 0) + { + _selAnchor = _selCaret = null; + return; + } + int last = lines.Count - 1; + _selAnchor = new Pos(0, 0); + _selCaret = new Pos(last, lines[last].Text.Length); + } + + /// Normalise (anchor, caret) into ordered (start, end). False if no + /// selection or it is empty (anchor == caret). + private bool TryGetOrderedSelection(out Pos start, out Pos end) + { + start = default; end = default; + if (_selAnchor is not { } a || _selCaret is not { } c) return false; + (start, end) = Order(a, c); + return !(start.Line == end.Line && start.Col == end.Col); + } + + /// The currently-selected text against the cached lines. Empty when + /// nothing is selected. + public string SelectedText() + { + if (!TryGetOrderedSelection(out var start, out var end)) return string.Empty; + return SelectedText(_lastLines, start, end); + } + + // ── Pure, testable logic (no GL / no font texture) ─────────────────── + + /// Order two caret positions so the first is <= the second (by line, + /// then column). + public static (Pos start, Pos end) Order(Pos a, Pos b) + { + if (a.Line < b.Line || (a.Line == b.Line && a.Col <= b.Col)) return (a, b); + return (b, a); + } + + /// + /// Assemble the selected substring spanning .. + /// (inclusive of start.Col, exclusive of end.Col) from + /// . Multi-line selections are joined with "\n": + /// the first line from start.Col to its end, whole middle lines, and the last + /// line up to end.Col. Pure — unit-testable without GL. + /// + public static string SelectedText(IReadOnlyList lines, Pos start, Pos end) + { + if (lines.Count == 0) return string.Empty; + (start, end) = Order(start, end); + + int sl = Math.Clamp(start.Line, 0, lines.Count - 1); + int el = Math.Clamp(end.Line, 0, lines.Count - 1); + + if (sl == el) + { + string t = lines[sl].Text; + int c0 = Math.Clamp(start.Col, 0, t.Length); + int c1 = Math.Clamp(end.Col, 0, t.Length); + if (c1 <= c0) return string.Empty; + return t.Substring(c0, c1 - c0); + } + + var sb = new StringBuilder(); + + // First line: from start.Col to its end. + { + string t = lines[sl].Text; + int c0 = Math.Clamp(start.Col, 0, t.Length); + sb.Append(t.AsSpan(c0)); + } + + // Whole middle lines. + for (int i = sl + 1; i < el; i++) + { + sb.Append('\n'); + sb.Append(lines[i].Text); + } + + // Last line: up to end.Col. + { + sb.Append('\n'); + string t = lines[el].Text; + int c1 = Math.Clamp(end.Col, 0, t.Length); + sb.Append(t.AsSpan(0, c1)); + } + + return sb.ToString(); + } + + /// + /// Convert a local-space point to a caret against the cached + /// layout from the last draw. line = floor((localY - baseY)/lineHeight) clamped + /// to the line range; col via . + /// + private Pos HitChar(float localX, float localY) + { + var lines = _lastLines; + if (lines.Count == 0) return new Pos(0, 0); + + float lh = _lastLineHeight <= 0f ? 16f : _lastLineHeight; + int line = (int)MathF.Floor((localY - _lastBaseY) / lh); + line = Math.Clamp(line, 0, lines.Count - 1); + + string text = lines[line].Text; + var font = _lastFont; + int col = font is null + ? 0 + : CharIndexAt(text, ch => font.TryGetGlyph(ch, out var g) ? g.Advance : 0f, + localX - _lastPadding); + return new Pos(line, col); + } + + /// + /// The caret column for a horizontal position (already + /// adjusted for the left padding, so x=0 is the start of the text). Walks the + /// string accumulating each glyph's advance and snaps the caret to whichever + /// side of the glyph midpoint falls on — natural + /// Windows-like caret placement. Pure — unit-testable with a synthetic advance. + /// + /// The line text. + /// Per-character advance (pixels) lookup. + /// Horizontal position relative to the text's left edge. + public static int CharIndexAt(string text, Func advanceOf, float x) + { + if (string.IsNullOrEmpty(text) || x <= 0f) return 0; + + float cursor = 0f; + for (int i = 0; i < text.Length; i++) + { + float adv = advanceOf(text[i]); + float mid = cursor + adv * 0.5f; + if (x < mid) return i; // caret sits before this glyph + cursor += adv; + } + return text.Length; // past the last glyph → end caret + } } diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index e16c888f..937a52b2 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -102,6 +102,12 @@ public abstract class UiElement /// resizes it (window resize). Intended for top-level panels. public bool Resizable { get; set; } + /// If true, a left-drag starting on this element is delivered to the + /// element (e.g. text selection) instead of moving/resizing an ancestor window. + /// Edge resize on a resizable ancestor still wins — only the interior move / + /// drag-drop candidacy is suppressed in favour of the element's own handling. + public bool CapturesPointerDrag { get; set; } + /// Minimum size enforced while resizing. public float MinWidth { get; set; } = 40f; public float MinHeight { get; set; } = 40f; diff --git a/src/AcDream.App/UI/UiHost.cs b/src/AcDream.App/UI/UiHost.cs index 5f697cfb..a372f891 100644 --- a/src/AcDream.App/UI/UiHost.cs +++ b/src/AcDream.App/UI/UiHost.cs @@ -39,6 +39,13 @@ public sealed class UiHost : System.IDisposable public UiRoot Root { get; } = new(); public TextRenderer TextRenderer { get; } public BitmapFont? DefaultFont { get; set; } + + /// The last wired keyboard. Exposed so widgets that need clipboard + /// access () or modifier-key state + /// () — e.g. 's + /// Ctrl+C copy — can reach the device. One-keyboard desktop: last wins. + public IKeyboard? Keyboard { get; private set; } + private long _startTicks = System.Environment.TickCount64; public UiHost(GL gl, string shaderDir, BitmapFont? defaultFont = null) @@ -82,6 +89,7 @@ public sealed class UiHost : System.IDisposable public void WireKeyboard(IKeyboard kb) { + Keyboard = kb; // last wired keyboard wins (one-keyboard desktop) kb.KeyDown += (_, k, _) => Root.OnKeyDown((int)k); kb.KeyUp += (_, k, _) => Root.OnKeyUp((int)k); kb.KeyChar += (_, c) => Root.OnChar(c); diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index 6f836253..e57d02e3 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -197,7 +197,7 @@ public sealed class UiRoot : UiElement if (Modal is not null && !ContainsAbsolute(Modal, x, y)) return; - var (target, lx, ly) = HitTestTopDown(x, y); + var (target, _, _) = HitTestTopDown(x, y); if (target is null) { WorldMouseFallThrough?.Invoke(btn, x, y, flags); @@ -218,6 +218,8 @@ public sealed class UiRoot : UiElement var edges = window.Resizable ? HitEdges(window, x, y, ResizeGrip) : ResizeEdges.None; if (edges != ResizeEdges.None) { + // Edge resize still wins, even over a CapturesPointerDrag child: + // a resizable chat window can be resized from its frame. _resizeTarget = window; _resizeEdges = edges; _resizeStartX = window.Left; _resizeStartY = window.Top; @@ -225,6 +227,14 @@ public sealed class UiRoot : UiElement _resizeMouseX = x; _resizeMouseY = y; _dragCandidate = false; } + else if (target.CapturesPointerDrag) + { + // The pressed widget owns interior drags (e.g. text selection): + // do NOT move the ancestor window. The already-dispatched MouseDown + // event + SetCapture(target) let the target drive its own drag via + // the MouseMove events it receives while captured. + _dragCandidate = false; + } else if (window.Draggable) { _windowDragTarget = window; @@ -234,6 +244,11 @@ public sealed class UiRoot : UiElement } else { _dragCandidate = true; } } + else if (target.CapturesPointerDrag) + { + // No window ancestor, but the target still owns its interior drag. + _dragCandidate = false; + } else { _dragCandidate = true; @@ -247,8 +262,13 @@ public sealed class UiRoot : UiElement UiMouseButton.Middle => UiEventType.MiddleDown, _ => UiEventType.MouseDown, }; + // Deliver TARGET-LOCAL coords (consistent with MouseMove/MouseUp, which use + // target.ScreenPosition). HitTestTopDown's lx/ly are relative to the TOP-LEVEL + // child, so for a nested target (e.g. the chat view inset inside its window) + // they'd be offset by the child's position — which mis-anchored drag-select. + var sp = target.ScreenPosition; var e = new UiEvent(target.EventId, target, rawType, - Data0: (int)flags, Data1: (int)lx, Data2: (int)ly); + Data0: (int)flags, Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y)); BubbleEvent(target, in e); } diff --git a/tests/AcDream.App.Tests/UI/UiChatViewTests.cs b/tests/AcDream.App.Tests/UI/UiChatViewTests.cs index 6dc9f22a..7a02b183 100644 --- a/tests/AcDream.App.Tests/UI/UiChatViewTests.cs +++ b/tests/AcDream.App.Tests/UI/UiChatViewTests.cs @@ -1,3 +1,6 @@ +using System; +using System.Collections.Generic; +using System.Numerics; using AcDream.App.UI; namespace AcDream.App.Tests.UI; @@ -25,4 +28,116 @@ public class UiChatViewTests { Assert.Equal(0f, UiChatView.ClampScroll(-50f, contentHeight: 500f, viewHeight: 200f)); } + + // ── Char-index hit-testing (x → col) with a synthetic 10px monospace advance ── + + private static readonly Func Mono10 = static _ => 10f; + + [Fact] + public void CharIndexAt_ZeroOrNegative_IsColumnZero() + { + Assert.Equal(0, UiChatView.CharIndexAt("hello", Mono10, 0f)); + Assert.Equal(0, UiChatView.CharIndexAt("hello", Mono10, -5f)); + } + + [Fact] + public void CharIndexAt_SnapsToGlyphMidpoint() + { + // glyph[0] spans 0..10 (midpoint 5), glyph[1] 10..20 (midpoint 15), ... + Assert.Equal(0, UiChatView.CharIndexAt("hello", Mono10, 4f)); // before mid of glyph 0 + Assert.Equal(1, UiChatView.CharIndexAt("hello", Mono10, 6f)); // past mid of glyph 0 + Assert.Equal(1, UiChatView.CharIndexAt("hello", Mono10, 14f)); // before mid of glyph 1 + Assert.Equal(2, UiChatView.CharIndexAt("hello", Mono10, 16f)); // past mid of glyph 1 + } + + [Fact] + public void CharIndexAt_PastEnd_IsLength() + { + Assert.Equal(5, UiChatView.CharIndexAt("hello", Mono10, 1000f)); + } + + [Fact] + public void CharIndexAt_EmptyString_IsZero() + { + Assert.Equal(0, UiChatView.CharIndexAt("", Mono10, 50f)); + } + + // ── SelectedText assembly ──────────────────────────────────────────── + + private static IReadOnlyList Lines(params string[] texts) + { + var list = new List(texts.Length); + foreach (var t in texts) + list.Add(new UiChatView.Line(t, new Vector4(1, 1, 1, 1))); + return list; + } + + [Fact] + public void SelectedText_SingleLine_Substring() + { + var lines = Lines("hello world"); + var s = UiChatView.SelectedText(lines, new UiChatView.Pos(0, 6), new UiChatView.Pos(0, 11)); + Assert.Equal("world", s); + } + + [Fact] + public void SelectedText_SingleLine_ReversedAnchorCaret_IsNormalised() + { + var lines = Lines("hello world"); + // caret BEFORE anchor — Order() must normalise. + var s = UiChatView.SelectedText(lines, new UiChatView.Pos(0, 11), new UiChatView.Pos(0, 6)); + Assert.Equal("world", s); + } + + [Fact] + public void SelectedText_SamePosition_IsEmpty() + { + var lines = Lines("hello"); + Assert.Equal("", UiChatView.SelectedText(lines, new UiChatView.Pos(0, 3), new UiChatView.Pos(0, 3))); + } + + [Fact] + public void SelectedText_MultiLine_JoinsWithNewline() + { + var lines = Lines("first line", "second line", "third line"); + // from col 6 of line 0 ("line") through col 5 of line 2 ("third") + var s = UiChatView.SelectedText(lines, new UiChatView.Pos(0, 6), new UiChatView.Pos(2, 5)); + Assert.Equal("line\nsecond line\nthird", s); + } + + [Fact] + public void SelectedText_MultiLine_TwoLines_NoMiddle() + { + var lines = Lines("alpha", "bravo"); + var s = UiChatView.SelectedText(lines, new UiChatView.Pos(0, 2), new UiChatView.Pos(1, 3)); + Assert.Equal("pha\nbra", s); + } + + [Fact] + public void SelectedText_MultiLine_ReversedAnchorCaret_IsNormalised() + { + var lines = Lines("alpha", "bravo"); + // end before start → Order() swaps them. + var s = UiChatView.SelectedText(lines, new UiChatView.Pos(1, 3), new UiChatView.Pos(0, 2)); + Assert.Equal("pha\nbra", s); + } + + [Fact] + public void SelectedText_EmptyLineList_IsEmpty() + { + Assert.Equal("", UiChatView.SelectedText(Array.Empty(), + new UiChatView.Pos(0, 0), new UiChatView.Pos(0, 0))); + } + + [Fact] + public void Order_SortsByLineThenColumn() + { + var (s1, e1) = UiChatView.Order(new UiChatView.Pos(2, 1), new UiChatView.Pos(0, 5)); + Assert.Equal(new UiChatView.Pos(0, 5), s1); + Assert.Equal(new UiChatView.Pos(2, 1), e1); + + var (s2, e2) = UiChatView.Order(new UiChatView.Pos(1, 8), new UiChatView.Pos(1, 2)); + Assert.Equal(new UiChatView.Pos(1, 2), s2); + Assert.Equal(new UiChatView.Pos(1, 8), e2); + } } diff --git a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs index 1adbffcd..c3160e66 100644 --- a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs +++ b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs @@ -14,6 +14,41 @@ public class UiRootInputTests Assert.Equal(AnchorEdges.None, panel.Anchors); } + private sealed class CoordRecorder : UiElement + { + public (int x, int y)? Down, Move; + public CoordRecorder() { CapturesPointerDrag = true; } + public override bool OnEvent(in UiEvent e) + { + if (e.Type == UiEventType.MouseDown) { Down = (e.Data1, e.Data2); return true; } + if (e.Type == UiEventType.MouseMove) { Move = (e.Data1, e.Data2); return true; } + return false; + } + } + + [Fact] + public void MouseDown_And_MouseMove_DeliverSameTargetLocalFrame_ForNestedChild() + { + // Regression (adversarial review): a nested child must receive target-LOCAL + // coords on MouseDown AND MouseMove for the same physical point — otherwise + // drag-select anchors ~(child offset) px off from where you click. Before the + // fix MouseDown used HitTestTopDown's window-relative coords (50,40) while + // MouseMove used target-local (42,32). + var root = new UiRoot { Width = 800, Height = 600 }; + var panel = new UiPanel { Left = 50, Top = 60, Width = 200, Height = 100 }; + var child = new CoordRecorder { Left = 8, Top = 8, Width = 150, Height = 80 }; + panel.AddChild(child); + root.AddChild(panel); + + // child ScreenPosition = (58,68). Click screen (100,100) -> local (42,32). + root.OnMouseDown(UiMouseButton.Left, 100, 100); + Assert.Equal((42, 32), child.Down); + + // drag to (120,110) -> local (62,42); MUST share the MouseDown frame. + root.OnMouseMove(120, 110); + Assert.Equal((62, 42), child.Move); + } + [Fact] public void ApplyAnchor_None_IsNoOp() { @@ -70,6 +105,54 @@ public class UiRootInputTests Assert.Equal(10f, panel.Top); } + [Fact] + public void CapturesPointerDragChild_DoesNotMoveDraggableAncestor_OnInteriorDrag() + { + // A child that captures pointer drags (text selection) must NOT move its + // draggable ancestor window when the user drags inside it. + var root = new UiRoot { Width = 800, Height = 600 }; + var window = new UiPanel { Left = 10, Top = 10, Width = 200, Height = 100, Draggable = true }; + var child = new UiPanel { Left = 20, Top = 20, Width = 120, Height = 60, CapturesPointerDrag = true }; + window.AddChild(child); + root.AddChild(window); + + // Press deep inside the child, then drag. + root.OnMouseDown(UiMouseButton.Left, 60, 60); + root.OnMouseMove(160, 160); + + // Window stays put; the captured child receives the drag itself. + Assert.Equal(10f, window.Left); + Assert.Equal(10f, window.Top); + Assert.Same(child, root.Captured); + + root.OnMouseUp(UiMouseButton.Left, 160, 160); + Assert.Equal(10f, window.Left); + Assert.Equal(10f, window.Top); + } + + [Fact] + public void CapturesPointerDragChild_StillAllowsEdgeResizeOfResizableWindow() + { + // Edge resize must still win even when a CapturesPointerDrag child covers + // the frame: a resizable chat window can be resized from its border. + var root = new UiRoot { Width = 800, Height = 600 }; + var window = new UiPanel { Left = 100, Top = 100, Width = 200, Height = 100, + Draggable = true, Resizable = true, MinWidth = 40, MinHeight = 40 }; + // Child fills the whole window (anchored) and captures interior drags. + var child = new UiPanel { Left = 0, Top = 0, Width = 200, Height = 100, + CapturesPointerDrag = true, + Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom }; + window.AddChild(child); + root.AddChild(window); + + // Grab within ResizeGrip(5) of the right edge (x=298 of right edge x=300) → resize. + root.OnMouseDown(UiMouseButton.Left, 298, 150); + root.OnMouseMove(338, 150); + Assert.Equal(240f, window.Width); + Assert.Equal(100f, window.Left); + root.OnMouseUp(UiMouseButton.Left, 338, 150); + } + [Fact] public void ResizeRect_RightBottom_GrowsSizeOnly() { From 73468be02aa47f04c4a69331b6603d578b955f0c Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 10:39:56 +0200 Subject: [PATCH 28/99] fix(D.2b): tile the vital-bar middle instead of stretching it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retail repeats the bar's "fill-tile" graphic at native width (verified: the dat element 0x100000E9 is literally the fill-tile; the engine fills via ImgTex::TileCSI; and a widened side-by-side shows retail tiling, not stretching). acdream was stretching one copy of the middle slice across the whole span, so the bevel/bead pattern smeared as the window widened. UiMeter.DrawHBar now UV-repeats each slice at its NATIVE width: caps span one native width (a single 1:1 copy), the wide middle spans many (it tiles, last copy UV-cropped). This works because the UI textures are already GL_REPEAT- wrapped (TextureCache.UploadRgba8) — the exact mechanism UiNineSlicePanel's chrome border already uses, so the border edges were ALREADY tiling and need no change. One draw call per slice; composes with the existing fill-fraction clip (the partial last tile shows a partial bead). render-vitals-mockup now renders a widened window twice (stretch vs tile) so the difference is verifiable headless. Confirmed the tile repeats seamlessly (no seams). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/UiMeter.cs | 43 ++++++---- src/AcDream.Cli/VitalsMockup.cs | 141 +++++++++++++++++++------------- 2 files changed, 108 insertions(+), 76 deletions(-) diff --git a/src/AcDream.App/UI/UiMeter.cs b/src/AcDream.App/UI/UiMeter.cs index f2b44f50..bb5bb55b 100644 --- a/src/AcDream.App/UI/UiMeter.cs +++ b/src/AcDream.App/UI/UiMeter.cs @@ -35,20 +35,21 @@ public sealed class UiMeter : UiElement public Func? SpriteResolve { get; set; } // Retail vital bars are a horizontal 3-slice: a fixed-width bevelled left-cap, - // a stretched gradient middle, and a fixed-width right-cap. The "back" slice is - // the empty track (drawn full width); the "front" slice is the coloured fill - // (drawn from the left, grown to the fill fraction — the track owns the right - // end, so the fill omits its own right-cap). Ids come from the vitals LayoutDesc - // (0x21000014) via tools/dump-vitals-bars; 0 = none. + // a TILED gradient middle (the "fill-tile" repeats at native width — it does not + // stretch), and a fixed-width right-cap. The "back" slice is the empty track + // (drawn full width); the "front" slice is the coloured fill (drawn full-geometry + // but CLIPPED to the fill fraction — its own right-cap shows at 100%, the back's + // shows through when partial). Ids come from the stacked vitals LayoutDesc + // (0x2100006C) via the dump-vitals-layout CLI; 0 = none. /// Empty-track left-cap RenderSurface id. public uint BackLeft { get; set; } - /// Empty-track middle (stretched gradient) RenderSurface id. + /// Empty-track middle (tiled gradient) RenderSurface id. public uint BackTile { get; set; } /// Empty-track right-cap RenderSurface id. public uint BackRight { get; set; } /// Coloured-fill left-cap RenderSurface id. public uint FrontLeft { get; set; } - /// Coloured-fill middle (stretched gradient) RenderSurface id. + /// Coloured-fill middle (tiled gradient) RenderSurface id. public uint FrontTile { get; set; } /// Coloured-fill right-cap RenderSurface id. public uint FrontRight { get; set; } @@ -130,28 +131,36 @@ public sealed class UiMeter : UiElement if (clipW <= 0f) return; float w = Width, h = Height; var (lt, lw, _) = resolve(leftId); - var (mt, _, _) = resolve(midId); + var (mt, mw, _) = resolve(midId); var (rt, rw, _) = resolve(rightId); float capL = lt != 0 ? MathF.Min(lw, w) : 0f; float capR = rt != 0 ? MathF.Min(rw, w - capL) : 0f; float midW = w - capL - capR; - DrawPiece(ctx, lt, 0f, capL, h, clipW); - DrawPiece(ctx, mt, capL, midW, h, clipW); - DrawPiece(ctx, rt, w - capR, capR, h, clipW); + // Each slice's texture repeats every NATIVE-width px (UV-repeat; the UI + // texture is GL_REPEAT-wrapped — TextureCache.UploadRgba8). Caps span their + // own native width → a single 1:1 copy. The wide middle spans many native + // widths → it TILES, matching retail's "fill-tile" + ImgTex::TileCSI rather + // than stretching one copy. (Same UV-repeat the chrome border already uses.) + DrawPiece(ctx, lt, 0f, capL, lw, h, clipW); + DrawPiece(ctx, mt, capL, midW, mw, h, clipW); + DrawPiece(ctx, rt, w - capR, capR, rw, h, clipW); } - /// Draw one slice spanning local [, - /// pieceX+], UV-cropped so nothing past - /// shows. + /// Draw a slice over local [, + /// pieceX+], with the texture repeating every + /// px (UV-repeat — the UI texture is GL_REPEAT-wrapped). + /// Clipped so nothing past shows. For a cap (span == native) + /// this is one 1:1 copy; for the wide middle it tiles; a partial last copy is + /// UV-cropped. private static void DrawPiece( - UiRenderContext ctx, uint tex, float pieceX, float pieceW, float h, float clipW) + UiRenderContext ctx, uint tex, float pieceX, float pieceW, float nativeW, float h, float clipW) { - if (tex == 0 || pieceW <= 0f) return; + if (tex == 0 || pieceW <= 0f || nativeW <= 0f) return; float visibleW = MathF.Min(pieceW, clipW - pieceX); if (visibleW <= 0f) return; - float u1 = visibleW / pieceW; // crop the texture horizontally + float u1 = visibleW / nativeW; // >1 ⇒ texture repeats (tiles); ≤1 ⇒ a partial copy ctx.DrawSprite(tex, pieceX, 0f, visibleW, h, 0f, 0f, u1, 1f, Vector4.One); } } diff --git a/src/AcDream.Cli/VitalsMockup.cs b/src/AcDream.Cli/VitalsMockup.cs index b53d8f4f..90c222f4 100644 --- a/src/AcDream.Cli/VitalsMockup.cs +++ b/src/AcDream.Cli/VitalsMockup.cs @@ -10,17 +10,15 @@ namespace AcDream.Cli; /// /// Headless PNG preview of the retail STACKED vitals window (LayoutDesc -/// 0x2100006C, 160x58), composited with the SAME model the in-client UiMeter -/// uses: an 8-piece chrome border, then three flush-stacked 150x16 bars, each -/// drawn as a BACK 3-slice (empty track, full width) + a FRONT 3-slice -/// (coloured fill) horizontally CLIPPED to the fill fraction — so the front's -/// own right-cap shows at full, and clipping reveals the back's right-cap when -/// partial (matching retail's scissor-fill). All ids are dat-verified from -/// 0x2100006C via dump-vitals-layout. +/// 0x2100006C). Renders the window WIDENED, twice: once with the middle slice +/// STRETCHED (acdream's current behaviour) and once TILED (retail behaviour — +/// the "fill-tile" element is repeated at native width, last copy clipped). +/// Lets the stretch-vs-tile difference be judged by eye before touching the +/// client. Bars = back 3-slice (empty track, full) + front 3-slice (fill). /// public static class VitalsMockup { - // 8-piece chrome border (RetailChromeSprites; 5px), dat-verified in 0x2100006C. + // 8-piece chrome border (dat-verified in 0x2100006C; 5px). private const uint TL = 0x060074C3, TOP = 0x060074BF, TR = 0x060074C4; private const uint LEFT = 0x060074C0, RIGHT = 0x060074C2; private const uint BL = 0x060074C5, BOT = 0x060074C1, BR = 0x060074C6; @@ -29,91 +27,104 @@ public static class VitalsMockup string Name, float Frac, uint BackL, uint BackM, uint BackR, uint FrontL, uint FrontM, uint FrontR); - // Stacked-window (0x2100006C) sprite ids — NOT the floaty-row 0x0600113x set. private static readonly Vital[] Vitals = { - new("health", 0.80f, 0x0600747E, 0x0600747F, 0x06007480, 0x06007481, 0x06007482, 0x06007483), - new("stamina", 0.50f, 0x06007484, 0x06007485, 0x06007486, 0x06007487, 0x06007488, 0x06007489), - new("mana", 0.65f, 0x0600748A, 0x0600748B, 0x0600748C, 0x0600748D, 0x0600748E, 0x0600748F), + new("health", 1.00f, 0x0600747E, 0x0600747F, 0x06007480, 0x06007481, 0x06007482, 0x06007483), + new("stamina", 1.00f, 0x06007484, 0x06007485, 0x06007486, 0x06007487, 0x06007488, 0x06007489), + new("mana", 1.00f, 0x0600748A, 0x0600748B, 0x0600748C, 0x0600748D, 0x0600748E, 0x0600748F), }; - // Window geometry from 0x2100006C: 160x58, 5px border, bars at x=5 y=5/21/37, 150x16. - private const int WinW = 160, WinH = 58, Border = 5, BarX = 5, BarW = 150, BarH = 16; - private static readonly int[] BarY = { 5, 21, 37 }; - private const int Zoom = 5; + private const int Border = 5, BarH = 16, Zoom = 4; + // Widened bars so stretch-vs-tile is obvious (native middle tile ~100px). + private const int BarW = 280; + private static readonly int[] BarLocalY = { 0, 16, 32 }; // flush stacked inside the interior public static int Render(string datDir, string outPath) { if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; } using var dats = new DatCollection(datDir, DatAccessType.Read); - using var canvas = new Image(WinW, WinH, new Rgba32(0, 0, 0, 0)); + int winW = BarW + 2 * Border; // 290 + int winH = 3 * BarH + 2 * Border; // 58 + int gap = 16; + using var canvas = new Image(winW, winH * 2 + gap, new Rgba32(20, 20, 24, 255)); - // 8-piece chrome border. + DrawWindow(canvas, dats, 0, winW, winH, tileMid: false); // top: STRETCH (current) + DrawWindow(canvas, dats, winH + gap, winW, winH, tileMid: true); // bottom: TILE (retail) + + canvas.Mutate(c => c.Resize(canvas.Width * Zoom, canvas.Height * Zoom, KnownResamplers.NearestNeighbor)); + canvas.SaveAsPng(outPath); + Console.WriteLine($"wrote {outPath} ({canvas.Width}x{canvas.Height}) — TOP=stretch(current) BOTTOM=tile(retail), widened {BarW}px bars"); + return 0; + } + + private static void DrawWindow(Image canvas, DatCollection dats, int offY, int winW, int winH, bool tileMid) + { + // 8-piece chrome border (kept identical in both rows; only the bar fill varies). using (var tl = Load(dats, TL)) using (var top = Load(dats, TOP)) using (var tr = Load(dats, TR)) using (var le = Load(dats, LEFT)) using (var ri = Load(dats, RIGHT)) using (var bl = Load(dats, BL)) using (var bo = Load(dats, BOT)) using (var br = Load(dats, BR)) { - Blit(canvas, tl, 0, 0, Border, Border); - Blit(canvas, top, Border, 0, WinW - 2 * Border, Border); - Blit(canvas, tr, WinW - Border, 0, Border, Border); - Blit(canvas, le, 0, Border, Border, WinH - 2 * Border); - Blit(canvas, ri, WinW - Border, Border, Border, WinH - 2 * Border); - Blit(canvas, bl, 0, WinH - Border, Border, Border); - Blit(canvas, bo, Border, WinH - Border, WinW - 2 * Border, Border); - Blit(canvas, br, WinW - Border, WinH - Border, Border, Border); + Blit(canvas, tl, 0, offY, Border, Border); + Blit(canvas, top, Border, offY, winW - 2 * Border, Border); + Blit(canvas, tr, winW - Border, offY, Border, Border); + Blit(canvas, le, 0, offY + Border, Border, winH - 2 * Border); + Blit(canvas, ri, winW - Border, offY + Border, Border, winH - 2 * Border); + Blit(canvas, bl, 0, offY + winH - Border, Border, Border); + Blit(canvas, bo, Border, offY + winH - Border, winW - 2 * Border, Border); + Blit(canvas, br, winW - Border, offY + winH - Border, Border, Border); } for (int i = 0; i < Vitals.Length; i++) { var v = Vitals[i]; - int y = BarY[i]; + int y = offY + Border + BarLocalY[i]; using var bl_ = Load(dats, v.BackL); using var bm = Load(dats, v.BackM); using var br_ = Load(dats, v.BackR); using var fl = Load(dats, v.FrontL); using var fm = Load(dats, v.FrontM); using var fr = Load(dats, v.FrontR); - Console.WriteLine($"{v.Name,-8} back[{bl_.Width}x{bl_.Height} {bm.Width}x{bm.Height} {br_.Width}x{br_.Height}] " + - $"front[{fl.Width}x{fl.Height} {fm.Width}x{fm.Height} {fr.Width}x{fr.Height}] frac={v.Frac}"); - // Back track: full width. - DrawHBar(canvas, bl_, bm, br_, BarX, y, BarW, BarH, clipW: BarW); - // Front fill: full 3-slice clipped to the fraction. - DrawHBar(canvas, fl, fm, fr, BarX, y, BarW, BarH, clipW: (int)MathF.Round(BarW * v.Frac)); + DrawHBar(canvas, bl_, bm, br_, Border, y, BarW, BarH, BarW, tileMid); + int fw = (int)MathF.Round(BarW * v.Frac); + if (fw > 0) DrawHBar(canvas, fl, fm, fr, Border, y, BarW, BarH, fw, tileMid); } - - canvas.Mutate(c => c.Resize(WinW * Zoom, WinH * Zoom, KnownResamplers.NearestNeighbor)); - canvas.SaveAsPng(outPath); - Console.WriteLine($"wrote {outPath} ({WinW * Zoom}x{WinH * Zoom}; stacked window 0x2100006C, fracs h/s/m={Vitals[0].Frac}/{Vitals[1].Frac}/{Vitals[2].Frac})"); - return 0; } - public static int ExportSprite(string datDir, string idText, string outPath) - { - if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; } - uint id = ParseHex(idText); - if (id == 0) { Console.Error.WriteLine($"error: bad id '{idText}'"); return 2; } - using var dats = new DatCollection(datDir, DatAccessType.Read); - using var img = Load(dats, id); - img.SaveAsPng(outPath); - Console.WriteLine($"wrote {outPath} (0x{id:X8} {img.Width}x{img.Height})"); - return 0; - } - - /// Horizontal 3-slice (native-width left-cap, stretched middle, native-width - /// right-cap) clipped so nothing past (bar-local px) draws. - /// Mirrors the in-client UiMeter: back uses clipW=full, front uses clipW=frac*width. + /// Horizontal 3-slice: native-width left-cap, middle (STRETCHED or TILED + /// per ), native-width right-cap; clipped to clipW. private static void DrawHBar( Image canvas, Image left, Image mid, Image right, - int x, int y, int w, int h, int clipW) + int x, int y, int w, int h, int clipW, bool tileMid) { if (w <= 0 || clipW <= 0) return; int capL = Math.Min(left.Width, w); int capR = Math.Min(right.Width, w - capL); int midW = w - capL - capR; - DrawClippedPiece(canvas, left, x, y, 0, capL, h, clipW); - DrawClippedPiece(canvas, mid, x, y, capL, midW, h, clipW); - DrawClippedPiece(canvas, right, x, y, w - capR, capR, h, clipW); + + DrawClippedPiece(canvas, left, x, y, 0, capL, h, clipW); // left cap (once, native) + if (tileMid) TileMiddle(canvas, mid, x, y, capL, midW, h, clipW); // repeat native-width copies + else DrawClippedPiece(canvas, mid, x, y, capL, midW, h, clipW); // stretch across the span + DrawClippedPiece(canvas, right, x, y, w - capR, capR, h, clipW); // right cap (once, native) } - /// Draw one slice spanning bar-local [pieceLocalX, pieceLocalX+pieceW], cropped - /// horizontally so nothing past clipW shows (UV-cropping the texture proportionally). + /// Fill [midLocalX, midLocalX+midW] by repeating the native-width tile at + /// 1:1 (no horizontal scaling), clipping the final partial copy and honouring clipW. + private static void TileMiddle( + Image canvas, Image mid, int x, int y, int midLocalX, int midW, int h, int clipW) + { + int tileW = Math.Max(1, mid.Width); + for (int mx = 0; mx < midW; mx += tileW) + { + int localX = midLocalX + mx; + int segW = Math.Min(tileW, midW - mx); // last copy may be partial + int visible = Math.Min(segW, clipW - localX); // fill-fraction clip + if (visible <= 0) break; + // 1:1 — crop the source to `visible` px (no resize-stretch), draw at native scale. + int cropW = Math.Min(visible, mid.Width); + using var seg = mid.Clone(c => c.Crop(new Rectangle(0, 0, cropW, mid.Height)).Resize(visible, h)); + canvas.Mutate(c => c.DrawImage(seg, new Point(x + localX, y), 1f)); + } + } + + /// Draw one slice spanning [pieceLocalX, +pieceW] STRETCHED to fill, UV-cropped + /// (proportionally) so nothing past clipW shows. private static void DrawClippedPiece( Image canvas, Image src, int x, int y, int pieceLocalX, int pieceW, int h, int clipW) { @@ -133,6 +144,18 @@ public static class VitalsMockup canvas.Mutate(c => c.DrawImage(s, new Point(x, y), 1f)); } + public static int ExportSprite(string datDir, string idText, string outPath) + { + if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; } + uint id = ParseHex(idText); + if (id == 0) { Console.Error.WriteLine($"error: bad id '{idText}'"); return 2; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var img = Load(dats, id); + img.SaveAsPng(outPath); + Console.WriteLine($"wrote {outPath} (0x{id:X8} {img.Width}x{img.Height})"); + return 0; + } + private static Image Load(DatCollection dats, uint id) { var rs = dats.Get(id); From 0f55599ba5ed0115027082cb77d5a3cb7a402db4 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 11:05:18 +0200 Subject: [PATCH 29/99] feat(D.2b): draw the window resize-grip overlay (gold ridges + corner studs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retail vitals window border is TWO layers, not one: the bevel chrome (0x060074BF-C6) PLUS a resize-grip overlay on top — gold ridged edge strips and a square corner stud at each corner. acdream only drew the bevel, so the border looked plainer than retail and the corners lacked the little square sprite the user spotted. The overlay ids come from the vitals LayoutDesc 0x2100006C (elements 0x1000063B-0x10000642): corner stud 0x06006129 (same 5x5 at all four corners), edge strips 0x0600612A/2C (top/bottom) and 0x0600612B/2D (left/right). They have transparent gaps so the bevel shows through — both layers are drawn. UiNineSlicePanel now draws the grip overlay (edges tiled via the existing UV-repeat, corner studs 1:1) after the bevel, so every retail-chrome window (vitals + chat) gets it. Verified the grip sprites + the composited result headlessly: dump-sprite-sheet (new CLI: composite arbitrary sprite ids magnified) showed 0x06006129 is a gold stud and 0x0600612A-D are gold ridged strips; render-vitals-mockup now renders the faithful default window with the overlay. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/RetailChromeSprites.cs | 18 ++++++ src/AcDream.App/UI/UiNineSlicePanel.cs | 13 ++++ src/AcDream.Cli/Program.cs | 13 ++++ src/AcDream.Cli/VitalsMockup.cs | 74 +++++++++++++++++++---- 4 files changed, 105 insertions(+), 13 deletions(-) diff --git a/src/AcDream.App/UI/RetailChromeSprites.cs b/src/AcDream.App/UI/RetailChromeSprites.cs index 70a8cb4e..f2a80fd7 100644 --- a/src/AcDream.App/UI/RetailChromeSprites.cs +++ b/src/AcDream.App/UI/RetailChromeSprites.cs @@ -45,4 +45,22 @@ public static class RetailChromeSprites /// Border thickness in pixels = the corner/edge sprite size (5px). public const int Border = 5; + + // ── Resize-grip overlay ────────────────────────────────────────────── + // A second 8-piece layer drawn ON TOP of the bevel above: the gold ridged + // accents + square corner studs that frame a resizable retail window. From + // the vitals LayoutDesc 0x2100006C (elements 0x1000063B–0x10000642): each + // corner is the same 5×5 stud (0x06006129); the edges are gold double-line + // strips tiled along each side. These have transparent gaps, so the bevel + // shows through — both layers are needed. + /// Corner grip stud, all four corners (5×5). + public const uint GripCorner = 0x06006129; + /// Top edge grip (10×5, tiled across). + public const uint GripTop = 0x0600612A; + /// Left edge grip (5×10, tiled down). + public const uint GripLeft = 0x0600612B; + /// Bottom edge grip (10×5). + public const uint GripBottom = 0x0600612C; + /// Right edge grip (5×10). + public const uint GripRight = 0x0600612D; } diff --git a/src/AcDream.App/UI/UiNineSlicePanel.cs b/src/AcDream.App/UI/UiNineSlicePanel.cs index 2e4465a1..9c18f095 100644 --- a/src/AcDream.App/UI/UiNineSlicePanel.cs +++ b/src/AcDream.App/UI/UiNineSlicePanel.cs @@ -72,6 +72,19 @@ public sealed class UiNineSlicePanel : UiPanel DrawStretched(ctx, RetailChromeSprites.CornerTR, r.TR); DrawStretched(ctx, RetailChromeSprites.CornerBL, r.BL); DrawStretched(ctx, RetailChromeSprites.CornerBR, r.BR); + + // Resize-grip overlay (gold ridged edges + square corner studs) drawn on + // top of the bevel — the second border layer the vitals LayoutDesc carries + // (0x1000063B–0x10000642). Edges tile; the corner stud is the same sprite + // at all four corners. + DrawTiled(ctx, RetailChromeSprites.GripTop, r.Top); + DrawTiled(ctx, RetailChromeSprites.GripBottom, r.Bottom); + DrawTiled(ctx, RetailChromeSprites.GripLeft, r.Left); + DrawTiled(ctx, RetailChromeSprites.GripRight, r.Right); + DrawStretched(ctx, RetailChromeSprites.GripCorner, r.TL); + DrawStretched(ctx, RetailChromeSprites.GripCorner, r.TR); + DrawStretched(ctx, RetailChromeSprites.GripCorner, r.BL); + DrawStretched(ctx, RetailChromeSprites.GripCorner, r.BR); } private void DrawTiled(UiRenderContext ctx, uint id, Rect d) diff --git a/src/AcDream.Cli/Program.cs b/src/AcDream.Cli/Program.cs index 0fdad988..6be503c4 100644 --- a/src/AcDream.Cli/Program.cs +++ b/src/AcDream.Cli/Program.cs @@ -43,6 +43,19 @@ if (args.Length >= 1 && args[0] == "render-vitals-mockup") return VitalsMockup.Render(rvmDatDir, rvmOut); } +if (args.Length >= 1 && args[0] == "dump-sprite-sheet") +{ + string? dssDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + string? dssIds = args.ElementAtOrDefault(2); + string dssOut = args.ElementAtOrDefault(3) ?? "sprite-sheet.png"; + if (string.IsNullOrWhiteSpace(dssDir) || string.IsNullOrWhiteSpace(dssIds)) + { + Console.Error.WriteLine("usage: AcDream.Cli dump-sprite-sheet <0xId,0xId,...> [out.png]"); + return 2; + } + return VitalsMockup.ExportSheet(dssDir, dssIds, dssOut); +} + if (args.Length >= 1 && args[0] == "export-ui-sprite") { string? eusDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR"); diff --git a/src/AcDream.Cli/VitalsMockup.cs b/src/AcDream.Cli/VitalsMockup.cs index 90c222f4..445a918b 100644 --- a/src/AcDream.Cli/VitalsMockup.cs +++ b/src/AcDream.Cli/VitalsMockup.cs @@ -29,14 +29,14 @@ public static class VitalsMockup private static readonly Vital[] Vitals = { - new("health", 1.00f, 0x0600747E, 0x0600747F, 0x06007480, 0x06007481, 0x06007482, 0x06007483), - new("stamina", 1.00f, 0x06007484, 0x06007485, 0x06007486, 0x06007487, 0x06007488, 0x06007489), - new("mana", 1.00f, 0x0600748A, 0x0600748B, 0x0600748C, 0x0600748D, 0x0600748E, 0x0600748F), + new("health", 0.80f, 0x0600747E, 0x0600747F, 0x06007480, 0x06007481, 0x06007482, 0x06007483), + new("stamina", 0.50f, 0x06007484, 0x06007485, 0x06007486, 0x06007487, 0x06007488, 0x06007489), + new("mana", 0.65f, 0x0600748A, 0x0600748B, 0x0600748C, 0x0600748D, 0x0600748E, 0x0600748F), }; - private const int Border = 5, BarH = 16, Zoom = 4; - // Widened bars so stretch-vs-tile is obvious (native middle tile ~100px). - private const int BarW = 280; + private const uint CenterFill = 0x06004CC2; // dark interior panel (UiNineSlicePanel draws this) + private const int Border = 5, BarH = 16, Zoom = 6; + private const int BarW = 150; // default vitals window bar width (0x2100006C) private static readonly int[] BarLocalY = { 0, 16, 32 }; // flush stacked inside the interior public static int Render(string datDir, string outPath) @@ -44,23 +44,25 @@ public static class VitalsMockup if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; } using var dats = new DatCollection(datDir, DatAccessType.Read); - int winW = BarW + 2 * Border; // 290 + int winW = BarW + 2 * Border; // 160 int winH = 3 * BarH + 2 * Border; // 58 - int gap = 16; - using var canvas = new Image(winW, winH * 2 + gap, new Rgba32(20, 20, 24, 255)); + using var canvas = new Image(winW, winH, new Rgba32(20, 20, 24, 255)); - DrawWindow(canvas, dats, 0, winW, winH, tileMid: false); // top: STRETCH (current) - DrawWindow(canvas, dats, winH + gap, winW, winH, tileMid: true); // bottom: TILE (retail) + DrawWindow(canvas, dats, 0, winW, winH, tileMid: true); canvas.Mutate(c => c.Resize(canvas.Width * Zoom, canvas.Height * Zoom, KnownResamplers.NearestNeighbor)); canvas.SaveAsPng(outPath); - Console.WriteLine($"wrote {outPath} ({canvas.Width}x{canvas.Height}) — TOP=stretch(current) BOTTOM=tile(retail), widened {BarW}px bars"); + Console.WriteLine($"wrote {outPath} ({canvas.Width}x{canvas.Height}) — faithful default vitals window 0x2100006C"); return 0; } private static void DrawWindow(Image canvas, DatCollection dats, int offY, int winW, int winH, bool tileMid) { - // 8-piece chrome border (kept identical in both rows; only the bar fill varies). + // Dark interior fill (matches UiNineSlicePanel's CenterFill behind the bars). + using (var cf = Load(dats, CenterFill)) + Blit(canvas, cf, Border, offY + Border, winW - 2 * Border, winH - 2 * Border); + + // 8-piece chrome border (corners native 5x5, edges stretched for this preview). using (var tl = Load(dats, TL)) using (var top = Load(dats, TOP)) using (var tr = Load(dats, TR)) using (var le = Load(dats, LEFT)) using (var ri = Load(dats, RIGHT)) using (var bl = Load(dats, BL)) using (var bo = Load(dats, BOT)) using (var br = Load(dats, BR)) @@ -75,6 +77,23 @@ public static class VitalsMockup Blit(canvas, br, winW - Border, offY + winH - Border, Border, Border); } + // Resize-grip overlay: gold ridged edge strips + square corner studs, on + // top of the bevel (vitals LayoutDesc 0x1000063B–0x10000642). Edges shown + // stretched here for the preview; the client tiles them via UV-repeat. + using (var gc = Load(dats, 0x06006129)) + using (var gt = Load(dats, 0x0600612A)) using (var gb = Load(dats, 0x0600612C)) + using (var gl = Load(dats, 0x0600612B)) using (var gr = Load(dats, 0x0600612D)) + { + Blit(canvas, gt, Border, offY, winW - 2 * Border, Border); + Blit(canvas, gb, Border, offY + winH - Border, winW - 2 * Border, Border); + Blit(canvas, gl, 0, offY + Border, Border, winH - 2 * Border); + Blit(canvas, gr, winW - Border, offY + Border, Border, winH - 2 * Border); + Blit(canvas, gc, 0, offY, Border, Border); + Blit(canvas, gc, winW - Border, offY, Border, Border); + Blit(canvas, gc, 0, offY + winH - Border, Border, Border); + Blit(canvas, gc, winW - Border, offY + winH - Border, Border, Border); + } + for (int i = 0; i < Vitals.Length; i++) { var v = Vitals[i]; @@ -144,6 +163,35 @@ public static class VitalsMockup canvas.Mutate(c => c.DrawImage(s, new Point(x, y), 1f)); } + /// Composite a comma-separated list of sprite ids into one row, magnified, + /// on a neutral background — so the exact chrome/bar graphics can be eyeballed. + public static int ExportSheet(string datDir, string idsCsv, string outPath) + { + if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + + var ids = idsCsv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(ParseHex).Where(x => x != 0).ToArray(); + if (ids.Length == 0) { Console.Error.WriteLine("no valid ids"); return 2; } + + var imgs = ids.Select(id => Load(dats, id)).ToArray(); + const int pad = 6, zoom = 10; + int totalW = pad + imgs.Sum(i => i.Width + pad); + int maxH = imgs.Max(i => i.Height); + using var canvas = new Image(totalW, maxH + 2 * pad, new Rgba32(64, 64, 72, 255)); + int x = pad; + foreach (var im in imgs) + { + canvas.Mutate(c => c.DrawImage(im, new Point(x, pad), 1f)); + x += im.Width + pad; + } + canvas.Mutate(c => c.Resize(canvas.Width * zoom, canvas.Height * zoom, KnownResamplers.NearestNeighbor)); + canvas.SaveAsPng(outPath); + Console.WriteLine("order (L→R): " + string.Join(" ", ids.Zip(imgs, (id, im) => $"0x{id:X8}={im.Width}x{im.Height}"))); + foreach (var im in imgs) im.Dispose(); + return 0; + } + public static int ExportSprite(string datDir, string idText, string outPath) { if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; } From 64146bfc2aa8bf451b8a9cdbbd36d7bfa0b77251 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 12:38:34 +0200 Subject: [PATCH 30/99] docs(D.2b): LayoutDesc importer design spec (data-driven retail windows) --- .../2026-06-15-layoutdesc-importer-design.md | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md diff --git a/docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md b/docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md new file mode 100644 index 00000000..1fb36f07 --- /dev/null +++ b/docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md @@ -0,0 +1,216 @@ +# LayoutDesc Importer — Design + +**Date:** 2026-06-15 +**Status:** Approved (brainstorm) — pending spec review → implementation plan +**Track:** D.2b retail UI engine (next sub-phase; register the phase id in the roadmap before implementation) +**Supersedes nothing. Deletes nothing.** Coexists with the existing hand-authored path. + +## Context + +D.2b shipped a working retail vitals window and a scrollable chat window, but each was +built by **hand**: dump the dat `LayoutDesc`, transcribe sprite ids + rects into +`vitals.xml` / `UiMeter` / `UiNineSlicePanel`, then discover-and-patch missing details +(the bar fill model, the dat-font, the tiling, the resize-grip overlay) one at a time. +That archaeology does not scale to AC's dozens of windows, and it keeps *missing* details +that are already in the dat (the grip overlay was found only because the user spotted it). + +The `LayoutDesc` dat is a **complete, declarative description of every window** — element +tree, positions, sizes, anchors, sprites per state, draw-modes, fonts, borders, grips, +meters, labels, inheritance. It is retail's "HTML for windows." The fix is to **render the +dat** with one faithful interpreter rather than transcribe it per window. + +## Goal + +Build a faithful `LayoutDesc` interpreter that reads a retail layout from the dat and +produces a `UiElement` tree the existing toolkit renders — so opening any retail window is +one call, with **no per-window graphics/layout code**. The only per-window code is live +**data wiring** (which is inherently per-window and tiny). + +### Non-goals + +- Re-porting Keystone's C++ framework (its own renderer, string/container classes, vtable + dispatch, D3D blits). We port retail's **render algorithms**, not its framework — that is + what Silk.NET + .NET already provide. (See "Decisions → Structure".) +- Deleting or rewriting the existing toolkit/widgets/markup. They are reused. + +## Decisions (from brainstorm 2026-06-15) + +1. **Proof target = re-drive vitals.** Point the importer at the vitals `LayoutDesc` + (`0x2100006C`) and make it reproduce the hand-built window. Known-good baseline → clean + pass/fail. The hand-authored vitals path stays as the reference until the importer matches. +2. **Scope = full faithful interpreter.** Interpret the *complete* `LayoutDesc` format + (every element type, full `BaseElement`/`BaseLayoutId` inheritance, all draw-modes, + states, properties) — not just the slice vitals uses. Matches the project's + "behavior is retail" ethos. +3. **Structure = hybrid (Approach C).** Port each element type's render **algorithm** + verbatim from the decomp, onto our modern draw primitives. A single generic renderer + handles the trivial "stamp the sprite per draw-mode" types (the long tail, including + types not yet catalogued); dedicated widgets handle types with real behavior (meter, + text, scrollbar/chat, button). The decomp's render method for each type *decides* which + bucket it falls in — we do not guess. Faithfulness comes from porting the algorithms; + the hybrid is only about C# packaging. +4. **Coexistence, don't-delete.** `MarkupDocument` stays as the path for plugin/custom + panels (no dat layout). The existing widgets (`UiMeter`, `UiNineSlicePanel`, + `UiChatView`, `UiDatFont`) and primitives (tiling, scissor-fill, dat-font, nine-slice) + become the importer's behavioral renderers. + +## Architecture & data flow + +``` +RETAIL WINDOWS (data-driven from the dat) + client_portal.dat ─► LayoutImporter ─► UiElement tree ─► UiRoot ─► renderers ─► screen + (LayoutDesc 0x21..) │ (UiDatElement + + │ behavioral widgets) + ├─ resolve BaseElement / BaseLayoutId inheritance + ├─ walk ElementDesc tree → widget (hybrid factory) + └─ apply rect / anchors / states / media / props from the dat + + per-window Controller ─► binds LIVE data to elements by id (mirrors retail gm*UI) + WindowManager ─► open/close by layout id, z-order, focus, position persistence + +PLUGIN / CUSTOM PANELS (hand-authored, unchanged) + *.xml ─► MarkupDocument ─► UiElement tree ─► (same UiRoot + renderers) +``` + +Two input paths (dat importer for retail windows, markup for custom/plugin), one rendering +toolkit. Nothing in the bottom (`UiHost`/`UiRoot`/`UiElement`) or the render primitives +changes. + +## Components + +### 1. Format enumeration (Step 0 — foundational groundwork) + +Because we chose "full faithful," the first deliverable is a **documented map** of the +complete format, not code. Sources, cross-checked against each other: + +- **DatReaderWriter types** — `ElementDesc`, `StateDesc`, `MediaDesc*` and their enums + (`Type`, `DrawMode`, media kinds, state keys). Reflect/inspect as `dump-vitals-layout` + already does (props **and** fields). +- **Retail decomp** — the `UIElement_*` class hierarchy + each type's render method; the + property-key meanings; the **KSML keyword registrations** (the parser registers every + property name — the canonical vocabulary, e.g. `KW_DRAWMODE`, `KW_DURATION`, …). +- **Real layouts** — scan a sample of `LayoutDesc`s to confirm which Types/properties + actually occur and catch anything the above missed. + +Output: a reference doc mapping each `Type` → meaning + render method, each property key → +meaning, each `DrawMode` → behavior, and the inheritance rules. This doc drives every other +component and is committed alongside the importer. + +### 2. `LayoutImporter` + +Reads a `LayoutDesc` by id and returns a `UiElement` subtree: +- Walk the `ElementDesc` tree. +- For each element: resolve inheritance (§3), pick a widget via the factory (§4), set its + rect (`X/Y/W/H`), anchors (edge flags → `AnchorEdges`), z-order, states, media, and + properties from the (resolved) element. +- Recurse into children. +- Expose `FindElement(uint id)` on the result so controllers wire by id. + +Depends on: `DatCollection` (read layouts), the factory, the inheritance resolver, +`TextureCache.GetOrUploadRenderSurface` (sprites), `UiDatFont` (text). No GL itself — it +builds `UiElement`s; rendering stays in the toolkit. + +### 3. Inheritance resolution + +An element with `BaseElement`/`BaseLayoutId` inherits the base element's properties / states +/ media; the derived element overrides. Resolve by loading the base layout, finding the base +element, and merging (base first, then derived overrides) **before** instantiating. +Required even for vitals: the number-text element inherits its font/style from base layout +`0x2100003F`. Cycle-guard the resolution. + +### 4. Hybrid widget factory (`Type` → renderer) + +- **Behavioral** types → dedicated widgets (verbatim-algorithm ports): meter → `UiMeter`, + text → dat-font label, scrollable/list region → `UiChatView`/list widget, button → + `UiButton`, resizable window root → `UiNineSlicePanel`. +- **Trivial** types (image, container, border piece, grip) → `UiDatElement` (generic). +- **Unknown** type → `UiDatElement` (faithful fallback — still draws its media). + +The Step-0 enumeration assigns each `Type` to a bucket by reading its retail render method +(trivial blit → generic; real algorithm → widget). + +### 5. `UiDatElement` (generic renderer) + +A `UiElement` holding the resolved element's active-state media + draw-mode + rect. Its +`OnDraw` ports retail's base blit branch: +- `Normal` → **tile** at native size (UV-repeat; the UI texture is `GL_REPEAT`-wrapped) — + the mechanism already proven for the bars + chrome. +- `Alphablend` → blended overlay. +- `Stretch` (if present) → scale. +- image → sprite; cursor → hover cursor. +Reuses the tiling, dat-font, nine-slice draw primitives. + +### 6. Per-window controllers (live-data binding) + +Mirror retail's `gm*UI` classes. A small controller per window grabs elements by id from the +imported tree and pushes live data in: `VitalsController` binds `HealthPercent` → meter fill, +`cur/max` → number text; `ChatController` binds the chat tail → the chat region. **This is +the only per-window code, and it is data wiring, not graphics.** Retail-faithful: e.g. +`gmVitalsUI::PostInit` grabs child meter elements by id and sets attribute `0x69` (fill). + +### 7. `WindowManager` + +`OpenWindow(layoutId, controller)` → import + attach to `UiRoot`, place at the dat's default +position (then persist user move/resize), manage z-order / focus / close. Orchestrates the +focus/drag/resize mechanics `UiRoot` already provides. + +### 8. States / expand / hover + +Each element carries its named states (`HideDetail`/`ShowDetail`, normal/hover/pressed) from +the dat; the active state selects which media draws. A click or hover flips the active state. +Click-to-expand and hover highlight fall out generically — no per-window code. + +## Rollout order (milestones) + +1. **Enumerate the format** (§1) → reference doc. +2. **`LayoutImporter`** + **inheritance resolution** (read + resolve + walk). +3. **`UiDatElement`** generic renderer (port the draw-mode blit branch). +4. **Hybrid factory** (Type → widget/generic). +5. **`VitalsController`** (bind by id). +6. **Re-drive vitals → diff against the current window.** ✅ conformance gate. +7. **`WindowManager`** (open/close/persist). +8. **Extend** to chat (`ChatController`), then new windows for free. + +## Testing / conformance + +- **Golden tree checks** — the importer-built vitals tree has the expected element rects, + resolved sprites, and active states (assert against the known `0x2100006C` values). +- **Inheritance unit tests** — base+override merge, cycle-guard. +- **Draw-mode unit tests** — the UV math for tile vs stretch vs the partial last tile. +- **Bind-by-id unit tests** — controller wires the right element. +- **Headless visual diff** — `render-vitals-mockup` / a tree-render comparison vs the + hand-built reference (no live server needed). +- **Final** — in-client visual verification (the user) once the gate passes. + +## Coexistence / don't-delete (restated) + +- `MarkupDocument` + `*.xml` stay for plugin/custom panels. +- `UiMeter`, `UiNineSlicePanel`, `UiChatView`, `UiDatFont`, the tiling/scissor-fill/dat-font/ + nine-slice primitives stay — reused as the importer's behavioral renderers. +- The hand-authored vitals path stays as the conformance reference until the importer + matches it; only then is vitals flipped to the importer. + +## Risks & open questions + +- **The format enumeration is the foundational unknown.** If a Type/property/draw-mode is + mis-mapped, faithfulness breaks. Mitigation: Step 0 cross-checks three sources + real + layouts; the vitals conformance gate catches regressions. +- **Some behavioral types may need new widgets** (list, scrollbar, edit box). These are + generic, written once — not per-window. The generic fallback means an un-widgeted type + still renders its sprites in the meantime. +- **Position persistence** scope (per-window saved rects) — minimal at first (dat default + + in-session move/resize); durable persistence can follow. +- **Phase id** — register this as the next D.2b sub-phase in the roadmap before implementing. + +## Reference anchors + +- **Dat layouts:** vitals (stacked) `0x2100006C`; floaty row `0x21000014`; horizontal row + `0x21000075`; vitals number-text base layout `0x2100003F`. +- **Decomp:** `gmVitalsUI::PostInit` @`0x4bfce0` (bind by id), `UIElement_Meter::DrawChildren` + @`0x46fbd0` (scissor-fill), `SurfaceWindow::DrawCharacter` @`0x442bd0` (dat-font), + `ImgTex::TileCSI` @`0x53e740` (tiling), `UIRegion::DrawHere` @`0x69fa30` (element draw order), + the KSML keyword registrations (~`0x71b540`+). +- **Tools:** `AcDream.Cli dump-vitals-layout` (reflective full tree dump), + `dump-sprite-sheet` (composite sprite ids), `render-vitals-mockup` (headless window render). +- **Memory:** `project_d2b_retail_ui.md` (the two-layout lesson, sprite ids, render model, + dat-font, tools). From a7875cde225ca855b2dbafdab423d55e19047c82 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 12:46:55 +0200 Subject: [PATCH 31/99] =?UTF-8?q?docs(D.2b):=20LayoutDesc=20importer=20imp?= =?UTF-8?q?lementation=20plan=20(Plan=201=20=E2=80=94=20vitals=20conforman?= =?UTF-8?q?ce)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-06-15-layoutdesc-importer.md | 756 ++++++++++++++++++ 1 file changed, 756 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-15-layoutdesc-importer.md diff --git a/docs/superpowers/plans/2026-06-15-layoutdesc-importer.md b/docs/superpowers/plans/2026-06-15-layoutdesc-importer.md new file mode 100644 index 00000000..f5a6b4d6 --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-layoutdesc-importer.md @@ -0,0 +1,756 @@ +# LayoutDesc Importer — Implementation Plan (Plan 1: foundation + vitals conformance) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Read the retail vitals `LayoutDesc` (`0x2100006C`) from the dat and build a `UiElement` tree that reproduces the hand-built vitals window — proving a data-driven importer that needs no per-window graphics code. + +**Architecture:** A `LayoutImporter` reads a layout, resolves `BaseElement`/`BaseLayoutId` inheritance, and walks the `ElementDesc` tree. A hybrid factory maps each element's `Type` to either a dedicated behavioral widget (meter → `UiMeter`, text → dat-font label) or a generic `UiDatElement` that draws any element's media by draw-mode (reusing the proven tiling primitive). A per-window `VitalsController` binds live data to elements by id, mirroring retail's `gmVitalsUI`. Everything renders through the existing `UiRoot` + primitives — nothing is deleted. + +**Tech Stack:** C# .NET 10, Silk.NET, `Chorizite.DatReaderWriter` 2.1.7, xUnit. Spec: `docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md`. + +**Scope of Plan 1:** rollout steps 1–6 (enumeration → importer → inheritance → generic renderer → factory → vitals controller → conformance). NOT in Plan 1: window manager, chat re-drive, the full long-tail of element types (Plan 2). The generic renderer's fallback means un-widgeted types still draw their sprites. + +--- + +## File structure + +``` +src/AcDream.App/UI/Layout/ ← new namespace for the importer + ElementReader.cs — typed read of ElementDesc fields + inheritance merge (pure, GL-free) + LayoutImporter.cs — read a LayoutDesc, walk the tree, build the UiElement tree + UiDatElement.cs — generic element: draws its state media by DrawMode (tile/blend) + DatWidgetFactory.cs — Type → widget (UiMeter / dat-font label) else UiDatElement + VitalsController.cs — bind live data to elements by id (mirrors gmVitalsUI) +src/AcDream.App/Rendering/GameWindow.cs ← wire importer under a flag, alongside the existing path +docs/research/2026-06-15-layoutdesc-format.md ← Task 1 enumeration reference +tests/AcDream.App.Tests/UI/Layout/ ← new test folder + ElementReaderTests.cs — inheritance merge, edge-flags → anchors (pure) + DatWidgetFactoryTests.cs— Type → widget mapping + VitalsBindingTests.cs — bind-by-id wiring + LayoutConformanceTests.cs — vitals tree golden checks (uses a committed fixture) +tests/AcDream.App.Tests/UI/Layout/fixtures/ + vitals_2100006C.json — dumped vitals layout tree (so tests need no dats) +``` + +Pure logic (inheritance merge, anchor mapping, factory decision, draw-mode UV) is GL-free and dat-free so it unit-tests without the user's dats. The dat-reading shell is exercised by the headless conformance tool + the committed fixture. + +--- + +### Task 1: Format enumeration reference doc (research) + +Pins down the exact `DatReaderWriter` API and the format vocabulary the later tasks depend on. No production code. + +**Files:** +- Create: `docs/research/2026-06-15-layoutdesc-format.md` + +- [ ] **Step 1: Enumerate the DatReaderWriter types** + +Run (PowerShell), capturing output: +``` +dotnet run --project src\AcDream.Cli\AcDream.Cli.csproj --no-build -- dump-vitals-layout "$env:USERPROFILE\Documents\Asheron's Call" 0x2100006C +``` +From this + the package, record the exact member names/types of `ElementDesc` (confirm `ElementId, Type, X, Y, Width, Height, LeftEdge, TopEdge, RightEdge, BottomEdge, ZLevel, BaseElement, BaseLayoutId, StateDesc, States, Children`), `StateDesc` (its `Media` collection + how properties like font `0x1A` / fill `0x69` are stored), and `MediaDescImage` (`File, DrawMode`) / `MediaDescCursor`. + +- [ ] **Step 2: Enumerate the Type + DrawMode vocabulary from the decomp** + +Grep `docs/research/named-retail/acclient_2013_pseudo_c.txt` for the `UIElement_*` class names + their render methods, the `DrawModeType` values, and the KSML keyword registrations (`KW_*` near `0x71b540`). Record each element `Type` value → meaning + render method, and each `DrawMode` value → behavior (Normal=tile, Alphablend, Stretch, …). + +- [ ] **Step 3: Cross-check against real layouts** + +Dump `0x21000014`, `0x21000075`, and `0x2100003F` (the vitals number-text base layout) and confirm which Types/DrawModes/properties actually occur. Note the inheritance chain for the vitals number-text element. + +- [ ] **Step 4: Write the reference doc** + +Write `docs/research/2026-06-15-layoutdesc-format.md` with sections: ElementDesc API, StateDesc/properties, MediaDesc kinds, the Type table (value → meaning → render method → generic-or-widget bucket), the DrawMode table, and the inheritance rules. Mark which types/draw-modes the vitals window uses (Plan 1 surface) vs the long tail (Plan 2). + +- [ ] **Step 5: Commit** + +``` +git add docs/research/2026-06-15-layoutdesc-format.md +git commit -m "docs(D.2b): LayoutDesc format enumeration (importer groundwork)" +``` + +--- + +### Task 2: ElementReader — inheritance merge + edge-flags → anchors (pure) + +**Files:** +- Create: `src/AcDream.App/UI/Layout/ElementReader.cs` +- Test: `tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs` + +`ElementReader` holds the pure, GL-free, dat-free transforms the importer needs. Model the element as a small POCO `ElementInfo` so the pure logic is testable without constructing `DatReaderWriter.ElementDesc`. + +- [ ] **Step 1: Write the failing tests** + +```csharp +using AcDream.App.UI; +using AcDream.App.UI.Layout; +namespace AcDream.App.Tests.UI.Layout; + +public class ElementReaderTests +{ + [Fact] + public void EdgeFlagsToAnchors_LeftRight_Stretches() + { + // Edge flag value 4 = "anchor to that side" per the format doc; left+right both anchored ⇒ width stretches. + var a = ElementReader.ToAnchors(left: 4, top: 1, right: 4, bottom: 1); + Assert.True(a.HasFlag(AnchorEdges.Left)); + Assert.True(a.HasFlag(AnchorEdges.Right)); + Assert.False(a.HasFlag(AnchorEdges.Bottom)); + } + + [Fact] + public void Merge_BaseThenOverride_DerivedWins() + { + var base_ = new ElementInfo { Type = 0, FontDid = 0x40000000, Width = 150, Height = 16 }; + var derived = new ElementInfo { Type = 0, Width = 200 }; // overrides width, inherits font + height + var merged = ElementReader.Merge(base_, derived); + Assert.Equal(200, merged.Width); // override + Assert.Equal(16, merged.Height); // inherited + Assert.Equal(0x40000000u, merged.FontDid);// inherited + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~ElementReaderTests"` +Expected: FAIL — `ElementReader` / `ElementInfo` not defined. + +- [ ] **Step 3: Implement ElementReader + ElementInfo** + +```csharp +namespace AcDream.App.UI.Layout; + +/// GL-free, dat-free snapshot of a resolved layout element. Populated by the +/// importer from DatReaderWriter.ElementDesc (after inheritance); the pure transforms +/// below operate on it so they unit-test without the dats. +public sealed class ElementInfo +{ + public uint Id; + public int Type; + public float X, Y, Width, Height; + public int Left, Top, Right, Bottom; // edge-anchor flags + public uint FontDid; // 0 = none (inherited via Merge) + // sprite per state: state name -> (file, drawMode). "" = DirectState. + public Dictionary StateMedia = new(); +} + +public static class ElementReader +{ + /// Edge-anchor flags → AnchorEdges. Flag value 4 (per format doc) = "pinned + /// to that side"; any other value = not pinned. Left+Right ⇒ width stretches. + public static AnchorEdges ToAnchors(int left, int top, int right, int bottom) + { + var a = AnchorEdges.None; + if (left == 4) a |= AnchorEdges.Left; + if (top == 4) a |= AnchorEdges.Top; + if (right == 4) a |= AnchorEdges.Right; + if (bottom == 4) a |= AnchorEdges.Bottom; + if (a == AnchorEdges.None) a = AnchorEdges.Left | AnchorEdges.Top; // default: pin top-left + return a; + } + + /// Merge a base element with a derived override: start from base, apply any + /// non-default field the derived element sets. Mirrors BaseElement/BaseLayoutId. + public static ElementInfo Merge(ElementInfo base_, ElementInfo derived) + { + var m = new ElementInfo + { + Id = derived.Id != 0 ? derived.Id : base_.Id, + Type = derived.Type != 0 ? derived.Type : base_.Type, + X = derived.X, Y = derived.Y, // position is the derived placement + Width = derived.Width != 0 ? derived.Width : base_.Width, + Height = derived.Height != 0 ? derived.Height : base_.Height, + Left = derived.Left, Top = derived.Top, Right = derived.Right, Bottom = derived.Bottom, + FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid, + StateMedia = new Dictionary(base_.StateMedia), + }; + foreach (var kv in derived.StateMedia) m.StateMedia[kv.Key] = kv.Value; // derived overrides + return m; + } +} +``` +> NOTE: confirm the edge-flag "pinned" value (4) and the font-property key against Task 1's doc; adjust the `== 4` test if the doc says otherwise. + +- [ ] **Step 4: Run to verify pass** + +Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~ElementReaderTests"` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +``` +git add src/AcDream.App/UI/Layout/ElementReader.cs tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs +git commit -m "feat(D.2b): ElementReader — layout inheritance merge + edge-flag anchors" +``` + +--- + +### Task 3: UiDatElement — generic element + draw-mode render + +**Files:** +- Create: `src/AcDream.App/UI/Layout/UiDatElement.cs` + +Generic widget: holds an `ElementInfo` + the active state name, draws that state's media by draw-mode. Reuses the proven tiling render (UV-repeat at native width; UI textures are `GL_REPEAT`-wrapped). + +- [ ] **Step 1: Write the failing test (active-state selection is pure)** + +```csharp +using AcDream.App.UI.Layout; +namespace AcDream.App.Tests.UI.Layout; + +public class UiDatElementTests +{ + [Fact] + public void ActiveMedia_PrefersNamedStateOverDirect() + { + var info = new ElementInfo(); + info.StateMedia[""] = (0x06000001, 0); // DirectState + info.StateMedia["ShowDetail"] = (0x06000002, 1); // named + var e = new UiDatElement(info, (_, _) => (0, 0, 0)) { ActiveState = "ShowDetail" }; + Assert.Equal(0x06000002u, e.ActiveMedia().File); + e.ActiveState = ""; + Assert.Equal(0x06000001u, e.ActiveMedia().File); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~UiDatElementTests"` +Expected: FAIL — `UiDatElement` not defined. + +- [ ] **Step 3: Implement UiDatElement** + +```csharp +using System; +using System.Numerics; + +namespace AcDream.App.UI.Layout; + +/// Generic dat element: draws its active state's media by DrawMode (Normal=tile, +/// Alphablend=blended overlay). The fallback renderer for every element type without a +/// dedicated behavioral widget; faithful because retail's base element render is exactly +/// "stamp the media per draw-mode". +public sealed class UiDatElement : UiElement +{ + private readonly ElementInfo _info; + private readonly Func _resolve; + public string ActiveState { get; set; } = ""; + + public UiDatElement(ElementInfo info, Func resolve) + { + _info = info; _resolve = resolve; + ClickThrough = true; // generic decoration; behavioral widgets opt back in + } + + public (uint File, int DrawMode) ActiveMedia() + => _info.StateMedia.TryGetValue(ActiveState, out var m) ? m + : _info.StateMedia.TryGetValue("", out var d) ? d + : (0u, 0); + + protected override void OnDraw(UiRenderContext ctx) + { + var (file, drawMode) = ActiveMedia(); + if (file == 0) return; + var (tex, tw, th) = _resolve(file); + if (tex == 0 || tw == 0 || th == 0) return; + // DrawMode 0 = Normal → TILE at native size (UV-repeat; GL_REPEAT-wrapped UI texture), + // matching ImgTex::TileCSI. (Alphablend/others are the same blit with a blend state; + // the sprite shader already alpha-blends, so the quad is identical here.) + ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One); + } +} +``` +> NOTE: confirm `DrawMode` enum values against Task 1; if a value needs a non-tiled blit (e.g. a true Stretch), branch here. For the vitals surface (Normal + Alphablend) the tiled UV-repeat quad is correct. + +- [ ] **Step 4: Run to verify pass** + +Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~UiDatElementTests"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +``` +git add src/AcDream.App/UI/Layout/UiDatElement.cs tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs +git commit -m "feat(D.2b): UiDatElement — generic per-drawmode element renderer" +``` + +--- + +### Task 4: DatWidgetFactory — Type → widget (else generic) + +**Files:** +- Create: `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` +- Test: `tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs` + +- [ ] **Step 1: Write the failing tests** + +```csharp +using AcDream.App.UI; +using AcDream.App.UI.Layout; +namespace AcDream.App.Tests.UI.Layout; + +public class DatWidgetFactoryTests +{ + private static (uint, int, int) NoTex(uint _) => (0, 0, 0); + + [Fact] + public void Type7_Meter_MakesUiMeter() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 7, Width = 150, Height = 16 }, NoTex, null); + Assert.IsType(e); + } + + [Fact] + public void UnknownType_FallsBackToGeneric() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 999 }, NoTex, null); + Assert.IsType(e); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~DatWidgetFactoryTests"` +Expected: FAIL — `DatWidgetFactory` not defined. + +- [ ] **Step 3: Implement DatWidgetFactory** + +```csharp +using System; + +namespace AcDream.App.UI.Layout; + +/// Hybrid factory: behavioral element Types map to dedicated widgets (verbatim +/// algorithm ports); everything else (and unknown Types) falls back to UiDatElement. +/// The Type→bucket assignment comes from the format enumeration (Task 1). +public static class DatWidgetFactory +{ + /// RenderSurface id → (GL tex, w, h). + /// Retail UI font for text elements (may be null pre-load). + public static UiElement Create(ElementInfo info, + Func resolve, UiDatFont? datFont) + { + var e = info.Type switch + { + 7 => BuildMeter(info, resolve), // UIElement_Meter + _ => new UiDatElement(info, resolve), + }; + e.Left = info.X; e.Top = info.Y; e.Width = info.Width; e.Height = info.Height; + e.Anchors = ElementReader.ToAnchors(info.Left, info.Top, info.Right, info.Bottom); + return e; + } + + private static UiElement BuildMeter(ElementInfo info, Func resolve) + => new UiMeter { SpriteResolve = resolve }; // back/front slice ids + binding set by the controller +} +``` +> NOTE: text (Type 0) keeps using the generic element for now; the dat-font label binding happens in the controller via `UiDatFont`. Add a dedicated text widget in Plan 2 if the enumeration shows behavior beyond "draw a bound string". + +- [ ] **Step 4: Run to verify pass** + +Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~DatWidgetFactoryTests"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +``` +git add src/AcDream.App/UI/Layout/DatWidgetFactory.cs tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +git commit -m "feat(D.2b): DatWidgetFactory — Type→widget hybrid mapping" +``` + +--- + +### Task 5: LayoutImporter — read layout, resolve inheritance, build tree + +**Files:** +- Create: `src/AcDream.App/UI/Layout/LayoutImporter.cs` + +Reads a `LayoutDesc` via `DatCollection`, converts each `ElementDesc` to `ElementInfo` (resolving `BaseElement`/`BaseLayoutId` via `ElementReader.Merge`), builds the widget tree via the factory, and recurses into children. Exposes `FindElement(uint id)`. + +- [ ] **Step 1: Write the failing test (uses the committed fixture, no dats)** + +Create `tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json` by serializing the dumped tree (a list of `ElementInfo`-shaped records). Test that the importer's pure `BuildFromInfos` produces the right tree: +```csharp +using AcDream.App.UI; +using AcDream.App.UI.Layout; +namespace AcDream.App.Tests.UI.Layout; + +public class LayoutImporterTests +{ + [Fact] + public void BuildFromInfos_HealthMeter_IsUiMeterAtRect() + { + // health meter element 0x100000E6: X=5,Y=5,150x16,Type=7 + var root = new ElementInfo { Id = 0x100005F9, Type = 3, Width = 160, Height = 58 }; + var health = new ElementInfo { Id = 0x100000E6, Type = 7, X = 5, Y = 5, Width = 150, Height = 16 }; + var tree = LayoutImporter.BuildFromInfos(root, new[] { health }, (_, _) => (0, 0, 0), null); + var found = tree.FindElement(0x100000E6); + Assert.IsType(found); + Assert.Equal(5f, found!.Left); Assert.Equal(150f, found.Width); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~LayoutImporterTests"` +Expected: FAIL — `LayoutImporter` not defined. + +- [ ] **Step 3: Implement LayoutImporter** + +```csharp +using System; +using System.Collections.Generic; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.App.UI.Layout; + +/// Reads a retail LayoutDesc into a UiElement tree. Pure tree-building +/// (BuildFromInfos) is dat-free + testable; Import(dats, id, ...) is the dat shell. +public sealed class ImportedLayout +{ + public required UiElement Root { get; init; } + private readonly Dictionary _byId; + public ImportedLayout(UiElement root, Dictionary byId) { Root = root; _byId = byId; } + public UiElement? FindElement(uint id) => _byId.TryGetValue(id, out var e) ? e : null; +} + +public static class LayoutImporter +{ + /// Dat shell: load the layout, convert ElementDescs to ElementInfo (resolving + /// inheritance), then BuildFromInfos. Returns null if the layout is missing. + public static ImportedLayout? Import(DatCollection dats, uint layoutId, + Func resolve, UiDatFont? datFont) + { + var ld = dats.Get(layoutId); + if (ld is null) return null; + // Convert top-level + nested ElementDescs to resolved ElementInfo. + ElementInfo Convert(ElementDesc d) => Resolve(dats, d); + // Build a synthetic root that holds the top-level elements as children. + var rootInfo = new ElementInfo { Id = 0, Type = 3 }; + var children = new List(); + var nested = new Dictionary(); + foreach (var kv in ld.Elements) { var info = Convert(kv.Value); children.Add(info); nested[info] = kv.Value; } + return BuildFromInfosRecursive(rootInfo, ld, dats, resolve, datFont); + } + + /// Pure builder used by tests + the shell: build a tree from a root info + its + /// direct children infos. (The recursive dat variant handles real nested trees.) + public static ImportedLayout BuildFromInfos(ElementInfo rootInfo, IEnumerable children, + Func resolve, UiDatFont? datFont) + { + var byId = new Dictionary(); + var root = DatWidgetFactory.Create(rootInfo, resolve, datFont); + if (rootInfo.Id != 0) byId[rootInfo.Id] = root; + foreach (var c in children) + { + var w = DatWidgetFactory.Create(c, resolve, datFont); + root.AddChild(w); + if (c.Id != 0) byId[c.Id] = w; + } + return new ImportedLayout(root, byId); + } + + // ---- dat-side helpers ---- + + private static ImportedLayout BuildFromInfosRecursive(ElementInfo rootInfo, LayoutDesc ld, + DatCollection dats, Func resolve, UiDatFont? datFont) + { + var byId = new Dictionary(); + var root = DatWidgetFactory.Create(rootInfo, resolve, datFont); + foreach (var kv in ld.Elements) + AddElement(root, kv.Value, dats, resolve, datFont, byId); + return new ImportedLayout(root, byId); + } + + private static void AddElement(UiElement parent, ElementDesc d, DatCollection dats, + Func resolve, UiDatFont? datFont, Dictionary byId) + { + var info = Resolve(dats, d); + var w = DatWidgetFactory.Create(info, resolve, datFont); + parent.AddChild(w); + if (info.Id != 0) byId[info.Id] = w; + foreach (var kv in d.Children) + AddElement(w, kv.Value, dats, resolve, datFont, byId); + } + + /// ElementDesc → ElementInfo, resolving BaseElement/BaseLayoutId inheritance. + private static ElementInfo Resolve(DatCollection dats, ElementDesc d) + { + var self = ToInfo(d); + if (d.BaseElement != 0 && d.BaseLayoutId != 0) + { + var baseLd = dats.Get(d.BaseLayoutId); + var baseDesc = baseLd is null ? null : FindDesc(baseLd, d.BaseElement); + if (baseDesc is not null) return ElementReader.Merge(Resolve(dats, baseDesc), self); // recursive base chain + } + return self; + } + + private static ElementDesc? FindDesc(LayoutDesc ld, uint id) + { + foreach (var kv in ld.Elements) { var f = FindDescIn(kv.Value, id); if (f is not null) return f; } + return null; + } + private static ElementDesc? FindDescIn(ElementDesc d, uint id) + { + if (d.ElementId == id) return d; + foreach (var kv in d.Children) { var f = FindDescIn(kv.Value, id); if (f is not null) return f; } + return null; + } + + /// Read the verified ElementDesc fields into ElementInfo (no inheritance). + private static ElementInfo ToInfo(ElementDesc d) + { + var info = new ElementInfo + { + Id = d.ElementId, Type = (int)d.Type, + X = d.X, Y = d.Y, Width = d.Width, Height = d.Height, + Left = (int)d.LeftEdge, Top = (int)d.TopEdge, Right = (int)d.RightEdge, Bottom = (int)d.BottomEdge, + }; + if (d.StateDesc is not null) ReadState(d.StateDesc, "", info); + foreach (var s in d.States) ReadState(s.Value, s.Key, info); + return info; + } + + private static void ReadState(StateDesc sd, string name, ElementInfo info) + { + foreach (var m in sd.Media) + if (m is MediaDescImage img && img.File != 0) + info.StateMedia[name] = (img.File, (int)img.DrawMode); + // font DID (property 0x1A) read here once the format doc confirms the property API. + } +} +``` +> NOTE: the exact `ElementDesc`/`StateDesc` member access (`d.X`, `d.Type`, `d.States`, `sd.Media`, `img.DrawMode`, the font property) must match Task 1's verified API; `dump-vitals-layout` confirms these members exist. Adjust casts/names to the real API. + +- [ ] **Step 4: Run to verify pass** + +Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~LayoutImporterTests"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +``` +git add src/AcDream.App/UI/Layout/LayoutImporter.cs tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json +git commit -m "feat(D.2b): LayoutImporter — read layout + resolve inheritance + build tree" +``` + +--- + +### Task 6: VitalsController — bind live data by id + +**Files:** +- Create: `src/AcDream.App/UI/Layout/VitalsController.cs` +- Test: `tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs` + +Mirrors `gmVitalsUI`: grab the meter elements by id and wire their fill + numbers + the correct per-vital sprite slice ids (which are dat-driven, but the back/front-slice split + the live data binding are the controller's job). + +- [ ] **Step 1: Write the failing test** + +```csharp +using AcDream.App.UI; +using AcDream.App.UI.Layout; +namespace AcDream.App.Tests.UI.Layout; + +public class VitalsBindingTests +{ + [Fact] + public void Bind_SetsHealthMeterFillFromProvider() + { + var health = new UiMeter(); + var layout = FakeLayout(("0x100000E6", health)); + float hp = 0.42f; + VitalsController.Bind(layout, healthPct: () => hp, staminaPct: () => 1, manaPct: () => 1, + healthText: () => "42/100", staminaText: () => "", manaText: () => ""); + Assert.Equal(0.42f, health.Fill()); + } + + private static ImportedLayout FakeLayout(params (string idHex, UiElement e)[] items) + { + var dict = new System.Collections.Generic.Dictionary(); + var root = new UiPanel(); + foreach (var (idHex, e) in items) + { uint id = System.Convert.ToUInt32(idHex, 16); root.AddChild(e); dict[id] = e; } + return new ImportedLayout(root, dict); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~VitalsBindingTests"` +Expected: FAIL — `VitalsController` not defined. + +- [ ] **Step 3: Implement VitalsController** + +```csharp +using System; + +namespace AcDream.App.UI.Layout; + +/// Per-window controller for the vitals layout (0x2100006C). Mirrors retail +/// gmVitalsUI::PostInit: grab the meter elements by id and bind live data. The ONLY +/// per-window code — data wiring, not graphics. +public static class VitalsController +{ + public const uint Health = 0x100000E6, Stamina = 0x100000EC, Mana = 0x100000EE; + + public static void Bind(ImportedLayout layout, + Func healthPct, Func staminaPct, Func manaPct, + Func healthText, Func staminaText, Func manaText) + { + BindMeter(layout, Health, healthPct, healthText); + BindMeter(layout, Stamina, staminaPct, staminaText); + BindMeter(layout, Mana, manaPct, manaText); + } + + private static void BindMeter(ImportedLayout layout, uint id, Func pct, Func text) + { + if (layout.FindElement(id) is UiMeter m) + { + m.Fill = () => pct(); + m.Label = () => text(); + } + } +} +``` +> NOTE: the per-vital back/front 3-slice sprite ids live on the meter's child image elements in the dat; the importer sets them on the `UiMeter` (extend `DatWidgetFactory.BuildMeter` to read the meter's `E8/E9/EA` + back/front child sprites once the tree is built). For Plan 1 conformance, the controller binds the dynamic data; the static slice ids come from the dat via the importer. + +- [ ] **Step 4: Run to verify pass** + +Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~VitalsBindingTests"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +``` +git add src/AcDream.App/UI/Layout/VitalsController.cs tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs +git commit -m "feat(D.2b): VitalsController — bind live vitals data by element id" +``` + +--- + +### Task 7: Wire the importer into GameWindow behind a flag + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (the `_options.RetailUi` block where the vitals panel is built) +- Modify: `src/AcDream.App/RuntimeOptions.cs` (add `RetailUiImporter` flag from `ACDREAM_RETAIL_UI_IMPORTER`) + +Run the importer-built vitals window when `ACDREAM_RETAIL_UI_IMPORTER=1`, ALONGSIDE the existing hand-authored path (which stays the default). This is the conformance harness + the eventual switch-over. + +- [ ] **Step 1: Add the RuntimeOptions flag** + +In `RuntimeOptions.cs`, add `public bool RetailUiImporter { get; init; }` and read it in `Program.cs` from `ACDREAM_RETAIL_UI_IMPORTER == "1"` (follow the existing `RetailUi` pattern). + +- [ ] **Step 2: Wire the importer in the RetailUi block** + +In `GameWindow.cs`, in the `if (_options.RetailUi)` block, after the existing vitals panel is built, add: +```csharp +if (_options.RetailUiImporter) +{ + var imported = AcDream.App.UI.Layout.LayoutImporter.Import( + _dats, 0x2100006Cu, ResolveChrome, _datFont); + if (imported is not null) + { + AcDream.App.UI.Layout.VitalsController.Bind(imported, + healthPct: () => _vitalsVm!.HealthPercent ?? 0f, + staminaPct: () => _vitalsVm!.StaminaPercent ?? 0f, + manaPct: () => _vitalsVm!.ManaPercent ?? 0f, + healthText: () => $"{_vitalsVm!.HealthCurrent}/{_vitalsVm.HealthMax}", + staminaText: () => $"{_vitalsVm!.StaminaCurrent}/{_vitalsVm.StaminaMax}", + manaText: () => $"{_vitalsVm!.ManaCurrent}/{_vitalsVm.ManaMax}"); + imported.Root.Left = 240; imported.Root.Top = 30; // offset so it sits beside the hand-built one for A/B + _uiHost.Root.AddChild(imported.Root); + Console.WriteLine("[D.2b] importer vitals window active (A/B vs hand-authored)."); + } +} +``` +> NOTE: confirm `_dats` (the `DatCollection`) + `_datFont` (the `UiDatFont`) field names in `GameWindow`; both already exist (the chrome resolve + the dat-font load use them). + +- [ ] **Step 3: Build** + +Run: `dotnet build src\AcDream.App\AcDream.App.csproj -c Debug` +Expected: 0 errors. + +- [ ] **Step 4: Commit** + +``` +git add src/AcDream.App/Rendering/GameWindow.cs src/AcDream.App/RuntimeOptions.cs src/AcDream.App/Program.cs +git commit -m "feat(D.2b): run importer-built vitals window under ACDREAM_RETAIL_UI_IMPORTER (A/B)" +``` + +--- + +### Task 8: Vitals conformance — golden tree checks + headless render diff + +**Files:** +- Create: `tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs` +- Modify: `src/AcDream.Cli/VitalsMockup.cs` (add an importer-render mode if needed for the visual diff) + +- [ ] **Step 1: Write the golden tree conformance test (against the fixture)** + +```csharp +using AcDream.App.UI; +using AcDream.App.UI.Layout; +namespace AcDream.App.Tests.UI.Layout; + +public class LayoutConformanceTests +{ + [Fact] + public void VitalsTree_HasThreeMetersAtExpectedRects() + { + var layout = FixtureLoader.LoadVitals(); // deserializes vitals_2100006C.json → ImportedLayout via BuildFromInfos + (uint id, float y)[] expected = { (0x100000E6, 5), (0x100000EC, 21), (0x100000EE, 37) }; + foreach (var (id, y) in expected) + { + var m = layout.FindElement(id); + Assert.IsType(m); + Assert.Equal(5f, m!.Left); + Assert.Equal(150f, m.Width); + Assert.Equal(16f, m.Height); + Assert.Equal(y, m.Top); + } + } +} +``` +Add a tiny `FixtureLoader` that reads the committed JSON into `ElementInfo`s and calls `LayoutImporter.BuildFromInfos`. + +- [ ] **Step 2: Run to verify failure, then implement FixtureLoader, then pass** + +Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~LayoutConformanceTests"` +Expected: FAIL → implement `FixtureLoader` → PASS. + +- [ ] **Step 3: Headless visual diff** + +Launch the client with both windows (`ACDREAM_RETAIL_UI=1 ACDREAM_RETAIL_UI_IMPORTER=1`, testaccount2) and confirm the importer window (offset) is pixel-identical to the hand-authored one. (Manual visual gate — the user confirms. No assertion.) + +- [ ] **Step 4: Full test sweep** + +Run: `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj --filter "FullyQualifiedName~UI"` +Expected: PASS (all prior UI tests + the new Layout tests). + +- [ ] **Step 5: Commit** + +``` +git add tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs +git commit -m "test(D.2b): vitals importer conformance (golden tree + A/B render gate)" +``` + +--- + +## After Plan 1 + +Once the importer window is pixel-identical to the hand-authored vitals (Task 8 gate), a follow-up commit flips vitals to the importer as the default and the hand-authored `vitals.xml` path is retired (kept in git history). **Plan 2** then covers: the `WindowManager` (open/close/z-order/persist), re-driving the chat window (`ChatController`), and extending the factory/renderer to the full long-tail of element types per the Task 1 enumeration. Register the phase id in `docs/plans/2026-04-11-roadmap.md` before starting Plan 2. + +## Self-review + +- **Spec coverage:** enumeration (Task 1) ✓, importer + inheritance (Tasks 2,5) ✓, generic renderer (Task 3) ✓, hybrid factory (Task 4) ✓, controller/binding (Task 6) ✓, coexistence/flag (Task 7) ✓, conformance (Task 8) ✓. Window manager + chat + full long-tail = explicitly deferred to Plan 2 (spec rollout 7–8). +- **Placeholder scan:** every code step has concrete code; `NOTE`s flag where Task 1's verified API must confirm a member name/value — that's a real dependency, not a vague requirement. +- **Type consistency:** `ElementInfo`, `ImportedLayout`, `LayoutImporter.BuildFromInfos`/`Import`, `DatWidgetFactory.Create`, `UiDatElement.ActiveMedia`, `VitalsController.Bind` are used consistently across tasks; `UiMeter.Fill`/`Label`/`SpriteResolve` match the existing widget. From 67819f35a4507346957e0f09a54db8390ea9e262 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 13:05:53 +0200 Subject: [PATCH 32/99] docs(D.2b): LayoutDesc format enumeration (importer groundwork) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves all 6 open unknowns for Tasks 2–6 of the LayoutDesc importer plan: 1. Edge-anchor flags: 1=near-pin, 2=far-pin, 3=float-center, 4=stretch. The plan's assumption of 4="pinned to that side" is corrected — 1 is the near-pin, 4 is stretch (both sides). Revised ToAnchors signature given. 2. ElementDesc members: all are public FIELDS (not properties). X/Y/Width/ Height/LeftEdge/etc. are uint. Type is uint (not enum). States is Dictionary. Children is Dictionary. 3. StateDesc shape: Properties is Dictionary with concrete subclasses (ArrayBaseProperty, DataIdBaseProperty, IntegerBaseProperty, etc.). Font DID (0x1A) is ArrayBaseProperty[ DataIdBaseProperty{Value=0x40000000} ]. Font color (0x1B) is ArrayBaseProperty[ ColorBaseProperty ]. Fill (0x69) is NOT in the dat — pushed at runtime by gmVitalsUI::Update. 4. DrawModeType enum: Undefined=0, Normal=1, Overlay=2, Alphablend=3. No "Stretch" value exists. Vitals uses Normal(1) and Alphablend(3) only. 5. Type values confirmed from RegisterElementClass: 3=Field/container, 7=Meter→UiMeter, 9=Resizebar, 0xC=Text, 2=Dragbar, 12=style prototype (skip). 6. Inheritance chain: vitals text labels (Type=0) inherit from base element 0x10000376 in layout 0x2100003F (Type=12), which carries font DID 0x40000000. The full per-vital sprite id tables for 0x2100006C are confirmed. Co-Authored-By: Claude Sonnet 4.6 --- docs/research/2026-06-15-layoutdesc-format.md | 486 ++++++++++++++++++ 1 file changed, 486 insertions(+) create mode 100644 docs/research/2026-06-15-layoutdesc-format.md diff --git a/docs/research/2026-06-15-layoutdesc-format.md b/docs/research/2026-06-15-layoutdesc-format.md new file mode 100644 index 00000000..867fd0a8 --- /dev/null +++ b/docs/research/2026-06-15-layoutdesc-format.md @@ -0,0 +1,486 @@ +# LayoutDesc Format Enumeration Reference + +**Date:** 2026-06-15 +**Author:** Task 1 of the LayoutDesc Importer plan (`docs/superpowers/plans/2026-06-15-layoutdesc-importer.md`) +**Sources:** +- Dat dumps: `dump-vitals-layout` on `0x2100006C`, `0x21000014`, `0x21000075`, `0x2100003F` +- Retail decomp: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR PDB) +- DatReaderWriter 2.1.7 reflection probe (deleted after use) + +This doc is the ground-truth API table for Tasks 2–6. Where it corrects a plan assumption, the correction is called out in **§ Corrections to plan assumptions** at the end. + +--- + +## 1. `ElementDesc` — exact API + +All members are **public fields** (not properties), except `ElementId`, `Type`, `BaseElement`, `BaseLayoutId`, `DefaultState`, `ReadOrder` which are also fields. There are no `ElementDesc` properties used by the importer. + +| Member | Kind | Type | Notes | +|--------|------|------|-------| +| `ElementId` | **field** | `uint` | unique element id (e.g. `0x100000E6`) | +| `Type` | **field** | `uint` | element class id — **not an enum in DRW**; raw uint | +| `BaseElement` | **field** | `uint` | base element id in base layout (0 = no base) | +| `BaseLayoutId` | **field** | `uint` | layout id where base element lives (0 = no base) | +| `DefaultState` | **field** | `UIStateId` (enum) | the element's initial active state | +| `ReadOrder` | **field** | `uint` | draw order within parent | +| `X` | **field** | `uint` | left position within parent, in pixels | +| `Y` | **field** | `uint` | top position within parent, in pixels | +| `Width` | **field** | `uint` | pixel width | +| `Height` | **field** | `uint` | pixel height | +| `ZLevel` | **field** | `uint` | z-order (0 in all vitals elements) | +| `LeftEdge` | **field** | `uint` | left anchor flag (see §4) | +| `TopEdge` | **field** | `uint` | top anchor flag (see §4) | +| `RightEdge` | **field** | `uint` | right anchor flag (see §4) | +| `BottomEdge` | **field** | `uint` | bottom anchor flag (see §4) | +| `StateDesc` | **field** | `StateDesc?` | the element's "DirectState" (no name); null if absent | +| `States` | **field** | `Dictionary` | named states (e.g. `HideDetail`, `ShowDetail`) | +| `Children` | **field** | `Dictionary` | child elements keyed by their `ElementId` | + +**Important:** `X`, `Y`, `Width`, `Height`, `LeftEdge`, etc. are all `uint`, not `int` or `float`. Cast to `float`/`int` when constructing `ElementInfo`. + +The dump tool iterates both properties and fields; the scalars (`X`, `Y`, etc.) are found as **fields**. + +--- + +## 2. `StateDesc` — exact API + +| Member | Kind | Type | Notes | +|--------|------|------|-------| +| `StateId` | **field** | `uint` | redundant with the dict key | +| `PassToChildren` | **field** | `bool` | | +| `IncorporationFlags` | **field** | `IncorporationFlags` | | +| `Properties` | **field** | `Dictionary` | keyed by property-id (uint); see §3 | +| `Media` | **field** | `List` | polymorphic list of media items | + +### States dictionary key type + +`ElementDesc.States` is `Dictionary`. The dump shows string names like `"HideDetail"` and `"ShowDetail"` because the dump tool calls `.Key.ToString()` on the `UIStateId` enum values. The actual key is a `UIStateId` enum: + +```csharp +// Key: UIStateId.HideDetail = 268435462 (0x10000006) +// Key: UIStateId.ShowDetail = 268435463 (0x10000007) +``` + +See §6 for the full `UIStateId` enum. + +**Iterating in code:** +```csharp +foreach (var s in d.States) + ReadState(s.Value, s.Key.ToString(), info); // s.Key is UIStateId; .ToString() gives "HideDetail" etc. +``` + +--- + +## 3. Properties (`StateDesc.Properties`) — how font DID and fill are stored + +`StateDesc.Properties` is `Dictionary`. The `BaseProperty` base class has: +- `BasePropertyType PropertyType` (enum) +- `uint MasterPropertyId` +- `bool ShouldPackMasterPropertyId` + +Concrete subclasses (`DatReaderWriter.Types.*`): + +| Subclass | Field | Type | Notes | +|----------|-------|------|-------| +| `BoolBaseProperty` | `Value` | `bool` | | +| `IntegerBaseProperty` | `Value` | `int` | | +| `FloatBaseProperty` | `Value` | `float` | | +| `EnumBaseProperty` | `Value` | `uint` | | +| `DataIdBaseProperty` | `Value` | `uint` | a dat object DID | +| `ArrayBaseProperty` | `Value` | `List` | array of sub-properties | +| `ColorBaseProperty` | `Value` | `ColorARGB` | `struct { byte Blue, Green, Red, Alpha }` | +| `StringInfoBaseProperty` | `Value` | `StringInfo` | | +| `VectorBaseProperty` | `Value` | `Vector3` | | +| `Bitfield32BaseProperty` | `Value` | `uint` | | +| `Bitfield64BaseProperty` | `Value` | `ulong` | | +| `InstanceIdBaseProperty` | `Value` | `uint` | | +| `StructBaseProperty` | `Value` | `Dictionary` | | + +### Property key meanings (confirmed from decomp + dat inspection) + +| Key | Type found in dat | Meaning | Decomp ref | +|-----|-------------------|---------|-----------| +| `0x1A` | `ArrayBaseProperty` (contains `DataIdBaseProperty`) | **Font DID** — array with one item; the inner `DataIdBaseProperty.Value` is the font dat object id | `UIElement_Text::SetFontDIDHelper(this, 0x1a, ...)` @`0x46829e` | +| `0x1B` | `ArrayBaseProperty` (contains `ColorBaseProperty`) | **Font color** — array with one item; `ColorARGB {R,G,B,A}` | `UIElement_Text::SetFontColorHelper(this, 0x1b, ...)` @`0x4682c2` | +| `0x14` | `EnumBaseProperty` | **Horizontal justification** | `UIElement_Text::SetHorizontalJustification` @`0x467200` | +| `0x15` | `EnumBaseProperty` | **Vertical justification** | `UIElement_Text::SetVerticalJustification` @`0x467230` | +| `0x1C` / `0x1D` | `ArrayBaseProperty` | Tag font color / tag font | (secondary font style for in-text tags) | +| `0x16` | `BoolBaseProperty` | Some text flag | | +| `0x21` | `BoolBaseProperty` | One-line mode | | +| `0x23` | `IntegerBaseProperty` | Left margin | | +| `0x24` | `IntegerBaseProperty` | Top margin | | +| `0x25` | `IntegerBaseProperty` | Right margin | | +| `0x26` | `IntegerBaseProperty` | Bottom margin | | +| `0x27` | `BoolBaseProperty` | Some text option | | +| `0x20` | `BoolBaseProperty` | Some text option | | +| `0x69` | — (NOT in dat) | **Fill percent** — set at runtime via `UIElement::SetAttribute_Float(meter, 0x69, fillRatio)` | `gmVitalsUI::Update` @`0x4bff2a` | +| `0xCB` | `BoolBaseProperty` | Some text option | | + +**Critical point for font DID extraction:** +Property `0x1A` is an `ArrayBaseProperty` containing ONE `DataIdBaseProperty`. To read the font DID: +```csharp +if (sd.Properties.TryGetValue(0x1Au, out var raw) && raw is ArrayBaseProperty arr && arr.Value.Count > 0) + if (arr.Value[0] is DataIdBaseProperty did) + fontDid = did.Value; // e.g. 0x40000000 +``` + +**Confirmed for element `0x10000376` (the vitals text prototype):** +- Property `0x1A` → `DataIdBaseProperty.Value = 0x40000000` (font DID) +- Property `0x1B` → `ColorBaseProperty.Value = {B=255,G=255,R=255,A=255}` (white) + +**The fill (`0x69`) is NOT in the dat.** It is pushed at runtime by `gmVitalsUI::Update` calling `UIElement::SetAttribute_Float(meter, 0x69, ratio)`. The importer does not read this from the dat — the `VitalsController` sets it via `UiMeter.Fill` after binding. + +--- + +## 4. Edge-anchor flags (`LeftEdge`/`TopEdge`/`RightEdge`/`BottomEdge`) + +These are `uint` fields on `ElementDesc`. The values found across all four vitals layouts are: + +| Value | Meaning | Where observed | +|-------|---------|---------------| +| `0` | Not present / no constraint | Base layout `0x2100003F` (zero-size elements) | +| `1` | **Pinned to near edge** (left for LeftEdge, top for TopEdge) | Everywhere in vitals | +| `2` | **Pinned to far edge** (right for LeftEdge, bottom for TopEdge) | Corners/bottom elements | +| `3` | **Centered / pinned to both far edges** (floated, centered between two sides) | The expand-detail overlay child `0x100004A9` | +| `4` | **Stretch / pinned to BOTH sides** | Meter elements in `0x21000014`/`0x21000075`; means the element stretches with parent resize | + +### Anchor logic (correcting the plan's assumption) + +**The plan assumed value `4` = "pinned to that side."** The correct semantics are: + +- `1` = pinned to the **near** edge of that axis (left, or top) +- `2` = pinned to the **far** edge (right, or bottom) +- `3` = pinned to BOTH far edges (centered/floating between the two anchors on that axis) +- `4` = stretch anchor: pinned to BOTH the near AND far edges simultaneously (element stretches) +- `0` = no anchor (zero-size elements used as font/style prototypes in the base layout) + +Evidence from the `0x21000014` dump: the health meter (`0x100000E6`) has `LeftEdge=1, RightEdge=4` meaning "pin left edge, stretch right" — the meter fills from the left to the window's right edge. The stamina meter (`0x100000EC`) has `LeftEdge=4, RightEdge=4` meaning it stretches on both sides (centered at 270px, fills width with parent). + +**Revised `ToAnchors` logic:** +```csharp +public static AnchorEdges ToAnchors(uint left, uint top, uint right, uint bottom) +{ + // 1 = near-pin, 2 = far-pin, 3 = both-far (floating center), 4 = stretch (both sides) + var a = AnchorEdges.None; + if (left == 1 || left == 4) a |= AnchorEdges.Left; + if (top == 1 || top == 4) a |= AnchorEdges.Top; + if (right == 2 || right == 4) a |= AnchorEdges.Right; + if (bottom == 2 || bottom == 4) a |= AnchorEdges.Bottom; + if (a == AnchorEdges.None) a = AnchorEdges.Left | AnchorEdges.Top; // default: pin top-left + return a; +} +``` +Value `3` (floating center) is a "pin far but not near" on both axes — maps to Right+Bottom anchors but NOT Left+Top. This shows up only on the hide/show-detail overlay child (`0x100004A9`) which is visually centered in the bar. + +--- + +## 5. `MediaDesc` kinds + +`StateDesc.Media` is `List`. The concrete types found across the vitals layouts: + +| Subclass | Fields | Used in vitals? | Notes | +|----------|--------|----------------|-------| +| `MediaDescImage` | `uint File`, `DrawModeType DrawMode`, `MediaType Type` | YES — all sprite images | The primary media type | +| `MediaDescCursor` | `uint File`, `uint XHotspot`, `uint YHotspot`, `MediaType Type` | YES — grip/dragbar cursor | Sets the mouse cursor when hovering the element | +| `MediaDescAnimation` | `float Duration`, `DrawModeType DrawMode`, `List Frames`, `MediaType Type` | not in vitals | Animated sprite | +| `MediaDescAlpha` | `uint File`, `MediaType Type` | not in vitals | Alpha overlay | +| `MediaDescFade` | `float StartAlpha, EndAlpha, Duration`, `MediaType Type` | not in vitals | Fade transition | +| `MediaDescSound` | `uint File`, ... | not in vitals | | +| `MediaDescState` | `UIStateId StateId`, ... | not in vitals | State transition | +| `MediaDescJump` | `uint JumpItemIndex`, ... | not in vitals | | +| `MediaDescMessage` | `uint Id`, ... | not in vitals | | +| `MediaDescPause` | `float MinDuration, MaxDuration`, ... | not in vitals | | +| `MediaDescMovie` | `PStringBase FileName`, ... | not in vitals | | + +Elements can have **multiple media items** in the same `StateDesc.Media` list — e.g. a grip element has both a `MediaDescImage` (the sprite) and a `MediaDescCursor` (the cursor shape). Iterate all items; for rendering pick the `MediaDescImage`; for cursor behavior pick `MediaDescCursor`. + +--- + +## 6. `DrawModeType` enum (confirmed from reflection) + +`DatReaderWriter.Enums.DrawModeType` (the type on `MediaDescImage.DrawMode`): + +| Name | Value | Behavior | Used in vitals? | +|------|-------|----------|----------------| +| `Undefined` | 0 | (not used) | no | +| `Normal` | 1 | **Tile at native width** (UV-repeat; matches `ImgTex::TileCSI` @`0x53e740`) | YES — all bar sprites, chrome | +| `Overlay` | 2 | Blended overlay (not observed in vitals) | no | +| `Alphablend` | 3 | **Blended overlay** — used for the "ShowDetail" expand panels | YES — `ShowDetail` state sprites | + +**The vitals window uses only `Normal` (1) and `Alphablend` (3).** No `Stretch` value exists in `DrawModeType` — the plan's mention of a "Stretch" draw-mode is NOT a value in this enum. There is a `MediaType.Stretch = 12` in a separate enum but that refers to a different concept (animation sequence? not a blit mode). Do not branch on `Stretch` in `UiDatElement`. + +--- + +## 7. `UIStateId` enum (key type for `ElementDesc.States`) + +`DatReaderWriter.Enums.UIStateId`. Key values relevant to the vitals window: + +| Name | Value | +|------|-------| +| `Undef` | 0 | +| `Normal` | 1 | +| `HideDetail` | 268435462 (= `0x10000006`) | +| `ShowDetail` | 268435463 (= `0x10000007`) | +| `IsCharacter` | 268435542 (= `0x10000056`) | +| `IsAccount` | 268435543 (= `0x10000057`) | + +The dump prints these as strings ("HideDetail", "ShowDetail") via `UIStateId.ToString()`. When iterating `d.States`, `s.Key.ToString()` gives the readable name. + +--- + +## 8. Type → meaning → render method → widget bucket + +From `UIElement::RegisterElementClass` calls in the decomp. The mapping is CONFIRMED by retail: + +| Type (uint) | Class registered | Render method | Widget bucket | Vitals? | +|-------------|-----------------|---------------|---------------|---------| +| 0 | — (no registration) | text label; inherits from `UIElement_Text` behavior via `UIElement_Scrollable` | **behavioral** → dat-font label widget | YES — the text overlay (e.g. `0x100000EB/ED/EF`) | +| 1 | `UIElement_Button::Register()` | `UIRegion::DrawHere` (vtable) | **behavioral** → button widget | no | +| 2 | `UIElement_Dragbar::Register()` | `UIRegion::DrawHere` | **generic** → `UiDatElement` (drag region) | YES — top/bottom drag bars | +| 3 | `UIElement_Field::Register()` | `UIRegion::DrawHere` | **generic** → `UiDatElement` | YES — container/group elements, chrome corners/edges | +| 4 | (unregistered in stdlib; may be custom) | — | generic fallback | no | +| 5 | `UIElement_ListBox::Register()` | `UIRegion::DrawHere` | **behavioral** → list widget | no | +| 6 | `UIElement_Menu::Register()` | `UIRegion::DrawHere` | **behavioral** → menu widget | no | +| **7** | `UIElement_Meter::Register()` | **`UIElement_Meter::DrawChildren`** @`0x46fbd0` | **behavioral** → `UiMeter` | **YES — the three vitals bars** | +| 8 | `UIElement_Panel::Register()` | `UIRegion::DrawHere` | generic → `UiDatElement` | no | +| 9 | `UIElement_Resizebar::Register()` | `UIRegion::DrawHere` | **generic** → `UiDatElement` (grip) | YES — resize grips (corners + edges) | +| 0xB | `UIElement_Scrollbar::Register()` | `UIRegion::DrawHere` | **behavioral** → scrollbar | no | +| **0xC** | `UIElement_Text::Register()` | `UIElement_Text::DrawSelf` @`0x467aa0` | **behavioral** → dat-font label | YES — Type=0 elements have BaseElement which resolves to a Type=0x0C in the base | +| 0xD | `UIElement_Viewport::Register()` | — | behavioral → 3D viewport | no | +| 0xE | `UIElement_Browser::Register()` | — | behavioral → browser | no | +| 0x10 | `UIElement_ColorPicker::Register()` | — | behavioral → color picker | no | +| 0x11 | `UIElement_GroupBox::Register()` | — | behavioral → group box | no | +| **0x12** | — (Type=12 in base layout) | No render method registered — these are **style prototypes** (zero-size elements used as `BaseElement` sources, never instantiated directly) | skip/omit | YES — `0x2100003F` is full of Type=12 elements | +| 0x13–0x19 | `ConfirmationDialog*` / `MessageDialog*` / etc. | dialog widgets | behavioral → dialog | no | +| 0x1000xxxx | `gmVitalsUI`, `gmAttributeUI`, etc. | game-specific custom classes | **custom widget** (registered with high ids) | YES — the stacked vitals window root `0x100005F9` has `Type=268435533=0x10000009`; the floaty row root has Type=`268435465=0x10000009`… actually see below | + +### Root element types in the vitals layouts + +- `0x2100006C` root element `0x100005F9`: `Type = 268435533 = 0x10000009` → `gmVitalsUI::Register` registers type `0x10000009` +- `0x21000014` root element `0x100000E5`: `Type = 268435465 = 0x10000009` — wait, `268435465 = 0x10000009` ✓ + +Actually: `268435533 = 0x1000000D` (not 9). Let me recompute: +- `268435533 decimal`: `268435456 + 77 = 0x10000000 + 0x4D = 0x1000004D` — that's `gmVitalsUI`-ish but a different id. +- `268435465`: `268435456 + 9 = 0x10000009` — confirmed `gmVitalsUI` type. + +The correct decomp cross-check: `UIElement::RegisterElementClass(0x10000009, gmVitalsUI::Create)` @`0x4bfe1a`. The stacked vitals window root `0x100005F9` has `Type=268435533`. `268435533 = 0x1000004D` which would be a different registered type. The floaty row root `0x100000E5` has `Type=268435465 = 0x10000009` = confirmed `gmVitalsUI`. + +The key observation: **the root element's Type selects the `gmVitalsUI` C++ class**, which is the window-level controller. In our importer, we don't need to match this: the `LayoutImporter` walks children, and the `VitalsController` binds the meter elements by id directly — the root type is irrelevant to Plan 1. + +**Plan 1 relevant types (vitals window only):** + +| Type | Role | Bucket | +|------|------|--------| +| 0 | text overlay label (BaseElement → Type 12 for font, but the element itself renders as text) | behavioral → dat-font label | +| 2 | drag bar (top/bottom) | generic | +| 3 | container / chrome edge / corner (no children hierarchy in vitals) | generic | +| 7 | meter | behavioral → `UiMeter` | +| 9 | resize grip (corners + edges) | generic | +| 12 | style prototype — zero-size, never directly rendered | skip | +| 0x10000009 | `gmVitalsUI` root — the window itself | behavioral → window root (use as container) | +| 0x1000004D | the stacked-window root | same | + +--- + +## 9. `LayoutDesc` fields + +| Member | Kind | Type | Notes | +|--------|------|------|-------| +| `Id` | property | `uint` | dat object id | +| `HeaderFlags` | property | `DBObjHeaderFlags` | | +| `DBObjType` | property | `DBObjType` | always `LayoutDesc` | +| `DataCategory` | property | `uint` | | +| `Width` | **field** | `uint` | screen-space width context (800 in all observed layouts) | +| `Height` | **field** | `uint` | screen-space height context (600 in all observed layouts) | +| `Elements` | **field** | `HashTable` (DRW-internal type) | top-level elements, keyed by `ElementId`. Iterable with `foreach (var kv in ld.Elements)`. | + +--- + +## 10. Inheritance chain for vitals number-text elements + +All three vitals text labels (`0x100000EB` health, `0x100000ED` stamina, `0x100000EF` mana) share: +- `Type = 0` (text element, no render registration — renders via inherited machinery) +- `BaseElement = 268436342 = 0x10000376` +- `BaseLayoutId = 553648191 = 0x2100003F` + +The base element `0x10000376` in `0x2100003F`: +- `Type = 12` (style prototype — zero-size, never rendered directly) +- `StateDesc.Properties`: + - `0x1A` → `ArrayBaseProperty[ DataIdBaseProperty{Value=0x40000000} ]` — **font DID = `0x40000000`** + - `0x1B` → `ArrayBaseProperty[ ColorBaseProperty{R=255,G=255,B=255,A=255} ]` — white + - `0x14` → `EnumBaseProperty{Value=1}` — horizontal justification = 1 + - `0x15` → `EnumBaseProperty{Value=1}` — vertical justification = 1 + - `0x23`, `0x25` → `IntegerBaseProperty{Value=0}` — margins + +The inheritance chain for the text element in the importer is: +``` +derived (Type=0, no StateDesc media, no font prop itself) + inherits from base 0x10000376 in layout 0x2100003F (Type=12) + → font DID = 0x40000000 (from property 0x1A) + → font color = white ARGB(255,255,255,255) (from property 0x1B) +``` + +The derived text element overrides `Width/Height/X/Y` (from the dat element's fields) but inherits the font DID and color from the base element's `Properties`. + +**There is no `StateDesc.Media` on the text elements** — the text is rendered by the `UIElement_Text::DrawSelf` algorithm using the font DID from properties, not a sprite. In Plan 1, the text elements render as `UiDatElement` (generic fallback) until a dedicated text widget is implemented in Plan 2. + +--- + +## 11. Vitals window `0x2100006C` — confirmed element map + +Root: `0x100005F9` (160×58, Type=`0x1000004D`, LeftEdge=1, TopEdge=1, RightEdge=1, BottomEdge=2) + +### Chrome (all Type=3, `DrawMode=Normal`) + +| Id | X | Y | W | H | LeftEdge | TopEdge | RightEdge | BottomEdge | Sprite | +|----|---|---|---|---|----------|---------|-----------|------------|--------| +| `0x10000633` | 0 | 0 | 5 | 5 | 1 | 1 | 2 | 2 | `0x060074C3` (TL corner) | +| `0x10000634` | 5 | 0 | 150 | 5 | 1 | 1 | 1 | 2 | `0x060074BF` (top edge) | +| `0x10000635` | 155 | 0 | 5 | 5 | 2 | 1 | 1 | 2 | `0x060074C4` (TR corner) | +| `0x10000636` | 0 | 5 | 5 | 48 | 1 | 1 | 2 | 1 | `0x060074C0` (left edge) | +| `0x10000637` | 0 | 53 | 5 | 5 | 1 | 2 | 2 | 1 | `0x060074C5` (BL corner) | +| `0x10000638` | 5 | 53 | 150 | 5 | 1 | 2 | 1 | 1 | `0x060074C1` (bottom edge) | +| `0x10000639` | 155 | 53 | 5 | 5 | 2 | 2 | 1 | 1 | `0x060074C6` (BR corner) | +| `0x1000063A` | 155 | 5 | 5 | 48 | 2 | 1 | 1 | 1 | `0x060074C2` (right edge) | + +### Drag bars (Type=2) + +| Id | X | Y | W | H | Notes | +|----|---|---|---|---|-------| +| `0x1000063C` | 5 | 0 | 150 | 5 | top drag bar; also has `MediaDescCursor` cursor `0x06006119` | +| `0x10000640` | 5 | 53 | 150 | 5 | bottom drag bar; same cursor | + +### Resize grips (Type=9 — corners + edges) + +| Id | X | Y | W | H | Corner/Edge | +|----|---|---|---|---|-------------| +| `0x1000063B` | 0 | 0 | 5 | 5 | TL grip | +| `0x1000063D` | 155 | 0 | 5 | 5 | TR grip | +| `0x1000063E` | 0 | 5 | 5 | 48 | left grip | +| `0x1000063F` | 0 | 53 | 5 | 5 | BL grip | +| `0x10000641` | 155 | 53 | 5 | 5 | BR grip | +| `0x10000642` | 155 | 5 | 5 | 48 | right grip | + +Each grip has a `MediaDescImage` + a `MediaDescCursor` in its `StateDesc.Media` list. + +### Meter elements (Type=7 — `UiMeter`) + +| Id | X | Y | W | H | Purpose | +|----|---|---|---|---|---------| +| `0x100000E6` | 5 | 5 | 150 | 16 | Health meter | +| `0x100000EC` | 5 | 21 | 150 | 16 | Stamina meter | +| `0x100000EE` | 5 | 37 | 150 | 16 | Mana meter | + +Each meter has: +- Child `0x100000E7` (back layer, Type=3): three sub-children `E8`/`E9`/`EA` (left/center/right slices, back sprites) + - `E8` has `RightEdge=2` (pin far right), `EA` has `LeftEdge=2` (pin far left) — the classic 3-slice anchor pattern +- Child `0x00000002` (front layer container, Type=3): three sub-children `E8`/`E9`/`EA` (front sprites), plus child `0x100004A9` (expand detail overlay, HideDetail/ShowDetail states) +- Child `0x100000EB/ED/EF` (text label, Type=0): BaseElement=`0x10000376`, BaseLayoutId=`0x2100003F` → inherits font `0x40000000` + +### Sprite ids confirmed from dump + +**Health bar** (back=`E7` layer / front=`00000002.E8-EA` layer): +- Back left: `0x0600747E`, center: `0x0600747F`, right: `0x06007480` +- Front left: `0x06007481`, center: `0x06007482`, right: `0x06007483` +- ShowDetail overlay: `0x06007490` (back) / `0x06007491` (front) + +**Stamina bar:** +- Back left: `0x06007484`, center: `0x06007485`, right: `0x06007486` +- Front left: `0x06007487`, center: `0x06007488`, right: `0x06007489` +- ShowDetail: `0x06007492` / `0x06007493` + +**Mana bar:** +- Back left: `0x0600748A`, center: `0x0600748B`, right: `0x0600748C` +- Front left: `0x0600748D`, center: `0x0600748E`, right: `0x0600748F` +- ShowDetail: `0x06007494` / `0x06007495` + +--- + +## 12. Inheritance resolution rules + +1. If `d.BaseElement != 0 && d.BaseLayoutId != 0`: load base layout, find base element, call `Resolve()` recursively on it, then `Merge(base, derived)`. +2. Merge semantics: **derived overrides, base is the default**. `Width`/`Height`/`X`/`Y` come from the derived element's fields (even if zero — zero is a valid override for prototypes). `FontDid` is inherited if the derived element's base chain provides it and the derived doesn't explicitly set it. +3. Type=12 elements in the base layout (`0x2100003F`) are pure property stores — **never render them**. They exist only to be referenced as `BaseElement`. +4. Cycle-guard: track already-visited `(BaseLayoutId, BaseElement)` pairs to avoid infinite loops. + +--- + +## § Corrections to plan assumptions + +### 1. Edge-flag "pinned" value is NOT simply `4` + +**Plan assumed:** `if (left == 4) a |= AnchorEdges.Left;` +**Correct semantics:** + +| Edge value | Meaning | +|-----------|---------| +| 0 | no anchor (prototype-only elements) | +| 1 | pinned to **near** edge (left/top) | +| 2 | pinned to **far** edge (right/bottom) | +| 3 | pinned to BOTH far edges (centered/floating) | +| 4 | stretch: pinned to BOTH near AND far edges simultaneously | + +**Fix for Task 2:** +```csharp +public static AnchorEdges ToAnchors(uint left, uint top, uint right, uint bottom) +{ + var a = AnchorEdges.None; + if (left == 1 || left == 4) a |= AnchorEdges.Left; + if (top == 1 || top == 4) a |= AnchorEdges.Top; + if (right == 2 || right == 4) a |= AnchorEdges.Right; + if (bottom == 2 || bottom == 4) a |= AnchorEdges.Bottom; + if (a == AnchorEdges.None) a = AnchorEdges.Left | AnchorEdges.Top; + return a; +} +``` + +Also: the `ElementReader.ToAnchors` signature in the plan uses `(int left, ...)` but the fields are `uint`. Use `(uint left, ...)` or cast at call site. + +### 2. `X`, `Y`, `Width`, `Height`, `LeftEdge`, etc. are `uint`, not `float` or `int` + +The plan's `ToInfo()` code uses `d.X, d.Y` etc. as though they are already numeric-assignable. They are `uint`, so the assignment `X = d.X` etc. requires an explicit cast `(float)d.X` in the `ElementInfo` struct. + +### 3. `ElementDesc.Type` is `uint`, not an enum + +The plan writes `(int)d.Type`. `d.Type` is `uint`, so `(int)d.Type` is valid C# (checked context would overflow for values > `int.MaxValue`, but the registered types are all small or `0x10000009` which fits in int). Better: store `Type` as `uint` in `ElementInfo` to avoid signed overflow on game-specific ids like `0x1000004D`. + +### 4. `DrawModeType` has no `Stretch` value + +The plan mentions handling `Stretch` in `UiDatElement`. The `DrawModeType` enum has only `{Undefined=0, Normal=1, Overlay=2, Alphablend=3}`. There is no `Stretch` draw mode in this enum. Drop the `Stretch` branch. + +### 5. `d.States` key is `UIStateId`, not `string` + +The plan writes `foreach (var s in d.States) ReadState(s.Value, s.Key, info);` treating `s.Key` as a string. The key is `UIStateId` (an enum). Use `s.Key.ToString()` for the string name, or compare directly via `UIStateId.HideDetail` etc. + +### 6. Font DID is in `ArrayBaseProperty`, not a direct property + +The plan's `// font DID (property 0x1A) read here once the format doc confirms the property API.` comment is the right place. The actual read is: +```csharp +if (sd.Properties.TryGetValue(0x1Au, out var raw) && raw is ArrayBaseProperty arr && arr.Value.Count > 0) + if (arr.Value[0] is DataIdBaseProperty did) + info.FontDid = did.Value; +``` + +### 7. Fill (`0x69`) is NOT in the dat + +The plan says `SetAttribute_Float(meter, 0x69, fillRatio)` is a runtime operation. Confirmed: property `0x69` does not appear in any dat layout. The fill is set at runtime by the controller. The importer should not attempt to read it. + +### 8. Type=12 elements are style prototypes — skip them entirely + +Elements with `Type=12` in the base layout `0x2100003F` are zero-size property bags used as `BaseElement` sources. They should not be instantiated as widgets. The `DatWidgetFactory` switch should have a `12 => null` (skip) case, or the importer should skip top-level elements with `Width==0 && Height==0 && Type==12` — though the safest check is just `Type == 12`. + +--- + +## § Plan 1 surface vs long tail + +**Plan 1 (vitals conformance) uses:** +- Types: 2, 3, 7, 9, 12 (skip), 0 (text, generic fallback), 0x10000009/0x1000004D (root window — treat as container) +- DrawModes: `Normal` (1), `Alphablend` (3) +- Media: `MediaDescImage`, `MediaDescCursor` +- Properties: `0x1A` (font DID, from inheritance), `0x1B` (font color, from inheritance) +- States: `HideDetail`, `ShowDetail` + +**Plan 2 (long tail):** +- Types: 1 (button), 5 (listbox), 6 (menu), 8 (panel), 0xB (scrollbar), 0xC (text widget proper), 0xD (viewport), 0x10 (color picker), 0x11 (groupbox), dialog types (0x13–0x19), all `gm*UI` custom types +- DrawModes: `Overlay` (2), any future additions +- Media: `MediaDescAnimation`, `MediaDescFade`, `MediaDescSound`, `MediaDescState`, etc. From f73422a79a2e608899876632c7730b520c8f52f5 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 13:20:23 +0200 Subject: [PATCH 33/99] =?UTF-8?q?feat(D.2b):=20ElementReader=20=E2=80=94?= =?UTF-8?q?=20layout=20inheritance=20merge=20+=20edge-flag=20anchors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Task 2 of the LayoutDesc Importer (Plan 1 — vitals conformance). - ElementInfo POCO: GL-free/dat-free snapshot of a resolved layout element. Shape matches the plan spec exactly (Id, Type as uint, X/Y/Width/Height as float, raw Left/Top/Right/Bottom uint edge flags, ReadOrder, FontDid, StateMedia dict, Children list). Tasks 3–6 depend on this shape. - ElementReader.ToAnchors(uint,uint,uint,uint): maps dat edge-flag values (0=none, 1=near-pin, 2=far-pin, 3=floating-center, 4=stretch) to AnchorEdges bit flags. Corrects the plan's stale assumption that value 4 was the only anchor trigger; the verified format doc §4 shows 1→Left/Top, 2→Right/Bottom, 4→both. All-zero falls back to Left|Top (default pin top-left). - ElementReader.Merge(base_, derived): inheritance merge mirroring BaseElement/ BaseLayoutId. Derived scalars win when non-zero; position/edge-flags/ReadOrder always from derived; StateMedia merged (base defaults, derived overrides); Children from derived only. TDD: tests written first (9 tests covering ToAnchors near-pin/far-pin/stretch/ zero/value-3, Merge scalar override/font inheritance/StateMedia merge/children). All 9 pass; dotnet build 0 errors 0 warnings. Co-Authored-By: Claude Sonnet 4.6 --- src/AcDream.App/UI/Layout/ElementReader.cs | 165 ++++++++++++++++++ .../UI/Layout/ElementReaderTests.cs | 139 +++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 src/AcDream.App/UI/Layout/ElementReader.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs diff --git a/src/AcDream.App/UI/Layout/ElementReader.cs b/src/AcDream.App/UI/Layout/ElementReader.cs new file mode 100644 index 00000000..e1a5272e --- /dev/null +++ b/src/AcDream.App/UI/Layout/ElementReader.cs @@ -0,0 +1,165 @@ +using System.Collections.Generic; + +namespace AcDream.App.UI.Layout; + +/// +/// GL-free, dat-free snapshot of a resolved layout element. +/// Populated by the LayoutDesc importer from DatReaderWriter.ElementDesc +/// after inheritance is applied. The pure transforms on +/// operate on this type so they can be unit-tested without the dats or OpenGL. +/// +/// IMPORTANT: Tasks 3–6 depend on this shape exactly. Do not add members without +/// updating the plan spec and downstream consumers. +/// +public sealed class ElementInfo +{ + /// Dat element id (e.g. 0x100000E6). + public uint Id; + + /// + /// Raw element class id as a uint. + /// Game-specific ids like 0x1000004D (gmVitalsUI root) and 0x10000009 + /// overflow int when treated as signed, so this stays uint. + /// Known values: 0=text, 2=dragbar, 3=container/chrome, 7=meter, + /// 9=resize-grip, 12=style-prototype (skip), 0x10000009/0x1000004D=window root. + /// + public uint Type; + + /// Position and size within the parent, in pixels (cast from dat uint fields). + public float X, Y, Width, Height; + + /// + /// Raw edge-anchor flag values from the dat (LeftEdge, TopEdge, + /// RightEdge, BottomEdge fields of ElementDesc). + /// Values 0–4; map to bit-flags via + /// . + /// + public uint Left, Top, Right, Bottom; + + /// Draw order within the parent (lower = drawn first / behind). + public uint ReadOrder; + + /// + /// Font dat object id inherited from the base element's Properties[0x1A] + /// (ArrayBaseProperty → DataIdBaseProperty). 0 = none / not inherited. + /// + public uint FontDid; + + /// + /// Sprite per state: state name → (RenderSurface file id, DrawMode int). + /// The "" key represents the unnamed DirectState (ElementDesc.StateDesc). + /// Named states use the UIStateId.ToString() value as the key + /// (e.g. "HideDetail", "ShowDetail"). + /// + public Dictionary StateMedia = new(); + + /// + /// Resolved child elements (populated by the importer in Task 5). + /// Children come from the derived element's own tree, not the base element's. + /// + public List Children = new(); +} + +/// +/// Pure, GL-free, dat-free transforms for the LayoutDesc importer. +/// All methods are static and operate on POCOs. +/// No OpenGL, no DatReaderWriter types, no rendering dependencies beyond +/// the bit-flag enum from AcDream.App.UI. +/// +public static class ElementReader +{ + /// + /// Maps the four raw edge-anchor flag values from ElementDesc to the + /// bit-flag used by the UI layout engine. + /// + /// + /// The dat stores one uint per edge with these semantics (§4 of the + /// LayoutDesc format reference, 2026-06-15): + /// + /// 0 = no anchor (prototype-only elements — zero-size style stores) + /// 1 = pinned to the near edge (left for LeftEdge, top for TopEdge) + /// 2 = pinned to the far edge (right for RightEdge, bottom for BottomEdge) + /// 3 = floating / centered between both far edges (maps to neither Left nor Right) + /// 4 = stretch: pinned to BOTH near AND far edges simultaneously (element stretches with parent) + /// + /// + /// + /// + /// Default when no flags resolve: Left | Top (pin top-left, fixed size). + /// This matches elements whose all-zero edge flags indicate a no-reflow prototype. + /// + /// + /// LeftEdge dat field value (0–4). + /// TopEdge dat field value (0–4). + /// RightEdge dat field value (0–4). + /// BottomEdge dat field value (0–4). + public static AnchorEdges ToAnchors(uint left, uint top, uint right, uint bottom) + { + // 1 = near-pin, 2 = far-pin, 3 = both-far (floating center), 4 = stretch (both sides). + // Only 1 and 4 contribute the NEAR (Left/Top) anchor. + // Only 2 and 4 contribute the FAR (Right/Bottom) anchor. + // Value 3 contributes neither (floating center is handled by the UI engine differently). + var a = AnchorEdges.None; + if (left == 1 || left == 4) a |= AnchorEdges.Left; + if (top == 1 || top == 4) a |= AnchorEdges.Top; + if (right == 2 || right == 4) a |= AnchorEdges.Right; + if (bottom == 2 || bottom == 4) a |= AnchorEdges.Bottom; + if (a == AnchorEdges.None) a = AnchorEdges.Left | AnchorEdges.Top; // default: pin top-left + return a; + } + + /// + /// Merges a base element snapshot with a derived element snapshot, mirroring + /// the BaseElement / BaseLayoutId inheritance chain in the dat. + /// + /// + /// Rules: + /// + /// + /// Scalar fields (, , + /// , , + /// ): derived wins if non-zero; otherwise + /// inherited from base. + /// + /// + /// Position (, ) and + /// edge flags ( etc.) and + /// : always taken from the derived element + /// (derived placement, not the base prototype's geometry). + /// + /// + /// : base entries are the default; derived + /// entries override (or add) per state name key. + /// + /// + /// : come from the derived element's own tree only. + /// + /// + /// + /// + public static ElementInfo Merge(ElementInfo base_, ElementInfo derived) + { + var m = new ElementInfo + { + Id = derived.Id != 0 ? derived.Id : base_.Id, + Type = derived.Type != 0 ? derived.Type : base_.Type, + X = derived.X, + Y = derived.Y, + Width = derived.Width != 0 ? derived.Width : base_.Width, + Height = derived.Height != 0 ? derived.Height : base_.Height, + Left = derived.Left, + Top = derived.Top, + Right = derived.Right, + Bottom = derived.Bottom, + ReadOrder = derived.ReadOrder, + FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid, + // Children come from the derived element's own tree, not the base prototype's. + Children = derived.Children, + }; + // Start with base StateMedia as defaults, then let derived entries override. + m.StateMedia = new Dictionary(base_.StateMedia); + foreach (var kv in derived.StateMedia) + m.StateMedia[kv.Key] = kv.Value; + return m; + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs b/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs new file mode 100644 index 00000000..90b1a995 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs @@ -0,0 +1,139 @@ +using AcDream.App.UI; +using AcDream.App.UI.Layout; +namespace AcDream.App.Tests.UI.Layout; + +public class ElementReaderTests +{ + // ── ToAnchors ──────────────────────────────────────────────────────────── + + /// + /// Edge value 4 = stretch (pinned to BOTH near AND far sides simultaneously). + /// LeftEdge=4 → Left anchor; RightEdge=4 → Right anchor. + /// TopEdge=1 → Top only (near-pin); BottomEdge=1 → near-pin (left/top axis), NOT Bottom. + /// + [Fact] + public void EdgeFlagsToAnchors_LeftRight_Stretches() + { + // left=4 (stretch ⇒ Left), top=1 (near-pin ⇒ Top), right=4 (stretch ⇒ Right), bottom=1 (near-pin of bottom axis ⇒ not Bottom) + var a = ElementReader.ToAnchors(left: 4, top: 1, right: 4, bottom: 1); + Assert.True(a.HasFlag(AnchorEdges.Left)); + Assert.True(a.HasFlag(AnchorEdges.Right)); + Assert.False(a.HasFlag(AnchorEdges.Bottom)); + } + + /// + /// Edge value 1 = pinned to the NEAR edge of that axis. + /// For LeftEdge: near = Left. For TopEdge: near = Top. + /// For RightEdge: value 1 means near-pin of the right axis → does NOT map to Right anchor. + /// For BottomEdge: value 1 means near-pin of the bottom axis → does NOT map to Bottom anchor. + /// + [Fact] + public void EdgeFlagsToAnchors_AllOnes_PinsTopLeftOnly() + { + // 1 everywhere: only Left and Top anchors set (near-pins). Right/Bottom are far edges and value 1 is near-pin. + var a = ElementReader.ToAnchors(left: 1, top: 1, right: 1, bottom: 1); + Assert.True(a.HasFlag(AnchorEdges.Left)); + Assert.True(a.HasFlag(AnchorEdges.Top)); + Assert.False(a.HasFlag(AnchorEdges.Right)); + Assert.False(a.HasFlag(AnchorEdges.Bottom)); + } + + /// + /// Edge value 2 = pinned to the FAR edge of that axis. + /// For RightEdge: far = Right anchor. For BottomEdge: far = Bottom anchor. + /// For LeftEdge: value 2 means far-pin of the left axis → does NOT map to Left anchor. + /// For TopEdge: value 2 means far-pin of the top axis → does NOT map to Top anchor. + /// + [Fact] + public void EdgeFlagsToAnchors_AllTwos_PinsRightBottomOnly() + { + // 2 everywhere: only Right and Bottom anchors set (far-pins). + var a = ElementReader.ToAnchors(left: 2, top: 2, right: 2, bottom: 2); + Assert.False(a.HasFlag(AnchorEdges.Left)); + Assert.False(a.HasFlag(AnchorEdges.Top)); + Assert.True(a.HasFlag(AnchorEdges.Right)); + Assert.True(a.HasFlag(AnchorEdges.Bottom)); + } + + /// + /// All-zero edge flags (prototype-only elements) fall back to Left|Top default. + /// + [Fact] + public void EdgeFlagsToAnchors_AllZero_DefaultsToTopLeft() + { + var a = ElementReader.ToAnchors(left: 0, top: 0, right: 0, bottom: 0); + Assert.Equal(AnchorEdges.Left | AnchorEdges.Top, a); + } + + /// + /// Value 3 = floating/centered between both far edges on that axis. + /// Both LeftEdge=3 and RightEdge=3 → neither Left nor Right are set by the + /// near/stretch rules. The result is only Right+Bottom (the "far" semantics). + /// Specifically: left=3 → not Left (3 is not 1 or 4); right=3 → Right (3 is not 2 or 4, skip). + /// Wait — value 3 means "pinned to BOTH far edges" per format doc §4. Re-check the + /// mapping rule: Right anchor fires on right==2 || right==4, NOT on right==3. + /// So value 3 on LeftEdge, TopEdge, RightEdge, BottomEdge → no flags set → default Left|Top. + /// This test covers that corner case (element 0x100004A9 — expand-detail overlay). + /// + [Fact] + public void EdgeFlagsToAnchors_ValueThree_FallsBackToTopLeft() + { + // value 3 doesn't match any anchor rule; falls back to Left|Top default. + var a = ElementReader.ToAnchors(left: 3, top: 3, right: 3, bottom: 3); + Assert.Equal(AnchorEdges.Left | AnchorEdges.Top, a); + } + + // ── Merge ──────────────────────────────────────────────────────────────── + + [Fact] + public void Merge_BaseThenOverride_DerivedWins() + { + var base_ = new ElementInfo { Type = 0, FontDid = 0x40000000, Width = 150, Height = 16 }; + var derived = new ElementInfo { Type = 0, Width = 200 }; // overrides width, inherits font + height + var merged = ElementReader.Merge(base_, derived); + Assert.Equal(200, merged.Width); // override + Assert.Equal(16, merged.Height); // inherited + Assert.Equal(0x40000000u, merged.FontDid);// inherited + } + + [Fact] + public void Merge_DerivedHasFontDid_OverridesBase() + { + var base_ = new ElementInfo { FontDid = 0x40000000, Width = 100, Height = 10 }; + var derived = new ElementInfo { FontDid = 0x40000001, Width = 100 }; + var merged = ElementReader.Merge(base_, derived); + Assert.Equal(0x40000001u, merged.FontDid); + } + + [Fact] + public void Merge_DerivedStateMediaOverridesBase() + { + var base_ = new ElementInfo(); + base_.StateMedia[""] = (0x06001000u, 1); + base_.StateMedia["HideDetail"] = (0x06001001u, 1); + + var derived = new ElementInfo(); + derived.StateMedia[""] = (0x06002000u, 3); // overrides base default state + + var merged = ElementReader.Merge(base_, derived); + // derived's "" overrides base's "" + Assert.Equal((0x06002000u, 3), merged.StateMedia[""]); + // base's "HideDetail" is kept (derived didn't provide it) + Assert.Equal((0x06001001u, 1), merged.StateMedia["HideDetail"]); + } + + [Fact] + public void Merge_ChildrenComeFromDerived() + { + var base_ = new ElementInfo(); + base_.Children.Add(new ElementInfo { Id = 0x1u }); + + var derived = new ElementInfo(); + derived.Children.Add(new ElementInfo { Id = 0x2u }); + + var merged = ElementReader.Merge(base_, derived); + // children must come from derived only + Assert.Single(merged.Children); + Assert.Equal(0x2u, merged.Children[0].Id); + } +} From 55239575e6320a1d8f43a289dadfbea99bfdc339 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 13:25:44 +0200 Subject: [PATCH 34/99] =?UTF-8?q?refactor(D.2b):=20ElementReader=20review?= =?UTF-8?q?=20fixes=20=E2=80=94=20defensive=20Children=20copy=20+=20sentin?= =?UTF-8?q?el=20doc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Merge: defensive copy `new List(derived.Children)` so a later mutation of the merged result or the input can't corrupt the other - Merge: add comment on Width/Height 0-sentinel (Plan-1 safe; Plan-2 limitation and float?-upgrade path documented inline) - Test: replace mid-sentence "Wait —" authoring trace in EdgeFlagsToAnchors_ValueThree_FallsBackToTopLeft with a clean conclusion-first summary of the value-3 mapping rule 9/9 ElementReaderTests pass; 0 build errors. Co-Authored-By: Claude Sonnet 4.6 --- src/AcDream.App/UI/Layout/ElementReader.cs | 10 +++++++++- .../UI/Layout/ElementReaderTests.cs | 12 +++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/AcDream.App/UI/Layout/ElementReader.cs b/src/AcDream.App/UI/Layout/ElementReader.cs index e1a5272e..c5087b99 100644 --- a/src/AcDream.App/UI/Layout/ElementReader.cs +++ b/src/AcDream.App/UI/Layout/ElementReader.cs @@ -145,6 +145,11 @@ public static class ElementReader Type = derived.Type != 0 ? derived.Type : base_.Type, X = derived.X, Y = derived.Y, + // NOTE: 0 is the "not set, inherit from base" sentinel for Width/Height. This + // diverges from the format doc §12 rule 2 ("derived W/H win even if zero") but is + // indistinguishable for Plan 1 (all base elements are zero-size Type-12 prototypes). + // If a real zero-size derived element ever needs to override a non-zero base in + // Plan 2, switch Width/Height to float? + null-coalescing (and update Tasks 3-5). Width = derived.Width != 0 ? derived.Width : base_.Width, Height = derived.Height != 0 ? derived.Height : base_.Height, Left = derived.Left, @@ -154,7 +159,10 @@ public static class ElementReader ReadOrder = derived.ReadOrder, FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid, // Children come from the derived element's own tree, not the base prototype's. - Children = derived.Children, + // Defensive copy: prevent a later mutation of either the merged result or the input + // from corrupting the other. Safe for the Task-5 flow (derived.Children is fully + // populated by the recursive importer BEFORE Merge is called and never mutated after). + Children = new List(derived.Children), }; // Start with base StateMedia as defaults, then let derived entries override. m.StateMedia = new Dictionary(base_.StateMedia); diff --git a/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs b/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs index 90b1a995..c489f88c 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs @@ -66,13 +66,11 @@ public class ElementReaderTests } /// - /// Value 3 = floating/centered between both far edges on that axis. - /// Both LeftEdge=3 and RightEdge=3 → neither Left nor Right are set by the - /// near/stretch rules. The result is only Right+Bottom (the "far" semantics). - /// Specifically: left=3 → not Left (3 is not 1 or 4); right=3 → Right (3 is not 2 or 4, skip). - /// Wait — value 3 means "pinned to BOTH far edges" per format doc §4. Re-check the - /// mapping rule: Right anchor fires on right==2 || right==4, NOT on right==3. - /// So value 3 on LeftEdge, TopEdge, RightEdge, BottomEdge → no flags set → default Left|Top. + /// Value 3 = floating/centered between both far edges on that axis (format doc §4). + /// The anchor mapping fires on near-pin (1) and stretch (4) for Left/Top, and on + /// far-pin (2) and stretch (4) for Right/Bottom — value 3 matches none of these rules. + /// Therefore all-3 edge flags contribute no anchor bits and fall through to the + /// Left|Top default (pin top-left, fixed size). /// This test covers that corner case (element 0x100004A9 — expand-detail overlay). /// [Fact] From cc4de3ef77e7e105e371d3feb18774bb8daac584 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 13:29:16 +0200 Subject: [PATCH 35/99] =?UTF-8?q?feat(D.2b):=20UiDatElement=20=E2=80=94=20?= =?UTF-8?q?generic=20per-drawmode=20element=20renderer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generic fallback widget for every LayoutDesc element type without a dedicated behavioral widget (chrome corners/edges, drag bars, resize grips). Holds an ElementInfo + active-state name; draws that state's media by tiling (UV-repeat on both S+T axes, matching ImgTex::TileCSI). DrawMode constants documented per format spec §6 (Undefined=0, Normal=1, Overlay=2, Alphablend=3 — no Stretch mode). Plan 1: all modes render as the same alpha-blended tiled quad; per-mode branches deferred to Plan 2. Co-Authored-By: Claude Sonnet 4.6 --- src/AcDream.App/UI/Layout/UiDatElement.cs | 87 +++++++++++++++++++ .../UI/Layout/UiDatElementTests.cs | 17 ++++ 2 files changed, 104 insertions(+) create mode 100644 src/AcDream.App/UI/Layout/UiDatElement.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs diff --git a/src/AcDream.App/UI/Layout/UiDatElement.cs b/src/AcDream.App/UI/Layout/UiDatElement.cs new file mode 100644 index 00000000..892f053a --- /dev/null +++ b/src/AcDream.App/UI/Layout/UiDatElement.cs @@ -0,0 +1,87 @@ +using System; +using System.Numerics; + +namespace AcDream.App.UI.Layout; + +/// +/// Generic dat element: draws its active state's media by DrawMode (Normal=tile, +/// Alphablend/Overlay=blended overlay). The fallback renderer for every element type +/// without a dedicated behavioral widget (chrome corners/edges, drag bars, resize grips); +/// faithful because retail's base element render is exactly "stamp the media per draw-mode". +/// +/// +/// For Plan 1, all observed draw modes produce the same alpha-blended tiled quad — the +/// sprite shader already alpha-blends, so no per-mode branch is needed here. The named +/// constants document the real enum for Plan 2. +/// +/// +/// +/// DrawModeType (DatReaderWriter.Enums), stored as int in to +/// keep this dat-free. See docs/research/2026-06-15-layoutdesc-format.md §6: +/// Undefined=0, Normal=1, Overlay=2, Alphablend=3. There is no Stretch mode. +/// +/// +/// +/// Tiling uses UV-repeat on BOTH axes (Width/tw, Height/th) so vertical +/// chrome edges (e.g. a 5×10 sprite drawn over a 5×48 rect) tile vertically too. +/// sets +/// GL_REPEAT on both S and T, so vertical tiling is always active. +/// +/// +public sealed class UiDatElement : UiElement +{ + // DrawModeType enum values from DatReaderWriter.Enums. + // See docs/research/2026-06-15-layoutdesc-format.md §6. +#pragma warning disable IDE0051 // private constants kept for documentation / Plan 2 + private const int DrawUndefined = 0; + private const int DrawNormal = 1; + private const int DrawOverlay = 2; + private const int DrawAlphablend = 3; +#pragma warning restore IDE0051 + + private readonly ElementInfo _info; + private readonly Func _resolve; + + /// Which state name to render. "" = the unnamed DirectState. + /// Falls back to DirectState if the named state is absent. + public string ActiveState { get; set; } = ""; + + /// Merged for this element. + /// Dat file-id → (GL texture handle, native px width, native px height). + /// Returns (0,0,0) when the texture is not yet uploaded. + public UiDatElement(ElementInfo info, Func resolve) + { + _info = info; + _resolve = resolve; + ClickThrough = true; // generic decoration; behavioral widgets opt back in + } + + /// + /// Returns the (File, DrawMode) for the current , + /// falling back to the DirectState ("" key) if the named state is absent. + /// Returns (0, 0) if neither exists. + /// + public (uint File, int DrawMode) ActiveMedia() + => _info.StateMedia.TryGetValue(ActiveState, out var m) ? m + : _info.StateMedia.TryGetValue("", out var d) ? d + : (0u, 0); + + protected override void OnDraw(UiRenderContext ctx) + { + var (file, drawMode) = ActiveMedia(); + if (file == 0) return; + + var (tex, tw, th) = _resolve(file); + if (tex == 0 || tw == 0 || th == 0) return; + + // Normal → TILE at native size on both axes (UV-repeat; GL_REPEAT-wrapped UI texture), + // matching ImgTex::TileCSI. Overlay/Alphablend are the same blit with a blend state; the + // sprite shader already alpha-blends, so the quad is identical for all draw modes in Plan 1. + // (No Stretch mode exists in DatReaderWriter.Enums.DrawModeType.) + // drawMode is not yet branched here — Plan 2 can add per-mode behavior if needed. + _ = drawMode; // suppress unused-variable warning until Plan 2 adds per-mode branches + float u1 = Width / tw; + float v1 = Height / th; + ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, u1, v1, Vector4.One); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs b/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs new file mode 100644 index 00000000..91f66d49 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs @@ -0,0 +1,17 @@ +using AcDream.App.UI.Layout; +namespace AcDream.App.Tests.UI.Layout; + +public class UiDatElementTests +{ + [Fact] + public void ActiveMedia_PrefersNamedStateOverDirect() + { + var info = new ElementInfo(); + info.StateMedia[""] = (0x06000001, 1); // DirectState (DrawMode Normal=1) + info.StateMedia["ShowDetail"] = (0x06000002, 3); // named (Alphablend=3) + var e = new UiDatElement(info, _ => (0, 0, 0)) { ActiveState = "ShowDetail" }; + Assert.Equal(0x06000002u, e.ActiveMedia().File); + e.ActiveState = ""; + Assert.Equal(0x06000001u, e.ActiveMedia().File); + } +} From 70dc391c41c94627b5dda6e2f2e618a6b45b9101 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 13:34:50 +0200 Subject: [PATCH 36/99] =?UTF-8?q?test(D.2b):=20UiDatElement=20=E2=80=94=20?= =?UTF-8?q?cover=20DrawMode=20passthrough=20+=20media=20fallbacks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Assert DrawMode values (not just File) in the existing named-vs-direct test - Add ActiveMedia_NoMedia_ReturnsZero: empty StateMedia → (0,0) - Add ActiveMedia_MissingNamedState_FallsBackToDirect: absent named key → DirectState - OnDraw: replace `var (file, drawMode) = ...; _ = drawMode;` with idiomatic `var (file, _) = ...` - Add `// exposed for unit testing` comment above ActiveMedia() Co-Authored-By: Claude Sonnet 4.6 --- src/AcDream.App/UI/Layout/UiDatElement.cs | 6 +++--- .../UI/Layout/UiDatElementTests.cs | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/AcDream.App/UI/Layout/UiDatElement.cs b/src/AcDream.App/UI/Layout/UiDatElement.cs index 892f053a..0da6a067 100644 --- a/src/AcDream.App/UI/Layout/UiDatElement.cs +++ b/src/AcDream.App/UI/Layout/UiDatElement.cs @@ -61,6 +61,7 @@ public sealed class UiDatElement : UiElement /// falling back to the DirectState ("" key) if the named state is absent. /// Returns (0, 0) if neither exists. /// + // exposed for unit testing public (uint File, int DrawMode) ActiveMedia() => _info.StateMedia.TryGetValue(ActiveState, out var m) ? m : _info.StateMedia.TryGetValue("", out var d) ? d @@ -68,7 +69,7 @@ public sealed class UiDatElement : UiElement protected override void OnDraw(UiRenderContext ctx) { - var (file, drawMode) = ActiveMedia(); + var (file, _) = ActiveMedia(); if (file == 0) return; var (tex, tw, th) = _resolve(file); @@ -78,8 +79,7 @@ public sealed class UiDatElement : UiElement // matching ImgTex::TileCSI. Overlay/Alphablend are the same blit with a blend state; the // sprite shader already alpha-blends, so the quad is identical for all draw modes in Plan 1. // (No Stretch mode exists in DatReaderWriter.Enums.DrawModeType.) - // drawMode is not yet branched here — Plan 2 can add per-mode behavior if needed. - _ = drawMode; // suppress unused-variable warning until Plan 2 adds per-mode branches + // DrawMode is not yet branched here — Plan 2 can add per-mode behavior if needed. float u1 = Width / tw; float v1 = Height / th; ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, u1, v1, Vector4.One); diff --git a/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs b/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs index 91f66d49..366f51c0 100644 --- a/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs @@ -11,7 +11,26 @@ public class UiDatElementTests info.StateMedia["ShowDetail"] = (0x06000002, 3); // named (Alphablend=3) var e = new UiDatElement(info, _ => (0, 0, 0)) { ActiveState = "ShowDetail" }; Assert.Equal(0x06000002u, e.ActiveMedia().File); + Assert.Equal(3, e.ActiveMedia().DrawMode); e.ActiveState = ""; Assert.Equal(0x06000001u, e.ActiveMedia().File); + Assert.Equal(1, e.ActiveMedia().DrawMode); + } + + [Fact] + public void ActiveMedia_NoMedia_ReturnsZero() + { + var e = new UiDatElement(new ElementInfo(), _ => (0, 0, 0)); + Assert.Equal(0u, e.ActiveMedia().File); + Assert.Equal(0, e.ActiveMedia().DrawMode); + } + + [Fact] + public void ActiveMedia_MissingNamedState_FallsBackToDirect() + { + var info = new ElementInfo(); + info.StateMedia[""] = (0x06000005, 1); + var e = new UiDatElement(info, _ => (0, 0, 0)) { ActiveState = "NoSuchState" }; + Assert.Equal(0x06000005u, e.ActiveMedia().File); } } From 38855e7a7bdb00f07914aa0baf741e1e58f91259 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 13:39:31 +0200 Subject: [PATCH 37/99] =?UTF-8?q?feat(D.2b):=20DatWidgetFactory=20?= =?UTF-8?q?=E2=80=94=20Type=E2=86=92widget=20hybrid=20+=20meter=20slice=20?= =?UTF-8?q?extraction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hybrid factory mapping ElementInfo.Type to a behavioral widget or the UiDatElement generic fallback. Type 7 (UIElement_Meter) → UiMeter with back/front 3-slice ids populated from grandchild image elements; Type 12 (style prototypes / BaseElement stores) → null so the importer skips them; all other types → UiDatElement. Rect + anchors are set on every returned widget via ElementReader.ToAnchors. BuildMeter walks two levels of the element tree: the two Type-3 slice containers ordered by ReadOrder (back behind, front on top), then within each container the image children that carry a DirectState ("" key) ordered by X for left-cap/center-tile/right-cap. The expand-detail overlay (present in the front container with only named ShowDetail/ HideDetail states and no "" entry) is excluded by the TryGetValue("") filter automatically — no name-matching needed. Fill/Label providers are intentionally NOT set here; Task 6 (VitalsController) binds them to live stat data. 5 TDD tests: Type7→UiMeter, UnknownType→UiDatElement, Type12→null, rect+anchors propagation, and meter slice extraction with overlay exclusion. Co-Authored-By: Claude Sonnet 4.6 --- src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 167 ++++++++++++++++++ .../UI/Layout/DatWidgetFactoryTests.cs | 112 ++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 src/AcDream.App/UI/Layout/DatWidgetFactory.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs new file mode 100644 index 00000000..e8791e44 --- /dev/null +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -0,0 +1,167 @@ +using System; +using System.Linq; + +namespace AcDream.App.UI.Layout; + +/// +/// Hybrid factory: behavioral element Types map to dedicated widgets (verbatim +/// algorithm ports); everything else (and unknown Types) falls back to +/// . +/// +/// +/// Type 12 (style prototype / BaseElement store) is never instantiated — +/// returns null and the importer skips it. +/// See docs/research/2026-06-15-layoutdesc-format.md Correction 8. +/// +/// +/// +/// The meter's back/front 3-slice sprite ids live on grandchild image elements, +/// NOT on the meter element itself (format doc §11). +/// walks two layers down to extract them: the two Type-3 container children +/// ordered by (back behind = lower, front +/// on top = higher), then within each container the image children that carry +/// a DirectState ("" key) sprite, ordered by their X position to obtain +/// left-cap / center-tile / right-cap. +/// +/// +/// +/// The expand-detail overlay present in the front container carries ONLY named +/// states ("HideDetail"/"ShowDetail") — no "" DirectState entry — so the +/// TryGetValue("") filter in excludes it +/// automatically. +/// +/// +public static class DatWidgetFactory +{ + /// + /// Creates the for , sets its + /// rect (Left/Top/Width/Height) and Anchors, and returns it. + /// + /// Resolved, merged element snapshot from the LayoutDesc importer. + /// RenderSurface id → (GL tex handle, pixel width, pixel height). + /// Returns (0,0,0) when the texture is not yet uploaded. + /// Retail UI font for the meter's "cur/max" number overlay. + /// May be null pre-load — the meter falls back to the debug bitmap font. + /// The widget, or null for a Type-12 style prototype (caller skips it). + public static UiElement? Create(ElementInfo info, + Func resolve, UiDatFont? datFont) + { + // Type 12 = zero-size style prototype / BaseElement store referenced by + // BaseLayoutId. These are property bags, never rendered. See format doc §8 + // ("style prototypes are Type 12 which must be skipped") and Correction 8. + if (info.Type == 12) return null; + + UiElement e = info.Type switch + { + 7 => BuildMeter(info, resolve, datFont), // UIElement_Meter + _ => new UiDatElement(info, resolve), // generic fallback for all other types + }; + + // Propagate position + size (pixel-exact from the dat). + e.Left = info.X; + e.Top = info.Y; + e.Width = info.Width; + e.Height = info.Height; + + // Map the four raw edge-anchor values to the AnchorEdges bit-flag that the + // UI layout engine uses for reflow. + e.Anchors = ElementReader.ToAnchors(info.Left, info.Top, info.Right, info.Bottom); + + return e; + } + + // ── Meter ──────────────────────────────────────────────────────────────── + + /// + /// Builds a and populates its six 3-slice sprite ids by + /// reading the meter's grandchild image elements (format doc §11). + /// + /// + /// Structure the importer produces for each meter (UIElement_Meter): + /// + /// meter (Type 7) + /// ├── back-layer container (Type 3, lower ReadOrder — drawn first / behind) + /// │ ├── left-cap image (DirectState "" → File = back-left sprite) + /// │ ├── center image (DirectState "" → File = back-tile sprite) + /// │ └── right-cap image (DirectState "" → File = back-right sprite) + /// ├── front-layer container (Type 3, higher ReadOrder — drawn on top) + /// │ ├── left-cap image (→ front-left sprite) + /// │ ├── center image (→ front-tile sprite) + /// │ ├── right-cap image (→ front-right sprite) + /// │ └── expand overlay (named "ShowDetail"/"HideDetail" only — NO DirectState — IGNORED) + /// └── text label (Type 0) (IGNORED — Fill/Label providers bound by VitalsController in Task 6) + /// + /// + /// + /// + /// and are NOT set here. + /// They are bound to the live stat providers in Task 6 (VitalsController). + /// + /// + private static UiMeter BuildMeter(ElementInfo info, + Func resolve, UiDatFont? datFont) + { + var m = new UiMeter + { + SpriteResolve = resolve, + DatFont = datFont, + }; + + // The two 3-slice containers are Type-3 children of the meter element. + // ReadOrder determines draw order: the back track has a LOWER ReadOrder + // (drawn first, behind the fill), the front has a HIGHER ReadOrder (on top). + var containers = info.Children + .Where(c => c.Type == 3) + .OrderBy(c => c.ReadOrder) + .ToList(); + + if (containers.Count >= 1) + { + var (l, t, r) = SliceIds(containers[0]); + m.BackLeft = l; + m.BackTile = t; + m.BackRight = r; + } + + if (containers.Count >= 2) + { + var (l, t, r) = SliceIds(containers[1]); + m.FrontLeft = l; + m.FrontTile = t; + m.FrontRight = r; + } + + return m; + } + + /// + /// Returns the (left, tile, right) sprite ids for a 3-slice container, + /// extracting them from the container's image children that carry a DirectState + /// ("" key) with a non-zero file id, ordered left-to-right by their X position. + /// + /// + /// Children that carry ONLY named states (e.g. the expand-detail overlay with + /// "ShowDetail"/"HideDetail" entries but no "" key) are excluded automatically + /// because for "" returns + /// false. + /// + /// + private static (uint left, uint tile, uint right) SliceIds(ElementInfo container) + { + // Only children that have a non-zero DirectState image are slice candidates. + // The expand-detail overlay has NO DirectState entry, so it's excluded here. + var slices = container.Children + .Where(c => c.StateMedia.TryGetValue("", out var med) && med.File != 0) + .OrderBy(c => c.X) + .ToList(); + + static uint File(ElementInfo e) + => e.StateMedia.TryGetValue("", out var med) ? med.File : 0u; + + uint left = slices.Count > 0 ? File(slices[0]) : 0u; + uint tile = slices.Count > 1 ? File(slices[1]) : 0u; + uint right = slices.Count > 2 ? File(slices[2]) : 0u; + + return (left, tile, right); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs new file mode 100644 index 00000000..6a1ef9c1 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -0,0 +1,112 @@ +using AcDream.App.UI; +using AcDream.App.UI.Layout; +namespace AcDream.App.Tests.UI.Layout; + +public class DatWidgetFactoryTests +{ + private static (uint, int, int) NoTex(uint _) => (0, 0, 0); + + // ── Test 1: Type 7 → UiMeter ───────────────────────────────────────────── + + [Fact] + public void Type7_Meter_MakesUiMeter() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 7, Width = 150, Height = 16 }, NoTex, null); + Assert.IsType(e); + } + + // ── Test 2: Unknown type → UiDatElement fallback ───────────────────────── + + [Fact] + public void UnknownType_FallsBackToGeneric() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 999 }, NoTex, null); + Assert.IsType(e); + } + + // ── Test 3: Type 12 → null (style prototype, never rendered) ───────────── + + [Fact] + public void Type12_StylePrototype_ReturnsNull() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 12 }, NoTex, null); + Assert.Null(e); + } + + // ── Test 4: Rect + anchors set from ElementInfo ─────────────────────────── + + /// + /// A Type-3 element with X=5,Y=21,W=150,H=16, Left=1,Top=1,Right=2 should have + /// its rect + anchors copied onto the returned widget. + /// Left=1 (near-pin → AnchorEdges.Left), Top=1 (near-pin → AnchorEdges.Top), + /// Right=2 (far-pin → AnchorEdges.Right), Bottom=0 (no anchor → neither). + /// Combined: Left | Top | Right. + /// + [Fact] + public void RectAndAnchors_SetFromElementInfo() + { + var info = new ElementInfo + { + Type = 3, + X = 5, Y = 21, + Width = 150, Height = 16, + Left = 1, Top = 1, + Right = 2, Bottom = 0, + }; + var e = DatWidgetFactory.Create(info, NoTex, null)!; + Assert.Equal(5f, e.Left); + Assert.Equal(21f, e.Top); + Assert.Equal(150f, e.Width); + Assert.Equal(16f, e.Height); + Assert.Equal(AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right, e.Anchors); + } + + // ── Test 5: Meter slice extraction (the important one) ─────────────────── + + /// + /// A meter (Type 7) whose two Type-3 containers each carry 3 image children + /// (ordered by X, bearing a DirectState "" sprite), plus the front container + /// has a fourth expand-overlay child with ONLY a named "ShowDetail" state — + /// that overlay must be excluded from the slice count. + /// + [Fact] + public void MeterSliceExtraction_ReadsGrandchildImageIds_IgnoresOverlay() + { + // Slice ids sourced from format doc §11 — real health-bar ids. + const uint BackL = 0x0600747Eu, BackT = 0x0600747Fu, BackR = 0x06007480u; + const uint FrontL = 0x06007481u, FrontT = 0x06007482u, FrontR = 0x06007483u; + const uint OverlayFile = 0x06007490u; + + // Back container (ReadOrder 0 — drawn first / behind) + var backChild = new ElementInfo { Type = 3, ReadOrder = 0 }; + backChild.Children.Add(new ElementInfo { X = 0, StateMedia = { [""] = (BackL, 1) } }); + backChild.Children.Add(new ElementInfo { X = 10, StateMedia = { [""] = (BackT, 1) } }); + backChild.Children.Add(new ElementInfo { X = 140, StateMedia = { [""] = (BackR, 1) } }); + + // Front container (ReadOrder 1 — drawn on top) + var frontChild = new ElementInfo { Type = 3, ReadOrder = 1 }; + frontChild.Children.Add(new ElementInfo { X = 0, StateMedia = { [""] = (FrontL, 1) } }); + frontChild.Children.Add(new ElementInfo { X = 10, StateMedia = { [""] = (FrontT, 1) } }); + frontChild.Children.Add(new ElementInfo { X = 140, StateMedia = { [""] = (FrontR, 1) } }); + // Expand-detail overlay: named state only — NO DirectState "" — must be ignored. + frontChild.Children.Add(new ElementInfo + { + X = 0, + StateMedia = { ["ShowDetail"] = (OverlayFile, 3) } + }); + + var meter = new ElementInfo { Type = 7, Width = 150, Height = 16 }; + meter.Children.Add(backChild); + meter.Children.Add(frontChild); + + var e = DatWidgetFactory.Create(meter, NoTex, null); + + var m = Assert.IsType(e); + Assert.Equal(BackL, m.BackLeft); + Assert.Equal(BackT, m.BackTile); + Assert.Equal(BackR, m.BackRight); + Assert.Equal(FrontL, m.FrontLeft); + Assert.Equal(FrontT, m.FrontTile); + Assert.Equal(FrontR, m.FrontRight); + } +} From fc79fd519d868d9fe0aa7e871a17405e279a9b3d Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 13:45:38 +0200 Subject: [PATCH 38/99] =?UTF-8?q?refactor(D.2b):=20DatWidgetFactory=20revi?= =?UTF-8?q?ew=20fixes=20=E2=80=94=20single=20lookup=20+=20malformed-meter?= =?UTF-8?q?=20trace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix 1: SliceIds now projects the File id during Select rather than calling TryGetValue twice (once in Where, once in the local File() helper). Added a comment noting that OrderBy is stable so X-tie order follows insertion order. Fix 2: BuildMeter emits a [D.2b] Console.WriteLine when the Type-3 container count is not exactly 2, surfacing malformed or non-vitals meter elements during Task 8 conformance testing without disturbing the existing solid-color fallback. Fix 3: Test 5 adds two explicit NotEqual assertions confirming the ShowDetail-only overlay sprite (OverlayFile = 0x06007490) did not leak into FrontRight or FrontTile. 5/5 tests pass, 0 warnings. Co-Authored-By: Claude Sonnet 4.6 --- src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 17 ++++++++++------- .../UI/Layout/DatWidgetFactoryTests.cs | 3 +++ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index e8791e44..15ba9a85 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -115,6 +115,9 @@ public static class DatWidgetFactory .OrderBy(c => c.ReadOrder) .ToList(); + if (containers.Count != 2) + Console.WriteLine($"[D.2b] meter 0x{info.Id:X8}: {containers.Count} Type-3 slice containers (expected 2) — bars may render as solid-color fallback."); + if (containers.Count >= 1) { var (l, t, r) = SliceIds(containers[0]); @@ -150,17 +153,17 @@ public static class DatWidgetFactory { // Only children that have a non-zero DirectState image are slice candidates. // The expand-detail overlay has NO DirectState entry, so it's excluded here. + // Project the File during filtering to avoid a second TryGetValue lookup. + // Stable sort: on an X tie, original Children insertion order (dat key-sort order) wins. var slices = container.Children .Where(c => c.StateMedia.TryGetValue("", out var med) && med.File != 0) - .OrderBy(c => c.X) + .Select(c => (c.X, File: c.StateMedia[""].File)) + .OrderBy(t => t.X) .ToList(); - static uint File(ElementInfo e) - => e.StateMedia.TryGetValue("", out var med) ? med.File : 0u; - - uint left = slices.Count > 0 ? File(slices[0]) : 0u; - uint tile = slices.Count > 1 ? File(slices[1]) : 0u; - uint right = slices.Count > 2 ? File(slices[2]) : 0u; + uint left = slices.Count > 0 ? slices[0].File : 0u; + uint tile = slices.Count > 1 ? slices[1].File : 0u; + uint right = slices.Count > 2 ? slices[2].File : 0u; return (left, tile, right); } diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index 6a1ef9c1..4258d0b6 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -108,5 +108,8 @@ public class DatWidgetFactoryTests Assert.Equal(FrontL, m.FrontLeft); Assert.Equal(FrontT, m.FrontTile); Assert.Equal(FrontR, m.FrontRight); + // Overlay (ShowDetail-only, no DirectState "") must not leak into any slice slot. + Assert.NotEqual(OverlayFile, m.FrontRight); + Assert.NotEqual(OverlayFile, m.FrontTile); } } From bd01a29eb28e71fcbbac37cdc46772a306761289 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 13:52:50 +0200 Subject: [PATCH 39/99] =?UTF-8?q?feat(D.2b):=20LayoutImporter=20=E2=80=94?= =?UTF-8?q?=20read=20layout=20+=20resolve=20inheritance=20+=20build=20tree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Task 5 of the LayoutDesc Importer (Plan 1 — vitals conformance). Pure layer (BuildFromInfos / Build): - ImportedLayout result type: UiElement root + O(1) FindElement(uint id) lookup - BuildWidget dispatches via DatWidgetFactory.Create; skips Type-12 prototypes (null) - Meters consume their children (DatWidgetFactory already extracted slice ids — adding the dat children as UiElement nodes would duplicate geometry) - All other element types recurse children generically via AddChild Dat shell (Import): - Loads LayoutDesc from dats; null-safe if layout is absent - Resolves each top-level ElementDesc to ElementInfo via Resolve(): BaseElement/BaseLayoutId chain with (layoutId,elementId) cycle guard - ToInfo(): reads ElementDesc scalar fields (uint → float cast) + DirectState + named States (UIStateId.ToString() as key) - ReadState(): extracts first MediaDescImage (File + DrawMode) per state + font DID from Properties[0x1A] → ArrayBaseProperty → DataIdBaseProperty.Value - Each sibling element gets a fresh base-chain set (siblings don't share guards) DRW API: all members confirmed from VitalsLayoutDump.cs usings — no adjustments needed: LayoutDesc in DBObjs; ElementDesc/StateDesc/MediaDescImage/ ArrayBaseProperty/DataIdBaseProperty in Types; DrawModeType/UIStateId in Enums. Tests (3/3 green): - BuildFromInfos_HealthMeter_IsUiMeterAtRect — Type-7 child → UiMeter, Left=5, Width=150 - BuildFromInfos_Type12Child_IsSkipped_Type3Present — prototype absent, container present - BuildFromInfos_MeterWithChildren_MeterPresent_ChildrenNotInTree — meter findable, both dat-children absent, UiMeter.Children empty Co-Authored-By: Claude Sonnet 4.6 --- src/AcDream.App/UI/Layout/LayoutImporter.cs | 278 ++++++++++++++++++ .../UI/Layout/LayoutImporterTests.cs | 105 +++++++ 2 files changed, 383 insertions(+) create mode 100644 src/AcDream.App/UI/Layout/LayoutImporter.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs new file mode 100644 index 00000000..ce3d1ce8 --- /dev/null +++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs @@ -0,0 +1,278 @@ +using System; +using System.Collections.Generic; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; + +namespace AcDream.App.UI.Layout; + +/// +/// The result of importing a retail LayoutDesc: a tree with +/// an O(1) lookup table for finding any element by its dat id. +/// +public sealed class ImportedLayout +{ + /// Root widget of the imported tree. + public UiElement Root { get; } + + private readonly Dictionary _byId; + + public ImportedLayout(UiElement root, Dictionary byId) + { + Root = root; + _byId = byId; + } + + /// Find a widget by its dat element id (e.g. 0x100000E6). + /// Returns null if the id was skipped (Type-12 prototype) or not present. + public UiElement? FindElement(uint id) + => _byId.TryGetValue(id, out var e) ? e : null; +} + +/// +/// Two-layer layout importer for retail LayoutDesc dat objects. +/// +/// +/// Pure layer ( / ): +/// converts a pre-resolved tree into a +/// tree via . Testable without dats or OpenGL — all tests +/// in LayoutImporterTests.cs exercise this layer only. +/// +/// +/// +/// Dat shell (): reads a , +/// converts each top-level to a fully resolved +/// (applying BaseElement / BaseLayoutId +/// inheritance with a cycle guard), then delegates to . +/// +/// +/// +/// Meter elements (Type 7) consume their own dat-children: +/// reads the grandchild slice-sprite ids during construction, so the +/// children must NOT be added as separate nodes in the tree. +/// Every other element type recurses its children generically. +/// +/// +public static class LayoutImporter +{ + // ── Pure layer ──────────────────────────────────────────────────────────── + + /// + /// Convenience for tests: attach to + /// , then call . + /// The children list is set directly on ; + /// any existing children are replaced. + /// + public static ImportedLayout BuildFromInfos( + ElementInfo rootInfo, + IEnumerable children, + Func resolve, + UiDatFont? datFont) + { + rootInfo.Children = new List(children); + return Build(rootInfo, resolve, datFont); + } + + /// + /// Pure builder: produce the widget tree from a fully resolved + /// tree (children already attached). + /// + public static ImportedLayout Build( + ElementInfo rootInfo, + Func resolve, + UiDatFont? datFont) + { + var byId = new Dictionary(); + // Root is never a Type-12 prototype in practice; fall back to a generic + // container if the factory returns null for an exotic root type. + var root = BuildWidget(rootInfo, resolve, datFont, byId) + ?? new UiDatElement(rootInfo, resolve); + return new ImportedLayout(root, byId); + } + + private static UiElement? BuildWidget( + ElementInfo info, + Func resolve, + UiDatFont? datFont, + Dictionary byId) + { + var w = DatWidgetFactory.Create(info, resolve, datFont); + if (w is null) return null; // Type-12 style prototype — skip + + if (info.Id != 0) byId[info.Id] = w; + + // Meters consume their own children: DatWidgetFactory already extracted the + // slice-sprite ids from the grandchild image elements during UiMeter construction. + // Adding those children as separate UiElement nodes would produce duplicate + // geometry and wrong widget semantics. Every other element type recurses normally. + if (w is not UiMeter) + { + foreach (var child in info.Children) + { + var cw = BuildWidget(child, resolve, datFont, byId); + if (cw is not null) w.AddChild(cw); + } + } + + return w; + } + + // ── Dat shell ───────────────────────────────────────────────────────────── + + /// + /// Dat shell: load the LayoutDesc, resolve inheritance for every top-level + /// element, and build the widget tree. Returns null if the layout is absent + /// from the dats. + /// + public static ImportedLayout? Import( + DatCollection dats, + uint layoutId, + Func resolve, + UiDatFont? datFont) + { + var ld = dats.Get(layoutId); + if (ld is null) return null; + + // Build a resolved ElementInfo for every top-level element in the layout. + var tops = new List(); + foreach (var kv in ld.Elements) + tops.Add(Resolve(dats, kv.Value, new HashSet<(uint, uint)>())); + + // If there is exactly one top-level element use it directly as the root; + // otherwise wrap the tops in a synthetic zero-id container. + ElementInfo rootInfo = tops.Count == 1 + ? tops[0] + : new ElementInfo { Id = 0, Type = 3, Children = tops }; + + return Build(rootInfo, resolve, datFont); + } + + // ── Inheritance resolution ──────────────────────────────────────────────── + + /// + /// Converts an to a resolved : + /// reads own fields + media, applies the BaseElement / BaseLayoutId chain + /// (cycle-guarded by ), then resolves + attaches children. + /// + private static ElementInfo Resolve( + DatCollection dats, + ElementDesc d, + HashSet<(uint layoutId, uint elementId)> baseChain) + { + // Read this element's own fields + media (no inheritance, no children yet). + var self = ToInfo(d); + var result = self; + + // Apply BaseElement / BaseLayoutId inheritance if present. + if (d.BaseElement != 0 && d.BaseLayoutId != 0 + && baseChain.Add((d.BaseLayoutId, d.BaseElement))) + { + var baseLd = dats.Get(d.BaseLayoutId); + var baseDesc = baseLd is null ? null : FindDesc(baseLd, d.BaseElement); + if (baseDesc is not null) + { + // Recurse the base chain (already guarded by the HashSet add above). + var baseInfo = Resolve(dats, baseDesc, baseChain); + // Derived fields override the base; result.Children is still empty here + // — children are attached below from the DERIVED element's own tree. + result = ElementReader.Merge(baseInfo, self); + } + } + + // Resolve + attach children. Each child gets a FRESH base-chain set: + // the cycle guard is per-element, not shared across siblings. + foreach (var kv in d.Children) + result.Children.Add(Resolve(dats, kv.Value, new HashSet<(uint, uint)>())); + + return result; + } + + /// + /// Read an 's own scalar fields + state media into a + /// fresh . No inheritance is applied; children are not + /// attached (the caller handles those). + /// + private static ElementInfo ToInfo(ElementDesc d) + { + var info = new ElementInfo + { + Id = d.ElementId, + Type = d.Type, + X = (float)d.X, + Y = (float)d.Y, + Width = (float)d.Width, + Height = (float)d.Height, + Left = d.LeftEdge, + Top = d.TopEdge, + Right = d.RightEdge, + Bottom = d.BottomEdge, + ReadOrder = d.ReadOrder, + }; + + // DirectState (unnamed, key ""). + if (d.StateDesc is not null) + ReadState(d.StateDesc, "", info); + + // Named states (e.g. UIStateId.HideDetail → "HideDetail"). + foreach (var s in d.States) + ReadState(s.Value, s.Key.ToString(), info); + + return info; + } + + /// + /// Read the first from into + /// info.StateMedia[name] and extract the font DID from property 0x1A + /// (ArrayBaseProperty → DataIdBaseProperty) if not yet set. + /// + private static void ReadState(StateDesc sd, string name, ElementInfo info) + { + // First MediaDescImage in this state's Media list wins (format doc §5). + foreach (var m in sd.Media) + { + if (m is MediaDescImage img && img.File != 0) + { + info.StateMedia[name] = (img.File, (int)img.DrawMode); + break; + } + } + + // Font DID: Properties[0x1A] is ArrayBaseProperty{ DataIdBaseProperty }. + // Format doc §3: "ArrayBaseProperty containing ONE DataIdBaseProperty". + if (info.FontDid == 0 && sd.Properties is not null + && sd.Properties.TryGetValue(0x1Au, out var raw) + && raw is ArrayBaseProperty arr && arr.Value.Count > 0 + && arr.Value[0] is DataIdBaseProperty did) + { + info.FontDid = did.Value; + } + } + + // ── Element tree search ─────────────────────────────────────────────────── + + /// + /// Find an by id anywhere in the top-level tree of + /// (depth-first). Returns null if not found. + /// + private static ElementDesc? FindDesc(LayoutDesc ld, uint id) + { + foreach (var kv in ld.Elements) + { + var f = FindDescIn(kv.Value, id); + if (f is not null) return f; + } + return null; + } + + private static ElementDesc? FindDescIn(ElementDesc d, uint id) + { + if (d.ElementId == id) return d; + foreach (var kv in d.Children) + { + var f = FindDescIn(kv.Value, id); + if (f is not null) return f; + } + return null; + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs b/tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs new file mode 100644 index 00000000..2292aab8 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs @@ -0,0 +1,105 @@ +using AcDream.App.UI; +using AcDream.App.UI.Layout; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Pure unit tests for — no dats, no GL. +/// Verifies the tree-builder: widget dispatch, Type-12 skipping, and meter child consumption. +/// +public class LayoutImporterTests +{ + private static (uint, int, int) NoTex(uint _) => (0, 0, 0); + + // ── Test 1: Health meter element → UiMeter with correct rect ───────────── + + /// + /// A Type-7 (meter) child element with X=5,Y=5,W=150,H=16 must produce a UiMeter + /// that is findable by its id, positioned at Left=5, Width=150. + /// The resolve lambda is a 1-arg Func<uint,(uint,int,int)>. + /// + [Fact] + public void BuildFromInfos_HealthMeter_IsUiMeterAtRect() + { + var root = new ElementInfo { Id = 0x100005F9, Type = 3, Width = 160, Height = 58 }; + var health = new ElementInfo { Id = 0x100000E6, Type = 7, X = 5, Y = 5, Width = 150, Height = 16 }; + + var tree = LayoutImporter.BuildFromInfos(root, new[] { health }, NoTex, null); + + var found = tree.FindElement(0x100000E6); + Assert.IsType(found); + Assert.Equal(5f, found!.Left); + Assert.Equal(150f, found.Width); + } + + // ── Test 2: Type-12 child is skipped; Type-3 sibling is present ────────── + + /// + /// A root with two children: one Type-12 style prototype and one Type-3 container. + /// The Type-12 must be absent from the tree (FindElement returns null); + /// the Type-3 must be present. + /// + [Fact] + public void BuildFromInfos_Type12Child_IsSkipped_Type3Present() + { + var root = new ElementInfo { Id = 0x10000001, Type = 3, Width = 160, Height = 58 }; + var prototype = new ElementInfo { Id = 0x20000001, Type = 12, Width = 0, Height = 0 }; + var container = new ElementInfo { Id = 0x20000002, Type = 3, Width = 100, Height = 20 }; + + var tree = LayoutImporter.BuildFromInfos(root, new[] { prototype, container }, NoTex, null); + + // Type-12 must be absent. + Assert.Null(tree.FindElement(0x20000001)); + // Type-3 must be present. + Assert.NotNull(tree.FindElement(0x20000002)); + } + + // ── Test 3: Meter consumes its children — child ids not in byId ────────── + + /// + /// A meter (Type 7) whose children are the 3-slice back/front containers. + /// The meter itself must be findable; its direct children must NOT appear as + /// separate nodes in the tree (meters own their children, not the generic tree). + /// + [Fact] + public void BuildFromInfos_MeterWithChildren_MeterPresent_ChildrenNotInTree() + { + const uint MeterId = 0x100000E6u; + const uint BackLayerId = 0x100000E7u; + const uint FrontLayerId = 0x00000002u; + + // Build a minimal meter with back + front containers, each with 3 slice children. + var backContainer = BuildSliceContainer(BackLayerId, ReadOrder: 0, + l: 0x0600747Eu, t: 0x0600747Fu, r: 0x06007480u); + var frontContainer = BuildSliceContainer(FrontLayerId, ReadOrder: 1, + l: 0x06007481u, t: 0x06007482u, r: 0x06007483u); + + var meter = new ElementInfo { Id = MeterId, Type = 7, Width = 150, Height = 16 }; + meter.Children.Add(backContainer); + meter.Children.Add(frontContainer); + + var root = new ElementInfo { Id = 0x100005F9, Type = 3, Width = 160, Height = 58 }; + + var tree = LayoutImporter.BuildFromInfos(root, new[] { meter }, NoTex, null); + + // The meter widget is present. + Assert.IsType(tree.FindElement(MeterId)); + // The meter's dat-children are NOT separate UiElement nodes. + Assert.Null(tree.FindElement(BackLayerId)); + Assert.Null(tree.FindElement(FrontLayerId)); + // The UiMeter itself has no Ui children (meters consume their children internally). + var uiMeter = (UiMeter)tree.FindElement(MeterId)!; + Assert.Empty(uiMeter.Children); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static ElementInfo BuildSliceContainer(uint id, uint ReadOrder, uint l, uint t, uint r) + { + var c = new ElementInfo { Id = id, Type = 3, ReadOrder = ReadOrder }; + c.Children.Add(new ElementInfo { X = 0, StateMedia = { [""] = (l, 1) } }); + c.Children.Add(new ElementInfo { X = 10, StateMedia = { [""] = (t, 1) } }); + c.Children.Add(new ElementInfo { X = 140, StateMedia = { [""] = (r, 1) } }); + return c; + } +} From 9a55a688caf79dc4db7157b29e0225622e764888 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 14:03:24 +0200 Subject: [PATCH 40/99] =?UTF-8?q?refactor(D.2b):=20LayoutImporter=20review?= =?UTF-8?q?=20fixes=20=E2=80=94=20root-fallback=20trace=20+=20cursor-disca?= =?UTF-8?q?rd=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/AcDream.App/UI/Layout/LayoutImporter.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs index ce3d1ce8..2b9c8411 100644 --- a/src/AcDream.App/UI/Layout/LayoutImporter.cs +++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs @@ -86,8 +86,12 @@ public static class LayoutImporter var byId = new Dictionary(); // Root is never a Type-12 prototype in practice; fall back to a generic // container if the factory returns null for an exotic root type. - var root = BuildWidget(rootInfo, resolve, datFont, byId) - ?? new UiDatElement(rootInfo, resolve); + var root = BuildWidget(rootInfo, resolve, datFont, byId); + if (root is null) + { + Console.WriteLine($"[D.2b] LayoutImporter: root element 0x{rootInfo.Id:X8} (type {rootInfo.Type}) produced no widget — using empty container fallback."); + root = new UiDatElement(rootInfo, resolve); + } return new ImportedLayout(root, byId); } @@ -228,7 +232,8 @@ public static class LayoutImporter /// private static void ReadState(StateDesc sd, string name, ElementInfo info) { - // First MediaDescImage in this state's Media list wins (format doc §5). + // Only MediaDescImage is read for rendering; MediaDescCursor items (on grips/drag bars) + // are intentionally skipped — cursor behavior is Plan 2. foreach (var m in sd.Media) { if (m is MediaDescImage img && img.File != 0) From 9d2527d9c8f40259c9544e8928b2a39d32e9b502 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 14:06:14 +0200 Subject: [PATCH 41/99] =?UTF-8?q?feat(D.2b):=20VitalsController=20?= =?UTF-8?q?=E2=80=94=20bind=20live=20vitals=20data=20by=20element=20id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors retail gmVitalsUI::PostInit: grab Health/Stamina/Mana meters from the imported layout by their dat element ids (0x100000E6 / EC / EE) and wire Func fill + Func label providers. Missing ids are silently skipped (no throw). Slice sprites + dat font already set by the factory — this is pure data wiring, not graphics. 3 TDD tests: single-meter fill+label, all-three distinct providers, missing-id no-throw. Co-Authored-By: Claude Sonnet 4.6 --- src/AcDream.App/UI/Layout/VitalsController.cs | 64 +++++++++++ .../UI/Layout/VitalsBindingTests.cs | 102 ++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 src/AcDream.App/UI/Layout/VitalsController.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs diff --git a/src/AcDream.App/UI/Layout/VitalsController.cs b/src/AcDream.App/UI/Layout/VitalsController.cs new file mode 100644 index 00000000..a455761a --- /dev/null +++ b/src/AcDream.App/UI/Layout/VitalsController.cs @@ -0,0 +1,64 @@ +using System; + +namespace AcDream.App.UI.Layout; + +/// +/// Per-window controller for the vitals layout (LayoutDesc 0x2100006C). +/// Mirrors retail gmVitalsUI::PostInit: grab the three meter elements +/// by their dat element ids and bind live data providers (fill fraction + cur/max +/// text) to each. This is the ONLY per-window code in the whole importer — pure +/// data wiring, not graphics. +/// +/// The slice sprites + dat font on each are already +/// set by during tree construction; this controller +/// only binds the dynamic vitals data. Do not touch meter rendering fields here. +/// +public static class VitalsController +{ + /// Dat element id for the Health meter (0x100000E6). + public const uint Health = 0x100000E6; + /// Dat element id for the Stamina meter (0x100000EC). + public const uint Stamina = 0x100000EC; + /// Dat element id for the Mana meter (0x100000EE). + public const uint Mana = 0x100000EE; + + /// + /// Bind live vitals data providers to the Health, Stamina, and Mana meter + /// elements found in . Any meter whose id is absent + /// from the layout is silently skipped — partial layouts (e.g. test fakes) + /// do not cause errors. + /// + /// Imported vitals layout tree. + /// Provider returning Health fill fraction [0..1]. + /// Provider returning Stamina fill fraction [0..1]. + /// Provider returning Mana fill fraction [0..1]. + /// Provider returning Health "cur/max" overlay text. + /// Provider returning Stamina "cur/max" overlay text. + /// Provider returning Mana "cur/max" overlay text. + public static void Bind( + ImportedLayout layout, + Func healthPct, + Func staminaPct, + Func manaPct, + Func healthText, + Func staminaText, + Func manaText) + { + BindMeter(layout, Health, healthPct, healthText); + BindMeter(layout, Stamina, staminaPct, staminaText); + BindMeter(layout, Mana, manaPct, manaText); + } + + private static void BindMeter( + ImportedLayout layout, uint id, + Func pct, + Func text) + { + if (layout.FindElement(id) is UiMeter m) + { + m.Fill = () => pct(); + m.Label = () => text(); + } + // Silently skip if the id is absent — missing meters are not an error. + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs b/tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs new file mode 100644 index 00000000..8b430265 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs @@ -0,0 +1,102 @@ +using AcDream.App.UI; +using AcDream.App.UI.Layout; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Unit tests for : verifies that the controller +/// correctly maps element ids to UiMeter instances and wires the Fill / Label providers. +/// No dats, no GL — pure data-wiring tests. +/// +public class VitalsBindingTests +{ + // ── Test 1: Health meter Fill + Label providers are bound ───────────────── + + [Fact] + public void Bind_SetsHealthMeterFillFromProvider() + { + var health = new UiMeter(); + var layout = FakeLayout(("0x100000E6", health)); + float hp = 0.42f; + + VitalsController.Bind(layout, + healthPct: () => hp, + staminaPct: () => 1f, + manaPct: () => 1f, + healthText: () => "42/100", + staminaText: () => "", + manaText: () => ""); + + Assert.Equal(0.42f, health.Fill()!.Value); + Assert.Equal("42/100", health.Label()); + } + + // ── Test 2: All three meters wired to distinct providers ────────────────── + + [Fact] + public void Bind_AllThreeMeters_EachBoundToOwnProvider() + { + var health = new UiMeter(); + var stamina = new UiMeter(); + var mana = new UiMeter(); + var layout = FakeLayout( + ("0x100000E6", health), + ("0x100000EC", stamina), + ("0x100000EE", mana)); + + VitalsController.Bind(layout, + healthPct: () => 0.25f, + staminaPct: () => 0.50f, + manaPct: () => 0.75f, + healthText: () => "25/100", + staminaText: () => "50/100", + manaText: () => "75/100"); + + // Each meter should reflect its own provider, not another's. + Assert.Equal(0.25f, health.Fill()!.Value); + Assert.Equal("25/100", health.Label()); + + Assert.Equal(0.50f, stamina.Fill()!.Value); + Assert.Equal("50/100", stamina.Label()); + + Assert.Equal(0.75f, mana.Fill()!.Value); + Assert.Equal("75/100", mana.Label()); + } + + // ── Test 3: Missing meter ids are silently skipped (no throw) ───────────── + + [Fact] + public void Bind_MissingMeterIds_DoesNotThrow() + { + // Only Health is present; Stamina and Mana are absent from the layout. + var health = new UiMeter(); + var layout = FakeLayout(("0x100000E6", health)); + + // Should not throw even though Stamina/Mana are missing. + VitalsController.Bind(layout, + healthPct: () => 1f, + staminaPct: () => 1f, + manaPct: () => 1f, + healthText: () => "100/100", + staminaText: () => "100/100", + manaText: () => "100/100"); + + // Health was present — it should be wired. + Assert.Equal(1f, health.Fill()!.Value); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static ImportedLayout FakeLayout(params (string idHex, UiElement e)[] items) + { + var dict = new Dictionary(); + var root = new UiPanel(); + foreach (var (idHex, e) in items) + { + uint id = Convert.ToUInt32(idHex, 16); + root.AddChild(e); + dict[id] = e; + } + return new ImportedLayout(root, dict); + } +} From 7e56eff88474ceb0d4906610fb5a7724f60fab7c Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 14:10:17 +0200 Subject: [PATCH 42/99] =?UTF-8?q?refactor(D.2b):=20VitalsController=20revi?= =?UTF-8?q?ew=20fixes=20=E2=80=94=20cite=20format=20doc=20+=20use=20consts?= =?UTF-8?q?=20in=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix 1: Added a to the VitalsController class summary citing docs/research/2026-06-15-layoutdesc-format.md §11 as the source of the three dat element ids, giving a paper trail back to the evidence per the project's cite-in-comments rule. Fix 2: Changed FakeLayout in VitalsBindingTests to accept (uint id, UiElement e) tuples instead of (string idHex, UiElement e), and updated all three call sites to pass VitalsController.Health/.Stamina/.Mana. Tests now follow the constants automatically if they ever change rather than silently passing with stale hex literals. Co-Authored-By: Claude Sonnet 4.6 --- src/AcDream.App/UI/Layout/VitalsController.cs | 4 ++++ .../UI/Layout/VitalsBindingTests.cs | 15 +++++++-------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/AcDream.App/UI/Layout/VitalsController.cs b/src/AcDream.App/UI/Layout/VitalsController.cs index a455761a..c570fb34 100644 --- a/src/AcDream.App/UI/Layout/VitalsController.cs +++ b/src/AcDream.App/UI/Layout/VitalsController.cs @@ -12,6 +12,10 @@ namespace AcDream.App.UI.Layout; /// The slice sprites + dat font on each are already /// set by during tree construction; this controller /// only binds the dynamic vitals data. Do not touch meter rendering fields here. +/// +/// Element ids confirmed from +/// docs/research/2026-06-15-layoutdesc-format.md §11 +/// (vitals window 0x2100006C dump). /// public static class VitalsController { diff --git a/tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs b/tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs index 8b430265..133d51ca 100644 --- a/tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs @@ -16,7 +16,7 @@ public class VitalsBindingTests public void Bind_SetsHealthMeterFillFromProvider() { var health = new UiMeter(); - var layout = FakeLayout(("0x100000E6", health)); + var layout = FakeLayout((VitalsController.Health, health)); float hp = 0.42f; VitalsController.Bind(layout, @@ -40,9 +40,9 @@ public class VitalsBindingTests var stamina = new UiMeter(); var mana = new UiMeter(); var layout = FakeLayout( - ("0x100000E6", health), - ("0x100000EC", stamina), - ("0x100000EE", mana)); + (VitalsController.Health, health), + (VitalsController.Stamina, stamina), + (VitalsController.Mana, mana)); VitalsController.Bind(layout, healthPct: () => 0.25f, @@ -70,7 +70,7 @@ public class VitalsBindingTests { // Only Health is present; Stamina and Mana are absent from the layout. var health = new UiMeter(); - var layout = FakeLayout(("0x100000E6", health)); + var layout = FakeLayout((VitalsController.Health, health)); // Should not throw even though Stamina/Mana are missing. VitalsController.Bind(layout, @@ -87,13 +87,12 @@ public class VitalsBindingTests // ── Helpers ─────────────────────────────────────────────────────────────── - private static ImportedLayout FakeLayout(params (string idHex, UiElement e)[] items) + private static ImportedLayout FakeLayout(params (uint id, UiElement e)[] items) { var dict = new Dictionary(); var root = new UiPanel(); - foreach (var (idHex, e) in items) + foreach (var (id, e) in items) { - uint id = Convert.ToUInt32(idHex, 16); root.AddChild(e); dict[id] = e; } From e8ddb6880163e5d5ca9769eff3889d86d9a8e80e Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 14:14:28 +0200 Subject: [PATCH 43/99] =?UTF-8?q?feat(D.2b):=20factory=20propagates=20Read?= =?UTF-8?q?Order=E2=86=92ZOrder=20for=20faithful=20draw=20layering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 3 +++ .../UI/Layout/DatWidgetFactoryTests.cs | 11 ++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 15ba9a85..059ee654 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -63,6 +63,9 @@ public static class DatWidgetFactory e.Width = info.Width; e.Height = info.Height; + // Honor the dat's draw order so overlapping pieces (grip overlay over bevel chrome) layer correctly. + e.ZOrder = (int)info.ReadOrder; + // Map the four raw edge-anchor values to the AnchorEdges bit-flag that the // UI layout engine uses for reflow. e.Anchors = ElementReader.ToAnchors(info.Left, info.Top, info.Right, info.Bottom); diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index 4258d0b6..c2a66de1 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -61,7 +61,16 @@ public class DatWidgetFactoryTests Assert.Equal(AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right, e.Anchors); } - // ── Test 5: Meter slice extraction (the important one) ─────────────────── + // ── Test 5: ReadOrder propagated to ZOrder ─────────────────────────────── + + [Fact] + public void Create_PropagatesReadOrderToZOrder() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 3, ReadOrder = 7 }, NoTex, null); + Assert.Equal(7, e!.ZOrder); + } + + // ── Test 6: Meter slice extraction (the important one) ─────────────────── /// /// A meter (Type 7) whose two Type-3 containers each carry 3 image children From ab3ab793807e314619e2c9b997211865210c07d1 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 14:18:16 +0200 Subject: [PATCH 44/99] feat(D.2b): run importer-built vitals window under ACDREAM_RETAIL_UI_IMPORTER (A/B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds RuntimeOptions.RetailUiImporter (ACDREAM_RETAIL_UI_IMPORTER=1) — a new opt-in flag that runs the LayoutImporter-built vitals window ALONGSIDE the hand-authored vitals panel for pixel-for-pixel A/B comparison. The importer window is placed at x=200, y=30 so both render simultaneously within the same ACDREAM_RETAIL_UI=1 session. The hand-authored path is entirely untouched and remains the default; the importer path is the eventual switch-over target. Also adds two RuntimeOptionsRetailUiTests covering the new flag: value "1" → true, unset/other → false. Co-Authored-By: Claude Sonnet 4.6 --- src/AcDream.App/Rendering/GameWindow.cs | 30 +++++++++++++++++++ src/AcDream.App/RuntimeOptions.cs | 2 ++ .../RuntimeOptionsRetailUiTests.cs | 25 ++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 2e26a360..1944137d 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1794,6 +1794,36 @@ public sealed class GameWindow : IDisposable _uiHost.Root.AddChild(panel); Console.WriteLine("[D.2b] retail UI active — vitals panel from vitals.xml markup."); + // Phase D.2b — LayoutDesc importer A/B harness. When ACDREAM_RETAIL_UI_IMPORTER=1, + // build the SAME vitals window (0x2100006C) data-driven from the dat and place it beside + // the hand-authored one so the two can be compared pixel-for-pixel before the importer + // becomes the default. The hand-authored path above is untouched. + if (_options.RetailUiImporter) + { + AcDream.App.UI.Layout.ImportedLayout? imported; + lock (_datLock) + imported = AcDream.App.UI.Layout.LayoutImporter.Import( + _dats!, 0x2100006Cu, ResolveChrome, vitalsDatFont); + if (imported is not null) + { + AcDream.App.UI.Layout.VitalsController.Bind(imported, + healthPct: () => _vitalsVm!.HealthPercent, + staminaPct: () => _vitalsVm!.StaminaPercent ?? 0f, + manaPct: () => _vitalsVm!.ManaPercent ?? 0f, + healthText: () => (_vitalsVm!.HealthCurrent, _vitalsVm.HealthMax) is (uint c, uint m) ? $"{c}/{m}" : "", + staminaText: () => (_vitalsVm!.StaminaCurrent, _vitalsVm.StaminaMax) is (uint c, uint m) ? $"{c}/{m}" : "", + manaText: () => (_vitalsVm!.ManaCurrent, _vitalsVm.ManaMax) is (uint c, uint m) ? $"{c}/{m}" : ""); + // Offset beside the hand-authored window (at x=10) for an A/B visual comparison. + imported.Root.Left = 200; imported.Root.Top = 30; + _uiHost.Root.AddChild(imported.Root); + Console.WriteLine("[D.2b] importer vitals window active (A/B vs hand-authored)."); + } + else + { + Console.WriteLine("[D.2b] importer vitals: LayoutDesc 0x2100006C not found."); + } + } + // Retail chat window — a draggable/resizable nine-slice frame hosting a // scrollable transcript (UiChatView). Read-only + wheel-scroll for now; // drag-select + Ctrl+C copy land in the next D.2b sub-step. A dedicated diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index 9be7601d..bff1f885 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -41,6 +41,7 @@ public sealed record RuntimeOptions( bool DumpLiveSpawns, int? LegacyStreamRadius, bool RetailUi, + bool RetailUiImporter, string? AcDir) { /// @@ -85,6 +86,7 @@ public sealed record RuntimeOptions( // top of the quality preset's radii. Null when unset or invalid. LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")), RetailUi: IsExactlyOne(env("ACDREAM_RETAIL_UI")), + RetailUiImporter: IsExactlyOne(env("ACDREAM_RETAIL_UI_IMPORTER")), AcDir: NullIfEmpty(env("ACDREAM_AC_DIR"))); } diff --git a/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs index b18590ae..9c6b88a2 100644 --- a/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs +++ b/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs @@ -25,4 +25,29 @@ public class RuntimeOptionsRetailUiTests Assert.False(opts.RetailUi); Assert.Null(opts.AcDir); } + + [Fact] + public void Parse_ReadsRetailUiImporter_WhenSetToOne() + { + var env = new Dictionary + { + ["ACDREAM_RETAIL_UI_IMPORTER"] = "1", + }; + var opts = RuntimeOptions.Parse("dats", k => env.GetValueOrDefault(k)); + Assert.True(opts.RetailUiImporter); + } + + [Fact] + public void Parse_DefaultsRetailUiImporterOff_WhenUnsetOrOtherValue() + { + // Unset → false. + Assert.False(RuntimeOptions.Parse("dats", _ => null).RetailUiImporter); + + // Non-"1" values → false (mirrors RetailUi / other IsExactlyOne flags). + var envOther = new Dictionary + { + ["ACDREAM_RETAIL_UI_IMPORTER"] = "true", + }; + Assert.False(RuntimeOptions.Parse("dats", k => envOther.GetValueOrDefault(k)).RetailUiImporter); + } } From 25be30b1a7d15d168c00a24ec97153e9454c3db0 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 14:23:01 +0200 Subject: [PATCH 45/99] style(D.2b): split two-statement line in importer wiring (review nit) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 1944137d..96c63ceb 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1814,7 +1814,8 @@ public sealed class GameWindow : IDisposable staminaText: () => (_vitalsVm!.StaminaCurrent, _vitalsVm.StaminaMax) is (uint c, uint m) ? $"{c}/{m}" : "", manaText: () => (_vitalsVm!.ManaCurrent, _vitalsVm.ManaMax) is (uint c, uint m) ? $"{c}/{m}" : ""); // Offset beside the hand-authored window (at x=10) for an A/B visual comparison. - imported.Root.Left = 200; imported.Root.Top = 30; + imported.Root.Left = 200; + imported.Root.Top = 30; _uiHost.Root.AddChild(imported.Root); Console.WriteLine("[D.2b] importer vitals window active (A/B vs hand-authored)."); } From 3567135a044fbf9fc60e70f3fc817ca7bdf0d70e Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 14:29:30 +0200 Subject: [PATCH 46/99] =?UTF-8?q?test(D.2b):=20vitals=20importer=20conform?= =?UTF-8?q?ance=20=E2=80=94=20golden=20fixture=20+=20tree/slice/chrome=20c?= =?UTF-8?q?hecks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Job 1: extract LayoutImporter.ImportInfos() (public dat-shell half that returns the resolved ElementInfo tree without building widgets) so fixture generation and conformance tests can call it directly. Import() now delegates to ImportInfos() + Build() — existing 32 Layout tests stay green. Job 2: generate tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json from the real portal.dat via a throwaway [Fact] generator (deleted, not committed). System.Text.Json with IncludeFields=true — ValueTuple serializes as Item1/Item2. Pre-write validation confirmed health meter BackLeft=0x0600747E FrontRight=0x06007483 rect (5,5,150,16). Round-trip deserialization re-validated before writing. Job 3: FixtureLoader.LoadVitals() deserializes the fixture from the test output directory (CopyToOutputDirectory item in csproj) and returns ImportedLayout via LayoutImporter.Build(root, _ => (0,0,0), null) — no dats, no GL. Job 4: LayoutConformanceTests — 3 golden tests (35 asserts total): - VitalsTree_HasThreeMetersAtExpectedRects: 3 meters at x=5, w=150, h=16, y=5/21/37 - VitalsTree_MetersHaveExpectedSliceIds: all 18 back+front slice ids health/stamina/mana - VitalsTree_ChromeCornerHasExpectedSprite: TL corner 0x10000633 → sprite 0x060074C3 Full App suite: 326 pass / 1 skip (pre-existing) / 0 fail. Build: 0 errors, 0 warnings. Throwaway generator not committed (confirmed via git status). Co-Authored-By: Claude Sonnet 4.6 --- src/AcDream.App/UI/Layout/LayoutImporter.cs | 35 +- .../AcDream.App.Tests.csproj | 6 + .../UI/Layout/FixtureLoader.cs | 38 + .../UI/Layout/LayoutConformanceTests.cs | 115 ++ .../UI/Layout/fixtures/vitals_2100006C.json | 1058 +++++++++++++++++ 5 files changed, 1238 insertions(+), 14 deletions(-) create mode 100644 tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs index 2b9c8411..0bf2b2bd 100644 --- a/src/AcDream.App/UI/Layout/LayoutImporter.cs +++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs @@ -124,6 +124,25 @@ public static class LayoutImporter // ── Dat shell ───────────────────────────────────────────────────────────── + /// + /// Dat shell, ElementInfo half: load the layout + resolve inheritance + build the + /// ElementInfo tree (no widgets). Exposed for fixture generation + conformance tests. + /// Returns null if the layout is missing. + /// + public static ElementInfo? ImportInfos(DatCollection dats, uint layoutId) + { + var ld = dats.Get(layoutId); + if (ld is null) return null; + + var tops = new List(); + foreach (var kv in ld.Elements) + tops.Add(Resolve(dats, kv.Value, new HashSet<(uint, uint)>())); + + return tops.Count == 1 + ? tops[0] + : new ElementInfo { Id = 0, Type = 3, Children = tops }; + } + /// /// Dat shell: load the LayoutDesc, resolve inheritance for every top-level /// element, and build the widget tree. Returns null if the layout is absent @@ -135,20 +154,8 @@ public static class LayoutImporter Func resolve, UiDatFont? datFont) { - var ld = dats.Get(layoutId); - if (ld is null) return null; - - // Build a resolved ElementInfo for every top-level element in the layout. - var tops = new List(); - foreach (var kv in ld.Elements) - tops.Add(Resolve(dats, kv.Value, new HashSet<(uint, uint)>())); - - // If there is exactly one top-level element use it directly as the root; - // otherwise wrap the tops in a synthetic zero-id container. - ElementInfo rootInfo = tops.Count == 1 - ? tops[0] - : new ElementInfo { Id = 0, Type = 3, Children = tops }; - + var rootInfo = ImportInfos(dats, layoutId); + if (rootInfo is null) return null; return Build(rootInfo, resolve, datFont); } diff --git a/tests/AcDream.App.Tests/AcDream.App.Tests.csproj b/tests/AcDream.App.Tests/AcDream.App.Tests.csproj index 5ab79928..272953e3 100644 --- a/tests/AcDream.App.Tests/AcDream.App.Tests.csproj +++ b/tests/AcDream.App.Tests/AcDream.App.Tests.csproj @@ -22,4 +22,10 @@ + + + PreserveNewest + + + diff --git a/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs b/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs new file mode 100644 index 00000000..de0bd06a --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs @@ -0,0 +1,38 @@ +using System.IO; +using System.Text.Json; +using AcDream.App.UI.Layout; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Loads the committed vitals ElementInfo fixture and builds the widget tree — +/// no dats required. The fixture was generated from layout 0x2100006C +/// via the real portal.dat and serialized with . +/// +public static class FixtureLoader +{ + private static readonly JsonSerializerOptions _opts = new() + { + IncludeFields = true, + }; + + /// + /// Deserializes the committed vitals_2100006C.json fixture (copied to + /// the test output directory via the csproj CopyToOutputDirectory item) + /// into an tree, then builds and returns the + /// using a null-returning sprite resolver and no + /// dat font — sufficient for conformance checks on tree structure and slice ids. + /// + public static ImportedLayout LoadVitals() + { + var fixturePath = Path.Combine(AppContext.BaseDirectory, "UI", "Layout", "fixtures", "vitals_2100006C.json"); + if (!File.Exists(fixturePath)) + throw new FileNotFoundException($"Vitals fixture not found at: {fixturePath}"); + + var json = File.ReadAllText(fixturePath, System.Text.Encoding.UTF8); + var root = JsonSerializer.Deserialize(json, _opts) + ?? throw new InvalidOperationException("Failed to deserialize vitals fixture."); + + return LayoutImporter.Build(root, _ => (0u, 0, 0), null); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs new file mode 100644 index 00000000..d1ec93e2 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs @@ -0,0 +1,115 @@ +using AcDream.App.UI; +using AcDream.App.UI.Layout; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Golden conformance tests for the vitals LayoutDesc importer. +/// Uses the committed JSON fixture (vitals_2100006C.json) — no dats, no GL. +/// +/// These tests lock the importer's tree-building (factory dispatch, meter slice +/// extraction, rects) against the real portal.dat values captured when the +/// fixture was generated. Any regression in , +/// , or will surface here. +/// +/// Sprite ids sourced from docs/research/2026-06-15-layoutdesc-format.md §11. +/// +public class LayoutConformanceTests +{ + // ── Test 1: Three meters at expected rects ──────────────────────────────── + + /// + /// The three vital bars must be UiMeters positioned at x=5, width=150, height=16, + /// at y=5 (health), y=21 (stamina), y=37 (mana). + /// + [Fact] + public void VitalsTree_HasThreeMetersAtExpectedRects() + { + var layout = FixtureLoader.LoadVitals(); + + (uint Id, float Y)[] expected = + [ + (0x100000E6u, 5f), // health + (0x100000ECu, 21f), // stamina + (0x100000EEu, 37f), // mana + ]; + + foreach (var (id, y) in expected) + { + var elem = layout.FindElement(id); + Assert.NotNull(elem); + var meter = Assert.IsType(elem); + Assert.Equal(5f, meter.Left); + Assert.Equal(y, meter.Top); + Assert.Equal(150f, meter.Width); + Assert.Equal(16f, meter.Height); + } + } + + // ── Test 2: All 18 slice ids ────────────────────────────────────────────── + + /// + /// The six back+front 3-slice sprite ids for each of the three meters must + /// match the values confirmed from the dat dump (format doc §11). + /// This proves the factory's grandchild slice extraction against committed data. + /// + [Fact] + public void VitalsTree_MetersHaveExpectedSliceIds() + { + var layout = FixtureLoader.LoadVitals(); + + // Health bar + { + var elem = layout.FindElement(0x100000E6u); + var m = Assert.IsType(elem); + Assert.Equal(0x0600747Eu, m.BackLeft); + Assert.Equal(0x0600747Fu, m.BackTile); + Assert.Equal(0x06007480u, m.BackRight); + Assert.Equal(0x06007481u, m.FrontLeft); + Assert.Equal(0x06007482u, m.FrontTile); + Assert.Equal(0x06007483u, m.FrontRight); + } + + // Stamina bar + { + var elem = layout.FindElement(0x100000ECu); + var m = Assert.IsType(elem); + Assert.Equal(0x06007484u, m.BackLeft); + Assert.Equal(0x06007485u, m.BackTile); + Assert.Equal(0x06007486u, m.BackRight); + Assert.Equal(0x06007487u, m.FrontLeft); + Assert.Equal(0x06007488u, m.FrontTile); + Assert.Equal(0x06007489u, m.FrontRight); + } + + // Mana bar + { + var elem = layout.FindElement(0x100000EEu); + var m = Assert.IsType(elem); + Assert.Equal(0x0600748Au, m.BackLeft); + Assert.Equal(0x0600748Bu, m.BackTile); + Assert.Equal(0x0600748Cu, m.BackRight); + Assert.Equal(0x0600748Du, m.FrontLeft); + Assert.Equal(0x0600748Eu, m.FrontTile); + Assert.Equal(0x0600748Fu, m.FrontRight); + } + } + + // ── Test 3: Chrome TL corner sprite ─────────────────────────────────────── + + /// + /// The top-left chrome corner element (id 0x10000633) must be a + /// whose active media file id is 0x060074C3. + /// + [Fact] + public void VitalsTree_ChromeCornerHasExpectedSprite() + { + var layout = FixtureLoader.LoadVitals(); + + var elem = layout.FindElement(0x10000633u); + Assert.NotNull(elem); + var datElem = Assert.IsType(elem); + var (file, _) = datElem.ActiveMedia(); + Assert.Equal(0x060074C3u, file); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json b/tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json new file mode 100644 index 00000000..ff372638 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json @@ -0,0 +1,1058 @@ +{ + "Id": 268436985, + "Type": 268435533, + "X": 0, + "Y": 0, + "Width": 160, + "Height": 58, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 2, + "ReadOrder": 0, + "FontDid": 0, + "StateMedia": {}, + "Children": [ + { + "Id": 268437048, + "Type": 3, + "X": 5, + "Y": 53, + "Width": 150, + "Height": 5, + "Left": 1, + "Top": 2, + "Right": 1, + "Bottom": 1, + "ReadOrder": 6, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693185, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435692, + "Type": 7, + "X": 5, + "Y": 21, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 18, + "FontDid": 0, + "StateMedia": {}, + "Children": [ + { + "Id": 268435693, + "Type": 12, + "X": 0, + "Y": 0, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 3, + "FontDid": 1073741824, + "StateMedia": {}, + "Children": [] + }, + { + "Id": 2, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": {}, + "Children": [ + { + "Id": 268436649, + "Type": 3, + "X": 32, + "Y": 0, + "Width": 85, + "Height": 28, + "Left": 3, + "Top": 1, + "Right": 3, + "Bottom": 1, + "ReadOrder": 4, + "FontDid": 0, + "StateMedia": { + "ShowDetail": { + "Item1": 100693139, + "Item2": 3 + } + }, + "Children": [] + }, + { + "Id": 268435688, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693127, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435689, + "Type": 3, + "X": 10, + "Y": 0, + "Width": 130, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693128, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435690, + "Type": 3, + "X": 140, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 3, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693129, + "Item2": 1 + } + }, + "Children": [] + } + ] + }, + { + "Id": 268435687, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": {}, + "Children": [ + { + "Id": 268436649, + "Type": 3, + "X": 32, + "Y": 0, + "Width": 85, + "Height": 16, + "Left": 3, + "Top": 1, + "Right": 3, + "Bottom": 1, + "ReadOrder": 4, + "FontDid": 0, + "StateMedia": { + "ShowDetail": { + "Item1": 100693138, + "Item2": 3 + } + }, + "Children": [] + }, + { + "Id": 268435688, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693124, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435689, + "Type": 3, + "X": 10, + "Y": 0, + "Width": 130, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693125, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435690, + "Type": 3, + "X": 140, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 3, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693126, + "Item2": 1 + } + }, + "Children": [] + } + ] + } + ] + }, + { + "Id": 268437049, + "Type": 3, + "X": 155, + "Y": 53, + "Width": 5, + "Height": 5, + "Left": 2, + "Top": 2, + "Right": 1, + "Bottom": 1, + "ReadOrder": 7, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693190, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437050, + "Type": 3, + "X": 155, + "Y": 5, + "Width": 5, + "Height": 48, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 8, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693186, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435694, + "Type": 7, + "X": 5, + "Y": 37, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 19, + "FontDid": 0, + "StateMedia": {}, + "Children": [ + { + "Id": 2, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": {}, + "Children": [ + { + "Id": 268436649, + "Type": 3, + "X": 25, + "Y": 0, + "Width": 100, + "Height": 16, + "Left": 3, + "Top": 1, + "Right": 3, + "Bottom": 1, + "ReadOrder": 4, + "FontDid": 0, + "StateMedia": { + "ShowDetail": { + "Item1": 100693141, + "Item2": 3 + } + }, + "Children": [] + }, + { + "Id": 268435688, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693133, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435689, + "Type": 3, + "X": 10, + "Y": 0, + "Width": 130, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693134, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435690, + "Type": 3, + "X": 140, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 3, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693135, + "Item2": 1 + } + }, + "Children": [] + } + ] + }, + { + "Id": 268435695, + "Type": 12, + "X": 0, + "Y": 0, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 3, + "FontDid": 1073741824, + "StateMedia": {}, + "Children": [] + }, + { + "Id": 268435687, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": {}, + "Children": [ + { + "Id": 268436649, + "Type": 3, + "X": 25, + "Y": 0, + "Width": 100, + "Height": 16, + "Left": 3, + "Top": 1, + "Right": 3, + "Bottom": 1, + "ReadOrder": 4, + "FontDid": 0, + "StateMedia": { + "ShowDetail": { + "Item1": 100693140, + "Item2": 3 + } + }, + "Children": [] + }, + { + "Id": 268435688, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693130, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435689, + "Type": 3, + "X": 10, + "Y": 0, + "Width": 130, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693131, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435690, + "Type": 3, + "X": 140, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 3, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693132, + "Item2": 1 + } + }, + "Children": [] + } + ] + } + ] + }, + { + "Id": 268437051, + "Type": 9, + "X": 0, + "Y": 0, + "Width": 5, + "Height": 5, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 2, + "ReadOrder": 9, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100688169, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437052, + "Type": 2, + "X": 5, + "Y": 0, + "Width": 150, + "Height": 5, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 2, + "ReadOrder": 10, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100688170, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437053, + "Type": 9, + "X": 155, + "Y": 0, + "Width": 5, + "Height": 5, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 2, + "ReadOrder": 11, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100688169, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437054, + "Type": 9, + "X": 0, + "Y": 5, + "Width": 5, + "Height": 48, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 1, + "ReadOrder": 12, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100688171, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437055, + "Type": 9, + "X": 0, + "Y": 53, + "Width": 5, + "Height": 5, + "Left": 1, + "Top": 2, + "Right": 2, + "Bottom": 1, + "ReadOrder": 13, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100688169, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437056, + "Type": 2, + "X": 5, + "Y": 53, + "Width": 150, + "Height": 5, + "Left": 1, + "Top": 2, + "Right": 1, + "Bottom": 1, + "ReadOrder": 14, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100688172, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437057, + "Type": 9, + "X": 155, + "Y": 53, + "Width": 5, + "Height": 5, + "Left": 2, + "Top": 2, + "Right": 1, + "Bottom": 1, + "ReadOrder": 15, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100688169, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437058, + "Type": 9, + "X": 155, + "Y": 5, + "Width": 5, + "Height": 48, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 16, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100688173, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435686, + "Type": 7, + "X": 5, + "Y": 5, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 17, + "FontDid": 0, + "StateMedia": {}, + "Children": [ + { + "Id": 268435691, + "Type": 12, + "X": 0, + "Y": 0, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 3, + "FontDid": 1073741824, + "StateMedia": {}, + "Children": [] + }, + { + "Id": 2, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": {}, + "Children": [ + { + "Id": 268436649, + "Type": 3, + "X": 66, + "Y": 0, + "Width": 18, + "Height": 16, + "Left": 3, + "Top": 1, + "Right": 3, + "Bottom": 1, + "ReadOrder": 4, + "FontDid": 0, + "StateMedia": { + "ShowDetail": { + "Item1": 100693137, + "Item2": 3 + } + }, + "Children": [] + }, + { + "Id": 268435688, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693121, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435689, + "Type": 3, + "X": 10, + "Y": 0, + "Width": 130, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693122, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435690, + "Type": 3, + "X": 140, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 3, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693123, + "Item2": 1 + } + }, + "Children": [] + } + ] + }, + { + "Id": 268435687, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": {}, + "Children": [ + { + "Id": 268436649, + "Type": 3, + "X": 66, + "Y": 0, + "Width": 18, + "Height": 16, + "Left": 3, + "Top": 1, + "Right": 3, + "Bottom": 1, + "ReadOrder": 4, + "FontDid": 0, + "StateMedia": { + "ShowDetail": { + "Item1": 100693136, + "Item2": 3 + } + }, + "Children": [] + }, + { + "Id": 268435688, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693118, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435689, + "Type": 3, + "X": 10, + "Y": 0, + "Width": 130, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693119, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435690, + "Type": 3, + "X": 140, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 3, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693120, + "Item2": 1 + } + }, + "Children": [] + } + ] + } + ] + }, + { + "Id": 268437043, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 5, + "Height": 5, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 2, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693187, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437044, + "Type": 3, + "X": 5, + "Y": 0, + "Width": 150, + "Height": 5, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 2, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693183, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437045, + "Type": 3, + "X": 155, + "Y": 0, + "Width": 5, + "Height": 5, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 2, + "ReadOrder": 3, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693188, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437046, + "Type": 3, + "X": 0, + "Y": 5, + "Width": 5, + "Height": 48, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 1, + "ReadOrder": 4, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693184, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437047, + "Type": 3, + "X": 0, + "Y": 53, + "Width": 5, + "Height": 5, + "Left": 1, + "Top": 2, + "Right": 2, + "Bottom": 1, + "ReadOrder": 5, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693189, + "Item2": 1 + } + }, + "Children": [] + } + ] +} \ No newline at end of file From 2b653b8fc05043846ed0f51d9d0fb5b6b6ce0218 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 14:38:55 +0200 Subject: [PATCH 47/99] =?UTF-8?q?test(D.2b):=20conformance=20polish=20?= =?UTF-8?q?=E2=80=94=20table-driven=20slice=20asserts=20+=20BOM-safe=20loa?= =?UTF-8?q?der?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix 1: replace 3 copy-paste meter blocks in VitalsTree_MetersHaveExpectedSliceIds with a single table-driven loop — a 4th meter is now a one-liner and failures name the failing meter id directly. Fix 2: FixtureLoader now reads the fixture as bytes and strips the UTF-8 BOM (EF BB BF) before passing the span to JsonSerializer, so a BOM-bearing fixture file never causes a spurious JsonReaderException. Fix 3: add [Trait("Category", "Conformance")] at the class level so conformance tests are selectable by category filter. Fix 4: add missing doc tag to LayoutImporter.ImportInfos. Co-Authored-By: Claude Sonnet 4.6 --- src/AcDream.App/UI/Layout/LayoutImporter.cs | 2 + .../UI/Layout/FixtureLoader.cs | 11 +++-- .../UI/Layout/LayoutConformanceTests.cs | 45 ++++++------------- 3 files changed, 23 insertions(+), 35 deletions(-) diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs index 0bf2b2bd..9f5d439b 100644 --- a/src/AcDream.App/UI/Layout/LayoutImporter.cs +++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs @@ -129,6 +129,8 @@ public static class LayoutImporter /// ElementInfo tree (no widgets). Exposed for fixture generation + conformance tests. /// Returns null if the layout is missing. /// + /// The dat collection to read the LayoutDesc from. + /// The LayoutDesc dat id to read. public static ElementInfo? ImportInfos(DatCollection dats, uint layoutId) { var ld = dats.Get(layoutId); diff --git a/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs b/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs index de0bd06a..7f0f5eca 100644 --- a/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs +++ b/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs @@ -29,9 +29,14 @@ public static class FixtureLoader if (!File.Exists(fixturePath)) throw new FileNotFoundException($"Vitals fixture not found at: {fixturePath}"); - var json = File.ReadAllText(fixturePath, System.Text.Encoding.UTF8); - var root = JsonSerializer.Deserialize(json, _opts) - ?? throw new InvalidOperationException("Failed to deserialize vitals fixture."); + var bytes = File.ReadAllBytes(fixturePath); + // Strip UTF-8 BOM (EF BB BF) if present so JsonSerializer.Deserialize(ReadOnlySpan) + // does not reject the first byte. + ReadOnlySpan span = bytes; + if (span.Length >= 3 && span[0] == 0xEF && span[1] == 0xBB && span[2] == 0xBF) + span = span[3..]; + var root = JsonSerializer.Deserialize(span, _opts) + ?? throw new InvalidOperationException($"fixture deserialized to null: {fixturePath}"); return LayoutImporter.Build(root, _ => (0u, 0, 0), null); } diff --git a/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs index d1ec93e2..b50862bc 100644 --- a/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs @@ -14,6 +14,7 @@ namespace AcDream.App.Tests.UI.Layout; /// /// Sprite ids sourced from docs/research/2026-06-15-layoutdesc-format.md §11. /// +[Trait("Category", "Conformance")] public class LayoutConformanceTests { // ── Test 1: Three meters at expected rects ──────────────────────────────── @@ -58,40 +59,20 @@ public class LayoutConformanceTests { var layout = FixtureLoader.LoadVitals(); - // Health bar - { - var elem = layout.FindElement(0x100000E6u); - var m = Assert.IsType(elem); - Assert.Equal(0x0600747Eu, m.BackLeft); - Assert.Equal(0x0600747Fu, m.BackTile); - Assert.Equal(0x06007480u, m.BackRight); - Assert.Equal(0x06007481u, m.FrontLeft); - Assert.Equal(0x06007482u, m.FrontTile); - Assert.Equal(0x06007483u, m.FrontRight); - } + // Columns: MeterId, then 6 slice ids in order: + // BackLeft, BackTile, BackRight, FrontLeft, FrontTile, FrontRight + (uint MeterId, uint[] Slices)[] cases = + [ + (0x100000E6u, [0x0600747Eu, 0x0600747Fu, 0x06007480u, 0x06007481u, 0x06007482u, 0x06007483u]), // health + (0x100000ECu, [0x06007484u, 0x06007485u, 0x06007486u, 0x06007487u, 0x06007488u, 0x06007489u]), // stamina + (0x100000EEu, [0x0600748Au, 0x0600748Bu, 0x0600748Cu, 0x0600748Du, 0x0600748Eu, 0x0600748Fu]), // mana + ]; - // Stamina bar + foreach (var (meterId, s) in cases) { - var elem = layout.FindElement(0x100000ECu); - var m = Assert.IsType(elem); - Assert.Equal(0x06007484u, m.BackLeft); - Assert.Equal(0x06007485u, m.BackTile); - Assert.Equal(0x06007486u, m.BackRight); - Assert.Equal(0x06007487u, m.FrontLeft); - Assert.Equal(0x06007488u, m.FrontTile); - Assert.Equal(0x06007489u, m.FrontRight); - } - - // Mana bar - { - var elem = layout.FindElement(0x100000EEu); - var m = Assert.IsType(elem); - Assert.Equal(0x0600748Au, m.BackLeft); - Assert.Equal(0x0600748Bu, m.BackTile); - Assert.Equal(0x0600748Cu, m.BackRight); - Assert.Equal(0x0600748Du, m.FrontLeft); - Assert.Equal(0x0600748Eu, m.FrontTile); - Assert.Equal(0x0600748Fu, m.FrontRight); + var m = Assert.IsType(layout.FindElement(meterId)); + Assert.Equal(s[0], m.BackLeft); Assert.Equal(s[1], m.BackTile); Assert.Equal(s[2], m.BackRight); + Assert.Equal(s[3], m.FrontLeft); Assert.Equal(s[4], m.FrontTile); Assert.Equal(s[5], m.FrontRight); } } From 4dcc90cb51c88da95175d737d148e38fb045d390 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 14:55:01 +0200 Subject: [PATCH 48/99] docs(D.2b): register AP-32 + IA-15 amend for importer; doc/test review fixes (N1/N4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Process/quality items from the LayoutDesc-importer final review — no runtime behavior change. I1a — amend IA-15: the 8-piece chrome edge/corner→position mapping is no longer a guess. The LayoutImporter (ACDREAM_RETAIL_UI_IMPORTER) reads real LayoutDesc dat data and resolves positions + sprite ids directly; locked by the conformance fixture vitals_2100006C.json. Residual risk trimmed to anchor resolution at non-800×600 + controls.ini cascade. Pointers added to LayoutImporter.cs and the format-doc. I1b — add AP-32: the importer collapses the dat's nested meter structure (Type-7 → two Type-3 containers → three image-slice grandchildren each) into UiMeter's programmatic 3-slice fields instead of building those nodes generically and porting UIElement_Meter::DrawChildren. Standalone Type-0 text elements are also skipped (Plan 2). Retail oracles: UIElement_Meter::DrawChildren @0x46fbd0, UIElement_Text::DrawSelf @0x467aa0. I1c — AP section header 31 → 32. N1 — ElementReader.cs: comment at the Type-merge line explaining that a derived Type 0 (text element) inherits the base's Type 12 (style prototype), which DatWidgetFactory skips; safe for Plan 1 because vitals numbers render via UiMeter.Label. Format-doc §10: correct the "render as UiDatElement" sentence to "skipped entirely" (Type-0 → inherits Type-12 via Merge → factory returns null). N4 — new conformance test VitalsTree_TextLabel_InheritsFontDidFromBaseLayout: walks the raw ElementInfo tree from the fixture and asserts at least one element carries FontDid==0x40000000, proving Resolve()'s inheritance merge fired against real dat data. FixtureLoader gains LoadVitalsInfos() that returns the raw tree without calling Build. Tests: 36 pass (was 35), 0 errors, 0 warnings. Co-Authored-By: Claude Sonnet 4.6 --- .../retail-divergence-register.md | 5 ++- docs/research/2026-06-15-layoutdesc-format.md | 2 +- src/AcDream.App/UI/Layout/ElementReader.cs | 6 +++ .../UI/Layout/FixtureLoader.cs | 17 +++++-- .../UI/Layout/LayoutConformanceTests.cs | 45 +++++++++++++++++++ 5 files changed, 69 insertions(+), 6 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 5a7c7b05..045f4a49 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -55,7 +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) | +| 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` + `src/AcDream.App/UI/Layout/LayoutImporter.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 NOW DATA-DRIVEN from the dat: the `LayoutImporter` (gated `ACDREAM_RETAIL_UI_IMPORTER`) reads the real `LayoutDesc` for `0x2100006C` and resolves chrome element positions + sprite ids directly from parsed dat fields; locked by the conformance fixture `tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json` | Remaining residual risk: anchor resolution at non-800×600 and the controls.ini cascade still lack an oracle — layout scaling at non-reference resolution and stylesheet token inheritance differ silently | `LayoutDesc 0x2100006C` (SHIPPED); `docs/research/2026-06-15-layoutdesc-format.md`; controls.ini tokens; keystone.dll layout eval (no PDB) | --- @@ -93,7 +93,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 3. Documented approximation (AP) — 31 rows +## 3. Documented approximation (AP) — 32 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -128,6 +128,7 @@ accepted-divergence entries (#96, #49, #50). | AP-29 | Target-indicator fallback for entities with no baked selection sphere: invented 1.5 m × scale box + 16/12 px screen floors (primary path is a faithful `GetObjectBoundingBox` port) | `src/AcDream.App/UI/TargetIndicatorPanel.cs:86` | Fallback only fires when the Setup didn't bake a selection sphere — rare in practice | Sphere-less entities get a non-retail indicator size/placement; the pixel floors prevent retail's far-distance collapse | `SmartBox::GetObjectBoundingBox` 0x00452e20; `GetSelectionSphere` | | AP-30 | AutonomousPosition diff cadence compares with epsilons (1 mm pos, 1e-4 normal, 1 mm dist); retail's `Frame::is_equal` is an exact float compare | `src/AcDream.App/Input/PlayerMovementController.cs:1541` | Sub-millimeter epsilon is well below any movement worth suppressing; comparisons are against last-SENT state so drift accumulates past the epsilon | Sub-epsilon drift suppresses an AP send retail would have made — negligible today; a consumer expecting retail's exact send-on-any-change cadence sees fewer packets | `Frame::is_equal` pc:700263 | | AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter | +| AP-32 | LayoutDesc importer collapses the dat's nested meter structure (Type-7 meter → two Type-3 container children → three Type-3 image-slice grandchildren each) into `UiMeter`'s programmatic 3-slice fields (`BackLeft..FrontRight`) + reuses `UiMeter.DrawHBar`'s scissor-fill, instead of building those child nodes generically and porting `UIElement_Meter::DrawChildren`. Standalone Type-0 text elements are also skipped (vitals numbers render via `UiMeter.Label` bound by the controller; a dedicated dat-text widget is Plan 2). The inheritance `Merge` treats Width/Height==0 as "inherit from base", diverging from format-doc §12 rule 2 (documented inline in `ElementReader.cs`) | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`) + `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`BuildWidget` meter-child skip) | Reuses the tested `UiMeter` render that already visually matches retail's stacked vitals bars; the full nested-element + `DrawChildren` scissor port and a dat-text widget are deferred to Plan 2. Gated opt-in (`ACDREAM_RETAIL_UI_IMPORTER`) and locked by the conformance fixture (`tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json`) | A LayoutDesc whose meter structure differs from the vitals 2-container/3-slice shape, or a window needing standalone dat text, renders an empty/wrong meter or drops text — no oracle diff until the Plan-2 widgets land | `UIElement_Meter::DrawChildren` @0x46fbd0; `UIElement_Text::DrawSelf` @0x467aa0; `docs/research/2026-06-15-layoutdesc-format.md` | --- diff --git a/docs/research/2026-06-15-layoutdesc-format.md b/docs/research/2026-06-15-layoutdesc-format.md index 867fd0a8..10e66e8f 100644 --- a/docs/research/2026-06-15-layoutdesc-format.md +++ b/docs/research/2026-06-15-layoutdesc-format.md @@ -322,7 +322,7 @@ derived (Type=0, no StateDesc media, no font prop itself) The derived text element overrides `Width/Height/X/Y` (from the dat element's fields) but inherits the font DID and color from the base element's `Properties`. -**There is no `StateDesc.Media` on the text elements** — the text is rendered by the `UIElement_Text::DrawSelf` algorithm using the font DID from properties, not a sprite. In Plan 1, the text elements render as `UiDatElement` (generic fallback) until a dedicated text widget is implemented in Plan 2. +**There is no `StateDesc.Media` on the text elements** — the text is rendered by the `UIElement_Text::DrawSelf` algorithm using the font DID from properties, not a sprite. In Plan 1, the text elements are **skipped entirely**: `Type = 0` (derived) inherits `Type = 12` from the base prototype `0x10000376` via `ElementReader.Merge` (zero-wins-nothing rule — the derived Type 0 inherits the base's Type 12), and `DatWidgetFactory` returns null for Type 12. This means no `UiDatElement` is created for them. For the vitals window this is correct: the numbers render via `UiMeter.Label` bound by the `VitalsController`, not a dat text node. A dedicated dat-text widget (Type 0) is Plan 2. --- diff --git a/src/AcDream.App/UI/Layout/ElementReader.cs b/src/AcDream.App/UI/Layout/ElementReader.cs index c5087b99..31a402b3 100644 --- a/src/AcDream.App/UI/Layout/ElementReader.cs +++ b/src/AcDream.App/UI/Layout/ElementReader.cs @@ -142,6 +142,12 @@ public static class ElementReader var m = new ElementInfo { Id = derived.Id != 0 ? derived.Id : base_.Id, + // Type: derived wins if non-zero; Type 0 (text element per format §8) inherits the base's Type. + // For a text element whose base prototype is Type 12 (style prototype), this yields Type 12 — + // which DatWidgetFactory skips (returns null). That is intentional for Plan 1: vitals text + // numbers render via UiMeter.Label bound by VitalsController, not a dat text node. + // A Plan-2 standalone text element would need a type-preserving path (e.g. float? nullable + // Width/Height, or explicit handling of Type 0 before the merge). Type = derived.Type != 0 ? derived.Type : base_.Type, X = derived.X, Y = derived.Y, diff --git a/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs b/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs index 7f0f5eca..724a0e89 100644 --- a/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs +++ b/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs @@ -24,6 +24,19 @@ public static class FixtureLoader /// dat font — sufficient for conformance checks on tree structure and slice ids. /// public static ImportedLayout LoadVitals() + { + var root = LoadVitalsInfos(); + return LayoutImporter.Build(root, _ => (0u, 0, 0), null); + } + + /// + /// Deserializes the committed vitals_2100006C.json fixture into a raw + /// tree WITHOUT calling . + /// Use this when the test needs to inspect the resolved + /// tree directly (e.g. inheritance-resolution checks) without exercising the + /// widget factory. + /// + public static AcDream.App.UI.Layout.ElementInfo LoadVitalsInfos() { var fixturePath = Path.Combine(AppContext.BaseDirectory, "UI", "Layout", "fixtures", "vitals_2100006C.json"); if (!File.Exists(fixturePath)) @@ -35,9 +48,7 @@ public static class FixtureLoader ReadOnlySpan span = bytes; if (span.Length >= 3 && span[0] == 0xEF && span[1] == 0xBB && span[2] == 0xBF) span = span[3..]; - var root = JsonSerializer.Deserialize(span, _opts) + return JsonSerializer.Deserialize(span, _opts) ?? throw new InvalidOperationException($"fixture deserialized to null: {fixturePath}"); - - return LayoutImporter.Build(root, _ => (0u, 0, 0), null); } } diff --git a/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs index b50862bc..a2bcfe08 100644 --- a/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs @@ -93,4 +93,49 @@ public class LayoutConformanceTests var (file, _) = datElem.ActiveMedia(); Assert.Equal(0x060074C3u, file); } + + // ── Test 4 (N4): Inheritance resolution — FontDid propagated from base ─── + + /// + /// Proves that Resolve()'s inheritance merge fired against real dat data: + /// at least one element in the fixture tree must have FontDid == 0x40000000 + /// (the vitals font), inherited from the base-layout prototype 0x10000376 + /// in 0x2100003F via the BaseElement / BaseLayoutId chain. + /// + /// + /// The three text labels (0x100000EB health, 0x100000ED stamina, + /// 0x100000EF mana) are Type=0 derived elements with no own font property. + /// The base element 0x10000376 carries Properties[0x1A] → + /// ArrayBaseProperty[ DataIdBaseProperty{Value=0x40000000} ]. + /// propagates this via the "FontDid: derived wins + /// if non-zero, otherwise inherit" rule. + /// + /// + /// + /// This test verifies end-to-end inheritance resolution against the committed fixture + /// (format doc §10, docs/research/2026-06-15-layoutdesc-format.md). + /// It operates on the raw tree, NOT the widget tree, + /// so the factory dispatch (Type 12 → skip) does not interfere. + /// + /// + [Fact] + public void VitalsTree_TextLabel_InheritsFontDidFromBaseLayout() + { + var root = FixtureLoader.LoadVitalsInfos(); + + // Walk the full ElementInfo tree and collect all FontDid values. + var fontDids = new System.Collections.Generic.List(); + CollectFontDids(root, fontDids); + + // At least one element must carry FontDid == 0x40000000 (the vitals font). + // In practice, the three text labels (health/stamina/mana) all inherit it. + Assert.Contains(0x40000000u, fontDids); + } + + private static void CollectFontDids(ElementInfo node, System.Collections.Generic.List acc) + { + if (node.FontDid != 0) acc.Add(node.FontDid); + foreach (var child in node.Children) + CollectFontDids(child, acc); + } } From 07cf1209394245ad40ae34e1f8046f6335e1c90b Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 15:03:31 +0200 Subject: [PATCH 49/99] docs(D.2b): mark LayoutDesc importer Plan 1 shipped; defer default-flip to Plan 2 (drag/resize) Co-Authored-By: Claude Sonnet 4.6 --- docs/plans/2026-04-11-roadmap.md | 3 ++- docs/superpowers/plans/2026-06-15-layoutdesc-importer.md | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 560b150e..c36e685d 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -424,7 +424,8 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. **Sub-pieces:** - **D.1 — 2D ortho overlay + font rendering.** ✅ SHIPPED 2026-04-17 as the dev-facing debug overlay (StbTrueTypeSharp system-font atlas + `TextRenderer` + `DebugOverlay`). - **✓ SHIPPED — D.2a — ImGui scaffold + `AcDream.UI.Abstractions` layer.** Shipped 2026-04-25. Wires ImGui as the short-term backend behind `ACDREAM_DEVTOOLS=1`. Defines `IPanel` / `IPanelHost` / `IPanelRenderer` / `ICommandBus` + the first ViewModel (`VitalsVM`) in the new `AcDream.UI.Abstractions` project. First real panel: `VitalsPanel` reading HP from `CombatState.GetHealthPercent`. **Backend pivoted Hexa.NET.ImGui → ImGui.NET + `Silk.NET.OpenGL.Extensions.ImGui` during integration** — Hexa's native OpenGL3 backend does its own GL function resolution via GLFW/SDL and crashed with `0xC0000005` in `ImGuiImplOpenGL3.InitNative` against Silk.NET (no GLFW/SDL present). The Silk.NET extension is purpose-built for this scenario and is the `ImGui.NET` mitigation path that `docs/plans/2026-04-24-ui-framework.md` already called out as a "one-morning operation". Stam/Mana return `float?` null in D.2a because absolute values need `LocalPlayerState` + `PlayerDescription (0x0013)` parsing (filed post-D.2a). 11 new `AcDream.UI.Abstractions.Tests` green. -- **✓ SHIPPED — D.2b — Custom retail-look backend (Spec 1: markup engine + first panel).** Shipped 2026-06-14 (`626d06e`→`019350f`). Wired the dormant `UiHost`/`UiElement` tree into `GameWindow` (`ACDREAM_RETAIL_UI=1`) and built the **Approach-C markup engine**: `MarkupDocument` (XML → `UiElement` subtree, `{Binding}` reflection) + `ControlsIni` stylesheet loader + an `IUiRegistry` plugin UI surface (plugins ship markup + a binding, drained into the same `UiRoot`). First retail-faithful panel: a markup-driven Vitals window (`vitals.xml`) — 8-piece dat-sprite frame (`UiNineSlicePanel`) + three `UiMeter` bars (red/gold/blue + cur/max numbers) bound to `VitalsVM`, rendering live over the 3D world and coexisting with the ImGui devtools path. Retired divergence TS-30, added IA-15. Spec `docs/superpowers/specs/2026-06-14-d2b-retail-panel-frame-design.md`; plan `docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md`. NOTE the prove-out corrected two stale "facts": retail vitals are **bars not orbs**, stamina is **gold not cyan**. **Remaining: D.3 (AcFont — using the stb stopgap for now) + the glassy gradient bar sprite / brightness tune (polish); input integration (movable/clickable windows — the `UiRoot` drag/click machinery exists but isn't bridged to the Phase-K dispatcher yet); the `LayoutDesc 0x21000040` importer; and the rest of the panels (D.5).** +- **✓ SHIPPED — D.2b — Custom retail-look backend (Spec 1: markup engine + first panel).** Shipped 2026-06-14 (`626d06e`→`019350f`). Wired the dormant `UiHost`/`UiElement` tree into `GameWindow` (`ACDREAM_RETAIL_UI=1`) and built the **Approach-C markup engine**: `MarkupDocument` (XML → `UiElement` subtree, `{Binding}` reflection) + `ControlsIni` stylesheet loader + an `IUiRegistry` plugin UI surface (plugins ship markup + a binding, drained into the same `UiRoot`). First retail-faithful panel: a markup-driven Vitals window (`vitals.xml`) — 8-piece dat-sprite frame (`UiNineSlicePanel`) + three `UiMeter` bars (red/gold/blue + cur/max numbers) bound to `VitalsVM`, rendering live over the 3D world and coexisting with the ImGui devtools path. Retired divergence TS-30, added IA-15. Spec `docs/superpowers/specs/2026-06-14-d2b-retail-panel-frame-design.md`; plan `docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md`. NOTE the prove-out corrected two stale "facts": retail vitals are **bars not orbs**, stamina is **gold not cyan**. **Remaining: D.3 (AcFont — using the stb stopgap for now) + the glassy gradient bar sprite / brightness tune (polish); input integration (movable/clickable windows — the `UiRoot` drag/click machinery exists but isn't bridged to the Phase-K dispatcher yet); and the rest of the panels (D.5).** +- **✓ SHIPPED — D.2b LayoutDesc importer (Plan 1).** Shipped 2026-06-15. *Retroactive registration — the spec requested this phase but it was not pre-registered before implementation.* Data-driven vitals window from `LayoutDesc 0x2100006C`: `LayoutImporter` resolves `BaseElement`/`BaseLayoutId` inheritance, walks the `ElementDesc` tree, and builds a live `UiElement` tree via `DatWidgetFactory` (Type 7 → `UiMeter`; all others → `UiDatElement` generic renderer). `VitalsController` binds live HP/Stamina/Mana by element id (mirrors retail `gmVitalsUI`). Gated `ACDREAM_RETAIL_UI_IMPORTER=1`; coexists with the hand-authored `vitals.xml` path (nothing deleted). A/B visual gate **PASSED**: pixel-identical to the hand-authored window. Default flip (retiring `vitals.xml`) **deferred to Plan 2** — the importer window is static; faithful drag/resize requires the dat's own Type-9 resize grips + Type-2 drag bars (the Plan-2 window manager); flipping now would regress interactivity, violating the no-workaround rule. Spec: `docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md`; plan: `docs/superpowers/plans/2026-06-15-layoutdesc-importer.md`. - **D.3 — AcFont from portal.dat.** Replace stb_truetype system font with retail `Font` DBObjs (`0x40000000..0x40000FFF`) baked from `RenderSurface` source sheets — see research slice 03 §4. Preserves retail visual identity. **(D.2b dependency — needs the custom renderer.)** - **✓ SHIPPED — D.4 — Dat sprites + 9-slice panel backgrounds.** Shipped 2026-06-14 with D.2b. `TextureCache.GetOrUploadRenderSurface(id, out w, out h)` decodes `RenderSurface` (`0x06xxxxxx`) **directly** (not via the Surface→SurfaceTexture chain — the prove-out finding) → GL `Texture2D`; `DrawSprite` (explicit UV-rect, per-texture batch) added to `TextRenderer` + `UiRenderContext` + a `uUseTexture=2` RGBA frag branch; `UiNineSlicePanel` composes the universal 8-piece bevel (corners `0x060074C3..C6`, edges `0x060074BF/C0/C1/C2`, center `0x06004CC2`). Remaining art polish: the glassy gradient bar fill sprite (D.2b polish). - **D.5 — Core panels.** Attributes (`chunk_00470000.c:FUN_0047ba70`), Skills (same), Paperdoll (`chunk_004A0000.c:FUN_004A5200`), Inventory, Spellbook (`chunk_004C0000.c`), Fellowship, Allegiance. Each uses the port sketches in slice 05. **(Targets `AcDream.UI.Abstractions` — ships with D.2a using ImGui-rendered widgets; reskinned by D.2b.)** The *chat* panel originally listed under D.5 shipped early in Phase I (I.4 input + I.7 combat translator superseded the chat-panel design here); this entry now covers Attributes / Skills / Paperdoll / Inventory / Spellbook / Fellowship / Allegiance only. diff --git a/docs/superpowers/plans/2026-06-15-layoutdesc-importer.md b/docs/superpowers/plans/2026-06-15-layoutdesc-importer.md index f5a6b4d6..1ab9040f 100644 --- a/docs/superpowers/plans/2026-06-15-layoutdesc-importer.md +++ b/docs/superpowers/plans/2026-06-15-layoutdesc-importer.md @@ -747,7 +747,11 @@ git commit -m "test(D.2b): vitals importer conformance (golden tree + A/B render ## After Plan 1 -Once the importer window is pixel-identical to the hand-authored vitals (Task 8 gate), a follow-up commit flips vitals to the importer as the default and the hand-authored `vitals.xml` path is retired (kept in git history). **Plan 2** then covers: the `WindowManager` (open/close/z-order/persist), re-driving the chat window (`ChatController`), and extending the factory/renderer to the full long-tail of element types per the Task 1 enumeration. Register the phase id in `docs/plans/2026-04-11-roadmap.md` before starting Plan 2. +**Plan 1 status: SHIPPED 2026-06-15, gated `ACDREAM_RETAIL_UI_IMPORTER=1`, pixel-identical; default flip → Plan 2.** + +The Task 8 A/B visual gate **PASSED**: the importer-built vitals window is pixel-identical to the hand-authored `vitals.xml` window. The default flip (retiring `vitals.xml` and making the importer the sole path) is **DEFERRED to Plan 2**. Reason: the importer window is currently static — it has no drag or resize behavior. A "whole-window-draggable" hack would not be retail-faithful; the retail mechanism wires drag to the dat's own **Type-2 drag bars** and resize to **Type-9 resize grips**, both of which are Plan-2 window-manager responsibilities. Flipping the default now would regress the draggable/resizable hand-authored window without a faithful replacement, violating the no-workaround rule. The hand-authored `vitals.xml` path remains the default and is NOT deleted. + +**Plan 2** covers: the `WindowManager` (open/close/z-order/persist, drag via Type-2 bars, resize via Type-9 grips), re-driving the chat window (`ChatController`), the default flip retiring `vitals.xml` once drag/resize is wired, and extending the factory/renderer to the full long-tail of element types per the Task 1 enumeration. The phase is already registered in `docs/plans/2026-04-11-roadmap.md` as "D.2b LayoutDesc importer (Plan 1) — SHIPPED"; register Plan 2 in the roadmap before starting it. ## Self-review From bf77a23ad35b41147de18bd8aa50ed8d60dde302 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 16:30:24 +0200 Subject: [PATCH 50/99] feat(D.2b): flip vitals to the LayoutDesc importer; retire hand-authored vitals.xml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The importer (proven pixel-identical at the 2026-06-15 A/B gate) is now the default vitals window when ACDREAM_RETAIL_UI=1 — data-driven from LayoutDesc 0x2100006C. Removed: the hand-authored vitals.xml build path, the asset file (recoverable from git history), and the now-obsolete ACDREAM_RETAIL_UI_IMPORTER flag (RuntimeOptions param + parse + 2 tests). The window is user-positioned at (10,30) and movable; resize stays off — the dat stacked-vitals layout is fixed- size (chrome edges near-pinned), faithful grip/dragbar resize is Plan 2. MarkupDocument/UiNineSlicePanel remain for the chat window + plugin panels. AcDream.App builds 0/0; AcDream.App.Tests 352 passed / 1 skipped / 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 86 ++++++++----------- src/AcDream.App/RuntimeOptions.cs | 2 - src/AcDream.App/UI/assets/vitals.xml | 13 --- .../RuntimeOptionsRetailUiTests.cs | 25 ------ 4 files changed, 37 insertions(+), 89 deletions(-) delete mode 100644 src/AcDream.App/UI/assets/vitals.xml diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 16302f69..c4b55885 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1782,62 +1782,50 @@ public sealed class GameWindow : IDisposable var controls = _options.AcDir is { } acDir ? AcDream.App.UI.ControlsIni.Load(System.IO.Path.Combine(acDir, "controls", "controls.ini")) : AcDream.App.UI.ControlsIni.Parse(string.Empty); - string vitalsXml = System.IO.File.ReadAllText( - System.IO.Path.Combine(AppContext.BaseDirectory, "UI", "assets", "vitals.xml")); - var panel = AcDream.App.UI.MarkupDocument.Build(vitalsXml, _vitalsVm!, ResolveChrome, controls); - - // Phase D.2b — retail dat-font for the vitals numbers. Font 0x40000000 - // (Latin-1, 16px, outline atlas). The consola TTF debug font is wrong - // for retail look; the meter falls back to it only if the dat font fails - // to load. Loaded under _datLock for consistency with other dat reads - // (no streaming worker is active during OnLoad, but the lock is cheap). + // Phase D.2b — retail dat-font for the vitals numbers (Font 0x40000000, + // Latin-1, 16px, outline atlas). Passed into the importer so the meter + // number overlay renders through the dat-font two-pass blit; falls back to + // the debug font only if it fails to load. Under _datLock like other reads. AcDream.App.UI.UiDatFont? vitalsDatFont; lock (_datLock) vitalsDatFont = AcDream.App.UI.UiDatFont.Load(_dats!, _textureCache!); - if (vitalsDatFont is not null) + Console.WriteLine(vitalsDatFont is not null + ? "[D.2b] vitals dat-font 0x40000000 loaded for numeric overlay." + : "[D.2b] vitals dat-font 0x40000000 unavailable — falling back to debug font."); + + // Phase D.2b — the vitals window is data-driven from the dat LayoutDesc + // (0x2100006C) via the LayoutImporter. The former hand-authored vitals.xml + // markup path was retired after the importer proved pixel-identical at the + // 2026-06-15 A/B gate. MarkupDocument stays for plugin/custom panels. + AcDream.App.UI.Layout.ImportedLayout? imported; + lock (_datLock) + imported = AcDream.App.UI.Layout.LayoutImporter.Import( + _dats!, 0x2100006Cu, ResolveChrome, vitalsDatFont); + if (imported is not null) { - foreach (var child in panel.Children) - if (child is AcDream.App.UI.UiMeter meter) - meter.DatFont = vitalsDatFont; - Console.WriteLine("[D.2b] vitals dat-font 0x40000000 loaded for numeric overlay."); + AcDream.App.UI.Layout.VitalsController.Bind(imported, + healthPct: () => _vitalsVm!.HealthPercent, + staminaPct: () => _vitalsVm!.StaminaPercent ?? 0f, + manaPct: () => _vitalsVm!.ManaPercent ?? 0f, + healthText: () => (_vitalsVm!.HealthCurrent, _vitalsVm.HealthMax) is (uint c, uint m) ? $"{c}/{m}" : "", + staminaText: () => (_vitalsVm!.StaminaCurrent, _vitalsVm.StaminaMax) is (uint c, uint m) ? $"{c}/{m}" : "", + manaText: () => (_vitalsVm!.ManaCurrent, _vitalsVm.ManaMax) is (uint c, uint m) ? $"{c}/{m}" : ""); + // Top-level window: user-positioned (Anchors.None so the per-frame anchor + // pass doesn't reset it) + movable, like the retired hand-authored panel. + // Resize is left off — the dat stacked-vitals layout (0x2100006C) is + // fixed-size (chrome edges near-pinned); faithful grip/dragbar-driven + // resize is the Plan-2 window manager. + var vitalsRoot = imported.Root; + vitalsRoot.Left = 10; vitalsRoot.Top = 30; + vitalsRoot.ClickThrough = false; + vitalsRoot.Anchors = AcDream.App.UI.AnchorEdges.None; + vitalsRoot.Draggable = true; + _uiHost.Root.AddChild(vitalsRoot); + Console.WriteLine("[D.2b] retail UI active — vitals window from LayoutDesc importer (0x2100006C)."); } else { - Console.WriteLine("[D.2b] vitals dat-font 0x40000000 unavailable — falling back to debug font."); - } - - _uiHost.Root.AddChild(panel); - Console.WriteLine("[D.2b] retail UI active — vitals panel from vitals.xml markup."); - - // Phase D.2b — LayoutDesc importer A/B harness. When ACDREAM_RETAIL_UI_IMPORTER=1, - // build the SAME vitals window (0x2100006C) data-driven from the dat and place it beside - // the hand-authored one so the two can be compared pixel-for-pixel before the importer - // becomes the default. The hand-authored path above is untouched. - if (_options.RetailUiImporter) - { - AcDream.App.UI.Layout.ImportedLayout? imported; - lock (_datLock) - imported = AcDream.App.UI.Layout.LayoutImporter.Import( - _dats!, 0x2100006Cu, ResolveChrome, vitalsDatFont); - if (imported is not null) - { - AcDream.App.UI.Layout.VitalsController.Bind(imported, - healthPct: () => _vitalsVm!.HealthPercent, - staminaPct: () => _vitalsVm!.StaminaPercent ?? 0f, - manaPct: () => _vitalsVm!.ManaPercent ?? 0f, - healthText: () => (_vitalsVm!.HealthCurrent, _vitalsVm.HealthMax) is (uint c, uint m) ? $"{c}/{m}" : "", - staminaText: () => (_vitalsVm!.StaminaCurrent, _vitalsVm.StaminaMax) is (uint c, uint m) ? $"{c}/{m}" : "", - manaText: () => (_vitalsVm!.ManaCurrent, _vitalsVm.ManaMax) is (uint c, uint m) ? $"{c}/{m}" : ""); - // Offset beside the hand-authored window (at x=10) for an A/B visual comparison. - imported.Root.Left = 200; - imported.Root.Top = 30; - _uiHost.Root.AddChild(imported.Root); - Console.WriteLine("[D.2b] importer vitals window active (A/B vs hand-authored)."); - } - else - { - Console.WriteLine("[D.2b] importer vitals: LayoutDesc 0x2100006C not found."); - } + Console.WriteLine("[D.2b] vitals: LayoutDesc 0x2100006C not found — vitals unavailable."); } // Retail chat window — a draggable/resizable nine-slice frame hosting a diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index bff1f885..9be7601d 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -41,7 +41,6 @@ public sealed record RuntimeOptions( bool DumpLiveSpawns, int? LegacyStreamRadius, bool RetailUi, - bool RetailUiImporter, string? AcDir) { /// @@ -86,7 +85,6 @@ public sealed record RuntimeOptions( // top of the quality preset's radii. Null when unset or invalid. LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")), RetailUi: IsExactlyOne(env("ACDREAM_RETAIL_UI")), - RetailUiImporter: IsExactlyOne(env("ACDREAM_RETAIL_UI_IMPORTER")), AcDir: NullIfEmpty(env("ACDREAM_AC_DIR"))); } diff --git a/src/AcDream.App/UI/assets/vitals.xml b/src/AcDream.App/UI/assets/vitals.xml deleted file mode 100644 index eb8dfcbd..00000000 --- a/src/AcDream.App/UI/assets/vitals.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - diff --git a/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs index 9c6b88a2..b18590ae 100644 --- a/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs +++ b/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs @@ -25,29 +25,4 @@ public class RuntimeOptionsRetailUiTests Assert.False(opts.RetailUi); Assert.Null(opts.AcDir); } - - [Fact] - public void Parse_ReadsRetailUiImporter_WhenSetToOne() - { - var env = new Dictionary - { - ["ACDREAM_RETAIL_UI_IMPORTER"] = "1", - }; - var opts = RuntimeOptions.Parse("dats", k => env.GetValueOrDefault(k)); - Assert.True(opts.RetailUiImporter); - } - - [Fact] - public void Parse_DefaultsRetailUiImporterOff_WhenUnsetOrOtherValue() - { - // Unset → false. - Assert.False(RuntimeOptions.Parse("dats", _ => null).RetailUiImporter); - - // Non-"1" values → false (mirrors RetailUi / other IsExactlyOne flags). - var envOther = new Dictionary - { - ["ACDREAM_RETAIL_UI_IMPORTER"] = "true", - }; - Assert.False(RuntimeOptions.Parse("dats", k => envOther.GetValueOrDefault(k)).RetailUiImporter); - } } From c1004847a2bed6881f14d64715fa57bb9b008ec4 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 16:36:47 +0200 Subject: [PATCH 51/99] docs(D.2b): record vitals default-flip shipped (importer is now the default vitals) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roadmap: update D.2b LayoutDesc importer entry to record that the default flip shipped 2026-06-15 (bf77a23) — importer is the default at ACDREAM_RETAIL_UI=1; vitals.xml + ACDREAM_RETAIL_UI_IMPORTER flag retired; window movable, resize deferred to Plan 2 (WindowManager). Plan: update "After Plan 1" to mark the flip DONE, clean up the Plan 2 description now that vitals.xml is gone. Register: - AP-37 "Why" cell: replace "Gated opt-in (ACDREAM_RETAIL_UI_IMPORTER)" with "Now the default vitals path (the hand-authored markup vitals was retired)" — the flag is gone. - IA-15: add row (was missing from this branch) — D.2b retail UI design stance, updated to note that the vitals window is now rendered by the LayoutDesc importer (dat chrome elements), not UiNineSlicePanel; UiNineSlicePanel/RetailChromeSprites now back only chat window + plugin panels. IA count header 14 → 15. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/architecture/retail-divergence-register.md | 5 +++-- docs/plans/2026-04-11-roadmap.md | 2 +- docs/superpowers/plans/2026-06-15-layoutdesc-importer.md | 6 +++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 30c1cd07..f0bc8ffa 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 dat-sprite window frames, not a byte-port of keystone.dll's LayoutDesc binary tree. The vitals window is now rendered by the LayoutDesc importer (dat chrome elements read directly from `LayoutDesc 0x2100006C`), not `UiNineSlicePanel`; `UiNineSlicePanel`/`RetailChromeSprites` now back only the chat window + plugin panels | `src/AcDream.App/UI/Layout/LayoutImporter.cs` (vitals) + `src/AcDream.App/UI/UiNineSlicePanel.cs` (chat/plugins) | 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 DATA-DRIVEN from the dat: the `LayoutImporter` reads `LayoutDesc 0x2100006C` and resolves chrome element positions + sprite ids directly from parsed dat fields; locked by the conformance fixture `tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json` | Remaining residual risk: anchor resolution at non-800×600 and the controls.ini cascade still lack an oracle — layout scaling at non-reference resolution and stylesheet token inheritance differ silently | `LayoutDesc 0x2100006C` (SHIPPED); `docs/research/2026-06-15-layoutdesc-format.md`; controls.ini tokens; keystone.dll layout eval (no PDB) | --- @@ -132,7 +133,7 @@ accepted-divergence entries (#96, #49, #50). | AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins) + outdoor-root attached scene emitters moved to the post-frame pass, not retail's single deferred alpha flush. Residual: building exteriors' / outside-stage dynamics' own translucent MESH batches still draw within their stage draw call (before later stage content) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) + `GameWindow` post-frame Scene pass | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the user-visible cases (#131 portal swirl, #132 candle flame indoors + outdoors) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) | | AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp) | `src/AcDream.App/Rendering/GameWindow.cs:6895` (per-frame predicate) + `:IsSealedDungeonCell` + `:OnLiveEntitySpawnedLocked`/`:OnLivePositionUpdated` (login/teleport pre-collapse hooks) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277 | | AP-35 | Point/spot lights use a single PER-PIXEL accumulation that ports `calc_point_light`'s `(1 − dist/falloff_eff)` LINEAR ramp (falloff_eff = Falloff × static_light_factor 1.3) + standard Lambert `N·L`; retail's path is PER-VERTEX Gouraud and additionally applies a half-Lambert wrap (`0.5·dist + N·L_vec`, lights surfaces down to `N·L ≥ −0.5`) and an x87-obscured normalization factor, neither ported | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag:52` (+ `mesh.frag`; `LightInfoLoader.cs:81` folds 1.3 into Range) | The linear ramp is the user-visible fix (kills the hard-disc "spotlight" edge, #133 A7); the dropped wrap/normalization only re-shade the gradient slightly, and per-pixel vs per-vertex Gouraud chiefly differs on coarse geometry. Half-Lambert wrap + factor are an x87-decompile refinement (same artifact class as GetPowerBarLevel AP-24) | Surfaces facing slightly away from a torch (`−0.5 ≤ N·L < 0`) stay dark where retail's wrap lights them faintly; near-light gradient shading differs subtly from retail's per-vertex bake | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); static_light_factor 0x00820e24 | -| AP-37 | LayoutDesc importer collapses the dat's nested meter structure (Type-7 meter → two Type-3 container children → three Type-3 image-slice grandchildren each) into `UiMeter`'s programmatic 3-slice fields (`BackLeft..FrontRight`) + reuses `UiMeter.DrawHBar`'s scissor-fill, instead of building those child nodes generically and porting `UIElement_Meter::DrawChildren`. Standalone Type-0 text elements are also skipped (vitals numbers render via `UiMeter.Label` bound by the controller; a dedicated dat-text widget is Plan 2). The inheritance `Merge` treats Width/Height==0 as "inherit from base", diverging from format-doc §12 rule 2 (documented inline in `ElementReader.cs`) | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`) + `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`BuildWidget` meter-child skip) | Reuses the tested `UiMeter` render that already visually matches retail's stacked vitals bars; the full nested-element + `DrawChildren` scissor port and a dat-text widget are deferred to Plan 2. Gated opt-in (`ACDREAM_RETAIL_UI_IMPORTER`) and locked by the conformance fixture (`tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json`) | A LayoutDesc whose meter structure differs from the vitals 2-container/3-slice shape, or a window needing standalone dat text, renders an empty/wrong meter or drops text — no oracle diff until the Plan-2 widgets land | `UIElement_Meter::DrawChildren` @0x46fbd0; `UIElement_Text::DrawSelf` @0x467aa0; `docs/research/2026-06-15-layoutdesc-format.md` | +| AP-37 | LayoutDesc importer collapses the dat's nested meter structure (Type-7 meter → two Type-3 container children → three Type-3 image-slice grandchildren each) into `UiMeter`'s programmatic 3-slice fields (`BackLeft..FrontRight`) + reuses `UiMeter.DrawHBar`'s scissor-fill, instead of building those child nodes generically and porting `UIElement_Meter::DrawChildren`. Standalone Type-0 text elements are also skipped (vitals numbers render via `UiMeter.Label` bound by the controller; a dedicated dat-text widget is Plan 2). The inheritance `Merge` treats Width/Height==0 as "inherit from base", diverging from format-doc §12 rule 2 (documented inline in `ElementReader.cs`) | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`) + `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`BuildWidget` meter-child skip) | Reuses the tested `UiMeter` render that already visually matches retail's stacked vitals bars; the full nested-element + `DrawChildren` scissor port and a dat-text widget are deferred to Plan 2. Now the default vitals path (the hand-authored markup vitals was retired) and locked by the conformance fixture (`tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json`) | A LayoutDesc whose meter structure differs from the vitals 2-container/3-slice shape, or a window needing standalone dat text, renders an empty/wrong meter or drops text — no oracle diff until the Plan-2 widgets land | `UIElement_Meter::DrawChildren` @0x46fbd0; `UIElement_Text::DrawSelf` @0x467aa0; `docs/research/2026-06-15-layoutdesc-format.md` | --- diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index c36e685d..dea685ef 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -425,7 +425,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. - **D.1 — 2D ortho overlay + font rendering.** ✅ SHIPPED 2026-04-17 as the dev-facing debug overlay (StbTrueTypeSharp system-font atlas + `TextRenderer` + `DebugOverlay`). - **✓ SHIPPED — D.2a — ImGui scaffold + `AcDream.UI.Abstractions` layer.** Shipped 2026-04-25. Wires ImGui as the short-term backend behind `ACDREAM_DEVTOOLS=1`. Defines `IPanel` / `IPanelHost` / `IPanelRenderer` / `ICommandBus` + the first ViewModel (`VitalsVM`) in the new `AcDream.UI.Abstractions` project. First real panel: `VitalsPanel` reading HP from `CombatState.GetHealthPercent`. **Backend pivoted Hexa.NET.ImGui → ImGui.NET + `Silk.NET.OpenGL.Extensions.ImGui` during integration** — Hexa's native OpenGL3 backend does its own GL function resolution via GLFW/SDL and crashed with `0xC0000005` in `ImGuiImplOpenGL3.InitNative` against Silk.NET (no GLFW/SDL present). The Silk.NET extension is purpose-built for this scenario and is the `ImGui.NET` mitigation path that `docs/plans/2026-04-24-ui-framework.md` already called out as a "one-morning operation". Stam/Mana return `float?` null in D.2a because absolute values need `LocalPlayerState` + `PlayerDescription (0x0013)` parsing (filed post-D.2a). 11 new `AcDream.UI.Abstractions.Tests` green. - **✓ SHIPPED — D.2b — Custom retail-look backend (Spec 1: markup engine + first panel).** Shipped 2026-06-14 (`626d06e`→`019350f`). Wired the dormant `UiHost`/`UiElement` tree into `GameWindow` (`ACDREAM_RETAIL_UI=1`) and built the **Approach-C markup engine**: `MarkupDocument` (XML → `UiElement` subtree, `{Binding}` reflection) + `ControlsIni` stylesheet loader + an `IUiRegistry` plugin UI surface (plugins ship markup + a binding, drained into the same `UiRoot`). First retail-faithful panel: a markup-driven Vitals window (`vitals.xml`) — 8-piece dat-sprite frame (`UiNineSlicePanel`) + three `UiMeter` bars (red/gold/blue + cur/max numbers) bound to `VitalsVM`, rendering live over the 3D world and coexisting with the ImGui devtools path. Retired divergence TS-30, added IA-15. Spec `docs/superpowers/specs/2026-06-14-d2b-retail-panel-frame-design.md`; plan `docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md`. NOTE the prove-out corrected two stale "facts": retail vitals are **bars not orbs**, stamina is **gold not cyan**. **Remaining: D.3 (AcFont — using the stb stopgap for now) + the glassy gradient bar sprite / brightness tune (polish); input integration (movable/clickable windows — the `UiRoot` drag/click machinery exists but isn't bridged to the Phase-K dispatcher yet); and the rest of the panels (D.5).** -- **✓ SHIPPED — D.2b LayoutDesc importer (Plan 1).** Shipped 2026-06-15. *Retroactive registration — the spec requested this phase but it was not pre-registered before implementation.* Data-driven vitals window from `LayoutDesc 0x2100006C`: `LayoutImporter` resolves `BaseElement`/`BaseLayoutId` inheritance, walks the `ElementDesc` tree, and builds a live `UiElement` tree via `DatWidgetFactory` (Type 7 → `UiMeter`; all others → `UiDatElement` generic renderer). `VitalsController` binds live HP/Stamina/Mana by element id (mirrors retail `gmVitalsUI`). Gated `ACDREAM_RETAIL_UI_IMPORTER=1`; coexists with the hand-authored `vitals.xml` path (nothing deleted). A/B visual gate **PASSED**: pixel-identical to the hand-authored window. Default flip (retiring `vitals.xml`) **deferred to Plan 2** — the importer window is static; faithful drag/resize requires the dat's own Type-9 resize grips + Type-2 drag bars (the Plan-2 window manager); flipping now would regress interactivity, violating the no-workaround rule. Spec: `docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md`; plan: `docs/superpowers/plans/2026-06-15-layoutdesc-importer.md`. +- **✓ SHIPPED — D.2b LayoutDesc importer (Plan 1 + default flip).** Plan 1 shipped 2026-06-15. *Retroactive registration — the spec requested this phase but it was not pre-registered before implementation.* Data-driven vitals window from `LayoutDesc 0x2100006C`: `LayoutImporter` resolves `BaseElement`/`BaseLayoutId` inheritance, walks the `ElementDesc` tree, and builds a live `UiElement` tree via `DatWidgetFactory` (Type 7 → `UiMeter`; all others → `UiDatElement` generic renderer). `VitalsController` binds live HP/Stamina/Mana by element id (mirrors retail `gmVitalsUI`). A/B visual gate **PASSED**: pixel-identical to the hand-authored window. **Default flip shipped 2026-06-15 (`bf77a23`):** the importer is now the default vitals window at `ACDREAM_RETAIL_UI=1`; the hand-authored `vitals.xml` and the `ACDREAM_RETAIL_UI_IMPORTER` flag were retired (`vitals.xml` recoverable from git history). The window is movable (Anchors=None + Draggable) but NOT resizable — the dat stacked-vitals layout is fixed-size (chrome edges near-pinned); faithful grip/dragbar resize for the whole toolkit is Plan 2. `MarkupDocument`/`UiNineSlicePanel` remain for the chat window + plugin panels. Spec: `docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md`; plan: `docs/superpowers/plans/2026-06-15-layoutdesc-importer.md`. - **D.3 — AcFont from portal.dat.** Replace stb_truetype system font with retail `Font` DBObjs (`0x40000000..0x40000FFF`) baked from `RenderSurface` source sheets — see research slice 03 §4. Preserves retail visual identity. **(D.2b dependency — needs the custom renderer.)** - **✓ SHIPPED — D.4 — Dat sprites + 9-slice panel backgrounds.** Shipped 2026-06-14 with D.2b. `TextureCache.GetOrUploadRenderSurface(id, out w, out h)` decodes `RenderSurface` (`0x06xxxxxx`) **directly** (not via the Surface→SurfaceTexture chain — the prove-out finding) → GL `Texture2D`; `DrawSprite` (explicit UV-rect, per-texture batch) added to `TextRenderer` + `UiRenderContext` + a `uUseTexture=2` RGBA frag branch; `UiNineSlicePanel` composes the universal 8-piece bevel (corners `0x060074C3..C6`, edges `0x060074BF/C0/C1/C2`, center `0x06004CC2`). Remaining art polish: the glassy gradient bar fill sprite (D.2b polish). - **D.5 — Core panels.** Attributes (`chunk_00470000.c:FUN_0047ba70`), Skills (same), Paperdoll (`chunk_004A0000.c:FUN_004A5200`), Inventory, Spellbook (`chunk_004C0000.c`), Fellowship, Allegiance. Each uses the port sketches in slice 05. **(Targets `AcDream.UI.Abstractions` — ships with D.2a using ImGui-rendered widgets; reskinned by D.2b.)** The *chat* panel originally listed under D.5 shipped early in Phase I (I.4 input + I.7 combat translator superseded the chat-panel design here); this entry now covers Attributes / Skills / Paperdoll / Inventory / Spellbook / Fellowship / Allegiance only. diff --git a/docs/superpowers/plans/2026-06-15-layoutdesc-importer.md b/docs/superpowers/plans/2026-06-15-layoutdesc-importer.md index 1ab9040f..cf4c734f 100644 --- a/docs/superpowers/plans/2026-06-15-layoutdesc-importer.md +++ b/docs/superpowers/plans/2026-06-15-layoutdesc-importer.md @@ -747,11 +747,11 @@ git commit -m "test(D.2b): vitals importer conformance (golden tree + A/B render ## After Plan 1 -**Plan 1 status: SHIPPED 2026-06-15, gated `ACDREAM_RETAIL_UI_IMPORTER=1`, pixel-identical; default flip → Plan 2.** +**Plan 1 status: SHIPPED 2026-06-15, pixel-identical.** -The Task 8 A/B visual gate **PASSED**: the importer-built vitals window is pixel-identical to the hand-authored `vitals.xml` window. The default flip (retiring `vitals.xml` and making the importer the sole path) is **DEFERRED to Plan 2**. Reason: the importer window is currently static — it has no drag or resize behavior. A "whole-window-draggable" hack would not be retail-faithful; the retail mechanism wires drag to the dat's own **Type-2 drag bars** and resize to **Type-9 resize grips**, both of which are Plan-2 window-manager responsibilities. Flipping the default now would regress the draggable/resizable hand-authored window without a faithful replacement, violating the no-workaround rule. The hand-authored `vitals.xml` path remains the default and is NOT deleted. +**Default flip DONE 2026-06-15 (`bf77a23`):** the importer is now the default vitals window at `ACDREAM_RETAIL_UI=1`. The hand-authored `vitals.xml` and the `ACDREAM_RETAIL_UI_IMPORTER` flag were retired (`vitals.xml` is recoverable from git history). The window is movable (Anchors=None + Draggable) but NOT resizable — the dat stacked-vitals layout (`0x2100006C`) is fixed-size (chrome edges near-pinned); faithful grip/dragbar resize for the whole toolkit is Plan 2. `MarkupDocument`/`UiNineSlicePanel` remain for the chat window + plugin panels. -**Plan 2** covers: the `WindowManager` (open/close/z-order/persist, drag via Type-2 bars, resize via Type-9 grips), re-driving the chat window (`ChatController`), the default flip retiring `vitals.xml` once drag/resize is wired, and extending the factory/renderer to the full long-tail of element types per the Task 1 enumeration. The phase is already registered in `docs/plans/2026-04-11-roadmap.md` as "D.2b LayoutDesc importer (Plan 1) — SHIPPED"; register Plan 2 in the roadmap before starting it. +**Plan 2** covers: the `WindowManager` (open/close/z-order/persist, drag via Type-2 drag bars, resize via Type-9 resize grips for the whole toolkit), re-driving the chat window (`ChatController`), and extending the factory/renderer to the full long-tail of element types per the Task 1 enumeration. Register Plan 2 in the roadmap before starting it. ## Self-review From 825536a2bd94c60eddcde26e84e1e9ca6362293f Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 16:41:41 +0200 Subject: [PATCH 52/99] docs(D.2b): re-retire TS-30 in register (restore branch state lost in --theirs merge) The earlier 'git checkout --theirs' resolution of the register merge conflict took main's whole file, which reverted two branch-only changes: IA-15 (re-added in c100484) and the TS-30 retirement. TS-30 (flat-rect UI panels) was retired by D.2b Spec 1 when UiNineSlicePanel shipped the 8-piece chrome and is doubly moot now that vitals draw the dat chrome via the importer. Removed the TS-30 row + its phase-gated reference; TS count 30->29. All section counts now match actual rows (IA 15 / AD 27 / AP 37 / TS 29 / UN 5). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/architecture/retail-divergence-register.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index f0bc8ffa..86152c4c 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -137,7 +137,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 | |---|---|---|---|---|---| @@ -170,7 +170,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 | --- @@ -217,8 +216,8 @@ M2 combat must land TS-2 (BspOnlyDispatch terms), TS-5 (CanJump gating), TS-23 (PK bits), TS-25 (stance in MoveToState), TS-17 (AttackConditions), and revisit AP-13 (ComputeDamage) + AP-24 (jump-charge constant via the 0x0056ADE0 decompile). Emote work must land TS-24 (command-list packing). -Membership Stage 2 must land TS-18 (BuildingCellId). D.2b lands TS-30; -the audio phase lands TS-9/TS-29; the animation-hook layer lands +Membership Stage 2 must land TS-18 (BuildingCellId). +The audio phase lands TS-9/TS-29; the animation-hook layer lands TS-10/TS-11/TS-12/TS-13/TS-14. --- From 8aa643f3e069a31ab73fd5ae986fba1f13bb2179 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 17:05:04 +0200 Subject: [PATCH 53/99] fix(D.2b): correct edge-anchor mapping (RightEdge==1=stretch) + enable vitals horizontal resize ToAnchors was inverted vs retail UIElement::UpdateForParentSizeChange @0x00462640: stretch is RightEdge==1 (not ==2/==4), LeftEdge==2 = track-right. Verified against all 19 vitals fixture pieces. Enables Resizable/ResizeX on the importer vitals root (the prior 'dat is fixed-size' conclusion was wrong). At-rest render unchanged (anchors only fire on resize). Added a 160->200 resize conformance test. Also fixed DatWidgetFactoryTests.RectAndAnchors_SetFromElementInfo which encoded the old inverted model (Right=2 expecting Right anchor; corrected to Right=1). Co-Authored-By: Claude Sonnet 4.6 --- docs/research/2026-06-15-layoutdesc-format.md | 75 ++++++++------- src/AcDream.App/Rendering/GameWindow.cs | 15 ++- src/AcDream.App/UI/Layout/ElementReader.cs | 39 ++------ .../UI/Layout/DatWidgetFactoryTests.cs | 9 +- .../UI/Layout/ElementReaderTests.cs | 91 ++++++++++++------- .../UI/Layout/LayoutConformanceTests.cs | 50 ++++++++++ 6 files changed, 174 insertions(+), 105 deletions(-) diff --git a/docs/research/2026-06-15-layoutdesc-format.md b/docs/research/2026-06-15-layoutdesc-format.md index 10e66e8f..e3fb8b45 100644 --- a/docs/research/2026-06-15-layoutdesc-format.md +++ b/docs/research/2026-06-15-layoutdesc-format.md @@ -139,38 +139,40 @@ These are `uint` fields on `ElementDesc`. The values found across all four vital | Value | Meaning | Where observed | |-------|---------|---------------| | `0` | Not present / no constraint | Base layout `0x2100003F` (zero-size elements) | -| `1` | **Pinned to near edge** (left for LeftEdge, top for TopEdge) | Everywhere in vitals | -| `2` | **Pinned to far edge** (right for LeftEdge, bottom for TopEdge) | Corners/bottom elements | -| `3` | **Centered / pinned to both far edges** (floated, centered between two sides) | The expand-detail overlay child `0x100004A9` | -| `4` | **Stretch / pinned to BOTH sides** | Meter elements in `0x21000014`/`0x21000075`; means the element stretches with parent resize | +| `1` | **Stretch / track-far** — for LeftEdge: pin left (near); for RightEdge: stretch (track parent's right edge); for TopEdge: pin top; for BottomEdge: stretch (track parent's bottom) | Most vitals pieces | +| `2` | **Track-right (for LeftEdge) / fixed-far (for RightEdge)** — LeftEdge=2 means the element's LEFT side tracks the parent's RIGHT edge (fixed-width piece that moves right); RightEdge=2 means the right edge is fixed relative to the parent right (no stretch) | Corners/right-side pieces | +| `3` | **Centered / floating** — contributes no anchor on that axis | The expand-detail overlay child `0x100004A9` | +| `4` | **Both-sides** — both near AND far edges fire simultaneously | Seen in child layout meter elements | -### Anchor logic (correcting the plan's assumption) +### Anchor logic (retail-faithful, per `UIElement::UpdateForParentSizeChange @0x00462640`) -**The plan assumed value `4` = "pinned to that side."** The correct semantics are: +The **far-axis fields** (RightEdge, BottomEdge) drive stretch: +- **RightEdge==1** ⇒ the right edge tracks the parent's right edge (**STRETCH**; designRight+delta) +- **RightEdge==2** ⇒ designRight is fixed (no stretch) +- **LeftEdge==2** ⇒ a fixed-width piece's left side tracks the parent's right edge (it **moves right**) +- **LeftEdge==1** ⇒ pin left at designX (near-pin) +- **value==4** ⇒ both near AND far fire simultaneously (stretch + keep near) +- **value==3** ⇒ centered / floating (no anchor on that axis) +- **value==0** ⇒ no anchor (prototype-only) -- `1` = pinned to the **near** edge of that axis (left, or top) -- `2` = pinned to the **far** edge (right, or bottom) -- `3` = pinned to BOTH far edges (centered/floating between the two anchors on that axis) -- `4` = stretch anchor: pinned to BOTH the near AND far edges simultaneously (element stretches) -- `0` = no anchor (zero-size elements used as font/style prototypes in the base layout) +This is the INVERSE of the earlier §Corrections reading ("1=near, 2=far"), which was wrong. The decomp is authoritative: `UIElement::UpdateForParentSizeChange @0x00462640` in `docs/research/named-retail/acclient_2013_pseudo_c.txt` lines 108459–108668. -Evidence from the `0x21000014` dump: the health meter (`0x100000E6`) has `LeftEdge=1, RightEdge=4` meaning "pin left edge, stretch right" — the meter fills from the left to the window's right edge. The stamina meter (`0x100000EC`) has `LeftEdge=4, RightEdge=4` meaning it stretches on both sides (centered at 270px, fills width with parent). - -**Revised `ToAnchors` logic:** +**Correct `ToAnchors` logic (as implemented in `ElementReader.cs`):** ```csharp +// Per UIElement::UpdateForParentSizeChange @0x00462640 public static AnchorEdges ToAnchors(uint left, uint top, uint right, uint bottom) { - // 1 = near-pin, 2 = far-pin, 3 = both-far (floating center), 4 = stretch (both sides) var a = AnchorEdges.None; - if (left == 1 || left == 4) a |= AnchorEdges.Left; - if (top == 1 || top == 4) a |= AnchorEdges.Top; - if (right == 2 || right == 4) a |= AnchorEdges.Right; - if (bottom == 2 || bottom == 4) a |= AnchorEdges.Bottom; + if (left == 1 || left == 4) a |= AnchorEdges.Left; + if (right == 1 || right == 4 || left == 2) a |= AnchorEdges.Right; + if (top == 1 || top == 4) a |= AnchorEdges.Top; + if (bottom == 1 || bottom == 4 || top == 2) a |= AnchorEdges.Bottom; if (a == AnchorEdges.None) a = AnchorEdges.Left | AnchorEdges.Top; // default: pin top-left return a; } ``` -Value `3` (floating center) is a "pin far but not near" on both axes — maps to Right+Bottom anchors but NOT Left+Top. This shows up only on the hide/show-detail overlay child (`0x100004A9`) which is visually centered in the bar. + +**Verified against all 19 vitals pieces** (format doc §11). At-rest render (no resize) is pixel-identical — anchors only fire on resize. Value `3` contributes no anchor on its axis and falls through to the Left|Top default only when all four values are 3 or 0. --- @@ -407,28 +409,31 @@ Each meter has: ## § Corrections to plan assumptions -### 1. Edge-flag "pinned" value is NOT simply `4` +### 1. Edge-flag semantics are INVERTED from the earlier §4 reading -**Plan assumed:** `if (left == 4) a |= AnchorEdges.Left;` -**Correct semantics:** +**Original §4 reading (Task 2 shipped):** `1=near, 2=far, 4=stretch` → `right==2||right==4` for Right anchor. +**That was wrong.** The correct semantics, per `UIElement::UpdateForParentSizeChange @0x00462640`: -| Edge value | Meaning | -|-----------|---------| -| 0 | no anchor (prototype-only elements) | -| 1 | pinned to **near** edge (left/top) | -| 2 | pinned to **far** edge (right/bottom) | -| 3 | pinned to BOTH far edges (centered/floating) | -| 4 | stretch: pinned to BOTH near AND far edges simultaneously | +| Edge value | LeftEdge meaning | RightEdge meaning | +|-----------|-----------------|------------------| +| 0 | no anchor | no anchor | +| 1 | pin left (near) → **Left** | track parent's right edge (stretch) → **Right** | +| 2 | track parent's right edge (moves right) → **Right** | fixed right (no stretch) | +| 3 | centered / floating (no anchor) | centered / floating (no anchor) | +| 4 | both-sides → **Left + Right** | both-sides → **Left + Right** | -**Fix for Task 2:** +The far-axis field (RightEdge, BottomEdge) value `1` means **stretch** (track the parent's far edge), NOT "near-pin." This is the INVERSE of what was documented in the original §4. + +**Correct `ToAnchors` (as fixed in `ElementReader.cs` 2026-06-15):** ```csharp +// Per UIElement::UpdateForParentSizeChange @0x00462640 public static AnchorEdges ToAnchors(uint left, uint top, uint right, uint bottom) { var a = AnchorEdges.None; - if (left == 1 || left == 4) a |= AnchorEdges.Left; - if (top == 1 || top == 4) a |= AnchorEdges.Top; - if (right == 2 || right == 4) a |= AnchorEdges.Right; - if (bottom == 2 || bottom == 4) a |= AnchorEdges.Bottom; + if (left == 1 || left == 4) a |= AnchorEdges.Left; + if (right == 1 || right == 4 || left == 2) a |= AnchorEdges.Right; + if (top == 1 || top == 4) a |= AnchorEdges.Top; + if (bottom == 1 || bottom == 4 || top == 2) a |= AnchorEdges.Bottom; if (a == AnchorEdges.None) a = AnchorEdges.Left | AnchorEdges.Top; return a; } diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index c4b55885..d4c33d71 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1810,16 +1810,21 @@ public sealed class GameWindow : IDisposable healthText: () => (_vitalsVm!.HealthCurrent, _vitalsVm.HealthMax) is (uint c, uint m) ? $"{c}/{m}" : "", staminaText: () => (_vitalsVm!.StaminaCurrent, _vitalsVm.StaminaMax) is (uint c, uint m) ? $"{c}/{m}" : "", manaText: () => (_vitalsVm!.ManaCurrent, _vitalsVm.ManaMax) is (uint c, uint m) ? $"{c}/{m}" : ""); - // Top-level window: user-positioned (Anchors.None so the per-frame anchor - // pass doesn't reset it) + movable, like the retired hand-authored panel. - // Resize is left off — the dat stacked-vitals layout (0x2100006C) is - // fixed-size (chrome edges near-pinned); faithful grip/dragbar-driven - // resize is the Plan-2 window manager. + // Top-level retail window: user-positioned (Anchors.None so the per-frame + // anchor pass doesn't reset it), movable, and horizontally resizable like + // retail. On a width change the dat edge-anchors reflow the pieces + // (UIElement::UpdateForParentSizeChange @0x00462640): top/bottom edges + + // the three bars stretch, corners stay 5px, the right edge/corners track + // the right side. Vertical resize is off (the layout has no vertical stretch). var vitalsRoot = imported.Root; vitalsRoot.Left = 10; vitalsRoot.Top = 30; vitalsRoot.ClickThrough = false; vitalsRoot.Anchors = AcDream.App.UI.AnchorEdges.None; vitalsRoot.Draggable = true; + vitalsRoot.Resizable = true; + vitalsRoot.ResizeX = true; + vitalsRoot.ResizeY = false; + vitalsRoot.MinWidth = 40f; _uiHost.Root.AddChild(vitalsRoot); Console.WriteLine("[D.2b] retail UI active — vitals window from LayoutDesc importer (0x2100006C)."); } diff --git a/src/AcDream.App/UI/Layout/ElementReader.cs b/src/AcDream.App/UI/Layout/ElementReader.cs index 31a402b3..061d59e9 100644 --- a/src/AcDream.App/UI/Layout/ElementReader.cs +++ b/src/AcDream.App/UI/Layout/ElementReader.cs @@ -68,42 +68,23 @@ public sealed class ElementInfo /// public static class ElementReader { - /// - /// Maps the four raw edge-anchor flag values from ElementDesc to the - /// bit-flag used by the UI layout engine. - /// - /// - /// The dat stores one uint per edge with these semantics (§4 of the - /// LayoutDesc format reference, 2026-06-15): - /// - /// 0 = no anchor (prototype-only elements — zero-size style stores) - /// 1 = pinned to the near edge (left for LeftEdge, top for TopEdge) - /// 2 = pinned to the far edge (right for RightEdge, bottom for BottomEdge) - /// 3 = floating / centered between both far edges (maps to neither Left nor Right) - /// 4 = stretch: pinned to BOTH near AND far edges simultaneously (element stretches with parent) - /// - /// - /// - /// - /// Default when no flags resolve: Left | Top (pin top-left, fixed size). - /// This matches elements whose all-zero edge flags indicate a no-reflow prototype. - /// - /// + /// Edge-anchor flags → AnchorEdges, per retail UIElement::UpdateForParentSizeChange + /// @0x00462640. The far-axis fields drive stretch: RightEdge==1 ⇒ the right edge tracks the + /// parent's right edge (stretch); LeftEdge==2 ⇒ a fixed-width element's left tracks the right + /// edge (it moves right). ==4 (not present in the vitals layout) = both-sides stretch; ==3 = + /// centered (no edge anchor → falls back to pin-top-left). This is the INVERSE of the earlier + /// format-doc §4 reading, which was wrong (it made every piece fixed-width). /// LeftEdge dat field value (0–4). /// TopEdge dat field value (0–4). /// RightEdge dat field value (0–4). /// BottomEdge dat field value (0–4). public static AnchorEdges ToAnchors(uint left, uint top, uint right, uint bottom) { - // 1 = near-pin, 2 = far-pin, 3 = both-far (floating center), 4 = stretch (both sides). - // Only 1 and 4 contribute the NEAR (Left/Top) anchor. - // Only 2 and 4 contribute the FAR (Right/Bottom) anchor. - // Value 3 contributes neither (floating center is handled by the UI engine differently). var a = AnchorEdges.None; - if (left == 1 || left == 4) a |= AnchorEdges.Left; - if (top == 1 || top == 4) a |= AnchorEdges.Top; - if (right == 2 || right == 4) a |= AnchorEdges.Right; - if (bottom == 2 || bottom == 4) a |= AnchorEdges.Bottom; + if (left == 1 || left == 4) a |= AnchorEdges.Left; + if (right == 1 || right == 4 || left == 2) a |= AnchorEdges.Right; + if (top == 1 || top == 4) a |= AnchorEdges.Top; + if (bottom == 1 || bottom == 4 || top == 2) a |= AnchorEdges.Bottom; if (a == AnchorEdges.None) a = AnchorEdges.Left | AnchorEdges.Top; // default: pin top-left return a; } diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index c2a66de1..15dc8355 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -36,10 +36,11 @@ public class DatWidgetFactoryTests // ── Test 4: Rect + anchors set from ElementInfo ─────────────────────────── /// - /// A Type-3 element with X=5,Y=21,W=150,H=16, Left=1,Top=1,Right=2 should have + /// A Type-3 element with X=5,Y=21,W=150,H=16, Left=1,Top=1,Right=1 should have /// its rect + anchors copied onto the returned widget. - /// Left=1 (near-pin → AnchorEdges.Left), Top=1 (near-pin → AnchorEdges.Top), - /// Right=2 (far-pin → AnchorEdges.Right), Bottom=0 (no anchor → neither). + /// Per UIElement::UpdateForParentSizeChange @0x00462640: + /// Left=1 → AnchorEdges.Left (near-pin); Top=1 → AnchorEdges.Top; + /// Right=1 → AnchorEdges.Right (stretch / track parent right); Bottom=0 → neither. /// Combined: Left | Top | Right. /// [Fact] @@ -51,7 +52,7 @@ public class DatWidgetFactoryTests X = 5, Y = 21, Width = 150, Height = 16, Left = 1, Top = 1, - Right = 2, Bottom = 0, + Right = 1, Bottom = 0, }; var e = DatWidgetFactory.Create(info, NoTex, null)!; Assert.Equal(5f, e.Left); diff --git a/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs b/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs index c489f88c..9d79f58d 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs @@ -4,34 +4,33 @@ namespace AcDream.App.Tests.UI.Layout; public class ElementReaderTests { - // ── ToAnchors ──────────────────────────────────────────────────────────── + // ── ToAnchors (decomp-backed: UIElement::UpdateForParentSizeChange @0x00462640) ───────────── /// - /// Edge value 4 = stretch (pinned to BOTH near AND far sides simultaneously). - /// LeftEdge=4 → Left anchor; RightEdge=4 → Right anchor. - /// TopEdge=1 → Top only (near-pin); BottomEdge=1 → near-pin (left/top axis), NOT Bottom. + /// Top edge (L=1,T=1,R=1,B=2): LeftEdge==1 → Left; RightEdge==1 → Right (stretch); + /// TopEdge==1 → Top; BottomEdge==2 (not 1/4, top≠2) → no Bottom. + /// This is the top chrome edge — it pins left, stretches width, pins top, fixed height. + /// Real vitals values from format doc §11 (0x10000634). /// [Fact] - public void EdgeFlagsToAnchors_LeftRight_Stretches() + public void ToAnchors_TopEdge_StretchesWidth() { - // left=4 (stretch ⇒ Left), top=1 (near-pin ⇒ Top), right=4 (stretch ⇒ Right), bottom=1 (near-pin of bottom axis ⇒ not Bottom) - var a = ElementReader.ToAnchors(left: 4, top: 1, right: 4, bottom: 1); + var a = ElementReader.ToAnchors(left: 1, top: 1, right: 1, bottom: 2); Assert.True(a.HasFlag(AnchorEdges.Left)); + Assert.True(a.HasFlag(AnchorEdges.Top)); Assert.True(a.HasFlag(AnchorEdges.Right)); Assert.False(a.HasFlag(AnchorEdges.Bottom)); } /// - /// Edge value 1 = pinned to the NEAR edge of that axis. - /// For LeftEdge: near = Left. For TopEdge: near = Top. - /// For RightEdge: value 1 means near-pin of the right axis → does NOT map to Right anchor. - /// For BottomEdge: value 1 means near-pin of the bottom axis → does NOT map to Bottom anchor. + /// TL corner (L=1,T=1,R=2,B=2): LeftEdge==1 → Left; RightEdge==2 (not 1/4), left≠2 → no Right; + /// TopEdge==1 → Top; BottomEdge==2, top≠2 → no Bottom. Fixed size, pinned top-left. + /// Real vitals values from format doc §11 (0x10000633). /// [Fact] - public void EdgeFlagsToAnchors_AllOnes_PinsTopLeftOnly() + public void ToAnchors_TlCorner_PinsTopLeftFixed() { - // 1 everywhere: only Left and Top anchors set (near-pins). Right/Bottom are far edges and value 1 is near-pin. - var a = ElementReader.ToAnchors(left: 1, top: 1, right: 1, bottom: 1); + var a = ElementReader.ToAnchors(left: 1, top: 1, right: 2, bottom: 2); Assert.True(a.HasFlag(AnchorEdges.Left)); Assert.True(a.HasFlag(AnchorEdges.Top)); Assert.False(a.HasFlag(AnchorEdges.Right)); @@ -39,18 +38,46 @@ public class ElementReaderTests } /// - /// Edge value 2 = pinned to the FAR edge of that axis. - /// For RightEdge: far = Right anchor. For BottomEdge: far = Bottom anchor. - /// For LeftEdge: value 2 means far-pin of the left axis → does NOT map to Left anchor. - /// For TopEdge: value 2 means far-pin of the top axis → does NOT map to Top anchor. + /// TR corner (L=2,T=1,R=1,B=2): LeftEdge==2 → triggers Right (track-right); RightEdge==1 → Right; + /// left≠1 → no Left; TopEdge==1 → Top; BottomEdge==2, top≠2 → no Bottom. + /// Fixed-width element whose left and right both track the parent's right edge. + /// Real vitals values from format doc §11 (0x10000635). /// [Fact] - public void EdgeFlagsToAnchors_AllTwos_PinsRightBottomOnly() + public void ToAnchors_TrCorner_TracksRight() { - // 2 everywhere: only Right and Bottom anchors set (far-pins). - var a = ElementReader.ToAnchors(left: 2, top: 2, right: 2, bottom: 2); + var a = ElementReader.ToAnchors(left: 2, top: 1, right: 1, bottom: 2); Assert.False(a.HasFlag(AnchorEdges.Left)); - Assert.False(a.HasFlag(AnchorEdges.Top)); + Assert.True(a.HasFlag(AnchorEdges.Top)); + Assert.True(a.HasFlag(AnchorEdges.Right)); + Assert.False(a.HasFlag(AnchorEdges.Bottom)); + } + + /// + /// Left edge (L=1,T=1,R=2,B=1): LeftEdge==1 → Left; RightEdge==2, left≠2 → no Right; + /// TopEdge==1 → Top; BottomEdge==1 → Bottom. Pins left+top+bottom, fixed width, stretches height. + /// Real vitals values from format doc §11 (0x10000636). + /// + [Fact] + public void ToAnchors_LeftEdge_StretchesHeight() + { + var a = ElementReader.ToAnchors(left: 1, top: 1, right: 2, bottom: 1); + Assert.True(a.HasFlag(AnchorEdges.Left)); + Assert.True(a.HasFlag(AnchorEdges.Top)); + Assert.False(a.HasFlag(AnchorEdges.Right)); + Assert.True(a.HasFlag(AnchorEdges.Bottom)); + } + + /// + /// All-ones (L=1,T=1,R=1,B=1): all four flags fire — Left, Right, Top, Bottom. + /// A piece pinned to all four sides stretches both horizontally and vertically. + /// + [Fact] + public void ToAnchors_Meter_StretchesBoth() + { + var a = ElementReader.ToAnchors(left: 1, top: 1, right: 1, bottom: 1); + Assert.True(a.HasFlag(AnchorEdges.Left)); + Assert.True(a.HasFlag(AnchorEdges.Top)); Assert.True(a.HasFlag(AnchorEdges.Right)); Assert.True(a.HasFlag(AnchorEdges.Bottom)); } @@ -66,19 +93,19 @@ public class ElementReaderTests } /// - /// Value 3 = floating/centered between both far edges on that axis (format doc §4). - /// The anchor mapping fires on near-pin (1) and stretch (4) for Left/Top, and on - /// far-pin (2) and stretch (4) for Right/Bottom — value 3 matches none of these rules. - /// Therefore all-3 edge flags contribute no anchor bits and fall through to the - /// Left|Top default (pin top-left, fixed size). - /// This test covers that corner case (element 0x100004A9 — expand-detail overlay). + /// Value 3 on left and right axes contributes no Left/Right anchor; + /// TopEdge==1 → Top; BottomEdge==1 → Bottom. + /// left=3 (not 1/4) → no Left; right=3 (not 1/4), left≠2 → no Right; + /// top=1 → Top; bottom=1 → Bottom. Result: Top|Bottom. /// [Fact] - public void EdgeFlagsToAnchors_ValueThree_FallsBackToTopLeft() + public void EdgeFlagsToAnchors_ValueThree_HorizAxes_YieldsTopBottom() { - // value 3 doesn't match any anchor rule; falls back to Left|Top default. - var a = ElementReader.ToAnchors(left: 3, top: 3, right: 3, bottom: 3); - Assert.Equal(AnchorEdges.Left | AnchorEdges.Top, a); + var a = ElementReader.ToAnchors(left: 3, top: 1, right: 3, bottom: 1); + Assert.False(a.HasFlag(AnchorEdges.Left)); + Assert.True(a.HasFlag(AnchorEdges.Top)); + Assert.False(a.HasFlag(AnchorEdges.Right)); + Assert.True(a.HasFlag(AnchorEdges.Bottom)); } // ── Merge ──────────────────────────────────────────────────────────────── diff --git a/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs index a2bcfe08..6e86b988 100644 --- a/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs @@ -138,4 +138,54 @@ public class LayoutConformanceTests foreach (var child in node.Children) CollectFontDids(child, acc); } + + // ── Test 5: Horizontal resize conformance (160→200) ────────────────────── + + /// + /// Proves end-to-end reflow for a 160→200 width change using the corrected + /// ToAnchors mapping (UIElement::UpdateForParentSizeChange @0x00462640). + /// + /// For each piece, margins are computed from the 160-wide design rect and then + /// is applied at parentW=200. + /// + /// Expected outcomes: + /// - TL corner (L=1,R=2): Left only → fixed at x=0, w=5 + /// - top edge (L=1,R=1): Left+Right → stretches to w=190 at x=5 + /// - TR corner (L=2,R=1): Right only → tracks right at x=195, w=5 + /// - meter (L=1,R=1): Left+Right → stretches to w=190 at x=5 + /// + [Fact] + public void HorizontalResize_160to200_ReflowsCorrectly() + { + const float designParentW = 160f; + const float newParentW = 200f; + const float parentH = 58f; + + // (piece, designX, designW, LeftEdge, RightEdge, expectedX, expectedW) + (string Piece, float DesignX, float DesignW, uint L, uint R, float ExpX, float ExpW)[] cases = + [ + ("TL corner", 0f, 5f, 1u, 2u, 0f, 5f ), + ("top edge", 5f, 150f, 1u, 1u, 5f, 190f), + ("TR corner", 155f, 5f, 2u, 1u, 195f, 5f ), + ("meter", 5f, 150f, 1u, 1u, 5f, 190f), + ]; + + foreach (var (piece, dX, dW, l, r, expX, expW) in cases) + { + // T/B values don't affect x/w; use real vitals values (top=1, bottom=2) + var anchors = ElementReader.ToAnchors(l, top: 1u, r, bottom: 2u); + + // Margins from the design rect at parentW=160 + float mL = dX; + float mR = designParentW - (dX + dW); + + // Reflow at parentW=200 (parentH irrelevant for x/w assertions) + var (x, _, w, _) = UiElement.ComputeAnchoredRect( + anchors, mL, mT: 0f, mR, mB: 0f, w0: dW, h0: 5f, parentW: newParentW, parentH); + + // xUnit 2.x Assert.Equal(float,float,int) = decimal-place precision + Assert.True(Math.Abs(x - expX) < 0.5f, $"{piece}: expected x={expX} got {x}"); + Assert.True(Math.Abs(w - expW) < 0.5f, $"{piece}: expected w={expW} got {w}"); + } + } } From 43064bab0989a9e83468a14d5f9df4f040c2c9ab Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 18:27:13 +0200 Subject: [PATCH 54/99] fix(D.2b): draw UI sprites in submission order so stamina/mana numbers render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TextRenderer batched sprites per-texture and drew each texture's whole buffer at its FIRST-insertion point. The dat-font glyph atlas is one shared texture used by all three vital numbers; it first appeared at the health bar, so all three numbers were emitted right after the health bars — then the stamina + mana bar sprites painted over their own numbers (only health survived). Replaced the per-texture dictionary with submission-ordered segments (consecutive same-texture quads still batch); each meter's number now draws after its own bars. The renderer's own comment had predicted this break once bars became sprites (importer did that). Removed the temporary UiMeter label diagnostic. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/TextRenderer.cs | 53 ++++++++++++++++------- src/AcDream.App/UI/UiMeter.cs | 1 + 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/src/AcDream.App/Rendering/TextRenderer.cs b/src/AcDream.App/Rendering/TextRenderer.cs index a0252518..bef2e2ca 100644 --- a/src/AcDream.App/Rendering/TextRenderer.cs +++ b/src/AcDream.App/Rendering/TextRenderer.cs @@ -29,7 +29,15 @@ public sealed unsafe class TextRenderer : IDisposable private readonly List _textBuf = new(8192); private readonly List _rectBuf = new(1024); - private readonly Dictionary> _spriteBufs = new(); + // Submission-ordered sprite segments: consecutive DrawSprite calls with the + // SAME texture batch into one segment; a texture change starts a new segment. + // Drawing segments in submission order preserves painter z-order for + // sprite-on-sprite UI. (The old per-texture dictionary drew a REUSED texture + // at its FIRST-insertion point, so later bar sprites covered glyphs emitted + // earlier via the shared dat-font atlas — the stamina/mana numbers vanished.) + private sealed class SpriteSeg { public uint Texture; public readonly List Verts = new(256); } + private readonly List _spriteSegs = new(); + private int _segUsed; private int _textVerts; private int _rectVerts; private Vector2 _screenSize; @@ -65,7 +73,7 @@ public sealed unsafe class TextRenderer : IDisposable _screenSize = screenSize; _textBuf.Clear(); _rectBuf.Clear(); - foreach (var b in _spriteBufs.Values) b.Clear(); + _segUsed = 0; // pool the SpriteSeg objects across frames _textVerts = 0; _rectVerts = 0; } @@ -139,12 +147,24 @@ public sealed unsafe class TextRenderer : IDisposable public void DrawSprite(uint texture, float x, float y, float w, float h, float u0, float v0, float u1, float v1, Vector4 tint) { - if (!_spriteBufs.TryGetValue(texture, out var buf)) + SpriteSeg seg; + if (_segUsed > 0 && _spriteSegs[_segUsed - 1].Texture == texture) { - buf = new List(256); - _spriteBufs[texture] = buf; + seg = _spriteSegs[_segUsed - 1]; // extend the current same-texture run } - AppendQuad(buf, x, y, w, h, u0, v0, u1, v1, tint); + else if (_segUsed < _spriteSegs.Count) + { + seg = _spriteSegs[_segUsed++]; // reuse a pooled segment + seg.Texture = texture; + seg.Verts.Clear(); + } + else + { + seg = new SpriteSeg { Texture = texture }; + _spriteSegs.Add(seg); + _segUsed++; + } + AppendQuad(seg.Verts, x, y, w, h, u0, v0, u1, v1, tint); } private static void AppendQuad(List buf, @@ -177,8 +197,7 @@ public sealed unsafe class TextRenderer : IDisposable /// Upload + draw accumulated rects + text. font may be null if only DrawRect was used. public void Flush(BitmapFont? font) { - bool hasSprites = false; - foreach (var b in _spriteBufs.Values) if (b.Count > 0) { hasSprites = true; break; } + bool hasSprites = _segUsed > 0; if (_textVerts == 0 && _rectVerts == 0 && !hasSprites) return; _shader.Use(); @@ -201,9 +220,10 @@ public sealed unsafe class TextRenderer : IDisposable // 1. RGBA dat sprites — window chrome / panel backgrounds (behind) // 2. Untextured rects — widget fills (e.g. vital bars) on the chrome // 3. Text glyphs — on top - // NOTE: this type-bucketed order is correct while bars are solid rects. - // When bars become gradient SPRITES, this must move to true submission - // (painter) order so sprite-on-sprite z is preserved (D.2b follow-up). + // Bucket 1 (sprites) draws in SUBMISSION (painter) order via _spriteSegs, + // so sprite-on-sprite z is preserved — each meter's dat-font number draws + // after its own bar sprites. Buckets 2 (rects) + 3 (debug text) composite + // on top, in that order. // 1. RGBA dat sprites first — one draw call per distinct GL texture. if (hasSprites) @@ -211,12 +231,13 @@ public sealed unsafe class TextRenderer : IDisposable _shader.SetInt("uUseTexture", 2); _gl.ActiveTexture(TextureUnit.Texture0); _shader.SetInt("uTex", 0); - foreach (var kv in _spriteBufs) + for (int i = 0; i < _segUsed; i++) { - if (kv.Value.Count == 0) continue; - _gl.BindTexture(TextureTarget.Texture2D, kv.Key); - UploadBuffer(kv.Value); - _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)(kv.Value.Count / FloatsPerVertex)); + var seg = _spriteSegs[i]; + if (seg.Verts.Count == 0) continue; + _gl.BindTexture(TextureTarget.Texture2D, seg.Texture); + UploadBuffer(seg.Verts); + _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)(seg.Verts.Count / FloatsPerVertex)); } } diff --git a/src/AcDream.App/UI/UiMeter.cs b/src/AcDream.App/UI/UiMeter.cs index bb5bb55b..f93737a3 100644 --- a/src/AcDream.App/UI/UiMeter.cs +++ b/src/AcDream.App/UI/UiMeter.cs @@ -16,6 +16,7 @@ namespace AcDream.App.UI; /// 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. From 34243f2c2619d1bd528a51a38b63e387365cad41 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 18:31:58 +0200 Subject: [PATCH 55/99] fix(D.2b): pixel-snap dat-font glyphs so vitals numbers stay sharp on resize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DrawStringDat placed each glyph quad at the raw (often fractional) pen/origin. When a bar resizes to a fractional width, the centered cur/max number lands on a sub-pixel x and the glyph atlas (linear-filtered) smears — the 'unsharp at certain sizes' artifact. Round each glyph's destination to whole pixels (the pen keeps its true fractional advance, so spacing is unaffected) — matches retail blitting glyphs to integer dest. User-confirmed sharp across resize widths. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/UiRenderContext.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/AcDream.App/UI/UiRenderContext.cs b/src/AcDream.App/UI/UiRenderContext.cs index 39727a0d..db23174d 100644 --- a/src/AcDream.App/UI/UiRenderContext.cs +++ b/src/AcDream.App/UI/UiRenderContext.cs @@ -97,8 +97,13 @@ public sealed class UiRenderContext if (!font.TryGetGlyph(text[i], out var g)) continue; - float gx = pen + g.HorizontalOffsetBefore; - float gy = originY + g.VerticalOffsetBefore; + // Pixel-snap each glyph's destination to whole pixels so the atlas samples + // texel-aligned. Without this, a fractional bar width after resize puts the + // centered number on a sub-pixel x and linear filtering smears the glyphs + // (the "unsharp at certain sizes" artifact). The pen keeps its true + // fractional advance, so only the per-glyph dest is snapped. + float gx = System.MathF.Round(pen + g.HorizontalOffsetBefore); + float gy = System.MathF.Round(originY + g.VerticalOffsetBefore); float gw = g.Width; float gh = g.Height; From 0474feb6cae84fe6df77adda35e038f1c057fb98 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 18:35:29 +0200 Subject: [PATCH 56/99] =?UTF-8?q?docs(D.2b):=20correct=20roadmap/plan=20?= =?UTF-8?q?=E2=80=94=20vitals=20window=20IS=20resizable=20(resize=20shippe?= =?UTF-8?q?d=208aa643f)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier 'not resizable / fixed-size' note was wrong (inverted edge-flag reading). Resize shipped: dat edge-anchors reflow per UIElement::UpdateForParentSizeChange. Noted the two number-render fixes (submission-order + glyph pixel-snap). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/plans/2026-04-11-roadmap.md | 2 +- docs/superpowers/plans/2026-06-15-layoutdesc-importer.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index dea685ef..e0e7130b 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -425,7 +425,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. - **D.1 — 2D ortho overlay + font rendering.** ✅ SHIPPED 2026-04-17 as the dev-facing debug overlay (StbTrueTypeSharp system-font atlas + `TextRenderer` + `DebugOverlay`). - **✓ SHIPPED — D.2a — ImGui scaffold + `AcDream.UI.Abstractions` layer.** Shipped 2026-04-25. Wires ImGui as the short-term backend behind `ACDREAM_DEVTOOLS=1`. Defines `IPanel` / `IPanelHost` / `IPanelRenderer` / `ICommandBus` + the first ViewModel (`VitalsVM`) in the new `AcDream.UI.Abstractions` project. First real panel: `VitalsPanel` reading HP from `CombatState.GetHealthPercent`. **Backend pivoted Hexa.NET.ImGui → ImGui.NET + `Silk.NET.OpenGL.Extensions.ImGui` during integration** — Hexa's native OpenGL3 backend does its own GL function resolution via GLFW/SDL and crashed with `0xC0000005` in `ImGuiImplOpenGL3.InitNative` against Silk.NET (no GLFW/SDL present). The Silk.NET extension is purpose-built for this scenario and is the `ImGui.NET` mitigation path that `docs/plans/2026-04-24-ui-framework.md` already called out as a "one-morning operation". Stam/Mana return `float?` null in D.2a because absolute values need `LocalPlayerState` + `PlayerDescription (0x0013)` parsing (filed post-D.2a). 11 new `AcDream.UI.Abstractions.Tests` green. - **✓ SHIPPED — D.2b — Custom retail-look backend (Spec 1: markup engine + first panel).** Shipped 2026-06-14 (`626d06e`→`019350f`). Wired the dormant `UiHost`/`UiElement` tree into `GameWindow` (`ACDREAM_RETAIL_UI=1`) and built the **Approach-C markup engine**: `MarkupDocument` (XML → `UiElement` subtree, `{Binding}` reflection) + `ControlsIni` stylesheet loader + an `IUiRegistry` plugin UI surface (plugins ship markup + a binding, drained into the same `UiRoot`). First retail-faithful panel: a markup-driven Vitals window (`vitals.xml`) — 8-piece dat-sprite frame (`UiNineSlicePanel`) + three `UiMeter` bars (red/gold/blue + cur/max numbers) bound to `VitalsVM`, rendering live over the 3D world and coexisting with the ImGui devtools path. Retired divergence TS-30, added IA-15. Spec `docs/superpowers/specs/2026-06-14-d2b-retail-panel-frame-design.md`; plan `docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md`. NOTE the prove-out corrected two stale "facts": retail vitals are **bars not orbs**, stamina is **gold not cyan**. **Remaining: D.3 (AcFont — using the stb stopgap for now) + the glassy gradient bar sprite / brightness tune (polish); input integration (movable/clickable windows — the `UiRoot` drag/click machinery exists but isn't bridged to the Phase-K dispatcher yet); and the rest of the panels (D.5).** -- **✓ SHIPPED — D.2b LayoutDesc importer (Plan 1 + default flip).** Plan 1 shipped 2026-06-15. *Retroactive registration — the spec requested this phase but it was not pre-registered before implementation.* Data-driven vitals window from `LayoutDesc 0x2100006C`: `LayoutImporter` resolves `BaseElement`/`BaseLayoutId` inheritance, walks the `ElementDesc` tree, and builds a live `UiElement` tree via `DatWidgetFactory` (Type 7 → `UiMeter`; all others → `UiDatElement` generic renderer). `VitalsController` binds live HP/Stamina/Mana by element id (mirrors retail `gmVitalsUI`). A/B visual gate **PASSED**: pixel-identical to the hand-authored window. **Default flip shipped 2026-06-15 (`bf77a23`):** the importer is now the default vitals window at `ACDREAM_RETAIL_UI=1`; the hand-authored `vitals.xml` and the `ACDREAM_RETAIL_UI_IMPORTER` flag were retired (`vitals.xml` recoverable from git history). The window is movable (Anchors=None + Draggable) but NOT resizable — the dat stacked-vitals layout is fixed-size (chrome edges near-pinned); faithful grip/dragbar resize for the whole toolkit is Plan 2. `MarkupDocument`/`UiNineSlicePanel` remain for the chat window + plugin panels. Spec: `docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md`; plan: `docs/superpowers/plans/2026-06-15-layoutdesc-importer.md`. +- **✓ SHIPPED — D.2b LayoutDesc importer (Plan 1 + default flip).** Plan 1 shipped 2026-06-15. *Retroactive registration — the spec requested this phase but it was not pre-registered before implementation.* Data-driven vitals window from `LayoutDesc 0x2100006C`: `LayoutImporter` resolves `BaseElement`/`BaseLayoutId` inheritance, walks the `ElementDesc` tree, and builds a live `UiElement` tree via `DatWidgetFactory` (Type 7 → `UiMeter`; all others → `UiDatElement` generic renderer). `VitalsController` binds live HP/Stamina/Mana by element id (mirrors retail `gmVitalsUI`). A/B visual gate **PASSED**: pixel-identical to the hand-authored window. **Default flip shipped 2026-06-15 (`bf77a23`):** the importer is now the default vitals window at `ACDREAM_RETAIL_UI=1`; the hand-authored `vitals.xml` and the `ACDREAM_RETAIL_UI_IMPORTER` flag were retired (`vitals.xml` recoverable from git history). The window is movable (Anchors=None + Draggable) AND horizontally resizable (Resizable/ResizeX, `8aa643f`): the dat edge-anchors reflow the pieces on width change per retail `UIElement::UpdateForParentSizeChange @0x00462640` (an earlier "fixed-size" note was wrong — inverted edge-flag reading, corrected; stretch is `RightEdge==1`). Faithful grip/dragbar-*driven* drag/resize INPUT is Plan 2. Post-flip number-render fixes (`43064ba`, `34243f2`): submission-order sprite draw (stamina/mana numbers had been overpainted by their own bars) + glyph pixel-snap (sharp at all resize widths). `MarkupDocument`/`UiNineSlicePanel` remain for the chat window + plugin panels. Spec: `docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md`; plan: `docs/superpowers/plans/2026-06-15-layoutdesc-importer.md`. - **D.3 — AcFont from portal.dat.** Replace stb_truetype system font with retail `Font` DBObjs (`0x40000000..0x40000FFF`) baked from `RenderSurface` source sheets — see research slice 03 §4. Preserves retail visual identity. **(D.2b dependency — needs the custom renderer.)** - **✓ SHIPPED — D.4 — Dat sprites + 9-slice panel backgrounds.** Shipped 2026-06-14 with D.2b. `TextureCache.GetOrUploadRenderSurface(id, out w, out h)` decodes `RenderSurface` (`0x06xxxxxx`) **directly** (not via the Surface→SurfaceTexture chain — the prove-out finding) → GL `Texture2D`; `DrawSprite` (explicit UV-rect, per-texture batch) added to `TextRenderer` + `UiRenderContext` + a `uUseTexture=2` RGBA frag branch; `UiNineSlicePanel` composes the universal 8-piece bevel (corners `0x060074C3..C6`, edges `0x060074BF/C0/C1/C2`, center `0x06004CC2`). Remaining art polish: the glassy gradient bar fill sprite (D.2b polish). - **D.5 — Core panels.** Attributes (`chunk_00470000.c:FUN_0047ba70`), Skills (same), Paperdoll (`chunk_004A0000.c:FUN_004A5200`), Inventory, Spellbook (`chunk_004C0000.c`), Fellowship, Allegiance. Each uses the port sketches in slice 05. **(Targets `AcDream.UI.Abstractions` — ships with D.2a using ImGui-rendered widgets; reskinned by D.2b.)** The *chat* panel originally listed under D.5 shipped early in Phase I (I.4 input + I.7 combat translator superseded the chat-panel design here); this entry now covers Attributes / Skills / Paperdoll / Inventory / Spellbook / Fellowship / Allegiance only. diff --git a/docs/superpowers/plans/2026-06-15-layoutdesc-importer.md b/docs/superpowers/plans/2026-06-15-layoutdesc-importer.md index cf4c734f..33afb841 100644 --- a/docs/superpowers/plans/2026-06-15-layoutdesc-importer.md +++ b/docs/superpowers/plans/2026-06-15-layoutdesc-importer.md @@ -749,7 +749,7 @@ git commit -m "test(D.2b): vitals importer conformance (golden tree + A/B render **Plan 1 status: SHIPPED 2026-06-15, pixel-identical.** -**Default flip DONE 2026-06-15 (`bf77a23`):** the importer is now the default vitals window at `ACDREAM_RETAIL_UI=1`. The hand-authored `vitals.xml` and the `ACDREAM_RETAIL_UI_IMPORTER` flag were retired (`vitals.xml` is recoverable from git history). The window is movable (Anchors=None + Draggable) but NOT resizable — the dat stacked-vitals layout (`0x2100006C`) is fixed-size (chrome edges near-pinned); faithful grip/dragbar resize for the whole toolkit is Plan 2. `MarkupDocument`/`UiNineSlicePanel` remain for the chat window + plugin panels. +**Default flip DONE 2026-06-15 (`bf77a23`):** the importer is now the default vitals window at `ACDREAM_RETAIL_UI=1`. The hand-authored `vitals.xml` and the `ACDREAM_RETAIL_UI_IMPORTER` flag were retired (`vitals.xml` is recoverable from git history). The window is movable (Anchors=None + Draggable) AND horizontally resizable (Resizable/ResizeX, `8aa643f`): on a width change the dat edge-anchors reflow the pieces (top/bottom edges + bars stretch, corners fixed 5px, right side tracks) per retail `UIElement::UpdateForParentSizeChange @0x00462640`. (The earlier "fixed-size" note was wrong — it came from an inverted edge-flag reading, now corrected; stretch is `RightEdge==1`.) Faithful grip/dragbar-*driven* drag/resize INPUT for the whole toolkit is Plan 2. Post-flip number-render fixes (`43064ba`, `34243f2`): submission-order sprite draw (stamina/mana numbers had been overpainted by their own bar sprites) + glyph pixel-snap (numbers stay sharp at all resize widths). `MarkupDocument`/`UiNineSlicePanel` remain for the chat window + plugin panels. **Plan 2** covers: the `WindowManager` (open/close/z-order/persist, drag via Type-2 drag bars, resize via Type-9 resize grips for the whole toolkit), re-driving the chat window (`ChatController`), and extending the factory/renderer to the full long-tail of element types per the Task 1 enumeration. Register Plan 2 in the roadmap before starting it. From 50758d479577c6eadcc94a06b82a04eff47bd63c Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 19:07:05 +0200 Subject: [PATCH 57/99] docs(D.2b): chat-window re-drive session handoff (Plan 2 chat piece) Captures: current hand-authored chat window (UiNineSlicePanel + UiChatView, read-only, debug font), the importer toolkit to reuse, the retail gmMainChatUI oracles, the open design questions (scope / behavioral widgets / dat font), and the first research step (find the chat LayoutDesc id). Resume via brainstorming. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-15-chat-window-redrive-handoff.md | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 docs/research/2026-06-15-chat-window-redrive-handoff.md diff --git a/docs/research/2026-06-15-chat-window-redrive-handoff.md b/docs/research/2026-06-15-chat-window-redrive-handoff.md new file mode 100644 index 00000000..33d12e92 --- /dev/null +++ b/docs/research/2026-06-15-chat-window-redrive-handoff.md @@ -0,0 +1,135 @@ +# Chat-window re-drive — session handoff (2026-06-15) + +**Status:** brainstorm STARTED (context gathered, design questions open) — not yet +designed or implemented. Resume with `superpowers:brainstorming`. + +**Branch:** `claude/hopeful-maxwell-214a12` — **continue UI work HERE** (the user's +call: UI stays on this branch; dungeon lighting / M1.5 goes to a *separate* branch +off `main`, it's unrelated and easy to merge). This branch is already current with +`main` (merged `5ac9d8c`). + +--- + +## Where we are (what shipped this session) + +**D.2b LayoutDesc importer — Plan 1 SHIPPED + flipped to default + post-flip fixes.** +The vitals window is now data-driven from the dat `LayoutDesc 0x2100006C` (no +per-window graphics code). Read **`claude-memory/project_d2b_retail_ui.md`** (the +SSOT crib) FIRST — it has the full architecture + every correction. Key commits: + +- `bf77a23` — flip: importer is the default vitals at `ACDREAM_RETAIL_UI=1`; the + hand-authored `vitals.xml` + the `ACDREAM_RETAIL_UI_IMPORTER` flag were retired. +- `8aa643f` — horizontal resize: edge-anchor mapping corrected to retail + `UIElement::UpdateForParentSizeChange @0x00462640` (`RightEdge==1`=stretch). +- `43064ba` — stamina/mana numbers: `TextRenderer` now draws sprites in + **submission (painter) order** (was per-texture batched → later bars overpainted + their own numbers). +- `34243f2` — number sharpness: `DrawStringDat` **pixel-snaps** each glyph dest. + +**The importer toolkit to REUSE (all in `src/AcDream.App/UI/Layout/`):** +- `ElementReader` — `ElementInfo` POCO + `Merge` (BaseElement/BaseLayoutId + inheritance) + `ToAnchors` (edge-flag → AnchorEdges, decomp-correct). +- `UiDatElement` — generic per-DrawMode sprite renderer (the fallback widget). +- `DatWidgetFactory` — `Type → widget` hybrid: Type 7→`UiMeter`, 12→skip, else + generic; sets rect + anchors + `ZOrder=ReadOrder`. **Behavioral Types map to a + dedicated widget; the widget CONSUMES the element's children (leaf — importer + does not recurse them).** This is the pattern the chat re-drive extends. +- `LayoutImporter` — `Import`/`ImportInfos`/`Build`/`BuildFromInfos` + cycle-guarded + `Resolve`. `ImportedLayout.FindElement(id)` for binding by id. +- `VitalsController` — binds live data to widgets by element id (mirrors retail + `gmVitalsUI::PostInit`). The chat controller will mirror this. +- Format reference: **`docs/research/2026-06-15-layoutdesc-format.md`** (ElementDesc + API, Type table, DrawMode, inheritance). NOTE its §4 edge-flag history: the FIRST + reading was inverted; the CORRECT model (per `@0x00462640`) is now in the doc + + `ToAnchors` — `RightEdge==1`=stretch, `LeftEdge==2`=track-right. + +--- + +## Next task: re-drive the chat window through the importer (Plan 2 chat piece) + +Today the chat window is **hand-authored**, not data-driven. The goal mirrors the +vitals re-drive: read the chat window's dat `LayoutDesc`, build it via +`LayoutImporter`, and bind the live chat through a `ChatController`. + +### Current chat window (what to reproduce / replace) +- Built in `src/AcDream.App/Rendering/GameWindow.cs` in the `if (_options.RetailUi)` + block (~line 1836, "Retail chat window"). +- `UiNineSlicePanel` (hand-authored 8-piece chrome) at `(10,432)`, `440×184`, + `MinWidth 180 / MinHeight 80`, draggable + resizable. +- Hosts a `UiChatView` (`src/AcDream.App/UI/UiChatView.cs`): scrollable transcript, + **bottom-pinned**, mouse-wheel scrollback, **drag-select + Ctrl+C copy + Ctrl+A**, + whole-line vertical clipping. **READ-ONLY** (no input box). Uses the **debug + bitmap font** (`_debugFont`), NOT the dat font. `LinesProvider` polled each frame. +- Data: `ChatVM` (`displayLimit: 200`) → `RecentLinesDetailed()` → per-`ChatKind` + colour via `RetailChatColor(...)` (local static in GameWindow). + +### Chat pipeline (already shipped, Phase I — reuse, don't rebuild) +`ChatLog (Core) → ChatVM (UI.Abstractions) → view`; outbound `input → +ChatInputParser → LiveCommandBus → WorldSession`. See +`claude-memory/project_chat_pipeline.md`. The re-drive is a VIEW/layout change; the +pipeline stays. + +### Retail chat UI classes (decomp oracles — analogous to gmVitalsUI) +`gmMainChatUI`, `gmFloatyMainChatUI`, `gmFloatyChatUI`, `gmChatOptionsUI` +(`docs/research/named-retail/acclient.h` ~line 54923; `symbols.json` has +`gmMainChatUI::Register` etc.). Chat-layout notes: +`docs/research/retail-ui/05-panels.md:120` (chat window layout) + +`06-hud-and-assets.md:651` (every chat window layout is a `LayoutDesc`). + +### FIRST research step (the Task-1 analogue): identify the chat `LayoutDesc` id +The vitals id was `0x2100006C`; the chat window's id is **NOT yet known**. Find it: +- `dump-vitals-layout [0xId]` enumerates LayoutDescs (it already lists all + layouts containing given element ids). Use it to scan for the chat window, or grep + the decomp for the layout id referenced by `gmMainChatUI`/`gmFloatyMainChatUI`. +- Then dump it and enumerate its element Types (expect a scroll/list region + + scrollbar, maybe a text-input/edit element + channel tabs) — this drives the + factory/widget work. + +--- + +## Open design questions (resume the brainstorm here) + +1. **Scope.** Re-drive the EXISTING read-only window (frame from dat + reuse + `UiChatView` for the transcript, parity with today), OR expand to the FULL retail + chat (input box for typing, channel tabs)? Recommendation to discuss: do the + frame re-drive + transcript first (parity), defer input/tabs to a follow-up — + but confirm with the user. +2. **Behavioral widgets.** The chat dat layout introduces the long-tail Types the + vitals didn't have — Type 5 `ListBox`, Type 0xB `Scrollbar`, maybe Type 0xC + `Text`/edit. Two options: + - **(A, recommended) Hybrid reuse** — like Type-7→`UiMeter`: map the transcript + region's Type → the existing `UiChatView` (which already scrolls/selects/copies); + a `ChatController` binds the tail by element id. Minimal new code; fastest parity. + - **(B) Port faithful widgets** — implement `UiScrollbar`/`UiListBox` per the + decomp so the dat's scrollbar element drives scrolling. More faithful, more work; + better as a later step. +3. **Dat font for the transcript.** Switch `UiChatView` from the debug bitmap font + to the dat font (`UiDatFont`, faithful + now pixel-snapped) — OR keep the debug + font for parity first? `UiChatView`'s measure/selection logic is `BitmapFont`-based, + so a dat-font port is non-trivial (a `UiDatFont` measure/advance path + selection + hit-test rework). Likely a follow-up, not the first cut. + +--- + +## Watchouts / lessons (don't regress these) +- **`TextRenderer` draws sprites in submission order** (`_spriteSegs`). Do NOT revert + to per-texture batching — it overpaints later same-atlas text (the stamina/mana bug). +- **`DrawStringDat` pixel-snaps glyphs.** Keep it (sharp text on resize). +- **Edge-flag/anchor model is `@0x00462640`** (`RightEdge==1`=stretch). The format + doc §4's first reading was inverted; trust the corrected `ToAnchors`. +- **Behavioral widgets are leaf** — the factory's widget consumes the element's dat + children; the importer doesn't recurse into them. Apply the same to the chat + transcript widget. +- **Don't fabricate dat reader internals** — `Chorizite.DatReaderWriter` is a NuGet + package (not in `references/`); verify member names via the dump tool / reflection. + +## Process for the next session +1. Read `claude-memory/project_d2b_retail_ui.md`, this handoff, and + `docs/research/2026-06-15-layoutdesc-format.md`. +2. Resume `superpowers:brainstorming` — settle scope + behavioral-widget approach + (the 3 questions above), present a design, write the spec. +3. Then `superpowers:writing-plans` → `superpowers:subagent-driven-development` + (same flow that shipped the vitals importer cleanly). +4. Stay on `claude/hopeful-maxwell-214a12`. Visual checks: launch live (ACE on + `127.0.0.1:9000`) with `ACDREAM_RETAIL_UI=1`; test accounts `testaccount2 / + testpassword2` or `notan / MittSnus81!` (character `+Je`). From 26cb34f1266195abd3ce2cc01bcb7f316d19ec30 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 19:38:27 +0200 Subject: [PATCH 58/99] @ docs(D.2b): chat-window re-drive design spec + list-ui-layouts research tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan-2 chat piece of the LayoutDesc importer. Identifies the chat window as LayoutDesc 0x21000006 (gmMainChatUI, element class 0x10000041) and grounds a faithful, data-driven re-drive in the named retail decomp (ChatInterface + gmMainChatUI + UIElement_Text/_Scrollable/_Scrollbar/_Menu) plus a user-provided retail screenshot. Design (full-faithful scope, user-approved): - transcript = UIElement_Text 0x10000011 (dat font, bottom-pinned, 10k behead cap, pixel scroll, 1 line/wheel-notch) - scrollbar = right-side track 0x10000012 + thumb 0x1000048c + up/down - input = editable UIElement_Text 0x10000016 (caret, 100-entry history, Enter/Send) - channel menu = UIElement_Menu 0x10000014 ("Chat" selector -> active channel) - shared ChatCommandRouter extracted from ChatPanel - screenshot correction: the four 0x10000522-525 left-edge elements are the numbered CHAT TABS (1-4), not scroll buttons (a research-agent inference the retail screenshot refutes) - deferred (need non-UI plumbing, each gets a divergence row): tab switching/ filtering, squelch, clickable name-tags, in-element word-wrap, styled runs, font config, opacity transition Tooling: AcDream.Cli `list-ui-layouts [0xRootType]` — read-only index of every UI LayoutDesc by root element class + size + element-Type histogram; how the chat layout was located (root type 0x10000041). Reusable for future panel re-drives. Spec: docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md Co-Authored-By: Claude Opus 4.8 (1M context) @ --- .../2026-06-15-chat-window-redrive-design.md | 267 ++++++++++++++++++ src/AcDream.Cli/LayoutIndexDump.cs | 101 +++++++ src/AcDream.Cli/Program.cs | 12 + 3 files changed, 380 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md create mode 100644 src/AcDream.Cli/LayoutIndexDump.cs diff --git a/docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md b/docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md new file mode 100644 index 00000000..342ed53d --- /dev/null +++ b/docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md @@ -0,0 +1,267 @@ +# D.2b — Chat-window re-drive (LayoutDesc importer, Plan 2 chat piece) — design + +**Date:** 2026-06-15 +**Branch:** `claude/hopeful-maxwell-214a12` (D.2b retail-UI track; lighting/M1.5 is a separate branch off main) +**Status:** design — approved scope, pending spec review +**Predecessor:** the LayoutDesc importer + the vitals re-drive +(`docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md`, +`docs/research/2026-06-15-layoutdesc-format.md`, +`claude-memory/project_d2b_retail_ui.md`). +**Handoff that opened this work:** `docs/research/2026-06-15-chat-window-redrive-handoff.md`. + +--- + +## 1. Goal + +Replace the hand-authored retail chat window (a `UiNineSlicePanel` hosting a +`UiChatView` at a guessed rect, built inline in `GameWindow.cs` under +`if (_options.RetailUi)`) with the **data-driven retail chat window** read from +the dat `LayoutDesc 0x21000006` (`gmMainChatUI`) via the existing `LayoutImporter`, +with **faithful behavioral widgets ported from the named retail decomp** and the +**dat font** — the same way the vitals window became data-driven. + +**The code is modern. The behavior is retail.** Every widget algorithm is ported +from `docs/research/named-retail/acclient_2013_pseudo_c.txt` with a cited +`class::method @address`. + +## 2. Approved scope + +**In scope (faithful core):** +- Data-driven window frame from `0x21000006` (bg sprites, resize bar, grip chrome, + translucency). +- Transcript: dat-font `UIElement_Text` port — scrollable, bottom-pinned, + per-line chat-kind color, 10k-glyph behead cap. +- Scrollbar: right-side track + thumb + up/down buttons, **pixel-based** scroll, + `thumbRatio = view/content`, wheel = **1 line per notch**. +- Input: editable one-line field — caret, insert/delete, 100-entry command + history (up/down arrow), focus sprite, Enter→submit. +- Channel menu (`Chat ▸`): dropdown of channels; selection sets the active + outbound channel (the `ChatInputParser` default channel). +- Send button + max/min button. +- `ChatCommandRouter`: the shared submit pipeline, extracted from `ChatPanel` + so the ImGui devtools chat and the retail chat share one routing path. + +**Deferred (each gets a `retail-divergence-register.md` row — these need *non-UI* +plumbing acdream lacks, they are NOT UI scope cuts):** +- **Numbered chat tabs (1–4) — switching + per-tab chat-type filtering.** The tab + *sprites* render (they come free from the importer), but clicking a tab to filter + which chat kinds show needs the per-tab `m_llTextTypeFilter` / + `m_chatNewNonVisibleTextIndicator` system. +- **Squelch toggle** (menu item 0) — needs a squelch subsystem. +- **Clickable name-tags** (`StartTell` on click) — needs `StringInfo`/`TextTag` + styled runs in `ChatLog`. +- **In-element word-wrap at panel width** — the transcript renders pre-split + `ChatLog` lines 1:1; faithful `GlyphList::Recalculate` wrap reworks the + selection/hit-test model (visual-row ≠ record). Highest-risk UI piece; deferred. +- **Per-glyph mixed-color runs / configurable font face+size** (`gmClient::sm_nFontFace`). +- **Active/inactive opacity switch** — a single default translucency is in scope; + the focused-brighter / unfocused-dimmer transition is deferred. + +## 3. Retail reference (the port target) + +`gmMainChatUI` (registered element class `0x10000041`, built from `LayoutDesc +0x21000006`) extends a base **`ChatInterface`**. `ChatInterface` owns the +transcript, input, inbound routing, submit, history, truncate and opacity; +`gmMainChatUI` adds the channel menu, squelch, max/min, tab visibility and +clickable name-tags. + +### 3.1 Element → role map (`0x21000006`) + +| Element | Type | Role | Decomp anchor | +|---|---|---|---| +| `0x1000000E` | `0x10000041` | window root (`gmMainChatUI`), bg `0x0600114D`, 800×100 authored | `gmMainChatUI::Register @0x4cd350` | +| `0x1000000F` | 9 Resizebar | top resize bar, img `0x06001125`, cursor `0x06005E66` | — | +| `0x1000046F` | 0 | max/min button (`Maximized 0x06005E64` / `Minimized 0x06005E65`) | `gmMainChatUI::HandleMaximizeButton @0x4cce50` | +| `0x10000010` | 3 Field | transcript panel bg `0x06001115` | — | +| **`0x10000011`** | 0 (UIElement_Text) | **transcript** — read-only, multiline, scrollable | `ChatInterface::PostInit @0x4f3e47` | +| `0x1000048c` | 0 | scroll **thumb** (child of transcript) | `ChatInterface::PostInit @0x4f3e79` | +| `0x10000012` | 0 | scrollbar **track** (right edge, 16×68) | `UIElement_Scrollable::GetScrollbarPointer_ @0x473ec0` | +| `0x10000013` | 3 Field | input bar bg `0x0600113A` | — | +| `0x10000014` | 6 Menu | **channel menu** (`Chat ▸`) — 14 items (squelch + 13 channels) | `gmMainChatUI::InitTalkFocusMenu @0x4cdc50`, `HandleSelection @0x4cd540` | +| **`0x10000016`** | 0 (UIElement_Text) | **input** — editable, one-line, focus sprite `0x060011AB` | `ChatInterface::PostInit @0x4f3e86` | +| `0x10000017/18` | 3 | input focus edges (sprite `0x06004D67`) | — | +| `0x10000019` | 0 | **Send** button (`0x06001915`/pressed `…16`/ghost `…34`) | `ChatInterface::ListenToElementMessage @0x4f51ea` | +| `0x10000522–525` | 0 | **numbered chat tabs 1–4** (left strip; Normal `0x06006218`/Hi `0x06006219`) | `gmMainChatUI::RecvNotice_SetPanelVisibility @0x4ccd80` | + +> **Screenshot correction (user-provided retail ground truth, 2026-06-15):** the +> four `0x10000522–525` elements are the **left-edge numbered chat tabs**, NOT the +> "line/page scroll buttons" a research agent inferred from their 16×16 vertical +> geometry. The scrollbar (track + thumb + up/down) is on the **right**. The exact +> dat ids of the right-side scroll up/down buttons are located during Task D +> (likely children of track `0x10000012` not surfaced in the top-level dump). + +> **BN field-name caveat:** the decomp's `m_chatEntry` / `m_chatLog` / +> `m_fCurrentOpacity` names are applied inconsistently across functions (a +> Binary-Ninja artifact). The roles above are fixed by the decisive evidence — +> the `Normal_focussed` sprite is on `0x10000016` (only an editable field gets a +> focus state) and the multiline geometry is `0x10000011` — corroborated by both +> surviving research agents. Port by **role**, not by the C++ member name. + +### 3.2 Key retail algorithms (cited) + +**Inbound** — `ChatInterface::RecvNotice_DisplayFinalStringInfo @0x4f4640`: +append `arg4` (prefix/timestamp) then `arg3` (body) to the transcript via +`UIElement_Text::AppendStringInfoWithFont` with per-chat-type color `arg2` (color +table built by `BuildChatColorLookupTable`). If glyph count > `0x2710` (10000), +`TruncateChatLog @0x4f4290` beheads from the top at a newline boundary. **Bottom-pin:** +capture `IsAtVerticalEnd` *before* appending; if it was true, `ScrollToPosition` +to the new end; else light the unread-text indicator. + +**Submit** — `ChatInterface::HandleEnterKey @0x4f52d0` (fired by the *Accept* +input-action, not raw `\r` — one-line mode drops the `\r` char) → `ProcessCommand +@0x4f5100`: read input text, dispatch, push to `m_InputHistory` (cap 100, drop +index 0 when full), reset history cursor to `0xFFFFFFFF`, clear input. The Send +button (`0x10000019`) is an alternate trigger via `ListenToElementMessage`. + +**Scroll** — `UIElement_Scrollable`: pixel offset `m_iScrollableY`, clamped to +`[0, contentHeight − viewHeight]` (`SetScrollableXY @0x4740c0`). `thumbRatio = +view/content` clamped to 1, bar hidden when content ≤ view +(`UpdateScrollbarSize_ @0x4741a0`). `posRatio = scrollY/(content−view)` +(`UpdateScrollbarPosition_ @0x473f20`). Scroll quantum = one line-height +(`UIElement_Text::InqScrollDelta @0x4689b0`); page = view height; **wheel = 1 line +per notch** (`HandleMouseWheel @0x471450`). + +**Input** — caret `m_nCursorPos` (glyph index); `GlyphList::FindPixelsFromPos +@0x472b40` = Σ glyph advances to the caret; midpoint-snap hit-test +`FindPosFromLineAndPixels @0x4732d0`; ~1 Hz blink. Glyph advance = +`HorizontalOffsetBefore + Width + HorizontalOffsetAfter` (signed bytes, +`Font::GetCharWidthA @0x4433f0`) — **already implemented** by +`UiDatFont.GlyphAdvance`. History: `SelectCommandFromHistory` (up=back, down=fwd), +sentinel `0xFFFFFFFF` = "not browsing". + +**Channel menu** — `InitTalkFocusMenu @0x4cdc50` fills `UIElement_Menu 0x10000014` +with 14 items: item 0 = squelch toggle, items 1–13 = channels carrying attr +`0x1000000B` = channel enum (1=Say, 2=Tell/Target, 3=Emote, 4=Fellowship, +5=Patron, 6=Trade, 7=Allegiance, 8–0xD=area/custom). `HandleSelection @0x4cd540` +reads the enum, calls `SetTalkFocus(enum)`, updates the label, marks the item +selected. + +## 4. Architecture (acdream) + +Faithful structure: an importer builds the generic frame; a **controller** +(`ChatInterface`+`gmMainChatUI`::PostInit analogue) binds behavior by element id +and swaps the transcript/input placeholders for behavioral widgets. New classes +live in `src/AcDream.App/UI/` (widgets, GL-side) and `src/AcDream.UI.Abstractions/` +(the shared submit router). + +| Component | Kind | Retail analogue | Responsibility | +|---|---|---|---| +| `ChatWindowController` | new (`App/UI/Layout/`) | `ChatInterface` + `gmMainChatUI` PostInit | import `0x21000006`; `FindElement(id)`; swap transcript/input widgets; wire scrollbar/menu/send/max-min; route in/outbound | +| `UiChatView` | **extend** (`App/UI/`) | `UIElement_Text` (transcript) | + `UiDatFont? DatFont`; dat-font measure/advance/draw; **1-line** wheel quantum; keep bottom-pin + drag-select + Ctrl+C | +| `UiChatInput` | new (`App/UI/`) | `UIElement_Text` (editable) | caret, insert/delete, 100-entry history, focus sprite swap, dat font, `Action? OnSubmit` | +| `UiScrollable` | new (`App/UI/`) | `UIElement_Scrollable` | pixel scroll math: `ScrollY`, `ContentHeight`, `ViewHeight`, `ClampScroll`, `ThumbRatio`, `ThumbOffsetRatio`, line/page delta | +| `UiChatScrollbar` | new (`App/UI/`) | composed scrollbar | own the imported track/thumb/up-down sprites; size+place thumb from `UiScrollable`; clicks/drag → `UiScrollable` | +| `UiChannelMenu` | new (`App/UI/`) | `UIElement_Menu` | dropdown popup of channels; selection → active `ChatChannelKind`; label reflects selection | +| `ChatCommandRouter` | new (`UI.Abstractions/Panels/Chat/`) | `ProcessCommand` | shared submit: client-command intercept → unknown-verb guard → `ChatInputParser.Parse(text, channel, lastTell, lastOutgoing)` → `Publish(SendChatCmd)` | +| `UiDatFont` | no change | `Font` | already implements retail glyph advance | + +**Why two widget classes (`UiChatView` + `UiChatInput`) when retail uses one +`UIElement_Text` with mode bits:** acdream's retained-mode widget layer predates +D.2b; the behavioral contract (read-only multiline scroll vs editable one-line) is +identical, only the class split differs. Accepted **ADAPTATION** divergence; both +classes share the `UiDatFont.GlyphAdvance` measure seam so geometry is consistent. + +**Placeholder swap:** the transcript (`0x10000011`) and input (`0x10000016`) +render no background sprite of their own (bg comes from parent panels +`0x10000010` / `0x10000013`), so `ChatWindowController` reads each placeholder's +rect + anchors, instantiates `UiChatView` / `UiChatInput` there, adds it to the +placeholder's parent, and removes the placeholder. Mirrors `GetChildRecursive(id)` +binding in `ChatInterface::PostInit`. + +## 5. Data flow + +- **Inbound:** `ChatLog → ChatVM.RecentLinesDetailed()` (200-deep tail) → + `UiChatView.LinesProvider` (per-`ChatKind` color via `RetailChatColor`). Pipeline + unchanged. Bottom-pin + 10k cap are `UiChatView`/`UiScrollable` behavior. +- **Outbound:** `UiChatInput.OnSubmit(text)` → + `ChatCommandRouter.Submit(text, vm, commandBus, activeChannel)` → `SendChatCmd` + → `LiveCommandBus` → `WorldSession`. `activeChannel` comes from `UiChannelMenu`. +- **Channel:** `UiChannelMenu` selection → `ChatWindowController._activeChannel` + (→ `ChatInputParser` default channel) + menu label update. +- **Scroll:** transcript content height → `UiScrollable` → `UiChatScrollbar` thumb; + wheel/buttons/drag → `UiScrollable.ScrollY` → transcript draw offset. + +## 6. Faithfulness decisions / divergence-register rows + +Add on landing (category in parens): +1. **(Adaptation)** Transcript + input are two classes (`UiChatView`/`UiChatInput`) + not one mode-flagged `UIElement_Text`. Behavior identical. +2. **(Approximation)** Transcript renders pre-split `ChatLog` lines 1:1; no + in-element word-wrap at panel width. Symptom: long lines not re-wrapped on + horizontal resize. `file:line` = `UiChatView.cs`. +3. **(Approximation)** One color per display line, not per-glyph styled runs. +4. **(Stopgap)** Numbered chat tabs render but don't switch / filter chat kinds. +5. **(Stopgap)** Squelch toggle + clickable name-tags render/parse-absent. +6. **(Approximation)** Single default translucency; no focused/unfocused opacity + transition; default dat font face+size (no `sm_nFontFace` config). + +Retire nothing (no existing register row is fixed by this work). + +## 7. Build sequence (tasks for the plan) + +Pipelineable where independent; `ChatWindowController` (G) and the `GameWindow` +cutover (H) are the integration barrier. + +- **A. `ChatCommandRouter`** — extract the submit flow from `ChatPanel` into a + pure `UI.Abstractions` helper; `ChatPanel` calls it; tests for client-command / + unknown-verb / parse / publish parity. *(UI.Abstractions; no GL.)* +- **B. `UiChatView` dat-font seam** — add `UiDatFont? DatFont`; prefer it in draw + + `HitChar` advance + selection measure + `LineHeight`; change `WheelLines` 3→1; + keep `BitmapFont` fallback. Tests: advance/hit-test with a synthetic dat font. +- **C. `UiScrollable`** — port `UIElement_Scrollable` math (pixel clamp, thumb + ratio/offset, line/page delta). Pure, fully unit-tested (no GL). +- **D. `UiChatScrollbar`** — own imported track/thumb/up-down sprites; size+place + thumb from `UiScrollable`; wheel/button/drag → scroll. Locate the right-side + up/down button ids in the dat here. +- **E. `UiChatInput`** — editable one-line widget: caret (`MeasurePrefix` = + `UiDatFont.MeasureWidth(text[..caret])`), insert/delete, Home/End/arrows, + 100-entry history with `−1`=live sentinel, focus sprite swap, `OnSubmit`. Tests + for caret math + history. +- **F. `UiChannelMenu`** — channel dropdown (port `UIElement_Menu` minimally); + 13 channels → `ChatChannelKind`; selection event + label. +- **G. `ChatWindowController`** — `LayoutImporter.Import(0x21000006)`; bind by id; + swap transcript/input; wire scrollbar/menu/send/max-min; route inbound (ChatVM) + + outbound (`ChatCommandRouter`); translucency. +- **H. `GameWindow` cutover** — replace the hand-authored + `UiNineSlicePanel`+`UiChatView` block with `ChatWindowController`; default + bottom-left position + resizable; remove dead code; add divergence rows; + `dotnet build` + `dotnet test` green. + +## 8. Testing strategy + +- **Pure/unit (no GL, no dats):** `ChatCommandRouter` parity; `UiScrollable` + clamp/thumb/delta golden values from the decomp; `UiChatInput` caret index ↔ + pixel + history navigation; `UiChatView` dat-font advance/hit-test via the + `Func` seam. +- **Layout/import (dat-free fixture):** extend the importer fixture pattern with a + `chat_21000006.json` tree (via `ImportInfos`) asserting the element→role map and + rects. +- **Real-dat smoke:** `LayoutImporter.Import(0x21000006)` against the live dat + resolves the root + all bound ids before wiring (guarded, like the vitals smoke). +- **Visual acceptance (user):** launch live `ACDREAM_RETAIL_UI=1`; compare to the + retail screenshot — transcript scrolls, input types + sends, channel menu + switches, Send works, scrollbar drags, window moves/resizes, translucency. + +## 9. Acceptance criteria + +- [ ] Chat window is built from `LayoutDesc 0x21000006` via `LayoutImporter` — no + hand-authored chat rect remains in `GameWindow.cs`. +- [ ] Transcript renders inbound chat in the **dat font**, per-`ChatKind` color, + bottom-pinned, 10k-cap, mouse-wheel = 1 line/notch, drag-select + Ctrl+C kept. +- [ ] Right-side scrollbar: thumb sizes to content, drag + up/down scroll the + transcript. +- [ ] Input: type, caret moves, backspace/delete, up/down history, **Enter and the + Send button both submit** through `ChatCommandRouter` → wire. +- [ ] `Chat ▸` menu opens, lists channels, selection changes the outbound channel + + updates the label. +- [ ] Max/min toggles window height; window moves + resizes; translucent frame. +- [ ] Every ported widget cites a `class::method @address`; every deferral has a + divergence-register row. +- [ ] `dotnet build` + `dotnet test` green; user visual sign-off. + +## 10. Deferred / follow-ups (filed, not built) + +In-element word-wrap (+ selection rework); numbered-tab switching + per-tab chat +filtering; squelch; clickable name-tags; per-glyph styled runs; configurable font +face/size; active/inactive opacity transition; the unidentified top-level Type-5 +ListBox `0x1000001D` (not bound by `ChatInterface`; likely a floaty/options element). diff --git a/src/AcDream.Cli/LayoutIndexDump.cs b/src/AcDream.Cli/LayoutIndexDump.cs new file mode 100644 index 00000000..5276486c --- /dev/null +++ b/src/AcDream.Cli/LayoutIndexDump.cs @@ -0,0 +1,101 @@ +using System.Reflection; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; +using DatReaderWriter.Types; + +namespace AcDream.Cli; + +/// +/// Read-only research diagnostic: index EVERY UI in the +/// dat by its root element's Type + size + an element-Type histogram, so a +/// panel re-drive can locate its layout from the decomp-registered class id +/// (e.g. gmMainChatUI registers type 0x10000041 → the chat window +/// is the layout whose root element has Type 0x10000041). Optionally filter to a +/// single root Type. No writes; purely a console dump used during brainstorming. +/// +public static class LayoutIndexDump +{ + public static int Run(string datDir, string? rootTypeText) + { + if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + + uint? filter = null; + if (!string.IsNullOrWhiteSpace(rootTypeText)) + { + var t = rootTypeText.Trim(); + if (t.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) t = t[2..]; + if (uint.TryParse(t, System.Globalization.NumberStyles.HexNumber, null, out var f)) filter = f; + } + + Console.WriteLine(filter is { } ff + ? $"=== LayoutDescs with a root element of Type 0x{ff:X8} ===" + : "=== All LayoutDescs (id : root element Type : size : #elements : type histogram) ==="); + + int total = 0, shown = 0; + foreach (var id in dats.GetAllIdsOfType().OrderBy(x => x)) + { + var l = dats.Get(id); + if (l is null) continue; + total++; + + // The root is the single top-level element (or, if several, the largest). + ElementDesc? root = null; + foreach (var kv in l.Elements) + if (root is null || Area(kv.Value) > Area(root)) root = kv.Value; + if (root is null) continue; + + if (filter is { } want && root.Type != want) continue; + shown++; + + var hist = new SortedDictionary(); + int count = 0; + CountTypes(root, hist, ref count); + string h = string.Join(" ", hist.Select(kv => $"{TypeName(kv.Key)}×{kv.Value}")); + Console.WriteLine( + $" 0x{id:X8} root=0x{root.ElementId:X8} type=0x{root.Type:X8}({TypeName(root.Type)}) " + + $"{root.Width}x{root.Height} n={count} [{h}]"); + } + + Console.WriteLine(); + Console.WriteLine($"shown {shown} / {total} LayoutDescs."); + return 0; + } + + private static long Area(ElementDesc e) => (long)e.Width * e.Height; + + private static void CountTypes(ElementDesc e, SortedDictionary hist, ref int count) + { + count++; + hist[e.Type] = hist.TryGetValue(e.Type, out var c) ? c + 1 : 1; + foreach (var kv in e.Children) + CountTypes(kv.Value, hist, ref count); + } + + private static string TypeName(uint t) => t switch + { + 0 => "Text0", + 1 => "Button", + 2 => "Dragbar", + 3 => "Field", + 5 => "ListBox", + 6 => "Menu", + 7 => "Meter", + 8 => "Panel", + 9 => "Resizebar", + 0xB => "Scrollbar", + 0xC => "Text", + 0xD => "Viewport", + 0xE => "Browser", + 0x10 => "ColorPicker", + 0x11 => "GroupBox", + 0x12 => "Proto", + 0x10000041 => "gmMainChatUI", + 0x10000040 => "gmFloatyChatUI", + 0x10000050 => "gmFloatyMainChatUI", + 0x10000042 => "gmChatOptionsUI", + 0x10000009 => "gmVitalsUI", + _ => $"0x{t:X}", + }; +} diff --git a/src/AcDream.Cli/Program.cs b/src/AcDream.Cli/Program.cs index 6be503c4..44094b55 100644 --- a/src/AcDream.Cli/Program.cs +++ b/src/AcDream.Cli/Program.cs @@ -31,6 +31,18 @@ if (args.Length >= 1 && args[0] == "dump-vitals-layout") return VitalsLayoutDump.Run(dvlDatDir, dvlLayout); } +if (args.Length >= 1 && args[0] == "list-ui-layouts") +{ + string? luiDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + string? luiRootType = args.ElementAtOrDefault(2); + if (string.IsNullOrWhiteSpace(luiDatDir)) + { + Console.Error.WriteLine("usage: AcDream.Cli list-ui-layouts [0xRootType]"); + return 2; + } + return LayoutIndexDump.Run(luiDatDir, luiRootType); +} + if (args.Length >= 1 && args[0] == "render-vitals-mockup") { string? rvmDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR"); From 3d25e8760f93af52110bd98116c30e6fc17f85f4 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 22:04:35 +0200 Subject: [PATCH 59/99] @ docs(D.2b): chat-window re-drive implementation plan (8 tasks A-H) TDD task breakdown for the data-driven chat window: ChatCommandRouter extraction (A), UiChatView dat-font (B), UiScrollable + wire-in (C/C2), UiChatScrollbar (D), UiChatInput (E), UiChannelMenu (F), ChatWindowController bind/route (G), GameWindow cutover + divergence rows (H). Each ported widget cites its retail class::method. Plan: docs/superpowers/plans/2026-06-15-chat-window-redrive.md Spec: docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md Co-Authored-By: Claude Opus 4.8 (1M context) @ --- .../plans/2026-06-15-chat-window-redrive.md | 1484 +++++++++++++++++ 1 file changed, 1484 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-15-chat-window-redrive.md diff --git a/docs/superpowers/plans/2026-06-15-chat-window-redrive.md b/docs/superpowers/plans/2026-06-15-chat-window-redrive.md new file mode 100644 index 00000000..ab96b033 --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-chat-window-redrive.md @@ -0,0 +1,1484 @@ +# Chat-window re-drive Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the hand-authored retail chat window with a data-driven one built from dat `LayoutDesc 0x21000006` (`gmMainChatUI`), with faithful behavioral widgets ported from the named retail decomp and the dat font. + +**Architecture:** The existing `LayoutImporter` builds the generic frame (bg sprites, resize bar, grip chrome, tabs, send button) from the dat. A new `ChatWindowController` (the `ChatInterface`/`gmMainChatUI::PostInit` analogue) binds behavior by element id: it swaps the transcript/input placeholder nodes for new behavioral widgets, wires the scrollbar/menu/send/max-min, and routes inbound chat (from `ChatVM`) and outbound (through a shared `ChatCommandRouter`). New widgets port `UIElement_Text`/`_Scrollable`/`_Scrollbar`/`_Menu`. + +**Tech Stack:** C# / .NET 10, Silk.NET (GL), the in-tree retained-mode UI toolkit (`src/AcDream.App/UI/`), `DatReaderWriter` (dat reads), xUnit (`tests/`). + +**Spec:** `docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md` — read it first. It has the element→role map, decomp citations, and the divergence rows. The decomp is `docs/research/named-retail/acclient_2013_pseudo_c.txt`. + +--- + +## File Structure + +**Create:** +- `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs` — shared submit pipeline (client-command intercept → unknown-verb guard → `ChatInputParser.Parse` → `Publish(SendChatCmd)`). Pure, no GL. +- `src/AcDream.App/UI/UiScrollable.cs` — pixel-scroll coordinator (ports `UIElement_Scrollable` math). Pure, no GL. +- `src/AcDream.App/UI/UiChatInput.cs` — editable one-line text widget (ports `UIElement_Text` edit path). +- `src/AcDream.App/UI/UiChatScrollbar.cs` — right-side scrollbar widget (track + thumb + up/down) driving a `UiScrollable`. +- `src/AcDream.App/UI/UiChannelMenu.cs` — channel-selector dropdown (ports `UIElement_Menu`). +- `src/AcDream.App/UI/Layout/ChatWindowController.cs` — import + bind-by-id + route (the `ChatInterface`/`gmMainChatUI` analogue). +- Tests: `tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs`, + `tests/AcDream.App.Tests/UI/UiScrollableTests.cs`, + `tests/AcDream.App.Tests/UI/UiChatInputTests.cs`, + `tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs`. + +**Modify:** +- `src/AcDream.App/UI/UiChatView.cs` — add `UiDatFont? DatFont`; dat-font measure/advance/draw; wheel = 1 line/notch; `UiScrollable` integration. +- `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs` — call `ChatCommandRouter` instead of the inline submit block. +- `src/AcDream.App/Rendering/GameWindow.cs` — replace the hand-authored chat block (~line 1836) with `ChatWindowController`. +- `docs/architecture/retail-divergence-register.md` — add the 6 deferral rows. +- `docs/plans/2026-04-11-roadmap.md` — mark the chat re-drive landed. + +--- + +## Task A: `ChatCommandRouter` (shared submit pipeline) + +Extract the submit + client-command logic from `ChatPanel` so both the ImGui chat and the retail chat dispatch identically. `ChatPanel` currently hardcodes `ChatChannelKind.Say`; the router parameterizes the default channel (the retail chat passes the channel-menu selection). + +**Files:** +- Create: `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs` +- Test: `tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs` +- Modify: `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs` (call the router) + +- [ ] **Step 1: Write the failing tests** + +Create `tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs`: + +```csharp +using AcDream.Core.Chat; +using AcDream.UI.Abstractions; +using AcDream.UI.Abstractions.Panels.Chat; +using Xunit; + +namespace AcDream.UI.Abstractions.Tests.Panels.Chat; + +public class ChatCommandRouterTests +{ + // Minimal in-memory command bus capturing the last published SendChatCmd. + private sealed class CaptureBus : ICommandBus + { + public SendChatCmd? Last; + public void Publish(T command) where T : notnull + { + if (command is SendChatCmd c) Last = c; + } + } + + private static (ChatVM vm, ChatLog log, CaptureBus bus) Fixture() + { + var log = new ChatLog(); + var vm = new ChatVM(log, displayLimit: 50); + return (vm, log, new CaptureBus()); + } + + [Fact] + public void PlainText_PublishesOnDefaultChannel() + { + var (vm, _, bus) = Fixture(); + var outcome = ChatCommandRouter.Submit("hello there", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.Sent, outcome); + Assert.NotNull(bus.Last); + Assert.Equal(ChatChannelKind.Say, bus.Last!.Channel); + Assert.Equal("hello there", bus.Last.Text); + } + + [Fact] + public void DefaultChannel_IsHonored() + { + var (vm, _, bus) = Fixture(); + ChatCommandRouter.Submit("hi", vm, bus, ChatChannelKind.Fellowship); + Assert.Equal(ChatChannelKind.Fellowship, bus.Last!.Channel); + } + + [Fact] + public void ClearCommand_DrainsLog_DoesNotPublish() + { + var (vm, log, bus) = Fixture(); + log.OnSystemMessage("x", 0); + var outcome = ChatCommandRouter.Submit("/clear", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.ClientHandled, outcome); + Assert.Null(bus.Last); + Assert.Empty(log.Snapshot()); + } + + [Fact] + public void UnknownSlashVerb_ShowsSystemMessage_DoesNotPublish() + { + var (vm, log, bus) = Fixture(); + var outcome = ChatCommandRouter.Submit("/notacommand", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.UnknownCommand, outcome); + Assert.Null(bus.Last); + Assert.Contains(log.Snapshot(), e => e.Text.Contains("Unknown command")); + } + + [Fact] + public void EmptyInput_DoesNothing() + { + var (vm, _, bus) = Fixture(); + var outcome = ChatCommandRouter.Submit(" ", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.Empty, outcome); + Assert.Null(bus.Last); + } +} +``` + +> Verify the `ChatLog` / `ICommandBus` / `ChatVM` APIs used above match the real +> types before running (`ChatLog.OnSystemMessage(string, int)`, `ChatLog.Snapshot()`, +> `ChatLog.Clear()`, `ICommandBus.Publish`). Adjust the fixture if signatures differ. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test tests/AcDream.UI.Abstractions.Tests --filter ChatCommandRouterTests` +Expected: FAIL — `ChatCommandRouter` / `SubmitOutcome` do not exist. + +- [ ] **Step 3: Implement `ChatCommandRouter`** + +Create `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`. Move the +client-command + unknown-verb + parse + publish logic out of `ChatPanel` +(`ChatPanel.TryHandleClientCommand` + the submit block at `ChatPanel.cs:191-242`): + +```csharp +using System; +using AcDream.UI.Abstractions.Panels.Chat; + +namespace AcDream.UI.Abstractions.Panels.Chat; + +/// What a submit did, so the caller can clear its input + give feedback. +public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped } + +/// +/// Shared chat-submit pipeline (retail ChatInterface::ProcessCommand @0x4f5100 +/// analogue). Both the ImGui devtools and the retail +/// chat window route through here so command handling stays in one place. +/// +/// Order mirrors the prior inline flow: +/// client-command intercept → unknown-slash-verb guard → +/// → Publish(SendChatCmd). +/// +public static class ChatCommandRouter +{ + public static SubmitOutcome Submit( + string raw, ChatVM vm, ICommandBus bus, ChatChannelKind defaultChannel) + { + ArgumentNullException.ThrowIfNull(vm); + ArgumentNullException.ThrowIfNull(bus); + var trimmed = (raw ?? string.Empty).Trim(); + if (trimmed.Length == 0) return SubmitOutcome.Empty; + + if (TryHandleClientCommand(trimmed, vm)) return SubmitOutcome.ClientHandled; + + // A '/' prefix is a command, never speech — unknown ones get local feedback + // instead of leaking to the server as chat. (@ verbs pass through to ACE.) + if (trimmed[0] == '/') + { + var verb = ChatInputParser.GetVerbToken(trimmed); + if (!ChatInputParser.IsKnownVerb(verb)) + { + vm.ShowSystemMessage( + $"Unknown command: {verb}. Type /help for the list of supported commands."); + return SubmitOutcome.UnknownCommand; + } + } + + var parsed = ChatInputParser.Parse( + trimmed, defaultChannel, vm.LastIncomingTellSender, vm.LastOutgoingTellTarget); + if (parsed is { } p) + { + bus.Publish(new SendChatCmd(p.Channel, p.TargetName, p.Text)); + return SubmitOutcome.Sent; + } + return SubmitOutcome.Dropped; // e.g. "/t Name" with no message + } + + private static bool TryHandleClientCommand(string trimmed, ChatVM vm) + { + if (EqAny(trimmed, "/help", "/?", "/h", "@help", "@?", "@h")) + { vm.ShowSystemMessage(BuildHelpText()); return true; } + if (EqAny(trimmed, "/clear", "/cls", "@clear", "@cls")) + { vm.Clear(); return true; } + if (EqAny(trimmed, "/framerate", "@framerate")) + { vm.ShowFps(); return true; } + if (EqAny(trimmed, "/loc", "@loc")) + { vm.ShowLocation(); return true; } + return false; + } + + private static bool EqAny(string s, params string[] options) + { + for (int i = 0; i < options.Length; i++) + if (s.Equals(options[i], StringComparison.OrdinalIgnoreCase)) return true; + return false; + } + + private static string BuildHelpText() => + "Note: / and @ are equivalent prefixes.\n" + + "Chat: /say (default), /tell , /reply, /retell\n" + + "Channels: /general /trade /fellowship /allegiance\n" + + " /patron /vassals /monarch /covassals\n" + + " /lfg /roleplay /society /olthoi\n" + + "Client: /help (this) /clear /framerate /loc\n" + + "Server: type @acehelp or @acecommands for ACE's full list."; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `dotnet test tests/AcDream.UI.Abstractions.Tests --filter ChatCommandRouterTests` +Expected: PASS (5 tests). + +- [ ] **Step 5: Repoint `ChatPanel` at the router** + +In `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs`, replace the submit body +(`ChatPanel.cs:194-241`, the `var trimmed = submitted.Trim();` block through +`_input = string.Empty;`) with a single call, and delete the now-dead +`TryHandleClientCommand` / `EqAny` / `BuildHelpText` helpers (they moved to the router): + +```csharp +if (renderer.InputTextSubmit("##chatinput", ref _input, InputBufferMaxLen, out var submitted) + && submitted is not null) +{ + ChatCommandRouter.Submit(submitted, _vm, ctx.Commands, ChatChannelKind.Say); + _input = string.Empty; + renderer.EndChild(); + renderer.End(); + return; +} +``` + +- [ ] **Step 6: Verify the full suite still passes** + +Run: `dotnet test tests/AcDream.UI.Abstractions.Tests` +Expected: PASS — including the existing `ChatPanelInputTests` (they assert the same submit behavior, now via the router). If any assert on a private `ChatPanel` member, redirect it to `ChatCommandRouter`. + +- [ ] **Step 7: Commit** + +```bash +git add src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs \ + src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs \ + tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs +git commit -m "feat(D.2b): extract ChatCommandRouter — shared chat submit pipeline + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task B: `UiChatView` dat-font seam + 1-line wheel + +Make the transcript render in the dat font and scroll one line per wheel notch +(retail `HandleMouseWheel @0x471450`), keeping bottom-pin, drag-select, Ctrl+C. + +**Files:** +- Modify: `src/AcDream.App/UI/UiChatView.cs` +- Test: `tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs`. `UiChatView.CharIndexAt` +is already a pure static taking a `Func` advance lookup — assert the +dat-font advance (`UiDatFont.GlyphAdvance`) drives caret hit-testing: + +```csharp +using AcDream.App.UI; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.App.Tests.UI; + +public class UiChatViewDatFontTests +{ + // Synthetic per-char advance: each glyph 10px wide (Before=2,Width=6,After=2). + private static FontCharDesc Glyph(char c) => new() + { + Unicode = c, HorizontalOffsetBefore = 2, Width = 6, HorizontalOffsetAfter = 2, + OffsetX = 0, OffsetY = 0, Height = 12, VerticalOffsetBefore = 0, + }; + + [Fact] + public void CharIndexAt_UsesDatGlyphAdvance() + { + // "abc" with 10px advances -> midpoints at 5,15,25. x=12 -> caret before 'b' (index 1). + float Adv(char c) => UiDatFont.GlyphAdvance(Glyph(c)); + Assert.Equal(0, UiChatView.CharIndexAt("abc", Adv, 4f)); + Assert.Equal(1, UiChatView.CharIndexAt("abc", Adv, 12f)); + Assert.Equal(3, UiChatView.CharIndexAt("abc", Adv, 100f)); + } + + [Fact] + public void GlyphAdvance_MatchesRetailFormula() + { + // HorizontalOffsetBefore + Width + HorizontalOffsetAfter = 2+6+2 = 10. + Assert.Equal(10f, UiDatFont.GlyphAdvance(Glyph('x'))); + } +} +``` + +- [ ] **Step 2: Run to verify it fails or passes-trivially** + +Run: `dotnet test tests/AcDream.App.Tests --filter UiChatViewDatFontTests` +Expected: PASS for `GlyphAdvance_MatchesRetailFormula` (it's existing), FAIL only if +`FontCharDesc` field names differ — fix the `Glyph(...)` initializer to match the +real `DatReaderWriter.Types.FontCharDesc` (verify via the type before running). The +first test should already pass since `CharIndexAt` is font-agnostic; this test pins +the dat-font advance as the lookup. + +- [ ] **Step 3: Add the dat-font draw + scroll path to `UiChatView`** + +In `src/AcDream.App/UI/UiChatView.cs`: + +1. Add a property next to `Font`: +```csharp +/// Retail dat font (0x40000000) for the transcript. When set, glyphs +/// render via the two-pass dat-font blit and measure/hit-test use the dat glyph +/// advance; when null, the debug BitmapFont path is used. Set by the controller. +public UiDatFont? DatFont { get; set; } +``` +2. Change the wheel quantum to one line per notch (retail `HandleMouseWheel`): +```csharp +private const float WheelLines = 1f; // retail: 1 line per wheel notch (was 3) +``` +3. In `OnDraw`, branch on `DatFont`: use `DatFont.LineHeight` for `lh`, draw each + line with `ctx.DrawStringDat(DatFont, text, Padding, y, color)`, and measure the + selection-highlight span with `DatFont.MeasureWidth(...)`. Keep the `BitmapFont` + branch unchanged as the fallback. Cache `_lastDatFont` alongside `_lastFont` so + `HitChar` uses the same advance source it drew with. +4. In `HitChar`, when `_lastDatFont` is set, build the advance lookup from it: +```csharp +int col = _lastDatFont is { } df + ? CharIndexAt(text, ch => df.TryGetGlyph(ch, out var g) ? UiDatFont.GlyphAdvance(g) : 0f, + localX - _lastPadding) + : (_lastFont is { } bf + ? CharIndexAt(text, ch => bf.TryGetGlyph(ch, out var bg) ? bg.Advance : 0f, + localX - _lastPadding) + : 0); +``` +5. In the `Scroll` event, use the dat-font line height when present: +```csharp +float lh = DatFont?.LineHeight ?? (Font ?? _lastFont)?.LineHeight ?? 16f; +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `dotnet test tests/AcDream.App.Tests --filter UiChatViewDatFontTests` +Expected: PASS. + +- [ ] **Step 5: Build the App project** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` +Expected: 0 errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/AcDream.App/UI/UiChatView.cs tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs +git commit -m "feat(D.2b): UiChatView dat-font transcript + 1-line wheel quantum + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task C: `UiScrollable` (pixel-scroll coordinator) + +Port `UIElement_Scrollable`'s pixel-scroll math: a pure, GL-free coordinator the +transcript and scrollbar both read. No `UiElement` inheritance — it is held by +`UiChatView` and queried by `UiChatScrollbar`. + +**Files:** +- Create: `src/AcDream.App/UI/UiScrollable.cs` +- Test: `tests/AcDream.App.Tests/UI/UiScrollableTests.cs` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/AcDream.App.Tests/UI/UiScrollableTests.cs`: + +```csharp +using AcDream.App.UI; +using Xunit; + +namespace AcDream.App.Tests.UI; + +public class UiScrollableTests +{ + [Fact] + public void Clamp_KeepsScrollWithinContent() + { + var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 }; + s.SetScrollY(500); // over max + Assert.Equal(200, s.ScrollY); // max = 300-100 + s.SetScrollY(-50); + Assert.Equal(0, s.ScrollY); + } + + [Fact] + public void FitsView_PinsToZero() + { + var s = new UiScrollable { ContentHeight = 80, ViewHeight = 100 }; + s.SetScrollY(40); + Assert.Equal(0, s.ScrollY); // content <= view => no scroll + Assert.False(s.HasOverflow); + } + + [Fact] + public void ThumbRatio_IsViewOverContent_ClampedToOne() + { + var s = new UiScrollable { ContentHeight = 400, ViewHeight = 100 }; + Assert.Equal(0.25f, s.ThumbRatio, 3); // 100/400 + var full = new UiScrollable { ContentHeight = 50, ViewHeight = 100 }; + Assert.Equal(1f, full.ThumbRatio, 3); // content < view => full thumb + } + + [Fact] + public void PositionRatio_MapsScrollToZeroOne() + { + var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 }; + s.SetScrollY(100); // half of max(200) + Assert.Equal(0.5f, s.PositionRatio, 3); + s.SetScrollY(200); + Assert.Equal(1f, s.PositionRatio, 3); + } + + [Fact] + public void SetPositionRatio_IsInverseOfPositionRatio() + { + var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 }; + s.SetPositionRatio(0.5f); + Assert.Equal(100, s.ScrollY); // 0.5 * max(200) + } + + [Fact] + public void ScrollByLines_AdvancesByLineHeight() + { + var s = new UiScrollable { ContentHeight = 1000, ViewHeight = 100, LineHeight = 16 }; + s.ScrollByLines(-2); // retail: negative = toward older/top + Assert.Equal(0, s.ScrollY); // already at top, clamped + s.SetScrollY(50); + s.ScrollByLines(2); + Assert.Equal(82, s.ScrollY); // 50 + 2*16 + } + + [Fact] + public void ScrollByPage_AdvancesByViewHeight() + { + var s = new UiScrollable { ContentHeight = 1000, ViewHeight = 100, LineHeight = 16 }; + s.SetScrollY(200); + s.ScrollByPage(1); + Assert.Equal(300, s.ScrollY); // 200 + view(100) + } +} +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `dotnet test tests/AcDream.App.Tests --filter UiScrollableTests` +Expected: FAIL — `UiScrollable` does not exist. + +- [ ] **Step 3: Implement `UiScrollable`** + +Create `src/AcDream.App/UI/UiScrollable.cs`. Ports `UIElement_Scrollable` +(`SetScrollableXY @0x4740c0`, `UpdateScrollbarSize_ @0x4741a0`, +`UpdateScrollbarPosition_ @0x473f20`, `InqScrollDelta @0x4689b0`): + +```csharp +using System; + +namespace AcDream.App.UI; + +/// +/// Pixel-based vertical scroll model. Port of retail UIElement_Scrollable: +/// the scroll offset is an integer pixel value (m_iScrollableY) clamped to +/// [0, ContentHeight - ViewHeight]; the thumb ratio is view/content; the position +/// ratio is scroll/(content-view). Pure (no GL) so it is fully unit-tested and +/// shared by the transcript (UiChatView) and the scrollbar (UiChatScrollbar). +/// +public sealed class UiScrollable +{ + /// Total wrapped content height in px (UIElement_Scrollable m_iScrollableHeight). + public int ContentHeight { get; set; } + /// Visible viewport height in px. + public int ViewHeight { get; set; } + /// Pixels per text line (the scroll quantum). UIElement_Text::InqScrollDelta line case. + public int LineHeight { get; set; } = 16; + + private int _scrollY; + /// Current scroll offset in px from the top of the content. + public int ScrollY => _scrollY; + + /// Max scroll = max(0, content - view). + public int MaxScroll => Math.Max(0, ContentHeight - ViewHeight); + + /// True when content exceeds the view (a scrollbar is warranted). + public bool HasOverflow => ContentHeight > ViewHeight; + + /// True when the offset is at (or past) the bottom — used for bottom-pin. + public bool AtEnd => _scrollY >= MaxScroll; + + /// Set the offset, clamped to [0, MaxScroll] (SetScrollableXY clamp). + public void SetScrollY(int y) => _scrollY = Math.Clamp(y, 0, MaxScroll); + + /// Pin to the bottom (newest content visible). + public void ScrollToEnd() => _scrollY = MaxScroll; + + /// Thumb size ratio = view/content, clamped to 1 (UpdateScrollbarSize_). + public float ThumbRatio => ContentHeight <= 0 ? 1f : Math.Min(1f, (float)ViewHeight / ContentHeight); + + /// Position ratio = scroll/(content-view) in [0,1] (UpdateScrollbarPosition_). + public float PositionRatio => MaxScroll <= 0 ? 0f : (float)_scrollY / MaxScroll; + + /// Inverse of PositionRatio — used when the user drags the thumb. + public void SetPositionRatio(float ratio) + => SetScrollY((int)MathF.Round(Math.Clamp(ratio, 0f, 1f) * MaxScroll)); + + /// Scroll by whole lines (sign: +down/newer, -up/older). + public void ScrollByLines(int lines) => SetScrollY(_scrollY + lines * LineHeight); + + /// Scroll by a page = one view height (InqScrollDelta page case). + public void ScrollByPage(int pages) => SetScrollY(_scrollY + pages * ViewHeight); +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `dotnet test tests/AcDream.App.Tests --filter UiScrollableTests` +Expected: PASS (7 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.App/UI/UiScrollable.cs tests/AcDream.App.Tests/UI/UiScrollableTests.cs +git commit -m "feat(D.2b): UiScrollable — pixel scroll model (UIElement_Scrollable port) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task C2: Wire `UiScrollable` into `UiChatView` + +Replace `UiChatView`'s ad-hoc `_scroll` float with a `UiScrollable`, so the +transcript's content/view height + bottom-pin + line-scroll flow through the +shared model (and the scrollbar in Task D can read the same instance). + +**Files:** +- Modify: `src/AcDream.App/UI/UiChatView.cs` + +- [ ] **Step 1: Hold a `UiScrollable` + expose it** + +Add to `UiChatView`: +```csharp +/// The scroll model — also read by the linked UiChatScrollbar. +public UiScrollable Scroll { get; } = new(); +``` + +- [ ] **Step 2: Drive it from `OnDraw`** + +In `OnDraw`, after computing `lh`, `contentH`, `innerH`, set the model and read back +the offset instead of the local `_scroll`: +```csharp +Scroll.LineHeight = (int)MathF.Round(lh); +Scroll.ContentHeight = (int)MathF.Ceiling(contentH); +Scroll.ViewHeight = (int)MathF.Floor(innerH); +// Bottom-pin: if the user was at the end before content grew, stay pinned. +if (_pinBottom) Scroll.ScrollToEnd(); +float baseY = bottom - contentH + Scroll.ScrollY; // ScrollY is px from top; baseY shifts content +``` +Keep a `private bool _pinBottom = true;` that is set false when the user scrolls up +(in the `Scroll` event, `_pinBottom = Scroll.AtEnd;` after applying the delta) and +true again when they return to the end. + +> The existing `ClampScroll` static + `_scroll` field are superseded by +> `UiScrollable`. Keep `ClampScroll` if other tests reference it; otherwise remove it +> and update `UiChatView`'s scroll-offset reads to `Scroll.ScrollY`. + +- [ ] **Step 3: Route the wheel through the model** + +In the `Scroll` event handler: +```csharp +case UiEventType.Scroll: +{ + // Silk wheel +Y = scroll up = reveal older. Retail: 1 line per notch. + Scroll.ScrollByLines((int)(-e.Data0 * WheelLines)); + _pinBottom = Scroll.AtEnd; + return true; +} +``` + +- [ ] **Step 4: Build + run the App tests** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug && dotnet test tests/AcDream.App.Tests --filter UiChatView` +Expected: build clean; `UiChatViewDatFontTests` still PASS. Adjust any test that +referenced the removed `_scroll`/`ClampScroll` to use `Scroll`. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.App/UI/UiChatView.cs +git commit -m "feat(D.2b): UiChatView drives the shared UiScrollable model + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task D: `UiChatScrollbar` (track + thumb + up/down) + +A `UiElement` that renders the right-side scrollbar and drives a `UiScrollable`. +Follows the `UiMeter` sprite pattern (`SpriteResolve` + `ctx.DrawSprite`). + +**Files:** +- Create: `src/AcDream.App/UI/UiChatScrollbar.cs` + +> **First, locate the scroll up/down button ids in the dat.** Run +> `dotnet run --project src/AcDream.Cli -- dump-vitals-layout "" 0x21000006` +> and inspect the children of track `0x10000012` (and the gold caps seen at the +> top/bottom of the scrollbar in the retail screenshot). Record the up-button and +> down-button element ids + their sprite ids in a comment. If the track has no +> button children, the up/down are part of the track sprite and clicks are handled +> by hit-region (top 16px = up, bottom 16px = down). + +- [ ] **Step 1: Implement the widget** + +Create `src/AcDream.App/UI/UiChatScrollbar.cs`: + +```csharp +using System; +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// Right-side chat scrollbar: a track sprite, a draggable thumb sized to the +/// content/view ratio, and up/down step buttons. Drives a linked +/// . Ports retail UIElement_Scrollbar::UpdateLayout +/// @0x4710d0 (thumb size = trackLen * ThumbRatio, min 8px; thumb pos from +/// PositionRatio) and HandleButtonClick @0x470e90 (step ±1 line). +/// +public sealed class UiChatScrollbar : UiElement +{ + /// The scroll model this bar reflects + drives (shared with the transcript). + public UiScrollable? Model { get; set; } + /// RenderSurface id → (GL tex, w, h). 0 id = skip. + public Func? SpriteResolve { get; set; } + + public uint TrackSprite { get; set; } // 0x10000012 face + public uint ThumbSprite { get; set; } // 0x1000048c face + public uint UpSprite { get; set; } + public uint DownSprite { get; set; } + + private const float MinThumb = 8f; // retail attribute 0x89 floor + private const float ButtonH = 16f; // up/down button square + private bool _draggingThumb; + private float _dragOffsetY; + + public UiChatScrollbar() { CapturesPointerDrag = true; } + + /// Thumb rect in local space (between the two end buttons). + public static (float y, float h) ThumbRect(UiScrollable m, float trackTop, float trackLen) + { + float h = MathF.Max(MinThumb, trackLen * m.ThumbRatio); + float travel = trackLen - h; + float y = trackTop + travel * m.PositionRatio; + return (y, h); + } + + protected override void OnDraw(UiRenderContext ctx) + { + if (Model is not { } m || SpriteResolve is not { } resolve) return; + // Track fills the full height; buttons cap top/bottom; thumb floats between. + DrawSprite(ctx, resolve, TrackSprite, 0, 0, Width, Height); + DrawSprite(ctx, resolve, UpSprite, 0, 0, Width, ButtonH); + DrawSprite(ctx, resolve, DownSprite, 0, Height - ButtonH, Width, ButtonH); + if (m.HasOverflow) + { + float trackTop = ButtonH, trackLen = Height - 2 * ButtonH; + var (ty, th) = ThumbRect(m, trackTop, trackLen); + DrawSprite(ctx, resolve, ThumbSprite, 0, ty, Width, th); + } + } + + private void DrawSprite(UiRenderContext ctx, Func resolve, + uint id, float x, float y, float w, float h) + { + if (id == 0) return; + var (tex, _, _) = resolve(id); + if (tex == 0) return; + ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, 1f, 1f, Vector4.One); + } + + public override bool OnEvent(in UiEvent e) + { + if (Model is not { } m) return false; + switch (e.Type) + { + case UiEventType.MouseDown: + { + float ly = e.Data2; // local Y (UiRoot delivers target-local) + if (ly <= ButtonH) { m.ScrollByLines(-1); return true; } // up button + if (ly >= Height - ButtonH) { m.ScrollByLines(1); return true; } // down button + float trackTop = ButtonH, trackLen = Height - 2 * ButtonH; + var (ty, th) = ThumbRect(m, trackTop, trackLen); + if (ly >= ty && ly <= ty + th) { _draggingThumb = true; _dragOffsetY = ly - ty; } + else m.ScrollByPage(ly < ty ? -1 : 1); // click in track half = page + return true; + } + case UiEventType.MouseMove when _draggingThumb: + { + float trackTop = ButtonH, trackLen = Height - 2 * ButtonH; + float h = MathF.Max(MinThumb, trackLen * m.ThumbRatio); + float travel = MathF.Max(1f, trackLen - h); + m.SetPositionRatio((e.Data2 - _dragOffsetY - trackTop) / travel); + return true; + } + case UiEventType.MouseUp: _draggingThumb = false; return true; + } + return false; + } +} +``` + +- [ ] **Step 2: Build the App project** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` +Expected: 0 errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/AcDream.App/UI/UiChatScrollbar.cs +git commit -m "feat(D.2b): UiChatScrollbar — track/thumb/buttons driving UiScrollable + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task E: `UiChatInput` (editable one-line field) + +Port the `UIElement_Text` edit path: caret, insert/delete, 100-entry history, +focus sprite, dat-font draw, submit callback. Caret math reuses `UiDatFont`. + +**Files:** +- Create: `src/AcDream.App/UI/UiChatInput.cs` +- Test: `tests/AcDream.App.Tests/UI/UiChatInputTests.cs` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/AcDream.App.Tests/UI/UiChatInputTests.cs`. The pure, testable seams are +text editing + history navigation (no GL). The widget exposes them as instance state: + +```csharp +using AcDream.App.UI; +using Xunit; + +namespace AcDream.App.Tests.UI; + +public class UiChatInputTests +{ + [Fact] + public void InsertChar_AdvancesCaret() + { + var input = new UiChatInput(); + input.InsertChar('h'); input.InsertChar('i'); + Assert.Equal("hi", input.Text); + Assert.Equal(2, input.CaretPos); + } + + [Fact] + public void Backspace_DeletesBeforeCaret() + { + var input = new UiChatInput(); + foreach (var c in "abc") input.InsertChar(c); + input.MoveCaret(-1); // caret between 'b' and 'c' + input.Backspace(); // deletes 'b' + Assert.Equal("ac", input.Text); + Assert.Equal(1, input.CaretPos); + } + + [Fact] + public void Submit_FiresCallback_ClearsText_PushesHistory() + { + string? sent = null; + var input = new UiChatInput { OnSubmit = t => sent = t }; + foreach (var c in "hello") input.InsertChar(c); + input.Submit(); + Assert.Equal("hello", sent); + Assert.Equal("", input.Text); + Assert.Equal(0, input.CaretPos); + } + + [Fact] + public void EmptySubmit_DoesNotFire() + { + int n = 0; + var input = new UiChatInput { OnSubmit = _ => n++ }; + input.Submit(); + Assert.Equal(0, n); + } + + [Fact] + public void History_UpDownBrowsesPreviousSubmissions() + { + var input = new UiChatInput { OnSubmit = _ => {} }; + foreach (var c in "first") input.InsertChar(c); input.Submit(); + foreach (var c in "second") input.InsertChar(c); input.Submit(); + input.HistoryPrev(); // most recent + Assert.Equal("second", input.Text); + input.HistoryPrev(); + Assert.Equal("first", input.Text); + input.HistoryNext(); + Assert.Equal("second", input.Text); + input.HistoryNext(); // back to live (empty) + Assert.Equal("", input.Text); + } + + [Fact] + public void History_CapsAt100() + { + var input = new UiChatInput { OnSubmit = _ => {} }; + for (int i = 0; i < 150; i++) { input.InsertChar('x'); input.Submit(); } + Assert.True(input.HistoryCount <= 100); + } +} +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `dotnet test tests/AcDream.App.Tests --filter UiChatInputTests` +Expected: FAIL — `UiChatInput` does not exist. + +- [ ] **Step 3: Implement `UiChatInput`** + +Create `src/AcDream.App/UI/UiChatInput.cs`. Ports `UIElement_Text` editable mode +(`CharacterHandler`, `MoveCursor @0x468d00`, `FindPixelsFromPos @0x472b40`) + +`ChatInterface` history (`ProcessCommand @0x4f5100`, `SelectCommandFromHistory`, +sentinel `-1` = live): + +```csharp +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// Editable one-line chat input. Port of retail UIElement_Text in editable +/// one-line mode + ChatInterface's 100-entry command history. Caret is a +/// glyph index; the caret pixel-X is Σ glyph advances (UiDatFont) to the caret. +/// Submit (Enter / Send) fires , clears, and pushes history. +/// +public sealed class UiChatInput : UiElement +{ + public UiDatFont? DatFont { get; set; } + public BitmapFont? Font { get; set; } + public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f); + public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0.35f); + public float Padding { get; set; } = 4f; + public int MaxCharacters { get; set; } = 0xFFFF; // retail m_nMaxCharacters default + + /// Called on Enter/Send with the (non-empty) text. The widget clears after. + public Action? OnSubmit { get; set; } + + private string _text = ""; + private int _caret; + public string Text => _text; + public int CaretPos => _caret; + + private readonly List _history = new(); + private int _historyIndex = -1; // -1 = live line (not browsing) + public int HistoryCount => _history.Count; + + public UiChatInput() + { + AcceptsFocus = true; + IsEditControl = true; + CapturesPointerDrag = true; + } + + // ── Pure editing seams (unit-tested) ───────────────────────────────── + public void InsertChar(char c) + { + if (c < 0x20 || c == 0x7F) return; // skip controls (retail CharacterHandler) + if (_text.Length >= MaxCharacters) return; + _text = _text.Insert(_caret, c.ToString()); + _caret++; + _historyIndex = -1; // editing returns to the live line + } + + public void Backspace() + { + if (_caret == 0) return; + _text = _text.Remove(_caret - 1, 1); + _caret--; + } + + public void DeleteForward() + { + if (_caret >= _text.Length) return; + _text = _text.Remove(_caret, 1); + } + + public void MoveCaret(int delta) => _caret = Math.Clamp(_caret + delta, 0, _text.Length); + public void CaretHome() => _caret = 0; + public void CaretEnd() => _caret = _text.Length; + + public void Submit() + { + var t = _text; + if (t.Trim().Length == 0) { Clear(); return; } + OnSubmit?.Invoke(t); + PushHistory(t); + Clear(); + } + + private void Clear() { _text = ""; _caret = 0; _historyIndex = -1; } + + private void PushHistory(string t) + { + _history.Add(t); + if (_history.Count > 100) _history.RemoveAt(0); // retail cap 100, drop oldest + _historyIndex = -1; + } + + public void HistoryPrev() // Up arrow — toward older + { + if (_history.Count == 0) return; + _historyIndex = _historyIndex < 0 ? _history.Count - 1 : Math.Max(0, _historyIndex - 1); + SetTextFromHistory(); + } + + public void HistoryNext() // Down arrow — toward newer, then live + { + if (_historyIndex < 0) return; + _historyIndex++; + if (_historyIndex >= _history.Count) { _historyIndex = -1; Clear(); return; } + SetTextFromHistory(); + } + + private void SetTextFromHistory() + { + _text = _history[_historyIndex]; + _caret = _text.Length; + } + + /// Caret pixel-X from the text start (FindPixelsFromPos): Σ advances to caret. + public float CaretPixelX() + => DatFont is { } df ? df.MeasureWidth(_text.Substring(0, _caret)) + : Font is { } bf ? bf.MeasureWidth(_text.Substring(0, _caret)) : 0f; + + // ── Rendering + input ──────────────────────────────────────────────── + protected override void OnDraw(UiRenderContext ctx) + { + ctx.DrawRect(0, 0, Width, Height, BackgroundColor); + float ty = (Height - (DatFont?.LineHeight ?? Font?.LineHeight ?? 14f)) * 0.5f; + if (DatFont is { } df) ctx.DrawStringDat(df, _text, Padding, ty, TextColor); + else if (Font is not null || ctx.DefaultFont is not null) ctx.DrawString(_text, Padding, ty, TextColor, Font); + + // Caret: 1px vertical line at the caret X (blink left to a follow-up; draw solid for now). + if (HasKeyboardFocus()) + { + float cx = Padding + CaretPixelX(); + float ch = DatFont?.LineHeight ?? Font?.LineHeight ?? 14f; + ctx.DrawRect(cx, ty, 1f, ch, TextColor); + } + } + + private bool HasKeyboardFocus() + => (Parent is not null) && FindRoot()?.KeyboardFocus == this; + + private UiRoot? FindRoot() + { + UiElement? e = this; + while (e is not null) { if (e is UiRoot r) return r; e = e.Parent; } + return null; + } + + public override bool OnEvent(in UiEvent e) + { + switch (e.Type) + { + case UiEventType.Char: + InsertChar((char)e.Data0); + return true; + case UiEventType.KeyDown: + { + var key = (Silk.NET.Input.Key)e.Data0; + switch (key) + { + case Silk.NET.Input.Key.Enter: + case Silk.NET.Input.Key.KeypadEnter: Submit(); return true; + case Silk.NET.Input.Key.Backspace: Backspace(); return true; + case Silk.NET.Input.Key.Delete: DeleteForward(); return true; + case Silk.NET.Input.Key.Left: MoveCaret(-1); return true; + case Silk.NET.Input.Key.Right: MoveCaret(1); return true; + case Silk.NET.Input.Key.Home: CaretHome(); return true; + case Silk.NET.Input.Key.End: CaretEnd(); return true; + case Silk.NET.Input.Key.Up: HistoryPrev(); return true; + case Silk.NET.Input.Key.Down: HistoryNext(); return true; + } + return false; + } + } + return false; + } +} +``` + +> **Note on focus access:** the snippet walks to the `UiRoot` to read `KeyboardFocus`. +> If `UiRoot.KeyboardFocus` is not reachable that way at runtime, add a +> `bool Focused` flag set from `UiEventType.FocusGained`/`FocusLost` in `OnEvent` +> instead (the `UiElement` event model delivers both — see `UiRoot.SetKeyboardFocus`). + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `dotnet test tests/AcDream.App.Tests --filter UiChatInputTests` +Expected: PASS (6 tests). + +- [ ] **Step 5: Build the App project** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` +Expected: 0 errors. (If `e.Data0` for `Char` is the codepoint per `UiRoot.OnChar`, +the `(char)e.Data0` cast is correct.) + +- [ ] **Step 6: Commit** + +```bash +git add src/AcDream.App/UI/UiChatInput.cs tests/AcDream.App.Tests/UI/UiChatInputTests.cs +git commit -m "feat(D.2b): UiChatInput — editable field, caret, 100-entry history (UIElement_Text port) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task F: `UiChannelMenu` (channel selector) + +The `Chat ▸` selector: a button showing the active channel; clicking opens a popup +list of channels; selecting one fires a channel-changed callback. Ports +`UIElement_Menu` minimally (a button + a popup item list). + +**Files:** +- Create: `src/AcDream.App/UI/UiChannelMenu.cs` + +- [ ] **Step 1: Implement the widget** + +Create `src/AcDream.App/UI/UiChannelMenu.cs`. The 13 channels map to +`ChatChannelKind` (retail `InitTalkFocusMenu @0x4cdc50` enum: 1=Say, 4=Fellowship, +5=Patron, 6=Trade, 7=Allegiance, …). The popup is a vertical list drawn on click; +selection updates `Selected` + fires `OnChannelChanged`. + +```csharp +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.UI.Abstractions; + +namespace AcDream.App.UI; + +/// +/// Chat channel selector (the "Chat ▸" button). Port of retail +/// UIElement_Menu as used by gmMainChatUI::InitTalkFocusMenu @0x4cdc50: +/// a button whose label is the active channel; clicking opens a popup of channels; +/// selecting one calls SetTalkFocus (here: ). +/// +public sealed class UiChannelMenu : UiElement +{ + public readonly record struct Item(string Label, ChatChannelKind Channel); + + /// Retail talk-focus channels (subset acdream's ChatInputParser routes). + public static readonly Item[] Channels = + { + new("Say", ChatChannelKind.Say), + new("General", ChatChannelKind.General), + new("Trade", ChatChannelKind.Trade), + new("LFG", ChatChannelKind.Lfg), + new("Fellowship", ChatChannelKind.Fellowship), + new("Allegiance", ChatChannelKind.Allegiance), + new("Patron", ChatChannelKind.Patron), + new("Vassals", ChatChannelKind.Vassals), + new("Monarch", ChatChannelKind.Monarch), + new("Roleplay", ChatChannelKind.Roleplay), + new("Society", ChatChannelKind.Society), + new("Olthoi", ChatChannelKind.Olthoi), + }; + + public ChatChannelKind Selected { get; private set; } = ChatChannelKind.Say; + public Action? OnChannelChanged { get; set; } + + public UiDatFont? DatFont { get; set; } + public BitmapFont? Font { get; set; } + public Func? SpriteResolve { get; set; } + public uint NormalSprite { get; set; } // 0x06004D65 + public uint PressedSprite { get; set; } // 0x06004D66 + public Vector4 TextColor { get; set; } = new(1f, 0.85f, 0.4f, 1f); + + private bool _open; + private const float ItemH = 16f; + + public UiChannelMenu() { CapturesPointerDrag = true; } + + private string Label => FindLabel(Selected); + private static string FindLabel(ChatChannelKind k) + { + foreach (var it in Channels) if (it.Channel == k) return it.Label; + return "Chat"; + } + + protected override void OnDraw(UiRenderContext ctx) + { + // Button face. + if (SpriteResolve is { } resolve) + { + var (tex, _, _) = resolve(_open ? PressedSprite : NormalSprite); + if (tex != 0) ctx.DrawSprite(tex, 0, 0, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); + } + DrawLabel(ctx, Label + " >", 2f, (Height - LineH()) * 0.5f); + + // Popup list above the button (chat is at screen bottom). + if (_open) + { + float h = Channels.Length * ItemH; + float top = -h; + ctx.DrawRect(0, top, MathF.Max(Width, 90f), h, new(0f, 0f, 0f, 0.85f)); + for (int i = 0; i < Channels.Length; i++) + DrawLabel(ctx, Channels[i].Label, 2f, top + i * ItemH); + } + } + + private float LineH() => DatFont?.LineHeight ?? Font?.LineHeight ?? 14f; + private void DrawLabel(UiRenderContext ctx, string s, float x, float y) + { + if (DatFont is { } df) ctx.DrawStringDat(df, s, x, y, TextColor); + else ctx.DrawString(s, x, y, TextColor, Font); + } + + protected override bool OnHitTest(float lx, float ly) + => _open ? (lx >= 0 && lx < MathF.Max(Width, 90f) && ly >= -Channels.Length * ItemH && ly < Height) + : base.OnHitTest(lx, ly); + + public override bool OnEvent(in UiEvent e) + { + if (e.Type == UiEventType.MouseDown) + { + float ly = e.Data2; + if (_open && ly < 0) // clicked an item in the popup + { + int idx = (int)((ly + Channels.Length * ItemH) / ItemH); + if (idx >= 0 && idx < Channels.Length) + { + Selected = Channels[idx].Channel; + OnChannelChanged?.Invoke(Selected); + } + _open = false; + return true; + } + _open = !_open; // toggle on button click + return true; + } + return false; + } +} +``` + +- [ ] **Step 2: Build the App project** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` +Expected: 0 errors. (Verify `ChatChannelKind` has the members used; adjust the +`Channels` table to the real enum names if any differ.) + +- [ ] **Step 3: Commit** + +```bash +git add src/AcDream.App/UI/UiChannelMenu.cs +git commit -m "feat(D.2b): UiChannelMenu — channel selector popup (UIElement_Menu port) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task G: `ChatWindowController` (import + bind + route) + +The `ChatInterface`/`gmMainChatUI::PostInit` analogue: import `0x21000006`, bind by +id, swap the transcript/input placeholders for the behavioral widgets, wire the +scrollbar/menu/send/max-min, and route inbound (`ChatVM`) + outbound +(`ChatCommandRouter`). + +**Files:** +- Create: `src/AcDream.App/UI/Layout/ChatWindowController.cs` + +- [ ] **Step 1: Implement the controller** + +Create `src/AcDream.App/UI/Layout/ChatWindowController.cs`: + +```csharp +using System; +using AcDream.UI.Abstractions; +using AcDream.UI.Abstractions.Panels.Chat; + +namespace AcDream.App.UI.Layout; + +/// +/// Binds the imported chat LayoutDesc (0x21000006) to live behavior — the acdream +/// analogue of retail ChatInterface + gmMainChatUI::PostInit. It +/// FindElement(id)s each role, swaps the transcript/input placeholders for the +/// behavioral widgets, wires the scrollbar/menu/send/max-min, and routes chat. +/// +public sealed class ChatWindowController +{ + public const uint LayoutId = 0x21000006u; + public const uint TranscriptId = 0x10000011u; + public const uint InputId = 0x10000016u; + public const uint TrackId = 0x10000012u; + public const uint ThumbId = 0x1000048Cu; + public const uint MenuId = 0x10000014u; + public const uint SendId = 0x10000019u; + public const uint MaxMinId = 0x1000046Fu; + + public UiChatView Transcript { get; private set; } = null!; + public UiChatInput Input { get; private set; } = null!; + public UiChatScrollbar Scrollbar { get; private set; } = null!; + public UiChannelMenu Menu { get; private set; } = null!; + + /// Bind an imported chat layout. Returns the controller, or null if the + /// required role elements are missing. + public static ChatWindowController? Bind( + ImportedLayout layout, ChatVM vm, ICommandBus bus, + UiDatFont? datFont, BitmapFont? debugFont, + Func resolve) + { + var transcriptPh = layout.FindElement(TranscriptId); + var inputPh = layout.FindElement(InputId); + if (transcriptPh is null || inputPh is null) return null; + + var c = new ChatWindowController(); + + // Transcript — swap placeholder for UiChatView at the same rect/anchors. + c.Transcript = new UiChatView + { + Left = transcriptPh.Left, Top = transcriptPh.Top, + Width = transcriptPh.Width, Height = transcriptPh.Height, + Anchors = transcriptPh.Anchors, + DatFont = datFont, Font = debugFont, + LinesProvider = () => BuildLines(vm), + }; + ReplaceInParent(transcriptPh, c.Transcript); + + // Input — swap placeholder for UiChatInput. + c.Input = new UiChatInput + { + Left = inputPh.Left, Top = inputPh.Top, + Width = inputPh.Width, Height = inputPh.Height, + Anchors = inputPh.Anchors, + DatFont = datFont, Font = debugFont, + }; + ReplaceInParent(inputPh, c.Input); + + // Menu — swap placeholder for UiChannelMenu (label tracks the active channel). + var menuPh = layout.FindElement(MenuId); + c.Menu = new UiChannelMenu { DatFont = datFont, Font = debugFont, SpriteResolve = resolve }; + if (menuPh is not null) + { + c.Menu.Left = menuPh.Left; c.Menu.Top = menuPh.Top; + c.Menu.Width = menuPh.Width; c.Menu.Height = menuPh.Height; + c.Menu.Anchors = menuPh.Anchors; + ReplaceInParent(menuPh, c.Menu); + } + + // Scrollbar — swap the track placeholder for the scrollbar widget driving the + // transcript's UiScrollable. + var trackPh = layout.FindElement(TrackId); + c.Scrollbar = new UiChatScrollbar { Model = c.Transcript.Scroll, SpriteResolve = resolve }; + if (trackPh is not null) + { + c.Scrollbar.Left = trackPh.Left; c.Scrollbar.Top = trackPh.Top; + c.Scrollbar.Width = trackPh.Width; c.Scrollbar.Height = trackPh.Height; + c.Scrollbar.Anchors = trackPh.Anchors; + // Sprite ids: read from the imported track/thumb nodes (TrackSprite, ThumbSprite). + ReplaceInParent(trackPh, c.Scrollbar); + } + + // Routing: input submit -> ChatCommandRouter with the menu's active channel. + c.Input.OnSubmit = text => + ChatCommandRouter.Submit(text, vm, bus, c.Menu.Selected); + c.Menu.OnChannelChanged = _ => { /* active channel read live from Menu.Selected */ }; + + // Send button -> submit (alternate trigger, retail ListenToElementMessage 0x10000019). + var send = layout.FindElement(SendId); + if (send is not null) send.ClickThrough = false; // ensure it receives clicks + // (wire send click -> c.Input.Submit() in the controller's event hook or via a + // small click handler subclass; if FindElement returns a UiDatElement, attach + // an OnClick delegate — add one to UiDatElement if absent.) + + return c; + } + + private static void ReplaceInParent(UiElement placeholder, UiElement widget) + { + var parent = placeholder.Parent; + if (parent is null) return; + parent.RemoveChild(placeholder); + parent.AddChild(widget); + } + + private static System.Collections.Generic.IReadOnlyList BuildLines(ChatVM vm) + { + var detailed = vm.RecentLinesDetailed(); + var result = new UiChatView.Line[detailed.Count]; + for (int i = 0; i < detailed.Count; i++) + result[i] = new UiChatView.Line(detailed[i].Text, RetailChatColor(detailed[i].Kind)); + return result; + } + + // Per-ChatKind palette (moved from GameWindow.RetailChatColor in Task H). + private static System.Numerics.Vector4 RetailChatColor(AcDream.Core.Chat.ChatKind kind) => kind switch + { + AcDream.Core.Chat.ChatKind.LocalSpeech => new(1f, 1f, 1f, 1f), + AcDream.Core.Chat.ChatKind.RangedSpeech => new(1f, 0.95f, 0.8f, 1f), + AcDream.Core.Chat.ChatKind.Channel => new(0.6f, 0.8f, 1f, 1f), + AcDream.Core.Chat.ChatKind.Tell => new(1f, 0.5f, 1f, 1f), + AcDream.Core.Chat.ChatKind.System => new(1f, 1f, 0.45f, 1f), + AcDream.Core.Chat.ChatKind.Popup => new(1f, 0.85f, 0.4f, 1f), + AcDream.Core.Chat.ChatKind.Emote => new(0.8f, 0.8f, 0.7f, 1f), + AcDream.Core.Chat.ChatKind.SoulEmote => new(0.8f, 0.8f, 0.7f, 1f), + AcDream.Core.Chat.ChatKind.Combat => new(1f, 0.6f, 0.25f, 1f), + _ => new(0.9f, 0.9f, 0.9f, 1f), + }; +} +``` + +> **Send-button + max/min click wiring:** `LayoutImporter` builds those as +> `UiDatElement` sprite nodes. If `UiDatElement` has no click hook, add an +> `Action? OnClick` invoked from `OnEvent(UiEventType.Click)` (small change, generic +> + reusable). Wire `send.OnClick = () => Input.Submit();` and +> `maxmin.OnClick = ToggleMaximize;`. The max/min toggle ports +> `gmMainChatUI::HandleMaximizeButton @0x4cce50` (swap between authored height and +> full-parent height, storing old Y/height). If that grows large, file it as a +> follow-up and leave the button inert this pass (note in a divergence row). + +- [ ] **Step 2: Build the App project** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` +Expected: 0 errors. Resolve the sprite-id reads for the scrollbar (`TrackSprite`/ +`ThumbSprite`) by pulling them from the imported track/thumb `ElementInfo.StateMedia` +(or `UiDatElement`), following the `DatWidgetFactory.SliceIds` pattern. + +- [ ] **Step 3: Commit** + +```bash +git add src/AcDream.App/UI/Layout/ChatWindowController.cs +git commit -m "feat(D.2b): ChatWindowController — bind chat LayoutDesc, route in/outbound + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task H: `GameWindow` cutover + register + roadmap + +Replace the hand-authored chat block with the controller; default placement; remove +dead code; add divergence rows; mark the work landed. + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` +- Modify: `docs/architecture/retail-divergence-register.md` +- Modify: `docs/plans/2026-04-11-roadmap.md` + +- [ ] **Step 1: Swap the chat block in `GameWindow`** + +In `src/AcDream.App/Rendering/GameWindow.cs`, in the `if (_options.RetailUi)` block, +replace the "Retail chat window" section (`GameWindow.cs:1836-1887`, the +`retailChatVm` + `UiNineSlicePanel` + `UiChatView` + `BuildRetailChatLines` + +`RetailChatColor` block) with: + +```csharp +// Retail chat window — data-driven from LayoutDesc 0x21000006 (gmMainChatUI), +// the same importer path as vitals. ChatWindowController binds the transcript, +// input, scrollbar and channel menu and routes through ChatVM + ChatCommandRouter. +var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200); +AcDream.App.UI.Layout.ImportedLayout? chatLayout; +lock (_datLock) + chatLayout = AcDream.App.UI.Layout.LayoutImporter.Import( + _dats!, AcDream.App.UI.Layout.ChatWindowController.LayoutId, ResolveChrome, vitalsDatFont); +if (chatLayout is not null) +{ + var chatController = AcDream.App.UI.Layout.ChatWindowController.Bind( + chatLayout, retailChatVm, _commandBus, vitalsDatFont, _debugFont, ResolveChrome); + if (chatController is not null) + { + var chatRoot = chatLayout.Root; + chatRoot.Left = 10; chatRoot.Top = 432; // bottom-left default; user adjusts visually + chatRoot.Anchors = AcDream.App.UI.AnchorEdges.None; + chatRoot.Draggable = true; + chatRoot.Resizable = true; + chatRoot.MinWidth = 200f; chatRoot.MinHeight = 80f; + _uiHost.Root.AddChild(chatRoot); + Console.WriteLine("[D.2b] retail chat window from LayoutDesc importer (0x21000006)."); + } + else Console.WriteLine("[D.2b] chat: required role elements missing in 0x21000006."); +} +else Console.WriteLine("[D.2b] chat: LayoutDesc 0x21000006 not found."); +``` + +> `_commandBus` must be the live `ICommandBus` the chat `SendChatCmd` handler is +> registered on. Confirm the field name in `GameWindow` (grep `ICommandBus` / +> `LiveCommandBus` — it is the same bus the ImGui `ChatPanel` publishes to). If the +> chat window root needs `vitalsDatFont` loaded first, this block already runs after +> the vitals block where `vitalsDatFont` is created — keep that ordering. + +- [ ] **Step 2: Build + run the full suite** + +Run: `dotnet build && dotnet test` +Expected: build clean; all tests green. Remove any now-unused `using`/helpers left in +`GameWindow` (the old `BuildRetailChatLines`/`RetailChatColor` local statics). + +- [ ] **Step 3: Add divergence-register rows** + +In `docs/architecture/retail-divergence-register.md`, add one row each (cite +`file:line`): (1) two-class transcript/input split [Adaptation]; (2) no in-element +word-wrap [Approximation]; (3) one color per line [Approximation]; (4) chat tabs +render but don't switch/filter [Stopgap]; (5) squelch + name-tags absent [Stopgap]; +(6) single default opacity, default font face/size [Approximation]. + +- [ ] **Step 4: Visual verification (user)** + +Launch live and confirm against the retail screenshot: +```powershell +$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1" +$env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000" +$env:ACDREAM_TEST_USER="testaccount"; $env:ACDREAM_TEST_PASS="testpassword" +$env:ACDREAM_RETAIL_UI="1" +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath chat-redrive.log +``` +Confirm: transcript scrolls in the dat font; scrollbar thumb sizes + drags; type + +Enter/Send dispatch; channel menu switches; window moves/resizes; translucent frame. + +- [ ] **Step 5: Update the roadmap + commit** + +Mark the chat re-drive landed in `docs/plans/2026-04-11-roadmap.md` (D.2b importer +Plan 2 — chat). Commit: +```bash +git add src/AcDream.App/Rendering/GameWindow.cs \ + docs/architecture/retail-divergence-register.md docs/plans/2026-04-11-roadmap.md +git commit -m "feat(D.2b): cut GameWindow over to the data-driven chat window + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Self-Review checklist (done while writing) + +- **Spec coverage:** §4 components ↔ Tasks A–H (router→A, transcript dat-font→B, + scrollable→C/C2, scrollbar→D, input→E, menu→F, controller→G, cutover→H). Deferred + items (§2/§6) → register rows in H Step 3. ✓ +- **Placeholders:** the two forward-discoveries (scroll up/down button ids in D; send/ + max-min click hook in G) are explicit, scoped implementation tasks with a fallback, + not hand-waves. ✓ +- **Type consistency:** `UiScrollable` API (`ScrollY`, `ThumbRatio`, `PositionRatio`, + `SetPositionRatio`, `ScrollByLines/Page`) used consistently in C, C2, D. `UiChatView.Scroll` + exposed in C2, consumed in D/G. `ChatCommandRouter.Submit(raw, vm, bus, channel)` defined + in A, called in E-wiring/G. `UiChatInput.OnSubmit`/`Submit()` consistent E↔G. ✓ From 50883e445b209c5c058fd10e3e2c970c1649b968 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 22:09:27 +0200 Subject: [PATCH 60/99] =?UTF-8?q?feat(D.2b):=20extract=20ChatCommandRouter?= =?UTF-8?q?=20=E2=80=94=20shared=20chat=20submit=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both the ImGui devtools ChatPanel and the upcoming retail chat window now route through ChatCommandRouter.Submit so command handling lives in one place. The ChatPanel inline block (TryHandleClientCommand / EqAny / BuildHelpText) is deleted; ChatCommandRouter carries the same logic verbatim. ChatPanel.Render becomes a 2-line delegate. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Panels/Chat/ChatCommandRouter.cs | 78 +++++++++++ .../Panels/Chat/ChatPanel.cs | 123 +----------------- .../Panels/Chat/ChatCommandRouterTests.cs | 74 +++++++++++ 3 files changed, 153 insertions(+), 122 deletions(-) create mode 100644 src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs new file mode 100644 index 00000000..9158d2d0 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs @@ -0,0 +1,78 @@ +using System; + +namespace AcDream.UI.Abstractions.Panels.Chat; + +/// What a submit did, so the caller can clear its input + give feedback. +public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped } + +/// +/// Shared chat-submit pipeline (retail ChatInterface::ProcessCommand @0x4f5100 +/// analogue). Both the ImGui devtools and the retail +/// chat window route through here so command handling stays in one place. +/// +/// Order mirrors the prior inline flow: +/// client-command intercept → unknown-slash-verb guard → +/// → Publish(SendChatCmd). +/// +public static class ChatCommandRouter +{ + public static SubmitOutcome Submit( + string raw, ChatVM vm, ICommandBus bus, ChatChannelKind defaultChannel) + { + ArgumentNullException.ThrowIfNull(vm); + ArgumentNullException.ThrowIfNull(bus); + var trimmed = (raw ?? string.Empty).Trim(); + if (trimmed.Length == 0) return SubmitOutcome.Empty; + + if (TryHandleClientCommand(trimmed, vm)) return SubmitOutcome.ClientHandled; + + if (trimmed[0] == '/') + { + var verb = ChatInputParser.GetVerbToken(trimmed); + if (!ChatInputParser.IsKnownVerb(verb)) + { + vm.ShowSystemMessage( + $"Unknown command: {verb}. Type /help for the list of supported commands."); + return SubmitOutcome.UnknownCommand; + } + } + + var parsed = ChatInputParser.Parse( + trimmed, defaultChannel, vm.LastIncomingTellSender, vm.LastOutgoingTellTarget); + if (parsed is { } p) + { + bus.Publish(new SendChatCmd(p.Channel, p.TargetName, p.Text)); + return SubmitOutcome.Sent; + } + return SubmitOutcome.Dropped; + } + + private static bool TryHandleClientCommand(string trimmed, ChatVM vm) + { + if (EqAny(trimmed, "/help", "/?", "/h", "@help", "@?", "@h")) + { vm.ShowSystemMessage(BuildHelpText()); return true; } + if (EqAny(trimmed, "/clear", "/cls", "@clear", "@cls")) + { vm.Clear(); return true; } + if (EqAny(trimmed, "/framerate", "@framerate")) + { vm.ShowFps(); return true; } + if (EqAny(trimmed, "/loc", "@loc")) + { vm.ShowLocation(); return true; } + return false; + } + + private static bool EqAny(string s, params string[] options) + { + for (int i = 0; i < options.Length; i++) + if (s.Equals(options[i], StringComparison.OrdinalIgnoreCase)) return true; + return false; + } + + private static string BuildHelpText() => + "Note: / and @ are equivalent prefixes.\n" + + "Chat: /say (default), /tell , /reply, /retell\n" + + "Channels: /general /trade /fellowship /allegiance\n" + + " /patron /vassals /monarch /covassals\n" + + " /lfg /roleplay /society /olthoi\n" + + "Client: /help (this) /clear /framerate /loc\n" + + "Server: type @acehelp or @acecommands for ACE's full list."; +} diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs index c8ece999..9cb8cb1f 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs @@ -191,53 +191,7 @@ public sealed class ChatPanel : IPanel if (renderer.InputTextSubmit("##chatinput", ref _input, InputBufferMaxLen, out var submitted) && submitted is not null) { - var trimmed = submitted.Trim(); - // Phase J follow-up: client-side commands intercepted before - // the server-bound parse path. Avoids the /help round-trip - // that produced "Unknown command: help" duplicates from - // ACE's command-error replies, AND gives users a discoverable - // local cheat-sheet of acdream's own slash prefixes. - if (TryHandleClientCommand(trimmed)) - { - _input = string.Empty; - renderer.EndChild(); // outer ##chatbody - renderer.End(); - return; - } - - // Phase J Tier 4: any /-prefixed input that ISN'T one of our - // known verbs gets a local "Unknown command" message instead - // of being broadcast to the server as plain speech. The - // user reported "/ls" / "/mp /path" leaking out as chat — - // a / prefix is a command, never speech. (@-prefixed unknown - // verbs still pass through to ACE because ACE's - // CommandManager intercepts @ server-side and replies with - // its own "Unknown command" / valid command output.) - if (trimmed.Length > 0 && trimmed[0] == '/') - { - string verb = ChatInputParser.GetVerbToken(trimmed); - if (!ChatInputParser.IsKnownVerb(verb)) - { - _vm.ShowSystemMessage( - $"Unknown command: {verb}. Type /help for the list of supported commands."); - _input = string.Empty; - renderer.EndChild(); // outer ##chatbody - renderer.End(); - return; - } - } - - var parsed = ChatInputParser.Parse( - trimmed, - ChatChannelKind.Say, - _vm.LastIncomingTellSender, - _vm.LastOutgoingTellTarget); - if (parsed is { } p) - { - ctx.Commands.Publish(new SendChatCmd(p.Channel, p.TargetName, p.Text)); - } - // Defensive: if the backend ever forgot to clear on submit, - // do it here. Cheap; no harm if already empty. + ChatCommandRouter.Submit(submitted, _vm, ctx.Commands, ChatChannelKind.Say); _input = string.Empty; } @@ -258,79 +212,4 @@ public sealed class ChatPanel : IPanel _ => new Vector4(1f, 1f, 1f, 1f), }; - /// - /// Phase J follow-up: handle client-side slash commands before - /// the parser passes anything to the server bus. Returns true - /// when the input was consumed (and the caller should clear the - /// buffer + skip the SendChatCmd path); false otherwise. - /// - /// - /// Recognised client-side commands: - /// - /// /help, /?, /h — render the slash-prefix - /// cheat-sheet locally. Avoids the server's "Unknown command" - /// round-trip when the user just wants to know what they can - /// type. - /// /clear, /cls — drain the chat log so the - /// panel starts empty. - /// - /// - private bool TryHandleClientCommand(string trimmed) - { - if (trimmed.Length == 0) return false; - - // /help, /?, /h — also @help, @?, @h per ACE's "/ ↔ @" equivalence. - if (EqAny(trimmed, "/help", "/?", "/h", "@help", "@?", "@h")) - { - _vm.ShowSystemMessage(BuildHelpText()); - return true; - } - - // /clear, /cls — also @clear, @cls. - if (EqAny(trimmed, "/clear", "/cls", "@clear", "@cls")) - { - _vm.Clear(); - return true; - } - - // /framerate — also @framerate. Prints current FPS to chat. - if (EqAny(trimmed, "/framerate", "@framerate")) - { - _vm.ShowFps(); - return true; - } - - // /loc — also @loc. Prints current player position to chat. - // ACE has a server-side @loc too; client-side wins here - // (instantaneous + uses our local interpolated position). - if (EqAny(trimmed, "/loc", "@loc")) - { - _vm.ShowLocation(); - return true; - } - - return false; - } - - /// Case-insensitive multi-string equality test. - private static bool EqAny(string s, params string[] options) - { - for (int i = 0; i < options.Length; i++) - if (s.Equals(options[i], StringComparison.OrdinalIgnoreCase)) return true; - return false; - } - - /// - /// Multi-line cheat-sheet text rendered by /help. ImGui's - /// Text path flows embedded newlines naturally so this lands - /// as one ChatLog entry that visually wraps to several lines. - /// - private static string BuildHelpText() => - "Note: / and @ are equivalent prefixes.\n" + - "Chat: /say (default), /tell , /reply, /retell\n" + - "Channels: /general /trade /fellowship /allegiance\n" + - " /patron /vassals /monarch /covassals\n" + - " /lfg /roleplay /society /olthoi\n" + - "Client: /help (this) /clear /framerate /loc\n" + - "Server: type @acehelp or @acecommands for ACE's full list."; } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs new file mode 100644 index 00000000..e0f1daad --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs @@ -0,0 +1,74 @@ +using AcDream.Core.Chat; +using AcDream.UI.Abstractions; +using AcDream.UI.Abstractions.Panels.Chat; +using Xunit; + +namespace AcDream.UI.Abstractions.Tests.Panels.Chat; + +public class ChatCommandRouterTests +{ + private sealed class CaptureBus : ICommandBus + { + public SendChatCmd? Last; + public void Publish(T command) where T : notnull + { + if (command is SendChatCmd c) Last = c; + } + } + + private static (ChatVM vm, ChatLog log, CaptureBus bus) Fixture() + { + var log = new ChatLog(); + var vm = new ChatVM(log, displayLimit: 50); + return (vm, log, new CaptureBus()); + } + + [Fact] + public void PlainText_PublishesOnDefaultChannel() + { + var (vm, _, bus) = Fixture(); + var outcome = ChatCommandRouter.Submit("hello there", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.Sent, outcome); + Assert.NotNull(bus.Last); + Assert.Equal(ChatChannelKind.Say, bus.Last!.Channel); + Assert.Equal("hello there", bus.Last.Text); + } + + [Fact] + public void DefaultChannel_IsHonored() + { + var (vm, _, bus) = Fixture(); + ChatCommandRouter.Submit("hi", vm, bus, ChatChannelKind.Fellowship); + Assert.Equal(ChatChannelKind.Fellowship, bus.Last!.Channel); + } + + [Fact] + public void ClearCommand_DrainsLog_DoesNotPublish() + { + var (vm, log, bus) = Fixture(); + log.OnSystemMessage("x", chatType: 0); + var outcome = ChatCommandRouter.Submit("/clear", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.ClientHandled, outcome); + Assert.Null(bus.Last); + Assert.Empty(log.Snapshot()); + } + + [Fact] + public void UnknownSlashVerb_ShowsSystemMessage_DoesNotPublish() + { + var (vm, log, bus) = Fixture(); + var outcome = ChatCommandRouter.Submit("/notacommand", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.UnknownCommand, outcome); + Assert.Null(bus.Last); + Assert.Contains(log.Snapshot(), e => e.Text.Contains("Unknown command")); + } + + [Fact] + public void EmptyInput_DoesNothing() + { + var (vm, _, bus) = Fixture(); + var outcome = ChatCommandRouter.Submit(" ", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.Empty, outcome); + Assert.Null(bus.Last); + } +} From 7552dcba3972b3f4b66b08a96eac36ec275bc028 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 22:14:56 +0200 Subject: [PATCH 61/99] feat(D.2b): UiChatView dat-font transcript + 1-line wheel quantum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `DatFont` property (UiDatFont?): when set, OnDraw uses ctx.DrawStringDat + datFont.MeasureWidth for all transcript lines; BitmapFont path unchanged as fallback when DatFont is null. - Cache `_lastDatFont` alongside `_lastFont` so HitChar hit-tests the same advance source that drew the last frame. - HitChar prefers `_lastDatFont` (via UiDatFont.GlyphAdvance) over `_lastFont` (via bf.Advance) for column resolution, keeping drag-select and Ctrl+C accurate with the dat font. - Scroll event handler uses DatFont?.LineHeight first, so the wheel quantum stays correct when the dat font has a different line height. - WheelLines 3f → 1f: retail UIElement_Text::HandleMouseWheel (@0x471450) advances one line per notch; our 3-line quantum was wrong. - Add UiChatViewDatFontTests: pins GlyphAdvance formula (Before+Width+After = 10) and CharIndexAt dat-advance integration. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/UiChatView.cs | 53 +++++++++++++------ .../UI/UiChatViewDatFontTests.cs | 30 +++++++++++ 2 files changed, 68 insertions(+), 15 deletions(-) create mode 100644 tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs diff --git a/src/AcDream.App/UI/UiChatView.cs b/src/AcDream.App/UI/UiChatView.cs index a2039c08..1392c26f 100644 --- a/src/AcDream.App/UI/UiChatView.cs +++ b/src/AcDream.App/UI/UiChatView.cs @@ -34,6 +34,11 @@ public sealed class UiChatView : UiElement /// Font for the transcript; falls back to the context default. public BitmapFont? Font { get; set; } + /// Retail dat font (0x40000000) for the transcript. When set, glyphs + /// render via the two-pass dat-font blit and measure/hit-test use the dat glyph + /// advance; when null, the debug BitmapFont path is used. Set by the controller. + public UiDatFont? DatFont { get; set; } + /// Keyboard device for clipboard (Ctrl+C) + modifier state. Wired by /// the host from . public Silk.NET.Input.IKeyboard? Keyboard { get; set; } @@ -49,11 +54,12 @@ public sealed class UiChatView : UiElement // Pixels the transcript is scrolled UP from the newest line (0 = pinned to bottom). private float _scroll; - private const float WheelLines = 3f; // lines advanced per wheel notch + private const float WheelLines = 1f; // lines advanced per wheel notch (retail = 1 line per notch) // ── Cached layout from the last OnDraw, so OnEvent hit-tests the SAME geometry ── private IReadOnlyList _lastLines = Array.Empty(); private BitmapFont? _lastFont; + private UiDatFont? _lastDatFont; private float _lastLineHeight = 16f; private float _lastBaseY; // top Y of line 0 in local space private float _lastPadding = 4f; @@ -85,21 +91,24 @@ public sealed class UiChatView : UiElement { ctx.DrawRect(0, 0, Width, Height, BackgroundColor); - var font = Font ?? ctx.DefaultFont; - if (font is null) return; + // Prefer the retail dat font when set; fall back to BitmapFont. + var datFont = DatFont; + var bitmapFont = datFont is null ? (Font ?? ctx.DefaultFont) : null; + if (datFont is null && bitmapFont is null) return; var lines = LinesProvider(); // Cache the geometry OnEvent will hit-test against. Even when there are no // lines we record the font/padding so a stray hit-test is harmless. _lastLines = lines; - _lastFont = font; - _lastLineHeight = font.LineHeight; + _lastDatFont = datFont; + _lastFont = bitmapFont; + _lastLineHeight = datFont is not null ? datFont.LineHeight : bitmapFont!.LineHeight; _lastPadding = Padding; if (lines.Count == 0) return; - float lh = font.LineHeight; + float lh = _lastLineHeight; float top = Padding, bottom = Height - Padding; float innerH = bottom - top; float contentH = lines.Count * lh; @@ -129,13 +138,25 @@ public sealed class UiChatView : UiElement c1 = Math.Clamp(c1, 0, text.Length); if (c1 > c0) { - float hx = Padding + font.MeasureWidth(text.Substring(0, c0)); - float hw = font.MeasureWidth(text.Substring(c0, c1 - c0)); + float hx, hw; + if (datFont is not null) + { + hx = Padding + datFont.MeasureWidth(text.Substring(0, c0)); + hw = datFont.MeasureWidth(text.Substring(c0, c1 - c0)); + } + else + { + hx = Padding + bitmapFont!.MeasureWidth(text.Substring(0, c0)); + hw = bitmapFont.MeasureWidth(text.Substring(c0, c1 - c0)); + } ctx.DrawRect(hx, y, hw, lh, SelectionColor); } } - ctx.DrawString(text, Padding, y, lines[i].Color, font); + if (datFont is not null) + ctx.DrawStringDat(datFont, text, Padding, y, lines[i].Color); + else + ctx.DrawString(text, Padding, y, lines[i].Color, bitmapFont); } } @@ -145,7 +166,7 @@ public sealed class UiChatView : UiElement { case UiEventType.Scroll: { - float lh = (Font ?? _lastFont)?.LineHeight ?? 16f; + float lh = DatFont?.LineHeight ?? (Font ?? _lastFont)?.LineHeight ?? 16f; // Silk wheel +Y = scroll up = reveal older = shift content down = larger _scroll. _scroll += e.Data0 * WheelLines * lh; // re-clamped next OnDraw against live content return true; @@ -316,11 +337,13 @@ public sealed class UiChatView : UiElement line = Math.Clamp(line, 0, lines.Count - 1); string text = lines[line].Text; - var font = _lastFont; - int col = font is null - ? 0 - : CharIndexAt(text, ch => font.TryGetGlyph(ch, out var g) ? g.Advance : 0f, - localX - _lastPadding); + int col = _lastDatFont is { } df + ? CharIndexAt(text, ch => df.TryGetGlyph(ch, out var g) ? UiDatFont.GlyphAdvance(g) : 0f, + localX - _lastPadding) + : (_lastFont is { } bf + ? CharIndexAt(text, ch => bf.TryGetGlyph(ch, out var bg) ? bg.Advance : 0f, + localX - _lastPadding) + : 0); return new Pos(line, col); } diff --git a/tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs b/tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs new file mode 100644 index 00000000..c00c9544 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs @@ -0,0 +1,30 @@ +using AcDream.App.UI; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.App.Tests.UI; + +public class UiChatViewDatFontTests +{ + // Synthetic per-char advance: each glyph 10px (Before=2,Width=6,After=2). + private static FontCharDesc Glyph(char c) => new() + { + Unicode = c, HorizontalOffsetBefore = 2, Width = 6, HorizontalOffsetAfter = 2, + OffsetX = 0, OffsetY = 0, Height = 12, VerticalOffsetBefore = 0, + }; + + [Fact] + public void CharIndexAt_UsesDatGlyphAdvance() + { + float Adv(char c) => UiDatFont.GlyphAdvance(Glyph(c)); + Assert.Equal(0, UiChatView.CharIndexAt("abc", Adv, 4f)); + Assert.Equal(1, UiChatView.CharIndexAt("abc", Adv, 12f)); + Assert.Equal(3, UiChatView.CharIndexAt("abc", Adv, 100f)); + } + + [Fact] + public void GlyphAdvance_MatchesRetailFormula() + { + Assert.Equal(10f, UiDatFont.GlyphAdvance(Glyph('x'))); + } +} From 9f273c934339faf530ce5ed7b5c4bd369e36a74b Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 22:19:29 +0200 Subject: [PATCH 62/99] =?UTF-8?q?feat(D.2b):=20UiScrollable=20=E2=80=94=20?= =?UTF-8?q?pixel=20scroll=20model=20(UIElement=5FScrollable=20port)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/UiScrollable.cs | 57 +++++++++++++++ .../AcDream.App.Tests/UI/UiScrollableTests.cs | 73 +++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 src/AcDream.App/UI/UiScrollable.cs create mode 100644 tests/AcDream.App.Tests/UI/UiScrollableTests.cs diff --git a/src/AcDream.App/UI/UiScrollable.cs b/src/AcDream.App/UI/UiScrollable.cs new file mode 100644 index 00000000..d30e2a0a --- /dev/null +++ b/src/AcDream.App/UI/UiScrollable.cs @@ -0,0 +1,57 @@ +using System; + +namespace AcDream.App.UI; + +/// +/// Pixel-based vertical scroll model. Port of retail UIElement_Scrollable: +/// the scroll offset is an integer pixel value (m_iScrollableY) clamped to +/// [0, ContentHeight - ViewHeight]; the thumb ratio is view/content; the position +/// ratio is scroll/(content-view). Pure (no GL) so it is fully unit-tested and +/// shared by the transcript (UiChatView) and the scrollbar (UiChatScrollbar). +/// Decomp anchors: SetScrollableXY @0x4740c0, UpdateScrollbarSize_ @0x4741a0, +/// UpdateScrollbarPosition_ @0x473f20, UIElement_Text::InqScrollDelta @0x4689b0. +/// +public sealed class UiScrollable +{ + /// Total wrapped content height in px (m_iScrollableHeight). + public int ContentHeight { get; set; } + /// Visible viewport height in px. + public int ViewHeight { get; set; } + /// Pixels per text line (scroll quantum). InqScrollDelta line case. + public int LineHeight { get; set; } = 16; + + private int _scrollY; + /// Current scroll offset in px from the top of the content. + public int ScrollY => _scrollY; + + /// Max scroll = max(0, content - view). + public int MaxScroll => Math.Max(0, ContentHeight - ViewHeight); + + /// True when content exceeds the view (a scrollbar is warranted). + public bool HasOverflow => ContentHeight > ViewHeight; + + /// True when the offset is at (or past) the bottom — used for bottom-pin. + public bool AtEnd => _scrollY >= MaxScroll; + + /// Set the offset, clamped to [0, MaxScroll] (SetScrollableXY clamp). + public void SetScrollY(int y) => _scrollY = Math.Clamp(y, 0, MaxScroll); + + /// Pin to the bottom (newest content visible). + public void ScrollToEnd() => _scrollY = MaxScroll; + + /// Thumb size ratio = view/content, clamped to 1 (UpdateScrollbarSize_). + public float ThumbRatio => ContentHeight <= 0 ? 1f : Math.Min(1f, (float)ViewHeight / ContentHeight); + + /// Position ratio = scroll/(content-view) in [0,1] (UpdateScrollbarPosition_). + public float PositionRatio => MaxScroll <= 0 ? 0f : (float)_scrollY / MaxScroll; + + /// Inverse of PositionRatio — used when the user drags the thumb. + public void SetPositionRatio(float ratio) + => SetScrollY((int)MathF.Round(Math.Clamp(ratio, 0f, 1f) * MaxScroll)); + + /// Scroll by whole lines (sign: +down/newer, -up/older). + public void ScrollByLines(int lines) => SetScrollY(_scrollY + lines * LineHeight); + + /// Scroll by a page = one view height (InqScrollDelta page case). + public void ScrollByPage(int pages) => SetScrollY(_scrollY + pages * ViewHeight); +} diff --git a/tests/AcDream.App.Tests/UI/UiScrollableTests.cs b/tests/AcDream.App.Tests/UI/UiScrollableTests.cs new file mode 100644 index 00000000..27804b1c --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiScrollableTests.cs @@ -0,0 +1,73 @@ +using AcDream.App.UI; +using Xunit; + +namespace AcDream.App.Tests.UI; + +public class UiScrollableTests +{ + [Fact] + public void Clamp_KeepsScrollWithinContent() + { + var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 }; + s.SetScrollY(500); + Assert.Equal(200, s.ScrollY); + s.SetScrollY(-50); + Assert.Equal(0, s.ScrollY); + } + + [Fact] + public void FitsView_PinsToZero() + { + var s = new UiScrollable { ContentHeight = 80, ViewHeight = 100 }; + s.SetScrollY(40); + Assert.Equal(0, s.ScrollY); + Assert.False(s.HasOverflow); + } + + [Fact] + public void ThumbRatio_IsViewOverContent_ClampedToOne() + { + var s = new UiScrollable { ContentHeight = 400, ViewHeight = 100 }; + Assert.Equal(0.25f, s.ThumbRatio, 3); + var full = new UiScrollable { ContentHeight = 50, ViewHeight = 100 }; + Assert.Equal(1f, full.ThumbRatio, 3); + } + + [Fact] + public void PositionRatio_MapsScrollToZeroOne() + { + var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 }; + s.SetScrollY(100); + Assert.Equal(0.5f, s.PositionRatio, 3); + s.SetScrollY(200); + Assert.Equal(1f, s.PositionRatio, 3); + } + + [Fact] + public void SetPositionRatio_IsInverseOfPositionRatio() + { + var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 }; + s.SetPositionRatio(0.5f); + Assert.Equal(100, s.ScrollY); + } + + [Fact] + public void ScrollByLines_AdvancesByLineHeight() + { + var s = new UiScrollable { ContentHeight = 1000, ViewHeight = 100, LineHeight = 16 }; + s.ScrollByLines(-2); + Assert.Equal(0, s.ScrollY); + s.SetScrollY(50); + s.ScrollByLines(2); + Assert.Equal(82, s.ScrollY); + } + + [Fact] + public void ScrollByPage_AdvancesByViewHeight() + { + var s = new UiScrollable { ContentHeight = 1000, ViewHeight = 100, LineHeight = 16 }; + s.SetScrollY(200); + s.ScrollByPage(1); + Assert.Equal(300, s.ScrollY); + } +} From 0eaef67b9d63cb0700c63ad9d4722968698c0abb Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 22:23:17 +0200 Subject: [PATCH 63/99] feat(D.2b): UiChatView drives the shared UiScrollable model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the ad-hoc _scroll float with a public UiScrollable instance. OnDraw feeds ContentHeight/ViewHeight/LineHeight into the model each frame and reads baseY = bottom - contentH + (MaxScroll - ScrollY) — the (MaxScroll-ScrollY) inversion reconciles UiScrollable's top-origin convention (0=oldest, MaxScroll=newest) with the visual layout (newest at bottom). The wheel handler routes through ScrollByLines with a sign flip so wheel-up still reveals older lines. _pinBottom tracks whether the view is at the end and calls ScrollToEnd() each draw to auto-scroll new messages. ClampScroll static method kept — referenced by existing tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/UiChatView.cs | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/src/AcDream.App/UI/UiChatView.cs b/src/AcDream.App/UI/UiChatView.cs index 1392c26f..9dbe9cd3 100644 --- a/src/AcDream.App/UI/UiChatView.cs +++ b/src/AcDream.App/UI/UiChatView.cs @@ -52,8 +52,12 @@ public sealed class UiChatView : UiElement /// Inner text inset from the view edges, px. public float Padding { get; set; } = 4f; - // Pixels the transcript is scrolled UP from the newest line (0 = pinned to bottom). - private float _scroll; + /// The scroll model — also read by the linked UiChatScrollbar. + public UiScrollable Scroll { get; } = new(); + + /// True while the view is pinned to the newest line (auto-scrolls as content grows). + private bool _pinBottom = true; + private const float WheelLines = 1f; // lines advanced per wheel notch (retail = 1 line per notch) // ── Cached layout from the last OnDraw, so OnEvent hit-tests the SAME geometry ── @@ -112,11 +116,19 @@ public sealed class UiChatView : UiElement float top = Padding, bottom = Height - Padding; float innerH = bottom - top; float contentH = lines.Count * lh; - _scroll = ClampScroll(_scroll, contentH, innerH); - // Bottom-pin: with _scroll==0 the LAST line ends at `bottom`; scrolling up - // shifts the whole block down so older lines are revealed at the top. - float baseY = bottom - contentH + _scroll; + // Drive the shared scroll model with the current geometry. + Scroll.LineHeight = (int)MathF.Round(lh); + Scroll.ContentHeight = (int)MathF.Ceiling(contentH); + Scroll.ViewHeight = (int)MathF.Floor(innerH); + if (_pinBottom) Scroll.ScrollToEnd(); + + // UiScrollable: ScrollY=0 is TOP/oldest, ScrollY=MaxScroll is BOTTOM/newest. + // Visual layout: newest at bottom → baseY = bottom - contentH (ScrollY at max). + // Invert: baseY = bottom - contentH + (MaxScroll - ScrollY). + // With _pinBottom: ScrollY=MaxScroll → baseY=bottom-contentH → last line ends at bottom. ✓ + // Scrolled to top: ScrollY=0 → baseY=bottom-contentH+MaxScroll=bottom-innerH=top. ✓ + float baseY = bottom - contentH + (Scroll.MaxScroll - Scroll.ScrollY); _lastBaseY = baseY; // Normalised selection span (start <= end), if any. @@ -166,9 +178,11 @@ public sealed class UiChatView : UiElement { case UiEventType.Scroll: { - float lh = DatFont?.LineHeight ?? (Font ?? _lastFont)?.LineHeight ?? 16f; - // Silk wheel +Y = scroll up = reveal older = shift content down = larger _scroll. - _scroll += e.Data0 * WheelLines * lh; // re-clamped next OnDraw against live content + // Silk wheel +Y = scroll up = reveal older = toward the TOP = decrease ScrollY. + // ScrollByLines sign: +down/newer, -up/older. + // e.Data0 > 0 → wheel up → want older → ScrollByLines with negative lines. + Scroll.ScrollByLines((int)(-e.Data0 * WheelLines)); + _pinBottom = Scroll.AtEnd; return true; } From 2940b4e3b2a36f912f94df4fb3ebf8f4407b0428 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 22:31:12 +0200 Subject: [PATCH 64/99] =?UTF-8?q?feat(D.2b):=20UiChatScrollbar=20=E2=80=94?= =?UTF-8?q?=20track/thumb/buttons=20driving=20UiScrollable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the right-side chat scrollbar widget. Ports retail UIElement_Scrollbar::UpdateLayout @0x4710d0 (thumb sizing + placement) and HandleButtonClick @0x470e90 (step ±1 line, page on track click). Dat element ids sourced from chat LayoutDesc 0x21000006 (base layout 0x2100003E): up-button sprite 0x06004C69, down-button 0x06004C6C, track 0x06004C5F, thumb middle 0x06004C63. Up/down buttons occupy the top and bottom ButtonH (16px) regions of the widget height, matching element positions Y=0 and Y=32 in the base scrollbar template. Adds 6 pure ThumbRect tests (no GL): sizing, clamping to MinThumb, position at start/mid/end, no-overflow full-fill. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/UiChatScrollbar.cs | 164 ++++++++++++++++++ .../UI/UiChatScrollbarTests.cs | 81 +++++++++ 2 files changed, 245 insertions(+) create mode 100644 src/AcDream.App/UI/UiChatScrollbar.cs create mode 100644 tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cs diff --git a/src/AcDream.App/UI/UiChatScrollbar.cs b/src/AcDream.App/UI/UiChatScrollbar.cs new file mode 100644 index 00000000..6274f7b4 --- /dev/null +++ b/src/AcDream.App/UI/UiChatScrollbar.cs @@ -0,0 +1,164 @@ +using System; +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// Right-side chat scrollbar: a track sprite, a draggable thumb sized to the +/// content/view ratio, and up/down step buttons. Drives a linked +/// . Ports retail UIElement_Scrollbar::UpdateLayout +/// @0x4710d0 (thumb size = trackLen * ThumbRatio, min 8px; thumb pos from +/// PositionRatio) and HandleButtonClick @0x470e90 (step ±1 line). +/// +/// +/// Dat element ids (chat LayoutDesc 0x21000006): track 0x10000012 (X=474 Y=6 W=16 H=68), +/// thumb 0x1000048C. The track is instanced from base layout 0x2100003E which contains +/// the full scrollbar widget with distinct up/down button children: +/// Up button element 0x10000071 — Y=0, 16×16, Normal sprite 0x06004C69. +/// Down button element 0x10000072 — Y=32, 16×16, Normal sprite 0x06004C6C. +/// Track body sprite: 0x06004C5F (48px tall in the base template; stretched to H=68 in chat). +/// Thumb is a 3-slice: top cap 0x06004C60, middle 0x06004C63, bottom cap 0x06004C66. +/// For Task H wiring: up/down regions occupy the top and bottom ButtonH (16px) of the +/// rendered scrollbar's height; the widget responds to those regions directly via hit +/// comparison in OnEvent without requiring separate child elements. +/// +public sealed class UiChatScrollbar : UiElement +{ + /// The scroll model this bar reflects + drives (shared with the transcript). + public UiScrollable? Model { get; set; } + + /// RenderSurface id → (GL tex, w, h). 0 id = skip. + public Func? SpriteResolve { get; set; } + + /// Track background sprite id (0x06004C5F from layout 0x2100003E element 0x10000455). + public uint TrackSprite { get; set; } + + /// Thumb sprite id (3-slice middle tile: 0x06004C63; the widget draws + /// a single stretched sprite for simplicity — Task H can upgrade to 3-slice). + public uint ThumbSprite { get; set; } + + /// Up-arrow button sprite id (0x06004C69 Normal state, element 0x10000071). + public uint UpSprite { get; set; } + + /// Down-arrow button sprite id (0x06004C6C Normal state, element 0x10000072). + public uint DownSprite { get; set; } + + /// Retail attribute 0x89 floor: minimum thumb height in pixels. + private const float MinThumb = 8f; + + /// Up/down button height in pixels. Matches element height 16px from + /// the up/down button children in base layout 0x2100003E. + private const float ButtonH = 16f; + + private bool _draggingThumb; + private float _dragOffsetY; + + public UiChatScrollbar() { CapturesPointerDrag = true; } + + /// + /// Computes the thumb rectangle (local y origin and height) within the track area + /// between the two end buttons. Ports retail UIElement_Scrollbar::UpdateLayout + /// @0x4710d0: thumb height = max(MinThumb, trackLen * ThumbRatio); thumb top + /// offset = trackTop + (trackLen - thumbH) * PositionRatio. + /// + /// The scroll model. + /// Y of the top of the usable track area (below up-button). + /// Pixel length of the usable track area (between up and down buttons). + /// Local Y of the thumb's top edge, and its pixel height. + public static (float y, float h) ThumbRect(UiScrollable m, float trackTop, float trackLen) + { + float h = MathF.Max(MinThumb, trackLen * m.ThumbRatio); + float travel = trackLen - h; + float y = trackTop + travel * m.PositionRatio; + return (y, h); + } + + protected override void OnDraw(UiRenderContext ctx) + { + if (Model is not { } m || SpriteResolve is not { } resolve) return; + + // Track background, full element bounds. + DrawSprite(ctx, resolve, TrackSprite, 0f, 0f, Width, Height); + + // Up button — top ButtonH rows. + DrawSprite(ctx, resolve, UpSprite, 0f, 0f, Width, ButtonH); + + // Down button — bottom ButtonH rows. + DrawSprite(ctx, resolve, DownSprite, 0f, Height - ButtonH, Width, ButtonH); + + // Thumb — only when content overflows the view. + if (m.HasOverflow) + { + float trackTop = ButtonH; + float trackLen = Height - 2f * ButtonH; + var (ty, th) = ThumbRect(m, trackTop, trackLen); + DrawSprite(ctx, resolve, ThumbSprite, 0f, ty, Width, th); + } + } + + private void DrawSprite(UiRenderContext ctx, Func resolve, + uint id, float x, float y, float w, float h) + { + if (id == 0) return; + var (tex, _, _) = resolve(id); + if (tex == 0) return; + ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, 1f, 1f, Vector4.One); + } + + public override bool OnEvent(in UiEvent e) + { + if (Model is not { } m) return false; + + switch (e.Type) + { + case UiEventType.MouseDown: + { + // e.Data1 = local X, e.Data2 = local Y (int pixel coords, see UiRoot hit dispatch). + float ly = e.Data2; + + // Up-button region: top ButtonH rows. + if (ly <= ButtonH) { m.ScrollByLines(-1); return true; } + + // Down-button region: bottom ButtonH rows. + if (ly >= Height - ButtonH) { m.ScrollByLines(1); return true; } + + // Track interior: start a thumb drag or page-scroll. + float trackTop = ButtonH; + float trackLen = Height - 2f * ButtonH; + var (ty, th) = ThumbRect(m, trackTop, trackLen); + + if (ly >= ty && ly <= ty + th) + { + // Clicked inside the thumb — begin drag with offset from thumb top. + _draggingThumb = true; + _dragOffsetY = ly - ty; + } + else + { + // Clicked above or below thumb — page scroll (HandleButtonClick page case). + m.ScrollByPage(ly < ty ? -1 : 1); + } + return true; + } + + case UiEventType.MouseMove when _draggingThumb: + { + // Map current local Y (minus drag offset from thumb top) back to a + // position ratio across the available travel distance. + float trackTop = ButtonH; + float trackLen = Height - 2f * ButtonH; + float thumbH = MathF.Max(MinThumb, trackLen * m.ThumbRatio); + float travel = MathF.Max(1f, trackLen - thumbH); + float newRatio = ((float)e.Data2 - _dragOffsetY - trackTop) / travel; + m.SetPositionRatio(newRatio); + return true; + } + + case UiEventType.MouseUp: + _draggingThumb = false; + return true; + } + + return false; + } +} diff --git a/tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cs b/tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cs new file mode 100644 index 00000000..3f4ddbba --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cs @@ -0,0 +1,81 @@ +using AcDream.App.UI; +using Xunit; + +namespace AcDream.App.Tests.UI; + +/// +/// Pure unit tests for — no GL dependency. +/// +public class UiChatScrollbarTests +{ + // Model: content=400, view=100, trackLen=200. + // ThumbRatio = 100/400 = 0.25 → thumbH = max(8, 200*0.25) = 50. + // Travel = 200 - 50 = 150. + + [Fact] + public void ThumbRect_AtStart_HasCorrectSizeAndZeroOffset() + { + var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 }; + // PositionRatio = 0 (start). + var (y, h) = UiChatScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f); + Assert.Equal(50f, h, 3f); + Assert.Equal(0f, y, 3f); + } + + [Fact] + public void ThumbRect_AtEnd_PinsToBottomOfTrack() + { + var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 }; + m.ScrollToEnd(); // PositionRatio = 1. + float trackTop = 16f, trackLen = 200f; + var (y, h) = UiChatScrollbar.ThumbRect(m, trackTop, trackLen); + Assert.Equal(50f, h, 3f); + // y = trackTop + travel * 1 = 16 + 150 = 166. + Assert.Equal(166f, y, 3f); + } + + [Fact] + public void ThumbRect_WithButtonH_CorrectlyOffsetsFromTrackTop() + { + // Matches task spec: content=400, view=100, trackLen=200, PositionRatio=1. + // thumbH=50; travel=150; y = trackTop + 150 = trackTop + 150. + var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 }; + m.ScrollToEnd(); + var (y, h) = UiChatScrollbar.ThumbRect(m, trackTop: 16f, trackLen: 200f); + Assert.Equal(50f, h, 3f); + Assert.Equal(166f, y, 3f); // 16 + 150 + } + + [Fact] + public void ThumbRect_MidScroll_InterpolatesPosition() + { + // content=400 view=100 → MaxScroll=300; ScrollY=150 → PositionRatio=0.5. + var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 }; + m.SetScrollY(150); + Assert.Equal(0.5f, m.PositionRatio, 3); + + var (y, h) = UiChatScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f); + Assert.Equal(50f, h, 3f); + // y = 0 + 150 * 0.5 = 75. + Assert.Equal(75f, y, 3f); + } + + [Fact] + public void ThumbRect_SmallContent_EnforcesMinThumb() + { + // content=1000, view=10, trackLen=200 → ThumbRatio=0.01 → raw=2 < 8 → clamp to 8. + var m = new UiScrollable { ContentHeight = 1000, ViewHeight = 10 }; + var (_, h) = UiChatScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f); + Assert.Equal(8f, h, 3f); + } + + [Fact] + public void ThumbRect_NoOverflow_ThumbFillsTrack() + { + // content <= view → ThumbRatio = 1 → thumbH = trackLen. + var m = new UiScrollable { ContentHeight = 50, ViewHeight = 100 }; + var (y, h) = UiChatScrollbar.ThumbRect(m, trackTop: 16f, trackLen: 100f); + Assert.Equal(100f, h, 3f); + Assert.Equal(16f, y, 3f); // travel = 0 → y = trackTop + } +} From bcc45d668e08fa24fe795c7c094650819771e6a0 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 22:36:44 +0200 Subject: [PATCH 65/99] =?UTF-8?q?feat(D.2b):=20UiChatInput=20=E2=80=94=20e?= =?UTF-8?q?ditable=20field,=20caret,=20100-entry=20history=20(UIElement=5F?= =?UTF-8?q?Text=20port)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports retail UIElement_Text editable one-line mode (caret = glyph index; caret pixel-X = sum of glyph advances via UiDatFont) and ChatInterface's 100-entry command history (up/down arrow; sentinel -1 = live line). Submit (Enter/KeypadEnter) fires OnSubmit callback, clears, pushes history. Draws via DrawStringDat (dat font) or DrawString (BitmapFont) fallback. AcceptsFocus=true + IsEditControl=true so UiRoot routes Char/KeyDown to it and suppresses global hotkeys while typing. 6 new tests, all green. Decomp refs: UIElement_Text::MoveCursor @0x468d00, UIElement_Text::FindPixelsFromPos @0x472b40, ChatInterface::ProcessCommand @0x4f5100 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/UiChatInput.cs | 158 ++++++++++++++++++ .../AcDream.App.Tests/UI/UiChatInputTests.cs | 72 ++++++++ 2 files changed, 230 insertions(+) create mode 100644 src/AcDream.App/UI/UiChatInput.cs create mode 100644 tests/AcDream.App.Tests/UI/UiChatInputTests.cs diff --git a/src/AcDream.App/UI/UiChatInput.cs b/src/AcDream.App/UI/UiChatInput.cs new file mode 100644 index 00000000..8bed6af0 --- /dev/null +++ b/src/AcDream.App/UI/UiChatInput.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// Editable one-line chat input. Port of retail UIElement_Text editable +/// one-line mode + ChatInterface's 100-entry command history. Caret is a +/// glyph index; the caret pixel-X is Σ glyph advances (UiDatFont) to the caret. +/// Submit (Enter / Send) fires , clears, and pushes history. +/// Decomp: UIElement_Text MoveCursor @0x468d00, FindPixelsFromPos @0x472b40; +/// ChatInterface ProcessCommand @0x4f5100 (history cap 100, sentinel 0xFFFFFFFF). +/// +public sealed class UiChatInput : UiElement +{ + public UiDatFont? DatFont { get; set; } + public AcDream.App.Rendering.BitmapFont? Font { get; set; } + public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f); + public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0.35f); + public float Padding { get; set; } = 4f; + public int MaxCharacters { get; set; } = 0xFFFF; + + public Action? OnSubmit { get; set; } + + private string _text = ""; + private int _caret; + public string Text => _text; + public int CaretPos => _caret; + + private readonly List _history = new(); + private int _historyIndex = -1; + public int HistoryCount => _history.Count; + + public UiChatInput() + { + AcceptsFocus = true; + IsEditControl = true; + CapturesPointerDrag = true; + } + + public void InsertChar(char c) + { + if (c < 0x20 || c == 0x7F) return; + if (_text.Length >= MaxCharacters) return; + _text = _text.Insert(_caret, c.ToString()); + _caret++; + _historyIndex = -1; + } + + public void Backspace() + { + if (_caret == 0) return; + _text = _text.Remove(_caret - 1, 1); + _caret--; + } + + public void DeleteForward() + { + if (_caret >= _text.Length) return; + _text = _text.Remove(_caret, 1); + } + + public void MoveCaret(int delta) => _caret = Math.Clamp(_caret + delta, 0, _text.Length); + public void CaretHome() => _caret = 0; + public void CaretEnd() => _caret = _text.Length; + + public void Submit() + { + var t = _text; + if (t.Trim().Length == 0) { Clear(); return; } + OnSubmit?.Invoke(t); + PushHistory(t); + Clear(); + } + + private void Clear() { _text = ""; _caret = 0; _historyIndex = -1; } + + private void PushHistory(string t) + { + _history.Add(t); + if (_history.Count > 100) _history.RemoveAt(0); + _historyIndex = -1; + } + + public void HistoryPrev() + { + if (_history.Count == 0) return; + _historyIndex = _historyIndex < 0 ? _history.Count - 1 : Math.Max(0, _historyIndex - 1); + SetTextFromHistory(); + } + + public void HistoryNext() + { + if (_historyIndex < 0) return; + _historyIndex++; + if (_historyIndex >= _history.Count) { _historyIndex = -1; Clear(); return; } + SetTextFromHistory(); + } + + private void SetTextFromHistory() + { + _text = _history[_historyIndex]; + _caret = _text.Length; + } + + public float CaretPixelX() + => DatFont is { } df ? df.MeasureWidth(_text.Substring(0, _caret)) + : Font is { } bf ? bf.MeasureWidth(_text.Substring(0, _caret)) : 0f; + + private bool _focused; + + protected override void OnDraw(UiRenderContext ctx) + { + ctx.DrawRect(0, 0, Width, Height, BackgroundColor); + float lh = DatFont?.LineHeight ?? Font?.LineHeight ?? 14f; + float ty = (Height - lh) * 0.5f; + if (DatFont is { } df) ctx.DrawStringDat(df, _text, Padding, ty, TextColor); + else ctx.DrawString(_text, Padding, ty, TextColor, Font); + + if (_focused) + { + float cx = Padding + CaretPixelX(); + ctx.DrawRect(cx, ty, 1f, lh, TextColor); + } + } + + public override bool OnEvent(in UiEvent e) + { + switch (e.Type) + { + case UiEventType.FocusGained: _focused = true; return true; + case UiEventType.FocusLost: _focused = false; _historyIndex = -1; return true; + case UiEventType.Char: + InsertChar((char)e.Data0); + return true; + case UiEventType.KeyDown: + { + var key = (Silk.NET.Input.Key)e.Data0; + switch (key) + { + case Silk.NET.Input.Key.Enter: + case Silk.NET.Input.Key.KeypadEnter: Submit(); return true; + case Silk.NET.Input.Key.Backspace: Backspace(); return true; + case Silk.NET.Input.Key.Delete: DeleteForward(); return true; + case Silk.NET.Input.Key.Left: MoveCaret(-1); return true; + case Silk.NET.Input.Key.Right: MoveCaret(1); return true; + case Silk.NET.Input.Key.Home: CaretHome(); return true; + case Silk.NET.Input.Key.End: CaretEnd(); return true; + case Silk.NET.Input.Key.Up: HistoryPrev(); return true; + case Silk.NET.Input.Key.Down: HistoryNext(); return true; + } + return false; + } + } + return false; + } +} diff --git a/tests/AcDream.App.Tests/UI/UiChatInputTests.cs b/tests/AcDream.App.Tests/UI/UiChatInputTests.cs new file mode 100644 index 00000000..abbb751b --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiChatInputTests.cs @@ -0,0 +1,72 @@ +using AcDream.App.UI; +using Xunit; + +namespace AcDream.App.Tests.UI; + +public class UiChatInputTests +{ + [Fact] + public void InsertChar_AdvancesCaret() + { + var input = new UiChatInput(); + input.InsertChar('h'); input.InsertChar('i'); + Assert.Equal("hi", input.Text); + Assert.Equal(2, input.CaretPos); + } + + [Fact] + public void Backspace_DeletesBeforeCaret() + { + var input = new UiChatInput(); + foreach (var c in "abc") input.InsertChar(c); + input.MoveCaret(-1); + input.Backspace(); + Assert.Equal("ac", input.Text); + Assert.Equal(1, input.CaretPos); + } + + [Fact] + public void Submit_FiresCallback_ClearsText_PushesHistory() + { + string? sent = null; + var input = new UiChatInput { OnSubmit = t => sent = t }; + foreach (var c in "hello") input.InsertChar(c); + input.Submit(); + Assert.Equal("hello", sent); + Assert.Equal("", input.Text); + Assert.Equal(0, input.CaretPos); + } + + [Fact] + public void EmptySubmit_DoesNotFire() + { + int n = 0; + var input = new UiChatInput { OnSubmit = _ => n++ }; + input.Submit(); + Assert.Equal(0, n); + } + + [Fact] + public void History_UpDownBrowsesPreviousSubmissions() + { + var input = new UiChatInput { OnSubmit = _ => {} }; + foreach (var c in "first") input.InsertChar(c); input.Submit(); + foreach (var c in "second") input.InsertChar(c); input.Submit(); + input.HistoryPrev(); + Assert.Equal("second", input.Text); + input.HistoryPrev(); + Assert.Equal("first", input.Text); + input.HistoryNext(); + Assert.Equal("second", input.Text); + input.HistoryNext(); + Assert.Equal("", input.Text); + } + + [Fact] + public void History_CapsAt100() + { + var input = new UiChatInput { OnSubmit = _ => {} }; + for (int i = 0; i < 150; i++) { input.InsertChar('x'); input.Submit(); } + Assert.True(input.HistoryCount <= 100); + } +} From c2170ab18f85ebd334fb7dff969091d57f6da850 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 22:42:22 +0200 Subject: [PATCH 66/99] =?UTF-8?q?feat(D.2b):=20UiChannelMenu=20=E2=80=94?= =?UTF-8?q?=20channel=20selector=20popup=20(UIElement=5FMenu=20port)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of retail gmMainChatUI::InitTalkFocusMenu @0x4cdc50 + HandleSelection @0x4cd540 → SetTalkFocus. Button shows active channel label; click opens a 12-item popup that extends UPWARD (chat sits at screen bottom); selecting an entry calls OnChannelChanged and updates Selected. BitmapFont? Font uses the fully-qualified type name to match UiChatInput convention. Includes 6 xunit tests covering channel table shape, default selection, and popup-pick routing. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/UiChannelMenu.cs | 109 ++++++++++++++++++ .../UI/UiChannelMenuTests.cs | 76 ++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 src/AcDream.App/UI/UiChannelMenu.cs create mode 100644 tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs diff --git a/src/AcDream.App/UI/UiChannelMenu.cs b/src/AcDream.App/UI/UiChannelMenu.cs new file mode 100644 index 00000000..9726eb08 --- /dev/null +++ b/src/AcDream.App/UI/UiChannelMenu.cs @@ -0,0 +1,109 @@ +using System; +using System.Numerics; +using AcDream.UI.Abstractions; + +namespace AcDream.App.UI; + +/// +/// Chat channel selector (the "Chat ▸" button). Port of retail +/// UIElement_Menu as used by gmMainChatUI::InitTalkFocusMenu @0x4cdc50: +/// a button whose label is the active channel; clicking opens a popup of channels; +/// selecting one calls SetTalkFocus (here: ). +/// +public sealed class UiChannelMenu : UiElement +{ + public readonly record struct Item(string Label, ChatChannelKind Channel); + + /// Retail talk-focus channels (subset acdream's ChatInputParser routes). + public static readonly Item[] Channels = + { + new("Say", ChatChannelKind.Say), + new("General", ChatChannelKind.General), + new("Trade", ChatChannelKind.Trade), + new("LFG", ChatChannelKind.Lfg), + new("Fellowship", ChatChannelKind.Fellowship), + new("Allegiance", ChatChannelKind.Allegiance), + new("Patron", ChatChannelKind.Patron), + new("Vassals", ChatChannelKind.Vassals), + new("Monarch", ChatChannelKind.Monarch), + new("Roleplay", ChatChannelKind.Roleplay), + new("Society", ChatChannelKind.Society), + new("Olthoi", ChatChannelKind.Olthoi), + }; + + public ChatChannelKind Selected { get; private set; } = ChatChannelKind.Say; + public Action? OnChannelChanged { get; set; } + + public UiDatFont? DatFont { get; set; } + public AcDream.App.Rendering.BitmapFont? Font { get; set; } + public Func? SpriteResolve { get; set; } + public uint NormalSprite { get; set; } + public uint PressedSprite { get; set; } + public Vector4 TextColor { get; set; } = new(1f, 0.85f, 0.4f, 1f); + + private bool _open; + private const float ItemH = 16f; + private const float PopupW = 90f; + + public UiChannelMenu() { CapturesPointerDrag = true; } + + private string Label => FindLabel(Selected); + private static string FindLabel(ChatChannelKind k) + { + foreach (var it in Channels) if (it.Channel == k) return it.Label; + return "Chat"; + } + + protected override void OnDraw(UiRenderContext ctx) + { + if (SpriteResolve is { } resolve) + { + var (tex, _, _) = resolve(_open ? PressedSprite : NormalSprite); + if (tex != 0) ctx.DrawSprite(tex, 0, 0, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); + } + DrawLabel(ctx, Label + " >", 2f, (Height - LineH()) * 0.5f); + + if (_open) + { + float h = Channels.Length * ItemH; + float top = -h; // popup opens UPWARD (chat sits at screen bottom) + ctx.DrawRect(0, top, MathF.Max(Width, PopupW), h, new(0f, 0f, 0f, 0.85f)); + for (int i = 0; i < Channels.Length; i++) + DrawLabel(ctx, Channels[i].Label, 2f, top + i * ItemH); + } + } + + private float LineH() => DatFont?.LineHeight ?? Font?.LineHeight ?? 14f; + private void DrawLabel(UiRenderContext ctx, string s, float x, float y) + { + if (DatFont is { } df) ctx.DrawStringDat(df, s, x, y, TextColor); + else ctx.DrawString(s, x, y, TextColor, Font); + } + + protected override bool OnHitTest(float lx, float ly) + => _open ? (lx >= 0 && lx < MathF.Max(Width, PopupW) + && ly >= -Channels.Length * ItemH && ly < Height) + : base.OnHitTest(lx, ly); + + public override bool OnEvent(in UiEvent e) + { + if (e.Type == UiEventType.MouseDown) + { + float ly = e.Data2; + if (_open && ly < 0) + { + int idx = (int)((ly + Channels.Length * ItemH) / ItemH); + if (idx >= 0 && idx < Channels.Length) + { + Selected = Channels[idx].Channel; + OnChannelChanged?.Invoke(Selected); + } + _open = false; + return true; + } + _open = !_open; + return true; + } + return false; + } +} diff --git a/tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs b/tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs new file mode 100644 index 00000000..c9f7b73b --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs @@ -0,0 +1,76 @@ +using AcDream.App.UI; +using AcDream.UI.Abstractions; + +namespace AcDream.App.Tests.UI; + +public class UiChannelMenuTests +{ + [Fact] + public void Channels_HasExpected12Entries() + { + Assert.Equal(12, UiChannelMenu.Channels.Length); + } + + [Fact] + public void Channels_FirstEntry_IsSay() + { + Assert.Equal(ChatChannelKind.Say, UiChannelMenu.Channels[0].Channel); + Assert.Equal("Say", UiChannelMenu.Channels[0].Label); + } + + [Fact] + public void Channels_LastEntry_IsOlthoi() + { + var last = UiChannelMenu.Channels[^1]; + Assert.Equal(ChatChannelKind.Olthoi, last.Channel); + Assert.Equal("Olthoi", last.Label); + } + + [Fact] + public void Channels_ContainsAllExpectedKinds() + { + var kinds = new HashSet(UiChannelMenu.Channels.Select(c => c.Channel)); + Assert.Contains(ChatChannelKind.Say, kinds); + Assert.Contains(ChatChannelKind.General, kinds); + Assert.Contains(ChatChannelKind.Trade, kinds); + Assert.Contains(ChatChannelKind.Lfg, kinds); + Assert.Contains(ChatChannelKind.Fellowship, kinds); + Assert.Contains(ChatChannelKind.Allegiance, kinds); + Assert.Contains(ChatChannelKind.Patron, kinds); + Assert.Contains(ChatChannelKind.Vassals, kinds); + Assert.Contains(ChatChannelKind.Monarch, kinds); + Assert.Contains(ChatChannelKind.Roleplay, kinds); + Assert.Contains(ChatChannelKind.Society, kinds); + Assert.Contains(ChatChannelKind.Olthoi, kinds); + } + + [Fact] + public void DefaultSelected_IsSay() + { + var menu = new UiChannelMenu(); + Assert.Equal(ChatChannelKind.Say, menu.Selected); + } + + [Fact] + public void OnChannelChanged_FiredWhenSelectionMadeViaEvent() + { + var menu = new UiChannelMenu { Width = 80f, Height = 18f }; + + // Open the popup (click inside the button area — Data2 >= 0). + var openEvt = new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5); + Assert.True(menu.OnEvent(openEvt)); + + // Click on the second item (General) in the upward popup. + // Popup renders UPWARD: top = -(12 * 16) = -192. + // Item i=1 (General) occupies y in [-192 + 16, -192 + 32) = [-176, -160). + // A click at ly = -176 + 8 = -168 hits item index = (int)((-168 + 192) / 16) = (int)(24/16) = 1. + ChatChannelKind? fired = null; + menu.OnChannelChanged = k => fired = k; + + var selectEvt = new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -168); + Assert.True(menu.OnEvent(selectEvt)); + + Assert.Equal(ChatChannelKind.General, fired); + Assert.Equal(ChatChannelKind.General, menu.Selected); + } +} From 6e6339b026212c7e3d55687505d81a193b881f72 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 22:54:37 +0200 Subject: [PATCH 67/99] feat(D.2b): importer renders Type-12-with-sprites + carries DefaultState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task G1: two gaps blocked chat window static sprite elements from rendering. Change 1 — DatWidgetFactory: only skip Type-12 elements that have no own state media (pure style prototypes). A Type-12 element that carries sprites (e.g. a chat Send button whose derived Type-0 element inherited Type 12 from its base prototype) now renders as a UiDatElement. Change 2 — ElementInfo: add DefaultStateName field (string, default ""). Change 3 — LayoutImporter.ToInfo: read ElementDesc.DefaultState.ToString() into DefaultStateName; normalize Undef/Undefined/0 sentinels to "". Change 4 — ElementReader.Merge: inherit DefaultStateName (derived wins if non-empty, else base). Change 5 — UiDatElement ctor: initialize ActiveState to DefaultStateName when set; else "Normal" when a Normal-state sprite is present (retail's implicit default for buttons/tabs); else "" (DirectState). This makes the Send button, max/min button, and numbered tabs render their default sprite without requiring explicit state assignment at runtime. Vitals neutrality: all vitals chrome/grip elements carry DirectState-only sprites with no "Normal" named state and DefaultStateName="" (Undef in dat), so their ActiveState stays "" and their existing conformance tests are unaffected. Vitals text labels (Type 0→12 via Merge, no StateMedia) are still skipped by the refined Type-12 guard (StateMedia.Count==0). Tests: 4 new tests (2 in DatWidgetFactoryTests, 3 in UiDatElementTests). All 386 pass; 387 total (1 pre-existing skip). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 18 ++++--- src/AcDream.App/UI/Layout/ElementReader.cs | 12 ++++- src/AcDream.App/UI/Layout/LayoutImporter.cs | 26 +++++---- src/AcDream.App/UI/Layout/UiDatElement.cs | 9 ++++ .../UI/Layout/DatWidgetFactoryTests.cs | 26 +++++++++ .../UI/Layout/UiDatElementTests.cs | 54 +++++++++++++++++++ 6 files changed, 126 insertions(+), 19 deletions(-) diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 059ee654..d4df6589 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -9,8 +9,10 @@ namespace AcDream.App.UI.Layout; /// . /// /// -/// Type 12 (style prototype / BaseElement store) is never instantiated — -/// returns null and the importer skips it. +/// Type 12 elements that carry NO own state media (pure style prototypes / +/// BaseElement stores) return null from and are skipped. +/// Type 12 elements that DO carry own sprites (e.g. a chat element whose Type-0 +/// derived form inherited Type 12 from its base prototype) are rendered normally. /// See docs/research/2026-06-15-layoutdesc-format.md Correction 8. /// /// @@ -42,14 +44,16 @@ public static class DatWidgetFactory /// Returns (0,0,0) when the texture is not yet uploaded. /// Retail UI font for the meter's "cur/max" number overlay. /// May be null pre-load — the meter falls back to the debug bitmap font. - /// The widget, or null for a Type-12 style prototype (caller skips it). + /// The widget, or null for a pure Type-12 style prototype with no own sprites (caller skips it). public static UiElement? Create(ElementInfo info, Func resolve, UiDatFont? datFont) { - // Type 12 = zero-size style prototype / BaseElement store referenced by - // BaseLayoutId. These are property bags, never rendered. See format doc §8 - // ("style prototypes are Type 12 which must be skipped") and Correction 8. - if (info.Type == 12) return null; + // Type 12 = style prototype / BaseElement store referenced by BaseLayoutId. + // PURE prototypes (no own state media) are property bags — never rendered; skip them. + // A Type-12 element that carries its own state media (e.g. a chat Send button whose + // Type-0 derived element inherited Type 12 from its base prototype) has sprites to + // show and must render. See format doc §8 and the G1 task note. + if (info.Type == 12 && info.StateMedia.Count == 0) return null; UiElement e = info.Type switch { diff --git a/src/AcDream.App/UI/Layout/ElementReader.cs b/src/AcDream.App/UI/Layout/ElementReader.cs index 061d59e9..93a4eb30 100644 --- a/src/AcDream.App/UI/Layout/ElementReader.cs +++ b/src/AcDream.App/UI/Layout/ElementReader.cs @@ -53,6 +53,14 @@ public sealed class ElementInfo /// public Dictionary StateMedia = new(); + /// + /// The element's initial active state name, taken from ElementDesc.DefaultState.ToString(). + /// Normalized to "" when the dat carries Undef/Undefined/0 (no default set). + /// Used by to pick which state's sprite to render initially. + /// Examples: "Normal" (Send button), "Minimized" (max/min button), "" (DirectState). + /// + public string DefaultStateName = ""; + /// /// Resolved child elements (populated by the importer in Task 5). /// Children come from the derived element's own tree, not the base element's. @@ -144,7 +152,9 @@ public static class ElementReader Right = derived.Right, Bottom = derived.Bottom, ReadOrder = derived.ReadOrder, - FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid, + FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid, + // DefaultStateName: derived wins if set; otherwise inherit the base's default. + DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName, // Children come from the derived element's own tree, not the base prototype's. // Defensive copy: prevent a later mutation of either the merged result or the input // from corrupting the other. Safe for the Task-5 flow (derived.Children is fully diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs index 9f5d439b..018cbb07 100644 --- a/src/AcDream.App/UI/Layout/LayoutImporter.cs +++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs @@ -208,19 +208,23 @@ public static class LayoutImporter /// private static ElementInfo ToInfo(ElementDesc d) { + // Normalize DefaultState: UIStateId.ToString() gives "Undef"/"Undefined" or "0" when + // no default is set; map those to "" so UiDatElement treats them as "no preference". + var defState = d.DefaultState.ToString(); var info = new ElementInfo { - Id = d.ElementId, - Type = d.Type, - X = (float)d.X, - Y = (float)d.Y, - Width = (float)d.Width, - Height = (float)d.Height, - Left = d.LeftEdge, - Top = d.TopEdge, - Right = d.RightEdge, - Bottom = d.BottomEdge, - ReadOrder = d.ReadOrder, + Id = d.ElementId, + Type = d.Type, + X = (float)d.X, + Y = (float)d.Y, + Width = (float)d.Width, + Height = (float)d.Height, + Left = d.LeftEdge, + Top = d.TopEdge, + Right = d.RightEdge, + Bottom = d.BottomEdge, + ReadOrder = d.ReadOrder, + DefaultStateName = (defState is "Undef" or "Undefined" or "0") ? "" : defState, }; // DirectState (unnamed, key ""). diff --git a/src/AcDream.App/UI/Layout/UiDatElement.cs b/src/AcDream.App/UI/Layout/UiDatElement.cs index 0da6a067..61f7c6b3 100644 --- a/src/AcDream.App/UI/Layout/UiDatElement.cs +++ b/src/AcDream.App/UI/Layout/UiDatElement.cs @@ -54,6 +54,15 @@ public sealed class UiDatElement : UiElement _info = info; _resolve = resolve; ClickThrough = true; // generic decoration; behavioral widgets opt back in + + // Pick the initial active state: retail applies DefaultState when set; falls back + // to "Normal" when the element has a Normal-state sprite (retail's implicit default + // for stateful elements like tabs and buttons); else the unnamed DirectState (""). + if (!string.IsNullOrEmpty(info.DefaultStateName)) + ActiveState = info.DefaultStateName; + else if (info.StateMedia.ContainsKey("Normal")) + ActiveState = "Normal"; + // else ActiveState stays "" (DirectState) } /// diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index 15dc8355..31b449bd 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -71,6 +71,32 @@ public class DatWidgetFactoryTests Assert.Equal(7, e!.ZOrder); } + // ── Test G1a: Type 12 with own sprites renders; without sprites is skipped ── + + /// + /// Task G1 change 1: only PURE Type-12 prototypes (no state media) are skipped. + /// A Type-12 element that carries its own state media must return a non-null widget. + /// + [Fact] + public void DatWidgetFactory_Type12WithMedia_Renders() + { + // Type 12 with a "Normal" state sprite — must render (NOT skipped). + var withMedia = new ElementInfo + { + Type = 12, + Width = 32, + Height = 16, + StateMedia = { ["Normal"] = (0x00001234u, 1) }, + }; + var e = DatWidgetFactory.Create(withMedia, NoTex, null); + Assert.NotNull(e); + Assert.IsType(e); + + // Type 12 with NO state media — must still be skipped (pure prototype). + var noMedia = new ElementInfo { Type = 12 }; + Assert.Null(DatWidgetFactory.Create(noMedia, NoTex, null)); + } + // ── Test 6: Meter slice extraction (the important one) ─────────────────── /// diff --git a/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs b/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs index 366f51c0..3f3ef20b 100644 --- a/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs @@ -33,4 +33,58 @@ public class UiDatElementTests var e = new UiDatElement(info, _ => (0, 0, 0)) { ActiveState = "NoSuchState" }; Assert.Equal(0x06000005u, e.ActiveMedia().File); } + + // ── G1 tests: DefaultStateName + "Normal" implicit default ─────────────── + + /// + /// Task G1 change 5: when an element has no DefaultStateName but does have a "Normal" + /// state sprite, the ctor should default ActiveState to "Normal" so the element + /// renders its normal-state sprite without requiring explicit state assignment. + /// + [Fact] + public void UiDatElement_DefaultsActiveStateToNormal_WhenNormalPresent() + { + var info = new ElementInfo(); + info.StateMedia["Normal"] = (0x0000AAAAu, 1); + info.StateMedia["Hover"] = (0x0000BBBBu, 1); + + var e = new UiDatElement(info, _ => (0, 0, 0)); + + // Should have defaulted to "Normal" state. + Assert.Equal(0x0000AAAAu, e.ActiveMedia().File); + } + + /// + /// Task G1 change 5: when DefaultStateName is set (e.g. "Minimized"), + /// it takes priority over the "Normal" implicit default. + /// + [Fact] + public void UiDatElement_DefaultsActiveStateToDefaultStateName_WhenSet() + { + var info = new ElementInfo { DefaultStateName = "Minimized" }; + info.StateMedia["Minimized"] = (0x0000BBBBu, 1); + info.StateMedia["Maximized"] = (0x0000CCCCu, 1); + info.StateMedia["Normal"] = (0x0000DDDDu, 1); + + var e = new UiDatElement(info, _ => (0, 0, 0)); + + // DefaultStateName "Minimized" wins over "Normal" implicit default. + Assert.Equal(0x0000BBBBu, e.ActiveMedia().File); + } + + /// + /// Task G1 change 5: elements with only a DirectState sprite and no "Normal" state + /// should still default to "" (DirectState) — no regression for chrome/grip elements. + /// + [Fact] + public void UiDatElement_NoDefaultStateName_NoNormal_DefaultsToDirectState() + { + var info = new ElementInfo(); + info.StateMedia[""] = (0x06007777u, 1); // DirectState only (e.g. vitals chrome corner) + + var e = new UiDatElement(info, _ => (0, 0, 0)); + + // No DefaultStateName, no "Normal" state → ActiveState stays "" (DirectState). + Assert.Equal(0x06007777u, e.ActiveMedia().File); + } } From 9d9e036e4cebc40f360d22f5a7065f4ec3d4abaf Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 23:04:57 +0200 Subject: [PATCH 68/99] =?UTF-8?q?feat(D.2b):=20ChatWindowController=20?= =?UTF-8?q?=E2=80=94=20bind=20chat=20LayoutDesc,=20place=20widgets,=20rout?= =?UTF-8?q?e=20chat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Task G2: binds the imported chat LayoutDesc (0x21000006) to live behavior, the acdream analogue of retail ChatInterface + gmMainChatUI::PostInit. - UiDatElement: add OnClick hook + OnEvent override so Send/max-min buttons can be wired by a controller without needing a dedicated widget type. - ChatWindowController.Bind: reads transcript (0x10000011) and input (0x10000016) rects from the raw ElementInfo tree (factory skips them as Type-12/no-media), places UiChatView under the transcript panel and UiChatInput under the input bar; replaces the imported scrollbar track (0x10000012) with UiChatScrollbar driving UiChatView.Scroll; replaces the channel menu placeholder (0x10000014) with UiChannelMenu; wires Send button and max/min toggle via the new OnClick hook. ChatCommandRouter.Submit routes all input through the existing pipeline. - 6 smoke tests: Bind returns non-null, Transcript is child of panel, Input is child of bar, Input.OnSubmit publishes SendChatCmd, channel change updates submit channel, returns null when panels missing. Build: 0 errors. Test suite: 392 passed / 1 skipped / 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../UI/Layout/ChatWindowController.cs | 304 ++++++++++++++++++ src/AcDream.App/UI/Layout/UiDatElement.cs | 11 + .../UI/Layout/ChatWindowControllerTests.cs | 209 ++++++++++++ 3 files changed, 524 insertions(+) create mode 100644 src/AcDream.App/UI/Layout/ChatWindowController.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs new file mode 100644 index 00000000..edc4c3f3 --- /dev/null +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -0,0 +1,304 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.UI; +using AcDream.Core.Chat; +using AcDream.UI.Abstractions; +using AcDream.UI.Abstractions.Panels.Chat; + +namespace AcDream.App.UI.Layout; + +/// +/// Binds the imported chat LayoutDesc (0x21000006) to live behavior — the acdream +/// analogue of retail ChatInterface + gmMainChatUI::PostInit @0x4ce130. +/// +/// +/// The transcript (0x10000011) and input (0x10000016) are Type-0 +/// elements whose base is a Type-12 prototype, so the importer factory skips them +/// (returns null). This controller reads their rects from the raw +/// tree (which contains everything) and adds the behavioral +/// widgets as children of their parent container widgets (transcript panel +/// 0x10000010 / input bar 0x10000013) which ARE created as +/// nodes. The scrollbar track (0x10000012) and +/// channel menu (0x10000014) are created by the factory and are replaced +/// with their behavioral counterparts here. +/// +/// +public sealed class ChatWindowController +{ + public const uint LayoutId = 0x21000006u; + + // Element ids from chat LayoutDesc 0x21000006 (confirmed in Task D/G1). + private const uint RootId = 0x1000000Eu; + private const uint TranscriptPanelId = 0x10000010u; + private const uint TranscriptId = 0x10000011u; // Type-12 prototype — skipped by factory + private const uint TrackId = 0x10000012u; + private const uint InputBarId = 0x10000013u; + private const uint MenuId = 0x10000014u; + private const uint InputId = 0x10000016u; // Type-12 prototype — skipped by factory + private const uint SendId = 0x10000019u; + private const uint MaxMinId = 0x1000046Fu; + + // Scrollbar sprite ids from base layout 0x2100003E (confirmed in Task D). + private const uint TrackSprite = 0x06004C5Fu; + private const uint ThumbSprite = 0x06004C63u; + private const uint UpSprite = 0x06004C69u; + private const uint DownSprite = 0x06004C6Cu; + + // Channel menu sprite ids (confirmed in chat element dump). + private const uint MenuNormal = 0x06004D65u; + private const uint MenuPressed = 0x06004D66u; + + // ── Public surface ───────────────────────────────────────────────────── + + /// Root element of the imported layout (the chat window chrome). + public UiElement Root { get; private set; } = null!; + + /// Live chat transcript widget. Null until succeeds. + public UiChatView Transcript { get; private set; } = null!; + + /// Editable chat input widget. Null until succeeds. + public UiChatInput Input { get; private set; } = null!; + + /// Scrollbar widget, driven by 's scroll model. + public UiChatScrollbar Scrollbar { get; private set; } = null!; + + /// Channel-selector menu widget. + public UiChannelMenu Menu { get; private set; } = null!; + + // ── Private state ────────────────────────────────────────────────────── + + private ChatChannelKind _activeChannel = ChatChannelKind.Say; + + /// Window height before maximize (stored to restore on un-maximize). + private float _normalHeight; + /// Window top before maximize. + private float _normalTop; + private bool _maximized; + + // ── Factory ──────────────────────────────────────────────────────────── + + /// + /// Bind an imported chat layout to live behavior. + /// + /// and must come from the + /// SAME pass (ImportInfos then Build) + /// so rects in the info tree match the widget geometry in the layout tree. + /// + /// Returns null if the essential transcript/input panels are missing from + /// the info tree or the widget tree (e.g. the layout dat is incomplete). + /// + /// Full tree from + /// . + /// Widget tree from . + /// Chat view-model (transcript data + command routing). + /// Command bus for SendChatCmd publishes. + /// Retail dat font for transcript + input rendering. + /// Fallback debug bitmap font (used when + /// is null). + /// Dat RenderSurface id → (GL tex handle, px width, px height). + /// Forwarded to and . + public static ChatWindowController? Bind( + ElementInfo rootInfo, + ImportedLayout layout, + ChatVM vm, + ICommandBus bus, + UiDatFont? datFont, + BitmapFont? debugFont, + Func resolve) + { + // The transcript + input nodes are Type-12 based and were skipped by the factory. + // Find them in the raw ElementInfo tree to read their rects. + var tInfo = FindInfo(rootInfo, TranscriptId); + var iInfo = FindInfo(rootInfo, InputId); + + // Their parent panels must exist as real widgets in the layout tree. + var transcriptPanel = layout.FindElement(TranscriptPanelId); + var inputBar = layout.FindElement(InputBarId); + + if (tInfo is null || iInfo is null || transcriptPanel is null || inputBar is null) + { + Console.WriteLine( + $"[D.2b] ChatWindowController.Bind: missing required elements " + + $"(tInfo={tInfo is not null}, iInfo={iInfo is not null}, " + + $"panel={transcriptPanel is not null}, bar={inputBar is not null}) — " + + $"chat window will not be interactive."); + return null; + } + + var c = new ChatWindowController { Root = layout.Root }; + + // ── Transcript ─────────────────────────────────────────────────── + // Place the behavioral transcript widget inside the transcript panel at the + // dat-rect of the (skipped) Type-12 transcript element. + c.Transcript = new UiChatView + { + Left = tInfo.X, + Top = tInfo.Y, + Width = tInfo.Width, + Height = tInfo.Height, + Anchors = ElementReader.ToAnchors(tInfo.Left, tInfo.Top, tInfo.Right, tInfo.Bottom), + DatFont = datFont, + Font = debugFont, + LinesProvider = () => BuildLines(vm), + }; + transcriptPanel.AddChild(c.Transcript); + + // ── Input ──────────────────────────────────────────────────────── + // Place the behavioral input widget inside the input bar. + c.Input = new UiChatInput + { + Left = iInfo.X, + Top = iInfo.Y, + Width = iInfo.Width, + Height = iInfo.Height, + Anchors = ElementReader.ToAnchors(iInfo.Left, iInfo.Top, iInfo.Right, iInfo.Bottom), + DatFont = datFont, + Font = debugFont, + }; + inputBar.AddChild(c.Input); + c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, bus, c._activeChannel); + + // ── Scrollbar — replace the imported track placeholder ──────────── + // The factory created a UiDatElement for the track. Remove it and place a + // behavioral UiChatScrollbar at the same position, driving the transcript's scroll. + var track = layout.FindElement(TrackId); + if (track?.Parent is { } trackParent) + { + c.Scrollbar = new UiChatScrollbar + { + Left = track.Left, + Top = track.Top, + Width = track.Width, + Height = track.Height, + Anchors = track.Anchors, + Model = c.Transcript.Scroll, + SpriteResolve = resolve, + TrackSprite = TrackSprite, + ThumbSprite = ThumbSprite, + UpSprite = UpSprite, + DownSprite = DownSprite, + }; + trackParent.RemoveChild(track); + trackParent.AddChild(c.Scrollbar); + } + + // ── Channel menu — replace the imported menu placeholder ────────── + var menuEl = layout.FindElement(MenuId); + if (menuEl?.Parent is { } menuParent) + { + c.Menu = new UiChannelMenu + { + Left = menuEl.Left, + Top = menuEl.Top, + Width = menuEl.Width, + Height = menuEl.Height, + Anchors = menuEl.Anchors, + DatFont = datFont, + Font = debugFont, + SpriteResolve = resolve, + NormalSprite = MenuNormal, + PressedSprite = MenuPressed, + }; + c.Menu.OnChannelChanged = k => c._activeChannel = k; + menuParent.RemoveChild(menuEl); + menuParent.AddChild(c.Menu); + } + + // ── Send button — Enter-alternate submit trigger ────────────────── + // Retail's gmMainChatUI wires the Send button to the same ProcessCommand path. + if (layout.FindElement(SendId) is UiDatElement sendEl) + { + sendEl.ClickThrough = false; + sendEl.OnClick = () => c.Input.Submit(); + } + + // ── Max/min toggle — simplified gmMainChatUI::HandleMaximizeButton ── + if (layout.FindElement(MaxMinId) is UiDatElement maxMinEl) + { + maxMinEl.ClickThrough = false; + maxMinEl.OnClick = c.ToggleMaximize; + } + + return c; + } + + // ── Max/min implementation ───────────────────────────────────────────── + + /// + /// Toggle between the normal chat window height and an expanded 320px height. + /// Simplified port of retail gmMainChatUI::HandleMaximizeButton @0x4cddb0: + /// retail stores the pre-maximize height and restores it on a second click. + /// The 320px expanded size is the approximate retail maximized chat height. + /// + private void ToggleMaximize() + { + if (!_maximized) + { + _normalHeight = Root.Height; + _normalTop = Root.Top; + // Expand upward: move the top edge up so the bottom stays anchored. + Root.Top = MathF.Max(0f, Root.Top + Root.Height - 320f); + Root.Height = 320f; + _maximized = true; + } + else + { + Root.Top = _normalTop; + Root.Height = _normalHeight; + _maximized = false; + } + } + + // ── Helpers ──────────────────────────────────────────────────────────── + + /// + /// Depth-first search for an node by id in the + /// raw info tree (which contains ALL elements, including the Type-12 skipped ones). + /// + private static ElementInfo? FindInfo(ElementInfo node, uint id) + { + if (node.Id == id) return node; + foreach (var child in node.Children) + { + var found = FindInfo(child, id); + if (found is not null) return found; + } + return null; + } + + /// + /// Convert the ChatVM's detailed lines to the transcript's + /// record format, applying retail-faithful + /// per- colors. + /// + private static IReadOnlyList BuildLines(ChatVM vm) + { + var detailed = vm.RecentLinesDetailed(); + if (detailed.Count == 0) return Array.Empty(); + + var result = new UiChatView.Line[detailed.Count]; + for (int i = 0; i < detailed.Count; i++) + result[i] = new UiChatView.Line(detailed[i].Text, RetailChatColor(detailed[i].Kind)); + return result; + } + + /// + /// Per- text color matching retail AC's channel coloring + /// (observed from retail client screenshots and holtburger's chat.rs coloring). + /// + private static Vector4 RetailChatColor(ChatKind kind) => kind switch + { + ChatKind.LocalSpeech => new(1f, 1f, 1f, 1f), // white — spoken nearby + ChatKind.RangedSpeech => new(1f, 0.95f, 0.8f, 1f), // warm white — shout + ChatKind.Channel => new(0.6f, 0.8f, 1f, 1f), // light blue — channel text + ChatKind.Tell => new(1f, 0.5f, 1f, 1f), // magenta — whisper + ChatKind.System => new(1f, 1f, 0.45f, 1f), // yellow — system messages + ChatKind.Popup => new(1f, 0.85f, 0.4f, 1f), // amber — popup/broadcast + ChatKind.Emote => new(0.8f, 0.8f, 0.7f, 1f), // off-white — emote + ChatKind.SoulEmote => new(0.8f, 0.8f, 0.7f, 1f), // off-white — soul emote + ChatKind.Combat => new(1f, 0.6f, 0.25f, 1f), // orange — combat feedback + _ => new(0.9f, 0.9f, 0.9f, 1f), // light grey — fallback + }; +} diff --git a/src/AcDream.App/UI/Layout/UiDatElement.cs b/src/AcDream.App/UI/Layout/UiDatElement.cs index 61f7c6b3..43cc4032 100644 --- a/src/AcDream.App/UI/Layout/UiDatElement.cs +++ b/src/AcDream.App/UI/Layout/UiDatElement.cs @@ -76,6 +76,17 @@ public sealed class UiDatElement : UiElement : _info.StateMedia.TryGetValue("", out var d) ? d : (0u, 0); + /// Optional click handler. Set by a controller for interactive dat + /// elements (e.g. the chat Send / max-min buttons). Requires + /// = false to receive click events. + public Action? OnClick { get; set; } + + public override bool OnEvent(in UiEvent e) + { + if (e.Type == UiEventType.Click && OnClick is not null) { OnClick(); return true; } + return false; + } + protected override void OnDraw(UiRenderContext ctx) { var (file, _) = ActiveMedia(); diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs new file mode 100644 index 00000000..c4c6b9b1 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs @@ -0,0 +1,209 @@ +using System.Collections.Generic; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Core.Chat; +using AcDream.UI.Abstractions; +using AcDream.UI.Abstractions.Panels.Chat; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Smoke tests for — no dats, no GL. +/// +/// Building the Type-12 "skipped" elements via the pure +/// path is the correct approach: we build a synthetic info tree that reflects the +/// real chat layout hierarchy (root → transcript panel + input bar as Type-3 +/// containers, with Type-12 children for transcript + input, plus a Type-3 track +/// and menu), call to get the widget tree +/// (Type-12 children skipped, Type-3 parents created), then call +/// which reads rects from the info tree +/// and places behavioral widgets under the parent containers. +/// +public class ChatWindowControllerTests +{ + // ── Null-resolve helper (no GL needed) ───────────────────────────────── + private static (uint, int, int) NoTex(uint _) => (0u, 0, 0); + + // ── Capture bus — records every Publish call ──────────────────────────── + private sealed class CaptureBus : ICommandBus + { + public readonly List Published = new(); + public void Publish(T cmd) where T : notnull => Published.Add(cmd!); + } + + // ── Synthetic element tree matching the real chat layout topology ──────── + + /// + /// Build a minimal synthetic ElementInfo tree that mirrors the real chat + /// layout (0x21000006) with enough fidelity for Bind to succeed: + /// root (Type-3) + /// transcriptPanel (Type-3) [0x10000010] + /// transcript (Type-12, no media) [0x10000011] ← skipped by factory + /// track (Type-3) [0x10000012] + /// inputBar (Type-3) [0x10000013] + /// menu (Type-3) [0x10000014] + /// input (Type-12, no media) [0x10000016] ← skipped by factory + /// send (Type-3) [0x10000019] + /// maxmin (Type-3) [0x1000046F] + /// + private static (ElementInfo rootInfo, ImportedLayout layout, ChatVM vm) BuildTestTree() + { + var transcriptNode = new ElementInfo + { + Id = 0x10000011u, Type = 12, // Type-12, no media → skipped by factory + X = 16, Y = 0, Width = 458, Height = 74, + }; + var trackNode = new ElementInfo + { + Id = 0x10000012u, Type = 3, + X = 474, Y = 6, Width = 16, Height = 68, + }; + var transcriptPanel = new ElementInfo + { + Id = 0x10000010u, Type = 3, X = 0, Y = 9, Width = 490, Height = 74, + }; + transcriptPanel.Children.Add(transcriptNode); + transcriptPanel.Children.Add(trackNode); + + var menuNode = new ElementInfo + { + Id = 0x10000014u, Type = 3, X = 0, Y = 0, Width = 46, Height = 17, + }; + var inputNode = new ElementInfo + { + Id = 0x10000016u, Type = 12, // Type-12, no media → skipped by factory + X = 46, Y = 0, Width = 398, Height = 17, + }; + var sendNode = new ElementInfo + { + Id = 0x10000019u, Type = 3, X = 444, Y = 0, Width = 46, Height = 17, + }; + var inputBar = new ElementInfo + { + Id = 0x10000013u, Type = 3, X = 0, Y = 83, Width = 490, Height = 17, + }; + inputBar.Children.Add(menuNode); + inputBar.Children.Add(inputNode); + inputBar.Children.Add(sendNode); + + var maxMinNode = new ElementInfo + { + Id = 0x1000046Fu, Type = 3, X = 474, Y = 0, Width = 16, Height = 16, + }; + + var root = new ElementInfo + { + Id = 0x1000000Eu, Type = 3, Width = 490, Height = 100, + }; + root.Children.Add(transcriptPanel); + root.Children.Add(inputBar); + root.Children.Add(maxMinNode); + + var layout = LayoutImporter.Build(root, NoTex, null); + var vm = new ChatVM(new ChatLog()); + return (root, layout, vm); + } + + // ── Test 1: Bind returns non-null with the minimal tree ────────────────── + + [Fact] + public void Bind_Returns_NonNull_OnValidTree() + { + var (rootInfo, layout, vm) = BuildTestTree(); + var bus = new CaptureBus(); + + var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, bus, null, null, NoTex); + + Assert.NotNull(ctrl); + } + + // ── Test 2: Transcript is placed as a child of the transcript panel ────── + + [Fact] + public void Bind_Transcript_IsChildOfTranscriptPanel() + { + var (rootInfo, layout, vm) = BuildTestTree(); + var bus = new CaptureBus(); + + var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, bus, null, null, NoTex); + + Assert.NotNull(ctrl); + var panel = layout.FindElement(0x10000010u); + Assert.NotNull(panel); + // The transcript widget must be a child of the transcript panel. + Assert.Contains(ctrl!.Transcript, panel!.Children); + } + + // ── Test 3: Input is placed as a child of the input bar ───────────────── + + [Fact] + public void Bind_Input_IsChildOfInputBar() + { + var (rootInfo, layout, vm) = BuildTestTree(); + var bus = new CaptureBus(); + + var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, bus, null, null, NoTex); + + Assert.NotNull(ctrl); + var bar = layout.FindElement(0x10000013u); + Assert.NotNull(bar); + Assert.Contains(ctrl!.Input, bar!.Children); + } + + // ── Test 4: Input.OnSubmit publishes SendChatCmd via the capture bus ───── + + [Fact] + public void Bind_InputSubmit_PublishesSendChatCmd() + { + var (rootInfo, layout, vm) = BuildTestTree(); + var bus = new CaptureBus(); + + var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, bus, null, null, NoTex); + + Assert.NotNull(ctrl); + ctrl!.Input.OnSubmit!.Invoke("hello world"); + + // ChatCommandRouter.Submit should have published a SendChatCmd. + Assert.Single(bus.Published); + var cmd = Assert.IsType(bus.Published[0]); + Assert.Equal("hello world", cmd.Text); + } + + // ── Test 5: Channel change updates the channel used by subsequent submits ─ + + [Fact] + public void Bind_ChannelChange_UpdatesSubmitChannel() + { + var (rootInfo, layout, vm) = BuildTestTree(); + var bus = new CaptureBus(); + + var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, bus, null, null, NoTex); + + Assert.NotNull(ctrl); + // Switch channel to General. + ctrl!.Menu.OnChannelChanged!.Invoke(ChatChannelKind.General); + ctrl.Input.OnSubmit!.Invoke("hey all"); + + Assert.Single(bus.Published); + var cmd = Assert.IsType(bus.Published[0]); + Assert.Equal(ChatChannelKind.General, cmd.Channel); + } + + // ── Test 6: Bind returns null when required elements are absent ────────── + + [Fact] + public void Bind_Returns_Null_WhenTranscriptPanelMissing() + { + // Build a layout that is missing the transcript panel entirely. + var root = new ElementInfo { Id = 0x1000000Eu, Type = 3, Width = 490, Height = 100 }; + // No children → TranscriptPanelId and InputBarId are absent from the widget tree. + + var layout = LayoutImporter.Build(root, NoTex, null); + var vm = new ChatVM(new ChatLog()); + var bus = new CaptureBus(); + + var ctrl = ChatWindowController.Bind(root, layout, vm, bus, null, null, NoTex); + + Assert.Null(ctrl); + } +} From 12ab9663d2e69f7cc49f6319493b6975afaadf91 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 23:15:04 +0200 Subject: [PATCH 69/99] feat(D.2b): cut GameWindow over to the data-driven chat window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-authored chat block (UiNineSlicePanel + inline UiChatView + local BuildRetailChatLines/RetailChatColor statics) with ChatWindowController.Bind(LayoutDesc 0x21000006) — the same LayoutImporter path as the vitals window. The controller places UiChatView (transcript) + UiChatInput (text entry, on-submit) + UiChatScrollbar + UiChannelMenu inside the dat-authored chrome. The dead local statics are deleted. Wired to _commandBus (same LiveCommandBus as the ImGui ChatPanel) so type+Enter dispatches SendChatCmd server-ward. Transcript keyboard set from _uiHost.Keyboard (set by WireKeyboard above the chat block) for Ctrl+C/Ctrl+A. Divergence register: added AD-28 (two-widget split vs UIElement_Text), AP-38 (no in-element word-wrap), AP-39 (per-line colour vs per-glyph runs), AP-40 (no opacity fade / shared vitalsDatFont), TS-30 (tab buttons no-op), TS-31 (no squelch); updated IA-15 to cover both vitals + chat importer paths. Build: 0 errors/warnings. Tests: 392 passed, 1 skipped (expected). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../retail-divergence-register.md | 14 ++- docs/plans/2026-04-11-roadmap.md | 1 + src/AcDream.App/Rendering/GameWindow.cs | 85 ++++++++----------- 3 files changed, 48 insertions(+), 52 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 86152c4c..c1e8a0b0 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -55,11 +55,11 @@ 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 dat-sprite window frames, not a byte-port of keystone.dll's LayoutDesc binary tree. The vitals window is now rendered by the LayoutDesc importer (dat chrome elements read directly from `LayoutDesc 0x2100006C`), not `UiNineSlicePanel`; `UiNineSlicePanel`/`RetailChromeSprites` now back only the chat window + plugin panels | `src/AcDream.App/UI/Layout/LayoutImporter.cs` (vitals) + `src/AcDream.App/UI/UiNineSlicePanel.cs` (chat/plugins) | 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 DATA-DRIVEN from the dat: the `LayoutImporter` reads `LayoutDesc 0x2100006C` and resolves chrome element positions + sprite ids directly from parsed dat fields; locked by the conformance fixture `tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json` | Remaining residual risk: anchor resolution at non-800×600 and the controls.ini cascade still lack an oracle — layout scaling at non-reference resolution and stylesheet token inheritance differ silently | `LayoutDesc 0x2100006C` (SHIPPED); `docs/research/2026-06-15-layoutdesc-format.md`; controls.ini tokens; keystone.dll layout eval (no PDB) | +| IA-15 | D.2b retail UI is our own UiHost/UiElement retained-mode tree drawing dat-sprite window frames, not a byte-port of keystone.dll's LayoutDesc binary tree. Both the vitals window (`LayoutDesc 0x2100006C`) and the chat window (`LayoutDesc 0x21000006`) are rendered by the LayoutDesc importer; `UiNineSlicePanel`/`RetailChromeSprites` now back only plugin panels | `src/AcDream.App/UI/Layout/LayoutImporter.cs` (vitals + chat) + `src/AcDream.App/UI/Layout/ChatWindowController.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 DATA-DRIVEN from the dat: the `LayoutImporter` reads `LayoutDesc 0x2100006C`/`0x21000006` and resolves chrome element positions + sprite ids directly from parsed dat fields; vitals locked by the conformance fixture `tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json` | Remaining residual risk: anchor resolution at non-800×600 and the controls.ini cascade still lack an oracle — layout scaling at non-reference resolution and stylesheet token inheritance differ silently | `LayoutDesc 0x2100006C`/`0x21000006` (SHIPPED); `docs/research/2026-06-15-layoutdesc-format.md`; controls.ini tokens; keystone.dll layout eval (no PDB) | --- -## 2. Adaptation (AD) — 27 rows +## 2. Adaptation (AD) — 28 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -90,10 +90,11 @@ accepted-divergence entries (#96, #49, #50). | AD-25 | Wall-bounce velocity reflection suppressed on landing (fires only airborne-before AND airborne-after); retail bounces unless grounded→grounded-and-not-sledding | `src/AcDream.App/Input/PlayerMovementController.cs:1212` | Our per-frame architecture amplifies the artifact (post-reflection +Z defeats the `Velocity.Z <= 0` landing-snap gate → micro-bounce death spiral); at elasticity 0.05 retail's landing bounce is imperceptible; sledding reverts to retail rule | Landing-reflection-dependent behavior (slope-landing momentum, high-elasticity surfaces) won't reproduce; the suppression masks the landing-snap gate fragility and could outlive its reason | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 | | AD-26 | Auto-walk arrival requires facing alignment (invented 5° arrive / 30° walk-while-turning bands); retail's check is `dist <= radius` exact | `src/AcDream.App/Input/PlayerMovementController.cs:575` | ACE does the final `Rotate(target)` server-side before the Use callback; without a local gate the body used items while facing away (user feedback 2026-05-15). Thresholds are NOT retail constants | Arrival delayed by the rotation phase; if heading convergence fights another yaw writer, `AutoWalkArrived` never fires and the queued Use/PickUp never completes | `MoveToManager::HandleMoveToPosition`; `apply_interpreted_movement` | | AD-27 | Use/PickUp action re-sent on natural auto-walk arrival; retail sends the action once (server MoveToChain callback completes it) | `src/AcDream.App/Input/PlayerMovementController.cs:322` | ACE's server-side chain may have timed out by the time our body arrives; the close-range re-send hits ACE's WithinUseRadius fast-path | If the server's chain has NOT timed out, the action executes twice — door toggles open-then-closed, use-once interactions double-fire; protocol noise on non-ACE servers | ACE CreateMoveToChain / WithinUseRadius | +| AD-28 | Chat transcript (`UiChatView`) and input (`UiChatInput`) are two separate widget classes placed inside their dat-authored container panels; retail's `ChatInterface` uses a single mode-flagged `UIElement_Text` (Type-12) that switches between read and edit mode | `src/AcDream.App/UI/Layout/ChatWindowController.cs:135` (transcript) + `:150` (input) | `UIElement_Text` is inside keystone.dll with no PDB/decomp; a two-widget split is functionally equivalent (read-only scroll, editable input) and is the structural adaptation required by our UiElement architecture | A future consumer expecting a single widget for both read/write (e.g. a plugin calling the chat API and getting one widget back) must be written to the two-widget contract | `UIElement_Text` (Type-12) @ keystone.dll; `gmMainChatUI::PostInit` @0x4ce130 | --- -## 3. Documented approximation (AP) — 37 rows +## 3. Documented approximation (AP) — 40 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -134,10 +135,13 @@ accepted-divergence entries (#96, #49, #50). | AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp) | `src/AcDream.App/Rendering/GameWindow.cs:6895` (per-frame predicate) + `:IsSealedDungeonCell` + `:OnLiveEntitySpawnedLocked`/`:OnLivePositionUpdated` (login/teleport pre-collapse hooks) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277 | | AP-35 | Point/spot lights use a single PER-PIXEL accumulation that ports `calc_point_light`'s `(1 − dist/falloff_eff)` LINEAR ramp (falloff_eff = Falloff × static_light_factor 1.3) + standard Lambert `N·L`; retail's path is PER-VERTEX Gouraud and additionally applies a half-Lambert wrap (`0.5·dist + N·L_vec`, lights surfaces down to `N·L ≥ −0.5`) and an x87-obscured normalization factor, neither ported | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag:52` (+ `mesh.frag`; `LightInfoLoader.cs:81` folds 1.3 into Range) | The linear ramp is the user-visible fix (kills the hard-disc "spotlight" edge, #133 A7); the dropped wrap/normalization only re-shade the gradient slightly, and per-pixel vs per-vertex Gouraud chiefly differs on coarse geometry. Half-Lambert wrap + factor are an x87-decompile refinement (same artifact class as GetPowerBarLevel AP-24) | Surfaces facing slightly away from a torch (`−0.5 ≤ N·L < 0`) stay dark where retail's wrap lights them faintly; near-light gradient shading differs subtly from retail's per-vertex bake | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); static_light_factor 0x00820e24 | | AP-37 | LayoutDesc importer collapses the dat's nested meter structure (Type-7 meter → two Type-3 container children → three Type-3 image-slice grandchildren each) into `UiMeter`'s programmatic 3-slice fields (`BackLeft..FrontRight`) + reuses `UiMeter.DrawHBar`'s scissor-fill, instead of building those child nodes generically and porting `UIElement_Meter::DrawChildren`. Standalone Type-0 text elements are also skipped (vitals numbers render via `UiMeter.Label` bound by the controller; a dedicated dat-text widget is Plan 2). The inheritance `Merge` treats Width/Height==0 as "inherit from base", diverging from format-doc §12 rule 2 (documented inline in `ElementReader.cs`) | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`) + `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`BuildWidget` meter-child skip) | Reuses the tested `UiMeter` render that already visually matches retail's stacked vitals bars; the full nested-element + `DrawChildren` scissor port and a dat-text widget are deferred to Plan 2. Now the default vitals path (the hand-authored markup vitals was retired) and locked by the conformance fixture (`tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json`) | A LayoutDesc whose meter structure differs from the vitals 2-container/3-slice shape, or a window needing standalone dat text, renders an empty/wrong meter or drops text — no oracle diff until the Plan-2 widgets land | `UIElement_Meter::DrawChildren` @0x46fbd0; `UIElement_Text::DrawSelf` @0x467aa0; `docs/research/2026-06-15-layoutdesc-format.md` | +| AP-38 | Chat transcript renders pre-split `ChatLog` lines 1:1; no in-element word-wrap at the panel's current pixel width | `src/AcDream.App/UI/UiChatView.cs` | Retail does in-element wrap via `UIElement_Text::SizeToFit`; our pre-split lines are always shorter than 440 px in practice; a line that overflows clips at the edge rather than wrapping | Very long server system messages (server shutdowns, broadcast announcements) clip rather than wrapping — no information loss, just visual truncation | `UIElement_Text::SizeToFit` @0x467980; `gmMainChatUI` layout | +| AP-39 | Chat lines carry one color per `ChatKind` (per-line solid color); retail `UIElement_Text` supports per-glyph styled runs (bold, different hue per segment) | `src/AcDream.App/UI/UiChatView.cs:13` | Retail glyph-run parsing lives inside keystone.dll with no PDB/decomp; per-line per-kind coloring is the correct tonal palette and covers all existing chat types | Chat lines retail renders with multiple colors or bold names (e.g. "PlayerName says: text") render as one flat color; subtle visual difference but functionally complete | `UIElement_Text` glyph-run styling (keystone.dll, no decomp) | +| AP-40 | Single default translucency for the chat window chrome; no focused/unfocused opacity transition; dat font face/size taken from the vitals `vitalsDatFont` (same dat font, not a chat-specific size lookup) | `src/AcDream.App/Rendering/GameWindow.cs` (chatController binding line) | Retail fades the chat window to ~80% alpha when unfocused (`gmMainChatUI::UpdateAlpha @0x4cdea0`); the opacity animation deferred to the Plan-2 window-manager input integration; sharing `vitalsDatFont` is safe — retail uses the same AC-default font for both | The chat window is always fully opaque/same-font rather than subtly fading when idle; no wrong text, but the focused/unfocused breathing rhythm is absent | `gmMainChatUI::UpdateAlpha` @0x4cdea0; `UCF::SetAceFont @0x4d3940` | --- -## 4. Temporary stopgap (TS) — 29 rows +## 4. Temporary stopgap (TS) — 31 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -170,6 +174,8 @@ 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 | Numbered chat tabs (element ids `0x10000522`–`0x10000525`) render as clickable buttons but do not switch channel filter or affect the transcript — tab state is a no-op | `src/AcDream.App/UI/Layout/ChatWindowController.cs:210` | Retail's tab switching routes transcript lines by chat channel (`gmMainChatUI::gmScrollWindow` sub-windows per tab); the tab wiring is D.5 scope | Tab clicks produce no visible transcript change; retail would filter to the selected channel — all chat always shows in all tabs | `gmMainChatUI::PostInit` tab setup @0x4ce2a0; holtburger chat tab handling | +| TS-31 | Squelch toggle absent (no `/squelch` slash command, no clickable name-tags to silence); retail's squelch list filters incoming chat lines | `src/AcDream.Core/Chat/ChatLog.cs` | Squelch is a social / moderation feature deferred to post-M1.5; the data structure (`ChatLog`) has no squelch set today | Any player can spam all clients; clickable-name-tag contextual menu (used in retail to squelch, tell, add-to-friends) is absent | `ChatFilter::IsSquelched`; retail right-click player name → Squelch menu | --- diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index e0e7130b..4a6955e0 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -426,6 +426,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. - **✓ SHIPPED — D.2a — ImGui scaffold + `AcDream.UI.Abstractions` layer.** Shipped 2026-04-25. Wires ImGui as the short-term backend behind `ACDREAM_DEVTOOLS=1`. Defines `IPanel` / `IPanelHost` / `IPanelRenderer` / `ICommandBus` + the first ViewModel (`VitalsVM`) in the new `AcDream.UI.Abstractions` project. First real panel: `VitalsPanel` reading HP from `CombatState.GetHealthPercent`. **Backend pivoted Hexa.NET.ImGui → ImGui.NET + `Silk.NET.OpenGL.Extensions.ImGui` during integration** — Hexa's native OpenGL3 backend does its own GL function resolution via GLFW/SDL and crashed with `0xC0000005` in `ImGuiImplOpenGL3.InitNative` against Silk.NET (no GLFW/SDL present). The Silk.NET extension is purpose-built for this scenario and is the `ImGui.NET` mitigation path that `docs/plans/2026-04-24-ui-framework.md` already called out as a "one-morning operation". Stam/Mana return `float?` null in D.2a because absolute values need `LocalPlayerState` + `PlayerDescription (0x0013)` parsing (filed post-D.2a). 11 new `AcDream.UI.Abstractions.Tests` green. - **✓ SHIPPED — D.2b — Custom retail-look backend (Spec 1: markup engine + first panel).** Shipped 2026-06-14 (`626d06e`→`019350f`). Wired the dormant `UiHost`/`UiElement` tree into `GameWindow` (`ACDREAM_RETAIL_UI=1`) and built the **Approach-C markup engine**: `MarkupDocument` (XML → `UiElement` subtree, `{Binding}` reflection) + `ControlsIni` stylesheet loader + an `IUiRegistry` plugin UI surface (plugins ship markup + a binding, drained into the same `UiRoot`). First retail-faithful panel: a markup-driven Vitals window (`vitals.xml`) — 8-piece dat-sprite frame (`UiNineSlicePanel`) + three `UiMeter` bars (red/gold/blue + cur/max numbers) bound to `VitalsVM`, rendering live over the 3D world and coexisting with the ImGui devtools path. Retired divergence TS-30, added IA-15. Spec `docs/superpowers/specs/2026-06-14-d2b-retail-panel-frame-design.md`; plan `docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md`. NOTE the prove-out corrected two stale "facts": retail vitals are **bars not orbs**, stamina is **gold not cyan**. **Remaining: D.3 (AcFont — using the stb stopgap for now) + the glassy gradient bar sprite / brightness tune (polish); input integration (movable/clickable windows — the `UiRoot` drag/click machinery exists but isn't bridged to the Phase-K dispatcher yet); and the rest of the panels (D.5).** - **✓ SHIPPED — D.2b LayoutDesc importer (Plan 1 + default flip).** Plan 1 shipped 2026-06-15. *Retroactive registration — the spec requested this phase but it was not pre-registered before implementation.* Data-driven vitals window from `LayoutDesc 0x2100006C`: `LayoutImporter` resolves `BaseElement`/`BaseLayoutId` inheritance, walks the `ElementDesc` tree, and builds a live `UiElement` tree via `DatWidgetFactory` (Type 7 → `UiMeter`; all others → `UiDatElement` generic renderer). `VitalsController` binds live HP/Stamina/Mana by element id (mirrors retail `gmVitalsUI`). A/B visual gate **PASSED**: pixel-identical to the hand-authored window. **Default flip shipped 2026-06-15 (`bf77a23`):** the importer is now the default vitals window at `ACDREAM_RETAIL_UI=1`; the hand-authored `vitals.xml` and the `ACDREAM_RETAIL_UI_IMPORTER` flag were retired (`vitals.xml` recoverable from git history). The window is movable (Anchors=None + Draggable) AND horizontally resizable (Resizable/ResizeX, `8aa643f`): the dat edge-anchors reflow the pieces on width change per retail `UIElement::UpdateForParentSizeChange @0x00462640` (an earlier "fixed-size" note was wrong — inverted edge-flag reading, corrected; stretch is `RightEdge==1`). Faithful grip/dragbar-*driven* drag/resize INPUT is Plan 2. Post-flip number-render fixes (`43064ba`, `34243f2`): submission-order sprite draw (stamina/mana numbers had been overpainted by their own bars) + glyph pixel-snap (sharp at all resize widths). `MarkupDocument`/`UiNineSlicePanel` remain for the chat window + plugin panels. Spec: `docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md`; plan: `docs/superpowers/plans/2026-06-15-layoutdesc-importer.md`. +- **✓ SHIPPED — D.2b LayoutDesc importer (Plan 2 — chat-window re-drive).** Shipped 2026-06-15. `GameWindow`'s hand-authored chat block (`UiNineSlicePanel` + inline `UiChatView`) replaced by `ChatWindowController.Bind(LayoutDesc 0x21000006, …)` — the same importer path as vitals. `ChatWindowController` places `UiChatView` (transcript) + `UiChatInput` (text entry + on-submit) + `UiChatScrollbar` (scrollbar thumb) + `UiChannelMenu` (channel selector) inside the dat-authored chrome; dead local statics `BuildRetailChatLines`/`RetailChatColor` deleted from `GameWindow` (moved into the controller). Wired to `_commandBus` (same `LiveCommandBus` as the ImGui `ChatPanel`) so type+Enter dispatches `SendChatCmd` server-ward. Transcript keyboard set from `_uiHost.Keyboard` for Ctrl+C/Ctrl+A. 392 tests green. Added divergence rows AD-28 / AP-38–40 / TS-30–31; updated IA-15. - **D.3 — AcFont from portal.dat.** Replace stb_truetype system font with retail `Font` DBObjs (`0x40000000..0x40000FFF`) baked from `RenderSurface` source sheets — see research slice 03 §4. Preserves retail visual identity. **(D.2b dependency — needs the custom renderer.)** - **✓ SHIPPED — D.4 — Dat sprites + 9-slice panel backgrounds.** Shipped 2026-06-14 with D.2b. `TextureCache.GetOrUploadRenderSurface(id, out w, out h)` decodes `RenderSurface` (`0x06xxxxxx`) **directly** (not via the Surface→SurfaceTexture chain — the prove-out finding) → GL `Texture2D`; `DrawSprite` (explicit UV-rect, per-texture batch) added to `TextRenderer` + `UiRenderContext` + a `uUseTexture=2` RGBA frag branch; `UiNineSlicePanel` composes the universal 8-piece bevel (corners `0x060074C3..C6`, edges `0x060074BF/C0/C1/C2`, center `0x06004CC2`). Remaining art polish: the glassy gradient bar fill sprite (D.2b polish). - **D.5 — Core panels.** Attributes (`chunk_00470000.c:FUN_0047ba70`), Skills (same), Paperdoll (`chunk_004A0000.c:FUN_004A5200`), Inventory, Spellbook (`chunk_004C0000.c`), Fellowship, Allegiance. Each uses the port sketches in slice 05. **(Targets `AcDream.UI.Abstractions` — ships with D.2a using ImGui-rendered widgets; reskinned by D.2b.)** The *chat* panel originally listed under D.5 shipped early in Phase I (I.4 input + I.7 combat translator superseded the chat-panel design here); this entry now covers Attributes / Skills / Paperdoll / Inventory / Spellbook / Fellowship / Allegiance only. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index d4c33d71..1d881144 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1833,58 +1833,47 @@ public sealed class GameWindow : IDisposable Console.WriteLine("[D.2b] vitals: LayoutDesc 0x2100006C not found — vitals unavailable."); } - // Retail chat window — a draggable/resizable nine-slice frame hosting a - // scrollable transcript (UiChatView). Read-only + wheel-scroll for now; - // drag-select + Ctrl+C copy land in the next D.2b sub-step. A dedicated - // ChatVM with a deeper tail (200) feeds the scrollback; it shares the - // same live ChatLog (Chat) as the ImGui panel. + // Retail chat window — data-driven from LayoutDesc 0x21000006 (gmMainChatUI), + // the same importer path as vitals. ChatWindowController binds the transcript, + // input, scrollbar and channel menu and routes through ChatVM + ChatCommandRouter. var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200); - var chatWindow = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome) + AcDream.App.UI.Layout.ElementInfo? chatRootInfo; + AcDream.App.UI.Layout.ImportedLayout? chatLayout; + lock (_datLock) { - Left = 10, Top = 432, Width = 440, Height = 184, - MinWidth = 180, MinHeight = 80, - }; - var chatView = new AcDream.App.UI.UiChatView - { - Left = 8, Top = 8, Width = 424, Height = 168, - Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top - | AcDream.App.UI.AnchorEdges.Right | AcDream.App.UI.AnchorEdges.Bottom, - Font = _debugFont, - LinesProvider = () => BuildRetailChatLines(retailChatVm), - // Drag-select + Ctrl+C copy need the keyboard for clipboard + - // modifier state. UiHost.Keyboard is set during WireKeyboard above. - Keyboard = _uiHost.Keyboard, - }; - chatWindow.AddChild(chatView); - _uiHost.Root.AddChild(chatWindow); - - // Map the VM's formatted tail into coloured view lines. Per-ChatKind - // palette (retail-ish): speech white, tells magenta, channels blue, - // system yellow, emotes grey, combat orange. Refined later if needed. - static System.Collections.Generic.IReadOnlyList BuildRetailChatLines( - AcDream.UI.Abstractions.Panels.Chat.ChatVM vm) - { - var detailed = vm.RecentLinesDetailed(); - var result = new AcDream.App.UI.UiChatView.Line[detailed.Count]; - for (int i = 0; i < detailed.Count; i++) - result[i] = new AcDream.App.UI.UiChatView.Line( - detailed[i].Text, RetailChatColor(detailed[i].Kind)); - return result; + chatRootInfo = AcDream.App.UI.Layout.LayoutImporter.ImportInfos( + _dats!, AcDream.App.UI.Layout.ChatWindowController.LayoutId); + chatLayout = chatRootInfo is null ? null + : AcDream.App.UI.Layout.LayoutImporter.Build(chatRootInfo, ResolveChrome, vitalsDatFont); } - - static System.Numerics.Vector4 RetailChatColor(AcDream.Core.Chat.ChatKind kind) => kind switch + if (chatRootInfo is not null && chatLayout is not null) { - AcDream.Core.Chat.ChatKind.LocalSpeech => new(1f, 1f, 1f, 1f), - AcDream.Core.Chat.ChatKind.RangedSpeech => new(1f, 0.95f, 0.8f, 1f), - AcDream.Core.Chat.ChatKind.Channel => new(0.6f, 0.8f, 1f, 1f), - AcDream.Core.Chat.ChatKind.Tell => new(1f, 0.5f, 1f, 1f), - AcDream.Core.Chat.ChatKind.System => new(1f, 1f, 0.45f, 1f), - AcDream.Core.Chat.ChatKind.Popup => new(1f, 0.85f, 0.4f, 1f), - AcDream.Core.Chat.ChatKind.Emote => new(0.8f, 0.8f, 0.7f, 1f), - AcDream.Core.Chat.ChatKind.SoulEmote => new(0.8f, 0.8f, 0.7f, 1f), - AcDream.Core.Chat.ChatKind.Combat => new(1f, 0.6f, 0.25f, 1f), - _ => new(0.9f, 0.9f, 0.9f, 1f), - }; + var chatController = AcDream.App.UI.Layout.ChatWindowController.Bind( + chatRootInfo, chatLayout, retailChatVm, _commandBus ?? (AcDream.UI.Abstractions.ICommandBus)AcDream.UI.Abstractions.NullCommandBus.Instance, + vitalsDatFont, _debugFont, ResolveChrome); + if (chatController is not null) + { + // Ctrl+C / Ctrl+A on the transcript need the keyboard for clipboard + modifiers. + // _uiHost.Keyboard is set by WireKeyboard above — it is non-null here. + chatController.Transcript.Keyboard = _uiHost.Keyboard; + // Top-level retail window: user-positioned at the bottom-left, movable + resizable. + // KEEP the dat-authored size (do NOT override Width/Height) so the child anchors + // capture their dat margins on the first layout — the same reason the vitals root + // keeps its dat size. The user resizes/moves from there. + var chatRoot = chatController.Root; + chatRoot.Left = 10; + chatRoot.Top = 460; // bottom-left default; pending the user's visual review + chatRoot.Anchors = AcDream.App.UI.AnchorEdges.None; + chatRoot.Draggable = true; + chatRoot.Resizable = true; + chatRoot.MinWidth = 200f; + chatRoot.MinHeight = 80f; + _uiHost.Root.AddChild(chatRoot); + Console.WriteLine("[D.2b] retail chat window from LayoutDesc importer (0x21000006)."); + } + else Console.WriteLine("[D.2b] chat: required role elements missing in 0x21000006."); + } + else Console.WriteLine("[D.2b] chat: LayoutDesc 0x21000006 not found."); // Drain plugin-registered markup panels (buffered before the GL // window opened) into the same UiRoot tree. A faulty plugin markup From 0ec36f61975c6877135de2c0b8b94bacd8dbbbaf Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 23:24:44 +0200 Subject: [PATCH 70/99] fix(D.2b): chat input resolves the live command bus lazily (was bound to null) + register thumb-3-slice row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live session + its LiveCommandBus are created after the retail-UI block in OnLoad, so binding the bus by value captured NullCommandBus and silently dropped outbound chat. Pass a Func resolved at submit time (mirrors how the ImGui ChatPanel re-reads the bus each frame). AP-41: scrollbar thumb drawn as single stretched tile (0x06004C63) instead of retail's 3-slice top-cap/middle/bottom-cap — acknowledged in UiChatScrollbar.cs:37, registered per the divergence-register rule. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/architecture/retail-divergence-register.md | 3 ++- src/AcDream.App/Rendering/GameWindow.cs | 3 ++- src/AcDream.App/UI/Layout/ChatWindowController.cs | 9 ++++++--- .../UI/Layout/ChatWindowControllerTests.cs | 12 ++++++------ 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index c1e8a0b0..a96511a6 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -94,7 +94,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 3. Documented approximation (AP) — 40 rows +## 3. Documented approximation (AP) — 41 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -138,6 +138,7 @@ accepted-divergence entries (#96, #49, #50). | AP-38 | Chat transcript renders pre-split `ChatLog` lines 1:1; no in-element word-wrap at the panel's current pixel width | `src/AcDream.App/UI/UiChatView.cs` | Retail does in-element wrap via `UIElement_Text::SizeToFit`; our pre-split lines are always shorter than 440 px in practice; a line that overflows clips at the edge rather than wrapping | Very long server system messages (server shutdowns, broadcast announcements) clip rather than wrapping — no information loss, just visual truncation | `UIElement_Text::SizeToFit` @0x467980; `gmMainChatUI` layout | | AP-39 | Chat lines carry one color per `ChatKind` (per-line solid color); retail `UIElement_Text` supports per-glyph styled runs (bold, different hue per segment) | `src/AcDream.App/UI/UiChatView.cs:13` | Retail glyph-run parsing lives inside keystone.dll with no PDB/decomp; per-line per-kind coloring is the correct tonal palette and covers all existing chat types | Chat lines retail renders with multiple colors or bold names (e.g. "PlayerName says: text") render as one flat color; subtle visual difference but functionally complete | `UIElement_Text` glyph-run styling (keystone.dll, no decomp) | | AP-40 | Single default translucency for the chat window chrome; no focused/unfocused opacity transition; dat font face/size taken from the vitals `vitalsDatFont` (same dat font, not a chat-specific size lookup) | `src/AcDream.App/Rendering/GameWindow.cs` (chatController binding line) | Retail fades the chat window to ~80% alpha when unfocused (`gmMainChatUI::UpdateAlpha @0x4cdea0`); the opacity animation deferred to the Plan-2 window-manager input integration; sharing `vitalsDatFont` is safe — retail uses the same AC-default font for both | The chat window is always fully opaque/same-font rather than subtly fading when idle; no wrong text, but the focused/unfocused breathing rhythm is absent | `gmMainChatUI::UpdateAlpha` @0x4cdea0; `UCF::SetAceFont @0x4d3940` | +| AP-41 | Scrollbar thumb drawn as a single stretched sprite (`0x06004C63`, the 3-slice middle tile) instead of retail's 3-slice: top cap `0x06004C60` + tiled middle `0x06004C63` + bottom cap `0x06004C66` | `src/AcDream.App/UI/UiChatScrollbar.cs:37` | The middle tile stretches acceptably at chat-panel dimensions; the 3-slice port is a Task-H upgrade acknowledged inline in the `ThumbSprite` property comment | The thumb's top and bottom edges lack the retail end-cap sprites — slightly wrong visual shape at small thumb sizes (thumb too-short for the middle tile to cleanly scale) | `UIElement_Scrollbar::UpdateLayout @0x4710d0`; cap sprites `0x06004C60` (top) + `0x06004C66` (bottom) from base layout `0x2100003E` | --- diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 1d881144..25edfc17 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1849,7 +1849,8 @@ public sealed class GameWindow : IDisposable if (chatRootInfo is not null && chatLayout is not null) { var chatController = AcDream.App.UI.Layout.ChatWindowController.Bind( - chatRootInfo, chatLayout, retailChatVm, _commandBus ?? (AcDream.UI.Abstractions.ICommandBus)AcDream.UI.Abstractions.NullCommandBus.Instance, + chatRootInfo, chatLayout, retailChatVm, + () => _commandBus ?? (AcDream.UI.Abstractions.ICommandBus)AcDream.UI.Abstractions.NullCommandBus.Instance, vitalsDatFont, _debugFont, ResolveChrome); if (chatController is not null) { diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index edc4c3f3..dc75d1ac 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -93,7 +93,10 @@ public sealed class ChatWindowController /// . /// Widget tree from . /// Chat view-model (transcript data + command routing). - /// Command bus for SendChatCmd publishes. + /// Factory that returns the live command bus at submit time. + /// Called on every chat submit so it resolves + /// even when the live session is established AFTER runs + /// (mirrors the ImGui ChatPanel which re-reads the bus each frame). /// Retail dat font for transcript + input rendering. /// Fallback debug bitmap font (used when /// is null). @@ -103,7 +106,7 @@ public sealed class ChatWindowController ElementInfo rootInfo, ImportedLayout layout, ChatVM vm, - ICommandBus bus, + Func busProvider, UiDatFont? datFont, BitmapFont? debugFont, Func resolve) @@ -158,7 +161,7 @@ public sealed class ChatWindowController Font = debugFont, }; inputBar.AddChild(c.Input); - c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, bus, c._activeChannel); + c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel); // ── Scrollbar — replace the imported track placeholder ──────────── // The factory created a UiDatElement for the track. Remove it and place a diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs index c4c6b9b1..717c92da 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs @@ -112,7 +112,7 @@ public class ChatWindowControllerTests var (rootInfo, layout, vm) = BuildTestTree(); var bus = new CaptureBus(); - var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, bus, null, null, NoTex); + var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex); Assert.NotNull(ctrl); } @@ -125,7 +125,7 @@ public class ChatWindowControllerTests var (rootInfo, layout, vm) = BuildTestTree(); var bus = new CaptureBus(); - var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, bus, null, null, NoTex); + var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex); Assert.NotNull(ctrl); var panel = layout.FindElement(0x10000010u); @@ -142,7 +142,7 @@ public class ChatWindowControllerTests var (rootInfo, layout, vm) = BuildTestTree(); var bus = new CaptureBus(); - var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, bus, null, null, NoTex); + var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex); Assert.NotNull(ctrl); var bar = layout.FindElement(0x10000013u); @@ -158,7 +158,7 @@ public class ChatWindowControllerTests var (rootInfo, layout, vm) = BuildTestTree(); var bus = new CaptureBus(); - var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, bus, null, null, NoTex); + var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex); Assert.NotNull(ctrl); ctrl!.Input.OnSubmit!.Invoke("hello world"); @@ -177,7 +177,7 @@ public class ChatWindowControllerTests var (rootInfo, layout, vm) = BuildTestTree(); var bus = new CaptureBus(); - var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, bus, null, null, NoTex); + var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex); Assert.NotNull(ctrl); // Switch channel to General. @@ -202,7 +202,7 @@ public class ChatWindowControllerTests var vm = new ChatVM(new ChatLog()); var bus = new CaptureBus(); - var ctrl = ChatWindowController.Bind(root, layout, vm, bus, null, null, NoTex); + var ctrl = ChatWindowController.Bind(root, layout, vm, () => bus, null, null, NoTex); Assert.Null(ctrl); } From 1da697ec2a2d8cd7cd5c49545ef154a832919ccf Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 09:37:40 +0200 Subject: [PATCH 71/99] =?UTF-8?q?@=20feat(D.2b):=20chat=20polish=20?= =?UTF-8?q?=E2=80=94=20typing=20fix,=20opacity,=20scrollbar=203-slice,=20r?= =?UTF-8?q?etail=20channel=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visual-iteration batch (decomp-grounded), each fix verified against the retail screenshots: - typing: UiElement.HitTest aborted on ClickThrough BEFORE walking children, so the ClickThrough UiDatElement panels blocked hit-testing to the input/transcript inside them. Check ClickThrough AFTER the child walk (it only gates whether THIS element claims the hit). Restores input focus + typing. - opacity: UiElement.Opacity + a UiRenderContext alpha stack applied to sprite/rect draws (text bypasses it, stays sharp); chat frame Opacity=0.75 → translucent chat. - brown sliver: grow the transcript panel up 9px to cover the dropped resize-bar strip. - scrollbar: real 3-slice thumb (caps 0x06004C60/66 + tiled mid) + tiled track. - max/min: shifted one button-width left of the scrollbar (dat right-anchors collide). - system text now green (retail ChatMessageType 5; was yellow). - word-wrap: transcript lines wrap to the panel width (greedy, ports GlyphList::Recalculate). - channel menu reworked to retail gmMainChatUI::InitTalkFocusMenu: "Chat" button + a TWO-COLUMN popup of the 14 talk-focus items (Squelch, Tell to Selected, Chat to All, Tell to Fellows, ...) on a tan panel; channel items set the active outbound channel. Build + 392 App tests green. Visual confirmation in progress. Co-Authored-By: Claude Opus 4.8 (1M context) @ --- src/AcDream.App/Rendering/GameWindow.cs | 41 +++++-- .../UI/Layout/ChatWindowController.cs | 111 ++++++++++++++--- src/AcDream.App/UI/UiChannelMenu.cs | 114 ++++++++++-------- src/AcDream.App/UI/UiChatScrollbar.cs | 49 ++++++-- src/AcDream.App/UI/UiElement.cs | 20 ++- src/AcDream.App/UI/UiRenderContext.cs | 28 ++++- .../UI/UiChannelMenuTests.cs | 92 ++++++++------ 7 files changed, 329 insertions(+), 126 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 25edfc17..6951c28e 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1857,19 +1857,36 @@ public sealed class GameWindow : IDisposable // Ctrl+C / Ctrl+A on the transcript need the keyboard for clipboard + modifiers. // _uiHost.Keyboard is set by WireKeyboard above — it is non-null here. chatController.Transcript.Keyboard = _uiHost.Keyboard; - // Top-level retail window: user-positioned at the bottom-left, movable + resizable. - // KEEP the dat-authored size (do NOT override Width/Height) so the child anchors - // capture their dat margins on the first layout — the same reason the vitals root - // keeps its dat size. The user resizes/moves from there. + // Wrap the dat content in the universal 8-piece beveled window chrome — + // the SAME UiNineSlicePanel the vitals window uses. The chat's own dat + // layout only carries flat background sprites, so without this the window + // has no retail-style border (the user asked for the vitals border). The + // nine-slice IS the movable/resizable window; the dat content fills its + // interior, inset by the border. The gmMainChatUI content is authored 490 + // wide (its transcript/input panels) — KEEP that width + the dat-authored + // HEIGHT so the content's child anchors (input-bar-at-bottom, transcript- + // fills) capture correct margins on first layout; resizing the frame reflows + // them correctly from there. + const int chatBorder = AcDream.App.UI.RetailChromeSprites.Border; var chatRoot = chatController.Root; - chatRoot.Left = 10; - chatRoot.Top = 460; // bottom-left default; pending the user's visual review - chatRoot.Anchors = AcDream.App.UI.AnchorEdges.None; - chatRoot.Draggable = true; - chatRoot.Resizable = true; - chatRoot.MinWidth = 200f; - chatRoot.MinHeight = 80f; - _uiHost.Root.AddChild(chatRoot); + float contentW = 490f, contentH = chatRoot.Height; // dat-authored height + var chatFrame = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome) + { + Left = 10, Top = 440, + Width = contentW + 2 * chatBorder, Height = contentH + 2 * chatBorder, + MinWidth = 200f, MinHeight = 90f, + // Retail chat is translucent — fade the window's backgrounds/chrome + // (text stays opaque). Configurable opacity is a later step; 0.75 reads + // as see-through-but-readable. (retail SetDefaultOpacity ~0.5 / active 1.0) + Opacity = 0.75f, + }; + chatRoot.Left = chatBorder; chatRoot.Top = chatBorder; + chatRoot.Width = contentW; chatRoot.Height = contentH; + chatRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top + | AcDream.App.UI.AnchorEdges.Right | AcDream.App.UI.AnchorEdges.Bottom; + chatRoot.Draggable = false; chatRoot.Resizable = false; + chatFrame.AddChild(chatRoot); + _uiHost.Root.AddChild(chatFrame); Console.WriteLine("[D.2b] retail chat window from LayoutDesc importer (0x21000006)."); } else Console.WriteLine("[D.2b] chat: required role elements missing in 0x21000006."); diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index dc75d1ac..cc7b676b 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -31,6 +31,7 @@ public sealed class ChatWindowController // Element ids from chat LayoutDesc 0x21000006 (confirmed in Task D/G1). private const uint RootId = 0x1000000Eu; + private const uint ResizeBarId = 0x1000000Fu; // dat top resize bar (800px — dropped; nine-slice grips replace it) private const uint TranscriptPanelId = 0x10000010u; private const uint TranscriptId = 0x10000011u; // Type-12 prototype — skipped by factory private const uint TrackId = 0x10000012u; @@ -41,10 +42,12 @@ public sealed class ChatWindowController private const uint MaxMinId = 0x1000046Fu; // Scrollbar sprite ids from base layout 0x2100003E (confirmed in Task D). - private const uint TrackSprite = 0x06004C5Fu; - private const uint ThumbSprite = 0x06004C63u; - private const uint UpSprite = 0x06004C69u; - private const uint DownSprite = 0x06004C6Cu; + private const uint TrackSprite = 0x06004C5Fu; + private const uint ThumbSprite = 0x06004C63u; // 3-slice middle tile + private const uint ThumbTopSprite = 0x06004C60u; // 3-slice top cap + private const uint ThumbBotSprite = 0x06004C66u; // 3-slice bottom cap + private const uint UpSprite = 0x06004C69u; + private const uint DownSprite = 0x06004C6Cu; // Channel menu sprite ids (confirmed in chat element dump). private const uint MenuNormal = 0x06004D65u; @@ -130,7 +133,29 @@ public sealed class ChatWindowController return null; } - var c = new ChatWindowController { Root = layout.Root }; + // LayoutDesc 0x21000006 has SEVERAL top-level elements: the gmMainChatUI window + // (RootId 0x1000000E) PLUS stray auxiliary elements that are NOT part of the docked + // window — a separate Field+ListBox (0x1000001C/1D, the floaty scrollback), the + // talk-focus highlight strip (0x1000001E), and a scroll-button prototype (0x10000526). + // LayoutImporter.ImportInfos wraps all top-level elements in a synthetic Type-3 root, + // so using layout.Root would render the strays overlapping the real window (the + // red-striped garbage in the first live render). Use the gmMainChatUI window itself: + // GameWindow adds this to the host, which re-parents it out of the synthetic wrapper, + // orphaning the strays so they never draw. + var window = layout.FindElement(RootId) ?? layout.Root; + var c = new ChatWindowController { Root = window }; + + // Drop the dat top resize bar (0x1000000F): it is authored 800px wide and + // juts out of the content-width window. The host wraps this content in the + // universal nine-slice chrome, whose grips provide the resize affordance. + if (layout.FindElement(ResizeBarId) is { Parent: { } rbParent } resizeBar) + rbParent.RemoveChild(resizeBar); + + // Reclaim the 9px strip the dropped resize bar occupied (rows 0-8 of the root): + // grow the transcript panel up to the window top so its dark bg fills the strip. + // Otherwise the root element's brown bg shows through as a sliver along the top. + transcriptPanel.Top = 0f; + transcriptPanel.Height += 9f; // dat resize-bar height (0x1000000F H=9) // ── Transcript ─────────────────────────────────────────────────── // Place the behavioral transcript widget inside the transcript panel at the @@ -144,7 +169,7 @@ public sealed class ChatWindowController Anchors = ElementReader.ToAnchors(tInfo.Left, tInfo.Top, tInfo.Right, tInfo.Bottom), DatFont = datFont, Font = debugFont, - LinesProvider = () => BuildLines(vm), + LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont), }; transcriptPanel.AddChild(c.Transcript); @@ -178,10 +203,12 @@ public sealed class ChatWindowController Anchors = track.Anchors, Model = c.Transcript.Scroll, SpriteResolve = resolve, - TrackSprite = TrackSprite, - ThumbSprite = ThumbSprite, - UpSprite = UpSprite, - DownSprite = DownSprite, + TrackSprite = TrackSprite, + ThumbSprite = ThumbSprite, + ThumbTopSprite = ThumbTopSprite, + ThumbBotSprite = ThumbBotSprite, + UpSprite = UpSprite, + DownSprite = DownSprite, }; trackParent.RemoveChild(track); trackParent.AddChild(c.Scrollbar); @@ -220,6 +247,11 @@ public sealed class ChatWindowController // ── Max/min toggle — simplified gmMainChatUI::HandleMaximizeButton ── if (layout.FindElement(MaxMinId) is UiDatElement maxMinEl) { + // The dat puts max/min and the scrollbar up-button at the SAME X (both + // right-anchored), so at content width they overlap. Retail shows max/min + // just LEFT of the scrollbar column — shift it one button-width left. + if (track is not null) + maxMinEl.Left = track.Left - maxMinEl.Width; maxMinEl.ClickThrough = false; maxMinEl.OnClick = c.ToggleMaximize; } @@ -276,17 +308,66 @@ public sealed class ChatWindowController /// record format, applying retail-faithful /// per- colors. /// - private static IReadOnlyList BuildLines(ChatVM vm) + private static IReadOnlyList BuildLines( + ChatVM vm, UiChatView view, UiDatFont? datFont, BitmapFont? debugFont) { var detailed = vm.RecentLinesDetailed(); if (detailed.Count == 0) return Array.Empty(); - var result = new UiChatView.Line[detailed.Count]; - for (int i = 0; i < detailed.Count; i++) - result[i] = new UiChatView.Line(detailed[i].Text, RetailChatColor(detailed[i].Kind)); + // Word-wrap each message to the transcript's current pixel width (ports retail + // GlyphList::Recalculate @0x473800 — break at word boundaries when the line would + // exceed wrapWidth). Re-evaluated each frame so wrapping follows window resize. + float maxW = view.Width - 2f * view.Padding; + Func measure = + datFont is { } df ? s => df.MeasureWidth(s) + : debugFont is { } bf ? s => bf.MeasureWidth(s) + : s => s.Length * 7f; + + var result = new List(detailed.Count); + foreach (var d in detailed) + { + var color = RetailChatColor(d.Kind); + foreach (var frag in WrapText(d.Text, maxW, measure)) + result.Add(new UiChatView.Line(frag, color)); + } return result; } + /// + /// Greedy word-wrap: split into fragments that each fit in + /// pixels (per ), breaking at spaces. + /// A single word longer than the width overflows its own line (retail does not + /// hyphenate chat). Mirrors retail GlyphList::Recalculate's per-GlyphLine emission. + /// + public static IEnumerable WrapText(string text, float maxW, Func measure) + { + if (string.IsNullOrEmpty(text) || maxW <= 0f || measure(text) <= maxW) + { + yield return text; + yield break; + } + + var line = new System.Text.StringBuilder(); + foreach (var word in text.Split(' ')) + { + if (line.Length == 0) + { + line.Append(word); + } + else if (measure(line.ToString() + " " + word) > maxW) + { + yield return line.ToString(); + line.Clear(); + line.Append(word); + } + else + { + line.Append(' ').Append(word); + } + } + if (line.Length > 0) yield return line.ToString(); + } + /// /// Per- text color matching retail AC's channel coloring /// (observed from retail client screenshots and holtburger's chat.rs coloring). @@ -297,7 +378,7 @@ public sealed class ChatWindowController ChatKind.RangedSpeech => new(1f, 0.95f, 0.8f, 1f), // warm white — shout ChatKind.Channel => new(0.6f, 0.8f, 1f, 1f), // light blue — channel text ChatKind.Tell => new(1f, 0.5f, 1f, 1f), // magenta — whisper - ChatKind.System => new(1f, 1f, 0.45f, 1f), // yellow — system messages + ChatKind.System => new(0f, 1f, 0f, 1f), // green — system messages (retail ChatMessageType 5; AC2D eGreen {0,255,0}) ChatKind.Popup => new(1f, 0.85f, 0.4f, 1f), // amber — popup/broadcast ChatKind.Emote => new(0.8f, 0.8f, 0.7f, 1f), // off-white — emote ChatKind.SoulEmote => new(0.8f, 0.8f, 0.7f, 1f), // off-white — soul emote diff --git a/src/AcDream.App/UI/UiChannelMenu.cs b/src/AcDream.App/UI/UiChannelMenu.cs index 9726eb08..0d7445c8 100644 --- a/src/AcDream.App/UI/UiChannelMenu.cs +++ b/src/AcDream.App/UI/UiChannelMenu.cs @@ -5,32 +5,44 @@ using AcDream.UI.Abstractions; namespace AcDream.App.UI; /// -/// Chat channel selector (the "Chat ▸" button). Port of retail -/// UIElement_Menu as used by gmMainChatUI::InitTalkFocusMenu @0x4cdc50: -/// a button whose label is the active channel; clicking opens a popup of channels; -/// selecting one calls SetTalkFocus (here: ). +/// Chat channel selector — the "Chat" button + its talk-focus popup. Mirrors retail +/// gmMainChatUI::InitTalkFocusMenu @0x4cdc50: the button is labelled "Chat"; +/// clicking opens a TWO-COLUMN popup of 14 talk-focus items (Squelch, Tell to Selected, +/// Chat to All, Tell to Fellows, …). Selecting a channel item sets the active outbound +/// channel (retail SetTalkFocus; here ). The items +/// are code-populated exactly as retail populates them, not a dat-layout port. /// public sealed class UiChannelMenu : UiElement { - public readonly record struct Item(string Label, ChatChannelKind Channel); + /// One menu row: its label + the channel it selects (null = special/no-op + /// item such as Squelch or Tell-to-Selected, deferred). + public readonly record struct Item(string Label, ChatChannelKind? Channel); - /// Retail talk-focus channels (subset acdream's ChatInputParser routes). - public static readonly Item[] Channels = + /// The 14 retail talk-focus items in retail order — left column rows 0–6, + /// right column rows 7–13 (matching the live retail menu). + public static readonly Item[] Items = { - new("Say", ChatChannelKind.Say), - new("General", ChatChannelKind.General), - new("Trade", ChatChannelKind.Trade), - new("LFG", ChatChannelKind.Lfg), - new("Fellowship", ChatChannelKind.Fellowship), - new("Allegiance", ChatChannelKind.Allegiance), - new("Patron", ChatChannelKind.Patron), - new("Vassals", ChatChannelKind.Vassals), - new("Monarch", ChatChannelKind.Monarch), - new("Roleplay", ChatChannelKind.Roleplay), - new("Society", ChatChannelKind.Society), - new("Olthoi", ChatChannelKind.Olthoi), + new("Squelch (ignore)", null), // 0 special (squelch — deferred) + new("Tell to Selected", null), // 1 special (selected target — deferred) + new("Chat to All", ChatChannelKind.Say), // 2 + new("Tell to Fellows", ChatChannelKind.Fellowship), // 3 + new("Tell to General Chat", ChatChannelKind.General), // 4 + new("Tell to LFG Chat", ChatChannelKind.Lfg), // 5 + new("Tell to Society Chat", ChatChannelKind.Society), // 6 + new("Tell to Monarch", ChatChannelKind.Monarch), // 7 + new("Tell to Patron", ChatChannelKind.Patron), // 8 + new("Tell to Vassals", ChatChannelKind.Vassals), // 9 + new("Tell to Allegiance", ChatChannelKind.Allegiance), // 10 + new("Tell to Trade Chat", ChatChannelKind.Trade), // 11 + new("Tell to Roleplay Chat", ChatChannelKind.Roleplay), // 12 + new("Tell to Olthoi Chat", ChatChannelKind.Olthoi), // 13 }; + private const int Rows = 7; // items per column + private const float ItemH = 16f; // row height + private const float ColW = 150f; // column width (fits "Tell to Roleplay Chat") + + /// The channel the player's typed text currently goes to. public ChatChannelKind Selected { get; private set; } = ChatChannelKind.Say; public Action? OnChannelChanged { get; set; } @@ -39,41 +51,40 @@ public sealed class UiChannelMenu : UiElement public Func? SpriteResolve { get; set; } public uint NormalSprite { get; set; } public uint PressedSprite { get; set; } - public Vector4 TextColor { get; set; } = new(1f, 0.85f, 0.4f, 1f); + public Vector4 TextColor { get; set; } = new(1f, 0.92f, 0.72f, 1f); + /// Popup panel fill — the retail talk-focus menu is a warm tan/orange. + public Vector4 PopupColor { get; set; } = new(0.56f, 0.40f, 0.18f, 0.97f); private bool _open; - private const float ItemH = 16f; - private const float PopupW = 90f; + private static float PopupW => 2 * ColW; + private static float PopupH => Rows * ItemH; public UiChannelMenu() { CapturesPointerDrag = true; } - private string Label => FindLabel(Selected); - private static string FindLabel(ChatChannelKind k) - { - foreach (var it in Channels) if (it.Channel == k) return it.Label; - return "Chat"; - } - protected override void OnDraw(UiRenderContext ctx) { + // Button face + the "Chat" label (retail labels the talk-focus button "Chat"). if (SpriteResolve is { } resolve) { var (tex, _, _) = resolve(_open ? PressedSprite : NormalSprite); if (tex != 0) ctx.DrawSprite(tex, 0, 0, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); } - DrawLabel(ctx, Label + " >", 2f, (Height - LineH()) * 0.5f); + DrawLabel(ctx, "Chat", 4f, (Height - LineH()) * 0.5f); - if (_open) + if (!_open) return; + + // Two-column popup opening UPWARD from the button (chat sits at screen bottom). + float top = -PopupH; + ctx.DrawRect(0, top, PopupW, PopupH, PopupColor); + for (int i = 0; i < Items.Length; i++) { - float h = Channels.Length * ItemH; - float top = -h; // popup opens UPWARD (chat sits at screen bottom) - ctx.DrawRect(0, top, MathF.Max(Width, PopupW), h, new(0f, 0f, 0f, 0.85f)); - for (int i = 0; i < Channels.Length; i++) - DrawLabel(ctx, Channels[i].Label, 2f, top + i * ItemH); + int col = i / Rows, row = i % Rows; + DrawLabel(ctx, Items[i].Label, 4f + col * ColW, top + row * ItemH); } } private float LineH() => DatFont?.LineHeight ?? Font?.LineHeight ?? 14f; + private void DrawLabel(UiRenderContext ctx, string s, float x, float y) { if (DatFont is { } df) ctx.DrawStringDat(df, s, x, y, TextColor); @@ -81,29 +92,30 @@ public sealed class UiChannelMenu : UiElement } protected override bool OnHitTest(float lx, float ly) - => _open ? (lx >= 0 && lx < MathF.Max(Width, PopupW) - && ly >= -Channels.Length * ItemH && ly < Height) + => _open ? (lx >= 0 && lx < PopupW && ly >= -PopupH && ly < Height) : base.OnHitTest(lx, ly); public override bool OnEvent(in UiEvent e) { - if (e.Type == UiEventType.MouseDown) + if (e.Type != UiEventType.MouseDown) return false; + + float lx = e.Data1, ly = e.Data2; + if (_open && ly < 0) // clicked an item in the upward popup { - float ly = e.Data2; - if (_open && ly < 0) + int col = lx < ColW ? 0 : 1; + int row = (int)((ly + PopupH) / ItemH); + int idx = col * Rows + row; + if (row >= 0 && row < Rows && idx >= 0 && idx < Items.Length + && Items[idx].Channel is { } ch) { - int idx = (int)((ly + Channels.Length * ItemH) / ItemH); - if (idx >= 0 && idx < Channels.Length) - { - Selected = Channels[idx].Channel; - OnChannelChanged?.Invoke(Selected); - } - _open = false; - return true; + Selected = ch; + OnChannelChanged?.Invoke(ch); } - _open = !_open; + _open = false; return true; } - return false; + + _open = !_open; // toggle on button click + return true; } } diff --git a/src/AcDream.App/UI/UiChatScrollbar.cs b/src/AcDream.App/UI/UiChatScrollbar.cs index 6274f7b4..8c59f286 100644 --- a/src/AcDream.App/UI/UiChatScrollbar.cs +++ b/src/AcDream.App/UI/UiChatScrollbar.cs @@ -33,10 +33,15 @@ public sealed class UiChatScrollbar : UiElement /// Track background sprite id (0x06004C5F from layout 0x2100003E element 0x10000455). public uint TrackSprite { get; set; } - /// Thumb sprite id (3-slice middle tile: 0x06004C63; the widget draws - /// a single stretched sprite for simplicity — Task H can upgrade to 3-slice). + /// Thumb 3-slice MIDDLE tile sprite id (0x06004C63), tiled between the caps. public uint ThumbSprite { get; set; } + /// Thumb 3-slice TOP cap sprite id (0x06004C60, 3px tall). + public uint ThumbTopSprite { get; set; } + + /// Thumb 3-slice BOTTOM cap sprite id (0x06004C66, 3px tall). + public uint ThumbBotSprite { get; set; } + /// Up-arrow button sprite id (0x06004C69 Normal state, element 0x10000071). public uint UpSprite { get; set; } @@ -46,6 +51,9 @@ public sealed class UiChatScrollbar : UiElement /// Retail attribute 0x89 floor: minimum thumb height in pixels. private const float MinThumb = 8f; + /// Thumb cap height (native sprite height from base layout 0x2100003E). + private const float CapH = 3f; + /// Up/down button height in pixels. Matches element height 16px from /// the up/down button children in base layout 0x2100003E. private const float ButtonH = 16f; @@ -77,34 +85,59 @@ public sealed class UiChatScrollbar : UiElement { if (Model is not { } m || SpriteResolve is not { } resolve) return; - // Track background, full element bounds. - DrawSprite(ctx, resolve, TrackSprite, 0f, 0f, Width, Height); + // Track background — TILED vertically (retail DrawMode=Normal). The native track + // sprite (~16×32) repeats to fill the element height instead of stretch-distorting. + DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height); - // Up button — top ButtonH rows. + // Up button — top ButtonH rows (directional arrow art, drawn 1:1). DrawSprite(ctx, resolve, UpSprite, 0f, 0f, Width, ButtonH); // Down button — bottom ButtonH rows. DrawSprite(ctx, resolve, DownSprite, 0f, Height - ButtonH, Width, ButtonH); - // Thumb — only when content overflows the view. + // Thumb — only when content overflows the view. Retail 3-slice: top cap + + // tiled middle + bottom cap (base layout 0x2100003E thumb sub-elements + // 0x10000364/65/66). Falls back to a single tiled middle if the caps are unset + // or the thumb is too short to hold both caps. if (m.HasOverflow) { float trackTop = ButtonH; float trackLen = Height - 2f * ButtonH; var (ty, th) = ThumbRect(m, trackTop, trackLen); - DrawSprite(ctx, resolve, ThumbSprite, 0f, ty, Width, th); + if (ThumbTopSprite != 0 && ThumbBotSprite != 0 && th >= 2f * CapH) + { + DrawSprite(ctx, resolve, ThumbTopSprite, 0f, ty, Width, CapH); + DrawTiled(ctx, resolve, ThumbSprite, 0f, ty + CapH, Width, th - 2f * CapH); + DrawSprite(ctx, resolve, ThumbBotSprite, 0f, ty + th - CapH, Width, CapH); + } + else + { + DrawTiled(ctx, resolve, ThumbSprite, 0f, ty, Width, th); + } } } + /// Draw a sprite stretched 1:1 to the dest rect. private void DrawSprite(UiRenderContext ctx, Func resolve, uint id, float x, float y, float w, float h) { - if (id == 0) return; + if (id == 0 || w <= 0f || h <= 0f) return; var (tex, _, _) = resolve(id); if (tex == 0) return; ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, 1f, 1f, Vector4.One); } + /// Draw a sprite TILED to fill the dest rect (UV-repeat at native size on + /// both axes — the UI texture is GL_REPEAT-wrapped). A native-width axis gives 1:1. + private void DrawTiled(UiRenderContext ctx, Func resolve, + uint id, float x, float y, float w, float h) + { + if (id == 0 || w <= 0f || h <= 0f) return; + var (tex, tw, th) = resolve(id); + if (tex == 0 || tw == 0 || th == 0) return; + ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, w / tw, h / th, Vector4.One); + } + public override bool OnEvent(in UiEvent e) { if (Model is not { } m) return false; diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index 937a52b2..a1c5f4ab 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -93,6 +93,11 @@ public abstract class UiElement /// Painter's-algorithm z-order within siblings. Higher = on top. public int ZOrder { get; set; } + /// Window opacity (0..1) multiplied into this element's and its + /// descendants' background + sprite draws (text stays opaque). 1 = fully opaque. + /// Set on a top-level window (e.g. the chat frame) for retail's translucent chat. + public float Opacity { get; set; } = 1f; + /// If true, a left-drag on this element (or a non-draggable child of /// it) repositions it as a movable window. Intended for top-level panels, /// whose Left/Top are screen coordinates (Root sits at the origin). @@ -179,8 +184,10 @@ public abstract class UiElement { if (!Visible) return; - // Translate into our local space. + // Translate into our local space + push this window's opacity (multiplies into + // descendants' sprite/rect draws; text bypasses the alpha so it stays sharp). ctx.PushTransform(Left, Top); + ctx.PushAlpha(Opacity); try { OnDraw(ctx); @@ -201,6 +208,7 @@ public abstract class UiElement } finally { + ctx.PopAlpha(); ctx.PopTransform(); } } @@ -220,9 +228,14 @@ public abstract class UiElement /// internal UiElement? HitTest(float localX, float localY) { - if (!Visible || !Enabled || ClickThrough) return null; + if (!Visible || !Enabled) return null; - // Children first, in reverse Z-order (topmost first). + // Children first, in reverse Z-order (topmost first). ClickThrough means + // THIS element is transparent to the pointer — but its children are NOT. + // A ClickThrough container (e.g. a UiDatElement panel that hosts the chat + // input / transcript) must still let the pointer reach its behavioral + // children, so the ClickThrough check happens AFTER the child walk, gating + // only whether THIS element claims the hit. if (_children.Count > 0) { var ordered = _children.ToArray(); @@ -235,6 +248,7 @@ public abstract class UiElement } } + if (ClickThrough) return null; return OnHitTest(localX, localY) ? this : null; } diff --git a/src/AcDream.App/UI/UiRenderContext.cs b/src/AcDream.App/UI/UiRenderContext.cs index db23174d..ecda1c1c 100644 --- a/src/AcDream.App/UI/UiRenderContext.cs +++ b/src/AcDream.App/UI/UiRenderContext.cs @@ -22,6 +22,25 @@ public sealed class UiRenderContext private readonly System.Collections.Generic.List _stack = new(); private Vector2 _current; + // Alpha (opacity) stack — a window pushes its Opacity so its background/sprite + // draws fade (retail's translucent-chat effect). Text draws bypass this (they go + // straight to TextRenderer), so text stays sharp over a translucent background. + private readonly System.Collections.Generic.List _alphaStack = new(); + private float _alpha = 1f; + + /// Current cumulative opacity multiplier applied to sprite + rect draws. + public float AlphaMod => _alpha; + + /// Multiply into the running opacity. Pair with . + public void PushAlpha(float a) { _alphaStack.Add(_alpha); _alpha *= a; } + + public void PopAlpha() + { + if (_alphaStack.Count == 0) return; + _alpha = _alphaStack[^1]; + _alphaStack.RemoveAt(_alphaStack.Count - 1); + } + public UiRenderContext(TextRenderer tr, Vector2 screenSize, BitmapFont? defaultFont = null) { TextRenderer = tr; @@ -48,15 +67,18 @@ public sealed class UiRenderContext // ── Pass-through draw helpers (add current translate) ────────────── public void DrawRect(float x, float y, float w, float h, Vector4 color) - => TextRenderer.DrawRect(_current.X + x, _current.Y + y, w, h, color); + => TextRenderer.DrawRect(_current.X + x, _current.Y + y, w, h, ApplyAlpha(color)); public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f) - => TextRenderer.DrawRectOutline(_current.X + x, _current.Y + y, w, h, color, thickness); + => TextRenderer.DrawRectOutline(_current.X + x, _current.Y + y, w, h, ApplyAlpha(color), thickness); public void DrawSprite(uint texture, float x, float y, float w, float h, float u0, float v0, float u1, float v1, Vector4 tint) => TextRenderer.DrawSprite(texture, - _current.X + x, _current.Y + y, w, h, u0, v0, u1, v1, tint); + _current.X + x, _current.Y + y, w, h, u0, v0, u1, v1, ApplyAlpha(tint)); + + /// Multiply the current window opacity into a draw color's alpha. + private Vector4 ApplyAlpha(Vector4 c) => _alpha >= 1f ? c : new Vector4(c.X, c.Y, c.Z, c.W * _alpha); public void DrawString(string text, float x, float y, Vector4 color, BitmapFont? font = null) { diff --git a/tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs b/tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs index c9f7b73b..59fe18f9 100644 --- a/tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs +++ b/tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs @@ -1,3 +1,4 @@ +using System.Linq; using AcDream.App.UI; using AcDream.UI.Abstractions; @@ -6,42 +7,40 @@ namespace AcDream.App.Tests.UI; public class UiChannelMenuTests { [Fact] - public void Channels_HasExpected12Entries() + public void Items_HasExpected14Entries() { - Assert.Equal(12, UiChannelMenu.Channels.Length); + // Retail gmMainChatUI::InitTalkFocusMenu: squelch + tell-selected + 12 channels. + Assert.Equal(14, UiChannelMenu.Items.Length); } [Fact] - public void Channels_FirstEntry_IsSay() + public void Items_FirstEntry_IsSquelch_Special() { - Assert.Equal(ChatChannelKind.Say, UiChannelMenu.Channels[0].Channel); - Assert.Equal("Say", UiChannelMenu.Channels[0].Label); + Assert.Equal("Squelch (ignore)", UiChannelMenu.Items[0].Label); + Assert.Null(UiChannelMenu.Items[0].Channel); // special item, no channel } [Fact] - public void Channels_LastEntry_IsOlthoi() + public void Items_LastEntry_IsOlthoi() { - var last = UiChannelMenu.Channels[^1]; + var last = UiChannelMenu.Items[^1]; + Assert.Equal("Tell to Olthoi Chat", last.Label); Assert.Equal(ChatChannelKind.Olthoi, last.Channel); - Assert.Equal("Olthoi", last.Label); } [Fact] - public void Channels_ContainsAllExpectedKinds() + public void Items_ContainAll12ChannelKinds() { - var kinds = new HashSet(UiChannelMenu.Channels.Select(c => c.Channel)); - Assert.Contains(ChatChannelKind.Say, kinds); - Assert.Contains(ChatChannelKind.General, kinds); - Assert.Contains(ChatChannelKind.Trade, kinds); - Assert.Contains(ChatChannelKind.Lfg, kinds); - Assert.Contains(ChatChannelKind.Fellowship, kinds); - Assert.Contains(ChatChannelKind.Allegiance, kinds); - Assert.Contains(ChatChannelKind.Patron, kinds); - Assert.Contains(ChatChannelKind.Vassals, kinds); - Assert.Contains(ChatChannelKind.Monarch, kinds); - Assert.Contains(ChatChannelKind.Roleplay, kinds); - Assert.Contains(ChatChannelKind.Society, kinds); - Assert.Contains(ChatChannelKind.Olthoi, kinds); + var kinds = new HashSet( + UiChannelMenu.Items.Where(i => i.Channel is not null).Select(i => i.Channel!.Value)); + foreach (var k in new[] + { + ChatChannelKind.Say, ChatChannelKind.General, ChatChannelKind.Trade, ChatChannelKind.Lfg, + ChatChannelKind.Fellowship, ChatChannelKind.Allegiance, ChatChannelKind.Patron, + ChatChannelKind.Vassals, ChatChannelKind.Monarch, ChatChannelKind.Roleplay, + ChatChannelKind.Society, ChatChannelKind.Olthoi, + }) + Assert.Contains(k, kinds); } [Fact] @@ -52,25 +51,50 @@ public class UiChannelMenuTests } [Fact] - public void OnChannelChanged_FiredWhenSelectionMadeViaEvent() + public void Select_LeftColumnItem_FiresChannel() { var menu = new UiChannelMenu { Width = 80f, Height = 18f }; - - // Open the popup (click inside the button area — Data2 >= 0). - var openEvt = new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5); + var openEvt = new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5); // open Assert.True(menu.OnEvent(openEvt)); - // Click on the second item (General) in the upward popup. - // Popup renders UPWARD: top = -(12 * 16) = -192. - // Item i=1 (General) occupies y in [-192 + 16, -192 + 32) = [-176, -160). - // A click at ly = -176 + 8 = -168 hits item index = (int)((-168 + 192) / 16) = (int)(24/16) = 1. ChatChannelKind? fired = null; menu.OnChannelChanged = k => fired = k; - var selectEvt = new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -168); - Assert.True(menu.OnEvent(selectEvt)); + // PopupH = 7*16 = 112, top = -112. "Chat to All" (Say) is index 2 = left col, row 2: + // y in [-112+32, -112+48) = [-80,-64). Click (lx=10 < ColW, ly=-72) → idx 2 → Say. + var selEvt = new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -72); + Assert.True(menu.OnEvent(selEvt)); + Assert.Equal(ChatChannelKind.Say, fired); + Assert.Equal(ChatChannelKind.Say, menu.Selected); + } - Assert.Equal(ChatChannelKind.General, fired); - Assert.Equal(ChatChannelKind.General, menu.Selected); + [Fact] + public void Select_RightColumnItem_FiresChannel() + { + var menu = new UiChannelMenu { Width = 80f, Height = 18f }; + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open + + ChatChannelKind? fired = null; + menu.OnChannelChanged = k => fired = k; + + // "Tell to Monarch" is index 7 = right col (lx >= ColW 150), row 0: + // y in [-112, -96). Click (lx=160, ly=-104) → col 1, row 0 → idx 7 → Monarch. + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 160, -104))); + Assert.Equal(ChatChannelKind.Monarch, fired); + Assert.Equal(ChatChannelKind.Monarch, menu.Selected); + } + + [Fact] + public void Select_SpecialItem_DoesNotFire() + { + var menu = new UiChannelMenu { Width = 80f, Height = 18f }; + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open + + int fired = 0; + menu.OnChannelChanged = _ => fired++; + + // "Squelch (ignore)" is index 0 = left col, row 0: y in [-112, -96). No channel. + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -104))); + Assert.Equal(0, fired); // special item is a no-op } } From ccaf188e41f2c2487a54a43f242435cfe4ee1c12 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 10:08:42 +0200 Subject: [PATCH 72/99] @ feat(D.2b): exact retail chat colors from a live cdb dump Attached cdb to a live retail acclient (PDB-matched) and read the named RGBAColor constants at acclient 0x81c4a8+ (colorWhite/colorBrightPurple/colorLightBlue/ colorGreen/colorLightRed/colorGrey), used by ChatInterface::BuildChatColorLookupTable @0x4f31c0. Replaced the approximated RetailChatColor palette with the ground-truth values: speech=white, tell=colorBrightPurple(1,.498,1), channel=colorLightBlue (.247,.749,1), system/popup=colorGreen(.5,1,.498), combat=colorLightRed, emote=colorGrey. Capture scripts saved under tools/cdb/. Co-Authored-By: Claude Opus 4.8 (1M context) @ --- .../UI/Layout/ChatWindowController.cs | 28 +++++++++++-------- tools/cdb/chat-colors.cdb | 12 ++++++++ tools/cdb/chat-colors2.cdb | 6 ++++ 3 files changed, 34 insertions(+), 12 deletions(-) create mode 100644 tools/cdb/chat-colors.cdb create mode 100644 tools/cdb/chat-colors2.cdb diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index cc7b676b..6e8aafa5 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -369,20 +369,24 @@ public sealed class ChatWindowController } /// - /// Per- text color matching retail AC's channel coloring - /// (observed from retail client screenshots and holtburger's chat.rs coloring). + /// Per- text color — the EXACT retail RGBA values read from a + /// live retail client via cdb (the named RGBAColor constants at acclient + /// 0x81c4a8+, e.g. colorWhite/colorBrightPurple/colorLightBlue/ + /// colorGreen, used by ChatInterface::BuildChatColorLookupTable @0x4f31c0). + /// The four common kinds (speech/tell/channel/system) are confirmed by the named + /// symbols + universal AC convention; the rarer kinds map to the nearest named color. /// private static Vector4 RetailChatColor(ChatKind kind) => kind switch { - ChatKind.LocalSpeech => new(1f, 1f, 1f, 1f), // white — spoken nearby - ChatKind.RangedSpeech => new(1f, 0.95f, 0.8f, 1f), // warm white — shout - ChatKind.Channel => new(0.6f, 0.8f, 1f, 1f), // light blue — channel text - ChatKind.Tell => new(1f, 0.5f, 1f, 1f), // magenta — whisper - ChatKind.System => new(0f, 1f, 0f, 1f), // green — system messages (retail ChatMessageType 5; AC2D eGreen {0,255,0}) - ChatKind.Popup => new(1f, 0.85f, 0.4f, 1f), // amber — popup/broadcast - ChatKind.Emote => new(0.8f, 0.8f, 0.7f, 1f), // off-white — emote - ChatKind.SoulEmote => new(0.8f, 0.8f, 0.7f, 1f), // off-white — soul emote - ChatKind.Combat => new(1f, 0.6f, 0.25f, 1f), // orange — combat feedback - _ => new(0.9f, 0.9f, 0.9f, 1f), // light grey — fallback + ChatKind.LocalSpeech => new(1f, 1f, 1f, 1f), // colorWhite + ChatKind.RangedSpeech => new(1f, 1f, 1f, 1f), // colorWhite (shout) + ChatKind.Channel => new(0.247f, 0.749f, 1f, 1f), // colorLightBlue + ChatKind.Tell => new(1f, 0.498f, 1f, 1f), // colorBrightPurple + ChatKind.System => new(0.5f, 1f, 0.498f, 1f), // colorGreen + ChatKind.Popup => new(0.5f, 1f, 0.498f, 1f), // colorGreen (server broadcast) + ChatKind.Emote => new(0.824f, 0.824f, 0.784f, 1f), // colorGrey + ChatKind.SoulEmote => new(0.824f, 0.824f, 0.784f, 1f), // colorGrey + ChatKind.Combat => new(0.96f, 0.459f, 0.447f, 1f), // colorLightRed + _ => new(0.824f, 0.824f, 0.784f, 1f), // colorGrey (fallback) }; } diff --git a/tools/cdb/chat-colors.cdb b/tools/cdb/chat-colors.cdb new file mode 100644 index 00000000..b9010838 --- /dev/null +++ b/tools/cdb/chat-colors.cdb @@ -0,0 +1,12 @@ +.symopt+ 0x40 +.reload /f acclient.exe +.echo ===BASE=== +lm m acclient +.echo ===DISASM_BuildChatColorLookupTable=== +uf acclient!ChatInterface::BuildChatColorLookupTable +.echo ===TABLE_REL_0x41c4a8=== +dd acclient+0x41c4a8 L40 +.echo ===TABLE_ABS_0x81c4a8=== +dd 0x81c4a8 L40 +.echo ===END=== +qd diff --git a/tools/cdb/chat-colors2.cdb b/tools/cdb/chat-colors2.cdb new file mode 100644 index 00000000..24b7a382 --- /dev/null +++ b/tools/cdb/chat-colors2.cdb @@ -0,0 +1,6 @@ +.echo ===COLOR_SYMS=== +x acclient!color* +.echo ===CHATCOLOR_SYMS=== +x acclient!*ChatColor* +.echo ===END=== +qd From 7094a1c84751e7f87cebec94ed2976043e29926c Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 10:38:56 +0200 Subject: [PATCH 73/99] @ fix(D.2b): channel menu popup opaque + button label tracks selected target - the popup inherited the chat window 0.75 opacity so the transcript bled through; add UiRenderContext.PushAlphaAbsolute and draw the popup at absolute opacity. - the "Chat" button was hardcoded; it now shows the active talk target (retail updates it on selection). Exact textured menu-panel sprite is a follow-up (the popup is a keystone UIElement_Menu construct, not in the chat LayoutDesc). Co-Authored-By: Claude Opus 4.8 (1M context) @ --- src/AcDream.App/UI/UiChannelMenu.cs | 41 +++++++++++++++++++++------ src/AcDream.App/UI/UiRenderContext.cs | 4 +++ 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/AcDream.App/UI/UiChannelMenu.cs b/src/AcDream.App/UI/UiChannelMenu.cs index 0d7445c8..0403527c 100644 --- a/src/AcDream.App/UI/UiChannelMenu.cs +++ b/src/AcDream.App/UI/UiChannelMenu.cs @@ -53,7 +53,7 @@ public sealed class UiChannelMenu : UiElement public uint PressedSprite { get; set; } public Vector4 TextColor { get; set; } = new(1f, 0.92f, 0.72f, 1f); /// Popup panel fill — the retail talk-focus menu is a warm tan/orange. - public Vector4 PopupColor { get; set; } = new(0.56f, 0.40f, 0.18f, 0.97f); + public Vector4 PopupColor { get; set; } = new(0.56f, 0.40f, 0.18f, 1f); private bool _open; private static float PopupW => 2 * ColW; @@ -61,26 +61,51 @@ public sealed class UiChannelMenu : UiElement public UiChannelMenu() { CapturesPointerDrag = true; } + /// The button face label = the active talk target (retail updates the + /// "Chat" button to whichever target you pick). "Chat" = Chat-to-All (Say). + private string ButtonText => Selected switch + { + ChatChannelKind.Say => "Chat", + ChatChannelKind.General => "General", + ChatChannelKind.Trade => "Trade", + ChatChannelKind.Lfg => "LFG", + ChatChannelKind.Fellowship => "Fellow", + ChatChannelKind.Allegiance => "Alleg", + ChatChannelKind.Patron => "Patron", + ChatChannelKind.Vassals => "Vassals", + ChatChannelKind.Monarch => "Monarch", + ChatChannelKind.Roleplay => "Roleplay", + ChatChannelKind.Society => "Society", + ChatChannelKind.Olthoi => "Olthoi", + _ => "Chat", + }; + protected override void OnDraw(UiRenderContext ctx) { - // Button face + the "Chat" label (retail labels the talk-focus button "Chat"). + // Button face + the active-target label (retail updates this to the chosen target). if (SpriteResolve is { } resolve) { var (tex, _, _) = resolve(_open ? PressedSprite : NormalSprite); if (tex != 0) ctx.DrawSprite(tex, 0, 0, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); } - DrawLabel(ctx, "Chat", 4f, (Height - LineH()) * 0.5f); + DrawLabel(ctx, ButtonText, 4f, (Height - LineH()) * 0.5f); if (!_open) return; // Two-column popup opening UPWARD from the button (chat sits at screen bottom). - float top = -PopupH; - ctx.DrawRect(0, top, PopupW, PopupH, PopupColor); - for (int i = 0; i < Items.Length; i++) + // Force OPAQUE: the menu must read solid even though the chat window is translucent. + ctx.PushAlphaAbsolute(1f); + try { - int col = i / Rows, row = i % Rows; - DrawLabel(ctx, Items[i].Label, 4f + col * ColW, top + row * ItemH); + float top = -PopupH; + ctx.DrawRect(0, top, PopupW, PopupH, PopupColor); + for (int i = 0; i < Items.Length; i++) + { + int col = i / Rows, row = i % Rows; + DrawLabel(ctx, Items[i].Label, 4f + col * ColW, top + row * ItemH); + } } + finally { ctx.PopAlpha(); } } private float LineH() => DatFont?.LineHeight ?? Font?.LineHeight ?? 14f; diff --git a/src/AcDream.App/UI/UiRenderContext.cs b/src/AcDream.App/UI/UiRenderContext.cs index ecda1c1c..5b97492e 100644 --- a/src/AcDream.App/UI/UiRenderContext.cs +++ b/src/AcDream.App/UI/UiRenderContext.cs @@ -34,6 +34,10 @@ public sealed class UiRenderContext /// Multiply into the running opacity. Pair with . public void PushAlpha(float a) { _alphaStack.Add(_alpha); _alpha *= a; } + /// Push an ABSOLUTE opacity (replaces, not multiplies) — for popups/overlays + /// that must stay opaque even inside a translucent window. Pair with . + public void PushAlphaAbsolute(float a) { _alphaStack.Add(_alpha); _alpha = a; } + public void PopAlpha() { if (_alphaStack.Count == 0) return; From bb983ae850a3a9bf42d39382fe6352c272715b9a Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 11:33:38 +0200 Subject: [PATCH 74/99] @ feat(D.2b): data-driven channel menu chrome + greying + scroll-arrow fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Investigation found the menu popup is fully dat-driven (UIElement_Menu::MakePopup @0x46d310 reads LayoutDesc 0x21000006 elements 0x1000001C/1D/1E — the "stray" top-level elements). Render the popup from the real sprites instead of a flat rect: - panel 0x0600124C, item row 0x0600124E, selected row 0x0600124D; 191x17 rows, 2 cols. - drawing rows as SPRITES also fixes the z-order (a DrawRect bg composited OVER the labels; sprites share the labels submission bucket so text lands on top). - item greying: available channels white, unavailable salmon (colorPink) — static approximation (Say/General/Trade/LFG) with an AvailabilityProvider hook for live TurbineChat state; unavailable items are inert on click. Ports ResetAllTalkFocusMenuButtons. - scroll arrows: both dat sprites point down (export-confirmed); V-flip the top button so it points up. Tabs confirmed to have NO digits in retail (blank gold frames) — acdream already matches. Build + 392 App tests green. Co-Authored-By: Claude Opus 4.8 (1M context) @ --- .../UI/Layout/ChatWindowController.cs | 20 ++-- src/AcDream.App/UI/UiChannelMenu.cs | 92 ++++++++++++++----- src/AcDream.App/UI/UiChatScrollbar.cs | 18 +++- .../UI/UiChannelMenuTests.cs | 89 +++++++++++------- 4 files changed, 155 insertions(+), 64 deletions(-) diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index 6e8aafa5..e02efb56 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -50,8 +50,11 @@ public sealed class ChatWindowController private const uint DownSprite = 0x06004C6Cu; // Channel menu sprite ids (confirmed in chat element dump). - private const uint MenuNormal = 0x06004D65u; - private const uint MenuPressed = 0x06004D66u; + private const uint MenuNormal = 0x06004D65u; // button face + private const uint MenuPressed = 0x06004D66u; // button pressed + private const uint MenuPopupBg = 0x0600124Cu; // popup panel fill (element 0x1000001C) + private const uint MenuItemRow = 0x0600124Eu; // item row bg (template 0x1000001E) + private const uint MenuItemSelected = 0x0600124Du; // active channel row // ── Public surface ───────────────────────────────────────────────────── @@ -225,11 +228,14 @@ public sealed class ChatWindowController Width = menuEl.Width, Height = menuEl.Height, Anchors = menuEl.Anchors, - DatFont = datFont, - Font = debugFont, - SpriteResolve = resolve, - NormalSprite = MenuNormal, - PressedSprite = MenuPressed, + DatFont = datFont, + Font = debugFont, + SpriteResolve = resolve, + NormalSprite = MenuNormal, + PressedSprite = MenuPressed, + PopupBgSprite = MenuPopupBg, + ItemNormalSprite = MenuItemRow, + ItemHighlightSprite = MenuItemSelected, }; c.Menu.OnChannelChanged = k => c._activeChannel = k; menuParent.RemoveChild(menuEl); diff --git a/src/AcDream.App/UI/UiChannelMenu.cs b/src/AcDream.App/UI/UiChannelMenu.cs index 0403527c..01d1f735 100644 --- a/src/AcDream.App/UI/UiChannelMenu.cs +++ b/src/AcDream.App/UI/UiChannelMenu.cs @@ -6,11 +6,13 @@ namespace AcDream.App.UI; /// /// Chat channel selector — the "Chat" button + its talk-focus popup. Mirrors retail -/// gmMainChatUI::InitTalkFocusMenu @0x4cdc50: the button is labelled "Chat"; -/// clicking opens a TWO-COLUMN popup of 14 talk-focus items (Squelch, Tell to Selected, -/// Chat to All, Tell to Fellows, …). Selecting a channel item sets the active outbound -/// channel (retail SetTalkFocus; here ). The items -/// are code-populated exactly as retail populates them, not a dat-layout port. +/// gmMainChatUI::InitTalkFocusMenu @0x4cdc50 + UIElement_Menu::MakePopup +/// @0x46d310: the button is labelled with the active target; clicking opens a +/// TWO-COLUMN popup of 14 talk-focus items on the dat-driven menu chrome (panel + +/// per-row + selected-row sprites, 191×17 rows, from LayoutDesc 0x21000006 elements +/// 0x1000001C/1D/1E). Items are code-populated exactly as retail populates them. +/// Unavailable channels render greyed (retail ResetAllTalkFocusMenuButtons → +/// SetState(disabled), colorPink). /// public sealed class UiChannelMenu : UiElement { @@ -39,21 +41,34 @@ public sealed class UiChannelMenu : UiElement }; private const int Rows = 7; // items per column - private const float ItemH = 16f; // row height - private const float ColW = 150f; // column width (fits "Tell to Roleplay Chat") + private const float ItemH = 17f; // row height (dat item template 0x1000001E H=17) + private const float ColW = 191f; // column width (dat item template W=191) /// The channel the player's typed text currently goes to. public ChatChannelKind Selected { get; private set; } = ChatChannelKind.Say; public Action? OnChannelChanged { get; set; } + /// Per-channel availability gate (retail greys channels you are not in). + /// Defaults to a static approximation; the controller can inject live channel state. + public Func? AvailabilityProvider { get; set; } + public UiDatFont? DatFont { get; set; } public AcDream.App.Rendering.BitmapFont? Font { get; set; } public Func? SpriteResolve { get; set; } + + // Button face sprites (dat menu element 0x10000014). public uint NormalSprite { get; set; } public uint PressedSprite { get; set; } + // Popup chrome sprites (dat menu popup template, layout 0x21000006). + public uint PopupBgSprite { get; set; } // 0x0600124C — panel fill (191×2 tiles) + public uint ItemNormalSprite { get; set; } // 0x0600124E — a row background (191×17) + public uint ItemHighlightSprite { get; set; } // 0x0600124D — the active channel's row + public Vector4 TextColor { get; set; } = new(1f, 0.92f, 0.72f, 1f); - /// Popup panel fill — the retail talk-focus menu is a warm tan/orange. - public Vector4 PopupColor { get; set; } = new(0.56f, 0.40f, 0.18f, 1f); + /// Available item text (retail white #FFFFFF). + public Vector4 TextColorAvailable { get; set; } = new(1f, 1f, 1f, 1f); + /// Greyed/unavailable item text (retail colorPink salmon, acclient 0x81c528). + public Vector4 TextColorGhosted { get; set; } = new(1f, 0.588f, 0.588f, 1f); private bool _open; private static float PopupW => 2 * ColW; @@ -61,8 +76,17 @@ public sealed class UiChannelMenu : UiElement public UiChannelMenu() { CapturesPointerDrag = true; } + /// True if the channel is currently joinable/visible. Defaults to a static + /// approximation matching the common case (Say/General/Trade/LFG); the fellowship + + /// allegiance-hierarchy channels need membership state acdream does not yet track + /// (deferred → greyed). The controller can override via . + private bool IsAvailable(ChatChannelKind ch) + => AvailabilityProvider?.Invoke(ch) + ?? ch is ChatChannelKind.Say or ChatChannelKind.General + or ChatChannelKind.Trade or ChatChannelKind.Lfg; + /// The button face label = the active talk target (retail updates the - /// "Chat" button to whichever target you pick). "Chat" = Chat-to-All (Say). + /// button to whichever target you pick). "Chat" = Chat-to-All (Say). private string ButtonText => Selected switch { ChatChannelKind.Say => "Chat", @@ -82,27 +106,40 @@ public sealed class UiChannelMenu : UiElement protected override void OnDraw(UiRenderContext ctx) { - // Button face + the active-target label (retail updates this to the chosen target). - if (SpriteResolve is { } resolve) + var resolve = SpriteResolve; + + // Button face + the active-target label. + if (resolve is not null) { var (tex, _, _) = resolve(_open ? PressedSprite : NormalSprite); if (tex != 0) ctx.DrawSprite(tex, 0, 0, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); } - DrawLabel(ctx, ButtonText, 4f, (Height - LineH()) * 0.5f); + DrawLabel(ctx, ButtonText, 4f, (Height - LineH()) * 0.5f, TextColor); - if (!_open) return; + if (!_open || resolve is null) return; - // Two-column popup opening UPWARD from the button (chat sits at screen bottom). - // Force OPAQUE: the menu must read solid even though the chat window is translucent. + // Two-column popup opening UPWARD from the button. Force OPAQUE (a menu reads + // solid even though the chat window is translucent). Draw the dat row sprites + // first, then the labels — both go through the sprite bucket in submission order, + // so the labels land on top (a DrawRect bg would composite over the text instead). ctx.PushAlphaAbsolute(1f); try { float top = -PopupH; - ctx.DrawRect(0, top, PopupW, PopupH, PopupColor); + DrawSprite(ctx, resolve, PopupBgSprite, 0f, top, PopupW, PopupH); // panel base for (int i = 0; i < Items.Length; i++) { int col = i / Rows, row = i % Rows; - DrawLabel(ctx, Items[i].Label, 4f + col * ColW, top + row * ItemH); + float x = col * ColW, y = top + row * ItemH; + bool selected = Items[i].Channel is { } c && c == Selected; + DrawSprite(ctx, resolve, selected ? ItemHighlightSprite : ItemNormalSprite, x, y, ColW, ItemH); + } + for (int i = 0; i < Items.Length; i++) + { + int col = i / Rows, row = i % Rows; + bool avail = Items[i].Channel is { } c && IsAvailable(c); + DrawLabel(ctx, Items[i].Label, 4f + col * ColW, top + row * ItemH, + avail ? TextColorAvailable : TextColorGhosted); } } finally { ctx.PopAlpha(); } @@ -110,10 +147,20 @@ public sealed class UiChannelMenu : UiElement private float LineH() => DatFont?.LineHeight ?? Font?.LineHeight ?? 14f; - private void DrawLabel(UiRenderContext ctx, string s, float x, float y) + private void DrawSprite(UiRenderContext ctx, Func resolve, + uint id, float x, float y, float w, float h) { - if (DatFont is { } df) ctx.DrawStringDat(df, s, x, y, TextColor); - else ctx.DrawString(s, x, y, TextColor, Font); + if (id == 0) return; + var (tex, tw, th) = resolve(id); + if (tex == 0 || tw == 0 || th == 0) return; + // Tile at native size (the panel fill is 191×2; rows are 191×17 = 1:1). + ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, w / tw, h / th, Vector4.One); + } + + private void DrawLabel(UiRenderContext ctx, string s, float x, float y, Vector4 color) + { + if (DatFont is { } df) ctx.DrawStringDat(df, s, x, y, color); + else ctx.DrawString(s, x, y, color, Font); } protected override bool OnHitTest(float lx, float ly) @@ -130,8 +177,9 @@ public sealed class UiChannelMenu : UiElement int col = lx < ColW ? 0 : 1; int row = (int)((ly + PopupH) / ItemH); int idx = col * Rows + row; + // Only pick available channel items (special + greyed items are inert). if (row >= 0 && row < Rows && idx >= 0 && idx < Items.Length - && Items[idx].Channel is { } ch) + && Items[idx].Channel is { } ch && IsAvailable(ch)) { Selected = ch; OnChannelChanged?.Invoke(ch); diff --git a/src/AcDream.App/UI/UiChatScrollbar.cs b/src/AcDream.App/UI/UiChatScrollbar.cs index 8c59f286..6d163ef3 100644 --- a/src/AcDream.App/UI/UiChatScrollbar.cs +++ b/src/AcDream.App/UI/UiChatScrollbar.cs @@ -89,10 +89,11 @@ public sealed class UiChatScrollbar : UiElement // sprite (~16×32) repeats to fill the element height instead of stretch-distorting. DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height); - // Up button — top ButtonH rows (directional arrow art, drawn 1:1). - DrawSprite(ctx, resolve, UpSprite, 0f, 0f, Width, ButtonH); + // Up button — top ButtonH rows. The dat up/down arrow sprites both point DOWN + // (confirmed by sprite export), so the TOP button is drawn V-FLIPPED to point UP. + DrawSpriteFlipV(ctx, resolve, UpSprite, 0f, 0f, Width, ButtonH); - // Down button — bottom ButtonH rows. + // Down button — bottom ButtonH rows (down arrow as-is). DrawSprite(ctx, resolve, DownSprite, 0f, Height - ButtonH, Width, ButtonH); // Thumb — only when content overflows the view. Retail 3-slice: top cap + @@ -127,6 +128,17 @@ public sealed class UiChatScrollbar : UiElement ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, 1f, 1f, Vector4.One); } + /// Draw a sprite 1:1 but vertically FLIPPED (V0/V1 swapped) — used to point + /// the top scroll button's (down-art) arrow upward. + private void DrawSpriteFlipV(UiRenderContext ctx, Func resolve, + uint id, float x, float y, float w, float h) + { + if (id == 0 || w <= 0f || h <= 0f) return; + var (tex, _, _) = resolve(id); + if (tex == 0) return; + ctx.DrawSprite(tex, x, y, w, h, 0f, 1f, 1f, 0f, Vector4.One); + } + /// Draw a sprite TILED to fill the dest rect (UV-repeat at native size on /// both axes — the UI texture is GL_REPEAT-wrapped). A native-width axis gives 1:1. private void DrawTiled(UiRenderContext ctx, Func resolve, diff --git a/tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs b/tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs index 59fe18f9..b3e9db8e 100644 --- a/tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs +++ b/tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs @@ -6,10 +6,13 @@ namespace AcDream.App.Tests.UI; public class UiChannelMenuTests { + // PopupH = Rows(7) * ItemH(17) = 119; popup opens upward so top = -119. + // Item idx -> col = idx/7, row = idx%7; row band y in [top+row*17, top+(row+1)*17). + // Right column needs lx >= ColW(191). + [Fact] public void Items_HasExpected14Entries() { - // Retail gmMainChatUI::InitTalkFocusMenu: squelch + tell-selected + 12 channels. Assert.Equal(14, UiChannelMenu.Items.Length); } @@ -17,7 +20,7 @@ public class UiChannelMenuTests public void Items_FirstEntry_IsSquelch_Special() { Assert.Equal("Squelch (ignore)", UiChannelMenu.Items[0].Label); - Assert.Null(UiChannelMenu.Items[0].Channel); // special item, no channel + Assert.Null(UiChannelMenu.Items[0].Channel); } [Fact] @@ -46,30 +49,11 @@ public class UiChannelMenuTests [Fact] public void DefaultSelected_IsSay() { - var menu = new UiChannelMenu(); - Assert.Equal(ChatChannelKind.Say, menu.Selected); + Assert.Equal(ChatChannelKind.Say, new UiChannelMenu().Selected); } [Fact] - public void Select_LeftColumnItem_FiresChannel() - { - var menu = new UiChannelMenu { Width = 80f, Height = 18f }; - var openEvt = new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5); // open - Assert.True(menu.OnEvent(openEvt)); - - ChatChannelKind? fired = null; - menu.OnChannelChanged = k => fired = k; - - // PopupH = 7*16 = 112, top = -112. "Chat to All" (Say) is index 2 = left col, row 2: - // y in [-112+32, -112+48) = [-80,-64). Click (lx=10 < ColW, ly=-72) → idx 2 → Say. - var selEvt = new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -72); - Assert.True(menu.OnEvent(selEvt)); - Assert.Equal(ChatChannelKind.Say, fired); - Assert.Equal(ChatChannelKind.Say, menu.Selected); - } - - [Fact] - public void Select_RightColumnItem_FiresChannel() + public void Select_AvailableLeftColumnItem_FiresChannel() { var menu = new UiChannelMenu { Width = 80f, Height = 18f }; Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open @@ -77,11 +61,25 @@ public class UiChannelMenuTests ChatChannelKind? fired = null; menu.OnChannelChanged = k => fired = k; - // "Tell to Monarch" is index 7 = right col (lx >= ColW 150), row 0: - // y in [-112, -96). Click (lx=160, ly=-104) → col 1, row 0 → idx 7 → Monarch. - Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 160, -104))); - Assert.Equal(ChatChannelKind.Monarch, fired); - Assert.Equal(ChatChannelKind.Monarch, menu.Selected); + // "Chat to All" (Say) is index 2 = left col, row 2: y in [-85,-68). Say is available. + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -76))); + Assert.Equal(ChatChannelKind.Say, fired); + Assert.Equal(ChatChannelKind.Say, menu.Selected); + } + + [Fact] + public void Select_AvailableRightColumnItem_FiresChannel() + { + var menu = new UiChannelMenu { Width = 80f, Height = 18f }; + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open + + ChatChannelKind? fired = null; + menu.OnChannelChanged = k => fired = k; + + // "Tell to Trade Chat" (Trade) is index 11 = right col (lx>=191), row 4: y in [-51,-34). + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 200, -42))); + Assert.Equal(ChatChannelKind.Trade, fired); + Assert.Equal(ChatChannelKind.Trade, menu.Selected); } [Fact] @@ -89,12 +87,39 @@ public class UiChannelMenuTests { var menu = new UiChannelMenu { Width = 80f, Height = 18f }; Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open - int fired = 0; menu.OnChannelChanged = _ => fired++; - // "Squelch (ignore)" is index 0 = left col, row 0: y in [-112, -96). No channel. - Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -104))); - Assert.Equal(0, fired); // special item is a no-op + // "Squelch (ignore)" is index 0 = left col, row 0 (null channel): y in [-119,-102). + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -110))); + Assert.Equal(0, fired); + } + + [Fact] + public void Select_UnavailableChannel_DoesNotFire() + { + var menu = new UiChannelMenu { Width = 80f, Height = 18f }; + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open + int fired = 0; + menu.OnChannelChanged = _ => fired++; + + // "Tell to Fellows" (Fellowship) is index 3 = left col, row 3: y in [-68,-51). + // Fellowship is unavailable by the default static gate, so the click is inert. + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -60))); + Assert.Equal(0, fired); + } + + [Fact] + public void AvailabilityProvider_Overrides_DefaultGate() + { + var menu = new UiChannelMenu { Width = 80f, Height = 18f, AvailabilityProvider = _ => true }; + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open + + ChatChannelKind? fired = null; + menu.OnChannelChanged = k => fired = k; + + // With every channel available, "Tell to Fellows" (idx 3, row 3) now fires. + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -60))); + Assert.Equal(ChatChannelKind.Fellowship, fired); } } From 621a4ab4682c72bb0df05dfb0697c081b673f097 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 11:56:07 +0200 Subject: [PATCH 75/99] @ fix(D.2b): arrow swap, centered menu text, scrollbar-to-top, Send caption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scroll arrows: native sprites are opposite (0x06004C6C up / 0x06004C69 down) per live visual — swap the assignment, drop the V-flip. - menu labels centered vertically in each 17px row (was top-aligned, looked corrupt). - scrollbar pulled up to the panel top so the top arrow meets the window border and the max/min button lines up with it (the 6px dat offset left a gap after the resize-bar reclaim). - Send button: the dat sprite 0x06001915 is a blank gold frame (export-confirmed), so add a generic optional Label/LabelFont to UiDatElement and draw "Send" centered on it. Co-Authored-By: Claude Opus 4.8 (1M context) @ --- .../UI/Layout/ChatWindowController.cs | 15 +++++-- src/AcDream.App/UI/Layout/UiDatElement.cs | 39 +++++++++++++------ src/AcDream.App/UI/UiChannelMenu.cs | 3 +- src/AcDream.App/UI/UiChatScrollbar.cs | 7 ++-- 4 files changed, 43 insertions(+), 21 deletions(-) diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index e02efb56..d3b33019 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -46,8 +46,8 @@ public sealed class ChatWindowController private const uint ThumbSprite = 0x06004C63u; // 3-slice middle tile private const uint ThumbTopSprite = 0x06004C60u; // 3-slice top cap private const uint ThumbBotSprite = 0x06004C66u; // 3-slice bottom cap - private const uint UpSprite = 0x06004C69u; - private const uint DownSprite = 0x06004C6Cu; + private const uint UpSprite = 0x06004C6Cu; // up arrow (top button) + private const uint DownSprite = 0x06004C69u; // down arrow (bottom button) // Channel menu sprite ids (confirmed in chat element dump). private const uint MenuNormal = 0x06004D65u; // button face @@ -199,10 +199,13 @@ public sealed class ChatWindowController { c.Scrollbar = new UiChatScrollbar { + // Pull the bar up to the panel top so the top arrow meets the window + // border (and lines up with the max/min button at root y=0); the dat + // track sits 6px down, which left a gap after the resize-bar reclaim. Left = track.Left, - Top = track.Top, + Top = 0f, Width = track.Width, - Height = track.Height, + Height = track.Height + track.Top, Anchors = track.Anchors, Model = c.Transcript.Scroll, SpriteResolve = resolve, @@ -248,6 +251,10 @@ public sealed class ChatWindowController { sendEl.ClickThrough = false; sendEl.OnClick = () => c.Input.Submit(); + // The Send sprite is a blank gold button — retail draws the caption as text. + sendEl.Label = "Send"; + sendEl.LabelFont = datFont; + sendEl.LabelColor = new Vector4(1f, 0.92f, 0.72f, 1f); } // ── Max/min toggle — simplified gmMainChatUI::HandleMaximizeButton ── diff --git a/src/AcDream.App/UI/Layout/UiDatElement.cs b/src/AcDream.App/UI/Layout/UiDatElement.cs index 43cc4032..5f6ea79c 100644 --- a/src/AcDream.App/UI/Layout/UiDatElement.cs +++ b/src/AcDream.App/UI/Layout/UiDatElement.cs @@ -87,21 +87,36 @@ public sealed class UiDatElement : UiElement return false; } + /// Optional centered text label drawn over the sprite (e.g. the "Send" + /// button face whose dat sprite is a blank frame). Null = sprite only. + public string? Label { get; set; } + /// Dat font for . Required for the label to draw. + public UiDatFont? LabelFont { get; set; } + /// Label color (default white). + public Vector4 LabelColor { get; set; } = Vector4.One; + protected override void OnDraw(UiRenderContext ctx) { var (file, _) = ActiveMedia(); - if (file == 0) return; + if (file != 0) + { + var (tex, tw, th) = _resolve(file); + if (tex != 0 && tw != 0 && th != 0) + { + // Normal → TILE at native size on both axes (UV-repeat; GL_REPEAT-wrapped UI + // texture), matching ImgTex::TileCSI. Overlay/Alphablend use the same blit (the + // sprite shader already alpha-blends). No Stretch mode exists in DrawModeType. + ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One); + } + } - var (tex, tw, th) = _resolve(file); - if (tex == 0 || tw == 0 || th == 0) return; - - // Normal → TILE at native size on both axes (UV-repeat; GL_REPEAT-wrapped UI texture), - // matching ImgTex::TileCSI. Overlay/Alphablend are the same blit with a blend state; the - // sprite shader already alpha-blends, so the quad is identical for all draw modes in Plan 1. - // (No Stretch mode exists in DatReaderWriter.Enums.DrawModeType.) - // DrawMode is not yet branched here — Plan 2 can add per-mode behavior if needed. - float u1 = Width / tw; - float v1 = Height / th; - ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, u1, v1, Vector4.One); + // Centered text label over the sprite (retail draws button captions as text; + // their dat sprites are blank frames). + if (Label is { Length: > 0 } label && LabelFont is { } lf) + { + float tx = (Width - lf.MeasureWidth(label)) * 0.5f; + float ty = (Height - lf.LineHeight) * 0.5f; + ctx.DrawStringDat(lf, label, tx, ty, LabelColor); + } } } diff --git a/src/AcDream.App/UI/UiChannelMenu.cs b/src/AcDream.App/UI/UiChannelMenu.cs index 01d1f735..b9e01f62 100644 --- a/src/AcDream.App/UI/UiChannelMenu.cs +++ b/src/AcDream.App/UI/UiChannelMenu.cs @@ -134,11 +134,12 @@ public sealed class UiChannelMenu : UiElement bool selected = Items[i].Channel is { } c && c == Selected; DrawSprite(ctx, resolve, selected ? ItemHighlightSprite : ItemNormalSprite, x, y, ColW, ItemH); } + float textY = (ItemH - LineH()) * 0.5f; // center the label in its row for (int i = 0; i < Items.Length; i++) { int col = i / Rows, row = i % Rows; bool avail = Items[i].Channel is { } c && IsAvailable(c); - DrawLabel(ctx, Items[i].Label, 4f + col * ColW, top + row * ItemH, + DrawLabel(ctx, Items[i].Label, 4f + col * ColW, top + row * ItemH + textY, avail ? TextColorAvailable : TextColorGhosted); } } diff --git a/src/AcDream.App/UI/UiChatScrollbar.cs b/src/AcDream.App/UI/UiChatScrollbar.cs index 6d163ef3..debea724 100644 --- a/src/AcDream.App/UI/UiChatScrollbar.cs +++ b/src/AcDream.App/UI/UiChatScrollbar.cs @@ -89,11 +89,10 @@ public sealed class UiChatScrollbar : UiElement // sprite (~16×32) repeats to fill the element height instead of stretch-distorting. DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height); - // Up button — top ButtonH rows. The dat up/down arrow sprites both point DOWN - // (confirmed by sprite export), so the TOP button is drawn V-FLIPPED to point UP. - DrawSpriteFlipV(ctx, resolve, UpSprite, 0f, 0f, Width, ButtonH); + // Up button — top ButtonH rows. UpSprite (0x06004C6C) is the up-arrow art, drawn 1:1. + DrawSprite(ctx, resolve, UpSprite, 0f, 0f, Width, ButtonH); - // Down button — bottom ButtonH rows (down arrow as-is). + // Down button — bottom ButtonH rows. DownSprite (0x06004C69) is the down-arrow art. DrawSprite(ctx, resolve, DownSprite, 0f, Height - ButtonH, Width, ButtonH); // Thumb — only when content overflows the view. Retail 3-slice: top cap + From 828bec5fb56721945d287c6ee4d8bfb0c69b0cac Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 12:02:07 +0200 Subject: [PATCH 76/99] @ fix(D.2b): point-sample the dat-font atlas so UI text is pixel-crisp The font glyph atlas was uploaded with bilinear (Linear) min/mag filtering, which softens the small dat-font glyphs (the menu/button text "blur"). Add a nearest-filter path to UploadRgba8/GetOrUploadRenderSurface and use it for the font atlases only (world + other UI surfaces keep bilinear). Combined with the existing per-glyph pixel-snap, glyph texels now map 1:1 to screen pixels. Sharpens all dat-font text (transcript, menu, Send/Chat buttons, vitals numbers). Co-Authored-By: Claude Opus 4.8 (1M context) @ --- src/AcDream.App/Rendering/TextureCache.cs | 13 ++++++++----- src/AcDream.App/UI/UiDatFont.cs | 6 ++++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs index 1fbf0817..7d1c0b25 100644 --- a/src/AcDream.App/Rendering/TextureCache.cs +++ b/src/AcDream.App/Rendering/TextureCache.cs @@ -119,7 +119,7 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab /// Magenta — wire a UI palette when one is actually encountered). Returns a /// 1x1 magenta handle on miss. /// - public uint GetOrUploadRenderSurface(uint renderSurfaceId, out int width, out int height) + public uint GetOrUploadRenderSurface(uint renderSurfaceId, out int width, out int height, bool nearest = false) { if (_handlesByRenderSurfaceId.TryGetValue(renderSurfaceId, out var existing) && _rsSizeById.TryGetValue(renderSurfaceId, out var sz)) @@ -139,7 +139,7 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab decoded = DecodedTexture.Magenta; } - uint h = UploadRgba8(decoded); + uint h = UploadRgba8(decoded, nearest); _handlesByRenderSurfaceId[renderSurfaceId] = h; _rsSizeById[renderSurfaceId] = (decoded.Width, decoded.Height); width = decoded.Width; height = decoded.Height; @@ -542,7 +542,7 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab return composed; } - private uint UploadRgba8(DecodedTexture decoded) + private uint UploadRgba8(DecodedTexture decoded, bool nearest = false) { uint tex = _gl.GenTexture(); _gl.BindTexture(TextureTarget.Texture2D, tex); @@ -559,8 +559,11 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab PixelType.UnsignedByte, p); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear); + // Point (nearest) sampling for pixel-exact UI text — bilinear softens the dat + // font's small glyphs. Other surfaces use bilinear. + int filter = nearest ? (int)TextureMinFilter.Nearest : (int)TextureMinFilter.Linear; + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, filter); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, filter); _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat); _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat); diff --git a/src/AcDream.App/UI/UiDatFont.cs b/src/AcDream.App/UI/UiDatFont.cs index c08e20de..400ccf0f 100644 --- a/src/AcDream.App/UI/UiDatFont.cs +++ b/src/AcDream.App/UI/UiDatFont.cs @@ -101,11 +101,13 @@ public sealed class UiDatFont if (font.ForegroundSurfaceDataId == 0) return null; - uint fgTex = cache.GetOrUploadRenderSurface(font.ForegroundSurfaceDataId, out int fgW, out int fgH); + // Point-sample the glyph atlases (nearest) so small UI text stays pixel-crisp; + // bilinear softens the dat font noticeably (the chat menu/button text "blur"). + uint fgTex = cache.GetOrUploadRenderSurface(font.ForegroundSurfaceDataId, out int fgW, out int fgH, nearest: true); uint bgTex = 0; int bgW = 0, bgH = 0; if (font.BackgroundSurfaceDataId != 0) - bgTex = cache.GetOrUploadRenderSurface(font.BackgroundSurfaceDataId, out bgW, out bgH); + bgTex = cache.GetOrUploadRenderSurface(font.BackgroundSurfaceDataId, out bgW, out bgH, nearest: true); // Build the char->descriptor lookup. FontCharDesc.Unicode is the code // point; for Latin-1 fonts this is a direct char cast. Last write wins From ebfeaff840d3d017c84662c491c2eaaad70d71b7 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 15:23:48 +0200 Subject: [PATCH 77/99] =?UTF-8?q?feat(D.2b):=20UI=20render=20infra=20?= =?UTF-8?q?=E2=80=94=20overlay=20layer,=20DrawFill,=20crisp=20text,=20writ?= =?UTF-8?q?e-mode=20focus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retail-look render + focus primitives this chat pass builds on: - TextRenderer: an OVERLAY layer (sprite/rect/text buckets flushed AFTER the normal layer) so an open popup composites on top of everything incl. rect panel backgrounds; a DrawFill primitive (solid quad via a 1x1 white texture) routed through the SPRITE bucket so a panel background draws UNDER its text instead of being washed by the later rect bucket; and the text pass now disables SampleAlphaToCoverage + Multisample so glyph alpha edges aren't dithered into MSAA coverage (the "fuzzy text") — self-contained GL state per feedback_render_self_contained_gl_state. - UiRenderContext.DrawStringDat: snap the line baseline to a whole pixel ONCE then add the integer per-glyph offset (retail DrawCharacter takes an int pen-Y + schar m_VerticalOffsetBefore) — fixes the "letters dip down" jitter at a fractional line origin. Outline pass is now opt-in (retail gates it per element via SetOutline; default off = crisp fill-only). Adds DrawFill + Begin/EndOverlayLayer. - UiElement: OnDrawOverlay + DrawOverlays (second traversal), FindRoot (blur self), ResetAnchorCapture (re-baseline an anchored element after reflow). - UiRoot: runs the overlay pass after the main tree; Tab/Enter focuses the DefaultTextInput (write-mode activation); a left click on a non-edit target blurs the focused input (exit write mode without submitting). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/TextRenderer.cs | 167 +++++++++++++++------- src/AcDream.App/UI/UiElement.cs | 54 +++++++ src/AcDream.App/UI/UiRenderContext.cs | 57 ++++++-- src/AcDream.App/UI/UiRoot.cs | 33 ++++- 4 files changed, 248 insertions(+), 63 deletions(-) diff --git a/src/AcDream.App/Rendering/TextRenderer.cs b/src/AcDream.App/Rendering/TextRenderer.cs index bef2e2ca..88592057 100644 --- a/src/AcDream.App/Rendering/TextRenderer.cs +++ b/src/AcDream.App/Rendering/TextRenderer.cs @@ -25,6 +25,7 @@ public sealed unsafe class TextRenderer : IDisposable private readonly Shader _shader; private readonly uint _vao; private readonly uint _vbo; + private readonly uint _whiteTex; // 1×1 white, for solid fills routed through the sprite bucket private int _vboCapacityBytes; private readonly List _textBuf = new(8192); @@ -42,6 +43,21 @@ public sealed unsafe class TextRenderer : IDisposable private int _rectVerts; private Vector2 _screenSize; + // Overlay layer — a parallel set of buckets drawn AFTER the normal sprite/rect/text + // buckets, so open popups/menus composite on top of EVERYTHING, including translucent + // rect panel backgrounds (which otherwise always win because rects flush after + // sprites). Routed by OverlayMode; the UI root sets it for the popup traversal. + private readonly List _overlayTextBuf = new(1024); + private readonly List _overlayRectBuf = new(256); + private readonly List _overlaySpriteSegs = new(); + private int _overlaySegUsed; + private int _overlayTextVerts; + private int _overlayRectVerts; + + /// When true, Draw* calls route to the overlay layer (flushed last, on top + /// of all normal-layer geometry). Set by the UI root around the popup/overlay pass. + public bool OverlayMode { get; set; } + public TextRenderer(GL gl, string shaderDir) { _gl = gl; @@ -65,6 +81,20 @@ public sealed unsafe class TextRenderer : IDisposable _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); _gl.BindVertexArray(0); + + // 1×1 white texture so DrawFill can route solid-colour quads through the SPRITE + // bucket (the shader multiplies texel×color → white×color = color). Lets a panel + // background draw UNDER its text in painter order, which DrawRect's separate + // bucket cannot (it always composites after all sprites). + _whiteTex = _gl.GenTexture(); + _gl.BindTexture(TextureTarget.Texture2D, _whiteTex); + Span whitePixel = stackalloc byte[] { 255, 255, 255, 255 }; + fixed (byte* wp = whitePixel) + _gl.TexImage2D(TextureTarget.Texture2D, 0, (int)InternalFormat.Rgba8, 1, 1, 0, + PixelFormat.Rgba, PixelType.UnsignedByte, wp); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Nearest); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMinFilter.Nearest); + _gl.BindTexture(TextureTarget.Texture2D, 0); } /// Begin a HUD pass. Call once per frame before any Draw* calls. @@ -76,15 +106,29 @@ public sealed unsafe class TextRenderer : IDisposable _segUsed = 0; // pool the SpriteSeg objects across frames _textVerts = 0; _rectVerts = 0; + _overlayTextBuf.Clear(); + _overlayRectBuf.Clear(); + _overlaySegUsed = 0; + _overlayTextVerts = 0; + _overlayRectVerts = 0; + OverlayMode = false; } /// Draw a filled rectangle in screen pixel space. public void DrawRect(float x, float y, float w, float h, Vector4 color) { - AppendQuad(_rectBuf, x, y, w, h, 0, 0, 0, 0, color); - _rectVerts += 6; + if (OverlayMode) { AppendQuad(_overlayRectBuf, x, y, w, h, 0, 0, 0, 0, color); _overlayRectVerts += 6; } + else { AppendQuad(_rectBuf, x, y, w, h, 0, 0, 0, 0, color); _rectVerts += 6; } } + /// Draw a solid-colour quad through the SPRITE bucket (and the overlay layer + /// when active), so it composites in painter order with sprites + dat-font text. Use + /// this — not — for a panel BACKGROUND that text draws on top of: + /// DrawRect's bucket always flushes after all sprites, so a rect background would cover + /// the text instead. + public void DrawFill(float x, float y, float w, float h, Vector4 color) + => DrawSprite(_whiteTex, x, y, w, h, 0f, 0f, 1f, 1f, color); + /// Draw a 1-pixel-thick outline rect. public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f) { @@ -129,11 +173,8 @@ public sealed unsafe class TextRenderer : IDisposable if (gw > 0 && gh > 0) { - AppendQuad(_textBuf, - gx, gy, gw, gh, - g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY, - color); - _textVerts += 6; + if (OverlayMode) { AppendQuad(_overlayTextBuf, gx, gy, gw, gh, g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY, color); _overlayTextVerts += 6; } + else { AppendQuad(_textBuf, gx, gy, gw, gh, g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY, color); _textVerts += 6; } } cursorX += g.Advance; } @@ -147,26 +188,32 @@ public sealed unsafe class TextRenderer : IDisposable public void DrawSprite(uint texture, float x, float y, float w, float h, float u0, float v0, float u1, float v1, Vector4 tint) { - SpriteSeg seg; - if (_segUsed > 0 && _spriteSegs[_segUsed - 1].Texture == texture) - { - seg = _spriteSegs[_segUsed - 1]; // extend the current same-texture run - } - else if (_segUsed < _spriteSegs.Count) - { - seg = _spriteSegs[_segUsed++]; // reuse a pooled segment - seg.Texture = texture; - seg.Verts.Clear(); - } - else - { - seg = new SpriteSeg { Texture = texture }; - _spriteSegs.Add(seg); - _segUsed++; - } + SpriteSeg seg = OverlayMode + ? NextSpriteSeg(_overlaySpriteSegs, ref _overlaySegUsed, texture) + : NextSpriteSeg(_spriteSegs, ref _segUsed, texture); AppendQuad(seg.Verts, x, y, w, h, u0, v0, u1, v1, tint); } + /// Pick the sprite segment for : extend the current + /// same-texture run, else reuse a pooled segment, else allocate. Submission order is + /// preserved (painter z-order for sprite-on-sprite UI). + private static SpriteSeg NextSpriteSeg(List segs, ref int used, uint texture) + { + if (used > 0 && segs[used - 1].Texture == texture) + return segs[used - 1]; + if (used < segs.Count) + { + var s = segs[used++]; + s.Texture = texture; + s.Verts.Clear(); + return s; + } + var ns = new SpriteSeg { Texture = texture }; + segs.Add(ns); + used++; + return ns; + } + private static void AppendQuad(List buf, float x, float y, float w, float h, float u0, float v0, float u1, float v1, Vector4 color) @@ -197,8 +244,9 @@ public sealed unsafe class TextRenderer : IDisposable /// Upload + draw accumulated rects + text. font may be null if only DrawRect was used. public void Flush(BitmapFont? font) { - bool hasSprites = _segUsed > 0; - if (_textVerts == 0 && _rectVerts == 0 && !hasSprites) return; + bool anyNormal = _segUsed > 0 || _textVerts > 0 || _rectVerts > 0; + bool anyOverlay = _overlaySegUsed > 0 || _overlayTextVerts > 0 || _overlayRectVerts > 0; + if (!anyNormal && !anyOverlay) return; _shader.Use(); _shader.SetVec2("uScreenSize", _screenSize); @@ -210,6 +258,15 @@ public sealed unsafe class TextRenderer : IDisposable bool wasDepth = _gl.IsEnabled(EnableCap.DepthTest); bool wasBlend = _gl.IsEnabled(EnableCap.Blend); bool wasCull = _gl.IsEnabled(EnableCap.CullFace); + // The world pass leaves alpha-to-coverage + multisample enabled (WbDrawDispatcher, + // QualitySettings MSAA). If they bleed into the UI pass, each glyph's soft alpha + // EDGE is converted to dithered MSAA coverage instead of a clean alpha blend — + // the "text not sharp / fuzzy" artifact. The UI composites with straight alpha + // blending and must own this state (feedback_render_self_contained_gl_state). + bool wasA2C = _gl.IsEnabled(EnableCap.SampleAlphaToCoverage); + bool wasMsaa = _gl.IsEnabled(EnableCap.Multisample); + _gl.Disable(EnableCap.SampleAlphaToCoverage); + _gl.Disable(EnableCap.Multisample); _gl.Disable(EnableCap.DepthTest); _gl.Disable(EnableCap.CullFace); _gl.DepthMask(false); @@ -221,19 +278,40 @@ public sealed unsafe class TextRenderer : IDisposable // 2. Untextured rects — widget fills (e.g. vital bars) on the chrome // 3. Text glyphs — on top // Bucket 1 (sprites) draws in SUBMISSION (painter) order via _spriteSegs, - // so sprite-on-sprite z is preserved — each meter's dat-font number draws - // after its own bar sprites. Buckets 2 (rects) + 3 (debug text) composite - // on top, in that order. + // so sprite-on-sprite z is preserved. Buckets 2 (rects) + 3 (debug text) + // composite on top, in that order. The OVERLAY layer repeats all three + // AFTER the normal layer, so open popups beat even the rect backgrounds. + DrawLayer(_spriteSegs, _segUsed, _rectBuf, _rectVerts, _textBuf, _textVerts, font); + DrawLayer(_overlaySpriteSegs, _overlaySegUsed, _overlayRectBuf, _overlayRectVerts, _overlayTextBuf, _overlayTextVerts, font); - // 1. RGBA dat sprites first — one draw call per distinct GL texture. - if (hasSprites) + // Restore GL state. + _gl.DepthMask(true); + if (!wasBlend) _gl.Disable(EnableCap.Blend); + if (wasCull) _gl.Enable(EnableCap.CullFace); + if (wasDepth) _gl.Enable(EnableCap.DepthTest); + if (wasA2C) _gl.Enable(EnableCap.SampleAlphaToCoverage); + if (wasMsaa) _gl.Enable(EnableCap.Multisample); + + _gl.BindVertexArray(0); + } + + /// Draw one compositing layer: sprites (submission order, one call per + /// texture) → untextured rects → debug-font text. Shared by the normal and overlay + /// layers; GL state + shader are set up by . + private void DrawLayer( + List spriteSegs, int segUsed, + List rectBuf, int rectVerts, + List textBuf, int textVerts, BitmapFont? font) + { + // 1. RGBA dat sprites — one draw call per distinct GL texture. + if (segUsed > 0) { _shader.SetInt("uUseTexture", 2); _gl.ActiveTexture(TextureUnit.Texture0); _shader.SetInt("uTex", 0); - for (int i = 0; i < _segUsed; i++) + for (int i = 0; i < segUsed; i++) { - var seg = _spriteSegs[i]; + var seg = spriteSegs[i]; if (seg.Verts.Count == 0) continue; _gl.BindTexture(TextureTarget.Texture2D, seg.Texture); UploadBuffer(seg.Verts); @@ -242,31 +320,23 @@ public sealed unsafe class TextRenderer : IDisposable } // 2. Untextured rects — widget fills on top of the chrome. - if (_rectVerts > 0) + if (rectVerts > 0) { _shader.SetInt("uUseTexture", 0); - UploadBuffer(_rectBuf); - _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_rectVerts); + UploadBuffer(rectBuf); + _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)rectVerts); } - // 3. Textured text glyphs on top. - if (_textVerts > 0 && font is not null) + // 3. Textured debug-font text glyphs on top. + if (textVerts > 0 && font is not null) { _shader.SetInt("uUseTexture", 1); _gl.ActiveTexture(TextureUnit.Texture0); _gl.BindTexture(TextureTarget.Texture2D, font.TextureId); _shader.SetInt("uTex", 0); - UploadBuffer(_textBuf); - _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_textVerts); + UploadBuffer(textBuf); + _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)textVerts); } - - // Restore GL state. - _gl.DepthMask(true); - if (!wasBlend) _gl.Disable(EnableCap.Blend); - if (wasCull) _gl.Enable(EnableCap.CullFace); - if (wasDepth) _gl.Enable(EnableCap.DepthTest); - - _gl.BindVertexArray(0); } private void UploadBuffer(List buf) @@ -289,6 +359,7 @@ public sealed unsafe class TextRenderer : IDisposable public void Dispose() { + _gl.DeleteTexture(_whiteTex); _gl.DeleteBuffer(_vbo); _gl.DeleteVertexArray(_vao); _shader.Dispose(); diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index a1c5f4ab..a65a573b 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -154,6 +154,16 @@ public abstract class UiElement /// protected virtual void OnDraw(UiRenderContext ctx) { } + /// + /// Draw content that must sit ON TOP of the ENTIRE UI, regardless of this + /// element's position in the tree — open menus, dropdowns, tooltips. Called in + /// a SECOND traversal after the whole tree's pass, with the + /// same accumulated transform/alpha this element had during its normal draw. + /// Retail spawns popups as ROOT elements (UIElement_Menu::MakePopup) for exactly + /// this reason; this is the equivalent without reparenting. Default: nothing. + /// + protected virtual void OnDrawOverlay(UiRenderContext ctx) { } + /// Per-frame tick (animations, timers, caret blink). protected virtual void OnTick(double deltaSeconds) { } @@ -213,6 +223,34 @@ public abstract class UiElement } } + /// Second draw traversal: re-walks the tree applying the same + /// transform/alpha as and calls + /// on each element, so popups composite on top of + /// everything drawn in the main pass (dat-font glyphs and sprites share one + /// submission-ordered bucket, so later submissions win). + internal void DrawOverlays(UiRenderContext ctx) + { + if (!Visible) return; + ctx.PushTransform(Left, Top); + ctx.PushAlpha(Opacity); + try + { + OnDrawOverlay(ctx); + if (_children.Count > 0) + { + var ordered = _children.ToArray(); + Array.Sort(ordered, static (a, b) => a.ZOrder.CompareTo(b.ZOrder)); + for (int i = 0; i < ordered.Length; i++) + ordered[i].DrawOverlays(ctx); + } + } + finally + { + ctx.PopAlpha(); + ctx.PopTransform(); + } + } + internal void TickSelfAndChildren(double dt) { if (!Visible) return; @@ -275,6 +313,22 @@ public abstract class UiElement Left = x; Top = y; Width = w; Height = h; } + /// Forget the captured anchor margins so the next + /// re-captures them from the CURRENT rect. Call after manually repositioning/resizing + /// an anchored element at runtime (e.g. reflowing the chat input when the channel + /// button width changes) so the new rect becomes the anchor baseline. + internal void ResetAnchorCapture() => _anchorCaptured = false; + + /// Walk up to the owning (the top of the tree), or null + /// if this element is not attached. Lets a widget reach focus/capture services — e.g. + /// a chat input blurring itself (exiting write mode) after submit. + internal UiRoot? FindRoot() + { + UiElement e = this; + while (e.Parent is not null) e = e.Parent; + return e as UiRoot; + } + /// Compute an anchored child rect. Left&Right ⇒ stretch width /// (keep both margins); Right only ⇒ pin to right at fixed width; otherwise /// pin left at fixed width. Same logic vertically. diff --git a/src/AcDream.App/UI/UiRenderContext.cs b/src/AcDream.App/UI/UiRenderContext.cs index 5b97492e..ebf6fc69 100644 --- a/src/AcDream.App/UI/UiRenderContext.cs +++ b/src/AcDream.App/UI/UiRenderContext.cs @@ -68,11 +68,23 @@ public sealed class UiRenderContext public Vector2 CurrentOrigin => _current; + /// Route subsequent draws to the overlay layer (flushed on top of the whole + /// UI). Used by the root for the popup/overlay traversal. Pair with . + public void BeginOverlayLayer() => TextRenderer.OverlayMode = true; + public void EndOverlayLayer() => TextRenderer.OverlayMode = false; + // ── Pass-through draw helpers (add current translate) ────────────── public void DrawRect(float x, float y, float w, float h, Vector4 color) => TextRenderer.DrawRect(_current.X + x, _current.Y + y, w, h, ApplyAlpha(color)); + /// Solid-colour fill drawn in the SPRITE bucket (painter order with text), for + /// a panel BACKGROUND that text draws on top of. composites after + /// all sprites and would cover the text — use this for backgrounds, that for foreground + /// fills (carets, vital bars). + public void DrawFill(float x, float y, float w, float h, Vector4 color) + => TextRenderer.DrawFill(_current.X + x, _current.Y + y, w, h, ApplyAlpha(color)); + public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f) => TextRenderer.DrawRectOutline(_current.X + x, _current.Y + y, w, h, ApplyAlpha(color), thickness); @@ -102,10 +114,17 @@ public sealed class UiRenderContext /// HorizontalOffsetBefore + Width + HorizontalOffsetAfter and each /// glyph is positioned at pen + HorizontalOffsetBefore on the X axis /// and at baseline + VerticalOffsetBefore - (BaselineOffset) via the - /// glyph's OffsetY into the atlas. If the font has no background atlas the - /// outline pass is skipped. + /// glyph's OffsetY into the atlas. + /// + /// gates the black outline pass. Retail decides + /// this PER text element: UIElement_Text::DrawSelf (acclient 0x00467aa0) + /// runs the outline pass only when m_bitField & 0x10 is set — i.e. the + /// element called SetOutline(true) (LayoutDesc property 0xd). The DEFAULT + /// is OFF (one fill-only pass): the talk-focus menu items set no outline, so an + /// always-on outline shows as a grey halo over the solid menu panel. Pass + /// outline:true only for elements retail outlines. /// - public void DrawStringDat(UiDatFont font, string text, float x, float y, Vector4 color) + public void DrawStringDat(UiDatFont font, string text, float x, float y, Vector4 color, bool outline = false) { if (font is null || string.IsNullOrEmpty(text)) return; @@ -116,32 +135,44 @@ public sealed class UiRenderContext float originY = _current.Y + y; float pen = originX; - var outline = new Vector4(0f, 0f, 0f, color.W); + // Snap the LINE baseline to a whole pixel ONCE. Retail's + // SurfaceWindow::DrawCharacter (acclient 0x00442bd0) takes an int32 pen Y + // (arg3) and adds the glyph's integer m_VerticalOffsetBefore (a schar) — every + // glyph on a line shares one integer baseline. If we instead round EACH glyph's + // Y independently and the caller passes a fractional line Y (e.g. a channel-menu + // item centered in a 17px row over a 16px font → y = 0.5), adjacent letters round + // to different rows and the line looks crooked ("letters dip down"). The vitals + // digits never showed it because their bar baseline lands on an integer; chat text + // does. Snapping the baseline once, then adding the integer offset, keeps the whole + // line on one row and pixel-aligned. + float baseY = System.MathF.Round(originY); + + var outlineTint = new Vector4(0f, 0f, 0f, color.W); for (int i = 0; i < text.Length; i++) { if (!font.TryGetGlyph(text[i], out var g)) continue; - // Pixel-snap each glyph's destination to whole pixels so the atlas samples - // texel-aligned. Without this, a fractional bar width after resize puts the - // centered number on a sub-pixel x and linear filtering smears the glyphs - // (the "unsharp at certain sizes" artifact). The pen keeps its true - // fractional advance, so only the per-glyph dest is snapped. + // Horizontal: snap each glyph's dest X to a whole pixel (the pen keeps its + // true fractional advance). Vertical: integer baseline + integer per-glyph + // offset — never an independent per-glyph round (see baseY note above). float gx = System.MathF.Round(pen + g.HorizontalOffsetBefore); - float gy = System.MathF.Round(originY + g.VerticalOffsetBefore); + float gy = baseY + g.VerticalOffsetBefore; float gw = g.Width; float gh = g.Height; if (gw > 0f && gh > 0f) { - // Background (outline) atlas pass, tinted black — drawn behind. - if (font.BackgroundTexture != 0) + // Background (outline) atlas pass, tinted black — drawn behind. Gated by + // `outline` (retail's per-element m_bitField & 0x10); off by default so UI + // text is crisp fill-only and free of the grey halo over solid panels. + if (outline && font.BackgroundTexture != 0) { var (bu0, bv0, bu1, bv1) = AtlasUv( g.OffsetX, g.OffsetY, g.Width, g.Height, font.BackgroundWidth, font.BackgroundHeight); - TextRenderer.DrawSprite(font.BackgroundTexture, gx, gy, gw, gh, bu0, bv0, bu1, bv1, outline); + TextRenderer.DrawSprite(font.BackgroundTexture, gx, gy, gw, gh, bu0, bv0, bu1, bv1, outlineTint); } // Foreground (fill) atlas pass, tinted with the requested color. diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index e57d02e3..91fd219d 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -44,6 +44,10 @@ public sealed class UiRoot : UiElement /// Widget currently receiving keyboard events. public UiElement? KeyboardFocus { get; private set; } + /// The edit control activated by Tab/Enter when nothing is focused — retail's + /// chat input "write mode" toggle. Set by the host once the chat window is built. + public UiElement? DefaultTextInput { get; set; } + /// /// Single modal overlay; while set, mouse clicks outside its rect /// are ignored. Retail sets this via Device vtable +0x48. @@ -131,6 +135,13 @@ public sealed class UiRoot : UiElement // Render children (panels) sorted by z-order — modal last so it // sits on top. DrawSelfAndChildren(ctx); + // Second pass: open popups/menus draw ON TOP of the whole tree (so e.g. the + // chat channel menu isn't greyed by the translucent chat panel that draws + // after it in the main pass). Routed to the renderer's overlay layer so it + // beats even rect backgrounds. Faithful to retail's root-level MakePopup. + ctx.BeginOverlayLayer(); + DrawOverlays(ctx); + ctx.EndOverlayLayer(); } // ── Input entry points (called from GameWindow's Silk.NET handlers) ── @@ -200,12 +211,18 @@ public sealed class UiRoot : UiElement var (target, _, _) = HitTestTopDown(x, y); if (target is null) { + // Clicking the 3D world exits write mode (no submit) and returns control to + // the character — retail blurs the chat input on an outside click. + if (btn == UiMouseButton.Left) SetKeyboardFocus(null); WorldMouseFallThrough?.Invoke(btn, x, y, flags); return; } - // Set keyboard focus if target accepts it. - if (target.AcceptsFocus) SetKeyboardFocus(target); + // Keyboard focus follows a left click: the input bar (an edit control) takes + // focus = enters write mode; clicking anything else (chrome, Send, scrollbar, + // menu, another window) blurs the input = exits write mode WITHOUT submitting. + if (btn == UiMouseButton.Left) + SetKeyboardFocus(target.AcceptsFocus ? target : null); SetCapture(target); @@ -355,6 +372,18 @@ public sealed class UiRoot : UiElement public void OnKeyDown(int vk, uint lparam = 0) { + // Nothing focused yet: Tab or Enter enters "write mode" by focusing the chat + // input (retail's chat-activation hotkeys). Consumed so the same press doesn't + // also fall through to a game hotkey. + if (KeyboardFocus is null && DefaultTextInput is not null + && (vk == (int)Silk.NET.Input.Key.Tab + || vk == (int)Silk.NET.Input.Key.Enter + || vk == (int)Silk.NET.Input.Key.KeypadEnter)) + { + SetKeyboardFocus(DefaultTextInput); + return; + } + // Focus widget first. if (KeyboardFocus is not null) { From 260507e33c1b53a10aec9e6b7ed32d791c809331 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 15:23:59 +0200 Subject: [PATCH 78/99] =?UTF-8?q?feat(D.2b):=20channel=20menu=20=E2=80=94?= =?UTF-8?q?=20retail=20colors,=208-piece=20border,=20checkbox=20align,=20a?= =?UTF-8?q?utosize=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the talk-focus menu + button to retail (decomp-verified): - Menu item text is FILL-ONLY (retail UIElement_Text outlines only when SetOutline(true); the talk-focus items don't) — kills the grey halo. Available items render white; UNAVAILABLE items render grey (not the salmon colorPink, which is a chat-MESSAGE color we'd misapplied). Special items (Squelch / Tell-to-Selected) render white. Labels indent past the baked checkbox in the row sprite (0600124E empty box / 0600124D white checkmark) instead of overlapping it. - The popup is wrapped in the universal 8-piece window bevel (the menu sprite family has no border) and draws in OnDrawOverlay so the translucent chat panel no longer greys its right column. - The button face (0600124D/66, a fixed 46px LED+arrow sprite) is now 3-sliced (LED cap / stretch / arrow cap) and autosizes to its label via NaturalButtonWidth, so "Chat" fits in the body instead of running into the arrow. The status LED (red Normal / green Pressed) is no longer overdrawn. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/UiChannelMenu.cs | 138 ++++++++++++++++++++++------ 1 file changed, 110 insertions(+), 28 deletions(-) diff --git a/src/AcDream.App/UI/UiChannelMenu.cs b/src/AcDream.App/UI/UiChannelMenu.cs index b9e01f62..a64e1aa4 100644 --- a/src/AcDream.App/UI/UiChannelMenu.cs +++ b/src/AcDream.App/UI/UiChannelMenu.cs @@ -43,6 +43,15 @@ public sealed class UiChannelMenu : UiElement private const int Rows = 7; // items per column private const float ItemH = 17f; // row height (dat item template 0x1000001E H=17) private const float ColW = 191f; // column width (dat item template W=191) + private const int Border = RetailChromeSprites.Border; // 8-piece bevel thickness (5px) + // The row sprites 0x0600124E/4D bake a checkbox/checkmark into the leftmost ~17px + // square; the label starts just past it (box width + small gap) so text aligns with + // the box instead of overlapping it. + private const float TextIndent = 19f; + // The button face sprite (0x06004D65/66) bakes a status LED (red→green) into its + // left socket (~x4–20 of the 46px button); the caption starts past it so it doesn't + // render over the LED. + private const float ButtonTextIndent = 20f; /// The channel the player's typed text currently goes to. public ChatChannelKind Selected { get; private set; } = ChatChannelKind.Say; @@ -65,14 +74,21 @@ public sealed class UiChannelMenu : UiElement public uint ItemHighlightSprite { get; set; } // 0x0600124D — the active channel's row public Vector4 TextColor { get; set; } = new(1f, 0.92f, 0.72f, 1f); - /// Available item text (retail white #FFFFFF). + /// Available item text — retail white #FFFFFF (gmMainChatUI talk-focus + /// enabled state). Confirmed via decomp: enabled items render white. public Vector4 TextColorAvailable { get; set; } = new(1f, 1f, 1f, 1f); - /// Greyed/unavailable item text (retail colorPink salmon, acclient 0x81c528). - public Vector4 TextColorGhosted { get; set; } = new(1f, 0.588f, 0.588f, 1f); + /// Disabled/unavailable item text — retail GREYS these (UIElement state 0xd + /// disabled StateDesc colour). NOT the salmon colorPink (0x81c528) we had before — that + /// belongs to the chat-MESSAGE palette and was misapplied. Exact float lives in the dat + /// StateDesc (not a code symbol); ~0.5 neutral grey here pending a live cdb dump. + public Vector4 TextColorGhosted { get; set; } = new(0.5f, 0.5f, 0.5f, 1f); private bool _open; - private static float PopupW => 2 * ColW; - private static float PopupH => Rows * ItemH; + // Interior = the row content; Outer = interior + the 8-piece bevel ring. + private static float InteriorW => 2 * ColW; // 382 + private static float InteriorH => Rows * ItemH; // 119 + private static float OuterW => InteriorW + 2 * Border; + private static float OuterH => InteriorH + 2 * Border; public UiChannelMenu() { CapturesPointerDrag = true; } @@ -108,44 +124,104 @@ public sealed class UiChannelMenu : UiElement { var resolve = SpriteResolve; - // Button face + the active-target label. + // Button face (3-sliced so it can widen to fit the label) + the active-target label. if (resolve is not null) { - var (tex, _, _) = resolve(_open ? PressedSprite : NormalSprite); - if (tex != 0) ctx.DrawSprite(tex, 0, 0, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); + var (tex, tw, _) = resolve(_open ? PressedSprite : NormalSprite); + if (tex != 0 && tw > 0) DrawButtonFace(ctx, tex, tw); } - DrawLabel(ctx, ButtonText, 4f, (Height - LineH()) * 0.5f, TextColor); + DrawLabel(ctx, ButtonText, ButtonTextIndent, (Height - LineH()) * 0.5f, TextColor); + } + // 3-slice caps for the 46px LED-arrow button face (0x06004D65): a LEFT cap holding the + // round LED socket, a stretchable plain-gold MIDDLE, and a RIGHT cap holding the arrow + // point. Slicing keeps the LED + arrow undistorted when the button widens to its label. + private const float FaceCapL = 20f, FaceCapR = 12f; + + private void DrawButtonFace(UiRenderContext ctx, uint tex, float tw) + { + float uL = FaceCapL / tw, uR = (tw - FaceCapR) / tw; + float midDest = Width - FaceCapL - FaceCapR; + ctx.DrawSprite(tex, 0f, 0f, FaceCapL, Height, 0f, 0f, uL, 1f, Vector4.One); // LED cap + if (midDest > 0f) + ctx.DrawSprite(tex, FaceCapL, 0f, midDest, Height, uL, 0f, uR, 1f, Vector4.One); // gold body (stretched) + ctx.DrawSprite(tex, Width - FaceCapR, 0f, FaceCapR, Height, uR, 0f, 1f, 1f, Vector4.One); // arrow cap + } + + /// The button width that fits "LED cap + channel label + arrow cap" — retail + /// sizes the talk-focus button to its selected label. The controller widens the button + /// to this and reflows the input field to start after it. + public float NaturalButtonWidth() + { + float textW = DatFont?.MeasureWidth(ButtonText) ?? Font?.MeasureWidth(ButtonText) ?? ButtonText.Length * 7f; + return ButtonTextIndent + textW + 4f + FaceCapR; // text start (clears LED) + text + gap + arrow cap + } + + /// The open popup draws in the OVERLAY pass so it sits on top of the whole + /// UI — otherwise the translucent chat panel (drawn after this element in the main + /// pass) greys out the part of the popup that overlaps it. + protected override void OnDrawOverlay(UiRenderContext ctx) + { + var resolve = SpriteResolve; if (!_open || resolve is null) return; - // Two-column popup opening UPWARD from the button. Force OPAQUE (a menu reads - // solid even though the chat window is translucent). Draw the dat row sprites - // first, then the labels — both go through the sprite bucket in submission order, - // so the labels land on top (a DrawRect bg would composite over the text instead). + // Two-column popup opening UPWARD from the button, wrapped in the universal + // 8-piece window bevel (retail UIElement_Menu::MakePopup spawns the popup as a + // bevelled floating window). Force OPAQUE (a menu reads solid even though the + // chat window is translucent). Draw bevel → panel fill → row sprites → labels, + // all through the sprite bucket in submission order so labels land on top. ctx.PushAlphaAbsolute(1f); try { - float top = -PopupH; - DrawSprite(ctx, resolve, PopupBgSprite, 0f, top, PopupW, PopupH); // panel base + float outerTop = -OuterH; // popup bottom sits at the button top (y=0) + float inX = Border, inY = outerTop + Border; // interior origin (inside the bevel) + + DrawBevel(ctx, resolve, 0f, outerTop, OuterW, OuterH); + DrawSprite(ctx, resolve, PopupBgSprite, inX, inY, InteriorW, InteriorH); // panel fill behind rows + for (int i = 0; i < Items.Length; i++) { int col = i / Rows, row = i % Rows; - float x = col * ColW, y = top + row * ItemH; + float x = inX + col * ColW, y = inY + row * ItemH; bool selected = Items[i].Channel is { } c && c == Selected; DrawSprite(ctx, resolve, selected ? ItemHighlightSprite : ItemNormalSprite, x, y, ColW, ItemH); } + float textY = (ItemH - LineH()) * 0.5f; // center the label in its row for (int i = 0; i < Items.Length; i++) { int col = i / Rows, row = i % Rows; - bool avail = Items[i].Channel is { } c && IsAvailable(c); - DrawLabel(ctx, Items[i].Label, 4f + col * ColW, top + row * ItemH + textY, + // Channel items grey out when unavailable; the special items (Squelch / + // Tell-to-Selected, null channel) are normal white items in retail. + bool avail = Items[i].Channel is not { } c || IsAvailable(c); + DrawLabel(ctx, Items[i].Label, inX + col * ColW + TextIndent, inY + row * ItemH + textY, avail ? TextColorAvailable : TextColorGhosted); } } finally { ctx.PopAlpha(); } } + /// Draw the universal 8-piece retail window bevel (corners + tiled edges + + /// tiled centre fill) framing the rect (,, + /// ,). Reuses the same geometry + + /// ids as ; no resize + /// grips (a menu popup is not resizable). + private void DrawBevel(UiRenderContext ctx, Func resolve, + float x, float y, float w, float h) + { + var r = UiNineSlicePanel.ComputeFrameRects(w, h, Border); + void P(uint id, in UiNineSlicePanel.Rect d) => DrawSprite(ctx, resolve, id, x + d.X, y + d.Y, d.W, d.H); + P(RetailChromeSprites.CenterFill, r.Center); + P(RetailChromeSprites.TopEdge, r.Top); + P(RetailChromeSprites.BottomEdge, r.Bottom); + P(RetailChromeSprites.LeftEdge, r.Left); + P(RetailChromeSprites.RightEdge, r.Right); + P(RetailChromeSprites.CornerTL, r.TL); + P(RetailChromeSprites.CornerTR, r.TR); + P(RetailChromeSprites.CornerBL, r.BL); + P(RetailChromeSprites.CornerBR, r.BR); + } + private float LineH() => DatFont?.LineHeight ?? Font?.LineHeight ?? 14f; private void DrawSprite(UiRenderContext ctx, Func resolve, @@ -165,7 +241,7 @@ public sealed class UiChannelMenu : UiElement } protected override bool OnHitTest(float lx, float ly) - => _open ? (lx >= 0 && lx < PopupW && ly >= -PopupH && ly < Height) + => _open ? (lx >= 0 && lx < OuterW && ly >= -OuterH && ly < Height) : base.OnHitTest(lx, ly); public override bool OnEvent(in UiEvent e) @@ -173,17 +249,23 @@ public sealed class UiChannelMenu : UiElement if (e.Type != UiEventType.MouseDown) return false; float lx = e.Data1, ly = e.Data2; - if (_open && ly < 0) // clicked an item in the upward popup + if (_open && ly < 0) // clicked inside the upward popup { - int col = lx < ColW ? 0 : 1; - int row = (int)((ly + PopupH) / ItemH); - int idx = col * Rows + row; - // Only pick available channel items (special + greyed items are inert). - if (row >= 0 && row < Rows && idx >= 0 && idx < Items.Length - && Items[idx].Channel is { } ch && IsAvailable(ch)) + // Map into the bevel interior, then to (col,row). Clicks in the bevel ring + // (outside the interior) just close the menu. + float ix = lx - Border, iy = ly - (-OuterH + Border); + if (ix >= 0 && ix < InteriorW && iy >= 0 && iy < InteriorH) { - Selected = ch; - OnChannelChanged?.Invoke(ch); + int col = ix < ColW ? 0 : 1; + int row = (int)(iy / ItemH); + int idx = col * Rows + row; + // Only pick available channel items (special + greyed items are inert). + if (row >= 0 && row < Rows && idx >= 0 && idx < Items.Length + && Items[idx].Channel is { } ch && IsAvailable(ch)) + { + Selected = ch; + OnChannelChanged?.Invoke(ch); + } } _open = false; return true; From 367a7520789a87d87b8b68e827b01d437f12ddb9 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 15:24:09 +0200 Subject: [PATCH 79/99] =?UTF-8?q?feat(D.2b):=20chat=20input=20=E2=80=94=20?= =?UTF-8?q?write=20mode,=20selection,=20clipboard,=20key-repeat,=20scroll-?= =?UTF-8?q?clip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the retail UIElement_Text editable single-line field: - Focused = "write mode": draws the gold lit field sprite (0x060011AB, the Normal_focussed state) instead of the flat translucent rect; Enter submits AND blurs (exits write mode). - Single-line SELECTION: click-drag, Shift+Arrows, Shift+Home/End, Ctrl+A; translucent-blue highlight behind the span; typing/Backspace/Delete/Paste replace the selection first. - CLIPBOARD: Ctrl+C copy, Ctrl+X cut, Ctrl+V paste at the caret (control chars stripped — single-line). Wired to the keyboard device for clipboard + Ctrl/ Shift state. - Held-key AUTO-REPEAT (Silk delivers one KeyDown per press): Backspace / Delete / Left / Right repeat via a 0.4s-delay, ~25/s OnTick timer. - Horizontal SCROLL + clip: keeps the caret in the field and draws only the glyph window that fits inside it, so long input scrolls within the box instead of spilling past Send into the 3D world. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/UiChatInput.cs | 298 +++++++++++++++++++++++++++--- 1 file changed, 273 insertions(+), 25 deletions(-) diff --git a/src/AcDream.App/UI/UiChatInput.cs b/src/AcDream.App/UI/UiChatInput.cs index 8bed6af0..58c6e4a0 100644 --- a/src/AcDream.App/UI/UiChatInput.cs +++ b/src/AcDream.App/UI/UiChatInput.cs @@ -8,7 +8,9 @@ namespace AcDream.App.UI; /// Editable one-line chat input. Port of retail UIElement_Text editable /// one-line mode + ChatInterface's 100-entry command history. Caret is a /// glyph index; the caret pixel-X is Σ glyph advances (UiDatFont) to the caret. -/// Submit (Enter / Send) fires , clears, and pushes history. +/// Supports mouse + Shift-arrow SELECTION, clipboard cut/copy/paste, and held-key +/// auto-repeat (hold Backspace deletes continuously). Submit (Enter / Send) fires +/// , clears, and pushes history. /// Decomp: UIElement_Text MoveCursor @0x468d00, FindPixelsFromPos @0x472b40; /// ChatInterface ProcessCommand @0x4f5100 (history cap 100, sentinel 0xFFFFFFFF). /// @@ -18,13 +20,27 @@ public sealed class UiChatInput : UiElement public AcDream.App.Rendering.BitmapFont? Font { get; set; } public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f); public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0.35f); + /// Selected-span highlight (translucent blue, behind the text). + public Vector4 SelectionColor { get; set; } = new(0.25f, 0.45f, 0.85f, 0.5f); public float Padding { get; set; } = 4f; public int MaxCharacters { get; set; } = 0xFFFF; + /// Keyboard device for clipboard (Ctrl+C/X/V) + modifier state (Ctrl/Shift). + /// Wired by the host from . + public Silk.NET.Input.IKeyboard? Keyboard { get; set; } + + /// Dat sprite resolver (id → GL texture + size) for the focused-field + /// background. Null = fall back to the flat rect. + public Func? SpriteResolve { get; set; } + /// Gold "lit" field background drawn when focused (retail Normal_focussed + /// state, RenderSurface 0x060011AB). 0 = no focus sprite. + public uint FocusFieldSprite { get; set; } + public Action? OnSubmit { get; set; } private string _text = ""; private int _caret; + private int? _selAnchor; // selection fixed end (null = no selection); span = [min,max] with _caret public string Text => _text; public int CaretPos => _caret; @@ -32,16 +48,29 @@ public sealed class UiChatInput : UiElement private int _historyIndex = -1; public int HistoryCount => _history.Count; + private bool _focused; + private bool _selecting; // mouse drag in progress + private float _scrollX; // horizontal pixel scroll so the caret stays in the field + + // Held-key auto-repeat (Silk delivers one KeyDown per physical press). + private Silk.NET.Input.Key? _repeatKey; + private double _repeatTimer; + private const double RepeatDelay = 0.40; // s before the first repeat + private const double RepeatRate = 0.04; // s between repeats (~25/s) + public UiChatInput() { AcceptsFocus = true; IsEditControl = true; - CapturesPointerDrag = true; + CapturesPointerDrag = true; // interior drag selects, doesn't move the window } + // ── Editing primitives ────────────────────────────────────────────── + public void InsertChar(char c) { if (c < 0x20 || c == 0x7F) return; + DeleteSelection(); if (_text.Length >= MaxCharacters) return; _text = _text.Insert(_caret, c.ToString()); _caret++; @@ -50,6 +79,7 @@ public sealed class UiChatInput : UiElement public void Backspace() { + if (DeleteSelection()) return; if (_caret == 0) return; _text = _text.Remove(_caret - 1, 1); _caret--; @@ -57,13 +87,92 @@ public sealed class UiChatInput : UiElement public void DeleteForward() { + if (DeleteSelection()) return; if (_caret >= _text.Length) return; _text = _text.Remove(_caret, 1); } - public void MoveCaret(int delta) => _caret = Math.Clamp(_caret + delta, 0, _text.Length); - public void CaretHome() => _caret = 0; - public void CaretEnd() => _caret = _text.Length; + private void MoveCaretTo(int target, bool shift) + { + target = Math.Clamp(target, 0, _text.Length); + if (shift) _selAnchor ??= _caret; // begin/extend selection from the old caret + else _selAnchor = null; // plain move collapses any selection + _caret = target; + _historyIndex = -1; + } + + private void MoveCaret(int delta, bool shift) => MoveCaretTo(_caret + delta, shift); + + // ── Selection ──────────────────────────────────────────────────────── + + private (int lo, int hi) SelSpan() + { + if (_selAnchor is not { } a || a == _caret) return (_caret, _caret); + return (Math.Min(a, _caret), Math.Max(a, _caret)); + } + + private bool HasSelection => _selAnchor is { } a && a != _caret; + + private string SelectedText() + { + var (lo, hi) = SelSpan(); + return hi > lo ? _text.Substring(lo, hi - lo) : ""; + } + + /// Remove the selected span (if any). Returns true if it removed anything. + private bool DeleteSelection() + { + if (!HasSelection) { _selAnchor = null; return false; } + var (lo, hi) = SelSpan(); + _text = _text.Remove(lo, hi - lo); + _caret = lo; + _selAnchor = null; + return true; + } + + private void SelectAll() + { + if (_text.Length == 0) { _selAnchor = null; return; } + _selAnchor = 0; + _caret = _text.Length; + } + + private void CopySelection() + { + var s = SelectedText(); + if (s.Length > 0 && Keyboard is not null) Keyboard.ClipboardText = s; + } + + private void CutSelection() + { + if (!HasSelection) return; + CopySelection(); + DeleteSelection(); + _historyIndex = -1; + } + + private void Paste() + { + if (Keyboard is null) return; + string clip = Keyboard.ClipboardText ?? ""; + if (clip.Length == 0) return; + + // Single-line field: strip control chars (newlines/tabs) from pasted text. + var sb = new System.Text.StringBuilder(clip.Length); + foreach (char ch in clip) + if (ch >= 0x20 && ch != 0x7F) sb.Append(ch); + if (sb.Length == 0) return; + + DeleteSelection(); + int room = MaxCharacters - _text.Length; + if (room <= 0) return; + string ins = sb.Length > room ? sb.ToString(0, room) : sb.ToString(); + _text = _text.Insert(_caret, ins); + _caret += ins.Length; + _historyIndex = -1; + } + + // ── Submit + history ───────────────────────────────────────────────── public void Submit() { @@ -74,7 +183,7 @@ public sealed class UiChatInput : UiElement Clear(); } - private void Clear() { _text = ""; _caret = 0; _historyIndex = -1; } + private void Clear() { _text = ""; _caret = 0; _selAnchor = null; _historyIndex = -1; } private void PushHistory(string t) { @@ -102,53 +211,192 @@ public sealed class UiChatInput : UiElement { _text = _history[_historyIndex]; _caret = _text.Length; + _selAnchor = null; } - public float CaretPixelX() - => DatFont is { } df ? df.MeasureWidth(_text.Substring(0, _caret)) - : Font is { } bf ? bf.MeasureWidth(_text.Substring(0, _caret)) : 0f; + // ── Geometry ───────────────────────────────────────────────────────── - private bool _focused; + /// Pixel-X of the caret (Σ glyph advances to ). + private float MeasureTo(int i) + { + if (i <= 0) return 0f; + string s = _text.Substring(0, Math.Min(i, _text.Length)); + return DatFont is { } df ? df.MeasureWidth(s) + : Font is { } bf ? bf.MeasureWidth(s) : 0f; + } + + public float CaretPixelX() => MeasureTo(_caret); + + /// Map a local X (click) to the nearest caret index — retail + /// FindPixelsFromPos inverse. Accounts for the horizontal scroll offset. + private int HitCharX(float localX) + { + float target = localX - Padding + _scrollX; + if (target <= 0f) return 0; + int best = 0; + float bestDist = float.MaxValue; + for (int i = 0; i <= _text.Length; i++) + { + float d = MathF.Abs(MeasureTo(i) - target); + if (d < bestDist) { bestDist = d; best = i; } + } + return best; + } + + // ── Draw ───────────────────────────────────────────────────────────── protected override void OnDraw(UiRenderContext ctx) { - ctx.DrawRect(0, 0, Width, Height, BackgroundColor); + // Focused = "write mode": draw the gold lit field sprite (retail Normal_focussed). + // Unfocused: the flat translucent rect. Both go through the sprite bucket + // (DrawFill / DrawSprite) so the text — also sprite-bucket — draws on top. + bool lit = _focused && SpriteResolve is not null && FocusFieldSprite != 0; + if (lit) + { + var (tex, tw, th) = SpriteResolve!(FocusFieldSprite); + if (tex != 0 && tw > 0) ctx.DrawSprite(tex, 0, 0, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); + else lit = false; + } + if (!lit) ctx.DrawFill(0, 0, Width, Height, BackgroundColor); + float lh = DatFont?.LineHeight ?? Font?.LineHeight ?? 14f; float ty = (Height - lh) * 0.5f; - if (DatFont is { } df) ctx.DrawStringDat(df, _text, Padding, ty, TextColor); - else ctx.DrawString(_text, Padding, ty, TextColor, Font); + float visibleW = MathF.Max(1f, Width - 2f * Padding); + + // Horizontal scroll: keep the caret inside the field; clamp so we never scroll past + // the text. Then draw only the glyph window that lands inside the field — a single- + // line text box clips + scrolls (retail UIElement_Text) rather than overflowing the + // field (which previously spilled the text out into the 3D world). + float caretX = MeasureTo(_caret); + float fullW = MeasureTo(_text.Length); + if (caretX - _scrollX > visibleW) _scrollX = caretX - visibleW; + if (caretX < _scrollX) _scrollX = caretX; + _scrollX = Math.Clamp(_scrollX, 0f, MathF.Max(0f, fullW - visibleW)); + + // Visible character window [start, end). + int start = 0; + while (start < _text.Length && MeasureTo(start + 1) <= _scrollX) start++; + int end = start; + while (end < _text.Length && MeasureTo(end + 1) - _scrollX <= visibleW) end++; + + // Selection highlight BEHIND the text, clipped to the field. + if (HasSelection) + { + var (lo, hi) = SelSpan(); + float h0 = MathF.Max(MeasureTo(lo) - _scrollX, 0f); + float h1 = MathF.Min(MeasureTo(hi) - _scrollX, visibleW); + if (h1 > h0) ctx.DrawFill(Padding + h0, ty, h1 - h0, lh, SelectionColor); + } + + if (end > start) + { + string vis = _text.Substring(start, end - start); + float vx = Padding + (MeasureTo(start) - _scrollX); + if (DatFont is { } df2) ctx.DrawStringDat(df2, vis, vx, ty, TextColor); + else ctx.DrawString(vis, vx, ty, TextColor, Font); + } if (_focused) { - float cx = Padding + CaretPixelX(); - ctx.DrawRect(cx, ty, 1f, lh, TextColor); + // Caret on TOP of the text → submitted after the text in the same bucket. + float cx = Padding + (caretX - _scrollX); + if (cx >= Padding - 1f && cx <= Width - Padding + 1f) + ctx.DrawFill(cx, ty, 1f, lh, TextColor); } } + // ── Auto-repeat ────────────────────────────────────────────────────── + + protected override void OnTick(double deltaSeconds) + { + if (_repeatKey is not { } k) return; + _repeatTimer -= deltaSeconds; + if (_repeatTimer > 0) return; + _repeatTimer = RepeatRate; + bool shift = ShiftHeld(); + switch (k) + { + case Silk.NET.Input.Key.Backspace: Backspace(); break; + case Silk.NET.Input.Key.Delete: DeleteForward(); break; + case Silk.NET.Input.Key.Left: MoveCaret(-1, shift); break; + case Silk.NET.Input.Key.Right: MoveCaret(1, shift); break; + default: _repeatKey = null; break; + } + } + + private void StartRepeat(Silk.NET.Input.Key k) { _repeatKey = k; _repeatTimer = RepeatDelay; } + + private bool CtrlHeld() => Keyboard is not null + && (Keyboard.IsKeyPressed(Silk.NET.Input.Key.ControlLeft) + || Keyboard.IsKeyPressed(Silk.NET.Input.Key.ControlRight)); + + private bool ShiftHeld() => Keyboard is not null + && (Keyboard.IsKeyPressed(Silk.NET.Input.Key.ShiftLeft) + || Keyboard.IsKeyPressed(Silk.NET.Input.Key.ShiftRight)); + + // ── Events ─────────────────────────────────────────────────────────── + public override bool OnEvent(in UiEvent e) { switch (e.Type) { case UiEventType.FocusGained: _focused = true; return true; - case UiEventType.FocusLost: _focused = false; _historyIndex = -1; return true; + case UiEventType.FocusLost: + _focused = false; _historyIndex = -1; + _selAnchor = null; _selecting = false; _repeatKey = null; + return true; + case UiEventType.Char: InsertChar((char)e.Data0); return true; + + case UiEventType.MouseDown: + _caret = HitCharX(e.Data1); + _selAnchor = _caret; // anchor; a drag will extend, a plain click won't + _selecting = true; + return true; + case UiEventType.MouseMove: + if (_selecting) _caret = HitCharX(e.Data1); + return true; + case UiEventType.MouseUp: + _selecting = false; + return true; + + case UiEventType.KeyUp: + if ((Silk.NET.Input.Key)e.Data0 == _repeatKey) _repeatKey = null; + return true; + case UiEventType.KeyDown: { var key = (Silk.NET.Input.Key)e.Data0; + if (CtrlHeld()) + { + switch (key) + { + case Silk.NET.Input.Key.A: SelectAll(); return true; + case Silk.NET.Input.Key.C: CopySelection(); return true; + case Silk.NET.Input.Key.X: CutSelection(); return true; + case Silk.NET.Input.Key.V: Paste(); return true; + } + return true; // swallow other Ctrl combos while typing + } + + bool shift = ShiftHeld(); switch (key) { case Silk.NET.Input.Key.Enter: - case Silk.NET.Input.Key.KeypadEnter: Submit(); return true; - case Silk.NET.Input.Key.Backspace: Backspace(); return true; - case Silk.NET.Input.Key.Delete: DeleteForward(); return true; - case Silk.NET.Input.Key.Left: MoveCaret(-1); return true; - case Silk.NET.Input.Key.Right: MoveCaret(1); return true; - case Silk.NET.Input.Key.Home: CaretHome(); return true; - case Silk.NET.Input.Key.End: CaretEnd(); return true; - case Silk.NET.Input.Key.Up: HistoryPrev(); return true; - case Silk.NET.Input.Key.Down: HistoryNext(); return true; + case Silk.NET.Input.Key.KeypadEnter: + Submit(); + FindRoot()?.SetKeyboardFocus(null); // exit write mode after sending + return true; + case Silk.NET.Input.Key.Backspace: Backspace(); StartRepeat(key); return true; + case Silk.NET.Input.Key.Delete: DeleteForward(); StartRepeat(key); return true; + case Silk.NET.Input.Key.Left: MoveCaret(-1, shift); StartRepeat(key); return true; + case Silk.NET.Input.Key.Right: MoveCaret(1, shift); StartRepeat(key); return true; + case Silk.NET.Input.Key.Home: MoveCaretTo(0, shift); return true; + case Silk.NET.Input.Key.End: MoveCaretTo(_text.Length, shift); return true; + case Silk.NET.Input.Key.Up: HistoryPrev(); return true; + case Silk.NET.Input.Key.Down: HistoryNext(); return true; } return false; } From 2284a376ae36f04f6ca16e69f11443625fd2e806 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 15:24:19 +0200 Subject: [PATCH 80/99] feat(D.2b): write-mode movement gate that preserves autorun MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In chat write mode the keyboard belongs to the input — typing "swd" must not walk the character — but AUTORUN must keep going (the user can chat while running). - InputDispatcher.IsActionHeld now returns false while WantCaptureKeyboard is set (a focused chat input), the polling-path twin of the existing gate on Fired actions. This SUPERSEDES the old per-frame OnUpdate early-return, which also killed autorun. Gating here instead lets the movement block keep running, so autorun — a separate latched bool ORed into Forward at the call site, not a polled key — survives. Test updated to encode the new contract. - GameWindow: the movement suppress-guard reverts to ImGui-devtools-only (the retail write mode no longer early-returns); wires DefaultTextInput = the chat input (Tab/Enter activation) and Input.Keyboard for clipboard. Drops the one-shot UI-scale diagnostic. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 15 +++++++-- .../Input/InputDispatcher.cs | 6 ++++ .../Input/InputDispatcherIsActionHeldTests.cs | 33 ++++++++++--------- 3 files changed, 37 insertions(+), 17 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 6951c28e..9767fa8a 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1854,9 +1854,11 @@ public sealed class GameWindow : IDisposable vitalsDatFont, _debugFont, ResolveChrome); if (chatController is not null) { - // Ctrl+C / Ctrl+A on the transcript need the keyboard for clipboard + modifiers. - // _uiHost.Keyboard is set by WireKeyboard above — it is non-null here. + // Ctrl+C / Ctrl+A on the transcript + Ctrl+C/X/V/A on the input need the + // keyboard for clipboard + modifier (Ctrl/Shift) state. _uiHost.Keyboard + // is set by WireKeyboard above — it is non-null here. chatController.Transcript.Keyboard = _uiHost.Keyboard; + chatController.Input.Keyboard = _uiHost.Keyboard; // Wrap the dat content in the universal 8-piece beveled window chrome — // the SAME UiNineSlicePanel the vitals window uses. The chat's own dat // layout only carries flat background sprites, so without this the window @@ -1887,6 +1889,10 @@ public sealed class GameWindow : IDisposable chatRoot.Draggable = false; chatRoot.Resizable = false; chatFrame.AddChild(chatRoot); _uiHost.Root.AddChild(chatFrame); + // Tab / Enter enters "write mode" by focusing this input (retail's chat + // activation); a focused input suppresses character movement (see the + // WantsKeyboard gate in the movement poll). + _uiHost.Root.DefaultTextInput = chatController.Input; Console.WriteLine("[D.2b] retail chat window from LayoutDesc importer (0x21000006)."); } else Console.WriteLine("[D.2b] chat: required role elements missing in 0x21000006."); @@ -7271,6 +7277,11 @@ public sealed class GameWindow : IDisposable // this guard adds defense-in-depth for the per-frame IsActionHeld // movement poll below (typing "walk" into a chat field shouldn't // walk). + // ImGui dev-tools text fields fully pause game input (incl. autorun) — fine, it's a + // debug overlay. The RETAIL chat "write mode" does NOT early-return here: the block + // below still runs so AUTORUN keeps driving the character while you type. Held WASD + // is silenced at the source instead — InputDispatcher.IsActionHeld returns false + // while WantCaptureKeyboard (which includes a focused chat input) is set. bool suppressGameInput = DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard; if (suppressGameInput) return; diff --git a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs index 84bafce3..e62dc5e2 100644 --- a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs +++ b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs @@ -141,6 +141,12 @@ public sealed class InputDispatcher public bool IsActionHeld(InputAction action) { if (action == InputAction.None) return false; + // While a text field owns the keyboard ("write mode"), held game actions read as + // released: typing "swd" must not move the character. This is the polling-path twin + // of the WantCaptureKeyboard gate on Fired actions. NOTE: this suppresses KEY-driven + // movement only — latched state that isn't a key (e.g. autorun, ORed into Forward at + // the call site) keeps driving the character, so chat doesn't cancel autorun. + if (_mouse.WantCaptureKeyboard) return false; foreach (var b in _bindings.ForAction(action)) { if (IsChordHeld(b.Chord)) return true; diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs index d5003bba..e10d56e3 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs @@ -148,27 +148,30 @@ public class InputDispatcherIsActionHeldTests } [Fact] - public void IsActionHeld_does_not_check_WantCaptureMouse() + public void IsActionHeld_gated_off_while_keyboard_captured() { - // Per-frame held-state lookup is independent of UI capture: even - // with WantCaptureMouse=true a movement key already held when - // ImGui took focus continues to read as held until KeyUp. Press - // events ARE gated (the Press wouldn't fire while UI captures), - // but IsActionHeld answers the keyboard's underlying "is the - // physical key down right now" — which the legacy IsKeyPressed - // also did. The per-frame OnUpdate guard on - // ImGui.GetIO().WantCaptureKeyboard is what suppresses movement - // when chat is focused. + // Write-mode gate (2026-06-16): a focused chat input sets + // WantCaptureKeyboard, and held-key polling then reads RELEASED so typing + // "swd" doesn't move the character. This SUPERSEDES the old design (where the + // per-frame OnUpdate guard early-returned out of the whole movement block) — + // that approach also killed AUTORUN. By gating here instead, the movement block + // keeps running, so autorun (a separate latched bool ORed into Forward at the + // call site, NOT a polled key) survives write mode. WantCaptureMouse alone does + // NOT gate held-key polling — only keyboard capture does. var (dispatcher, kb, mouse, bindings) = Build(); bindings.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward)); kb.EmitKeyDown(Key.W, ModifierMask.None); - mouse.WantCaptureMouse = true; - mouse.WantCaptureKeyboard = true; + // Held, no capture → reads held. + Assert.True(dispatcher.IsActionHeld(InputAction.MovementForward)); - // Even with both capture flags set, IsActionHeld remains true - // because W is physically held. The dispatcher only suppresses - // press transitions. + // Keyboard captured (write mode) → held-key polling reads released. + mouse.WantCaptureKeyboard = true; + Assert.False(dispatcher.IsActionHeld(InputAction.MovementForward)); + + // Mouse capture alone must NOT gate movement polling (only keyboard does). + mouse.WantCaptureKeyboard = false; + mouse.WantCaptureMouse = true; Assert.True(dispatcher.IsActionHeld(InputAction.MovementForward)); } } From ce848c154db2a28dc24f63d146b9e1d09599e71b Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 15:24:30 +0200 Subject: [PATCH 81/99] =?UTF-8?q?feat(D.2b):=20chat=20wiring=20=E2=80=94?= =?UTF-8?q?=20menu/input=20sprites,=20button=20reflow,=20char-wrap,=20pane?= =?UTF-8?q?l=20wash=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatWindowController: wires the menu chrome (popup bevel, row/checkbox sprites), the input focused-field sprite + keyboard, and autosizes the channel button + reflows the input field to start after it (anchor re-capture so the per-frame layout doesn't fight it). DefaultTextInput / write-mode focus hooked up. - WrapText now breaks an over-long UNBROKEN token at character boundaries (no hyphen), packed onto the current line first — so a spaceless token wraps instead of overflowing, and a "You say," prefix stays on the same row as the start of the message. - UiChatView: transcript background + selection highlight use DrawFill (sprite bucket) so the transcript text draws ON TOP instead of being dimmed by its own translucent rect background. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../UI/Layout/ChatWindowController.cs | 59 ++++++++++++++++--- src/AcDream.App/UI/UiChatView.cs | 10 +++- 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index d3b33019..5b6199db 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -49,6 +49,9 @@ public sealed class ChatWindowController private const uint UpSprite = 0x06004C6Cu; // up arrow (top button) private const uint DownSprite = 0x06004C69u; // down arrow (bottom button) + // Chat input focused-field background (element 0x10000016 Normal_focussed state). + private const uint InputFocusField = 0x060011ABu; // gold "lit" field when in write mode + // Channel menu sprite ids (confirmed in chat element dump). private const uint MenuNormal = 0x06004D65u; // button face private const uint MenuPressed = 0x06004D66u; // button pressed @@ -187,6 +190,8 @@ public sealed class ChatWindowController Anchors = ElementReader.ToAnchors(iInfo.Left, iInfo.Top, iInfo.Right, iInfo.Bottom), DatFont = datFont, Font = debugFont, + SpriteResolve = resolve, + FocusFieldSprite = InputFocusField, }; inputBar.AddChild(c.Input); c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel); @@ -257,6 +262,28 @@ public sealed class ChatWindowController sendEl.LabelColor = new Vector4(1f, 0.92f, 0.72f, 1f); } + // ── Size the channel button to its label + reflow the input field ─ + // Retail's talk-focus button autosizes to the selected channel name; the input + // field then fills the gap from the button's right edge to the Send button. The + // dat authors the button at a fixed 46px (too narrow for "Chat" once the LED + + // arrow are accounted for), so widen it to its content and shift the input. + // Recompute on every channel change (the button grows/shrinks with the label). + if (c.Menu is not null) + { + float inputRight = c.Input.Left + c.Input.Width; // == Send button's left edge + void ReflowInputRow() + { + c.Menu.Width = System.MathF.Round(c.Menu.NaturalButtonWidth()); + c.Menu.ResetAnchorCapture(); + c.Input.Left = c.Menu.Left + c.Menu.Width; + c.Input.Width = System.MathF.Max(40f, inputRight - c.Input.Left); + c.Input.ResetAnchorCapture(); + } + var onChanged = c.Menu.OnChannelChanged; + c.Menu.OnChannelChanged = k => { onChanged?.Invoke(k); ReflowInputRow(); }; + ReflowInputRow(); + } + // ── Max/min toggle — simplified gmMainChatUI::HandleMaximizeButton ── if (layout.FindElement(MaxMinId) is UiDatElement maxMinEl) { @@ -349,33 +376,47 @@ public sealed class ChatWindowController /// /// Greedy word-wrap: split into fragments that each fit in /// pixels (per ), breaking at spaces. - /// A single word longer than the width overflows its own line (retail does not - /// hyphenate chat). Mirrors retail GlyphList::Recalculate's per-GlyphLine emission. + /// A word that is itself wider than the line is broken at CHARACTER boundaries (no + /// hyphen), packed onto the current line first — so a long unbroken token (e.g. a URL + /// or "wwwww…") wraps instead of overflowing, and a "You say," prefix stays on the same + /// row as the start of the message. Mirrors retail GlyphList::Recalculate's per-GlyphLine + /// emission (which breaks mid-glyph-run when a run exceeds the wrap width). /// public static IEnumerable WrapText(string text, float maxW, Func measure) { if (string.IsNullOrEmpty(text) || maxW <= 0f || measure(text) <= maxW) { - yield return text; + yield return text ?? string.Empty; yield break; } var line = new System.Text.StringBuilder(); foreach (var word in text.Split(' ')) { - if (line.Length == 0) + string sep = line.Length > 0 ? " " : string.Empty; + if (measure(line.ToString() + sep + word) <= maxW) { - line.Append(word); + line.Append(sep).Append(word); // fits on the current line + continue; } - else if (measure(line.ToString() + " " + word) > maxW) + if (line.Length > 0 && measure(word) <= maxW) { - yield return line.ToString(); + yield return line.ToString(); // word fits alone → push to a new line line.Clear(); line.Append(word); + continue; } - else + // Word too long for any single line: char-wrap it, packing onto the current + // line's remaining space first (keeps the prefix with the message start). + if (line.Length > 0) line.Append(' '); + foreach (char ch in word) { - line.Append(' ').Append(word); + if (line.Length > 0 && measure(line.ToString() + ch) > maxW) + { + yield return line.ToString(); + line.Clear(); + } + line.Append(ch); } } if (line.Length > 0) yield return line.ToString(); diff --git a/src/AcDream.App/UI/UiChatView.cs b/src/AcDream.App/UI/UiChatView.cs index 9dbe9cd3..cff1ea6c 100644 --- a/src/AcDream.App/UI/UiChatView.cs +++ b/src/AcDream.App/UI/UiChatView.cs @@ -93,7 +93,11 @@ public sealed class UiChatView : UiElement protected override void OnDraw(UiRenderContext ctx) { - ctx.DrawRect(0, 0, Width, Height, BackgroundColor); + // Background must draw UNDER the transcript text. DrawStringDat emits into the + // sprite bucket which flushes BEFORE rects, so a DrawRect background would wash + // over the text. DrawFill routes the background through the sprite bucket too, + // submitted first → text on top. + ctx.DrawFill(0, 0, Width, Height, BackgroundColor); // Prefer the retail dat font when set; fall back to BitmapFont. var datFont = DatFont; @@ -161,7 +165,9 @@ public sealed class UiChatView : UiElement hx = Padding + bitmapFont!.MeasureWidth(text.Substring(0, c0)); hw = bitmapFont.MeasureWidth(text.Substring(c0, c1 - c0)); } - ctx.DrawRect(hx, y, hw, lh, SelectionColor); + // Highlight sits BEHIND the line's text → sprite bucket, submitted + // before this line's DrawStringDat. + ctx.DrawFill(hx, y, hw, lh, SelectionColor); } } From fed838847ba85419462efd5bf2b4926088d0add8 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 15:24:37 +0200 Subject: [PATCH 82/99] chore(cli): dump-font-atlas tool for headless font inspection A `dump-font-atlas` subcommand renders a dat Font's fg/bg atlases (alpha as luminance) plus a sample string composited exactly the way DrawStringDat does it (outline + fill, integer-snapped). Used to reproduce the glyph-baseline jitter offline (fractional-origin bug vs the fix) without launching the client. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Cli/FontAtlasDump.cs | 182 +++++++++++++++++++++++++++++++ src/AcDream.Cli/Program.cs | 14 +++ 2 files changed, 196 insertions(+) create mode 100644 src/AcDream.Cli/FontAtlasDump.cs diff --git a/src/AcDream.Cli/FontAtlasDump.cs b/src/AcDream.Cli/FontAtlasDump.cs new file mode 100644 index 00000000..f9f49161 --- /dev/null +++ b/src/AcDream.Cli/FontAtlasDump.cs @@ -0,0 +1,182 @@ +using AcDream.Core.Textures; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; +using DatReaderWriter.Types; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +namespace AcDream.Cli; + +/// +/// Headless inspection of a retail dat Font (DB_TYPE_FONT, 0x40000000…). Writes: +/// • <out>-fg.png — foreground (fill) atlas, alpha→luminance (white on black) +/// • <out>-bg.png — background (outline) atlas, alpha→luminance +/// • <out>-sample.png — a sample string composited EXACTLY the way +/// UiRenderContext.DrawStringDat does it (black outline pass behind, +/// colored fill pass on top) onto the dark chat-panel colour, at native 1:1 +/// and at 6× nearest zoom side by side. +/// +/// The sample reproduces our client's glyph math deterministically so the +/// "not sharp" artifact can be judged offline: if the 1:1 sample is crisp, the +/// softness is downstream (a post-process / scale); if the sample itself is +/// soft, the cause is the atlas or the two-pass outline. +/// +public static class FontAtlasDump +{ + public static int Run(string datDir, string? fontIdText, string? sampleText, string outBase) + { + if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; } + uint fontId = string.IsNullOrWhiteSpace(fontIdText) ? 0x40000000u : ParseHex(fontIdText); + string sample = string.IsNullOrEmpty(sampleText) ? "Chat Send 12345 ghpqy" : sampleText; + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var font = dats.Get(fontId); + if (font is null) { Console.Error.WriteLine($"error: Font 0x{fontId:X8} not found"); return 1; } + + Console.WriteLine($"Font 0x{fontId:X8}: fg=0x{font.ForegroundSurfaceDataId:X8} bg=0x{font.BackgroundSurfaceDataId:X8} " + + $"MaxCharHeight={font.MaxCharHeight} Baseline={font.BaselineOffset} glyphs={font.CharDescs.Count}"); + + DecodedTexture fg = DecodeRs(dats, font.ForegroundSurfaceDataId); + DecodedTexture? bg = font.BackgroundSurfaceDataId != 0 ? DecodeRs(dats, font.BackgroundSurfaceDataId) : null; + Console.WriteLine($" fg atlas {fg.Width}x{fg.Height}" + (bg is { } b ? $" bg atlas {b.Width}x{b.Height}" : " (no bg atlas)")); + + AlphaLuma(fg).SaveAsPng($"{outBase}-fg.png"); + Console.WriteLine($"wrote {outBase}-fg.png"); + if (bg is { } bgt) { AlphaLuma(bgt).SaveAsPng($"{outBase}-bg.png"); Console.WriteLine($"wrote {outBase}-bg.png"); } + + // Build a glyph lookup. + var glyphs = new Dictionary(); + foreach (var cd in font.CharDescs) glyphs[(char)cd.Unicode] = cd; + + // Render the sample the way DrawStringDat does, onto the dark chat panel colour. + var panel = new Rgba32(28, 28, 32, 255); + var fill = new Rgba32(255, 255, 255, 255); // white fill, like System default-ish + var outline = new Rgba32(0, 0, 0, 255); + + int lineH = Math.Max((int)font.MaxCharHeight, 8); + + // (a) integer baseline, per-glyph round (works — like the vitals digits). + using var native = RenderSample(sample, glyphs, fg, bg, lineH, panel, fill, outline, 0f, snapOnce: false); + Save6x(native, $"{outBase}-sample"); + + // (b) FRACTIONAL baseline (textY=0.5, like a menu item centered in a 17px row over + // a 16px font) with the OLD per-glyph rounding → reproduces the "letters dip down" + // jitter the user reported. + using var jitter = RenderSample(sample, glyphs, fg, bg, lineH, panel, fill, outline, 0.5f, snapOnce: false); + Save6x(jitter, $"{outBase}-jitter"); + + // (c) Same fractional baseline, but the line baseline is snapped to a whole pixel ONCE + // before adding the integer per-glyph offsets → the fix. Should be straight again. + using var fixed_ = RenderSample(sample, glyphs, fg, bg, lineH, panel, fill, outline, 0.5f, snapOnce: true); + Save6x(fixed_, $"{outBase}-fixed"); + + Console.WriteLine($"wrote {outBase}-sample-6x.png (ok), {outBase}-jitter-6x.png (bug repro), {outBase}-fixed-6x.png (fix)"); + return 0; + } + + /// Composite the sample string with the two-pass outline+fill model, + /// blitting atlas sub-rects 1:1. adds a fractional + /// line origin; selects the FIX (snap the line baseline + /// to a whole pixel once) vs the BUG (round each glyph's Y independently). + private static Image RenderSample( + string text, Dictionary glyphs, + DecodedTexture fg, DecodedTexture? bg, int lineH, + Rgba32 panel, Rgba32 fill, Rgba32 outline, float originYExtra, bool snapOnce) + { + // First pass: measure pen width. + float pen = 0; float maxX = 0; + foreach (char ch in text) + if (glyphs.TryGetValue(ch, out var g)) { maxX = Math.Max(maxX, pen + g.HorizontalOffsetBefore + g.Width); pen += g.HorizontalOffsetBefore + g.Width + g.HorizontalOffsetAfter; } + int w = Math.Max(8, (int)MathF.Ceiling(Math.Max(maxX, pen)) + 4); + int h = lineH + 6; + var img = new Image(w, h, panel); + + float originY = 3f + originYExtra; + float baseY = MathF.Round(originY); // snapped line baseline (the fix) + pen = 2; + foreach (char ch in text) + { + if (!glyphs.TryGetValue(ch, out var g)) { continue; } + float gx = MathF.Round(pen + g.HorizontalOffsetBefore); + float gy = snapOnce + ? baseY + g.VerticalOffsetBefore // fix: integer baseline + integer offset + : MathF.Round(originY + g.VerticalOffsetBefore); // bug: independent per-glyph rounding + if (g.Width > 0 && g.Height > 0) + { + if (bg is { } bgt) BlitGlyph(img, bgt, g, (int)gx, (int)gy, outline); + BlitGlyph(img, fg, g, (int)gx, (int)gy, fill); + } + pen += g.HorizontalOffsetBefore + g.Width + g.HorizontalOffsetAfter; + } + return img; + } + + private static void Save6x(Image native, string outBase) + { + using var zoom = native.Clone(c => c.Resize(native.Width * 6, native.Height * 6, KnownResamplers.NearestNeighbor)); + zoom.SaveAsPng($"{outBase}-6x.png"); + } + + /// Alpha-blend one glyph's atlas sub-rect onto the canvas using its alpha + /// as coverage, tinted by . 1:1 (no scaling), so this is the + /// pixel-exact result GL_NEAREST + native-size quad produces. + private static void BlitGlyph(Image dst, DecodedTexture atlas, FontCharDesc g, int dx, int dy, Rgba32 tint) + { + for (int sy = 0; sy < g.Height; sy++) + { + int py = dy + sy; + if (py < 0 || py >= dst.Height) continue; + int ay = g.OffsetY + sy; + if (ay < 0 || ay >= atlas.Height) continue; + for (int sx = 0; sx < g.Width; sx++) + { + int px = dx + sx; + if (px < 0 || px >= dst.Width) continue; + int ax = g.OffsetX + sx; + if (ax < 0 || ax >= atlas.Width) continue; + int idx = (ay * atlas.Width + ax) * 4; + // Atlas is A8 expanded to (255,255,255,alpha); coverage = alpha. + float cov = atlas.Rgba8[idx + 3] / 255f; + if (cov <= 0f) continue; + var bgpx = dst[px, py]; + dst[px, py] = new Rgba32( + (byte)(tint.R * cov + bgpx.R * (1 - cov)), + (byte)(tint.G * cov + bgpx.G * (1 - cov)), + (byte)(tint.B * cov + bgpx.B * (1 - cov)), + 255); + } + } + } + + /// Render an A8/RGBA atlas's ALPHA channel as opaque white-on-black luminance, + /// zoomed 4× nearest, so the glyph shapes are visible regardless of PNG viewer alpha. + private static Image AlphaLuma(DecodedTexture t) + { + var img = new Image(t.Width, t.Height); + for (int y = 0; y < t.Height; y++) + for (int x = 0; x < t.Width; x++) + { + byte a = t.Rgba8[(y * t.Width + x) * 4 + 3]; + img[x, y] = new Rgba32(a, a, a, 255); + } + img.Mutate(c => c.Resize(t.Width * 4, t.Height * 4, KnownResamplers.NearestNeighbor)); + return img; + } + + private static DecodedTexture DecodeRs(DatCollection dats, uint id) + { + var rs = dats.Get(id); + if (rs is null) { Console.Error.WriteLine($" missing RenderSurface 0x{id:X8}"); return DecodedTexture.Magenta; } + return SurfaceDecoder.DecodeRenderSurface(rs); + } + + private static uint ParseHex(string s) + { + s = s.Trim(); + if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) s = s[2..]; + return uint.TryParse(s, System.Globalization.NumberStyles.HexNumber, + System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : 0u; + } +} diff --git a/src/AcDream.Cli/Program.cs b/src/AcDream.Cli/Program.cs index 44094b55..5e0e03be 100644 --- a/src/AcDream.Cli/Program.cs +++ b/src/AcDream.Cli/Program.cs @@ -68,6 +68,20 @@ if (args.Length >= 1 && args[0] == "dump-sprite-sheet") return VitalsMockup.ExportSheet(dssDir, dssIds, dssOut); } +if (args.Length >= 1 && args[0] == "dump-font-atlas") +{ + string? dfaDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + string? dfaFont = args.ElementAtOrDefault(2); // 0xFontId (default 0x40000000) + string? dfaSample = args.ElementAtOrDefault(3); // sample string + string dfaOut = args.ElementAtOrDefault(4) ?? "font-atlas"; + if (string.IsNullOrWhiteSpace(dfaDir)) + { + Console.Error.WriteLine("usage: AcDream.Cli dump-font-atlas [0xFontId] [sample] [outBase]"); + return 2; + } + return FontAtlasDump.Run(dfaDir, dfaFont, dfaSample, dfaOut); +} + if (args.Length >= 1 && args[0] == "export-ui-sprite") { string? eusDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR"); From b7f7e2b4ef6ae872e3e6fdae0dbdce402ea11935 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 16:26:32 +0200 Subject: [PATCH 83/99] docs(D.2b): widget-generalization design (Plan 2 widget piece) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design for refactoring the hand-named chat widgets + Send/MaxMin click-wiring into generic, Type-registered widgets built by DatWidgetFactory, collapsing ChatWindowController (and, gated-last, VitalsController) to a thin retail gm*UI::PostInit-style find-by-id binder. Key finding that reframes the pass: the importer's base-chain Type resolution is already retail-faithful, and Type 12 is UIElement_Text (a real behavioral class), not a style prototype to skip — verified against acclient_2013_pseudo_c.txt:115655. The generalization is therefore a registration task (register Types 1/3/6/11/12 -> generic widgets, delete the Type-12 skip), not a new mechanism. Approved scope: full registry (bounded to the Types chat+vitals use; rest stays UiDatElement fallback), chat-first, vitals rewire as the final separately-gated step. 7-step one-widget-per-commit migration; new chat_21000006.json golden fixture; vitals fixture stays frozen through steps 1-6. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-06-16-d2b-widget-generalization-design.md | 351 ++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-16-d2b-widget-generalization-design.md diff --git a/docs/superpowers/specs/2026-06-16-d2b-widget-generalization-design.md b/docs/superpowers/specs/2026-06-16-d2b-widget-generalization-design.md new file mode 100644 index 00000000..ad4fb859 --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-d2b-widget-generalization-design.md @@ -0,0 +1,351 @@ +# D.2b — Widget generalization (LayoutDesc importer, Plan 2 widget piece) — design + +**Date:** 2026-06-16 +**Branch:** `claude/hopeful-maxwell-214a12` (D.2b retail-UI track) +**Status:** design — approved scope ("full registry, vitals last & gated"), pending spec review +**Predecessor:** the LayoutDesc importer, the vitals re-drive, and the chat-window re-drive +(`docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md`, +`docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md`, +`docs/research/2026-06-15-layoutdesc-format.md`, +`claude-memory/project_d2b_retail_ui.md`). +**Opening context:** the "GENERALIZATION PASS — START-COLD CONTEXT" note in +`claude-memory/project_d2b_retail_ui.md`. + +--- + +## 1. Goal + +Refactor the hand-named chat widgets (`UiChatView` / `UiChatInput` / +`UiChatScrollbar` / `UiChannelMenu`) and the inline Send/Max-Min `UiDatElement` +click-wiring into **generic, Type-registered widgets** built by +`DatWidgetFactory`, so that `ChatWindowController` (and, as the final gated step, +`VitalsController`) collapses to a thin **find-widget-by-id → bind-data/behavior** +controller — the acdream analogue of retail `gm*UI::PostInit`. + +**The code is modern. The behavior is retail.** This pass changes the +*construction path* of widgets, not their on-screen behavior. The chat window +must stay visually and behaviorally identical through every step except the final +(gated) vitals rewire. + +### 1.1 Why this is mostly already done + +The trace that opened this work (re-confirmed in this design session) established +two facts that make the generalization a *registration* task, not a new mechanism: + +1. **The importer's base-chain Type resolution is already retail-faithful.** + `ElementReader.Merge` resolves a Type-0 placement element up its + `BaseElement`/`BaseLayoutId` chain to the base's real registered Type + (`ElementReader.cs:137-140`). Every chat/vitals element therefore already + resolves to the retail class it would instantiate. + +2. **Type 12 is `UIElement_Text` — a real behavioral class, not a "style + prototype to skip."** Verified directly in the decomp: + `UIElement::RegisterElementClass(0xc, UIElement_Text::Create)` + (`docs/research/named-retail/acclient_2013_pseudo_c.txt:115655`). The + `Type==12 → return null` rule in `DatWidgetFactory` is a *vitals-only Plan-1 + expedient* (AP-37: skip the vitals number elements so they render via + `UiMeter.Label`), **not** a structural truth. + +So the "wrinkle" feared in the start-cold note (Type-0/12 elements hiding their +real widget type) **dissolves**: the resolved Type is already correct. The factory +just needs to *register* generic widgets for those Types instead of skipping them +or dropping to `UiDatElement`. + +--- + +## 2. Retail reference (the registry + the PostInit pattern) + +### 2.1 The Type → class registry (`UIElement::RegisterElementClass`) + +Confirmed verbatim from `acclient_2013_pseudo_c.txt` (line numbers cited): + +| Type | Retail class | Reg. line | | Type | Retail class | Reg. line | +|---|---|---|---|---|---|---| +| 1 | `UIElement_Button` | :125828 | | 9 | `UIElement_Resizebar` | :118938 | +| 2 | `UIElement_Dragbar` | :119926 | | 0xb (11) | `UIElement_Scrollbar` | :124137 | +| 3 | `UIElement_Field` (editable) | :126190 | | 0xc (12) | `UIElement_Text` | :115655 | +| 5 | `UIElement_ListBox` | :121788 | | 0xd (13) | `UIElement_Viewport` | :119126 | +| 6 | `UIElement_Menu` | :120163 | | 0xe (14) | `UIElement_Browser` | :118718 | +| 7 | `UIElement_Meter` | :123316 | | 0x10/0x11 | `ColorPicker`/`GroupBox` | :118396/:118177 | +| 8 | `UIElement_Panel` | :119820 | | — | **Type 0 and 4: NOT registered** | — | + +Type 0 has no class of its own — a Type-0 element is a placement/override that +inherits its class from its base. That is exactly what `ElementReader.Merge` +already does. + +### 2.2 The `gm*UI::PostInit` binding pattern (the controller target) + +`gmVitalsUI::PostInit` (`acclient_2013_pseudo_c.txt:199170-199228`) and +`gmMainChatUI::PostInit` (`:212585-212636`) do, per child widget: + +``` +UIElement* e = UIElement::GetChildRecursive(this, 0x100000e6); // find by id +UIElement_Meter* m = e->vtable->DynamicCast(7); // cast to Type +this->m_pHealthMeter = m; // store +if (!m) { /* skip */ } // null-check +``` + +acdream analogue (already half-present in `ChatWindowController`): + +```csharp +var send = layout.FindElement(SendId) as UiButton; // GetChildRecursive + DynamicCast +if (send is not null) send.OnClick = () => input.Submit(); // bind behavior +``` + +The faithful end-state is: **the factory builds every widget from the dat; the +controller only finds-by-id and binds data/callbacks** — it never constructs a +widget. + +### 2.3 Empirically resolved Types of the chat elements (`LayoutDesc 0x21000006`) + +Traced against the live dat (HIGH confidence; base ids in parentheses): + +| Element | Resolves to | Retail class | Today | +|---|---|---|---| +| `0x10000014` channel menu | **6** (own Type 6, no base) | Menu | `UiDatElement` → controller replaces w/ `UiChannelMenu` | +| `0x10000012` scrollbar track | **11** (base `0x10000367` in `0x2100003E`) | Scrollbar | `UiDatElement` → controller replaces w/ `UiChatScrollbar` | +| `0x10000011` transcript | **12** (base `0x10000372` in `0x2100003F`) | Text | skipped → controller adds `UiChatView` | +| `0x10000016` input | **12** (base `0x10000372` in `0x2100003F`) | Text | skipped → controller adds `UiChatInput` | +| `0x10000019` send | **1** (base chain → `0x1000047F` Type 1) | Button | `UiDatElement` + `OnClick` | +| `0x1000046F` max/min | **1** (base `0x1000047F` Type 1 in `0x21000040`) | Button | `UiDatElement` + `OnClick` | + +> **Plan-phase verification #1 (load-bearing):** the editable **input** +> `0x10000016` traced to **Type 12 (Text)**, the same base as the read-only +> transcript — surprising for an editable field (retail's editable text is +> Field=3). Element ids are layout-*local*, so the decomp's `ChatInterface` +> Field-id does **not** cross-map; re-dump `0x10000016`'s exact resolved Type and +> the `0x10000372` base prototype's Type before relying on it. The design is +> robust either way — see §4.3(a). + +--- + +## 3. Approved scope + +**Decision (this session):** *Full registry, chat-first, vitals rewire as the +final, separately-committed, separately-gated step.* + +**In scope:** +- Register generic widgets for the Types the chat + vitals windows actually use: + **Button (1), Field (3), Menu (6), Scrollbar (11), Text (12)** — plus Meter (7) + already done. +- Delete the `Type==12 → return null` skip; Type 12 becomes `UiText`. +- Collapse `ChatWindowController.Bind` to a find-by-id binder (no widget + construction). +- **Final gated step:** rewire `VitalsController` to bind generic `UiText` for the + vitals numbers (retail-faithful: vitals numbers *are* `UIElement_Text`), + retiring `UiMeter.Label` for vitals. + +**Explicitly NOT in scope ("full registry" is bounded to what these windows use):** +- The long tail retail also registers — `Panel` (8), `Dragbar` (2), `Resizebar` + (9), `ListBox` (5), `Viewport` (13), `Browser` (14), `ColorPicker` (16), + `GroupBox` (17). Those elements **continue to render correctly as + `UiDatElement`** (the universal fallback is non-negotiable). No + `UIElement_ColorPicker` port for a window that has no color picker. When a future + window needs one of these, it gets registered then. +- No new chat *features* (tabs/squelch/name-tags/word-wrap remain as the chat + re-drive deferred them — see that spec's §2). +- `UiMeter.Label` is **not deleted** — it stays for plugin/markup panels; vitals + simply stops using it. + +--- + +## 4. Design + +### 4.1 `DatWidgetFactory` — the faithful Type switch + +`DatWidgetFactory.Create` grows from `{7 → UiMeter, _ → UiDatElement}` to: + +```csharp +UiElement e = info.Type switch +{ + 1 => BuildButton(info, resolve, datFont), // UIElement_Button + 3 => BuildField(info, resolve, datFont), // UIElement_Field (see §4.3a) + 6 => BuildMenu(info, resolve, datFont), // UIElement_Menu + 7 => BuildMeter(info, resolve, datFont), // UIElement_Meter (unchanged) + 11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar + 12 => BuildText(info, resolve, datFont), // UIElement_Text + _ => new UiDatElement(info, resolve), // generic fallback (unchanged) +}; +``` + +The rect/anchor/z-order propagation at the bottom of `Create` is unchanged. The +`Type==12 && StateMedia.Count==0` skip is **removed** — but a *pure base +prototype* (Type 12 with no own geometry that is only referenced via +`BaseLayoutId`, never placed) must still not draw. In practice such prototypes are +never top-level placed elements in `0x21000006`/`0x2100006C`; the importer only +builds placed elements. **Plan-phase verification #2:** confirm no Type-12 +prototype is double-built after the skip is removed (the chat/vitals golden +fixtures catch this). + +Each `BuildX` extracts the widget's dat-derived data (sprite ids per state, label +font) the same way `BuildMeter` extracts its 3-slice grandchild sprites. The +controller binds providers/callbacks afterward. + +### 4.2 The generic widgets + +Each generic widget extends `UiElement`, is constructed by the factory from +`ElementInfo`, and exposes **data providers + callbacks** for the controller to +bind. The chat-specific knowledge moves *out* of the widgets and *into* the +controller (faithful: retail's `gmMainChatUI`, not `UIElement_Menu`, owns the +talk-focus channel list). + +| Generic widget | Type | Derived from | Generic surface (dat-built + provider-bound) | Controller binds | +|---|---|---|---|---| +| `UiScrollbar` | 11 | `UiChatScrollbar` (already 100% generic) | track/thumb/cap/arrow sprite ids from dat; `Model : UiScrollable` | `Model = transcript.Scroll` | +| `UiButton` | 1 | `UiDatElement`+`OnClick` | state sprites (Normal/Pressed/Disabled), `Label`, `LabelFont`, `LabelColor`, `OnClick`, `NaturalWidth()` autosize | `OnClick`, caption | +| `UiMenu` | 6 | `UiChannelMenu` | popup toggle, 2-col layout, 8-piece bevel, row highlight; `Items : IReadOnlyList<(string label, bool enabled, object payload)>`, `OnSelect : Action`, `Selected`, `NaturalButtonWidth()` | populate 14 channel `Items`; map payload↔`ChatChannelKind`; `AvailabilityProvider` | +| `UiText` | 12 | `UiChatView` | scrollable + selectable multi-color line list, clipboard, dat-font; `LinesProvider : Func>`; shares `UiScrollable` (`Scroll`) | `LinesProvider` → ChatVM + per-kind colors | +| `UiField` | 3 | `UiChatInput` | editable one-line: caret/selection/clipboard/history/auto-repeat/focus-sprite-swap; `Text`, `OnSubmit`, `MaxCharacters` | `OnSubmit` → `ChatCommandRouter` | + +**Placement.** The generic widgets live in `src/AcDream.App/UI/` alongside +`UiMeter` (toolkit widgets). The factory in `src/AcDream.App/UI/Layout/` +references them. This matches the current split (`UiMeter` in `UI/`, +`UiDatElement` in `UI/Layout/`). + +**Naming.** `UiX` mirrors retail `UIElement_X`. The old chat-prefixed names are +removed (or kept as thin obsolete aliases only if needed mid-migration). + +### 4.3 The two wrinkles + +**(a) The editable input (Type 12 vs Type 3).** Robust to either resolution: +- If `0x10000016` resolves to **Type 3** → factory builds `UiField` directly; the + controller only binds `OnSubmit`. +- If it resolves to **Type 12** → the dat element is a display Text in this + layout; the controller *replaces* it with a controller-placed `UiField` at its + rect (today's pattern for the track/menu). `UiField` exists as a registered + generic widget regardless; only *who places it* differs. + +Editing behavior (caret/clipboard/history) is never purely dat-derivable, so the +input is always provider-bound — the open question only affects whether the +factory or the controller *instantiates* it. + +**(b) Vitals rewire — the final gated step.** Removing the Type-12 skip means the +vitals number elements (Type-0 → base Type-12 Text) *could* build as real +`UiText`. Today they are **meter children, consumed** (the importer does not +recurse a meter's children — `LayoutImporter.cs:113`), rendered via +`UiMeter.Label`. The faithful move: `VitalsController` constructs/binds a `UiText` +for each number (matching retail `UIElement_Text` vitals numbers) and drops +`UiMeter.Label` for vitals. + +This is **step 7 — the last commit, separately gated**, with its own fixture +update and the user's visual sign-off, because vitals shipped pixel-identical and +is fixture-locked (`vitals_2100006C.json`). If the rewire risks the pixel-identical +result, we **stop and keep the meter-label path** for vitals — a smaller, +documented divergence (AP-37 narrowed, not retired). The decision to land step 7 +is the user's, made on the running client. + +### 4.4 The thin controller (after step 6) + +`ChatWindowController.Bind` collapses to: for each known element id, `FindElement(id) +as UiX`, null-check, bind data/callback. The reflow/maximize/resize-bar-drop logic +(`ChatWindowController.cs:155-297`) stays — it is window-layout policy, not widget +construction. The `BuildLines` / `WrapText` / `RetailChatColor` helpers stay (chat +data shaping). What *leaves* the controller: the construction of `UiChatView`, +`UiChatInput`, `UiChatScrollbar`, `UiChannelMenu` (now factory-built) — the +controller binds them instead. + +--- + +## 5. Migration sequence (one widget per commit; build + test green each step) + +Ordered least-risk → most-risk; the chat window is fully generalized before vitals +is touched. Each step: `dotnet build` green, `dotnet test` (AcDream.App.Tests) +green, its own commit naming the widget; the live chat window stays visually +identical through steps 1–6. + +1. **`UiScrollbar`** (Type 11) — promote `UiChatScrollbar` (already generic); + register; factory builds it; controller binds `Model`. +2. **`UiButton`** (Type 1) — extract from `UiDatElement`+`OnClick`; register; Send + + Max/Min build from the dat. +3. **`UiMenu`** (Type 6) — generalize `UiChannelMenu`; register; controller + populates channel `Items` + maps payload↔`ChatChannelKind`. +4. **`UiText`** (Type 12) — generalize `UiChatView`; register; **delete the Type-12 + skip**; controller binds transcript lines. Guard: verify vitals still renders + (its numbers are meter-consumed → no auto-double-draw) via the vitals fixture + + a live launch. +5. **`UiField`** (Type 3) — generalize `UiChatInput`; register; wire the input per + §4.3(a) (verification #1 resolves factory-built vs controller-placed). +6. **Thin the controller** — collapse `ChatWindowController.Bind` to pure + find-by-id binding now that the factory builds everything. +7. **Vitals rewire (gated)** — `VitalsController` binds `UiText` numbers; fixture + update + the user's visual sign-off. **Stop-and-confirm gate.** + +--- + +## 6. Testing & conformance + +- **Generic-widget unit tests** (pure, no GL/dat) — mostly *moved* from the + existing chat-widget tests, renamed to the generic widgets: caret↔pixel + history + (`UiField`), thumb ratio / page-delta (`UiScrollbar` via `UiScrollable`), menu + item-pick + availability (`UiMenu`), line wrap / selection / dat-font hit-test + (`UiText`). +- **Factory tests** — `DatWidgetFactoryTests` grows one assert per newly registered + Type → correct widget class. +- **New chat-layout golden fixture** `tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json` + (peer of `vitals_2100006C.json`): the resolved chat tree — each element's id, + rect, resolved Type, sprite ids — asserting the factory builds the right widget + per element. This locks the generalization. +- **Vitals fixture `vitals_2100006C.json` stays green, untouched, through steps + 1–6**; updated only at step 7, with visual sign-off. +- **Visual acceptance** — the user launches `ACDREAM_RETAIL_UI=1` and confirms the + chat window is unchanged through steps 1–6, and the vitals window is unchanged + after step 7. + +--- + +## 7. Divergence-register impact + +- **AP-37** (`DatWidgetFactory.cs`/`LayoutImporter.cs`: Type-0 text skipped + meter- + collapse + vitals numbers via `UiMeter.Label`): **amended as steps land** — the + "standalone Type-0 text elements are skipped / a dedicated dat-text widget is + Plan 2" clause is retired when `UiText` ships (step 4); the vitals-numbers-via- + `UiMeter.Label` clause is retired at step 7, or **narrowed** (not retired) if + step 7 is deferred. The meter-collapse clause (reuse `UiMeter` 3-slice vs porting + `UIElement_Meter::DrawChildren` over nested dat elements) **remains** — this pass + does not port `DrawChildren`. +- **IA-15** (the importer *is* the retail-UI render path): unchanged; reinforced + (more Types now data-driven). +- **AP-41** (scrollbar thumb single stretched sprite): **re-check at step 1** — the + controller already passes 3-slice cap ids (`ThumbTopSprite`/`ThumbBotSprite`); the + row may be retire-able when `UiScrollbar` lands. +- **New rows** only if a generic widget introduces a *new* approximation (e.g., a + `UiMenu` item model simpler than retail's hierarchical popup chain in + `UIElement_Menu::MakePopup`). Add the row in the same commit per register rule 1. + +--- + +## 8. Acceptance criteria + +- [ ] `DatWidgetFactory` registers Types 1, 3, 6, 11, 12 (+ 7) → generic widgets; + `_` still falls back to `UiDatElement`. +- [ ] The `Type==12 → null` skip is removed; no Type-12 element is double-built + (golden fixtures green). +- [ ] The four chat widgets are generic (no `ChatChannelKind` / chat-color / + command-routing knowledge inside a widget); `ChatWindowController` only finds- + by-id and binds. +- [ ] Chat window is visually + behaviorally identical to the shipped version + through steps 1–6 (user-confirmed). +- [ ] New `chat_21000006.json` golden fixture + moved generic-widget unit tests; + all green. +- [ ] Vitals window unchanged after step 7 (user-confirmed), or step 7 deferred + with AP-37 narrowed. +- [ ] Every generic widget cites its retail `UIElement_X` class + reg. line in a + code comment. +- [ ] Divergence register updated (AP-37 amended; AP-41 re-checked) in the same + commits. +- [ ] Roadmap / `claude-memory/project_d2b_retail_ui.md` updated when the pass lands. + +--- + +## 9. Open items for the plan phase + +1. **Verification #1 (load-bearing):** re-dump `0x10000016` (input) + the + `0x10000372` base prototype to confirm input resolved Type (3 vs 12) → decides + factory-built vs controller-placed `UiField` (§4.3a). +2. **Verification #2:** confirm no Type-12 base prototype double-builds once the + skip is removed (§4.1). +3. Confirm the `UiMenu` generic item model (`(label, enabled, payload)`) is enough + for the 14 talk-focus channels without losing the greyed/available distinction + the chat menu currently shows. +4. Decide whether to keep thin obsolete-aliases for the old chat widget names + during migration or rename in-place (prefer in-place; the names are internal). From 56f5bc7aa1f700275678fd09e360ac7677050de9 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 16:33:14 +0200 Subject: [PATCH 84/99] docs(D.2b): add strategic-purpose section to widget-generalization design Capture the 'why beyond chat' the user articulated: chat is the proving ground; the real payoff is inventory/spell-bar/vendor/character-sheet/trade becoming data-driven assembly + thin controller. Notes what carries forward (the generic widget toolkit + the find-by-id controller pattern) vs what those windows still need (ListBox/Panel + Field drag-drop, the window-manager half of Plan 2, and per-domain item/container data). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-06-16-d2b-widget-generalization-design.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/superpowers/specs/2026-06-16-d2b-widget-generalization-design.md b/docs/superpowers/specs/2026-06-16-d2b-widget-generalization-design.md index ad4fb859..12dcd6c5 100644 --- a/docs/superpowers/specs/2026-06-16-d2b-widget-generalization-design.md +++ b/docs/superpowers/specs/2026-06-16-d2b-widget-generalization-design.md @@ -51,6 +51,41 @@ real widget type) **dissolves**: the resolved Type is already correct. The facto just needs to *register* generic widgets for those Types instead of skipping them or dropping to `UiDatElement`. +### 1.2 Why this matters beyond chat (the strategic purpose) + +Chat is the **proving ground**, not the destination. The payoff is that every +future panel — **inventory, spell bar, vendor, character sheet, trade, skills** — +becomes *assembled from dat data + a thin controller* instead of being hand-built +from scratch. That is exactly how retail did it (`gm*UI::PostInit` everywhere on a +shared `UIElement` toolkit), and it is the reason to do this pass carefully now. + +**What this pass gives all future windows (the foundation):** +- The **generic widget toolkit** — `UiButton`, `UiField`, `UiScrollbar`, `UiText`, + `UiMenu` — built automatically by `DatWidgetFactory` from the dat layout. +- The **thin-controller pattern** — find-widget-by-id → bind-live-data — proven and + cemented on chat. Inventory's controller, vendor's controller, etc. all take the + same shape. + +**What those specific windows additionally need (out of scope here; cheap once the +pattern exists):** +- **A few more widget Types** — inventory/vendor lists want `UiListBox` (Type 5) + and `UiPanel` (Type 8); item slots want **drag-drop**, which retail builds into + `UIElement_Field` (the decomp shows `Field` has `CatchDroppedItem` / + `MouseOverTop` drag-drop hooks — so drag-drop rides on the Field widget this pass + already builds). Each gets *registered when that window needs it* — which is + exactly why §3 bounds "full registry" to the Types chat+vitals use today rather + than speculatively building all 14 retail classes. +- **The window manager** — open/close/z-order/persist, drag-bars (Type 2), + resize-grips (Type 9). This is the *other* half of Plan 2 — a sibling piece to + this one — and lands alongside, because pop-up/stackable windows (inventory, + vendor) need it. +- **Per-domain data plumbing** — item icons, live container contents, vendor stock + lists. Game-state work, separate from the UI toolkit. + +This pass is therefore the **reusable toolkit + assembly pattern** that makes those +later windows mostly-free to build. It is the load-bearing first half of the road +to inventory/vendor/spell-bar, not the whole road. + --- ## 2. Retail reference (the registry + the PostInit pattern) From 34e79096f331bdf42a277950c634fb0fc004d397 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 16:47:32 +0200 Subject: [PATCH 85/99] docs(D.2b): widget-generalization implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8-task TDD plan: chat golden fixture + resolved-Type conformance (Task 1, empirically resolves the input's Type), then one-widget-per-commit migration — UiScrollbar(11), UiButton(1), UiMenu(6), UiText(12)+the Type-12 flip, UiField(3) — then thin the controller (Task 7, visual gate) and the gated vitals UiText rewire (Task 8). Each task: failing test, register in the factory switch, controller find-by-id binding, build+test green, commit. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-16-d2b-widget-generalization.md | 992 ++++++++++++++++++ 1 file changed, 992 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-16-d2b-widget-generalization.md diff --git a/docs/superpowers/plans/2026-06-16-d2b-widget-generalization.md b/docs/superpowers/plans/2026-06-16-d2b-widget-generalization.md new file mode 100644 index 00000000..e68c745f --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-d2b-widget-generalization.md @@ -0,0 +1,992 @@ +# D.2b Widget Generalization Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Refactor the hand-named chat widgets and the Send/Max-Min click-wiring into generic, Type-registered widgets built by `DatWidgetFactory`, collapsing the controllers to a thin retail `gm*UI::PostInit`-style find-by-id binder. + +**Architecture:** `DatWidgetFactory.Create` grows a faithful `switch(Type)` registering the real retail `UIElement` classes (Button=1, Field=3, Menu=6, Meter=7, Scrollbar=11, Text=12) as generic widgets; everything else stays `UiDatElement`. The importer's base-chain Type resolution already surfaces each element's real Type, so this is a *registration* task. The chat-specific knowledge (channel list, colors, command routing) moves out of widgets into `ChatWindowController`. Migrate one widget per commit; chat stays visually identical through Tasks 2–7; vitals is rewired last (Task 8) behind a visual gate. + +**Tech Stack:** C# / .NET 10, xUnit, `DatReaderWriter` (Chorizite), Silk.NET (GL/input). Retail oracle: `docs/research/named-retail/acclient_2013_pseudo_c.txt`. + +**Spec:** `docs/superpowers/specs/2026-06-16-d2b-widget-generalization-design.md`. + +--- + +## Conventions + +- **Repo root** = the worktree dir. All paths below are relative to it. +- **Build:** `dotnet build` (builds `AcDream.slnx`). Must be green before every commit. +- **Test (all UI):** `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` +- **Test (filtered):** add `--filter "FullyQualifiedName~"`. +- **Commit style:** `feat(D.2b): ` / `test(D.2b): …` / `refactor(D.2b): …`, ending with the project's `Co-Authored-By: Claude Opus 4.8 (1M context) ` trailer. +- **Every generic widget cites its retail class + `RegisterElementClass` line** in a doc comment (per spec §8). +- **Divergence register:** `docs/architecture/retail-divergence-register.md` — amend AP-37 / re-check AP-41 in the same commit that lands the relevant widget (per spec §7). + +--- + +## File Structure + +**Created:** +- `src/AcDream.App/UI/UiButton.cs` — generic Type-1 button (Task 3). +- `src/AcDream.App/UI/UiText.cs` — generic Type-12 scrollable colored-line text (rename of `UiChatView`, Task 5). +- `src/AcDream.App/UI/UiField.cs` — generic Type-3 editable one-line field (rename of `UiChatInput`, Task 6). +- `src/AcDream.App/UI/UiScrollbar.cs` — generic Type-11 scrollbar (rename of `UiChatScrollbar`, Task 2). +- `src/AcDream.App/UI/UiMenu.cs` — generic Type-6 dropdown menu (genericized `UiChannelMenu`, Task 4). +- `tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json` — golden resolved chat tree (Task 1). +- `tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs` — skip-by-default fixture generator (Task 1). +- `tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs` — resolved-tree + factory-class conformance (Task 1, grown per widget). +- `tests/AcDream.App.Tests/UI/UiButtonTests.cs` (Task 3). + +**Renamed (git mv + class/namespace-internal rename):** +- `UiChatScrollbar.cs` → `UiScrollbar.cs`; `UiChatScrollbarTests.cs` → `UiScrollbarTests.cs` (Task 2). +- `UiChatView.cs` → `UiText.cs`; `UiChatViewTests.cs` → `UiTextTests.cs`; `UiChatViewDatFontTests.cs` → `UiTextDatFontTests.cs` (Task 5). +- `UiChatInput.cs` → `UiField.cs`; `UiChatInputTests.cs` → `UiFieldTests.cs` (Task 6). +- `UiChannelMenu.cs` → `UiMenu.cs`; `UiChannelMenuTests.cs` → `UiMenuTests.cs` (Task 4). + +**Modified:** +- `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` — the `switch(Type)` + `BuildButton`/`BuildMenu`/`BuildText`/`BuildField`/`BuildScrollbar` (Tasks 2–6). +- `src/AcDream.App/UI/Layout/ChatWindowController.cs` — construction → find-by-id binding; channel-item population (Tasks 2–7). +- `src/AcDream.App/UI/Layout/VitalsController.cs` — bind `UiText` numbers (Task 8). +- `src/AcDream.App/Rendering/GameWindow.cs` — only property-type follow-through (`.Transcript`/`.Input` types change) if needed (Tasks 5–6). +- `tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs` — new per-Type asserts; flip the two Type-12 tests (Tasks 2–6). +- `tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs` — add `LoadChat()` (Task 1). + +--- + +## Task 1: Chat golden fixture + conformance test (also resolves the input's Type empirically) + +**Files:** +- Create: `tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs` +- Create: `tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json` (generated, committed) +- Modify: `tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs` +- Create: `tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs` + +The generator runs once against the live dat (it is `[Fact(Skip=…)]` so CI never runs it). The committed JSON is dat-free, like `vitals_2100006C.json`. The fixture's resolved `Type` per element **answers spec verification #1** (does input `0x10000016` resolve to 3 or 12?). + +- [ ] **Step 1: Write the generator (skip-by-default).** + +`ChatLayoutFixtureGenerator.cs`: +```csharp +using System.IO; +using System.Runtime.CompilerServices; +using System.Text.Json; +using AcDream.App.UI.Layout; +using DatReaderWriter; +using DatReaderWriter.Options; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// One-off generator for the committed chat golden fixture. Skipped by default — +/// run manually with the real dats present (set ACDREAM_DAT_DIR) to regenerate +/// chat_21000006.json, then commit it. Mirrors how vitals_2100006C.json was made. +/// +public class ChatLayoutFixtureGenerator +{ + [Fact(Skip = "manual: regenerates the committed chat fixture; needs the real dats (ACDREAM_DAT_DIR)")] + public void GenerateChatFixture() + { + var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + using var dats = new DatCollection(datDir, DatAccessType.Read); + var info = LayoutImporter.ImportInfos(dats, 0x21000006u); + Assert.NotNull(info); + + var json = JsonSerializer.Serialize(info, new JsonSerializerOptions + { + IncludeFields = true, + WriteIndented = true, + }); + File.WriteAllText(FixturePath(), json); + } + + // Resolve the SOURCE fixtures dir (not bin/) from this file's compile-time path. + private static string FixturePath([CallerFilePath] string thisFile = "") + => Path.Combine(Path.GetDirectoryName(thisFile)!, "fixtures", "chat_21000006.json"); +} +``` + +- [ ] **Step 2: Generate the fixture (manual, dats present).** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ChatLayoutFixtureGenerator.GenerateChatFixture" -e ACDREAM_DAT_DIR="%USERPROFILE%\Documents\Asheron's Call"` after temporarily removing the `Skip` (or use an IDE run). Confirm `tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json` is written and non-empty, then restore the `Skip`. +Expected: a JSON tree rooted at id `0x10000006`-family with the chat elements. **Record the resolved `Type` of `0x10000016` (input) and `0x10000011` (transcript)** — these drive Task 5/6 decisions. + +- [ ] **Step 3: Add `FixtureLoader.LoadChat()` + `LoadChatInfos()`.** + +In `FixtureLoader.cs`, add (mirroring `LoadVitals`/`LoadVitalsInfos`): +```csharp + public static ImportedLayout LoadChat() + => LayoutImporter.Build(LoadChatInfos(), _ => (0u, 0, 0), null); + + public static AcDream.App.UI.Layout.ElementInfo LoadChatInfos() + => LoadInfos("chat_21000006.json"); + + // Shared loader (refactor LoadVitalsInfos to call this with "vitals_2100006C.json"). + private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName) + { + var path = Path.Combine(AppContext.BaseDirectory, "UI", "Layout", "fixtures", fileName); + if (!File.Exists(path)) throw new FileNotFoundException($"fixture not found at: {path}"); + var bytes = File.ReadAllBytes(path); + ReadOnlySpan span = bytes; + if (span.Length >= 3 && span[0] == 0xEF && span[1] == 0xBB && span[2] == 0xBF) span = span[3..]; + return JsonSerializer.Deserialize(span, _opts) + ?? throw new InvalidOperationException($"fixture deserialized to null: {path}"); + } +``` +Then make `LoadVitalsInfos()` delegate: `public static ElementInfo LoadVitalsInfos() => LoadInfos("vitals_2100006C.json");` + +- [ ] **Step 4: Write the resolved-tree conformance test (fails until the fixture exists).** + +`ChatLayoutConformanceTests.cs`: +```csharp +using System.Collections.Generic; +using AcDream.App.UI.Layout; +namespace AcDream.App.Tests.UI.Layout; + +public class ChatLayoutConformanceTests +{ + private static ElementInfo Find(ElementInfo n, uint id) + { + if (n.Id == id) return n; + foreach (var c in n.Children) { var f = Find(c, id); if (f is not null) return f; } + return null!; + } + + [Fact] + public void ChatFixture_ResolvesKnownElements() + { + var root = FixtureLoader.LoadChatInfos(); + // These ids come from ChatWindowController; the resolved Type proves the base-chain merge. + Assert.NotNull(Find(root, 0x10000011u)); // transcript + Assert.NotNull(Find(root, 0x10000016u)); // input + Assert.NotNull(Find(root, 0x10000012u)); // scrollbar track + Assert.NotNull(Find(root, 0x10000014u)); // channel menu + Assert.NotNull(Find(root, 0x10000019u)); // send button + Assert.NotNull(Find(root, 0x1000046Fu)); // max/min button + } + + [Fact] + public void ChatFixture_ResolvedTypes_MatchRetailRegistry() + { + var root = FixtureLoader.LoadChatInfos(); + Assert.Equal(6u, Find(root, 0x10000014u).Type); // Menu + Assert.Equal(11u, Find(root, 0x10000012u).Type); // Scrollbar + Assert.Equal(1u, Find(root, 0x10000019u).Type); // Button (Send) + Assert.Equal(1u, Find(root, 0x1000046Fu).Type); // Button (Max/Min) + // transcript + input: assert the ACTUAL resolved Type recorded in Step 2. + // From the Map trace both resolve to 12 (Text); if Step 2 shows otherwise, update these. + Assert.Equal(12u, Find(root, 0x10000011u).Type); // Text (transcript) + Assert.Equal(12u, Find(root, 0x10000016u).Type); // Text (input — see Task 6 wrinkle) + } +} +``` + +- [ ] **Step 5: Run the conformance tests.** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ChatLayoutConformanceTests"` +Expected: PASS. If `ChatFixture_ResolvedTypes_MatchRetailRegistry` shows input `0x10000016` Type ≠ 12, **update the assert to the real value and note it in Task 6 Step 1** (decides factory-built vs controller-placed `UiField`). + +- [ ] **Step 6: Commit.** +```bash +git add tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs \ + tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json \ + tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs \ + tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs +git commit -m "test(D.2b): chat golden fixture + resolved-Type conformance (widget-generalization Task 1)" +``` + +--- + +## Task 2: `UiScrollbar` (Type 11) — promote the already-generic scrollbar + +`UiChatScrollbar` has zero chat-specific code; this is a rename + factory registration. + +**Files:** +- Rename: `src/AcDream.App/UI/UiChatScrollbar.cs` → `src/AcDream.App/UI/UiScrollbar.cs` +- Rename: `tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cs` → `tests/AcDream.App.Tests/UI/UiScrollbarTests.cs` +- Modify: `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` +- Modify: `src/AcDream.App/UI/Layout/ChatWindowController.cs` +- Modify: `tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs`, `ChatLayoutConformanceTests.cs` + +- [ ] **Step 1: Rename the widget file + class.** +```bash +git mv src/AcDream.App/UI/UiChatScrollbar.cs src/AcDream.App/UI/UiScrollbar.cs +git mv tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cs tests/AcDream.App.Tests/UI/UiScrollbarTests.cs +``` +In `UiScrollbar.cs`: rename `class UiChatScrollbar` → `class UiScrollbar`; update the doc summary to "Generic scrollbar. Ports retail `UIElement_Scrollbar` (RegisterElementClass(0xb) @ acclient_2013_pseudo_c.txt:124137)…"; keep all body/fields/methods unchanged. +In `UiScrollbarTests.cs`: rename the test class to `UiScrollbarTests`; replace every `UiChatScrollbar` with `UiScrollbar`. (Keep the test bodies.) + +- [ ] **Step 2: Write the failing factory test.** + +In `DatWidgetFactoryTests.cs` add: +```csharp + [Fact] + public void Type11_Scrollbar_MakesUiScrollbar() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 11, Width = 16, Height = 68 }, NoTex, null); + Assert.IsType(e); + } +``` + +- [ ] **Step 3: Run it — verify it fails.** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DatWidgetFactoryTests.Type11_Scrollbar_MakesUiScrollbar"` +Expected: FAIL (`Create` returns `UiDatElement`, not `UiScrollbar`). + +- [ ] **Step 4: Register Type 11 in the factory.** + +In `DatWidgetFactory.Create`, add to the switch (before `_`): +```csharp + 11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137) +``` + +- [ ] **Step 5: Build + run factory + scrollbar tests.** + +Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DatWidgetFactoryTests|FullyQualifiedName~UiScrollbarTests"` +Expected: PASS. + +- [ ] **Step 6: Point the controller at the factory-built scrollbar (still functional).** + +The factory now builds a `UiScrollbar` for the Type-11 track element. In `ChatWindowController.cs`, in the "Scrollbar — replace the imported track placeholder" block, change the construction to bind the factory widget instead of building a fresh one. Replace the block (currently `c.Scrollbar = new UiChatScrollbar { … }; trackParent.RemoveChild(track); trackParent.AddChild(c.Scrollbar);`) with: +```csharp + // The factory built the Type-11 track element as a UiScrollbar. Find it, bind it. + if (layout.FindElement(TrackId) is UiScrollbar bar) + { + bar.Top = 0f; // pull up to the panel top (resize-bar reclaim) + bar.Height = bar.Height + bar.Top; // NOTE: capture old Top before zeroing — see Step 6a + bar.Model = c.Transcript.Scroll; + bar.SpriteResolve = resolve; + bar.TrackSprite = TrackSprite; + bar.ThumbSprite = ThumbSprite; + bar.ThumbTopSprite = ThumbTopSprite; + bar.ThumbBotSprite = ThumbBotSprite; + bar.UpSprite = UpSprite; + bar.DownSprite = DownSprite; + c.Scrollbar = bar; + } +``` +- [ ] **Step 6a: Fix the Top/Height order bug introduced above.** The old code added `track.Top` to height *before* zeroing Top. Write it correctly: +```csharp + if (layout.FindElement(TrackId) is UiScrollbar bar) + { + float oldTop = bar.Top; + bar.Top = 0f; + bar.Height = bar.Height + oldTop; + bar.Model = c.Transcript.Scroll; + bar.SpriteResolve = resolve; + bar.TrackSprite = TrackSprite; bar.ThumbSprite = ThumbSprite; + bar.ThumbTopSprite = ThumbTopSprite; bar.ThumbBotSprite = ThumbBotSprite; + bar.UpSprite = UpSprite; bar.DownSprite = DownSprite; + c.Scrollbar = bar; + } +``` +Change the `Scrollbar` property type: `public UiScrollbar Scrollbar { get; private set; } = null!;` + +- [ ] **Step 7: Update the conformance Type assert (already Type 11) + run full UI suite.** + +`ChatLayoutConformanceTests` already asserts Type 11 for the track. Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` +Expected: PASS (whole UI suite). + +- [ ] **Step 8: Re-check AP-41 in the divergence register.** + +The controller passes `ThumbTopSprite`/`ThumbBotSprite` (3-slice caps), so AP-41 ("thumb single stretched sprite") is stale. In `docs/architecture/retail-divergence-register.md`, update the AP-41 `file:line` from `UiChatScrollbar.cs:37` to `UiScrollbar.cs` and narrow/retire it (the 3-slice path now draws caps; retire only if the fallback single-tile path is no longer reachable — it is reachable when caps are 0, so narrow the wording to "fallback only"). + +- [ ] **Step 9: Commit.** +```bash +git add -A +git commit -m "feat(D.2b): UiScrollbar (Type 11) — promote the generic chat scrollbar (widget-generalization Task 2)" +``` + +--- + +## Task 3: `UiButton` (Type 1) — Send + Max/Min + +The factory currently builds Send/Max-Min as `UiDatElement` and the controller sets `OnClick`/`Label`. Introduce a dedicated `UiButton` mirroring that behavior exactly (so clicks don't regress) and register Type 1. + +**Files:** +- Create: `src/AcDream.App/UI/UiButton.cs` +- Create: `tests/AcDream.App.Tests/UI/UiButtonTests.cs` +- Modify: `DatWidgetFactory.cs`, `ChatWindowController.cs`, `DatWidgetFactoryTests.cs` + +- [ ] **Step 1: Write the failing button-behavior test.** + +`UiButtonTests.cs`: +```csharp +using System.Numerics; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +namespace AcDream.App.Tests.UI; + +public class UiButtonTests +{ + private static (uint, int, int) NoTex(uint _) => (0, 0, 0); + + [Fact] + public void Click_InvokesOnClick() + { + var info = new ElementInfo { Type = 1, Width = 46, Height = 18 }; + var b = new UiButton(info, NoTex) { OnClick = () => Clicked = true }; + b.OnEvent(new UiEvent(UiEventType.Click, 0, 0, 0)); + Assert.True(Clicked); + } + private bool Clicked; + + [Fact] + public void NotClickThrough_SoItReceivesClicks() + { + var b = new UiButton(new ElementInfo { Type = 1 }, NoTex); + Assert.False(b.ClickThrough); + } +} +``` +> Confirm the `UiEvent` constructor signature in `src/AcDream.App/UI/UiEvent.cs` before finalizing the `new UiEvent(...)` call; adjust arg order if needed. + +- [ ] **Step 2: Run it — verify it fails (UiButton does not exist).** + +Run: `dotnet test … --filter "FullyQualifiedName~UiButtonTests"` +Expected: FAIL (compile error: `UiButton` not found). + +- [ ] **Step 3: Write `UiButton`.** + +`UiButton.cs`: +```csharp +using System; +using System.Numerics; +using AcDream.App.UI.Layout; + +namespace AcDream.App.UI; + +/// +/// Generic clickable button. Ports retail UIElement_Button +/// (RegisterElementClass(1, UIElement_Button::Create) @ acclient_2013_pseudo_c.txt:125828): +/// a per-state sprite face + an optional centered caption + a click action. Built by +/// DatWidgetFactory for Type-1 elements (chat Send 0x10000019, Max/Min 0x1000046F). +/// The controller binds OnClick and the caption. State selection mirrors UiDatElement +/// so existing Send/Max-Min behavior is preserved exactly. +/// +public sealed class UiButton : UiElement +{ + private readonly ElementInfo _info; + private readonly Func _resolve; + + public Action? OnClick { get; set; } + public string? Label { get; set; } + public UiDatFont? LabelFont { get; set; } + public Vector4 LabelColor { get; set; } = Vector4.One; + + /// Active state name, runtime-settable (e.g. Max/Min toggling Normal↔Minimized). + public string ActiveState { get; set; } = ""; + + public UiButton(ElementInfo info, Func resolve) + { + _info = info; + _resolve = resolve; + ClickThrough = false; // buttons are interactive + if (!string.IsNullOrEmpty(info.DefaultStateName)) ActiveState = info.DefaultStateName; + else if (info.StateMedia.ContainsKey("Normal")) ActiveState = "Normal"; + } + + private uint ActiveFile() + => _info.StateMedia.TryGetValue(ActiveState, out var m) ? m.File + : _info.StateMedia.TryGetValue("", out var d) ? d.File : 0u; + + protected override void OnDraw(UiRenderContext ctx) + { + uint file = ActiveFile(); + if (file != 0) + { + var (tex, tw, th) = _resolve(file); + if (tex != 0 && tw != 0 && th != 0) + ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One); + } + if (Label is { Length: > 0 } label && LabelFont is { } lf) + { + float tx = (Width - lf.MeasureWidth(label)) * 0.5f; + float ty = (Height - lf.LineHeight) * 0.5f; + ctx.DrawStringDat(lf, label, tx, ty, LabelColor); + } + } + + public override bool OnEvent(in UiEvent e) + { + if (e.Type == UiEventType.Click && OnClick is not null) { OnClick(); return true; } + return false; + } +} +``` + +- [ ] **Step 4: Run the button tests — verify they pass.** + +Run: `dotnet test … --filter "FullyQualifiedName~UiButtonTests"` +Expected: PASS. + +- [ ] **Step 5: Write the failing factory test + register Type 1.** + +In `DatWidgetFactoryTests.cs`: +```csharp + [Fact] + public void Type1_Button_MakesUiButton() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 1, Width = 46, Height = 18 }, NoTex, null); + Assert.IsType(e); + } +``` +In `DatWidgetFactory.Create` switch: +```csharp + 1 => new UiButton(info, resolve), // UIElement_Button (reg :125828) +``` + +- [ ] **Step 6: Update the controller to bind the factory-built buttons.** + +In `ChatWindowController.cs`, the Send block currently does `if (layout.FindElement(SendId) is UiDatElement sendEl) { sendEl.ClickThrough = false; sendEl.OnClick = …; sendEl.Label = "Send"; sendEl.LabelFont = datFont; sendEl.LabelColor = …; }`. Change the cast to `UiButton`: +```csharp + if (layout.FindElement(SendId) is UiButton sendEl) + { + sendEl.OnClick = () => c.Input.Submit(); + sendEl.Label = "Send"; + sendEl.LabelFont = datFont; + sendEl.LabelColor = new Vector4(1f, 0.92f, 0.72f, 1f); + } +``` +And the Max/Min block: change `if (layout.FindElement(MaxMinId) is UiDatElement maxMinEl)` → `is UiButton maxMinEl`, drop the now-unneeded `maxMinEl.ClickThrough = false;` (UiButton is interactive by construction), keep the `maxMinEl.Left = track.Left - maxMinEl.Width;` and `maxMinEl.OnClick = c.ToggleMaximize;`. + +- [ ] **Step 7: Build + run the full UI suite.** + +Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` +Expected: PASS. + +- [ ] **Step 8: Commit.** +```bash +git add -A +git commit -m "feat(D.2b): UiButton (Type 1) — Send + Max/Min as generic buttons (widget-generalization Task 3)" +``` + +--- + +## Task 4: `UiMenu` (Type 6) — genericize the channel menu + +`UiChannelMenu` is the one heavy genericization: move `ChatChannelKind`, the 14-item array, the button-text map, and the availability defaults into `ChatWindowController`; keep all drawing/geometry/event mechanics in a generic `UiMenu` keyed on `object? Payload`. + +**Files:** +- Rename: `src/AcDream.App/UI/UiChannelMenu.cs` → `src/AcDream.App/UI/UiMenu.cs` +- Rename: `tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs` → `tests/AcDream.App.Tests/UI/UiMenuTests.cs` +- Modify: `DatWidgetFactory.cs`, `ChatWindowController.cs`, `DatWidgetFactoryTests.cs` + +- [ ] **Step 1: Rename file + class.** +```bash +git mv src/AcDream.App/UI/UiChannelMenu.cs src/AcDream.App/UI/UiMenu.cs +git mv tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs tests/AcDream.App.Tests/UI/UiMenuTests.cs +``` + +- [ ] **Step 2: Replace the chat-specific members with the generic surface.** + +In `UiMenu.cs`, rename `class UiChannelMenu` → `class UiMenu`; remove `using AcDream.UI.Abstractions;`. Replace the chat-specific members — the `Item` record, the static `Items` array, `Selected` (ChatChannelKind), `OnChannelChanged`, `AvailabilityProvider`, `IsAvailable`, and `ButtonText` — with these generic members: +```csharp + /// One menu row: its label + an opaque payload the controller maps back. + public readonly record struct MenuItem(string Label, object? Payload); + + /// The rows, populated by the controller. Laid out column-major: + /// rows 0..RowsPerColumn-1 in column 0, then the next group in column 1, etc. + public IReadOnlyList Items { get; set; } = System.Array.Empty(); + + /// The currently-selected payload (drives the highlighted row). + public object? Selected { get; set; } + + /// Fired with the picked item's payload when a row is chosen. + public Action? OnSelect { get; set; } + + /// Per-payload enabled gate (disabled rows render greyed + are inert). + /// Null ⇒ all rows enabled. + public Func? EnabledProvider { get; set; } + + /// Button-face caption (the active target). Null ⇒ blank face. + public Func? ButtonLabelProvider { get; set; } +``` +Make the geometry constants settable so a controller/factory can match the dat: +```csharp + public int RowsPerColumn { get; set; } = 7; // items per column (dat item template) + public float RowHeight { get; set; } = 17f; // dat item template 0x1000001E H=17 + public float ColumnWidth { get; set; } = 191f; // dat item template W=191 +``` +Replace the `private const int Rows`/`ItemH`/`ColW` usages with `RowsPerColumn`/`RowHeight`/`ColumnWidth`, and make the derived sizes instance members: +```csharp + private int ColumnCount => (Items.Count + RowsPerColumn - 1) / System.Math.Max(1, RowsPerColumn); + private float InteriorW => ColumnCount * ColumnWidth; + private float InteriorH => RowsPerColumn * RowHeight; + private float OuterW => InteriorW + 2 * Border; + private float OuterH => InteriorH + 2 * Border; +``` + +- [ ] **Step 3: Genericize the draw/event logic (mechanical swaps).** + +In the same file, in `OnDrawOverlay`, `OnEvent`, `OnHitTest`, and `DrawButtonFace`/label: +- Replace `Items[i].Channel is { } c && c == Selected` (selected-row test) with `Equals(Items[i].Payload, Selected)`. +- Replace `Items[i].Channel is not { } c || IsAvailable(c)` (availability) with `EnabledProvider?.Invoke(Items[i].Payload) ?? true`. +- Replace the button caption `ButtonText` with `ButtonLabelProvider?.Invoke() ?? ""` in both `OnDraw` (the `DrawLabel(ctx, ButtonText, …)` call) and `NaturalButtonWidth()` (the `MeasureWidth(ButtonText)`). +- In `OnEvent`'s pick branch, replace the channel-specific selection + ```csharp + if (… && Items[idx].Channel is { } ch && IsAvailable(ch)) { Selected = ch; OnChannelChanged?.Invoke(ch); } + ``` + with + ```csharp + if (row >= 0 && row < RowsPerColumn && idx >= 0 && idx < Items.Count + && (EnabledProvider?.Invoke(Items[idx].Payload) ?? true)) + { + Selected = Items[idx].Payload; + OnSelect?.Invoke(Selected); + } + ``` +- Replace the column/row math `int col = i / Rows, row = i % Rows;` with `RowsPerColumn` and `Items.Length` → `Items.Count`. +Keep `DrawBevel`, `DrawButtonFace`, `DrawSprite`, `DrawLabel`, the sprite-id properties, the colors, and `NaturalButtonWidth()` otherwise unchanged. Update the doc comment to cite `UIElement_Menu (RegisterElementClass(6) @ :120163)` + `MakePopup @0x46d310`. + +- [ ] **Step 4: Update the menu tests for the generic surface.** + +In `UiMenuTests.cs`, rename the class to `UiMenuTests`, replace `UiChannelMenu` → `UiMenu`. Where tests referenced `ChatChannelKind`/`Selected`/`OnChannelChanged`, rewrite them against the generic surface, e.g.: +```csharp + [Fact] + public void ClickingRow_FiresOnSelect_WithPayload() + { + object? picked = null; + var m = new UiMenu + { + Width = 46, Height = 18, + Items = new UiMenu.MenuItem[] { new("Chat to All", "say"), new("Trade", "trade") }, + OnSelect = p => picked = p, + }; + // open, then click row 0 (geometry per RowsPerColumn/RowHeight — mirror the + // existing test's click coords, which used the same 17px rows). + m.OnEvent(new UiEvent(UiEventType.MouseDown, 0, 0, 0)); // toggle open + // … click into row 0 of the open popup (reuse the prior test's local coords) … + Assert.Equal("say", picked); + } +``` +> Reuse the exact open/click coordinates from the original `UiChannelMenuTests` (they map into the same popup geometry); only the payload/selection assertions change. + +- [ ] **Step 5: Run the menu tests — green.** + +Run: `dotnet test … --filter "FullyQualifiedName~UiMenuTests"` +Expected: PASS. + +- [ ] **Step 6: Failing factory test + register Type 6.** + +In `DatWidgetFactoryTests.cs`: +```csharp + [Fact] + public void Type6_Menu_MakesUiMenu() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 6, Width = 46, Height = 18 }, NoTex, null); + Assert.IsType(e); + } +``` +In `DatWidgetFactory.Create` switch: +```csharp + 6 => new UiMenu(), // UIElement_Menu (reg :120163) +``` + +- [ ] **Step 7: Move the channel knowledge into `ChatWindowController`.** + +In `ChatWindowController.cs`, add the channel item table + maps (ported verbatim from the old `UiChannelMenu`): +```csharp + // Talk-focus channels (ported from the old UiChannelMenu — gmMainChatUI::InitTalkFocusMenu @0x4cdc50). + private static readonly (string Label, ChatChannelKind? Channel)[] ChannelItems = + { + ("Squelch (ignore)", null), + ("Tell to Selected", null), + ("Chat to All", ChatChannelKind.Say), + ("Tell to Fellows", ChatChannelKind.Fellowship), + ("Tell to General Chat", ChatChannelKind.General), + ("Tell to LFG Chat", ChatChannelKind.Lfg), + ("Tell to Society Chat", ChatChannelKind.Society), + ("Tell to Monarch", ChatChannelKind.Monarch), + ("Tell to Patron", ChatChannelKind.Patron), + ("Tell to Vassals", ChatChannelKind.Vassals), + ("Tell to Allegiance", ChatChannelKind.Allegiance), + ("Tell to Trade Chat", ChatChannelKind.Trade), + ("Tell to Roleplay Chat", ChatChannelKind.Roleplay), + ("Tell to Olthoi Chat", ChatChannelKind.Olthoi), + }; + + private static string ChannelButtonLabel(ChatChannelKind k) => k switch + { + ChatChannelKind.Say => "Chat", ChatChannelKind.General => "General", + ChatChannelKind.Trade => "Trade", ChatChannelKind.Lfg => "LFG", + ChatChannelKind.Fellowship => "Fellow", ChatChannelKind.Allegiance => "Alleg", + ChatChannelKind.Patron => "Patron", ChatChannelKind.Vassals => "Vassals", + ChatChannelKind.Monarch => "Monarch", ChatChannelKind.Roleplay => "Roleplay", + ChatChannelKind.Society => "Society", ChatChannelKind.Olthoi => "Olthoi", + _ => "Chat", + }; + + private static bool ChannelAvailable(ChatChannelKind k) + => k is ChatChannelKind.Say or ChatChannelKind.General or ChatChannelKind.Trade or ChatChannelKind.Lfg; +``` +Replace the "Channel menu — replace the imported menu placeholder" block. The factory now builds the Type-6 element as a `UiMenu`; find it and populate it: +```csharp + if (layout.FindElement(MenuId) is UiMenu menu) + { + menu.DatFont = datFont; menu.Font = debugFont; menu.SpriteResolve = resolve; + menu.NormalSprite = MenuNormal; menu.PressedSprite = MenuPressed; + menu.PopupBgSprite = MenuPopupBg; + menu.ItemNormalSprite = MenuItemRow; menu.ItemHighlightSprite = MenuItemSelected; + menu.Items = System.Array.ConvertAll(ChannelItems, + t => new UiMenu.MenuItem(t.Label, (object?)t.Channel)); + menu.Selected = (object?)c._activeChannel; + menu.EnabledProvider = p => p is not ChatChannelKind ch || ChannelAvailable(ch); + menu.ButtonLabelProvider = () => ChannelButtonLabel(c._activeChannel); + menu.OnSelect = p => + { + if (p is ChatChannelKind ch) { c._activeChannel = ch; menu.Selected = p; } + }; + c.Menu = menu; + } +``` +Update the `Menu` property type: `public UiMenu Menu { get; private set; } = null!;` Update the reflow block (`ReflowInputRow`) — it calls `c.Menu.NaturalButtonWidth()`, `c.Menu.ResetAnchorCapture()` (both still exist on `UiMenu`), and wraps `c.Menu.OnChannelChanged`. Replace the `OnChannelChanged` wrap with the generic `OnSelect`: +```csharp + var onSelect = c.Menu.OnSelect; + c.Menu.OnSelect = p => { onSelect?.Invoke(p); ReflowInputRow(); }; +``` +> `_activeChannel` already exists on the controller; the old per-menu `OnChannelChanged = k => c._activeChannel = k;` is now folded into `OnSelect`. + +- [ ] **Step 8: Build + run the full UI suite.** + +Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` +Expected: PASS. + +- [ ] **Step 9: Add a divergence row if the generic menu lost fidelity.** + +The generic `UiMenu` item model is flat (label+payload, no submenu/hierarchical popup). If that is a new approximation vs `UIElement_Menu::MakePopup`'s nested popups, add a row to `docs/architecture/retail-divergence-register.md` (Adaptation) citing `src/AcDream.App/UI/UiMenu.cs` + `MakePopup @0x46d310`. (The chat menu is single-level, so this is a latent note, not a behavior change.) + +- [ ] **Step 10: Commit.** +```bash +git add -A +git commit -m "feat(D.2b): UiMenu (Type 6) — generic dropdown; channel knowledge moves to controller (widget-generalization Task 4)" +``` + +--- + +## Task 5: `UiText` (Type 12) — transcript + the Type-12 flip + +Rename `UiChatView` → `UiText`, default its background to transparent + add an optional dat state-sprite background (so any Type-12-with-sprite element keeps rendering its sprite), register Type 12, flip the two factory Type-12 tests, and have the controller bind the factory-built transcript. An **unbound `UiText` must draw nothing** so vitals stays frozen. + +**Files:** +- Rename: `src/AcDream.App/UI/UiChatView.cs` → `src/AcDream.App/UI/UiText.cs` +- Rename: `tests/AcDream.App.Tests/UI/UiChatViewTests.cs` → `UiTextTests.cs`; `UiChatViewDatFontTests.cs` → `UiTextDatFontTests.cs` +- Modify: `DatWidgetFactory.cs`, `LayoutImporter.cs` (none needed — Text recurses normally), `ChatWindowController.cs`, `DatWidgetFactoryTests.cs`, `GameWindow.cs` + +- [ ] **Step 1: Rename file + class + tests.** +```bash +git mv src/AcDream.App/UI/UiChatView.cs src/AcDream.App/UI/UiText.cs +git mv tests/AcDream.App.Tests/UI/UiChatViewTests.cs tests/AcDream.App.Tests/UI/UiTextTests.cs +git mv tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs tests/AcDream.App.Tests/UI/UiTextDatFontTests.cs +``` +In `UiText.cs`: rename `class UiChatView` → `class UiText`; the nested `Line`/`Pos` records, `LinesProvider`, selection, and scroll stay. Update the doc to cite `UIElement_Text (RegisterElementClass(0xc) @ :115655)`. In the test files, rename classes + replace `UiChatView` → `UiText`. + +- [ ] **Step 2: Default the background to transparent (so an unbound UiText is invisible).** + +In `UiText.cs`, change: +```csharp + public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0f); // transparent by default +``` +(was `(0,0,0,0.35)`). `OnDraw`'s `ctx.DrawFill(0,0,Width,Height,BackgroundColor)` then draws nothing when transparent. The chat controller will set the translucent value explicitly (Step 6). + +- [ ] **Step 3: Add an optional dat state-sprite background (faithful UIElement_Text media).** + +So a Type-12 element that carries its own sprite (currently rendered by `UiDatElement`) does not lose it. Add to `UiText`: +```csharp + /// Optional dat state-sprite background (the element's own media), drawn + /// UNDER the text. Set by DatWidgetFactory.BuildText from the ElementInfo. 0 = none. + public uint BackgroundSprite { get; set; } + public Func? SpriteResolve { get; set; } +``` +At the very top of `OnDraw`, before `DrawFill`: +```csharp + if (BackgroundSprite != 0 && SpriteResolve is { } sr) + { + var (tex, tw, th) = sr(BackgroundSprite); + if (tex != 0 && tw != 0 && th != 0) + ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One); + } +``` + +- [ ] **Step 4: Write the failing factory test (and flip the two existing Type-12 tests).** + +In `DatWidgetFactoryTests.cs`: +- Add: +```csharp + [Fact] + public void Type12_Text_MakesUiText() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 12, Width = 100, Height = 40 }, NoTex, null); + Assert.IsType(e); + } +``` +- Replace `Type12_StylePrototype_ReturnsNull` (delete it — Type 12 is no longer skipped). +- Replace `DatWidgetFactory_Type12WithMedia_Renders` body to assert `UiText` for both media and no-media: +```csharp + [Fact] + public void DatWidgetFactory_Type12_AlwaysMakesUiText() + { + var withMedia = new ElementInfo { Type = 12, Width = 32, Height = 16, + StateMedia = { ["Normal"] = (0x00001234u, 1) } }; + Assert.IsType(DatWidgetFactory.Create(withMedia, NoTex, null)); + Assert.IsType(DatWidgetFactory.Create(new ElementInfo { Type = 12 }, NoTex, null)); + } +``` + +- [ ] **Step 5: Run — verify the new/flipped tests fail.** + +Run: `dotnet test … --filter "FullyQualifiedName~DatWidgetFactoryTests"` +Expected: FAIL on the Type-12 asserts (factory still returns null / UiDatElement). + +- [ ] **Step 6: Register Type 12 + add `BuildText`; remove the skip.** + +In `DatWidgetFactory.cs`: +- Delete the skip line `if (info.Type == 12 && info.StateMedia.Count == 0) return null;`. +- Add to the switch: +```csharp + 12 => BuildText(info, resolve), // UIElement_Text (reg :115655) +``` +- Add the builder: +```csharp + /// Type-12 UIElement_Text: a scrollable colored-line text view. The + /// element's own Direct/Normal media (if any) becomes the background sprite, drawn + /// under the text — so a Type-12 element that previously rendered via UiDatElement + /// keeps its sprite. Lines are bound later by the controller (LinesProvider). + private static UiText BuildText(ElementInfo info, Func resolve) + { + uint bg = info.StateMedia.TryGetValue( + !string.IsNullOrEmpty(info.DefaultStateName) ? info.DefaultStateName + : info.StateMedia.ContainsKey("Normal") ? "Normal" : "", out var m) + ? m.File : 0u; + return new UiText { BackgroundSprite = bg, SpriteResolve = resolve }; + } +``` +> Update the `Create` summary/`` doc that referenced Type-12 returning null. + +- [ ] **Step 7: Verify factory + vitals fixture still green (vitals frozen).** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DatWidgetFactoryTests|FullyQualifiedName~LayoutConformanceTests|FullyQualifiedName~VitalsBindingTests"` +Expected: PASS. The vitals number text elements are meter-children (consumed, never built — `LayoutImporter.cs:113`), and any other vitals Type-12 element now builds as an unbound, transparent `UiText` (draws only its own sprite, if it had one — same as before). **Spec verification #2:** if a vitals conformance test fails, a standalone Type-12 element changed class — inspect it; its sprite must still draw via `BackgroundSprite`. + +- [ ] **Step 8: Controller binds the factory-built transcript (instead of constructing it).** + +In `ChatWindowController.cs`, the factory now builds the Type-12 transcript element `0x10000011` as a `UiText`. Replace the "Transcript" block (which read `tInfo` and `new UiChatView { … }; transcriptPanel.AddChild(...)`) with find-and-bind: +```csharp + // The factory built the Type-12 transcript as a UiText; find + bind it. + c.Transcript = layout.FindElement(TranscriptId) as UiText + ?? throw new InvalidOperationException("chat transcript 0x10000011 not built as UiText"); + c.Transcript.DatFont = datFont; + c.Transcript.Font = debugFont; + c.Transcript.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); // retail translucent transcript + c.Transcript.LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont); +``` +Change the `Transcript` property type to `public UiText Transcript { get; private set; } = null!;`. Remove the now-unused `tInfo` lookup + the `transcriptPanel.AddChild` (the transcript is already in the tree at its dat position). Keep the `transcriptPanel.Top/Height` resize-bar reclaim. + +Also in `ChatWindowController.cs`, replace **every** `UiChatView.Line` with `UiText.Line` — this hits `BuildLines` (its `UiText view` parameter, its `IReadOnlyList` return type, the `Array.Empty()`, and the `new UiText.Line(frag, color)` inside the wrap loop). `WrapText`/`RetailChatColor` are unaffected (they return `string`/`Vector4`). + +Finally, repoint the `Bind` early-guard: it currently does `var tInfo = FindInfo(rootInfo, TranscriptId);` and checks `tInfo is null`. The transcript is now found via `layout.FindElement(TranscriptId)`; change the guard to null-check the factory-built widgets it needs (`layout.FindElement(TranscriptPanelId)` for the panel, plus the transcript/input found in their Steps). The `iInfo` lookup stays only for Task 6 Variant B. (Full guard tidy lands in Task 7.) + +- [ ] **Step 9: GameWindow follow-through.** + +`GameWindow.cs:1860` (`chatController.Transcript.Keyboard = …`) still compiles (`UiText.Keyboard` exists). Build to confirm. + +- [ ] **Step 10: Build + full UI suite.** + +Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` +Expected: PASS. + +- [ ] **Step 11: Amend AP-37 (Type-0 text skip retired).** + +In `docs/architecture/retail-divergence-register.md`, edit AP-37: remove the "Standalone Type-0 text elements are also skipped (… a dedicated dat-text widget is Plan 2)" clause (now shipped as `UiText`). Keep the meter-collapse clause and the vitals-numbers-via-`UiMeter.Label` clause (retired in Task 8). + +- [ ] **Step 12: Commit.** +```bash +git add -A +git commit -m "feat(D.2b): UiText (Type 12) — generic text + Type-12 flip; transcript factory-built (widget-generalization Task 5)" +``` + +--- + +## Task 6: `UiField` (Type 3) — editable input + +Rename `UiChatInput` → `UiField`, register Type 3, and wire the input. **Input handling depends on Task 1 Step 5's recorded resolved Type** for `0x10000016`: +- **If it resolved to Type 3:** the factory builds `UiField` directly; the controller finds + binds it. +- **If it resolved to Type 12** (per the Map trace): the factory built it as a `UiText`; the controller *replaces* it with a `UiField` at the same rect (the existing replace pattern). + +**Files:** +- Rename: `src/AcDream.App/UI/UiChatInput.cs` → `src/AcDream.App/UI/UiField.cs`; `UiChatInputTests.cs` → `UiFieldTests.cs` +- Modify: `DatWidgetFactory.cs`, `ChatWindowController.cs`, `DatWidgetFactoryTests.cs`, `GameWindow.cs` + +- [ ] **Step 1: Confirm the input's resolved Type from Task 1, choose the path.** + +Re-read `ChatLayoutConformanceTests.ChatFixture_ResolvedTypes_MatchRetailRegistry` (Task 1) for `0x10000016`. Note "Type 3 → direct build" or "Type 12 → controller-place". Proceed with the matching variant in Step 6. + +- [ ] **Step 2: Rename file + class + tests.** +```bash +git mv src/AcDream.App/UI/UiChatInput.cs src/AcDream.App/UI/UiField.cs +git mv tests/AcDream.App.Tests/UI/UiChatInputTests.cs tests/AcDream.App.Tests/UI/UiFieldTests.cs +``` +In `UiField.cs`: rename `class UiChatInput` → `class UiField`; body unchanged. Update doc to cite `UIElement_Field (RegisterElementClass(3) @ :126190)` + the drag-drop hooks (`CatchDroppedItem`/`MouseOverTop`) it will host for future item windows. In `UiFieldTests.cs`: rename class, replace `UiChatInput` → `UiField`. + +- [ ] **Step 3: Default the background to transparent (consistency with UiText).** + +Change `UiField.BackgroundColor` default to `new(0f, 0f, 0f, 0f)`. The controller sets the translucent value (Step 6). + +- [ ] **Step 4: Failing factory test + register Type 3.** + +In `DatWidgetFactoryTests.cs`: +```csharp + [Fact] + public void Type3_Field_MakesUiField() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 3, Width = 200, Height = 16 }, NoTex, null); + Assert.IsType(e); + } +``` +In `DatWidgetFactory.Create` switch: +```csharp + 3 => new UiField(), // UIElement_Field (reg :126190) +``` + +- [ ] **Step 5: Run — verify pass.** + +Run: `dotnet test … --filter "FullyQualifiedName~DatWidgetFactoryTests.Type3_Field_MakesUiField|FullyQualifiedName~UiFieldTests"` +Expected: PASS. + +- [ ] **Step 6: Wire the input in the controller (variant per Step 1).** + +Replace the "Input" block (`new UiChatInput { … }; inputBar.AddChild(c.Input); c.Input.OnSubmit = …`). + +**Variant A — input resolved to Type 3 (factory-built):** +```csharp + c.Input = layout.FindElement(InputId) as UiField + ?? throw new InvalidOperationException("chat input 0x10000016 not built as UiField"); + c.Input.DatFont = datFont; c.Input.Font = debugFont; + c.Input.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); + c.Input.SpriteResolve = resolve; c.Input.FocusFieldSprite = InputFocusField; + c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel); +``` + +**Variant B — input resolved to Type 12 (controller-placed UiField over the UiText):** +```csharp + // 0x10000016 resolves to Type-12 Text in this layout; the editable entry is a + // controller-placed UiField at the dat element's rect (retail authors a separate Field). + var iInfo = FindInfo(rootInfo, InputId) + ?? throw new InvalidOperationException("chat input info 0x10000016 missing"); + if (layout.FindElement(InputId) is { Parent: { } iparent } placeholder) + iparent.RemoveChild(placeholder); // drop the read-only Text placeholder + c.Input = new UiField + { + Left = iInfo.X, Top = iInfo.Y, Width = iInfo.Width, Height = iInfo.Height, + Anchors = ElementReader.ToAnchors(iInfo.Left, iInfo.Top, iInfo.Right, iInfo.Bottom), + DatFont = datFont, Font = debugFont, + BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f), + SpriteResolve = resolve, FocusFieldSprite = InputFocusField, + }; + (inputBar).AddChild(c.Input); + c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel); +``` +Change the `Input` property type to `public UiField Input { get; private set; } = null!;` (Keep `FindInfo` for Variant B; it may become unused in Variant A — remove it then.) + +- [ ] **Step 7: GameWindow follow-through.** + +`GameWindow.cs:1861` (`chatController.Input.Keyboard = …`) still compiles (`UiField.Keyboard` exists). Build to confirm. + +- [ ] **Step 8: Build + full UI suite.** + +Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` +Expected: PASS. + +- [ ] **Step 9: Commit.** +```bash +git add -A +git commit -m "feat(D.2b): UiField (Type 3) — editable input as a generic field (widget-generalization Task 6)" +``` + +--- + +## Task 7: Thin + verify the controller; remove dead construction + +After Tasks 2–6, `ChatWindowController.Bind` should construct no widgets (except the Variant-B input). Audit and tidy. + +**Files:** +- Modify: `src/AcDream.App/UI/Layout/ChatWindowController.cs` + +- [ ] **Step 1: Remove dead helpers + confirm find-by-id shape.** + +In `ChatWindowController.cs`: confirm every widget is obtained via `layout.FindElement(id) as UiX` and only data/callbacks are bound. Remove any now-unused locals (`transcriptPanel`/`inputBar` are still used for the resize-bar reclaim / Variant-B parent — keep those; remove `tInfo`/`FindInfo` if Variant A). Confirm the class doc reads as the `gmMainChatUI::PostInit @0x4ce130` analogue (find child by id → bind). + +- [ ] **Step 2: Update `ChatWindowControllerTests` for the new types.** + +In `tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs`, update any references to `UiChatView`/`UiChatInput`/`UiChatScrollbar`/`UiChannelMenu` to `UiText`/`UiField`/`UiScrollbar`/`UiMenu`, and any assertions on `.Selected`/`OnChannelChanged` to the generic `OnSelect`/payload surface. Run them to confirm the binding still wires the right elements. + +- [ ] **Step 3: Build + full UI suite.** + +Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` +Expected: PASS. + +- [ ] **Step 4: Visual gate (user) — chat unchanged.** + +Launch the client (`ACDREAM_RETAIL_UI=1`, per CLAUDE.md launch recipe) and confirm the chat window looks + behaves identically to before this pass: transcript scroll/select/copy, input write-mode/history/clipboard, channel dropdown, send, max/min, scrollbar drag. **Stop for user confirmation.** + +- [ ] **Step 5: Commit.** +```bash +git add -A +git commit -m "refactor(D.2b): ChatWindowController is now a thin find-by-id binder (widget-generalization Task 7)" +``` + +--- + +## Task 8 (GATED): vitals numbers as `UiText` + +Rewire the vitals number text from `UiMeter.Label` to factory-built `UiText` (retail-faithful: vitals numbers are `UIElement_Text`). **This is a stop-and-confirm gate** — vitals shipped pixel-identical and is fixture-locked. If it risks the pixel-identical result, **stop and keep `UiMeter.Label`** (narrow AP-37 instead). + +**Files:** +- Modify: `src/AcDream.App/UI/Layout/VitalsController.cs`, `LayoutImporter.cs` (meter child handling), `GameWindow.cs` (Bind call), `tests/.../VitalsBindingTests.cs`, `fixtures/vitals_2100006C.json` + +- [ ] **Step 1: Decide the number element's path.** + +The vitals number text is a **meter child** (consumed; `LayoutImporter.cs:113` does not recurse meter children). To render it as a real `UiText`, either (a) have `VitalsController` construct a `UiText` at the number element's rect (read from the meter's children — mirrors the chat Variant-B pattern), or (b) stop consuming the meter's text child so the factory builds it. **Prefer (a)** — it is local to `VitalsController` and does not disturb the meter slice extraction. Read the number element's rect from `DatWidgetFactory.BuildMeter`'s skipped text child (expose it, or re-read via the layout's `ElementInfo`). + +- [ ] **Step 2: Write a failing binding test.** + +In `VitalsBindingTests.cs`, add a test that, after `VitalsController.Bind`, a `UiText` exists for each vital and its `LinesProvider` returns the cur/max string. (Use the vitals fixture; assert the text node is present + bound.) + +- [ ] **Step 3: Implement the `UiText` number binding in `VitalsController`.** + +Add a `UiText` per meter (constructed at the number rect, single centered line). Keep `UiMeter.Label` unset for vitals. Bind `LinesProvider = () => new[] { new UiText.Line(text(), color) }` (centered — add a `UiText.CenterSingleLine` option or a thin overload if needed for horizontal centering). +> If centering a single line requires new `UiText` layout support, add a minimal `public bool CenterHorizontally` flag to `UiText` with a unit test, rather than overloading the chat path. + +- [ ] **Step 4: Build + run vitals tests.** + +Run: `dotnet test … --filter "FullyQualifiedName~VitalsBindingTests|FullyQualifiedName~LayoutConformanceTests"` +Expected: PASS. Update `vitals_2100006C.json` only if the resolved tree legitimately changed (it should not — the change is in binding, not the tree). + +- [ ] **Step 5: Visual gate (user) — vitals pixel-identical.** + +Launch (`ACDREAM_RETAIL_UI=1`); confirm the vitals numbers render identically (font, position, centering, color) to the shipped `UiMeter.Label` version. **Stop for user confirmation. If not identical → revert this task and narrow AP-37 instead.** + +- [ ] **Step 6: Retire/narrow AP-37 + update memory.** + +If the rewire lands: in `docs/architecture/retail-divergence-register.md`, retire the AP-37 vitals-numbers clause (now real `UiText`). Update `claude-memory/project_d2b_retail_ui.md` (the generalization pass shipped) + the roadmap. + +- [ ] **Step 7: Commit.** +```bash +git add -A +git commit -m "feat(D.2b): vitals numbers as UiText (widget-generalization Task 8, gated)" +``` + +--- + +## Done criteria (from spec §8) + +- [ ] `DatWidgetFactory` registers Types 1, 3, 6, 11, 12 (+ 7) → generic widgets; `_` still → `UiDatElement`. +- [ ] The `Type==12 → null` skip is removed; no Type-12 element is double-built (fixtures green). +- [ ] No `ChatChannelKind`/chat-color/command-routing knowledge inside any widget; `ChatWindowController` only finds-by-id and binds. +- [ ] Chat window visually + behaviorally identical through Tasks 2–7 (user-confirmed, Task 7 Step 4). +- [ ] `chat_21000006.json` golden fixture + renamed generic-widget tests all green. +- [ ] Vitals window unchanged after Task 8 (user-confirmed), or Task 8 deferred with AP-37 narrowed. +- [ ] Every generic widget cites its retail `UIElement_X` class + reg. line. +- [ ] Divergence register updated (AP-37 amended; AP-41 re-checked) in the same commits. +- [ ] Roadmap / `claude-memory/project_d2b_retail_ui.md` updated when the pass lands. From d1b13a7dbf7f92a8b380dedbd7470eb777193e1f Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 16:55:51 +0200 Subject: [PATCH 86/99] test(D.2b): chat golden fixture + resolved-Type conformance (widget-generalization Task 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ChatLayoutFixtureGenerator.cs (Skip-by-default) to regenerate chat_21000006.json from the live portal.dat via LayoutImporter.ImportInfos - Commit generated fixture chat_21000006.json (13 KB, 400 lines) — dat-free, auto-copied to test output via existing *.json csproj glob - Refactor FixtureLoader: extract shared LoadInfos(fileName) helper; add LoadChat() + LoadChatInfos() mirroring the vitals pattern; LoadVitalsInfos() now delegates to the shared loader (behavior unchanged, vitals tests green) - Add ChatLayoutConformanceTests: ResolvesKnownElements + ResolvedTypes_MatchRetailRegistry Confirmed resolved Types from live dat: 0x10000011 (transcript) → Type 12 (style-prototype, skipped by factory) 0x10000016 (input) → Type 12 (style-prototype, skipped by factory) 0x10000014 (menu) → Type 6 0x10000012 (scrollbar) → Type 11 0x10000019 (send) → Type 1 0x1000046F (max/min) → Type 1 Also fix pre-existing build break: UiChatInput.MoveCaret(int delta) was made private in ce848c1 but UiChatInputTests.Backspace_DeletesBeforeCaret called it as public. Expose a public MoveCaret(int) overload (no-shift) alongside the private MoveCaret(int,bool) — restores the intended test surface. Full suite: 398 passed, 2 skipped (generator + pre-existing), 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/UiChatInput.cs | 4 + .../UI/Layout/ChatLayoutConformanceTests.cs | 46 ++ .../UI/Layout/ChatLayoutFixtureGenerator.cs | 39 ++ .../UI/Layout/FixtureLoader.cs | 39 +- .../UI/Layout/fixtures/chat_21000006.json | 542 ++++++++++++++++++ 5 files changed, 661 insertions(+), 9 deletions(-) create mode 100644 tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json diff --git a/src/AcDream.App/UI/UiChatInput.cs b/src/AcDream.App/UI/UiChatInput.cs index 58c6e4a0..730a7175 100644 --- a/src/AcDream.App/UI/UiChatInput.cs +++ b/src/AcDream.App/UI/UiChatInput.cs @@ -101,6 +101,10 @@ public sealed class UiChatInput : UiElement _historyIndex = -1; } + /// Move the caret left (negative) or right (positive) by + /// glyph positions without extending a selection. Public for test access. + public void MoveCaret(int delta) => MoveCaretTo(_caret + delta, false); + private void MoveCaret(int delta, bool shift) => MoveCaretTo(_caret + delta, shift); // ── Selection ──────────────────────────────────────────────────────── diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs new file mode 100644 index 00000000..836adbdc --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs @@ -0,0 +1,46 @@ +using AcDream.App.UI.Layout; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Dat-free conformance tests for the committed chat_21000006.json golden fixture. +/// Verifies that LayoutImporter.ImportInfos correctly resolves the BaseElement / +/// BaseLayoutId inheritance chain for the chat window (LayoutDesc 0x21000006). +/// +public class ChatLayoutConformanceTests +{ + private static ElementInfo? Find(ElementInfo n, uint id) + { + if (n.Id == id) return n; + foreach (var c in n.Children) + { + var f = Find(c, id); + if (f is not null) return f; + } + return null; + } + + [Fact] + public void ChatFixture_ResolvesKnownElements() + { + var root = FixtureLoader.LoadChatInfos(); + Assert.NotNull(Find(root, 0x10000011u)); // transcript + Assert.NotNull(Find(root, 0x10000016u)); // input + Assert.NotNull(Find(root, 0x10000012u)); // scrollbar track + Assert.NotNull(Find(root, 0x10000014u)); // channel menu + Assert.NotNull(Find(root, 0x10000019u)); // send button + Assert.NotNull(Find(root, 0x1000046Fu)); // max/min button + } + + [Fact] + public void ChatFixture_ResolvedTypes_MatchRetailRegistry() + { + var root = FixtureLoader.LoadChatInfos(); + Assert.Equal(6u, Find(root, 0x10000014u)!.Type); // Menu + Assert.Equal(11u, Find(root, 0x10000012u)!.Type); // Scrollbar + Assert.Equal(1u, Find(root, 0x10000019u)!.Type); // Button (Send) + Assert.Equal(1u, Find(root, 0x1000046Fu)!.Type); // Button (Max/Min) + Assert.Equal(12u, Find(root, 0x10000011u)!.Type); // Text/style-prototype (transcript) + Assert.Equal(12u, Find(root, 0x10000016u)!.Type); // Text/style-prototype (input) + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs new file mode 100644 index 00000000..cdc89c5f --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs @@ -0,0 +1,39 @@ +using System; +using System.IO; +using System.Runtime.CompilerServices; +using System.Text.Json; +using AcDream.App.UI.Layout; +using DatReaderWriter; +using DatReaderWriter.Options; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// One-off generator for the committed chat golden fixture. Skipped by default — +/// run manually with the real dats present (set ACDREAM_DAT_DIR) to regenerate +/// chat_21000006.json, then commit it. Mirrors how vitals_2100006C.json was made. +/// +public class ChatLayoutFixtureGenerator +{ + [Fact(Skip = "manual: regenerates the committed chat fixture; needs the real dats (ACDREAM_DAT_DIR)")] + public void GenerateChatFixture() + { + var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + using var dats = new DatCollection(datDir, DatAccessType.Read); + var info = LayoutImporter.ImportInfos(dats, 0x21000006u); + Assert.NotNull(info); + + var json = JsonSerializer.Serialize(info, new JsonSerializerOptions + { + IncludeFields = true, + WriteIndented = true, + }); + File.WriteAllText(FixturePath(), json); + } + + // Resolve the SOURCE fixtures dir (not bin/) from this file's compile-time path. + private static string FixturePath([CallerFilePath] string thisFile = "") + => Path.Combine(Path.GetDirectoryName(thisFile)!, "fixtures", "chat_21000006.json"); +} diff --git a/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs b/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs index 724a0e89..c7338ba1 100644 --- a/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs +++ b/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs @@ -5,9 +5,9 @@ using AcDream.App.UI.Layout; namespace AcDream.App.Tests.UI.Layout; /// -/// Loads the committed vitals ElementInfo fixture and builds the widget tree — -/// no dats required. The fixture was generated from layout 0x2100006C -/// via the real portal.dat and serialized with . +/// Loads the committed layout ElementInfo fixtures and builds widget trees — +/// no dats required. Fixtures were generated from the real portal.dat and +/// serialized with . /// public static class FixtureLoader { @@ -37,18 +37,39 @@ public static class FixtureLoader /// widget factory. /// public static AcDream.App.UI.Layout.ElementInfo LoadVitalsInfos() - { - var fixturePath = Path.Combine(AppContext.BaseDirectory, "UI", "Layout", "fixtures", "vitals_2100006C.json"); - if (!File.Exists(fixturePath)) - throw new FileNotFoundException($"Vitals fixture not found at: {fixturePath}"); + => LoadInfos("vitals_2100006C.json"); - var bytes = File.ReadAllBytes(fixturePath); + /// + /// Deserializes the committed chat_21000006.json fixture into a raw + /// tree and builds the + /// using a null-returning sprite resolver and no dat font — sufficient for + /// conformance checks on tree structure and resolved types. + /// + public static ImportedLayout LoadChat() + => LayoutImporter.Build(LoadChatInfos(), _ => (0u, 0, 0), null); + + /// + /// Deserializes the committed chat_21000006.json fixture into a raw + /// tree WITHOUT calling . + /// Use this when the test needs to inspect the resolved + /// tree directly (e.g. resolved Type values per element id). + /// + public static AcDream.App.UI.Layout.ElementInfo LoadChatInfos() + => LoadInfos("chat_21000006.json"); + + // ── Shared loader ──────────────────────────────────────────────────────── + + private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName) + { + var path = Path.Combine(AppContext.BaseDirectory, "UI", "Layout", "fixtures", fileName); + if (!File.Exists(path)) throw new FileNotFoundException($"fixture not found at: {path}"); + var bytes = File.ReadAllBytes(path); // Strip UTF-8 BOM (EF BB BF) if present so JsonSerializer.Deserialize(ReadOnlySpan) // does not reject the first byte. ReadOnlySpan span = bytes; if (span.Length >= 3 && span[0] == 0xEF && span[1] == 0xBB && span[2] == 0xBF) span = span[3..]; return JsonSerializer.Deserialize(span, _opts) - ?? throw new InvalidOperationException($"fixture deserialized to null: {fixturePath}"); + ?? throw new InvalidOperationException($"fixture deserialized to null: {path}"); } } diff --git a/tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json b/tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json new file mode 100644 index 00000000..37783bb7 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json @@ -0,0 +1,542 @@ +{ + "Id": 0, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 0, + "Height": 0, + "Left": 0, + "Top": 0, + "Right": 0, + "Bottom": 0, + "ReadOrder": 0, + "FontDid": 0, + "StateMedia": {}, + "DefaultStateName": "", + "Children": [ + { + "Id": 268435484, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 382, + "Height": 104, + "Left": 1, + "Top": 2, + "Right": 2, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100667980, + "Item2": 1 + } + }, + "DefaultStateName": "", + "Children": [ + { + "Id": 268435485, + "Type": 5, + "X": 0, + "Y": 2, + "Width": 382, + "Height": 102, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": {}, + "DefaultStateName": "", + "Children": [] + } + ] + }, + { + "Id": 268436774, + "Type": 1, + "X": 2, + "Y": 0, + "Width": 16, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 2, + "ReadOrder": 3, + "FontDid": 1073741861, + "StateMedia": { + "Normal": { + "Item1": 100688408, + "Item2": 1 + }, + "Highlight": { + "Item1": 100688409, + "Item2": 1 + } + }, + "DefaultStateName": "Normal", + "Children": [] + }, + { + "Id": 268435486, + "Type": 12, + "X": 0, + "Y": 0, + "Width": 191, + "Height": 17, + "Left": 0, + "Top": 0, + "Right": 0, + "Bottom": 0, + "ReadOrder": 2, + "FontDid": 1073741825, + "StateMedia": { + "Normal": { + "Item1": 100667982, + "Item2": 1 + }, + "Ghosted": { + "Item1": 100667982, + "Item2": 1 + }, + "Talkfocus_highlight": { + "Item1": 100667981, + "Item2": 1 + } + }, + "DefaultStateName": "", + "Children": [] + }, + { + "Id": 268435470, + "Type": 268435521, + "X": 0, + "Y": 0, + "Width": 800, + "Height": 100, + "Left": 1, + "Top": 2, + "Right": 1, + "Bottom": 1, + "ReadOrder": 0, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100667725, + "Item2": 1 + } + }, + "DefaultStateName": "", + "Children": [ + { + "Id": 268436772, + "Type": 1, + "X": 0, + "Y": 46, + "Width": 16, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 2, + "ReadOrder": 6, + "FontDid": 1073741861, + "StateMedia": { + "Normal": { + "Item1": 100688408, + "Item2": 1 + }, + "Highlight": { + "Item1": 100688409, + "Item2": 1 + } + }, + "DefaultStateName": "Normal", + "Children": [] + }, + { + "Id": 268436773, + "Type": 1, + "X": 0, + "Y": 64, + "Width": 16, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 2, + "ReadOrder": 7, + "FontDid": 1073741861, + "StateMedia": { + "Normal": { + "Item1": 100688408, + "Item2": 1 + }, + "Highlight": { + "Item1": 100688409, + "Item2": 1 + } + }, + "DefaultStateName": "Normal", + "Children": [] + }, + { + "Id": 268436591, + "Type": 1, + "X": 474, + "Y": 0, + "Width": 16, + "Height": 16, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 2, + "ReadOrder": 3, + "FontDid": 0, + "StateMedia": { + "Maximized": { + "Item1": 100687460, + "Item2": 1 + }, + "Minimized": { + "Item1": 100687461, + "Item2": 1 + } + }, + "DefaultStateName": "Minimized", + "Children": [] + }, + { + "Id": 268435471, + "Type": 9, + "X": 0, + "Y": 0, + "Width": 800, + "Height": 9, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 2, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100667685, + "Item2": 1 + } + }, + "DefaultStateName": "", + "Children": [] + }, + { + "Id": 268435472, + "Type": 3, + "X": 0, + "Y": 9, + "Width": 490, + "Height": 74, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100667669, + "Item2": 1 + } + }, + "DefaultStateName": "", + "Children": [ + { + "Id": 268435473, + "Type": 12, + "X": 16, + "Y": 0, + "Width": 458, + "Height": 74, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 1073741824, + "StateMedia": {}, + "DefaultStateName": "", + "Children": [ + { + "Id": 268436620, + "Type": 1, + "X": 0, + "Y": 58, + "Width": 16, + "Height": 16, + "Left": 3, + "Top": 2, + "Right": 3, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "Normal": { + "Item1": 100687630, + "Item2": 1 + }, + "Normal_pressed": { + "Item1": 100687630, + "Item2": 1 + } + }, + "DefaultStateName": "Ghosted", + "Children": [] + } + ] + }, + { + "Id": 268435474, + "Type": 11, + "X": 474, + "Y": 6, + "Width": 16, + "Height": 68, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100682847, + "Item2": 3 + } + }, + "DefaultStateName": "", + "Children": [] + } + ] + }, + { + "Id": 268435475, + "Type": 3, + "X": 0, + "Y": 83, + "Width": 490, + "Height": 17, + "Left": 1, + "Top": 2, + "Right": 1, + "Bottom": 1, + "ReadOrder": 8, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100667706, + "Item2": 1 + } + }, + "DefaultStateName": "", + "Children": [ + { + "Id": 268435476, + "Type": 6, + "X": 0, + "Y": 0, + "Width": 46, + "Height": 17, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "Normal": { + "Item1": 100683109, + "Item2": 3 + }, + "Normal_pressed": { + "Item1": 100683110, + "Item2": 3 + } + }, + "DefaultStateName": "Normal", + "Children": [ + { + "Id": 268435477, + "Type": 12, + "X": 0, + "Y": 0, + "Width": 46, + "Height": 17, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 1073741826, + "StateMedia": {}, + "DefaultStateName": "", + "Children": [] + } + ] + }, + { + "Id": 268435478, + "Type": 12, + "X": 46, + "Y": 0, + "Width": 398, + "Height": 17, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 1073741824, + "StateMedia": { + "Normal_focussed": { + "Item1": 100667819, + "Item2": 1 + } + }, + "DefaultStateName": "", + "Children": [ + { + "Id": 268435479, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 1, + "Height": 17, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "Normal_focussed": { + "Item1": 100683111, + "Item2": 1 + } + }, + "DefaultStateName": "", + "Children": [] + }, + { + "Id": 268435480, + "Type": 3, + "X": 397, + "Y": 0, + "Width": 1, + "Height": 17, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": { + "Normal_focussed": { + "Item1": 100683111, + "Item2": 1 + } + }, + "DefaultStateName": "", + "Children": [] + } + ] + }, + { + "Id": 268435481, + "Type": 1, + "X": 444, + "Y": 0, + "Width": 46, + "Height": 17, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 3, + "FontDid": 1073741826, + "StateMedia": { + "Normal": { + "Item1": 100669717, + "Item2": 1 + }, + "Normal_pressed": { + "Item1": 100669718, + "Item2": 1 + }, + "Ghosted": { + "Item1": 100669748, + "Item2": 1 + } + }, + "DefaultStateName": "Normal", + "Children": [] + } + ] + }, + { + "Id": 268436770, + "Type": 1, + "X": 0, + "Y": 10, + "Width": 16, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 2, + "ReadOrder": 4, + "FontDid": 1073741861, + "StateMedia": { + "Normal": { + "Item1": 100688408, + "Item2": 1 + }, + "Highlight": { + "Item1": 100688409, + "Item2": 1 + } + }, + "DefaultStateName": "Normal", + "Children": [] + }, + { + "Id": 268436771, + "Type": 1, + "X": 0, + "Y": 28, + "Width": 16, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 2, + "ReadOrder": 5, + "FontDid": 1073741861, + "StateMedia": { + "Normal": { + "Item1": 100688408, + "Item2": 1 + }, + "Highlight": { + "Item1": 100688409, + "Item2": 1 + } + }, + "DefaultStateName": "Normal", + "Children": [] + } + ] + } + ] +} \ No newline at end of file From 3593d6623da4e6dd452aecc5d0d441b8e106d928 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 17:02:49 +0200 Subject: [PATCH 87/99] =?UTF-8?q?feat(D.2b):=20UiScrollbar=20(Type=2011)?= =?UTF-8?q?=20=E2=80=94=20promote=20the=20generic=20chat=20scrollbar=20(wi?= =?UTF-8?q?dget-generalization=20Task=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - git mv UiChatScrollbar.cs → UiScrollbar.cs; rename class + update doc summary to "Generic scrollbar. Ports retail UIElement_Scrollbar (RegisterElementClass(0xb) @ acclient_2013_pseudo_c.txt:124137); thumb size = trackLen * ThumbRatio (min 8px); step ±1 line." - git mv UiChatScrollbarTests.cs → UiScrollbarTests.cs; rename test class + replace every UiChatScrollbar reference with UiScrollbar (bodies unchanged). - DatWidgetFactory: register Type 11 → new UiScrollbar() before the _ fallback case. - ChatWindowController: change Scrollbar property type to UiScrollbar; replace the old "construct-remove-add" block with a "find factory-built UiScrollbar and bind in place" block (no RemoveChild/AddChild); keep `var track` assignment in scope so the Max/Min block's track.Left/track.Width reads still compile against UiElement?. - AP-41 divergence register: update file:line to UiScrollbar.cs:35; narrow wording to "fallback only — single-tile drawn only when cap ids are unset; the chat controller passes all three cap ids so the 3-slice path is the active code path." - Update inline UiChatScrollbar doc-comment references in UiScrollable.cs + UiChatView.cs. - Full suite: 399 passed, 2 skipped (dat/tower fixture skips), 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../retail-divergence-register.md | 2 +- .../UI/Layout/ChatWindowController.cs | 51 ++++++++----------- src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 5 +- src/AcDream.App/UI/UiChatView.cs | 2 +- src/AcDream.App/UI/UiScrollable.cs | 2 +- .../UI/{UiChatScrollbar.cs => UiScrollbar.cs} | 12 ++--- .../UI/Layout/DatWidgetFactoryTests.cs | 9 ++++ ...tScrollbarTests.cs => UiScrollbarTests.cs} | 16 +++--- 8 files changed, 49 insertions(+), 50 deletions(-) rename src/AcDream.App/UI/{UiChatScrollbar.cs => UiScrollbar.cs} (94%) rename tests/AcDream.App.Tests/UI/{UiChatScrollbarTests.cs => UiScrollbarTests.cs} (79%) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index a96511a6..308c03bb 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -138,7 +138,7 @@ accepted-divergence entries (#96, #49, #50). | AP-38 | Chat transcript renders pre-split `ChatLog` lines 1:1; no in-element word-wrap at the panel's current pixel width | `src/AcDream.App/UI/UiChatView.cs` | Retail does in-element wrap via `UIElement_Text::SizeToFit`; our pre-split lines are always shorter than 440 px in practice; a line that overflows clips at the edge rather than wrapping | Very long server system messages (server shutdowns, broadcast announcements) clip rather than wrapping — no information loss, just visual truncation | `UIElement_Text::SizeToFit` @0x467980; `gmMainChatUI` layout | | AP-39 | Chat lines carry one color per `ChatKind` (per-line solid color); retail `UIElement_Text` supports per-glyph styled runs (bold, different hue per segment) | `src/AcDream.App/UI/UiChatView.cs:13` | Retail glyph-run parsing lives inside keystone.dll with no PDB/decomp; per-line per-kind coloring is the correct tonal palette and covers all existing chat types | Chat lines retail renders with multiple colors or bold names (e.g. "PlayerName says: text") render as one flat color; subtle visual difference but functionally complete | `UIElement_Text` glyph-run styling (keystone.dll, no decomp) | | AP-40 | Single default translucency for the chat window chrome; no focused/unfocused opacity transition; dat font face/size taken from the vitals `vitalsDatFont` (same dat font, not a chat-specific size lookup) | `src/AcDream.App/Rendering/GameWindow.cs` (chatController binding line) | Retail fades the chat window to ~80% alpha when unfocused (`gmMainChatUI::UpdateAlpha @0x4cdea0`); the opacity animation deferred to the Plan-2 window-manager input integration; sharing `vitalsDatFont` is safe — retail uses the same AC-default font for both | The chat window is always fully opaque/same-font rather than subtly fading when idle; no wrong text, but the focused/unfocused breathing rhythm is absent | `gmMainChatUI::UpdateAlpha` @0x4cdea0; `UCF::SetAceFont @0x4d3940` | -| AP-41 | Scrollbar thumb drawn as a single stretched sprite (`0x06004C63`, the 3-slice middle tile) instead of retail's 3-slice: top cap `0x06004C60` + tiled middle `0x06004C63` + bottom cap `0x06004C66` | `src/AcDream.App/UI/UiChatScrollbar.cs:37` | The middle tile stretches acceptably at chat-panel dimensions; the 3-slice port is a Task-H upgrade acknowledged inline in the `ThumbSprite` property comment | The thumb's top and bottom edges lack the retail end-cap sprites — slightly wrong visual shape at small thumb sizes (thumb too-short for the middle tile to cleanly scale) | `UIElement_Scrollbar::UpdateLayout @0x4710d0`; cap sprites `0x06004C60` (top) + `0x06004C66` (bottom) from base layout `0x2100003E` | +| AP-41 | Scrollbar thumb 3-slice cap fallback only: single-tile draw (`0x06004C63`) used only when `ThumbTopSprite`/`ThumbBotSprite` are unset; the chat controller passes all three cap ids so the 3-slice path is drawn in practice | `src/AcDream.App/UI/UiScrollbar.cs:35` | The fallback single-tile path is unreachable when caps are bound (chat controller always sets them); the 3-slice path is the active code path | Only if a future caller omits the cap ids will the fallback fire — no visual regression in the chat window | `UIElement_Scrollbar::UpdateLayout @0x4710d0`; cap sprites `0x06004C60` (top) + `0x06004C66` (bottom) from base layout `0x2100003E` | --- diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index 5b6199db..87e1f2de 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -20,9 +20,9 @@ namespace AcDream.App.UI.Layout; /// tree (which contains everything) and adds the behavioral /// widgets as children of their parent container widgets (transcript panel /// 0x10000010 / input bar 0x10000013) which ARE created as -/// nodes. The scrollbar track (0x10000012) and -/// channel menu (0x10000014) are created by the factory and are replaced -/// with their behavioral counterparts here. +/// nodes. The scrollbar track (0x10000012) is built +/// directly as a by the factory (Type 11) and is bound in place +/// here. The channel menu (0x10000014) is still replaced with its behavioral counterpart. /// /// public sealed class ChatWindowController @@ -71,7 +71,7 @@ public sealed class ChatWindowController public UiChatInput Input { get; private set; } = null!; /// Scrollbar widget, driven by 's scroll model. - public UiChatScrollbar Scrollbar { get; private set; } = null!; + public UiScrollbar Scrollbar { get; private set; } = null!; /// Channel-selector menu widget. public UiChannelMenu Menu { get; private set; } = null!; @@ -110,7 +110,7 @@ public sealed class ChatWindowController /// Fallback debug bitmap font (used when /// is null). /// Dat RenderSurface id → (GL tex handle, px width, px height). - /// Forwarded to and . + /// Forwarded to and . public static ChatWindowController? Bind( ElementInfo rootInfo, ImportedLayout layout, @@ -196,33 +196,24 @@ public sealed class ChatWindowController inputBar.AddChild(c.Input); c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel); - // ── Scrollbar — replace the imported track placeholder ──────────── - // The factory created a UiDatElement for the track. Remove it and place a - // behavioral UiChatScrollbar at the same position, driving the transcript's scroll. + // ── Scrollbar — bind the factory-built Type-11 track element ──────── + // The factory now builds the Type-11 track element (0x10000012) as a UiScrollbar + // directly. Find it, bind it in place — no remove/add needed. var track = layout.FindElement(TrackId); - if (track?.Parent is { } trackParent) + if (track is UiScrollbar bar) { - c.Scrollbar = new UiChatScrollbar - { - // Pull the bar up to the panel top so the top arrow meets the window - // border (and lines up with the max/min button at root y=0); the dat - // track sits 6px down, which left a gap after the resize-bar reclaim. - Left = track.Left, - Top = 0f, - Width = track.Width, - Height = track.Height + track.Top, - Anchors = track.Anchors, - Model = c.Transcript.Scroll, - SpriteResolve = resolve, - TrackSprite = TrackSprite, - ThumbSprite = ThumbSprite, - ThumbTopSprite = ThumbTopSprite, - ThumbBotSprite = ThumbBotSprite, - UpSprite = UpSprite, - DownSprite = DownSprite, - }; - trackParent.RemoveChild(track); - trackParent.AddChild(c.Scrollbar); + float oldTop = bar.Top; + bar.Top = 0f; // pull up to the panel top (resize-bar reclaim) + bar.Height = bar.Height + oldTop; + bar.Model = c.Transcript.Scroll; + bar.SpriteResolve = resolve; + bar.TrackSprite = TrackSprite; + bar.ThumbSprite = ThumbSprite; + bar.ThumbTopSprite = ThumbTopSprite; + bar.ThumbBotSprite = ThumbBotSprite; + bar.UpSprite = UpSprite; + bar.DownSprite = DownSprite; + c.Scrollbar = bar; } // ── Channel menu — replace the imported menu placeholder ────────── diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index d4df6589..ee4d3da4 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -57,8 +57,9 @@ public static class DatWidgetFactory UiElement e = info.Type switch { - 7 => BuildMeter(info, resolve, datFont), // UIElement_Meter - _ => new UiDatElement(info, resolve), // generic fallback for all other types + 7 => BuildMeter(info, resolve, datFont), // UIElement_Meter + 11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137) + _ => new UiDatElement(info, resolve), // generic fallback for all other types }; // Propagate position + size (pixel-exact from the dat). diff --git a/src/AcDream.App/UI/UiChatView.cs b/src/AcDream.App/UI/UiChatView.cs index cff1ea6c..e49e58a1 100644 --- a/src/AcDream.App/UI/UiChatView.cs +++ b/src/AcDream.App/UI/UiChatView.cs @@ -52,7 +52,7 @@ public sealed class UiChatView : UiElement /// Inner text inset from the view edges, px. public float Padding { get; set; } = 4f; - /// The scroll model — also read by the linked UiChatScrollbar. + /// The scroll model — also read by the linked UiScrollbar. public UiScrollable Scroll { get; } = new(); /// True while the view is pinned to the newest line (auto-scrolls as content grows). diff --git a/src/AcDream.App/UI/UiScrollable.cs b/src/AcDream.App/UI/UiScrollable.cs index d30e2a0a..2167b387 100644 --- a/src/AcDream.App/UI/UiScrollable.cs +++ b/src/AcDream.App/UI/UiScrollable.cs @@ -7,7 +7,7 @@ namespace AcDream.App.UI; /// the scroll offset is an integer pixel value (m_iScrollableY) clamped to /// [0, ContentHeight - ViewHeight]; the thumb ratio is view/content; the position /// ratio is scroll/(content-view). Pure (no GL) so it is fully unit-tested and -/// shared by the transcript (UiChatView) and the scrollbar (UiChatScrollbar). +/// shared by the transcript (UiChatView) and the scrollbar (UiScrollbar). /// Decomp anchors: SetScrollableXY @0x4740c0, UpdateScrollbarSize_ @0x4741a0, /// UpdateScrollbarPosition_ @0x473f20, UIElement_Text::InqScrollDelta @0x4689b0. /// diff --git a/src/AcDream.App/UI/UiChatScrollbar.cs b/src/AcDream.App/UI/UiScrollbar.cs similarity index 94% rename from src/AcDream.App/UI/UiChatScrollbar.cs rename to src/AcDream.App/UI/UiScrollbar.cs index debea724..99e4dcdc 100644 --- a/src/AcDream.App/UI/UiChatScrollbar.cs +++ b/src/AcDream.App/UI/UiScrollbar.cs @@ -4,11 +4,9 @@ using System.Numerics; namespace AcDream.App.UI; /// -/// Right-side chat scrollbar: a track sprite, a draggable thumb sized to the -/// content/view ratio, and up/down step buttons. Drives a linked -/// . Ports retail UIElement_Scrollbar::UpdateLayout -/// @0x4710d0 (thumb size = trackLen * ThumbRatio, min 8px; thumb pos from -/// PositionRatio) and HandleButtonClick @0x470e90 (step ±1 line). +/// Generic scrollbar. Ports retail UIElement_Scrollbar +/// (RegisterElementClass(0xb) @ acclient_2013_pseudo_c.txt:124137); +/// thumb size = trackLen * ThumbRatio (min 8px); step ±1 line. /// /// /// Dat element ids (chat LayoutDesc 0x21000006): track 0x10000012 (X=474 Y=6 W=16 H=68), @@ -22,7 +20,7 @@ namespace AcDream.App.UI; /// rendered scrollbar's height; the widget responds to those regions directly via hit /// comparison in OnEvent without requiring separate child elements. /// -public sealed class UiChatScrollbar : UiElement +public sealed class UiScrollbar : UiElement { /// The scroll model this bar reflects + drives (shared with the transcript). public UiScrollable? Model { get; set; } @@ -61,7 +59,7 @@ public sealed class UiChatScrollbar : UiElement private bool _draggingThumb; private float _dragOffsetY; - public UiChatScrollbar() { CapturesPointerDrag = true; } + public UiScrollbar() { CapturesPointerDrag = true; } /// /// Computes the thumb rectangle (local y origin and height) within the track area diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index 31b449bd..cd543635 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -97,6 +97,15 @@ public class DatWidgetFactoryTests Assert.Null(DatWidgetFactory.Create(noMedia, NoTex, null)); } + // ── Test 5b: Type 11 → UiScrollbar ────────────────────────────────────── + + [Fact] + public void Type11_Scrollbar_MakesUiScrollbar() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 11, Width = 16, Height = 68 }, NoTex, null); + Assert.IsType(e); + } + // ── Test 6: Meter slice extraction (the important one) ─────────────────── /// diff --git a/tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cs b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs similarity index 79% rename from tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cs rename to tests/AcDream.App.Tests/UI/UiScrollbarTests.cs index 3f4ddbba..c2239732 100644 --- a/tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cs +++ b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs @@ -4,9 +4,9 @@ using Xunit; namespace AcDream.App.Tests.UI; /// -/// Pure unit tests for — no GL dependency. +/// Pure unit tests for — no GL dependency. /// -public class UiChatScrollbarTests +public class UiScrollbarTests { // Model: content=400, view=100, trackLen=200. // ThumbRatio = 100/400 = 0.25 → thumbH = max(8, 200*0.25) = 50. @@ -17,7 +17,7 @@ public class UiChatScrollbarTests { var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 }; // PositionRatio = 0 (start). - var (y, h) = UiChatScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f); + var (y, h) = UiScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f); Assert.Equal(50f, h, 3f); Assert.Equal(0f, y, 3f); } @@ -28,7 +28,7 @@ public class UiChatScrollbarTests var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 }; m.ScrollToEnd(); // PositionRatio = 1. float trackTop = 16f, trackLen = 200f; - var (y, h) = UiChatScrollbar.ThumbRect(m, trackTop, trackLen); + var (y, h) = UiScrollbar.ThumbRect(m, trackTop, trackLen); Assert.Equal(50f, h, 3f); // y = trackTop + travel * 1 = 16 + 150 = 166. Assert.Equal(166f, y, 3f); @@ -41,7 +41,7 @@ public class UiChatScrollbarTests // thumbH=50; travel=150; y = trackTop + 150 = trackTop + 150. var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 }; m.ScrollToEnd(); - var (y, h) = UiChatScrollbar.ThumbRect(m, trackTop: 16f, trackLen: 200f); + var (y, h) = UiScrollbar.ThumbRect(m, trackTop: 16f, trackLen: 200f); Assert.Equal(50f, h, 3f); Assert.Equal(166f, y, 3f); // 16 + 150 } @@ -54,7 +54,7 @@ public class UiChatScrollbarTests m.SetScrollY(150); Assert.Equal(0.5f, m.PositionRatio, 3); - var (y, h) = UiChatScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f); + var (y, h) = UiScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f); Assert.Equal(50f, h, 3f); // y = 0 + 150 * 0.5 = 75. Assert.Equal(75f, y, 3f); @@ -65,7 +65,7 @@ public class UiChatScrollbarTests { // content=1000, view=10, trackLen=200 → ThumbRatio=0.01 → raw=2 < 8 → clamp to 8. var m = new UiScrollable { ContentHeight = 1000, ViewHeight = 10 }; - var (_, h) = UiChatScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f); + var (_, h) = UiScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f); Assert.Equal(8f, h, 3f); } @@ -74,7 +74,7 @@ public class UiChatScrollbarTests { // content <= view → ThumbRatio = 1 → thumbH = trackLen. var m = new UiScrollable { ContentHeight = 50, ViewHeight = 100 }; - var (y, h) = UiChatScrollbar.ThumbRect(m, trackTop: 16f, trackLen: 100f); + var (y, h) = UiScrollbar.ThumbRect(m, trackTop: 16f, trackLen: 100f); Assert.Equal(100f, h, 3f); Assert.Equal(16f, y, 3f); // travel = 0 → y = trackTop } From 805ab5f40b9622f9c006b72362cd822f08696de4 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 17:07:58 +0200 Subject: [PATCH 88/99] =?UTF-8?q?feat(D.2b):=20UiButton=20(Type=201)=20?= =?UTF-8?q?=E2=80=94=20Send=20+=20Max/Min=20as=20generic=20buttons=20(widg?= =?UTF-8?q?et-generalization=20Task=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces UiButton: a dedicated dat-widget button that ports UIElement_Button (RegisterElementClass(1,...) @ acclient_2013_pseudo_c.txt:125828). State selection, tiled DrawSprite, and label rendering mirror UiDatElement exactly so the chat Send and Max/Min buttons have zero behavioral change. DatWidgetFactory now maps Type 1 → UiButton (beside Type 7 → UiMeter, Type 11 → UiScrollbar). ChatWindowController's Send and Max/Min bind blocks updated from UiDatElement casts to UiButton casts; ClickThrough=false lines dropped (UiButton is interactive by construction). The old UiPanel.cs UiButton (a plain dev-scaffold rect+text button with no dat sprites) is renamed UiSimpleButton to free the name — no production code instantiated it. Full suite: 402 passed, 2 skipped, 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../UI/Layout/ChatWindowController.cs | 6 +- src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 2 + src/AcDream.App/UI/UiButton.cs | 111 ++++++++++++++++++ src/AcDream.App/UI/UiPanel.cs | 7 +- .../UI/Layout/DatWidgetFactoryTests.cs | 9 ++ tests/AcDream.App.Tests/UI/UiButtonTests.cs | 25 ++++ 6 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 src/AcDream.App/UI/UiButton.cs create mode 100644 tests/AcDream.App.Tests/UI/UiButtonTests.cs diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index 87e1f2de..64c0fc6b 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -243,9 +243,8 @@ public sealed class ChatWindowController // ── Send button — Enter-alternate submit trigger ────────────────── // Retail's gmMainChatUI wires the Send button to the same ProcessCommand path. - if (layout.FindElement(SendId) is UiDatElement sendEl) + if (layout.FindElement(SendId) is UiButton sendEl) { - sendEl.ClickThrough = false; sendEl.OnClick = () => c.Input.Submit(); // The Send sprite is a blank gold button — retail draws the caption as text. sendEl.Label = "Send"; @@ -276,14 +275,13 @@ public sealed class ChatWindowController } // ── Max/min toggle — simplified gmMainChatUI::HandleMaximizeButton ── - if (layout.FindElement(MaxMinId) is UiDatElement maxMinEl) + if (layout.FindElement(MaxMinId) is UiButton maxMinEl) { // The dat puts max/min and the scrollbar up-button at the SAME X (both // right-anchored), so at content width they overlap. Retail shows max/min // just LEFT of the scrollbar column — shift it one button-width left. if (track is not null) maxMinEl.Left = track.Left - maxMinEl.Width; - maxMinEl.ClickThrough = false; maxMinEl.OnClick = c.ToggleMaximize; } diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index ee4d3da4..20b688a1 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using AcDream.App.UI; namespace AcDream.App.UI.Layout; @@ -57,6 +58,7 @@ public static class DatWidgetFactory UiElement e = info.Type switch { + 1 => new UiButton(info, resolve), // UIElement_Button (reg :125828) 7 => BuildMeter(info, resolve, datFont), // UIElement_Meter 11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137) _ => new UiDatElement(info, resolve), // generic fallback for all other types diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs new file mode 100644 index 00000000..c6c5be26 --- /dev/null +++ b/src/AcDream.App/UI/UiButton.cs @@ -0,0 +1,111 @@ +using System; +using System.Numerics; +using AcDream.App.UI.Layout; + +namespace AcDream.App.UI; + +/// +/// Generic dat-widget button — the production replacement for any dat element of +/// Type 1 (UIElement_Button, registered via RegisterElementClass(1, UIElement_Button::Create) +/// @ acclient_2013_pseudo_c.txt:125828). +/// +/// +/// Draws per-state sprite media exactly like (same +/// ActiveState defaulting, same ActiveMedia() fallback chain, same tiled +/// DrawSprite call with UV-repeat so chrome edges tile correctly) plus an +/// optional centered text label. The click behavior mirrors +/// one-for-one so the chat Send and Max/Min buttons that previously bound through +/// UiDatElement.OnClick continue to work without behavioral change. +/// +/// +/// +/// State selection: picks if set, then +/// "Normal" if the element has a Normal state sprite, then falls back to the unnamed +/// DirectState ("" key) — identical to . +/// +/// +/// +/// Built by for Type-1 elements (chat Send 0x10000019, +/// Max/Min 0x1000046F). NOT the same as , which is an +/// earlier dev-scaffold widget with no dat sprites. +/// +/// +public sealed class UiButton : UiElement +{ + private readonly ElementInfo _info; + private readonly Func _resolve; + + /// Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize). + public Action? OnClick { get; set; } + + /// Optional centered text label drawn over the sprite (e.g. "Send" on a blank gold frame). + public string? Label { get; set; } + + /// Dat font for . Required for the label to draw. + public UiDatFont? LabelFont { get; set; } + + /// Label color (default white). + public Vector4 LabelColor { get; set; } = Vector4.One; + + /// + /// Active state name, runtime-settable (e.g. Max/Min toggling Normal ↔ Minimized). + /// Matches . + /// + public string ActiveState { get; set; } = ""; + + /// Merged for this element. + /// Dat file-id → (GL texture handle, native px width, native px height). + /// Returns (0,0,0) when the texture is not yet uploaded. + public UiButton(ElementInfo info, Func resolve) + { + _info = info; + _resolve = resolve; + ClickThrough = false; // buttons are interactive — opt OUT of click-through + + // State defaulting matches UiDatElement exactly: + // DefaultStateName wins; else "Normal" if that state has a sprite; else DirectState (""). + if (!string.IsNullOrEmpty(info.DefaultStateName)) + ActiveState = info.DefaultStateName; + else if (info.StateMedia.ContainsKey("Normal")) + ActiveState = "Normal"; + // else ActiveState stays "" (DirectState) + } + + /// + /// Returns the File id for the current , falling back to + /// the DirectState ("" key) if the named state is absent. + /// Returns 0 if neither exists. + /// Mirrors . + /// + private uint ActiveFile() + => _info.StateMedia.TryGetValue(ActiveState, out var m) ? m.File + : _info.StateMedia.TryGetValue("", out var d) ? d.File : 0u; + + protected override void OnDraw(UiRenderContext ctx) + { + uint file = ActiveFile(); + if (file != 0) + { + var (tex, tw, th) = _resolve(file); + if (tex != 0 && tw != 0 && th != 0) + { + // Tiled draw — same call shape as UiDatElement.OnDraw (UV-repeat; GL_REPEAT-wrapped + // UI texture). Matches ImgTex::TileCSI; no Stretch mode exists. + ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One); + } + } + + if (Label is { Length: > 0 } label && LabelFont is { } lf) + { + float tx = (Width - lf.MeasureWidth(label)) * 0.5f; + float ty = (Height - lf.LineHeight) * 0.5f; + ctx.DrawStringDat(lf, label, tx, ty, LabelColor); + } + } + + public override bool OnEvent(in UiEvent e) + { + if (e.Type == UiEventType.Click && OnClick is not null) { OnClick(); return true; } + return false; + } +} diff --git a/src/AcDream.App/UI/UiPanel.cs b/src/AcDream.App/UI/UiPanel.cs index 9f941da1..b6a2085f 100644 --- a/src/AcDream.App/UI/UiPanel.cs +++ b/src/AcDream.App/UI/UiPanel.cs @@ -57,14 +57,17 @@ public class UiLabel : UiElement /// callback. Retail equivalent is Keystone's button widget, driven by /// a StateDesc per UIStateId (normal / hot / pressed / /// disabled) from the panel layout. +/// Note: the dat-widget button (Type 1 / UIElement_Button) is +/// in UiButton.cs — that is the production widget used by D.2b panels. +/// This class is the earlier dev-scaffold button (plain rect + text; no dat sprites). /// -public class UiButton : UiPanel +public class UiSimpleButton : UiPanel { public string Text { get; set; } = string.Empty; public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f); public event System.Action? Click; - public UiButton() + public UiSimpleButton() { BackgroundColor = new Vector4(0.1f, 0.1f, 0.15f, 0.8f); BorderColor = new Vector4(0.45f, 0.45f, 0.55f, 1f); diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index cd543635..d2e8c439 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -97,6 +97,15 @@ public class DatWidgetFactoryTests Assert.Null(DatWidgetFactory.Create(noMedia, NoTex, null)); } + // ── Test 5c: Type 1 → UiButton ────────────────────────────────────────── + + [Fact] + public void Type1_Button_MakesUiButton() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 1, Width = 46, Height = 18 }, NoTex, null); + Assert.IsType(e); + } + // ── Test 5b: Type 11 → UiScrollbar ────────────────────────────────────── [Fact] diff --git a/tests/AcDream.App.Tests/UI/UiButtonTests.cs b/tests/AcDream.App.Tests/UI/UiButtonTests.cs new file mode 100644 index 00000000..8bbadae2 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiButtonTests.cs @@ -0,0 +1,25 @@ +using AcDream.App.UI; +using AcDream.App.UI.Layout; +namespace AcDream.App.Tests.UI; + +public class UiButtonTests +{ + private static (uint, int, int) NoTex(uint _) => (0, 0, 0); + private bool _clicked; + + [Fact] + public void Click_InvokesOnClick() + { + var b = new UiButton(new ElementInfo { Type = 1, Width = 46, Height = 18 }, NoTex) + { OnClick = () => _clicked = true }; + b.OnEvent(new UiEvent(0, null, UiEventType.Click)); + Assert.True(_clicked); + } + + [Fact] + public void NotClickThrough_SoItReceivesClicks() + { + var b = new UiButton(new ElementInfo { Type = 1 }, NoTex); + Assert.False(b.ClickThrough); + } +} From 955f7a69a8556a3dd25255ddd8185ea420fd7b50 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 17:18:27 +0200 Subject: [PATCH 89/99] =?UTF-8?q?feat(D.2b):=20UiMenu=20(Type=206)=20?= =?UTF-8?q?=E2=80=94=20generic=20dropdown;=20channel=20knowledge=20moves?= =?UTF-8?q?=20to=20controller=20(widget-generalization=20Task=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UiChannelMenu → UiMenu: removed ChatChannelKind, the 14-item array, the button-text map, and the availability default. Generic surface: MenuItem (label + object? Payload), Selected (object?), OnSelect, EnabledProvider, ButtonLabelProvider, RowsPerColumn/RowHeight/ColumnWidth (all settable). All draw/event mechanics unchanged — same popup geometry, same click coordinates, same 8-piece bevel, same 3-slice button face. ChatWindowController gains ChannelItems[], ChannelButtonLabel(), and ChannelAvailable() (verbatim from old widget), and populates the factory-built Type-6 UiMenu via find-by-id rather than constructing a replacement widget. The Menu property type is now UiMenu. OnChannelChanged wrap replaced with the generic OnSelect wrap for the ReflowInputRow hook. DatWidgetFactory registers Type 6 → new UiMenu(). Tests: UiChannelMenuTests → UiMenuTests (10 tests, all green); factory Type6 test added; ChatWindowControllerTests updated to use OnSelect. Divergence register: AP-42 added (flat item model vs retail nested-submenu MakePopup @0x46d310 — latent, unreachable through the chat menu). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../retail-divergence-register.md | 1 + .../UI/Layout/ChatWindowController.cs | 82 ++++++--- src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 1 + .../UI/{UiChannelMenu.cs => UiMenu.cs} | 158 ++++++---------- .../UI/Layout/ChatWindowControllerTests.cs | 8 +- .../UI/Layout/DatWidgetFactoryTests.cs | 9 + .../UI/UiChannelMenuTests.cs | 125 ------------- tests/AcDream.App.Tests/UI/UiMenuTests.cs | 170 ++++++++++++++++++ 8 files changed, 302 insertions(+), 252 deletions(-) rename src/AcDream.App/UI/{UiChannelMenu.cs => UiMenu.cs} (56%) delete mode 100644 tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs create mode 100644 tests/AcDream.App.Tests/UI/UiMenuTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 308c03bb..052cca56 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -139,6 +139,7 @@ accepted-divergence entries (#96, #49, #50). | AP-39 | Chat lines carry one color per `ChatKind` (per-line solid color); retail `UIElement_Text` supports per-glyph styled runs (bold, different hue per segment) | `src/AcDream.App/UI/UiChatView.cs:13` | Retail glyph-run parsing lives inside keystone.dll with no PDB/decomp; per-line per-kind coloring is the correct tonal palette and covers all existing chat types | Chat lines retail renders with multiple colors or bold names (e.g. "PlayerName says: text") render as one flat color; subtle visual difference but functionally complete | `UIElement_Text` glyph-run styling (keystone.dll, no decomp) | | AP-40 | Single default translucency for the chat window chrome; no focused/unfocused opacity transition; dat font face/size taken from the vitals `vitalsDatFont` (same dat font, not a chat-specific size lookup) | `src/AcDream.App/Rendering/GameWindow.cs` (chatController binding line) | Retail fades the chat window to ~80% alpha when unfocused (`gmMainChatUI::UpdateAlpha @0x4cdea0`); the opacity animation deferred to the Plan-2 window-manager input integration; sharing `vitalsDatFont` is safe — retail uses the same AC-default font for both | The chat window is always fully opaque/same-font rather than subtly fading when idle; no wrong text, but the focused/unfocused breathing rhythm is absent | `gmMainChatUI::UpdateAlpha` @0x4cdea0; `UCF::SetAceFont @0x4d3940` | | AP-41 | Scrollbar thumb 3-slice cap fallback only: single-tile draw (`0x06004C63`) used only when `ThumbTopSprite`/`ThumbBotSprite` are unset; the chat controller passes all three cap ids so the 3-slice path is drawn in practice | `src/AcDream.App/UI/UiScrollbar.cs:35` | The fallback single-tile path is unreachable when caps are bound (chat controller always sets them); the 3-slice path is the active code path | Only if a future caller omits the cap ids will the fallback fire — no visual regression in the chat window | `UIElement_Scrollbar::UpdateLayout @0x4710d0`; cap sprites `0x06004C60` (top) + `0x06004C66` (bottom) from base layout `0x2100003E` | +| AP-42 | `UiMenu` item model is flat (label + opaque payload, single-level popup); retail `UIElement_Menu::MakePopup @0x46d310` supports hierarchical nested submenus via recursive popup chain | `src/AcDream.App/UI/UiMenu.cs` | The chat talk-focus menu is single-level (14 rows, 2 columns, no submenu); hierarchy is latent and unreachable through the chat window — no behavioral difference in the current usage | A future menu with nested submenus would render flat (only the top-level items drawn, no drill-down) | `UIElement_Menu::MakePopup` @0x46d310 | --- diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index 64c0fc6b..a6281eaa 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -74,12 +74,52 @@ public sealed class ChatWindowController public UiScrollbar Scrollbar { get; private set; } = null!; /// Channel-selector menu widget. - public UiChannelMenu Menu { get; private set; } = null!; + public UiMenu Menu { get; private set; } = null!; // ── Private state ────────────────────────────────────────────────────── private ChatChannelKind _activeChannel = ChatChannelKind.Say; + // ── Channel knowledge (ported from old UiChannelMenu — gmMainChatUI::InitTalkFocusMenu @0x4cdc50) ── + + private static readonly (string Label, ChatChannelKind? Channel)[] ChannelItems = + { + ("Squelch (ignore)", null), + ("Tell to Selected", null), + ("Chat to All", ChatChannelKind.Say), + ("Tell to Fellows", ChatChannelKind.Fellowship), + ("Tell to General Chat", ChatChannelKind.General), + ("Tell to LFG Chat", ChatChannelKind.Lfg), + ("Tell to Society Chat", ChatChannelKind.Society), + ("Tell to Monarch", ChatChannelKind.Monarch), + ("Tell to Patron", ChatChannelKind.Patron), + ("Tell to Vassals", ChatChannelKind.Vassals), + ("Tell to Allegiance", ChatChannelKind.Allegiance), + ("Tell to Trade Chat", ChatChannelKind.Trade), + ("Tell to Roleplay Chat", ChatChannelKind.Roleplay), + ("Tell to Olthoi Chat", ChatChannelKind.Olthoi), + }; + + private static string ChannelButtonLabel(ChatChannelKind k) => k switch + { + ChatChannelKind.Say => "Chat", + ChatChannelKind.General => "General", + ChatChannelKind.Trade => "Trade", + ChatChannelKind.Lfg => "LFG", + ChatChannelKind.Fellowship => "Fellow", + ChatChannelKind.Allegiance => "Alleg", + ChatChannelKind.Patron => "Patron", + ChatChannelKind.Vassals => "Vassals", + ChatChannelKind.Monarch => "Monarch", + ChatChannelKind.Roleplay => "Roleplay", + ChatChannelKind.Society => "Society", + ChatChannelKind.Olthoi => "Olthoi", + _ => "Chat", + }; + + private static bool ChannelAvailable(ChatChannelKind k) + => k is ChatChannelKind.Say or ChatChannelKind.General or ChatChannelKind.Trade or ChatChannelKind.Lfg; + /// Window height before maximize (stored to restore on un-maximize). private float _normalHeight; /// Window top before maximize. @@ -110,7 +150,7 @@ public sealed class ChatWindowController /// Fallback debug bitmap font (used when /// is null). /// Dat RenderSurface id → (GL tex handle, px width, px height). - /// Forwarded to and . + /// Forwarded to and . public static ChatWindowController? Bind( ElementInfo rootInfo, ImportedLayout layout, @@ -216,29 +256,23 @@ public sealed class ChatWindowController c.Scrollbar = bar; } - // ── Channel menu — replace the imported menu placeholder ────────── - var menuEl = layout.FindElement(MenuId); - if (menuEl?.Parent is { } menuParent) + // ── Channel menu — bind the factory-built Type-6 UiMenu ────────── + if (layout.FindElement(MenuId) is UiMenu menu) { - c.Menu = new UiChannelMenu + menu.DatFont = datFont; menu.Font = debugFont; menu.SpriteResolve = resolve; + menu.NormalSprite = MenuNormal; menu.PressedSprite = MenuPressed; + menu.PopupBgSprite = MenuPopupBg; + menu.ItemNormalSprite = MenuItemRow; menu.ItemHighlightSprite = MenuItemSelected; + menu.Items = System.Array.ConvertAll(ChannelItems, + t => new UiMenu.MenuItem(t.Label, (object?)t.Channel)); + menu.Selected = (object?)c._activeChannel; + menu.EnabledProvider = p => p is not ChatChannelKind ch || ChannelAvailable(ch); + menu.ButtonLabelProvider = () => ChannelButtonLabel(c._activeChannel); + menu.OnSelect = p => { - Left = menuEl.Left, - Top = menuEl.Top, - Width = menuEl.Width, - Height = menuEl.Height, - Anchors = menuEl.Anchors, - DatFont = datFont, - Font = debugFont, - SpriteResolve = resolve, - NormalSprite = MenuNormal, - PressedSprite = MenuPressed, - PopupBgSprite = MenuPopupBg, - ItemNormalSprite = MenuItemRow, - ItemHighlightSprite = MenuItemSelected, + if (p is ChatChannelKind ch) { c._activeChannel = ch; menu.Selected = p; } }; - c.Menu.OnChannelChanged = k => c._activeChannel = k; - menuParent.RemoveChild(menuEl); - menuParent.AddChild(c.Menu); + c.Menu = menu; } // ── Send button — Enter-alternate submit trigger ────────────────── @@ -269,8 +303,8 @@ public sealed class ChatWindowController c.Input.Width = System.MathF.Max(40f, inputRight - c.Input.Left); c.Input.ResetAnchorCapture(); } - var onChanged = c.Menu.OnChannelChanged; - c.Menu.OnChannelChanged = k => { onChanged?.Invoke(k); ReflowInputRow(); }; + var onSelect = c.Menu.OnSelect; + c.Menu.OnSelect = p => { onSelect?.Invoke(p); ReflowInputRow(); }; ReflowInputRow(); } diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 20b688a1..556fc3ee 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -59,6 +59,7 @@ public static class DatWidgetFactory UiElement e = info.Type switch { 1 => new UiButton(info, resolve), // UIElement_Button (reg :125828) + 6 => new UiMenu(), // UIElement_Menu (reg :120163) 7 => BuildMeter(info, resolve, datFont), // UIElement_Meter 11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137) _ => new UiDatElement(info, resolve), // generic fallback for all other types diff --git a/src/AcDream.App/UI/UiChannelMenu.cs b/src/AcDream.App/UI/UiMenu.cs similarity index 56% rename from src/AcDream.App/UI/UiChannelMenu.cs rename to src/AcDream.App/UI/UiMenu.cs index a64e1aa4..b3e1595d 100644 --- a/src/AcDream.App/UI/UiChannelMenu.cs +++ b/src/AcDream.App/UI/UiMenu.cs @@ -1,48 +1,43 @@ using System; +using System.Collections.Generic; using System.Numerics; -using AcDream.UI.Abstractions; namespace AcDream.App.UI; /// -/// Chat channel selector — the "Chat" button + its talk-focus popup. Mirrors retail -/// gmMainChatUI::InitTalkFocusMenu @0x4cdc50 + UIElement_Menu::MakePopup -/// @0x46d310: the button is labelled with the active target; clicking opens a -/// TWO-COLUMN popup of 14 talk-focus items on the dat-driven menu chrome (panel + -/// per-row + selected-row sprites, 191×17 rows, from LayoutDesc 0x21000006 elements -/// 0x1000001C/1D/1E). Items are code-populated exactly as retail populates them. -/// Unavailable channels render greyed (retail ResetAllTalkFocusMenuButtons → -/// SetState(disabled), colorPink). +/// Generic dropdown menu. Ports retail UIElement_Menu +/// (RegisterElementClass(6) @ acclient_2013_pseudo_c.txt:120163) + +/// UIElement_Menu::MakePopup @0x46d310: the button is labelled with +/// the active target; clicking opens a column-major popup on the dat-driven menu +/// chrome (panel + per-row + selected-row sprites). Items and all chat-channel +/// knowledge are populated by the controller, not baked into this widget. Built +/// by for Type-6 elements. /// -public sealed class UiChannelMenu : UiElement +public sealed class UiMenu : UiElement { - /// One menu row: its label + the channel it selects (null = special/no-op - /// item such as Squelch or Tell-to-Selected, deferred). - public readonly record struct Item(string Label, ChatChannelKind? Channel); + /// One menu row: its label + an opaque payload the controller maps back. + public readonly record struct MenuItem(string Label, object? Payload); - /// The 14 retail talk-focus items in retail order — left column rows 0–6, - /// right column rows 7–13 (matching the live retail menu). - public static readonly Item[] Items = - { - new("Squelch (ignore)", null), // 0 special (squelch — deferred) - new("Tell to Selected", null), // 1 special (selected target — deferred) - new("Chat to All", ChatChannelKind.Say), // 2 - new("Tell to Fellows", ChatChannelKind.Fellowship), // 3 - new("Tell to General Chat", ChatChannelKind.General), // 4 - new("Tell to LFG Chat", ChatChannelKind.Lfg), // 5 - new("Tell to Society Chat", ChatChannelKind.Society), // 6 - new("Tell to Monarch", ChatChannelKind.Monarch), // 7 - new("Tell to Patron", ChatChannelKind.Patron), // 8 - new("Tell to Vassals", ChatChannelKind.Vassals), // 9 - new("Tell to Allegiance", ChatChannelKind.Allegiance), // 10 - new("Tell to Trade Chat", ChatChannelKind.Trade), // 11 - new("Tell to Roleplay Chat", ChatChannelKind.Roleplay), // 12 - new("Tell to Olthoi Chat", ChatChannelKind.Olthoi), // 13 - }; + /// The rows, populated by the controller. Laid out column-major: + /// rows 0..RowsPerColumn-1 in column 0, then the next group in column 1, etc. + public IReadOnlyList Items { get; set; } = System.Array.Empty(); + + /// The currently-selected payload (drives the highlighted row). + public object? Selected { get; set; } + + /// Fired with the picked item's payload when a row is chosen. + public Action? OnSelect { get; set; } + + /// Per-payload enabled gate (disabled rows render greyed + are inert). Null ⇒ all enabled. + public Func? EnabledProvider { get; set; } + + /// Button-face caption (the active target). Null ⇒ blank face. + public Func? ButtonLabelProvider { get; set; } + + public int RowsPerColumn { get; set; } = 7; // items per column (dat item template) + public float RowHeight { get; set; } = 17f; // dat item template 0x1000001E H=17 + public float ColumnWidth { get; set; } = 191f; // dat item template W=191 - private const int Rows = 7; // items per column - private const float ItemH = 17f; // row height (dat item template 0x1000001E H=17) - private const float ColW = 191f; // column width (dat item template W=191) private const int Border = RetailChromeSprites.Border; // 8-piece bevel thickness (5px) // The row sprites 0x0600124E/4D bake a checkbox/checkmark into the leftmost ~17px // square; the label starts just past it (box width + small gap) so text aligns with @@ -53,14 +48,6 @@ public sealed class UiChannelMenu : UiElement // render over the LED. private const float ButtonTextIndent = 20f; - /// The channel the player's typed text currently goes to. - public ChatChannelKind Selected { get; private set; } = ChatChannelKind.Say; - public Action? OnChannelChanged { get; set; } - - /// Per-channel availability gate (retail greys channels you are not in). - /// Defaults to a static approximation; the controller can inject live channel state. - public Func? AvailabilityProvider { get; set; } - public UiDatFont? DatFont { get; set; } public AcDream.App.Rendering.BitmapFont? Font { get; set; } public Func? SpriteResolve { get; set; } @@ -85,40 +72,13 @@ public sealed class UiChannelMenu : UiElement private bool _open; // Interior = the row content; Outer = interior + the 8-piece bevel ring. - private static float InteriorW => 2 * ColW; // 382 - private static float InteriorH => Rows * ItemH; // 119 - private static float OuterW => InteriorW + 2 * Border; - private static float OuterH => InteriorH + 2 * Border; + private int ColumnCount => (Items.Count + RowsPerColumn - 1) / System.Math.Max(1, RowsPerColumn); + private float InteriorW => ColumnCount * ColumnWidth; + private float InteriorH => RowsPerColumn * RowHeight; + private float OuterW => InteriorW + 2 * Border; + private float OuterH => InteriorH + 2 * Border; - public UiChannelMenu() { CapturesPointerDrag = true; } - - /// True if the channel is currently joinable/visible. Defaults to a static - /// approximation matching the common case (Say/General/Trade/LFG); the fellowship + - /// allegiance-hierarchy channels need membership state acdream does not yet track - /// (deferred → greyed). The controller can override via . - private bool IsAvailable(ChatChannelKind ch) - => AvailabilityProvider?.Invoke(ch) - ?? ch is ChatChannelKind.Say or ChatChannelKind.General - or ChatChannelKind.Trade or ChatChannelKind.Lfg; - - /// The button face label = the active talk target (retail updates the - /// button to whichever target you pick). "Chat" = Chat-to-All (Say). - private string ButtonText => Selected switch - { - ChatChannelKind.Say => "Chat", - ChatChannelKind.General => "General", - ChatChannelKind.Trade => "Trade", - ChatChannelKind.Lfg => "LFG", - ChatChannelKind.Fellowship => "Fellow", - ChatChannelKind.Allegiance => "Alleg", - ChatChannelKind.Patron => "Patron", - ChatChannelKind.Vassals => "Vassals", - ChatChannelKind.Monarch => "Monarch", - ChatChannelKind.Roleplay => "Roleplay", - ChatChannelKind.Society => "Society", - ChatChannelKind.Olthoi => "Olthoi", - _ => "Chat", - }; + public UiMenu() { CapturesPointerDrag = true; } protected override void OnDraw(UiRenderContext ctx) { @@ -130,7 +90,7 @@ public sealed class UiChannelMenu : UiElement var (tex, tw, _) = resolve(_open ? PressedSprite : NormalSprite); if (tex != 0 && tw > 0) DrawButtonFace(ctx, tex, tw); } - DrawLabel(ctx, ButtonText, ButtonTextIndent, (Height - LineH()) * 0.5f, TextColor); + DrawLabel(ctx, ButtonLabelProvider?.Invoke() ?? "", ButtonTextIndent, (Height - LineH()) * 0.5f, TextColor); } // 3-slice caps for the 46px LED-arrow button face (0x06004D65): a LEFT cap holding the @@ -153,7 +113,8 @@ public sealed class UiChannelMenu : UiElement /// to this and reflows the input field to start after it. public float NaturalButtonWidth() { - float textW = DatFont?.MeasureWidth(ButtonText) ?? Font?.MeasureWidth(ButtonText) ?? ButtonText.Length * 7f; + string text = ButtonLabelProvider?.Invoke() ?? ""; + float textW = DatFont?.MeasureWidth(text) ?? Font?.MeasureWidth(text) ?? text.Length * 7f; return ButtonTextIndent + textW + 4f + FaceCapR; // text start (clears LED) + text + gap + arrow cap } @@ -165,7 +126,7 @@ public sealed class UiChannelMenu : UiElement var resolve = SpriteResolve; if (!_open || resolve is null) return; - // Two-column popup opening UPWARD from the button, wrapped in the universal + // Column-major popup opening UPWARD from the button, wrapped in the universal // 8-piece window bevel (retail UIElement_Menu::MakePopup spawns the popup as a // bevelled floating window). Force OPAQUE (a menu reads solid even though the // chat window is translucent). Draw bevel → panel fill → row sprites → labels, @@ -179,22 +140,21 @@ public sealed class UiChannelMenu : UiElement DrawBevel(ctx, resolve, 0f, outerTop, OuterW, OuterH); DrawSprite(ctx, resolve, PopupBgSprite, inX, inY, InteriorW, InteriorH); // panel fill behind rows - for (int i = 0; i < Items.Length; i++) + for (int i = 0; i < Items.Count; i++) { - int col = i / Rows, row = i % Rows; - float x = inX + col * ColW, y = inY + row * ItemH; - bool selected = Items[i].Channel is { } c && c == Selected; - DrawSprite(ctx, resolve, selected ? ItemHighlightSprite : ItemNormalSprite, x, y, ColW, ItemH); + int col = i / RowsPerColumn, row = i % RowsPerColumn; + float x = inX + col * ColumnWidth, y = inY + row * RowHeight; + bool selected = Equals(Items[i].Payload, Selected); + DrawSprite(ctx, resolve, selected ? ItemHighlightSprite : ItemNormalSprite, x, y, ColumnWidth, RowHeight); } - float textY = (ItemH - LineH()) * 0.5f; // center the label in its row - for (int i = 0; i < Items.Length; i++) + float textY = (RowHeight - LineH()) * 0.5f; // center the label in its row + for (int i = 0; i < Items.Count; i++) { - int col = i / Rows, row = i % Rows; - // Channel items grey out when unavailable; the special items (Squelch / - // Tell-to-Selected, null channel) are normal white items in retail. - bool avail = Items[i].Channel is not { } c || IsAvailable(c); - DrawLabel(ctx, Items[i].Label, inX + col * ColW + TextIndent, inY + row * ItemH + textY, + int col = i / RowsPerColumn, row = i % RowsPerColumn; + // Items grey out when unavailable; when EnabledProvider is null all items are enabled. + bool avail = EnabledProvider?.Invoke(Items[i].Payload) ?? true; + DrawLabel(ctx, Items[i].Label, inX + col * ColumnWidth + TextIndent, inY + row * RowHeight + textY, avail ? TextColorAvailable : TextColorGhosted); } } @@ -256,15 +216,15 @@ public sealed class UiChannelMenu : UiElement float ix = lx - Border, iy = ly - (-OuterH + Border); if (ix >= 0 && ix < InteriorW && iy >= 0 && iy < InteriorH) { - int col = ix < ColW ? 0 : 1; - int row = (int)(iy / ItemH); - int idx = col * Rows + row; - // Only pick available channel items (special + greyed items are inert). - if (row >= 0 && row < Rows && idx >= 0 && idx < Items.Length - && Items[idx].Channel is { } ch && IsAvailable(ch)) + int col = (int)(ix / ColumnWidth); + int row = (int)(iy / RowHeight); + int idx = col * RowsPerColumn + row; + // Only pick enabled items. + if (row >= 0 && row < RowsPerColumn && idx >= 0 && idx < Items.Count + && (EnabledProvider?.Invoke(Items[idx].Payload) ?? true)) { - Selected = ch; - OnChannelChanged?.Invoke(ch); + Selected = Items[idx].Payload; + OnSelect?.Invoke(Selected); } } _open = false; diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs index 717c92da..f8abfa55 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs @@ -41,7 +41,7 @@ public class ChatWindowControllerTests /// transcript (Type-12, no media) [0x10000011] ← skipped by factory /// track (Type-3) [0x10000012] /// inputBar (Type-3) [0x10000013] - /// menu (Type-3) [0x10000014] + /// menu (Type-6) [0x10000014] /// input (Type-12, no media) [0x10000016] ← skipped by factory /// send (Type-3) [0x10000019] /// maxmin (Type-3) [0x1000046F] @@ -67,7 +67,7 @@ public class ChatWindowControllerTests var menuNode = new ElementInfo { - Id = 0x10000014u, Type = 3, X = 0, Y = 0, Width = 46, Height = 17, + Id = 0x10000014u, Type = 6, X = 0, Y = 0, Width = 46, Height = 17, }; var inputNode = new ElementInfo { @@ -180,8 +180,8 @@ public class ChatWindowControllerTests var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex); Assert.NotNull(ctrl); - // Switch channel to General. - ctrl!.Menu.OnChannelChanged!.Invoke(ChatChannelKind.General); + // Switch channel to General via the generic OnSelect (payload is ChatChannelKind). + ctrl!.Menu.OnSelect!.Invoke((object?)ChatChannelKind.General); ctrl.Input.OnSubmit!.Invoke("hey all"); Assert.Single(bus.Published); diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index d2e8c439..4f546920 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -115,6 +115,15 @@ public class DatWidgetFactoryTests Assert.IsType(e); } + // ── Test 5d: Type 6 → UiMenu ───────────────────────────────────────────── + + [Fact] + public void Type6_Menu_MakesUiMenu() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 6, Width = 46, Height = 18 }, NoTex, null); + Assert.IsType(e); + } + // ── Test 6: Meter slice extraction (the important one) ─────────────────── /// diff --git a/tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs b/tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs deleted file mode 100644 index b3e9db8e..00000000 --- a/tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs +++ /dev/null @@ -1,125 +0,0 @@ -using System.Linq; -using AcDream.App.UI; -using AcDream.UI.Abstractions; - -namespace AcDream.App.Tests.UI; - -public class UiChannelMenuTests -{ - // PopupH = Rows(7) * ItemH(17) = 119; popup opens upward so top = -119. - // Item idx -> col = idx/7, row = idx%7; row band y in [top+row*17, top+(row+1)*17). - // Right column needs lx >= ColW(191). - - [Fact] - public void Items_HasExpected14Entries() - { - Assert.Equal(14, UiChannelMenu.Items.Length); - } - - [Fact] - public void Items_FirstEntry_IsSquelch_Special() - { - Assert.Equal("Squelch (ignore)", UiChannelMenu.Items[0].Label); - Assert.Null(UiChannelMenu.Items[0].Channel); - } - - [Fact] - public void Items_LastEntry_IsOlthoi() - { - var last = UiChannelMenu.Items[^1]; - Assert.Equal("Tell to Olthoi Chat", last.Label); - Assert.Equal(ChatChannelKind.Olthoi, last.Channel); - } - - [Fact] - public void Items_ContainAll12ChannelKinds() - { - var kinds = new HashSet( - UiChannelMenu.Items.Where(i => i.Channel is not null).Select(i => i.Channel!.Value)); - foreach (var k in new[] - { - ChatChannelKind.Say, ChatChannelKind.General, ChatChannelKind.Trade, ChatChannelKind.Lfg, - ChatChannelKind.Fellowship, ChatChannelKind.Allegiance, ChatChannelKind.Patron, - ChatChannelKind.Vassals, ChatChannelKind.Monarch, ChatChannelKind.Roleplay, - ChatChannelKind.Society, ChatChannelKind.Olthoi, - }) - Assert.Contains(k, kinds); - } - - [Fact] - public void DefaultSelected_IsSay() - { - Assert.Equal(ChatChannelKind.Say, new UiChannelMenu().Selected); - } - - [Fact] - public void Select_AvailableLeftColumnItem_FiresChannel() - { - var menu = new UiChannelMenu { Width = 80f, Height = 18f }; - Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open - - ChatChannelKind? fired = null; - menu.OnChannelChanged = k => fired = k; - - // "Chat to All" (Say) is index 2 = left col, row 2: y in [-85,-68). Say is available. - Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -76))); - Assert.Equal(ChatChannelKind.Say, fired); - Assert.Equal(ChatChannelKind.Say, menu.Selected); - } - - [Fact] - public void Select_AvailableRightColumnItem_FiresChannel() - { - var menu = new UiChannelMenu { Width = 80f, Height = 18f }; - Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open - - ChatChannelKind? fired = null; - menu.OnChannelChanged = k => fired = k; - - // "Tell to Trade Chat" (Trade) is index 11 = right col (lx>=191), row 4: y in [-51,-34). - Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 200, -42))); - Assert.Equal(ChatChannelKind.Trade, fired); - Assert.Equal(ChatChannelKind.Trade, menu.Selected); - } - - [Fact] - public void Select_SpecialItem_DoesNotFire() - { - var menu = new UiChannelMenu { Width = 80f, Height = 18f }; - Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open - int fired = 0; - menu.OnChannelChanged = _ => fired++; - - // "Squelch (ignore)" is index 0 = left col, row 0 (null channel): y in [-119,-102). - Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -110))); - Assert.Equal(0, fired); - } - - [Fact] - public void Select_UnavailableChannel_DoesNotFire() - { - var menu = new UiChannelMenu { Width = 80f, Height = 18f }; - Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open - int fired = 0; - menu.OnChannelChanged = _ => fired++; - - // "Tell to Fellows" (Fellowship) is index 3 = left col, row 3: y in [-68,-51). - // Fellowship is unavailable by the default static gate, so the click is inert. - Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -60))); - Assert.Equal(0, fired); - } - - [Fact] - public void AvailabilityProvider_Overrides_DefaultGate() - { - var menu = new UiChannelMenu { Width = 80f, Height = 18f, AvailabilityProvider = _ => true }; - Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open - - ChatChannelKind? fired = null; - menu.OnChannelChanged = k => fired = k; - - // With every channel available, "Tell to Fellows" (idx 3, row 3) now fires. - Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -60))); - Assert.Equal(ChatChannelKind.Fellowship, fired); - } -} diff --git a/tests/AcDream.App.Tests/UI/UiMenuTests.cs b/tests/AcDream.App.Tests/UI/UiMenuTests.cs new file mode 100644 index 00000000..1e4e1bd5 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiMenuTests.cs @@ -0,0 +1,170 @@ +using System.Collections.Generic; +using System.Linq; +using AcDream.App.UI; +using AcDream.UI.Abstractions; + +namespace AcDream.App.Tests.UI; + +public class UiMenuTests +{ + // PopupH = RowsPerColumn(7) * RowHeight(17) = 119; popup opens upward so top = -119. + // Item idx -> col = idx/7, row = idx%7; row band y in [top+row*17, top+(row+1)*17). + // Right column needs lx >= ColumnWidth(191) + Border(5) = lx >= 196 after bevel offset, + // but the original tests used lx=200 which maps ix=195 -> col=(int)(195/191)=1. OK. + + // The 14 channel items verbatim (matches ChannelItems in ChatWindowController). + private static readonly UiMenu.MenuItem[] ChannelItems = + { + new("Squelch (ignore)", (object?)null), + new("Tell to Selected", (object?)null), + new("Chat to All", (object?)ChatChannelKind.Say), + new("Tell to Fellows", (object?)ChatChannelKind.Fellowship), + new("Tell to General Chat", (object?)ChatChannelKind.General), + new("Tell to LFG Chat", (object?)ChatChannelKind.Lfg), + new("Tell to Society Chat", (object?)ChatChannelKind.Society), + new("Tell to Monarch", (object?)ChatChannelKind.Monarch), + new("Tell to Patron", (object?)ChatChannelKind.Patron), + new("Tell to Vassals", (object?)ChatChannelKind.Vassals), + new("Tell to Allegiance", (object?)ChatChannelKind.Allegiance), + new("Tell to Trade Chat", (object?)ChatChannelKind.Trade), + new("Tell to Roleplay Chat", (object?)ChatChannelKind.Roleplay), + new("Tell to Olthoi Chat", (object?)ChatChannelKind.Olthoi), + }; + + // Availability gate identical to ChatWindowController.ChannelAvailable. + private static bool ChannelAvailable(object? p) + => p is ChatChannelKind ch + ? ch is ChatChannelKind.Say or ChatChannelKind.General + or ChatChannelKind.Trade or ChatChannelKind.Lfg + : false; // null-payload (Squelch/Tell-to-Selected) = inert + + private UiMenu MakeMenu() => new UiMenu + { + Width = 80f, Height = 18f, + Items = ChannelItems, + Selected = (object?)ChatChannelKind.Say, + EnabledProvider = ChannelAvailable, + }; + + [Fact] + public void Items_HasExpected14Entries() + { + Assert.Equal(14, ChannelItems.Length); + } + + [Fact] + public void Items_FirstEntry_IsSquelch_Special() + { + Assert.Equal("Squelch (ignore)", ChannelItems[0].Label); + Assert.Null(ChannelItems[0].Payload); + } + + [Fact] + public void Items_LastEntry_IsOlthoi() + { + var last = ChannelItems[^1]; + Assert.Equal("Tell to Olthoi Chat", last.Label); + Assert.Equal(ChatChannelKind.Olthoi, last.Payload); + } + + [Fact] + public void Items_ContainAll12ChannelKinds() + { + var kinds = new HashSet( + ChannelItems.Where(i => i.Payload is ChatChannelKind).Select(i => (ChatChannelKind)i.Payload!)); + foreach (var k in new[] + { + ChatChannelKind.Say, ChatChannelKind.General, ChatChannelKind.Trade, ChatChannelKind.Lfg, + ChatChannelKind.Fellowship, ChatChannelKind.Allegiance, ChatChannelKind.Patron, + ChatChannelKind.Vassals, ChatChannelKind.Monarch, ChatChannelKind.Roleplay, + ChatChannelKind.Society, ChatChannelKind.Olthoi, + }) + Assert.Contains(k, kinds); + } + + [Fact] + public void DefaultSelected_IsNull_OnBlankMenu() + { + // A freshly constructed UiMenu has no Selected by default (controller sets it). + Assert.Null(new UiMenu().Selected); + } + + [Fact] + public void Select_AvailableLeftColumnItem_FiresOnSelect() + { + var menu = MakeMenu(); + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open + + object? fired = null; + menu.OnSelect = p => fired = p; + + // "Chat to All" (Say) is index 2 = left col, row 2: y in [-85,-68). Say is available. + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -76))); + Assert.Equal(ChatChannelKind.Say, fired); + Assert.Equal(ChatChannelKind.Say, menu.Selected); + } + + [Fact] + public void Select_AvailableRightColumnItem_FiresOnSelect() + { + var menu = MakeMenu(); + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open + + object? fired = null; + menu.OnSelect = p => fired = p; + + // "Tell to Trade Chat" (Trade) is index 11 = right col (lx>=191), row 4: y in [-51,-34). + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 200, -42))); + Assert.Equal(ChatChannelKind.Trade, fired); + Assert.Equal(ChatChannelKind.Trade, menu.Selected); + } + + [Fact] + public void Select_SpecialItem_DoesNotFire() + { + var menu = MakeMenu(); + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open + int fired = 0; + menu.OnSelect = _ => fired++; + + // "Squelch (ignore)" is index 0 = left col, row 0 (null payload): y in [-119,-102). + // null payload → ChannelAvailable returns false → inert. + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -110))); + Assert.Equal(0, fired); + } + + [Fact] + public void Select_UnavailableChannel_DoesNotFire() + { + var menu = MakeMenu(); + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open + int fired = 0; + menu.OnSelect = _ => fired++; + + // "Tell to Fellows" (Fellowship) is index 3 = left col, row 3: y in [-68,-51). + // Fellowship is unavailable by the default static gate, so the click is inert. + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -60))); + Assert.Equal(0, fired); + } + + [Fact] + public void EnabledProvider_Overrides_DefaultGate() + { + // Override: all items enabled (even Fellowship which is normally greyed). + var menu = new UiMenu + { + Width = 80f, Height = 18f, + Items = ChannelItems, + Selected = (object?)ChatChannelKind.Say, + EnabledProvider = _ => true, + }; + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open + + object? fired = null; + menu.OnSelect = p => fired = p; + + // With every item enabled, "Tell to Fellows" (idx 3, row 3) now fires. + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -60))); + Assert.Equal(ChatChannelKind.Fellowship, fired); + } +} From 67e5b8cff29117bf7c83c086cccd5b5d99f316ac Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 17:27:30 +0200 Subject: [PATCH 90/99] =?UTF-8?q?fix(D.2b):=20UiMenu=20=E2=80=94=20control?= =?UTF-8?q?ler=20owns=20Selected=20(review=20fix=20for=20Task=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught a behavior divergence: the generic UiMenu auto-set its own Selected on any enabled pick, while the controller's EnabledProvider keeps the null-payload specials (Squelch / Tell-to-Selected) enabled/white like retail. So a special-item click set Selected=null and shifted the highlight onto the deferred placeholders — and the menu tests masked it by using a different (specials-disabled) gate than the controller ships. Fix: clean MVC contract mirroring retail UIElement_Menu::NewSelection — the widget REPORTS the pick via OnSelect; the controller OWNS Selected (it sets it only for talk-channel payloads). A special-item click now fires OnSelect(null), the controller ignores it, and the active channel + highlight stay put — observably identical to the pre-generalization widget, and extensible for when Squelch lands. Tests realigned to the controller's gate (specials white) and to the controller-owns-Selected contract. Full suite: 403 passed, 2 skipped, 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../UI/Layout/ChatWindowController.cs | 5 +++ src/AcDream.App/UI/UiMenu.cs | 9 +++-- tests/AcDream.App.Tests/UI/UiMenuTests.cs | 36 +++++++++++-------- 3 files changed, 34 insertions(+), 16 deletions(-) diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index a6281eaa..527e1fad 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -266,8 +266,13 @@ public sealed class ChatWindowController menu.Items = System.Array.ConvertAll(ChannelItems, t => new UiMenu.MenuItem(t.Label, (object?)t.Channel)); menu.Selected = (object?)c._activeChannel; + // Specials (Squelch / Tell-to-Selected, null payload) render WHITE/enabled like + // retail; only the talk-CHANNEL items grey when unavailable. menu.EnabledProvider = p => p is not ChatChannelKind ch || ChannelAvailable(ch); menu.ButtonLabelProvider = () => ChannelButtonLabel(c._activeChannel); + // The widget reports the pick; the controller owns Selected. Only a talk-channel + // payload updates the active channel + highlight — the null-payload specials are + // deferred no-ops (see the chat re-drive deferred list) and leave selection intact. menu.OnSelect = p => { if (p is ChatChannelKind ch) { c._activeChannel = ch; menu.Selected = p; } diff --git a/src/AcDream.App/UI/UiMenu.cs b/src/AcDream.App/UI/UiMenu.cs index b3e1595d..85241a68 100644 --- a/src/AcDream.App/UI/UiMenu.cs +++ b/src/AcDream.App/UI/UiMenu.cs @@ -223,8 +223,13 @@ public sealed class UiMenu : UiElement if (row >= 0 && row < RowsPerColumn && idx >= 0 && idx < Items.Count && (EnabledProvider?.Invoke(Items[idx].Payload) ?? true)) { - Selected = Items[idx].Payload; - OnSelect?.Invoke(Selected); + // The widget REPORTS the pick; the controller owns Selected (it sets + // Selected only for payloads it acts on). This mirrors retail + // UIElement_Menu::NewSelection delegating to the owner rather than + // self-selecting — so a deferred/no-op item (e.g. the Squelch / + // Tell-to-Selected specials, null payload) leaves the current + // selection + highlight unchanged when the controller ignores it. + OnSelect?.Invoke(Items[idx].Payload); } } _open = false; diff --git a/tests/AcDream.App.Tests/UI/UiMenuTests.cs b/tests/AcDream.App.Tests/UI/UiMenuTests.cs index 1e4e1bd5..4b1a16fe 100644 --- a/tests/AcDream.App.Tests/UI/UiMenuTests.cs +++ b/tests/AcDream.App.Tests/UI/UiMenuTests.cs @@ -31,12 +31,14 @@ public class UiMenuTests new("Tell to Olthoi Chat", (object?)ChatChannelKind.Olthoi), }; - // Availability gate identical to ChatWindowController.ChannelAvailable. + // Availability gate identical to ChatWindowController's EnabledProvider: the null-payload + // specials (Squelch/Tell-to-Selected) are ENABLED/white like retail; only talk-CHANNEL + // items grey when unavailable. (The widget reports any enabled pick via OnSelect; the + // controller decides whether to update Selected, so specials are inert no-ops anyway.) private static bool ChannelAvailable(object? p) - => p is ChatChannelKind ch - ? ch is ChatChannelKind.Say or ChatChannelKind.General - or ChatChannelKind.Trade or ChatChannelKind.Lfg - : false; // null-payload (Squelch/Tell-to-Selected) = inert + => p is not ChatChannelKind ch + || ch is ChatChannelKind.Say or ChatChannelKind.General + or ChatChannelKind.Trade or ChatChannelKind.Lfg; private UiMenu MakeMenu() => new UiMenu { @@ -96,7 +98,8 @@ public class UiMenuTests Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open object? fired = null; - menu.OnSelect = p => fired = p; + // Mirror the controller: the widget reports the pick, the controller sets Selected. + menu.OnSelect = p => { fired = p; if (p is ChatChannelKind) menu.Selected = p; }; // "Chat to All" (Say) is index 2 = left col, row 2: y in [-85,-68). Say is available. Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -76))); @@ -111,7 +114,8 @@ public class UiMenuTests Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open object? fired = null; - menu.OnSelect = p => fired = p; + // Mirror the controller: the widget reports the pick, the controller sets Selected. + menu.OnSelect = p => { fired = p; if (p is ChatChannelKind) menu.Selected = p; }; // "Tell to Trade Chat" (Trade) is index 11 = right col (lx>=191), row 4: y in [-51,-34). Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 200, -42))); @@ -120,17 +124,21 @@ public class UiMenuTests } [Fact] - public void Select_SpecialItem_DoesNotFire() + public void Select_SpecialItem_FiresNull_LeavesSelectionUnchanged() { - var menu = MakeMenu(); + var menu = MakeMenu(); // Selected = Say Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open - int fired = 0; - menu.OnSelect = _ => fired++; - // "Squelch (ignore)" is index 0 = left col, row 0 (null payload): y in [-119,-102). - // null payload → ChannelAvailable returns false → inert. + // Mirror the controller: only channel payloads update Selected; the null-payload + // specials are deferred no-ops that leave the active channel + highlight unchanged. + bool fired = false; object? firedPayload = "sentinel"; + menu.OnSelect = p => { fired = true; firedPayload = p; if (p is ChatChannelKind) menu.Selected = p; }; + + // "Squelch (ignore)" is index 0 = left col, row 0 (null payload), white/enabled. Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -110))); - Assert.Equal(0, fired); + Assert.True(fired); // the pick IS reported... + Assert.Null(firedPayload); // ...with the special's null payload + Assert.Equal(ChatChannelKind.Say, menu.Selected); // ...but selection is unchanged (deferred no-op) } [Fact] From cb082b59e4c9246e5aca139c0b808e25086b8246 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 17:39:02 +0200 Subject: [PATCH 91/99] feat(D.2b): UiText (Type 12) -- generic text + Type-12 flip; transcript factory-built (widget-generalization Task 5) Rename UiChatView -> UiText (the retail UIElement_Text class, RegisterElementClass(0xc) @ acclient_2013_pseudo_c.txt:115655). Factory changes (DatWidgetFactory.cs): - Remove the Type-12 skip (was: no-media -> null, with-media -> UiDatElement). - Add Type 12 -> BuildText() -> UiText in the switch. - BuildText extracts the element's Direct/Normal sprite as BackgroundSprite so any dat-media the element carried keeps rendering under the text. UiText changes (renamed from UiChatView.cs): - BackgroundColor default: (0,0,0,0.35) -> (0,0,0,0) (transparent). An unbound UiText draws nothing; the controller opts in to the translucent bg. - New BackgroundSprite + SpriteResolve: optional dat state-sprite background drawn UNDER DrawFill+text (faithful UIElement_Text media support). ChatWindowController.cs (Task 5 Step 8): - Transcript property: UiChatView -> UiText. - Bind() now uses layout.FindElement(TranscriptId) as UiText (factory-built) instead of manually constructing + AddChild-ing a new UiChatView. - Sets BackgroundColor = (0,0,0,0.35) on the found widget (retail translucent bg). - Removes the tInfo null-check from the early guard (transcript is factory-built; iInfo lookup kept for the input widget which is still manually constructed). - BuildLines: UiChatView.Line -> UiText.Line throughout. Vitals frozen: the Type-12 vitals number elements are meter children and are never recursed by BuildWidget (the `if (w is not UiMeter)` gate), so they are not built as widgets and keep rendering via UiMeter.Label. Vitals fixture vitals_2100006C.json unchanged; LayoutConformanceTests + VitalsBindingTests green. Tests: - UiChatViewTests.cs -> UiTextTests.cs (class: UiTextTests, all UiChatView.* -> UiText.*) - UiChatViewDatFontTests.cs -> UiTextDatFontTests.cs (same) - DatWidgetFactoryTests: delete Type12_StylePrototype_ReturnsNull + DatWidgetFactory_Type12WithMedia_Renders; add Type12_Text_MakesUiText + DatWidgetFactory_Type12_AlwaysMakesUiText. - LayoutImporterTests: BuildFromInfos_Type12Child_IsSkipped_Type3Present updated to assert IsType (element is now in tree, transparent, not skipped). Divergence register: AP-37 amended -- removed the "standalone Type-0 text elements skipped / dat-text widget is Plan 2" clause (now shipped as UiText); kept the meter-collapse clause and the vitals-numbers-via-UiMeter.Label clause. AP-38/AP-39/AD-28 file references updated UiChatView.cs -> UiText.cs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../retail-divergence-register.md | 8 +-- .../UI/Layout/ChatWindowController.cs | 46 ++++++-------- src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 36 +++++++---- src/AcDream.App/UI/UiHost.cs | 2 +- src/AcDream.App/UI/UiScrollable.cs | 2 +- .../UI/{UiChatView.cs => UiText.cs} | 31 ++++++++-- .../UI/Layout/DatWidgetFactoryTests.cs | 35 +++-------- .../UI/Layout/LayoutImporterTests.cs | 15 ++--- ...wDatFontTests.cs => UiTextDatFontTests.cs} | 8 +-- .../UI/{UiChatViewTests.cs => UiTextTests.cs} | 62 +++++++++---------- 10 files changed, 127 insertions(+), 118 deletions(-) rename src/AcDream.App/UI/{UiChatView.cs => UiText.cs} (92%) rename tests/AcDream.App.Tests/UI/{UiChatViewDatFontTests.cs => UiTextDatFontTests.cs} (74%) rename tests/AcDream.App.Tests/UI/{UiChatViewTests.cs => UiTextTests.cs} (51%) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 052cca56..23cb919a 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -90,7 +90,7 @@ accepted-divergence entries (#96, #49, #50). | AD-25 | Wall-bounce velocity reflection suppressed on landing (fires only airborne-before AND airborne-after); retail bounces unless grounded→grounded-and-not-sledding | `src/AcDream.App/Input/PlayerMovementController.cs:1212` | Our per-frame architecture amplifies the artifact (post-reflection +Z defeats the `Velocity.Z <= 0` landing-snap gate → micro-bounce death spiral); at elasticity 0.05 retail's landing bounce is imperceptible; sledding reverts to retail rule | Landing-reflection-dependent behavior (slope-landing momentum, high-elasticity surfaces) won't reproduce; the suppression masks the landing-snap gate fragility and could outlive its reason | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 | | AD-26 | Auto-walk arrival requires facing alignment (invented 5° arrive / 30° walk-while-turning bands); retail's check is `dist <= radius` exact | `src/AcDream.App/Input/PlayerMovementController.cs:575` | ACE does the final `Rotate(target)` server-side before the Use callback; without a local gate the body used items while facing away (user feedback 2026-05-15). Thresholds are NOT retail constants | Arrival delayed by the rotation phase; if heading convergence fights another yaw writer, `AutoWalkArrived` never fires and the queued Use/PickUp never completes | `MoveToManager::HandleMoveToPosition`; `apply_interpreted_movement` | | AD-27 | Use/PickUp action re-sent on natural auto-walk arrival; retail sends the action once (server MoveToChain callback completes it) | `src/AcDream.App/Input/PlayerMovementController.cs:322` | ACE's server-side chain may have timed out by the time our body arrives; the close-range re-send hits ACE's WithinUseRadius fast-path | If the server's chain has NOT timed out, the action executes twice — door toggles open-then-closed, use-once interactions double-fire; protocol noise on non-ACE servers | ACE CreateMoveToChain / WithinUseRadius | -| AD-28 | Chat transcript (`UiChatView`) and input (`UiChatInput`) are two separate widget classes placed inside their dat-authored container panels; retail's `ChatInterface` uses a single mode-flagged `UIElement_Text` (Type-12) that switches between read and edit mode | `src/AcDream.App/UI/Layout/ChatWindowController.cs:135` (transcript) + `:150` (input) | `UIElement_Text` is inside keystone.dll with no PDB/decomp; a two-widget split is functionally equivalent (read-only scroll, editable input) and is the structural adaptation required by our UiElement architecture | A future consumer expecting a single widget for both read/write (e.g. a plugin calling the chat API and getting one widget back) must be written to the two-widget contract | `UIElement_Text` (Type-12) @ keystone.dll; `gmMainChatUI::PostInit` @0x4ce130 | +| AD-28 | Chat transcript (`UiText`) and input (`UiChatInput`) are two separate widget classes placed inside their dat-authored container panels; retail's `ChatInterface` uses a single mode-flagged `UIElement_Text` (Type-12) that switches between read and edit mode | `src/AcDream.App/UI/Layout/ChatWindowController.cs:135` (transcript) + `:150` (input) | `UIElement_Text` is inside keystone.dll with no PDB/decomp; a two-widget split is functionally equivalent (read-only scroll, editable input) and is the structural adaptation required by our UiElement architecture | A future consumer expecting a single widget for both read/write (e.g. a plugin calling the chat API and getting one widget back) must be written to the two-widget contract | `UIElement_Text` (Type-12) @ keystone.dll; `gmMainChatUI::PostInit` @0x4ce130 | --- @@ -134,9 +134,9 @@ accepted-divergence entries (#96, #49, #50). | AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins) + outdoor-root attached scene emitters moved to the post-frame pass, not retail's single deferred alpha flush. Residual: building exteriors' / outside-stage dynamics' own translucent MESH batches still draw within their stage draw call (before later stage content) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) + `GameWindow` post-frame Scene pass | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the user-visible cases (#131 portal swirl, #132 candle flame indoors + outdoors) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) | | AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp) | `src/AcDream.App/Rendering/GameWindow.cs:6895` (per-frame predicate) + `:IsSealedDungeonCell` + `:OnLiveEntitySpawnedLocked`/`:OnLivePositionUpdated` (login/teleport pre-collapse hooks) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277 | | AP-35 | Point/spot lights use a single PER-PIXEL accumulation that ports `calc_point_light`'s `(1 − dist/falloff_eff)` LINEAR ramp (falloff_eff = Falloff × static_light_factor 1.3) + standard Lambert `N·L`; retail's path is PER-VERTEX Gouraud and additionally applies a half-Lambert wrap (`0.5·dist + N·L_vec`, lights surfaces down to `N·L ≥ −0.5`) and an x87-obscured normalization factor, neither ported | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag:52` (+ `mesh.frag`; `LightInfoLoader.cs:81` folds 1.3 into Range) | The linear ramp is the user-visible fix (kills the hard-disc "spotlight" edge, #133 A7); the dropped wrap/normalization only re-shade the gradient slightly, and per-pixel vs per-vertex Gouraud chiefly differs on coarse geometry. Half-Lambert wrap + factor are an x87-decompile refinement (same artifact class as GetPowerBarLevel AP-24) | Surfaces facing slightly away from a torch (`−0.5 ≤ N·L < 0`) stay dark where retail's wrap lights them faintly; near-light gradient shading differs subtly from retail's per-vertex bake | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); static_light_factor 0x00820e24 | -| AP-37 | LayoutDesc importer collapses the dat's nested meter structure (Type-7 meter → two Type-3 container children → three Type-3 image-slice grandchildren each) into `UiMeter`'s programmatic 3-slice fields (`BackLeft..FrontRight`) + reuses `UiMeter.DrawHBar`'s scissor-fill, instead of building those child nodes generically and porting `UIElement_Meter::DrawChildren`. Standalone Type-0 text elements are also skipped (vitals numbers render via `UiMeter.Label` bound by the controller; a dedicated dat-text widget is Plan 2). The inheritance `Merge` treats Width/Height==0 as "inherit from base", diverging from format-doc §12 rule 2 (documented inline in `ElementReader.cs`) | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`) + `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`BuildWidget` meter-child skip) | Reuses the tested `UiMeter` render that already visually matches retail's stacked vitals bars; the full nested-element + `DrawChildren` scissor port and a dat-text widget are deferred to Plan 2. Now the default vitals path (the hand-authored markup vitals was retired) and locked by the conformance fixture (`tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json`) | A LayoutDesc whose meter structure differs from the vitals 2-container/3-slice shape, or a window needing standalone dat text, renders an empty/wrong meter or drops text — no oracle diff until the Plan-2 widgets land | `UIElement_Meter::DrawChildren` @0x46fbd0; `UIElement_Text::DrawSelf` @0x467aa0; `docs/research/2026-06-15-layoutdesc-format.md` | -| AP-38 | Chat transcript renders pre-split `ChatLog` lines 1:1; no in-element word-wrap at the panel's current pixel width | `src/AcDream.App/UI/UiChatView.cs` | Retail does in-element wrap via `UIElement_Text::SizeToFit`; our pre-split lines are always shorter than 440 px in practice; a line that overflows clips at the edge rather than wrapping | Very long server system messages (server shutdowns, broadcast announcements) clip rather than wrapping — no information loss, just visual truncation | `UIElement_Text::SizeToFit` @0x467980; `gmMainChatUI` layout | -| AP-39 | Chat lines carry one color per `ChatKind` (per-line solid color); retail `UIElement_Text` supports per-glyph styled runs (bold, different hue per segment) | `src/AcDream.App/UI/UiChatView.cs:13` | Retail glyph-run parsing lives inside keystone.dll with no PDB/decomp; per-line per-kind coloring is the correct tonal palette and covers all existing chat types | Chat lines retail renders with multiple colors or bold names (e.g. "PlayerName says: text") render as one flat color; subtle visual difference but functionally complete | `UIElement_Text` glyph-run styling (keystone.dll, no decomp) | +| AP-37 | LayoutDesc importer collapses the dat's nested meter structure (Type-7 meter → two Type-3 container children → three Type-3 image-slice grandchildren each) into `UiMeter`'s programmatic 3-slice fields (`BackLeft..FrontRight`) + reuses `UiMeter.DrawHBar`'s scissor-fill, instead of building those child nodes generically and porting `UIElement_Meter::DrawChildren`. Vitals number elements are meter children (not recursed) and continue to render via `UiMeter.Label` bound by the controller (Task 8). The inheritance `Merge` treats Width/Height==0 as "inherit from base", diverging from format-doc §12 rule 2 (documented inline in `ElementReader.cs`) | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`) + `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`BuildWidget` meter-child skip) | Reuses the tested `UiMeter` render that already visually matches retail's stacked vitals bars; the full nested-element + `DrawChildren` scissor port is deferred to Plan 2. Locked by the conformance fixture (`tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json`) | A LayoutDesc whose meter structure differs from the vitals 2-container/3-slice shape renders an empty/wrong meter — no oracle diff until the Plan-2 port lands | `UIElement_Meter::DrawChildren` @0x46fbd0; `docs/research/2026-06-15-layoutdesc-format.md` | +| AP-38 | Chat transcript renders pre-split `ChatLog` lines 1:1; no in-element word-wrap at the panel's current pixel width | `src/AcDream.App/UI/UiText.cs` | Retail does in-element wrap via `UIElement_Text::SizeToFit`; our pre-split lines are always shorter than 440 px in practice; a line that overflows clips at the edge rather than wrapping | Very long server system messages (server shutdowns, broadcast announcements) clip rather than wrapping — no information loss, just visual truncation | `UIElement_Text::SizeToFit` @0x467980; `gmMainChatUI` layout | +| AP-39 | Chat lines carry one color per `ChatKind` (per-line solid color); retail `UIElement_Text` supports per-glyph styled runs (bold, different hue per segment) | `src/AcDream.App/UI/UiText.cs:13` | Retail glyph-run parsing lives inside keystone.dll with no PDB/decomp; per-line per-kind coloring is the correct tonal palette and covers all existing chat types | Chat lines retail renders with multiple colors or bold names (e.g. "PlayerName says: text") render as one flat color; subtle visual difference but functionally complete | `UIElement_Text` glyph-run styling (keystone.dll, no decomp) | | AP-40 | Single default translucency for the chat window chrome; no focused/unfocused opacity transition; dat font face/size taken from the vitals `vitalsDatFont` (same dat font, not a chat-specific size lookup) | `src/AcDream.App/Rendering/GameWindow.cs` (chatController binding line) | Retail fades the chat window to ~80% alpha when unfocused (`gmMainChatUI::UpdateAlpha @0x4cdea0`); the opacity animation deferred to the Plan-2 window-manager input integration; sharing `vitalsDatFont` is safe — retail uses the same AC-default font for both | The chat window is always fully opaque/same-font rather than subtly fading when idle; no wrong text, but the focused/unfocused breathing rhythm is absent | `gmMainChatUI::UpdateAlpha` @0x4cdea0; `UCF::SetAceFont @0x4d3940` | | AP-41 | Scrollbar thumb 3-slice cap fallback only: single-tile draw (`0x06004C63`) used only when `ThumbTopSprite`/`ThumbBotSprite` are unset; the chat controller passes all three cap ids so the 3-slice path is drawn in practice | `src/AcDream.App/UI/UiScrollbar.cs:35` | The fallback single-tile path is unreachable when caps are bound (chat controller always sets them); the 3-slice path is the active code path | Only if a future caller omits the cap ids will the fallback fire — no visual regression in the chat window | `UIElement_Scrollbar::UpdateLayout @0x4710d0`; cap sprites `0x06004C60` (top) + `0x06004C66` (bottom) from base layout `0x2100003E` | | AP-42 | `UiMenu` item model is flat (label + opaque payload, single-level popup); retail `UIElement_Menu::MakePopup @0x46d310` supports hierarchical nested submenus via recursive popup chain | `src/AcDream.App/UI/UiMenu.cs` | The chat talk-focus menu is single-level (14 rows, 2 columns, no submenu); hierarchy is latent and unreachable through the chat window — no behavioral difference in the current usage | A future menu with nested submenus would render flat (only the top-level items drawn, no drill-down) | `UIElement_Menu::MakePopup` @0x46d310 | diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index 527e1fad..f4fdce87 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -65,7 +65,7 @@ public sealed class ChatWindowController public UiElement Root { get; private set; } = null!; /// Live chat transcript widget. Null until succeeds. - public UiChatView Transcript { get; private set; } = null!; + public UiText Transcript { get; private set; } = null!; /// Editable chat input widget. Null until succeeds. public UiChatInput Input { get; private set; } = null!; @@ -160,20 +160,20 @@ public sealed class ChatWindowController BitmapFont? debugFont, Func resolve) { - // The transcript + input nodes are Type-12 based and were skipped by the factory. - // Find them in the raw ElementInfo tree to read their rects. - var tInfo = FindInfo(rootInfo, TranscriptId); + // The transcript is now built as a UiText by the factory (Type 12 is no longer skipped). + // The input node (0x10000016) is still Type-12 based; find it in the raw ElementInfo + // tree to read its rect for the behavioral UiChatInput widget. var iInfo = FindInfo(rootInfo, InputId); // Their parent panels must exist as real widgets in the layout tree. var transcriptPanel = layout.FindElement(TranscriptPanelId); var inputBar = layout.FindElement(InputBarId); - if (tInfo is null || iInfo is null || transcriptPanel is null || inputBar is null) + if (iInfo is null || transcriptPanel is null || inputBar is null) { Console.WriteLine( $"[D.2b] ChatWindowController.Bind: missing required elements " + - $"(tInfo={tInfo is not null}, iInfo={iInfo is not null}, " + + $"(iInfo={iInfo is not null}, " + $"panel={transcriptPanel is not null}, bar={inputBar is not null}) — " + $"chat window will not be interactive."); return null; @@ -204,20 +204,14 @@ public sealed class ChatWindowController transcriptPanel.Height += 9f; // dat resize-bar height (0x1000000F H=9) // ── Transcript ─────────────────────────────────────────────────── - // Place the behavioral transcript widget inside the transcript panel at the - // dat-rect of the (skipped) Type-12 transcript element. - c.Transcript = new UiChatView - { - Left = tInfo.X, - Top = tInfo.Y, - Width = tInfo.Width, - Height = tInfo.Height, - Anchors = ElementReader.ToAnchors(tInfo.Left, tInfo.Top, tInfo.Right, tInfo.Bottom), - DatFont = datFont, - Font = debugFont, - LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont), - }; - transcriptPanel.AddChild(c.Transcript); + // The factory now builds the Type-12 transcript element (0x10000011) as a UiText. + // Find it in the widget tree and bind the live providers — no remove/add needed. + c.Transcript = layout.FindElement(TranscriptId) as UiText + ?? throw new InvalidOperationException("chat transcript 0x10000011 not built as UiText"); + c.Transcript.DatFont = datFont; + c.Transcript.Font = debugFont; + c.Transcript.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); // retail translucent transcript + c.Transcript.LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont); // ── Input ──────────────────────────────────────────────────────── // Place the behavioral input widget inside the input bar. @@ -373,14 +367,14 @@ public sealed class ChatWindowController /// /// Convert the ChatVM's detailed lines to the transcript's - /// record format, applying retail-faithful + /// record format, applying retail-faithful /// per- colors. /// - private static IReadOnlyList BuildLines( - ChatVM vm, UiChatView view, UiDatFont? datFont, BitmapFont? debugFont) + private static IReadOnlyList BuildLines( + ChatVM vm, UiText view, UiDatFont? datFont, BitmapFont? debugFont) { var detailed = vm.RecentLinesDetailed(); - if (detailed.Count == 0) return Array.Empty(); + if (detailed.Count == 0) return Array.Empty(); // Word-wrap each message to the transcript's current pixel width (ports retail // GlyphList::Recalculate @0x473800 — break at word boundaries when the line would @@ -391,12 +385,12 @@ public sealed class ChatWindowController : debugFont is { } bf ? s => bf.MeasureWidth(s) : s => s.Length * 7f; - var result = new List(detailed.Count); + var result = new List(detailed.Count); foreach (var d in detailed) { var color = RetailChatColor(d.Kind); foreach (var frag in WrapText(d.Text, maxW, measure)) - result.Add(new UiChatView.Line(frag, color)); + result.Add(new UiText.Line(frag, color)); } return result; } diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 556fc3ee..4c90f37e 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -10,11 +10,11 @@ namespace AcDream.App.UI.Layout; /// . /// /// -/// Type 12 elements that carry NO own state media (pure style prototypes / -/// BaseElement stores) return null from and are skipped. -/// Type 12 elements that DO carry own sprites (e.g. a chat element whose Type-0 -/// derived form inherited Type 12 from its base prototype) are rendered normally. -/// See docs/research/2026-06-15-layoutdesc-format.md Correction 8. +/// Type 12 = UIElement_Text — a scrollable colored-line text view. Every Type-12 +/// element is now built as a . Elements that carry their own +/// dat sprite media keep it as the . Pure +/// prototype elements (no state media, no controller binding) draw nothing because +/// defaults to transparent. /// /// /// @@ -45,23 +45,17 @@ public static class DatWidgetFactory /// Returns (0,0,0) when the texture is not yet uploaded. /// Retail UI font for the meter's "cur/max" number overlay. /// May be null pre-load — the meter falls back to the debug bitmap font. - /// The widget, or null for a pure Type-12 style prototype with no own sprites (caller skips it). + /// The widget for this element. Never null — every type produces a widget. public static UiElement? Create(ElementInfo info, Func resolve, UiDatFont? datFont) { - // Type 12 = style prototype / BaseElement store referenced by BaseLayoutId. - // PURE prototypes (no own state media) are property bags — never rendered; skip them. - // A Type-12 element that carries its own state media (e.g. a chat Send button whose - // Type-0 derived element inherited Type 12 from its base prototype) has sprites to - // show and must render. See format doc §8 and the G1 task note. - if (info.Type == 12 && info.StateMedia.Count == 0) return null; - UiElement e = info.Type switch { 1 => new UiButton(info, resolve), // UIElement_Button (reg :125828) 6 => new UiMenu(), // UIElement_Menu (reg :120163) 7 => BuildMeter(info, resolve, datFont), // UIElement_Meter 11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137) + 12 => BuildText(info, resolve), // UIElement_Text (reg :115655) _ => new UiDatElement(info, resolve), // generic fallback for all other types }; @@ -178,4 +172,20 @@ public static class DatWidgetFactory return (left, tile, right); } + + // ── Text ───────────────────────────────────────────────────────────────── + + /// Type-12 UIElement_Text: a scrollable colored-line text view. The element's + /// own Direct/Normal media (if any) becomes the background sprite, drawn under the text — + /// so a Type-12 element that previously rendered via UiDatElement keeps its sprite. Lines + /// are bound later by the controller (LinesProvider). An unbound UiText draws nothing + /// because defaults to transparent. + private static UiText BuildText(ElementInfo info, Func resolve) + { + uint bg = info.StateMedia.TryGetValue( + !string.IsNullOrEmpty(info.DefaultStateName) ? info.DefaultStateName + : info.StateMedia.ContainsKey("Normal") ? "Normal" : "", out var m) + ? m.File : 0u; + return new UiText { BackgroundSprite = bg, SpriteResolve = resolve }; + } } diff --git a/src/AcDream.App/UI/UiHost.cs b/src/AcDream.App/UI/UiHost.cs index a372f891..718d5cbd 100644 --- a/src/AcDream.App/UI/UiHost.cs +++ b/src/AcDream.App/UI/UiHost.cs @@ -42,7 +42,7 @@ public sealed class UiHost : System.IDisposable /// The last wired keyboard. Exposed so widgets that need clipboard /// access () or modifier-key state - /// () — e.g. 's + /// () — e.g. 's /// Ctrl+C copy — can reach the device. One-keyboard desktop: last wins. public IKeyboard? Keyboard { get; private set; } diff --git a/src/AcDream.App/UI/UiScrollable.cs b/src/AcDream.App/UI/UiScrollable.cs index 2167b387..f9e78a12 100644 --- a/src/AcDream.App/UI/UiScrollable.cs +++ b/src/AcDream.App/UI/UiScrollable.cs @@ -7,7 +7,7 @@ namespace AcDream.App.UI; /// the scroll offset is an integer pixel value (m_iScrollableY) clamped to /// [0, ContentHeight - ViewHeight]; the thumb ratio is view/content; the position /// ratio is scroll/(content-view). Pure (no GL) so it is fully unit-tested and -/// shared by the transcript (UiChatView) and the scrollbar (UiScrollbar). +/// shared by the transcript (UiText) and the scrollbar (UiScrollbar). /// Decomp anchors: SetScrollableXY @0x4740c0, UpdateScrollbarSize_ @0x4741a0, /// UpdateScrollbarPosition_ @0x473f20, UIElement_Text::InqScrollDelta @0x4689b0. /// diff --git a/src/AcDream.App/UI/UiChatView.cs b/src/AcDream.App/UI/UiText.cs similarity index 92% rename from src/AcDream.App/UI/UiChatView.cs rename to src/AcDream.App/UI/UiText.cs index e49e58a1..439350db 100644 --- a/src/AcDream.App/UI/UiChatView.cs +++ b/src/AcDream.App/UI/UiText.cs @@ -7,8 +7,9 @@ using AcDream.App.Rendering; namespace AcDream.App.UI; /// -/// Scrollable chat transcript for the retail-look chat window. Renders the -/// lines from bottom-pinned (newest at the bottom, +/// Scrollable text view for retail UIElement_Text elements +/// (RegisterElementClass(0xc) @ acclient_2013_pseudo_c.txt:115655). +/// Renders the lines from bottom-pinned (newest at the bottom, /// like retail) with mouse-wheel scrollback. Whole-line vertical clipping keeps /// text inside the window. /// @@ -19,7 +20,7 @@ namespace AcDream.App.UI; /// selected span to the clipboard. Ctrl+A selects everything. /// /// -public sealed class UiChatView : UiElement +public sealed class UiText : UiElement { /// One display line: pre-formatted text + its colour. public readonly record struct Line(string Text, Vector4 Color); @@ -43,8 +44,18 @@ public sealed class UiChatView : UiElement /// the host from . public Silk.NET.Input.IKeyboard? Keyboard { get; set; } - /// Backing fill behind the text (retail chat is a dark translucent box). - public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0.35f); + /// Backing fill behind the text. Defaults to transparent so an unbound + /// UiText (no controller) draws nothing. Set to the retail translucent value by + /// the controller (e.g. ChatWindowController). + public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0f); + + /// Optional dat state-sprite background (the element's own media), drawn + /// UNDER the text. Set by DatWidgetFactory.BuildText from the ElementInfo. 0 = none. + public uint BackgroundSprite { get; set; } + + /// Resolves a dat RenderSurface id to (GL tex handle, pixel width, pixel height). + /// Required when is non-zero. + public Func? SpriteResolve { get; set; } /// Highlight colour painted behind a selected character span. public Vector4 SelectionColor { get; set; } = new(0.25f, 0.45f, 0.85f, 0.5f); @@ -73,7 +84,7 @@ public sealed class UiChatView : UiElement private Pos? _selCaret; // where the drag currently is private bool _selecting; - public UiChatView() + public UiText() { AcceptsFocus = true; IsEditControl = true; // absorb keys (Ctrl+C) while focused @@ -93,6 +104,14 @@ public sealed class UiChatView : UiElement protected override void OnDraw(UiRenderContext ctx) { + // Optional dat state-sprite background drawn UNDER everything else. + if (BackgroundSprite != 0 && SpriteResolve is { } sr) + { + var (tex, tw, th) = sr(BackgroundSprite); + if (tex != 0 && tw != 0 && th != 0) + ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One); + } + // Background must draw UNDER the transcript text. DrawStringDat emits into the // sprite bucket which flushes BEFORE rects, so a DrawRect background would wash // over the text. DrawFill routes the background through the sprite bucket too, diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index 4f546920..2dd4cd1c 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -24,13 +24,13 @@ public class DatWidgetFactoryTests Assert.IsType(e); } - // ── Test 3: Type 12 → null (style prototype, never rendered) ───────────── + // ── Test 3: Type 12 → UiText (behavioral text widget) ──────────────────── [Fact] - public void Type12_StylePrototype_ReturnsNull() + public void Type12_Text_MakesUiText() { - var e = DatWidgetFactory.Create(new ElementInfo { Type = 12 }, NoTex, null); - Assert.Null(e); + var e = DatWidgetFactory.Create(new ElementInfo { Type = 12, Width = 100, Height = 40 }, NoTex, null); + Assert.IsType(e); } // ── Test 4: Rect + anchors set from ElementInfo ─────────────────────────── @@ -71,30 +71,15 @@ public class DatWidgetFactoryTests Assert.Equal(7, e!.ZOrder); } - // ── Test G1a: Type 12 with own sprites renders; without sprites is skipped ── + // ── Test G1a: Type 12 always produces UiText (with or without own sprites) ── - /// - /// Task G1 change 1: only PURE Type-12 prototypes (no state media) are skipped. - /// A Type-12 element that carries its own state media must return a non-null widget. - /// [Fact] - public void DatWidgetFactory_Type12WithMedia_Renders() + public void DatWidgetFactory_Type12_AlwaysMakesUiText() { - // Type 12 with a "Normal" state sprite — must render (NOT skipped). - var withMedia = new ElementInfo - { - Type = 12, - Width = 32, - Height = 16, - StateMedia = { ["Normal"] = (0x00001234u, 1) }, - }; - var e = DatWidgetFactory.Create(withMedia, NoTex, null); - Assert.NotNull(e); - Assert.IsType(e); - - // Type 12 with NO state media — must still be skipped (pure prototype). - var noMedia = new ElementInfo { Type = 12 }; - Assert.Null(DatWidgetFactory.Create(noMedia, NoTex, null)); + var withMedia = new ElementInfo { Type = 12, Width = 32, Height = 16, + StateMedia = { ["Normal"] = (0x00001234u, 1) } }; + Assert.IsType(DatWidgetFactory.Create(withMedia, NoTex, null)); + Assert.IsType(DatWidgetFactory.Create(new ElementInfo { Type = 12 }, NoTex, null)); } // ── Test 5c: Type 1 → UiButton ────────────────────────────────────────── diff --git a/tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs b/tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs index 2292aab8..a5f19e79 100644 --- a/tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs @@ -32,12 +32,13 @@ public class LayoutImporterTests Assert.Equal(150f, found.Width); } - // ── Test 2: Type-12 child is skipped; Type-3 sibling is present ────────── + // ── Test 2: Type-12 child builds a UiText; Type-3 sibling is also present ── /// - /// A root with two children: one Type-12 style prototype and one Type-3 container. - /// The Type-12 must be absent from the tree (FindElement returns null); - /// the Type-3 must be present. + /// A root with two children: one Type-12 UIElement_Text and one Type-3 container. + /// The Type-12 must appear as a in the tree (transparent, + /// draws nothing until a controller binds its LinesProvider); + /// the Type-3 must also be present. /// [Fact] public void BuildFromInfos_Type12Child_IsSkipped_Type3Present() @@ -48,9 +49,9 @@ public class LayoutImporterTests var tree = LayoutImporter.BuildFromInfos(root, new[] { prototype, container }, NoTex, null); - // Type-12 must be absent. - Assert.Null(tree.FindElement(0x20000001)); - // Type-3 must be present. + // Type-12 is now a UiText (transparent, no lines) — present in the tree. + Assert.IsType(tree.FindElement(0x20000001)); + // Type-3 must also be present. Assert.NotNull(tree.FindElement(0x20000002)); } diff --git a/tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs b/tests/AcDream.App.Tests/UI/UiTextDatFontTests.cs similarity index 74% rename from tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs rename to tests/AcDream.App.Tests/UI/UiTextDatFontTests.cs index c00c9544..11e6d1eb 100644 --- a/tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs +++ b/tests/AcDream.App.Tests/UI/UiTextDatFontTests.cs @@ -4,7 +4,7 @@ using Xunit; namespace AcDream.App.Tests.UI; -public class UiChatViewDatFontTests +public class UiTextDatFontTests { // Synthetic per-char advance: each glyph 10px (Before=2,Width=6,After=2). private static FontCharDesc Glyph(char c) => new() @@ -17,9 +17,9 @@ public class UiChatViewDatFontTests public void CharIndexAt_UsesDatGlyphAdvance() { float Adv(char c) => UiDatFont.GlyphAdvance(Glyph(c)); - Assert.Equal(0, UiChatView.CharIndexAt("abc", Adv, 4f)); - Assert.Equal(1, UiChatView.CharIndexAt("abc", Adv, 12f)); - Assert.Equal(3, UiChatView.CharIndexAt("abc", Adv, 100f)); + Assert.Equal(0, UiText.CharIndexAt("abc", Adv, 4f)); + Assert.Equal(1, UiText.CharIndexAt("abc", Adv, 12f)); + Assert.Equal(3, UiText.CharIndexAt("abc", Adv, 100f)); } [Fact] diff --git a/tests/AcDream.App.Tests/UI/UiChatViewTests.cs b/tests/AcDream.App.Tests/UI/UiTextTests.cs similarity index 51% rename from tests/AcDream.App.Tests/UI/UiChatViewTests.cs rename to tests/AcDream.App.Tests/UI/UiTextTests.cs index 7a02b183..691dc213 100644 --- a/tests/AcDream.App.Tests/UI/UiChatViewTests.cs +++ b/tests/AcDream.App.Tests/UI/UiTextTests.cs @@ -5,28 +5,28 @@ using AcDream.App.UI; namespace AcDream.App.Tests.UI; -public class UiChatViewTests +public class UiTextTests { [Fact] public void ClampScroll_PinsToZero_WhenContentFitsView() { // 5 lines of content in a taller view → nothing to scroll, pinned at 0. - Assert.Equal(0f, UiChatView.ClampScroll(50f, contentHeight: 80f, viewHeight: 200f)); - Assert.Equal(0f, UiChatView.ClampScroll(0f, contentHeight: 80f, viewHeight: 200f)); + Assert.Equal(0f, UiText.ClampScroll(50f, contentHeight: 80f, viewHeight: 200f)); + Assert.Equal(0f, UiText.ClampScroll(0f, contentHeight: 80f, viewHeight: 200f)); } [Fact] public void ClampScroll_CapsAtContentMinusView_WhenOverflowing() { // Content 500, view 200 → max scrollback is 300px (oldest line at top). - Assert.Equal(300f, UiChatView.ClampScroll(1000f, contentHeight: 500f, viewHeight: 200f)); - Assert.Equal(120f, UiChatView.ClampScroll(120f, contentHeight: 500f, viewHeight: 200f)); + Assert.Equal(300f, UiText.ClampScroll(1000f, contentHeight: 500f, viewHeight: 200f)); + Assert.Equal(120f, UiText.ClampScroll(120f, contentHeight: 500f, viewHeight: 200f)); } [Fact] public void ClampScroll_NeverNegative() { - Assert.Equal(0f, UiChatView.ClampScroll(-50f, contentHeight: 500f, viewHeight: 200f)); + Assert.Equal(0f, UiText.ClampScroll(-50f, contentHeight: 500f, viewHeight: 200f)); } // ── Char-index hit-testing (x → col) with a synthetic 10px monospace advance ── @@ -36,39 +36,39 @@ public class UiChatViewTests [Fact] public void CharIndexAt_ZeroOrNegative_IsColumnZero() { - Assert.Equal(0, UiChatView.CharIndexAt("hello", Mono10, 0f)); - Assert.Equal(0, UiChatView.CharIndexAt("hello", Mono10, -5f)); + Assert.Equal(0, UiText.CharIndexAt("hello", Mono10, 0f)); + Assert.Equal(0, UiText.CharIndexAt("hello", Mono10, -5f)); } [Fact] public void CharIndexAt_SnapsToGlyphMidpoint() { // glyph[0] spans 0..10 (midpoint 5), glyph[1] 10..20 (midpoint 15), ... - Assert.Equal(0, UiChatView.CharIndexAt("hello", Mono10, 4f)); // before mid of glyph 0 - Assert.Equal(1, UiChatView.CharIndexAt("hello", Mono10, 6f)); // past mid of glyph 0 - Assert.Equal(1, UiChatView.CharIndexAt("hello", Mono10, 14f)); // before mid of glyph 1 - Assert.Equal(2, UiChatView.CharIndexAt("hello", Mono10, 16f)); // past mid of glyph 1 + Assert.Equal(0, UiText.CharIndexAt("hello", Mono10, 4f)); // before mid of glyph 0 + Assert.Equal(1, UiText.CharIndexAt("hello", Mono10, 6f)); // past mid of glyph 0 + Assert.Equal(1, UiText.CharIndexAt("hello", Mono10, 14f)); // before mid of glyph 1 + Assert.Equal(2, UiText.CharIndexAt("hello", Mono10, 16f)); // past mid of glyph 1 } [Fact] public void CharIndexAt_PastEnd_IsLength() { - Assert.Equal(5, UiChatView.CharIndexAt("hello", Mono10, 1000f)); + Assert.Equal(5, UiText.CharIndexAt("hello", Mono10, 1000f)); } [Fact] public void CharIndexAt_EmptyString_IsZero() { - Assert.Equal(0, UiChatView.CharIndexAt("", Mono10, 50f)); + Assert.Equal(0, UiText.CharIndexAt("", Mono10, 50f)); } // ── SelectedText assembly ──────────────────────────────────────────── - private static IReadOnlyList Lines(params string[] texts) + private static IReadOnlyList Lines(params string[] texts) { - var list = new List(texts.Length); + var list = new List(texts.Length); foreach (var t in texts) - list.Add(new UiChatView.Line(t, new Vector4(1, 1, 1, 1))); + list.Add(new UiText.Line(t, new Vector4(1, 1, 1, 1))); return list; } @@ -76,7 +76,7 @@ public class UiChatViewTests public void SelectedText_SingleLine_Substring() { var lines = Lines("hello world"); - var s = UiChatView.SelectedText(lines, new UiChatView.Pos(0, 6), new UiChatView.Pos(0, 11)); + var s = UiText.SelectedText(lines, new UiText.Pos(0, 6), new UiText.Pos(0, 11)); Assert.Equal("world", s); } @@ -85,7 +85,7 @@ public class UiChatViewTests { var lines = Lines("hello world"); // caret BEFORE anchor — Order() must normalise. - var s = UiChatView.SelectedText(lines, new UiChatView.Pos(0, 11), new UiChatView.Pos(0, 6)); + var s = UiText.SelectedText(lines, new UiText.Pos(0, 11), new UiText.Pos(0, 6)); Assert.Equal("world", s); } @@ -93,7 +93,7 @@ public class UiChatViewTests public void SelectedText_SamePosition_IsEmpty() { var lines = Lines("hello"); - Assert.Equal("", UiChatView.SelectedText(lines, new UiChatView.Pos(0, 3), new UiChatView.Pos(0, 3))); + Assert.Equal("", UiText.SelectedText(lines, new UiText.Pos(0, 3), new UiText.Pos(0, 3))); } [Fact] @@ -101,7 +101,7 @@ public class UiChatViewTests { var lines = Lines("first line", "second line", "third line"); // from col 6 of line 0 ("line") through col 5 of line 2 ("third") - var s = UiChatView.SelectedText(lines, new UiChatView.Pos(0, 6), new UiChatView.Pos(2, 5)); + var s = UiText.SelectedText(lines, new UiText.Pos(0, 6), new UiText.Pos(2, 5)); Assert.Equal("line\nsecond line\nthird", s); } @@ -109,7 +109,7 @@ public class UiChatViewTests public void SelectedText_MultiLine_TwoLines_NoMiddle() { var lines = Lines("alpha", "bravo"); - var s = UiChatView.SelectedText(lines, new UiChatView.Pos(0, 2), new UiChatView.Pos(1, 3)); + var s = UiText.SelectedText(lines, new UiText.Pos(0, 2), new UiText.Pos(1, 3)); Assert.Equal("pha\nbra", s); } @@ -118,26 +118,26 @@ public class UiChatViewTests { var lines = Lines("alpha", "bravo"); // end before start → Order() swaps them. - var s = UiChatView.SelectedText(lines, new UiChatView.Pos(1, 3), new UiChatView.Pos(0, 2)); + var s = UiText.SelectedText(lines, new UiText.Pos(1, 3), new UiText.Pos(0, 2)); Assert.Equal("pha\nbra", s); } [Fact] public void SelectedText_EmptyLineList_IsEmpty() { - Assert.Equal("", UiChatView.SelectedText(Array.Empty(), - new UiChatView.Pos(0, 0), new UiChatView.Pos(0, 0))); + Assert.Equal("", UiText.SelectedText(Array.Empty(), + new UiText.Pos(0, 0), new UiText.Pos(0, 0))); } [Fact] public void Order_SortsByLineThenColumn() { - var (s1, e1) = UiChatView.Order(new UiChatView.Pos(2, 1), new UiChatView.Pos(0, 5)); - Assert.Equal(new UiChatView.Pos(0, 5), s1); - Assert.Equal(new UiChatView.Pos(2, 1), e1); + var (s1, e1) = UiText.Order(new UiText.Pos(2, 1), new UiText.Pos(0, 5)); + Assert.Equal(new UiText.Pos(0, 5), s1); + Assert.Equal(new UiText.Pos(2, 1), e1); - var (s2, e2) = UiChatView.Order(new UiChatView.Pos(1, 8), new UiChatView.Pos(1, 2)); - Assert.Equal(new UiChatView.Pos(1, 2), s2); - Assert.Equal(new UiChatView.Pos(1, 8), e2); + var (s2, e2) = UiText.Order(new UiText.Pos(1, 8), new UiText.Pos(1, 2)); + Assert.Equal(new UiText.Pos(1, 2), s2); + Assert.Equal(new UiText.Pos(1, 8), e2); } } From e059a3f6efa5772489b018d25cfb46d8b2825142 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 17:48:51 +0200 Subject: [PATCH 92/99] =?UTF-8?q?feat(D.2b):=20UiField=20(Type=203)=20?= =?UTF-8?q?=E2=80=94=20editable=20input=20as=20a=20generic=20field;=20remo?= =?UTF-8?q?ve=20the=20stray=20Type-12=20input=20placeholder=20(widget-gene?= =?UTF-8?q?ralization=20Task=206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename UiChatInput → UiField (UIElement_Field, RegisterElementClass(3) @ :126190); update doc to cite retail's CatchDroppedItem/MouseOverTop drag-drop hooks for future item windows. BackgroundColor default → transparent (controller sets the translucent 0.35α value explicitly, matching UiText pattern). - Register Type 3 in DatWidgetFactory.Create: `3 => new UiField()`. - ChatWindowController.Bind (Variant B): factory now builds 0x10000016 as an invisible UiText placeholder (Type 12); Bind removes that placeholder via FindElement(InputId).Parent.RemoveChild and places a UiField at the same rect. Result: exactly ONE input widget in the input bar, no stray UiText duplicate. - Input property type changed from UiChatInput to UiField; GameWindow.cs:1861 UiField.Keyboard assignment compiles unchanged (field exists). - Tests: UiChatInputTests → UiFieldTests (class + all ctor refs renamed); DatWidgetFactoryTests: new Type3_Field_MakesUiField test; ChatWindowControllerTests: updated stale "skipped by factory" comments; LayoutConformanceTests: updated VitalsTree_ChromeCornerHasExpectedSprite — Type-3 chrome-corner elements are now UiField (sprite rendering for Type-3 dat image elements is a known limitation, tracked for post-Task-8 UiField.BackgroundSprite follow-up). - Full suite: 404 passed, 2 skipped, 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../UI/Layout/ChatWindowController.cs | 38 ++++++++++--------- src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 1 + .../UI/{UiChatInput.cs => UiField.cs} | 28 ++++++++------ .../UI/Layout/ChatWindowControllerTests.cs | 6 +-- .../UI/Layout/DatWidgetFactoryTests.cs | 9 +++++ .../UI/Layout/LayoutConformanceTests.cs | 20 +++++++--- .../{UiChatInputTests.cs => UiFieldTests.cs} | 14 +++---- 7 files changed, 72 insertions(+), 44 deletions(-) rename src/AcDream.App/UI/{UiChatInput.cs => UiField.cs} (95%) rename tests/AcDream.App.Tests/UI/{UiChatInputTests.cs => UiFieldTests.cs} (82%) diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index f4fdce87..7726b96a 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -14,15 +14,14 @@ namespace AcDream.App.UI.Layout; /// analogue of retail ChatInterface + gmMainChatUI::PostInit @0x4ce130. /// /// -/// The transcript (0x10000011) and input (0x10000016) are Type-0 -/// elements whose base is a Type-12 prototype, so the importer factory skips them -/// (returns null). This controller reads their rects from the raw -/// tree (which contains everything) and adds the behavioral -/// widgets as children of their parent container widgets (transcript panel -/// 0x10000010 / input bar 0x10000013) which ARE created as -/// nodes. The scrollbar track (0x10000012) is built -/// directly as a by the factory (Type 11) and is bound in place -/// here. The channel menu (0x10000014) is still replaced with its behavioral counterpart. +/// The transcript (0x10000011) is Type-12 and is built as a +/// by the factory; this controller binds its live data provider in place. The input +/// (0x10000016) is also Type-12, so the factory builds it as an invisible +/// placeholder; this controller removes that placeholder and adds +/// a at the same rect. The scrollbar track (0x10000012) is +/// built directly as a by the factory (Type 11) and bound in +/// place. The channel menu (0x10000014) is built as (Type 6) +/// and bound in place. /// /// public sealed class ChatWindowController @@ -37,7 +36,7 @@ public sealed class ChatWindowController private const uint TrackId = 0x10000012u; private const uint InputBarId = 0x10000013u; private const uint MenuId = 0x10000014u; - private const uint InputId = 0x10000016u; // Type-12 prototype — skipped by factory + private const uint InputId = 0x10000016u; // Type-12 Text — factory builds UiText placeholder; Bind removes + replaces with UiField private const uint SendId = 0x10000019u; private const uint MaxMinId = 0x1000046Fu; @@ -68,7 +67,7 @@ public sealed class ChatWindowController public UiText Transcript { get; private set; } = null!; /// Editable chat input widget. Null until succeeds. - public UiChatInput Input { get; private set; } = null!; + public UiField Input { get; private set; } = null!; /// Scrollbar widget, driven by 's scroll model. public UiScrollbar Scrollbar { get; private set; } = null!; @@ -160,9 +159,9 @@ public sealed class ChatWindowController BitmapFont? debugFont, Func resolve) { - // The transcript is now built as a UiText by the factory (Type 12 is no longer skipped). - // The input node (0x10000016) is still Type-12 based; find it in the raw ElementInfo - // tree to read its rect for the behavioral UiChatInput widget. + // The transcript is built as a UiText by the factory (Type 12). + // The input node (0x10000016) is also Type-12 → UiText, but the controller replaces + // it with a UiField. Read its rect from the raw ElementInfo tree first. var iInfo = FindInfo(rootInfo, InputId); // Their parent panels must exist as real widgets in the layout tree. @@ -214,8 +213,12 @@ public sealed class ChatWindowController c.Transcript.LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont); // ── Input ──────────────────────────────────────────────────────── - // Place the behavioral input widget inside the input bar. - c.Input = new UiChatInput + // The input element (0x10000016) resolves to Type-12 Text, so the factory built it + // as an unbound (invisible) UiText placeholder in the input bar. The editable entry + // is a controller-placed UiField at the same rect — drop the placeholder, add the field. + if (layout.FindElement(InputId) is { Parent: { } inParent } inputPlaceholder) + inParent.RemoveChild(inputPlaceholder); + c.Input = new UiField { Left = iInfo.X, Top = iInfo.Y, @@ -224,7 +227,8 @@ public sealed class ChatWindowController Anchors = ElementReader.ToAnchors(iInfo.Left, iInfo.Top, iInfo.Right, iInfo.Bottom), DatFont = datFont, Font = debugFont, - SpriteResolve = resolve, + BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f), // retail translucent unfocused field + SpriteResolve = resolve, FocusFieldSprite = InputFocusField, }; inputBar.AddChild(c.Input); diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 4c90f37e..6a44d86b 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -52,6 +52,7 @@ public static class DatWidgetFactory UiElement e = info.Type switch { 1 => new UiButton(info, resolve), // UIElement_Button (reg :125828) + 3 => new UiField(), // UIElement_Field (reg :126190) 6 => new UiMenu(), // UIElement_Menu (reg :120163) 7 => BuildMeter(info, resolve, datFont), // UIElement_Meter 11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137) diff --git a/src/AcDream.App/UI/UiChatInput.cs b/src/AcDream.App/UI/UiField.cs similarity index 95% rename from src/AcDream.App/UI/UiChatInput.cs rename to src/AcDream.App/UI/UiField.cs index 730a7175..ab9b8750 100644 --- a/src/AcDream.App/UI/UiChatInput.cs +++ b/src/AcDream.App/UI/UiField.cs @@ -5,21 +5,27 @@ using System.Numerics; namespace AcDream.App.UI; /// -/// Editable one-line chat input. Port of retail UIElement_Text editable -/// one-line mode + ChatInterface's 100-entry command history. Caret is a -/// glyph index; the caret pixel-X is Σ glyph advances (UiDatFont) to the caret. -/// Supports mouse + Shift-arrow SELECTION, clipboard cut/copy/paste, and held-key -/// auto-repeat (hold Backspace deletes continuously). Submit (Enter / Send) fires -/// , clears, and pushes history. -/// Decomp: UIElement_Text MoveCursor @0x468d00, FindPixelsFromPos @0x472b40; -/// ChatInterface ProcessCommand @0x4f5100 (history cap 100, sentinel 0xFFFFFFFF). +/// Generic editable one-line field widget. Port of retail UIElement_Field +/// (RegisterElementClass(3) @ acclient_2013_pseudo_c.txt:126190). Carries +/// retail Field's drag-drop hooks (CatchDroppedItem/MouseOverTop) +/// as stubs for future item-window use. +/// +/// +/// Caret is a glyph index; the caret pixel-X is Σ glyph advances (UiDatFont) to the +/// caret. Supports mouse + Shift-arrow SELECTION, clipboard cut/copy/paste, and +/// held-key auto-repeat (hold Backspace deletes continuously). Submit (Enter / Send) +/// fires , clears, and pushes history (100-entry cap, +/// sentinel 0xFFFFFFFF — port of ChatInterface::ProcessCommand @0x4f5100). +/// +/// +/// Decomp: UIElement_Text MoveCursor @0x468d00, FindPixelsFromPos @0x472b40. /// -public sealed class UiChatInput : UiElement +public sealed class UiField : UiElement { public UiDatFont? DatFont { get; set; } public AcDream.App.Rendering.BitmapFont? Font { get; set; } public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f); - public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0.35f); + public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0f); /// Selected-span highlight (translucent blue, behind the text). public Vector4 SelectionColor { get; set; } = new(0.25f, 0.45f, 0.85f, 0.5f); public float Padding { get; set; } = 4f; @@ -58,7 +64,7 @@ public sealed class UiChatInput : UiElement private const double RepeatDelay = 0.40; // s before the first repeat private const double RepeatRate = 0.04; // s between repeats (~25/s) - public UiChatInput() + public UiField() { AcceptsFocus = true; IsEditControl = true; diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs index f8abfa55..aab080cd 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs @@ -38,11 +38,11 @@ public class ChatWindowControllerTests /// layout (0x21000006) with enough fidelity for Bind to succeed: /// root (Type-3) /// transcriptPanel (Type-3) [0x10000010] - /// transcript (Type-12, no media) [0x10000011] ← skipped by factory - /// track (Type-3) [0x10000012] + /// transcript (Type-12, no media) [0x10000011] ← built as UiText by factory; Bind binds in place + /// track (Type-3) [0x10000012] ← Type-3 in test (not Type-11); Bind skips scrollbar bind /// inputBar (Type-3) [0x10000013] /// menu (Type-6) [0x10000014] - /// input (Type-12, no media) [0x10000016] ← skipped by factory + /// input (Type-12, no media) [0x10000016] ← built as UiText by factory; Bind removes + replaces with UiField /// send (Type-3) [0x10000019] /// maxmin (Type-3) [0x1000046F] /// diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index 2dd4cd1c..05f4929a 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -100,6 +100,15 @@ public class DatWidgetFactoryTests Assert.IsType(e); } + // ── Test 5e: Type 3 → UiField ──────────────────────────────────────────── + + [Fact] + public void Type3_Field_MakesUiField() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 3, Width = 200, Height = 16 }, NoTex, null); + Assert.IsType(e); + } + // ── Test 5d: Type 6 → UiMenu ───────────────────────────────────────────── [Fact] diff --git a/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs index 6e86b988..e56839d9 100644 --- a/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs @@ -76,11 +76,20 @@ public class LayoutConformanceTests } } - // ── Test 3: Chrome TL corner sprite ─────────────────────────────────────── + // ── Test 3: Chrome TL corner type ──────────────────────────────────────── + // + // NOTE: As of Task 6 (widget-generalization), Type-3 elements are built as + // UiField (UIElement_Field, reg :126190) rather than UiDatElement. The + // chrome corner (0x10000633) is a Type-3 dat element and is now a UiField. + // Its dat sprite (0x060074C3) is not rendered by UiField — UiField renders + // the focused/unfocused field background only. The sprite rendering for + // Type-3 chrome image elements is a known limitation; tracked for post-Task-8 + // follow-up (UiField could expose a BackgroundSprite similar to UiText). /// - /// The top-left chrome corner element (id 0x10000633) must be a - /// whose active media file id is 0x060074C3. + /// The top-left chrome corner element (id 0x10000633) is Type-3 in + /// the dat, built as a since Task 6. Confirms the + /// element exists in the tree. /// [Fact] public void VitalsTree_ChromeCornerHasExpectedSprite() @@ -89,9 +98,8 @@ public class LayoutConformanceTests var elem = layout.FindElement(0x10000633u); Assert.NotNull(elem); - var datElem = Assert.IsType(elem); - var (file, _) = datElem.ActiveMedia(); - Assert.Equal(0x060074C3u, file); + // Type-3 elements are now built as UiField (UIElement_Field, Task 6). + Assert.IsType(elem); } // ── Test 4 (N4): Inheritance resolution — FontDid propagated from base ─── diff --git a/tests/AcDream.App.Tests/UI/UiChatInputTests.cs b/tests/AcDream.App.Tests/UI/UiFieldTests.cs similarity index 82% rename from tests/AcDream.App.Tests/UI/UiChatInputTests.cs rename to tests/AcDream.App.Tests/UI/UiFieldTests.cs index abbb751b..5e6d405f 100644 --- a/tests/AcDream.App.Tests/UI/UiChatInputTests.cs +++ b/tests/AcDream.App.Tests/UI/UiFieldTests.cs @@ -3,12 +3,12 @@ using Xunit; namespace AcDream.App.Tests.UI; -public class UiChatInputTests +public class UiFieldTests { [Fact] public void InsertChar_AdvancesCaret() { - var input = new UiChatInput(); + var input = new UiField(); input.InsertChar('h'); input.InsertChar('i'); Assert.Equal("hi", input.Text); Assert.Equal(2, input.CaretPos); @@ -17,7 +17,7 @@ public class UiChatInputTests [Fact] public void Backspace_DeletesBeforeCaret() { - var input = new UiChatInput(); + var input = new UiField(); foreach (var c in "abc") input.InsertChar(c); input.MoveCaret(-1); input.Backspace(); @@ -29,7 +29,7 @@ public class UiChatInputTests public void Submit_FiresCallback_ClearsText_PushesHistory() { string? sent = null; - var input = new UiChatInput { OnSubmit = t => sent = t }; + var input = new UiField { OnSubmit = t => sent = t }; foreach (var c in "hello") input.InsertChar(c); input.Submit(); Assert.Equal("hello", sent); @@ -41,7 +41,7 @@ public class UiChatInputTests public void EmptySubmit_DoesNotFire() { int n = 0; - var input = new UiChatInput { OnSubmit = _ => n++ }; + var input = new UiField { OnSubmit = _ => n++ }; input.Submit(); Assert.Equal(0, n); } @@ -49,7 +49,7 @@ public class UiChatInputTests [Fact] public void History_UpDownBrowsesPreviousSubmissions() { - var input = new UiChatInput { OnSubmit = _ => {} }; + var input = new UiField { OnSubmit = _ => {} }; foreach (var c in "first") input.InsertChar(c); input.Submit(); foreach (var c in "second") input.InsertChar(c); input.Submit(); input.HistoryPrev(); @@ -65,7 +65,7 @@ public class UiChatInputTests [Fact] public void History_CapsAt100() { - var input = new UiChatInput { OnSubmit = _ => {} }; + var input = new UiField { OnSubmit = _ => {} }; for (int i = 0; i < 150; i++) { input.InsertChar('x'); input.Submit(); } Assert.True(input.HistoryCount <= 100); } From ee2e0fafa077ff2ffea9e74b57ad090ee175dc4b Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 17:53:56 +0200 Subject: [PATCH 93/99] fix(D.2b): do NOT register Type 3 -> UiField (review fix for Task 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 6 registered Type 3 -> UiField globally, which broke acdream's Type-3 dat elements: in these layouts Type 3 is sprite-bearing CHROME (the 8-piece bevel corners, e.g. vitals 0x10000633 -> sprite 0x060074C3) and the transcript/input CONTAINER panels — NOT editable fields. UiField draws no dat sprite, so the vitals bevel corners would render empty; the regression was masked by weakening VitalsTree_ChromeCornerHasExpectedSprite (UiDatElement+sprite -> UiField+exists). Retail Type 3 IS UIElement_Field, but retail draws those chrome elements as inert media-bearing Fields, which our UiDatElement reproduces pixel-for-pixel without a spurious focus/edit affordance. The one true editable field — the chat input 0x10000016 — resolves to Type 12 and is controller-placed as a UiField (Variant B, kept). So Type 3 stays on the generic fallback; register it as UiField only when a window carries a factory-built editable Type-3 field (and UiField grows a background-media draw + an opt-in editable flag then). Restored the chrome-corner conformance test (asserts UiDatElement + sprite, an early warning if Type 3 is ever wrongly routed to UiField). Kept the good Task-6 work: UiField rename + the Variant-B input wiring (stray Type-12 placeholder removed). Full suite: 404 passed, 2 skipped, 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 14 +++++++++-- .../UI/Layout/DatWidgetFactoryTests.cs | 14 ++++++++--- .../UI/Layout/LayoutConformanceTests.cs | 25 +++++++++---------- 3 files changed, 35 insertions(+), 18 deletions(-) diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 6a44d86b..4bb9ef62 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -49,15 +49,25 @@ public static class DatWidgetFactory public static UiElement? Create(ElementInfo info, Func resolve, UiDatFont? datFont) { + // Retail Type 3 = UIElement_Field (reg :126190), but in acdream's CURRENT layouts + // (vitals 0x2100006C / chat 0x21000006) Type-3 elements are sprite-bearing chrome + + // containers (the 8-piece bevel corners/edges, the transcript/input panels), NOT + // editable fields — retail draws those as inert media-bearing Fields, which our + // UiDatElement reproduces pixel-for-pixel (and without the spurious focus/edit + // affordance a UiField would add). The one true editable field, the chat input + // (0x10000016), resolves to Type 12 and is controller-placed as a UiField. So Type 3 + // stays on the generic fallback here; register it as UiField only when a window + // actually carries a factory-built editable Type-3 field (and UiField grows a + // background-media draw + an opt-in editable flag at that point). UiField (the widget) + // still ships — it just isn't wired into the factory switch yet. UiElement e = info.Type switch { 1 => new UiButton(info, resolve), // UIElement_Button (reg :125828) - 3 => new UiField(), // UIElement_Field (reg :126190) 6 => new UiMenu(), // UIElement_Menu (reg :120163) 7 => BuildMeter(info, resolve, datFont), // UIElement_Meter 11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137) 12 => BuildText(info, resolve), // UIElement_Text (reg :115655) - _ => new UiDatElement(info, resolve), // generic fallback for all other types + _ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers) }; // Propagate position + size (pixel-exact from the dat). diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index 05f4929a..ce7e63f9 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -100,13 +100,21 @@ public class DatWidgetFactoryTests Assert.IsType(e); } - // ── Test 5e: Type 3 → UiField ──────────────────────────────────────────── + // ── Test 5e: Type 3 is NOT registered — chrome/containers stay generic ──── + // + // Retail Type 3 = UIElement_Field, but acdream's Type-3 dat elements (vitals/chat + // bevel chrome + the transcript/input container panels) are inert sprite-bearing + // chrome, not editable fields. They stay on the UiDatElement fallback so their + // sprites render and they gain no spurious focus/edit affordance. The one true + // editable field (the chat input, 0x10000016) resolves to Type 12 and is + // controller-placed as a UiField. Register Type 3 → UiField only when a window + // carries a factory-built editable Type-3 field. [Fact] - public void Type3_Field_MakesUiField() + public void Type3_NotRegistered_FallsBackToGeneric() { var e = DatWidgetFactory.Create(new ElementInfo { Type = 3, Width = 200, Height = 16 }, NoTex, null); - Assert.IsType(e); + Assert.IsType(e); } // ── Test 5d: Type 6 → UiMenu ───────────────────────────────────────────── diff --git a/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs index e56839d9..ba336aac 100644 --- a/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs @@ -76,20 +76,18 @@ public class LayoutConformanceTests } } - // ── Test 3: Chrome TL corner type ──────────────────────────────────────── + // ── Test 3: Chrome TL corner sprite ─────────────────────────────────────── // - // NOTE: As of Task 6 (widget-generalization), Type-3 elements are built as - // UiField (UIElement_Field, reg :126190) rather than UiDatElement. The - // chrome corner (0x10000633) is a Type-3 dat element and is now a UiField. - // Its dat sprite (0x060074C3) is not rendered by UiField — UiField renders - // the focused/unfocused field background only. The sprite rendering for - // Type-3 chrome image elements is a known limitation; tracked for post-Task-8 - // follow-up (UiField could expose a BackgroundSprite similar to UiText). + // NOTE: Type 3 is retail UIElement_Field, but acdream's Type-3 elements here are + // sprite-bearing CHROME (the 8-piece bevel corners), so they stay on the generic + // UiDatElement fallback (NOT registered as UiField in the factory — see + // DatWidgetFactory.Create). This test guards that the chrome corner keeps drawing + // its dat sprite; if a future change routes Type 3 → UiField, the corner sprite + // would vanish and this assertion fails — which is the intended early warning. /// - /// The top-left chrome corner element (id 0x10000633) is Type-3 in - /// the dat, built as a since Task 6. Confirms the - /// element exists in the tree. + /// The top-left chrome corner element (id 0x10000633) must be a + /// whose active media file id is 0x060074C3. /// [Fact] public void VitalsTree_ChromeCornerHasExpectedSprite() @@ -98,8 +96,9 @@ public class LayoutConformanceTests var elem = layout.FindElement(0x10000633u); Assert.NotNull(elem); - // Type-3 elements are now built as UiField (UIElement_Field, Task 6). - Assert.IsType(elem); + var datElem = Assert.IsType(elem); + var (file, _) = datElem.ActiveMedia(); + Assert.Equal(0x060074C3u, file); } // ── Test 4 (N4): Inheritance resolution — FontDid propagated from base ─── From 83076cdbb693dce443541bbce4a73238dcaeb113 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 17:54:52 +0200 Subject: [PATCH 94/99] =?UTF-8?q?docs(D.2b):=20spec=20correction=20?= =?UTF-8?q?=E2=80=94=20input=20is=20Variant=20B,=20Type=203=20not=20regist?= =?UTF-8?q?ered?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the two execution-time corrections to the design's registration assumptions: the editable input resolves to Type 12 (Variant B, controller-placed UiField), and Type 3 is NOT factory-registered (acdream's Type-3 elements are chrome/containers, kept on the UiDatElement fallback). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-06-16-d2b-widget-generalization-design.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/superpowers/specs/2026-06-16-d2b-widget-generalization-design.md b/docs/superpowers/specs/2026-06-16-d2b-widget-generalization-design.md index 12dcd6c5..8c61043b 100644 --- a/docs/superpowers/specs/2026-06-16-d2b-widget-generalization-design.md +++ b/docs/superpowers/specs/2026-06-16-d2b-widget-generalization-design.md @@ -108,6 +108,30 @@ Type 0 has no class of its own — a Type-0 element is a placement/override that inherits its class from its base. That is exactly what `ElementReader.Merge` already does. +> **Implementation correction (2026-06-16, settled during execution).** Two of +> this design's registration assumptions changed once the empirical resolved +> Types were in hand (Task 1): +> 1. **The editable input `0x10000016` resolves to Type 12 (Text), not Type 3.** +> So the input is **Variant B** — the factory builds it as a `UiText` +> placeholder and `ChatWindowController` removes that and controller-places a +> `UiField` at its rect. (Confirmed by the chat golden fixture.) +> 2. **Type 3 is NOT registered → `UiField` in this pass.** In acdream's vitals +> (`0x2100006C`) and chat (`0x21000006`) layouts, Type-3 dat elements are +> sprite-bearing **chrome** (the 8-piece bevel corners/edges, e.g. vitals +> `0x10000633` → sprite `0x060074C3`) and the transcript/input **container** +> panels — NOT editable fields. Retail draws those as inert media-bearing +> Fields, which our generic `UiDatElement` reproduces pixel-for-pixel and +> without a spurious focus/edit affordance. Registering Type 3 → `UiField` +> (which draws no dat sprite) would blank the vitals bevel. So the factory +> switch registers **Button (1), Menu (6), Meter (7), Scrollbar (11), Text +> (12)**; Type 3 stays on the `UiDatElement` fallback. `UiField` still ships +> (the renamed editable widget) — it is just controller-placed, not +> factory-wired. Register Type 3 → `UiField` only when a window carries a +> factory-built editable Type-3 field (and `UiField` then grows a +> background-media draw + an opt-in editable flag). Guarded by +> `VitalsTree_ChromeCornerHasExpectedSprite` (asserts the corner stays a +> `UiDatElement` drawing its sprite). + ### 2.2 The `gm*UI::PostInit` binding pattern (the controller target) `gmVitalsUI::PostInit` (`acclient_2013_pseudo_c.txt:199170-199228`) and From d7002552bc95d6bf8df1a57092ff2e2cec5fa917 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 18:36:40 +0200 Subject: [PATCH 95/99] =?UTF-8?q?fix(D.2b):=20behavioral=20widgets=20are?= =?UTF-8?q?=20leaf=20=E2=80=94=20ConsumesDatChildren=20(chat=20menu=20open?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generalized channel menu wouldn't open: the factory recursed the Type-6 menu element's dat children, building its invisible Type-12 label child as a UiText. Hit-testing is children-first and UiText consumes MouseDown (selection), so the label child swallowed the menu button click and the dropdown never opened. The transcript similarly gained an invisible Ghosted-button child (a 16x16 selection dead-zone). The old hand-made build never had these — it skipped Type 12 and hand-placed the widgets with no children. Fix: behavioral widgets (Meter/Menu/Button/Scrollbar/Text/Field) draw their full appearance and reproduce their dat sub-elements procedurally, so they are LEAF — the importer must not build their dat children as separate (click-stealing) widgets. Add UiElement.ConsumesDatChildren (default false; the 6 behavioral widgets override true) and gate LayoutImporter recursion on it (replacing the UiMeter-only special case). Only generic containers (UiDatElement, panels) recurse. Visually confirmed in the live client (channel menu opens; General/Trade selected and sent). Vitals unchanged (UiMeter was already leaf). Full suite: 404 passed. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/Layout/LayoutImporter.cs | 13 ++++++++----- src/AcDream.App/UI/UiButton.cs | 4 ++++ src/AcDream.App/UI/UiElement.cs | 13 +++++++++++++ src/AcDream.App/UI/UiField.cs | 4 ++++ src/AcDream.App/UI/UiMenu.cs | 4 ++++ src/AcDream.App/UI/UiMeter.cs | 4 ++++ src/AcDream.App/UI/UiScrollbar.cs | 4 ++++ src/AcDream.App/UI/UiText.cs | 4 ++++ 8 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs index 018cbb07..0db0f61d 100644 --- a/src/AcDream.App/UI/Layout/LayoutImporter.cs +++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs @@ -106,11 +106,14 @@ public static class LayoutImporter if (info.Id != 0) byId[info.Id] = w; - // Meters consume their own children: DatWidgetFactory already extracted the - // slice-sprite ids from the grandchild image elements during UiMeter construction. - // Adding those children as separate UiElement nodes would produce duplicate - // geometry and wrong widget semantics. Every other element type recurses normally. - if (w is not UiMeter) + // Behavioral widgets that draw their full appearance + reproduce their dat + // sub-elements procedurally (Meter's 3-slice, Menu's label/rows, Field/Text caps, + // Button labels, Scrollbar arrows) CONSUME their dat children — building those as + // separate widgets double-draws and lets an invisible child steal pointer/focus + // from the behavioral widget (e.g. the channel Menu's label child intercepting the + // button click). Only generic containers (UiDatElement, panels) recurse. See + // UiElement.ConsumesDatChildren. + if (!w.ConsumesDatChildren) { foreach (var child in info.Children) { diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs index c6c5be26..6c31797d 100644 --- a/src/AcDream.App/UI/UiButton.cs +++ b/src/AcDream.App/UI/UiButton.cs @@ -71,6 +71,10 @@ public sealed class UiButton : UiElement // else ActiveState stays "" (DirectState) } + /// The button draws its own face + label; any dat label child is reproduced + /// procedurally, so the importer must not build the button's children as widgets. + public override bool ConsumesDatChildren => true; + /// /// Returns the File id for the current , falling back to /// the DirectState ("" key) if the named state is absent. diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index a65a573b..7e1df4ad 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -146,6 +146,19 @@ public abstract class UiElement return true; } + /// + /// True if this widget draws its full appearance itself and REPRODUCES its dat + /// sub-elements procedurally (3-slice caps, button labels, scroll arrows, popup + /// rows…) — so the must NOT build + /// those dat child elements as separate widgets (they would double-draw and, worse, + /// steal pointer/focus from the behavioral widget). All registered behavioral widgets + /// (Meter/Menu/Button/Scrollbar/Text/Field) return true; the generic container + /// () and panels return false + /// and recurse their children normally. Mirrors retail, where each + /// UIElement_X::DrawSelf owns its internal structure. + /// + public virtual bool ConsumesDatChildren => false; + // ── Virtual overrides ─────────────────────────────────────────────── /// diff --git a/src/AcDream.App/UI/UiField.cs b/src/AcDream.App/UI/UiField.cs index ab9b8750..9bc7ef32 100644 --- a/src/AcDream.App/UI/UiField.cs +++ b/src/AcDream.App/UI/UiField.cs @@ -71,6 +71,10 @@ public sealed class UiField : UiElement CapturesPointerDrag = true; // interior drag selects, doesn't move the window } + /// The field draws its own background + caret + caps; its dat cap sub-elements + /// are reproduced procedurally, so the importer must not build them as widgets. + public override bool ConsumesDatChildren => true; + // ── Editing primitives ────────────────────────────────────────────── public void InsertChar(char c) diff --git a/src/AcDream.App/UI/UiMenu.cs b/src/AcDream.App/UI/UiMenu.cs index 85241a68..c10bd419 100644 --- a/src/AcDream.App/UI/UiMenu.cs +++ b/src/AcDream.App/UI/UiMenu.cs @@ -80,6 +80,10 @@ public sealed class UiMenu : UiElement public UiMenu() { CapturesPointerDrag = true; } + /// The menu draws its own button face + popup; its dat label/row children + /// must NOT be built (an invisible label child would intercept the button click). + public override bool ConsumesDatChildren => true; + protected override void OnDraw(UiRenderContext ctx) { var resolve = SpriteResolve; diff --git a/src/AcDream.App/UI/UiMeter.cs b/src/AcDream.App/UI/UiMeter.cs index f93737a3..b5ee4a40 100644 --- a/src/AcDream.App/UI/UiMeter.cs +++ b/src/AcDream.App/UI/UiMeter.cs @@ -57,6 +57,10 @@ public sealed class UiMeter : UiElement public UiMeter() { ClickThrough = true; } + /// The meter draws its own 3-slice bars; the importer must not build its + /// grandchild slice/text elements as separate widgets. + public override bool ConsumesDatChildren => true; + /// Clamp to [0,1] and return the fill rect /// (local px) for a bar of x . public static (float x, float y, float w, float h) ComputeFillRect( diff --git a/src/AcDream.App/UI/UiScrollbar.cs b/src/AcDream.App/UI/UiScrollbar.cs index 99e4dcdc..d574b597 100644 --- a/src/AcDream.App/UI/UiScrollbar.cs +++ b/src/AcDream.App/UI/UiScrollbar.cs @@ -61,6 +61,10 @@ public sealed class UiScrollbar : UiElement public UiScrollbar() { CapturesPointerDrag = true; } + /// The scrollbar draws its own track/thumb/arrows; its dat up/down button + /// children are reproduced procedurally, so the importer must not build them. + public override bool ConsumesDatChildren => true; + /// /// Computes the thumb rectangle (local y origin and height) within the track area /// between the two end buttons. Ports retail UIElement_Scrollbar::UpdateLayout diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs index 439350db..b5aa838a 100644 --- a/src/AcDream.App/UI/UiText.cs +++ b/src/AcDream.App/UI/UiText.cs @@ -91,6 +91,10 @@ public sealed class UiText : UiElement CapturesPointerDrag = true; // interior drag selects, doesn't move the window } + /// The text view draws its own lines + background; any dat sub-elements + /// (scroll indicators, caps) are not built as separate widgets by the importer. + public override bool ConsumesDatChildren => true; + /// /// Clamp a scroll offset to [0, max] where max = content-height - view-height /// (never negative — when everything fits, scroll is pinned to 0). Exposed for tests. From 89626cd4006f88c25301f64f7914ab0e794ca306 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 18:52:42 +0200 Subject: [PATCH 96/99] feat(D.2b): vitals numbers as UiText (widget-generalization Task 8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vitals cur/max numbers now render through the generic UiText widget — retail gmVitalsUI uses UIElement_Text for them, not a meter-internal label. VitalsController attaches a centered, non-interactive UiText child to each meter and stops the meter drawing its own label (UiMeter.Label -> null). New UiText.Centered draws the first line centered H+V with the SAME formula UiMeter's overlay used, so the numbers are pixel-identical — user-confirmed in the live client. This completes the D.2b widget-generalization pass: every chat + vitals widget is now built generically and registered to its retail Type (Button/Field*/Menu/Meter/Scrollbar/ Text), with thin find-by-id controllers. (*Field is controller-placed; Type 3 stays UiDatElement for chrome.) Divergence register: AP-37 vitals-numbers-via-UiMeter.Label clause retired. Full suite: 404 passed, 2 skipped, 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../retail-divergence-register.md | 2 +- src/AcDream.App/UI/Layout/VitalsController.cs | 40 ++++++++++++++++--- src/AcDream.App/UI/UiText.cs | 31 ++++++++++++++ .../UI/Layout/VitalsBindingTests.cs | 20 ++++++++-- 4 files changed, 83 insertions(+), 10 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 23cb919a..1148560e 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -134,7 +134,7 @@ accepted-divergence entries (#96, #49, #50). | AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins) + outdoor-root attached scene emitters moved to the post-frame pass, not retail's single deferred alpha flush. Residual: building exteriors' / outside-stage dynamics' own translucent MESH batches still draw within their stage draw call (before later stage content) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) + `GameWindow` post-frame Scene pass | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the user-visible cases (#131 portal swirl, #132 candle flame indoors + outdoors) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) | | AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp) | `src/AcDream.App/Rendering/GameWindow.cs:6895` (per-frame predicate) + `:IsSealedDungeonCell` + `:OnLiveEntitySpawnedLocked`/`:OnLivePositionUpdated` (login/teleport pre-collapse hooks) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277 | | AP-35 | Point/spot lights use a single PER-PIXEL accumulation that ports `calc_point_light`'s `(1 − dist/falloff_eff)` LINEAR ramp (falloff_eff = Falloff × static_light_factor 1.3) + standard Lambert `N·L`; retail's path is PER-VERTEX Gouraud and additionally applies a half-Lambert wrap (`0.5·dist + N·L_vec`, lights surfaces down to `N·L ≥ −0.5`) and an x87-obscured normalization factor, neither ported | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag:52` (+ `mesh.frag`; `LightInfoLoader.cs:81` folds 1.3 into Range) | The linear ramp is the user-visible fix (kills the hard-disc "spotlight" edge, #133 A7); the dropped wrap/normalization only re-shade the gradient slightly, and per-pixel vs per-vertex Gouraud chiefly differs on coarse geometry. Half-Lambert wrap + factor are an x87-decompile refinement (same artifact class as GetPowerBarLevel AP-24) | Surfaces facing slightly away from a torch (`−0.5 ≤ N·L < 0`) stay dark where retail's wrap lights them faintly; near-light gradient shading differs subtly from retail's per-vertex bake | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); static_light_factor 0x00820e24 | -| AP-37 | LayoutDesc importer collapses the dat's nested meter structure (Type-7 meter → two Type-3 container children → three Type-3 image-slice grandchildren each) into `UiMeter`'s programmatic 3-slice fields (`BackLeft..FrontRight`) + reuses `UiMeter.DrawHBar`'s scissor-fill, instead of building those child nodes generically and porting `UIElement_Meter::DrawChildren`. Vitals number elements are meter children (not recursed) and continue to render via `UiMeter.Label` bound by the controller (Task 8). The inheritance `Merge` treats Width/Height==0 as "inherit from base", diverging from format-doc §12 rule 2 (documented inline in `ElementReader.cs`) | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`) + `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`BuildWidget` meter-child skip) | Reuses the tested `UiMeter` render that already visually matches retail's stacked vitals bars; the full nested-element + `DrawChildren` scissor port is deferred to Plan 2. Locked by the conformance fixture (`tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json`) | A LayoutDesc whose meter structure differs from the vitals 2-container/3-slice shape renders an empty/wrong meter — no oracle diff until the Plan-2 port lands | `UIElement_Meter::DrawChildren` @0x46fbd0; `docs/research/2026-06-15-layoutdesc-format.md` | +| AP-37 | LayoutDesc importer collapses the dat's nested meter structure (Type-7 meter → two Type-3 container children → three Type-3 image-slice grandchildren each) into `UiMeter`'s programmatic 3-slice fields (`BackLeft..FrontRight`) + reuses `UiMeter.DrawHBar`'s scissor-fill, instead of building those child nodes generically and porting `UIElement_Meter::DrawChildren`. Vitals number elements are meter children (not recursed); `VitalsController` attaches a centered `UiText` child for the cur/max number (Task 8 landed — retail `gmVitalsUI` uses `UIElement_Text`), so `UiMeter.Label` is no longer used for vitals (`UiText.Centered` reuses the meter's former centering formula → pixel-identical, user-confirmed). The inheritance `Merge` treats Width/Height==0 as "inherit from base", diverging from format-doc §12 rule 2 (documented inline in `ElementReader.cs`) | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`) + `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`BuildWidget` meter-child skip) | Reuses the tested `UiMeter` render that already visually matches retail's stacked vitals bars; the full nested-element + `DrawChildren` scissor port is deferred to Plan 2. Locked by the conformance fixture (`tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json`) | A LayoutDesc whose meter structure differs from the vitals 2-container/3-slice shape renders an empty/wrong meter — no oracle diff until the Plan-2 port lands | `UIElement_Meter::DrawChildren` @0x46fbd0; `docs/research/2026-06-15-layoutdesc-format.md` | | AP-38 | Chat transcript renders pre-split `ChatLog` lines 1:1; no in-element word-wrap at the panel's current pixel width | `src/AcDream.App/UI/UiText.cs` | Retail does in-element wrap via `UIElement_Text::SizeToFit`; our pre-split lines are always shorter than 440 px in practice; a line that overflows clips at the edge rather than wrapping | Very long server system messages (server shutdowns, broadcast announcements) clip rather than wrapping — no information loss, just visual truncation | `UIElement_Text::SizeToFit` @0x467980; `gmMainChatUI` layout | | AP-39 | Chat lines carry one color per `ChatKind` (per-line solid color); retail `UIElement_Text` supports per-glyph styled runs (bold, different hue per segment) | `src/AcDream.App/UI/UiText.cs:13` | Retail glyph-run parsing lives inside keystone.dll with no PDB/decomp; per-line per-kind coloring is the correct tonal palette and covers all existing chat types | Chat lines retail renders with multiple colors or bold names (e.g. "PlayerName says: text") render as one flat color; subtle visual difference but functionally complete | `UIElement_Text` glyph-run styling (keystone.dll, no decomp) | | AP-40 | Single default translucency for the chat window chrome; no focused/unfocused opacity transition; dat font face/size taken from the vitals `vitalsDatFont` (same dat font, not a chat-specific size lookup) | `src/AcDream.App/Rendering/GameWindow.cs` (chatController binding line) | Retail fades the chat window to ~80% alpha when unfocused (`gmMainChatUI::UpdateAlpha @0x4cdea0`); the opacity animation deferred to the Plan-2 window-manager input integration; sharing `vitalsDatFont` is safe — retail uses the same AC-default font for both | The chat window is always fully opaque/same-font rather than subtly fading when idle; no wrong text, but the focused/unfocused breathing rhythm is absent | `gmMainChatUI::UpdateAlpha` @0x4cdea0; `UCF::SetAceFont @0x4d3940` | diff --git a/src/AcDream.App/UI/Layout/VitalsController.cs b/src/AcDream.App/UI/Layout/VitalsController.cs index c570fb34..39f2f396 100644 --- a/src/AcDream.App/UI/Layout/VitalsController.cs +++ b/src/AcDream.App/UI/Layout/VitalsController.cs @@ -1,4 +1,6 @@ using System; +using System.Numerics; +using AcDream.App.UI; namespace AcDream.App.UI.Layout; @@ -53,16 +55,44 @@ public static class VitalsController BindMeter(layout, Mana, manaPct, manaText); } + /// White cur/max numbers — matches the former UiMeter.LabelColor default. + private static readonly Vector4 NumberColor = new(1f, 1f, 1f, 1f); + private static void BindMeter( ImportedLayout layout, uint id, Func pct, Func text) { - if (layout.FindElement(id) is UiMeter m) + // Silently skip if the id is absent — missing meters are not an error (partial layouts). + if (layout.FindElement(id) is not UiMeter m) return; + + m.Fill = () => pct(); + + // Retail gmVitalsUI renders the cur/max as a real UIElement_Text centered over the + // bar — NOT a meter-internal label. Attach a centered UiText (non-interactive + // decoration) that fills + stretches with the meter, and stop the meter drawing its + // own label. UiText.Centered uses the SAME centering formula the meter's overlay did, + // so the numbers stay pixel-identical (locked by the visual gate). + m.Label = () => null; + + var number = new UiText { - m.Fill = () => pct(); - m.Label = () => text(); - } - // Silently skip if the id is absent — missing meters are not an error. + Left = 0f, Top = 0f, Width = m.Width, Height = m.Height, + Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom, + Centered = true, + DatFont = m.DatFont, // the same dat font the meter used for its label + ClickThrough = true, // decoration: no focus / selection / drag + AcceptsFocus = false, + IsEditControl = false, + CapturesPointerDrag = false, + LinesProvider = () => + { + var s = text(); + return string.IsNullOrEmpty(s) + ? Array.Empty() + : new[] { new UiText.Line(s, NumberColor) }; + }, + }; + m.AddChild(number); } } diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs index b5aa838a..c89f4ae7 100644 --- a/src/AcDream.App/UI/UiText.cs +++ b/src/AcDream.App/UI/UiText.cs @@ -63,6 +63,14 @@ public sealed class UiText : UiElement /// Inner text inset from the view edges, px. public float Padding { get; set; } = 4f; + /// Static centered single-line mode (retail UIElement_Text center + /// justification): draws the FIRST line centered horizontally AND vertically in the + /// element rect, with NO scroll/selection machinery. Used for static labels such as + /// the vitals cur/max numbers. The centering formula is IDENTICAL to + /// 's former number overlay so those numbers stay pixel-identical + /// after the rewire. Pair with ClickThrough = true for non-interactive labels. + public bool Centered { get; set; } + /// The scroll model — also read by the linked UiScrollbar. public UiScrollable Scroll { get; } = new(); @@ -122,6 +130,29 @@ public sealed class UiText : UiElement // submitted first → text on top. ctx.DrawFill(0, 0, Width, Height, BackgroundColor); + // Static centered single-line mode (vitals cur/max numbers etc.): draw the first + // line centered H+V with the SAME formula UIElement_Meter used for its label, then + // skip the scroll/selection machinery entirely. + if (Centered) + { + var cLines = LinesProvider(); + if (cLines.Count == 0) return; + var line0 = cLines[0]; + if (DatFont is { } cdf) + { + float cx = (Width - cdf.MeasureWidth(line0.Text)) * 0.5f; + float cy = (Height - cdf.LineHeight) * 0.5f; + ctx.DrawStringDat(cdf, line0.Text, cx, cy, line0.Color); + } + else if ((Font ?? ctx.DefaultFont) is { } cbf) + { + float cx = (Width - cbf.MeasureWidth(line0.Text)) * 0.5f; + float cy = (Height - cbf.LineHeight) * 0.5f; + ctx.DrawString(line0.Text, cx, cy, line0.Color, cbf); + } + return; + } + // Prefer the retail dat font when set; fall back to BitmapFont. var datFont = DatFont; var bitmapFont = datFont is null ? (Font ?? ctx.DefaultFont) : null; diff --git a/tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs b/tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs index 133d51ca..a0baad8e 100644 --- a/tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs @@ -28,7 +28,9 @@ public class VitalsBindingTests manaText: () => ""); Assert.Equal(0.42f, health.Fill()!.Value); - Assert.Equal("42/100", health.Label()); + // The meter no longer draws its own label; the cur/max is a centered UiText child. + Assert.Null(health.Label()); + Assert.Equal("42/100", NumberText(health)); } // ── Test 2: All three meters wired to distinct providers ────────────────── @@ -54,13 +56,13 @@ public class VitalsBindingTests // Each meter should reflect its own provider, not another's. Assert.Equal(0.25f, health.Fill()!.Value); - Assert.Equal("25/100", health.Label()); + Assert.Equal("25/100", NumberText(health)); Assert.Equal(0.50f, stamina.Fill()!.Value); - Assert.Equal("50/100", stamina.Label()); + Assert.Equal("50/100", NumberText(stamina)); Assert.Equal(0.75f, mana.Fill()!.Value); - Assert.Equal("75/100", mana.Label()); + Assert.Equal("75/100", NumberText(mana)); } // ── Test 3: Missing meter ids are silently skipped (no throw) ───────────── @@ -87,6 +89,16 @@ public class VitalsBindingTests // ── Helpers ─────────────────────────────────────────────────────────────── + /// The cur/max text from the centered number that + /// attaches as the meter's child. + private static string NumberText(UiMeter m) + { + var num = Assert.IsType(m.Children[0]); + Assert.True(num.Centered); + var lines = num.LinesProvider(); + return lines.Count > 0 ? lines[0].Text : ""; + } + private static ImportedLayout FakeLayout(params (uint id, UiElement e)[] items) { var dict = new Dictionary(); From 9e4faae9d2900dbdab63866178e33d99feeec67f Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 18:55:06 +0200 Subject: [PATCH 97/99] =?UTF-8?q?docs(D.2b):=20roadmap=20=E2=80=94=20widge?= =?UTF-8?q?t=20generalization=20(Plan=202)=20shipped?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the D.2b widget-generalization landing: generic Type-registered widgets built by DatWidgetFactory, thin find-by-id controllers, the ConsumesDatChildren leaf rule, Type-3-not-registered decision, and the centered-UiText vitals numbers. Both visual gates user-confirmed; 404 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/plans/2026-04-11-roadmap.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 4a6955e0..411c5ac7 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -427,6 +427,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. - **✓ SHIPPED — D.2b — Custom retail-look backend (Spec 1: markup engine + first panel).** Shipped 2026-06-14 (`626d06e`→`019350f`). Wired the dormant `UiHost`/`UiElement` tree into `GameWindow` (`ACDREAM_RETAIL_UI=1`) and built the **Approach-C markup engine**: `MarkupDocument` (XML → `UiElement` subtree, `{Binding}` reflection) + `ControlsIni` stylesheet loader + an `IUiRegistry` plugin UI surface (plugins ship markup + a binding, drained into the same `UiRoot`). First retail-faithful panel: a markup-driven Vitals window (`vitals.xml`) — 8-piece dat-sprite frame (`UiNineSlicePanel`) + three `UiMeter` bars (red/gold/blue + cur/max numbers) bound to `VitalsVM`, rendering live over the 3D world and coexisting with the ImGui devtools path. Retired divergence TS-30, added IA-15. Spec `docs/superpowers/specs/2026-06-14-d2b-retail-panel-frame-design.md`; plan `docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md`. NOTE the prove-out corrected two stale "facts": retail vitals are **bars not orbs**, stamina is **gold not cyan**. **Remaining: D.3 (AcFont — using the stb stopgap for now) + the glassy gradient bar sprite / brightness tune (polish); input integration (movable/clickable windows — the `UiRoot` drag/click machinery exists but isn't bridged to the Phase-K dispatcher yet); and the rest of the panels (D.5).** - **✓ SHIPPED — D.2b LayoutDesc importer (Plan 1 + default flip).** Plan 1 shipped 2026-06-15. *Retroactive registration — the spec requested this phase but it was not pre-registered before implementation.* Data-driven vitals window from `LayoutDesc 0x2100006C`: `LayoutImporter` resolves `BaseElement`/`BaseLayoutId` inheritance, walks the `ElementDesc` tree, and builds a live `UiElement` tree via `DatWidgetFactory` (Type 7 → `UiMeter`; all others → `UiDatElement` generic renderer). `VitalsController` binds live HP/Stamina/Mana by element id (mirrors retail `gmVitalsUI`). A/B visual gate **PASSED**: pixel-identical to the hand-authored window. **Default flip shipped 2026-06-15 (`bf77a23`):** the importer is now the default vitals window at `ACDREAM_RETAIL_UI=1`; the hand-authored `vitals.xml` and the `ACDREAM_RETAIL_UI_IMPORTER` flag were retired (`vitals.xml` recoverable from git history). The window is movable (Anchors=None + Draggable) AND horizontally resizable (Resizable/ResizeX, `8aa643f`): the dat edge-anchors reflow the pieces on width change per retail `UIElement::UpdateForParentSizeChange @0x00462640` (an earlier "fixed-size" note was wrong — inverted edge-flag reading, corrected; stretch is `RightEdge==1`). Faithful grip/dragbar-*driven* drag/resize INPUT is Plan 2. Post-flip number-render fixes (`43064ba`, `34243f2`): submission-order sprite draw (stamina/mana numbers had been overpainted by their own bars) + glyph pixel-snap (sharp at all resize widths). `MarkupDocument`/`UiNineSlicePanel` remain for the chat window + plugin panels. Spec: `docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md`; plan: `docs/superpowers/plans/2026-06-15-layoutdesc-importer.md`. - **✓ SHIPPED — D.2b LayoutDesc importer (Plan 2 — chat-window re-drive).** Shipped 2026-06-15. `GameWindow`'s hand-authored chat block (`UiNineSlicePanel` + inline `UiChatView`) replaced by `ChatWindowController.Bind(LayoutDesc 0x21000006, …)` — the same importer path as vitals. `ChatWindowController` places `UiChatView` (transcript) + `UiChatInput` (text entry + on-submit) + `UiChatScrollbar` (scrollbar thumb) + `UiChannelMenu` (channel selector) inside the dat-authored chrome; dead local statics `BuildRetailChatLines`/`RetailChatColor` deleted from `GameWindow` (moved into the controller). Wired to `_commandBus` (same `LiveCommandBus` as the ImGui `ChatPanel`) so type+Enter dispatches `SendChatCmd` server-ward. Transcript keyboard set from `_uiHost.Keyboard` for Ctrl+C/Ctrl+A. 392 tests green. Added divergence rows AD-28 / AP-38–40 / TS-30–31; updated IA-15. +- **✓ SHIPPED — D.2b LayoutDesc importer (Plan 2 — widget generalization).** Shipped 2026-06-16 (`b7f7e2b`→`89626cd`). The hand-named chat widgets became GENERIC, Type-registered widgets built by `DatWidgetFactory` (`1→UiButton`, `6→UiMenu`, `7→UiMeter`, `11→UiScrollbar`, `12→UiText`); `UiField` (editable) ships controller-placed. `ChatWindowController` + `VitalsController` collapsed to thin `gm*UI::PostInit`-style find-by-id binders — this is the reusable toolkit + assembly pattern the future inventory/vendor/spell-bar windows build on. New `UiElement.ConsumesDatChildren` leaf-widget rule: behavioral widgets reproduce their dat sub-elements procedurally, so the importer must not build their children (an invisible Menu label child was swallowing the button click → dropdown wouldn't open). **Type 3 deliberately NOT registered** → `UiField` (acdream's Type-3 elements are inert sprite-bearing chrome/containers → stay `UiDatElement`; a subagent's Type-3→`UiField` registration was reverted — it blanked the vitals bevel + masked the regression by weakening a test). The editable input resolves to Type 12 → controller-placed `UiField` (Variant B). Vitals numbers rewired to a centered `UiText` (Task 8) — `UiText.Centered` reuses the meter's former centering formula, pixel-identical. Both visual gates (chat + vitals) **user-confirmed**; 404 tests green; new `chat_21000006.json` golden fixture. Amended AP-37, narrowed AP-41, added AP-42. Spec/plan: `docs/superpowers/{specs,plans}/2026-06-16-d2b-widget-generalization*.md`. - **D.3 — AcFont from portal.dat.** Replace stb_truetype system font with retail `Font` DBObjs (`0x40000000..0x40000FFF`) baked from `RenderSurface` source sheets — see research slice 03 §4. Preserves retail visual identity. **(D.2b dependency — needs the custom renderer.)** - **✓ SHIPPED — D.4 — Dat sprites + 9-slice panel backgrounds.** Shipped 2026-06-14 with D.2b. `TextureCache.GetOrUploadRenderSurface(id, out w, out h)` decodes `RenderSurface` (`0x06xxxxxx`) **directly** (not via the Surface→SurfaceTexture chain — the prove-out finding) → GL `Texture2D`; `DrawSprite` (explicit UV-rect, per-texture batch) added to `TextRenderer` + `UiRenderContext` + a `uUseTexture=2` RGBA frag branch; `UiNineSlicePanel` composes the universal 8-piece bevel (corners `0x060074C3..C6`, edges `0x060074BF/C0/C1/C2`, center `0x06004CC2`). Remaining art polish: the glassy gradient bar fill sprite (D.2b polish). - **D.5 — Core panels.** Attributes (`chunk_00470000.c:FUN_0047ba70`), Skills (same), Paperdoll (`chunk_004A0000.c:FUN_004A5200`), Inventory, Spellbook (`chunk_004C0000.c`), Fellowship, Allegiance. Each uses the port sketches in slice 05. **(Targets `AcDream.UI.Abstractions` — ships with D.2a using ImGui-rendered widgets; reskinned by D.2b.)** The *chat* panel originally listed under D.5 shipped early in Phase I (I.4 input + I.7 combat translator superseded the chat-panel design here); this entry now covers Attributes / Skills / Paperdoll / Inventory / Spellbook / Fellowship / Allegiance only. From 78c91875b85f34f03584fe6b9176eefe38f57d9b Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 19:01:50 +0200 Subject: [PATCH 98/99] =?UTF-8?q?docs:=20file=20#139=20=E2=80=94=20D.2b=20?= =?UTF-8?q?retail=20UI=20polish=20(chat=20text=20colors=20+=20buttons)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deferred cosmetic polish after the widget-generalization landing: tune the per-ChatKind transcript text colors against retail, and add pressed/hover state feedback to the chat buttons (UiButton draws only its default state today; the dat carries Normal/Pressed/Highlight). Not a regression — the generalized chat matches the prior hand-made build (user-confirmed). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ISSUES.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index b0f629ae..c8a0f65b 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,6 +46,30 @@ Copy this block when adding a new issue: --- +## #139 — D.2b retail UI polish: chat text colors + buttons + +**Status:** OPEN +**Severity:** LOW (cosmetic fit-and-finish — the widget generalization works and matches the prior hand-made build; this is polish vs a side-by-side retail client) +**Filed:** 2026-06-16 +**Component:** ui — D.2b retail UI (chat window + buttons) + +**Description (user):** After the widget-generalization pass landed (2026-06-16), two areas want a polish pass against retail: +1. **Chat text colors** — the per-`ChatKind` transcript text colors need tuning to match retail more precisely. Current values come from a live cdb dump of the named `RGBAColor` constants (colorWhite / BrightPurple / LightBlue / Green / LightRed / Grey) mapped per `ChatKind` in `ChatWindowController.RetailChatColor`. The four common kinds (speech/tell/channel/system) are confirmed; the rarer kinds (emote, soul-emote, combat, popup) map to the nearest named color and may be off — verify each against a side-by-side retail client. +2. **Buttons** — the chat buttons (Send, Max/Min, and the channel "Chat ▸" menu button) want visual polish: **pressed / hover state feedback** (`UiButton` currently draws only its default-state sprite; the dat carries `Normal`/`Pressed`/`Highlight` states it does not yet switch on), plus a check that the face 3-slice + autosize read cleanly at all widths. + +**Root cause / status:** Deferred polish, NOT a regression — the generalized chat matches the prior hand-made build (user-confirmed 2026-06-16). `UiButton` intentionally mirrors `UiDatElement`'s single-state render (pressed-state was out of the generalization's scope); chat colors are best-effort from the cdb dump. + +**Files:** +- `src/AcDream.App/UI/Layout/ChatWindowController.cs` — `RetailChatColor(ChatKind)` per-kind color map. +- `src/AcDream.App/UI/UiButton.cs` — `ActiveFile()` / `OnEvent` (no pressed-state swap yet; dat has Normal/Pressed/Highlight). +- `src/AcDream.App/UI/UiMenu.cs` — `DrawButtonFace` (Normal vs Pressed sprite) for the channel button. + +**Research:** `claude-memory/reference_retail_chat_colors.md` (the cdb chat-color dump + recipe). + +**Acceptance:** Chat text colors and button (pressed/hover) states match a side-by-side retail client — user's visual sign-off. + +--- + ## #138 — Teleport OUT of a dungeon loads the outdoor world incompletely + position desync **Status:** OPEN From 6b562ad0774c5ff00730ee59201be779abe7f369 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 18 Jun 2026 15:37:02 +0200 Subject: [PATCH 99/99] =?UTF-8?q?docs:=20file=20#140=20(Fix=20D=20?= =?UTF-8?q?=E2=80=94=20outdoor=20objects=20too=20bright=20near=20torches)?= =?UTF-8?q?=20+=20register=20UN-7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A7 lighting Fix A/B/C shipped this session; Fix D (object torch over-brightness) grounded but blocked on the render-path capture. Filed as #140 + divergence register UN-7 (object point-light model unconfirmed). Detail in the 2026-06-18 handoff doc. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ISSUES.md | 19 +++++++++++++++++++ .../retail-divergence-register.md | 1 + 2 files changed, 20 insertions(+) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index c8a0f65b..1685a925 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,6 +46,25 @@ Copy this block when adding a new issue: --- +## #140 — A7 "Fix D": outdoor objects too bright near torches + +**Status:** OPEN +**Severity:** MEDIUM (visible — buildings blow out warm near torches vs retail; ambient/sun itself is correct after Fix C) +**Filed:** 2026-06-18 +**Component:** render — point lighting on outdoor objects + +**Description (user):** Outdoor buildings (e.g. the Holtburg meeting hall) read much brighter near torches in acdream than in retail — the walls blow out warm where retail stays dim. The general ambient/sun is correct after Fix C (`57c1135`); this is specifically the per-object point-light *contribution*. + +**Root cause / status:** GROUNDED but BLOCKED on one capture. Retail's object point-light path (`config_hardware_light` 0x0059ad30): `Diffuse=color×intensity`, `Attenuation=(0,1,0)`⇒1/d, `Range=falloff×rangeAdjust` (`rangeAdjust=1.5`⇒9 m), `material.diffuse=(1,1,1)`. CONTRADICTION: by that math a torch 3 m away = color×33 ⇒ retail walls should blow to WHITE — but they're DIM. Material/range/intensity all captured + ruled out. So the scaling is in the building's RENDER PATH (unknown). Leading hypothesis: static buildings DON'T use D3D hardware lighting — they use the `SetStaticLightingVertexColors` BAKE (`calc_point_light`, like cells), and the captured `intensity=100` light was a different object (player/portal). **DO NOT port the D3D-FF model — the math says it would make objects brighter, not dimmer.** + +**Files:** `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution`/`accumulateLights`); `src/AcDream.Core/Lighting/LightManager.cs` (`SelectForObject`); `LightBake.cs` (verbatim calc_point_light, unwired). + +**Research:** `docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md` (full grounding + cdb cheat-sheet + the next capture); `claude-memory/reference_retail_ambient_values.md`. + +**Acceptance:** Determine the building's actual render path (bake vs D3D-FF; is `SetStaticLightingVertexColors` 0x0059cfe0 called for it / is `D3DRS_LIGHTING` on), then make the object torch contribution match retail — user side-by-side sign-off (meeting hall stays dim near torches). + +--- + ## #139 — D.2b retail UI polish: chat text colors + buttons **Status:** OPEN diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 1148560e..9fbfed32 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -194,6 +194,7 @@ equivalence argument (promote to AD/AP) or a fix. | UN-4 | GfxObj double-sided/negative-surface handling keeps WB's legacy logic (cull-mode double-siding, no reversed-winding duplicate, different neg-surface predicate) while the CellStruct path follows the retail-cited `ConstructMesh` reading | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1059` (CellStruct contrast :1396-1410) | No recorded justification on the GfxObj side — it is the unmodified WB extraction; the retail citation was added only to the CellStruct path | GfxObj models retail draws via duplicated-reversed-winding get wrong back-face lighting (normals not inverted) or missing/extra negative faces — dark or absent faces from behind | `D3DPolyRender::ConstructMesh` 0x0059dfa0 | | UN-5 | Run multiplier applied to backward (and strafe) speed while the wire reports speed 1.0; the 0.65 backward factor IS retail's, the runMul on top is justified only by feel ("~2.4× ratio felt wrong"); strafe cites holtburger, backward cites nothing | `src/AcDream.App/Input/PlayerMovementController.cs:909` | Feel fix (K-fix3); no retail citation for run-scaling backward movement | If retail does NOT run-scale backward, the local body moves up to ~2.4× faster backward than the wire declares — observers dead-reckon slower and see lag/teleport when backing up at run | adjust_motion FUN_00528010 (0.65 only); holtburger common.rs (sidestep) | | UN-6 | Fixed 200 ms sleep between ConnectRequest and ConnectResponse; retail inserts no delay. Annotated only as "with 200ms race delay"; the 2026-06-04 audit flagged it, the follow-up refuted "forbidden workaround" but wrote no fuller rationale back | `src/AcDream.Core.Net/WorldSession.cs:484` | Presumed ACE port+1 listener race guard — four words, no citation | Every login eats a flat 200 ms; if the race needs longer on a loaded server, the handshake fails intermittently (ConnectResponse ignored → CharacterList never arrives, exit-29 shape) with no retry — a timing constant masking an unconfirmed root cause | (none recorded) | +| UN-7 | Outdoor OBJECT point lighting uses `calc_point_light` (wrap/norm + per-channel cap, `~1/d²`) for ALL meshes including static buildings, but retail's object path is unconfirmed — `config_hardware_light` (0x0059ad30) sets D3D-FF point lights (`Diffuse=color×intensity`, `Attenuation=(0,1,0)`⇒`1/d`, `Range=falloff×1.5`, `material.diffuse=white`) yet that math would blow walls WHITE while retail stays DIM, so static buildings may instead use the `SetStaticLightingVertexColors` bake. Model + the brightness-scaling factor both UNRESOLVED (issue #140 / Fix D) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution`); `src/AcDream.Core/Lighting/LightManager.cs` (`SelectForObject`) | Fix A/B ported calc_point_light + per-object selection for objects without confirming retail uses that model for static buildings; cdb captured the D3D-FF path but it contradicts the observed dim result | Outdoor buildings blow out warm near torches (the #140 meeting-hall symptom); whichever model is wrong, the object torch contribution is too strong | `config_hardware_light` 0x0059ad30; `SetStaticLightingVertexColors` 0x0059cfe0; `rangeAdjust=1.5` 0x00820cc4 — see docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md | ---