merge: bring main (UN-7, #140 filing, D.2b UI rows) into A7 Fix D round-2 branch

Resolves the divergence-register conflict: kept the accurate per-VERTEX AP-35
(Fix A shipped per-vertex; main's row was the stale pre-Fix-A per-pixel text),
kept main's UI rows AP-37..AP-42, and renumbered this branch's torch-gate row
AP-37 -> AP-43 (AP-37 was taken by main's LayoutDesc row). AP count 41 -> 42.
Retargeted the AP-37 references in WbDrawDispatcher + the CHECKPOINT to AP-43.
Marked ISSUES #140 RESOLVED (b7d655b) with the corrected root cause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-20 09:29:53 +02:00
commit c83fd02642
94 changed files with 16216 additions and 199 deletions

3
.gitignore vendored
View file

@ -26,8 +26,11 @@ references/*
# Claude Code session state
.claude/
# Superpowers brainstorm visual-companion scratch (mockups regenerate; not source)
/.superpowers/
launch.log
launch-*.log
proveout*.log
launch.utf8.log
n4-verify*.log

View file

@ -46,6 +46,51 @@ Copy this block when adding a new issue:
---
## #140 — A7 "Fix D": outdoor objects too bright near torches
**Status:** RESOLVED (`b7d655b`, 2026-06-19 — user-confirmed side-by-side at the Holtburg meeting hall)
**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
**RESOLUTION (2026-06-19, round 2):** The "bake vs D3D-FF" framing below was the WRONG question — neither lights the building exterior. Retail's per-object torch binder `minimize_object_lighting` (0x0054d480) runs ONLY `if (Render::useSunlight == 0)` (`DrawMeshInternal` 0x0059f398), and the OUTDOOR landscape stage runs `useSunlightSet(1)` (`PView::DrawCells` 0x005a485a before `LScape::draw`). So retail lights outdoor objects (building exterior shells, scenery, outdoor creatures) with the **sun + ambient ONLY — never wall torches**. acdream was torch-lighting them. Fix: `WbDrawDispatcher.ComputeEntityLightSet` now gates torch selection on the object being indoor (`ParentCellId` is an EnvCell) via `IndoorObjectReceivesTorches`; outdoor objects get the sun only. acdream reads the dat falloffs faithfully (the orange torch is genuinely `Falloff 6`; the "reach too long" theory was a red herring). Register **AP-43**; the indoor-vs-outdoor *sun* half uses a per-frame player-inside global (residual logged in AP-43). Full handoff: `docs/research/2026-06-19-lighting-a7-fixD-round2-torch-reach-CHECKPOINT.md` (RESOLVED banner). Indoor-lighting follow-ups the user raised at the gate (windowed-building interior regime; portal swirl as a dynamic light) are SEPARATE M1.5 work, not part of this issue.
**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
**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

View file

@ -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,10 +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. 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 |
|---|---|---|---|---|---|
@ -89,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 (`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 |
---
## 3. Documented approximation (AP) — 36 rows
## 3. Documented approximation (AP) — 42 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@ -131,12 +133,18 @@ accepted-divergence entries (#96, #49, #50).
| AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 |
| 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-37 | Per-object torch (point/spot) lighting is gated on the OBJECT's own cell: an object selects the static wall-torches ONLY when its `ParentCellId` is an EnvCell (`(id & 0xFFFF) >= 0x0100`); outdoor objects (building exterior shells with null ParentCellId, outdoor scenery, outdoor creatures) get the SUN + ambient and NO torches. This is the faithful port of retail's `useSunlight` gate — `DrawMeshInternal` (0x0059f398) calls `minimize_object_lighting` only `if (Render::useSunlight == 0)`, and the outdoor landscape stage runs `useSunlightSet(1)` (`PView::DrawCells` 0x005a485a, before `LScape::draw`) so outdoor objects are never torch-lit (closes the Holtburg meeting-hall facade torch-flood — A7 Fix D round 2, the dat's intensity-100 `falloff 6` orange torches were washing the exterior shell). **Residual approximation:** the sun/no-sun half is a per-FRAME global keyed on the PLAYER being inside a cell (`UpdateSunFromSky` zeroes the sun when `playerInsideCell`), NOT retail's per-draw-STAGE `useSunlight` toggle. So a mixed-stage frame (standing in a doorway / look-in) lights through-aperture objects with the player's regime, not the object's stage regime | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IndoorObjectReceivesTorches` + `ComputeEntityLightSet`); sun half `src/AcDream.App/Rendering/GameWindow.cs:10421` (`UpdateSunFromSky`) | The visible case — player OUTSIDE, looking at an outdoor building/scenery — is now exactly faithful (sun+ambient, no torches); player INSIDE a cell gets torches with the sun globally killed = faithful indoor regime. Cross-aperture mismatch only affects objects seen THROUGH a doorway from the other lighting context | A through-aperture interior object viewed from outside gets the sun (player outside) instead of retail's indoor torches-no-sun; an outdoor object viewed from inside gets no sun (player inside) instead of retail's sunlit outside stage — doorway/look-in frames only, not the standalone outdoor or indoor case | `useSunlight` gate `DrawMeshInternal` 0x0059f398; `useSunlightSet` 0x0054d450; outside stage `PView::DrawCells` 0x005a485a (`useSunlightSet(1)`); `minimize_object_lighting` 0x0054d480; `config_hardware_light` Range=falloff×`rangeAdjust`(1.5) 0x0059ad30 |
| AP-43 | Per-object torch (point/spot) lighting is gated on the OBJECT's own cell: an object selects the static wall-torches ONLY when its `ParentCellId` is an EnvCell (`(id & 0xFFFF) >= 0x0100`); outdoor objects (building exterior shells with null ParentCellId, outdoor scenery, outdoor creatures) get the SUN + ambient and NO torches. This is the faithful port of retail's `useSunlight` gate — `DrawMeshInternal` (0x0059f398) calls `minimize_object_lighting` only `if (Render::useSunlight == 0)`, and the outdoor landscape stage runs `useSunlightSet(1)` (`PView::DrawCells` 0x005a485a, before `LScape::draw`) so outdoor objects are never torch-lit (closes the Holtburg meeting-hall facade torch-flood — A7 Fix D round 2, the dat's intensity-100 `falloff 6` orange torches were washing the exterior shell). **Residual approximation:** the sun/no-sun half is a per-FRAME global keyed on the PLAYER being inside a cell (`UpdateSunFromSky` zeroes the sun when `playerInsideCell`), NOT retail's per-draw-STAGE `useSunlight` toggle. So a mixed-stage frame (standing in a doorway / look-in) lights through-aperture objects with the player's regime, not the object's stage regime | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IndoorObjectReceivesTorches` + `ComputeEntityLightSet`); sun half `src/AcDream.App/Rendering/GameWindow.cs:10421` (`UpdateSunFromSky`) | The visible case — player OUTSIDE, looking at an outdoor building/scenery — is now exactly faithful (sun+ambient, no torches); player INSIDE a cell gets torches with the sun globally killed = faithful indoor regime. Cross-aperture mismatch only affects objects seen THROUGH a doorway from the other lighting context | A through-aperture interior object viewed from outside gets the sun (player outside) instead of retail's indoor torches-no-sun; an outdoor object viewed from inside gets no sun (player inside) instead of retail's sunlit outside stage — doorway/look-in frames only, not the standalone outdoor or indoor case | `useSunlight` gate `DrawMeshInternal` 0x0059f398; `useSunlightSet` 0x0054d450; outside stage `PView::DrawCells` 0x005a485a (`useSunlightSet(1)`); `minimize_object_lighting` 0x0054d480; `config_hardware_light` Range=falloff×`rangeAdjust`(1.5) 0x0059ad30 |
| AP-35 | Point/spot lights are now PER-VERTEX Gouraud (`pointContribution` ~line 153 of `mesh_modern.vert`) matching retail's `SetStaticLightingVertexColors` bake path. Half-Lambert wrap (`(1/1.5)·(N·D + 0.5·d)`) AND norm distance attenuation (`distsq>1 ? distsq·d : d`) ARE ported (A7 Fix A, `aa94ced`). Point-light sum clamped to [0,1] on its own accumulator before adding ambient+sun (A7 Fix D D-1, mirrors retail's per-vertex bake clamp). CPU oracle: `src/AcDream.Core/Lighting/LightBake.cs`, locked by `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`. **Residual (two parts):** (a) acdream lights in-shader each frame (per-frame GPU evaluate); retail bakes into the vertex buffer ONCE — an architecture/performance difference; the wrap + norm + clamp formula is the same, but bake-once is cheaper for static geometry; (b) acdream's `SelectForObject` keeps only the 8 NEAREST reaching point/spot lights per object/cell (`MaxLightsPerObject=8`, see AP-16), whereas retail's bake sums ALL reaching static lights per vertex — a surface reached by >8 point lights is dimmer in acdream than retail's bake result (rare in practice; a room has a handful of torches) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution` ~line 153; wrap ~line 163; norm ~line 167; point-sum clamp line 210) | Per-vertex Gouraud + wrap + norm + clamp all match retail. The two residuals are: (a) per-frame GPU vs bake-once — architecture/perf only; (b) 8-light cap dimming when >8 lights reach one surface — rare. `LightInfoLoader.cs:81` folds static_light_factor 1.3 into Range | (a) A new frame-time consumer bypassing `accumulateLights` would need to replicate the wrap + norm formula; per-frame GPU re-evaluate has higher per-frame cost than bake for static geometry. (b) A densely lit scene (>8 torches reaching one wall) renders dimmer than retail — see AP-16 for the 8-cap ownership | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); `SetStaticLightingVertexColors` 0x0059cfe0; 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); `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` |
| 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 |
---
## 4. Temporary stopgap (TS) — 30 rows
## 4. Temporary stopgap (TS) — 31 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@ -169,7 +177,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 | 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 |
| 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 |
---
@ -186,6 +195,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 |
---
@ -216,8 +226,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.
---

View file

@ -424,9 +424,12 @@ behavior. Estimated 1726 days focused work, 35 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); 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-3840 / TS-3031; 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.)**
- **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.)**

View file

@ -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 <datdir> [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`).

View file

@ -0,0 +1,491 @@
# 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 26. 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<UIStateId, StateDesc>` | named states (e.g. `HideDetail`, `ShowDetail`) |
| `Children` | **field** | `Dictionary<uint, ElementDesc>` | 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<uint, BaseProperty>` | keyed by property-id (uint); see §3 |
| `Media` | **field** | `List<MediaDesc>` | polymorphic list of media items |
### States dictionary key type
`ElementDesc.States` is `Dictionary<UIStateId, StateDesc>`. 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<uint, BaseProperty>`. 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<BaseProperty>` | 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<uint, BaseProperty>` | |
### 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` | **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 (retail-faithful, per `UIElement::UpdateForParentSizeChange @0x00462640`)
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)
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 108459108668.
**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)
{
var a = AnchorEdges.None;
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;
}
```
**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.
---
## 5. `MediaDesc` kinds
`StateDesc.Media` is `List<MediaDesc>`. 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<BaseProperty> 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<char> 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 |
| 0x130x19 | `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<uint, ElementDesc>` (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 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.
---
## 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 semantics are INVERTED from the earlier §4 reading
**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 | 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** |
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 (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;
}
```
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 (0x130x19), all `gm*UI` custom types
- DrawModes: `Overlay` (2), any future additions
- Media: `MediaDescAnimation`, `MediaDescFade`, `MediaDescSound`, `MediaDescState`, etc.

View file

@ -49,7 +49,7 @@ torch selection on the object being INDOOR (`ParentCellId` is an EnvCell, `(id&0
via the pure predicate `IndoorObjectReceivesTorches`. Outdoor objects (building shells — ParentCellId
null; outdoor scenery; outdoor creatures) keep the all-(-1) light set ⇒ sun + ambient only = retail.
The indoor "no sun" half is already handled by the global sun-kill when the player is inside a cell
(`UpdateSunFromSky`, intensity 0). Divergence-register row **AP-37** added (documents the residual:
(`UpdateSunFromSky`, intensity 0). Divergence-register row **AP-43** added (documents the residual:
acdream keys sun/torch on the object's own cell + a per-frame player-inside sun-kill, vs retail's
per-draw-STAGE `useSunlight` — only matters for through-doorway look-ins). Tests:
`WbDrawDispatcherTorchGateTests` (7✓), `HoltburgTorchFalloffProbeTests` (dat dump). Build green;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,760 @@
# 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 16 (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;
/// <summary>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.</summary>
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<string, (uint File, int DrawMode)> StateMedia = new();
}
public static class ElementReader
{
/// <summary>Edge-anchor flags → AnchorEdges. Flag value 4 (per format doc) = "pinned
/// to that side"; any other value = not pinned. Left+Right ⇒ width stretches.</summary>
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;
}
/// <summary>Merge a base element with a derived override: start from base, apply any
/// non-default field the derived element sets. Mirrors BaseElement/BaseLayoutId.</summary>
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<string, (uint, int)>(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;
/// <summary>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".</summary>
public sealed class UiDatElement : UiElement
{
private readonly ElementInfo _info;
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
public string ActiveState { get; set; } = "";
public UiDatElement(ElementInfo info, Func<uint, (uint, int, int)> 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<UiMeter>(e);
}
[Fact]
public void UnknownType_FallsBackToGeneric()
{
var e = DatWidgetFactory.Create(new ElementInfo { Type = 999 }, NoTex, null);
Assert.IsType<UiDatElement>(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;
/// <summary>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).</summary>
public static class DatWidgetFactory
{
/// <param name="resolve">RenderSurface id → (GL tex, w, h).</param>
/// <param name="datFont">Retail UI font for text elements (may be null pre-load).</param>
public static UiElement Create(ElementInfo info,
Func<uint, (uint, int, int)> 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<uint, (uint, int, int)> 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<UiMeter>(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;
/// <summary>Reads a retail LayoutDesc into a UiElement tree. Pure tree-building
/// (BuildFromInfos) is dat-free + testable; Import(dats, id, ...) is the dat shell.</summary>
public sealed class ImportedLayout
{
public required UiElement Root { get; init; }
private readonly Dictionary<uint, UiElement> _byId;
public ImportedLayout(UiElement root, Dictionary<uint, UiElement> byId) { Root = root; _byId = byId; }
public UiElement? FindElement(uint id) => _byId.TryGetValue(id, out var e) ? e : null;
}
public static class LayoutImporter
{
/// <summary>Dat shell: load the layout, convert ElementDescs to ElementInfo (resolving
/// inheritance), then BuildFromInfos. Returns null if the layout is missing.</summary>
public static ImportedLayout? Import(DatCollection dats, uint layoutId,
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont)
{
var ld = dats.Get<LayoutDesc>(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<ElementInfo>();
var nested = new Dictionary<ElementInfo, ElementDesc>();
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);
}
/// <summary>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.)</summary>
public static ImportedLayout BuildFromInfos(ElementInfo rootInfo, IEnumerable<ElementInfo> children,
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont)
{
var byId = new Dictionary<uint, UiElement>();
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<uint, (uint, int, int)> resolve, UiDatFont? datFont)
{
var byId = new Dictionary<uint, UiElement>();
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<uint, (uint, int, int)> resolve, UiDatFont? datFont, Dictionary<uint, UiElement> 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);
}
/// <summary>ElementDesc → ElementInfo, resolving BaseElement/BaseLayoutId inheritance.</summary>
private static ElementInfo Resolve(DatCollection dats, ElementDesc d)
{
var self = ToInfo(d);
if (d.BaseElement != 0 && d.BaseLayoutId != 0)
{
var baseLd = dats.Get<LayoutDesc>(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;
}
/// <summary>Read the verified ElementDesc fields into ElementInfo (no inheritance).</summary>
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<uint, UiElement>();
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;
/// <summary>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.</summary>
public static class VitalsController
{
public const uint Health = 0x100000E6, Stamina = 0x100000EC, Mana = 0x100000EE;
public static void Bind(ImportedLayout layout,
Func<float> healthPct, Func<float> staminaPct, Func<float> manaPct,
Func<string> healthText, Func<string> staminaText, Func<string> 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<float> pct, Func<string> 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<UiMeter>(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
**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) 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.
## 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 78).
- **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.

View file

@ -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 27; 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~<ClassName>"`.
- **Commit style:** `feat(D.2b): <widget> — <what>` / `test(D.2b): …` / `refactor(D.2b): …`, ending with the project's `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>` 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 26).
- `src/AcDream.App/UI/Layout/ChatWindowController.cs` — construction → find-by-id binding; channel-item population (Tasks 27).
- `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 56).
- `tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs` — new per-Type asserts; flip the two Type-12 tests (Tasks 26).
- `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;
/// <summary>
/// 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.
/// </summary>
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<byte> span = bytes;
if (span.Length >= 3 && span[0] == 0xEF && span[1] == 0xBB && span[2] == 0xBF) span = span[3..];
return JsonSerializer.Deserialize<AcDream.App.UI.Layout.ElementInfo>(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<UiScrollbar>(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;
/// <summary>
/// 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.
/// </summary>
public sealed class UiButton : UiElement
{
private readonly ElementInfo _info;
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
public Action? OnClick { get; set; }
public string? Label { get; set; }
public UiDatFont? LabelFont { get; set; }
public Vector4 LabelColor { get; set; } = Vector4.One;
/// <summary>Active state name, runtime-settable (e.g. Max/Min toggling Normal↔Minimized).</summary>
public string ActiveState { get; set; } = "";
public UiButton(ElementInfo info, Func<uint, (uint tex, int w, int h)> 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<UiButton>(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
/// <summary>One menu row: its label + an opaque payload the controller maps back.</summary>
public readonly record struct MenuItem(string Label, object? Payload);
/// <summary>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.</summary>
public IReadOnlyList<MenuItem> Items { get; set; } = System.Array.Empty<MenuItem>();
/// <summary>The currently-selected payload (drives the highlighted row).</summary>
public object? Selected { get; set; }
/// <summary>Fired with the picked item's payload when a row is chosen.</summary>
public Action<object?>? OnSelect { get; set; }
/// <summary>Per-payload enabled gate (disabled rows render greyed + are inert).
/// Null ⇒ all rows enabled.</summary>
public Func<object?, bool>? EnabledProvider { get; set; }
/// <summary>Button-face caption (the active target). Null ⇒ blank face.</summary>
public Func<string>? 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<UiMenu>(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
/// <summary>Optional dat state-sprite background (the element's own media), drawn
/// UNDER the text. Set by DatWidgetFactory.BuildText from the ElementInfo. 0 = none.</summary>
public uint BackgroundSprite { get; set; }
public Func<uint, (uint tex, int w, int h)>? 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<UiText>(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<UiText>(DatWidgetFactory.Create(withMedia, NoTex, null));
Assert.IsType<UiText>(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
/// <summary>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).</summary>
private static UiText BuildText(ElementInfo info, Func<uint, (uint, int, int)> 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/`<returns>` 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<UiText.Line>` return type, the `Array.Empty<UiText.Line>()`, 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<UiField>(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 26, `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 27 (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.

View file

@ -0,0 +1,392 @@
# D.2b — Retail panel frame + live Vitals (Approach C: KSML-style engine) — Design
**Date:** 2026-06-14
**Status:** Design approved (brainstorm) + **re-grounded 2026-06-14** onto the existing `AcDream.App/UI/` retained-mode scaffold (see §0). Pending spec re-review → implementation plan.
**Phase:** D.2b — Custom retail-look UI backend ([roadmap:427](../../../docs/plans/2026-04-11-roadmap.md))
**Milestone:** M5 "Looks like retail" — **explicitly PARALLELIZABLE with M3/M4** ([milestones:378](../../../docs/plans/2026-05-12-milestones.md)). Opened as a parallel track while M1.5 is the active critical-path milestone; the M5 parallelizable flag is the milestone-discipline carve-out.
**Grounding:** read-only research workflow `wf_39a90d37-e5a` (7 readers + gap-critic) + a direct read of `src/AcDream.App/UI/`. Every binding fact cites `file:line` in `src/` or a named-retail symbol.
---
## 0. Re-grounding correction (read this first)
The first draft of this spec proposed building a `RetailPanelHost : IPanelHost` +
`RetailPanelRenderer : IPanelRenderer` and a retained-mode toolkit *from scratch*.
**That was wrong.** A direct read of `src/AcDream.App/UI/` found a **complete,
dormant retained-mode toolkit** — the 2026-04-17 scaffold the roadmap names as
"the implementation foundation here" ([roadmap:427](../../../docs/plans/2026-04-11-roadmap.md)):
- **`UiRoot`** ([UiRoot.cs](../../../src/AcDream.App/UI/UiRoot.cs)) — the hard
part is already built: mouse routing, keyboard focus, mouse capture, a full
drag-drop state machine, tooltip timer, modal handling, click/right-click
detection, world fall-through. Retail-faithful event codes in
[UiEvent.cs](../../../src/AcDream.App/UI/UiEvent.cs).
- **`UiElement`** (geometry/tree/hit-test), **`UiPanel`/`UiLabel`/`UiButton`**
([UiPanel.cs](../../../src/AcDream.App/UI/UiPanel.cs)), **`UiHost`**
([UiHost.cs](../../../src/AcDream.App/UI/UiHost.cs) — packages `UiRoot` +
`TextRenderer` + font, with `Tick`/`Draw`/`WireMouse`/`WireKeyboard`),
**`UiRenderContext`** ([UiRenderContext.cs](../../../src/AcDream.App/UI/UiRenderContext.cs)
— transform stack + `DrawRect`/`DrawString`).
`UiHost` is **dormant** — never instantiated in `GameWindow` (verified: `new
UiHost(` appears only in a doc-comment). And `UiPanel.cs` is the *exact file*
divergence row TS-30 points at: it draws a flat translucent rect *"until our
AcFont/UiSpriteBatch consumes [9-slice dat sprites] directly."*
**Consequence:** the retail UI is this existing `UiRoot` tree — a separate system
from the ImGui `IPanelRenderer` path, **not** an `IPanelRenderer` implementation.
Spec 1 *wires the dormant `UiHost`* and *adds the few missing pieces*, rather than
building a backend. This is strictly less code and more faithful. §4/§5/§8/§9/§10
below are written against the scaffold.
*(Process note: the grounding workflow's "UI" readers keyed on the ImGui/Abstractions
framing in their prompts and never globbed `src/AcDream.App/UI/`. Lesson: a
subsystem-discovery pass must glob by directory, not only by the framing the
parent already has in mind.)*
## 1. Context & goal
acdream needs a retail-faithful game UI. The shipped path (D.2a) is an ImGui
overlay gated on `ACDREAM_DEVTOOLS=1` — a debugger aesthetic, intentionally
temporary. D.2b stands up the *retail-look* UI (the dormant `UiHost` tree) that
draws retail's actual dat assets, while the ImGui devtools path stays untouched.
**The user's framing (2026-06-14):** AC's UI engine is Keystone — and Keystone
was *already* markup + stylesheet (KSML, an HTML-clone XML defined by `ksml.xsd`,
+ `controls.ini`, a CSS-like INI stylesheet). So "make it look + behave like
retail, but author it in a CSS/HTML-style way" re-expresses AC's own design in
its modern equivalent.
**Approach decision (Approach C).** Three integration families were weighed:
(A) embed a real web engine (Ultralight/CEF), (B) a native HTML/CSS-subset lib
(RmlUi), (C) our own KSML-style markup + stylesheet over a retained-mode toolkit
on Silk.NET. **C chosen** for: zero external deps (keeps the native-AOT goal
intact), lowest memory (~310 MB vs CEF's 150300 MB), full control, and maximal
faithfulness — it mirrors Keystone directly, and the retained-mode toolkit C
needs *already exists* (§0).
This spec covers **Spec 1**: wire the scaffold + add the markup/stylesheet/sprite
gaps, proven end-to-end on **one** panel — the universal window frame wrapping
the live Vitals bars.
## 2. Scope
**In Spec 1:**
- Wire the dormant **`UiHost`** into `GameWindow`, gated by a new
`RuntimeOptions.RetailUi` toggle (`ACDREAM_RETAIL_UI=1`). The ImGui devtools
path is untouched and may run simultaneously.
- Add dat-sprite drawing: `UiRenderContext.DrawSprite` (UV-rect) + a
`TextRenderer` textured-sprite path + a `ui_text.frag` `uUseTexture=2` branch.
- A **`UiNineSlicePanel : UiPanel`** that draws the 8-piece dat-sprite window
frame + center fill (upgrading the exact code TS-30 cites) — title bar
(`UiLabel`) + a close button (`UiButton`, which already exists).
- A **`UiMeter : UiElement`** vital bar bound to a `Func<float>` reading
`VitalsVM`.
- The XML markup format (mirrors `ElementDesc`) + a `MarkupDocument` parser that
**instantiates a `UiElement` subtree** + a minimal `controls.ini` stylesheet
loader.
- The plugin-facing contract: plugins contribute a `UiElement`/markup subtree
added to `UiRoot` (§9) — designed now, first consumer first-party.
**Deferred to later sub-phases (explicitly OUT):**
- **Wiring `UiHost`'s input** (`WireMouse`/`WireKeyboard`) into the existing
Phase-K `InputDispatcher`. The `UiRoot` input *machinery* exists; *integrating*
two input consumers (route unconsumed `WorldMouseFallThrough` back to the game)
is its own concern. Spec 1 is **render-only** (`Tick` + `Draw`), so the frame +
live bars show but the close button isn't clicked and the window isn't dragged.
- The dat A8 glyph font loader (`AcFont`) → numeric overlays.
- The full anchor solver (`StateDesc::UpdateSizeAndPosition` port).
- The `LayoutDesc` binary importer (sub-project 3).
- Reskinning Chat / Debug / Settings.
- Login / char-select / chargen screens (raw-JPEG backgrounds, sub-project 4).
## 3. Source-verified facts (do-not-trust list)
The grounding caught several load-bearing "facts" that were wrong/unverified.
These are binding:
| Claimed (memory / first draft) | Reality (source-verified) |
|---|---|
| Build a retained-mode toolkit + `RetailPanelHost`/`RetailPanelRenderer` | The toolkit **exists** in `src/AcDream.App/UI/` (§0); the retail UI is the `UiRoot` tree, not an `IPanelRenderer` backend |
| `VitalsVM` is `record VitalsVM(int HpCurrent, …)` | Sealed class: `HealthPercent` (float), `StaminaPercent`/`ManaPercent` (float?), `*Current`/`*Max` (uint?), ctor `(CombatState, LocalPlayerState?)`, `SetLocalPlayerGuid(uint)` — [VitalsVM.cs:35](../../../src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs) |
| Chrome sprite IDs `0x06004CC2`/`0x21000040`/`0x060074BF..C6` are known | **Unverified + contradictory.** `0x06001125` (cited elsewhere) is the char-select highlight. **No chrome ID is trusted — Step 0 dat prove-out resolves them empirically.** |
| `#FFDBD6A8` "parchment cream" is the panel background | It is `[editbox]`/`[treeview]` **text** color. Real frame tokens: `[title]` bg `#FFFFFFFF`, font `Verdana-10-bold`, height 19; `[body]` bg `#00000000` (transparent), `color_border=#FF4F657D` |
| `DatCollection` is NOT thread-safe | Concurrent **reads are safe** since v2.1.7 ([2026-06-09 investigation](../../../docs/research/2026-06-09-dat-reader-thread-safety-investigation.md)). No UI-specific lock. |
| KSML is the panel-layout language | KSML is **rich-text-in-text-regions**; the panel layout format is the binary `LayoutDesc`/`ElementDesc` tree ([acclient.h:33693](../../../docs/research/named-retail/acclient.h)). Our markup mirrors `ElementDesc`. |
## 4. Architecture & placement
The retail UI lives in **`src/AcDream.App/UI/`** (where the scaffold already is).
New widgets/parsers join it as dedicated files. Code-Structure Rule 1 is honored
(nothing substantial added to `GameWindow.cs` — only a few wiring lines); Rule 2
(Core stays GL-free) and Rule 3 (panels target `AcDream.UI.Abstractions`) are
unaffected because the retail UI is a *separate* tree, not an `IPanelRenderer`
panel.
```
┌──────────────────────────────────────────────────────────┐
│ retail dat (read-only fidelity source) │
│ controls.ini → style tokens · RenderSurface 0x06xxxxxx │
│ → sprites · Font 0x40xxxxxx → glyphs (deferred) │
└───────────────┬──────────────────────────────────────────┘
│ TextureCache.GetOrUpload(id) → Texture2D
┌───────────────▼──────────────────────────────────────────┐
│ src/AcDream.App/UI/ (scaffold EXISTS; + = new in Spec 1) │
│ UiHost (exists, dormant) ─ wire into GameWindow │
│ UiRoot/UiElement (exist) ─ input + tree + hit-test │
│ UiRenderContext (exists) + DrawSprite(UV-rect) │
│ UiPanel/UiLabel/UiButton (exist) │
│ + UiNineSlicePanel : UiPanel (8-piece dat chrome) │
│ + UiMeter : UiElement (vital bar) │
│ + MarkupDocument (XML → UiElement subtree) │
│ + ControlsIni (stylesheet loader) │
│ uses Rendering/TextRenderer (+ sprite path, + DepthMask) │
└───────────────┬──────────────────────────────────────────┘
│ UiMeter.Fill = () => vm.HealthPercent
┌───────────────▼──────────────────────────────────────────┐
│ AcDream.UI.Abstractions (exists) — VitalsVM (unchanged) │
│ ↑ ImGui IPanelHost/IPanelRenderer path stays for │
│ ACDREAM_DEVTOOLS, fully independent of the above │
└──────────────────────────────────────────────────────────┘
```
**Coexistence.** Two UI systems run side by side, independently:
`ACDREAM_DEVTOOLS=1` → the ImGui overlay (unchanged); `ACDREAM_RETAIL_UI=1`
the `UiHost` tree. The retail pass renders in the post-3D slot
([GameWindow.cs:8232 region](../../../src/AcDream.App/Rendering/GameWindow.cs))
with deterministic ordering relative to ImGui. `UiHost.Draw` already does
`TextRenderer.Begin → Root.Draw(ctx) → TextRenderer.Flush`
([UiHost.cs:58](../../../src/AcDream.App/UI/UiHost.cs)).
## 5. Render foundation — extend the existing 2D path
`UiHost` draws the `UiRoot` tree through a `UiRenderContext` backed by the shared
`TextRenderer` ([UiHost.cs:58-67](../../../src/AcDream.App/UI/UiHost.cs)). That
`TextRenderer` does solid rects + R8 text today but **not** textured RGBA sprites
([ui_text.frag:9](../../../src/AcDream.App/Rendering/Shaders/ui_text.frag),
[TextRenderer.cs](../../../src/AcDream.App/Rendering/TextRenderer.cs)). Spec 1
adds the sprite path:
- **`ui_text.frag`** += a `uUseTexture==2` branch: `FragColor = texture(uTex,
vUv) * vColor;` (the existing `0`=solid and `1`=R8-coverage branches are
untouched).
- **`TextRenderer`** += `DrawSprite(uint texture, float x,y,w,h, float
u0,v0,u1,v1, Vector4 tint)` accumulating into **per-texture** sprite buffers
(`Dictionary<uint, List<float>>`), and a `Flush` pass that, after rects+text,
draws each texture's batch with `uUseTexture=2`. Reuses the existing
`AppendQuad` (which already takes `u0,v0,u1,v1`) + `UploadBuffer` machinery.
- **`TextRenderer.Flush`** += explicit **`DepthMask(false)`** (queried + restored)
— it disables `DepthTest` today but never sets `DepthMask`
([TextRenderer.cs:171](../../../src/AcDream.App/Rendering/TextRenderer.cs)).
Per the project's "render self-contained GL state" rule.
- **`UiRenderContext`** += `DrawSprite(uint texture, float x,y,w,h, float
u0,v0,u1,v1, Vector4 tint)` that adds the current transform and forwards to
`TextRenderer.DrawSprite` (mirrors the existing `DrawRect` forwarder at
[UiRenderContext.cs:50](../../../src/AcDream.App/UI/UiRenderContext.cs)).
No new shader class, VAO, or batcher — we extend the proven path the scaffold
already uses. (`Shader` is the simple file-based class
[Shader.cs](../../../src/AcDream.App/Rendering/Shader.cs); `GLSLShader`'s bindless
machinery is not needed.)
## 6. Dat assets & the Step-0 prove-out gate
`TextureCache.GetOrUpload(uint surfaceId)` returns a conventional `Texture2D`
GL handle (1×1 magenta on failure) — exactly right for the UI batch
([TextureCache.cs:70](../../../src/AcDream.App/Rendering/TextureCache.cs)). The
decode chain + `PFID_*` formats already work
([SurfaceDecoder.cs:39](../../../src/AcDream.Core/Textures/SurfaceDecoder.cs)).
`GameWindow` already holds a `TextureCache`; `UiHost`/`UiNineSlicePanel` receive
it (or a `Func<uint,uint>` sprite-resolver) by injection.
**Step 0 is empirical and comes first.** Because no chrome sprite ID is verified,
the first implementation task draws each candidate ID
(`0x06004CC2`, `0x060074BF..C6`, `0x0600129C`, …) as a raw quad and visually
confirms which decode to frame-shaped art vs magenta vs the wrong sprite. The
confirmed IDs are recorded in code comments before any chrome layout is written.
**No ID is hardcoded on faith.**
The frame is **8 quads + a center fill** (4 corner + 4 edge sprites + center),
not one stretched 9-slice texture. Slice/edge metrics are a **documented stopgap
constant** (with a divergence row) until the `LayoutDesc` tree is parsed
(sub-project 3).
## 7. Markup + stylesheet model
**Markup** mirrors `ElementDesc` 1:1 ([acclient.h:33693](../../../docs/research/named-retail/acclient.h));
`MarkupDocument` parses it and **instantiates a `UiElement` subtree** (a
`UiNineSlicePanel` with child `UiLabel`/`UiMeter`/`UiButton`). Authoring shape:
```xml
<panel id="acdream.vitals" x="10" y="30" w="220" h="96" title="Vitals">
<meter id="health" x="8" y="24" w="200" h="13" fill="{HealthPercent}" color="#FF0000"/>
<meter id="stamina" x="8" y="44" w="200" h="13" fill="{StaminaPercent}" color="#D9A626"/>
<meter id="mana" x="8" y="64" w="200" h="13" fill="{ManaPercent}" color="#0000FF"/>
</panel>
```
This is the shape the future `LayoutDesc` importer will *emit*, so authoring and
imported formats converge. It is **not** KSML (rich-text, deferred). `{Binding}`
expressions resolve against a supplied binding object (the `VitalsVM`) via
reflection on the property name.
**Anchor codes** are *defined* now (0=fixed, 1=stretch, 2=proportional-translate,
3=center, 4=proportional-scale — from `StateDesc::UpdateSizeAndPosition`
@`0x0069BF20`) but the **solver is deferred**: the Vitals window is fixed-size
(placed via the existing `UiElement.Left/Top`), so Spec 1 needs no solver.
**Stylesheet.** A small INI loader parses `controls.ini` keyed by element-type
section, honoring `#AARRGGBB` (alpha-first) and `font://Face-Pt[-style]`. Cascade:
element-type defaults → per-element `class=` → inline attributes. **Optional**
(§10): absent AC install → source-verified `[title]`/`[body]` fallback tokens.
## 8. VM binding (the Vitals slice)
The vitals panel is a `UiNineSlicePanel` (chrome) containing a `UiLabel` (title)
and three `UiMeter`s. Each `UiMeter` holds a `Func<float?> Fill` bound to the
real `VitalsVM` ([VitalsVM.cs:67](../../../src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs)):
`() => vm.HealthPercent`, `() => vm.StaminaPercent`, `() => vm.ManaPercent`. The
VM already does all server plumbing, so we do **not** re-derive vitals from the
retail `gmVitalsUI`/`CACQualities` decomp.
`UiMeter.OnDraw` draws the empty bar (`ctx.DrawRect`), the filled portion as a
**partial-size rect** (`width = pct * Width`), and a centered `current/max` numeric
overlay (`Func<string?> Label`). **Retail's vitals ARE exactly this — three stacked
horizontal bars (confirmed against a live retail client 2026-06-14), NOT orbs.**
Colors: Health red, **Stamina gold** (the earlier `#10F0F0` cyan research note was
wrong), Mana blue. A `null` fill/label (pre-`PlayerDescription`) renders gracefully.
The remaining gap to pixel-retail is the **glassy gradient bar fill sprite** + the
**retail dat font** for the numbers (today the stub `BitmapFont` draws them) — both
polish, deferred to §15.
The `VitalsVM` is constructed and given the player GUID the same way as today
([GameWindow.cs:1330](../../../src/AcDream.App/Rendering/GameWindow.cs) ctor,
:1984 `SetLocalPlayerGuid`); the retail-UI build path reuses that same VM
instance.
## 9. Plugin contract (designed now, first consumer first-party)
The plugin API is a day-1 constraint; plugin authors must be able to add retail
UI. The natural unit is a **`UiElement`/markup subtree added to `UiRoot`** (not
`IPanel`/`IPanelRenderer`, which is the ImGui devtools path). Spec 1 adds:
- A small `IUiRegistry` (in `AcDream.Plugin.Abstractions`) — `void
AddMarkupPanel(string markupPath, object binding)` (and/or `void
AddElement(UiElement)` once a plugin-safe element surface is decided). For
Spec 1, `AddMarkupPanel` is enough.
- `IPluginHost` gains `IUiRegistry Ui { get; }`
([IPluginHost.cs:8](../../../src/AcDream.Plugin.Abstractions/IPluginHost.cs)
has none today); `AppPluginHost` implements it
([AppPluginHost.cs:5](../../../src/AcDream.App/Plugins/AppPluginHost.cs)).
- Because plugin `Enable()` runs in `Program.cs` **before** the GL window opens
([Program.cs:55-60](../../../src/AcDream.App/Program.cs)), `AddMarkupPanel`
**buffers** registrations into a list that `GameWindow` drains into `UiRoot`
after `UiHost` is constructed. The threading/timing concern lives in the host;
the plugin call is unconditional.
- The first consumer is the first-party vitals panel (built directly in
`GameWindow`, not through the registry). Wiring an actual plugin-supplied markup
panel end-to-end is exercised by a smoke test but is otherwise the thin
follow-up. This task group is the **last** in the plan so the visible vitals
slice can land first if it slips.
## 10. Confirmed decisions (approved 2026-06-14)
1. **Render-only first slice.** `Tick` + `Draw` only; the `UiHost` input wiring
(`WireMouse`/`WireKeyboard`) is **not** connected to the existing Phase-K
`InputDispatcher` yet, so the close button isn't clickable and the window
isn't draggable. Rationale (corrected): the `UiRoot` input *machinery* already
exists — what's deferred is *integrating two input consumers* (routing
unconsumed `WorldMouseFallThrough` back to the game's dispatcher), which is its
own sub-phase.
2. **`controls.ini` optional.** Assume `C:\Turbine\Asheron's Call\` may or may not
exist. Add an `ACDREAM_AC_DIR` `RuntimeOptions` field; when absent, fall back
to the source-verified `[title]`/`[body]` token values. The build never fails
on a missing AC install.
## 11. Build sequence
| Step | Deliverable | Proves |
|---|---|---|
| 0 | Dat prove-out harness: draw candidate chrome IDs, confirm the real ones | Resolves the chrome-ID contradiction empirically |
| 1 | `ui_text.frag` `uUseTexture=2` + `TextRenderer.DrawSprite` + `DepthMask` + `UiRenderContext.DrawSprite` | A dat sprite composites over the 3D scene |
| 2 | `UiNineSlicePanel` draws an empty titled frame from confirmed dat sprites (stopgap insets) | Retail-shaped chrome renders |
| 3 | `UiMeter` + a hand-built vitals `UiNineSlicePanel` subtree bound to `VitalsVM`, wired via `UiHost` under `ACDREAM_RETAIL_UI` (render-only) | End-to-end live data + the scaffold lights up |
| 4 | `ControlsIni` parser (TDD) feeding the panel's title/colors | Stylesheet cascade |
| 5 | `MarkupDocument` parser (TDD) → builds the same vitals subtree from `vitals.xml` | The Approach-C markup engine |
| 6 *(last)* | `IUiRegistry` on `IPluginHost` + buffered drain into `UiRoot` + smoke test | Plugin-ready |
## 12. Error handling & edge cases
- **Missing/undecodable sprite**`GetOrUpload` magenta fallback is visible;
Step 0 catches it. A null/zero DataID in markup logs a warning, draws nothing.
- **AC install absent**`controls.ini` load skipped, baked fallback tokens used.
- **Vitals null percents** → empty bar (`UiMeter.Fill` returns null).
- **Window resize**`UiHost.Draw` already sets `Root.Width/Height` to the
current screen size each frame ([UiHost.cs:61](../../../src/AcDream.App/UI/UiHost.cs));
fixed-coord panels stay put. No DPI scaling (known out-of-scope gap).
- **Both toggles on** → ImGui Vitals and retail Vitals may both show (fine in dev).
## 13. Testing
- **`ControlsIni` parser** (pure, no GL) — unit tests for `#AARRGGBB`, `font://`,
cascade order. Lives in `src/AcDream.App/UI/`, tested in `tests/AcDream.App.Tests/`
(App-layer, Rule 6).
- **`MarkupDocument` parser** — unit tests for XML → `UiElement` tree shape
(types, geometry) and `{Binding}` resolution against a fake binding object.
- **`UiMeter` fill geometry** — unit test that fill fraction → partial rect width
(pure math; `UiMeter.ComputeFillRect(pct, w, h)` as a static helper so it's
testable without GL).
- **`UiNineSlice` geometry** — unit test that a frame size + insets → the 9 dst
rects (`UiNineSlicePanel.ComputeSliceRects` static helper).
- **Plugin smoke** — a test plugin calls `host.Ui.AddMarkupPanel` and the buffered
registration is drained (assert the panel is added to `UiRoot`).
- **Visual acceptance** (user) — retail-shaped Vitals frame with live bars under
`ACDREAM_RETAIL_UI=1`; ImGui path unaffected under `ACDREAM_DEVTOOLS=1`.
- `dotnet build` + `dotnet test` green.
## 14. Bookkeeping
- **Phase D.2b**, Milestone **M5** (parallelizable; NOT on the M1.5 critical
path). The CLAUDE.md "Current state" line stays on M1.5.
- **Divergence register:** in the commit that ships `UiNineSlicePanel` rendering a
real dat sprite, **delete row TS-30** ([retail-divergence-register.md:166](../../../docs/architecture/retail-divergence-register.md))
— its cited file (`UiPanel.cs`) is upgraded by the subclass — and **add one**
new IA-row (Intentional Architecture; keystone.dll has no PDB/decomp) for the
markup/serialization layer. Assign the next sequential IA number at commit time.
Retail oracle: "LayoutDesc 0x21xxxxxx; controls.ini panel-property vocabulary;
keystone.dll layout evaluation (no PDB)". Do **not** duplicate IA-12 (UI
toolkit *behavioral* approximation). A second row for the stopgap slice insets
is added when they ship.
- **Spec file:** this document.
## 15. Open gaps & deferred sub-projects
- **Input integration** — connect `UiHost.WireMouse`/`WireKeyboard` to the Phase-K
`InputDispatcher`, routing unconsumed `WorldMouseFallThrough`/`WorldKeyFallThrough`
back to the game. Next sub-phase (lights up the close button + window drag that
`UiRoot` already supports).
- **`AcFont`** — dat A8 glyph loader (Font `0x40000xxx``ForegroundSurfaceDataId`
→ RenderSurface, upload as **R8** so `ui_text.frag`'s `.r`-coverage branch works
unchanged) → numeric overlays + retail fonts. (Today `UiLabel` uses the
stb_truetype `BitmapFont`.)
- **Anchor solver** — port `StateDesc::UpdateSizeAndPosition`; with the importer.
- **`LayoutDesc` binary importer** (sub-project 3) — bulk-transpile retail layouts
→ our markup, supplying real insets + coords. Symbols: `LayoutDesc::InqFullDesc`
@`0x0069A520`, `ElementDesc::Incorporate` @`0x0069B5A0`
([2026-05-08 pseudocode](../../../docs/research/2026-05-08-retail-ui-layout-resolution-pseudocode.md)).
- **`PFID_CUSTOM_RAW_JPEG`** decode + login/char-select/chargen (sub-project 4).
## 16. Acceptance criteria
- [ ] Step 0 prove-out done; real chrome sprite IDs confirmed + recorded in code.
- [ ] In `ACDREAM_RETAIL_UI=1`: a retail-shaped Vitals window renders via the wired
`UiHost``UiNineSlicePanel` 8-piece dat-sprite border + title + drawn close
button — with three `UiMeter` bars tracking HP/Stam/Mana live as the
character takes damage / regens.
- [ ] In `ACDREAM_DEVTOOLS=1`: ImGui Vitals/Chat/Debug/Settings unchanged.
- [ ] `controls.ini` loads when present, falls back cleanly when absent.
- [ ] `MarkupDocument` builds the vitals subtree from `vitals.xml`; pure parsers
unit-tested; plugin smoke test drains a buffered `AddMarkupPanel`.
- [ ] TS-30 deleted + one new IA-row added, same commit as the chrome.
- [ ] `dotnet build` green, `dotnet test` green.
- [ ] Visual verification by the user.

View file

@ -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 (14) — 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` |
| `0x10000522525` | 0 | **numbered chat tabs 14** (left strip; Normal `0x06006218`/Hi `0x06006219`) | `gmMainChatUI::RecvNotice_SetPanelVisibility @0x4ccd80` |
> **Screenshot correction (user-provided retail ground truth, 2026-06-15):** the
> four `0x10000522525` 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/(contentview)`
(`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 113 = channels carrying attr
`0x1000000B` = channel enum (1=Say, 2=Tell/Target, 3=Emote, 4=Fellowship,
5=Patron, 6=Trade, 7=Allegiance, 80xD=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<string>? 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<char,FontCharDesc?>` 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).

View file

@ -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).

View file

@ -0,0 +1,410 @@
# 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`.
### 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)
### 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.
> **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
`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<object>`, `Selected`, `NaturalButtonWidth()` | populate 14 channel `Items`; map payload↔`ChatChannelKind`; `AvailabilityProvider` |
| `UiText` | 12 | `UiChatView` | scrollable + selectable multi-color line list, clipboard, dat-font; `LinesProvider : Func<IReadOnlyList<(string,Vector4)>>`; 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 16.
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
16**; 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 16, 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 16 (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).

View file

@ -50,6 +50,11 @@
<None Include="..\..\docs\research\data\spells.csv" Link="data\spells.csv">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<!-- Phase D.2b: KSML-style panel markup assets (vitals.xml etc.) ship
next to the binary so MarkupDocument.Build can load them at runtime. -->
<None Include="UI\assets\**\*.xml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<!-- Build the smoke plugin first and copy it into plugins/AcDream.Plugins.Smoke/ -->

View file

@ -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; }
}

View file

@ -0,0 +1,27 @@
using System.Collections.Generic;
using AcDream.Plugin.Abstractions;
namespace AcDream.App.Plugins;
/// <summary>
/// Buffers plugin <see cref="IUiRegistry.AddMarkupPanel"/> calls (which run in
/// Program.cs before the GL window opens) until GameWindow drains them into the
/// UiHost tree after construction.
/// </summary>
public sealed class BufferedUiRegistry : IUiRegistry
{
public readonly record struct Pending(string MarkupPath, object Binding);
private readonly List<Pending> _pending = new();
public void AddMarkupPanel(string markupPath, object binding)
=> _pending.Add(new Pending(markupPath, binding));
/// <summary>Return + clear all buffered registrations.</summary>
public IReadOnlyList<Pending> Drain()
{
var copy = _pending.ToArray();
_pending.Clear();
return copy;
}
}

View file

@ -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

View file

@ -612,6 +612,10 @@ 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 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).
@ -862,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);
}
@ -972,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);
@ -1054,7 +1062,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;
@ -1744,6 +1753,175 @@ 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);
// 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)
{
uint t = cache.GetOrUploadRenderSurface(id, out int w, out int h);
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);
// 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!);
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)
{
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 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).");
}
else
{
Console.WriteLine("[D.2b] vitals: LayoutDesc 0x2100006C not found — vitals unavailable.");
}
// 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.ElementInfo? chatRootInfo;
AcDream.App.UI.Layout.ImportedLayout? chatLayout;
lock (_datLock)
{
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);
}
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,
vitalsDatFont, _debugFont, ResolveChrome);
if (chatController is not null)
{
// 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
// 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;
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);
// 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.");
}
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
// 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
// mandatory as of N.5 ship amendment: WbMeshAdapter + WbDrawDispatcher
// always construct.
@ -7099,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;
@ -8476,6 +8659,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
@ -12496,6 +12689,7 @@ public sealed class GameWindow : IDisposable
_sceneLightingUbo?.Dispose();
_particleRenderer?.Dispose();
_debugLines?.Dispose();
_uiHost?.Dispose();
_textRenderer?.Dispose();
_debugFont?.Dispose();
_dats?.Dispose();

View file

@ -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;
}

View file

@ -25,14 +25,39 @@ 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<float> _textBuf = new(8192);
private readonly List<float> _rectBuf = new(1024);
// 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<float> Verts = new(256); }
private readonly List<SpriteSeg> _spriteSegs = new();
private int _segUsed;
private int _textVerts;
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<float> _overlayTextBuf = new(1024);
private readonly List<float> _overlayRectBuf = new(256);
private readonly List<SpriteSeg> _overlaySpriteSegs = new();
private int _overlaySegUsed;
private int _overlayTextVerts;
private int _overlayRectVerts;
/// <summary>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.</summary>
public bool OverlayMode { get; set; }
public TextRenderer(GL gl, string shaderDir)
{
_gl = gl;
@ -56,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<byte> 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);
}
/// <summary>Begin a HUD pass. Call once per frame before any Draw* calls.</summary>
@ -64,17 +103,32 @@ public sealed unsafe class TextRenderer : IDisposable
_screenSize = screenSize;
_textBuf.Clear();
_rectBuf.Clear();
_segUsed = 0; // pool the SpriteSeg objects across frames
_textVerts = 0;
_rectVerts = 0;
_overlayTextBuf.Clear();
_overlayRectBuf.Clear();
_overlaySegUsed = 0;
_overlayTextVerts = 0;
_overlayRectVerts = 0;
OverlayMode = false;
}
/// <summary>Draw a filled rectangle in screen pixel space.</summary>
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; }
}
/// <summary>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 <see cref="DrawRect"/> — 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.</summary>
public void DrawFill(float x, float y, float w, float h, Vector4 color)
=> DrawSprite(_whiteTex, x, y, w, h, 0f, 0f, 1f, 1f, color);
/// <summary>Draw a 1-pixel-thick outline rect.</summary>
public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f)
{
@ -119,16 +173,47 @@ 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;
}
}
/// <summary>
/// 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).
/// </summary>
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 = OverlayMode
? NextSpriteSeg(_overlaySpriteSegs, ref _overlaySegUsed, texture)
: NextSpriteSeg(_spriteSegs, ref _segUsed, texture);
AppendQuad(seg.Verts, x, y, w, h, u0, v0, u1, v1, tint);
}
/// <summary>Pick the sprite segment for <paramref name="texture"/>: 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).</summary>
private static SpriteSeg NextSpriteSeg(List<SpriteSeg> 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<float> buf,
float x, float y, float w, float h,
float u0, float v0, float u1, float v1, Vector4 color)
@ -159,7 +244,9 @@ public sealed unsafe class TextRenderer : IDisposable
/// <summary>Upload + draw accumulated rects + text. font may be null if only DrawRect was used.</summary>
public void Flush(BitmapFont? font)
{
if (_textVerts == 0 && _rectVerts == 0) 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);
@ -171,36 +258,85 @@ 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);
_gl.Enable(EnableCap.Blend);
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
// Untextured rects first — they form panel backgrounds.
if (_rectVerts > 0)
// 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
// Bucket 1 (sprites) draws in SUBMISSION (painter) order via _spriteSegs,
// 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);
// 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);
}
/// <summary>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 <see cref="Flush"/>.</summary>
private void DrawLayer(
List<SpriteSeg> spriteSegs, int segUsed,
List<float> rectBuf, int rectVerts,
List<float> textBuf, int textVerts, BitmapFont? font)
{
// 1. RGBA dat sprites — one draw call per distinct GL texture.
if (segUsed > 0)
{
_shader.SetInt("uUseTexture", 0);
UploadBuffer(_rectBuf);
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_rectVerts);
_shader.SetInt("uUseTexture", 2);
_gl.ActiveTexture(TextureUnit.Texture0);
_shader.SetInt("uTex", 0);
for (int i = 0; i < segUsed; i++)
{
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));
}
}
// Textured text glyphs.
if (_textVerts > 0 && font is not null)
// 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 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.
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<float> buf)
@ -223,6 +359,7 @@ public sealed unsafe class TextRenderer : IDisposable
public void Dispose()
{
_gl.DeleteTexture(_whiteTex);
_gl.DeleteBuffer(_vbo);
_gl.DeleteVertexArray(_vao);
_shader.Dispose();

View file

@ -14,6 +14,7 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
private readonly GL _gl;
private readonly DatCollection _dats;
private readonly Dictionary<uint, uint> _handlesBySurfaceId = new();
private readonly Dictionary<uint, (int w, int h)> _sizeBySurfaceId = new();
/// <summary>
/// Composite cache for surface-with-override-origtex entries (Phase 5
/// TextureChanges). Key = (baseSurfaceId, overrideOrigTextureId),
@ -30,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<uint, uint> _handlesByRenderSurfaceId = new();
private readonly Dictionary<uint, (int w, int h)> _rsSizeById = new();
private readonly Wb.BindlessSupport? _bindless;
// Bindless / Texture2DArray parallel caches. Keys mirror the legacy three
@ -80,6 +87,65 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
return h;
}
/// <summary>
/// Like <see cref="GetOrUpload(uint)"/> but also returns the decoded
/// pixel dimensions. UI 9-slice geometry needs the source size to
/// compute slice UVs. Cached alongside the handle.
/// </summary>
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;
}
/// <summary>
/// Upload a UI sprite by its RenderSurface DataId (0x06xxxxxx), decoded
/// DIRECTLY (Portal/HighRes → DecodeRenderSurface) rather than through the
/// Surface→SurfaceTexture chain that <see cref="GetOrUpload(uint)"/> 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.
/// </summary>
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))
{
width = sz.w; height = sz.h;
return existing;
}
DecodedTexture decoded;
if (_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out var rs)
|| _dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out rs))
{
decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette: null);
}
else
{
decoded = DecodedTexture.Magenta;
}
uint h = UploadRgba8(decoded, nearest);
_handlesByRenderSurfaceId[renderSurfaceId] = h;
_rsSizeById[renderSurfaceId] = (decoded.Width, decoded.Height);
width = decoded.Width; height = decoded.Height;
return h;
}
/// <summary>
/// Alpha-channel histogram for one decoded texture. Used to diagnose
/// "why are clouds not transparent" — if cloud textures come out with
@ -476,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);
@ -493,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);

View file

@ -2044,7 +2044,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
/// Falloff×1.3) were flooding the exterior shell that retail never torch-lights.
/// The indoor "no sun" half is already handled by the global sun kill when the
/// player is inside a cell (<c>UpdateSunFromSky</c>). See the divergence register
/// (AP-37) and docs/research/2026-06-19-lighting-a7-fixD-round2-*.
/// (AP-43) and docs/research/2026-06-19-lighting-a7-fixD-round2-*.
/// </para>
/// </summary>
private void ComputeEntityLightSet(WorldEntity entity)

View file

@ -39,7 +39,9 @@ public sealed record RuntimeOptions(
bool RetailCloseDegrades,
bool DumpSceneryZ,
bool DumpLiveSpawns,
int? LegacyStreamRadius)
int? LegacyStreamRadius,
bool RetailUi,
string? AcDir)
{
/// <summary>
/// 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")));
}
/// <summary>True iff live-mode credentials are present and valid for connecting.</summary>

View file

@ -0,0 +1,65 @@
using System.Collections.Generic;
using System.Globalization;
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// Minimal reader for retail's <c>controls.ini</c> — a flat INI with one
/// <c>[section]</c> per element type. Colors are <c>#AARRGGBB</c> (alpha
/// first). Optional: a missing file yields an empty sheet (callers fall back
/// to hardcoded defaults). See the D.2b spec §7.
/// </summary>
public sealed class ControlsIni
{
private readonly Dictionary<string, Dictionary<string, string>> _sections;
private ControlsIni(Dictionary<string, Dictionary<string, string>> s) => _sections = s;
/// <summary>Load from disk; returns an empty sheet if the file is absent.</summary>
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<string, Dictionary<string, string>>(System.StringComparer.OrdinalIgnoreCase);
Dictionary<string, string>? 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<string, string>(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;
/// <summary>Parse a <c>#AARRGGBB</c> token into an RGBA <see cref="Vector4"/>.</summary>
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;
}
}

View file

@ -0,0 +1,472 @@
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;
/// <summary>
/// Binds the imported chat LayoutDesc (0x21000006) to live behavior — the acdream
/// analogue of retail <c>ChatInterface</c> + <c>gmMainChatUI::PostInit @0x4ce130</c>.
///
/// <para>
/// The transcript (<c>0x10000011</c>) is Type-12 and is built as a <see cref="UiText"/>
/// by the factory; this controller binds its live data provider in place. The input
/// (<c>0x10000016</c>) is also Type-12, so the factory builds it as an invisible
/// <see cref="UiText"/> placeholder; this controller removes that placeholder and adds
/// a <see cref="UiField"/> at the same rect. The scrollbar track (<c>0x10000012</c>) is
/// built directly as a <see cref="UiScrollbar"/> by the factory (Type 11) and bound in
/// place. The channel menu (<c>0x10000014</c>) is built as <see cref="UiMenu"/> (Type 6)
/// and bound in place.
/// </para>
/// </summary>
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 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;
private const uint InputBarId = 0x10000013u;
private const uint MenuId = 0x10000014u;
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;
// Scrollbar sprite ids from base layout 0x2100003E (confirmed in Task D).
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 = 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
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 ─────────────────────────────────────────────────────
/// <summary>Root element of the imported layout (the chat window chrome).</summary>
public UiElement Root { get; private set; } = null!;
/// <summary>Live chat transcript widget. Null until <see cref="Bind"/> succeeds.</summary>
public UiText Transcript { get; private set; } = null!;
/// <summary>Editable chat input widget. Null until <see cref="Bind"/> succeeds.</summary>
public UiField Input { get; private set; } = null!;
/// <summary>Scrollbar widget, driven by <see cref="Transcript"/>'s scroll model.</summary>
public UiScrollbar Scrollbar { get; private set; } = null!;
/// <summary>Channel-selector menu widget.</summary>
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;
/// <summary>Window height before maximize (stored to restore on un-maximize).</summary>
private float _normalHeight;
/// <summary>Window top before maximize.</summary>
private float _normalTop;
private bool _maximized;
// ── Factory ────────────────────────────────────────────────────────────
/// <summary>
/// Bind an imported chat layout to live behavior.
///
/// <paramref name="rootInfo"/> and <paramref name="layout"/> must come from the
/// SAME <see cref="LayoutImporter"/> pass (<c>ImportInfos</c> then <c>Build</c>)
/// so rects in the info tree match the widget geometry in the layout tree.
///
/// Returns <c>null</c> if the essential transcript/input panels are missing from
/// the info tree or the widget tree (e.g. the layout dat is incomplete).
/// </summary>
/// <param name="rootInfo">Full <see cref="ElementInfo"/> tree from
/// <see cref="LayoutImporter.ImportInfos"/>.</param>
/// <param name="layout">Widget tree from <see cref="LayoutImporter.Build"/>.</param>
/// <param name="vm">Chat view-model (transcript data + command routing).</param>
/// <param name="busProvider">Factory that returns the live command bus at submit time.
/// Called on every chat submit so it resolves <see cref="AcDream.UI.Abstractions.LiveCommandBus"/>
/// even when the live session is established AFTER <see cref="Bind"/> runs
/// (mirrors the ImGui <c>ChatPanel</c> which re-reads the bus each frame).</param>
/// <param name="datFont">Retail dat font for transcript + input rendering.</param>
/// <param name="debugFont">Fallback debug bitmap font (used when
/// <paramref name="datFont"/> is null).</param>
/// <param name="resolve">Dat RenderSurface id → (GL tex handle, px width, px height).
/// Forwarded to <see cref="UiScrollbar"/> and <see cref="UiMenu"/>.</param>
public static ChatWindowController? Bind(
ElementInfo rootInfo,
ImportedLayout layout,
ChatVM vm,
Func<ICommandBus> busProvider,
UiDatFont? datFont,
BitmapFont? debugFont,
Func<uint, (uint tex, int w, int h)> resolve)
{
// 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.
var transcriptPanel = layout.FindElement(TranscriptPanelId);
var inputBar = layout.FindElement(InputBarId);
if (iInfo is null || transcriptPanel is null || inputBar is null)
{
Console.WriteLine(
$"[D.2b] ChatWindowController.Bind: missing required elements " +
$"(iInfo={iInfo is not null}, " +
$"panel={transcriptPanel is not null}, bar={inputBar is not null}) — " +
$"chat window will not be interactive.");
return null;
}
// 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 ───────────────────────────────────────────────────
// 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 ────────────────────────────────────────────────────────
// 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,
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), // retail translucent unfocused field
SpriteResolve = resolve,
FocusFieldSprite = InputFocusField,
};
inputBar.AddChild(c.Input);
c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel);
// ── 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 is UiScrollbar bar)
{
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 — bind the factory-built Type-6 UiMenu ──────────
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;
// 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; }
};
c.Menu = menu;
}
// ── Send button — Enter-alternate submit trigger ──────────────────
// Retail's gmMainChatUI wires the Send button to the same ProcessCommand path.
if (layout.FindElement(SendId) is UiButton sendEl)
{
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);
}
// ── 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 onSelect = c.Menu.OnSelect;
c.Menu.OnSelect = p => { onSelect?.Invoke(p); ReflowInputRow(); };
ReflowInputRow();
}
// ── Max/min toggle — simplified gmMainChatUI::HandleMaximizeButton ──
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.OnClick = c.ToggleMaximize;
}
return c;
}
// ── Max/min implementation ─────────────────────────────────────────────
/// <summary>
/// Toggle between the normal chat window height and an expanded 320px height.
/// Simplified port of retail <c>gmMainChatUI::HandleMaximizeButton @0x4cddb0</c>:
/// retail stores the pre-maximize height and restores it on a second click.
/// The 320px expanded size is the approximate retail maximized chat height.
/// </summary>
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 ────────────────────────────────────────────────────────────
/// <summary>
/// Depth-first search for an <see cref="ElementInfo"/> node by id in the
/// raw info tree (which contains ALL elements, including the Type-12 skipped ones).
/// </summary>
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;
}
/// <summary>
/// Convert the ChatVM's detailed lines to the transcript's
/// <see cref="UiText.Line"/> record format, applying retail-faithful
/// per-<see cref="ChatKind"/> colors.
/// </summary>
private static IReadOnlyList<UiText.Line> BuildLines(
ChatVM vm, UiText view, UiDatFont? datFont, BitmapFont? debugFont)
{
var detailed = vm.RecentLinesDetailed();
if (detailed.Count == 0) return Array.Empty<UiText.Line>();
// 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<string, float> measure =
datFont is { } df ? s => df.MeasureWidth(s)
: debugFont is { } bf ? s => bf.MeasureWidth(s)
: s => s.Length * 7f;
var result = new List<UiText.Line>(detailed.Count);
foreach (var d in detailed)
{
var color = RetailChatColor(d.Kind);
foreach (var frag in WrapText(d.Text, maxW, measure))
result.Add(new UiText.Line(frag, color));
}
return result;
}
/// <summary>
/// Greedy word-wrap: split <paramref name="text"/> into fragments that each fit in
/// <paramref name="maxW"/> pixels (per <paramref name="measure"/>), breaking at spaces.
/// 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).
/// </summary>
public static IEnumerable<string> WrapText(string text, float maxW, Func<string, float> measure)
{
if (string.IsNullOrEmpty(text) || maxW <= 0f || measure(text) <= maxW)
{
yield return text ?? string.Empty;
yield break;
}
var line = new System.Text.StringBuilder();
foreach (var word in text.Split(' '))
{
string sep = line.Length > 0 ? " " : string.Empty;
if (measure(line.ToString() + sep + word) <= maxW)
{
line.Append(sep).Append(word); // fits on the current line
continue;
}
if (line.Length > 0 && measure(word) <= maxW)
{
yield return line.ToString(); // word fits alone → push to a new line
line.Clear();
line.Append(word);
continue;
}
// 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)
{
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();
}
/// <summary>
/// Per-<see cref="ChatKind"/> text color — the EXACT retail RGBA values read from a
/// live retail client via cdb (the named <c>RGBAColor</c> constants at acclient
/// 0x81c4a8+, e.g. <c>colorWhite</c>/<c>colorBrightPurple</c>/<c>colorLightBlue</c>/
/// <c>colorGreen</c>, used by <c>ChatInterface::BuildChatColorLookupTable @0x4f31c0</c>).
/// 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.
/// </summary>
private static Vector4 RetailChatColor(ChatKind kind) => kind switch
{
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)
};
}

View file

@ -0,0 +1,202 @@
using System;
using System.Linq;
using AcDream.App.UI;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Hybrid factory: behavioral element Types map to dedicated widgets (verbatim
/// algorithm ports); everything else (and unknown Types) falls back to
/// <see cref="UiDatElement"/>.
///
/// <para>
/// Type 12 = UIElement_Text — a scrollable colored-line text view. Every Type-12
/// element is now built as a <see cref="UiText"/>. Elements that carry their own
/// dat sprite media keep it as the <see cref="UiText.BackgroundSprite"/>. Pure
/// prototype elements (no state media, no controller binding) draw nothing because
/// <see cref="UiText.BackgroundColor"/> defaults to transparent.
/// </para>
///
/// <para>
/// The meter's back/front 3-slice sprite ids live on grandchild image elements,
/// NOT on the meter element itself (format doc §11). <see cref="BuildMeter"/>
/// walks two layers down to extract them: the two Type-3 container children
/// ordered by <see cref="ElementInfo.ReadOrder"/> (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.
/// </para>
///
/// <para>
/// The expand-detail overlay present in the front container carries ONLY named
/// states ("HideDetail"/"ShowDetail") — no "" DirectState entry — so the
/// <c>TryGetValue("")</c> filter in <see cref="SliceIds"/> excludes it
/// automatically.
/// </para>
/// </summary>
public static class DatWidgetFactory
{
/// <summary>
/// Creates the <see cref="UiElement"/> for <paramref name="info"/>, sets its
/// rect (Left/Top/Width/Height) and Anchors, and returns it.
/// </summary>
/// <param name="info">Resolved, merged element snapshot from the LayoutDesc importer.</param>
/// <param name="resolve">RenderSurface id → (GL tex handle, pixel width, pixel height).
/// Returns (0,0,0) when the texture is not yet uploaded.</param>
/// <param name="datFont">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.</param>
/// <returns>The widget for this element. Never null — every type produces a widget.</returns>
public static UiElement? Create(ElementInfo info,
Func<uint, (uint, int, int)> 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)
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 (incl. Type 3 chrome/containers)
};
// Propagate position + size (pixel-exact from the dat).
e.Left = info.X;
e.Top = info.Y;
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);
return e;
}
// ── Meter ────────────────────────────────────────────────────────────────
/// <summary>
/// Builds a <see cref="UiMeter"/> and populates its six 3-slice sprite ids by
/// reading the meter's grandchild image elements (format doc §11).
///
/// <para>
/// Structure the importer produces for each meter (UIElement_Meter):
/// <code>
/// 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)
/// </code>
/// </para>
///
/// <para>
/// <see cref="UiMeter.Fill"/> and <see cref="UiMeter.Label"/> are NOT set here.
/// They are bound to the live stat providers in Task 6 (VitalsController).
/// </para>
/// </summary>
private static UiMeter BuildMeter(ElementInfo info,
Func<uint, (uint, int, int)> 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 != 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]);
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;
}
/// <summary>
/// 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.
///
/// <para>
/// Children that carry ONLY named states (e.g. the expand-detail overlay with
/// "ShowDetail"/"HideDetail" entries but no "" key) are excluded automatically
/// because <see cref="Dictionary{TKey,TValue}.TryGetValue"/> for "" returns
/// false.
/// </para>
/// </summary>
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.
// 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)
.Select(c => (c.X, File: c.StateMedia[""].File))
.OrderBy(t => t.X)
.ToList();
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);
}
// ── Text ─────────────────────────────────────────────────────────────────
/// <summary>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 <see cref="UiText.BackgroundColor"/> defaults to transparent.</summary>
private static UiText BuildText(ElementInfo info, Func<uint, (uint, int, int)> 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 };
}
}

View file

@ -0,0 +1,170 @@
using System.Collections.Generic;
namespace AcDream.App.UI.Layout;
/// <summary>
/// GL-free, dat-free snapshot of a resolved layout element.
/// Populated by the LayoutDesc importer from <c>DatReaderWriter.ElementDesc</c>
/// after inheritance is applied. The pure transforms on <see cref="ElementReader"/>
/// operate on this type so they can be unit-tested without the dats or OpenGL.
///
/// IMPORTANT: Tasks 36 depend on this shape exactly. Do not add members without
/// updating the plan spec and downstream consumers.
/// </summary>
public sealed class ElementInfo
{
/// <summary>Dat element id (e.g. <c>0x100000E6</c>).</summary>
public uint Id;
/// <summary>
/// Raw element class id as a uint.
/// Game-specific ids like <c>0x1000004D</c> (gmVitalsUI root) and <c>0x10000009</c>
/// overflow <c>int</c> when treated as signed, so this stays <c>uint</c>.
/// Known values: 0=text, 2=dragbar, 3=container/chrome, 7=meter,
/// 9=resize-grip, 12=style-prototype (skip), 0x10000009/0x1000004D=window root.
/// </summary>
public uint Type;
/// <summary>Position and size within the parent, in pixels (cast from dat uint fields).</summary>
public float X, Y, Width, Height;
/// <summary>
/// Raw edge-anchor flag values from the dat (<c>LeftEdge</c>, <c>TopEdge</c>,
/// <c>RightEdge</c>, <c>BottomEdge</c> fields of <c>ElementDesc</c>).
/// Values 04; map to <see cref="AnchorEdges"/> bit-flags via
/// <see cref="ElementReader.ToAnchors"/>.
/// </summary>
public uint Left, Top, Right, Bottom;
/// <summary>Draw order within the parent (lower = drawn first / behind).</summary>
public uint ReadOrder;
/// <summary>
/// Font dat object id inherited from the base element's <c>Properties[0x1A]</c>
/// (<c>ArrayBaseProperty → DataIdBaseProperty</c>). 0 = none / not inherited.
/// </summary>
public uint FontDid;
/// <summary>
/// Sprite per state: state name → (RenderSurface file id, DrawMode int).
/// The <c>""</c> key represents the unnamed DirectState (<c>ElementDesc.StateDesc</c>).
/// Named states use the <c>UIStateId.ToString()</c> value as the key
/// (e.g. <c>"HideDetail"</c>, <c>"ShowDetail"</c>).
/// </summary>
public Dictionary<string, (uint File, int DrawMode)> StateMedia = new();
/// <summary>
/// The element's initial active state name, taken from <c>ElementDesc.DefaultState.ToString()</c>.
/// Normalized to <c>""</c> when the dat carries Undef/Undefined/0 (no default set).
/// Used by <see cref="UiDatElement"/> to pick which state's sprite to render initially.
/// Examples: <c>"Normal"</c> (Send button), <c>"Minimized"</c> (max/min button), <c>""</c> (DirectState).
/// </summary>
public string DefaultStateName = "";
/// <summary>
/// Resolved child elements (populated by the importer in Task 5).
/// Children come from the derived element's own tree, not the base element's.
/// </summary>
public List<ElementInfo> Children = new();
}
/// <summary>
/// Pure, GL-free, dat-free transforms for the LayoutDesc importer.
/// All methods are static and operate on <see cref="ElementInfo"/> POCOs.
/// No OpenGL, no DatReaderWriter types, no rendering dependencies beyond
/// the <see cref="AnchorEdges"/> bit-flag enum from <c>AcDream.App.UI</c>.
/// </summary>
public static class ElementReader
{
/// <summary>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).</summary>
/// <param name="left">LeftEdge dat field value (04).</param>
/// <param name="top">TopEdge dat field value (04).</param>
/// <param name="right">RightEdge dat field value (04).</param>
/// <param name="bottom">BottomEdge dat field value (04).</param>
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 (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;
}
/// <summary>
/// Merges a base element snapshot with a derived element snapshot, mirroring
/// the <c>BaseElement</c> / <c>BaseLayoutId</c> inheritance chain in the dat.
///
/// <para>
/// Rules:
/// <list type="bullet">
/// <item><description>
/// Scalar fields (<see cref="ElementInfo.Id"/>, <see cref="ElementInfo.Type"/>,
/// <see cref="ElementInfo.Width"/>, <see cref="ElementInfo.Height"/>,
/// <see cref="ElementInfo.FontDid"/>): derived wins if non-zero; otherwise
/// inherited from base.
/// </description></item>
/// <item><description>
/// Position (<see cref="ElementInfo.X"/>, <see cref="ElementInfo.Y"/>) and
/// edge flags (<see cref="ElementInfo.Left"/> etc.) and
/// <see cref="ElementInfo.ReadOrder"/>: always taken from the derived element
/// (derived placement, not the base prototype's geometry).
/// </description></item>
/// <item><description>
/// <see cref="ElementInfo.StateMedia"/>: base entries are the default; derived
/// entries override (or add) per state name key.
/// </description></item>
/// <item><description>
/// <see cref="ElementInfo.Children"/>: come from the derived element's own tree only.
/// </description></item>
/// </list>
/// </para>
/// </summary>
public static ElementInfo Merge(ElementInfo base_, ElementInfo derived)
{
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,
// 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,
Top = derived.Top,
Right = derived.Right,
Bottom = derived.Bottom,
ReadOrder = derived.ReadOrder,
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
// populated by the recursive importer BEFORE Merge is called and never mutated after).
Children = new List<ElementInfo>(derived.Children),
};
// Start with base StateMedia as defaults, then let derived entries override.
m.StateMedia = new Dictionary<string, (uint, int)>(base_.StateMedia);
foreach (var kv in derived.StateMedia)
m.StateMedia[kv.Key] = kv.Value;
return m;
}
}

View file

@ -0,0 +1,299 @@
using System;
using System.Collections.Generic;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.App.UI.Layout;
/// <summary>
/// The result of importing a retail LayoutDesc: a <see cref="UiElement"/> tree with
/// an O(1) lookup table for finding any element by its dat id.
/// </summary>
public sealed class ImportedLayout
{
/// <summary>Root widget of the imported tree.</summary>
public UiElement Root { get; }
private readonly Dictionary<uint, UiElement> _byId;
public ImportedLayout(UiElement root, Dictionary<uint, UiElement> byId)
{
Root = root;
_byId = byId;
}
/// <summary>Find a widget by its dat element id (e.g. <c>0x100000E6</c>).
/// Returns null if the id was skipped (Type-12 prototype) or not present.</summary>
public UiElement? FindElement(uint id)
=> _byId.TryGetValue(id, out var e) ? e : null;
}
/// <summary>
/// Two-layer layout importer for retail LayoutDesc dat objects.
///
/// <para>
/// <strong>Pure layer</strong> (<see cref="Build"/> / <see cref="BuildFromInfos"/>):
/// converts a pre-resolved <see cref="ElementInfo"/> tree into a <see cref="UiElement"/>
/// tree via <see cref="DatWidgetFactory"/>. Testable without dats or OpenGL — all tests
/// in <c>LayoutImporterTests.cs</c> exercise this layer only.
/// </para>
///
/// <para>
/// <strong>Dat shell</strong> (<see cref="Import"/>): reads a <see cref="LayoutDesc"/>,
/// converts each top-level <see cref="ElementDesc"/> to a fully resolved
/// <see cref="ElementInfo"/> (applying <c>BaseElement</c> / <c>BaseLayoutId</c>
/// inheritance with a cycle guard), then delegates to <see cref="Build"/>.
/// </para>
///
/// <para>
/// Meter elements (Type 7) consume their own dat-children: <see cref="DatWidgetFactory"/>
/// reads the grandchild slice-sprite ids during <see cref="UiMeter"/> construction, so the
/// children must NOT be added as separate <see cref="UiElement"/> nodes in the tree.
/// Every other element type recurses its children generically.
/// </para>
/// </summary>
public static class LayoutImporter
{
// ── Pure layer ────────────────────────────────────────────────────────────
/// <summary>
/// Convenience for tests: attach <paramref name="children"/> to
/// <paramref name="rootInfo"/>, then call <see cref="Build"/>.
/// The children list is set directly on <paramref name="rootInfo"/>;
/// any existing children are replaced.
/// </summary>
public static ImportedLayout BuildFromInfos(
ElementInfo rootInfo,
IEnumerable<ElementInfo> children,
Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont)
{
rootInfo.Children = new List<ElementInfo>(children);
return Build(rootInfo, resolve, datFont);
}
/// <summary>
/// Pure builder: produce the widget tree from a fully resolved
/// <see cref="ElementInfo"/> tree (children already attached).
/// </summary>
public static ImportedLayout Build(
ElementInfo rootInfo,
Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont)
{
var byId = new Dictionary<uint, UiElement>();
// 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);
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);
}
private static UiElement? BuildWidget(
ElementInfo info,
Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont,
Dictionary<uint, UiElement> 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;
// 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)
{
var cw = BuildWidget(child, resolve, datFont, byId);
if (cw is not null) w.AddChild(cw);
}
}
return w;
}
// ── Dat shell ─────────────────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
/// <param name="dats">The dat collection to read the LayoutDesc from.</param>
/// <param name="layoutId">The LayoutDesc dat id to read.</param>
public static ElementInfo? ImportInfos(DatCollection dats, uint layoutId)
{
var ld = dats.Get<LayoutDesc>(layoutId);
if (ld is null) return null;
var tops = new List<ElementInfo>();
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 };
}
/// <summary>
/// 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.
/// </summary>
public static ImportedLayout? Import(
DatCollection dats,
uint layoutId,
Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont)
{
var rootInfo = ImportInfos(dats, layoutId);
if (rootInfo is null) return null;
return Build(rootInfo, resolve, datFont);
}
// ── Inheritance resolution ────────────────────────────────────────────────
/// <summary>
/// Converts an <see cref="ElementDesc"/> to a resolved <see cref="ElementInfo"/>:
/// reads own fields + media, applies the BaseElement / BaseLayoutId chain
/// (cycle-guarded by <paramref name="baseChain"/>), then resolves + attaches children.
/// </summary>
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<LayoutDesc>(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;
}
/// <summary>
/// Read an <see cref="ElementDesc"/>'s own scalar fields + state media into a
/// fresh <see cref="ElementInfo"/>. No inheritance is applied; children are not
/// attached (the caller handles those).
/// </summary>
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,
DefaultStateName = (defState is "Undef" or "Undefined" or "0") ? "" : defState,
};
// 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;
}
/// <summary>
/// Read the first <see cref="MediaDescImage"/> from <paramref name="sd"/> into
/// <c>info.StateMedia[name]</c> and extract the font DID from property 0x1A
/// (<c>ArrayBaseProperty → DataIdBaseProperty</c>) if not yet set.
/// </summary>
private static void ReadState(StateDesc sd, string name, ElementInfo info)
{
// 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)
{
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 ───────────────────────────────────────────────────
/// <summary>
/// Find an <see cref="ElementDesc"/> by id anywhere in the top-level tree of
/// <paramref name="ld"/> (depth-first). Returns null if not found.
/// </summary>
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;
}
}

View file

@ -0,0 +1,122 @@
using System;
using System.Numerics;
namespace AcDream.App.UI.Layout;
/// <summary>
/// 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".
///
/// <para>
/// 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.
/// </para>
///
/// <para>
/// DrawModeType (DatReaderWriter.Enums), stored as int in <see cref="ElementInfo"/> to
/// keep this dat-free. See docs/research/2026-06-15-layoutdesc-format.md §6:
/// <c>Undefined=0, Normal=1, Overlay=2, Alphablend=3</c>. There is no Stretch mode.
/// </para>
///
/// <para>
/// Tiling uses UV-repeat on BOTH axes (<c>Width/tw</c>, <c>Height/th</c>) so vertical
/// chrome edges (e.g. a 5×10 sprite drawn over a 5×48 rect) tile vertically too.
/// <see cref="AcDream.App.Rendering.TextureCache.UploadRgba8"/> sets
/// <c>GL_REPEAT</c> on both S and T, so vertical tiling is always active.
/// </para>
/// </summary>
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<uint, (uint tex, int w, int h)> _resolve;
/// <summary>Which state name to render. <c>""</c> = the unnamed DirectState.
/// Falls back to DirectState if the named state is absent.</summary>
public string ActiveState { get; set; } = "";
/// <param name="info">Merged <see cref="ElementInfo"/> for this element.</param>
/// <param name="resolve">Dat file-id → (GL texture handle, native px width, native px height).
/// Returns (0,0,0) when the texture is not yet uploaded.</param>
public UiDatElement(ElementInfo info, Func<uint, (uint tex, int w, int h)> resolve)
{
_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)
}
/// <summary>
/// Returns the (File, DrawMode) for the current <see cref="ActiveState"/>,
/// falling back to the DirectState (<c>""</c> key) if the named state is absent.
/// Returns (0, 0) if neither exists.
/// </summary>
// 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
: (0u, 0);
/// <summary>Optional click handler. Set by a controller for interactive dat
/// elements (e.g. the chat Send / max-min buttons). Requires
/// <see cref="UiElement.ClickThrough"/> = false to receive click events.</summary>
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;
}
/// <summary>Optional centered text label drawn over the sprite (e.g. the "Send"
/// button face whose dat sprite is a blank frame). Null = sprite only.</summary>
public string? Label { get; set; }
/// <summary>Dat font for <see cref="Label"/>. Required for the label to draw.</summary>
public UiDatFont? LabelFont { get; set; }
/// <summary>Label color (default white).</summary>
public Vector4 LabelColor { get; set; } = Vector4.One;
protected override void OnDraw(UiRenderContext ctx)
{
var (file, _) = ActiveMedia();
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);
}
}
// 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);
}
}
}

View file

@ -0,0 +1,98 @@
using System;
using System.Numerics;
using AcDream.App.UI;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Per-window controller for the vitals layout (LayoutDesc 0x2100006C).
/// Mirrors retail <c>gmVitalsUI::PostInit</c>: 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.
///
/// <para>The slice sprites + dat font on each <see cref="UiMeter"/> are already
/// set by <see cref="DatWidgetFactory"/> during tree construction; this controller
/// only binds the dynamic vitals data. Do not touch meter rendering fields here.</para>
///
/// <para>Element ids confirmed from
/// <c>docs/research/2026-06-15-layoutdesc-format.md §11</c>
/// (vitals window 0x2100006C dump).</para>
/// </summary>
public static class VitalsController
{
/// <summary>Dat element id for the Health meter (0x100000E6).</summary>
public const uint Health = 0x100000E6;
/// <summary>Dat element id for the Stamina meter (0x100000EC).</summary>
public const uint Stamina = 0x100000EC;
/// <summary>Dat element id for the Mana meter (0x100000EE).</summary>
public const uint Mana = 0x100000EE;
/// <summary>
/// Bind live vitals data providers to the Health, Stamina, and Mana meter
/// elements found in <paramref name="layout"/>. Any meter whose id is absent
/// from the layout is silently skipped — partial layouts (e.g. test fakes)
/// do not cause errors.
/// </summary>
/// <param name="layout">Imported vitals layout tree.</param>
/// <param name="healthPct">Provider returning Health fill fraction [0..1].</param>
/// <param name="staminaPct">Provider returning Stamina fill fraction [0..1].</param>
/// <param name="manaPct">Provider returning Mana fill fraction [0..1].</param>
/// <param name="healthText">Provider returning Health "cur/max" overlay text.</param>
/// <param name="staminaText">Provider returning Stamina "cur/max" overlay text.</param>
/// <param name="manaText">Provider returning Mana "cur/max" overlay text.</param>
public static void Bind(
ImportedLayout layout,
Func<float> healthPct,
Func<float> staminaPct,
Func<float> manaPct,
Func<string> healthText,
Func<string> staminaText,
Func<string> manaText)
{
BindMeter(layout, Health, healthPct, healthText);
BindMeter(layout, Stamina, staminaPct, staminaText);
BindMeter(layout, Mana, manaPct, manaText);
}
/// <summary>White cur/max numbers — matches the former <c>UiMeter.LabelColor</c> default.</summary>
private static readonly Vector4 NumberColor = new(1f, 1f, 1f, 1f);
private static void BindMeter(
ImportedLayout layout, uint id,
Func<float> pct,
Func<string> text)
{
// 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
{
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<UiText.Line>()
: new[] { new UiText.Line(s, NumberColor) };
},
};
m.AddChild(number);
}
}

View file

@ -0,0 +1,159 @@
using System;
using System.Globalization;
using System.Numerics;
using System.Reflection;
using System.Xml.Linq;
namespace AcDream.App.UI;
/// <summary>
/// Parses our KSML-style panel markup (mirrors retail's ElementDesc fields)
/// into a live <see cref="UiElement"/> subtree. <c>{Binding}</c> 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.
/// </summary>
public static class MarkupDocument
{
/// <param name="xml">Raw XML markup for a single panel.</param>
/// <param name="binding">Object whose public properties are bound to <c>{PropName}</c> attributes.</param>
/// <param name="resolve">Surface id → (GL handle, width, height) for chrome sprites.</param>
/// <param name="style">Optional controls.ini stylesheet for the title color.</param>
public static UiNineSlicePanel Build(
string xml, object binding, Func<uint, (uint, int, int)> 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 <panel>, 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"),
};
// 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))
{
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,
Anchors = Anchor((string?)el.Attribute("anchor")),
SpriteResolve = resolve,
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
}
}
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;
/// <summary>
/// Parses <c>#AARRGGBB</c> → RGBA <see cref="Vector4"/> (alpha first, matching
/// controls.ini convention). Falls back to opaque white on bad input.
/// </summary>
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<float?> 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<uint?> 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]);
}
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;
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;
}
}

View file

@ -0,0 +1,66 @@
namespace AcDream.App.UI;
/// <summary>
/// Retail window-chrome RenderSurface DataIds, CONFIRMED via the D.2b Step-0
/// prove-out (2026-06-14). These are RenderSurface objects (0x06xxxxxx) decoded
/// DIRECTLY (<see cref="Rendering.TextureCache.GetOrUploadRenderSurface"/>), NOT
/// through the Surface→SurfaceTexture chain.
///
/// <para>
/// The universal floating-window bevel is an <b>8-piece border</b> (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).
/// </para>
///
/// <para>
/// 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.
/// </para>
/// </summary>
public static class RetailChromeSprites
{
/// <summary>Tiled interior fill — the shared panel background (48×48).</summary>
public const uint CenterFill = 0x06004CC2;
/// <summary>Horizontal top edge (10×5, tiled across the top span).</summary>
public const uint TopEdge = 0x060074BF;
/// <summary>Horizontal bottom edge (10×5).</summary>
public const uint BottomEdge = 0x060074C1;
/// <summary>Vertical left edge (5×10).</summary>
public const uint LeftEdge = 0x060074C0;
/// <summary>Vertical right edge (5×10).</summary>
public const uint RightEdge = 0x060074C2;
/// <summary>Top-left corner (5×5).</summary>
public const uint CornerTL = 0x060074C3;
/// <summary>Top-right corner (5×5).</summary>
public const uint CornerTR = 0x060074C4;
/// <summary>Bottom-left corner (5×5).</summary>
public const uint CornerBL = 0x060074C5;
/// <summary>Bottom-right corner (5×5).</summary>
public const uint CornerBR = 0x060074C6;
/// <summary>Border thickness in pixels = the corner/edge sprite size (5px).</summary>
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 0x1000063B0x10000642): 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.
/// <summary>Corner grip stud, all four corners (5×5).</summary>
public const uint GripCorner = 0x06006129;
/// <summary>Top edge grip (10×5, tiled across).</summary>
public const uint GripTop = 0x0600612A;
/// <summary>Left edge grip (5×10, tiled down).</summary>
public const uint GripLeft = 0x0600612B;
/// <summary>Bottom edge grip (10×5).</summary>
public const uint GripBottom = 0x0600612C;
/// <summary>Right edge grip (5×10).</summary>
public const uint GripRight = 0x0600612D;
}

View file

@ -0,0 +1,115 @@
using System;
using System.Numerics;
using AcDream.App.UI.Layout;
namespace AcDream.App.UI;
/// <summary>
/// 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).
///
/// <para>
/// Draws per-state sprite media exactly like <see cref="UiDatElement"/> (same
/// <c>ActiveState</c> defaulting, same <c>ActiveMedia()</c> fallback chain, same tiled
/// <c>DrawSprite</c> call with UV-repeat so chrome edges tile correctly) plus an
/// optional centered text label. The click behavior mirrors <see cref="UiDatElement"/>
/// one-for-one so the chat Send and Max/Min buttons that previously bound through
/// <c>UiDatElement.OnClick</c> continue to work without behavioral change.
/// </para>
///
/// <para>
/// State selection: picks <see cref="ElementInfo.DefaultStateName"/> if set, then
/// "Normal" if the element has a Normal state sprite, then falls back to the unnamed
/// DirectState ("" key) — identical to <see cref="UiDatElement"/>.
/// </para>
///
/// <para>
/// Built by <see cref="DatWidgetFactory"/> for Type-1 elements (chat Send 0x10000019,
/// Max/Min 0x1000046F). NOT the same as <see cref="UiSimpleButton"/>, which is an
/// earlier dev-scaffold widget with no dat sprites.
/// </para>
/// </summary>
public sealed class UiButton : UiElement
{
private readonly ElementInfo _info;
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
/// <summary>Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize).</summary>
public Action? OnClick { get; set; }
/// <summary>Optional centered text label drawn over the sprite (e.g. "Send" on a blank gold frame).</summary>
public string? Label { get; set; }
/// <summary>Dat font for <see cref="Label"/>. Required for the label to draw.</summary>
public UiDatFont? LabelFont { get; set; }
/// <summary>Label color (default white).</summary>
public Vector4 LabelColor { get; set; } = Vector4.One;
/// <summary>
/// Active state name, runtime-settable (e.g. Max/Min toggling Normal ↔ Minimized).
/// Matches <see cref="UiDatElement.ActiveState"/>.
/// </summary>
public string ActiveState { get; set; } = "";
/// <param name="info">Merged <see cref="ElementInfo"/> for this element.</param>
/// <param name="resolve">Dat file-id → (GL texture handle, native px width, native px height).
/// Returns (0,0,0) when the texture is not yet uploaded.</param>
public UiButton(ElementInfo info, Func<uint, (uint tex, int w, int h)> 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)
}
/// <summary>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.</summary>
public override bool ConsumesDatChildren => true;
/// <summary>
/// Returns the File id for the current <see cref="ActiveState"/>, falling back to
/// the DirectState ("" key) if the named state is absent.
/// Returns 0 if neither exists.
/// Mirrors <see cref="UiDatElement.ActiveMedia()"/>.
/// </summary>
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;
}
}

View file

@ -0,0 +1,162 @@
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;
/// <summary>
/// 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 <see cref="UiRenderContext.DrawStringDat"/> can blit each glyph
/// as two textured quads exactly the way the retail client does.
///
/// <para>
/// Retail render model — <c>SurfaceWindow::DrawCharacter</c>
/// (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
/// <c>HorizontalOffsetBefore + Width + HorizontalOffsetAfter</c> (the function's
/// return value, accumulated by the string loop at 0x00467ed4
/// <c>edi_3 += var_98</c>), and each glyph is drawn starting at
/// <c>penX + HorizontalOffsetBefore</c>.
/// </para>
///
/// <para>
/// Atlas format: the foreground atlas (0x06005EE5 for Font 0x40000000) is
/// PFID_A8 — alpha-only. Our <c>SurfaceDecoder</c> expands A8 to RGBA as
/// (255,255,255, alpha). The UI sprite shader path (ui_text.frag,
/// <c>uUseTexture==2</c>) MULTIPLIES the sampled texel by the per-vertex tint
/// (<c>texture(uTex,vUv) * vColor</c>), 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.
/// </para>
/// </summary>
public sealed class UiDatFont
{
/// <summary>Retail UI font id (Latin-1, 16x16 max, with outline atlas).</summary>
public const uint DefaultFontId = 0x40000000u;
/// <summary>Foreground (glyph pixels) GL texture handle + atlas pixel size.</summary>
public uint ForegroundTexture { get; }
public int ForegroundWidth { get; }
public int ForegroundHeight { get; }
/// <summary>Background (outline/shadow) GL texture handle + atlas pixel size.
/// 0 when the font has no background atlas (then the outline pass is skipped).</summary>
public uint BackgroundTexture { get; }
public int BackgroundWidth { get; }
public int BackgroundHeight { get; }
/// <summary>Vertical advance between lines (retail MaxCharHeight).</summary>
public float LineHeight { get; }
/// <summary>Distance from a line's top to its baseline (retail BaselineOffset).</summary>
public float BaselineOffset { get; }
private readonly Dictionary<char, FontCharDesc> _glyphs;
private UiDatFont(
uint fgTex, int fgW, int fgH,
uint bgTex, int bgW, int bgH,
float lineHeight, float baselineOffset,
Dictionary<char, FontCharDesc> glyphs)
{
ForegroundTexture = fgTex; ForegroundWidth = fgW; ForegroundHeight = fgH;
BackgroundTexture = bgTex; BackgroundWidth = bgW; BackgroundHeight = bgH;
LineHeight = lineHeight;
BaselineOffset = baselineOffset;
_glyphs = glyphs;
}
/// <summary>True if this font carries a separate outline/shadow atlas
/// (retail's <c>m_pBackgroundSurface</c>). When false the outline pass is
/// skipped and only the foreground (fill) glyphs are drawn.</summary>
public bool HasBackground => BackgroundTexture != 0;
/// <summary>Look up a glyph descriptor for a character. Returns false for
/// characters not present in the font's table (callers skip them).</summary>
public bool TryGetGlyph(char c, out FontCharDesc glyph) => _glyphs.TryGetValue(c, out glyph!);
/// <summary>
/// Load Font <paramref name="fontId"/> 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.
/// </summary>
public static UiDatFont? Load(DatCollection dats, TextureCache cache, uint fontId = DefaultFontId)
{
ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(cache);
if (!dats.TryGet<Font>(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;
// 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, 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
// 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<char, FontCharDesc>(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);
}
/// <summary>
/// Total pen advance (in pixels) for <paramref name="text"/>, summing each
/// glyph's retail advance. Characters not in the font contribute nothing.
/// </summary>
public float MeasureWidth(string text)
=> MeasureWidth(text, c => _glyphs.TryGetValue(c, out var g) ? g : null);
/// <summary>
/// Pure pen-advance summation seam: total width of <paramref name="text"/>
/// given a <paramref name="lookup"/> 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.
/// </summary>
public static float MeasureWidth(string? text, Func<char, FontCharDesc?> 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;
}
/// <summary>
/// The retail per-glyph horizontal advance:
/// <c>HorizontalOffsetBefore + Width + HorizontalOffsetAfter</c>. This is the
/// value <c>SurfaceWindow::DrawCharacter</c> 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.
/// </summary>
public static float GlyphAdvance(FontCharDesc g)
=> g.HorizontalOffsetBefore + g.Width + g.HorizontalOffsetAfter;
}

View file

@ -4,6 +4,11 @@ using System.Numerics;
namespace AcDream.App.UI;
/// <summary>Which parent edges a child keeps a fixed margin to on resize.
/// Left+Right ⇒ width stretches; Top+Bottom ⇒ height stretches.</summary>
[System.Flags]
public enum AnchorEdges { None = 0, Left = 1, Top = 2, Right = 4, Bottom = 8 }
/// <summary>
/// Base class for every UI widget in the retained-mode tree.
///
@ -88,6 +93,39 @@ public abstract class UiElement
/// <summary>Painter's-algorithm z-order within siblings. Higher = on top.</summary>
public int ZOrder { get; set; }
/// <summary>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.</summary>
public float Opacity { get; set; } = 1f;
/// <summary>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).</summary>
public bool Draggable { get; set; }
/// <summary>If true, a left-drag starting near this element's edge/corner
/// resizes it (window resize). Intended for top-level panels.</summary>
public bool Resizable { get; set; }
/// <summary>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.</summary>
public bool CapturesPointerDrag { get; set; }
/// <summary>Minimum size enforced while resizing.</summary>
public float MinWidth { get; set; } = 40f;
public float MinHeight { get; set; } = 40f;
/// <summary>Allow horizontal (width) resize. Ignored unless <see cref="Resizable"/>.</summary>
public bool ResizeX { get; set; } = true;
/// <summary>Allow vertical (height) resize. Ignored unless <see cref="Resizable"/>.</summary>
public bool ResizeY { get; set; } = true;
/// <summary>Edges this element anchors to in its parent. Default Left|Top
/// (pinned top-left, fixed size — no reflow). Left|Right stretches width.</summary>
public AnchorEdges Anchors { get; set; } = AnchorEdges.Left | AnchorEdges.Top;
// ── Tree structure ──────────────────────────────────────────────────
public UiElement? Parent { get; private set; }
@ -108,6 +146,19 @@ public abstract class UiElement
return true;
}
/// <summary>
/// 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 <see cref="AcDream.App.UI.Layout.LayoutImporter"/> 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 <c>true</c>; the generic container
/// (<see cref="AcDream.App.UI.Layout.UiDatElement"/>) and panels return <c>false</c>
/// and recurse their children normally. Mirrors retail, where each
/// <c>UIElement_X::DrawSelf</c> owns its internal structure.
/// </summary>
public virtual bool ConsumesDatChildren => false;
// ── Virtual overrides ───────────────────────────────────────────────
/// <summary>
@ -116,6 +167,16 @@ public abstract class UiElement
/// </summary>
protected virtual void OnDraw(UiRenderContext ctx) { }
/// <summary>
/// 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 <see cref="OnDraw"/> 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.
/// </summary>
protected virtual void OnDrawOverlay(UiRenderContext ctx) { }
/// <summary>Per-frame tick (animations, timers, caret blink).</summary>
protected virtual void OnTick(double deltaSeconds) { }
@ -146,12 +207,18 @@ 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);
// 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)
{
@ -164,6 +231,35 @@ public abstract class UiElement
}
finally
{
ctx.PopAlpha();
ctx.PopTransform();
}
}
/// <summary>Second draw traversal: re-walks the tree applying the same
/// transform/alpha as <see cref="DrawSelfAndChildren"/> and calls
/// <see cref="OnDrawOverlay"/> 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).</summary>
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();
}
}
@ -183,9 +279,14 @@ public abstract class UiElement
/// </summary>
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();
@ -198,6 +299,70 @@ public abstract class UiElement
}
}
if (ClickThrough) return null;
return OnHitTest(localX, localY) ? this : null;
}
// ── Anchor layout ────────────────────────────────────────────────────
private bool _anchorCaptured;
private float _amL, _amT, _amR, _amB, _aw0, _ah0;
/// <summary>Reposition/resize this element per <see cref="Anchors"/>, keeping
/// the margins captured (at first layout / design size) to each anchored edge.
/// Called by the parent each frame before drawing children.</summary>
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;
}
/// <summary>Forget the captured anchor margins so the next <see cref="ApplyAnchor"/>
/// 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.</summary>
internal void ResetAnchorCapture() => _anchorCaptured = false;
/// <summary>Walk up to the owning <see cref="UiRoot"/> (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.</summary>
internal UiRoot? FindRoot()
{
UiElement e = this;
while (e.Parent is not null) e = e.Parent;
return e as UiRoot;
}
/// <summary>Compute an anchored child rect. Left&amp;Right ⇒ stretch width
/// (keep both margins); Right only ⇒ pin to right at fixed width; otherwise
/// pin left at fixed width. Same logic vertically.</summary>
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);
}
}

View file

@ -0,0 +1,420 @@
using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// Generic editable one-line field widget. Port of retail <c>UIElement_Field</c>
/// (<c>RegisterElementClass(3)</c> @ acclient_2013_pseudo_c.txt:126190). Carries
/// retail <c>Field</c>'s drag-drop hooks (<c>CatchDroppedItem</c>/<c>MouseOverTop</c>)
/// as stubs for future item-window use.
///
/// <para>
/// 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 <see cref="OnSubmit"/>, clears, and pushes history (100-entry cap,
/// sentinel 0xFFFFFFFF — port of <c>ChatInterface::ProcessCommand @0x4f5100</c>).
/// </para>
///
/// Decomp: UIElement_Text MoveCursor @0x468d00, FindPixelsFromPos @0x472b40.
/// </summary>
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, 0f);
/// <summary>Selected-span highlight (translucent blue, behind the text).</summary>
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;
/// <summary>Keyboard device for clipboard (Ctrl+C/X/V) + modifier state (Ctrl/Shift).
/// Wired by the host from <see cref="UiHost.Keyboard"/>.</summary>
public Silk.NET.Input.IKeyboard? Keyboard { get; set; }
/// <summary>Dat sprite resolver (id → GL texture + size) for the focused-field
/// background. Null = fall back to the flat <see cref="BackgroundColor"/> rect.</summary>
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
/// <summary>Gold "lit" field background drawn when focused (retail Normal_focussed
/// state, RenderSurface 0x060011AB). 0 = no focus sprite.</summary>
public uint FocusFieldSprite { get; set; }
public Action<string>? 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;
private readonly List<string> _history = new();
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 UiField()
{
AcceptsFocus = true;
IsEditControl = true;
CapturesPointerDrag = true; // interior drag selects, doesn't move the window
}
/// <summary>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.</summary>
public override bool ConsumesDatChildren => true;
// ── 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++;
_historyIndex = -1;
}
public void Backspace()
{
if (DeleteSelection()) return;
if (_caret == 0) return;
_text = _text.Remove(_caret - 1, 1);
_caret--;
}
public void DeleteForward()
{
if (DeleteSelection()) return;
if (_caret >= _text.Length) return;
_text = _text.Remove(_caret, 1);
}
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;
}
/// <summary>Move the caret left (negative) or right (positive) by <paramref name="delta"/>
/// glyph positions without extending a selection. Public for test access.</summary>
public void MoveCaret(int delta) => MoveCaretTo(_caret + delta, false);
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) : "";
}
/// <summary>Remove the selected span (if any). Returns true if it removed anything.</summary>
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()
{
var t = _text;
if (t.Trim().Length == 0) { Clear(); return; }
OnSubmit?.Invoke(t);
PushHistory(t);
Clear();
}
private void Clear() { _text = ""; _caret = 0; _selAnchor = null; _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;
_selAnchor = null;
}
// ── Geometry ─────────────────────────────────────────────────────────
/// <summary>Pixel-X of the caret (Σ glyph advances to <paramref name="i"/>).</summary>
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);
/// <summary>Map a local X (click) to the nearest caret index — retail
/// FindPixelsFromPos inverse. Accounts for the horizontal scroll offset.</summary>
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)
{
// 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;
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)
{
// 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;
_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();
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;
}
}
return false;
}
}

View file

@ -39,6 +39,13 @@ public sealed class UiHost : System.IDisposable
public UiRoot Root { get; } = new();
public TextRenderer TextRenderer { get; }
public BitmapFont? DefaultFont { get; set; }
/// <summary>The last wired keyboard. Exposed so widgets that need clipboard
/// access (<see cref="IKeyboard.ClipboardText"/>) or modifier-key state
/// (<see cref="IKeyboard.IsKeyPressed"/>) — e.g. <see cref="UiText"/>'s
/// Ctrl+C copy — can reach the device. One-keyboard desktop: last wins.</summary>
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);

View file

@ -0,0 +1,246 @@
using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// Generic dropdown menu. Ports retail <c>UIElement_Menu</c>
/// (<c>RegisterElementClass(6) @ acclient_2013_pseudo_c.txt:120163</c>) +
/// <c>UIElement_Menu::MakePopup @0x46d310</c>: 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 <see cref="AcDream.App.UI.Layout.DatWidgetFactory"/> for Type-6 elements.
/// </summary>
public sealed class UiMenu : UiElement
{
/// <summary>One menu row: its label + an opaque payload the controller maps back.</summary>
public readonly record struct MenuItem(string Label, object? Payload);
/// <summary>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.</summary>
public IReadOnlyList<MenuItem> Items { get; set; } = System.Array.Empty<MenuItem>();
/// <summary>The currently-selected payload (drives the highlighted row).</summary>
public object? Selected { get; set; }
/// <summary>Fired with the picked item's payload when a row is chosen.</summary>
public Action<object?>? OnSelect { get; set; }
/// <summary>Per-payload enabled gate (disabled rows render greyed + are inert). Null ⇒ all enabled.</summary>
public Func<object?, bool>? EnabledProvider { get; set; }
/// <summary>Button-face caption (the active target). Null ⇒ blank face.</summary>
public Func<string>? 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 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 (~x420 of the 46px button); the caption starts past it so it doesn't
// render over the LED.
private const float ButtonTextIndent = 20f;
public UiDatFont? DatFont { get; set; }
public AcDream.App.Rendering.BitmapFont? Font { get; set; }
public Func<uint, (uint tex, int w, int h)>? 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);
/// <summary>Available item text — retail white #FFFFFF (gmMainChatUI talk-focus
/// enabled state). Confirmed via decomp: enabled items render white.</summary>
public Vector4 TextColorAvailable { get; set; } = new(1f, 1f, 1f, 1f);
/// <summary>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.</summary>
public Vector4 TextColorGhosted { get; set; } = new(0.5f, 0.5f, 0.5f, 1f);
private bool _open;
// Interior = the row content; Outer = interior + the 8-piece bevel ring.
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 UiMenu() { CapturesPointerDrag = true; }
/// <summary>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).</summary>
public override bool ConsumesDatChildren => true;
protected override void OnDraw(UiRenderContext ctx)
{
var resolve = SpriteResolve;
// Button face (3-sliced so it can widen to fit the label) + the active-target label.
if (resolve is not null)
{
var (tex, tw, _) = resolve(_open ? PressedSprite : NormalSprite);
if (tex != 0 && tw > 0) DrawButtonFace(ctx, tex, tw);
}
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
// 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
}
/// <summary>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.</summary>
public float NaturalButtonWidth()
{
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
}
/// <summary>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.</summary>
protected override void OnDrawOverlay(UiRenderContext ctx)
{
var resolve = SpriteResolve;
if (!_open || resolve is null) return;
// 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,
// all through the sprite bucket in submission order so labels land on top.
ctx.PushAlphaAbsolute(1f);
try
{
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.Count; i++)
{
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 = (RowHeight - LineH()) * 0.5f; // center the label in its row
for (int i = 0; i < Items.Count; i++)
{
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);
}
}
finally { ctx.PopAlpha(); }
}
/// <summary>Draw the universal 8-piece retail window bevel (corners + tiled edges +
/// tiled centre fill) framing the rect (<paramref name="x"/>,<paramref name="y"/>,
/// <paramref name="w"/>,<paramref name="h"/>). Reuses the same geometry +
/// <see cref="RetailChromeSprites"/> ids as <see cref="UiNineSlicePanel"/>; no resize
/// grips (a menu popup is not resizable).</summary>
private void DrawBevel(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> 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<uint, (uint tex, int w, int h)> resolve,
uint id, float x, float y, float w, float h)
{
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)
=> _open ? (lx >= 0 && lx < OuterW && ly >= -OuterH && ly < Height)
: base.OnHitTest(lx, ly);
public override bool OnEvent(in UiEvent e)
{
if (e.Type != UiEventType.MouseDown) return false;
float lx = e.Data1, ly = e.Data2;
if (_open && ly < 0) // clicked inside the upward popup
{
// 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)
{
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))
{
// 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;
return true;
}
_open = !_open; // toggle on button click
return true;
}
}

View file

@ -0,0 +1,171 @@
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// 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. <see cref="Fill"/> returns 0..1 (null = no data → empty bar);
/// <see cref="Label"/> returns the overlay text (null = no number).
///
/// <para>
/// 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.
/// </para>
/// </summary>
public sealed class UiMeter : UiElement
{
/// <summary>Fill fraction provider; a null result draws an empty bar.</summary>
public Func<float?> Fill { get; set; } = () => 0f;
/// <summary>Centered overlay text provider (e.g. "291/291"); null = none.</summary>
public Func<string?> 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 LabelColor { get; set; } = new(1f, 1f, 1f, 1f);
/// <summary>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 <see cref="UiRenderContext.DefaultFont"/> bitmap font
/// is used instead. Set by the host when the retail UI is active.</summary>
public UiDatFont? DatFont { get; set; }
/// <summary>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.</summary>
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
// Retail vital bars are a horizontal 3-slice: a fixed-width bevelled left-cap,
// 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.
/// <summary>Empty-track left-cap RenderSurface id.</summary>
public uint BackLeft { get; set; }
/// <summary>Empty-track middle (tiled gradient) RenderSurface id.</summary>
public uint BackTile { get; set; }
/// <summary>Empty-track right-cap RenderSurface id.</summary>
public uint BackRight { get; set; }
/// <summary>Coloured-fill left-cap RenderSurface id.</summary>
public uint FrontLeft { get; set; }
/// <summary>Coloured-fill middle (tiled gradient) RenderSurface id.</summary>
public uint FrontTile { get; set; }
/// <summary>Coloured-fill right-cap RenderSurface id.</summary>
public uint FrontRight { get; set; }
public UiMeter() { ClickThrough = true; }
/// <summary>The meter draws its own 3-slice bars; the importer must not build its
/// grandchild slice/text elements as separate widgets.</summary>
public override bool ConsumesDatChildren => true;
/// <summary>Clamp <paramref name="pct"/> to [0,1] and return the fill rect
/// (local px) for a bar of <paramref name="w"/> x <paramref name="h"/>.</summary>
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)
{
float? pct = Fill();
float p = pct is float pf ? (pf < 0f ? 0f : pf > 1f ? 1f : pf) : 0f;
if (SpriteResolve is { } resolve && (BackLeft != 0 || BackTile != 0 || FrontTile != 0))
{
// 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, Width * p);
}
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();
if (!string.IsNullOrEmpty(label))
{
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);
}
}
}
/// <summary>
/// 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 <paramref name="clipW"/> (local px from the left) is drawn.
/// The back track passes <c>clipW = Width</c>; the front fill passes
/// <c>clipW = Width * fraction</c>. 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.
/// </summary>
private void DrawHBar(
UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
uint leftId, uint midId, uint rightId, float clipW)
{
if (clipW <= 0f) return;
float w = Width, h = Height;
var (lt, lw, _) = resolve(leftId);
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;
// 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);
}
/// <summary>Draw a slice over local [<paramref name="pieceX"/>,
/// pieceX+<paramref name="pieceW"/>], with the texture repeating every
/// <paramref name="nativeW"/> px (UV-repeat — the UI texture is GL_REPEAT-wrapped).
/// Clipped so nothing past <paramref name="clipW"/> 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.</summary>
private static void DrawPiece(
UiRenderContext ctx, uint tex, float pieceX, float pieceW, float nativeW, float h, float clipW)
{
if (tex == 0 || pieceW <= 0f || nativeW <= 0f) return;
float visibleW = MathF.Min(pieceW, clipW - pieceX);
if (visibleW <= 0f) return;
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);
}
}

View file

@ -0,0 +1,105 @@
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// A <see cref="UiPanel"/> whose background is the retail 8-piece window bevel
/// (<see cref="RetailChromeSprites"/>): 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:
/// <c>id => { var t = cache.GetOrUploadRenderSurface(id, out var w, out var h); return (t, w, h); }</c>.
/// </summary>
public sealed class UiNineSlicePanel : UiPanel
{
/// <summary>A placed chrome piece: destination rect in local pixel space.</summary>
public readonly record struct Rect(float X, float Y, float W, float H);
/// <summary>The nine destination rects for an 8-piece border + center.</summary>
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<uint, (uint tex, int w, int h)> _resolve;
public UiNineSlicePanel(System.Func<uint, (uint, int, int)> resolve)
{
_resolve = resolve;
BackgroundColor = Vector4.Zero; // suppress the base flat-rect fill
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;
}
/// <summary>
/// Destination rects (local px) for a frame of (<paramref name="w"/>,
/// <paramref name="h"/>) with border thickness <paramref name="b"/>:
/// 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.
/// </summary>
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);
// Resize-grip overlay (gold ridged edges + square corner studs) drawn on
// top of the bevel — the second border layer the vitals LayoutDesc carries
// (0x1000063B0x10000642). 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)
{
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);
}
}

View file

@ -57,14 +57,17 @@ public class UiLabel : UiElement
/// callback. Retail equivalent is Keystone's button widget, driven by
/// a <c>StateDesc</c> per <c>UIStateId</c> (normal / hot / pressed /
/// disabled) from the panel layout.
/// Note: the dat-widget button (Type 1 / UIElement_Button) is <see cref="AcDream.App.UI.UiButton"/>
/// in <c>UiButton.cs</c> — that is the production widget used by D.2b panels.
/// This class is the earlier dev-scaffold button (plain rect + text; no dat sprites).
/// </summary>
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);

View file

@ -22,6 +22,29 @@ public sealed class UiRenderContext
private readonly System.Collections.Generic.List<Vector2> _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<float> _alphaStack = new();
private float _alpha = 1f;
/// <summary>Current cumulative opacity multiplier applied to sprite + rect draws.</summary>
public float AlphaMod => _alpha;
/// <summary>Multiply <paramref name="a"/> into the running opacity. Pair with <see cref="PopAlpha"/>.</summary>
public void PushAlpha(float a) { _alphaStack.Add(_alpha); _alpha *= a; }
/// <summary>Push an ABSOLUTE opacity (replaces, not multiplies) — for popups/overlays
/// that must stay opaque even inside a translucent window. Pair with <see cref="PopAlpha"/>.</summary>
public void PushAlphaAbsolute(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;
@ -45,13 +68,33 @@ public sealed class UiRenderContext
public Vector2 CurrentOrigin => _current;
/// <summary>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 <see cref="EndOverlayLayer"/>.</summary>
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, color);
=> TextRenderer.DrawRect(_current.X + x, _current.Y + y, w, h, ApplyAlpha(color));
/// <summary>Solid-colour fill drawn in the SPRITE bucket (painter order with text), for
/// a panel BACKGROUND that text draws on top of. <see cref="DrawRect"/> composites after
/// all sprites and would cover the text — use this for backgrounds, that for foreground
/// fills (carets, vital bars).</summary>
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, 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, ApplyAlpha(tint));
/// <summary>Multiply the current window opacity into a draw color's alpha.</summary>
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)
{
@ -59,4 +102,101 @@ public sealed class UiRenderContext
if (f is null) return;
TextRenderer.DrawString(f, text, _current.X + x, _current.Y + y, color);
}
/// <summary>
/// Draw a single line of text with a retail dat font (<see cref="UiDatFont"/>),
/// at <paramref name="x"/>,<paramref name="y"/> = the top-left of the
/// typographic block (in this element's local space). Mirrors retail's
/// <c>SurfaceWindow::DrawCharacter</c> (acclient 0x00442bd0): for each glyph
/// the BACKGROUND atlas sub-rect is blitted first tinted black (the outline),
/// then the FOREGROUND atlas sub-rect tinted <paramref name="color"/> (the
/// fill). The pen advances by
/// <c>HorizontalOffsetBefore + Width + HorizontalOffsetAfter</c> and each
/// glyph is positioned at <c>pen + HorizontalOffsetBefore</c> on the X axis
/// and at <c>baseline + VerticalOffsetBefore - (BaselineOffset)</c> via the
/// glyph's OffsetY into the atlas.
///
/// <para><paramref name="outline"/> gates the black outline pass. Retail decides
/// this PER text element: <c>UIElement_Text::DrawSelf</c> (acclient 0x00467aa0)
/// runs the outline pass only when <c>m_bitField &amp; 0x10</c> is set — i.e. the
/// element called <c>SetOutline(true)</c> (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
/// <c>outline:true</c> only for elements retail outlines.</para>
/// </summary>
public void DrawStringDat(UiDatFont font, string text, float x, float y, Vector4 color, bool outline = false)
{
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;
// 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;
// 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 = baseY + g.VerticalOffsetBefore;
float gw = g.Width;
float gh = g.Height;
if (gw > 0f && gh > 0f)
{
// 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, outlineTint);
}
// 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);
}
}
/// <summary>Convert an (OffsetX,OffsetY,Width,Height) atlas pixel sub-rect to
/// normalized UVs for an atlas of <paramref name="atlasW"/> x
/// <paramref name="atlasH"/>. Guards against a zero-sized atlas.</summary>
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);
}
}

View file

@ -4,6 +4,10 @@ using System.Numerics;
namespace AcDream.App.UI;
/// <summary>Which edges of a window a resize-drag is affecting (corners combine two).</summary>
[System.Flags]
public enum ResizeEdges { None = 0, Left = 1, Right = 2, Top = 4, Bottom = 8 }
/// <summary>
/// Top-level UI container. Implements the retail "Device" responsibilities
/// (mouse cursor tracking, keyboard focus, modal overlay, mouse capture,
@ -40,6 +44,10 @@ public sealed class UiRoot : UiElement
/// <summary>Widget currently receiving keyboard events.</summary>
public UiElement? KeyboardFocus { get; private set; }
/// <summary>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.</summary>
public UiElement? DefaultTextInput { get; set; }
/// <summary>
/// Single modal overlay; while set, mouse clicks outside its rect
/// are ignored. Retail sets this via Device vtable +0x48.
@ -49,12 +57,30 @@ public sealed class UiRoot : UiElement
/// <summary>Widget with mouse capture (during click-drag).</summary>
public UiElement? Captured { get; private set; }
/// <summary>
/// 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.
/// </summary>
public bool WantsMouse => Captured is not null || HitTestTopDown(MouseX, MouseY).element is not null;
/// <summary>True when a widget holds keyboard focus (e.g. a focused chat input).</summary>
public bool WantsKeyboard => KeyboardFocus is not null;
/// <summary>Current drag source (set between drag-begin and drop/cancel).</summary>
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 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.
@ -109,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) ──
@ -120,6 +153,26 @@ 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)
{
_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)
@ -155,19 +208,68 @@ 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)
{
// 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);
// Capture + arm drag candidate (drag promotes on subsequent MouseMove > threshold).
SetCapture(target);
_dragCandidate = true;
// 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)
{
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;
_resizeStartW = window.Width; _resizeStartH = window.Height;
_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;
_windowDragOffX = x - (int)window.Left;
_windowDragOffY = y - (int)window.Top;
_dragCandidate = false;
}
else { _dragCandidate = true; }
}
else if (target.CapturesPointerDrag)
{
// No window ancestor, but the target still owns its interior drag.
_dragCandidate = false;
}
else
{
_dragCandidate = true;
}
// Dispatch raw MouseDown event (retail uses WM_LBUTTONDOWN = 0x201).
int rawType = btn switch
@ -177,8 +279,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);
}
@ -187,6 +294,20 @@ 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;
ReleaseCapture();
return;
}
if (DragSource is not null)
{
FinishDrag(x, y);
@ -251,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)
{
@ -436,6 +569,48 @@ public sealed class UiRoot : UiElement
return (null, 0, 0);
}
private static UiElement? FindWindow(UiElement? e)
{
while (e is not null)
{
if (e.Draggable || e.Resizable) return e;
e = e.Parent;
}
return null;
}
/// <summary>Which edges of <paramref name="w"/>'s screen rect the point
/// (<paramref name="x"/>,<paramref name="y"/>) is within <paramref name="grip"/> px of.
/// None if the point is outside the grip-expanded box entirely.</summary>
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;
if (!w.ResizeX) e &= ~(ResizeEdges.Left | ResizeEdges.Right);
if (!w.ResizeY) e &= ~(ResizeEdges.Top | ResizeEdges.Bottom);
return e;
}
/// <summary>Compute a resized rect from a start rect + drag delta + which edges,
/// clamping to (<paramref name="minW"/>,<paramref name="minH"/>). Left/Top edges
/// move the origin so the opposite edge stays put.</summary>
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;

View file

@ -0,0 +1,57 @@
using System;
namespace AcDream.App.UI;
/// <summary>
/// Pixel-based vertical scroll model. Port of retail <c>UIElement_Scrollable</c>:
/// the scroll offset is an integer pixel value (<c>m_iScrollableY</c>) 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 (UiText) and the scrollbar (UiScrollbar).
/// Decomp anchors: SetScrollableXY @0x4740c0, UpdateScrollbarSize_ @0x4741a0,
/// UpdateScrollbarPosition_ @0x473f20, UIElement_Text::InqScrollDelta @0x4689b0.
/// </summary>
public sealed class UiScrollable
{
/// <summary>Total wrapped content height in px (m_iScrollableHeight).</summary>
public int ContentHeight { get; set; }
/// <summary>Visible viewport height in px.</summary>
public int ViewHeight { get; set; }
/// <summary>Pixels per text line (scroll quantum). InqScrollDelta line case.</summary>
public int LineHeight { get; set; } = 16;
private int _scrollY;
/// <summary>Current scroll offset in px from the top of the content.</summary>
public int ScrollY => _scrollY;
/// <summary>Max scroll = max(0, content - view).</summary>
public int MaxScroll => Math.Max(0, ContentHeight - ViewHeight);
/// <summary>True when content exceeds the view (a scrollbar is warranted).</summary>
public bool HasOverflow => ContentHeight > ViewHeight;
/// <summary>True when the offset is at (or past) the bottom — used for bottom-pin.</summary>
public bool AtEnd => _scrollY >= MaxScroll;
/// <summary>Set the offset, clamped to [0, MaxScroll] (SetScrollableXY clamp).</summary>
public void SetScrollY(int y) => _scrollY = Math.Clamp(y, 0, MaxScroll);
/// <summary>Pin to the bottom (newest content visible).</summary>
public void ScrollToEnd() => _scrollY = MaxScroll;
/// <summary>Thumb size ratio = view/content, clamped to 1 (UpdateScrollbarSize_).</summary>
public float ThumbRatio => ContentHeight <= 0 ? 1f : Math.Min(1f, (float)ViewHeight / ContentHeight);
/// <summary>Position ratio = scroll/(content-view) in [0,1] (UpdateScrollbarPosition_).</summary>
public float PositionRatio => MaxScroll <= 0 ? 0f : (float)_scrollY / MaxScroll;
/// <summary>Inverse of PositionRatio — used when the user drags the thumb.</summary>
public void SetPositionRatio(float ratio)
=> SetScrollY((int)MathF.Round(Math.Clamp(ratio, 0f, 1f) * MaxScroll));
/// <summary>Scroll by whole lines (sign: +down/newer, -up/older).</summary>
public void ScrollByLines(int lines) => SetScrollY(_scrollY + lines * LineHeight);
/// <summary>Scroll by a page = one view height (InqScrollDelta page case).</summary>
public void ScrollByPage(int pages) => SetScrollY(_scrollY + pages * ViewHeight);
}

View file

@ -0,0 +1,210 @@
using System;
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// Generic scrollbar. Ports retail <c>UIElement_Scrollbar</c>
/// (RegisterElementClass(0xb) @ acclient_2013_pseudo_c.txt:124137);
/// thumb size = trackLen * ThumbRatio (min 8px); step ±1 line.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class UiScrollbar : UiElement
{
/// <summary>The scroll model this bar reflects + drives (shared with the transcript).</summary>
public UiScrollable? Model { get; set; }
/// <summary>RenderSurface id → (GL tex, w, h). 0 id = skip.</summary>
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
/// <summary>Track background sprite id (0x06004C5F from layout 0x2100003E element 0x10000455).</summary>
public uint TrackSprite { get; set; }
/// <summary>Thumb 3-slice MIDDLE tile sprite id (0x06004C63), tiled between the caps.</summary>
public uint ThumbSprite { get; set; }
/// <summary>Thumb 3-slice TOP cap sprite id (0x06004C60, 3px tall).</summary>
public uint ThumbTopSprite { get; set; }
/// <summary>Thumb 3-slice BOTTOM cap sprite id (0x06004C66, 3px tall).</summary>
public uint ThumbBotSprite { get; set; }
/// <summary>Up-arrow button sprite id (0x06004C69 Normal state, element 0x10000071).</summary>
public uint UpSprite { get; set; }
/// <summary>Down-arrow button sprite id (0x06004C6C Normal state, element 0x10000072).</summary>
public uint DownSprite { get; set; }
/// <summary>Retail attribute 0x89 floor: minimum thumb height in pixels.</summary>
private const float MinThumb = 8f;
/// <summary>Thumb cap height (native sprite height from base layout 0x2100003E).</summary>
private const float CapH = 3f;
/// <summary>Up/down button height in pixels. Matches element height 16px from
/// the up/down button children in base layout 0x2100003E.</summary>
private const float ButtonH = 16f;
private bool _draggingThumb;
private float _dragOffsetY;
public UiScrollbar() { CapturesPointerDrag = true; }
/// <summary>The scrollbar draws its own track/thumb/arrows; its dat up/down button
/// children are reproduced procedurally, so the importer must not build them.</summary>
public override bool ConsumesDatChildren => true;
/// <summary>
/// Computes the thumb rectangle (local y origin and height) within the track area
/// between the two end buttons. Ports retail <c>UIElement_Scrollbar::UpdateLayout
/// @0x4710d0</c>: thumb height = max(MinThumb, trackLen * ThumbRatio); thumb top
/// offset = trackTop + (trackLen - thumbH) * PositionRatio.
/// </summary>
/// <param name="m">The scroll model.</param>
/// <param name="trackTop">Y of the top of the usable track area (below up-button).</param>
/// <param name="trackLen">Pixel length of the usable track area (between up and down buttons).</param>
/// <returns>Local Y of the thumb's top edge, and its pixel height.</returns>
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 — 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. UpSprite (0x06004C6C) is the up-arrow art, drawn 1:1.
DrawSprite(ctx, resolve, UpSprite, 0f, 0f, Width, ButtonH);
// 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 +
// 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);
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);
}
}
}
/// <summary>Draw a sprite stretched 1:1 to the dest rect.</summary>
private void DrawSprite(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> 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, 0f, 1f, 1f, Vector4.One);
}
/// <summary>Draw a sprite 1:1 but vertically FLIPPED (V0/V1 swapped) — used to point
/// the top scroll button's (down-art) arrow upward.</summary>
private void DrawSpriteFlipV(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> 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);
}
/// <summary>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.</summary>
private void DrawTiled(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> 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;
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;
}
}

View file

@ -0,0 +1,448 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using System.Text;
using AcDream.App.Rendering;
namespace AcDream.App.UI;
/// <summary>
/// Scrollable text view for retail UIElement_Text elements
/// (<c>RegisterElementClass(0xc) @ acclient_2013_pseudo_c.txt:115655</c>).
/// Renders the lines from <see cref="LinesProvider"/> bottom-pinned (newest at the bottom,
/// like retail) with mouse-wheel scrollback. Whole-line vertical clipping keeps
/// text inside the window.
///
/// <para>
/// Supports Windows-like text selection: a left-click-drag inside the transcript
/// selects characters (the <see cref="UiElement.CapturesPointerDrag"/> 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.
/// </para>
/// </summary>
public sealed class UiText : UiElement
{
/// <summary>One display line: pre-formatted text + its colour.</summary>
public readonly record struct Line(string Text, Vector4 Color);
/// <summary>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).</summary>
public readonly record struct Pos(int Line, int Col);
/// <summary>Provider of the lines to show, oldest-first. Polled each frame.</summary>
public Func<IReadOnlyList<Line>> LinesProvider { get; set; } = static () => Array.Empty<Line>();
/// <summary>Font for the transcript; falls back to the context default.</summary>
public BitmapFont? Font { get; set; }
/// <summary>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.</summary>
public UiDatFont? DatFont { get; set; }
/// <summary>Keyboard device for clipboard (Ctrl+C) + modifier state. Wired by
/// the host from <see cref="UiHost.Keyboard"/>.</summary>
public Silk.NET.Input.IKeyboard? Keyboard { get; set; }
/// <summary>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. <c>ChatWindowController</c>).</summary>
public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0f);
/// <summary>Optional dat state-sprite background (the element's own media), drawn
/// UNDER the text. Set by DatWidgetFactory.BuildText from the ElementInfo. 0 = none.</summary>
public uint BackgroundSprite { get; set; }
/// <summary>Resolves a dat RenderSurface id to (GL tex handle, pixel width, pixel height).
/// Required when <see cref="BackgroundSprite"/> is non-zero.</summary>
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
/// <summary>Highlight colour painted behind a selected character span.</summary>
public Vector4 SelectionColor { get; set; } = new(0.25f, 0.45f, 0.85f, 0.5f);
/// <summary>Inner text inset from the view edges, px.</summary>
public float Padding { get; set; } = 4f;
/// <summary>Static centered single-line mode (retail <c>UIElement_Text</c> 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
/// <see cref="UiMeter"/>'s former number overlay so those numbers stay pixel-identical
/// after the rewire. Pair with <c>ClickThrough = true</c> for non-interactive labels.</summary>
public bool Centered { get; set; }
/// <summary>The scroll model — also read by the linked UiScrollbar.</summary>
public UiScrollable Scroll { get; } = new();
/// <summary>True while the view is pinned to the newest line (auto-scrolls as content grows).</summary>
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 ──
private IReadOnlyList<Line> _lastLines = Array.Empty<Line>();
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;
// ── Selection state ──────────────────────────────────────────────────
private Pos? _selAnchor; // where the drag started
private Pos? _selCaret; // where the drag currently is
private bool _selecting;
public UiText()
{
AcceptsFocus = true;
IsEditControl = true; // absorb keys (Ctrl+C) while focused
CapturesPointerDrag = true; // interior drag selects, doesn't move the window
}
/// <summary>The text view draws its own lines + background; any dat sub-elements
/// (scroll indicators, caps) are not built as separate widgets by the importer.</summary>
public override bool ConsumesDatChildren => true;
/// <summary>
/// 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.
/// </summary>
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)
{
// 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,
// 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;
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;
_lastDatFont = datFont;
_lastFont = bitmapFont;
_lastLineHeight = datFont is not null ? datFont.LineHeight : bitmapFont!.LineHeight;
_lastPadding = Padding;
if (lines.Count == 0) return;
float lh = _lastLineHeight;
float top = Padding, bottom = Height - Padding;
float innerH = bottom - top;
float contentH = lines.Count * lh;
// 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.
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)
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, 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));
}
// Highlight sits BEHIND the line's text → sprite bucket, submitted
// before this line's DrawStringDat.
ctx.DrawFill(hx, y, hw, lh, SelectionColor);
}
}
if (datFont is not null)
ctx.DrawStringDat(datFont, text, Padding, y, lines[i].Color);
else
ctx.DrawString(text, Padding, y, lines[i].Color, bitmapFont);
}
}
public override bool OnEvent(in UiEvent e)
{
switch (e.Type)
{
case UiEventType.Scroll:
{
// 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;
}
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 ────────────────────────────────────────────────
/// <summary>Select the entire cached transcript (Ctrl+A).</summary>
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);
}
/// <summary>Normalise (anchor, caret) into ordered (start, end). False if no
/// selection or it is empty (anchor == caret).</summary>
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);
}
/// <summary>The currently-selected text against the cached lines. Empty when
/// nothing is selected.</summary>
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) ───────────────────
/// <summary>Order two caret positions so the first is <= the second (by line,
/// then column).</summary>
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);
}
/// <summary>
/// Assemble the selected substring spanning <paramref name="start"/> ..
/// <paramref name="end"/> (inclusive of start.Col, exclusive of end.Col) from
/// <paramref name="lines"/>. 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.
/// </summary>
public static string SelectedText(IReadOnlyList<Line> 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();
}
/// <summary>
/// Convert a local-space point to a caret <see cref="Pos"/> against the cached
/// layout from the last draw. line = floor((localY - baseY)/lineHeight) clamped
/// to the line range; col via <see cref="CharIndexAt"/>.
/// </summary>
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;
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);
}
/// <summary>
/// The caret column for a horizontal position <paramref name="x"/> (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 <paramref name="x"/> falls on — natural
/// Windows-like caret placement. Pure — unit-testable with a synthetic advance.
/// </summary>
/// <param name="text">The line text.</param>
/// <param name="advanceOf">Per-character advance (pixels) lookup.</param>
/// <param name="x">Horizontal position relative to the text's left edge.</param>
public static int CharIndexAt(string text, Func<char, float> 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
}
}

View file

@ -9,6 +9,14 @@
<ItemGroup>
<PackageReference Include="Chorizite.DatReaderWriter" Version="2.1.7" />
<!-- render-vitals-mockup: SurfaceDecoder (Core) + ImageSharp for a headless
PNG composite of the retail vital bars, so the 3-slice assembly can be
verified without launching the client. -->
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />
</ItemGroup>
</Project>

View file

@ -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;
/// <summary>
/// Headless inspection of a retail dat Font (DB_TYPE_FONT, 0x40000000…). Writes:
/// • <c>&lt;out&gt;-fg.png</c> — foreground (fill) atlas, alpha→luminance (white on black)
/// • <c>&lt;out&gt;-bg.png</c> — background (outline) atlas, alpha→luminance
/// • <c>&lt;out&gt;-sample.png</c> — a sample string composited EXACTLY the way
/// <c>UiRenderContext.DrawStringDat</c> 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.
/// </summary>
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<Font>(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<char, FontCharDesc>();
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;
}
/// <summary>Composite the sample string with the two-pass outline+fill model,
/// blitting atlas sub-rects 1:1. <paramref name="originYExtra"/> adds a fractional
/// line origin; <paramref name="snapOnce"/> selects the FIX (snap the line baseline
/// to a whole pixel once) vs the BUG (round each glyph's Y independently).</summary>
private static Image<Rgba32> RenderSample(
string text, Dictionary<char, FontCharDesc> 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<Rgba32>(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<Rgba32> native, string outBase)
{
using var zoom = native.Clone(c => c.Resize(native.Width * 6, native.Height * 6, KnownResamplers.NearestNeighbor));
zoom.SaveAsPng($"{outBase}-6x.png");
}
/// <summary>Alpha-blend one glyph's atlas sub-rect onto the canvas using its alpha
/// as coverage, tinted by <paramref name="tint"/>. 1:1 (no scaling), so this is the
/// pixel-exact result GL_NEAREST + native-size quad produces.</summary>
private static void BlitGlyph(Image<Rgba32> 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);
}
}
}
/// <summary>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.</summary>
private static Image<Rgba32> AlphaLuma(DecodedTexture t)
{
var img = new Image<Rgba32>(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<RenderSurface>(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;
}
}

View file

@ -0,0 +1,101 @@
using System.Reflection;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using DatReaderWriter.Types;
namespace AcDream.Cli;
/// <summary>
/// Read-only research diagnostic: index EVERY UI <see cref="LayoutDesc"/> in the
/// dat by its root element's <c>Type</c> + size + an element-Type histogram, so a
/// panel re-drive can locate its layout from the decomp-registered class id
/// (e.g. <c>gmMainChatUI</c> registers type <c>0x10000041</c> → 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.
/// </summary>
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<LayoutDesc>().OrderBy(x => x))
{
var l = dats.Get<LayoutDesc>(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<uint, int>();
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<uint, int> 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}",
};
}

View file

@ -1,10 +1,100 @@
using System.Diagnostics;
using AcDream.Cli;
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 <dat-directory>");
return 2;
}
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 <dat-directory> [0xLayoutId]");
return 2;
}
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 <dat-directory> [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");
string rvmOut = args.ElementAtOrDefault(2) ?? "vitals-mockup.png";
if (string.IsNullOrWhiteSpace(rvmDatDir))
{
Console.Error.WriteLine("usage: AcDream.Cli render-vitals-mockup <dat-directory> [out.png]");
return 2;
}
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 <dat-directory> <0xId,0xId,...> [out.png]");
return 2;
}
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 <dat-directory> [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");
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 <dat-directory> <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.
@ -160,3 +250,146 @@ static (string Name, Func<int> Count)[] CountCellByLow16(DatCollection dats)
("Region", () => dats.GetAllIdsOfType<Region>().Count()),
};
}
/// <summary>
/// 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.
/// </summary>
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<LayoutDesc>())
{
var ld = dats.Get<LayoutDesc>(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(" <element not found>"); continue; }
var sprites = new List<(string Role, uint DataId, string DrawMode)>();
VbCollectSprites(meterElem, sprites, 0);
if (sprites.Count == 0)
{
Console.WriteLine(" <no sprites found in sub-tree>");
}
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()));
}
}

View file

@ -0,0 +1,152 @@
using System.Collections;
using System.Reflection;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using DatReaderWriter.Types;
namespace AcDream.Cli;
/// <summary>
/// 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.
/// </summary>
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<LayoutDesc>())
{
var l = dats.Get<LayoutDesc>(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<LayoutDesc>(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<string> 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()}");
}
}
/// <summary>Enumerate public properties AND public fields (the DatReaderWriter
/// generated types expose geometry/file ids as fields, not properties).</summary>
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;
}
}

View file

@ -0,0 +1,222 @@
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;
/// <summary>
/// Headless PNG preview of the retail STACKED vitals window (LayoutDesc
/// 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).
/// </summary>
public static class VitalsMockup
{
// 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;
private readonly record struct Vital(
string Name, float Frac,
uint BackL, uint BackM, uint BackR, uint FrontL, uint FrontM, uint FrontR);
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),
};
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)
{
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; // 160
int winH = 3 * BarH + 2 * Border; // 58
using var canvas = new Image<Rgba32>(winW, winH, new Rgba32(20, 20, 24, 255));
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}) — faithful default vitals window 0x2100006C");
return 0;
}
private static void DrawWindow(Image<Rgba32> canvas, DatCollection dats, int offY, int winW, int winH, bool tileMid)
{
// 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))
{
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);
}
// Resize-grip overlay: gold ridged edge strips + square corner studs, on
// top of the bevel (vitals LayoutDesc 0x1000063B0x10000642). 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];
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);
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);
}
}
/// <summary>Horizontal 3-slice: native-width left-cap, middle (STRETCHED or TILED
/// per <paramref name="tileMid"/>), native-width right-cap; clipped to clipW.</summary>
private static void DrawHBar(
Image<Rgba32> canvas, Image<Rgba32> left, Image<Rgba32> mid, Image<Rgba32> right,
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); // 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)
}
/// <summary>Fill [midLocalX, midLocalX+midW] by repeating the native-width tile at
/// 1:1 (no horizontal scaling), clipping the final partial copy and honouring clipW.</summary>
private static void TileMiddle(
Image<Rgba32> canvas, Image<Rgba32> 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));
}
}
/// <summary>Draw one slice spanning [pieceLocalX, +pieceW] STRETCHED to fill, UV-cropped
/// (proportionally) so nothing past clipW shows.</summary>
private static void DrawClippedPiece(
Image<Rgba32> canvas, Image<Rgba32> 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<Rgba32> canvas, Image<Rgba32> 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));
}
/// <summary>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.</summary>
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<Rgba32>(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; }
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<Rgba32> Load(DatCollection dats, uint id)
{
var rs = dats.Get<RenderSurface>(id);
if (rs is null) { Console.Error.WriteLine($" missing RenderSurface 0x{id:X8}"); return new Image<Rgba32>(1, 1); }
var dt = SurfaceDecoder.DecodeRenderSurface(rs);
return Image.LoadPixelData<Rgba32>(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;
}
}

View file

@ -80,6 +80,11 @@ public static class SurfaceDecoder
/// </summary>
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(

View file

@ -10,4 +10,5 @@ public interface IPluginHost
IPluginLogger Log { get; }
IGameState State { get; }
IEvents Events { get; }
IUiRegistry Ui { get; }
}

View file

@ -0,0 +1,14 @@
namespace AcDream.Plugin.Abstractions;
/// <summary>
/// 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 <c>Enable()</c>. Calls made before the GL window opens are
/// buffered and drained once the UI host exists.
/// </summary>
public interface IUiRegistry
{
/// <param name="markupPath">Absolute path to the plugin's panel markup file.</param>
/// <param name="binding">Object whose properties the markup's {Bindings} resolve against.</param>
void AddMarkupPanel(string markupPath, object binding);
}

View file

@ -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;

View file

@ -0,0 +1,78 @@
using System;
namespace AcDream.UI.Abstractions.Panels.Chat;
/// <summary>What a submit did, so the caller can clear its input + give feedback.</summary>
public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped }
/// <summary>
/// Shared chat-submit pipeline (retail <c>ChatInterface::ProcessCommand @0x4f5100</c>
/// analogue). Both the ImGui devtools <see cref="ChatPanel"/> and the retail
/// chat window route through here so command handling stays in one place.
///
/// Order mirrors the prior inline <see cref="ChatPanel"/> flow:
/// client-command intercept → unknown-slash-verb guard → <see cref="ChatInputParser.Parse"/>
/// → <c>Publish(SendChatCmd)</c>.
/// </summary>
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 <name>, /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.";
}

View file

@ -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),
};
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Recognised client-side commands:
/// <list type="bullet">
/// <item><c>/help</c>, <c>/?</c>, <c>/h</c> — 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.</item>
/// <item><c>/clear</c>, <c>/cls</c> — drain the chat log so the
/// panel starts empty.</item>
/// </list>
/// </remarks>
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;
}
/// <summary>Case-insensitive multi-string equality test.</summary>
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;
}
/// <summary>
/// Multi-line cheat-sheet text rendered by <c>/help</c>. ImGui's
/// <c>Text</c> path flows embedded newlines naturally so this lands
/// as one ChatLog entry that visually wraps to several lines.
/// </summary>
private static string BuildHelpText() =>
"Note: / and @ are equivalent prefixes.\n" +
"Chat: /say (default), /tell <name>, /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.";
}

View file

@ -22,4 +22,10 @@
<ProjectReference Include="..\..\src\AcDream.App\AcDream.App.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="UI\Layout\fixtures\*.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View file

@ -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
}
}

View file

@ -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<string, string?>
{
["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);
}
}

View file

@ -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
}
}

View file

@ -0,0 +1,46 @@
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// 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).
/// </summary>
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)
}
}

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
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");
}

View file

@ -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;
/// <summary>
/// Smoke tests for <see cref="ChatWindowController.Bind"/> — no dats, no GL.
///
/// Building the Type-12 "skipped" elements via the pure <see cref="LayoutImporter"/>
/// 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 <see cref="LayoutImporter.Build"/> to get the widget tree
/// (Type-12 children skipped, Type-3 parents created), then call
/// <see cref="ChatWindowController.Bind"/> which reads rects from the info tree
/// and places behavioral widgets under the parent containers.
/// </summary>
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<object> Published = new();
public void Publish<T>(T cmd) where T : notnull => Published.Add(cmd!);
}
// ── Synthetic element tree matching the real chat layout topology ────────
/// <summary>
/// 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] ← 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] ← built as UiText by factory; Bind removes + replaces with UiField
/// send (Type-3) [0x10000019]
/// maxmin (Type-3) [0x1000046F]
/// </summary>
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 = 6, 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<SendChatCmd>(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 via the generic OnSelect (payload is ChatChannelKind).
ctrl!.Menu.OnSelect!.Invoke((object?)ChatChannelKind.General);
ctrl.Input.OnSubmit!.Invoke("hey all");
Assert.Single(bus.Published);
var cmd = Assert.IsType<SendChatCmd>(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);
}
}

View file

@ -0,0 +1,180 @@
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<UiMeter>(e);
}
// ── Test 2: Unknown type → UiDatElement fallback ─────────────────────────
[Fact]
public void UnknownType_FallsBackToGeneric()
{
var e = DatWidgetFactory.Create(new ElementInfo { Type = 999 }, NoTex, null);
Assert.IsType<UiDatElement>(e);
}
// ── Test 3: Type 12 → UiText (behavioral text widget) ────────────────────
[Fact]
public void Type12_Text_MakesUiText()
{
var e = DatWidgetFactory.Create(new ElementInfo { Type = 12, Width = 100, Height = 40 }, NoTex, null);
Assert.IsType<UiText>(e);
}
// ── Test 4: Rect + anchors set from ElementInfo ───────────────────────────
/// <summary>
/// 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.
/// 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.
/// </summary>
[Fact]
public void RectAndAnchors_SetFromElementInfo()
{
var info = new ElementInfo
{
Type = 3,
X = 5, Y = 21,
Width = 150, Height = 16,
Left = 1, Top = 1,
Right = 1, 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: 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 G1a: Type 12 always produces UiText (with or without own sprites) ──
[Fact]
public void DatWidgetFactory_Type12_AlwaysMakesUiText()
{
var withMedia = new ElementInfo { Type = 12, Width = 32, Height = 16,
StateMedia = { ["Normal"] = (0x00001234u, 1) } };
Assert.IsType<UiText>(DatWidgetFactory.Create(withMedia, NoTex, null));
Assert.IsType<UiText>(DatWidgetFactory.Create(new ElementInfo { Type = 12 }, 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<UiButton>(e);
}
// ── 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<UiScrollbar>(e);
}
// ── 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_NotRegistered_FallsBackToGeneric()
{
var e = DatWidgetFactory.Create(new ElementInfo { Type = 3, Width = 200, Height = 16 }, NoTex, null);
Assert.IsType<UiDatElement>(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<UiMenu>(e);
}
// ── Test 6: Meter slice extraction (the important one) ───────────────────
/// <summary>
/// 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.
/// </summary>
[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<UiMeter>(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);
// Overlay (ShowDetail-only, no DirectState "") must not leak into any slice slot.
Assert.NotEqual(OverlayFile, m.FrontRight);
Assert.NotEqual(OverlayFile, m.FrontTile);
}
}

View file

@ -0,0 +1,164 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
public class ElementReaderTests
{
// ── ToAnchors (decomp-backed: UIElement::UpdateForParentSizeChange @0x00462640) ─────────────
/// <summary>
/// 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).
/// </summary>
[Fact]
public void ToAnchors_TopEdge_StretchesWidth()
{
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));
}
/// <summary>
/// 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).
/// </summary>
[Fact]
public void ToAnchors_TlCorner_PinsTopLeftFixed()
{
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));
Assert.False(a.HasFlag(AnchorEdges.Bottom));
}
/// <summary>
/// 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).
/// </summary>
[Fact]
public void ToAnchors_TrCorner_TracksRight()
{
var a = ElementReader.ToAnchors(left: 2, top: 1, right: 1, bottom: 2);
Assert.False(a.HasFlag(AnchorEdges.Left));
Assert.True(a.HasFlag(AnchorEdges.Top));
Assert.True(a.HasFlag(AnchorEdges.Right));
Assert.False(a.HasFlag(AnchorEdges.Bottom));
}
/// <summary>
/// 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).
/// </summary>
[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));
}
/// <summary>
/// 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.
/// </summary>
[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));
}
/// <summary>
/// All-zero edge flags (prototype-only elements) fall back to Left|Top default.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public void EdgeFlagsToAnchors_ValueThree_HorizAxes_YieldsTopBottom()
{
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 ────────────────────────────────────────────────────────────────
[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);
}
}

View file

@ -0,0 +1,75 @@
using System.IO;
using System.Text.Json;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Loads the committed layout ElementInfo fixtures and builds widget trees —
/// no dats required. Fixtures were generated from the real portal.dat and
/// serialized with <see cref="System.Text.Json"/>.
/// </summary>
public static class FixtureLoader
{
private static readonly JsonSerializerOptions _opts = new()
{
IncludeFields = true,
};
/// <summary>
/// Deserializes the committed <c>vitals_2100006C.json</c> fixture (copied to
/// the test output directory via the csproj <c>CopyToOutputDirectory</c> item)
/// into an <see cref="ElementInfo"/> tree, then builds and returns the
/// <see cref="ImportedLayout"/> using a null-returning sprite resolver and no
/// dat font — sufficient for conformance checks on tree structure and slice ids.
/// </summary>
public static ImportedLayout LoadVitals()
{
var root = LoadVitalsInfos();
return LayoutImporter.Build(root, _ => (0u, 0, 0), null);
}
/// <summary>
/// Deserializes the committed <c>vitals_2100006C.json</c> fixture into a raw
/// <see cref="ElementInfo"/> tree WITHOUT calling <see cref="LayoutImporter.Build"/>.
/// Use this when the test needs to inspect the resolved <see cref="ElementInfo"/>
/// tree directly (e.g. inheritance-resolution checks) without exercising the
/// widget factory.
/// </summary>
public static AcDream.App.UI.Layout.ElementInfo LoadVitalsInfos()
=> LoadInfos("vitals_2100006C.json");
/// <summary>
/// Deserializes the committed <c>chat_21000006.json</c> fixture into a raw
/// <see cref="ElementInfo"/> tree and builds the <see cref="ImportedLayout"/>
/// using a null-returning sprite resolver and no dat font — sufficient for
/// conformance checks on tree structure and resolved types.
/// </summary>
public static ImportedLayout LoadChat()
=> LayoutImporter.Build(LoadChatInfos(), _ => (0u, 0, 0), null);
/// <summary>
/// Deserializes the committed <c>chat_21000006.json</c> fixture into a raw
/// <see cref="ElementInfo"/> tree WITHOUT calling <see cref="LayoutImporter.Build"/>.
/// Use this when the test needs to inspect the resolved <see cref="ElementInfo"/>
/// tree directly (e.g. resolved Type values per element id).
/// </summary>
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<T>(ReadOnlySpan<byte>)
// does not reject the first byte.
ReadOnlySpan<byte> span = bytes;
if (span.Length >= 3 && span[0] == 0xEF && span[1] == 0xBB && span[2] == 0xBF)
span = span[3..];
return JsonSerializer.Deserialize<AcDream.App.UI.Layout.ElementInfo>(span, _opts)
?? throw new InvalidOperationException($"fixture deserialized to null: {path}");
}
}

View file

@ -0,0 +1,198 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Golden conformance tests for the vitals LayoutDesc importer.
/// Uses the committed JSON fixture (<c>vitals_2100006C.json</c>) — 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 <see cref="LayoutImporter"/>,
/// <see cref="DatWidgetFactory"/>, or <see cref="ElementReader"/> will surface here.
///
/// Sprite ids sourced from <c>docs/research/2026-06-15-layoutdesc-format.md §11</c>.
/// </summary>
[Trait("Category", "Conformance")]
public class LayoutConformanceTests
{
// ── Test 1: Three meters at expected rects ────────────────────────────────
/// <summary>
/// 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).
/// </summary>
[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<UiMeter>(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 ──────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
[Fact]
public void VitalsTree_MetersHaveExpectedSliceIds()
{
var layout = FixtureLoader.LoadVitals();
// 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
];
foreach (var (meterId, s) in cases)
{
var m = Assert.IsType<UiMeter>(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);
}
}
// ── Test 3: Chrome TL corner sprite ───────────────────────────────────────
//
// 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.
/// <summary>
/// The top-left chrome corner element (id <c>0x10000633</c>) must be a
/// <see cref="UiDatElement"/> whose active media file id is <c>0x060074C3</c>.
/// </summary>
[Fact]
public void VitalsTree_ChromeCornerHasExpectedSprite()
{
var layout = FixtureLoader.LoadVitals();
var elem = layout.FindElement(0x10000633u);
Assert.NotNull(elem);
var datElem = Assert.IsType<UiDatElement>(elem);
var (file, _) = datElem.ActiveMedia();
Assert.Equal(0x060074C3u, file);
}
// ── Test 4 (N4): Inheritance resolution — FontDid propagated from base ───
/// <summary>
/// Proves that <c>Resolve()</c>'s inheritance merge fired against real dat data:
/// at least one element in the fixture tree must have <c>FontDid == 0x40000000</c>
/// (the vitals font), inherited from the base-layout prototype <c>0x10000376</c>
/// in <c>0x2100003F</c> via the <c>BaseElement</c> / <c>BaseLayoutId</c> chain.
///
/// <para>
/// The three text labels (<c>0x100000EB</c> health, <c>0x100000ED</c> stamina,
/// <c>0x100000EF</c> mana) are Type=0 derived elements with no own font property.
/// The base element <c>0x10000376</c> carries <c>Properties[0x1A]</c> →
/// <c>ArrayBaseProperty[ DataIdBaseProperty{Value=0x40000000} ]</c>.
/// <see cref="ElementReader.Merge"/> propagates this via the "FontDid: derived wins
/// if non-zero, otherwise inherit" rule.
/// </para>
///
/// <para>
/// This test verifies end-to-end inheritance resolution against the committed fixture
/// (format doc §10, <c>docs/research/2026-06-15-layoutdesc-format.md</c>).
/// It operates on the raw <see cref="ElementInfo"/> tree, NOT the widget tree,
/// so the factory dispatch (Type 12 → skip) does not interfere.
/// </para>
/// </summary>
[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<uint>();
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<uint> acc)
{
if (node.FontDid != 0) acc.Add(node.FontDid);
foreach (var child in node.Children)
CollectFontDids(child, acc);
}
// ── Test 5: Horizontal resize conformance (160→200) ──────────────────────
/// <summary>
/// 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
/// <see cref="UiElement.ComputeAnchoredRect"/> 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
/// </summary>
[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}");
}
}
}

View file

@ -0,0 +1,106 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Pure unit tests for <see cref="LayoutImporter.BuildFromInfos"/> — no dats, no GL.
/// Verifies the tree-builder: widget dispatch, Type-12 skipping, and meter child consumption.
/// </summary>
public class LayoutImporterTests
{
private static (uint, int, int) NoTex(uint _) => (0, 0, 0);
// ── Test 1: Health meter element → UiMeter with correct rect ─────────────
/// <summary>
/// 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&lt;uint,(uint,int,int)&gt;.
/// </summary>
[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<UiMeter>(found);
Assert.Equal(5f, found!.Left);
Assert.Equal(150f, found.Width);
}
// ── Test 2: Type-12 child builds a UiText; Type-3 sibling is also present ──
/// <summary>
/// A root with two children: one Type-12 UIElement_Text and one Type-3 container.
/// The Type-12 must appear as a <see cref="UiText"/> in the tree (transparent,
/// draws nothing until a controller binds its <c>LinesProvider</c>);
/// the Type-3 must also be present.
/// </summary>
[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 is now a UiText (transparent, no lines) — present in the tree.
Assert.IsType<UiText>(tree.FindElement(0x20000001));
// Type-3 must also be present.
Assert.NotNull(tree.FindElement(0x20000002));
}
// ── Test 3: Meter consumes its children — child ids not in byId ──────────
/// <summary>
/// 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).
/// </summary>
[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<UiMeter>(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;
}
}

View file

@ -0,0 +1,90 @@
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);
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);
}
// ── G1 tests: DefaultStateName + "Normal" implicit default ───────────────
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// Task G1 change 5: when DefaultStateName is set (e.g. "Minimized"),
/// it takes priority over the "Normal" implicit default.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
}

View file

@ -0,0 +1,113 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Unit tests for <see cref="VitalsController.Bind"/>: 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.
/// </summary>
public class VitalsBindingTests
{
// ── Test 1: Health meter Fill + Label providers are bound ─────────────────
[Fact]
public void Bind_SetsHealthMeterFillFromProvider()
{
var health = new UiMeter();
var layout = FakeLayout((VitalsController.Health, 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);
// 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 ──────────────────
[Fact]
public void Bind_AllThreeMeters_EachBoundToOwnProvider()
{
var health = new UiMeter();
var stamina = new UiMeter();
var mana = new UiMeter();
var layout = FakeLayout(
(VitalsController.Health, health),
(VitalsController.Stamina, stamina),
(VitalsController.Mana, 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", NumberText(health));
Assert.Equal(0.50f, stamina.Fill()!.Value);
Assert.Equal("50/100", NumberText(stamina));
Assert.Equal(0.75f, mana.Fill()!.Value);
Assert.Equal("75/100", NumberText(mana));
}
// ── 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((VitalsController.Health, 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 ───────────────────────────────────────────────────────────────
/// <summary>The cur/max text from the centered <see cref="UiText"/> number that
/// <see cref="VitalsController"/> attaches as the meter's child.</summary>
private static string NumberText(UiMeter m)
{
var num = Assert.IsType<UiText>(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<uint, UiElement>();
var root = new UiPanel();
foreach (var (id, e) in items)
{
root.AddChild(e);
dict[id] = e;
}
return new ImportedLayout(root, dict);
}
}

View file

@ -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": []
}
]
}
]
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,78 @@
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 =
"<panel id=\"acdream.vitals\" x=\"10\" y=\"30\" w=\"220\" h=\"96\" title=\"Vitals\">" +
" <meter id=\"health\" x=\"8\" y=\"24\" w=\"200\" h=\"14\" fill=\"{HealthPercent}\" cur=\"{HealthCurrent}\" max=\"{HealthMax}\" color=\"#FFFF0000\"/>" +
"</panel>";
var panel = MarkupDocument.Build(xml, new FakeBinding(), _ => ((uint)1, 32, 32));
Assert.IsType<UiNineSlicePanel>(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<UiMeter>(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 =
"<panel id=\"v\" x=\"0\" y=\"0\" w=\"10\" h=\"10\" title=\"V\">" +
" <meter id=\"mana\" x=\"0\" y=\"0\" w=\"10\" h=\"2\" fill=\"{ManaPercent}\" cur=\"{ManaCurrent}\" max=\"{ManaMax}\" color=\"#FF0000FF\"/>" +
"</panel>";
var panel = MarkupDocument.Build(xml, new FakeBinding(), _ => ((uint)1, 32, 32));
var meter = Assert.IsType<UiMeter>(panel.Children[1]);
Assert.Null(meter.Fill());
Assert.Null(meter.Label());
}
[Fact]
public void Build_ResizeAttrX_SetsHorizontalOnly()
{
const string xml = "<panel id=\"v\" x=\"0\" y=\"0\" w=\"100\" h=\"50\" title=\"V\" resize=\"x\"></panel>";
var panel = MarkupDocument.Build(xml, new object(), _ => ((uint)1, 32, 32));
Assert.True(panel.ResizeX);
Assert.False(panel.ResizeY);
}
[Fact]
public void Build_ParsesNineSliceBarSpriteIds()
{
const string xml = "<panel id=\"v\" x=\"0\" y=\"0\" w=\"100\" h=\"50\" title=\"V\">" +
"<meter id=\"h\" x=\"0\" y=\"0\" w=\"100\" h=\"14\" fill=\"{HealthPercent}\" " +
"backleft=\"0x06001141\" backtile=\"0x06001140\" backright=\"0x0600113F\" " +
"frontleft=\"0x06001131\" fronttile=\"0x06001132\" frontright=\"0x06001133\"/>" +
"</panel>";
var panel = MarkupDocument.Build(xml, new FakeBinding(), _ => ((uint)7, 32, 32));
var meter = Assert.IsType<UiMeter>(panel.Children[1]);
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);
}
}

View file

@ -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);
}
}

View file

@ -0,0 +1,84 @@
using System.Collections.Generic;
using AcDream.App.UI;
using DatReaderWriter.Types;
namespace AcDream.App.Tests.UI;
/// <summary>
/// Pure pen-advance / MeasureWidth math for the retail dat font (no GL, no dat).
/// The advance per glyph is the retail
/// <c>HorizontalOffsetBefore + Width + HorizontalOffsetAfter</c>
/// (SurfaceWindow::DrawCharacter, acclient 0x00442c3a), accumulated across the
/// string the way the retail string loop does (0x00467ed4 edi_3 += var_98).
/// </summary>
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<char, FontCharDesc>
{
['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<char, FontCharDesc>
{
['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));
}
}

View file

@ -0,0 +1,72 @@
using AcDream.App.UI;
using Xunit;
namespace AcDream.App.Tests.UI;
public class UiFieldTests
{
[Fact]
public void InsertChar_AdvancesCaret()
{
var input = new UiField();
input.InsertChar('h'); input.InsertChar('i');
Assert.Equal("hi", input.Text);
Assert.Equal(2, input.CaretPos);
}
[Fact]
public void Backspace_DeletesBeforeCaret()
{
var input = new UiField();
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 UiField { 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 UiField { OnSubmit = _ => n++ };
input.Submit();
Assert.Equal(0, n);
}
[Fact]
public void History_UpDownBrowsesPreviousSubmissions()
{
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();
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 UiField { OnSubmit = _ => {} };
for (int i = 0; i < 150; i++) { input.InsertChar('x'); input.Submit(); }
Assert.True(input.HistoryCount <= 100);
}
}

View file

@ -0,0 +1,178 @@
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'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 not ChatChannelKind ch
|| ch is ChatChannelKind.Say or ChatChannelKind.General
or ChatChannelKind.Trade or ChatChannelKind.Lfg;
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<ChatChannelKind>(
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;
// 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)));
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;
// 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)));
Assert.Equal(ChatChannelKind.Trade, fired);
Assert.Equal(ChatChannelKind.Trade, menu.Selected);
}
[Fact]
public void Select_SpecialItem_FiresNull_LeavesSelectionUnchanged()
{
var menu = MakeMenu(); // Selected = Say
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
// 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.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]
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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -0,0 +1,239 @@
using System.Numerics;
using AcDream.App.UI;
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);
}
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()
{
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()
{
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);
}
[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()
{
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);
}
[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);
}
[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);
}
}

View file

@ -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);
}
}

View file

@ -0,0 +1,81 @@
using AcDream.App.UI;
using Xunit;
namespace AcDream.App.Tests.UI;
/// <summary>
/// Pure unit tests for <see cref="UiScrollbar.ThumbRect"/> — no GL dependency.
/// </summary>
public class UiScrollbarTests
{
// 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) = UiScrollbar.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) = UiScrollbar.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) = UiScrollbar.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) = UiScrollbar.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) = UiScrollbar.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) = UiScrollbar.ThumbRect(m, trackTop: 16f, trackLen: 100f);
Assert.Equal(100f, h, 3f);
Assert.Equal(16f, y, 3f); // travel = 0 → y = trackTop
}
}

View file

@ -0,0 +1,30 @@
using AcDream.App.UI;
using DatReaderWriter.Types;
using Xunit;
namespace AcDream.App.Tests.UI;
public class UiTextDatFontTests
{
// 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, UiText.CharIndexAt("abc", Adv, 4f));
Assert.Equal(1, UiText.CharIndexAt("abc", Adv, 12f));
Assert.Equal(3, UiText.CharIndexAt("abc", Adv, 100f));
}
[Fact]
public void GlyphAdvance_MatchesRetailFormula()
{
Assert.Equal(10f, UiDatFont.GlyphAdvance(Glyph('x')));
}
}

View file

@ -0,0 +1,143 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.UI;
namespace AcDream.App.Tests.UI;
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, 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, 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, UiText.ClampScroll(-50f, contentHeight: 500f, viewHeight: 200f));
}
// ── Char-index hit-testing (x → col) with a synthetic 10px monospace advance ──
private static readonly Func<char, float> Mono10 = static _ => 10f;
[Fact]
public void CharIndexAt_ZeroOrNegative_IsColumnZero()
{
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, 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, UiText.CharIndexAt("hello", Mono10, 1000f));
}
[Fact]
public void CharIndexAt_EmptyString_IsZero()
{
Assert.Equal(0, UiText.CharIndexAt("", Mono10, 50f));
}
// ── SelectedText assembly ────────────────────────────────────────────
private static IReadOnlyList<UiText.Line> Lines(params string[] texts)
{
var list = new List<UiText.Line>(texts.Length);
foreach (var t in texts)
list.Add(new UiText.Line(t, new Vector4(1, 1, 1, 1)));
return list;
}
[Fact]
public void SelectedText_SingleLine_Substring()
{
var lines = Lines("hello world");
var s = UiText.SelectedText(lines, new UiText.Pos(0, 6), new UiText.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 = UiText.SelectedText(lines, new UiText.Pos(0, 11), new UiText.Pos(0, 6));
Assert.Equal("world", s);
}
[Fact]
public void SelectedText_SamePosition_IsEmpty()
{
var lines = Lines("hello");
Assert.Equal("", UiText.SelectedText(lines, new UiText.Pos(0, 3), new UiText.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 = UiText.SelectedText(lines, new UiText.Pos(0, 6), new UiText.Pos(2, 5));
Assert.Equal("line\nsecond line\nthird", s);
}
[Fact]
public void SelectedText_MultiLine_TwoLines_NoMiddle()
{
var lines = Lines("alpha", "bravo");
var s = UiText.SelectedText(lines, new UiText.Pos(0, 2), new UiText.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 = 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("", UiText.SelectedText(Array.Empty<UiText.Line>(),
new UiText.Pos(0, 0), new UiText.Pos(0, 0)));
}
[Fact]
public void Order_SortsByLineThenColumn()
{
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) = 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);
}
}

View file

@ -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

View file

@ -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);
}
}

View file

@ -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));
}
}

View file

@ -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>(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);
}
}

12
tools/cdb/chat-colors.cdb Normal file
View file

@ -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

View file

@ -0,0 +1,6 @@
.echo ===COLOR_SYMS===
x acclient!color*
.echo ===CHATCOLOR_SYMS===
x acclient!*ChatColor*
.echo ===END===
qd