diff --git a/.gitignore b/.gitignore index 357fded9..215c618b 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/docs/ISSUES.md b/docs/ISSUES.md index b0f629ae..aa74fc83 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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 diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 11d8cabb..17d05de9 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -37,7 +37,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 1. Intentional architecture (IA) — 14 rows +## 1. Intentional architecture (IA) — 15 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -55,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. --- diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 79c7f4e5..411c5ac7 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -424,9 +424,12 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. **Sub-pieces:** - **D.1 — 2D ortho overlay + font rendering.** ✅ SHIPPED 2026-04-17 as the dev-facing debug overlay (StbTrueTypeSharp system-font atlas + `TextRenderer` + `DebugOverlay`). - **✓ SHIPPED — D.2a — ImGui scaffold + `AcDream.UI.Abstractions` layer.** Shipped 2026-04-25. Wires ImGui as the short-term backend behind `ACDREAM_DEVTOOLS=1`. Defines `IPanel` / `IPanelHost` / `IPanelRenderer` / `ICommandBus` + the first ViewModel (`VitalsVM`) in the new `AcDream.UI.Abstractions` project. First real panel: `VitalsPanel` reading HP from `CombatState.GetHealthPercent`. **Backend pivoted Hexa.NET.ImGui → ImGui.NET + `Silk.NET.OpenGL.Extensions.ImGui` during integration** — Hexa's native OpenGL3 backend does its own GL function resolution via GLFW/SDL and crashed with `0xC0000005` in `ImGuiImplOpenGL3.InitNative` against Silk.NET (no GLFW/SDL present). The Silk.NET extension is purpose-built for this scenario and is the `ImGui.NET` mitigation path that `docs/plans/2026-04-24-ui-framework.md` already called out as a "one-morning operation". Stam/Mana return `float?` null in D.2a because absolute values need `LocalPlayerState` + `PlayerDescription (0x0013)` parsing (filed post-D.2a). 11 new `AcDream.UI.Abstractions.Tests` green. -- **D.2b — Custom retail-look backend.** Implements the same `IPanel` / `IPanelRenderer` contracts using a custom retained-mode toolkit sourced from retail dat assets. Requires D.2a shipped. Panels get reskinned one at a time; ImGui stays as the `ACDREAM_DEVTOOLS=1` overlay forever. The original 2026-04-17 scaffold research (`UiRoot` / `UiElement` / `UiPanel` / `UiHost` + retail event codes + focus / drag-drop state machine + `WorldMouseFallThrough`) is the implementation foundation here — see `docs/research/retail-ui/`. +- **✓ SHIPPED — D.2b — Custom retail-look backend (Spec 1: markup engine + first panel).** Shipped 2026-06-14 (`626d06e`→`019350f`). Wired the dormant `UiHost`/`UiElement` tree into `GameWindow` (`ACDREAM_RETAIL_UI=1`) and built the **Approach-C markup engine**: `MarkupDocument` (XML → `UiElement` subtree, `{Binding}` reflection) + `ControlsIni` stylesheet loader + an `IUiRegistry` plugin UI surface (plugins ship markup + a binding, drained into the same `UiRoot`). First retail-faithful panel: a markup-driven Vitals window (`vitals.xml`) — 8-piece dat-sprite frame (`UiNineSlicePanel`) + three `UiMeter` bars (red/gold/blue + cur/max numbers) bound to `VitalsVM`, rendering live over the 3D world and coexisting with the ImGui devtools path. Retired divergence TS-30, added IA-15. Spec `docs/superpowers/specs/2026-06-14-d2b-retail-panel-frame-design.md`; plan `docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md`. NOTE the prove-out corrected two stale "facts": retail vitals are **bars not orbs**, stamina is **gold not cyan**. **Remaining: D.3 (AcFont — using the stb stopgap for now) + the glassy gradient bar sprite / brightness tune (polish); input integration (movable/clickable windows — the `UiRoot` drag/click machinery exists but isn't bridged to the Phase-K dispatcher yet); and the rest of the panels (D.5).** +- **✓ SHIPPED — D.2b LayoutDesc importer (Plan 1 + default flip).** Plan 1 shipped 2026-06-15. *Retroactive registration — the spec requested this phase but it was not pre-registered before implementation.* Data-driven vitals window from `LayoutDesc 0x2100006C`: `LayoutImporter` resolves `BaseElement`/`BaseLayoutId` inheritance, walks the `ElementDesc` tree, and builds a live `UiElement` tree via `DatWidgetFactory` (Type 7 → `UiMeter`; all others → `UiDatElement` generic renderer). `VitalsController` binds live HP/Stamina/Mana by element id (mirrors retail `gmVitalsUI`). A/B visual gate **PASSED**: pixel-identical to the hand-authored window. **Default flip shipped 2026-06-15 (`bf77a23`):** the importer is now the default vitals window at `ACDREAM_RETAIL_UI=1`; the hand-authored `vitals.xml` and the `ACDREAM_RETAIL_UI_IMPORTER` flag were retired (`vitals.xml` recoverable from git history). The window is movable (Anchors=None + Draggable) AND horizontally resizable (Resizable/ResizeX, `8aa643f`): the dat edge-anchors reflow the pieces on width change per retail `UIElement::UpdateForParentSizeChange @0x00462640` (an earlier "fixed-size" note was wrong — inverted edge-flag reading, corrected; stretch is `RightEdge==1`). Faithful grip/dragbar-*driven* drag/resize INPUT is Plan 2. Post-flip number-render fixes (`43064ba`, `34243f2`): submission-order sprite draw (stamina/mana numbers had been overpainted by their own bars) + glyph pixel-snap (sharp at all resize widths). `MarkupDocument`/`UiNineSlicePanel` remain for the chat window + plugin panels. Spec: `docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md`; plan: `docs/superpowers/plans/2026-06-15-layoutdesc-importer.md`. +- **✓ SHIPPED — D.2b LayoutDesc importer (Plan 2 — chat-window re-drive).** Shipped 2026-06-15. `GameWindow`'s hand-authored chat block (`UiNineSlicePanel` + inline `UiChatView`) replaced by `ChatWindowController.Bind(LayoutDesc 0x21000006, …)` — the same importer path as vitals. `ChatWindowController` places `UiChatView` (transcript) + `UiChatInput` (text entry + on-submit) + `UiChatScrollbar` (scrollbar thumb) + `UiChannelMenu` (channel selector) inside the dat-authored chrome; dead local statics `BuildRetailChatLines`/`RetailChatColor` deleted from `GameWindow` (moved into the controller). Wired to `_commandBus` (same `LiveCommandBus` as the ImGui `ChatPanel`) so type+Enter dispatches `SendChatCmd` server-ward. Transcript keyboard set from `_uiHost.Keyboard` for Ctrl+C/Ctrl+A. 392 tests green. Added divergence rows AD-28 / AP-38–40 / TS-30–31; updated IA-15. +- **✓ SHIPPED — D.2b LayoutDesc importer (Plan 2 — widget generalization).** Shipped 2026-06-16 (`b7f7e2b`→`89626cd`). The hand-named chat widgets became GENERIC, Type-registered widgets built by `DatWidgetFactory` (`1→UiButton`, `6→UiMenu`, `7→UiMeter`, `11→UiScrollbar`, `12→UiText`); `UiField` (editable) ships controller-placed. `ChatWindowController` + `VitalsController` collapsed to thin `gm*UI::PostInit`-style find-by-id binders — this is the reusable toolkit + assembly pattern the future inventory/vendor/spell-bar windows build on. New `UiElement.ConsumesDatChildren` leaf-widget rule: behavioral widgets reproduce their dat sub-elements procedurally, so the importer must not build their children (an invisible Menu label child was swallowing the button click → dropdown wouldn't open). **Type 3 deliberately NOT registered** → `UiField` (acdream's Type-3 elements are inert sprite-bearing chrome/containers → stay `UiDatElement`; a subagent's Type-3→`UiField` registration was reverted — it blanked the vitals bevel + masked the regression by weakening a test). The editable input resolves to Type 12 → controller-placed `UiField` (Variant B). Vitals numbers rewired to a centered `UiText` (Task 8) — `UiText.Centered` reuses the meter's former centering formula, pixel-identical. Both visual gates (chat + vitals) **user-confirmed**; 404 tests green; new `chat_21000006.json` golden fixture. Amended AP-37, narrowed AP-41, added AP-42. Spec/plan: `docs/superpowers/{specs,plans}/2026-06-16-d2b-widget-generalization*.md`. - **D.3 — AcFont from portal.dat.** Replace stb_truetype system font with retail `Font` DBObjs (`0x40000000..0x40000FFF`) baked from `RenderSurface` source sheets — see research slice 03 §4. Preserves retail visual identity. **(D.2b dependency — needs the custom renderer.)** -- **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.)** diff --git a/docs/research/2026-06-15-chat-window-redrive-handoff.md b/docs/research/2026-06-15-chat-window-redrive-handoff.md new file mode 100644 index 00000000..33d12e92 --- /dev/null +++ b/docs/research/2026-06-15-chat-window-redrive-handoff.md @@ -0,0 +1,135 @@ +# Chat-window re-drive — session handoff (2026-06-15) + +**Status:** brainstorm STARTED (context gathered, design questions open) — not yet +designed or implemented. Resume with `superpowers:brainstorming`. + +**Branch:** `claude/hopeful-maxwell-214a12` — **continue UI work HERE** (the user's +call: UI stays on this branch; dungeon lighting / M1.5 goes to a *separate* branch +off `main`, it's unrelated and easy to merge). This branch is already current with +`main` (merged `5ac9d8c`). + +--- + +## Where we are (what shipped this session) + +**D.2b LayoutDesc importer — Plan 1 SHIPPED + flipped to default + post-flip fixes.** +The vitals window is now data-driven from the dat `LayoutDesc 0x2100006C` (no +per-window graphics code). Read **`claude-memory/project_d2b_retail_ui.md`** (the +SSOT crib) FIRST — it has the full architecture + every correction. Key commits: + +- `bf77a23` — flip: importer is the default vitals at `ACDREAM_RETAIL_UI=1`; the + hand-authored `vitals.xml` + the `ACDREAM_RETAIL_UI_IMPORTER` flag were retired. +- `8aa643f` — horizontal resize: edge-anchor mapping corrected to retail + `UIElement::UpdateForParentSizeChange @0x00462640` (`RightEdge==1`=stretch). +- `43064ba` — stamina/mana numbers: `TextRenderer` now draws sprites in + **submission (painter) order** (was per-texture batched → later bars overpainted + their own numbers). +- `34243f2` — number sharpness: `DrawStringDat` **pixel-snaps** each glyph dest. + +**The importer toolkit to REUSE (all in `src/AcDream.App/UI/Layout/`):** +- `ElementReader` — `ElementInfo` POCO + `Merge` (BaseElement/BaseLayoutId + inheritance) + `ToAnchors` (edge-flag → AnchorEdges, decomp-correct). +- `UiDatElement` — generic per-DrawMode sprite renderer (the fallback widget). +- `DatWidgetFactory` — `Type → widget` hybrid: Type 7→`UiMeter`, 12→skip, else + generic; sets rect + anchors + `ZOrder=ReadOrder`. **Behavioral Types map to a + dedicated widget; the widget CONSUMES the element's children (leaf — importer + does not recurse them).** This is the pattern the chat re-drive extends. +- `LayoutImporter` — `Import`/`ImportInfos`/`Build`/`BuildFromInfos` + cycle-guarded + `Resolve`. `ImportedLayout.FindElement(id)` for binding by id. +- `VitalsController` — binds live data to widgets by element id (mirrors retail + `gmVitalsUI::PostInit`). The chat controller will mirror this. +- Format reference: **`docs/research/2026-06-15-layoutdesc-format.md`** (ElementDesc + API, Type table, DrawMode, inheritance). NOTE its §4 edge-flag history: the FIRST + reading was inverted; the CORRECT model (per `@0x00462640`) is now in the doc + + `ToAnchors` — `RightEdge==1`=stretch, `LeftEdge==2`=track-right. + +--- + +## Next task: re-drive the chat window through the importer (Plan 2 chat piece) + +Today the chat window is **hand-authored**, not data-driven. The goal mirrors the +vitals re-drive: read the chat window's dat `LayoutDesc`, build it via +`LayoutImporter`, and bind the live chat through a `ChatController`. + +### Current chat window (what to reproduce / replace) +- Built in `src/AcDream.App/Rendering/GameWindow.cs` in the `if (_options.RetailUi)` + block (~line 1836, "Retail chat window"). +- `UiNineSlicePanel` (hand-authored 8-piece chrome) at `(10,432)`, `440×184`, + `MinWidth 180 / MinHeight 80`, draggable + resizable. +- Hosts a `UiChatView` (`src/AcDream.App/UI/UiChatView.cs`): scrollable transcript, + **bottom-pinned**, mouse-wheel scrollback, **drag-select + Ctrl+C copy + Ctrl+A**, + whole-line vertical clipping. **READ-ONLY** (no input box). Uses the **debug + bitmap font** (`_debugFont`), NOT the dat font. `LinesProvider` polled each frame. +- Data: `ChatVM` (`displayLimit: 200`) → `RecentLinesDetailed()` → per-`ChatKind` + colour via `RetailChatColor(...)` (local static in GameWindow). + +### Chat pipeline (already shipped, Phase I — reuse, don't rebuild) +`ChatLog (Core) → ChatVM (UI.Abstractions) → view`; outbound `input → +ChatInputParser → LiveCommandBus → WorldSession`. See +`claude-memory/project_chat_pipeline.md`. The re-drive is a VIEW/layout change; the +pipeline stays. + +### Retail chat UI classes (decomp oracles — analogous to gmVitalsUI) +`gmMainChatUI`, `gmFloatyMainChatUI`, `gmFloatyChatUI`, `gmChatOptionsUI` +(`docs/research/named-retail/acclient.h` ~line 54923; `symbols.json` has +`gmMainChatUI::Register` etc.). Chat-layout notes: +`docs/research/retail-ui/05-panels.md:120` (chat window layout) + +`06-hud-and-assets.md:651` (every chat window layout is a `LayoutDesc`). + +### FIRST research step (the Task-1 analogue): identify the chat `LayoutDesc` id +The vitals id was `0x2100006C`; the chat window's id is **NOT yet known**. Find it: +- `dump-vitals-layout [0xId]` enumerates LayoutDescs (it already lists all + layouts containing given element ids). Use it to scan for the chat window, or grep + the decomp for the layout id referenced by `gmMainChatUI`/`gmFloatyMainChatUI`. +- Then dump it and enumerate its element Types (expect a scroll/list region + + scrollbar, maybe a text-input/edit element + channel tabs) — this drives the + factory/widget work. + +--- + +## Open design questions (resume the brainstorm here) + +1. **Scope.** Re-drive the EXISTING read-only window (frame from dat + reuse + `UiChatView` for the transcript, parity with today), OR expand to the FULL retail + chat (input box for typing, channel tabs)? Recommendation to discuss: do the + frame re-drive + transcript first (parity), defer input/tabs to a follow-up — + but confirm with the user. +2. **Behavioral widgets.** The chat dat layout introduces the long-tail Types the + vitals didn't have — Type 5 `ListBox`, Type 0xB `Scrollbar`, maybe Type 0xC + `Text`/edit. Two options: + - **(A, recommended) Hybrid reuse** — like Type-7→`UiMeter`: map the transcript + region's Type → the existing `UiChatView` (which already scrolls/selects/copies); + a `ChatController` binds the tail by element id. Minimal new code; fastest parity. + - **(B) Port faithful widgets** — implement `UiScrollbar`/`UiListBox` per the + decomp so the dat's scrollbar element drives scrolling. More faithful, more work; + better as a later step. +3. **Dat font for the transcript.** Switch `UiChatView` from the debug bitmap font + to the dat font (`UiDatFont`, faithful + now pixel-snapped) — OR keep the debug + font for parity first? `UiChatView`'s measure/selection logic is `BitmapFont`-based, + so a dat-font port is non-trivial (a `UiDatFont` measure/advance path + selection + hit-test rework). Likely a follow-up, not the first cut. + +--- + +## Watchouts / lessons (don't regress these) +- **`TextRenderer` draws sprites in submission order** (`_spriteSegs`). Do NOT revert + to per-texture batching — it overpaints later same-atlas text (the stamina/mana bug). +- **`DrawStringDat` pixel-snaps glyphs.** Keep it (sharp text on resize). +- **Edge-flag/anchor model is `@0x00462640`** (`RightEdge==1`=stretch). The format + doc §4's first reading was inverted; trust the corrected `ToAnchors`. +- **Behavioral widgets are leaf** — the factory's widget consumes the element's dat + children; the importer doesn't recurse into them. Apply the same to the chat + transcript widget. +- **Don't fabricate dat reader internals** — `Chorizite.DatReaderWriter` is a NuGet + package (not in `references/`); verify member names via the dump tool / reflection. + +## Process for the next session +1. Read `claude-memory/project_d2b_retail_ui.md`, this handoff, and + `docs/research/2026-06-15-layoutdesc-format.md`. +2. Resume `superpowers:brainstorming` — settle scope + behavioral-widget approach + (the 3 questions above), present a design, write the spec. +3. Then `superpowers:writing-plans` → `superpowers:subagent-driven-development` + (same flow that shipped the vitals importer cleanly). +4. Stay on `claude/hopeful-maxwell-214a12`. Visual checks: launch live (ACE on + `127.0.0.1:9000`) with `ACDREAM_RETAIL_UI=1`; test accounts `testaccount2 / + testpassword2` or `notan / MittSnus81!` (character `+Je`). diff --git a/docs/research/2026-06-15-layoutdesc-format.md b/docs/research/2026-06-15-layoutdesc-format.md new file mode 100644 index 00000000..e3fb8b45 --- /dev/null +++ b/docs/research/2026-06-15-layoutdesc-format.md @@ -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 2–6. Where it corrects a plan assumption, the correction is called out in **§ Corrections to plan assumptions** at the end. + +--- + +## 1. `ElementDesc` — exact API + +All members are **public fields** (not properties), except `ElementId`, `Type`, `BaseElement`, `BaseLayoutId`, `DefaultState`, `ReadOrder` which are also fields. There are no `ElementDesc` properties used by the importer. + +| Member | Kind | Type | Notes | +|--------|------|------|-------| +| `ElementId` | **field** | `uint` | unique element id (e.g. `0x100000E6`) | +| `Type` | **field** | `uint` | element class id — **not an enum in DRW**; raw uint | +| `BaseElement` | **field** | `uint` | base element id in base layout (0 = no base) | +| `BaseLayoutId` | **field** | `uint` | layout id where base element lives (0 = no base) | +| `DefaultState` | **field** | `UIStateId` (enum) | the element's initial active state | +| `ReadOrder` | **field** | `uint` | draw order within parent | +| `X` | **field** | `uint` | left position within parent, in pixels | +| `Y` | **field** | `uint` | top position within parent, in pixels | +| `Width` | **field** | `uint` | pixel width | +| `Height` | **field** | `uint` | pixel height | +| `ZLevel` | **field** | `uint` | z-order (0 in all vitals elements) | +| `LeftEdge` | **field** | `uint` | left anchor flag (see §4) | +| `TopEdge` | **field** | `uint` | top anchor flag (see §4) | +| `RightEdge` | **field** | `uint` | right anchor flag (see §4) | +| `BottomEdge` | **field** | `uint` | bottom anchor flag (see §4) | +| `StateDesc` | **field** | `StateDesc?` | the element's "DirectState" (no name); null if absent | +| `States` | **field** | `Dictionary` | named states (e.g. `HideDetail`, `ShowDetail`) | +| `Children` | **field** | `Dictionary` | child elements keyed by their `ElementId` | + +**Important:** `X`, `Y`, `Width`, `Height`, `LeftEdge`, etc. are all `uint`, not `int` or `float`. Cast to `float`/`int` when constructing `ElementInfo`. + +The dump tool iterates both properties and fields; the scalars (`X`, `Y`, etc.) are found as **fields**. + +--- + +## 2. `StateDesc` — exact API + +| Member | Kind | Type | Notes | +|--------|------|------|-------| +| `StateId` | **field** | `uint` | redundant with the dict key | +| `PassToChildren` | **field** | `bool` | | +| `IncorporationFlags` | **field** | `IncorporationFlags` | | +| `Properties` | **field** | `Dictionary` | keyed by property-id (uint); see §3 | +| `Media` | **field** | `List` | polymorphic list of media items | + +### States dictionary key type + +`ElementDesc.States` is `Dictionary`. The dump shows string names like `"HideDetail"` and `"ShowDetail"` because the dump tool calls `.Key.ToString()` on the `UIStateId` enum values. The actual key is a `UIStateId` enum: + +```csharp +// Key: UIStateId.HideDetail = 268435462 (0x10000006) +// Key: UIStateId.ShowDetail = 268435463 (0x10000007) +``` + +See §6 for the full `UIStateId` enum. + +**Iterating in code:** +```csharp +foreach (var s in d.States) + ReadState(s.Value, s.Key.ToString(), info); // s.Key is UIStateId; .ToString() gives "HideDetail" etc. +``` + +--- + +## 3. Properties (`StateDesc.Properties`) — how font DID and fill are stored + +`StateDesc.Properties` is `Dictionary`. The `BaseProperty` base class has: +- `BasePropertyType PropertyType` (enum) +- `uint MasterPropertyId` +- `bool ShouldPackMasterPropertyId` + +Concrete subclasses (`DatReaderWriter.Types.*`): + +| Subclass | Field | Type | Notes | +|----------|-------|------|-------| +| `BoolBaseProperty` | `Value` | `bool` | | +| `IntegerBaseProperty` | `Value` | `int` | | +| `FloatBaseProperty` | `Value` | `float` | | +| `EnumBaseProperty` | `Value` | `uint` | | +| `DataIdBaseProperty` | `Value` | `uint` | a dat object DID | +| `ArrayBaseProperty` | `Value` | `List` | array of sub-properties | +| `ColorBaseProperty` | `Value` | `ColorARGB` | `struct { byte Blue, Green, Red, Alpha }` | +| `StringInfoBaseProperty` | `Value` | `StringInfo` | | +| `VectorBaseProperty` | `Value` | `Vector3` | | +| `Bitfield32BaseProperty` | `Value` | `uint` | | +| `Bitfield64BaseProperty` | `Value` | `ulong` | | +| `InstanceIdBaseProperty` | `Value` | `uint` | | +| `StructBaseProperty` | `Value` | `Dictionary` | | + +### Property key meanings (confirmed from decomp + dat inspection) + +| Key | Type found in dat | Meaning | Decomp ref | +|-----|-------------------|---------|-----------| +| `0x1A` | `ArrayBaseProperty` (contains `DataIdBaseProperty`) | **Font DID** — array with one item; the inner `DataIdBaseProperty.Value` is the font dat object id | `UIElement_Text::SetFontDIDHelper(this, 0x1a, ...)` @`0x46829e` | +| `0x1B` | `ArrayBaseProperty` (contains `ColorBaseProperty`) | **Font color** — array with one item; `ColorARGB {R,G,B,A}` | `UIElement_Text::SetFontColorHelper(this, 0x1b, ...)` @`0x4682c2` | +| `0x14` | `EnumBaseProperty` | **Horizontal justification** | `UIElement_Text::SetHorizontalJustification` @`0x467200` | +| `0x15` | `EnumBaseProperty` | **Vertical justification** | `UIElement_Text::SetVerticalJustification` @`0x467230` | +| `0x1C` / `0x1D` | `ArrayBaseProperty` | Tag font color / tag font | (secondary font style for in-text tags) | +| `0x16` | `BoolBaseProperty` | Some text flag | | +| `0x21` | `BoolBaseProperty` | One-line mode | | +| `0x23` | `IntegerBaseProperty` | Left margin | | +| `0x24` | `IntegerBaseProperty` | Top margin | | +| `0x25` | `IntegerBaseProperty` | Right margin | | +| `0x26` | `IntegerBaseProperty` | Bottom margin | | +| `0x27` | `BoolBaseProperty` | Some text option | | +| `0x20` | `BoolBaseProperty` | Some text option | | +| `0x69` | — (NOT in dat) | **Fill percent** — set at runtime via `UIElement::SetAttribute_Float(meter, 0x69, fillRatio)` | `gmVitalsUI::Update` @`0x4bff2a` | +| `0xCB` | `BoolBaseProperty` | Some text option | | + +**Critical point for font DID extraction:** +Property `0x1A` is an `ArrayBaseProperty` containing ONE `DataIdBaseProperty`. To read the font DID: +```csharp +if (sd.Properties.TryGetValue(0x1Au, out var raw) && raw is ArrayBaseProperty arr && arr.Value.Count > 0) + if (arr.Value[0] is DataIdBaseProperty did) + fontDid = did.Value; // e.g. 0x40000000 +``` + +**Confirmed for element `0x10000376` (the vitals text prototype):** +- Property `0x1A` → `DataIdBaseProperty.Value = 0x40000000` (font DID) +- Property `0x1B` → `ColorBaseProperty.Value = {B=255,G=255,R=255,A=255}` (white) + +**The fill (`0x69`) is NOT in the dat.** It is pushed at runtime by `gmVitalsUI::Update` calling `UIElement::SetAttribute_Float(meter, 0x69, ratio)`. The importer does not read this from the dat — the `VitalsController` sets it via `UiMeter.Fill` after binding. + +--- + +## 4. Edge-anchor flags (`LeftEdge`/`TopEdge`/`RightEdge`/`BottomEdge`) + +These are `uint` fields on `ElementDesc`. The values found across all four vitals layouts are: + +| Value | Meaning | Where observed | +|-------|---------|---------------| +| `0` | Not present / no constraint | Base layout `0x2100003F` (zero-size elements) | +| `1` | **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 108459–108668. + +**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`. The concrete types found across the vitals layouts: + +| Subclass | Fields | Used in vitals? | Notes | +|----------|--------|----------------|-------| +| `MediaDescImage` | `uint File`, `DrawModeType DrawMode`, `MediaType Type` | YES — all sprite images | The primary media type | +| `MediaDescCursor` | `uint File`, `uint XHotspot`, `uint YHotspot`, `MediaType Type` | YES — grip/dragbar cursor | Sets the mouse cursor when hovering the element | +| `MediaDescAnimation` | `float Duration`, `DrawModeType DrawMode`, `List Frames`, `MediaType Type` | not in vitals | Animated sprite | +| `MediaDescAlpha` | `uint File`, `MediaType Type` | not in vitals | Alpha overlay | +| `MediaDescFade` | `float StartAlpha, EndAlpha, Duration`, `MediaType Type` | not in vitals | Fade transition | +| `MediaDescSound` | `uint File`, ... | not in vitals | | +| `MediaDescState` | `UIStateId StateId`, ... | not in vitals | State transition | +| `MediaDescJump` | `uint JumpItemIndex`, ... | not in vitals | | +| `MediaDescMessage` | `uint Id`, ... | not in vitals | | +| `MediaDescPause` | `float MinDuration, MaxDuration`, ... | not in vitals | | +| `MediaDescMovie` | `PStringBase FileName`, ... | not in vitals | | + +Elements can have **multiple media items** in the same `StateDesc.Media` list — e.g. a grip element has both a `MediaDescImage` (the sprite) and a `MediaDescCursor` (the cursor shape). Iterate all items; for rendering pick the `MediaDescImage`; for cursor behavior pick `MediaDescCursor`. + +--- + +## 6. `DrawModeType` enum (confirmed from reflection) + +`DatReaderWriter.Enums.DrawModeType` (the type on `MediaDescImage.DrawMode`): + +| Name | Value | Behavior | Used in vitals? | +|------|-------|----------|----------------| +| `Undefined` | 0 | (not used) | no | +| `Normal` | 1 | **Tile at native width** (UV-repeat; matches `ImgTex::TileCSI` @`0x53e740`) | YES — all bar sprites, chrome | +| `Overlay` | 2 | Blended overlay (not observed in vitals) | no | +| `Alphablend` | 3 | **Blended overlay** — used for the "ShowDetail" expand panels | YES — `ShowDetail` state sprites | + +**The vitals window uses only `Normal` (1) and `Alphablend` (3).** No `Stretch` value exists in `DrawModeType` — the plan's mention of a "Stretch" draw-mode is NOT a value in this enum. There is a `MediaType.Stretch = 12` in a separate enum but that refers to a different concept (animation sequence? not a blit mode). Do not branch on `Stretch` in `UiDatElement`. + +--- + +## 7. `UIStateId` enum (key type for `ElementDesc.States`) + +`DatReaderWriter.Enums.UIStateId`. Key values relevant to the vitals window: + +| Name | Value | +|------|-------| +| `Undef` | 0 | +| `Normal` | 1 | +| `HideDetail` | 268435462 (= `0x10000006`) | +| `ShowDetail` | 268435463 (= `0x10000007`) | +| `IsCharacter` | 268435542 (= `0x10000056`) | +| `IsAccount` | 268435543 (= `0x10000057`) | + +The dump prints these as strings ("HideDetail", "ShowDetail") via `UIStateId.ToString()`. When iterating `d.States`, `s.Key.ToString()` gives the readable name. + +--- + +## 8. Type → meaning → render method → widget bucket + +From `UIElement::RegisterElementClass` calls in the decomp. The mapping is CONFIRMED by retail: + +| Type (uint) | Class registered | Render method | Widget bucket | Vitals? | +|-------------|-----------------|---------------|---------------|---------| +| 0 | — (no registration) | text label; inherits from `UIElement_Text` behavior via `UIElement_Scrollable` | **behavioral** → dat-font label widget | YES — the text overlay (e.g. `0x100000EB/ED/EF`) | +| 1 | `UIElement_Button::Register()` | `UIRegion::DrawHere` (vtable) | **behavioral** → button widget | no | +| 2 | `UIElement_Dragbar::Register()` | `UIRegion::DrawHere` | **generic** → `UiDatElement` (drag region) | YES — top/bottom drag bars | +| 3 | `UIElement_Field::Register()` | `UIRegion::DrawHere` | **generic** → `UiDatElement` | YES — container/group elements, chrome corners/edges | +| 4 | (unregistered in stdlib; may be custom) | — | generic fallback | no | +| 5 | `UIElement_ListBox::Register()` | `UIRegion::DrawHere` | **behavioral** → list widget | no | +| 6 | `UIElement_Menu::Register()` | `UIRegion::DrawHere` | **behavioral** → menu widget | no | +| **7** | `UIElement_Meter::Register()` | **`UIElement_Meter::DrawChildren`** @`0x46fbd0` | **behavioral** → `UiMeter` | **YES — the three vitals bars** | +| 8 | `UIElement_Panel::Register()` | `UIRegion::DrawHere` | generic → `UiDatElement` | no | +| 9 | `UIElement_Resizebar::Register()` | `UIRegion::DrawHere` | **generic** → `UiDatElement` (grip) | YES — resize grips (corners + edges) | +| 0xB | `UIElement_Scrollbar::Register()` | `UIRegion::DrawHere` | **behavioral** → scrollbar | no | +| **0xC** | `UIElement_Text::Register()` | `UIElement_Text::DrawSelf` @`0x467aa0` | **behavioral** → dat-font label | YES — Type=0 elements have BaseElement which resolves to a Type=0x0C in the base | +| 0xD | `UIElement_Viewport::Register()` | — | behavioral → 3D viewport | no | +| 0xE | `UIElement_Browser::Register()` | — | behavioral → browser | no | +| 0x10 | `UIElement_ColorPicker::Register()` | — | behavioral → color picker | no | +| 0x11 | `UIElement_GroupBox::Register()` | — | behavioral → group box | no | +| **0x12** | — (Type=12 in base layout) | No render method registered — these are **style prototypes** (zero-size elements used as `BaseElement` sources, never instantiated directly) | skip/omit | YES — `0x2100003F` is full of Type=12 elements | +| 0x13–0x19 | `ConfirmationDialog*` / `MessageDialog*` / etc. | dialog widgets | behavioral → dialog | no | +| 0x1000xxxx | `gmVitalsUI`, `gmAttributeUI`, etc. | game-specific custom classes | **custom widget** (registered with high ids) | YES — the stacked vitals window root `0x100005F9` has `Type=268435533=0x10000009`; the floaty row root has Type=`268435465=0x10000009`… actually see below | + +### Root element types in the vitals layouts + +- `0x2100006C` root element `0x100005F9`: `Type = 268435533 = 0x10000009` → `gmVitalsUI::Register` registers type `0x10000009` +- `0x21000014` root element `0x100000E5`: `Type = 268435465 = 0x10000009` — wait, `268435465 = 0x10000009` ✓ + +Actually: `268435533 = 0x1000000D` (not 9). Let me recompute: +- `268435533 decimal`: `268435456 + 77 = 0x10000000 + 0x4D = 0x1000004D` — that's `gmVitalsUI`-ish but a different id. +- `268435465`: `268435456 + 9 = 0x10000009` — confirmed `gmVitalsUI` type. + +The correct decomp cross-check: `UIElement::RegisterElementClass(0x10000009, gmVitalsUI::Create)` @`0x4bfe1a`. The stacked vitals window root `0x100005F9` has `Type=268435533`. `268435533 = 0x1000004D` which would be a different registered type. The floaty row root `0x100000E5` has `Type=268435465 = 0x10000009` = confirmed `gmVitalsUI`. + +The key observation: **the root element's Type selects the `gmVitalsUI` C++ class**, which is the window-level controller. In our importer, we don't need to match this: the `LayoutImporter` walks children, and the `VitalsController` binds the meter elements by id directly — the root type is irrelevant to Plan 1. + +**Plan 1 relevant types (vitals window only):** + +| Type | Role | Bucket | +|------|------|--------| +| 0 | text overlay label (BaseElement → Type 12 for font, but the element itself renders as text) | behavioral → dat-font label | +| 2 | drag bar (top/bottom) | generic | +| 3 | container / chrome edge / corner (no children hierarchy in vitals) | generic | +| 7 | meter | behavioral → `UiMeter` | +| 9 | resize grip (corners + edges) | generic | +| 12 | style prototype — zero-size, never directly rendered | skip | +| 0x10000009 | `gmVitalsUI` root — the window itself | behavioral → window root (use as container) | +| 0x1000004D | the stacked-window root | same | + +--- + +## 9. `LayoutDesc` fields + +| Member | Kind | Type | Notes | +|--------|------|------|-------| +| `Id` | property | `uint` | dat object id | +| `HeaderFlags` | property | `DBObjHeaderFlags` | | +| `DBObjType` | property | `DBObjType` | always `LayoutDesc` | +| `DataCategory` | property | `uint` | | +| `Width` | **field** | `uint` | screen-space width context (800 in all observed layouts) | +| `Height` | **field** | `uint` | screen-space height context (600 in all observed layouts) | +| `Elements` | **field** | `HashTable` (DRW-internal type) | top-level elements, keyed by `ElementId`. Iterable with `foreach (var kv in ld.Elements)`. | + +--- + +## 10. Inheritance chain for vitals number-text elements + +All three vitals text labels (`0x100000EB` health, `0x100000ED` stamina, `0x100000EF` mana) share: +- `Type = 0` (text element, no render registration — renders via inherited machinery) +- `BaseElement = 268436342 = 0x10000376` +- `BaseLayoutId = 553648191 = 0x2100003F` + +The base element `0x10000376` in `0x2100003F`: +- `Type = 12` (style prototype — zero-size, never rendered directly) +- `StateDesc.Properties`: + - `0x1A` → `ArrayBaseProperty[ DataIdBaseProperty{Value=0x40000000} ]` — **font DID = `0x40000000`** + - `0x1B` → `ArrayBaseProperty[ ColorBaseProperty{R=255,G=255,B=255,A=255} ]` — white + - `0x14` → `EnumBaseProperty{Value=1}` — horizontal justification = 1 + - `0x15` → `EnumBaseProperty{Value=1}` — vertical justification = 1 + - `0x23`, `0x25` → `IntegerBaseProperty{Value=0}` — margins + +The inheritance chain for the text element in the importer is: +``` +derived (Type=0, no StateDesc media, no font prop itself) + inherits from base 0x10000376 in layout 0x2100003F (Type=12) + → font DID = 0x40000000 (from property 0x1A) + → font color = white ARGB(255,255,255,255) (from property 0x1B) +``` + +The derived text element overrides `Width/Height/X/Y` (from the dat element's fields) but inherits the font DID and color from the base element's `Properties`. + +**There is no `StateDesc.Media` on the text elements** — the text is rendered by the `UIElement_Text::DrawSelf` algorithm using the font DID from properties, not a sprite. In Plan 1, the text elements 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 (0x13–0x19), all `gm*UI` custom types +- DrawModes: `Overlay` (2), any future additions +- Media: `MediaDescAnimation`, `MediaDescFade`, `MediaDescSound`, `MediaDescState`, etc. diff --git a/docs/research/2026-06-19-lighting-a7-fixD-round2-torch-reach-CHECKPOINT.md b/docs/research/2026-06-19-lighting-a7-fixD-round2-torch-reach-CHECKPOINT.md index a9baf4ac..e221a636 100644 --- a/docs/research/2026-06-19-lighting-a7-fixD-round2-torch-reach-CHECKPOINT.md +++ b/docs/research/2026-06-19-lighting-a7-fixD-round2-torch-reach-CHECKPOINT.md @@ -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; diff --git a/docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md b/docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md new file mode 100644 index 00000000..5fff7b20 --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md @@ -0,0 +1,1322 @@ +# D.2b Retail Panel Frame + Live Vitals — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Render a retail-shaped Vitals window (8-piece dat-sprite frame + live HP/Stam/Mana bars) by wiring the dormant `AcDream.App/UI` retained-mode toolkit and adding a markup/stylesheet/sprite layer, gated behind `ACDREAM_RETAIL_UI=1`. + +**Architecture:** The retail UI is the **existing `UiRoot`/`UiElement` tree** driven by `UiHost` (dormant today) — a separate system from the ImGui devtools path. Spec 1 wires `UiHost` into `GameWindow`, extends the shared `TextRenderer` with a textured-sprite path, adds `UiNineSlicePanel` (chrome) + `UiMeter` (bar) widgets, a `MarkupDocument` that instantiates a `UiElement` subtree from XML, and a `controls.ini` stylesheet loader. Render-only (input integration deferred). Spec: [`docs/superpowers/specs/2026-06-14-d2b-retail-panel-frame-design.md`](../specs/2026-06-14-d2b-retail-panel-frame-design.md). + +**Tech Stack:** C# / .NET 10, Silk.NET OpenGL, xUnit 2.9.3. Dat assets via the existing `TextureCache` + `SurfaceDecoder`. + +--- + +## File Structure + +**New files:** +- `src/AcDream.App/UI/UiNineSlicePanel.cs` — `UiPanel` subclass drawing the 8-piece dat-sprite frame + center fill. +- `src/AcDream.App/UI/UiMeter.cs` — `UiElement` vital bar (bg + partial fill). +- `src/AcDream.App/UI/RetailChromeSprites.cs` — confirmed chrome sprite DataIDs + sizes + insets (filled by Step 0). +- `src/AcDream.App/UI/ControlsIni.cs` — flat INI stylesheet parser (`#AARRGGBB`, `font://`). +- `src/AcDream.App/UI/MarkupDocument.cs` — XML → `UiElement` subtree builder + `{Binding}` resolution. +- `src/AcDream.App/UI/assets/vitals.xml` — the first-party vitals markup (copied to output). +- `src/AcDream.Plugin.Abstractions/IUiRegistry.cs` — plugin-facing UI registration surface. +- `src/AcDream.App/Plugins/BufferedUiRegistry.cs` — buffers `AddMarkupPanel` until `UiHost` exists. +- `tests/AcDream.App.Tests/UI/ControlsIniTests.cs`, `MarkupDocumentTests.cs`, `UiMeterTests.cs`, `UiNineSlicePanelTests.cs` +- `tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs` +- `tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs` + +**Modified files:** +- `src/AcDream.App/RuntimeOptions.cs` — add `RetailUi`, `AcDir`. +- `src/AcDream.App/Rendering/Shaders/ui_text.frag` — add `uUseTexture==2` RGBA branch. +- `src/AcDream.App/Rendering/TextRenderer.cs` — add `DrawSprite` + per-texture batch + `DepthMask`. +- `src/AcDream.App/Rendering/TextureCache.cs` — add `GetOrUpload(id, out w, out h)` size overload. +- `src/AcDream.App/UI/UiRenderContext.cs` — add `DrawSprite` forwarder. +- `src/AcDream.App/Rendering/GameWindow.cs` — wire `UiHost` + vitals subtree (render-only). +- `src/AcDream.Plugin.Abstractions/IPluginHost.cs` + `src/AcDream.App/Plugins/AppPluginHost.cs` — add `Ui`. +- `src/AcDream.App/Program.cs` — construct `BufferedUiRegistry`, pass to host + window. +- `docs/architecture/retail-divergence-register.md` — delete TS-30, add IA row (in the chrome commit). + +--- + +## Task 1: RuntimeOptions — add RetailUi + AcDir toggles + +**Files:** +- Modify: `src/AcDream.App/RuntimeOptions.cs` +- Test: `tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs`: + +```csharp +using System.Collections.Generic; +using AcDream.App; + +namespace AcDream.App.Tests; + +public class RuntimeOptionsRetailUiTests +{ + [Fact] + public void Parse_ReadsRetailUiAndAcDir() + { + var env = new Dictionary + { + ["ACDREAM_RETAIL_UI"] = "1", + ["ACDREAM_AC_DIR"] = @"C:\Turbine\Asheron's Call", + }; + var opts = RuntimeOptions.Parse("dats", k => env.GetValueOrDefault(k)); + Assert.True(opts.RetailUi); + Assert.Equal(@"C:\Turbine\Asheron's Call", opts.AcDir); + } + + [Fact] + public void Parse_DefaultsRetailUiOffAndAcDirNull() + { + var opts = RuntimeOptions.Parse("dats", _ => null); + Assert.False(opts.RetailUi); + Assert.Null(opts.AcDir); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter RuntimeOptionsRetailUiTests` +Expected: FAIL to **compile** — `RetailUi` / `AcDir` are not members of `RuntimeOptions`. + +- [ ] **Step 3: Add the fields** + +In `src/AcDream.App/RuntimeOptions.cs`, add two parameters at the **end** of the record (line 42, after `int? LegacyStreamRadius`): + +```csharp + int? LegacyStreamRadius, + bool RetailUi, + string? AcDir) +``` + +And in `Parse` (after the `LegacyStreamRadius:` line, before the closing `);`): + +```csharp + LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")), + RetailUi: IsExactlyOne(env("ACDREAM_RETAIL_UI")), + AcDir: NullIfEmpty(env("ACDREAM_AC_DIR"))); +``` + +- [ ] **Step 4: Fix any positional construction sites** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj` +If any `new RuntimeOptions(...)` positional call site fails to compile (missing 2 args), append `, RetailUi: false, AcDir: null` to it. (`Program.cs` uses `FromEnvironment`→`Parse` with named args and is unaffected.) + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter RuntimeOptionsRetailUiTests` +Expected: PASS (2 tests). + +- [ ] **Step 6: Commit** + +```bash +git add src/AcDream.App/RuntimeOptions.cs tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs +git commit -m "feat(D.2b): RuntimeOptions.RetailUi + AcDir toggles" +``` + +--- + +## Task 2: Dat-sprite render capability + +GL code — verified by build + the Step-3 visual, not unit tests. + +**Files:** +- Modify: `src/AcDream.App/Rendering/Shaders/ui_text.frag` +- Modify: `src/AcDream.App/Rendering/TextRenderer.cs` +- Modify: `src/AcDream.App/Rendering/TextureCache.cs` +- Modify: `src/AcDream.App/UI/UiRenderContext.cs` + +- [ ] **Step 1: Add the RGBA branch to the fragment shader** + +In `src/AcDream.App/Rendering/Shaders/ui_text.frag`, replace the `main()` body's branch: + +```glsl +void main() { + if (uUseTexture == 1) { + // Font atlas is a single-channel R8 texture; red = coverage alpha. + float coverage = texture(uTex, vUv).r; + FragColor = vec4(vColor.rgb, vColor.a * coverage); + } else if (uUseTexture == 2) { + // RGBA dat sprite (decoded to RGBA8); modulate by tint/alpha. + FragColor = texture(uTex, vUv) * vColor; + } else { + FragColor = vColor; + } + if (FragColor.a < 0.005) discard; +} +``` + +- [ ] **Step 2: Add a size-returning overload to TextureCache** + +In `src/AcDream.App/Rendering/TextureCache.cs`, add a size cache field next to `_handlesBySurfaceId` (top-of-class field region): + +```csharp + private readonly Dictionary _sizeBySurfaceId = new(); +``` + +And add this method directly after `GetOrUpload(uint surfaceId)` (after line 81): + +```csharp + /// + /// Like but also returns the decoded + /// pixel dimensions. UI 9-slice geometry needs the source size to + /// compute slice UVs. Cached alongside the handle. + /// + public uint GetOrUpload(uint surfaceId, out int width, out int height) + { + if (_handlesBySurfaceId.TryGetValue(surfaceId, out var existing) + && _sizeBySurfaceId.TryGetValue(surfaceId, out var sz)) + { + width = sz.w; height = sz.h; + return existing; + } + + var decoded = DecodeFromDats(surfaceId, origTextureOverride: null, paletteOverride: null); + uint h = UploadRgba8(decoded); + _handlesBySurfaceId[surfaceId] = h; + _sizeBySurfaceId[surfaceId] = (decoded.Width, decoded.Height); + width = decoded.Width; height = decoded.Height; + return h; + } +``` + +- [ ] **Step 3: Add the textured-sprite path to TextRenderer** + +In `src/AcDream.App/Rendering/TextRenderer.cs`, add a per-texture sprite buffer field (next to `_textBuf`/`_rectBuf`, ~line 31): + +```csharp + private readonly Dictionary> _spriteBufs = new(); +``` + +Clear it in `Begin` (inside the existing `Begin`, after `_rectBuf.Clear();`): + +```csharp + foreach (var b in _spriteBufs.Values) b.Clear(); +``` + +Add the public draw method (after `DrawString`, ~line 130): + +```csharp + /// + /// Draw a textured sprite quad in screen pixel space with an explicit + /// source-UV rectangle (for 9-slice / atlas sub-regions). Batched per + /// GL texture handle; flushed with uUseTexture=2 (RGBA modulate). + /// + public void DrawSprite(uint texture, float x, float y, float w, float h, + float u0, float v0, float u1, float v1, Vector4 tint) + { + if (!_spriteBufs.TryGetValue(texture, out var buf)) + { + buf = new List(256); + _spriteBufs[texture] = buf; + } + AppendQuad(buf, x, y, w, h, u0, v0, u1, v1, tint); + } +``` + +In `Flush`, (a) change the early-out so sprites alone still draw, (b) set `DepthMask(false)` + restore, (c) draw the sprite batches. Replace the existing `Flush` body's guard and state block down through the text draw: + +Replace: +```csharp + if (_textVerts == 0 && _rectVerts == 0) return; +``` +with: +```csharp + bool hasSprites = false; + foreach (var b in _spriteBufs.Values) if (b.Count > 0) { hasSprites = true; break; } + if (_textVerts == 0 && _rectVerts == 0 && !hasSprites) return; +``` + +Replace the state-save block: +```csharp + bool wasDepth = _gl.IsEnabled(EnableCap.DepthTest); + bool wasBlend = _gl.IsEnabled(EnableCap.Blend); + bool wasCull = _gl.IsEnabled(EnableCap.CullFace); + _gl.Disable(EnableCap.DepthTest); + _gl.Disable(EnableCap.CullFace); + _gl.Enable(EnableCap.Blend); + _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); +``` +with (adds DepthMask off; restored to true below): +```csharp + bool wasDepth = _gl.IsEnabled(EnableCap.DepthTest); + bool wasBlend = _gl.IsEnabled(EnableCap.Blend); + bool wasCull = _gl.IsEnabled(EnableCap.CullFace); + _gl.Disable(EnableCap.DepthTest); + _gl.Disable(EnableCap.CullFace); + _gl.DepthMask(false); + _gl.Enable(EnableCap.Blend); + _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); +``` + +Add the sprite-draw block immediately **after** the text-glyph block (after the `if (_textVerts > 0 && font is not null) { ... }` block, before "Restore GL state"): + +```csharp + // RGBA dat sprites — one draw call per distinct GL texture. + if (hasSprites) + { + _shader.SetInt("uUseTexture", 2); + _gl.ActiveTexture(TextureUnit.Texture0); + _shader.SetInt("uTex", 0); + foreach (var kv in _spriteBufs) + { + if (kv.Value.Count == 0) continue; + _gl.BindTexture(TextureTarget.Texture2D, kv.Key); + UploadBuffer(kv.Value); + _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)(kv.Value.Count / FloatsPerVertex)); + } + } +``` + +Add DepthMask restore in the "Restore GL state" block (after the existing three restores). Restore to `true` — the next frame's depth *clear* requires depth writes enabled, so `true` is the correct (and only safe) post-UI value: +```csharp + _gl.DepthMask(true); +``` + +- [ ] **Step 4: Add the DrawSprite forwarder to UiRenderContext** + +In `src/AcDream.App/UI/UiRenderContext.cs`, after the `DrawRectOutline` forwarder (line 54): + +```csharp + public void DrawSprite(uint texture, float x, float y, float w, float h, + float u0, float v0, float u1, float v1, Vector4 tint) + => TextRenderer.DrawSprite(texture, + _current.X + x, _current.Y + y, w, h, u0, v0, u1, v1, tint); +``` + +- [ ] **Step 5: Build** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj` +Expected: build succeeds. + +- [ ] **Step 6: Commit** + +```bash +git add src/AcDream.App/Rendering/Shaders/ui_text.frag src/AcDream.App/Rendering/TextRenderer.cs src/AcDream.App/Rendering/TextureCache.cs src/AcDream.App/UI/UiRenderContext.cs +git commit -m "feat(D.2b): textured-sprite path in TextRenderer + UV-rect DrawSprite" +``` + +--- + +## Task 3: Step-0 chrome sprite prove-out (HUMAN-IN-THE-LOOP) + +Resolves the unverified chrome sprite IDs empirically (spec §6). Requires the user to run the client and eyeball candidates. + +**Files:** +- Create: `src/AcDream.App/UI/RetailChromeSprites.cs` +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (temporary prove-out block) + +- [ ] **Step 1: Create the constants file (empty placeholders to be filled by the run)** + +Create `src/AcDream.App/UI/RetailChromeSprites.cs`: + +```csharp +namespace AcDream.App.UI; + +/// +/// Confirmed retail window-chrome RenderSurface DataIDs + decoded sizes + +/// 9-slice insets. Values are filled by the Step-0 prove-out run (see +/// docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md, Task 3) +/// — do NOT trust pre-run values. Candidates dumped by the prove-out harness. +/// +public static class RetailChromeSprites +{ + // Candidate IDs to try in the Step-0 prove-out. Edit this list as needed. + public static readonly uint[] Candidates = + { + 0x06004CC2, 0x060074BF, 0x060074C0, 0x060074C1, 0x060074C2, + 0x060074C3, 0x060074C4, 0x060074C5, 0x060074C6, 0x0600129C, + }; + + // === FILLED BY STEP 0 (placeholder = magenta until confirmed) === + /// The single 9-sliceable frame sprite (or the body/center fill). + public static uint FrameSurfaceId = 0; // TODO Step 0: set to confirmed id + /// Corner inset in pixels (left/top/right/bottom assumed equal until LayoutDesc parse). + public static int Inset = 6; // TODO Step 0: tune to the real bevel +} +``` + +- [ ] **Step 2: Add a temporary prove-out block to OnRender** + +In `src/AcDream.App/Rendering/GameWindow.cs`, in `OnRender` after the 3D passes (just before the ImGui block at ~line 8158), add: + +```csharp + // Step-0 prove-out (D.2b Task 3): draw candidate chrome sprites in a + // labelled row so we can eyeball which decode to frame art. Gated by + // ACDREAM_RETAIL_UI_PROVEOUT=1. TEMPORARY — delete after Step 0. + if (System.Environment.GetEnvironmentVariable("ACDREAM_RETAIL_UI_PROVEOUT") == "1" + && _textureCache is not null && _textRenderer is not null) + { + _textRenderer.Begin(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y)); + float px = 20f; + foreach (var id in AcDream.App.UI.RetailChromeSprites.Candidates) + { + uint tex = _textureCache.GetOrUpload(id, out int tw, out int th); + _textRenderer.DrawSprite(tex, px, 60f, 96f, 96f, 0, 0, 1, 1, + System.Numerics.Vector4.One); + if (_debugFont is not null) + _textRenderer.DrawString(_debugFont, $"0x{id:X8}\n{tw}x{th}", px, 160f, + System.Numerics.Vector4.One); + px += 110f; + } + _textRenderer.Flush(_debugFont); + } +``` + +- [ ] **Step 3: Build + run the prove-out (manual)** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj` +Then launch with the prove-out flag (PowerShell): + +```powershell +$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" +$env:ACDREAM_RETAIL_UI_PROVEOUT = "1" +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath proveout.log +``` + +**Manual:** the user reports which candidate IDs render as frame/border art (vs magenta vs unrelated sprites) and their printed sizes. If the frame is a single 9-sliceable sprite, note that ID + size. If it's separate corner/edge sprites, note each. Tune `Candidates` and re-run if none match (widen the `0x0600xxxx` range near `0x060074xx`). + +- [ ] **Step 4: Record the confirmed values** + +Edit `RetailChromeSprites.cs`: set `FrameSurfaceId` to the confirmed id and `Inset` to the eyeballed bevel thickness. Add a comment with the decoded `WxH` and the date. + +- [ ] **Step 5: Remove the temporary prove-out block** + +Delete the `ACDREAM_RETAIL_UI_PROVEOUT` block from `GameWindow.cs` (it was scaffolding). + +- [ ] **Step 6: Commit** + +```bash +git add src/AcDream.App/UI/RetailChromeSprites.cs src/AcDream.App/Rendering/GameWindow.cs +git commit -m "feat(D.2b): Step-0 chrome sprite prove-out + confirmed RetailChromeSprites ids" +``` + +--- + +## Task 4: UiNineSlicePanel + +**Files:** +- Create: `src/AcDream.App/UI/UiNineSlicePanel.cs` +- Test: `tests/AcDream.App.Tests/UI/UiNineSlicePanelTests.cs` + +- [ ] **Step 1: Write the failing geometry test** + +Create `tests/AcDream.App.Tests/UI/UiNineSlicePanelTests.cs`: + +```csharp +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public class UiNineSlicePanelTests +{ + [Fact] + public void ComputeSliceRects_ProducesNinePatchesCoveringTheFrame() + { + // 100x80 frame, 32x32 source texture, 8px inset. + var rects = UiNineSlicePanel.ComputeSliceRects( + frameW: 100, frameH: 80, texW: 32, texH: 32, inset: 8); + + Assert.Equal(9, rects.Length); + + // Top-left corner: dst (0,0,8,8); src uv (0,0)-(8/32, 8/32). + var tl = rects[0]; + Assert.Equal(0f, tl.dstX); Assert.Equal(0f, tl.dstY); + Assert.Equal(8f, tl.dstW); Assert.Equal(8f, tl.dstH); + Assert.Equal(0f, tl.u0); Assert.Equal(0f, tl.v0); + Assert.Equal(8f / 32f, tl.u1, 5); Assert.Equal(8f / 32f, tl.v1, 5); + + // Center: dst (8,8, 100-16, 80-16); src uv inset..(tex-inset). + var center = rects[4]; + Assert.Equal(8f, center.dstX); Assert.Equal(8f, center.dstY); + Assert.Equal(84f, center.dstW); Assert.Equal(64f, center.dstH); + Assert.Equal(8f / 32f, center.u0, 5); + Assert.Equal(24f / 32f, center.u1, 5); + + // Bottom-right corner dst origin at (100-8, 80-8). + var br = rects[8]; + Assert.Equal(92f, br.dstX); Assert.Equal(72f, br.dstY); + Assert.Equal(8f, br.dstW); Assert.Equal(8f, br.dstH); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter UiNineSlicePanelTests` +Expected: FAIL to compile — `UiNineSlicePanel` does not exist. + +- [ ] **Step 3: Implement UiNineSlicePanel** + +Create `src/AcDream.App/UI/UiNineSlicePanel.cs`: + +```csharp +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// A whose background is a 9-sliced dat RenderSurface: +/// 4 fixed corners, 4 stretched edges, 1 stretched center. Retires the flat +/// translucent rect (divergence row TS-30). Insets come from +/// until the LayoutDesc importer supplies +/// per-panel metrics. +/// +public sealed class UiNineSlicePanel : UiPanel +{ + /// One slice patch: destination rect (local px) + source UVs (0..1). + public readonly record struct Slice( + float dstX, float dstY, float dstW, float dstH, + float u0, float v0, float u1, float v1); + + private readonly System.Func _resolve; + private readonly uint _surfaceId; + private readonly int _inset; + + /// Surface id → (GL handle, decoded width, height). + /// In production: id => { var t = cache.GetOrUpload(id, out var w, out var h); return (t, w, h); }. + public UiNineSlicePanel(System.Func resolve, + uint surfaceId, int inset) + { + _resolve = resolve; + _surfaceId = surfaceId; + _inset = inset; + BackgroundColor = Vector4.Zero; // suppress the base flat-rect fill + BorderColor = Vector4.Zero; + } + + /// + /// Compute the 9 patches for a frame of x + /// from a x + /// source with a uniform . + /// Order: TL, TC, TR, ML, MC, MR, BL, BC, BR (index 4 = center). + /// + public static Slice[] ComputeSliceRects( + float frameW, float frameH, int texW, int texH, int inset) + { + float i = inset; + // destination column/row edges + float[] dx = { 0, i, frameW - i, frameW }; + float[] dy = { 0, i, frameH - i, frameH }; + // source UV column/row edges (0..1) + float[] ux = { 0, i / texW, (texW - i) / texW, 1f }; + float[] uy = { 0, i / texH, (texH - i) / texH, 1f }; + + var slices = new Slice[9]; + int n = 0; + for (int row = 0; row < 3; row++) + for (int col = 0; col < 3; col++) + slices[n++] = new Slice( + dx[col], dy[row], dx[col + 1] - dx[col], dy[row + 1] - dy[row], + ux[col], uy[row], ux[col + 1], uy[row + 1]); + return slices; + } + + protected override void OnDraw(UiRenderContext ctx) + { + var (tex, tw, th) = _resolve(_surfaceId); + if (tex == 0 || tw == 0 || th == 0) return; + foreach (var s in ComputeSliceRects(Width, Height, tw, th, _inset)) + ctx.DrawSprite(tex, s.dstX, s.dstY, s.dstW, s.dstH, + s.u0, s.v0, s.u1, s.v1, Vector4.One); + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter UiNineSlicePanelTests` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.App/UI/UiNineSlicePanel.cs tests/AcDream.App.Tests/UI/UiNineSlicePanelTests.cs +git commit -m "feat(D.2b): UiNineSlicePanel (9-slice dat chrome) + geometry tests" +``` + +--- + +## Task 5: UiMeter + +**Files:** +- Create: `src/AcDream.App/UI/UiMeter.cs` +- Test: `tests/AcDream.App.Tests/UI/UiMeterTests.cs` + +- [ ] **Step 1: Write the failing fill-geometry test** + +Create `tests/AcDream.App.Tests/UI/UiMeterTests.cs`: + +```csharp +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public class UiMeterTests +{ + [Fact] + public void ComputeFillRect_HalfFillIsHalfWidth() + { + var (x, y, w, h) = UiMeter.ComputeFillRect(0.5f, 200f, 12f); + Assert.Equal(0f, x); Assert.Equal(0f, y); + Assert.Equal(100f, w); Assert.Equal(12f, h); + } + + [Theory] + [InlineData(-1f, 0f)] // clamps below 0 + [InlineData(2f, 200f)] // clamps above 1 + [InlineData(0f, 0f)] + [InlineData(1f, 200f)] + public void ComputeFillRect_ClampsFraction(float pct, float expectedW) + { + var (_, _, w, _) = UiMeter.ComputeFillRect(pct, 200f, 12f); + Assert.Equal(expectedW, w); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter UiMeterTests` +Expected: FAIL to compile — `UiMeter` does not exist. + +- [ ] **Step 3: Implement UiMeter** + +Create `src/AcDream.App/UI/UiMeter.cs`: + +```csharp +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// A horizontal vital bar: an empty background rect with a partial-width +/// fill. returns 0..1 (or null = no data → empty bar). +/// Solid-color for Spec 1; the retail orb sprite + scissor crop is a later +/// sub-phase. +/// +public sealed class UiMeter : UiElement +{ + /// Fill fraction provider; null result draws an empty bar. + public System.Func Fill { get; set; } = () => 0f; + public Vector4 BarColor { get; set; } = new(1f, 0f, 0f, 1f); + public Vector4 BgColor { get; set; } = new(0f, 0f, 0f, 0.5f); + + public UiMeter() { ClickThrough = true; } + + /// Clamp to [0,1] and return the fill + /// rect (local px) for a bar of x . + public static (float x, float y, float w, float h) ComputeFillRect( + float pct, float w, float h) + { + if (pct < 0f) pct = 0f; + if (pct > 1f) pct = 1f; + return (0f, 0f, w * pct, h); + } + + protected override void OnDraw(UiRenderContext ctx) + { + ctx.DrawRect(0, 0, Width, Height, BgColor); + float? pct = Fill(); + if (pct is float p) + { + var (fx, fy, fw, fh) = ComputeFillRect(p, Width, Height); + if (fw > 0f) ctx.DrawRect(fx, fy, fw, fh, BarColor); + } + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter UiMeterTests` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.App/UI/UiMeter.cs tests/AcDream.App.Tests/UI/UiMeterTests.cs +git commit -m "feat(D.2b): UiMeter vital bar + fill-geometry tests" +``` + +--- + +## Task 6: Wire UiHost + hand-built vitals subtree (render-only) + retire TS-30 + +Visual-acceptance task. First on-screen retail panel. + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` +- Modify: `docs/architecture/retail-divergence-register.md` + +- [ ] **Step 1: Add the UiHost field** + +In `GameWindow.cs`, next to `_vitalsVm` (~line 614): + +```csharp + // Phase D.2b — retail-look UI tree. Null unless ACDREAM_RETAIL_UI=1. + private AcDream.App.UI.UiHost? _uiHost; +``` + +- [ ] **Step 2: Construct UiHost + the vitals subtree in OnLoad** + +In `GameWindow.cs` OnLoad, **after** `_textureCache` is constructed (after line 1724) and after `_vitalsVm` is available, add. Note: `_vitalsVm` is built today only inside the DevTools block (line 1330). Hoist its construction so it exists for the retail path too — change line 1330's block so the VM is created when `DevToolsEnabled || _options.RetailUi`. Concretely, ensure this runs regardless of DevTools: + +```csharp + _vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer); +``` + +**Also ungate the GUID setter:** the `_vitalsVm.SetLocalPlayerGuid(...)` call at EnterWorld (~line 1984) must run whenever `_vitalsVm` is non-null — not only under DevTools — or retail-only mode reads HP=1.0 forever. Change any `if (DevToolsEnabled)` guard around that call to `if (_vitalsVm is not null)` (use the null-conditional `_vitalsVm?.SetLocalPlayerGuid(guid);` if simpler). Verify the exact guard at the call site before editing. + +Then add the retail wiring (after `_textureCache` exists): + +```csharp + if (_options.RetailUi) + { + string shadersDir = Path.Combine(AppContext.BaseDirectory, "Rendering", "Shaders"); + _uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont); + + var cache = _textureCache!; + (uint, int, int) Resolve(uint id) + { + uint t = cache.GetOrUpload(id, out int w, out int h); + return (t, w, h); + } + + var panel = new AcDream.App.UI.UiNineSlicePanel( + Resolve, + AcDream.App.UI.RetailChromeSprites.FrameSurfaceId, + AcDream.App.UI.RetailChromeSprites.Inset) + { Left = 10, Top = 30, Width = 220, Height = 96 }; + + var title = new AcDream.App.UI.UiLabel + { Text = "Vitals", Left = 8, Top = 4, + TextColor = new System.Numerics.Vector4(1, 1, 1, 1) }; + panel.AddChild(title); + + var vm = _vitalsVm!; + panel.AddChild(new AcDream.App.UI.UiMeter + { Left = 8, Top = 24, Width = 200, Height = 13, + BarColor = new System.Numerics.Vector4(1f, 0f, 0f, 1f), + Fill = () => vm.HealthPercent }); + panel.AddChild(new AcDream.App.UI.UiMeter + { Left = 8, Top = 44, Width = 200, Height = 13, + BarColor = new System.Numerics.Vector4(0.063f, 0.94f, 0.94f, 1f), + Fill = () => vm.StaminaPercent }); + panel.AddChild(new AcDream.App.UI.UiMeter + { Left = 8, Top = 64, Width = 200, Height = 13, + BarColor = new System.Numerics.Vector4(0f, 0f, 1f, 1f), + Fill = () => vm.ManaPercent }); + + _uiHost.Root.AddChild(panel); + } +``` + +(`UiLabel` draws via the stb `BitmapFont` `_debugFont`; if `_debugFont` is null the title simply doesn't draw — acceptable for Spec 1.) + +- [ ] **Step 3: Draw the retail UI each frame** + +In `GameWindow.cs` OnRender, after the 3D passes and near the ImGui block (~line 8233, after `_imguiBootstrap` block or before it — order is deterministic either way; place it just before the ImGui `if` at line 8158 so ImGui composites on top in dev): + +```csharp + // Phase D.2b — retail-look UI tree (render-only; input integration deferred). + if (_options.RetailUi && _uiHost is not null) + { + _uiHost.Tick(deltaSeconds); + _uiHost.Draw(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y)); + } +``` + +- [ ] **Step 4: Dispose UiHost on shutdown** + +In `GameWindow.cs`'s dispose/shutdown path (near where `_textRenderer`/`_debugFont` are disposed, ~line 12043): + +```csharp + _uiHost?.Dispose(); +``` + +- [ ] **Step 5: Build + visual verify (manual)** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj` +Launch with `ACDREAM_RETAIL_UI=1` (+ the live-connection env from CLAUDE.md). **User confirms:** the Vitals window renders with the dat-sprite frame + three bars that track HP/Stam/Mana as the character takes damage/regens. Also launch with `ACDREAM_DEVTOOLS=1` (retail off) and confirm the ImGui panels are unchanged. + +- [ ] **Step 6: Retire TS-30 + add the IA row** + +In `docs/architecture/retail-divergence-register.md`: delete the **TS-30** row (line ~166). Add one new **IA** row (next sequential IA number) for the markup/serialization layer: + +``` +| IA-NN | D.2b retail UI is our own UiRoot tree + XML markup + controls.ini stylesheet, not a byte-port of keystone.dll's LayoutDesc binary tree (keystone.dll has no PDB/decomp) | src/AcDream.App/UI/UiNineSlicePanel.cs + MarkupDocument.cs | keystone.dll is outside decomp coverage — a byte-port is impossible by definition; we mirror retail's LayoutDesc/ElementDesc field model + controls.ini token vocabulary | Layout semantics the research under-specifies (anchor resolution at non-800x600, controls.ini cascade corners) differ silently with no oracle | LayoutDesc 0x21xxxxxx; controls.ini panel-property vocabulary; keystone.dll layout evaluation (no PDB) | +``` + +(Replace `IA-NN` with the actual next number; verify against the register head — there were 14 IA rows at the 2026-06-12 count, so likely `IA-15`.) + +- [ ] **Step 7: Commit** + +```bash +git add src/AcDream.App/Rendering/GameWindow.cs docs/architecture/retail-divergence-register.md +git commit -m "feat(D.2b): wire UiHost + live Vitals panel (render-only); retire TS-30, add IA row" +``` + +--- + +## Task 7: controls.ini stylesheet loader + +**Files:** +- Create: `src/AcDream.App/UI/ControlsIni.cs` +- Test: `tests/AcDream.App.Tests/UI/ControlsIniTests.cs` +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (apply title color/font tokens) + +- [ ] **Step 1: Write the failing parser tests** + +Create `tests/AcDream.App.Tests/UI/ControlsIniTests.cs`: + +```csharp +using System.Numerics; +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public class ControlsIniTests +{ + [Fact] + public void Parse_ReadsSectionTokens() + { + var ini = ControlsIni.Parse( + "[title]\nheight=19\ncolor=#FFFFFFFF\nfont=font://Verdana-10-bold\n" + + "[body]\nbgcolor=#00000000\ncolor_border=#FF4F657D\n"); + + Assert.Equal("19", ini.Get("title", "height")); + Assert.Equal("font://Verdana-10-bold", ini.Get("title", "font")); + Assert.Null(ini.Get("title", "missing")); + Assert.Null(ini.Get("nosuch", "height")); + } + + [Fact] + public void TryColor_ParsesAlphaFirstHex() + { + var ini = ControlsIni.Parse("[body]\ncolor_border=#FF4F657D\n"); + Assert.True(ini.TryColor("body", "color_border", out Vector4 c)); + Assert.Equal(0xFF / 255f, c.W, 5); // alpha + Assert.Equal(0x4F / 255f, c.X, 5); // red + Assert.Equal(0x65 / 255f, c.Y, 5); // green + Assert.Equal(0x7D / 255f, c.Z, 5); // blue + } + + [Fact] + public void Parse_MissingFileReturnsEmptyNotThrow() + { + var ini = ControlsIni.Load(@"Z:\does\not\exist\controls.ini"); + Assert.Null(ini.Get("title", "height")); // empty, no throw + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter ControlsIniTests` +Expected: FAIL to compile — `ControlsIni` does not exist. + +- [ ] **Step 3: Implement ControlsIni** + +Create `src/AcDream.App/UI/ControlsIni.cs`: + +```csharp +using System.Collections.Generic; +using System.Globalization; +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// Minimal reader for retail's controls.ini — a flat INI with one +/// [section] per element type. Colors are #AARRGGBB (alpha +/// first). Optional: a missing file yields an empty sheet (callers fall +/// back to hardcoded defaults). See spec §7. +/// +public sealed class ControlsIni +{ + private readonly Dictionary> _sections; + + private ControlsIni(Dictionary> s) => _sections = s; + + /// Load from disk; returns an empty sheet if the file is absent. + public static ControlsIni Load(string path) + => System.IO.File.Exists(path) + ? Parse(System.IO.File.ReadAllText(path)) + : new ControlsIni(new()); + + public static ControlsIni Parse(string text) + { + var sections = new Dictionary>(System.StringComparer.OrdinalIgnoreCase); + Dictionary? cur = null; + foreach (var raw in text.Split('\n')) + { + var line = raw.Trim(); + if (line.Length == 0 || line[0] == ';' || line[0] == '#') continue; + if (line[0] == '[' && line[^1] == ']') + { + var name = line[1..^1].Trim(); + cur = new Dictionary(System.StringComparer.OrdinalIgnoreCase); + sections[name] = cur; + continue; + } + int eq = line.IndexOf('='); + if (eq <= 0 || cur is null) continue; + cur[line[..eq].Trim()] = line[(eq + 1)..].Trim(); + } + return new ControlsIni(sections); + } + + public string? Get(string section, string key) + => _sections.TryGetValue(section, out var s) && s.TryGetValue(key, out var v) ? v : null; + + /// Parse a #AARRGGBB token into an RGBA . + public bool TryColor(string section, string key, out Vector4 color) + { + color = default; + var v = Get(section, key); + if (v is null || v.Length != 9 || v[0] != '#') return false; + if (!uint.TryParse(v.AsSpan(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out uint argb)) + return false; + float a = ((argb >> 24) & 0xFF) / 255f; + float r = ((argb >> 16) & 0xFF) / 255f; + float g = ((argb >> 8) & 0xFF) / 255f; + float b = (argb & 0xFF) / 255f; + color = new Vector4(r, g, b, a); + return true; + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter ControlsIniTests` +Expected: PASS (3 tests). + +- [ ] **Step 5: Apply the stylesheet to the title label** + +In `GameWindow.cs`'s retail wiring (Task 6 Step 2), before building `title`, load the sheet and use the `[title]` color with a fallback: + +```csharp + string? acDir = _options.AcDir; + var controls = acDir is not null + ? AcDream.App.UI.ControlsIni.Load(Path.Combine(acDir, "controls", "controls.ini")) + : AcDream.App.UI.ControlsIni.Parse(string.Empty); + var titleColor = controls.TryColor("title", "color", out var tc) + ? tc : new System.Numerics.Vector4(1, 1, 1, 1); +``` + +Then set `TextColor = titleColor` on the `title` label. + +- [ ] **Step 6: Build + commit** + +```bash +dotnet build src/AcDream.App/AcDream.App.csproj +git add src/AcDream.App/UI/ControlsIni.cs tests/AcDream.App.Tests/UI/ControlsIniTests.cs src/AcDream.App/Rendering/GameWindow.cs +git commit -m "feat(D.2b): controls.ini stylesheet loader (optional) + apply title color" +``` + +--- + +## Task 8: MarkupDocument — XML → UiElement subtree + +**Files:** +- Create: `src/AcDream.App/UI/MarkupDocument.cs` +- Create: `src/AcDream.App/UI/assets/vitals.xml` +- Test: `tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs` +- Modify: `src/AcDream.App/AcDream.App.csproj` (copy `UI/assets/*.xml` to output) +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (build the subtree from markup) + +- [ ] **Step 1: Write the failing parser test** + +Create `tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs`: + +```csharp +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public class MarkupDocumentTests +{ + private sealed class FakeBinding + { + public float HealthPercent => 0.5f; + public float? ManaPercent => null; + } + + [Fact] + public void Build_CreatesPanelWithMeterChildrenAndGeometry() + { + const string xml = + "" + + " " + + ""; + + var resolve = (uint id) => ((uint)1, 32, 32); + var panel = MarkupDocument.Build(xml, new FakeBinding(), resolve, + frameSurfaceId: 0x06000000, inset: 8); + + Assert.IsType(panel); + Assert.Equal(10f, panel.Left); + Assert.Equal(220f, panel.Width); + + // One UiMeter child whose fill resolves to the binding's 0.5. + Assert.Single(panel.Children); + var meter = Assert.IsType(panel.Children[0]); + Assert.Equal(8f, meter.Left); + Assert.Equal(200f, meter.Width); + Assert.Equal(0.5f, meter.Fill()); + } + + [Fact] + public void Build_NullBindingPropertyYieldsNullFill() + { + const string xml = + "" + + " " + + ""; + var panel = MarkupDocument.Build(xml, new FakeBinding(), + id => ((uint)1, 32, 32), 0x06000000, 8); + var meter = Assert.IsType(panel.Children[0]); + Assert.Null(meter.Fill()); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter MarkupDocumentTests` +Expected: FAIL to compile — `MarkupDocument` does not exist. + +- [ ] **Step 3: Implement MarkupDocument** + +Create `src/AcDream.App/UI/MarkupDocument.cs`: + +```csharp +using System; +using System.Globalization; +using System.Numerics; +using System.Xml.Linq; + +namespace AcDream.App.UI; + +/// +/// Parses our KSML-style panel markup (mirrors retail's ElementDesc fields) +/// into a live subtree. {Binding} attribute +/// values resolve against a supplied object by property name (reflection). +/// This is the format the future LayoutDesc importer will emit. See spec §7. +/// +public static class MarkupDocument +{ + /// Surface id → (GL handle, width, height). + public static UiNineSlicePanel Build( + string xml, object binding, Func resolve, + uint frameSurfaceId, int inset) + { + var root = XDocument.Parse(xml).Root + ?? throw new FormatException("empty markup"); + if (root.Name.LocalName != "panel") + throw new FormatException($"root must be , got <{root.Name.LocalName}>"); + + var panel = new UiNineSlicePanel(resolve, frameSurfaceId, inset) + { + Left = F(root, "x"), Top = F(root, "y"), + Width = F(root, "w"), Height = F(root, "h"), + }; + + string? title = (string?)root.Attribute("title"); + if (!string.IsNullOrEmpty(title)) + panel.AddChild(new UiLabel { Text = title, Left = 8, Top = 4 }); + + foreach (var el in root.Elements()) + { + switch (el.Name.LocalName) + { + case "meter": + panel.AddChild(new UiMeter + { + Left = F(el, "x"), Top = F(el, "y"), + Width = F(el, "w"), Height = F(el, "h"), + BarColor = Color((string?)el.Attribute("color")), + Fill = BindFloat((string?)el.Attribute("fill"), binding), + }); + break; + // future: case "label", "button", "image" ... + } + } + return panel; + } + + private static float F(XElement e, string attr) + => float.TryParse((string?)e.Attribute(attr), NumberStyles.Float, + CultureInfo.InvariantCulture, out var v) ? v : 0f; + + private static Vector4 Color(string? hex) + { + if (hex is { Length: 9 } && hex[0] == '#' + && uint.TryParse(hex.AsSpan(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out uint argb)) + { + return new Vector4( + ((argb >> 16) & 0xFF) / 255f, ((argb >> 8) & 0xFF) / 255f, + (argb & 0xFF) / 255f, ((argb >> 24) & 0xFF) / 255f); + } + return Vector4.One; + } + + /// Resolve "{Prop}" to a live getter against the binding; "" → constant 0. + private static Func BindFloat(string? expr, object binding) + { + if (expr is null || expr.Length < 3 || expr[0] != '{' || expr[^1] != '}') + return () => 0f; + string prop = expr[1..^1]; + var pi = binding.GetType().GetProperty(prop); + if (pi is null) return () => null; + return () => + { + object? v = pi.GetValue(binding); + return v switch + { + float f => f, + null => (float?)null, + _ => Convert.ToSingle(v, CultureInfo.InvariantCulture), + }; + }; + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter MarkupDocumentTests` +Expected: PASS (2 tests). + +- [ ] **Step 5: Add the vitals markup asset + copy-to-output** + +Create `src/AcDream.App/UI/assets/vitals.xml`: + +```xml + + + + + +``` + +In `src/AcDream.App/AcDream.App.csproj`, add an `ItemGroup` to copy UI assets to output: + +```xml + + + +``` + +- [ ] **Step 6: Replace the hand-built subtree with the markup build** + +In `GameWindow.cs`'s retail wiring (Task 6 Step 2), replace the hand-built `panel`/`title`/`UiMeter` block with: + +```csharp + string vitalsXmlPath = Path.Combine(AppContext.BaseDirectory, "UI", "assets", "vitals.xml"); + var panel = AcDream.App.UI.MarkupDocument.Build( + System.IO.File.ReadAllText(vitalsXmlPath), + _vitalsVm!, Resolve, + AcDream.App.UI.RetailChromeSprites.FrameSurfaceId, + AcDream.App.UI.RetailChromeSprites.Inset); + _uiHost.Root.AddChild(panel); +``` + +(The `controls.ini` title color from Task 7 can be applied by setting the title-`UiLabel`'s color after the build, or deferred — the markup path owns the title now.) + +- [ ] **Step 7: Build + visual verify + commit** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` +Launch with `ACDREAM_RETAIL_UI=1`; **user confirms** the markup-built panel renders identically to the hand-built one (frame + 3 live bars). + +```bash +git add src/AcDream.App/UI/MarkupDocument.cs src/AcDream.App/UI/assets/vitals.xml src/AcDream.App/AcDream.App.csproj tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs src/AcDream.App/Rendering/GameWindow.cs +git commit -m "feat(D.2b): MarkupDocument (XML -> UiElement tree) + vitals.xml; build panel from markup" +``` + +--- + +## Task 9: Plugin UI registry (capstone — designed-now, first consumer first-party) + +**Files:** +- Create: `src/AcDream.Plugin.Abstractions/IUiRegistry.cs` +- Modify: `src/AcDream.Plugin.Abstractions/IPluginHost.cs` +- Create: `src/AcDream.App/Plugins/BufferedUiRegistry.cs` +- Modify: `src/AcDream.App/Plugins/AppPluginHost.cs`, `src/AcDream.App/Program.cs`, `src/AcDream.App/Rendering/GameWindow.cs` +- Test: `tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs` + +- [ ] **Step 1: Define the registry interface** + +Create `src/AcDream.Plugin.Abstractions/IUiRegistry.cs`: + +```csharp +namespace AcDream.Plugin.Abstractions; + +/// +/// Plugin-facing UI registration. A plugin ships a markup file (KSML-style) +/// + a binding object exposing the data properties the markup binds to, and +/// registers it here from Enable(). Registrations made before the GL +/// window opens are buffered and drained once the UI host exists. +/// +public interface IUiRegistry +{ + /// Absolute path to the plugin's panel markup. + /// Object whose properties the markup's {Bindings} read. + void AddMarkupPanel(string markupPath, object binding); +} +``` + +- [ ] **Step 2: Add `Ui` to IPluginHost** + +In `src/AcDream.Plugin.Abstractions/IPluginHost.cs`: + +```csharp +public interface IPluginHost +{ + IPluginLogger Log { get; } + IGameState State { get; } + IEvents Events { get; } + IUiRegistry Ui { get; } +} +``` + +- [ ] **Step 3: Write the failing buffered-registry test** + +Create `tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs`: + +```csharp +using AcDream.App.Plugins; + +namespace AcDream.App.Tests.Plugins; + +public class BufferedUiRegistryTests +{ + [Fact] + public void Drain_YieldsBufferedRegistrationsOnce() + { + var reg = new BufferedUiRegistry(); + reg.AddMarkupPanel("a.xml", new object()); + reg.AddMarkupPanel("b.xml", new object()); + + var drained = reg.Drain(); + Assert.Equal(2, drained.Count); + Assert.Equal("a.xml", drained[0].MarkupPath); + + // Second drain is empty (consumed). + Assert.Empty(reg.Drain()); + } +} +``` + +- [ ] **Step 4: Run the test to verify it fails** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter BufferedUiRegistryTests` +Expected: FAIL to compile — `BufferedUiRegistry` does not exist. + +- [ ] **Step 5: Implement BufferedUiRegistry** + +Create `src/AcDream.App/Plugins/BufferedUiRegistry.cs`: + +```csharp +using System.Collections.Generic; +using AcDream.Plugin.Abstractions; + +namespace AcDream.App.Plugins; + +/// +/// Buffers plugin calls (which run in +/// Program.cs before the GL window opens) until GameWindow drains them into +/// the UiHost tree after construction. +/// +public sealed class BufferedUiRegistry : IUiRegistry +{ + public readonly record struct Pending(string MarkupPath, object Binding); + + private readonly List _pending = new(); + + public void AddMarkupPanel(string markupPath, object binding) + => _pending.Add(new Pending(markupPath, binding)); + + /// Return + clear all buffered registrations. + public IReadOnlyList Drain() + { + var copy = _pending.ToArray(); + _pending.Clear(); + return copy; + } +} +``` + +- [ ] **Step 6: Wire it through AppPluginHost + Program + GameWindow** + +`src/AcDream.App/Plugins/AppPluginHost.cs` — add the `Ui` member: + +```csharp + public AppPluginHost(IPluginLogger log, IGameState state, IEvents events, IUiRegistry ui) + { + Log = log; State = state; Events = events; Ui = ui; + } + + public IPluginLogger Log { get; } + public IGameState State { get; } + public IEvents Events { get; } + public IUiRegistry Ui { get; } +``` + +`src/AcDream.App/Program.cs` — construct the registry and pass it to host + window (replace lines 26 + 59): + +```csharp +var uiRegistry = new AcDream.App.Plugins.BufferedUiRegistry(); +var host = new AppPluginHost(new SerilogAdapter(Log.Logger), worldGameState, worldEvents, uiRegistry); +``` +```csharp + using var window = new GameWindow(runtimeOptions, worldGameState, worldEvents, uiRegistry); +``` + +`GameWindow` — add a constructor parameter `AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null`, store it in a field, and in the retail wiring (after `_uiHost.Root.AddChild(panel)`), drain it: + +```csharp + if (_uiRegistry is not null) + { + foreach (var p in _uiRegistry.Drain()) + { + var pluginPanel = AcDream.App.UI.MarkupDocument.Build( + System.IO.File.ReadAllText(p.MarkupPath), p.Binding, Resolve, + AcDream.App.UI.RetailChromeSprites.FrameSurfaceId, + AcDream.App.UI.RetailChromeSprites.Inset); + _uiHost.Root.AddChild(pluginPanel); + } + } +``` + +(Fix the `StubHost` in `tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs:28` to implement the new `Ui` member — return a throwaway `BufferedUiRegistry` or a stub.) + +- [ ] **Step 7: Run tests + build** + +Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter BufferedUiRegistryTests` +Expected: PASS. Fix any compile breaks in plugin-host implementors surfaced by the new interface member. + +- [ ] **Step 8: Commit** + +```bash +git add src/AcDream.Plugin.Abstractions/IUiRegistry.cs src/AcDream.Plugin.Abstractions/IPluginHost.cs src/AcDream.App/Plugins/BufferedUiRegistry.cs src/AcDream.App/Plugins/AppPluginHost.cs src/AcDream.App/Program.cs src/AcDream.App/Rendering/GameWindow.cs tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs +git commit -m "feat(D.2b): IUiRegistry plugin UI surface + buffered drain into UiHost" +``` + +--- + +## Final verification + +- [ ] `dotnet build` green (whole solution: `dotnet build AcDream.slnx`). +- [ ] `dotnet test` green (all test projects). +- [ ] `ACDREAM_RETAIL_UI=1`: retail Vitals window (frame + 3 live bars) renders; bars track damage/regen. +- [ ] `ACDREAM_DEVTOOLS=1` (retail off): ImGui panels unchanged. +- [ ] TS-30 deleted; one new IA row present. +- [ ] Update the roadmap: mark D.2b Spec 1 (retail panel frame + vitals) shipped in [`docs/plans/2026-04-11-roadmap.md`](../../plans/2026-04-11-roadmap.md). diff --git a/docs/superpowers/plans/2026-06-15-chat-window-redrive.md b/docs/superpowers/plans/2026-06-15-chat-window-redrive.md new file mode 100644 index 00000000..ab96b033 --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-chat-window-redrive.md @@ -0,0 +1,1484 @@ +# Chat-window re-drive Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the hand-authored retail chat window with a data-driven one built from dat `LayoutDesc 0x21000006` (`gmMainChatUI`), with faithful behavioral widgets ported from the named retail decomp and the dat font. + +**Architecture:** The existing `LayoutImporter` builds the generic frame (bg sprites, resize bar, grip chrome, tabs, send button) from the dat. A new `ChatWindowController` (the `ChatInterface`/`gmMainChatUI::PostInit` analogue) binds behavior by element id: it swaps the transcript/input placeholder nodes for new behavioral widgets, wires the scrollbar/menu/send/max-min, and routes inbound chat (from `ChatVM`) and outbound (through a shared `ChatCommandRouter`). New widgets port `UIElement_Text`/`_Scrollable`/`_Scrollbar`/`_Menu`. + +**Tech Stack:** C# / .NET 10, Silk.NET (GL), the in-tree retained-mode UI toolkit (`src/AcDream.App/UI/`), `DatReaderWriter` (dat reads), xUnit (`tests/`). + +**Spec:** `docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md` — read it first. It has the element→role map, decomp citations, and the divergence rows. The decomp is `docs/research/named-retail/acclient_2013_pseudo_c.txt`. + +--- + +## File Structure + +**Create:** +- `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs` — shared submit pipeline (client-command intercept → unknown-verb guard → `ChatInputParser.Parse` → `Publish(SendChatCmd)`). Pure, no GL. +- `src/AcDream.App/UI/UiScrollable.cs` — pixel-scroll coordinator (ports `UIElement_Scrollable` math). Pure, no GL. +- `src/AcDream.App/UI/UiChatInput.cs` — editable one-line text widget (ports `UIElement_Text` edit path). +- `src/AcDream.App/UI/UiChatScrollbar.cs` — right-side scrollbar widget (track + thumb + up/down) driving a `UiScrollable`. +- `src/AcDream.App/UI/UiChannelMenu.cs` — channel-selector dropdown (ports `UIElement_Menu`). +- `src/AcDream.App/UI/Layout/ChatWindowController.cs` — import + bind-by-id + route (the `ChatInterface`/`gmMainChatUI` analogue). +- Tests: `tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs`, + `tests/AcDream.App.Tests/UI/UiScrollableTests.cs`, + `tests/AcDream.App.Tests/UI/UiChatInputTests.cs`, + `tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs`. + +**Modify:** +- `src/AcDream.App/UI/UiChatView.cs` — add `UiDatFont? DatFont`; dat-font measure/advance/draw; wheel = 1 line/notch; `UiScrollable` integration. +- `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs` — call `ChatCommandRouter` instead of the inline submit block. +- `src/AcDream.App/Rendering/GameWindow.cs` — replace the hand-authored chat block (~line 1836) with `ChatWindowController`. +- `docs/architecture/retail-divergence-register.md` — add the 6 deferral rows. +- `docs/plans/2026-04-11-roadmap.md` — mark the chat re-drive landed. + +--- + +## Task A: `ChatCommandRouter` (shared submit pipeline) + +Extract the submit + client-command logic from `ChatPanel` so both the ImGui chat and the retail chat dispatch identically. `ChatPanel` currently hardcodes `ChatChannelKind.Say`; the router parameterizes the default channel (the retail chat passes the channel-menu selection). + +**Files:** +- Create: `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs` +- Test: `tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs` +- Modify: `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs` (call the router) + +- [ ] **Step 1: Write the failing tests** + +Create `tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs`: + +```csharp +using AcDream.Core.Chat; +using AcDream.UI.Abstractions; +using AcDream.UI.Abstractions.Panels.Chat; +using Xunit; + +namespace AcDream.UI.Abstractions.Tests.Panels.Chat; + +public class ChatCommandRouterTests +{ + // Minimal in-memory command bus capturing the last published SendChatCmd. + private sealed class CaptureBus : ICommandBus + { + public SendChatCmd? Last; + public void Publish(T command) where T : notnull + { + if (command is SendChatCmd c) Last = c; + } + } + + private static (ChatVM vm, ChatLog log, CaptureBus bus) Fixture() + { + var log = new ChatLog(); + var vm = new ChatVM(log, displayLimit: 50); + return (vm, log, new CaptureBus()); + } + + [Fact] + public void PlainText_PublishesOnDefaultChannel() + { + var (vm, _, bus) = Fixture(); + var outcome = ChatCommandRouter.Submit("hello there", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.Sent, outcome); + Assert.NotNull(bus.Last); + Assert.Equal(ChatChannelKind.Say, bus.Last!.Channel); + Assert.Equal("hello there", bus.Last.Text); + } + + [Fact] + public void DefaultChannel_IsHonored() + { + var (vm, _, bus) = Fixture(); + ChatCommandRouter.Submit("hi", vm, bus, ChatChannelKind.Fellowship); + Assert.Equal(ChatChannelKind.Fellowship, bus.Last!.Channel); + } + + [Fact] + public void ClearCommand_DrainsLog_DoesNotPublish() + { + var (vm, log, bus) = Fixture(); + log.OnSystemMessage("x", 0); + var outcome = ChatCommandRouter.Submit("/clear", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.ClientHandled, outcome); + Assert.Null(bus.Last); + Assert.Empty(log.Snapshot()); + } + + [Fact] + public void UnknownSlashVerb_ShowsSystemMessage_DoesNotPublish() + { + var (vm, log, bus) = Fixture(); + var outcome = ChatCommandRouter.Submit("/notacommand", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.UnknownCommand, outcome); + Assert.Null(bus.Last); + Assert.Contains(log.Snapshot(), e => e.Text.Contains("Unknown command")); + } + + [Fact] + public void EmptyInput_DoesNothing() + { + var (vm, _, bus) = Fixture(); + var outcome = ChatCommandRouter.Submit(" ", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.Empty, outcome); + Assert.Null(bus.Last); + } +} +``` + +> Verify the `ChatLog` / `ICommandBus` / `ChatVM` APIs used above match the real +> types before running (`ChatLog.OnSystemMessage(string, int)`, `ChatLog.Snapshot()`, +> `ChatLog.Clear()`, `ICommandBus.Publish`). Adjust the fixture if signatures differ. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test tests/AcDream.UI.Abstractions.Tests --filter ChatCommandRouterTests` +Expected: FAIL — `ChatCommandRouter` / `SubmitOutcome` do not exist. + +- [ ] **Step 3: Implement `ChatCommandRouter`** + +Create `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`. Move the +client-command + unknown-verb + parse + publish logic out of `ChatPanel` +(`ChatPanel.TryHandleClientCommand` + the submit block at `ChatPanel.cs:191-242`): + +```csharp +using System; +using AcDream.UI.Abstractions.Panels.Chat; + +namespace AcDream.UI.Abstractions.Panels.Chat; + +/// What a submit did, so the caller can clear its input + give feedback. +public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped } + +/// +/// Shared chat-submit pipeline (retail ChatInterface::ProcessCommand @0x4f5100 +/// analogue). Both the ImGui devtools and the retail +/// chat window route through here so command handling stays in one place. +/// +/// Order mirrors the prior inline flow: +/// client-command intercept → unknown-slash-verb guard → +/// → Publish(SendChatCmd). +/// +public static class ChatCommandRouter +{ + public static SubmitOutcome Submit( + string raw, ChatVM vm, ICommandBus bus, ChatChannelKind defaultChannel) + { + ArgumentNullException.ThrowIfNull(vm); + ArgumentNullException.ThrowIfNull(bus); + var trimmed = (raw ?? string.Empty).Trim(); + if (trimmed.Length == 0) return SubmitOutcome.Empty; + + if (TryHandleClientCommand(trimmed, vm)) return SubmitOutcome.ClientHandled; + + // A '/' prefix is a command, never speech — unknown ones get local feedback + // instead of leaking to the server as chat. (@ verbs pass through to ACE.) + if (trimmed[0] == '/') + { + var verb = ChatInputParser.GetVerbToken(trimmed); + if (!ChatInputParser.IsKnownVerb(verb)) + { + vm.ShowSystemMessage( + $"Unknown command: {verb}. Type /help for the list of supported commands."); + return SubmitOutcome.UnknownCommand; + } + } + + var parsed = ChatInputParser.Parse( + trimmed, defaultChannel, vm.LastIncomingTellSender, vm.LastOutgoingTellTarget); + if (parsed is { } p) + { + bus.Publish(new SendChatCmd(p.Channel, p.TargetName, p.Text)); + return SubmitOutcome.Sent; + } + return SubmitOutcome.Dropped; // e.g. "/t Name" with no message + } + + private static bool TryHandleClientCommand(string trimmed, ChatVM vm) + { + if (EqAny(trimmed, "/help", "/?", "/h", "@help", "@?", "@h")) + { vm.ShowSystemMessage(BuildHelpText()); return true; } + if (EqAny(trimmed, "/clear", "/cls", "@clear", "@cls")) + { vm.Clear(); return true; } + if (EqAny(trimmed, "/framerate", "@framerate")) + { vm.ShowFps(); return true; } + if (EqAny(trimmed, "/loc", "@loc")) + { vm.ShowLocation(); return true; } + return false; + } + + private static bool EqAny(string s, params string[] options) + { + for (int i = 0; i < options.Length; i++) + if (s.Equals(options[i], StringComparison.OrdinalIgnoreCase)) return true; + return false; + } + + private static string BuildHelpText() => + "Note: / and @ are equivalent prefixes.\n" + + "Chat: /say (default), /tell , /reply, /retell\n" + + "Channels: /general /trade /fellowship /allegiance\n" + + " /patron /vassals /monarch /covassals\n" + + " /lfg /roleplay /society /olthoi\n" + + "Client: /help (this) /clear /framerate /loc\n" + + "Server: type @acehelp or @acecommands for ACE's full list."; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `dotnet test tests/AcDream.UI.Abstractions.Tests --filter ChatCommandRouterTests` +Expected: PASS (5 tests). + +- [ ] **Step 5: Repoint `ChatPanel` at the router** + +In `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs`, replace the submit body +(`ChatPanel.cs:194-241`, the `var trimmed = submitted.Trim();` block through +`_input = string.Empty;`) with a single call, and delete the now-dead +`TryHandleClientCommand` / `EqAny` / `BuildHelpText` helpers (they moved to the router): + +```csharp +if (renderer.InputTextSubmit("##chatinput", ref _input, InputBufferMaxLen, out var submitted) + && submitted is not null) +{ + ChatCommandRouter.Submit(submitted, _vm, ctx.Commands, ChatChannelKind.Say); + _input = string.Empty; + renderer.EndChild(); + renderer.End(); + return; +} +``` + +- [ ] **Step 6: Verify the full suite still passes** + +Run: `dotnet test tests/AcDream.UI.Abstractions.Tests` +Expected: PASS — including the existing `ChatPanelInputTests` (they assert the same submit behavior, now via the router). If any assert on a private `ChatPanel` member, redirect it to `ChatCommandRouter`. + +- [ ] **Step 7: Commit** + +```bash +git add src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs \ + src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs \ + tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs +git commit -m "feat(D.2b): extract ChatCommandRouter — shared chat submit pipeline + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task B: `UiChatView` dat-font seam + 1-line wheel + +Make the transcript render in the dat font and scroll one line per wheel notch +(retail `HandleMouseWheel @0x471450`), keeping bottom-pin, drag-select, Ctrl+C. + +**Files:** +- Modify: `src/AcDream.App/UI/UiChatView.cs` +- Test: `tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs`. `UiChatView.CharIndexAt` +is already a pure static taking a `Func` advance lookup — assert the +dat-font advance (`UiDatFont.GlyphAdvance`) drives caret hit-testing: + +```csharp +using AcDream.App.UI; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.App.Tests.UI; + +public class UiChatViewDatFontTests +{ + // Synthetic per-char advance: each glyph 10px wide (Before=2,Width=6,After=2). + private static FontCharDesc Glyph(char c) => new() + { + Unicode = c, HorizontalOffsetBefore = 2, Width = 6, HorizontalOffsetAfter = 2, + OffsetX = 0, OffsetY = 0, Height = 12, VerticalOffsetBefore = 0, + }; + + [Fact] + public void CharIndexAt_UsesDatGlyphAdvance() + { + // "abc" with 10px advances -> midpoints at 5,15,25. x=12 -> caret before 'b' (index 1). + float Adv(char c) => UiDatFont.GlyphAdvance(Glyph(c)); + Assert.Equal(0, UiChatView.CharIndexAt("abc", Adv, 4f)); + Assert.Equal(1, UiChatView.CharIndexAt("abc", Adv, 12f)); + Assert.Equal(3, UiChatView.CharIndexAt("abc", Adv, 100f)); + } + + [Fact] + public void GlyphAdvance_MatchesRetailFormula() + { + // HorizontalOffsetBefore + Width + HorizontalOffsetAfter = 2+6+2 = 10. + Assert.Equal(10f, UiDatFont.GlyphAdvance(Glyph('x'))); + } +} +``` + +- [ ] **Step 2: Run to verify it fails or passes-trivially** + +Run: `dotnet test tests/AcDream.App.Tests --filter UiChatViewDatFontTests` +Expected: PASS for `GlyphAdvance_MatchesRetailFormula` (it's existing), FAIL only if +`FontCharDesc` field names differ — fix the `Glyph(...)` initializer to match the +real `DatReaderWriter.Types.FontCharDesc` (verify via the type before running). The +first test should already pass since `CharIndexAt` is font-agnostic; this test pins +the dat-font advance as the lookup. + +- [ ] **Step 3: Add the dat-font draw + scroll path to `UiChatView`** + +In `src/AcDream.App/UI/UiChatView.cs`: + +1. Add a property next to `Font`: +```csharp +/// Retail dat font (0x40000000) for the transcript. When set, glyphs +/// render via the two-pass dat-font blit and measure/hit-test use the dat glyph +/// advance; when null, the debug BitmapFont path is used. Set by the controller. +public UiDatFont? DatFont { get; set; } +``` +2. Change the wheel quantum to one line per notch (retail `HandleMouseWheel`): +```csharp +private const float WheelLines = 1f; // retail: 1 line per wheel notch (was 3) +``` +3. In `OnDraw`, branch on `DatFont`: use `DatFont.LineHeight` for `lh`, draw each + line with `ctx.DrawStringDat(DatFont, text, Padding, y, color)`, and measure the + selection-highlight span with `DatFont.MeasureWidth(...)`. Keep the `BitmapFont` + branch unchanged as the fallback. Cache `_lastDatFont` alongside `_lastFont` so + `HitChar` uses the same advance source it drew with. +4. In `HitChar`, when `_lastDatFont` is set, build the advance lookup from it: +```csharp +int col = _lastDatFont is { } df + ? CharIndexAt(text, ch => df.TryGetGlyph(ch, out var g) ? UiDatFont.GlyphAdvance(g) : 0f, + localX - _lastPadding) + : (_lastFont is { } bf + ? CharIndexAt(text, ch => bf.TryGetGlyph(ch, out var bg) ? bg.Advance : 0f, + localX - _lastPadding) + : 0); +``` +5. In the `Scroll` event, use the dat-font line height when present: +```csharp +float lh = DatFont?.LineHeight ?? (Font ?? _lastFont)?.LineHeight ?? 16f; +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `dotnet test tests/AcDream.App.Tests --filter UiChatViewDatFontTests` +Expected: PASS. + +- [ ] **Step 5: Build the App project** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` +Expected: 0 errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/AcDream.App/UI/UiChatView.cs tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs +git commit -m "feat(D.2b): UiChatView dat-font transcript + 1-line wheel quantum + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task C: `UiScrollable` (pixel-scroll coordinator) + +Port `UIElement_Scrollable`'s pixel-scroll math: a pure, GL-free coordinator the +transcript and scrollbar both read. No `UiElement` inheritance — it is held by +`UiChatView` and queried by `UiChatScrollbar`. + +**Files:** +- Create: `src/AcDream.App/UI/UiScrollable.cs` +- Test: `tests/AcDream.App.Tests/UI/UiScrollableTests.cs` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/AcDream.App.Tests/UI/UiScrollableTests.cs`: + +```csharp +using AcDream.App.UI; +using Xunit; + +namespace AcDream.App.Tests.UI; + +public class UiScrollableTests +{ + [Fact] + public void Clamp_KeepsScrollWithinContent() + { + var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 }; + s.SetScrollY(500); // over max + Assert.Equal(200, s.ScrollY); // max = 300-100 + s.SetScrollY(-50); + Assert.Equal(0, s.ScrollY); + } + + [Fact] + public void FitsView_PinsToZero() + { + var s = new UiScrollable { ContentHeight = 80, ViewHeight = 100 }; + s.SetScrollY(40); + Assert.Equal(0, s.ScrollY); // content <= view => no scroll + Assert.False(s.HasOverflow); + } + + [Fact] + public void ThumbRatio_IsViewOverContent_ClampedToOne() + { + var s = new UiScrollable { ContentHeight = 400, ViewHeight = 100 }; + Assert.Equal(0.25f, s.ThumbRatio, 3); // 100/400 + var full = new UiScrollable { ContentHeight = 50, ViewHeight = 100 }; + Assert.Equal(1f, full.ThumbRatio, 3); // content < view => full thumb + } + + [Fact] + public void PositionRatio_MapsScrollToZeroOne() + { + var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 }; + s.SetScrollY(100); // half of max(200) + Assert.Equal(0.5f, s.PositionRatio, 3); + s.SetScrollY(200); + Assert.Equal(1f, s.PositionRatio, 3); + } + + [Fact] + public void SetPositionRatio_IsInverseOfPositionRatio() + { + var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 }; + s.SetPositionRatio(0.5f); + Assert.Equal(100, s.ScrollY); // 0.5 * max(200) + } + + [Fact] + public void ScrollByLines_AdvancesByLineHeight() + { + var s = new UiScrollable { ContentHeight = 1000, ViewHeight = 100, LineHeight = 16 }; + s.ScrollByLines(-2); // retail: negative = toward older/top + Assert.Equal(0, s.ScrollY); // already at top, clamped + s.SetScrollY(50); + s.ScrollByLines(2); + Assert.Equal(82, s.ScrollY); // 50 + 2*16 + } + + [Fact] + public void ScrollByPage_AdvancesByViewHeight() + { + var s = new UiScrollable { ContentHeight = 1000, ViewHeight = 100, LineHeight = 16 }; + s.SetScrollY(200); + s.ScrollByPage(1); + Assert.Equal(300, s.ScrollY); // 200 + view(100) + } +} +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `dotnet test tests/AcDream.App.Tests --filter UiScrollableTests` +Expected: FAIL — `UiScrollable` does not exist. + +- [ ] **Step 3: Implement `UiScrollable`** + +Create `src/AcDream.App/UI/UiScrollable.cs`. Ports `UIElement_Scrollable` +(`SetScrollableXY @0x4740c0`, `UpdateScrollbarSize_ @0x4741a0`, +`UpdateScrollbarPosition_ @0x473f20`, `InqScrollDelta @0x4689b0`): + +```csharp +using System; + +namespace AcDream.App.UI; + +/// +/// Pixel-based vertical scroll model. Port of retail UIElement_Scrollable: +/// the scroll offset is an integer pixel value (m_iScrollableY) clamped to +/// [0, ContentHeight - ViewHeight]; the thumb ratio is view/content; the position +/// ratio is scroll/(content-view). Pure (no GL) so it is fully unit-tested and +/// shared by the transcript (UiChatView) and the scrollbar (UiChatScrollbar). +/// +public sealed class UiScrollable +{ + /// Total wrapped content height in px (UIElement_Scrollable m_iScrollableHeight). + public int ContentHeight { get; set; } + /// Visible viewport height in px. + public int ViewHeight { get; set; } + /// Pixels per text line (the scroll quantum). UIElement_Text::InqScrollDelta line case. + public int LineHeight { get; set; } = 16; + + private int _scrollY; + /// Current scroll offset in px from the top of the content. + public int ScrollY => _scrollY; + + /// Max scroll = max(0, content - view). + public int MaxScroll => Math.Max(0, ContentHeight - ViewHeight); + + /// True when content exceeds the view (a scrollbar is warranted). + public bool HasOverflow => ContentHeight > ViewHeight; + + /// True when the offset is at (or past) the bottom — used for bottom-pin. + public bool AtEnd => _scrollY >= MaxScroll; + + /// Set the offset, clamped to [0, MaxScroll] (SetScrollableXY clamp). + public void SetScrollY(int y) => _scrollY = Math.Clamp(y, 0, MaxScroll); + + /// Pin to the bottom (newest content visible). + public void ScrollToEnd() => _scrollY = MaxScroll; + + /// Thumb size ratio = view/content, clamped to 1 (UpdateScrollbarSize_). + public float ThumbRatio => ContentHeight <= 0 ? 1f : Math.Min(1f, (float)ViewHeight / ContentHeight); + + /// Position ratio = scroll/(content-view) in [0,1] (UpdateScrollbarPosition_). + public float PositionRatio => MaxScroll <= 0 ? 0f : (float)_scrollY / MaxScroll; + + /// Inverse of PositionRatio — used when the user drags the thumb. + public void SetPositionRatio(float ratio) + => SetScrollY((int)MathF.Round(Math.Clamp(ratio, 0f, 1f) * MaxScroll)); + + /// Scroll by whole lines (sign: +down/newer, -up/older). + public void ScrollByLines(int lines) => SetScrollY(_scrollY + lines * LineHeight); + + /// Scroll by a page = one view height (InqScrollDelta page case). + public void ScrollByPage(int pages) => SetScrollY(_scrollY + pages * ViewHeight); +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `dotnet test tests/AcDream.App.Tests --filter UiScrollableTests` +Expected: PASS (7 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.App/UI/UiScrollable.cs tests/AcDream.App.Tests/UI/UiScrollableTests.cs +git commit -m "feat(D.2b): UiScrollable — pixel scroll model (UIElement_Scrollable port) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task C2: Wire `UiScrollable` into `UiChatView` + +Replace `UiChatView`'s ad-hoc `_scroll` float with a `UiScrollable`, so the +transcript's content/view height + bottom-pin + line-scroll flow through the +shared model (and the scrollbar in Task D can read the same instance). + +**Files:** +- Modify: `src/AcDream.App/UI/UiChatView.cs` + +- [ ] **Step 1: Hold a `UiScrollable` + expose it** + +Add to `UiChatView`: +```csharp +/// The scroll model — also read by the linked UiChatScrollbar. +public UiScrollable Scroll { get; } = new(); +``` + +- [ ] **Step 2: Drive it from `OnDraw`** + +In `OnDraw`, after computing `lh`, `contentH`, `innerH`, set the model and read back +the offset instead of the local `_scroll`: +```csharp +Scroll.LineHeight = (int)MathF.Round(lh); +Scroll.ContentHeight = (int)MathF.Ceiling(contentH); +Scroll.ViewHeight = (int)MathF.Floor(innerH); +// Bottom-pin: if the user was at the end before content grew, stay pinned. +if (_pinBottom) Scroll.ScrollToEnd(); +float baseY = bottom - contentH + Scroll.ScrollY; // ScrollY is px from top; baseY shifts content +``` +Keep a `private bool _pinBottom = true;` that is set false when the user scrolls up +(in the `Scroll` event, `_pinBottom = Scroll.AtEnd;` after applying the delta) and +true again when they return to the end. + +> The existing `ClampScroll` static + `_scroll` field are superseded by +> `UiScrollable`. Keep `ClampScroll` if other tests reference it; otherwise remove it +> and update `UiChatView`'s scroll-offset reads to `Scroll.ScrollY`. + +- [ ] **Step 3: Route the wheel through the model** + +In the `Scroll` event handler: +```csharp +case UiEventType.Scroll: +{ + // Silk wheel +Y = scroll up = reveal older. Retail: 1 line per notch. + Scroll.ScrollByLines((int)(-e.Data0 * WheelLines)); + _pinBottom = Scroll.AtEnd; + return true; +} +``` + +- [ ] **Step 4: Build + run the App tests** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug && dotnet test tests/AcDream.App.Tests --filter UiChatView` +Expected: build clean; `UiChatViewDatFontTests` still PASS. Adjust any test that +referenced the removed `_scroll`/`ClampScroll` to use `Scroll`. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.App/UI/UiChatView.cs +git commit -m "feat(D.2b): UiChatView drives the shared UiScrollable model + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task D: `UiChatScrollbar` (track + thumb + up/down) + +A `UiElement` that renders the right-side scrollbar and drives a `UiScrollable`. +Follows the `UiMeter` sprite pattern (`SpriteResolve` + `ctx.DrawSprite`). + +**Files:** +- Create: `src/AcDream.App/UI/UiChatScrollbar.cs` + +> **First, locate the scroll up/down button ids in the dat.** Run +> `dotnet run --project src/AcDream.Cli -- dump-vitals-layout "" 0x21000006` +> and inspect the children of track `0x10000012` (and the gold caps seen at the +> top/bottom of the scrollbar in the retail screenshot). Record the up-button and +> down-button element ids + their sprite ids in a comment. If the track has no +> button children, the up/down are part of the track sprite and clicks are handled +> by hit-region (top 16px = up, bottom 16px = down). + +- [ ] **Step 1: Implement the widget** + +Create `src/AcDream.App/UI/UiChatScrollbar.cs`: + +```csharp +using System; +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// Right-side chat scrollbar: a track sprite, a draggable thumb sized to the +/// content/view ratio, and up/down step buttons. Drives a linked +/// . Ports retail UIElement_Scrollbar::UpdateLayout +/// @0x4710d0 (thumb size = trackLen * ThumbRatio, min 8px; thumb pos from +/// PositionRatio) and HandleButtonClick @0x470e90 (step ±1 line). +/// +public sealed class UiChatScrollbar : UiElement +{ + /// The scroll model this bar reflects + drives (shared with the transcript). + public UiScrollable? Model { get; set; } + /// RenderSurface id → (GL tex, w, h). 0 id = skip. + public Func? SpriteResolve { get; set; } + + public uint TrackSprite { get; set; } // 0x10000012 face + public uint ThumbSprite { get; set; } // 0x1000048c face + public uint UpSprite { get; set; } + public uint DownSprite { get; set; } + + private const float MinThumb = 8f; // retail attribute 0x89 floor + private const float ButtonH = 16f; // up/down button square + private bool _draggingThumb; + private float _dragOffsetY; + + public UiChatScrollbar() { CapturesPointerDrag = true; } + + /// Thumb rect in local space (between the two end buttons). + public static (float y, float h) ThumbRect(UiScrollable m, float trackTop, float trackLen) + { + float h = MathF.Max(MinThumb, trackLen * m.ThumbRatio); + float travel = trackLen - h; + float y = trackTop + travel * m.PositionRatio; + return (y, h); + } + + protected override void OnDraw(UiRenderContext ctx) + { + if (Model is not { } m || SpriteResolve is not { } resolve) return; + // Track fills the full height; buttons cap top/bottom; thumb floats between. + DrawSprite(ctx, resolve, TrackSprite, 0, 0, Width, Height); + DrawSprite(ctx, resolve, UpSprite, 0, 0, Width, ButtonH); + DrawSprite(ctx, resolve, DownSprite, 0, Height - ButtonH, Width, ButtonH); + if (m.HasOverflow) + { + float trackTop = ButtonH, trackLen = Height - 2 * ButtonH; + var (ty, th) = ThumbRect(m, trackTop, trackLen); + DrawSprite(ctx, resolve, ThumbSprite, 0, ty, Width, th); + } + } + + private void DrawSprite(UiRenderContext ctx, Func resolve, + uint id, float x, float y, float w, float h) + { + if (id == 0) return; + var (tex, _, _) = resolve(id); + if (tex == 0) return; + ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, 1f, 1f, Vector4.One); + } + + public override bool OnEvent(in UiEvent e) + { + if (Model is not { } m) return false; + switch (e.Type) + { + case UiEventType.MouseDown: + { + float ly = e.Data2; // local Y (UiRoot delivers target-local) + if (ly <= ButtonH) { m.ScrollByLines(-1); return true; } // up button + if (ly >= Height - ButtonH) { m.ScrollByLines(1); return true; } // down button + float trackTop = ButtonH, trackLen = Height - 2 * ButtonH; + var (ty, th) = ThumbRect(m, trackTop, trackLen); + if (ly >= ty && ly <= ty + th) { _draggingThumb = true; _dragOffsetY = ly - ty; } + else m.ScrollByPage(ly < ty ? -1 : 1); // click in track half = page + return true; + } + case UiEventType.MouseMove when _draggingThumb: + { + float trackTop = ButtonH, trackLen = Height - 2 * ButtonH; + float h = MathF.Max(MinThumb, trackLen * m.ThumbRatio); + float travel = MathF.Max(1f, trackLen - h); + m.SetPositionRatio((e.Data2 - _dragOffsetY - trackTop) / travel); + return true; + } + case UiEventType.MouseUp: _draggingThumb = false; return true; + } + return false; + } +} +``` + +- [ ] **Step 2: Build the App project** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` +Expected: 0 errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/AcDream.App/UI/UiChatScrollbar.cs +git commit -m "feat(D.2b): UiChatScrollbar — track/thumb/buttons driving UiScrollable + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task E: `UiChatInput` (editable one-line field) + +Port the `UIElement_Text` edit path: caret, insert/delete, 100-entry history, +focus sprite, dat-font draw, submit callback. Caret math reuses `UiDatFont`. + +**Files:** +- Create: `src/AcDream.App/UI/UiChatInput.cs` +- Test: `tests/AcDream.App.Tests/UI/UiChatInputTests.cs` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/AcDream.App.Tests/UI/UiChatInputTests.cs`. The pure, testable seams are +text editing + history navigation (no GL). The widget exposes them as instance state: + +```csharp +using AcDream.App.UI; +using Xunit; + +namespace AcDream.App.Tests.UI; + +public class UiChatInputTests +{ + [Fact] + public void InsertChar_AdvancesCaret() + { + var input = new UiChatInput(); + input.InsertChar('h'); input.InsertChar('i'); + Assert.Equal("hi", input.Text); + Assert.Equal(2, input.CaretPos); + } + + [Fact] + public void Backspace_DeletesBeforeCaret() + { + var input = new UiChatInput(); + foreach (var c in "abc") input.InsertChar(c); + input.MoveCaret(-1); // caret between 'b' and 'c' + input.Backspace(); // deletes 'b' + Assert.Equal("ac", input.Text); + Assert.Equal(1, input.CaretPos); + } + + [Fact] + public void Submit_FiresCallback_ClearsText_PushesHistory() + { + string? sent = null; + var input = new UiChatInput { OnSubmit = t => sent = t }; + foreach (var c in "hello") input.InsertChar(c); + input.Submit(); + Assert.Equal("hello", sent); + Assert.Equal("", input.Text); + Assert.Equal(0, input.CaretPos); + } + + [Fact] + public void EmptySubmit_DoesNotFire() + { + int n = 0; + var input = new UiChatInput { OnSubmit = _ => n++ }; + input.Submit(); + Assert.Equal(0, n); + } + + [Fact] + public void History_UpDownBrowsesPreviousSubmissions() + { + var input = new UiChatInput { OnSubmit = _ => {} }; + foreach (var c in "first") input.InsertChar(c); input.Submit(); + foreach (var c in "second") input.InsertChar(c); input.Submit(); + input.HistoryPrev(); // most recent + Assert.Equal("second", input.Text); + input.HistoryPrev(); + Assert.Equal("first", input.Text); + input.HistoryNext(); + Assert.Equal("second", input.Text); + input.HistoryNext(); // back to live (empty) + Assert.Equal("", input.Text); + } + + [Fact] + public void History_CapsAt100() + { + var input = new UiChatInput { OnSubmit = _ => {} }; + for (int i = 0; i < 150; i++) { input.InsertChar('x'); input.Submit(); } + Assert.True(input.HistoryCount <= 100); + } +} +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `dotnet test tests/AcDream.App.Tests --filter UiChatInputTests` +Expected: FAIL — `UiChatInput` does not exist. + +- [ ] **Step 3: Implement `UiChatInput`** + +Create `src/AcDream.App/UI/UiChatInput.cs`. Ports `UIElement_Text` editable mode +(`CharacterHandler`, `MoveCursor @0x468d00`, `FindPixelsFromPos @0x472b40`) + +`ChatInterface` history (`ProcessCommand @0x4f5100`, `SelectCommandFromHistory`, +sentinel `-1` = live): + +```csharp +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// Editable one-line chat input. Port of retail UIElement_Text in editable +/// one-line mode + ChatInterface's 100-entry command history. Caret is a +/// glyph index; the caret pixel-X is Σ glyph advances (UiDatFont) to the caret. +/// Submit (Enter / Send) fires , clears, and pushes history. +/// +public sealed class UiChatInput : UiElement +{ + public UiDatFont? DatFont { get; set; } + public BitmapFont? Font { get; set; } + public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f); + public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0.35f); + public float Padding { get; set; } = 4f; + public int MaxCharacters { get; set; } = 0xFFFF; // retail m_nMaxCharacters default + + /// Called on Enter/Send with the (non-empty) text. The widget clears after. + public Action? OnSubmit { get; set; } + + private string _text = ""; + private int _caret; + public string Text => _text; + public int CaretPos => _caret; + + private readonly List _history = new(); + private int _historyIndex = -1; // -1 = live line (not browsing) + public int HistoryCount => _history.Count; + + public UiChatInput() + { + AcceptsFocus = true; + IsEditControl = true; + CapturesPointerDrag = true; + } + + // ── Pure editing seams (unit-tested) ───────────────────────────────── + public void InsertChar(char c) + { + if (c < 0x20 || c == 0x7F) return; // skip controls (retail CharacterHandler) + if (_text.Length >= MaxCharacters) return; + _text = _text.Insert(_caret, c.ToString()); + _caret++; + _historyIndex = -1; // editing returns to the live line + } + + public void Backspace() + { + if (_caret == 0) return; + _text = _text.Remove(_caret - 1, 1); + _caret--; + } + + public void DeleteForward() + { + if (_caret >= _text.Length) return; + _text = _text.Remove(_caret, 1); + } + + public void MoveCaret(int delta) => _caret = Math.Clamp(_caret + delta, 0, _text.Length); + public void CaretHome() => _caret = 0; + public void CaretEnd() => _caret = _text.Length; + + public void Submit() + { + var t = _text; + if (t.Trim().Length == 0) { Clear(); return; } + OnSubmit?.Invoke(t); + PushHistory(t); + Clear(); + } + + private void Clear() { _text = ""; _caret = 0; _historyIndex = -1; } + + private void PushHistory(string t) + { + _history.Add(t); + if (_history.Count > 100) _history.RemoveAt(0); // retail cap 100, drop oldest + _historyIndex = -1; + } + + public void HistoryPrev() // Up arrow — toward older + { + if (_history.Count == 0) return; + _historyIndex = _historyIndex < 0 ? _history.Count - 1 : Math.Max(0, _historyIndex - 1); + SetTextFromHistory(); + } + + public void HistoryNext() // Down arrow — toward newer, then live + { + if (_historyIndex < 0) return; + _historyIndex++; + if (_historyIndex >= _history.Count) { _historyIndex = -1; Clear(); return; } + SetTextFromHistory(); + } + + private void SetTextFromHistory() + { + _text = _history[_historyIndex]; + _caret = _text.Length; + } + + /// Caret pixel-X from the text start (FindPixelsFromPos): Σ advances to caret. + public float CaretPixelX() + => DatFont is { } df ? df.MeasureWidth(_text.Substring(0, _caret)) + : Font is { } bf ? bf.MeasureWidth(_text.Substring(0, _caret)) : 0f; + + // ── Rendering + input ──────────────────────────────────────────────── + protected override void OnDraw(UiRenderContext ctx) + { + ctx.DrawRect(0, 0, Width, Height, BackgroundColor); + float ty = (Height - (DatFont?.LineHeight ?? Font?.LineHeight ?? 14f)) * 0.5f; + if (DatFont is { } df) ctx.DrawStringDat(df, _text, Padding, ty, TextColor); + else if (Font is not null || ctx.DefaultFont is not null) ctx.DrawString(_text, Padding, ty, TextColor, Font); + + // Caret: 1px vertical line at the caret X (blink left to a follow-up; draw solid for now). + if (HasKeyboardFocus()) + { + float cx = Padding + CaretPixelX(); + float ch = DatFont?.LineHeight ?? Font?.LineHeight ?? 14f; + ctx.DrawRect(cx, ty, 1f, ch, TextColor); + } + } + + private bool HasKeyboardFocus() + => (Parent is not null) && FindRoot()?.KeyboardFocus == this; + + private UiRoot? FindRoot() + { + UiElement? e = this; + while (e is not null) { if (e is UiRoot r) return r; e = e.Parent; } + return null; + } + + public override bool OnEvent(in UiEvent e) + { + switch (e.Type) + { + case UiEventType.Char: + InsertChar((char)e.Data0); + return true; + case UiEventType.KeyDown: + { + var key = (Silk.NET.Input.Key)e.Data0; + switch (key) + { + case Silk.NET.Input.Key.Enter: + case Silk.NET.Input.Key.KeypadEnter: Submit(); return true; + case Silk.NET.Input.Key.Backspace: Backspace(); return true; + case Silk.NET.Input.Key.Delete: DeleteForward(); return true; + case Silk.NET.Input.Key.Left: MoveCaret(-1); return true; + case Silk.NET.Input.Key.Right: MoveCaret(1); return true; + case Silk.NET.Input.Key.Home: CaretHome(); return true; + case Silk.NET.Input.Key.End: CaretEnd(); return true; + case Silk.NET.Input.Key.Up: HistoryPrev(); return true; + case Silk.NET.Input.Key.Down: HistoryNext(); return true; + } + return false; + } + } + return false; + } +} +``` + +> **Note on focus access:** the snippet walks to the `UiRoot` to read `KeyboardFocus`. +> If `UiRoot.KeyboardFocus` is not reachable that way at runtime, add a +> `bool Focused` flag set from `UiEventType.FocusGained`/`FocusLost` in `OnEvent` +> instead (the `UiElement` event model delivers both — see `UiRoot.SetKeyboardFocus`). + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `dotnet test tests/AcDream.App.Tests --filter UiChatInputTests` +Expected: PASS (6 tests). + +- [ ] **Step 5: Build the App project** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` +Expected: 0 errors. (If `e.Data0` for `Char` is the codepoint per `UiRoot.OnChar`, +the `(char)e.Data0` cast is correct.) + +- [ ] **Step 6: Commit** + +```bash +git add src/AcDream.App/UI/UiChatInput.cs tests/AcDream.App.Tests/UI/UiChatInputTests.cs +git commit -m "feat(D.2b): UiChatInput — editable field, caret, 100-entry history (UIElement_Text port) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task F: `UiChannelMenu` (channel selector) + +The `Chat ▸` selector: a button showing the active channel; clicking opens a popup +list of channels; selecting one fires a channel-changed callback. Ports +`UIElement_Menu` minimally (a button + a popup item list). + +**Files:** +- Create: `src/AcDream.App/UI/UiChannelMenu.cs` + +- [ ] **Step 1: Implement the widget** + +Create `src/AcDream.App/UI/UiChannelMenu.cs`. The 13 channels map to +`ChatChannelKind` (retail `InitTalkFocusMenu @0x4cdc50` enum: 1=Say, 4=Fellowship, +5=Patron, 6=Trade, 7=Allegiance, …). The popup is a vertical list drawn on click; +selection updates `Selected` + fires `OnChannelChanged`. + +```csharp +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.UI.Abstractions; + +namespace AcDream.App.UI; + +/// +/// Chat channel selector (the "Chat ▸" button). Port of retail +/// UIElement_Menu as used by gmMainChatUI::InitTalkFocusMenu @0x4cdc50: +/// a button whose label is the active channel; clicking opens a popup of channels; +/// selecting one calls SetTalkFocus (here: ). +/// +public sealed class UiChannelMenu : UiElement +{ + public readonly record struct Item(string Label, ChatChannelKind Channel); + + /// Retail talk-focus channels (subset acdream's ChatInputParser routes). + public static readonly Item[] Channels = + { + new("Say", ChatChannelKind.Say), + new("General", ChatChannelKind.General), + new("Trade", ChatChannelKind.Trade), + new("LFG", ChatChannelKind.Lfg), + new("Fellowship", ChatChannelKind.Fellowship), + new("Allegiance", ChatChannelKind.Allegiance), + new("Patron", ChatChannelKind.Patron), + new("Vassals", ChatChannelKind.Vassals), + new("Monarch", ChatChannelKind.Monarch), + new("Roleplay", ChatChannelKind.Roleplay), + new("Society", ChatChannelKind.Society), + new("Olthoi", ChatChannelKind.Olthoi), + }; + + public ChatChannelKind Selected { get; private set; } = ChatChannelKind.Say; + public Action? OnChannelChanged { get; set; } + + public UiDatFont? DatFont { get; set; } + public BitmapFont? Font { get; set; } + public Func? SpriteResolve { get; set; } + public uint NormalSprite { get; set; } // 0x06004D65 + public uint PressedSprite { get; set; } // 0x06004D66 + public Vector4 TextColor { get; set; } = new(1f, 0.85f, 0.4f, 1f); + + private bool _open; + private const float ItemH = 16f; + + public UiChannelMenu() { CapturesPointerDrag = true; } + + private string Label => FindLabel(Selected); + private static string FindLabel(ChatChannelKind k) + { + foreach (var it in Channels) if (it.Channel == k) return it.Label; + return "Chat"; + } + + protected override void OnDraw(UiRenderContext ctx) + { + // Button face. + if (SpriteResolve is { } resolve) + { + var (tex, _, _) = resolve(_open ? PressedSprite : NormalSprite); + if (tex != 0) ctx.DrawSprite(tex, 0, 0, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); + } + DrawLabel(ctx, Label + " >", 2f, (Height - LineH()) * 0.5f); + + // Popup list above the button (chat is at screen bottom). + if (_open) + { + float h = Channels.Length * ItemH; + float top = -h; + ctx.DrawRect(0, top, MathF.Max(Width, 90f), h, new(0f, 0f, 0f, 0.85f)); + for (int i = 0; i < Channels.Length; i++) + DrawLabel(ctx, Channels[i].Label, 2f, top + i * ItemH); + } + } + + private float LineH() => DatFont?.LineHeight ?? Font?.LineHeight ?? 14f; + private void DrawLabel(UiRenderContext ctx, string s, float x, float y) + { + if (DatFont is { } df) ctx.DrawStringDat(df, s, x, y, TextColor); + else ctx.DrawString(s, x, y, TextColor, Font); + } + + protected override bool OnHitTest(float lx, float ly) + => _open ? (lx >= 0 && lx < MathF.Max(Width, 90f) && ly >= -Channels.Length * ItemH && ly < Height) + : base.OnHitTest(lx, ly); + + public override bool OnEvent(in UiEvent e) + { + if (e.Type == UiEventType.MouseDown) + { + float ly = e.Data2; + if (_open && ly < 0) // clicked an item in the popup + { + int idx = (int)((ly + Channels.Length * ItemH) / ItemH); + if (idx >= 0 && idx < Channels.Length) + { + Selected = Channels[idx].Channel; + OnChannelChanged?.Invoke(Selected); + } + _open = false; + return true; + } + _open = !_open; // toggle on button click + return true; + } + return false; + } +} +``` + +- [ ] **Step 2: Build the App project** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` +Expected: 0 errors. (Verify `ChatChannelKind` has the members used; adjust the +`Channels` table to the real enum names if any differ.) + +- [ ] **Step 3: Commit** + +```bash +git add src/AcDream.App/UI/UiChannelMenu.cs +git commit -m "feat(D.2b): UiChannelMenu — channel selector popup (UIElement_Menu port) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task G: `ChatWindowController` (import + bind + route) + +The `ChatInterface`/`gmMainChatUI::PostInit` analogue: import `0x21000006`, bind by +id, swap the transcript/input placeholders for the behavioral widgets, wire the +scrollbar/menu/send/max-min, and route inbound (`ChatVM`) + outbound +(`ChatCommandRouter`). + +**Files:** +- Create: `src/AcDream.App/UI/Layout/ChatWindowController.cs` + +- [ ] **Step 1: Implement the controller** + +Create `src/AcDream.App/UI/Layout/ChatWindowController.cs`: + +```csharp +using System; +using AcDream.UI.Abstractions; +using AcDream.UI.Abstractions.Panels.Chat; + +namespace AcDream.App.UI.Layout; + +/// +/// Binds the imported chat LayoutDesc (0x21000006) to live behavior — the acdream +/// analogue of retail ChatInterface + gmMainChatUI::PostInit. It +/// FindElement(id)s each role, swaps the transcript/input placeholders for the +/// behavioral widgets, wires the scrollbar/menu/send/max-min, and routes chat. +/// +public sealed class ChatWindowController +{ + public const uint LayoutId = 0x21000006u; + public const uint TranscriptId = 0x10000011u; + public const uint InputId = 0x10000016u; + public const uint TrackId = 0x10000012u; + public const uint ThumbId = 0x1000048Cu; + public const uint MenuId = 0x10000014u; + public const uint SendId = 0x10000019u; + public const uint MaxMinId = 0x1000046Fu; + + public UiChatView Transcript { get; private set; } = null!; + public UiChatInput Input { get; private set; } = null!; + public UiChatScrollbar Scrollbar { get; private set; } = null!; + public UiChannelMenu Menu { get; private set; } = null!; + + /// Bind an imported chat layout. Returns the controller, or null if the + /// required role elements are missing. + public static ChatWindowController? Bind( + ImportedLayout layout, ChatVM vm, ICommandBus bus, + UiDatFont? datFont, BitmapFont? debugFont, + Func resolve) + { + var transcriptPh = layout.FindElement(TranscriptId); + var inputPh = layout.FindElement(InputId); + if (transcriptPh is null || inputPh is null) return null; + + var c = new ChatWindowController(); + + // Transcript — swap placeholder for UiChatView at the same rect/anchors. + c.Transcript = new UiChatView + { + Left = transcriptPh.Left, Top = transcriptPh.Top, + Width = transcriptPh.Width, Height = transcriptPh.Height, + Anchors = transcriptPh.Anchors, + DatFont = datFont, Font = debugFont, + LinesProvider = () => BuildLines(vm), + }; + ReplaceInParent(transcriptPh, c.Transcript); + + // Input — swap placeholder for UiChatInput. + c.Input = new UiChatInput + { + Left = inputPh.Left, Top = inputPh.Top, + Width = inputPh.Width, Height = inputPh.Height, + Anchors = inputPh.Anchors, + DatFont = datFont, Font = debugFont, + }; + ReplaceInParent(inputPh, c.Input); + + // Menu — swap placeholder for UiChannelMenu (label tracks the active channel). + var menuPh = layout.FindElement(MenuId); + c.Menu = new UiChannelMenu { DatFont = datFont, Font = debugFont, SpriteResolve = resolve }; + if (menuPh is not null) + { + c.Menu.Left = menuPh.Left; c.Menu.Top = menuPh.Top; + c.Menu.Width = menuPh.Width; c.Menu.Height = menuPh.Height; + c.Menu.Anchors = menuPh.Anchors; + ReplaceInParent(menuPh, c.Menu); + } + + // Scrollbar — swap the track placeholder for the scrollbar widget driving the + // transcript's UiScrollable. + var trackPh = layout.FindElement(TrackId); + c.Scrollbar = new UiChatScrollbar { Model = c.Transcript.Scroll, SpriteResolve = resolve }; + if (trackPh is not null) + { + c.Scrollbar.Left = trackPh.Left; c.Scrollbar.Top = trackPh.Top; + c.Scrollbar.Width = trackPh.Width; c.Scrollbar.Height = trackPh.Height; + c.Scrollbar.Anchors = trackPh.Anchors; + // Sprite ids: read from the imported track/thumb nodes (TrackSprite, ThumbSprite). + ReplaceInParent(trackPh, c.Scrollbar); + } + + // Routing: input submit -> ChatCommandRouter with the menu's active channel. + c.Input.OnSubmit = text => + ChatCommandRouter.Submit(text, vm, bus, c.Menu.Selected); + c.Menu.OnChannelChanged = _ => { /* active channel read live from Menu.Selected */ }; + + // Send button -> submit (alternate trigger, retail ListenToElementMessage 0x10000019). + var send = layout.FindElement(SendId); + if (send is not null) send.ClickThrough = false; // ensure it receives clicks + // (wire send click -> c.Input.Submit() in the controller's event hook or via a + // small click handler subclass; if FindElement returns a UiDatElement, attach + // an OnClick delegate — add one to UiDatElement if absent.) + + return c; + } + + private static void ReplaceInParent(UiElement placeholder, UiElement widget) + { + var parent = placeholder.Parent; + if (parent is null) return; + parent.RemoveChild(placeholder); + parent.AddChild(widget); + } + + private static System.Collections.Generic.IReadOnlyList BuildLines(ChatVM vm) + { + var detailed = vm.RecentLinesDetailed(); + var result = new UiChatView.Line[detailed.Count]; + for (int i = 0; i < detailed.Count; i++) + result[i] = new UiChatView.Line(detailed[i].Text, RetailChatColor(detailed[i].Kind)); + return result; + } + + // Per-ChatKind palette (moved from GameWindow.RetailChatColor in Task H). + private static System.Numerics.Vector4 RetailChatColor(AcDream.Core.Chat.ChatKind kind) => kind switch + { + AcDream.Core.Chat.ChatKind.LocalSpeech => new(1f, 1f, 1f, 1f), + AcDream.Core.Chat.ChatKind.RangedSpeech => new(1f, 0.95f, 0.8f, 1f), + AcDream.Core.Chat.ChatKind.Channel => new(0.6f, 0.8f, 1f, 1f), + AcDream.Core.Chat.ChatKind.Tell => new(1f, 0.5f, 1f, 1f), + AcDream.Core.Chat.ChatKind.System => new(1f, 1f, 0.45f, 1f), + AcDream.Core.Chat.ChatKind.Popup => new(1f, 0.85f, 0.4f, 1f), + AcDream.Core.Chat.ChatKind.Emote => new(0.8f, 0.8f, 0.7f, 1f), + AcDream.Core.Chat.ChatKind.SoulEmote => new(0.8f, 0.8f, 0.7f, 1f), + AcDream.Core.Chat.ChatKind.Combat => new(1f, 0.6f, 0.25f, 1f), + _ => new(0.9f, 0.9f, 0.9f, 1f), + }; +} +``` + +> **Send-button + max/min click wiring:** `LayoutImporter` builds those as +> `UiDatElement` sprite nodes. If `UiDatElement` has no click hook, add an +> `Action? OnClick` invoked from `OnEvent(UiEventType.Click)` (small change, generic +> + reusable). Wire `send.OnClick = () => Input.Submit();` and +> `maxmin.OnClick = ToggleMaximize;`. The max/min toggle ports +> `gmMainChatUI::HandleMaximizeButton @0x4cce50` (swap between authored height and +> full-parent height, storing old Y/height). If that grows large, file it as a +> follow-up and leave the button inert this pass (note in a divergence row). + +- [ ] **Step 2: Build the App project** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` +Expected: 0 errors. Resolve the sprite-id reads for the scrollbar (`TrackSprite`/ +`ThumbSprite`) by pulling them from the imported track/thumb `ElementInfo.StateMedia` +(or `UiDatElement`), following the `DatWidgetFactory.SliceIds` pattern. + +- [ ] **Step 3: Commit** + +```bash +git add src/AcDream.App/UI/Layout/ChatWindowController.cs +git commit -m "feat(D.2b): ChatWindowController — bind chat LayoutDesc, route in/outbound + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task H: `GameWindow` cutover + register + roadmap + +Replace the hand-authored chat block with the controller; default placement; remove +dead code; add divergence rows; mark the work landed. + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` +- Modify: `docs/architecture/retail-divergence-register.md` +- Modify: `docs/plans/2026-04-11-roadmap.md` + +- [ ] **Step 1: Swap the chat block in `GameWindow`** + +In `src/AcDream.App/Rendering/GameWindow.cs`, in the `if (_options.RetailUi)` block, +replace the "Retail chat window" section (`GameWindow.cs:1836-1887`, the +`retailChatVm` + `UiNineSlicePanel` + `UiChatView` + `BuildRetailChatLines` + +`RetailChatColor` block) with: + +```csharp +// Retail chat window — data-driven from LayoutDesc 0x21000006 (gmMainChatUI), +// the same importer path as vitals. ChatWindowController binds the transcript, +// input, scrollbar and channel menu and routes through ChatVM + ChatCommandRouter. +var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200); +AcDream.App.UI.Layout.ImportedLayout? chatLayout; +lock (_datLock) + chatLayout = AcDream.App.UI.Layout.LayoutImporter.Import( + _dats!, AcDream.App.UI.Layout.ChatWindowController.LayoutId, ResolveChrome, vitalsDatFont); +if (chatLayout is not null) +{ + var chatController = AcDream.App.UI.Layout.ChatWindowController.Bind( + chatLayout, retailChatVm, _commandBus, vitalsDatFont, _debugFont, ResolveChrome); + if (chatController is not null) + { + var chatRoot = chatLayout.Root; + chatRoot.Left = 10; chatRoot.Top = 432; // bottom-left default; user adjusts visually + chatRoot.Anchors = AcDream.App.UI.AnchorEdges.None; + chatRoot.Draggable = true; + chatRoot.Resizable = true; + chatRoot.MinWidth = 200f; chatRoot.MinHeight = 80f; + _uiHost.Root.AddChild(chatRoot); + Console.WriteLine("[D.2b] retail chat window from LayoutDesc importer (0x21000006)."); + } + else Console.WriteLine("[D.2b] chat: required role elements missing in 0x21000006."); +} +else Console.WriteLine("[D.2b] chat: LayoutDesc 0x21000006 not found."); +``` + +> `_commandBus` must be the live `ICommandBus` the chat `SendChatCmd` handler is +> registered on. Confirm the field name in `GameWindow` (grep `ICommandBus` / +> `LiveCommandBus` — it is the same bus the ImGui `ChatPanel` publishes to). If the +> chat window root needs `vitalsDatFont` loaded first, this block already runs after +> the vitals block where `vitalsDatFont` is created — keep that ordering. + +- [ ] **Step 2: Build + run the full suite** + +Run: `dotnet build && dotnet test` +Expected: build clean; all tests green. Remove any now-unused `using`/helpers left in +`GameWindow` (the old `BuildRetailChatLines`/`RetailChatColor` local statics). + +- [ ] **Step 3: Add divergence-register rows** + +In `docs/architecture/retail-divergence-register.md`, add one row each (cite +`file:line`): (1) two-class transcript/input split [Adaptation]; (2) no in-element +word-wrap [Approximation]; (3) one color per line [Approximation]; (4) chat tabs +render but don't switch/filter [Stopgap]; (5) squelch + name-tags absent [Stopgap]; +(6) single default opacity, default font face/size [Approximation]. + +- [ ] **Step 4: Visual verification (user)** + +Launch live and confirm against the retail screenshot: +```powershell +$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1" +$env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000" +$env:ACDREAM_TEST_USER="testaccount"; $env:ACDREAM_TEST_PASS="testpassword" +$env:ACDREAM_RETAIL_UI="1" +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath chat-redrive.log +``` +Confirm: transcript scrolls in the dat font; scrollbar thumb sizes + drags; type + +Enter/Send dispatch; channel menu switches; window moves/resizes; translucent frame. + +- [ ] **Step 5: Update the roadmap + commit** + +Mark the chat re-drive landed in `docs/plans/2026-04-11-roadmap.md` (D.2b importer +Plan 2 — chat). Commit: +```bash +git add src/AcDream.App/Rendering/GameWindow.cs \ + docs/architecture/retail-divergence-register.md docs/plans/2026-04-11-roadmap.md +git commit -m "feat(D.2b): cut GameWindow over to the data-driven chat window + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Self-Review checklist (done while writing) + +- **Spec coverage:** §4 components ↔ Tasks A–H (router→A, transcript dat-font→B, + scrollable→C/C2, scrollbar→D, input→E, menu→F, controller→G, cutover→H). Deferred + items (§2/§6) → register rows in H Step 3. ✓ +- **Placeholders:** the two forward-discoveries (scroll up/down button ids in D; send/ + max-min click hook in G) are explicit, scoped implementation tasks with a fallback, + not hand-waves. ✓ +- **Type consistency:** `UiScrollable` API (`ScrollY`, `ThumbRatio`, `PositionRatio`, + `SetPositionRatio`, `ScrollByLines/Page`) used consistently in C, C2, D. `UiChatView.Scroll` + exposed in C2, consumed in D/G. `ChatCommandRouter.Submit(raw, vm, bus, channel)` defined + in A, called in E-wiring/G. `UiChatInput.OnSubmit`/`Submit()` consistent E↔G. ✓ diff --git a/docs/superpowers/plans/2026-06-15-layoutdesc-importer.md b/docs/superpowers/plans/2026-06-15-layoutdesc-importer.md new file mode 100644 index 00000000..33afb841 --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-layoutdesc-importer.md @@ -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 1–6 (enumeration → importer → inheritance → generic renderer → factory → vitals controller → conformance). NOT in Plan 1: window manager, chat re-drive, the full long-tail of element types (Plan 2). The generic renderer's fallback means un-widgeted types still draw their sprites. + +--- + +## File structure + +``` +src/AcDream.App/UI/Layout/ ← new namespace for the importer + ElementReader.cs — typed read of ElementDesc fields + inheritance merge (pure, GL-free) + LayoutImporter.cs — read a LayoutDesc, walk the tree, build the UiElement tree + UiDatElement.cs — generic element: draws its state media by DrawMode (tile/blend) + DatWidgetFactory.cs — Type → widget (UiMeter / dat-font label) else UiDatElement + VitalsController.cs — bind live data to elements by id (mirrors gmVitalsUI) +src/AcDream.App/Rendering/GameWindow.cs ← wire importer under a flag, alongside the existing path +docs/research/2026-06-15-layoutdesc-format.md ← Task 1 enumeration reference +tests/AcDream.App.Tests/UI/Layout/ ← new test folder + ElementReaderTests.cs — inheritance merge, edge-flags → anchors (pure) + DatWidgetFactoryTests.cs— Type → widget mapping + VitalsBindingTests.cs — bind-by-id wiring + LayoutConformanceTests.cs — vitals tree golden checks (uses a committed fixture) +tests/AcDream.App.Tests/UI/Layout/fixtures/ + vitals_2100006C.json — dumped vitals layout tree (so tests need no dats) +``` + +Pure logic (inheritance merge, anchor mapping, factory decision, draw-mode UV) is GL-free and dat-free so it unit-tests without the user's dats. The dat-reading shell is exercised by the headless conformance tool + the committed fixture. + +--- + +### Task 1: Format enumeration reference doc (research) + +Pins down the exact `DatReaderWriter` API and the format vocabulary the later tasks depend on. No production code. + +**Files:** +- Create: `docs/research/2026-06-15-layoutdesc-format.md` + +- [ ] **Step 1: Enumerate the DatReaderWriter types** + +Run (PowerShell), capturing output: +``` +dotnet run --project src\AcDream.Cli\AcDream.Cli.csproj --no-build -- dump-vitals-layout "$env:USERPROFILE\Documents\Asheron's Call" 0x2100006C +``` +From this + the package, record the exact member names/types of `ElementDesc` (confirm `ElementId, Type, X, Y, Width, Height, LeftEdge, TopEdge, RightEdge, BottomEdge, ZLevel, BaseElement, BaseLayoutId, StateDesc, States, Children`), `StateDesc` (its `Media` collection + how properties like font `0x1A` / fill `0x69` are stored), and `MediaDescImage` (`File, DrawMode`) / `MediaDescCursor`. + +- [ ] **Step 2: Enumerate the Type + DrawMode vocabulary from the decomp** + +Grep `docs/research/named-retail/acclient_2013_pseudo_c.txt` for the `UIElement_*` class names + their render methods, the `DrawModeType` values, and the KSML keyword registrations (`KW_*` near `0x71b540`). Record each element `Type` value → meaning + render method, and each `DrawMode` value → behavior (Normal=tile, Alphablend, Stretch, …). + +- [ ] **Step 3: Cross-check against real layouts** + +Dump `0x21000014`, `0x21000075`, and `0x2100003F` (the vitals number-text base layout) and confirm which Types/DrawModes/properties actually occur. Note the inheritance chain for the vitals number-text element. + +- [ ] **Step 4: Write the reference doc** + +Write `docs/research/2026-06-15-layoutdesc-format.md` with sections: ElementDesc API, StateDesc/properties, MediaDesc kinds, the Type table (value → meaning → render method → generic-or-widget bucket), the DrawMode table, and the inheritance rules. Mark which types/draw-modes the vitals window uses (Plan 1 surface) vs the long tail (Plan 2). + +- [ ] **Step 5: Commit** + +``` +git add docs/research/2026-06-15-layoutdesc-format.md +git commit -m "docs(D.2b): LayoutDesc format enumeration (importer groundwork)" +``` + +--- + +### Task 2: ElementReader — inheritance merge + edge-flags → anchors (pure) + +**Files:** +- Create: `src/AcDream.App/UI/Layout/ElementReader.cs` +- Test: `tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs` + +`ElementReader` holds the pure, GL-free, dat-free transforms the importer needs. Model the element as a small POCO `ElementInfo` so the pure logic is testable without constructing `DatReaderWriter.ElementDesc`. + +- [ ] **Step 1: Write the failing tests** + +```csharp +using AcDream.App.UI; +using AcDream.App.UI.Layout; +namespace AcDream.App.Tests.UI.Layout; + +public class ElementReaderTests +{ + [Fact] + public void EdgeFlagsToAnchors_LeftRight_Stretches() + { + // Edge flag value 4 = "anchor to that side" per the format doc; left+right both anchored ⇒ width stretches. + var a = ElementReader.ToAnchors(left: 4, top: 1, right: 4, bottom: 1); + Assert.True(a.HasFlag(AnchorEdges.Left)); + Assert.True(a.HasFlag(AnchorEdges.Right)); + Assert.False(a.HasFlag(AnchorEdges.Bottom)); + } + + [Fact] + public void Merge_BaseThenOverride_DerivedWins() + { + var base_ = new ElementInfo { Type = 0, FontDid = 0x40000000, Width = 150, Height = 16 }; + var derived = new ElementInfo { Type = 0, Width = 200 }; // overrides width, inherits font + height + var merged = ElementReader.Merge(base_, derived); + Assert.Equal(200, merged.Width); // override + Assert.Equal(16, merged.Height); // inherited + Assert.Equal(0x40000000u, merged.FontDid);// inherited + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~ElementReaderTests"` +Expected: FAIL — `ElementReader` / `ElementInfo` not defined. + +- [ ] **Step 3: Implement ElementReader + ElementInfo** + +```csharp +namespace AcDream.App.UI.Layout; + +/// GL-free, dat-free snapshot of a resolved layout element. Populated by the +/// importer from DatReaderWriter.ElementDesc (after inheritance); the pure transforms +/// below operate on it so they unit-test without the dats. +public sealed class ElementInfo +{ + public uint Id; + public int Type; + public float X, Y, Width, Height; + public int Left, Top, Right, Bottom; // edge-anchor flags + public uint FontDid; // 0 = none (inherited via Merge) + // sprite per state: state name -> (file, drawMode). "" = DirectState. + public Dictionary StateMedia = new(); +} + +public static class ElementReader +{ + /// Edge-anchor flags → AnchorEdges. Flag value 4 (per format doc) = "pinned + /// to that side"; any other value = not pinned. Left+Right ⇒ width stretches. + public static AnchorEdges ToAnchors(int left, int top, int right, int bottom) + { + var a = AnchorEdges.None; + if (left == 4) a |= AnchorEdges.Left; + if (top == 4) a |= AnchorEdges.Top; + if (right == 4) a |= AnchorEdges.Right; + if (bottom == 4) a |= AnchorEdges.Bottom; + if (a == AnchorEdges.None) a = AnchorEdges.Left | AnchorEdges.Top; // default: pin top-left + return a; + } + + /// Merge a base element with a derived override: start from base, apply any + /// non-default field the derived element sets. Mirrors BaseElement/BaseLayoutId. + public static ElementInfo Merge(ElementInfo base_, ElementInfo derived) + { + var m = new ElementInfo + { + Id = derived.Id != 0 ? derived.Id : base_.Id, + Type = derived.Type != 0 ? derived.Type : base_.Type, + X = derived.X, Y = derived.Y, // position is the derived placement + Width = derived.Width != 0 ? derived.Width : base_.Width, + Height = derived.Height != 0 ? derived.Height : base_.Height, + Left = derived.Left, Top = derived.Top, Right = derived.Right, Bottom = derived.Bottom, + FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid, + StateMedia = new Dictionary(base_.StateMedia), + }; + foreach (var kv in derived.StateMedia) m.StateMedia[kv.Key] = kv.Value; // derived overrides + return m; + } +} +``` +> NOTE: confirm the edge-flag "pinned" value (4) and the font-property key against Task 1's doc; adjust the `== 4` test if the doc says otherwise. + +- [ ] **Step 4: Run to verify pass** + +Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~ElementReaderTests"` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +``` +git add src/AcDream.App/UI/Layout/ElementReader.cs tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs +git commit -m "feat(D.2b): ElementReader — layout inheritance merge + edge-flag anchors" +``` + +--- + +### Task 3: UiDatElement — generic element + draw-mode render + +**Files:** +- Create: `src/AcDream.App/UI/Layout/UiDatElement.cs` + +Generic widget: holds an `ElementInfo` + the active state name, draws that state's media by draw-mode. Reuses the proven tiling render (UV-repeat at native width; UI textures are `GL_REPEAT`-wrapped). + +- [ ] **Step 1: Write the failing test (active-state selection is pure)** + +```csharp +using AcDream.App.UI.Layout; +namespace AcDream.App.Tests.UI.Layout; + +public class UiDatElementTests +{ + [Fact] + public void ActiveMedia_PrefersNamedStateOverDirect() + { + var info = new ElementInfo(); + info.StateMedia[""] = (0x06000001, 0); // DirectState + info.StateMedia["ShowDetail"] = (0x06000002, 1); // named + var e = new UiDatElement(info, (_, _) => (0, 0, 0)) { ActiveState = "ShowDetail" }; + Assert.Equal(0x06000002u, e.ActiveMedia().File); + e.ActiveState = ""; + Assert.Equal(0x06000001u, e.ActiveMedia().File); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~UiDatElementTests"` +Expected: FAIL — `UiDatElement` not defined. + +- [ ] **Step 3: Implement UiDatElement** + +```csharp +using System; +using System.Numerics; + +namespace AcDream.App.UI.Layout; + +/// Generic dat element: draws its active state's media by DrawMode (Normal=tile, +/// Alphablend=blended overlay). The fallback renderer for every element type without a +/// dedicated behavioral widget; faithful because retail's base element render is exactly +/// "stamp the media per draw-mode". +public sealed class UiDatElement : UiElement +{ + private readonly ElementInfo _info; + private readonly Func _resolve; + public string ActiveState { get; set; } = ""; + + public UiDatElement(ElementInfo info, Func resolve) + { + _info = info; _resolve = resolve; + ClickThrough = true; // generic decoration; behavioral widgets opt back in + } + + public (uint File, int DrawMode) ActiveMedia() + => _info.StateMedia.TryGetValue(ActiveState, out var m) ? m + : _info.StateMedia.TryGetValue("", out var d) ? d + : (0u, 0); + + protected override void OnDraw(UiRenderContext ctx) + { + var (file, drawMode) = ActiveMedia(); + if (file == 0) return; + var (tex, tw, th) = _resolve(file); + if (tex == 0 || tw == 0 || th == 0) return; + // DrawMode 0 = Normal → TILE at native size (UV-repeat; GL_REPEAT-wrapped UI texture), + // matching ImgTex::TileCSI. (Alphablend/others are the same blit with a blend state; + // the sprite shader already alpha-blends, so the quad is identical here.) + ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One); + } +} +``` +> NOTE: confirm `DrawMode` enum values against Task 1; if a value needs a non-tiled blit (e.g. a true Stretch), branch here. For the vitals surface (Normal + Alphablend) the tiled UV-repeat quad is correct. + +- [ ] **Step 4: Run to verify pass** + +Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~UiDatElementTests"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +``` +git add src/AcDream.App/UI/Layout/UiDatElement.cs tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs +git commit -m "feat(D.2b): UiDatElement — generic per-drawmode element renderer" +``` + +--- + +### Task 4: DatWidgetFactory — Type → widget (else generic) + +**Files:** +- Create: `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` +- Test: `tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs` + +- [ ] **Step 1: Write the failing tests** + +```csharp +using AcDream.App.UI; +using AcDream.App.UI.Layout; +namespace AcDream.App.Tests.UI.Layout; + +public class DatWidgetFactoryTests +{ + private static (uint, int, int) NoTex(uint _) => (0, 0, 0); + + [Fact] + public void Type7_Meter_MakesUiMeter() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 7, Width = 150, Height = 16 }, NoTex, null); + Assert.IsType(e); + } + + [Fact] + public void UnknownType_FallsBackToGeneric() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 999 }, NoTex, null); + Assert.IsType(e); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~DatWidgetFactoryTests"` +Expected: FAIL — `DatWidgetFactory` not defined. + +- [ ] **Step 3: Implement DatWidgetFactory** + +```csharp +using System; + +namespace AcDream.App.UI.Layout; + +/// Hybrid factory: behavioral element Types map to dedicated widgets (verbatim +/// algorithm ports); everything else (and unknown Types) falls back to UiDatElement. +/// The Type→bucket assignment comes from the format enumeration (Task 1). +public static class DatWidgetFactory +{ + /// RenderSurface id → (GL tex, w, h). + /// Retail UI font for text elements (may be null pre-load). + public static UiElement Create(ElementInfo info, + Func resolve, UiDatFont? datFont) + { + var e = info.Type switch + { + 7 => BuildMeter(info, resolve), // UIElement_Meter + _ => new UiDatElement(info, resolve), + }; + e.Left = info.X; e.Top = info.Y; e.Width = info.Width; e.Height = info.Height; + e.Anchors = ElementReader.ToAnchors(info.Left, info.Top, info.Right, info.Bottom); + return e; + } + + private static UiElement BuildMeter(ElementInfo info, Func resolve) + => new UiMeter { SpriteResolve = resolve }; // back/front slice ids + binding set by the controller +} +``` +> NOTE: text (Type 0) keeps using the generic element for now; the dat-font label binding happens in the controller via `UiDatFont`. Add a dedicated text widget in Plan 2 if the enumeration shows behavior beyond "draw a bound string". + +- [ ] **Step 4: Run to verify pass** + +Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~DatWidgetFactoryTests"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +``` +git add src/AcDream.App/UI/Layout/DatWidgetFactory.cs tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +git commit -m "feat(D.2b): DatWidgetFactory — Type→widget hybrid mapping" +``` + +--- + +### Task 5: LayoutImporter — read layout, resolve inheritance, build tree + +**Files:** +- Create: `src/AcDream.App/UI/Layout/LayoutImporter.cs` + +Reads a `LayoutDesc` via `DatCollection`, converts each `ElementDesc` to `ElementInfo` (resolving `BaseElement`/`BaseLayoutId` via `ElementReader.Merge`), builds the widget tree via the factory, and recurses into children. Exposes `FindElement(uint id)`. + +- [ ] **Step 1: Write the failing test (uses the committed fixture, no dats)** + +Create `tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json` by serializing the dumped tree (a list of `ElementInfo`-shaped records). Test that the importer's pure `BuildFromInfos` produces the right tree: +```csharp +using AcDream.App.UI; +using AcDream.App.UI.Layout; +namespace AcDream.App.Tests.UI.Layout; + +public class LayoutImporterTests +{ + [Fact] + public void BuildFromInfos_HealthMeter_IsUiMeterAtRect() + { + // health meter element 0x100000E6: X=5,Y=5,150x16,Type=7 + var root = new ElementInfo { Id = 0x100005F9, Type = 3, Width = 160, Height = 58 }; + var health = new ElementInfo { Id = 0x100000E6, Type = 7, X = 5, Y = 5, Width = 150, Height = 16 }; + var tree = LayoutImporter.BuildFromInfos(root, new[] { health }, (_, _) => (0, 0, 0), null); + var found = tree.FindElement(0x100000E6); + Assert.IsType(found); + Assert.Equal(5f, found!.Left); Assert.Equal(150f, found.Width); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~LayoutImporterTests"` +Expected: FAIL — `LayoutImporter` not defined. + +- [ ] **Step 3: Implement LayoutImporter** + +```csharp +using System; +using System.Collections.Generic; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.App.UI.Layout; + +/// Reads a retail LayoutDesc into a UiElement tree. Pure tree-building +/// (BuildFromInfos) is dat-free + testable; Import(dats, id, ...) is the dat shell. +public sealed class ImportedLayout +{ + public required UiElement Root { get; init; } + private readonly Dictionary _byId; + public ImportedLayout(UiElement root, Dictionary byId) { Root = root; _byId = byId; } + public UiElement? FindElement(uint id) => _byId.TryGetValue(id, out var e) ? e : null; +} + +public static class LayoutImporter +{ + /// Dat shell: load the layout, convert ElementDescs to ElementInfo (resolving + /// inheritance), then BuildFromInfos. Returns null if the layout is missing. + public static ImportedLayout? Import(DatCollection dats, uint layoutId, + Func resolve, UiDatFont? datFont) + { + var ld = dats.Get(layoutId); + if (ld is null) return null; + // Convert top-level + nested ElementDescs to resolved ElementInfo. + ElementInfo Convert(ElementDesc d) => Resolve(dats, d); + // Build a synthetic root that holds the top-level elements as children. + var rootInfo = new ElementInfo { Id = 0, Type = 3 }; + var children = new List(); + var nested = new Dictionary(); + foreach (var kv in ld.Elements) { var info = Convert(kv.Value); children.Add(info); nested[info] = kv.Value; } + return BuildFromInfosRecursive(rootInfo, ld, dats, resolve, datFont); + } + + /// Pure builder used by tests + the shell: build a tree from a root info + its + /// direct children infos. (The recursive dat variant handles real nested trees.) + public static ImportedLayout BuildFromInfos(ElementInfo rootInfo, IEnumerable children, + Func resolve, UiDatFont? datFont) + { + var byId = new Dictionary(); + var root = DatWidgetFactory.Create(rootInfo, resolve, datFont); + if (rootInfo.Id != 0) byId[rootInfo.Id] = root; + foreach (var c in children) + { + var w = DatWidgetFactory.Create(c, resolve, datFont); + root.AddChild(w); + if (c.Id != 0) byId[c.Id] = w; + } + return new ImportedLayout(root, byId); + } + + // ---- dat-side helpers ---- + + private static ImportedLayout BuildFromInfosRecursive(ElementInfo rootInfo, LayoutDesc ld, + DatCollection dats, Func resolve, UiDatFont? datFont) + { + var byId = new Dictionary(); + var root = DatWidgetFactory.Create(rootInfo, resolve, datFont); + foreach (var kv in ld.Elements) + AddElement(root, kv.Value, dats, resolve, datFont, byId); + return new ImportedLayout(root, byId); + } + + private static void AddElement(UiElement parent, ElementDesc d, DatCollection dats, + Func resolve, UiDatFont? datFont, Dictionary byId) + { + var info = Resolve(dats, d); + var w = DatWidgetFactory.Create(info, resolve, datFont); + parent.AddChild(w); + if (info.Id != 0) byId[info.Id] = w; + foreach (var kv in d.Children) + AddElement(w, kv.Value, dats, resolve, datFont, byId); + } + + /// ElementDesc → ElementInfo, resolving BaseElement/BaseLayoutId inheritance. + private static ElementInfo Resolve(DatCollection dats, ElementDesc d) + { + var self = ToInfo(d); + if (d.BaseElement != 0 && d.BaseLayoutId != 0) + { + var baseLd = dats.Get(d.BaseLayoutId); + var baseDesc = baseLd is null ? null : FindDesc(baseLd, d.BaseElement); + if (baseDesc is not null) return ElementReader.Merge(Resolve(dats, baseDesc), self); // recursive base chain + } + return self; + } + + private static ElementDesc? FindDesc(LayoutDesc ld, uint id) + { + foreach (var kv in ld.Elements) { var f = FindDescIn(kv.Value, id); if (f is not null) return f; } + return null; + } + private static ElementDesc? FindDescIn(ElementDesc d, uint id) + { + if (d.ElementId == id) return d; + foreach (var kv in d.Children) { var f = FindDescIn(kv.Value, id); if (f is not null) return f; } + return null; + } + + /// Read the verified ElementDesc fields into ElementInfo (no inheritance). + private static ElementInfo ToInfo(ElementDesc d) + { + var info = new ElementInfo + { + Id = d.ElementId, Type = (int)d.Type, + X = d.X, Y = d.Y, Width = d.Width, Height = d.Height, + Left = (int)d.LeftEdge, Top = (int)d.TopEdge, Right = (int)d.RightEdge, Bottom = (int)d.BottomEdge, + }; + if (d.StateDesc is not null) ReadState(d.StateDesc, "", info); + foreach (var s in d.States) ReadState(s.Value, s.Key, info); + return info; + } + + private static void ReadState(StateDesc sd, string name, ElementInfo info) + { + foreach (var m in sd.Media) + if (m is MediaDescImage img && img.File != 0) + info.StateMedia[name] = (img.File, (int)img.DrawMode); + // font DID (property 0x1A) read here once the format doc confirms the property API. + } +} +``` +> NOTE: the exact `ElementDesc`/`StateDesc` member access (`d.X`, `d.Type`, `d.States`, `sd.Media`, `img.DrawMode`, the font property) must match Task 1's verified API; `dump-vitals-layout` confirms these members exist. Adjust casts/names to the real API. + +- [ ] **Step 4: Run to verify pass** + +Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~LayoutImporterTests"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +``` +git add src/AcDream.App/UI/Layout/LayoutImporter.cs tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json +git commit -m "feat(D.2b): LayoutImporter — read layout + resolve inheritance + build tree" +``` + +--- + +### Task 6: VitalsController — bind live data by id + +**Files:** +- Create: `src/AcDream.App/UI/Layout/VitalsController.cs` +- Test: `tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs` + +Mirrors `gmVitalsUI`: grab the meter elements by id and wire their fill + numbers + the correct per-vital sprite slice ids (which are dat-driven, but the back/front-slice split + the live data binding are the controller's job). + +- [ ] **Step 1: Write the failing test** + +```csharp +using AcDream.App.UI; +using AcDream.App.UI.Layout; +namespace AcDream.App.Tests.UI.Layout; + +public class VitalsBindingTests +{ + [Fact] + public void Bind_SetsHealthMeterFillFromProvider() + { + var health = new UiMeter(); + var layout = FakeLayout(("0x100000E6", health)); + float hp = 0.42f; + VitalsController.Bind(layout, healthPct: () => hp, staminaPct: () => 1, manaPct: () => 1, + healthText: () => "42/100", staminaText: () => "", manaText: () => ""); + Assert.Equal(0.42f, health.Fill()); + } + + private static ImportedLayout FakeLayout(params (string idHex, UiElement e)[] items) + { + var dict = new System.Collections.Generic.Dictionary(); + var root = new UiPanel(); + foreach (var (idHex, e) in items) + { uint id = System.Convert.ToUInt32(idHex, 16); root.AddChild(e); dict[id] = e; } + return new ImportedLayout(root, dict); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~VitalsBindingTests"` +Expected: FAIL — `VitalsController` not defined. + +- [ ] **Step 3: Implement VitalsController** + +```csharp +using System; + +namespace AcDream.App.UI.Layout; + +/// Per-window controller for the vitals layout (0x2100006C). Mirrors retail +/// gmVitalsUI::PostInit: grab the meter elements by id and bind live data. The ONLY +/// per-window code — data wiring, not graphics. +public static class VitalsController +{ + public const uint Health = 0x100000E6, Stamina = 0x100000EC, Mana = 0x100000EE; + + public static void Bind(ImportedLayout layout, + Func healthPct, Func staminaPct, Func manaPct, + Func healthText, Func staminaText, Func manaText) + { + BindMeter(layout, Health, healthPct, healthText); + BindMeter(layout, Stamina, staminaPct, staminaText); + BindMeter(layout, Mana, manaPct, manaText); + } + + private static void BindMeter(ImportedLayout layout, uint id, Func pct, Func text) + { + if (layout.FindElement(id) is UiMeter m) + { + m.Fill = () => pct(); + m.Label = () => text(); + } + } +} +``` +> NOTE: the per-vital back/front 3-slice sprite ids live on the meter's child image elements in the dat; the importer sets them on the `UiMeter` (extend `DatWidgetFactory.BuildMeter` to read the meter's `E8/E9/EA` + back/front child sprites once the tree is built). For Plan 1 conformance, the controller binds the dynamic data; the static slice ids come from the dat via the importer. + +- [ ] **Step 4: Run to verify pass** + +Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~VitalsBindingTests"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +``` +git add src/AcDream.App/UI/Layout/VitalsController.cs tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs +git commit -m "feat(D.2b): VitalsController — bind live vitals data by element id" +``` + +--- + +### Task 7: Wire the importer into GameWindow behind a flag + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (the `_options.RetailUi` block where the vitals panel is built) +- Modify: `src/AcDream.App/RuntimeOptions.cs` (add `RetailUiImporter` flag from `ACDREAM_RETAIL_UI_IMPORTER`) + +Run the importer-built vitals window when `ACDREAM_RETAIL_UI_IMPORTER=1`, ALONGSIDE the existing hand-authored path (which stays the default). This is the conformance harness + the eventual switch-over. + +- [ ] **Step 1: Add the RuntimeOptions flag** + +In `RuntimeOptions.cs`, add `public bool RetailUiImporter { get; init; }` and read it in `Program.cs` from `ACDREAM_RETAIL_UI_IMPORTER == "1"` (follow the existing `RetailUi` pattern). + +- [ ] **Step 2: Wire the importer in the RetailUi block** + +In `GameWindow.cs`, in the `if (_options.RetailUi)` block, after the existing vitals panel is built, add: +```csharp +if (_options.RetailUiImporter) +{ + var imported = AcDream.App.UI.Layout.LayoutImporter.Import( + _dats, 0x2100006Cu, ResolveChrome, _datFont); + if (imported is not null) + { + AcDream.App.UI.Layout.VitalsController.Bind(imported, + healthPct: () => _vitalsVm!.HealthPercent ?? 0f, + staminaPct: () => _vitalsVm!.StaminaPercent ?? 0f, + manaPct: () => _vitalsVm!.ManaPercent ?? 0f, + healthText: () => $"{_vitalsVm!.HealthCurrent}/{_vitalsVm.HealthMax}", + staminaText: () => $"{_vitalsVm!.StaminaCurrent}/{_vitalsVm.StaminaMax}", + manaText: () => $"{_vitalsVm!.ManaCurrent}/{_vitalsVm.ManaMax}"); + imported.Root.Left = 240; imported.Root.Top = 30; // offset so it sits beside the hand-built one for A/B + _uiHost.Root.AddChild(imported.Root); + Console.WriteLine("[D.2b] importer vitals window active (A/B vs hand-authored)."); + } +} +``` +> NOTE: confirm `_dats` (the `DatCollection`) + `_datFont` (the `UiDatFont`) field names in `GameWindow`; both already exist (the chrome resolve + the dat-font load use them). + +- [ ] **Step 3: Build** + +Run: `dotnet build src\AcDream.App\AcDream.App.csproj -c Debug` +Expected: 0 errors. + +- [ ] **Step 4: Commit** + +``` +git add src/AcDream.App/Rendering/GameWindow.cs src/AcDream.App/RuntimeOptions.cs src/AcDream.App/Program.cs +git commit -m "feat(D.2b): run importer-built vitals window under ACDREAM_RETAIL_UI_IMPORTER (A/B)" +``` + +--- + +### Task 8: Vitals conformance — golden tree checks + headless render diff + +**Files:** +- Create: `tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs` +- Modify: `src/AcDream.Cli/VitalsMockup.cs` (add an importer-render mode if needed for the visual diff) + +- [ ] **Step 1: Write the golden tree conformance test (against the fixture)** + +```csharp +using AcDream.App.UI; +using AcDream.App.UI.Layout; +namespace AcDream.App.Tests.UI.Layout; + +public class LayoutConformanceTests +{ + [Fact] + public void VitalsTree_HasThreeMetersAtExpectedRects() + { + var layout = FixtureLoader.LoadVitals(); // deserializes vitals_2100006C.json → ImportedLayout via BuildFromInfos + (uint id, float y)[] expected = { (0x100000E6, 5), (0x100000EC, 21), (0x100000EE, 37) }; + foreach (var (id, y) in expected) + { + var m = layout.FindElement(id); + Assert.IsType(m); + Assert.Equal(5f, m!.Left); + Assert.Equal(150f, m.Width); + Assert.Equal(16f, m.Height); + Assert.Equal(y, m.Top); + } + } +} +``` +Add a tiny `FixtureLoader` that reads the committed JSON into `ElementInfo`s and calls `LayoutImporter.BuildFromInfos`. + +- [ ] **Step 2: Run to verify failure, then implement FixtureLoader, then pass** + +Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~LayoutConformanceTests"` +Expected: FAIL → implement `FixtureLoader` → PASS. + +- [ ] **Step 3: Headless visual diff** + +Launch the client with both windows (`ACDREAM_RETAIL_UI=1 ACDREAM_RETAIL_UI_IMPORTER=1`, testaccount2) and confirm the importer window (offset) is pixel-identical to the hand-authored one. (Manual visual gate — the user confirms. No assertion.) + +- [ ] **Step 4: Full test sweep** + +Run: `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj --filter "FullyQualifiedName~UI"` +Expected: PASS (all prior UI tests + the new Layout tests). + +- [ ] **Step 5: Commit** + +``` +git add tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs +git commit -m "test(D.2b): vitals importer conformance (golden tree + A/B render gate)" +``` + +--- + +## After Plan 1 + +**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 7–8). +- **Placeholder scan:** every code step has concrete code; `NOTE`s flag where Task 1's verified API must confirm a member name/value — that's a real dependency, not a vague requirement. +- **Type consistency:** `ElementInfo`, `ImportedLayout`, `LayoutImporter.BuildFromInfos`/`Import`, `DatWidgetFactory.Create`, `UiDatElement.ActiveMedia`, `VitalsController.Bind` are used consistently across tasks; `UiMeter.Fill`/`Label`/`SpriteResolve` match the existing widget. diff --git a/docs/superpowers/plans/2026-06-16-d2b-widget-generalization.md b/docs/superpowers/plans/2026-06-16-d2b-widget-generalization.md new file mode 100644 index 00000000..e68c745f --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-d2b-widget-generalization.md @@ -0,0 +1,992 @@ +# D.2b Widget Generalization Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Refactor the hand-named chat widgets and the Send/Max-Min click-wiring into generic, Type-registered widgets built by `DatWidgetFactory`, collapsing the controllers to a thin retail `gm*UI::PostInit`-style find-by-id binder. + +**Architecture:** `DatWidgetFactory.Create` grows a faithful `switch(Type)` registering the real retail `UIElement` classes (Button=1, Field=3, Menu=6, Meter=7, Scrollbar=11, Text=12) as generic widgets; everything else stays `UiDatElement`. The importer's base-chain Type resolution already surfaces each element's real Type, so this is a *registration* task. The chat-specific knowledge (channel list, colors, command routing) moves out of widgets into `ChatWindowController`. Migrate one widget per commit; chat stays visually identical through Tasks 2–7; vitals is rewired last (Task 8) behind a visual gate. + +**Tech Stack:** C# / .NET 10, xUnit, `DatReaderWriter` (Chorizite), Silk.NET (GL/input). Retail oracle: `docs/research/named-retail/acclient_2013_pseudo_c.txt`. + +**Spec:** `docs/superpowers/specs/2026-06-16-d2b-widget-generalization-design.md`. + +--- + +## Conventions + +- **Repo root** = the worktree dir. All paths below are relative to it. +- **Build:** `dotnet build` (builds `AcDream.slnx`). Must be green before every commit. +- **Test (all UI):** `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` +- **Test (filtered):** add `--filter "FullyQualifiedName~"`. +- **Commit style:** `feat(D.2b): ` / `test(D.2b): …` / `refactor(D.2b): …`, ending with the project's `Co-Authored-By: Claude Opus 4.8 (1M context) ` trailer. +- **Every generic widget cites its retail class + `RegisterElementClass` line** in a doc comment (per spec §8). +- **Divergence register:** `docs/architecture/retail-divergence-register.md` — amend AP-37 / re-check AP-41 in the same commit that lands the relevant widget (per spec §7). + +--- + +## File Structure + +**Created:** +- `src/AcDream.App/UI/UiButton.cs` — generic Type-1 button (Task 3). +- `src/AcDream.App/UI/UiText.cs` — generic Type-12 scrollable colored-line text (rename of `UiChatView`, Task 5). +- `src/AcDream.App/UI/UiField.cs` — generic Type-3 editable one-line field (rename of `UiChatInput`, Task 6). +- `src/AcDream.App/UI/UiScrollbar.cs` — generic Type-11 scrollbar (rename of `UiChatScrollbar`, Task 2). +- `src/AcDream.App/UI/UiMenu.cs` — generic Type-6 dropdown menu (genericized `UiChannelMenu`, Task 4). +- `tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json` — golden resolved chat tree (Task 1). +- `tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs` — skip-by-default fixture generator (Task 1). +- `tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs` — resolved-tree + factory-class conformance (Task 1, grown per widget). +- `tests/AcDream.App.Tests/UI/UiButtonTests.cs` (Task 3). + +**Renamed (git mv + class/namespace-internal rename):** +- `UiChatScrollbar.cs` → `UiScrollbar.cs`; `UiChatScrollbarTests.cs` → `UiScrollbarTests.cs` (Task 2). +- `UiChatView.cs` → `UiText.cs`; `UiChatViewTests.cs` → `UiTextTests.cs`; `UiChatViewDatFontTests.cs` → `UiTextDatFontTests.cs` (Task 5). +- `UiChatInput.cs` → `UiField.cs`; `UiChatInputTests.cs` → `UiFieldTests.cs` (Task 6). +- `UiChannelMenu.cs` → `UiMenu.cs`; `UiChannelMenuTests.cs` → `UiMenuTests.cs` (Task 4). + +**Modified:** +- `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` — the `switch(Type)` + `BuildButton`/`BuildMenu`/`BuildText`/`BuildField`/`BuildScrollbar` (Tasks 2–6). +- `src/AcDream.App/UI/Layout/ChatWindowController.cs` — construction → find-by-id binding; channel-item population (Tasks 2–7). +- `src/AcDream.App/UI/Layout/VitalsController.cs` — bind `UiText` numbers (Task 8). +- `src/AcDream.App/Rendering/GameWindow.cs` — only property-type follow-through (`.Transcript`/`.Input` types change) if needed (Tasks 5–6). +- `tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs` — new per-Type asserts; flip the two Type-12 tests (Tasks 2–6). +- `tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs` — add `LoadChat()` (Task 1). + +--- + +## Task 1: Chat golden fixture + conformance test (also resolves the input's Type empirically) + +**Files:** +- Create: `tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs` +- Create: `tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json` (generated, committed) +- Modify: `tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs` +- Create: `tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs` + +The generator runs once against the live dat (it is `[Fact(Skip=…)]` so CI never runs it). The committed JSON is dat-free, like `vitals_2100006C.json`. The fixture's resolved `Type` per element **answers spec verification #1** (does input `0x10000016` resolve to 3 or 12?). + +- [ ] **Step 1: Write the generator (skip-by-default).** + +`ChatLayoutFixtureGenerator.cs`: +```csharp +using System.IO; +using System.Runtime.CompilerServices; +using System.Text.Json; +using AcDream.App.UI.Layout; +using DatReaderWriter; +using DatReaderWriter.Options; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// One-off generator for the committed chat golden fixture. Skipped by default — +/// run manually with the real dats present (set ACDREAM_DAT_DIR) to regenerate +/// chat_21000006.json, then commit it. Mirrors how vitals_2100006C.json was made. +/// +public class ChatLayoutFixtureGenerator +{ + [Fact(Skip = "manual: regenerates the committed chat fixture; needs the real dats (ACDREAM_DAT_DIR)")] + public void GenerateChatFixture() + { + var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + using var dats = new DatCollection(datDir, DatAccessType.Read); + var info = LayoutImporter.ImportInfos(dats, 0x21000006u); + Assert.NotNull(info); + + var json = JsonSerializer.Serialize(info, new JsonSerializerOptions + { + IncludeFields = true, + WriteIndented = true, + }); + File.WriteAllText(FixturePath(), json); + } + + // Resolve the SOURCE fixtures dir (not bin/) from this file's compile-time path. + private static string FixturePath([CallerFilePath] string thisFile = "") + => Path.Combine(Path.GetDirectoryName(thisFile)!, "fixtures", "chat_21000006.json"); +} +``` + +- [ ] **Step 2: Generate the fixture (manual, dats present).** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ChatLayoutFixtureGenerator.GenerateChatFixture" -e ACDREAM_DAT_DIR="%USERPROFILE%\Documents\Asheron's Call"` after temporarily removing the `Skip` (or use an IDE run). Confirm `tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json` is written and non-empty, then restore the `Skip`. +Expected: a JSON tree rooted at id `0x10000006`-family with the chat elements. **Record the resolved `Type` of `0x10000016` (input) and `0x10000011` (transcript)** — these drive Task 5/6 decisions. + +- [ ] **Step 3: Add `FixtureLoader.LoadChat()` + `LoadChatInfos()`.** + +In `FixtureLoader.cs`, add (mirroring `LoadVitals`/`LoadVitalsInfos`): +```csharp + public static ImportedLayout LoadChat() + => LayoutImporter.Build(LoadChatInfos(), _ => (0u, 0, 0), null); + + public static AcDream.App.UI.Layout.ElementInfo LoadChatInfos() + => LoadInfos("chat_21000006.json"); + + // Shared loader (refactor LoadVitalsInfos to call this with "vitals_2100006C.json"). + private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName) + { + var path = Path.Combine(AppContext.BaseDirectory, "UI", "Layout", "fixtures", fileName); + if (!File.Exists(path)) throw new FileNotFoundException($"fixture not found at: {path}"); + var bytes = File.ReadAllBytes(path); + ReadOnlySpan span = bytes; + if (span.Length >= 3 && span[0] == 0xEF && span[1] == 0xBB && span[2] == 0xBF) span = span[3..]; + return JsonSerializer.Deserialize(span, _opts) + ?? throw new InvalidOperationException($"fixture deserialized to null: {path}"); + } +``` +Then make `LoadVitalsInfos()` delegate: `public static ElementInfo LoadVitalsInfos() => LoadInfos("vitals_2100006C.json");` + +- [ ] **Step 4: Write the resolved-tree conformance test (fails until the fixture exists).** + +`ChatLayoutConformanceTests.cs`: +```csharp +using System.Collections.Generic; +using AcDream.App.UI.Layout; +namespace AcDream.App.Tests.UI.Layout; + +public class ChatLayoutConformanceTests +{ + private static ElementInfo Find(ElementInfo n, uint id) + { + if (n.Id == id) return n; + foreach (var c in n.Children) { var f = Find(c, id); if (f is not null) return f; } + return null!; + } + + [Fact] + public void ChatFixture_ResolvesKnownElements() + { + var root = FixtureLoader.LoadChatInfos(); + // These ids come from ChatWindowController; the resolved Type proves the base-chain merge. + Assert.NotNull(Find(root, 0x10000011u)); // transcript + Assert.NotNull(Find(root, 0x10000016u)); // input + Assert.NotNull(Find(root, 0x10000012u)); // scrollbar track + Assert.NotNull(Find(root, 0x10000014u)); // channel menu + Assert.NotNull(Find(root, 0x10000019u)); // send button + Assert.NotNull(Find(root, 0x1000046Fu)); // max/min button + } + + [Fact] + public void ChatFixture_ResolvedTypes_MatchRetailRegistry() + { + var root = FixtureLoader.LoadChatInfos(); + Assert.Equal(6u, Find(root, 0x10000014u).Type); // Menu + Assert.Equal(11u, Find(root, 0x10000012u).Type); // Scrollbar + Assert.Equal(1u, Find(root, 0x10000019u).Type); // Button (Send) + Assert.Equal(1u, Find(root, 0x1000046Fu).Type); // Button (Max/Min) + // transcript + input: assert the ACTUAL resolved Type recorded in Step 2. + // From the Map trace both resolve to 12 (Text); if Step 2 shows otherwise, update these. + Assert.Equal(12u, Find(root, 0x10000011u).Type); // Text (transcript) + Assert.Equal(12u, Find(root, 0x10000016u).Type); // Text (input — see Task 6 wrinkle) + } +} +``` + +- [ ] **Step 5: Run the conformance tests.** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ChatLayoutConformanceTests"` +Expected: PASS. If `ChatFixture_ResolvedTypes_MatchRetailRegistry` shows input `0x10000016` Type ≠ 12, **update the assert to the real value and note it in Task 6 Step 1** (decides factory-built vs controller-placed `UiField`). + +- [ ] **Step 6: Commit.** +```bash +git add tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs \ + tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json \ + tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs \ + tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs +git commit -m "test(D.2b): chat golden fixture + resolved-Type conformance (widget-generalization Task 1)" +``` + +--- + +## Task 2: `UiScrollbar` (Type 11) — promote the already-generic scrollbar + +`UiChatScrollbar` has zero chat-specific code; this is a rename + factory registration. + +**Files:** +- Rename: `src/AcDream.App/UI/UiChatScrollbar.cs` → `src/AcDream.App/UI/UiScrollbar.cs` +- Rename: `tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cs` → `tests/AcDream.App.Tests/UI/UiScrollbarTests.cs` +- Modify: `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` +- Modify: `src/AcDream.App/UI/Layout/ChatWindowController.cs` +- Modify: `tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs`, `ChatLayoutConformanceTests.cs` + +- [ ] **Step 1: Rename the widget file + class.** +```bash +git mv src/AcDream.App/UI/UiChatScrollbar.cs src/AcDream.App/UI/UiScrollbar.cs +git mv tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cs tests/AcDream.App.Tests/UI/UiScrollbarTests.cs +``` +In `UiScrollbar.cs`: rename `class UiChatScrollbar` → `class UiScrollbar`; update the doc summary to "Generic scrollbar. Ports retail `UIElement_Scrollbar` (RegisterElementClass(0xb) @ acclient_2013_pseudo_c.txt:124137)…"; keep all body/fields/methods unchanged. +In `UiScrollbarTests.cs`: rename the test class to `UiScrollbarTests`; replace every `UiChatScrollbar` with `UiScrollbar`. (Keep the test bodies.) + +- [ ] **Step 2: Write the failing factory test.** + +In `DatWidgetFactoryTests.cs` add: +```csharp + [Fact] + public void Type11_Scrollbar_MakesUiScrollbar() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 11, Width = 16, Height = 68 }, NoTex, null); + Assert.IsType(e); + } +``` + +- [ ] **Step 3: Run it — verify it fails.** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DatWidgetFactoryTests.Type11_Scrollbar_MakesUiScrollbar"` +Expected: FAIL (`Create` returns `UiDatElement`, not `UiScrollbar`). + +- [ ] **Step 4: Register Type 11 in the factory.** + +In `DatWidgetFactory.Create`, add to the switch (before `_`): +```csharp + 11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137) +``` + +- [ ] **Step 5: Build + run factory + scrollbar tests.** + +Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DatWidgetFactoryTests|FullyQualifiedName~UiScrollbarTests"` +Expected: PASS. + +- [ ] **Step 6: Point the controller at the factory-built scrollbar (still functional).** + +The factory now builds a `UiScrollbar` for the Type-11 track element. In `ChatWindowController.cs`, in the "Scrollbar — replace the imported track placeholder" block, change the construction to bind the factory widget instead of building a fresh one. Replace the block (currently `c.Scrollbar = new UiChatScrollbar { … }; trackParent.RemoveChild(track); trackParent.AddChild(c.Scrollbar);`) with: +```csharp + // The factory built the Type-11 track element as a UiScrollbar. Find it, bind it. + if (layout.FindElement(TrackId) is UiScrollbar bar) + { + bar.Top = 0f; // pull up to the panel top (resize-bar reclaim) + bar.Height = bar.Height + bar.Top; // NOTE: capture old Top before zeroing — see Step 6a + bar.Model = c.Transcript.Scroll; + bar.SpriteResolve = resolve; + bar.TrackSprite = TrackSprite; + bar.ThumbSprite = ThumbSprite; + bar.ThumbTopSprite = ThumbTopSprite; + bar.ThumbBotSprite = ThumbBotSprite; + bar.UpSprite = UpSprite; + bar.DownSprite = DownSprite; + c.Scrollbar = bar; + } +``` +- [ ] **Step 6a: Fix the Top/Height order bug introduced above.** The old code added `track.Top` to height *before* zeroing Top. Write it correctly: +```csharp + if (layout.FindElement(TrackId) is UiScrollbar bar) + { + float oldTop = bar.Top; + bar.Top = 0f; + bar.Height = bar.Height + oldTop; + bar.Model = c.Transcript.Scroll; + bar.SpriteResolve = resolve; + bar.TrackSprite = TrackSprite; bar.ThumbSprite = ThumbSprite; + bar.ThumbTopSprite = ThumbTopSprite; bar.ThumbBotSprite = ThumbBotSprite; + bar.UpSprite = UpSprite; bar.DownSprite = DownSprite; + c.Scrollbar = bar; + } +``` +Change the `Scrollbar` property type: `public UiScrollbar Scrollbar { get; private set; } = null!;` + +- [ ] **Step 7: Update the conformance Type assert (already Type 11) + run full UI suite.** + +`ChatLayoutConformanceTests` already asserts Type 11 for the track. Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` +Expected: PASS (whole UI suite). + +- [ ] **Step 8: Re-check AP-41 in the divergence register.** + +The controller passes `ThumbTopSprite`/`ThumbBotSprite` (3-slice caps), so AP-41 ("thumb single stretched sprite") is stale. In `docs/architecture/retail-divergence-register.md`, update the AP-41 `file:line` from `UiChatScrollbar.cs:37` to `UiScrollbar.cs` and narrow/retire it (the 3-slice path now draws caps; retire only if the fallback single-tile path is no longer reachable — it is reachable when caps are 0, so narrow the wording to "fallback only"). + +- [ ] **Step 9: Commit.** +```bash +git add -A +git commit -m "feat(D.2b): UiScrollbar (Type 11) — promote the generic chat scrollbar (widget-generalization Task 2)" +``` + +--- + +## Task 3: `UiButton` (Type 1) — Send + Max/Min + +The factory currently builds Send/Max-Min as `UiDatElement` and the controller sets `OnClick`/`Label`. Introduce a dedicated `UiButton` mirroring that behavior exactly (so clicks don't regress) and register Type 1. + +**Files:** +- Create: `src/AcDream.App/UI/UiButton.cs` +- Create: `tests/AcDream.App.Tests/UI/UiButtonTests.cs` +- Modify: `DatWidgetFactory.cs`, `ChatWindowController.cs`, `DatWidgetFactoryTests.cs` + +- [ ] **Step 1: Write the failing button-behavior test.** + +`UiButtonTests.cs`: +```csharp +using System.Numerics; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +namespace AcDream.App.Tests.UI; + +public class UiButtonTests +{ + private static (uint, int, int) NoTex(uint _) => (0, 0, 0); + + [Fact] + public void Click_InvokesOnClick() + { + var info = new ElementInfo { Type = 1, Width = 46, Height = 18 }; + var b = new UiButton(info, NoTex) { OnClick = () => Clicked = true }; + b.OnEvent(new UiEvent(UiEventType.Click, 0, 0, 0)); + Assert.True(Clicked); + } + private bool Clicked; + + [Fact] + public void NotClickThrough_SoItReceivesClicks() + { + var b = new UiButton(new ElementInfo { Type = 1 }, NoTex); + Assert.False(b.ClickThrough); + } +} +``` +> Confirm the `UiEvent` constructor signature in `src/AcDream.App/UI/UiEvent.cs` before finalizing the `new UiEvent(...)` call; adjust arg order if needed. + +- [ ] **Step 2: Run it — verify it fails (UiButton does not exist).** + +Run: `dotnet test … --filter "FullyQualifiedName~UiButtonTests"` +Expected: FAIL (compile error: `UiButton` not found). + +- [ ] **Step 3: Write `UiButton`.** + +`UiButton.cs`: +```csharp +using System; +using System.Numerics; +using AcDream.App.UI.Layout; + +namespace AcDream.App.UI; + +/// +/// Generic clickable button. Ports retail UIElement_Button +/// (RegisterElementClass(1, UIElement_Button::Create) @ acclient_2013_pseudo_c.txt:125828): +/// a per-state sprite face + an optional centered caption + a click action. Built by +/// DatWidgetFactory for Type-1 elements (chat Send 0x10000019, Max/Min 0x1000046F). +/// The controller binds OnClick and the caption. State selection mirrors UiDatElement +/// so existing Send/Max-Min behavior is preserved exactly. +/// +public sealed class UiButton : UiElement +{ + private readonly ElementInfo _info; + private readonly Func _resolve; + + public Action? OnClick { get; set; } + public string? Label { get; set; } + public UiDatFont? LabelFont { get; set; } + public Vector4 LabelColor { get; set; } = Vector4.One; + + /// Active state name, runtime-settable (e.g. Max/Min toggling Normal↔Minimized). + public string ActiveState { get; set; } = ""; + + public UiButton(ElementInfo info, Func resolve) + { + _info = info; + _resolve = resolve; + ClickThrough = false; // buttons are interactive + if (!string.IsNullOrEmpty(info.DefaultStateName)) ActiveState = info.DefaultStateName; + else if (info.StateMedia.ContainsKey("Normal")) ActiveState = "Normal"; + } + + private uint ActiveFile() + => _info.StateMedia.TryGetValue(ActiveState, out var m) ? m.File + : _info.StateMedia.TryGetValue("", out var d) ? d.File : 0u; + + protected override void OnDraw(UiRenderContext ctx) + { + uint file = ActiveFile(); + if (file != 0) + { + var (tex, tw, th) = _resolve(file); + if (tex != 0 && tw != 0 && th != 0) + ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One); + } + if (Label is { Length: > 0 } label && LabelFont is { } lf) + { + float tx = (Width - lf.MeasureWidth(label)) * 0.5f; + float ty = (Height - lf.LineHeight) * 0.5f; + ctx.DrawStringDat(lf, label, tx, ty, LabelColor); + } + } + + public override bool OnEvent(in UiEvent e) + { + if (e.Type == UiEventType.Click && OnClick is not null) { OnClick(); return true; } + return false; + } +} +``` + +- [ ] **Step 4: Run the button tests — verify they pass.** + +Run: `dotnet test … --filter "FullyQualifiedName~UiButtonTests"` +Expected: PASS. + +- [ ] **Step 5: Write the failing factory test + register Type 1.** + +In `DatWidgetFactoryTests.cs`: +```csharp + [Fact] + public void Type1_Button_MakesUiButton() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 1, Width = 46, Height = 18 }, NoTex, null); + Assert.IsType(e); + } +``` +In `DatWidgetFactory.Create` switch: +```csharp + 1 => new UiButton(info, resolve), // UIElement_Button (reg :125828) +``` + +- [ ] **Step 6: Update the controller to bind the factory-built buttons.** + +In `ChatWindowController.cs`, the Send block currently does `if (layout.FindElement(SendId) is UiDatElement sendEl) { sendEl.ClickThrough = false; sendEl.OnClick = …; sendEl.Label = "Send"; sendEl.LabelFont = datFont; sendEl.LabelColor = …; }`. Change the cast to `UiButton`: +```csharp + if (layout.FindElement(SendId) is UiButton sendEl) + { + sendEl.OnClick = () => c.Input.Submit(); + sendEl.Label = "Send"; + sendEl.LabelFont = datFont; + sendEl.LabelColor = new Vector4(1f, 0.92f, 0.72f, 1f); + } +``` +And the Max/Min block: change `if (layout.FindElement(MaxMinId) is UiDatElement maxMinEl)` → `is UiButton maxMinEl`, drop the now-unneeded `maxMinEl.ClickThrough = false;` (UiButton is interactive by construction), keep the `maxMinEl.Left = track.Left - maxMinEl.Width;` and `maxMinEl.OnClick = c.ToggleMaximize;`. + +- [ ] **Step 7: Build + run the full UI suite.** + +Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` +Expected: PASS. + +- [ ] **Step 8: Commit.** +```bash +git add -A +git commit -m "feat(D.2b): UiButton (Type 1) — Send + Max/Min as generic buttons (widget-generalization Task 3)" +``` + +--- + +## Task 4: `UiMenu` (Type 6) — genericize the channel menu + +`UiChannelMenu` is the one heavy genericization: move `ChatChannelKind`, the 14-item array, the button-text map, and the availability defaults into `ChatWindowController`; keep all drawing/geometry/event mechanics in a generic `UiMenu` keyed on `object? Payload`. + +**Files:** +- Rename: `src/AcDream.App/UI/UiChannelMenu.cs` → `src/AcDream.App/UI/UiMenu.cs` +- Rename: `tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs` → `tests/AcDream.App.Tests/UI/UiMenuTests.cs` +- Modify: `DatWidgetFactory.cs`, `ChatWindowController.cs`, `DatWidgetFactoryTests.cs` + +- [ ] **Step 1: Rename file + class.** +```bash +git mv src/AcDream.App/UI/UiChannelMenu.cs src/AcDream.App/UI/UiMenu.cs +git mv tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs tests/AcDream.App.Tests/UI/UiMenuTests.cs +``` + +- [ ] **Step 2: Replace the chat-specific members with the generic surface.** + +In `UiMenu.cs`, rename `class UiChannelMenu` → `class UiMenu`; remove `using AcDream.UI.Abstractions;`. Replace the chat-specific members — the `Item` record, the static `Items` array, `Selected` (ChatChannelKind), `OnChannelChanged`, `AvailabilityProvider`, `IsAvailable`, and `ButtonText` — with these generic members: +```csharp + /// One menu row: its label + an opaque payload the controller maps back. + public readonly record struct MenuItem(string Label, object? Payload); + + /// The rows, populated by the controller. Laid out column-major: + /// rows 0..RowsPerColumn-1 in column 0, then the next group in column 1, etc. + public IReadOnlyList Items { get; set; } = System.Array.Empty(); + + /// The currently-selected payload (drives the highlighted row). + public object? Selected { get; set; } + + /// Fired with the picked item's payload when a row is chosen. + public Action? OnSelect { get; set; } + + /// Per-payload enabled gate (disabled rows render greyed + are inert). + /// Null ⇒ all rows enabled. + public Func? EnabledProvider { get; set; } + + /// Button-face caption (the active target). Null ⇒ blank face. + public Func? ButtonLabelProvider { get; set; } +``` +Make the geometry constants settable so a controller/factory can match the dat: +```csharp + public int RowsPerColumn { get; set; } = 7; // items per column (dat item template) + public float RowHeight { get; set; } = 17f; // dat item template 0x1000001E H=17 + public float ColumnWidth { get; set; } = 191f; // dat item template W=191 +``` +Replace the `private const int Rows`/`ItemH`/`ColW` usages with `RowsPerColumn`/`RowHeight`/`ColumnWidth`, and make the derived sizes instance members: +```csharp + private int ColumnCount => (Items.Count + RowsPerColumn - 1) / System.Math.Max(1, RowsPerColumn); + private float InteriorW => ColumnCount * ColumnWidth; + private float InteriorH => RowsPerColumn * RowHeight; + private float OuterW => InteriorW + 2 * Border; + private float OuterH => InteriorH + 2 * Border; +``` + +- [ ] **Step 3: Genericize the draw/event logic (mechanical swaps).** + +In the same file, in `OnDrawOverlay`, `OnEvent`, `OnHitTest`, and `DrawButtonFace`/label: +- Replace `Items[i].Channel is { } c && c == Selected` (selected-row test) with `Equals(Items[i].Payload, Selected)`. +- Replace `Items[i].Channel is not { } c || IsAvailable(c)` (availability) with `EnabledProvider?.Invoke(Items[i].Payload) ?? true`. +- Replace the button caption `ButtonText` with `ButtonLabelProvider?.Invoke() ?? ""` in both `OnDraw` (the `DrawLabel(ctx, ButtonText, …)` call) and `NaturalButtonWidth()` (the `MeasureWidth(ButtonText)`). +- In `OnEvent`'s pick branch, replace the channel-specific selection + ```csharp + if (… && Items[idx].Channel is { } ch && IsAvailable(ch)) { Selected = ch; OnChannelChanged?.Invoke(ch); } + ``` + with + ```csharp + if (row >= 0 && row < RowsPerColumn && idx >= 0 && idx < Items.Count + && (EnabledProvider?.Invoke(Items[idx].Payload) ?? true)) + { + Selected = Items[idx].Payload; + OnSelect?.Invoke(Selected); + } + ``` +- Replace the column/row math `int col = i / Rows, row = i % Rows;` with `RowsPerColumn` and `Items.Length` → `Items.Count`. +Keep `DrawBevel`, `DrawButtonFace`, `DrawSprite`, `DrawLabel`, the sprite-id properties, the colors, and `NaturalButtonWidth()` otherwise unchanged. Update the doc comment to cite `UIElement_Menu (RegisterElementClass(6) @ :120163)` + `MakePopup @0x46d310`. + +- [ ] **Step 4: Update the menu tests for the generic surface.** + +In `UiMenuTests.cs`, rename the class to `UiMenuTests`, replace `UiChannelMenu` → `UiMenu`. Where tests referenced `ChatChannelKind`/`Selected`/`OnChannelChanged`, rewrite them against the generic surface, e.g.: +```csharp + [Fact] + public void ClickingRow_FiresOnSelect_WithPayload() + { + object? picked = null; + var m = new UiMenu + { + Width = 46, Height = 18, + Items = new UiMenu.MenuItem[] { new("Chat to All", "say"), new("Trade", "trade") }, + OnSelect = p => picked = p, + }; + // open, then click row 0 (geometry per RowsPerColumn/RowHeight — mirror the + // existing test's click coords, which used the same 17px rows). + m.OnEvent(new UiEvent(UiEventType.MouseDown, 0, 0, 0)); // toggle open + // … click into row 0 of the open popup (reuse the prior test's local coords) … + Assert.Equal("say", picked); + } +``` +> Reuse the exact open/click coordinates from the original `UiChannelMenuTests` (they map into the same popup geometry); only the payload/selection assertions change. + +- [ ] **Step 5: Run the menu tests — green.** + +Run: `dotnet test … --filter "FullyQualifiedName~UiMenuTests"` +Expected: PASS. + +- [ ] **Step 6: Failing factory test + register Type 6.** + +In `DatWidgetFactoryTests.cs`: +```csharp + [Fact] + public void Type6_Menu_MakesUiMenu() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 6, Width = 46, Height = 18 }, NoTex, null); + Assert.IsType(e); + } +``` +In `DatWidgetFactory.Create` switch: +```csharp + 6 => new UiMenu(), // UIElement_Menu (reg :120163) +``` + +- [ ] **Step 7: Move the channel knowledge into `ChatWindowController`.** + +In `ChatWindowController.cs`, add the channel item table + maps (ported verbatim from the old `UiChannelMenu`): +```csharp + // Talk-focus channels (ported from the old UiChannelMenu — gmMainChatUI::InitTalkFocusMenu @0x4cdc50). + private static readonly (string Label, ChatChannelKind? Channel)[] ChannelItems = + { + ("Squelch (ignore)", null), + ("Tell to Selected", null), + ("Chat to All", ChatChannelKind.Say), + ("Tell to Fellows", ChatChannelKind.Fellowship), + ("Tell to General Chat", ChatChannelKind.General), + ("Tell to LFG Chat", ChatChannelKind.Lfg), + ("Tell to Society Chat", ChatChannelKind.Society), + ("Tell to Monarch", ChatChannelKind.Monarch), + ("Tell to Patron", ChatChannelKind.Patron), + ("Tell to Vassals", ChatChannelKind.Vassals), + ("Tell to Allegiance", ChatChannelKind.Allegiance), + ("Tell to Trade Chat", ChatChannelKind.Trade), + ("Tell to Roleplay Chat", ChatChannelKind.Roleplay), + ("Tell to Olthoi Chat", ChatChannelKind.Olthoi), + }; + + private static string ChannelButtonLabel(ChatChannelKind k) => k switch + { + ChatChannelKind.Say => "Chat", ChatChannelKind.General => "General", + ChatChannelKind.Trade => "Trade", ChatChannelKind.Lfg => "LFG", + ChatChannelKind.Fellowship => "Fellow", ChatChannelKind.Allegiance => "Alleg", + ChatChannelKind.Patron => "Patron", ChatChannelKind.Vassals => "Vassals", + ChatChannelKind.Monarch => "Monarch", ChatChannelKind.Roleplay => "Roleplay", + ChatChannelKind.Society => "Society", ChatChannelKind.Olthoi => "Olthoi", + _ => "Chat", + }; + + private static bool ChannelAvailable(ChatChannelKind k) + => k is ChatChannelKind.Say or ChatChannelKind.General or ChatChannelKind.Trade or ChatChannelKind.Lfg; +``` +Replace the "Channel menu — replace the imported menu placeholder" block. The factory now builds the Type-6 element as a `UiMenu`; find it and populate it: +```csharp + if (layout.FindElement(MenuId) is UiMenu menu) + { + menu.DatFont = datFont; menu.Font = debugFont; menu.SpriteResolve = resolve; + menu.NormalSprite = MenuNormal; menu.PressedSprite = MenuPressed; + menu.PopupBgSprite = MenuPopupBg; + menu.ItemNormalSprite = MenuItemRow; menu.ItemHighlightSprite = MenuItemSelected; + menu.Items = System.Array.ConvertAll(ChannelItems, + t => new UiMenu.MenuItem(t.Label, (object?)t.Channel)); + menu.Selected = (object?)c._activeChannel; + menu.EnabledProvider = p => p is not ChatChannelKind ch || ChannelAvailable(ch); + menu.ButtonLabelProvider = () => ChannelButtonLabel(c._activeChannel); + menu.OnSelect = p => + { + if (p is ChatChannelKind ch) { c._activeChannel = ch; menu.Selected = p; } + }; + c.Menu = menu; + } +``` +Update the `Menu` property type: `public UiMenu Menu { get; private set; } = null!;` Update the reflow block (`ReflowInputRow`) — it calls `c.Menu.NaturalButtonWidth()`, `c.Menu.ResetAnchorCapture()` (both still exist on `UiMenu`), and wraps `c.Menu.OnChannelChanged`. Replace the `OnChannelChanged` wrap with the generic `OnSelect`: +```csharp + var onSelect = c.Menu.OnSelect; + c.Menu.OnSelect = p => { onSelect?.Invoke(p); ReflowInputRow(); }; +``` +> `_activeChannel` already exists on the controller; the old per-menu `OnChannelChanged = k => c._activeChannel = k;` is now folded into `OnSelect`. + +- [ ] **Step 8: Build + run the full UI suite.** + +Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` +Expected: PASS. + +- [ ] **Step 9: Add a divergence row if the generic menu lost fidelity.** + +The generic `UiMenu` item model is flat (label+payload, no submenu/hierarchical popup). If that is a new approximation vs `UIElement_Menu::MakePopup`'s nested popups, add a row to `docs/architecture/retail-divergence-register.md` (Adaptation) citing `src/AcDream.App/UI/UiMenu.cs` + `MakePopup @0x46d310`. (The chat menu is single-level, so this is a latent note, not a behavior change.) + +- [ ] **Step 10: Commit.** +```bash +git add -A +git commit -m "feat(D.2b): UiMenu (Type 6) — generic dropdown; channel knowledge moves to controller (widget-generalization Task 4)" +``` + +--- + +## Task 5: `UiText` (Type 12) — transcript + the Type-12 flip + +Rename `UiChatView` → `UiText`, default its background to transparent + add an optional dat state-sprite background (so any Type-12-with-sprite element keeps rendering its sprite), register Type 12, flip the two factory Type-12 tests, and have the controller bind the factory-built transcript. An **unbound `UiText` must draw nothing** so vitals stays frozen. + +**Files:** +- Rename: `src/AcDream.App/UI/UiChatView.cs` → `src/AcDream.App/UI/UiText.cs` +- Rename: `tests/AcDream.App.Tests/UI/UiChatViewTests.cs` → `UiTextTests.cs`; `UiChatViewDatFontTests.cs` → `UiTextDatFontTests.cs` +- Modify: `DatWidgetFactory.cs`, `LayoutImporter.cs` (none needed — Text recurses normally), `ChatWindowController.cs`, `DatWidgetFactoryTests.cs`, `GameWindow.cs` + +- [ ] **Step 1: Rename file + class + tests.** +```bash +git mv src/AcDream.App/UI/UiChatView.cs src/AcDream.App/UI/UiText.cs +git mv tests/AcDream.App.Tests/UI/UiChatViewTests.cs tests/AcDream.App.Tests/UI/UiTextTests.cs +git mv tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs tests/AcDream.App.Tests/UI/UiTextDatFontTests.cs +``` +In `UiText.cs`: rename `class UiChatView` → `class UiText`; the nested `Line`/`Pos` records, `LinesProvider`, selection, and scroll stay. Update the doc to cite `UIElement_Text (RegisterElementClass(0xc) @ :115655)`. In the test files, rename classes + replace `UiChatView` → `UiText`. + +- [ ] **Step 2: Default the background to transparent (so an unbound UiText is invisible).** + +In `UiText.cs`, change: +```csharp + public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0f); // transparent by default +``` +(was `(0,0,0,0.35)`). `OnDraw`'s `ctx.DrawFill(0,0,Width,Height,BackgroundColor)` then draws nothing when transparent. The chat controller will set the translucent value explicitly (Step 6). + +- [ ] **Step 3: Add an optional dat state-sprite background (faithful UIElement_Text media).** + +So a Type-12 element that carries its own sprite (currently rendered by `UiDatElement`) does not lose it. Add to `UiText`: +```csharp + /// Optional dat state-sprite background (the element's own media), drawn + /// UNDER the text. Set by DatWidgetFactory.BuildText from the ElementInfo. 0 = none. + public uint BackgroundSprite { get; set; } + public Func? SpriteResolve { get; set; } +``` +At the very top of `OnDraw`, before `DrawFill`: +```csharp + if (BackgroundSprite != 0 && SpriteResolve is { } sr) + { + var (tex, tw, th) = sr(BackgroundSprite); + if (tex != 0 && tw != 0 && th != 0) + ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One); + } +``` + +- [ ] **Step 4: Write the failing factory test (and flip the two existing Type-12 tests).** + +In `DatWidgetFactoryTests.cs`: +- Add: +```csharp + [Fact] + public void Type12_Text_MakesUiText() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 12, Width = 100, Height = 40 }, NoTex, null); + Assert.IsType(e); + } +``` +- Replace `Type12_StylePrototype_ReturnsNull` (delete it — Type 12 is no longer skipped). +- Replace `DatWidgetFactory_Type12WithMedia_Renders` body to assert `UiText` for both media and no-media: +```csharp + [Fact] + public void DatWidgetFactory_Type12_AlwaysMakesUiText() + { + var withMedia = new ElementInfo { Type = 12, Width = 32, Height = 16, + StateMedia = { ["Normal"] = (0x00001234u, 1) } }; + Assert.IsType(DatWidgetFactory.Create(withMedia, NoTex, null)); + Assert.IsType(DatWidgetFactory.Create(new ElementInfo { Type = 12 }, NoTex, null)); + } +``` + +- [ ] **Step 5: Run — verify the new/flipped tests fail.** + +Run: `dotnet test … --filter "FullyQualifiedName~DatWidgetFactoryTests"` +Expected: FAIL on the Type-12 asserts (factory still returns null / UiDatElement). + +- [ ] **Step 6: Register Type 12 + add `BuildText`; remove the skip.** + +In `DatWidgetFactory.cs`: +- Delete the skip line `if (info.Type == 12 && info.StateMedia.Count == 0) return null;`. +- Add to the switch: +```csharp + 12 => BuildText(info, resolve), // UIElement_Text (reg :115655) +``` +- Add the builder: +```csharp + /// Type-12 UIElement_Text: a scrollable colored-line text view. The + /// element's own Direct/Normal media (if any) becomes the background sprite, drawn + /// under the text — so a Type-12 element that previously rendered via UiDatElement + /// keeps its sprite. Lines are bound later by the controller (LinesProvider). + private static UiText BuildText(ElementInfo info, Func resolve) + { + uint bg = info.StateMedia.TryGetValue( + !string.IsNullOrEmpty(info.DefaultStateName) ? info.DefaultStateName + : info.StateMedia.ContainsKey("Normal") ? "Normal" : "", out var m) + ? m.File : 0u; + return new UiText { BackgroundSprite = bg, SpriteResolve = resolve }; + } +``` +> Update the `Create` summary/`` doc that referenced Type-12 returning null. + +- [ ] **Step 7: Verify factory + vitals fixture still green (vitals frozen).** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DatWidgetFactoryTests|FullyQualifiedName~LayoutConformanceTests|FullyQualifiedName~VitalsBindingTests"` +Expected: PASS. The vitals number text elements are meter-children (consumed, never built — `LayoutImporter.cs:113`), and any other vitals Type-12 element now builds as an unbound, transparent `UiText` (draws only its own sprite, if it had one — same as before). **Spec verification #2:** if a vitals conformance test fails, a standalone Type-12 element changed class — inspect it; its sprite must still draw via `BackgroundSprite`. + +- [ ] **Step 8: Controller binds the factory-built transcript (instead of constructing it).** + +In `ChatWindowController.cs`, the factory now builds the Type-12 transcript element `0x10000011` as a `UiText`. Replace the "Transcript" block (which read `tInfo` and `new UiChatView { … }; transcriptPanel.AddChild(...)`) with find-and-bind: +```csharp + // The factory built the Type-12 transcript as a UiText; find + bind it. + c.Transcript = layout.FindElement(TranscriptId) as UiText + ?? throw new InvalidOperationException("chat transcript 0x10000011 not built as UiText"); + c.Transcript.DatFont = datFont; + c.Transcript.Font = debugFont; + c.Transcript.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); // retail translucent transcript + c.Transcript.LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont); +``` +Change the `Transcript` property type to `public UiText Transcript { get; private set; } = null!;`. Remove the now-unused `tInfo` lookup + the `transcriptPanel.AddChild` (the transcript is already in the tree at its dat position). Keep the `transcriptPanel.Top/Height` resize-bar reclaim. + +Also in `ChatWindowController.cs`, replace **every** `UiChatView.Line` with `UiText.Line` — this hits `BuildLines` (its `UiText view` parameter, its `IReadOnlyList` return type, the `Array.Empty()`, and the `new UiText.Line(frag, color)` inside the wrap loop). `WrapText`/`RetailChatColor` are unaffected (they return `string`/`Vector4`). + +Finally, repoint the `Bind` early-guard: it currently does `var tInfo = FindInfo(rootInfo, TranscriptId);` and checks `tInfo is null`. The transcript is now found via `layout.FindElement(TranscriptId)`; change the guard to null-check the factory-built widgets it needs (`layout.FindElement(TranscriptPanelId)` for the panel, plus the transcript/input found in their Steps). The `iInfo` lookup stays only for Task 6 Variant B. (Full guard tidy lands in Task 7.) + +- [ ] **Step 9: GameWindow follow-through.** + +`GameWindow.cs:1860` (`chatController.Transcript.Keyboard = …`) still compiles (`UiText.Keyboard` exists). Build to confirm. + +- [ ] **Step 10: Build + full UI suite.** + +Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` +Expected: PASS. + +- [ ] **Step 11: Amend AP-37 (Type-0 text skip retired).** + +In `docs/architecture/retail-divergence-register.md`, edit AP-37: remove the "Standalone Type-0 text elements are also skipped (… a dedicated dat-text widget is Plan 2)" clause (now shipped as `UiText`). Keep the meter-collapse clause and the vitals-numbers-via-`UiMeter.Label` clause (retired in Task 8). + +- [ ] **Step 12: Commit.** +```bash +git add -A +git commit -m "feat(D.2b): UiText (Type 12) — generic text + Type-12 flip; transcript factory-built (widget-generalization Task 5)" +``` + +--- + +## Task 6: `UiField` (Type 3) — editable input + +Rename `UiChatInput` → `UiField`, register Type 3, and wire the input. **Input handling depends on Task 1 Step 5's recorded resolved Type** for `0x10000016`: +- **If it resolved to Type 3:** the factory builds `UiField` directly; the controller finds + binds it. +- **If it resolved to Type 12** (per the Map trace): the factory built it as a `UiText`; the controller *replaces* it with a `UiField` at the same rect (the existing replace pattern). + +**Files:** +- Rename: `src/AcDream.App/UI/UiChatInput.cs` → `src/AcDream.App/UI/UiField.cs`; `UiChatInputTests.cs` → `UiFieldTests.cs` +- Modify: `DatWidgetFactory.cs`, `ChatWindowController.cs`, `DatWidgetFactoryTests.cs`, `GameWindow.cs` + +- [ ] **Step 1: Confirm the input's resolved Type from Task 1, choose the path.** + +Re-read `ChatLayoutConformanceTests.ChatFixture_ResolvedTypes_MatchRetailRegistry` (Task 1) for `0x10000016`. Note "Type 3 → direct build" or "Type 12 → controller-place". Proceed with the matching variant in Step 6. + +- [ ] **Step 2: Rename file + class + tests.** +```bash +git mv src/AcDream.App/UI/UiChatInput.cs src/AcDream.App/UI/UiField.cs +git mv tests/AcDream.App.Tests/UI/UiChatInputTests.cs tests/AcDream.App.Tests/UI/UiFieldTests.cs +``` +In `UiField.cs`: rename `class UiChatInput` → `class UiField`; body unchanged. Update doc to cite `UIElement_Field (RegisterElementClass(3) @ :126190)` + the drag-drop hooks (`CatchDroppedItem`/`MouseOverTop`) it will host for future item windows. In `UiFieldTests.cs`: rename class, replace `UiChatInput` → `UiField`. + +- [ ] **Step 3: Default the background to transparent (consistency with UiText).** + +Change `UiField.BackgroundColor` default to `new(0f, 0f, 0f, 0f)`. The controller sets the translucent value (Step 6). + +- [ ] **Step 4: Failing factory test + register Type 3.** + +In `DatWidgetFactoryTests.cs`: +```csharp + [Fact] + public void Type3_Field_MakesUiField() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 3, Width = 200, Height = 16 }, NoTex, null); + Assert.IsType(e); + } +``` +In `DatWidgetFactory.Create` switch: +```csharp + 3 => new UiField(), // UIElement_Field (reg :126190) +``` + +- [ ] **Step 5: Run — verify pass.** + +Run: `dotnet test … --filter "FullyQualifiedName~DatWidgetFactoryTests.Type3_Field_MakesUiField|FullyQualifiedName~UiFieldTests"` +Expected: PASS. + +- [ ] **Step 6: Wire the input in the controller (variant per Step 1).** + +Replace the "Input" block (`new UiChatInput { … }; inputBar.AddChild(c.Input); c.Input.OnSubmit = …`). + +**Variant A — input resolved to Type 3 (factory-built):** +```csharp + c.Input = layout.FindElement(InputId) as UiField + ?? throw new InvalidOperationException("chat input 0x10000016 not built as UiField"); + c.Input.DatFont = datFont; c.Input.Font = debugFont; + c.Input.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); + c.Input.SpriteResolve = resolve; c.Input.FocusFieldSprite = InputFocusField; + c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel); +``` + +**Variant B — input resolved to Type 12 (controller-placed UiField over the UiText):** +```csharp + // 0x10000016 resolves to Type-12 Text in this layout; the editable entry is a + // controller-placed UiField at the dat element's rect (retail authors a separate Field). + var iInfo = FindInfo(rootInfo, InputId) + ?? throw new InvalidOperationException("chat input info 0x10000016 missing"); + if (layout.FindElement(InputId) is { Parent: { } iparent } placeholder) + iparent.RemoveChild(placeholder); // drop the read-only Text placeholder + c.Input = new UiField + { + Left = iInfo.X, Top = iInfo.Y, Width = iInfo.Width, Height = iInfo.Height, + Anchors = ElementReader.ToAnchors(iInfo.Left, iInfo.Top, iInfo.Right, iInfo.Bottom), + DatFont = datFont, Font = debugFont, + BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f), + SpriteResolve = resolve, FocusFieldSprite = InputFocusField, + }; + (inputBar).AddChild(c.Input); + c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel); +``` +Change the `Input` property type to `public UiField Input { get; private set; } = null!;` (Keep `FindInfo` for Variant B; it may become unused in Variant A — remove it then.) + +- [ ] **Step 7: GameWindow follow-through.** + +`GameWindow.cs:1861` (`chatController.Input.Keyboard = …`) still compiles (`UiField.Keyboard` exists). Build to confirm. + +- [ ] **Step 8: Build + full UI suite.** + +Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` +Expected: PASS. + +- [ ] **Step 9: Commit.** +```bash +git add -A +git commit -m "feat(D.2b): UiField (Type 3) — editable input as a generic field (widget-generalization Task 6)" +``` + +--- + +## Task 7: Thin + verify the controller; remove dead construction + +After Tasks 2–6, `ChatWindowController.Bind` should construct no widgets (except the Variant-B input). Audit and tidy. + +**Files:** +- Modify: `src/AcDream.App/UI/Layout/ChatWindowController.cs` + +- [ ] **Step 1: Remove dead helpers + confirm find-by-id shape.** + +In `ChatWindowController.cs`: confirm every widget is obtained via `layout.FindElement(id) as UiX` and only data/callbacks are bound. Remove any now-unused locals (`transcriptPanel`/`inputBar` are still used for the resize-bar reclaim / Variant-B parent — keep those; remove `tInfo`/`FindInfo` if Variant A). Confirm the class doc reads as the `gmMainChatUI::PostInit @0x4ce130` analogue (find child by id → bind). + +- [ ] **Step 2: Update `ChatWindowControllerTests` for the new types.** + +In `tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs`, update any references to `UiChatView`/`UiChatInput`/`UiChatScrollbar`/`UiChannelMenu` to `UiText`/`UiField`/`UiScrollbar`/`UiMenu`, and any assertions on `.Selected`/`OnChannelChanged` to the generic `OnSelect`/payload surface. Run them to confirm the binding still wires the right elements. + +- [ ] **Step 3: Build + full UI suite.** + +Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` +Expected: PASS. + +- [ ] **Step 4: Visual gate (user) — chat unchanged.** + +Launch the client (`ACDREAM_RETAIL_UI=1`, per CLAUDE.md launch recipe) and confirm the chat window looks + behaves identically to before this pass: transcript scroll/select/copy, input write-mode/history/clipboard, channel dropdown, send, max/min, scrollbar drag. **Stop for user confirmation.** + +- [ ] **Step 5: Commit.** +```bash +git add -A +git commit -m "refactor(D.2b): ChatWindowController is now a thin find-by-id binder (widget-generalization Task 7)" +``` + +--- + +## Task 8 (GATED): vitals numbers as `UiText` + +Rewire the vitals number text from `UiMeter.Label` to factory-built `UiText` (retail-faithful: vitals numbers are `UIElement_Text`). **This is a stop-and-confirm gate** — vitals shipped pixel-identical and is fixture-locked. If it risks the pixel-identical result, **stop and keep `UiMeter.Label`** (narrow AP-37 instead). + +**Files:** +- Modify: `src/AcDream.App/UI/Layout/VitalsController.cs`, `LayoutImporter.cs` (meter child handling), `GameWindow.cs` (Bind call), `tests/.../VitalsBindingTests.cs`, `fixtures/vitals_2100006C.json` + +- [ ] **Step 1: Decide the number element's path.** + +The vitals number text is a **meter child** (consumed; `LayoutImporter.cs:113` does not recurse meter children). To render it as a real `UiText`, either (a) have `VitalsController` construct a `UiText` at the number element's rect (read from the meter's children — mirrors the chat Variant-B pattern), or (b) stop consuming the meter's text child so the factory builds it. **Prefer (a)** — it is local to `VitalsController` and does not disturb the meter slice extraction. Read the number element's rect from `DatWidgetFactory.BuildMeter`'s skipped text child (expose it, or re-read via the layout's `ElementInfo`). + +- [ ] **Step 2: Write a failing binding test.** + +In `VitalsBindingTests.cs`, add a test that, after `VitalsController.Bind`, a `UiText` exists for each vital and its `LinesProvider` returns the cur/max string. (Use the vitals fixture; assert the text node is present + bound.) + +- [ ] **Step 3: Implement the `UiText` number binding in `VitalsController`.** + +Add a `UiText` per meter (constructed at the number rect, single centered line). Keep `UiMeter.Label` unset for vitals. Bind `LinesProvider = () => new[] { new UiText.Line(text(), color) }` (centered — add a `UiText.CenterSingleLine` option or a thin overload if needed for horizontal centering). +> If centering a single line requires new `UiText` layout support, add a minimal `public bool CenterHorizontally` flag to `UiText` with a unit test, rather than overloading the chat path. + +- [ ] **Step 4: Build + run vitals tests.** + +Run: `dotnet test … --filter "FullyQualifiedName~VitalsBindingTests|FullyQualifiedName~LayoutConformanceTests"` +Expected: PASS. Update `vitals_2100006C.json` only if the resolved tree legitimately changed (it should not — the change is in binding, not the tree). + +- [ ] **Step 5: Visual gate (user) — vitals pixel-identical.** + +Launch (`ACDREAM_RETAIL_UI=1`); confirm the vitals numbers render identically (font, position, centering, color) to the shipped `UiMeter.Label` version. **Stop for user confirmation. If not identical → revert this task and narrow AP-37 instead.** + +- [ ] **Step 6: Retire/narrow AP-37 + update memory.** + +If the rewire lands: in `docs/architecture/retail-divergence-register.md`, retire the AP-37 vitals-numbers clause (now real `UiText`). Update `claude-memory/project_d2b_retail_ui.md` (the generalization pass shipped) + the roadmap. + +- [ ] **Step 7: Commit.** +```bash +git add -A +git commit -m "feat(D.2b): vitals numbers as UiText (widget-generalization Task 8, gated)" +``` + +--- + +## Done criteria (from spec §8) + +- [ ] `DatWidgetFactory` registers Types 1, 3, 6, 11, 12 (+ 7) → generic widgets; `_` still → `UiDatElement`. +- [ ] The `Type==12 → null` skip is removed; no Type-12 element is double-built (fixtures green). +- [ ] No `ChatChannelKind`/chat-color/command-routing knowledge inside any widget; `ChatWindowController` only finds-by-id and binds. +- [ ] Chat window visually + behaviorally identical through Tasks 2–7 (user-confirmed, Task 7 Step 4). +- [ ] `chat_21000006.json` golden fixture + renamed generic-widget tests all green. +- [ ] Vitals window unchanged after Task 8 (user-confirmed), or Task 8 deferred with AP-37 narrowed. +- [ ] Every generic widget cites its retail `UIElement_X` class + reg. line. +- [ ] Divergence register updated (AP-37 amended; AP-41 re-checked) in the same commits. +- [ ] Roadmap / `claude-memory/project_d2b_retail_ui.md` updated when the pass lands. diff --git a/docs/superpowers/specs/2026-06-14-d2b-retail-panel-frame-design.md b/docs/superpowers/specs/2026-06-14-d2b-retail-panel-frame-design.md new file mode 100644 index 00000000..70b8e20f --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-d2b-retail-panel-frame-design.md @@ -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 (~3–10 MB vs CEF's 150–300 MB), full control, and maximal +faithfulness — it mirrors Keystone directly, and the retained-mode toolkit C +needs *already exists* (§0). + +This spec covers **Spec 1**: wire the scaffold + add the markup/stylesheet/sprite +gaps, proven end-to-end on **one** panel — the universal window frame wrapping +the live Vitals bars. + +## 2. Scope + +**In Spec 1:** +- Wire the dormant **`UiHost`** into `GameWindow`, gated by a new + `RuntimeOptions.RetailUi` toggle (`ACDREAM_RETAIL_UI=1`). The ImGui devtools + path is untouched and may run simultaneously. +- Add dat-sprite drawing: `UiRenderContext.DrawSprite` (UV-rect) + a + `TextRenderer` textured-sprite path + a `ui_text.frag` `uUseTexture=2` branch. +- A **`UiNineSlicePanel : UiPanel`** that draws the 8-piece dat-sprite window + frame + center fill (upgrading the exact code TS-30 cites) — title bar + (`UiLabel`) + a close button (`UiButton`, which already exists). +- A **`UiMeter : UiElement`** vital bar bound to a `Func` 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>`), 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` 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 + + + + + +``` + +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 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 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. diff --git a/docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md b/docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md new file mode 100644 index 00000000..342ed53d --- /dev/null +++ b/docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md @@ -0,0 +1,267 @@ +# D.2b — Chat-window re-drive (LayoutDesc importer, Plan 2 chat piece) — design + +**Date:** 2026-06-15 +**Branch:** `claude/hopeful-maxwell-214a12` (D.2b retail-UI track; lighting/M1.5 is a separate branch off main) +**Status:** design — approved scope, pending spec review +**Predecessor:** the LayoutDesc importer + the vitals re-drive +(`docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md`, +`docs/research/2026-06-15-layoutdesc-format.md`, +`claude-memory/project_d2b_retail_ui.md`). +**Handoff that opened this work:** `docs/research/2026-06-15-chat-window-redrive-handoff.md`. + +--- + +## 1. Goal + +Replace the hand-authored retail chat window (a `UiNineSlicePanel` hosting a +`UiChatView` at a guessed rect, built inline in `GameWindow.cs` under +`if (_options.RetailUi)`) with the **data-driven retail chat window** read from +the dat `LayoutDesc 0x21000006` (`gmMainChatUI`) via the existing `LayoutImporter`, +with **faithful behavioral widgets ported from the named retail decomp** and the +**dat font** — the same way the vitals window became data-driven. + +**The code is modern. The behavior is retail.** Every widget algorithm is ported +from `docs/research/named-retail/acclient_2013_pseudo_c.txt` with a cited +`class::method @address`. + +## 2. Approved scope + +**In scope (faithful core):** +- Data-driven window frame from `0x21000006` (bg sprites, resize bar, grip chrome, + translucency). +- Transcript: dat-font `UIElement_Text` port — scrollable, bottom-pinned, + per-line chat-kind color, 10k-glyph behead cap. +- Scrollbar: right-side track + thumb + up/down buttons, **pixel-based** scroll, + `thumbRatio = view/content`, wheel = **1 line per notch**. +- Input: editable one-line field — caret, insert/delete, 100-entry command + history (up/down arrow), focus sprite, Enter→submit. +- Channel menu (`Chat ▸`): dropdown of channels; selection sets the active + outbound channel (the `ChatInputParser` default channel). +- Send button + max/min button. +- `ChatCommandRouter`: the shared submit pipeline, extracted from `ChatPanel` + so the ImGui devtools chat and the retail chat share one routing path. + +**Deferred (each gets a `retail-divergence-register.md` row — these need *non-UI* +plumbing acdream lacks, they are NOT UI scope cuts):** +- **Numbered chat tabs (1–4) — switching + per-tab chat-type filtering.** The tab + *sprites* render (they come free from the importer), but clicking a tab to filter + which chat kinds show needs the per-tab `m_llTextTypeFilter` / + `m_chatNewNonVisibleTextIndicator` system. +- **Squelch toggle** (menu item 0) — needs a squelch subsystem. +- **Clickable name-tags** (`StartTell` on click) — needs `StringInfo`/`TextTag` + styled runs in `ChatLog`. +- **In-element word-wrap at panel width** — the transcript renders pre-split + `ChatLog` lines 1:1; faithful `GlyphList::Recalculate` wrap reworks the + selection/hit-test model (visual-row ≠ record). Highest-risk UI piece; deferred. +- **Per-glyph mixed-color runs / configurable font face+size** (`gmClient::sm_nFontFace`). +- **Active/inactive opacity switch** — a single default translucency is in scope; + the focused-brighter / unfocused-dimmer transition is deferred. + +## 3. Retail reference (the port target) + +`gmMainChatUI` (registered element class `0x10000041`, built from `LayoutDesc +0x21000006`) extends a base **`ChatInterface`**. `ChatInterface` owns the +transcript, input, inbound routing, submit, history, truncate and opacity; +`gmMainChatUI` adds the channel menu, squelch, max/min, tab visibility and +clickable name-tags. + +### 3.1 Element → role map (`0x21000006`) + +| Element | Type | Role | Decomp anchor | +|---|---|---|---| +| `0x1000000E` | `0x10000041` | window root (`gmMainChatUI`), bg `0x0600114D`, 800×100 authored | `gmMainChatUI::Register @0x4cd350` | +| `0x1000000F` | 9 Resizebar | top resize bar, img `0x06001125`, cursor `0x06005E66` | — | +| `0x1000046F` | 0 | max/min button (`Maximized 0x06005E64` / `Minimized 0x06005E65`) | `gmMainChatUI::HandleMaximizeButton @0x4cce50` | +| `0x10000010` | 3 Field | transcript panel bg `0x06001115` | — | +| **`0x10000011`** | 0 (UIElement_Text) | **transcript** — read-only, multiline, scrollable | `ChatInterface::PostInit @0x4f3e47` | +| `0x1000048c` | 0 | scroll **thumb** (child of transcript) | `ChatInterface::PostInit @0x4f3e79` | +| `0x10000012` | 0 | scrollbar **track** (right edge, 16×68) | `UIElement_Scrollable::GetScrollbarPointer_ @0x473ec0` | +| `0x10000013` | 3 Field | input bar bg `0x0600113A` | — | +| `0x10000014` | 6 Menu | **channel menu** (`Chat ▸`) — 14 items (squelch + 13 channels) | `gmMainChatUI::InitTalkFocusMenu @0x4cdc50`, `HandleSelection @0x4cd540` | +| **`0x10000016`** | 0 (UIElement_Text) | **input** — editable, one-line, focus sprite `0x060011AB` | `ChatInterface::PostInit @0x4f3e86` | +| `0x10000017/18` | 3 | input focus edges (sprite `0x06004D67`) | — | +| `0x10000019` | 0 | **Send** button (`0x06001915`/pressed `…16`/ghost `…34`) | `ChatInterface::ListenToElementMessage @0x4f51ea` | +| `0x10000522–525` | 0 | **numbered chat tabs 1–4** (left strip; Normal `0x06006218`/Hi `0x06006219`) | `gmMainChatUI::RecvNotice_SetPanelVisibility @0x4ccd80` | + +> **Screenshot correction (user-provided retail ground truth, 2026-06-15):** the +> four `0x10000522–525` elements are the **left-edge numbered chat tabs**, NOT the +> "line/page scroll buttons" a research agent inferred from their 16×16 vertical +> geometry. The scrollbar (track + thumb + up/down) is on the **right**. The exact +> dat ids of the right-side scroll up/down buttons are located during Task D +> (likely children of track `0x10000012` not surfaced in the top-level dump). + +> **BN field-name caveat:** the decomp's `m_chatEntry` / `m_chatLog` / +> `m_fCurrentOpacity` names are applied inconsistently across functions (a +> Binary-Ninja artifact). The roles above are fixed by the decisive evidence — +> the `Normal_focussed` sprite is on `0x10000016` (only an editable field gets a +> focus state) and the multiline geometry is `0x10000011` — corroborated by both +> surviving research agents. Port by **role**, not by the C++ member name. + +### 3.2 Key retail algorithms (cited) + +**Inbound** — `ChatInterface::RecvNotice_DisplayFinalStringInfo @0x4f4640`: +append `arg4` (prefix/timestamp) then `arg3` (body) to the transcript via +`UIElement_Text::AppendStringInfoWithFont` with per-chat-type color `arg2` (color +table built by `BuildChatColorLookupTable`). If glyph count > `0x2710` (10000), +`TruncateChatLog @0x4f4290` beheads from the top at a newline boundary. **Bottom-pin:** +capture `IsAtVerticalEnd` *before* appending; if it was true, `ScrollToPosition` +to the new end; else light the unread-text indicator. + +**Submit** — `ChatInterface::HandleEnterKey @0x4f52d0` (fired by the *Accept* +input-action, not raw `\r` — one-line mode drops the `\r` char) → `ProcessCommand +@0x4f5100`: read input text, dispatch, push to `m_InputHistory` (cap 100, drop +index 0 when full), reset history cursor to `0xFFFFFFFF`, clear input. The Send +button (`0x10000019`) is an alternate trigger via `ListenToElementMessage`. + +**Scroll** — `UIElement_Scrollable`: pixel offset `m_iScrollableY`, clamped to +`[0, contentHeight − viewHeight]` (`SetScrollableXY @0x4740c0`). `thumbRatio = +view/content` clamped to 1, bar hidden when content ≤ view +(`UpdateScrollbarSize_ @0x4741a0`). `posRatio = scrollY/(content−view)` +(`UpdateScrollbarPosition_ @0x473f20`). Scroll quantum = one line-height +(`UIElement_Text::InqScrollDelta @0x4689b0`); page = view height; **wheel = 1 line +per notch** (`HandleMouseWheel @0x471450`). + +**Input** — caret `m_nCursorPos` (glyph index); `GlyphList::FindPixelsFromPos +@0x472b40` = Σ glyph advances to the caret; midpoint-snap hit-test +`FindPosFromLineAndPixels @0x4732d0`; ~1 Hz blink. Glyph advance = +`HorizontalOffsetBefore + Width + HorizontalOffsetAfter` (signed bytes, +`Font::GetCharWidthA @0x4433f0`) — **already implemented** by +`UiDatFont.GlyphAdvance`. History: `SelectCommandFromHistory` (up=back, down=fwd), +sentinel `0xFFFFFFFF` = "not browsing". + +**Channel menu** — `InitTalkFocusMenu @0x4cdc50` fills `UIElement_Menu 0x10000014` +with 14 items: item 0 = squelch toggle, items 1–13 = channels carrying attr +`0x1000000B` = channel enum (1=Say, 2=Tell/Target, 3=Emote, 4=Fellowship, +5=Patron, 6=Trade, 7=Allegiance, 8–0xD=area/custom). `HandleSelection @0x4cd540` +reads the enum, calls `SetTalkFocus(enum)`, updates the label, marks the item +selected. + +## 4. Architecture (acdream) + +Faithful structure: an importer builds the generic frame; a **controller** +(`ChatInterface`+`gmMainChatUI`::PostInit analogue) binds behavior by element id +and swaps the transcript/input placeholders for behavioral widgets. New classes +live in `src/AcDream.App/UI/` (widgets, GL-side) and `src/AcDream.UI.Abstractions/` +(the shared submit router). + +| Component | Kind | Retail analogue | Responsibility | +|---|---|---|---| +| `ChatWindowController` | new (`App/UI/Layout/`) | `ChatInterface` + `gmMainChatUI` PostInit | import `0x21000006`; `FindElement(id)`; swap transcript/input widgets; wire scrollbar/menu/send/max-min; route in/outbound | +| `UiChatView` | **extend** (`App/UI/`) | `UIElement_Text` (transcript) | + `UiDatFont? DatFont`; dat-font measure/advance/draw; **1-line** wheel quantum; keep bottom-pin + drag-select + Ctrl+C | +| `UiChatInput` | new (`App/UI/`) | `UIElement_Text` (editable) | caret, insert/delete, 100-entry history, focus sprite swap, dat font, `Action? OnSubmit` | +| `UiScrollable` | new (`App/UI/`) | `UIElement_Scrollable` | pixel scroll math: `ScrollY`, `ContentHeight`, `ViewHeight`, `ClampScroll`, `ThumbRatio`, `ThumbOffsetRatio`, line/page delta | +| `UiChatScrollbar` | new (`App/UI/`) | composed scrollbar | own the imported track/thumb/up-down sprites; size+place thumb from `UiScrollable`; clicks/drag → `UiScrollable` | +| `UiChannelMenu` | new (`App/UI/`) | `UIElement_Menu` | dropdown popup of channels; selection → active `ChatChannelKind`; label reflects selection | +| `ChatCommandRouter` | new (`UI.Abstractions/Panels/Chat/`) | `ProcessCommand` | shared submit: client-command intercept → unknown-verb guard → `ChatInputParser.Parse(text, channel, lastTell, lastOutgoing)` → `Publish(SendChatCmd)` | +| `UiDatFont` | no change | `Font` | already implements retail glyph advance | + +**Why two widget classes (`UiChatView` + `UiChatInput`) when retail uses one +`UIElement_Text` with mode bits:** acdream's retained-mode widget layer predates +D.2b; the behavioral contract (read-only multiline scroll vs editable one-line) is +identical, only the class split differs. Accepted **ADAPTATION** divergence; both +classes share the `UiDatFont.GlyphAdvance` measure seam so geometry is consistent. + +**Placeholder swap:** the transcript (`0x10000011`) and input (`0x10000016`) +render no background sprite of their own (bg comes from parent panels +`0x10000010` / `0x10000013`), so `ChatWindowController` reads each placeholder's +rect + anchors, instantiates `UiChatView` / `UiChatInput` there, adds it to the +placeholder's parent, and removes the placeholder. Mirrors `GetChildRecursive(id)` +binding in `ChatInterface::PostInit`. + +## 5. Data flow + +- **Inbound:** `ChatLog → ChatVM.RecentLinesDetailed()` (200-deep tail) → + `UiChatView.LinesProvider` (per-`ChatKind` color via `RetailChatColor`). Pipeline + unchanged. Bottom-pin + 10k cap are `UiChatView`/`UiScrollable` behavior. +- **Outbound:** `UiChatInput.OnSubmit(text)` → + `ChatCommandRouter.Submit(text, vm, commandBus, activeChannel)` → `SendChatCmd` + → `LiveCommandBus` → `WorldSession`. `activeChannel` comes from `UiChannelMenu`. +- **Channel:** `UiChannelMenu` selection → `ChatWindowController._activeChannel` + (→ `ChatInputParser` default channel) + menu label update. +- **Scroll:** transcript content height → `UiScrollable` → `UiChatScrollbar` thumb; + wheel/buttons/drag → `UiScrollable.ScrollY` → transcript draw offset. + +## 6. Faithfulness decisions / divergence-register rows + +Add on landing (category in parens): +1. **(Adaptation)** Transcript + input are two classes (`UiChatView`/`UiChatInput`) + not one mode-flagged `UIElement_Text`. Behavior identical. +2. **(Approximation)** Transcript renders pre-split `ChatLog` lines 1:1; no + in-element word-wrap at panel width. Symptom: long lines not re-wrapped on + horizontal resize. `file:line` = `UiChatView.cs`. +3. **(Approximation)** One color per display line, not per-glyph styled runs. +4. **(Stopgap)** Numbered chat tabs render but don't switch / filter chat kinds. +5. **(Stopgap)** Squelch toggle + clickable name-tags render/parse-absent. +6. **(Approximation)** Single default translucency; no focused/unfocused opacity + transition; default dat font face+size (no `sm_nFontFace` config). + +Retire nothing (no existing register row is fixed by this work). + +## 7. Build sequence (tasks for the plan) + +Pipelineable where independent; `ChatWindowController` (G) and the `GameWindow` +cutover (H) are the integration barrier. + +- **A. `ChatCommandRouter`** — extract the submit flow from `ChatPanel` into a + pure `UI.Abstractions` helper; `ChatPanel` calls it; tests for client-command / + unknown-verb / parse / publish parity. *(UI.Abstractions; no GL.)* +- **B. `UiChatView` dat-font seam** — add `UiDatFont? DatFont`; prefer it in draw + + `HitChar` advance + selection measure + `LineHeight`; change `WheelLines` 3→1; + keep `BitmapFont` fallback. Tests: advance/hit-test with a synthetic dat font. +- **C. `UiScrollable`** — port `UIElement_Scrollable` math (pixel clamp, thumb + ratio/offset, line/page delta). Pure, fully unit-tested (no GL). +- **D. `UiChatScrollbar`** — own imported track/thumb/up-down sprites; size+place + thumb from `UiScrollable`; wheel/button/drag → scroll. Locate the right-side + up/down button ids in the dat here. +- **E. `UiChatInput`** — editable one-line widget: caret (`MeasurePrefix` = + `UiDatFont.MeasureWidth(text[..caret])`), insert/delete, Home/End/arrows, + 100-entry history with `−1`=live sentinel, focus sprite swap, `OnSubmit`. Tests + for caret math + history. +- **F. `UiChannelMenu`** — channel dropdown (port `UIElement_Menu` minimally); + 13 channels → `ChatChannelKind`; selection event + label. +- **G. `ChatWindowController`** — `LayoutImporter.Import(0x21000006)`; bind by id; + swap transcript/input; wire scrollbar/menu/send/max-min; route inbound (ChatVM) + + outbound (`ChatCommandRouter`); translucency. +- **H. `GameWindow` cutover** — replace the hand-authored + `UiNineSlicePanel`+`UiChatView` block with `ChatWindowController`; default + bottom-left position + resizable; remove dead code; add divergence rows; + `dotnet build` + `dotnet test` green. + +## 8. Testing strategy + +- **Pure/unit (no GL, no dats):** `ChatCommandRouter` parity; `UiScrollable` + clamp/thumb/delta golden values from the decomp; `UiChatInput` caret index ↔ + pixel + history navigation; `UiChatView` dat-font advance/hit-test via the + `Func` seam. +- **Layout/import (dat-free fixture):** extend the importer fixture pattern with a + `chat_21000006.json` tree (via `ImportInfos`) asserting the element→role map and + rects. +- **Real-dat smoke:** `LayoutImporter.Import(0x21000006)` against the live dat + resolves the root + all bound ids before wiring (guarded, like the vitals smoke). +- **Visual acceptance (user):** launch live `ACDREAM_RETAIL_UI=1`; compare to the + retail screenshot — transcript scrolls, input types + sends, channel menu + switches, Send works, scrollbar drags, window moves/resizes, translucency. + +## 9. Acceptance criteria + +- [ ] Chat window is built from `LayoutDesc 0x21000006` via `LayoutImporter` — no + hand-authored chat rect remains in `GameWindow.cs`. +- [ ] Transcript renders inbound chat in the **dat font**, per-`ChatKind` color, + bottom-pinned, 10k-cap, mouse-wheel = 1 line/notch, drag-select + Ctrl+C kept. +- [ ] Right-side scrollbar: thumb sizes to content, drag + up/down scroll the + transcript. +- [ ] Input: type, caret moves, backspace/delete, up/down history, **Enter and the + Send button both submit** through `ChatCommandRouter` → wire. +- [ ] `Chat ▸` menu opens, lists channels, selection changes the outbound channel + + updates the label. +- [ ] Max/min toggles window height; window moves + resizes; translucent frame. +- [ ] Every ported widget cites a `class::method @address`; every deferral has a + divergence-register row. +- [ ] `dotnet build` + `dotnet test` green; user visual sign-off. + +## 10. Deferred / follow-ups (filed, not built) + +In-element word-wrap (+ selection rework); numbered-tab switching + per-tab chat +filtering; squelch; clickable name-tags; per-glyph styled runs; configurable font +face/size; active/inactive opacity transition; the unidentified top-level Type-5 +ListBox `0x1000001D` (not bound by `ChatInterface`; likely a floaty/options element). diff --git a/docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md b/docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md new file mode 100644 index 00000000..1fb36f07 --- /dev/null +++ b/docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md @@ -0,0 +1,216 @@ +# LayoutDesc Importer — Design + +**Date:** 2026-06-15 +**Status:** Approved (brainstorm) — pending spec review → implementation plan +**Track:** D.2b retail UI engine (next sub-phase; register the phase id in the roadmap before implementation) +**Supersedes nothing. Deletes nothing.** Coexists with the existing hand-authored path. + +## Context + +D.2b shipped a working retail vitals window and a scrollable chat window, but each was +built by **hand**: dump the dat `LayoutDesc`, transcribe sprite ids + rects into +`vitals.xml` / `UiMeter` / `UiNineSlicePanel`, then discover-and-patch missing details +(the bar fill model, the dat-font, the tiling, the resize-grip overlay) one at a time. +That archaeology does not scale to AC's dozens of windows, and it keeps *missing* details +that are already in the dat (the grip overlay was found only because the user spotted it). + +The `LayoutDesc` dat is a **complete, declarative description of every window** — element +tree, positions, sizes, anchors, sprites per state, draw-modes, fonts, borders, grips, +meters, labels, inheritance. It is retail's "HTML for windows." The fix is to **render the +dat** with one faithful interpreter rather than transcribe it per window. + +## Goal + +Build a faithful `LayoutDesc` interpreter that reads a retail layout from the dat and +produces a `UiElement` tree the existing toolkit renders — so opening any retail window is +one call, with **no per-window graphics/layout code**. The only per-window code is live +**data wiring** (which is inherently per-window and tiny). + +### Non-goals + +- Re-porting Keystone's C++ framework (its own renderer, string/container classes, vtable + dispatch, D3D blits). We port retail's **render algorithms**, not its framework — that is + what Silk.NET + .NET already provide. (See "Decisions → Structure".) +- Deleting or rewriting the existing toolkit/widgets/markup. They are reused. + +## Decisions (from brainstorm 2026-06-15) + +1. **Proof target = re-drive vitals.** Point the importer at the vitals `LayoutDesc` + (`0x2100006C`) and make it reproduce the hand-built window. Known-good baseline → clean + pass/fail. The hand-authored vitals path stays as the reference until the importer matches. +2. **Scope = full faithful interpreter.** Interpret the *complete* `LayoutDesc` format + (every element type, full `BaseElement`/`BaseLayoutId` inheritance, all draw-modes, + states, properties) — not just the slice vitals uses. Matches the project's + "behavior is retail" ethos. +3. **Structure = hybrid (Approach C).** Port each element type's render **algorithm** + verbatim from the decomp, onto our modern draw primitives. A single generic renderer + handles the trivial "stamp the sprite per draw-mode" types (the long tail, including + types not yet catalogued); dedicated widgets handle types with real behavior (meter, + text, scrollbar/chat, button). The decomp's render method for each type *decides* which + bucket it falls in — we do not guess. Faithfulness comes from porting the algorithms; + the hybrid is only about C# packaging. +4. **Coexistence, don't-delete.** `MarkupDocument` stays as the path for plugin/custom + panels (no dat layout). The existing widgets (`UiMeter`, `UiNineSlicePanel`, + `UiChatView`, `UiDatFont`) and primitives (tiling, scissor-fill, dat-font, nine-slice) + become the importer's behavioral renderers. + +## Architecture & data flow + +``` +RETAIL WINDOWS (data-driven from the dat) + client_portal.dat ─► LayoutImporter ─► UiElement tree ─► UiRoot ─► renderers ─► screen + (LayoutDesc 0x21..) │ (UiDatElement + + │ behavioral widgets) + ├─ resolve BaseElement / BaseLayoutId inheritance + ├─ walk ElementDesc tree → widget (hybrid factory) + └─ apply rect / anchors / states / media / props from the dat + + per-window Controller ─► binds LIVE data to elements by id (mirrors retail gm*UI) + WindowManager ─► open/close by layout id, z-order, focus, position persistence + +PLUGIN / CUSTOM PANELS (hand-authored, unchanged) + *.xml ─► MarkupDocument ─► UiElement tree ─► (same UiRoot + renderers) +``` + +Two input paths (dat importer for retail windows, markup for custom/plugin), one rendering +toolkit. Nothing in the bottom (`UiHost`/`UiRoot`/`UiElement`) or the render primitives +changes. + +## Components + +### 1. Format enumeration (Step 0 — foundational groundwork) + +Because we chose "full faithful," the first deliverable is a **documented map** of the +complete format, not code. Sources, cross-checked against each other: + +- **DatReaderWriter types** — `ElementDesc`, `StateDesc`, `MediaDesc*` and their enums + (`Type`, `DrawMode`, media kinds, state keys). Reflect/inspect as `dump-vitals-layout` + already does (props **and** fields). +- **Retail decomp** — the `UIElement_*` class hierarchy + each type's render method; the + property-key meanings; the **KSML keyword registrations** (the parser registers every + property name — the canonical vocabulary, e.g. `KW_DRAWMODE`, `KW_DURATION`, …). +- **Real layouts** — scan a sample of `LayoutDesc`s to confirm which Types/properties + actually occur and catch anything the above missed. + +Output: a reference doc mapping each `Type` → meaning + render method, each property key → +meaning, each `DrawMode` → behavior, and the inheritance rules. This doc drives every other +component and is committed alongside the importer. + +### 2. `LayoutImporter` + +Reads a `LayoutDesc` by id and returns a `UiElement` subtree: +- Walk the `ElementDesc` tree. +- For each element: resolve inheritance (§3), pick a widget via the factory (§4), set its + rect (`X/Y/W/H`), anchors (edge flags → `AnchorEdges`), z-order, states, media, and + properties from the (resolved) element. +- Recurse into children. +- Expose `FindElement(uint id)` on the result so controllers wire by id. + +Depends on: `DatCollection` (read layouts), the factory, the inheritance resolver, +`TextureCache.GetOrUploadRenderSurface` (sprites), `UiDatFont` (text). No GL itself — it +builds `UiElement`s; rendering stays in the toolkit. + +### 3. Inheritance resolution + +An element with `BaseElement`/`BaseLayoutId` inherits the base element's properties / states +/ media; the derived element overrides. Resolve by loading the base layout, finding the base +element, and merging (base first, then derived overrides) **before** instantiating. +Required even for vitals: the number-text element inherits its font/style from base layout +`0x2100003F`. Cycle-guard the resolution. + +### 4. Hybrid widget factory (`Type` → renderer) + +- **Behavioral** types → dedicated widgets (verbatim-algorithm ports): meter → `UiMeter`, + text → dat-font label, scrollable/list region → `UiChatView`/list widget, button → + `UiButton`, resizable window root → `UiNineSlicePanel`. +- **Trivial** types (image, container, border piece, grip) → `UiDatElement` (generic). +- **Unknown** type → `UiDatElement` (faithful fallback — still draws its media). + +The Step-0 enumeration assigns each `Type` to a bucket by reading its retail render method +(trivial blit → generic; real algorithm → widget). + +### 5. `UiDatElement` (generic renderer) + +A `UiElement` holding the resolved element's active-state media + draw-mode + rect. Its +`OnDraw` ports retail's base blit branch: +- `Normal` → **tile** at native size (UV-repeat; the UI texture is `GL_REPEAT`-wrapped) — + the mechanism already proven for the bars + chrome. +- `Alphablend` → blended overlay. +- `Stretch` (if present) → scale. +- image → sprite; cursor → hover cursor. +Reuses the tiling, dat-font, nine-slice draw primitives. + +### 6. Per-window controllers (live-data binding) + +Mirror retail's `gm*UI` classes. A small controller per window grabs elements by id from the +imported tree and pushes live data in: `VitalsController` binds `HealthPercent` → meter fill, +`cur/max` → number text; `ChatController` binds the chat tail → the chat region. **This is +the only per-window code, and it is data wiring, not graphics.** Retail-faithful: e.g. +`gmVitalsUI::PostInit` grabs child meter elements by id and sets attribute `0x69` (fill). + +### 7. `WindowManager` + +`OpenWindow(layoutId, controller)` → import + attach to `UiRoot`, place at the dat's default +position (then persist user move/resize), manage z-order / focus / close. Orchestrates the +focus/drag/resize mechanics `UiRoot` already provides. + +### 8. States / expand / hover + +Each element carries its named states (`HideDetail`/`ShowDetail`, normal/hover/pressed) from +the dat; the active state selects which media draws. A click or hover flips the active state. +Click-to-expand and hover highlight fall out generically — no per-window code. + +## Rollout order (milestones) + +1. **Enumerate the format** (§1) → reference doc. +2. **`LayoutImporter`** + **inheritance resolution** (read + resolve + walk). +3. **`UiDatElement`** generic renderer (port the draw-mode blit branch). +4. **Hybrid factory** (Type → widget/generic). +5. **`VitalsController`** (bind by id). +6. **Re-drive vitals → diff against the current window.** ✅ conformance gate. +7. **`WindowManager`** (open/close/persist). +8. **Extend** to chat (`ChatController`), then new windows for free. + +## Testing / conformance + +- **Golden tree checks** — the importer-built vitals tree has the expected element rects, + resolved sprites, and active states (assert against the known `0x2100006C` values). +- **Inheritance unit tests** — base+override merge, cycle-guard. +- **Draw-mode unit tests** — the UV math for tile vs stretch vs the partial last tile. +- **Bind-by-id unit tests** — controller wires the right element. +- **Headless visual diff** — `render-vitals-mockup` / a tree-render comparison vs the + hand-built reference (no live server needed). +- **Final** — in-client visual verification (the user) once the gate passes. + +## Coexistence / don't-delete (restated) + +- `MarkupDocument` + `*.xml` stay for plugin/custom panels. +- `UiMeter`, `UiNineSlicePanel`, `UiChatView`, `UiDatFont`, the tiling/scissor-fill/dat-font/ + nine-slice primitives stay — reused as the importer's behavioral renderers. +- The hand-authored vitals path stays as the conformance reference until the importer + matches it; only then is vitals flipped to the importer. + +## Risks & open questions + +- **The format enumeration is the foundational unknown.** If a Type/property/draw-mode is + mis-mapped, faithfulness breaks. Mitigation: Step 0 cross-checks three sources + real + layouts; the vitals conformance gate catches regressions. +- **Some behavioral types may need new widgets** (list, scrollbar, edit box). These are + generic, written once — not per-window. The generic fallback means an un-widgeted type + still renders its sprites in the meantime. +- **Position persistence** scope (per-window saved rects) — minimal at first (dat default + + in-session move/resize); durable persistence can follow. +- **Phase id** — register this as the next D.2b sub-phase in the roadmap before implementing. + +## Reference anchors + +- **Dat layouts:** vitals (stacked) `0x2100006C`; floaty row `0x21000014`; horizontal row + `0x21000075`; vitals number-text base layout `0x2100003F`. +- **Decomp:** `gmVitalsUI::PostInit` @`0x4bfce0` (bind by id), `UIElement_Meter::DrawChildren` + @`0x46fbd0` (scissor-fill), `SurfaceWindow::DrawCharacter` @`0x442bd0` (dat-font), + `ImgTex::TileCSI` @`0x53e740` (tiling), `UIRegion::DrawHere` @`0x69fa30` (element draw order), + the KSML keyword registrations (~`0x71b540`+). +- **Tools:** `AcDream.Cli dump-vitals-layout` (reflective full tree dump), + `dump-sprite-sheet` (composite sprite ids), `render-vitals-mockup` (headless window render). +- **Memory:** `project_d2b_retail_ui.md` (the two-layout lesson, sprite ids, render model, + dat-font, tools). diff --git a/docs/superpowers/specs/2026-06-16-d2b-widget-generalization-design.md b/docs/superpowers/specs/2026-06-16-d2b-widget-generalization-design.md new file mode 100644 index 00000000..8c61043b --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-d2b-widget-generalization-design.md @@ -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`, `Selected`, `NaturalButtonWidth()` | populate 14 channel `Items`; map payload↔`ChatChannelKind`; `AvailabilityProvider` | +| `UiText` | 12 | `UiChatView` | scrollable + selectable multi-color line list, clipboard, dat-font; `LinesProvider : Func>`; shares `UiScrollable` (`Scroll`) | `LinesProvider` → ChatVM + per-kind colors | +| `UiField` | 3 | `UiChatInput` | editable one-line: caret/selection/clipboard/history/auto-repeat/focus-sprite-swap; `Text`, `OnSubmit`, `MaxCharacters` | `OnSubmit` → `ChatCommandRouter` | + +**Placement.** The generic widgets live in `src/AcDream.App/UI/` alongside +`UiMeter` (toolkit widgets). The factory in `src/AcDream.App/UI/Layout/` +references them. This matches the current split (`UiMeter` in `UI/`, +`UiDatElement` in `UI/Layout/`). + +**Naming.** `UiX` mirrors retail `UIElement_X`. The old chat-prefixed names are +removed (or kept as thin obsolete aliases only if needed mid-migration). + +### 4.3 The two wrinkles + +**(a) The editable input (Type 12 vs Type 3).** Robust to either resolution: +- If `0x10000016` resolves to **Type 3** → factory builds `UiField` directly; the + controller only binds `OnSubmit`. +- If it resolves to **Type 12** → the dat element is a display Text in this + layout; the controller *replaces* it with a controller-placed `UiField` at its + rect (today's pattern for the track/menu). `UiField` exists as a registered + generic widget regardless; only *who places it* differs. + +Editing behavior (caret/clipboard/history) is never purely dat-derivable, so the +input is always provider-bound — the open question only affects whether the +factory or the controller *instantiates* it. + +**(b) Vitals rewire — the final gated step.** Removing the Type-12 skip means the +vitals number elements (Type-0 → base Type-12 Text) *could* build as real +`UiText`. Today they are **meter children, consumed** (the importer does not +recurse a meter's children — `LayoutImporter.cs:113`), rendered via +`UiMeter.Label`. The faithful move: `VitalsController` constructs/binds a `UiText` +for each number (matching retail `UIElement_Text` vitals numbers) and drops +`UiMeter.Label` for vitals. + +This is **step 7 — the last commit, separately gated**, with its own fixture +update and the user's visual sign-off, because vitals shipped pixel-identical and +is fixture-locked (`vitals_2100006C.json`). If the rewire risks the pixel-identical +result, we **stop and keep the meter-label path** for vitals — a smaller, +documented divergence (AP-37 narrowed, not retired). The decision to land step 7 +is the user's, made on the running client. + +### 4.4 The thin controller (after step 6) + +`ChatWindowController.Bind` collapses to: for each known element id, `FindElement(id) +as UiX`, null-check, bind data/callback. The reflow/maximize/resize-bar-drop logic +(`ChatWindowController.cs:155-297`) stays — it is window-layout policy, not widget +construction. The `BuildLines` / `WrapText` / `RetailChatColor` helpers stay (chat +data shaping). What *leaves* the controller: the construction of `UiChatView`, +`UiChatInput`, `UiChatScrollbar`, `UiChannelMenu` (now factory-built) — the +controller binds them instead. + +--- + +## 5. Migration sequence (one widget per commit; build + test green each step) + +Ordered least-risk → most-risk; the chat window is fully generalized before vitals +is touched. Each step: `dotnet build` green, `dotnet test` (AcDream.App.Tests) +green, its own commit naming the widget; the live chat window stays visually +identical through steps 1–6. + +1. **`UiScrollbar`** (Type 11) — promote `UiChatScrollbar` (already generic); + register; factory builds it; controller binds `Model`. +2. **`UiButton`** (Type 1) — extract from `UiDatElement`+`OnClick`; register; Send + + Max/Min build from the dat. +3. **`UiMenu`** (Type 6) — generalize `UiChannelMenu`; register; controller + populates channel `Items` + maps payload↔`ChatChannelKind`. +4. **`UiText`** (Type 12) — generalize `UiChatView`; register; **delete the Type-12 + skip**; controller binds transcript lines. Guard: verify vitals still renders + (its numbers are meter-consumed → no auto-double-draw) via the vitals fixture + + a live launch. +5. **`UiField`** (Type 3) — generalize `UiChatInput`; register; wire the input per + §4.3(a) (verification #1 resolves factory-built vs controller-placed). +6. **Thin the controller** — collapse `ChatWindowController.Bind` to pure + find-by-id binding now that the factory builds everything. +7. **Vitals rewire (gated)** — `VitalsController` binds `UiText` numbers; fixture + update + the user's visual sign-off. **Stop-and-confirm gate.** + +--- + +## 6. Testing & conformance + +- **Generic-widget unit tests** (pure, no GL/dat) — mostly *moved* from the + existing chat-widget tests, renamed to the generic widgets: caret↔pixel + history + (`UiField`), thumb ratio / page-delta (`UiScrollbar` via `UiScrollable`), menu + item-pick + availability (`UiMenu`), line wrap / selection / dat-font hit-test + (`UiText`). +- **Factory tests** — `DatWidgetFactoryTests` grows one assert per newly registered + Type → correct widget class. +- **New chat-layout golden fixture** `tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json` + (peer of `vitals_2100006C.json`): the resolved chat tree — each element's id, + rect, resolved Type, sprite ids — asserting the factory builds the right widget + per element. This locks the generalization. +- **Vitals fixture `vitals_2100006C.json` stays green, untouched, through steps + 1–6**; updated only at step 7, with visual sign-off. +- **Visual acceptance** — the user launches `ACDREAM_RETAIL_UI=1` and confirms the + chat window is unchanged through steps 1–6, and the vitals window is unchanged + after step 7. + +--- + +## 7. Divergence-register impact + +- **AP-37** (`DatWidgetFactory.cs`/`LayoutImporter.cs`: Type-0 text skipped + meter- + collapse + vitals numbers via `UiMeter.Label`): **amended as steps land** — the + "standalone Type-0 text elements are skipped / a dedicated dat-text widget is + Plan 2" clause is retired when `UiText` ships (step 4); the vitals-numbers-via- + `UiMeter.Label` clause is retired at step 7, or **narrowed** (not retired) if + step 7 is deferred. The meter-collapse clause (reuse `UiMeter` 3-slice vs porting + `UIElement_Meter::DrawChildren` over nested dat elements) **remains** — this pass + does not port `DrawChildren`. +- **IA-15** (the importer *is* the retail-UI render path): unchanged; reinforced + (more Types now data-driven). +- **AP-41** (scrollbar thumb single stretched sprite): **re-check at step 1** — the + controller already passes 3-slice cap ids (`ThumbTopSprite`/`ThumbBotSprite`); the + row may be retire-able when `UiScrollbar` lands. +- **New rows** only if a generic widget introduces a *new* approximation (e.g., a + `UiMenu` item model simpler than retail's hierarchical popup chain in + `UIElement_Menu::MakePopup`). Add the row in the same commit per register rule 1. + +--- + +## 8. Acceptance criteria + +- [ ] `DatWidgetFactory` registers Types 1, 3, 6, 11, 12 (+ 7) → generic widgets; + `_` still falls back to `UiDatElement`. +- [ ] The `Type==12 → null` skip is removed; no Type-12 element is double-built + (golden fixtures green). +- [ ] The four chat widgets are generic (no `ChatChannelKind` / chat-color / + command-routing knowledge inside a widget); `ChatWindowController` only finds- + by-id and binds. +- [ ] Chat window is visually + behaviorally identical to the shipped version + through steps 1–6 (user-confirmed). +- [ ] New `chat_21000006.json` golden fixture + moved generic-widget unit tests; + all green. +- [ ] Vitals window unchanged after step 7 (user-confirmed), or step 7 deferred + with AP-37 narrowed. +- [ ] Every generic widget cites its retail `UIElement_X` class + reg. line in a + code comment. +- [ ] Divergence register updated (AP-37 amended; AP-41 re-checked) in the same + commits. +- [ ] Roadmap / `claude-memory/project_d2b_retail_ui.md` updated when the pass lands. + +--- + +## 9. Open items for the plan phase + +1. **Verification #1 (load-bearing):** re-dump `0x10000016` (input) + the + `0x10000372` base prototype to confirm input resolved Type (3 vs 12) → decides + factory-built vs controller-placed `UiField` (§4.3a). +2. **Verification #2:** confirm no Type-12 base prototype double-builds once the + skip is removed (§4.1). +3. Confirm the `UiMenu` generic item model (`(label, enabled, payload)`) is enough + for the 14 talk-focus channels without losing the greyed/available distinction + the chat menu currently shows. +4. Decide whether to keep thin obsolete-aliases for the old chat widget names + during migration or rename in-place (prefer in-place; the names are internal). diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj index d50c6b46..64eac77a 100644 --- a/src/AcDream.App/AcDream.App.csproj +++ b/src/AcDream.App/AcDream.App.csproj @@ -50,6 +50,11 @@ PreserveNewest + + + PreserveNewest + diff --git a/src/AcDream.App/Plugins/AppPluginHost.cs b/src/AcDream.App/Plugins/AppPluginHost.cs index 2916724e..5b06e67e 100644 --- a/src/AcDream.App/Plugins/AppPluginHost.cs +++ b/src/AcDream.App/Plugins/AppPluginHost.cs @@ -4,14 +4,16 @@ namespace AcDream.App.Plugins; public sealed class AppPluginHost : IPluginHost { - public AppPluginHost(IPluginLogger log, IGameState state, IEvents events) + public AppPluginHost(IPluginLogger log, IGameState state, IEvents events, IUiRegistry ui) { Log = log; State = state; Events = events; + Ui = ui; } public IPluginLogger Log { get; } public IGameState State { get; } public IEvents Events { get; } + public IUiRegistry Ui { get; } } diff --git a/src/AcDream.App/Plugins/BufferedUiRegistry.cs b/src/AcDream.App/Plugins/BufferedUiRegistry.cs new file mode 100644 index 00000000..bcab04fb --- /dev/null +++ b/src/AcDream.App/Plugins/BufferedUiRegistry.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using AcDream.Plugin.Abstractions; + +namespace AcDream.App.Plugins; + +/// +/// Buffers plugin calls (which run in +/// Program.cs before the GL window opens) until GameWindow drains them into the +/// UiHost tree after construction. +/// +public sealed class BufferedUiRegistry : IUiRegistry +{ + public readonly record struct Pending(string MarkupPath, object Binding); + + private readonly List _pending = new(); + + public void AddMarkupPanel(string markupPath, object binding) + => _pending.Add(new Pending(markupPath, binding)); + + /// Return + clear all buffered registrations. + public IReadOnlyList Drain() + { + var copy = _pending.ToArray(); + _pending.Clear(); + return copy; + } +} diff --git a/src/AcDream.App/Program.cs b/src/AcDream.App/Program.cs index bc43997b..b3aebd5a 100644 --- a/src/AcDream.App/Program.cs +++ b/src/AcDream.App/Program.cs @@ -23,7 +23,8 @@ var runtimeOptions = RuntimeOptions.FromEnvironment(datDir); var worldGameState = new AcDream.Core.Plugins.WorldGameState(); var worldEvents = new AcDream.Core.Plugins.WorldEvents(); -var host = new AppPluginHost(new SerilogAdapter(Log.Logger), worldGameState, worldEvents); +var uiRegistry = new AcDream.App.Plugins.BufferedUiRegistry(); +var host = new AppPluginHost(new SerilogAdapter(Log.Logger), worldGameState, worldEvents, uiRegistry); var pluginsDir = Path.Combine(AppContext.BaseDirectory, "plugins"); Log.Information("scanning plugins in {PluginsDir}", pluginsDir); @@ -56,7 +57,7 @@ try catch (Exception ex) { Log.Error(ex, "plugin enable failed: {Id}", plugin.Manifest.Id); } } - using var window = new GameWindow(runtimeOptions, worldGameState, worldEvents); + using var window = new GameWindow(runtimeOptions, worldGameState, worldEvents, uiRegistry); window.Run(); } finally diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 8fd2afda..e4c21d47 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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(); diff --git a/src/AcDream.App/Rendering/Shaders/ui_text.frag b/src/AcDream.App/Rendering/Shaders/ui_text.frag index 7740ea11..75c9cd3d 100644 --- a/src/AcDream.App/Rendering/Shaders/ui_text.frag +++ b/src/AcDream.App/Rendering/Shaders/ui_text.frag @@ -7,10 +7,13 @@ uniform sampler2D uTex; uniform int uUseTexture; void main() { - if (uUseTexture != 0) { + if (uUseTexture == 1) { // Font atlas is a single-channel R8 texture; red = coverage alpha. float coverage = texture(uTex, vUv).r; FragColor = vec4(vColor.rgb, vColor.a * coverage); + } else if (uUseTexture == 2) { + // RGBA dat sprite (decoded to RGBA8); modulate by tint/alpha. + FragColor = texture(uTex, vUv) * vColor; } else { FragColor = vColor; } diff --git a/src/AcDream.App/Rendering/TextRenderer.cs b/src/AcDream.App/Rendering/TextRenderer.cs index ad04da1a..88592057 100644 --- a/src/AcDream.App/Rendering/TextRenderer.cs +++ b/src/AcDream.App/Rendering/TextRenderer.cs @@ -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 _textBuf = new(8192); private readonly List _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 Verts = new(256); } + private readonly List _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 _overlayTextBuf = new(1024); + private readonly List _overlayRectBuf = new(256); + private readonly List _overlaySpriteSegs = new(); + private int _overlaySegUsed; + private int _overlayTextVerts; + private int _overlayRectVerts; + + /// When true, Draw* calls route to the overlay layer (flushed last, on top + /// of all normal-layer geometry). Set by the UI root around the popup/overlay pass. + public bool OverlayMode { get; set; } + public TextRenderer(GL gl, string shaderDir) { _gl = gl; @@ -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 whitePixel = stackalloc byte[] { 255, 255, 255, 255 }; + fixed (byte* wp = whitePixel) + _gl.TexImage2D(TextureTarget.Texture2D, 0, (int)InternalFormat.Rgba8, 1, 1, 0, + PixelFormat.Rgba, PixelType.UnsignedByte, wp); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Nearest); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMinFilter.Nearest); + _gl.BindTexture(TextureTarget.Texture2D, 0); } /// Begin a HUD pass. Call once per frame before any Draw* calls. @@ -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; } /// Draw a filled rectangle in screen pixel space. public void DrawRect(float x, float y, float w, float h, Vector4 color) { - AppendQuad(_rectBuf, x, y, w, h, 0, 0, 0, 0, color); - _rectVerts += 6; + if (OverlayMode) { AppendQuad(_overlayRectBuf, x, y, w, h, 0, 0, 0, 0, color); _overlayRectVerts += 6; } + else { AppendQuad(_rectBuf, x, y, w, h, 0, 0, 0, 0, color); _rectVerts += 6; } } + /// Draw a solid-colour quad through the SPRITE bucket (and the overlay layer + /// when active), so it composites in painter order with sprites + dat-font text. Use + /// this — not — for a panel BACKGROUND that text draws on top of: + /// DrawRect's bucket always flushes after all sprites, so a rect background would cover + /// the text instead. + public void DrawFill(float x, float y, float w, float h, Vector4 color) + => DrawSprite(_whiteTex, x, y, w, h, 0f, 0f, 1f, 1f, color); + /// Draw a 1-pixel-thick outline rect. public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f) { @@ -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; } } + /// + /// Draw a textured sprite quad in screen pixel space with an explicit + /// source-UV rectangle (for 9-slice / atlas sub-regions). Batched per + /// GL texture handle; flushed with uUseTexture=2 (RGBA modulate). + /// + public void DrawSprite(uint texture, float x, float y, float w, float h, + float u0, float v0, float u1, float v1, Vector4 tint) + { + SpriteSeg seg = OverlayMode + ? NextSpriteSeg(_overlaySpriteSegs, ref _overlaySegUsed, texture) + : NextSpriteSeg(_spriteSegs, ref _segUsed, texture); + AppendQuad(seg.Verts, x, y, w, h, u0, v0, u1, v1, tint); + } + + /// Pick the sprite segment for : extend the current + /// same-texture run, else reuse a pooled segment, else allocate. Submission order is + /// preserved (painter z-order for sprite-on-sprite UI). + private static SpriteSeg NextSpriteSeg(List segs, ref int used, uint texture) + { + if (used > 0 && segs[used - 1].Texture == texture) + return segs[used - 1]; + if (used < segs.Count) + { + var s = segs[used++]; + s.Texture = texture; + s.Verts.Clear(); + return s; + } + var ns = new SpriteSeg { Texture = texture }; + segs.Add(ns); + used++; + return ns; + } + private static void AppendQuad(List buf, float x, float y, float w, float h, float u0, float v0, float u1, float v1, Vector4 color) @@ -159,7 +244,9 @@ public sealed unsafe class TextRenderer : IDisposable /// Upload + draw accumulated rects + text. font may be null if only DrawRect was used. public void Flush(BitmapFont? font) { - 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); + } + + /// Draw one compositing layer: sprites (submission order, one call per + /// texture) → untextured rects → debug-font text. Shared by the normal and overlay + /// layers; GL state + shader are set up by . + private void DrawLayer( + List spriteSegs, int segUsed, + List rectBuf, int rectVerts, + List textBuf, int textVerts, BitmapFont? font) + { + // 1. RGBA dat sprites — one draw call per distinct GL texture. + if (segUsed > 0) { - _shader.SetInt("uUseTexture", 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 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(); diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs index 056ec01f..7d1c0b25 100644 --- a/src/AcDream.App/Rendering/TextureCache.cs +++ b/src/AcDream.App/Rendering/TextureCache.cs @@ -14,6 +14,7 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab private readonly GL _gl; private readonly DatCollection _dats; private readonly Dictionary _handlesBySurfaceId = new(); + private readonly Dictionary _sizeBySurfaceId = new(); /// /// Composite cache for surface-with-override-origtex entries (Phase 5 /// TextureChanges). Key = (baseSurfaceId, overrideOrigTextureId), @@ -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 _handlesByRenderSurfaceId = new(); + private readonly Dictionary _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; } + /// + /// Like but also returns the decoded + /// pixel dimensions. UI 9-slice geometry needs the source size to + /// compute slice UVs. Cached alongside the handle. + /// + public uint GetOrUpload(uint surfaceId, out int width, out int height) + { + if (_handlesBySurfaceId.TryGetValue(surfaceId, out var existing) + && _sizeBySurfaceId.TryGetValue(surfaceId, out var sz)) + { + width = sz.w; height = sz.h; + return existing; + } + + var decoded = DecodeFromDats(surfaceId, origTextureOverride: null, paletteOverride: null); + uint h = UploadRgba8(decoded); + _handlesBySurfaceId[surfaceId] = h; + _sizeBySurfaceId[surfaceId] = (decoded.Width, decoded.Height); + width = decoded.Width; height = decoded.Height; + return h; + } + + /// + /// Upload a UI sprite by its RenderSurface DataId (0x06xxxxxx), decoded + /// DIRECTLY (Portal/HighRes → DecodeRenderSurface) rather than through the + /// Surface→SurfaceTexture chain that uses + /// for world-geometry materials. This is the correct path for retail UI + /// chrome + font glyph sheets, which reference RenderSurface directly. + /// Palette is null for now (a paletted INDEX16/P8 UI sprite would return + /// Magenta — wire a UI palette when one is actually encountered). Returns a + /// 1x1 magenta handle on miss. + /// + public uint GetOrUploadRenderSurface(uint renderSurfaceId, out int width, out int height, 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(renderSurfaceId, out var rs) + || _dats.HighRes.TryGet(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; + } + /// /// 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); diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index f772dcc5..6b7ac0d6 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -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 (UpdateSunFromSky). 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-*. /// /// private void ComputeEntityLightSet(WorldEntity entity) diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index a1ceb4db..9be7601d 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -39,7 +39,9 @@ public sealed record RuntimeOptions( bool RetailCloseDegrades, bool DumpSceneryZ, bool DumpLiveSpawns, - int? LegacyStreamRadius) + int? LegacyStreamRadius, + bool RetailUi, + string? AcDir) { /// /// Build options from the process environment. Used by @@ -81,7 +83,9 @@ public sealed record RuntimeOptions( DumpLiveSpawns: IsExactlyOne(env("ACDREAM_DUMP_LIVE_SPAWNS")), // Legacy override for ACDREAM_STREAM_RADIUS. Caller applies it on // top of the quality preset's radii. Null when unset or invalid. - LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS"))); + LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")), + RetailUi: IsExactlyOne(env("ACDREAM_RETAIL_UI")), + AcDir: NullIfEmpty(env("ACDREAM_AC_DIR"))); } /// True iff live-mode credentials are present and valid for connecting. diff --git a/src/AcDream.App/UI/ControlsIni.cs b/src/AcDream.App/UI/ControlsIni.cs new file mode 100644 index 00000000..2812d696 --- /dev/null +++ b/src/AcDream.App/UI/ControlsIni.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// Minimal reader for retail's controls.ini — a flat INI with one +/// [section] per element type. Colors are #AARRGGBB (alpha +/// first). Optional: a missing file yields an empty sheet (callers fall back +/// to hardcoded defaults). See the D.2b spec §7. +/// +public sealed class ControlsIni +{ + private readonly Dictionary> _sections; + + private ControlsIni(Dictionary> s) => _sections = s; + + /// Load from disk; returns an empty sheet if the file is absent. + public static ControlsIni Load(string path) + => System.IO.File.Exists(path) + ? Parse(System.IO.File.ReadAllText(path)) + : new ControlsIni(new()); + + public static ControlsIni Parse(string text) + { + var sections = new Dictionary>(System.StringComparer.OrdinalIgnoreCase); + Dictionary? cur = null; + foreach (var raw in text.Split('\n')) + { + var line = raw.Trim(); + if (line.Length == 0 || line[0] == ';' || line[0] == '#') continue; + if (line[0] == '[' && line[^1] == ']') + { + var name = line[1..^1].Trim(); + cur = new Dictionary(System.StringComparer.OrdinalIgnoreCase); + sections[name] = cur; + continue; + } + int eq = line.IndexOf('='); + if (eq <= 0 || cur is null) continue; + cur[line[..eq].Trim()] = line[(eq + 1)..].Trim(); + } + return new ControlsIni(sections); + } + + public string? Get(string section, string key) + => _sections.TryGetValue(section, out var s) && s.TryGetValue(key, out var v) ? v : null; + + /// Parse a #AARRGGBB token into an RGBA . + public bool TryColor(string section, string key, out Vector4 color) + { + color = default; + var v = Get(section, key); + if (v is null || v.Length != 9 || v[0] != '#') return false; + if (!uint.TryParse(v.AsSpan(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out uint argb)) + return false; + float a = ((argb >> 24) & 0xFF) / 255f; + float r = ((argb >> 16) & 0xFF) / 255f; + float g = ((argb >> 8) & 0xFF) / 255f; + float b = (argb & 0xFF) / 255f; + color = new Vector4(r, g, b, a); + return true; + } +} diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs new file mode 100644 index 00000000..7726b96a --- /dev/null +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -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; + +/// +/// Binds the imported chat LayoutDesc (0x21000006) to live behavior — the acdream +/// analogue of retail ChatInterface + gmMainChatUI::PostInit @0x4ce130. +/// +/// +/// The transcript (0x10000011) is Type-12 and is built as a +/// by the factory; this controller binds its live data provider in place. The input +/// (0x10000016) is also Type-12, so the factory builds it as an invisible +/// placeholder; this controller removes that placeholder and adds +/// a at the same rect. The scrollbar track (0x10000012) is +/// built directly as a by the factory (Type 11) and bound in +/// place. The channel menu (0x10000014) is built as (Type 6) +/// and bound in place. +/// +/// +public sealed class ChatWindowController +{ + 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 ───────────────────────────────────────────────────── + + /// Root element of the imported layout (the chat window chrome). + public UiElement Root { get; private set; } = null!; + + /// Live chat transcript widget. Null until succeeds. + public UiText Transcript { get; private set; } = null!; + + /// Editable chat input widget. Null until succeeds. + public UiField Input { get; private set; } = null!; + + /// Scrollbar widget, driven by 's scroll model. + public UiScrollbar Scrollbar { get; private set; } = null!; + + /// Channel-selector menu widget. + public UiMenu Menu { get; private set; } = null!; + + // ── Private state ────────────────────────────────────────────────────── + + private ChatChannelKind _activeChannel = ChatChannelKind.Say; + + // ── Channel knowledge (ported from old UiChannelMenu — gmMainChatUI::InitTalkFocusMenu @0x4cdc50) ── + + private static readonly (string Label, ChatChannelKind? Channel)[] ChannelItems = + { + ("Squelch (ignore)", null), + ("Tell to Selected", null), + ("Chat to All", ChatChannelKind.Say), + ("Tell to Fellows", ChatChannelKind.Fellowship), + ("Tell to General Chat", ChatChannelKind.General), + ("Tell to LFG Chat", ChatChannelKind.Lfg), + ("Tell to Society Chat", ChatChannelKind.Society), + ("Tell to Monarch", ChatChannelKind.Monarch), + ("Tell to Patron", ChatChannelKind.Patron), + ("Tell to Vassals", ChatChannelKind.Vassals), + ("Tell to Allegiance", ChatChannelKind.Allegiance), + ("Tell to Trade Chat", ChatChannelKind.Trade), + ("Tell to Roleplay Chat", ChatChannelKind.Roleplay), + ("Tell to Olthoi Chat", ChatChannelKind.Olthoi), + }; + + private static string ChannelButtonLabel(ChatChannelKind k) => k switch + { + ChatChannelKind.Say => "Chat", + ChatChannelKind.General => "General", + ChatChannelKind.Trade => "Trade", + ChatChannelKind.Lfg => "LFG", + ChatChannelKind.Fellowship => "Fellow", + ChatChannelKind.Allegiance => "Alleg", + ChatChannelKind.Patron => "Patron", + ChatChannelKind.Vassals => "Vassals", + ChatChannelKind.Monarch => "Monarch", + ChatChannelKind.Roleplay => "Roleplay", + ChatChannelKind.Society => "Society", + ChatChannelKind.Olthoi => "Olthoi", + _ => "Chat", + }; + + private static bool ChannelAvailable(ChatChannelKind k) + => k is ChatChannelKind.Say or ChatChannelKind.General or ChatChannelKind.Trade or ChatChannelKind.Lfg; + + /// Window height before maximize (stored to restore on un-maximize). + private float _normalHeight; + /// Window top before maximize. + private float _normalTop; + private bool _maximized; + + // ── Factory ──────────────────────────────────────────────────────────── + + /// + /// Bind an imported chat layout to live behavior. + /// + /// and must come from the + /// SAME pass (ImportInfos then Build) + /// so rects in the info tree match the widget geometry in the layout tree. + /// + /// Returns null if the essential transcript/input panels are missing from + /// the info tree or the widget tree (e.g. the layout dat is incomplete). + /// + /// Full tree from + /// . + /// Widget tree from . + /// Chat view-model (transcript data + command routing). + /// Factory that returns the live command bus at submit time. + /// Called on every chat submit so it resolves + /// even when the live session is established AFTER runs + /// (mirrors the ImGui ChatPanel which re-reads the bus each frame). + /// Retail dat font for transcript + input rendering. + /// Fallback debug bitmap font (used when + /// is null). + /// Dat RenderSurface id → (GL tex handle, px width, px height). + /// Forwarded to and . + public static ChatWindowController? Bind( + ElementInfo rootInfo, + ImportedLayout layout, + ChatVM vm, + Func busProvider, + UiDatFont? datFont, + BitmapFont? debugFont, + Func 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 ───────────────────────────────────────────── + + /// + /// Toggle between the normal chat window height and an expanded 320px height. + /// Simplified port of retail gmMainChatUI::HandleMaximizeButton @0x4cddb0: + /// retail stores the pre-maximize height and restores it on a second click. + /// The 320px expanded size is the approximate retail maximized chat height. + /// + private void ToggleMaximize() + { + if (!_maximized) + { + _normalHeight = Root.Height; + _normalTop = Root.Top; + // Expand upward: move the top edge up so the bottom stays anchored. + Root.Top = MathF.Max(0f, Root.Top + Root.Height - 320f); + Root.Height = 320f; + _maximized = true; + } + else + { + Root.Top = _normalTop; + Root.Height = _normalHeight; + _maximized = false; + } + } + + // ── Helpers ──────────────────────────────────────────────────────────── + + /// + /// Depth-first search for an node by id in the + /// raw info tree (which contains ALL elements, including the Type-12 skipped ones). + /// + private static ElementInfo? FindInfo(ElementInfo node, uint id) + { + if (node.Id == id) return node; + foreach (var child in node.Children) + { + var found = FindInfo(child, id); + if (found is not null) return found; + } + return null; + } + + /// + /// Convert the ChatVM's detailed lines to the transcript's + /// record format, applying retail-faithful + /// per- colors. + /// + private static IReadOnlyList BuildLines( + ChatVM vm, UiText view, UiDatFont? datFont, BitmapFont? debugFont) + { + var detailed = vm.RecentLinesDetailed(); + if (detailed.Count == 0) return Array.Empty(); + + // Word-wrap each message to the transcript's current pixel width (ports retail + // GlyphList::Recalculate @0x473800 — break at word boundaries when the line would + // exceed wrapWidth). Re-evaluated each frame so wrapping follows window resize. + float maxW = view.Width - 2f * view.Padding; + Func measure = + datFont is { } df ? s => df.MeasureWidth(s) + : debugFont is { } bf ? s => bf.MeasureWidth(s) + : s => s.Length * 7f; + + var result = new List(detailed.Count); + foreach (var d in detailed) + { + var color = RetailChatColor(d.Kind); + foreach (var frag in WrapText(d.Text, maxW, measure)) + result.Add(new UiText.Line(frag, color)); + } + return result; + } + + /// + /// Greedy word-wrap: split into fragments that each fit in + /// pixels (per ), 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). + /// + public static IEnumerable WrapText(string text, float maxW, Func 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(); + } + + /// + /// Per- text color — the EXACT retail RGBA values read from a + /// live retail client via cdb (the named RGBAColor constants at acclient + /// 0x81c4a8+, e.g. colorWhite/colorBrightPurple/colorLightBlue/ + /// colorGreen, used by ChatInterface::BuildChatColorLookupTable @0x4f31c0). + /// The four common kinds (speech/tell/channel/system) are confirmed by the named + /// symbols + universal AC convention; the rarer kinds map to the nearest named color. + /// + private static Vector4 RetailChatColor(ChatKind kind) => kind switch + { + ChatKind.LocalSpeech => new(1f, 1f, 1f, 1f), // colorWhite + ChatKind.RangedSpeech => new(1f, 1f, 1f, 1f), // colorWhite (shout) + ChatKind.Channel => new(0.247f, 0.749f, 1f, 1f), // colorLightBlue + ChatKind.Tell => new(1f, 0.498f, 1f, 1f), // colorBrightPurple + ChatKind.System => new(0.5f, 1f, 0.498f, 1f), // colorGreen + ChatKind.Popup => new(0.5f, 1f, 0.498f, 1f), // colorGreen (server broadcast) + ChatKind.Emote => new(0.824f, 0.824f, 0.784f, 1f), // colorGrey + ChatKind.SoulEmote => new(0.824f, 0.824f, 0.784f, 1f), // colorGrey + ChatKind.Combat => new(0.96f, 0.459f, 0.447f, 1f), // colorLightRed + _ => new(0.824f, 0.824f, 0.784f, 1f), // colorGrey (fallback) + }; +} diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs new file mode 100644 index 00000000..4bb9ef62 --- /dev/null +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -0,0 +1,202 @@ +using System; +using System.Linq; +using AcDream.App.UI; + +namespace AcDream.App.UI.Layout; + +/// +/// Hybrid factory: behavioral element Types map to dedicated widgets (verbatim +/// algorithm ports); everything else (and unknown Types) falls back to +/// . +/// +/// +/// Type 12 = UIElement_Text — a scrollable colored-line text view. Every Type-12 +/// element is now built as a . Elements that carry their own +/// dat sprite media keep it as the . Pure +/// prototype elements (no state media, no controller binding) draw nothing because +/// defaults to transparent. +/// +/// +/// +/// The meter's back/front 3-slice sprite ids live on grandchild image elements, +/// NOT on the meter element itself (format doc §11). +/// walks two layers down to extract them: the two Type-3 container children +/// ordered by (back behind = lower, front +/// on top = higher), then within each container the image children that carry +/// a DirectState ("" key) sprite, ordered by their X position to obtain +/// left-cap / center-tile / right-cap. +/// +/// +/// +/// The expand-detail overlay present in the front container carries ONLY named +/// states ("HideDetail"/"ShowDetail") — no "" DirectState entry — so the +/// TryGetValue("") filter in excludes it +/// automatically. +/// +/// +public static class DatWidgetFactory +{ + /// + /// Creates the for , sets its + /// rect (Left/Top/Width/Height) and Anchors, and returns it. + /// + /// Resolved, merged element snapshot from the LayoutDesc importer. + /// RenderSurface id → (GL tex handle, pixel width, pixel height). + /// Returns (0,0,0) when the texture is not yet uploaded. + /// Retail UI font for the meter's "cur/max" number overlay. + /// May be null pre-load — the meter falls back to the debug bitmap font. + /// The widget for this element. Never null — every type produces a widget. + public static UiElement? Create(ElementInfo info, + Func resolve, UiDatFont? datFont) + { + // Retail Type 3 = UIElement_Field (reg :126190), but in acdream's CURRENT layouts + // (vitals 0x2100006C / chat 0x21000006) Type-3 elements are sprite-bearing chrome + + // containers (the 8-piece bevel corners/edges, the transcript/input panels), NOT + // editable fields — retail draws those as inert media-bearing Fields, which our + // UiDatElement reproduces pixel-for-pixel (and without the spurious focus/edit + // affordance a UiField would add). The one true editable field, the chat input + // (0x10000016), resolves to Type 12 and is controller-placed as a UiField. So Type 3 + // stays on the generic fallback here; register it as UiField only when a window + // actually carries a factory-built editable Type-3 field (and UiField grows a + // background-media draw + an opt-in editable flag at that point). UiField (the widget) + // still ships — it just isn't wired into the factory switch yet. + UiElement e = info.Type switch + { + 1 => new UiButton(info, resolve), // UIElement_Button (reg :125828) + 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 ──────────────────────────────────────────────────────────────── + + /// + /// Builds a and populates its six 3-slice sprite ids by + /// reading the meter's grandchild image elements (format doc §11). + /// + /// + /// Structure the importer produces for each meter (UIElement_Meter): + /// + /// meter (Type 7) + /// ├── back-layer container (Type 3, lower ReadOrder — drawn first / behind) + /// │ ├── left-cap image (DirectState "" → File = back-left sprite) + /// │ ├── center image (DirectState "" → File = back-tile sprite) + /// │ └── right-cap image (DirectState "" → File = back-right sprite) + /// ├── front-layer container (Type 3, higher ReadOrder — drawn on top) + /// │ ├── left-cap image (→ front-left sprite) + /// │ ├── center image (→ front-tile sprite) + /// │ ├── right-cap image (→ front-right sprite) + /// │ └── expand overlay (named "ShowDetail"/"HideDetail" only — NO DirectState — IGNORED) + /// └── text label (Type 0) (IGNORED — Fill/Label providers bound by VitalsController in Task 6) + /// + /// + /// + /// + /// and are NOT set here. + /// They are bound to the live stat providers in Task 6 (VitalsController). + /// + /// + private static UiMeter BuildMeter(ElementInfo info, + Func resolve, UiDatFont? datFont) + { + var m = new UiMeter + { + SpriteResolve = resolve, + DatFont = datFont, + }; + + // The two 3-slice containers are Type-3 children of the meter element. + // ReadOrder determines draw order: the back track has a LOWER ReadOrder + // (drawn first, behind the fill), the front has a HIGHER ReadOrder (on top). + var containers = info.Children + .Where(c => c.Type == 3) + .OrderBy(c => c.ReadOrder) + .ToList(); + + if (containers.Count != 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; + } + + /// + /// Returns the (left, tile, right) sprite ids for a 3-slice container, + /// extracting them from the container's image children that carry a DirectState + /// ("" key) with a non-zero file id, ordered left-to-right by their X position. + /// + /// + /// Children that carry ONLY named states (e.g. the expand-detail overlay with + /// "ShowDetail"/"HideDetail" entries but no "" key) are excluded automatically + /// because for "" returns + /// false. + /// + /// + private static (uint left, uint tile, uint right) SliceIds(ElementInfo container) + { + // Only children that have a non-zero DirectState image are slice candidates. + // The expand-detail overlay has NO DirectState entry, so it's excluded here. + // 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 ───────────────────────────────────────────────────────────────── + + /// Type-12 UIElement_Text: a scrollable colored-line text view. The element's + /// own Direct/Normal media (if any) becomes the background sprite, drawn under the text — + /// so a Type-12 element that previously rendered via UiDatElement keeps its sprite. Lines + /// are bound later by the controller (LinesProvider). An unbound UiText draws nothing + /// because defaults to transparent. + private static UiText BuildText(ElementInfo info, Func resolve) + { + uint bg = info.StateMedia.TryGetValue( + !string.IsNullOrEmpty(info.DefaultStateName) ? info.DefaultStateName + : info.StateMedia.ContainsKey("Normal") ? "Normal" : "", out var m) + ? m.File : 0u; + return new UiText { BackgroundSprite = bg, SpriteResolve = resolve }; + } +} diff --git a/src/AcDream.App/UI/Layout/ElementReader.cs b/src/AcDream.App/UI/Layout/ElementReader.cs new file mode 100644 index 00000000..93a4eb30 --- /dev/null +++ b/src/AcDream.App/UI/Layout/ElementReader.cs @@ -0,0 +1,170 @@ +using System.Collections.Generic; + +namespace AcDream.App.UI.Layout; + +/// +/// GL-free, dat-free snapshot of a resolved layout element. +/// Populated by the LayoutDesc importer from DatReaderWriter.ElementDesc +/// after inheritance is applied. The pure transforms on +/// operate on this type so they can be unit-tested without the dats or OpenGL. +/// +/// IMPORTANT: Tasks 3–6 depend on this shape exactly. Do not add members without +/// updating the plan spec and downstream consumers. +/// +public sealed class ElementInfo +{ + /// Dat element id (e.g. 0x100000E6). + public uint Id; + + /// + /// Raw element class id as a uint. + /// Game-specific ids like 0x1000004D (gmVitalsUI root) and 0x10000009 + /// overflow int when treated as signed, so this stays uint. + /// Known values: 0=text, 2=dragbar, 3=container/chrome, 7=meter, + /// 9=resize-grip, 12=style-prototype (skip), 0x10000009/0x1000004D=window root. + /// + public uint Type; + + /// Position and size within the parent, in pixels (cast from dat uint fields). + public float X, Y, Width, Height; + + /// + /// Raw edge-anchor flag values from the dat (LeftEdge, TopEdge, + /// RightEdge, BottomEdge fields of ElementDesc). + /// Values 0–4; map to bit-flags via + /// . + /// + public uint Left, Top, Right, Bottom; + + /// Draw order within the parent (lower = drawn first / behind). + public uint ReadOrder; + + /// + /// Font dat object id inherited from the base element's Properties[0x1A] + /// (ArrayBaseProperty → DataIdBaseProperty). 0 = none / not inherited. + /// + public uint FontDid; + + /// + /// Sprite per state: state name → (RenderSurface file id, DrawMode int). + /// The "" key represents the unnamed DirectState (ElementDesc.StateDesc). + /// Named states use the UIStateId.ToString() value as the key + /// (e.g. "HideDetail", "ShowDetail"). + /// + public Dictionary StateMedia = new(); + + /// + /// The element's initial active state name, taken from ElementDesc.DefaultState.ToString(). + /// Normalized to "" when the dat carries Undef/Undefined/0 (no default set). + /// Used by to pick which state's sprite to render initially. + /// Examples: "Normal" (Send button), "Minimized" (max/min button), "" (DirectState). + /// + public string DefaultStateName = ""; + + /// + /// Resolved child elements (populated by the importer in Task 5). + /// Children come from the derived element's own tree, not the base element's. + /// + public List Children = new(); +} + +/// +/// Pure, GL-free, dat-free transforms for the LayoutDesc importer. +/// All methods are static and operate on POCOs. +/// No OpenGL, no DatReaderWriter types, no rendering dependencies beyond +/// the bit-flag enum from AcDream.App.UI. +/// +public static class ElementReader +{ + /// Edge-anchor flags → AnchorEdges, per retail UIElement::UpdateForParentSizeChange + /// @0x00462640. The far-axis fields drive stretch: RightEdge==1 ⇒ the right edge tracks the + /// parent's right edge (stretch); LeftEdge==2 ⇒ a fixed-width element's left tracks the right + /// edge (it moves right). ==4 (not present in the vitals layout) = both-sides stretch; ==3 = + /// centered (no edge anchor → falls back to pin-top-left). This is the INVERSE of the earlier + /// format-doc §4 reading, which was wrong (it made every piece fixed-width). + /// LeftEdge dat field value (0–4). + /// TopEdge dat field value (0–4). + /// RightEdge dat field value (0–4). + /// BottomEdge dat field value (0–4). + public static AnchorEdges ToAnchors(uint left, uint top, uint right, uint bottom) + { + 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; + } + + /// + /// Merges a base element snapshot with a derived element snapshot, mirroring + /// the BaseElement / BaseLayoutId inheritance chain in the dat. + /// + /// + /// Rules: + /// + /// + /// Scalar fields (, , + /// , , + /// ): derived wins if non-zero; otherwise + /// inherited from base. + /// + /// + /// Position (, ) and + /// edge flags ( etc.) and + /// : always taken from the derived element + /// (derived placement, not the base prototype's geometry). + /// + /// + /// : base entries are the default; derived + /// entries override (or add) per state name key. + /// + /// + /// : come from the derived element's own tree only. + /// + /// + /// + /// + public static ElementInfo Merge(ElementInfo base_, ElementInfo derived) + { + var m = new ElementInfo + { + Id = derived.Id != 0 ? derived.Id : base_.Id, + // Type: derived 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(derived.Children), + }; + // Start with base StateMedia as defaults, then let derived entries override. + m.StateMedia = new Dictionary(base_.StateMedia); + foreach (var kv in derived.StateMedia) + m.StateMedia[kv.Key] = kv.Value; + return m; + } +} diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs new file mode 100644 index 00000000..0db0f61d --- /dev/null +++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs @@ -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; + +/// +/// The result of importing a retail LayoutDesc: a tree with +/// an O(1) lookup table for finding any element by its dat id. +/// +public sealed class ImportedLayout +{ + /// Root widget of the imported tree. + public UiElement Root { get; } + + private readonly Dictionary _byId; + + public ImportedLayout(UiElement root, Dictionary byId) + { + Root = root; + _byId = byId; + } + + /// Find a widget by its dat element id (e.g. 0x100000E6). + /// Returns null if the id was skipped (Type-12 prototype) or not present. + public UiElement? FindElement(uint id) + => _byId.TryGetValue(id, out var e) ? e : null; +} + +/// +/// Two-layer layout importer for retail LayoutDesc dat objects. +/// +/// +/// Pure layer ( / ): +/// converts a pre-resolved tree into a +/// tree via . Testable without dats or OpenGL — all tests +/// in LayoutImporterTests.cs exercise this layer only. +/// +/// +/// +/// Dat shell (): reads a , +/// converts each top-level to a fully resolved +/// (applying BaseElement / BaseLayoutId +/// inheritance with a cycle guard), then delegates to . +/// +/// +/// +/// Meter elements (Type 7) consume their own dat-children: +/// reads the grandchild slice-sprite ids during construction, so the +/// children must NOT be added as separate nodes in the tree. +/// Every other element type recurses its children generically. +/// +/// +public static class LayoutImporter +{ + // ── Pure layer ──────────────────────────────────────────────────────────── + + /// + /// Convenience for tests: attach to + /// , then call . + /// The children list is set directly on ; + /// any existing children are replaced. + /// + public static ImportedLayout BuildFromInfos( + ElementInfo rootInfo, + IEnumerable children, + Func resolve, + UiDatFont? datFont) + { + rootInfo.Children = new List(children); + return Build(rootInfo, resolve, datFont); + } + + /// + /// Pure builder: produce the widget tree from a fully resolved + /// tree (children already attached). + /// + public static ImportedLayout Build( + ElementInfo rootInfo, + Func resolve, + UiDatFont? datFont) + { + var byId = new Dictionary(); + // Root is never a Type-12 prototype in practice; fall back to a generic + // container if the factory returns null for an exotic root type. + var root = BuildWidget(rootInfo, resolve, datFont, byId); + 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 resolve, + UiDatFont? datFont, + Dictionary byId) + { + var w = DatWidgetFactory.Create(info, resolve, datFont); + if (w is null) return null; // Type-12 style prototype — skip + + if (info.Id != 0) byId[info.Id] = w; + + // 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 ───────────────────────────────────────────────────────────── + + /// + /// 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. + /// + /// The dat collection to read the LayoutDesc from. + /// The LayoutDesc dat id to read. + public static ElementInfo? ImportInfos(DatCollection dats, uint layoutId) + { + var ld = dats.Get(layoutId); + if (ld is null) return null; + + var tops = new List(); + foreach (var kv in ld.Elements) + tops.Add(Resolve(dats, kv.Value, new HashSet<(uint, uint)>())); + + return tops.Count == 1 + ? tops[0] + : new ElementInfo { Id = 0, Type = 3, Children = tops }; + } + + /// + /// Dat shell: load the LayoutDesc, resolve inheritance for every top-level + /// element, and build the widget tree. Returns null if the layout is absent + /// from the dats. + /// + public static ImportedLayout? Import( + DatCollection dats, + uint layoutId, + Func resolve, + UiDatFont? datFont) + { + var rootInfo = ImportInfos(dats, layoutId); + if (rootInfo is null) return null; + return Build(rootInfo, resolve, datFont); + } + + // ── Inheritance resolution ──────────────────────────────────────────────── + + /// + /// Converts an to a resolved : + /// reads own fields + media, applies the BaseElement / BaseLayoutId chain + /// (cycle-guarded by ), then resolves + attaches children. + /// + private static ElementInfo Resolve( + DatCollection dats, + ElementDesc d, + HashSet<(uint layoutId, uint elementId)> baseChain) + { + // Read this element's own fields + media (no inheritance, no children yet). + var self = ToInfo(d); + var result = self; + + // Apply BaseElement / BaseLayoutId inheritance if present. + if (d.BaseElement != 0 && d.BaseLayoutId != 0 + && baseChain.Add((d.BaseLayoutId, d.BaseElement))) + { + var baseLd = dats.Get(d.BaseLayoutId); + var baseDesc = baseLd is null ? null : FindDesc(baseLd, d.BaseElement); + if (baseDesc is not null) + { + // Recurse the base chain (already guarded by the HashSet add above). + var baseInfo = Resolve(dats, baseDesc, baseChain); + // Derived fields override the base; result.Children is still empty here + // — children are attached below from the DERIVED element's own tree. + result = ElementReader.Merge(baseInfo, self); + } + } + + // Resolve + attach children. Each child gets a FRESH base-chain set: + // the cycle guard is per-element, not shared across siblings. + foreach (var kv in d.Children) + result.Children.Add(Resolve(dats, kv.Value, new HashSet<(uint, uint)>())); + + return result; + } + + /// + /// Read an 's own scalar fields + state media into a + /// fresh . No inheritance is applied; children are not + /// attached (the caller handles those). + /// + private static ElementInfo ToInfo(ElementDesc d) + { + // 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; + } + + /// + /// Read the first from into + /// info.StateMedia[name] and extract the font DID from property 0x1A + /// (ArrayBaseProperty → DataIdBaseProperty) if not yet set. + /// + private static void ReadState(StateDesc sd, string name, ElementInfo info) + { + // 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 ─────────────────────────────────────────────────── + + /// + /// Find an by id anywhere in the top-level tree of + /// (depth-first). Returns null if not found. + /// + private static ElementDesc? FindDesc(LayoutDesc ld, uint id) + { + foreach (var kv in ld.Elements) + { + var f = FindDescIn(kv.Value, id); + if (f is not null) return f; + } + return null; + } + + private static ElementDesc? FindDescIn(ElementDesc d, uint id) + { + if (d.ElementId == id) return d; + foreach (var kv in d.Children) + { + var f = FindDescIn(kv.Value, id); + if (f is not null) return f; + } + return null; + } +} diff --git a/src/AcDream.App/UI/Layout/UiDatElement.cs b/src/AcDream.App/UI/Layout/UiDatElement.cs new file mode 100644 index 00000000..5f6ea79c --- /dev/null +++ b/src/AcDream.App/UI/Layout/UiDatElement.cs @@ -0,0 +1,122 @@ +using System; +using System.Numerics; + +namespace AcDream.App.UI.Layout; + +/// +/// Generic dat element: draws its active state's media by DrawMode (Normal=tile, +/// Alphablend/Overlay=blended overlay). The fallback renderer for every element type +/// without a dedicated behavioral widget (chrome corners/edges, drag bars, resize grips); +/// faithful because retail's base element render is exactly "stamp the media per draw-mode". +/// +/// +/// For Plan 1, all observed draw modes produce the same alpha-blended tiled quad — the +/// sprite shader already alpha-blends, so no per-mode branch is needed here. The named +/// constants document the real enum for Plan 2. +/// +/// +/// +/// DrawModeType (DatReaderWriter.Enums), stored as int in to +/// keep this dat-free. See docs/research/2026-06-15-layoutdesc-format.md §6: +/// Undefined=0, Normal=1, Overlay=2, Alphablend=3. There is no Stretch mode. +/// +/// +/// +/// Tiling uses UV-repeat on BOTH axes (Width/tw, Height/th) so vertical +/// chrome edges (e.g. a 5×10 sprite drawn over a 5×48 rect) tile vertically too. +/// sets +/// GL_REPEAT on both S and T, so vertical tiling is always active. +/// +/// +public sealed class UiDatElement : UiElement +{ + // DrawModeType enum values from DatReaderWriter.Enums. + // See docs/research/2026-06-15-layoutdesc-format.md §6. +#pragma warning disable IDE0051 // private constants kept for documentation / Plan 2 + private const int DrawUndefined = 0; + private const int DrawNormal = 1; + private const int DrawOverlay = 2; + private const int DrawAlphablend = 3; +#pragma warning restore IDE0051 + + private readonly ElementInfo _info; + private readonly Func _resolve; + + /// Which state name to render. "" = the unnamed DirectState. + /// Falls back to DirectState if the named state is absent. + public string ActiveState { get; set; } = ""; + + /// Merged for this element. + /// Dat file-id → (GL texture handle, native px width, native px height). + /// Returns (0,0,0) when the texture is not yet uploaded. + public UiDatElement(ElementInfo info, Func resolve) + { + _info = info; + _resolve = resolve; + ClickThrough = true; // generic decoration; behavioral widgets opt back in + + // 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) + } + + /// + /// Returns the (File, DrawMode) for the current , + /// falling back to the DirectState ("" key) if the named state is absent. + /// Returns (0, 0) if neither exists. + /// + // 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); + + /// Optional click handler. Set by a controller for interactive dat + /// elements (e.g. the chat Send / max-min buttons). Requires + /// = false to receive click events. + public Action? OnClick { get; set; } + + public override bool OnEvent(in UiEvent e) + { + if (e.Type == UiEventType.Click && OnClick is not null) { OnClick(); return true; } + return false; + } + + /// Optional centered text label drawn over the sprite (e.g. the "Send" + /// button face whose dat sprite is a blank frame). Null = sprite only. + public string? Label { get; set; } + /// Dat font for . Required for the label to draw. + public UiDatFont? LabelFont { get; set; } + /// Label color (default white). + public Vector4 LabelColor { get; set; } = Vector4.One; + + protected override void OnDraw(UiRenderContext ctx) + { + var (file, _) = ActiveMedia(); + if (file != 0) + { + 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); + } + } +} diff --git a/src/AcDream.App/UI/Layout/VitalsController.cs b/src/AcDream.App/UI/Layout/VitalsController.cs new file mode 100644 index 00000000..39f2f396 --- /dev/null +++ b/src/AcDream.App/UI/Layout/VitalsController.cs @@ -0,0 +1,98 @@ +using System; +using System.Numerics; +using AcDream.App.UI; + +namespace AcDream.App.UI.Layout; + +/// +/// Per-window controller for the vitals layout (LayoutDesc 0x2100006C). +/// Mirrors retail gmVitalsUI::PostInit: grab the three meter elements +/// by their dat element ids and bind live data providers (fill fraction + cur/max +/// text) to each. This is the ONLY per-window code in the whole importer — pure +/// data wiring, not graphics. +/// +/// The slice sprites + dat font on each are already +/// set by during tree construction; this controller +/// only binds the dynamic vitals data. Do not touch meter rendering fields here. +/// +/// Element ids confirmed from +/// docs/research/2026-06-15-layoutdesc-format.md §11 +/// (vitals window 0x2100006C dump). +/// +public static class VitalsController +{ + /// Dat element id for the Health meter (0x100000E6). + public const uint Health = 0x100000E6; + /// Dat element id for the Stamina meter (0x100000EC). + public const uint Stamina = 0x100000EC; + /// Dat element id for the Mana meter (0x100000EE). + public const uint Mana = 0x100000EE; + + /// + /// Bind live vitals data providers to the Health, Stamina, and Mana meter + /// elements found in . Any meter whose id is absent + /// from the layout is silently skipped — partial layouts (e.g. test fakes) + /// do not cause errors. + /// + /// Imported vitals layout tree. + /// Provider returning Health fill fraction [0..1]. + /// Provider returning Stamina fill fraction [0..1]. + /// Provider returning Mana fill fraction [0..1]. + /// Provider returning Health "cur/max" overlay text. + /// Provider returning Stamina "cur/max" overlay text. + /// Provider returning Mana "cur/max" overlay text. + public static void Bind( + ImportedLayout layout, + Func healthPct, + Func staminaPct, + Func manaPct, + Func healthText, + Func staminaText, + Func manaText) + { + BindMeter(layout, Health, healthPct, healthText); + BindMeter(layout, Stamina, staminaPct, staminaText); + BindMeter(layout, Mana, manaPct, manaText); + } + + /// White cur/max numbers — matches the former UiMeter.LabelColor default. + private static readonly Vector4 NumberColor = new(1f, 1f, 1f, 1f); + + private static void BindMeter( + ImportedLayout layout, uint id, + Func pct, + Func text) + { + // 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() + : new[] { new UiText.Line(s, NumberColor) }; + }, + }; + m.AddChild(number); + } +} diff --git a/src/AcDream.App/UI/MarkupDocument.cs b/src/AcDream.App/UI/MarkupDocument.cs new file mode 100644 index 00000000..1132479b --- /dev/null +++ b/src/AcDream.App/UI/MarkupDocument.cs @@ -0,0 +1,159 @@ +using System; +using System.Globalization; +using System.Numerics; +using System.Reflection; +using System.Xml.Linq; + +namespace AcDream.App.UI; + +/// +/// Parses our KSML-style panel markup (mirrors retail's ElementDesc fields) +/// into a live subtree. {Binding} attribute +/// values resolve against a supplied object by property name (reflection). +/// This is the format the future LayoutDesc importer will emit. See D.2b spec §7. +/// +public static class MarkupDocument +{ + /// Raw XML markup for a single panel. + /// Object whose public properties are bound to {PropName} attributes. + /// Surface id → (GL handle, width, height) for chrome sprites. + /// Optional controls.ini stylesheet for the title color. + public static UiNineSlicePanel Build( + string xml, object binding, Func resolve, + ControlsIni? style = null) + { + var root = XDocument.Parse(xml).Root ?? throw new FormatException("empty markup"); + if (root.Name.LocalName != "panel") + throw new FormatException($"root must be , got <{root.Name.LocalName}>"); + + var panel = new UiNineSlicePanel(resolve) + { + Left = F(root, "x"), + Top = F(root, "y"), + Width = F(root, "w"), + Height = F(root, "h"), + }; + + // 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; + + /// + /// Parses #AARRGGBB → RGBA (alpha first, matching + /// controls.ini convention). Falls back to opaque white on bad input. + /// + private static Vector4 Color(string? hex) + { + if (hex is { Length: 9 } && hex[0] == '#' + && uint.TryParse(hex.AsSpan(1), NumberStyles.HexNumber, + CultureInfo.InvariantCulture, out uint argb)) + return new Vector4( + ((argb >> 16) & 0xFF) / 255f, + ((argb >> 8) & 0xFF) / 255f, + (argb & 0xFF) / 255f, + ((argb >> 24) & 0xFF) / 255f); + return Vector4.One; + } + + private static Func BindFloat(string? expr, object binding) + { + var pi = Prop(expr, binding); + if (pi is null) return () => 0f; + return () => pi.GetValue(binding) switch + { + float f => f, + null => (float?)null, + var v => Convert.ToSingle(v, CultureInfo.InvariantCulture), + }; + } + + private static Func BindUint(string? expr, object binding) + { + var pi = Prop(expr, binding); + if (pi is null) return () => null; + return () => pi.GetValue(binding) switch + { + uint u => u, + null => (uint?)null, + var v => Convert.ToUInt32(v, CultureInfo.InvariantCulture), + }; + } + + private static PropertyInfo? Prop(string? expr, object binding) + { + if (expr is null || expr.Length < 3 || expr[0] != '{' || expr[^1] != '}') return null; + return binding.GetType().GetProperty(expr[1..^1]); + } + + 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; + } +} diff --git a/src/AcDream.App/UI/RetailChromeSprites.cs b/src/AcDream.App/UI/RetailChromeSprites.cs new file mode 100644 index 00000000..f2a80fd7 --- /dev/null +++ b/src/AcDream.App/UI/RetailChromeSprites.cs @@ -0,0 +1,66 @@ +namespace AcDream.App.UI; + +/// +/// Retail window-chrome RenderSurface DataIds, CONFIRMED via the D.2b Step-0 +/// prove-out (2026-06-14). These are RenderSurface objects (0x06xxxxxx) decoded +/// DIRECTLY (), NOT +/// through the Surface→SurfaceTexture chain. +/// +/// +/// The universal floating-window bevel is an 8-piece border (4 corners +/// 5×5 + 4 edges) drawn around a tiled center fill — it is NOT a single +/// 9-slice texture. Decoded sizes are in the comments (from the prove-out). +/// +/// +/// +/// The edge/corner → position mapping below is a reasonable guess pending the +/// LayoutDesc 0x21000040 parse (sub-project 3) and is confirmed visually in the +/// first vitals-panel render. If a corner's bevel highlight looks wrong, swap +/// the four corner constants; if top/bottom or left/right look inverted, swap +/// those edge pairs. +/// +/// +public static class RetailChromeSprites +{ + /// Tiled interior fill — the shared panel background (48×48). + public const uint CenterFill = 0x06004CC2; + + /// Horizontal top edge (10×5, tiled across the top span). + public const uint TopEdge = 0x060074BF; + /// Horizontal bottom edge (10×5). + public const uint BottomEdge = 0x060074C1; + /// Vertical left edge (5×10). + public const uint LeftEdge = 0x060074C0; + /// Vertical right edge (5×10). + public const uint RightEdge = 0x060074C2; + + /// Top-left corner (5×5). + public const uint CornerTL = 0x060074C3; + /// Top-right corner (5×5). + public const uint CornerTR = 0x060074C4; + /// Bottom-left corner (5×5). + public const uint CornerBL = 0x060074C5; + /// Bottom-right corner (5×5). + public const uint CornerBR = 0x060074C6; + + /// Border thickness in pixels = the corner/edge sprite size (5px). + public const int Border = 5; + + // ── Resize-grip overlay ────────────────────────────────────────────── + // A second 8-piece layer drawn ON TOP of the bevel above: the gold ridged + // accents + square corner studs that frame a resizable retail window. From + // the vitals LayoutDesc 0x2100006C (elements 0x1000063B–0x10000642): each + // corner is the same 5×5 stud (0x06006129); the edges are gold double-line + // strips tiled along each side. These have transparent gaps, so the bevel + // shows through — both layers are needed. + /// Corner grip stud, all four corners (5×5). + public const uint GripCorner = 0x06006129; + /// Top edge grip (10×5, tiled across). + public const uint GripTop = 0x0600612A; + /// Left edge grip (5×10, tiled down). + public const uint GripLeft = 0x0600612B; + /// Bottom edge grip (10×5). + public const uint GripBottom = 0x0600612C; + /// Right edge grip (5×10). + public const uint GripRight = 0x0600612D; +} diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs new file mode 100644 index 00000000..6c31797d --- /dev/null +++ b/src/AcDream.App/UI/UiButton.cs @@ -0,0 +1,115 @@ +using System; +using System.Numerics; +using AcDream.App.UI.Layout; + +namespace AcDream.App.UI; + +/// +/// Generic dat-widget button — the production replacement for any dat element of +/// Type 1 (UIElement_Button, registered via RegisterElementClass(1, UIElement_Button::Create) +/// @ acclient_2013_pseudo_c.txt:125828). +/// +/// +/// Draws per-state sprite media exactly like (same +/// ActiveState defaulting, same ActiveMedia() fallback chain, same tiled +/// DrawSprite call with UV-repeat so chrome edges tile correctly) plus an +/// optional centered text label. The click behavior mirrors +/// one-for-one so the chat Send and Max/Min buttons that previously bound through +/// UiDatElement.OnClick continue to work without behavioral change. +/// +/// +/// +/// State selection: picks if set, then +/// "Normal" if the element has a Normal state sprite, then falls back to the unnamed +/// DirectState ("" key) — identical to . +/// +/// +/// +/// Built by for Type-1 elements (chat Send 0x10000019, +/// Max/Min 0x1000046F). NOT the same as , which is an +/// earlier dev-scaffold widget with no dat sprites. +/// +/// +public sealed class UiButton : UiElement +{ + private readonly ElementInfo _info; + private readonly Func _resolve; + + /// Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize). + public Action? OnClick { get; set; } + + /// Optional centered text label drawn over the sprite (e.g. "Send" on a blank gold frame). + public string? Label { get; set; } + + /// Dat font for . Required for the label to draw. + public UiDatFont? LabelFont { get; set; } + + /// Label color (default white). + public Vector4 LabelColor { get; set; } = Vector4.One; + + /// + /// Active state name, runtime-settable (e.g. Max/Min toggling Normal ↔ Minimized). + /// Matches . + /// + public string ActiveState { get; set; } = ""; + + /// Merged for this element. + /// Dat file-id → (GL texture handle, native px width, native px height). + /// Returns (0,0,0) when the texture is not yet uploaded. + public UiButton(ElementInfo info, Func resolve) + { + _info = info; + _resolve = resolve; + ClickThrough = false; // buttons are interactive — opt OUT of click-through + + // State defaulting matches UiDatElement exactly: + // DefaultStateName wins; else "Normal" if that state has a sprite; else DirectState (""). + if (!string.IsNullOrEmpty(info.DefaultStateName)) + ActiveState = info.DefaultStateName; + else if (info.StateMedia.ContainsKey("Normal")) + ActiveState = "Normal"; + // else ActiveState stays "" (DirectState) + } + + /// The button draws its own face + label; any dat label child is reproduced + /// procedurally, so the importer must not build the button's children as widgets. + public override bool ConsumesDatChildren => true; + + /// + /// Returns the File id for the current , falling back to + /// the DirectState ("" key) if the named state is absent. + /// Returns 0 if neither exists. + /// Mirrors . + /// + private uint ActiveFile() + => _info.StateMedia.TryGetValue(ActiveState, out var m) ? m.File + : _info.StateMedia.TryGetValue("", out var d) ? d.File : 0u; + + protected override void OnDraw(UiRenderContext ctx) + { + uint file = ActiveFile(); + if (file != 0) + { + var (tex, tw, th) = _resolve(file); + if (tex != 0 && tw != 0 && th != 0) + { + // Tiled draw — same call shape as UiDatElement.OnDraw (UV-repeat; GL_REPEAT-wrapped + // UI texture). Matches ImgTex::TileCSI; no Stretch mode exists. + ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One); + } + } + + if (Label is { Length: > 0 } label && LabelFont is { } lf) + { + float tx = (Width - lf.MeasureWidth(label)) * 0.5f; + float ty = (Height - lf.LineHeight) * 0.5f; + ctx.DrawStringDat(lf, label, tx, ty, LabelColor); + } + } + + public override bool OnEvent(in UiEvent e) + { + if (e.Type == UiEventType.Click && OnClick is not null) { OnClick(); return true; } + return false; + } +} diff --git a/src/AcDream.App/UI/UiDatFont.cs b/src/AcDream.App/UI/UiDatFont.cs new file mode 100644 index 00000000..400ccf0f --- /dev/null +++ b/src/AcDream.App/UI/UiDatFont.cs @@ -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; + +/// +/// A retail dat-font (DB_TYPE_FONT, id range 0x40000000-0x40000FFF) ready for +/// 2D drawing. Holds the two GL atlas textures (foreground glyph pixels + +/// background outline/shadow), the per-glyph descriptor table, and the line +/// metrics, so can blit each glyph +/// as two textured quads exactly the way the retail client does. +/// +/// +/// Retail render model — SurfaceWindow::DrawCharacter +/// (acclient 0x00442bd0, Font::GetCharDesc + the two SurfaceWindow blits): for +/// each glyph it copies the BACKGROUND atlas sub-rect first, tinted with the +/// outline color (black), then the FOREGROUND atlas sub-rect, tinted with the +/// requested text color. The pen advances by +/// HorizontalOffsetBefore + Width + HorizontalOffsetAfter (the function's +/// return value, accumulated by the string loop at 0x00467ed4 +/// edi_3 += var_98), and each glyph is drawn starting at +/// penX + HorizontalOffsetBefore. +/// +/// +/// +/// Atlas format: the foreground atlas (0x06005EE5 for Font 0x40000000) is +/// PFID_A8 — alpha-only. Our SurfaceDecoder expands A8 to RGBA as +/// (255,255,255, alpha). The UI sprite shader path (ui_text.frag, +/// uUseTexture==2) MULTIPLIES the sampled texel by the per-vertex tint +/// (texture(uTex,vUv) * vColor), so tinting a white+alpha glyph by a +/// color gives that color with the glyph's alpha — black for the outline pass, +/// text color for the fill pass. No shader change was needed. +/// +/// +public sealed class UiDatFont +{ + /// Retail UI font id (Latin-1, 16x16 max, with outline atlas). + public const uint DefaultFontId = 0x40000000u; + + /// Foreground (glyph pixels) GL texture handle + atlas pixel size. + public uint ForegroundTexture { get; } + public int ForegroundWidth { get; } + public int ForegroundHeight { get; } + + /// Background (outline/shadow) GL texture handle + atlas pixel size. + /// 0 when the font has no background atlas (then the outline pass is skipped). + public uint BackgroundTexture { get; } + public int BackgroundWidth { get; } + public int BackgroundHeight { get; } + + /// Vertical advance between lines (retail MaxCharHeight). + public float LineHeight { get; } + + /// Distance from a line's top to its baseline (retail BaselineOffset). + public float BaselineOffset { get; } + + private readonly Dictionary _glyphs; + + private UiDatFont( + uint fgTex, int fgW, int fgH, + uint bgTex, int bgW, int bgH, + float lineHeight, float baselineOffset, + Dictionary glyphs) + { + ForegroundTexture = fgTex; ForegroundWidth = fgW; ForegroundHeight = fgH; + BackgroundTexture = bgTex; BackgroundWidth = bgW; BackgroundHeight = bgH; + LineHeight = lineHeight; + BaselineOffset = baselineOffset; + _glyphs = glyphs; + } + + /// True if this font carries a separate outline/shadow atlas + /// (retail's m_pBackgroundSurface). When false the outline pass is + /// skipped and only the foreground (fill) glyphs are drawn. + public bool HasBackground => BackgroundTexture != 0; + + /// Look up a glyph descriptor for a character. Returns false for + /// characters not present in the font's table (callers skip them). + public bool TryGetGlyph(char c, out FontCharDesc glyph) => _glyphs.TryGetValue(c, out glyph!); + + /// + /// Load Font from the dat collection and upload + /// both atlases through the texture cache (the same direct-RenderSurface + /// path the D.2b chrome sprites use). Returns null if the Font DBObj is + /// missing — callers fall back to the debug bitmap font. + /// + public static UiDatFont? Load(DatCollection dats, TextureCache cache, uint fontId = DefaultFontId) + { + ArgumentNullException.ThrowIfNull(dats); + ArgumentNullException.ThrowIfNull(cache); + + if (!dats.TryGet(fontId, out var font) || font is null) + return null; + + // Foreground atlas is required; without it there are no glyph pixels. + if (font.ForegroundSurfaceDataId == 0) + return null; + + // 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(font.CharDescs.Count); + foreach (var cd in font.CharDescs) + glyphs[(char)cd.Unicode] = cd; + + return new UiDatFont( + fgTex, fgW, fgH, + bgTex, bgW, bgH, + lineHeight: font.MaxCharHeight, + baselineOffset: font.BaselineOffset, + glyphs); + } + + /// + /// Total pen advance (in pixels) for , summing each + /// glyph's retail advance. Characters not in the font contribute nothing. + /// + public float MeasureWidth(string text) + => MeasureWidth(text, c => _glyphs.TryGetValue(c, out var g) ? g : null); + + /// + /// Pure pen-advance summation seam: total width of + /// given a that maps each char to its descriptor + /// (null = not in the font → contributes nothing). Lets the advance math be + /// unit-tested with synthetic glyphs, with no GL or dat dependency. + /// + public static float MeasureWidth(string? text, Func lookup) + { + ArgumentNullException.ThrowIfNull(lookup); + if (string.IsNullOrEmpty(text)) return 0f; + float w = 0f; + for (int i = 0; i < text.Length; i++) + if (lookup(text[i]) is { } g) + w += GlyphAdvance(g); + return w; + } + + /// + /// The retail per-glyph horizontal advance: + /// HorizontalOffsetBefore + Width + HorizontalOffsetAfter. This is the + /// value SurfaceWindow::DrawCharacter returns for proportional text + /// (flag bit 0x10 set, acclient 0x00442c3a) and the string loop accumulates + /// into the pen. Pulled out as a pure static so the math is unit-testable + /// without GL or the dat. + /// + public static float GlyphAdvance(FontCharDesc g) + => g.HorizontalOffsetBefore + g.Width + g.HorizontalOffsetAfter; +} diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index ae9a0a7c..7e1df4ad 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -4,6 +4,11 @@ using System.Numerics; namespace AcDream.App.UI; +/// Which parent edges a child keeps a fixed margin to on resize. +/// Left+Right ⇒ width stretches; Top+Bottom ⇒ height stretches. +[System.Flags] +public enum AnchorEdges { None = 0, Left = 1, Top = 2, Right = 4, Bottom = 8 } + /// /// Base class for every UI widget in the retained-mode tree. /// @@ -88,6 +93,39 @@ public abstract class UiElement /// Painter's-algorithm z-order within siblings. Higher = on top. public int ZOrder { get; set; } + /// Window opacity (0..1) multiplied into this element's and its + /// descendants' background + sprite draws (text stays opaque). 1 = fully opaque. + /// Set on a top-level window (e.g. the chat frame) for retail's translucent chat. + public float Opacity { get; set; } = 1f; + + /// If true, a left-drag on this element (or a non-draggable child of + /// it) repositions it as a movable window. Intended for top-level panels, + /// whose Left/Top are screen coordinates (Root sits at the origin). + public bool Draggable { get; set; } + + /// If true, a left-drag starting near this element's edge/corner + /// resizes it (window resize). Intended for top-level panels. + public bool Resizable { get; set; } + + /// If true, a left-drag starting on this element is delivered to the + /// element (e.g. text selection) instead of moving/resizing an ancestor window. + /// Edge resize on a resizable ancestor still wins — only the interior move / + /// drag-drop candidacy is suppressed in favour of the element's own handling. + public bool CapturesPointerDrag { get; set; } + + /// Minimum size enforced while resizing. + public float MinWidth { get; set; } = 40f; + public float MinHeight { get; set; } = 40f; + + /// Allow horizontal (width) resize. Ignored unless . + public bool ResizeX { get; set; } = true; + /// Allow vertical (height) resize. Ignored unless . + public bool ResizeY { get; set; } = true; + + /// Edges this element anchors to in its parent. Default Left|Top + /// (pinned top-left, fixed size — no reflow). Left|Right stretches width. + public AnchorEdges Anchors { get; set; } = AnchorEdges.Left | AnchorEdges.Top; + // ── Tree structure ────────────────────────────────────────────────── public UiElement? Parent { get; private set; } @@ -108,6 +146,19 @@ public abstract class UiElement return true; } + /// + /// True if this widget draws its full appearance itself and REPRODUCES its dat + /// sub-elements procedurally (3-slice caps, button labels, scroll arrows, popup + /// rows…) — so the must NOT build + /// those dat child elements as separate widgets (they would double-draw and, worse, + /// steal pointer/focus from the behavioral widget). All registered behavioral widgets + /// (Meter/Menu/Button/Scrollbar/Text/Field) return true; the generic container + /// () and panels return false + /// and recurse their children normally. Mirrors retail, where each + /// UIElement_X::DrawSelf owns its internal structure. + /// + public virtual bool ConsumesDatChildren => false; + // ── Virtual overrides ─────────────────────────────────────────────── /// @@ -116,6 +167,16 @@ public abstract class UiElement /// protected virtual void OnDraw(UiRenderContext ctx) { } + /// + /// Draw content that must sit ON TOP of the ENTIRE UI, regardless of this + /// element's position in the tree — open menus, dropdowns, tooltips. Called in + /// a SECOND traversal after the whole tree's pass, with the + /// same accumulated transform/alpha this element had during its normal draw. + /// Retail spawns popups as ROOT elements (UIElement_Menu::MakePopup) for exactly + /// this reason; this is the equivalent without reparenting. Default: nothing. + /// + protected virtual void OnDrawOverlay(UiRenderContext ctx) { } + /// Per-frame tick (animations, timers, caret blink). protected virtual void OnTick(double deltaSeconds) { } @@ -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(); + } + } + + /// Second draw traversal: re-walks the tree applying the same + /// transform/alpha as and calls + /// on each element, so popups composite on top of + /// everything drawn in the main pass (dat-font glyphs and sprites share one + /// submission-ordered bucket, so later submissions win). + internal void DrawOverlays(UiRenderContext ctx) + { + if (!Visible) return; + ctx.PushTransform(Left, Top); + ctx.PushAlpha(Opacity); + try + { + OnDrawOverlay(ctx); + if (_children.Count > 0) + { + var ordered = _children.ToArray(); + Array.Sort(ordered, static (a, b) => a.ZOrder.CompareTo(b.ZOrder)); + for (int i = 0; i < ordered.Length; i++) + ordered[i].DrawOverlays(ctx); + } + } + finally + { + ctx.PopAlpha(); ctx.PopTransform(); } } @@ -183,9 +279,14 @@ public abstract class UiElement /// internal UiElement? HitTest(float localX, float localY) { - if (!Visible || !Enabled || ClickThrough) return null; + if (!Visible || !Enabled) return null; - // Children first, in reverse Z-order (topmost first). + // Children first, in reverse Z-order (topmost first). ClickThrough means + // THIS element is transparent to the pointer — but its children are NOT. + // A ClickThrough container (e.g. a UiDatElement panel that hosts the chat + // input / transcript) must still let the pointer reach its behavioral + // children, so the ClickThrough check happens AFTER the child walk, gating + // only whether THIS element claims the hit. if (_children.Count > 0) { var ordered = _children.ToArray(); @@ -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; + + /// Reposition/resize this element per , keeping + /// the margins captured (at first layout / design size) to each anchored edge. + /// Called by the parent each frame before drawing children. + internal void ApplyAnchor(float parentW, float parentH) + { + if (Anchors == AnchorEdges.None) return; + if (!_anchorCaptured) + { + _amL = Left; _amT = Top; + _amR = parentW - (Left + Width); + _amB = parentH - (Top + Height); + _aw0 = Width; _ah0 = Height; + _anchorCaptured = true; + } + var (x, y, w, h) = ComputeAnchoredRect(Anchors, _amL, _amT, _amR, _amB, _aw0, _ah0, parentW, parentH); + Left = x; Top = y; Width = w; Height = h; + } + + /// Forget the captured anchor margins so the next + /// re-captures them from the CURRENT rect. Call after manually repositioning/resizing + /// an anchored element at runtime (e.g. reflowing the chat input when the channel + /// button width changes) so the new rect becomes the anchor baseline. + internal void ResetAnchorCapture() => _anchorCaptured = false; + + /// Walk up to the owning (the top of the tree), or null + /// if this element is not attached. Lets a widget reach focus/capture services — e.g. + /// a chat input blurring itself (exiting write mode) after submit. + internal UiRoot? FindRoot() + { + UiElement e = this; + while (e.Parent is not null) e = e.Parent; + return e as UiRoot; + } + + /// Compute an anchored child rect. Left&Right ⇒ stretch width + /// (keep both margins); Right only ⇒ pin to right at fixed width; otherwise + /// pin left at fixed width. Same logic vertically. + public static (float x, float y, float w, float h) ComputeAnchoredRect( + AnchorEdges a, float mL, float mT, float mR, float mB, + float w0, float h0, float parentW, float parentH) + { + bool l = (a & AnchorEdges.Left) != 0, r = (a & AnchorEdges.Right) != 0; + float x, w; + if (l && r) { x = mL; w = parentW - mR - mL; } + else if (r) { w = w0; x = parentW - mR - w0; } + else { x = mL; w = w0; } + + bool t = (a & AnchorEdges.Top) != 0, b = (a & AnchorEdges.Bottom) != 0; + float y, h; + if (t && b) { y = mT; h = parentH - mB - mT; } + else if (b) { h = h0; y = parentH - mB - h0; } + else { y = mT; h = h0; } + + if (w < 0) w = 0; + if (h < 0) h = 0; + return (x, y, w, h); + } } diff --git a/src/AcDream.App/UI/UiField.cs b/src/AcDream.App/UI/UiField.cs new file mode 100644 index 00000000..9bc7ef32 --- /dev/null +++ b/src/AcDream.App/UI/UiField.cs @@ -0,0 +1,420 @@ +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// Generic editable one-line field widget. Port of retail UIElement_Field +/// (RegisterElementClass(3) @ acclient_2013_pseudo_c.txt:126190). Carries +/// retail Field's drag-drop hooks (CatchDroppedItem/MouseOverTop) +/// as stubs for future item-window use. +/// +/// +/// Caret is a glyph index; the caret pixel-X is Σ glyph advances (UiDatFont) to the +/// caret. Supports mouse + Shift-arrow SELECTION, clipboard cut/copy/paste, and +/// held-key auto-repeat (hold Backspace deletes continuously). Submit (Enter / Send) +/// fires , clears, and pushes history (100-entry cap, +/// sentinel 0xFFFFFFFF — port of ChatInterface::ProcessCommand @0x4f5100). +/// +/// +/// Decomp: UIElement_Text MoveCursor @0x468d00, FindPixelsFromPos @0x472b40. +/// +public sealed class 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); + /// Selected-span highlight (translucent blue, behind the text). + public Vector4 SelectionColor { get; set; } = new(0.25f, 0.45f, 0.85f, 0.5f); + public float Padding { get; set; } = 4f; + public int MaxCharacters { get; set; } = 0xFFFF; + + /// Keyboard device for clipboard (Ctrl+C/X/V) + modifier state (Ctrl/Shift). + /// Wired by the host from . + public Silk.NET.Input.IKeyboard? Keyboard { get; set; } + + /// Dat sprite resolver (id → GL texture + size) for the focused-field + /// background. Null = fall back to the flat rect. + public Func? SpriteResolve { get; set; } + /// Gold "lit" field background drawn when focused (retail Normal_focussed + /// state, RenderSurface 0x060011AB). 0 = no focus sprite. + public uint FocusFieldSprite { get; set; } + + public Action? OnSubmit { get; set; } + + private string _text = ""; + private int _caret; + private int? _selAnchor; // selection fixed end (null = no selection); span = [min,max] with _caret + public string Text => _text; + public int CaretPos => _caret; + + private readonly List _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 + } + + /// The field draws its own background + caret + caps; its dat cap sub-elements + /// are reproduced procedurally, so the importer must not build them as widgets. + public override bool ConsumesDatChildren => true; + + // ── Editing primitives ────────────────────────────────────────────── + + public void InsertChar(char c) + { + 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; + } + + /// Move the caret left (negative) or right (positive) by + /// glyph positions without extending a selection. Public for test access. + public void MoveCaret(int delta) => MoveCaretTo(_caret + delta, false); + + private void MoveCaret(int delta, bool shift) => MoveCaretTo(_caret + delta, shift); + + // ── Selection ──────────────────────────────────────────────────────── + + private (int lo, int hi) SelSpan() + { + if (_selAnchor is not { } a || a == _caret) return (_caret, _caret); + return (Math.Min(a, _caret), Math.Max(a, _caret)); + } + + private bool HasSelection => _selAnchor is { } a && a != _caret; + + private string SelectedText() + { + var (lo, hi) = SelSpan(); + return hi > lo ? _text.Substring(lo, hi - lo) : ""; + } + + /// Remove the selected span (if any). Returns true if it removed anything. + private bool DeleteSelection() + { + if (!HasSelection) { _selAnchor = null; return false; } + var (lo, hi) = SelSpan(); + _text = _text.Remove(lo, hi - lo); + _caret = lo; + _selAnchor = null; + return true; + } + + private void SelectAll() + { + if (_text.Length == 0) { _selAnchor = null; return; } + _selAnchor = 0; + _caret = _text.Length; + } + + private void CopySelection() + { + var s = SelectedText(); + if (s.Length > 0 && Keyboard is not null) Keyboard.ClipboardText = s; + } + + private void CutSelection() + { + if (!HasSelection) return; + CopySelection(); + DeleteSelection(); + _historyIndex = -1; + } + + private void Paste() + { + if (Keyboard is null) return; + string clip = Keyboard.ClipboardText ?? ""; + if (clip.Length == 0) return; + + // Single-line field: strip control chars (newlines/tabs) from pasted text. + var sb = new System.Text.StringBuilder(clip.Length); + foreach (char ch in clip) + if (ch >= 0x20 && ch != 0x7F) sb.Append(ch); + if (sb.Length == 0) return; + + DeleteSelection(); + int room = MaxCharacters - _text.Length; + if (room <= 0) return; + string ins = sb.Length > room ? sb.ToString(0, room) : sb.ToString(); + _text = _text.Insert(_caret, ins); + _caret += ins.Length; + _historyIndex = -1; + } + + // ── Submit + history ───────────────────────────────────────────────── + + public void Submit() + { + 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 ───────────────────────────────────────────────────────── + + /// Pixel-X of the caret (Σ glyph advances to ). + private float MeasureTo(int i) + { + if (i <= 0) return 0f; + string s = _text.Substring(0, Math.Min(i, _text.Length)); + return DatFont is { } df ? df.MeasureWidth(s) + : Font is { } bf ? bf.MeasureWidth(s) : 0f; + } + + public float CaretPixelX() => MeasureTo(_caret); + + /// Map a local X (click) to the nearest caret index — retail + /// FindPixelsFromPos inverse. Accounts for the horizontal scroll offset. + private int HitCharX(float localX) + { + float target = localX - Padding + _scrollX; + if (target <= 0f) return 0; + int best = 0; + float bestDist = float.MaxValue; + for (int i = 0; i <= _text.Length; i++) + { + float d = MathF.Abs(MeasureTo(i) - target); + if (d < bestDist) { bestDist = d; best = i; } + } + return best; + } + + // ── Draw ───────────────────────────────────────────────────────────── + + protected override void OnDraw(UiRenderContext ctx) + { + // 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; + } +} diff --git a/src/AcDream.App/UI/UiHost.cs b/src/AcDream.App/UI/UiHost.cs index 5f697cfb..718d5cbd 100644 --- a/src/AcDream.App/UI/UiHost.cs +++ b/src/AcDream.App/UI/UiHost.cs @@ -39,6 +39,13 @@ public sealed class UiHost : System.IDisposable public UiRoot Root { get; } = new(); public TextRenderer TextRenderer { get; } public BitmapFont? DefaultFont { get; set; } + + /// The last wired keyboard. Exposed so widgets that need clipboard + /// access () or modifier-key state + /// () — e.g. 's + /// Ctrl+C copy — can reach the device. One-keyboard desktop: last wins. + public IKeyboard? Keyboard { get; private set; } + private long _startTicks = System.Environment.TickCount64; public UiHost(GL gl, string shaderDir, BitmapFont? defaultFont = null) @@ -82,6 +89,7 @@ public sealed class UiHost : System.IDisposable public void WireKeyboard(IKeyboard kb) { + Keyboard = kb; // last wired keyboard wins (one-keyboard desktop) kb.KeyDown += (_, k, _) => Root.OnKeyDown((int)k); kb.KeyUp += (_, k, _) => Root.OnKeyUp((int)k); kb.KeyChar += (_, c) => Root.OnChar(c); diff --git a/src/AcDream.App/UI/UiMenu.cs b/src/AcDream.App/UI/UiMenu.cs new file mode 100644 index 00000000..c10bd419 --- /dev/null +++ b/src/AcDream.App/UI/UiMenu.cs @@ -0,0 +1,246 @@ +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// Generic dropdown menu. Ports retail UIElement_Menu +/// (RegisterElementClass(6) @ acclient_2013_pseudo_c.txt:120163) + +/// UIElement_Menu::MakePopup @0x46d310: the button is labelled with +/// the active target; clicking opens a column-major popup on the dat-driven menu +/// chrome (panel + per-row + selected-row sprites). Items and all chat-channel +/// knowledge are populated by the controller, not baked into this widget. Built +/// by for Type-6 elements. +/// +public sealed class UiMenu : UiElement +{ + /// One menu row: its label + an opaque payload the controller maps back. + public readonly record struct MenuItem(string Label, object? Payload); + + /// The rows, populated by the controller. Laid out column-major: + /// rows 0..RowsPerColumn-1 in column 0, then the next group in column 1, etc. + public IReadOnlyList Items { get; set; } = System.Array.Empty(); + + /// The currently-selected payload (drives the highlighted row). + public object? Selected { get; set; } + + /// Fired with the picked item's payload when a row is chosen. + public Action? OnSelect { get; set; } + + /// Per-payload enabled gate (disabled rows render greyed + are inert). Null ⇒ all enabled. + public Func? EnabledProvider { get; set; } + + /// Button-face caption (the active target). Null ⇒ blank face. + public Func? ButtonLabelProvider { get; set; } + + public int RowsPerColumn { get; set; } = 7; // items per column (dat item template) + public float RowHeight { get; set; } = 17f; // dat item template 0x1000001E H=17 + public float ColumnWidth { get; set; } = 191f; // dat item template W=191 + + private const int Border = RetailChromeSprites.Border; // 8-piece bevel thickness (5px) + // The row sprites 0x0600124E/4D bake a checkbox/checkmark into the leftmost ~17px + // square; the label starts just past it (box width + small gap) so text aligns with + // the box instead of overlapping it. + private const float TextIndent = 19f; + // The button face sprite (0x06004D65/66) bakes a status LED (red→green) into its + // left socket (~x4–20 of the 46px button); the caption starts past it so it doesn't + // render over the LED. + private const float ButtonTextIndent = 20f; + + public UiDatFont? DatFont { get; set; } + public AcDream.App.Rendering.BitmapFont? Font { get; set; } + public Func? SpriteResolve { get; set; } + + // Button face sprites (dat menu element 0x10000014). + public uint NormalSprite { get; set; } + public uint PressedSprite { get; set; } + // Popup chrome sprites (dat menu popup template, layout 0x21000006). + public uint PopupBgSprite { get; set; } // 0x0600124C — panel fill (191×2 tiles) + public uint ItemNormalSprite { get; set; } // 0x0600124E — a row background (191×17) + public uint ItemHighlightSprite { get; set; } // 0x0600124D — the active channel's row + + public Vector4 TextColor { get; set; } = new(1f, 0.92f, 0.72f, 1f); + /// Available item text — retail white #FFFFFF (gmMainChatUI talk-focus + /// enabled state). Confirmed via decomp: enabled items render white. + public Vector4 TextColorAvailable { get; set; } = new(1f, 1f, 1f, 1f); + /// Disabled/unavailable item text — retail GREYS these (UIElement state 0xd + /// disabled StateDesc colour). NOT the salmon colorPink (0x81c528) we had before — that + /// belongs to the chat-MESSAGE palette and was misapplied. Exact float lives in the dat + /// StateDesc (not a code symbol); ~0.5 neutral grey here pending a live cdb dump. + public Vector4 TextColorGhosted { get; set; } = new(0.5f, 0.5f, 0.5f, 1f); + + private bool _open; + // 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; } + + /// The menu draws its own button face + popup; its dat label/row children + /// must NOT be built (an invisible label child would intercept the button click). + public override bool ConsumesDatChildren => true; + + protected override void OnDraw(UiRenderContext ctx) + { + var resolve = SpriteResolve; + + // 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 + } + + /// The button width that fits "LED cap + channel label + arrow cap" — retail + /// sizes the talk-focus button to its selected label. The controller widens the button + /// to this and reflows the input field to start after it. + public float NaturalButtonWidth() + { + 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 + } + + /// The open popup draws in the OVERLAY pass so it sits on top of the whole + /// UI — otherwise the translucent chat panel (drawn after this element in the main + /// pass) greys out the part of the popup that overlaps it. + protected override void OnDrawOverlay(UiRenderContext ctx) + { + var resolve = SpriteResolve; + if (!_open || resolve is null) return; + + // 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(); } + } + + /// Draw the universal 8-piece retail window bevel (corners + tiled edges + + /// tiled centre fill) framing the rect (,, + /// ,). Reuses the same geometry + + /// ids as ; no resize + /// grips (a menu popup is not resizable). + private void DrawBevel(UiRenderContext ctx, Func resolve, + float x, float y, float w, float h) + { + var r = UiNineSlicePanel.ComputeFrameRects(w, h, Border); + void P(uint id, in UiNineSlicePanel.Rect d) => DrawSprite(ctx, resolve, id, x + d.X, y + d.Y, d.W, d.H); + P(RetailChromeSprites.CenterFill, r.Center); + P(RetailChromeSprites.TopEdge, r.Top); + P(RetailChromeSprites.BottomEdge, r.Bottom); + P(RetailChromeSprites.LeftEdge, r.Left); + P(RetailChromeSprites.RightEdge, r.Right); + P(RetailChromeSprites.CornerTL, r.TL); + P(RetailChromeSprites.CornerTR, r.TR); + P(RetailChromeSprites.CornerBL, r.BL); + P(RetailChromeSprites.CornerBR, r.BR); + } + + private float LineH() => DatFont?.LineHeight ?? Font?.LineHeight ?? 14f; + + private void DrawSprite(UiRenderContext ctx, Func resolve, + 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; + } +} diff --git a/src/AcDream.App/UI/UiMeter.cs b/src/AcDream.App/UI/UiMeter.cs new file mode 100644 index 00000000..b5ee4a40 --- /dev/null +++ b/src/AcDream.App/UI/UiMeter.cs @@ -0,0 +1,171 @@ +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// A horizontal vital bar (retail HP/Stamina/Mana style): a background rect, a +/// partial-width solid fill, and an optional centered "current/max" numeric +/// overlay. returns 0..1 (null = no data → empty bar); +/// returns the overlay text (null = no number). +/// +/// +/// Solid-color fill + debug font for Spec 1. The retail gradient bar sprite +/// (glassy center highlight) and the retail dat font are a later polish pass — +/// retail's vitals are bars exactly like this, just sprited. +/// +/// +public sealed class UiMeter : UiElement +{ + + /// Fill fraction provider; a null result draws an empty bar. + public Func Fill { get; set; } = () => 0f; + /// Centered overlay text provider (e.g. "291/291"); null = none. + public Func Label { get; set; } = () => null; + public Vector4 BarColor { get; set; } = new(1f, 0f, 0f, 1f); + public Vector4 BgColor { get; set; } = new(0f, 0f, 0f, 0.5f); + public Vector4 LabelColor { get; set; } = new(1f, 1f, 1f, 1f); + + /// Retail dat font (Font 0x40000000) for the "cur/max" overlay. When + /// set, the label renders through the dat-font two-pass blit (outline + fill); + /// when null, the debug bitmap font + /// is used instead. Set by the host when the retail UI is active. + public UiDatFont? DatFont { get; set; } + + /// Resolver from a RenderSurface DataId to (GL handle, w, h). When set + /// with the 9-slice ids below, the bar draws the retail sprites instead of solid color. + public Func? SpriteResolve { get; set; } + + // 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. + /// Empty-track left-cap RenderSurface id. + public uint BackLeft { get; set; } + /// Empty-track middle (tiled gradient) RenderSurface id. + public uint BackTile { get; set; } + /// Empty-track right-cap RenderSurface id. + public uint BackRight { get; set; } + /// Coloured-fill left-cap RenderSurface id. + public uint FrontLeft { get; set; } + /// Coloured-fill middle (tiled gradient) RenderSurface id. + public uint FrontTile { get; set; } + /// Coloured-fill right-cap RenderSurface id. + public uint FrontRight { get; set; } + + public UiMeter() { ClickThrough = true; } + + /// The meter draws its own 3-slice bars; the importer must not build its + /// grandchild slice/text elements as separate widgets. + public override bool ConsumesDatChildren => true; + + /// Clamp to [0,1] and return the fill rect + /// (local px) for a bar of x . + public static (float x, float y, float w, float h) ComputeFillRect( + 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); + } + } + } + + /// + /// Draws the full-width horizontal 3-slice (native-width left-cap, stretched + /// middle, native-width right-cap) over this meter's rect, horizontally CLIPPED + /// so nothing past (local px from the left) is drawn. + /// The back track passes clipW = Width; the front fill passes + /// clipW = Width * fraction. Clipping UV-crops each slice proportionally, + /// so the fill ends cleanly and the back's right-cap shows through when partial. + /// A 0 id skips that slice. + /// + private void DrawHBar( + UiRenderContext ctx, Func 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); + } + + /// Draw a slice over local [, + /// pieceX+], with the texture repeating every + /// px (UV-repeat — the UI texture is GL_REPEAT-wrapped). + /// Clipped so nothing past shows. For a cap (span == native) + /// this is one 1:1 copy; for the wide middle it tiles; a partial last copy is + /// UV-cropped. + private static void DrawPiece( + UiRenderContext ctx, uint tex, float pieceX, float pieceW, float 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); + } +} diff --git a/src/AcDream.App/UI/UiNineSlicePanel.cs b/src/AcDream.App/UI/UiNineSlicePanel.cs new file mode 100644 index 00000000..9c18f095 --- /dev/null +++ b/src/AcDream.App/UI/UiNineSlicePanel.cs @@ -0,0 +1,105 @@ +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// A whose background is the retail 8-piece window bevel +/// (): 4 corners + 4 edges around a tiled +/// center fill. Retires the flat translucent rect (divergence row TS-30). +/// Sprites resolve to (GL handle, width, height) via an injected delegate so +/// the widget is testable without GL. In production: +/// id => { var t = cache.GetOrUploadRenderSurface(id, out var w, out var h); return (t, w, h); }. +/// +public sealed class UiNineSlicePanel : UiPanel +{ + /// A placed chrome piece: destination rect in local pixel space. + public readonly record struct Rect(float X, float Y, float W, float H); + + /// The nine destination rects for an 8-piece border + center. + public readonly record struct FrameRects( + Rect Center, Rect Top, Rect Bottom, Rect Left, Rect Right, + Rect TL, Rect TR, Rect BL, Rect BR); + + private readonly System.Func _resolve; + + public UiNineSlicePanel(System.Func resolve) + { + _resolve = resolve; + BackgroundColor = Vector4.Zero; // suppress the base flat-rect fill + BorderColor = Vector4.Zero; + 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; + } + + /// + /// Destination rects (local px) for a frame of (, + /// ) with border thickness : + /// b×b corners, top/bottom edges spanning the interior width at height b, + /// left/right edges spanning the interior height at width b, center fills + /// the interior. + /// + public static FrameRects ComputeFrameRects(float w, float h, int b) + { + float innerW = w - 2 * b; + float innerH = h - 2 * b; + return new FrameRects( + Center: new Rect(b, b, innerW, innerH), + Top: new Rect(b, 0, innerW, b), + Bottom: new Rect(b, h - b, innerW, b), + Left: new Rect(0, b, b, innerH), + Right: new Rect(w - b, b, b, innerH), + TL: new Rect(0, 0, b, b), + TR: new Rect(w - b, 0, b, b), + BL: new Rect(0, h - b, b, b), + BR: new Rect(w - b, h - b, b, b)); + } + + protected override void OnDraw(UiRenderContext ctx) + { + var r = ComputeFrameRects(Width, Height, RetailChromeSprites.Border); + // center + edges tile (UV repeat); corners stretch 1:1. + DrawTiled(ctx, RetailChromeSprites.CenterFill, r.Center); + DrawTiled(ctx, RetailChromeSprites.TopEdge, r.Top); + DrawTiled(ctx, RetailChromeSprites.BottomEdge, r.Bottom); + DrawTiled(ctx, RetailChromeSprites.LeftEdge, r.Left); + DrawTiled(ctx, RetailChromeSprites.RightEdge, r.Right); + DrawStretched(ctx, RetailChromeSprites.CornerTL, r.TL); + DrawStretched(ctx, RetailChromeSprites.CornerTR, r.TR); + DrawStretched(ctx, RetailChromeSprites.CornerBL, r.BL); + DrawStretched(ctx, RetailChromeSprites.CornerBR, r.BR); + + // Resize-grip overlay (gold ridged edges + square corner studs) drawn on + // top of the bevel — the second border layer the vitals LayoutDesc carries + // (0x1000063B–0x10000642). Edges tile; the corner stud is the same sprite + // at all four corners. + DrawTiled(ctx, RetailChromeSprites.GripTop, r.Top); + DrawTiled(ctx, RetailChromeSprites.GripBottom, r.Bottom); + DrawTiled(ctx, RetailChromeSprites.GripLeft, r.Left); + DrawTiled(ctx, RetailChromeSprites.GripRight, r.Right); + DrawStretched(ctx, RetailChromeSprites.GripCorner, r.TL); + DrawStretched(ctx, RetailChromeSprites.GripCorner, r.TR); + DrawStretched(ctx, RetailChromeSprites.GripCorner, r.BL); + DrawStretched(ctx, RetailChromeSprites.GripCorner, r.BR); + } + + private void DrawTiled(UiRenderContext ctx, uint id, Rect d) + { + if (d.W <= 0 || d.H <= 0) return; + var (tex, tw, th) = _resolve(id); + if (tex == 0 || tw == 0 || th == 0) return; + ctx.DrawSprite(tex, d.X, d.Y, d.W, d.H, 0, 0, d.W / tw, d.H / th, Vector4.One); + } + + private void DrawStretched(UiRenderContext ctx, uint id, Rect d) + { + if (d.W <= 0 || d.H <= 0) return; + var (tex, _, _) = _resolve(id); + if (tex == 0) return; + ctx.DrawSprite(tex, d.X, d.Y, d.W, d.H, 0, 0, 1, 1, Vector4.One); + } +} diff --git a/src/AcDream.App/UI/UiPanel.cs b/src/AcDream.App/UI/UiPanel.cs index 9f941da1..b6a2085f 100644 --- a/src/AcDream.App/UI/UiPanel.cs +++ b/src/AcDream.App/UI/UiPanel.cs @@ -57,14 +57,17 @@ public class UiLabel : UiElement /// callback. Retail equivalent is Keystone's button widget, driven by /// a StateDesc per UIStateId (normal / hot / pressed / /// disabled) from the panel layout. +/// Note: the dat-widget button (Type 1 / UIElement_Button) is +/// in UiButton.cs — that is the production widget used by D.2b panels. +/// This class is the earlier dev-scaffold button (plain rect + text; no dat sprites). /// -public class UiButton : UiPanel +public class UiSimpleButton : UiPanel { public string Text { get; set; } = string.Empty; public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f); public event System.Action? Click; - public UiButton() + public UiSimpleButton() { BackgroundColor = new Vector4(0.1f, 0.1f, 0.15f, 0.8f); BorderColor = new Vector4(0.45f, 0.45f, 0.55f, 1f); diff --git a/src/AcDream.App/UI/UiRenderContext.cs b/src/AcDream.App/UI/UiRenderContext.cs index 51ce7b83..ebf6fc69 100644 --- a/src/AcDream.App/UI/UiRenderContext.cs +++ b/src/AcDream.App/UI/UiRenderContext.cs @@ -22,6 +22,29 @@ public sealed class UiRenderContext private readonly System.Collections.Generic.List _stack = new(); private Vector2 _current; + // Alpha (opacity) stack — a window pushes its Opacity so its background/sprite + // draws fade (retail's translucent-chat effect). Text draws bypass this (they go + // straight to TextRenderer), so text stays sharp over a translucent background. + private readonly System.Collections.Generic.List _alphaStack = new(); + private float _alpha = 1f; + + /// Current cumulative opacity multiplier applied to sprite + rect draws. + public float AlphaMod => _alpha; + + /// Multiply into the running opacity. Pair with . + public void PushAlpha(float a) { _alphaStack.Add(_alpha); _alpha *= a; } + + /// Push an ABSOLUTE opacity (replaces, not multiplies) — for popups/overlays + /// that must stay opaque even inside a translucent window. Pair with . + public void PushAlphaAbsolute(float a) { _alphaStack.Add(_alpha); _alpha = a; } + + public void PopAlpha() + { + if (_alphaStack.Count == 0) return; + _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; + /// Route subsequent draws to the overlay layer (flushed on top of the whole + /// UI). Used by the root for the popup/overlay traversal. Pair with . + public void BeginOverlayLayer() => TextRenderer.OverlayMode = true; + public void EndOverlayLayer() => TextRenderer.OverlayMode = false; + // ── Pass-through draw helpers (add current translate) ────────────── public void DrawRect(float x, float y, float w, float h, Vector4 color) - => TextRenderer.DrawRect(_current.X + x, _current.Y + y, w, h, color); + => TextRenderer.DrawRect(_current.X + x, _current.Y + y, w, h, ApplyAlpha(color)); + + /// Solid-colour fill drawn in the SPRITE bucket (painter order with text), for + /// a panel BACKGROUND that text draws on top of. composites after + /// all sprites and would cover the text — use this for backgrounds, that for foreground + /// fills (carets, vital bars). + public void DrawFill(float x, float y, float w, float h, Vector4 color) + => TextRenderer.DrawFill(_current.X + x, _current.Y + y, w, h, ApplyAlpha(color)); public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f) - => TextRenderer.DrawRectOutline(_current.X + x, _current.Y + y, w, h, 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)); + + /// Multiply the current window opacity into a draw color's alpha. + private Vector4 ApplyAlpha(Vector4 c) => _alpha >= 1f ? c : new Vector4(c.X, c.Y, c.Z, c.W * _alpha); public void DrawString(string text, float x, float y, Vector4 color, BitmapFont? font = null) { @@ -59,4 +102,101 @@ public sealed class UiRenderContext if (f is null) return; TextRenderer.DrawString(f, text, _current.X + x, _current.Y + y, color); } + + /// + /// Draw a single line of text with a retail dat font (), + /// at , = the top-left of the + /// typographic block (in this element's local space). Mirrors retail's + /// SurfaceWindow::DrawCharacter (acclient 0x00442bd0): for each glyph + /// the BACKGROUND atlas sub-rect is blitted first tinted black (the outline), + /// then the FOREGROUND atlas sub-rect tinted (the + /// fill). The pen advances by + /// HorizontalOffsetBefore + Width + HorizontalOffsetAfter and each + /// glyph is positioned at pen + HorizontalOffsetBefore on the X axis + /// and at baseline + VerticalOffsetBefore - (BaselineOffset) via the + /// glyph's OffsetY into the atlas. + /// + /// gates the black outline pass. Retail decides + /// this PER text element: UIElement_Text::DrawSelf (acclient 0x00467aa0) + /// runs the outline pass only when m_bitField & 0x10 is set — i.e. the + /// element called SetOutline(true) (LayoutDesc property 0xd). The DEFAULT + /// is OFF (one fill-only pass): the talk-focus menu items set no outline, so an + /// always-on outline shows as a grey halo over the solid menu panel. Pass + /// outline:true only for elements retail outlines. + /// + public void DrawStringDat(UiDatFont font, string text, float x, float y, Vector4 color, 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); + } + } + + /// Convert an (OffsetX,OffsetY,Width,Height) atlas pixel sub-rect to + /// normalized UVs for an atlas of x + /// . Guards against a zero-sized atlas. + private static (float u0, float v0, float u1, float v1) AtlasUv( + int offsetX, int offsetY, int width, int height, int atlasW, int atlasH) + { + if (atlasW <= 0 || atlasH <= 0) return (0f, 0f, 0f, 0f); + float u0 = offsetX / (float)atlasW; + float v0 = offsetY / (float)atlasH; + float u1 = (offsetX + width) / (float)atlasW; + float v1 = (offsetY + height) / (float)atlasH; + return (u0, v0, u1, v1); + } } diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index 7df41739..91fd219d 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -4,6 +4,10 @@ using System.Numerics; namespace AcDream.App.UI; +/// Which edges of a window a resize-drag is affecting (corners combine two). +[System.Flags] +public enum ResizeEdges { None = 0, Left = 1, Right = 2, Top = 4, Bottom = 8 } + /// /// Top-level UI container. Implements the retail "Device" responsibilities /// (mouse cursor tracking, keyboard focus, modal overlay, mouse capture, @@ -40,6 +44,10 @@ public sealed class UiRoot : UiElement /// Widget currently receiving keyboard events. public UiElement? KeyboardFocus { get; private set; } + /// The edit control activated by Tab/Enter when nothing is focused — retail's + /// chat input "write mode" toggle. Set by the host once the chat window is built. + public UiElement? DefaultTextInput { get; set; } + /// /// Single modal overlay; while set, mouse clicks outside its rect /// are ignored. Retail sets this via Device vtable +0x48. @@ -49,12 +57,30 @@ public sealed class UiRoot : UiElement /// Widget with mouse capture (during click-drag). public UiElement? Captured { get; private set; } + /// + /// True when the pointer is over a widget OR a widget holds mouse capture. + /// The host ORs this into the InputDispatcher's WantCaptureMouse gate so game + /// actions (movement, world-pick) are suppressed while the user interacts with + /// a retail window — mirrors ImGui's WantCaptureMouse. + /// + public bool WantsMouse => Captured is not null || HitTestTopDown(MouseX, MouseY).element is not null; + + /// True when a widget holds keyboard focus (e.g. a focused chat input). + public bool WantsKeyboard => KeyboardFocus is not null; + /// Current drag source (set between drag-begin and drop/cancel). public UiElement? DragSource { get; private set; } public object? DragPayload { get; private set; } private UiElement? _lastDragHoverTarget; private int _pressX, _pressY; private bool _dragCandidate; + private UiElement? _windowDragTarget; + private int _windowDragOffX, _windowDragOffY; + private 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; + } + + /// Which edges of 's screen rect the point + /// (,) is within px of. + /// None if the point is outside the grip-expanded box entirely. + internal static ResizeEdges HitEdges(UiElement w, int x, int y, int grip) + { + float l = w.Left, t = w.Top, r = w.Left + w.Width, b = w.Top + w.Height; + if (x < l - grip || x > r + grip || y < t - grip || y > b + grip) return ResizeEdges.None; + var e = ResizeEdges.None; + if (System.Math.Abs(x - l) <= grip) e |= ResizeEdges.Left; + if (System.Math.Abs(x - r) <= grip) e |= ResizeEdges.Right; + if (System.Math.Abs(y - t) <= grip) e |= ResizeEdges.Top; + if (System.Math.Abs(y - b) <= grip) e |= ResizeEdges.Bottom; + if (!w.ResizeX) e &= ~(ResizeEdges.Left | ResizeEdges.Right); + if (!w.ResizeY) e &= ~(ResizeEdges.Top | ResizeEdges.Bottom); + return e; + } + + /// Compute a resized rect from a start rect + drag delta + which edges, + /// clamping to (,). Left/Top edges + /// move the origin so the opposite edge stays put. + public static (float x, float y, float w, float h) ResizeRect( + float startX, float startY, float startW, float startH, + ResizeEdges edges, float dx, float dy, float minW, float minH) + { + float x = startX, y = startY, w = startW, h = startH; + if ((edges & ResizeEdges.Right) != 0) w = System.Math.Max(minW, startW + dx); + if ((edges & ResizeEdges.Bottom) != 0) h = System.Math.Max(minH, startH + dy); + if ((edges & ResizeEdges.Left) != 0) { float nw = System.Math.Max(minW, startW - dx); x = startX + (startW - nw); w = nw; } + if ((edges & ResizeEdges.Top) != 0) { float nh = System.Math.Max(minH, startH - dy); y = startY + (startH - nh); h = nh; } + return (x, y, w, h); + } + private static bool ContainsAbsolute(UiElement e, int x, int y) { var sp = e.ScreenPosition; diff --git a/src/AcDream.App/UI/UiScrollable.cs b/src/AcDream.App/UI/UiScrollable.cs new file mode 100644 index 00000000..f9e78a12 --- /dev/null +++ b/src/AcDream.App/UI/UiScrollable.cs @@ -0,0 +1,57 @@ +using System; + +namespace AcDream.App.UI; + +/// +/// Pixel-based vertical scroll model. Port of retail UIElement_Scrollable: +/// the scroll offset is an integer pixel value (m_iScrollableY) clamped to +/// [0, ContentHeight - ViewHeight]; the thumb ratio is view/content; the position +/// ratio is scroll/(content-view). Pure (no GL) so it is fully unit-tested and +/// shared by the transcript (UiText) and the scrollbar (UiScrollbar). +/// Decomp anchors: SetScrollableXY @0x4740c0, UpdateScrollbarSize_ @0x4741a0, +/// UpdateScrollbarPosition_ @0x473f20, UIElement_Text::InqScrollDelta @0x4689b0. +/// +public sealed class UiScrollable +{ + /// Total wrapped content height in px (m_iScrollableHeight). + public int ContentHeight { get; set; } + /// Visible viewport height in px. + public int ViewHeight { get; set; } + /// Pixels per text line (scroll quantum). InqScrollDelta line case. + public int LineHeight { get; set; } = 16; + + private int _scrollY; + /// Current scroll offset in px from the top of the content. + public int ScrollY => _scrollY; + + /// Max scroll = max(0, content - view). + public int MaxScroll => Math.Max(0, ContentHeight - ViewHeight); + + /// True when content exceeds the view (a scrollbar is warranted). + public bool HasOverflow => ContentHeight > ViewHeight; + + /// True when the offset is at (or past) the bottom — used for bottom-pin. + public bool AtEnd => _scrollY >= MaxScroll; + + /// Set the offset, clamped to [0, MaxScroll] (SetScrollableXY clamp). + public void SetScrollY(int y) => _scrollY = Math.Clamp(y, 0, MaxScroll); + + /// Pin to the bottom (newest content visible). + public void ScrollToEnd() => _scrollY = MaxScroll; + + /// Thumb size ratio = view/content, clamped to 1 (UpdateScrollbarSize_). + public float ThumbRatio => ContentHeight <= 0 ? 1f : Math.Min(1f, (float)ViewHeight / ContentHeight); + + /// Position ratio = scroll/(content-view) in [0,1] (UpdateScrollbarPosition_). + public float PositionRatio => MaxScroll <= 0 ? 0f : (float)_scrollY / MaxScroll; + + /// Inverse of PositionRatio — used when the user drags the thumb. + public void SetPositionRatio(float ratio) + => SetScrollY((int)MathF.Round(Math.Clamp(ratio, 0f, 1f) * MaxScroll)); + + /// Scroll by whole lines (sign: +down/newer, -up/older). + public void ScrollByLines(int lines) => SetScrollY(_scrollY + lines * LineHeight); + + /// Scroll by a page = one view height (InqScrollDelta page case). + public void ScrollByPage(int pages) => SetScrollY(_scrollY + pages * ViewHeight); +} diff --git a/src/AcDream.App/UI/UiScrollbar.cs b/src/AcDream.App/UI/UiScrollbar.cs new file mode 100644 index 00000000..d574b597 --- /dev/null +++ b/src/AcDream.App/UI/UiScrollbar.cs @@ -0,0 +1,210 @@ +using System; +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// Generic scrollbar. Ports retail UIElement_Scrollbar +/// (RegisterElementClass(0xb) @ acclient_2013_pseudo_c.txt:124137); +/// thumb size = trackLen * ThumbRatio (min 8px); step ±1 line. +/// +/// +/// Dat element ids (chat LayoutDesc 0x21000006): track 0x10000012 (X=474 Y=6 W=16 H=68), +/// thumb 0x1000048C. The track is instanced from base layout 0x2100003E which contains +/// the full scrollbar widget with distinct up/down button children: +/// Up button element 0x10000071 — Y=0, 16×16, Normal sprite 0x06004C69. +/// Down button element 0x10000072 — Y=32, 16×16, Normal sprite 0x06004C6C. +/// Track body sprite: 0x06004C5F (48px tall in the base template; stretched to H=68 in chat). +/// Thumb is a 3-slice: top cap 0x06004C60, middle 0x06004C63, bottom cap 0x06004C66. +/// For Task H wiring: up/down regions occupy the top and bottom ButtonH (16px) of the +/// rendered scrollbar's height; the widget responds to those regions directly via hit +/// comparison in OnEvent without requiring separate child elements. +/// +public sealed class UiScrollbar : UiElement +{ + /// The scroll model this bar reflects + drives (shared with the transcript). + public UiScrollable? Model { get; set; } + + /// RenderSurface id → (GL tex, w, h). 0 id = skip. + public Func? SpriteResolve { get; set; } + + /// Track background sprite id (0x06004C5F from layout 0x2100003E element 0x10000455). + public uint TrackSprite { get; set; } + + /// Thumb 3-slice MIDDLE tile sprite id (0x06004C63), tiled between the caps. + public uint ThumbSprite { get; set; } + + /// Thumb 3-slice TOP cap sprite id (0x06004C60, 3px tall). + public uint ThumbTopSprite { get; set; } + + /// Thumb 3-slice BOTTOM cap sprite id (0x06004C66, 3px tall). + public uint ThumbBotSprite { get; set; } + + /// Up-arrow button sprite id (0x06004C69 Normal state, element 0x10000071). + public uint UpSprite { get; set; } + + /// Down-arrow button sprite id (0x06004C6C Normal state, element 0x10000072). + public uint DownSprite { get; set; } + + /// Retail attribute 0x89 floor: minimum thumb height in pixels. + private const float MinThumb = 8f; + + /// Thumb cap height (native sprite height from base layout 0x2100003E). + private const float CapH = 3f; + + /// Up/down button height in pixels. Matches element height 16px from + /// the up/down button children in base layout 0x2100003E. + private const float ButtonH = 16f; + + private bool _draggingThumb; + private float _dragOffsetY; + + public UiScrollbar() { CapturesPointerDrag = true; } + + /// The scrollbar draws its own track/thumb/arrows; its dat up/down button + /// children are reproduced procedurally, so the importer must not build them. + public override bool ConsumesDatChildren => true; + + /// + /// Computes the thumb rectangle (local y origin and height) within the track area + /// between the two end buttons. Ports retail UIElement_Scrollbar::UpdateLayout + /// @0x4710d0: thumb height = max(MinThumb, trackLen * ThumbRatio); thumb top + /// offset = trackTop + (trackLen - thumbH) * PositionRatio. + /// + /// The scroll model. + /// Y of the top of the usable track area (below up-button). + /// Pixel length of the usable track area (between up and down buttons). + /// Local Y of the thumb's top edge, and its pixel height. + public static (float y, float h) ThumbRect(UiScrollable m, float trackTop, float trackLen) + { + float h = MathF.Max(MinThumb, trackLen * m.ThumbRatio); + float travel = trackLen - h; + float y = trackTop + travel * m.PositionRatio; + return (y, h); + } + + protected override void OnDraw(UiRenderContext ctx) + { + if (Model is not { } m || SpriteResolve is not { } resolve) return; + + // Track background — 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); + } + } + } + + /// Draw a sprite stretched 1:1 to the dest rect. + private void DrawSprite(UiRenderContext ctx, Func resolve, + uint id, float x, float y, float w, float h) + { + if (id == 0 || w <= 0f || h <= 0f) return; + var (tex, _, _) = resolve(id); + if (tex == 0) return; + ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, 1f, 1f, Vector4.One); + } + + /// Draw a sprite 1:1 but vertically FLIPPED (V0/V1 swapped) — used to point + /// the top scroll button's (down-art) arrow upward. + private void DrawSpriteFlipV(UiRenderContext ctx, Func resolve, + uint id, float x, float y, float w, float h) + { + if (id == 0 || w <= 0f || h <= 0f) return; + var (tex, _, _) = resolve(id); + if (tex == 0) return; + ctx.DrawSprite(tex, x, y, w, h, 0f, 1f, 1f, 0f, Vector4.One); + } + + /// Draw a sprite TILED to fill the dest rect (UV-repeat at native size on + /// both axes — the UI texture is GL_REPEAT-wrapped). A native-width axis gives 1:1. + private void DrawTiled(UiRenderContext ctx, Func resolve, + 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; + } +} diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs new file mode 100644 index 00000000..c89f4ae7 --- /dev/null +++ b/src/AcDream.App/UI/UiText.cs @@ -0,0 +1,448 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using System.Text; +using AcDream.App.Rendering; + +namespace AcDream.App.UI; + +/// +/// Scrollable text view for retail UIElement_Text elements +/// (RegisterElementClass(0xc) @ acclient_2013_pseudo_c.txt:115655). +/// Renders the lines from bottom-pinned (newest at the bottom, +/// like retail) with mouse-wheel scrollback. Whole-line vertical clipping keeps +/// text inside the window. +/// +/// +/// Supports Windows-like text selection: a left-click-drag inside the transcript +/// selects characters (the opt-out +/// stops that interior drag from moving the host window), and Ctrl+C copies the +/// selected span to the clipboard. Ctrl+A selects everything. +/// +/// +public sealed class UiText : UiElement +{ + /// One display line: pre-formatted text + its colour. + public readonly record struct Line(string Text, Vector4 Color); + + /// A caret position: a line index into the cached line list plus a + /// character index (0..line.Text.Length, i.e. a caret slot between glyphs). + public readonly record struct Pos(int Line, int Col); + + /// Provider of the lines to show, oldest-first. Polled each frame. + public Func> LinesProvider { get; set; } = static () => Array.Empty(); + + /// Font for the transcript; falls back to the context default. + public BitmapFont? Font { get; set; } + + /// Retail dat font (0x40000000) for the transcript. When set, glyphs + /// render via the two-pass dat-font blit and measure/hit-test use the dat glyph + /// advance; when null, the debug BitmapFont path is used. Set by the controller. + public UiDatFont? DatFont { get; set; } + + /// Keyboard device for clipboard (Ctrl+C) + modifier state. Wired by + /// the host from . + public Silk.NET.Input.IKeyboard? Keyboard { get; set; } + + /// Backing fill behind the text. Defaults to transparent so an unbound + /// UiText (no controller) draws nothing. Set to the retail translucent value by + /// the controller (e.g. ChatWindowController). + public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0f); + + /// Optional dat state-sprite background (the element's own media), drawn + /// UNDER the text. Set by DatWidgetFactory.BuildText from the ElementInfo. 0 = none. + public uint BackgroundSprite { get; set; } + + /// Resolves a dat RenderSurface id to (GL tex handle, pixel width, pixel height). + /// Required when is non-zero. + public Func? SpriteResolve { get; set; } + + /// Highlight colour painted behind a selected character span. + public Vector4 SelectionColor { get; set; } = new(0.25f, 0.45f, 0.85f, 0.5f); + + /// Inner text inset from the view edges, px. + public float Padding { get; set; } = 4f; + + /// Static centered single-line mode (retail UIElement_Text center + /// justification): draws the FIRST line centered horizontally AND vertically in the + /// element rect, with NO scroll/selection machinery. Used for static labels such as + /// the vitals cur/max numbers. The centering formula is IDENTICAL to + /// 's former number overlay so those numbers stay pixel-identical + /// after the rewire. Pair with ClickThrough = true for non-interactive labels. + public bool Centered { get; set; } + + /// The scroll model — also read by the linked UiScrollbar. + public UiScrollable Scroll { get; } = new(); + + /// True while the view is pinned to the newest line (auto-scrolls as content grows). + private bool _pinBottom = true; + + private const float WheelLines = 1f; // lines advanced per wheel notch (retail = 1 line per notch) + + // ── Cached layout from the last OnDraw, so OnEvent hit-tests the SAME geometry ── + private IReadOnlyList _lastLines = Array.Empty(); + private BitmapFont? _lastFont; + private UiDatFont? _lastDatFont; + private float _lastLineHeight = 16f; + private float _lastBaseY; // top Y of line 0 in local space + private float _lastPadding = 4f; + + // ── 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 + } + + /// The text view draws its own lines + background; any dat sub-elements + /// (scroll indicators, caps) are not built as separate widgets by the importer. + public override bool ConsumesDatChildren => true; + + /// + /// Clamp a scroll offset to [0, max] where max = content-height - view-height + /// (never negative — when everything fits, scroll is pinned to 0). Exposed for tests. + /// + 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 ──────────────────────────────────────────────── + + /// Select the entire cached transcript (Ctrl+A). + private void SelectAll() + { + var lines = _lastLines; + if (lines.Count == 0) + { + _selAnchor = _selCaret = null; + return; + } + int last = lines.Count - 1; + _selAnchor = new Pos(0, 0); + _selCaret = new Pos(last, lines[last].Text.Length); + } + + /// Normalise (anchor, caret) into ordered (start, end). False if no + /// selection or it is empty (anchor == caret). + private bool TryGetOrderedSelection(out Pos start, out Pos end) + { + start = default; end = default; + if (_selAnchor is not { } a || _selCaret is not { } c) return false; + (start, end) = Order(a, c); + return !(start.Line == end.Line && start.Col == end.Col); + } + + /// The currently-selected text against the cached lines. Empty when + /// nothing is selected. + public string SelectedText() + { + if (!TryGetOrderedSelection(out var start, out var end)) return string.Empty; + return SelectedText(_lastLines, start, end); + } + + // ── Pure, testable logic (no GL / no font texture) ─────────────────── + + /// Order two caret positions so the first is <= the second (by line, + /// then column). + public static (Pos start, Pos end) Order(Pos a, Pos b) + { + if (a.Line < b.Line || (a.Line == b.Line && a.Col <= b.Col)) return (a, b); + return (b, a); + } + + /// + /// Assemble the selected substring spanning .. + /// (inclusive of start.Col, exclusive of end.Col) from + /// . Multi-line selections are joined with "\n": + /// the first line from start.Col to its end, whole middle lines, and the last + /// line up to end.Col. Pure — unit-testable without GL. + /// + public static string SelectedText(IReadOnlyList lines, Pos start, Pos end) + { + if (lines.Count == 0) return string.Empty; + (start, end) = Order(start, end); + + int sl = Math.Clamp(start.Line, 0, lines.Count - 1); + int el = Math.Clamp(end.Line, 0, lines.Count - 1); + + if (sl == el) + { + string t = lines[sl].Text; + int c0 = Math.Clamp(start.Col, 0, t.Length); + int c1 = Math.Clamp(end.Col, 0, t.Length); + if (c1 <= c0) return string.Empty; + return t.Substring(c0, c1 - c0); + } + + var sb = new StringBuilder(); + + // First line: from start.Col to its end. + { + string t = lines[sl].Text; + int c0 = Math.Clamp(start.Col, 0, t.Length); + sb.Append(t.AsSpan(c0)); + } + + // Whole middle lines. + for (int i = sl + 1; i < el; i++) + { + sb.Append('\n'); + sb.Append(lines[i].Text); + } + + // Last line: up to end.Col. + { + sb.Append('\n'); + string t = lines[el].Text; + int c1 = Math.Clamp(end.Col, 0, t.Length); + sb.Append(t.AsSpan(0, c1)); + } + + return sb.ToString(); + } + + /// + /// Convert a local-space point to a caret against the cached + /// layout from the last draw. line = floor((localY - baseY)/lineHeight) clamped + /// to the line range; col via . + /// + private Pos HitChar(float localX, float localY) + { + var lines = _lastLines; + if (lines.Count == 0) return new Pos(0, 0); + + float lh = _lastLineHeight <= 0f ? 16f : _lastLineHeight; + int line = (int)MathF.Floor((localY - _lastBaseY) / lh); + line = Math.Clamp(line, 0, lines.Count - 1); + + string text = lines[line].Text; + 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); + } + + /// + /// The caret column for a horizontal position (already + /// adjusted for the left padding, so x=0 is the start of the text). Walks the + /// string accumulating each glyph's advance and snaps the caret to whichever + /// side of the glyph midpoint falls on — natural + /// Windows-like caret placement. Pure — unit-testable with a synthetic advance. + /// + /// The line text. + /// Per-character advance (pixels) lookup. + /// Horizontal position relative to the text's left edge. + public static int CharIndexAt(string text, Func advanceOf, float x) + { + if (string.IsNullOrEmpty(text) || x <= 0f) return 0; + + float cursor = 0f; + for (int i = 0; i < text.Length; i++) + { + float adv = advanceOf(text[i]); + float mid = cursor + adv * 0.5f; + if (x < mid) return i; // caret sits before this glyph + cursor += adv; + } + return text.Length; // past the last glyph → end caret + } +} diff --git a/src/AcDream.Cli/AcDream.Cli.csproj b/src/AcDream.Cli/AcDream.Cli.csproj index 7d30223e..e964e5cb 100644 --- a/src/AcDream.Cli/AcDream.Cli.csproj +++ b/src/AcDream.Cli/AcDream.Cli.csproj @@ -9,6 +9,14 @@ + + + + + + diff --git a/src/AcDream.Cli/FontAtlasDump.cs b/src/AcDream.Cli/FontAtlasDump.cs new file mode 100644 index 00000000..f9f49161 --- /dev/null +++ b/src/AcDream.Cli/FontAtlasDump.cs @@ -0,0 +1,182 @@ +using AcDream.Core.Textures; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; +using DatReaderWriter.Types; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +namespace AcDream.Cli; + +/// +/// Headless inspection of a retail dat Font (DB_TYPE_FONT, 0x40000000…). Writes: +/// • <out>-fg.png — foreground (fill) atlas, alpha→luminance (white on black) +/// • <out>-bg.png — background (outline) atlas, alpha→luminance +/// • <out>-sample.png — a sample string composited EXACTLY the way +/// UiRenderContext.DrawStringDat does it (black outline pass behind, +/// colored fill pass on top) onto the dark chat-panel colour, at native 1:1 +/// and at 6× nearest zoom side by side. +/// +/// The sample reproduces our client's glyph math deterministically so the +/// "not sharp" artifact can be judged offline: if the 1:1 sample is crisp, the +/// softness is downstream (a post-process / scale); if the sample itself is +/// soft, the cause is the atlas or the two-pass outline. +/// +public static class FontAtlasDump +{ + public static int Run(string datDir, string? fontIdText, string? sampleText, string outBase) + { + if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; } + uint fontId = string.IsNullOrWhiteSpace(fontIdText) ? 0x40000000u : ParseHex(fontIdText); + string sample = string.IsNullOrEmpty(sampleText) ? "Chat Send 12345 ghpqy" : sampleText; + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var font = dats.Get(fontId); + if (font is null) { Console.Error.WriteLine($"error: Font 0x{fontId:X8} not found"); return 1; } + + Console.WriteLine($"Font 0x{fontId:X8}: fg=0x{font.ForegroundSurfaceDataId:X8} bg=0x{font.BackgroundSurfaceDataId:X8} " + + $"MaxCharHeight={font.MaxCharHeight} Baseline={font.BaselineOffset} glyphs={font.CharDescs.Count}"); + + DecodedTexture fg = DecodeRs(dats, font.ForegroundSurfaceDataId); + DecodedTexture? bg = font.BackgroundSurfaceDataId != 0 ? DecodeRs(dats, font.BackgroundSurfaceDataId) : null; + Console.WriteLine($" fg atlas {fg.Width}x{fg.Height}" + (bg is { } b ? $" bg atlas {b.Width}x{b.Height}" : " (no bg atlas)")); + + AlphaLuma(fg).SaveAsPng($"{outBase}-fg.png"); + Console.WriteLine($"wrote {outBase}-fg.png"); + if (bg is { } bgt) { AlphaLuma(bgt).SaveAsPng($"{outBase}-bg.png"); Console.WriteLine($"wrote {outBase}-bg.png"); } + + // Build a glyph lookup. + var glyphs = new Dictionary(); + foreach (var cd in font.CharDescs) glyphs[(char)cd.Unicode] = cd; + + // Render the sample the way DrawStringDat does, onto the dark chat panel colour. + var panel = new Rgba32(28, 28, 32, 255); + var fill = new Rgba32(255, 255, 255, 255); // white fill, like System default-ish + var outline = new Rgba32(0, 0, 0, 255); + + int lineH = Math.Max((int)font.MaxCharHeight, 8); + + // (a) integer baseline, per-glyph round (works — like the vitals digits). + using var native = RenderSample(sample, glyphs, fg, bg, lineH, panel, fill, outline, 0f, snapOnce: false); + Save6x(native, $"{outBase}-sample"); + + // (b) FRACTIONAL baseline (textY=0.5, like a menu item centered in a 17px row over + // a 16px font) with the OLD per-glyph rounding → reproduces the "letters dip down" + // jitter the user reported. + using var jitter = RenderSample(sample, glyphs, fg, bg, lineH, panel, fill, outline, 0.5f, snapOnce: false); + Save6x(jitter, $"{outBase}-jitter"); + + // (c) Same fractional baseline, but the line baseline is snapped to a whole pixel ONCE + // before adding the integer per-glyph offsets → the fix. Should be straight again. + using var fixed_ = RenderSample(sample, glyphs, fg, bg, lineH, panel, fill, outline, 0.5f, snapOnce: true); + Save6x(fixed_, $"{outBase}-fixed"); + + Console.WriteLine($"wrote {outBase}-sample-6x.png (ok), {outBase}-jitter-6x.png (bug repro), {outBase}-fixed-6x.png (fix)"); + return 0; + } + + /// Composite the sample string with the two-pass outline+fill model, + /// blitting atlas sub-rects 1:1. adds a fractional + /// line origin; selects the FIX (snap the line baseline + /// to a whole pixel once) vs the BUG (round each glyph's Y independently). + private static Image RenderSample( + string text, Dictionary glyphs, + DecodedTexture fg, DecodedTexture? bg, int lineH, + Rgba32 panel, Rgba32 fill, Rgba32 outline, float originYExtra, bool snapOnce) + { + // First pass: measure pen width. + float pen = 0; float maxX = 0; + foreach (char ch in text) + if (glyphs.TryGetValue(ch, out var g)) { maxX = Math.Max(maxX, pen + g.HorizontalOffsetBefore + g.Width); pen += g.HorizontalOffsetBefore + g.Width + g.HorizontalOffsetAfter; } + int w = Math.Max(8, (int)MathF.Ceiling(Math.Max(maxX, pen)) + 4); + int h = lineH + 6; + var img = new Image(w, h, panel); + + float originY = 3f + originYExtra; + float baseY = MathF.Round(originY); // snapped line baseline (the fix) + pen = 2; + foreach (char ch in text) + { + if (!glyphs.TryGetValue(ch, out var g)) { continue; } + float gx = MathF.Round(pen + g.HorizontalOffsetBefore); + float gy = snapOnce + ? baseY + g.VerticalOffsetBefore // fix: integer baseline + integer offset + : MathF.Round(originY + g.VerticalOffsetBefore); // bug: independent per-glyph rounding + if (g.Width > 0 && g.Height > 0) + { + if (bg is { } bgt) BlitGlyph(img, bgt, g, (int)gx, (int)gy, outline); + BlitGlyph(img, fg, g, (int)gx, (int)gy, fill); + } + pen += g.HorizontalOffsetBefore + g.Width + g.HorizontalOffsetAfter; + } + return img; + } + + private static void Save6x(Image native, string outBase) + { + using var zoom = native.Clone(c => c.Resize(native.Width * 6, native.Height * 6, KnownResamplers.NearestNeighbor)); + zoom.SaveAsPng($"{outBase}-6x.png"); + } + + /// Alpha-blend one glyph's atlas sub-rect onto the canvas using its alpha + /// as coverage, tinted by . 1:1 (no scaling), so this is the + /// pixel-exact result GL_NEAREST + native-size quad produces. + private static void BlitGlyph(Image dst, DecodedTexture atlas, FontCharDesc g, int dx, int dy, Rgba32 tint) + { + for (int sy = 0; sy < g.Height; sy++) + { + int py = dy + sy; + if (py < 0 || py >= dst.Height) continue; + int ay = g.OffsetY + sy; + if (ay < 0 || ay >= atlas.Height) continue; + for (int sx = 0; sx < g.Width; sx++) + { + int px = dx + sx; + if (px < 0 || px >= dst.Width) continue; + int ax = g.OffsetX + sx; + if (ax < 0 || ax >= atlas.Width) continue; + int idx = (ay * atlas.Width + ax) * 4; + // Atlas is A8 expanded to (255,255,255,alpha); coverage = alpha. + float cov = atlas.Rgba8[idx + 3] / 255f; + if (cov <= 0f) continue; + var bgpx = dst[px, py]; + dst[px, py] = new Rgba32( + (byte)(tint.R * cov + bgpx.R * (1 - cov)), + (byte)(tint.G * cov + bgpx.G * (1 - cov)), + (byte)(tint.B * cov + bgpx.B * (1 - cov)), + 255); + } + } + } + + /// Render an A8/RGBA atlas's ALPHA channel as opaque white-on-black luminance, + /// zoomed 4× nearest, so the glyph shapes are visible regardless of PNG viewer alpha. + private static Image AlphaLuma(DecodedTexture t) + { + var img = new Image(t.Width, t.Height); + for (int y = 0; y < t.Height; y++) + for (int x = 0; x < t.Width; x++) + { + byte a = t.Rgba8[(y * t.Width + x) * 4 + 3]; + img[x, y] = new Rgba32(a, a, a, 255); + } + img.Mutate(c => c.Resize(t.Width * 4, t.Height * 4, KnownResamplers.NearestNeighbor)); + return img; + } + + private static DecodedTexture DecodeRs(DatCollection dats, uint id) + { + var rs = dats.Get(id); + if (rs is null) { Console.Error.WriteLine($" missing RenderSurface 0x{id:X8}"); return DecodedTexture.Magenta; } + return SurfaceDecoder.DecodeRenderSurface(rs); + } + + private static uint ParseHex(string s) + { + s = s.Trim(); + if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) s = s[2..]; + return uint.TryParse(s, System.Globalization.NumberStyles.HexNumber, + System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : 0u; + } +} diff --git a/src/AcDream.Cli/LayoutIndexDump.cs b/src/AcDream.Cli/LayoutIndexDump.cs new file mode 100644 index 00000000..5276486c --- /dev/null +++ b/src/AcDream.Cli/LayoutIndexDump.cs @@ -0,0 +1,101 @@ +using System.Reflection; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; +using DatReaderWriter.Types; + +namespace AcDream.Cli; + +/// +/// Read-only research diagnostic: index EVERY UI in the +/// dat by its root element's Type + size + an element-Type histogram, so a +/// panel re-drive can locate its layout from the decomp-registered class id +/// (e.g. gmMainChatUI registers type 0x10000041 → the chat window +/// is the layout whose root element has Type 0x10000041). Optionally filter to a +/// single root Type. No writes; purely a console dump used during brainstorming. +/// +public static class LayoutIndexDump +{ + public static int Run(string datDir, string? rootTypeText) + { + if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + + uint? filter = null; + if (!string.IsNullOrWhiteSpace(rootTypeText)) + { + var t = rootTypeText.Trim(); + if (t.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) t = t[2..]; + if (uint.TryParse(t, System.Globalization.NumberStyles.HexNumber, null, out var f)) filter = f; + } + + Console.WriteLine(filter is { } ff + ? $"=== LayoutDescs with a root element of Type 0x{ff:X8} ===" + : "=== All LayoutDescs (id : root element Type : size : #elements : type histogram) ==="); + + int total = 0, shown = 0; + foreach (var id in dats.GetAllIdsOfType().OrderBy(x => x)) + { + var l = dats.Get(id); + if (l is null) continue; + total++; + + // The root is the single top-level element (or, if several, the largest). + ElementDesc? root = null; + foreach (var kv in l.Elements) + if (root is null || Area(kv.Value) > Area(root)) root = kv.Value; + if (root is null) continue; + + if (filter is { } want && root.Type != want) continue; + shown++; + + var hist = new SortedDictionary(); + int count = 0; + CountTypes(root, hist, ref count); + string h = string.Join(" ", hist.Select(kv => $"{TypeName(kv.Key)}×{kv.Value}")); + Console.WriteLine( + $" 0x{id:X8} root=0x{root.ElementId:X8} type=0x{root.Type:X8}({TypeName(root.Type)}) " + + $"{root.Width}x{root.Height} n={count} [{h}]"); + } + + Console.WriteLine(); + Console.WriteLine($"shown {shown} / {total} LayoutDescs."); + return 0; + } + + private static long Area(ElementDesc e) => (long)e.Width * e.Height; + + private static void CountTypes(ElementDesc e, SortedDictionary hist, ref int count) + { + count++; + hist[e.Type] = hist.TryGetValue(e.Type, out var c) ? c + 1 : 1; + foreach (var kv in e.Children) + CountTypes(kv.Value, hist, ref count); + } + + private static string TypeName(uint t) => t switch + { + 0 => "Text0", + 1 => "Button", + 2 => "Dragbar", + 3 => "Field", + 5 => "ListBox", + 6 => "Menu", + 7 => "Meter", + 8 => "Panel", + 9 => "Resizebar", + 0xB => "Scrollbar", + 0xC => "Text", + 0xD => "Viewport", + 0xE => "Browser", + 0x10 => "ColorPicker", + 0x11 => "GroupBox", + 0x12 => "Proto", + 0x10000041 => "gmMainChatUI", + 0x10000040 => "gmFloatyChatUI", + 0x10000050 => "gmFloatyMainChatUI", + 0x10000042 => "gmChatOptionsUI", + 0x10000009 => "gmVitalsUI", + _ => $"0x{t:X}", + }; +} diff --git a/src/AcDream.Cli/Program.cs b/src/AcDream.Cli/Program.cs index a4c290ee..5e0e03be 100644 --- a/src/AcDream.Cli/Program.cs +++ b/src/AcDream.Cli/Program.cs @@ -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 "); + 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 [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 [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 [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 <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 [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 <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 Count)[] CountCellByLow16(DatCollection dats) ("Region", () => dats.GetAllIdsOfType().Count()), }; } + +/// +/// dump-vitals-bars: find the vitals window LayoutDesc (0x21000014) and print the +/// RenderSurface DataIds (0x06xxxxxx) used by the Health, Stamina, and Mana meter +/// bars. Each meter element (E6/EC/EE) has two child sub-groups per bar visual +/// (front-bar and back-bar/track), each containing: +/// - elem 0x100004A9 (ShowDetail state image = Alphablend fill sprite) +/// - elem 0x100000E8 (DirectStateDesc = left-edge sprite) +/// - elem 0x100000E9 (DirectStateDesc = fill-tile sprite) +/// - elem 0x100000EA (DirectStateDesc = right-edge sprite) +/// +/// Based on the Sept 2013 EoR retail dat, vitals layout id = 0x21000014. +/// Element ids from gmVitalsUI::PostInit in acclient_2013_pseudo_c.txt. +/// +static int DumpVitalsBars(string dvbDatDir) +{ + const uint HEALTH_ELEM_ID = 0x100000E6u; + const uint STAMINA_ELEM_ID = 0x100000ECu; + const uint MANA_ELEM_ID = 0x100000EEu; + + if (!Directory.Exists(dvbDatDir)) + { + Console.Error.WriteLine($"error: directory not found: {dvbDatDir}"); + return 2; + } + + using var dats = new DatCollection(dvbDatDir, DatAccessType.Read); + + // Find the vitals layout: scan all LayoutDescs for one containing the health meter element. + Console.WriteLine("Scanning LayoutDescs for vitals window (element 0x100000E6 = Health meter)..."); + uint? vitalsId = null; + LayoutDesc? vitalsLayout = null; + foreach (var id in dats.GetAllIdsOfType()) + { + var ld = dats.Get(id); + if (ld is null) continue; + if (VbContainsElementId(ld, HEALTH_ELEM_ID)) { vitalsId = id; vitalsLayout = ld; break; } + } + + if (vitalsLayout is null) + { + Console.Error.WriteLine("ERROR: no LayoutDesc contains element 0x100000E6 (Health meter)."); + return 1; + } + Console.WriteLine($"Found vitals layout: 0x{vitalsId!.Value:X8}"); + Console.WriteLine(); + + // For each vital meter, collect all MediaDescImage DataIds from its sub-tree. + var meters = new[] { (HEALTH_ELEM_ID, "HEALTH"), (STAMINA_ELEM_ID, "STAMINA"), (MANA_ELEM_ID, "MANA") }; + foreach (var (eid, vitalName) in meters) + { + Console.WriteLine($"{vitalName} meter (element 0x{eid:X8}) in layout 0x{vitalsId!.Value:X8}:"); + var meterElem = VbFindElement(vitalsLayout!, eid); + if (meterElem is null) { Console.WriteLine(" "); continue; } + + var sprites = new List<(string Role, uint DataId, string DrawMode)>(); + VbCollectSprites(meterElem, sprites, 0); + + if (sprites.Count == 0) + { + Console.WriteLine(" "); + } + else + { + foreach (var (role, dataId, drawMode) in sprites) + Console.WriteLine($" {role,-35} 0x{dataId:X8} ({drawMode})"); + } + Console.WriteLine(); + } + + return 0; +} + +// ─── dump-vitals-bars helpers ─────────────────────────────────────────────── + +static bool VbContainsElementId(LayoutDesc ld, uint targetId) +{ + var elems = ld.Elements; + foreach (var kvp in elems) + { + if (kvp.Key == targetId) return true; + if (VbChildContains(kvp.Value, targetId)) return true; + } + return false; +} + +static bool VbChildContains(ElementDesc elem, uint targetId) +{ + foreach (var kvp in elem.Children) + { + if (kvp.Key == targetId) return true; + if (VbChildContains(kvp.Value, targetId)) return true; + } + return false; +} + +static ElementDesc? VbFindElement(LayoutDesc ld, uint targetId) +{ + foreach (var kvp in ld.Elements) + { + if (kvp.Key == targetId) return kvp.Value; + var found = VbFindChild(kvp.Value, targetId); + if (found is not null) return found; + } + return null; +} + +static ElementDesc? VbFindChild(ElementDesc elem, uint targetId) +{ + foreach (var kvp in elem.Children) + { + if (kvp.Key == targetId) return kvp.Value; + var found = VbFindChild(kvp.Value, targetId); + if (found is not null) return found; + } + return null; +} + +static void VbCollectSprites(ElementDesc elem, List<(string, uint, string)> out_, int depth) +{ + string indent = new string(' ', depth * 2); + + // Check the element's direct StateDesc + if (elem.StateDesc is not null) + VbExtractMedia(elem.StateDesc, $"{indent}elem_0x{elem.ElementId:X8}.DirectState", out_); + + // Check each named state + foreach (var kvp in elem.States) + VbExtractMedia(kvp.Value, $"{indent}elem_0x{elem.ElementId:X8}.{kvp.Key}", out_); + + // Recurse into children + foreach (var kvp in elem.Children) + VbCollectSprites(kvp.Value, out_, depth + 1); +} + +static void VbExtractMedia(StateDesc sd, string role, List<(string, uint, string)> out_) +{ + foreach (var m in sd.Media) + { + if (m is MediaDescImage img && img.File != 0) + out_.Add((role, img.File, img.DrawMode.ToString())); + } +} diff --git a/src/AcDream.Cli/VitalsLayoutDump.cs b/src/AcDream.Cli/VitalsLayoutDump.cs new file mode 100644 index 00000000..675f671b --- /dev/null +++ b/src/AcDream.Cli/VitalsLayoutDump.cs @@ -0,0 +1,152 @@ +using System.Collections; +using System.Reflection; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; +using DatReaderWriter.Types; + +namespace AcDream.Cli; + +/// +/// Full reflective dump of a vitals LayoutDesc element tree: every scalar +/// property (position/size/flags) of each ElementDesc + its state sprites, +/// so the real bar rects + spacing + window size can be read from the dat +/// instead of guessed. Uses reflection so it doesn't depend on knowing the +/// DatReaderWriter property names ahead of time. +/// +public static class VitalsLayoutDump +{ + public static int Run(string datDir, string? layoutIdText) + { + if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + + // Default to the vitals layout dump-vitals-bars found; allow override. + uint layoutId = 0x21000014u; + if (!string.IsNullOrWhiteSpace(layoutIdText)) + { + var t = layoutIdText.Trim(); + if (t.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) t = t[2..]; + uint.TryParse(t, System.Globalization.NumberStyles.HexNumber, null, out layoutId); + } + + // First: scan ALL LayoutDescs that contain a vitals meter element, with root size, + // so we can tell whether 0x21000014 is the one the user sees (row vs stacked). + Console.WriteLine("=== LayoutDescs containing a vitals meter element (0x100000E6/EC/EE) ==="); + foreach (var id in dats.GetAllIdsOfType()) + { + var l = dats.Get(id); + if (l is null) continue; + if (!ContainsAny(l, 0x100000E6u, 0x100000ECu, 0x100000EEu)) continue; + Console.WriteLine($" 0x{id:X8} {RootSizeSummary(l)}"); + } + Console.WriteLine(); + + var ld = dats.Get(layoutId); + if (ld is null) { Console.Error.WriteLine($"layout 0x{layoutId:X8} not found"); return 1; } + + Console.WriteLine($"=== FULL DUMP layout 0x{layoutId:X8} ==="); + DumpScalars("LayoutDesc", ld, 0); + foreach (var kv in ld.Elements) + DumpElement(kv.Value, 1); + return 0; + } + + private static bool ContainsAny(LayoutDesc l, params uint[] ids) + { + foreach (var kv in l.Elements) + if (ElemContains(kv.Value, ids)) return true; + return false; + } + + private static bool ElemContains(ElementDesc e, uint[] ids) + { + if (Array.IndexOf(ids, e.ElementId) >= 0) return true; + foreach (var kv in e.Children) + if (ElemContains(kv.Value, ids)) return true; + return false; + } + + private static string RootSizeSummary(LayoutDesc l) + { + // Print any LayoutDesc-level scalar that looks like a size. + var sb = new System.Text.StringBuilder(); + foreach (var p in l.GetType().GetProperties()) + { + if (p.GetIndexParameters().Length > 0) continue; + if (p.Name is "Elements") continue; + object? v; try { v = p.GetValue(l); } catch { continue; } + if (v is null) continue; + if (IsScalar(v)) sb.Append($"{p.Name}={v} "); + } + return sb.ToString().Trim(); + } + + private static void DumpElement(ElementDesc e, int depth) + { + string ind = new string(' ', depth * 2); + Console.WriteLine($"{ind}element 0x{e.ElementId:X8}"); + DumpScalars(ind + " ", e, depth); + + if (e.StateDesc is not null) DumpMedia(ind + " [DirectState]", e.StateDesc); + foreach (var s in e.States) + DumpMedia($"{ind} [state {s.Key}]", s.Value); + + foreach (var c in e.Children) + DumpElement(c.Value, depth + 1); + } + + private static readonly HashSet Skip = new() { "Children", "States", "StateDesc", "Elements", "Media" }; + + private static void DumpScalars(string label, object o, int depth) + { + foreach (var (name, val) in Members(o)) + { + if (Skip.Contains(name)) continue; + if (IsScalar(val)) + Console.WriteLine($"{label} {name} = {Fmt(name, val)}"); + } + } + + private static void DumpMedia(string label, StateDesc sd) + { + foreach (var m in sd.Media) + { + var sb = new System.Text.StringBuilder(); + foreach (var (name, val) in Members(m)) + if (IsScalar(val)) sb.Append($"{name}={Fmt(name, val)} "); + Console.WriteLine($"{label} {m.GetType().Name}: {sb.ToString().Trim()}"); + } + } + + /// Enumerate public properties AND public fields (the DatReaderWriter + /// generated types expose geometry/file ids as fields, not properties). + private static IEnumerable<(string name, object val)> Members(object o) + { + var t = o.GetType(); + foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (p.GetIndexParameters().Length > 0) continue; + object? v; try { v = p.GetValue(o); } catch { continue; } + if (v is not null) yield return (p.Name, v); + } + foreach (var f in t.GetFields(BindingFlags.Public | BindingFlags.Instance)) + { + object? v; try { v = f.GetValue(o); } catch { continue; } + if (v is not null) yield return (f.Name, v); + } + } + + private static string Fmt(string name, object v) => + name.Contains("File", StringComparison.OrdinalIgnoreCase) && v is uint u ? $"0x{u:X8}" : v.ToString() ?? ""; + + private static bool IsScalar(object v) + { + var t = v.GetType(); + if (v is string) return true; + if (t.IsPrimitive || t.IsEnum) return true; + if (v is IEnumerable) return false; + // value-type structs (Rectangle/Point/etc.) — print via ToString + return t.IsValueType; + } +} diff --git a/src/AcDream.Cli/VitalsMockup.cs b/src/AcDream.Cli/VitalsMockup.cs new file mode 100644 index 00000000..445a918b --- /dev/null +++ b/src/AcDream.Cli/VitalsMockup.cs @@ -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; + +/// +/// 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). +/// +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(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 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 0x1000063B–0x10000642). Edges shown + // stretched here for the preview; the client tiles them via UV-repeat. + using (var gc = Load(dats, 0x06006129)) + using (var gt = Load(dats, 0x0600612A)) using (var gb = Load(dats, 0x0600612C)) + using (var gl = Load(dats, 0x0600612B)) using (var gr = Load(dats, 0x0600612D)) + { + Blit(canvas, gt, Border, offY, winW - 2 * Border, Border); + Blit(canvas, gb, Border, offY + winH - Border, winW - 2 * Border, Border); + Blit(canvas, gl, 0, offY + Border, Border, winH - 2 * Border); + Blit(canvas, gr, winW - Border, offY + Border, Border, winH - 2 * Border); + Blit(canvas, gc, 0, offY, Border, Border); + Blit(canvas, gc, winW - Border, offY, Border, Border); + Blit(canvas, gc, 0, offY + winH - Border, Border, Border); + Blit(canvas, gc, winW - Border, offY + winH - Border, Border, Border); + } + + for (int i = 0; i < Vitals.Length; i++) + { + var v = Vitals[i]; + 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); + } + } + + /// Horizontal 3-slice: native-width left-cap, middle (STRETCHED or TILED + /// per ), native-width right-cap; clipped to clipW. + private static void DrawHBar( + Image canvas, Image left, Image mid, Image right, + int x, int y, int w, int h, int clipW, 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) + } + + /// Fill [midLocalX, midLocalX+midW] by repeating the native-width tile at + /// 1:1 (no horizontal scaling), clipping the final partial copy and honouring clipW. + private static void TileMiddle( + Image canvas, Image mid, int x, int y, int midLocalX, int midW, int h, int clipW) + { + int tileW = Math.Max(1, mid.Width); + for (int mx = 0; mx < midW; mx += tileW) + { + int localX = midLocalX + mx; + int segW = Math.Min(tileW, midW - mx); // last copy may be partial + int visible = Math.Min(segW, clipW - localX); // fill-fraction clip + if (visible <= 0) break; + // 1:1 — crop the source to `visible` px (no resize-stretch), draw at native scale. + int cropW = Math.Min(visible, mid.Width); + using var seg = mid.Clone(c => c.Crop(new Rectangle(0, 0, cropW, mid.Height)).Resize(visible, h)); + canvas.Mutate(c => c.DrawImage(seg, new Point(x + localX, y), 1f)); + } + } + + /// Draw one slice spanning [pieceLocalX, +pieceW] STRETCHED to fill, UV-cropped + /// (proportionally) so nothing past clipW shows. + private static void DrawClippedPiece( + Image canvas, Image src, int x, int y, int pieceLocalX, int pieceW, int h, int clipW) + { + if (pieceW <= 0) return; + int visibleW = Math.Min(pieceW, clipW - pieceLocalX); + if (visibleW <= 0) return; + int srcCropW = Math.Max(1, (int)MathF.Round(src.Width * (visibleW / (float)pieceW))); + srcCropW = Math.Min(srcCropW, src.Width); + using var piece = src.Clone(c => c.Crop(new Rectangle(0, 0, srcCropW, src.Height)).Resize(visibleW, h)); + canvas.Mutate(c => c.DrawImage(piece, new Point(x + pieceLocalX, y), 1f)); + } + + private static void Blit(Image canvas, Image src, int x, int y, int dw, int dh) + { + 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)); + } + + /// Composite a comma-separated list of sprite ids into one row, magnified, + /// on a neutral background — so the exact chrome/bar graphics can be eyeballed. + public static int ExportSheet(string datDir, string idsCsv, string outPath) + { + if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + + var ids = idsCsv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(ParseHex).Where(x => x != 0).ToArray(); + if (ids.Length == 0) { Console.Error.WriteLine("no valid ids"); return 2; } + + var imgs = ids.Select(id => Load(dats, id)).ToArray(); + const int pad = 6, zoom = 10; + int totalW = pad + imgs.Sum(i => i.Width + pad); + int maxH = imgs.Max(i => i.Height); + using var canvas = new Image(totalW, maxH + 2 * pad, new Rgba32(64, 64, 72, 255)); + int x = pad; + foreach (var im in imgs) + { + canvas.Mutate(c => c.DrawImage(im, new Point(x, pad), 1f)); + x += im.Width + pad; + } + canvas.Mutate(c => c.Resize(canvas.Width * zoom, canvas.Height * zoom, KnownResamplers.NearestNeighbor)); + canvas.SaveAsPng(outPath); + Console.WriteLine("order (L→R): " + string.Join(" ", ids.Zip(imgs, (id, im) => $"0x{id:X8}={im.Width}x{im.Height}"))); + foreach (var im in imgs) im.Dispose(); + return 0; + } + + public static int ExportSprite(string datDir, string idText, string outPath) + { + if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; } + uint id = ParseHex(idText); + if (id == 0) { Console.Error.WriteLine($"error: bad id '{idText}'"); return 2; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var img = Load(dats, id); + img.SaveAsPng(outPath); + Console.WriteLine($"wrote {outPath} (0x{id:X8} {img.Width}x{img.Height})"); + return 0; + } + + private static Image Load(DatCollection dats, uint id) + { + var rs = dats.Get(id); + if (rs is null) { Console.Error.WriteLine($" missing RenderSurface 0x{id:X8}"); return new Image(1, 1); } + var dt = SurfaceDecoder.DecodeRenderSurface(rs); + return Image.LoadPixelData(dt.Rgba8, dt.Width, dt.Height); + } + + private static uint ParseHex(string s) + { + s = s.Trim(); + if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) s = s[2..]; + return uint.TryParse(s, System.Globalization.NumberStyles.HexNumber, + System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : 0u; + } +} diff --git a/src/AcDream.Core/Textures/SurfaceDecoder.cs b/src/AcDream.Core/Textures/SurfaceDecoder.cs index 49cfe199..f727a59c 100644 --- a/src/AcDream.Core/Textures/SurfaceDecoder.cs +++ b/src/AcDream.Core/Textures/SurfaceDecoder.cs @@ -80,6 +80,11 @@ public static class SurfaceDecoder /// public static DecodedTexture DecodeSolidColor(DatReaderWriter.Types.ColorARGB color, float translucency) { + // Malformed Base1Solid (or OrigTextureId==0) surface with no color value: + // signal undecodable (Magenta) instead of NRE. This method is called + // directly from TextureCache.DecodeFromDats, OUTSIDE DecodeRenderSurface's + // try/catch, so it must be null-safe itself. + if (color is null) return DecodedTexture.Magenta; float opacity = Math.Clamp(1f - translucency, 0f, 1f); byte alpha = (byte)Math.Clamp(color.Alpha * opacity, 0f, 255f); return new DecodedTexture( diff --git a/src/AcDream.Plugin.Abstractions/IPluginHost.cs b/src/AcDream.Plugin.Abstractions/IPluginHost.cs index 7374ea91..dca64d7b 100644 --- a/src/AcDream.Plugin.Abstractions/IPluginHost.cs +++ b/src/AcDream.Plugin.Abstractions/IPluginHost.cs @@ -10,4 +10,5 @@ public interface IPluginHost IPluginLogger Log { get; } IGameState State { get; } IEvents Events { get; } + IUiRegistry Ui { get; } } diff --git a/src/AcDream.Plugin.Abstractions/IUiRegistry.cs b/src/AcDream.Plugin.Abstractions/IUiRegistry.cs new file mode 100644 index 00000000..1b724f1a --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/IUiRegistry.cs @@ -0,0 +1,14 @@ +namespace AcDream.Plugin.Abstractions; + +/// +/// Plugin-facing UI registration. A plugin ships a markup file (KSML-style) + +/// a binding object exposing the data properties the markup binds to, and +/// registers it from Enable(). Calls made before the GL window opens are +/// buffered and drained once the UI host exists. +/// +public interface IUiRegistry +{ + /// Absolute path to the plugin's panel markup file. + /// Object whose properties the markup's {Bindings} resolve against. + void AddMarkupPanel(string markupPath, object binding); +} diff --git a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs index 84bafce3..e62dc5e2 100644 --- a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs +++ b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs @@ -141,6 +141,12 @@ public sealed class InputDispatcher public bool IsActionHeld(InputAction action) { if (action == InputAction.None) return false; + // While a text field owns the keyboard ("write mode"), held game actions read as + // released: typing "swd" must not move the character. This is the polling-path twin + // of the WantCaptureKeyboard gate on Fired actions. NOTE: this suppresses KEY-driven + // movement only — latched state that isn't a key (e.g. autorun, ORed into Forward at + // the call site) keeps driving the character, so chat doesn't cancel autorun. + if (_mouse.WantCaptureKeyboard) return false; foreach (var b in _bindings.ForAction(action)) { if (IsChordHeld(b.Chord)) return true; diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs new file mode 100644 index 00000000..9158d2d0 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs @@ -0,0 +1,78 @@ +using System; + +namespace AcDream.UI.Abstractions.Panels.Chat; + +/// What a submit did, so the caller can clear its input + give feedback. +public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped } + +/// +/// Shared chat-submit pipeline (retail ChatInterface::ProcessCommand @0x4f5100 +/// analogue). Both the ImGui devtools and the retail +/// chat window route through here so command handling stays in one place. +/// +/// Order mirrors the prior inline flow: +/// client-command intercept → unknown-slash-verb guard → +/// → Publish(SendChatCmd). +/// +public static class ChatCommandRouter +{ + public static SubmitOutcome Submit( + string raw, ChatVM vm, ICommandBus bus, ChatChannelKind defaultChannel) + { + ArgumentNullException.ThrowIfNull(vm); + ArgumentNullException.ThrowIfNull(bus); + var trimmed = (raw ?? string.Empty).Trim(); + if (trimmed.Length == 0) return SubmitOutcome.Empty; + + if (TryHandleClientCommand(trimmed, vm)) return SubmitOutcome.ClientHandled; + + if (trimmed[0] == '/') + { + var verb = ChatInputParser.GetVerbToken(trimmed); + if (!ChatInputParser.IsKnownVerb(verb)) + { + vm.ShowSystemMessage( + $"Unknown command: {verb}. Type /help for the list of supported commands."); + return SubmitOutcome.UnknownCommand; + } + } + + var parsed = ChatInputParser.Parse( + trimmed, defaultChannel, vm.LastIncomingTellSender, vm.LastOutgoingTellTarget); + if (parsed is { } p) + { + bus.Publish(new SendChatCmd(p.Channel, p.TargetName, p.Text)); + return SubmitOutcome.Sent; + } + return SubmitOutcome.Dropped; + } + + private static bool TryHandleClientCommand(string trimmed, ChatVM vm) + { + if (EqAny(trimmed, "/help", "/?", "/h", "@help", "@?", "@h")) + { vm.ShowSystemMessage(BuildHelpText()); return true; } + if (EqAny(trimmed, "/clear", "/cls", "@clear", "@cls")) + { vm.Clear(); return true; } + if (EqAny(trimmed, "/framerate", "@framerate")) + { vm.ShowFps(); return true; } + if (EqAny(trimmed, "/loc", "@loc")) + { vm.ShowLocation(); return true; } + return false; + } + + private static bool EqAny(string s, params string[] options) + { + for (int i = 0; i < options.Length; i++) + if (s.Equals(options[i], StringComparison.OrdinalIgnoreCase)) return true; + return false; + } + + private static string BuildHelpText() => + "Note: / and @ are equivalent prefixes.\n" + + "Chat: /say (default), /tell , /reply, /retell\n" + + "Channels: /general /trade /fellowship /allegiance\n" + + " /patron /vassals /monarch /covassals\n" + + " /lfg /roleplay /society /olthoi\n" + + "Client: /help (this) /clear /framerate /loc\n" + + "Server: type @acehelp or @acecommands for ACE's full list."; +} diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs index c8ece999..9cb8cb1f 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs @@ -191,53 +191,7 @@ public sealed class ChatPanel : IPanel if (renderer.InputTextSubmit("##chatinput", ref _input, InputBufferMaxLen, out var submitted) && submitted is not null) { - var trimmed = submitted.Trim(); - // Phase J follow-up: client-side commands intercepted before - // the server-bound parse path. Avoids the /help round-trip - // that produced "Unknown command: help" duplicates from - // ACE's command-error replies, AND gives users a discoverable - // local cheat-sheet of acdream's own slash prefixes. - if (TryHandleClientCommand(trimmed)) - { - _input = string.Empty; - renderer.EndChild(); // outer ##chatbody - renderer.End(); - return; - } - - // Phase J Tier 4: any /-prefixed input that ISN'T one of our - // known verbs gets a local "Unknown command" message instead - // of being broadcast to the server as plain speech. The - // user reported "/ls" / "/mp /path" leaking out as chat — - // a / prefix is a command, never speech. (@-prefixed unknown - // verbs still pass through to ACE because ACE's - // CommandManager intercepts @ server-side and replies with - // its own "Unknown command" / valid command output.) - if (trimmed.Length > 0 && trimmed[0] == '/') - { - string verb = ChatInputParser.GetVerbToken(trimmed); - if (!ChatInputParser.IsKnownVerb(verb)) - { - _vm.ShowSystemMessage( - $"Unknown command: {verb}. Type /help for the list of supported commands."); - _input = string.Empty; - renderer.EndChild(); // outer ##chatbody - renderer.End(); - return; - } - } - - var parsed = ChatInputParser.Parse( - trimmed, - ChatChannelKind.Say, - _vm.LastIncomingTellSender, - _vm.LastOutgoingTellTarget); - if (parsed is { } p) - { - ctx.Commands.Publish(new SendChatCmd(p.Channel, p.TargetName, p.Text)); - } - // Defensive: if the backend ever forgot to clear on submit, - // do it here. Cheap; no harm if already empty. + ChatCommandRouter.Submit(submitted, _vm, ctx.Commands, ChatChannelKind.Say); _input = string.Empty; } @@ -258,79 +212,4 @@ public sealed class ChatPanel : IPanel _ => new Vector4(1f, 1f, 1f, 1f), }; - /// - /// Phase J follow-up: handle client-side slash commands before - /// the parser passes anything to the server bus. Returns true - /// when the input was consumed (and the caller should clear the - /// buffer + skip the SendChatCmd path); false otherwise. - /// - /// - /// Recognised client-side commands: - /// - /// /help, /?, /h — render the slash-prefix - /// cheat-sheet locally. Avoids the server's "Unknown command" - /// round-trip when the user just wants to know what they can - /// type. - /// /clear, /cls — drain the chat log so the - /// panel starts empty. - /// - /// - private bool TryHandleClientCommand(string trimmed) - { - if (trimmed.Length == 0) return false; - - // /help, /?, /h — also @help, @?, @h per ACE's "/ ↔ @" equivalence. - if (EqAny(trimmed, "/help", "/?", "/h", "@help", "@?", "@h")) - { - _vm.ShowSystemMessage(BuildHelpText()); - return true; - } - - // /clear, /cls — also @clear, @cls. - if (EqAny(trimmed, "/clear", "/cls", "@clear", "@cls")) - { - _vm.Clear(); - return true; - } - - // /framerate — also @framerate. Prints current FPS to chat. - if (EqAny(trimmed, "/framerate", "@framerate")) - { - _vm.ShowFps(); - return true; - } - - // /loc — also @loc. Prints current player position to chat. - // ACE has a server-side @loc too; client-side wins here - // (instantaneous + uses our local interpolated position). - if (EqAny(trimmed, "/loc", "@loc")) - { - _vm.ShowLocation(); - return true; - } - - return false; - } - - /// Case-insensitive multi-string equality test. - private static bool EqAny(string s, params string[] options) - { - for (int i = 0; i < options.Length; i++) - if (s.Equals(options[i], StringComparison.OrdinalIgnoreCase)) return true; - return false; - } - - /// - /// Multi-line cheat-sheet text rendered by /help. ImGui's - /// Text path flows embedded newlines naturally so this lands - /// as one ChatLog entry that visually wraps to several lines. - /// - private static string BuildHelpText() => - "Note: / and @ are equivalent prefixes.\n" + - "Chat: /say (default), /tell , /reply, /retell\n" + - "Channels: /general /trade /fellowship /allegiance\n" + - " /patron /vassals /monarch /covassals\n" + - " /lfg /roleplay /society /olthoi\n" + - "Client: /help (this) /clear /framerate /loc\n" + - "Server: type @acehelp or @acecommands for ACE's full list."; } diff --git a/tests/AcDream.App.Tests/AcDream.App.Tests.csproj b/tests/AcDream.App.Tests/AcDream.App.Tests.csproj index 5ab79928..272953e3 100644 --- a/tests/AcDream.App.Tests/AcDream.App.Tests.csproj +++ b/tests/AcDream.App.Tests/AcDream.App.Tests.csproj @@ -22,4 +22,10 @@ + + + PreserveNewest + + + diff --git a/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs b/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs new file mode 100644 index 00000000..6e22e17f --- /dev/null +++ b/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs @@ -0,0 +1,21 @@ +using AcDream.App.Plugins; + +namespace AcDream.App.Tests.Plugins; + +public class BufferedUiRegistryTests +{ + [Fact] + public void Drain_YieldsBufferedRegistrationsOnceThenEmpty() + { + var reg = new BufferedUiRegistry(); + reg.AddMarkupPanel("a.xml", new object()); + reg.AddMarkupPanel("b.xml", new object()); + + var drained = reg.Drain(); + Assert.Equal(2, drained.Count); + Assert.Equal("a.xml", drained[0].MarkupPath); + Assert.Equal("b.xml", drained[1].MarkupPath); + + Assert.Empty(reg.Drain()); // consumed + } +} diff --git a/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs new file mode 100644 index 00000000..b18590ae --- /dev/null +++ b/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using AcDream.App; + +namespace AcDream.App.Tests; + +public class RuntimeOptionsRetailUiTests +{ + [Fact] + public void Parse_ReadsRetailUiAndAcDir() + { + var env = new Dictionary + { + ["ACDREAM_RETAIL_UI"] = "1", + ["ACDREAM_AC_DIR"] = @"C:\Turbine\Asheron's Call", + }; + var opts = RuntimeOptions.Parse("dats", k => env.GetValueOrDefault(k)); + Assert.True(opts.RetailUi); + Assert.Equal(@"C:\Turbine\Asheron's Call", opts.AcDir); + } + + [Fact] + public void Parse_DefaultsRetailUiOffAndAcDirNull() + { + var opts = RuntimeOptions.Parse("dats", _ => null); + Assert.False(opts.RetailUi); + Assert.Null(opts.AcDir); + } +} diff --git a/tests/AcDream.App.Tests/UI/ControlsIniTests.cs b/tests/AcDream.App.Tests/UI/ControlsIniTests.cs new file mode 100644 index 00000000..d4802e27 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/ControlsIniTests.cs @@ -0,0 +1,38 @@ +using System.Numerics; +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public class ControlsIniTests +{ + [Fact] + public void Parse_ReadsSectionTokens() + { + var ini = ControlsIni.Parse( + "[title]\nheight=19\ncolor=#FFFFFFFF\nfont=font://Verdana-10-bold\n" + + "[body]\nbgcolor=#00000000\ncolor_border=#FF4F657D\n"); + + Assert.Equal("19", ini.Get("title", "height")); + Assert.Equal("font://Verdana-10-bold", ini.Get("title", "font")); + Assert.Null(ini.Get("title", "missing")); + Assert.Null(ini.Get("nosuch", "height")); + } + + [Fact] + public void TryColor_ParsesAlphaFirstHex() + { + var ini = ControlsIni.Parse("[body]\ncolor_border=#FF4F657D\n"); + Assert.True(ini.TryColor("body", "color_border", out Vector4 c)); + Assert.Equal(0xFF / 255f, c.W, 5); // alpha + Assert.Equal(0x4F / 255f, c.X, 5); // red + Assert.Equal(0x65 / 255f, c.Y, 5); // green + Assert.Equal(0x7D / 255f, c.Z, 5); // blue + } + + [Fact] + public void Load_MissingFileReturnsEmptyNotThrow() + { + var ini = ControlsIni.Load(@"Z:\does\not\exist\controls.ini"); + Assert.Null(ini.Get("title", "height")); // empty, no throw + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs new file mode 100644 index 00000000..836adbdc --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs @@ -0,0 +1,46 @@ +using AcDream.App.UI.Layout; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Dat-free conformance tests for the committed chat_21000006.json golden fixture. +/// Verifies that LayoutImporter.ImportInfos correctly resolves the BaseElement / +/// BaseLayoutId inheritance chain for the chat window (LayoutDesc 0x21000006). +/// +public class ChatLayoutConformanceTests +{ + private static ElementInfo? Find(ElementInfo n, uint id) + { + if (n.Id == id) return n; + foreach (var c in n.Children) + { + var f = Find(c, id); + if (f is not null) return f; + } + return null; + } + + [Fact] + public void ChatFixture_ResolvesKnownElements() + { + var root = FixtureLoader.LoadChatInfos(); + Assert.NotNull(Find(root, 0x10000011u)); // transcript + Assert.NotNull(Find(root, 0x10000016u)); // input + Assert.NotNull(Find(root, 0x10000012u)); // scrollbar track + Assert.NotNull(Find(root, 0x10000014u)); // channel menu + Assert.NotNull(Find(root, 0x10000019u)); // send button + Assert.NotNull(Find(root, 0x1000046Fu)); // max/min button + } + + [Fact] + public void ChatFixture_ResolvedTypes_MatchRetailRegistry() + { + var root = FixtureLoader.LoadChatInfos(); + Assert.Equal(6u, Find(root, 0x10000014u)!.Type); // Menu + Assert.Equal(11u, Find(root, 0x10000012u)!.Type); // Scrollbar + Assert.Equal(1u, Find(root, 0x10000019u)!.Type); // Button (Send) + Assert.Equal(1u, Find(root, 0x1000046Fu)!.Type); // Button (Max/Min) + Assert.Equal(12u, Find(root, 0x10000011u)!.Type); // Text/style-prototype (transcript) + Assert.Equal(12u, Find(root, 0x10000016u)!.Type); // Text/style-prototype (input) + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs new file mode 100644 index 00000000..cdc89c5f --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs @@ -0,0 +1,39 @@ +using System; +using System.IO; +using System.Runtime.CompilerServices; +using System.Text.Json; +using AcDream.App.UI.Layout; +using DatReaderWriter; +using DatReaderWriter.Options; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// One-off generator for the committed chat golden fixture. Skipped by default — +/// run manually with the real dats present (set ACDREAM_DAT_DIR) to regenerate +/// chat_21000006.json, then commit it. Mirrors how vitals_2100006C.json was made. +/// +public class ChatLayoutFixtureGenerator +{ + [Fact(Skip = "manual: regenerates the committed chat fixture; needs the real dats (ACDREAM_DAT_DIR)")] + public void GenerateChatFixture() + { + var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + using var dats = new DatCollection(datDir, DatAccessType.Read); + var info = LayoutImporter.ImportInfos(dats, 0x21000006u); + Assert.NotNull(info); + + var json = JsonSerializer.Serialize(info, new JsonSerializerOptions + { + IncludeFields = true, + WriteIndented = true, + }); + File.WriteAllText(FixturePath(), json); + } + + // Resolve the SOURCE fixtures dir (not bin/) from this file's compile-time path. + private static string FixturePath([CallerFilePath] string thisFile = "") + => Path.Combine(Path.GetDirectoryName(thisFile)!, "fixtures", "chat_21000006.json"); +} diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs new file mode 100644 index 00000000..aab080cd --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs @@ -0,0 +1,209 @@ +using System.Collections.Generic; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Core.Chat; +using AcDream.UI.Abstractions; +using AcDream.UI.Abstractions.Panels.Chat; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Smoke tests for — no dats, no GL. +/// +/// Building the Type-12 "skipped" elements via the pure +/// path is the correct approach: we build a synthetic info tree that reflects the +/// real chat layout hierarchy (root → transcript panel + input bar as Type-3 +/// containers, with Type-12 children for transcript + input, plus a Type-3 track +/// and menu), call to get the widget tree +/// (Type-12 children skipped, Type-3 parents created), then call +/// which reads rects from the info tree +/// and places behavioral widgets under the parent containers. +/// +public class ChatWindowControllerTests +{ + // ── Null-resolve helper (no GL needed) ───────────────────────────────── + private static (uint, int, int) NoTex(uint _) => (0u, 0, 0); + + // ── Capture bus — records every Publish call ──────────────────────────── + private sealed class CaptureBus : ICommandBus + { + public readonly List Published = new(); + public void Publish(T cmd) where T : notnull => Published.Add(cmd!); + } + + // ── Synthetic element tree matching the real chat layout topology ──────── + + /// + /// Build a minimal synthetic ElementInfo tree that mirrors the real chat + /// layout (0x21000006) with enough fidelity for Bind to succeed: + /// root (Type-3) + /// transcriptPanel (Type-3) [0x10000010] + /// transcript (Type-12, no media) [0x10000011] ← 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] + /// + 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(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(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); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs new file mode 100644 index 00000000..ce7e63f9 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -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(e); + } + + // ── Test 2: Unknown type → UiDatElement fallback ───────────────────────── + + [Fact] + public void UnknownType_FallsBackToGeneric() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 999 }, NoTex, null); + Assert.IsType(e); + } + + // ── Test 3: Type 12 → 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(e); + } + + // ── Test 4: Rect + anchors set from ElementInfo ─────────────────────────── + + /// + /// A Type-3 element with X=5,Y=21,W=150,H=16, Left=1,Top=1,Right=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. + /// + [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(DatWidgetFactory.Create(withMedia, NoTex, null)); + Assert.IsType(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(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(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(e); + } + + // ── Test 5d: Type 6 → UiMenu ───────────────────────────────────────────── + + [Fact] + public void Type6_Menu_MakesUiMenu() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 6, Width = 46, Height = 18 }, NoTex, null); + Assert.IsType(e); + } + + // ── Test 6: Meter slice extraction (the important one) ─────────────────── + + /// + /// A meter (Type 7) whose two Type-3 containers each carry 3 image children + /// (ordered by X, bearing a DirectState "" sprite), plus the front container + /// has a fourth expand-overlay child with ONLY a named "ShowDetail" state — + /// that overlay must be excluded from the slice count. + /// + [Fact] + public void MeterSliceExtraction_ReadsGrandchildImageIds_IgnoresOverlay() + { + // Slice ids sourced from format doc §11 — real health-bar ids. + const uint BackL = 0x0600747Eu, BackT = 0x0600747Fu, BackR = 0x06007480u; + const uint FrontL = 0x06007481u, FrontT = 0x06007482u, FrontR = 0x06007483u; + const uint OverlayFile = 0x06007490u; + + // Back container (ReadOrder 0 — drawn first / behind) + var backChild = new ElementInfo { Type = 3, ReadOrder = 0 }; + backChild.Children.Add(new ElementInfo { X = 0, StateMedia = { [""] = (BackL, 1) } }); + backChild.Children.Add(new ElementInfo { X = 10, StateMedia = { [""] = (BackT, 1) } }); + backChild.Children.Add(new ElementInfo { X = 140, StateMedia = { [""] = (BackR, 1) } }); + + // Front container (ReadOrder 1 — drawn on top) + var frontChild = new ElementInfo { Type = 3, ReadOrder = 1 }; + frontChild.Children.Add(new ElementInfo { X = 0, StateMedia = { [""] = (FrontL, 1) } }); + frontChild.Children.Add(new ElementInfo { X = 10, StateMedia = { [""] = (FrontT, 1) } }); + frontChild.Children.Add(new ElementInfo { X = 140, StateMedia = { [""] = (FrontR, 1) } }); + // Expand-detail overlay: named state only — NO DirectState "" — must be ignored. + frontChild.Children.Add(new ElementInfo + { + X = 0, + StateMedia = { ["ShowDetail"] = (OverlayFile, 3) } + }); + + var meter = new ElementInfo { Type = 7, Width = 150, Height = 16 }; + meter.Children.Add(backChild); + meter.Children.Add(frontChild); + + var e = DatWidgetFactory.Create(meter, NoTex, null); + + var m = Assert.IsType(e); + Assert.Equal(BackL, m.BackLeft); + Assert.Equal(BackT, m.BackTile); + Assert.Equal(BackR, m.BackRight); + Assert.Equal(FrontL, m.FrontLeft); + Assert.Equal(FrontT, m.FrontTile); + Assert.Equal(FrontR, m.FrontRight); + // Overlay (ShowDetail-only, no DirectState "") must not leak into any slice slot. + Assert.NotEqual(OverlayFile, m.FrontRight); + Assert.NotEqual(OverlayFile, m.FrontTile); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs b/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs new file mode 100644 index 00000000..9d79f58d --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs @@ -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) ───────────── + + /// + /// Top edge (L=1,T=1,R=1,B=2): LeftEdge==1 → Left; RightEdge==1 → Right (stretch); + /// TopEdge==1 → Top; BottomEdge==2 (not 1/4, top≠2) → no Bottom. + /// This is the top chrome edge — it pins left, stretches width, pins top, fixed height. + /// Real vitals values from format doc §11 (0x10000634). + /// + [Fact] + public void 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)); + } + + /// + /// TL corner (L=1,T=1,R=2,B=2): LeftEdge==1 → Left; RightEdge==2 (not 1/4), left≠2 → no Right; + /// TopEdge==1 → Top; BottomEdge==2, top≠2 → no Bottom. Fixed size, pinned top-left. + /// Real vitals values from format doc §11 (0x10000633). + /// + [Fact] + public void 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)); + } + + /// + /// TR corner (L=2,T=1,R=1,B=2): LeftEdge==2 → triggers Right (track-right); RightEdge==1 → Right; + /// left≠1 → no Left; TopEdge==1 → Top; BottomEdge==2, top≠2 → no Bottom. + /// Fixed-width element whose left and right both track the parent's right edge. + /// Real vitals values from format doc §11 (0x10000635). + /// + [Fact] + public void 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)); + } + + /// + /// Left edge (L=1,T=1,R=2,B=1): LeftEdge==1 → Left; RightEdge==2, left≠2 → no Right; + /// TopEdge==1 → Top; BottomEdge==1 → Bottom. Pins left+top+bottom, fixed width, stretches height. + /// Real vitals values from format doc §11 (0x10000636). + /// + [Fact] + public void ToAnchors_LeftEdge_StretchesHeight() + { + var a = ElementReader.ToAnchors(left: 1, top: 1, right: 2, bottom: 1); + Assert.True(a.HasFlag(AnchorEdges.Left)); + Assert.True(a.HasFlag(AnchorEdges.Top)); + Assert.False(a.HasFlag(AnchorEdges.Right)); + Assert.True(a.HasFlag(AnchorEdges.Bottom)); + } + + /// + /// All-ones (L=1,T=1,R=1,B=1): all four flags fire — Left, Right, Top, Bottom. + /// A piece pinned to all four sides stretches both horizontally and vertically. + /// + [Fact] + public void ToAnchors_Meter_StretchesBoth() + { + var a = ElementReader.ToAnchors(left: 1, top: 1, right: 1, bottom: 1); + Assert.True(a.HasFlag(AnchorEdges.Left)); + Assert.True(a.HasFlag(AnchorEdges.Top)); + Assert.True(a.HasFlag(AnchorEdges.Right)); + Assert.True(a.HasFlag(AnchorEdges.Bottom)); + } + + /// + /// All-zero edge flags (prototype-only elements) fall back to Left|Top default. + /// + [Fact] + public void EdgeFlagsToAnchors_AllZero_DefaultsToTopLeft() + { + var a = ElementReader.ToAnchors(left: 0, top: 0, right: 0, bottom: 0); + Assert.Equal(AnchorEdges.Left | AnchorEdges.Top, a); + } + + /// + /// Value 3 on left and right axes contributes no Left/Right anchor; + /// TopEdge==1 → Top; BottomEdge==1 → Bottom. + /// left=3 (not 1/4) → no Left; right=3 (not 1/4), left≠2 → no Right; + /// top=1 → Top; bottom=1 → Bottom. Result: Top|Bottom. + /// + [Fact] + public void EdgeFlagsToAnchors_ValueThree_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); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs b/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs new file mode 100644 index 00000000..c7338ba1 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs @@ -0,0 +1,75 @@ +using System.IO; +using System.Text.Json; +using AcDream.App.UI.Layout; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Loads the committed layout ElementInfo fixtures and builds widget trees — +/// no dats required. Fixtures were generated from the real portal.dat and +/// serialized with . +/// +public static class FixtureLoader +{ + private static readonly JsonSerializerOptions _opts = new() + { + IncludeFields = true, + }; + + /// + /// Deserializes the committed vitals_2100006C.json fixture (copied to + /// the test output directory via the csproj CopyToOutputDirectory item) + /// into an tree, then builds and returns the + /// using a null-returning sprite resolver and no + /// dat font — sufficient for conformance checks on tree structure and slice ids. + /// + public static ImportedLayout LoadVitals() + { + var root = LoadVitalsInfos(); + return LayoutImporter.Build(root, _ => (0u, 0, 0), null); + } + + /// + /// Deserializes the committed vitals_2100006C.json fixture into a raw + /// tree WITHOUT calling . + /// Use this when the test needs to inspect the resolved + /// tree directly (e.g. inheritance-resolution checks) without exercising the + /// widget factory. + /// + public static AcDream.App.UI.Layout.ElementInfo LoadVitalsInfos() + => LoadInfos("vitals_2100006C.json"); + + /// + /// Deserializes the committed chat_21000006.json fixture into a raw + /// tree and builds the + /// using a null-returning sprite resolver and no dat font — sufficient for + /// conformance checks on tree structure and resolved types. + /// + public static ImportedLayout LoadChat() + => LayoutImporter.Build(LoadChatInfos(), _ => (0u, 0, 0), null); + + /// + /// Deserializes the committed chat_21000006.json fixture into a raw + /// tree WITHOUT calling . + /// Use this when the test needs to inspect the resolved + /// tree directly (e.g. resolved Type values per element id). + /// + public static AcDream.App.UI.Layout.ElementInfo LoadChatInfos() + => LoadInfos("chat_21000006.json"); + + // ── Shared loader ──────────────────────────────────────────────────────── + + private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName) + { + var path = Path.Combine(AppContext.BaseDirectory, "UI", "Layout", "fixtures", fileName); + if (!File.Exists(path)) throw new FileNotFoundException($"fixture not found at: {path}"); + var bytes = File.ReadAllBytes(path); + // Strip UTF-8 BOM (EF BB BF) if present so JsonSerializer.Deserialize(ReadOnlySpan) + // does not reject the first byte. + ReadOnlySpan span = bytes; + if (span.Length >= 3 && span[0] == 0xEF && span[1] == 0xBB && span[2] == 0xBF) + span = span[3..]; + return JsonSerializer.Deserialize(span, _opts) + ?? throw new InvalidOperationException($"fixture deserialized to null: {path}"); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs new file mode 100644 index 00000000..ba336aac --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs @@ -0,0 +1,198 @@ +using AcDream.App.UI; +using AcDream.App.UI.Layout; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Golden conformance tests for the vitals LayoutDesc importer. +/// Uses the committed JSON fixture (vitals_2100006C.json) — no dats, no GL. +/// +/// These tests lock the importer's tree-building (factory dispatch, meter slice +/// extraction, rects) against the real portal.dat values captured when the +/// fixture was generated. Any regression in , +/// , or will surface here. +/// +/// Sprite ids sourced from docs/research/2026-06-15-layoutdesc-format.md §11. +/// +[Trait("Category", "Conformance")] +public class LayoutConformanceTests +{ + // ── Test 1: Three meters at expected rects ──────────────────────────────── + + /// + /// The three vital bars must be UiMeters positioned at x=5, width=150, height=16, + /// at y=5 (health), y=21 (stamina), y=37 (mana). + /// + [Fact] + public void VitalsTree_HasThreeMetersAtExpectedRects() + { + var layout = FixtureLoader.LoadVitals(); + + (uint Id, float Y)[] expected = + [ + (0x100000E6u, 5f), // health + (0x100000ECu, 21f), // stamina + (0x100000EEu, 37f), // mana + ]; + + foreach (var (id, y) in expected) + { + var elem = layout.FindElement(id); + Assert.NotNull(elem); + var meter = Assert.IsType(elem); + Assert.Equal(5f, meter.Left); + Assert.Equal(y, meter.Top); + Assert.Equal(150f, meter.Width); + Assert.Equal(16f, meter.Height); + } + } + + // ── Test 2: All 18 slice ids ────────────────────────────────────────────── + + /// + /// The six back+front 3-slice sprite ids for each of the three meters must + /// match the values confirmed from the dat dump (format doc §11). + /// This proves the factory's grandchild slice extraction against committed data. + /// + [Fact] + public void VitalsTree_MetersHaveExpectedSliceIds() + { + var layout = FixtureLoader.LoadVitals(); + + // 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(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. + + /// + /// The top-left chrome corner element (id 0x10000633) must be a + /// whose active media file id is 0x060074C3. + /// + [Fact] + public void VitalsTree_ChromeCornerHasExpectedSprite() + { + var layout = FixtureLoader.LoadVitals(); + + var elem = layout.FindElement(0x10000633u); + Assert.NotNull(elem); + var datElem = Assert.IsType(elem); + var (file, _) = datElem.ActiveMedia(); + Assert.Equal(0x060074C3u, file); + } + + // ── Test 4 (N4): Inheritance resolution — FontDid propagated from base ─── + + /// + /// Proves that Resolve()'s inheritance merge fired against real dat data: + /// at least one element in the fixture tree must have FontDid == 0x40000000 + /// (the vitals font), inherited from the base-layout prototype 0x10000376 + /// in 0x2100003F via the BaseElement / BaseLayoutId chain. + /// + /// + /// The three text labels (0x100000EB health, 0x100000ED stamina, + /// 0x100000EF mana) are Type=0 derived elements with no own font property. + /// The base element 0x10000376 carries Properties[0x1A] → + /// ArrayBaseProperty[ DataIdBaseProperty{Value=0x40000000} ]. + /// propagates this via the "FontDid: derived wins + /// if non-zero, otherwise inherit" rule. + /// + /// + /// + /// This test verifies end-to-end inheritance resolution against the committed fixture + /// (format doc §10, docs/research/2026-06-15-layoutdesc-format.md). + /// It operates on the raw tree, NOT the widget tree, + /// so the factory dispatch (Type 12 → skip) does not interfere. + /// + /// + [Fact] + public void VitalsTree_TextLabel_InheritsFontDidFromBaseLayout() + { + var root = FixtureLoader.LoadVitalsInfos(); + + // Walk the full ElementInfo tree and collect all FontDid values. + var fontDids = new System.Collections.Generic.List(); + CollectFontDids(root, fontDids); + + // At least one element must carry FontDid == 0x40000000 (the vitals font). + // In practice, the three text labels (health/stamina/mana) all inherit it. + Assert.Contains(0x40000000u, fontDids); + } + + private static void CollectFontDids(ElementInfo node, System.Collections.Generic.List acc) + { + if (node.FontDid != 0) acc.Add(node.FontDid); + foreach (var child in node.Children) + CollectFontDids(child, acc); + } + + // ── Test 5: Horizontal resize conformance (160→200) ────────────────────── + + /// + /// Proves end-to-end reflow for a 160→200 width change using the corrected + /// ToAnchors mapping (UIElement::UpdateForParentSizeChange @0x00462640). + /// + /// For each piece, margins are computed from the 160-wide design rect and then + /// is applied at parentW=200. + /// + /// Expected outcomes: + /// - TL corner (L=1,R=2): Left only → fixed at x=0, w=5 + /// - top edge (L=1,R=1): Left+Right → stretches to w=190 at x=5 + /// - TR corner (L=2,R=1): Right only → tracks right at x=195, w=5 + /// - meter (L=1,R=1): Left+Right → stretches to w=190 at x=5 + /// + [Fact] + public void HorizontalResize_160to200_ReflowsCorrectly() + { + const float designParentW = 160f; + const float newParentW = 200f; + const float parentH = 58f; + + // (piece, designX, designW, LeftEdge, RightEdge, expectedX, expectedW) + (string Piece, float DesignX, float DesignW, uint L, uint R, float ExpX, float ExpW)[] cases = + [ + ("TL corner", 0f, 5f, 1u, 2u, 0f, 5f ), + ("top edge", 5f, 150f, 1u, 1u, 5f, 190f), + ("TR corner", 155f, 5f, 2u, 1u, 195f, 5f ), + ("meter", 5f, 150f, 1u, 1u, 5f, 190f), + ]; + + foreach (var (piece, dX, dW, l, r, expX, expW) in cases) + { + // T/B values don't affect x/w; use real vitals values (top=1, bottom=2) + var anchors = ElementReader.ToAnchors(l, top: 1u, r, bottom: 2u); + + // Margins from the design rect at parentW=160 + float mL = dX; + float mR = designParentW - (dX + dW); + + // Reflow at parentW=200 (parentH irrelevant for x/w assertions) + var (x, _, w, _) = UiElement.ComputeAnchoredRect( + anchors, mL, mT: 0f, mR, mB: 0f, w0: dW, h0: 5f, parentW: newParentW, parentH); + + // xUnit 2.x Assert.Equal(float,float,int) = decimal-place precision + Assert.True(Math.Abs(x - expX) < 0.5f, $"{piece}: expected x={expX} got {x}"); + Assert.True(Math.Abs(w - expW) < 0.5f, $"{piece}: expected w={expW} got {w}"); + } + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs b/tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs new file mode 100644 index 00000000..a5f19e79 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs @@ -0,0 +1,106 @@ +using AcDream.App.UI; +using AcDream.App.UI.Layout; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Pure unit tests for — no dats, no GL. +/// Verifies the tree-builder: widget dispatch, Type-12 skipping, and meter child consumption. +/// +public class LayoutImporterTests +{ + private static (uint, int, int) NoTex(uint _) => (0, 0, 0); + + // ── Test 1: Health meter element → UiMeter with correct rect ───────────── + + /// + /// A Type-7 (meter) child element with X=5,Y=5,W=150,H=16 must produce a UiMeter + /// that is findable by its id, positioned at Left=5, Width=150. + /// The resolve lambda is a 1-arg Func<uint,(uint,int,int)>. + /// + [Fact] + public void BuildFromInfos_HealthMeter_IsUiMeterAtRect() + { + var root = new ElementInfo { Id = 0x100005F9, Type = 3, Width = 160, Height = 58 }; + var health = new ElementInfo { Id = 0x100000E6, Type = 7, X = 5, Y = 5, Width = 150, Height = 16 }; + + var tree = LayoutImporter.BuildFromInfos(root, new[] { health }, NoTex, null); + + var found = tree.FindElement(0x100000E6); + Assert.IsType(found); + Assert.Equal(5f, found!.Left); + Assert.Equal(150f, found.Width); + } + + // ── Test 2: Type-12 child builds a UiText; Type-3 sibling is also present ── + + /// + /// A root with two children: one Type-12 UIElement_Text and one Type-3 container. + /// The Type-12 must appear as a in the tree (transparent, + /// draws nothing until a controller binds its LinesProvider); + /// the Type-3 must also be present. + /// + [Fact] + public void BuildFromInfos_Type12Child_IsSkipped_Type3Present() + { + 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(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 ────────── + + /// + /// A meter (Type 7) whose children are the 3-slice back/front containers. + /// The meter itself must be findable; its direct children must NOT appear as + /// separate nodes in the tree (meters own their children, not the generic tree). + /// + [Fact] + public void BuildFromInfos_MeterWithChildren_MeterPresent_ChildrenNotInTree() + { + const uint MeterId = 0x100000E6u; + const uint BackLayerId = 0x100000E7u; + const uint FrontLayerId = 0x00000002u; + + // Build a minimal meter with back + front containers, each with 3 slice children. + var backContainer = BuildSliceContainer(BackLayerId, ReadOrder: 0, + l: 0x0600747Eu, t: 0x0600747Fu, r: 0x06007480u); + var frontContainer = BuildSliceContainer(FrontLayerId, ReadOrder: 1, + l: 0x06007481u, t: 0x06007482u, r: 0x06007483u); + + var meter = new ElementInfo { Id = MeterId, Type = 7, Width = 150, Height = 16 }; + meter.Children.Add(backContainer); + meter.Children.Add(frontContainer); + + var root = new ElementInfo { Id = 0x100005F9, Type = 3, Width = 160, Height = 58 }; + + var tree = LayoutImporter.BuildFromInfos(root, new[] { meter }, NoTex, null); + + // The meter widget is present. + Assert.IsType(tree.FindElement(MeterId)); + // The meter's dat-children are NOT separate UiElement nodes. + Assert.Null(tree.FindElement(BackLayerId)); + Assert.Null(tree.FindElement(FrontLayerId)); + // The UiMeter itself has no Ui children (meters consume their children internally). + var uiMeter = (UiMeter)tree.FindElement(MeterId)!; + Assert.Empty(uiMeter.Children); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static ElementInfo BuildSliceContainer(uint id, uint ReadOrder, uint l, uint t, uint r) + { + var c = new ElementInfo { Id = id, Type = 3, ReadOrder = ReadOrder }; + c.Children.Add(new ElementInfo { X = 0, StateMedia = { [""] = (l, 1) } }); + c.Children.Add(new ElementInfo { X = 10, StateMedia = { [""] = (t, 1) } }); + c.Children.Add(new ElementInfo { X = 140, StateMedia = { [""] = (r, 1) } }); + return c; + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs b/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs new file mode 100644 index 00000000..3f3ef20b --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs @@ -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 ─────────────── + + /// + /// Task G1 change 5: when an element has no DefaultStateName but does have a "Normal" + /// state sprite, the ctor should default ActiveState to "Normal" so the element + /// renders its normal-state sprite without requiring explicit state assignment. + /// + [Fact] + public void UiDatElement_DefaultsActiveStateToNormal_WhenNormalPresent() + { + var info = new ElementInfo(); + info.StateMedia["Normal"] = (0x0000AAAAu, 1); + info.StateMedia["Hover"] = (0x0000BBBBu, 1); + + var e = new UiDatElement(info, _ => (0, 0, 0)); + + // Should have defaulted to "Normal" state. + Assert.Equal(0x0000AAAAu, e.ActiveMedia().File); + } + + /// + /// Task G1 change 5: when DefaultStateName is set (e.g. "Minimized"), + /// it takes priority over the "Normal" implicit default. + /// + [Fact] + public void UiDatElement_DefaultsActiveStateToDefaultStateName_WhenSet() + { + var info = new ElementInfo { DefaultStateName = "Minimized" }; + info.StateMedia["Minimized"] = (0x0000BBBBu, 1); + info.StateMedia["Maximized"] = (0x0000CCCCu, 1); + info.StateMedia["Normal"] = (0x0000DDDDu, 1); + + var e = new UiDatElement(info, _ => (0, 0, 0)); + + // DefaultStateName "Minimized" wins over "Normal" implicit default. + Assert.Equal(0x0000BBBBu, e.ActiveMedia().File); + } + + /// + /// Task G1 change 5: elements with only a DirectState sprite and no "Normal" state + /// should still default to "" (DirectState) — no regression for chrome/grip elements. + /// + [Fact] + public void UiDatElement_NoDefaultStateName_NoNormal_DefaultsToDirectState() + { + var info = new ElementInfo(); + info.StateMedia[""] = (0x06007777u, 1); // DirectState only (e.g. vitals chrome corner) + + var e = new UiDatElement(info, _ => (0, 0, 0)); + + // No DefaultStateName, no "Normal" state → ActiveState stays "" (DirectState). + Assert.Equal(0x06007777u, e.ActiveMedia().File); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs b/tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs new file mode 100644 index 00000000..a0baad8e --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs @@ -0,0 +1,113 @@ +using AcDream.App.UI; +using AcDream.App.UI.Layout; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Unit tests for : verifies that the controller +/// correctly maps element ids to UiMeter instances and wires the Fill / Label providers. +/// No dats, no GL — pure data-wiring tests. +/// +public class VitalsBindingTests +{ + // ── Test 1: Health meter Fill + Label providers are bound ───────────────── + + [Fact] + public void Bind_SetsHealthMeterFillFromProvider() + { + var health = new UiMeter(); + var layout = FakeLayout((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 ─────────────────────────────────────────────────────────────── + + /// The cur/max text from the centered number that + /// attaches as the meter's child. + private static string NumberText(UiMeter m) + { + var num = Assert.IsType(m.Children[0]); + Assert.True(num.Centered); + var lines = num.LinesProvider(); + return lines.Count > 0 ? lines[0].Text : ""; + } + + private static ImportedLayout FakeLayout(params (uint id, UiElement e)[] items) + { + var dict = new Dictionary(); + var root = new UiPanel(); + foreach (var (id, e) in items) + { + root.AddChild(e); + dict[id] = e; + } + return new ImportedLayout(root, dict); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json b/tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json new file mode 100644 index 00000000..37783bb7 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json @@ -0,0 +1,542 @@ +{ + "Id": 0, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 0, + "Height": 0, + "Left": 0, + "Top": 0, + "Right": 0, + "Bottom": 0, + "ReadOrder": 0, + "FontDid": 0, + "StateMedia": {}, + "DefaultStateName": "", + "Children": [ + { + "Id": 268435484, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 382, + "Height": 104, + "Left": 1, + "Top": 2, + "Right": 2, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100667980, + "Item2": 1 + } + }, + "DefaultStateName": "", + "Children": [ + { + "Id": 268435485, + "Type": 5, + "X": 0, + "Y": 2, + "Width": 382, + "Height": 102, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": {}, + "DefaultStateName": "", + "Children": [] + } + ] + }, + { + "Id": 268436774, + "Type": 1, + "X": 2, + "Y": 0, + "Width": 16, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 2, + "ReadOrder": 3, + "FontDid": 1073741861, + "StateMedia": { + "Normal": { + "Item1": 100688408, + "Item2": 1 + }, + "Highlight": { + "Item1": 100688409, + "Item2": 1 + } + }, + "DefaultStateName": "Normal", + "Children": [] + }, + { + "Id": 268435486, + "Type": 12, + "X": 0, + "Y": 0, + "Width": 191, + "Height": 17, + "Left": 0, + "Top": 0, + "Right": 0, + "Bottom": 0, + "ReadOrder": 2, + "FontDid": 1073741825, + "StateMedia": { + "Normal": { + "Item1": 100667982, + "Item2": 1 + }, + "Ghosted": { + "Item1": 100667982, + "Item2": 1 + }, + "Talkfocus_highlight": { + "Item1": 100667981, + "Item2": 1 + } + }, + "DefaultStateName": "", + "Children": [] + }, + { + "Id": 268435470, + "Type": 268435521, + "X": 0, + "Y": 0, + "Width": 800, + "Height": 100, + "Left": 1, + "Top": 2, + "Right": 1, + "Bottom": 1, + "ReadOrder": 0, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100667725, + "Item2": 1 + } + }, + "DefaultStateName": "", + "Children": [ + { + "Id": 268436772, + "Type": 1, + "X": 0, + "Y": 46, + "Width": 16, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 2, + "ReadOrder": 6, + "FontDid": 1073741861, + "StateMedia": { + "Normal": { + "Item1": 100688408, + "Item2": 1 + }, + "Highlight": { + "Item1": 100688409, + "Item2": 1 + } + }, + "DefaultStateName": "Normal", + "Children": [] + }, + { + "Id": 268436773, + "Type": 1, + "X": 0, + "Y": 64, + "Width": 16, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 2, + "ReadOrder": 7, + "FontDid": 1073741861, + "StateMedia": { + "Normal": { + "Item1": 100688408, + "Item2": 1 + }, + "Highlight": { + "Item1": 100688409, + "Item2": 1 + } + }, + "DefaultStateName": "Normal", + "Children": [] + }, + { + "Id": 268436591, + "Type": 1, + "X": 474, + "Y": 0, + "Width": 16, + "Height": 16, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 2, + "ReadOrder": 3, + "FontDid": 0, + "StateMedia": { + "Maximized": { + "Item1": 100687460, + "Item2": 1 + }, + "Minimized": { + "Item1": 100687461, + "Item2": 1 + } + }, + "DefaultStateName": "Minimized", + "Children": [] + }, + { + "Id": 268435471, + "Type": 9, + "X": 0, + "Y": 0, + "Width": 800, + "Height": 9, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 2, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100667685, + "Item2": 1 + } + }, + "DefaultStateName": "", + "Children": [] + }, + { + "Id": 268435472, + "Type": 3, + "X": 0, + "Y": 9, + "Width": 490, + "Height": 74, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100667669, + "Item2": 1 + } + }, + "DefaultStateName": "", + "Children": [ + { + "Id": 268435473, + "Type": 12, + "X": 16, + "Y": 0, + "Width": 458, + "Height": 74, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 1073741824, + "StateMedia": {}, + "DefaultStateName": "", + "Children": [ + { + "Id": 268436620, + "Type": 1, + "X": 0, + "Y": 58, + "Width": 16, + "Height": 16, + "Left": 3, + "Top": 2, + "Right": 3, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "Normal": { + "Item1": 100687630, + "Item2": 1 + }, + "Normal_pressed": { + "Item1": 100687630, + "Item2": 1 + } + }, + "DefaultStateName": "Ghosted", + "Children": [] + } + ] + }, + { + "Id": 268435474, + "Type": 11, + "X": 474, + "Y": 6, + "Width": 16, + "Height": 68, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100682847, + "Item2": 3 + } + }, + "DefaultStateName": "", + "Children": [] + } + ] + }, + { + "Id": 268435475, + "Type": 3, + "X": 0, + "Y": 83, + "Width": 490, + "Height": 17, + "Left": 1, + "Top": 2, + "Right": 1, + "Bottom": 1, + "ReadOrder": 8, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100667706, + "Item2": 1 + } + }, + "DefaultStateName": "", + "Children": [ + { + "Id": 268435476, + "Type": 6, + "X": 0, + "Y": 0, + "Width": 46, + "Height": 17, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "Normal": { + "Item1": 100683109, + "Item2": 3 + }, + "Normal_pressed": { + "Item1": 100683110, + "Item2": 3 + } + }, + "DefaultStateName": "Normal", + "Children": [ + { + "Id": 268435477, + "Type": 12, + "X": 0, + "Y": 0, + "Width": 46, + "Height": 17, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 1073741826, + "StateMedia": {}, + "DefaultStateName": "", + "Children": [] + } + ] + }, + { + "Id": 268435478, + "Type": 12, + "X": 46, + "Y": 0, + "Width": 398, + "Height": 17, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 1073741824, + "StateMedia": { + "Normal_focussed": { + "Item1": 100667819, + "Item2": 1 + } + }, + "DefaultStateName": "", + "Children": [ + { + "Id": 268435479, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 1, + "Height": 17, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "Normal_focussed": { + "Item1": 100683111, + "Item2": 1 + } + }, + "DefaultStateName": "", + "Children": [] + }, + { + "Id": 268435480, + "Type": 3, + "X": 397, + "Y": 0, + "Width": 1, + "Height": 17, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": { + "Normal_focussed": { + "Item1": 100683111, + "Item2": 1 + } + }, + "DefaultStateName": "", + "Children": [] + } + ] + }, + { + "Id": 268435481, + "Type": 1, + "X": 444, + "Y": 0, + "Width": 46, + "Height": 17, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 3, + "FontDid": 1073741826, + "StateMedia": { + "Normal": { + "Item1": 100669717, + "Item2": 1 + }, + "Normal_pressed": { + "Item1": 100669718, + "Item2": 1 + }, + "Ghosted": { + "Item1": 100669748, + "Item2": 1 + } + }, + "DefaultStateName": "Normal", + "Children": [] + } + ] + }, + { + "Id": 268436770, + "Type": 1, + "X": 0, + "Y": 10, + "Width": 16, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 2, + "ReadOrder": 4, + "FontDid": 1073741861, + "StateMedia": { + "Normal": { + "Item1": 100688408, + "Item2": 1 + }, + "Highlight": { + "Item1": 100688409, + "Item2": 1 + } + }, + "DefaultStateName": "Normal", + "Children": [] + }, + { + "Id": 268436771, + "Type": 1, + "X": 0, + "Y": 28, + "Width": 16, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 2, + "ReadOrder": 5, + "FontDid": 1073741861, + "StateMedia": { + "Normal": { + "Item1": 100688408, + "Item2": 1 + }, + "Highlight": { + "Item1": 100688409, + "Item2": 1 + } + }, + "DefaultStateName": "Normal", + "Children": [] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json b/tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json new file mode 100644 index 00000000..ff372638 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json @@ -0,0 +1,1058 @@ +{ + "Id": 268436985, + "Type": 268435533, + "X": 0, + "Y": 0, + "Width": 160, + "Height": 58, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 2, + "ReadOrder": 0, + "FontDid": 0, + "StateMedia": {}, + "Children": [ + { + "Id": 268437048, + "Type": 3, + "X": 5, + "Y": 53, + "Width": 150, + "Height": 5, + "Left": 1, + "Top": 2, + "Right": 1, + "Bottom": 1, + "ReadOrder": 6, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693185, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435692, + "Type": 7, + "X": 5, + "Y": 21, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 18, + "FontDid": 0, + "StateMedia": {}, + "Children": [ + { + "Id": 268435693, + "Type": 12, + "X": 0, + "Y": 0, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 3, + "FontDid": 1073741824, + "StateMedia": {}, + "Children": [] + }, + { + "Id": 2, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": {}, + "Children": [ + { + "Id": 268436649, + "Type": 3, + "X": 32, + "Y": 0, + "Width": 85, + "Height": 28, + "Left": 3, + "Top": 1, + "Right": 3, + "Bottom": 1, + "ReadOrder": 4, + "FontDid": 0, + "StateMedia": { + "ShowDetail": { + "Item1": 100693139, + "Item2": 3 + } + }, + "Children": [] + }, + { + "Id": 268435688, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693127, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435689, + "Type": 3, + "X": 10, + "Y": 0, + "Width": 130, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693128, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435690, + "Type": 3, + "X": 140, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 3, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693129, + "Item2": 1 + } + }, + "Children": [] + } + ] + }, + { + "Id": 268435687, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": {}, + "Children": [ + { + "Id": 268436649, + "Type": 3, + "X": 32, + "Y": 0, + "Width": 85, + "Height": 16, + "Left": 3, + "Top": 1, + "Right": 3, + "Bottom": 1, + "ReadOrder": 4, + "FontDid": 0, + "StateMedia": { + "ShowDetail": { + "Item1": 100693138, + "Item2": 3 + } + }, + "Children": [] + }, + { + "Id": 268435688, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693124, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435689, + "Type": 3, + "X": 10, + "Y": 0, + "Width": 130, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693125, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435690, + "Type": 3, + "X": 140, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 3, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693126, + "Item2": 1 + } + }, + "Children": [] + } + ] + } + ] + }, + { + "Id": 268437049, + "Type": 3, + "X": 155, + "Y": 53, + "Width": 5, + "Height": 5, + "Left": 2, + "Top": 2, + "Right": 1, + "Bottom": 1, + "ReadOrder": 7, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693190, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437050, + "Type": 3, + "X": 155, + "Y": 5, + "Width": 5, + "Height": 48, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 8, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693186, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435694, + "Type": 7, + "X": 5, + "Y": 37, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 19, + "FontDid": 0, + "StateMedia": {}, + "Children": [ + { + "Id": 2, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": {}, + "Children": [ + { + "Id": 268436649, + "Type": 3, + "X": 25, + "Y": 0, + "Width": 100, + "Height": 16, + "Left": 3, + "Top": 1, + "Right": 3, + "Bottom": 1, + "ReadOrder": 4, + "FontDid": 0, + "StateMedia": { + "ShowDetail": { + "Item1": 100693141, + "Item2": 3 + } + }, + "Children": [] + }, + { + "Id": 268435688, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693133, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435689, + "Type": 3, + "X": 10, + "Y": 0, + "Width": 130, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693134, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435690, + "Type": 3, + "X": 140, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 3, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693135, + "Item2": 1 + } + }, + "Children": [] + } + ] + }, + { + "Id": 268435695, + "Type": 12, + "X": 0, + "Y": 0, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 3, + "FontDid": 1073741824, + "StateMedia": {}, + "Children": [] + }, + { + "Id": 268435687, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": {}, + "Children": [ + { + "Id": 268436649, + "Type": 3, + "X": 25, + "Y": 0, + "Width": 100, + "Height": 16, + "Left": 3, + "Top": 1, + "Right": 3, + "Bottom": 1, + "ReadOrder": 4, + "FontDid": 0, + "StateMedia": { + "ShowDetail": { + "Item1": 100693140, + "Item2": 3 + } + }, + "Children": [] + }, + { + "Id": 268435688, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693130, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435689, + "Type": 3, + "X": 10, + "Y": 0, + "Width": 130, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693131, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435690, + "Type": 3, + "X": 140, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 3, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693132, + "Item2": 1 + } + }, + "Children": [] + } + ] + } + ] + }, + { + "Id": 268437051, + "Type": 9, + "X": 0, + "Y": 0, + "Width": 5, + "Height": 5, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 2, + "ReadOrder": 9, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100688169, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437052, + "Type": 2, + "X": 5, + "Y": 0, + "Width": 150, + "Height": 5, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 2, + "ReadOrder": 10, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100688170, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437053, + "Type": 9, + "X": 155, + "Y": 0, + "Width": 5, + "Height": 5, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 2, + "ReadOrder": 11, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100688169, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437054, + "Type": 9, + "X": 0, + "Y": 5, + "Width": 5, + "Height": 48, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 1, + "ReadOrder": 12, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100688171, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437055, + "Type": 9, + "X": 0, + "Y": 53, + "Width": 5, + "Height": 5, + "Left": 1, + "Top": 2, + "Right": 2, + "Bottom": 1, + "ReadOrder": 13, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100688169, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437056, + "Type": 2, + "X": 5, + "Y": 53, + "Width": 150, + "Height": 5, + "Left": 1, + "Top": 2, + "Right": 1, + "Bottom": 1, + "ReadOrder": 14, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100688172, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437057, + "Type": 9, + "X": 155, + "Y": 53, + "Width": 5, + "Height": 5, + "Left": 2, + "Top": 2, + "Right": 1, + "Bottom": 1, + "ReadOrder": 15, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100688169, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437058, + "Type": 9, + "X": 155, + "Y": 5, + "Width": 5, + "Height": 48, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 16, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100688173, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435686, + "Type": 7, + "X": 5, + "Y": 5, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 17, + "FontDid": 0, + "StateMedia": {}, + "Children": [ + { + "Id": 268435691, + "Type": 12, + "X": 0, + "Y": 0, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 3, + "FontDid": 1073741824, + "StateMedia": {}, + "Children": [] + }, + { + "Id": 2, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": {}, + "Children": [ + { + "Id": 268436649, + "Type": 3, + "X": 66, + "Y": 0, + "Width": 18, + "Height": 16, + "Left": 3, + "Top": 1, + "Right": 3, + "Bottom": 1, + "ReadOrder": 4, + "FontDid": 0, + "StateMedia": { + "ShowDetail": { + "Item1": 100693137, + "Item2": 3 + } + }, + "Children": [] + }, + { + "Id": 268435688, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693121, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435689, + "Type": 3, + "X": 10, + "Y": 0, + "Width": 130, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693122, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435690, + "Type": 3, + "X": 140, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 3, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693123, + "Item2": 1 + } + }, + "Children": [] + } + ] + }, + { + "Id": 268435687, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 150, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": {}, + "Children": [ + { + "Id": 268436649, + "Type": 3, + "X": 66, + "Y": 0, + "Width": 18, + "Height": 16, + "Left": 3, + "Top": 1, + "Right": 3, + "Bottom": 1, + "ReadOrder": 4, + "FontDid": 0, + "StateMedia": { + "ShowDetail": { + "Item1": 100693136, + "Item2": 3 + } + }, + "Children": [] + }, + { + "Id": 268435688, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 1, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693118, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435689, + "Type": 3, + "X": 10, + "Y": 0, + "Width": 130, + "Height": 16, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693119, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268435690, + "Type": 3, + "X": 140, + "Y": 0, + "Width": 10, + "Height": 16, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 3, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693120, + "Item2": 1 + } + }, + "Children": [] + } + ] + } + ] + }, + { + "Id": 268437043, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 5, + "Height": 5, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 2, + "ReadOrder": 1, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693187, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437044, + "Type": 3, + "X": 5, + "Y": 0, + "Width": 150, + "Height": 5, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 2, + "ReadOrder": 2, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693183, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437045, + "Type": 3, + "X": 155, + "Y": 0, + "Width": 5, + "Height": 5, + "Left": 2, + "Top": 1, + "Right": 1, + "Bottom": 2, + "ReadOrder": 3, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693188, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437046, + "Type": 3, + "X": 0, + "Y": 5, + "Width": 5, + "Height": 48, + "Left": 1, + "Top": 1, + "Right": 2, + "Bottom": 1, + "ReadOrder": 4, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693184, + "Item2": 1 + } + }, + "Children": [] + }, + { + "Id": 268437047, + "Type": 3, + "X": 0, + "Y": 53, + "Width": 5, + "Height": 5, + "Left": 1, + "Top": 2, + "Right": 2, + "Bottom": 1, + "ReadOrder": 5, + "FontDid": 0, + "StateMedia": { + "": { + "Item1": 100693189, + "Item2": 1 + } + }, + "Children": [] + } + ] +} \ No newline at end of file diff --git a/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs new file mode 100644 index 00000000..d45aa374 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs @@ -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 = + "" + + " " + + ""; + + var panel = MarkupDocument.Build(xml, new FakeBinding(), _ => ((uint)1, 32, 32)); + + Assert.IsType(panel); + Assert.Equal(10f, panel.Left); + Assert.Equal(220f, panel.Width); + Assert.Equal(2, panel.Children.Count); // title UiLabel + 1 meter + var meter = Assert.IsType(panel.Children[1]); + Assert.Equal(8f, meter.Left); + Assert.Equal(200f, meter.Width); + Assert.Equal(0.5f, meter.Fill()); + Assert.Equal("109/218", meter.Label()); + } + + [Fact] + public void Build_NullBindingValuesYieldNullFillAndLabel() + { + const string xml = + "" + + " " + + ""; + var panel = MarkupDocument.Build(xml, new FakeBinding(), _ => ((uint)1, 32, 32)); + var meter = Assert.IsType(panel.Children[1]); + Assert.Null(meter.Fill()); + Assert.Null(meter.Label()); + } + + [Fact] + public void Build_ResizeAttrX_SetsHorizontalOnly() + { + const string xml = ""; + var panel = MarkupDocument.Build(xml, new object(), _ => ((uint)1, 32, 32)); + Assert.True(panel.ResizeX); + Assert.False(panel.ResizeY); + } + + [Fact] + public void Build_ParsesNineSliceBarSpriteIds() + { + const string xml = "" + + "" + + ""; + var panel = MarkupDocument.Build(xml, new FakeBinding(), _ => ((uint)7, 32, 32)); + var meter = Assert.IsType(panel.Children[1]); + Assert.Equal(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); + } +} diff --git a/tests/AcDream.App.Tests/UI/UiButtonTests.cs b/tests/AcDream.App.Tests/UI/UiButtonTests.cs new file mode 100644 index 00000000..8bbadae2 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiButtonTests.cs @@ -0,0 +1,25 @@ +using AcDream.App.UI; +using AcDream.App.UI.Layout; +namespace AcDream.App.Tests.UI; + +public class UiButtonTests +{ + private static (uint, int, int) NoTex(uint _) => (0, 0, 0); + private bool _clicked; + + [Fact] + public void Click_InvokesOnClick() + { + var b = new UiButton(new ElementInfo { Type = 1, Width = 46, Height = 18 }, NoTex) + { OnClick = () => _clicked = true }; + b.OnEvent(new UiEvent(0, null, UiEventType.Click)); + Assert.True(_clicked); + } + + [Fact] + public void NotClickThrough_SoItReceivesClicks() + { + var b = new UiButton(new ElementInfo { Type = 1 }, NoTex); + Assert.False(b.ClickThrough); + } +} diff --git a/tests/AcDream.App.Tests/UI/UiDatFontTests.cs b/tests/AcDream.App.Tests/UI/UiDatFontTests.cs new file mode 100644 index 00000000..55a6457a --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiDatFontTests.cs @@ -0,0 +1,84 @@ +using System.Collections.Generic; +using AcDream.App.UI; +using DatReaderWriter.Types; + +namespace AcDream.App.Tests.UI; + +/// +/// Pure pen-advance / MeasureWidth math for the retail dat font (no GL, no dat). +/// The advance per glyph is the retail +/// HorizontalOffsetBefore + Width + HorizontalOffsetAfter +/// (SurfaceWindow::DrawCharacter, acclient 0x00442c3a), accumulated across the +/// string the way the retail string loop does (0x00467ed4 edi_3 += var_98). +/// +public class UiDatFontTests +{ + private static FontCharDesc Glyph( + ushort unicode, byte width, + sbyte before = 0, sbyte after = 0, + ushort offsetX = 0, ushort offsetY = 0, byte height = 16, sbyte vBefore = 0) + => new() + { + Unicode = unicode, + Width = width, + Height = height, + OffsetX = offsetX, + OffsetY = offsetY, + HorizontalOffsetBefore = before, + HorizontalOffsetAfter = after, + VerticalOffsetBefore = vBefore, + }; + + [Fact] + public void GlyphAdvance_SumsBeforeWidthAfter() + { + var g = Glyph('A', width: 8, before: 1, after: 2); + Assert.Equal(11f, UiDatFont.GlyphAdvance(g)); + } + + [Fact] + public void GlyphAdvance_HandlesNegativeBearings() + { + // Kerned glyph: a negative left-bearing pulls it leftward; the advance + // still nets out to before + width + after. + var g = Glyph('j', width: 4, before: -1, after: 0); + Assert.Equal(3f, UiDatFont.GlyphAdvance(g)); + } + + [Fact] + public void MeasureWidth_SumsEachGlyphAdvance() + { + var table = new Dictionary + { + ['2'] = Glyph('2', width: 7, before: 1, after: 1), // advance 9 + ['9'] = Glyph('9', width: 7, before: 1, after: 1), // advance 9 + ['1'] = Glyph('1', width: 3, before: 2, after: 1), // advance 6 + ['/'] = Glyph('/', width: 4, before: 0, after: 1), // advance 5 + }; + FontCharDesc? Lookup(char c) => table.TryGetValue(c, out var g) ? g : null; + + // "291/291" = 9 + 9 + 6 + 5 + 9 + 9 + 6 = 53 + Assert.Equal(53f, UiDatFont.MeasureWidth("291/291", Lookup)); + } + + [Fact] + public void MeasureWidth_SkipsCharactersNotInFont() + { + var table = new Dictionary + { + ['5'] = Glyph('5', width: 6, before: 1, after: 1), // advance 8 + }; + FontCharDesc? Lookup(char c) => table.TryGetValue(c, out var g) ? g : null; + + // 'X' has no glyph → contributes nothing; only the two '5's count. + Assert.Equal(16f, UiDatFont.MeasureWidth("5X5", Lookup)); + } + + [Fact] + public void MeasureWidth_EmptyOrNullIsZero() + { + FontCharDesc? Lookup(char c) => null; + Assert.Equal(0f, UiDatFont.MeasureWidth("", Lookup)); + Assert.Equal(0f, UiDatFont.MeasureWidth(null, Lookup)); + } +} diff --git a/tests/AcDream.App.Tests/UI/UiFieldTests.cs b/tests/AcDream.App.Tests/UI/UiFieldTests.cs new file mode 100644 index 00000000..5e6d405f --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiFieldTests.cs @@ -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); + } +} diff --git a/tests/AcDream.App.Tests/UI/UiMenuTests.cs b/tests/AcDream.App.Tests/UI/UiMenuTests.cs new file mode 100644 index 00000000..4b1a16fe --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiMenuTests.cs @@ -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( + 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); + } +} diff --git a/tests/AcDream.App.Tests/UI/UiMeterTests.cs b/tests/AcDream.App.Tests/UI/UiMeterTests.cs new file mode 100644 index 00000000..9e7637e9 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiMeterTests.cs @@ -0,0 +1,25 @@ +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public class UiMeterTests +{ + [Fact] + public void ComputeFillRect_HalfFillIsHalfWidth() + { + var (x, y, w, h) = UiMeter.ComputeFillRect(0.5f, 200f, 12f); + Assert.Equal(0f, x); Assert.Equal(0f, y); + Assert.Equal(100f, w); Assert.Equal(12f, h); + } + + [Theory] + [InlineData(-1f, 0f)] // clamps below 0 + [InlineData(2f, 200f)] // clamps above 1 + [InlineData(0f, 0f)] + [InlineData(1f, 200f)] + public void ComputeFillRect_ClampsFraction(float pct, float expectedW) + { + var (_, _, w, _) = UiMeter.ComputeFillRect(pct, 200f, 12f); + Assert.Equal(expectedW, w); + } +} diff --git a/tests/AcDream.App.Tests/UI/UiNineSlicePanelTests.cs b/tests/AcDream.App.Tests/UI/UiNineSlicePanelTests.cs new file mode 100644 index 00000000..8a2b3d0a --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiNineSlicePanelTests.cs @@ -0,0 +1,27 @@ +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public class UiNineSlicePanelTests +{ + [Fact] + public void ComputeFrameRects_PlacesCornersEdgesAndCenter() + { + var r = UiNineSlicePanel.ComputeFrameRects(100, 80, 5); + + // 5x5 corners at the four corners + Assert.Equal(new UiNineSlicePanel.Rect(0, 0, 5, 5), r.TL); + Assert.Equal(new UiNineSlicePanel.Rect(95, 0, 5, 5), r.TR); + Assert.Equal(new UiNineSlicePanel.Rect(0, 75, 5, 5), r.BL); + Assert.Equal(new UiNineSlicePanel.Rect(95, 75, 5, 5), r.BR); + + // edges span the interior (100-2*5 = 90 wide, 80-2*5 = 70 tall) + Assert.Equal(new UiNineSlicePanel.Rect(5, 0, 90, 5), r.Top); + Assert.Equal(new UiNineSlicePanel.Rect(5, 75, 90, 5), r.Bottom); + Assert.Equal(new UiNineSlicePanel.Rect(0, 5, 5, 70), r.Left); + Assert.Equal(new UiNineSlicePanel.Rect(95, 5, 5, 70), r.Right); + + // center fills the interior + Assert.Equal(new UiNineSlicePanel.Rect(5, 5, 90, 70), r.Center); + } +} diff --git a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs new file mode 100644 index 00000000..c3160e66 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs @@ -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); + } +} diff --git a/tests/AcDream.App.Tests/UI/UiScrollableTests.cs b/tests/AcDream.App.Tests/UI/UiScrollableTests.cs new file mode 100644 index 00000000..27804b1c --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiScrollableTests.cs @@ -0,0 +1,73 @@ +using AcDream.App.UI; +using Xunit; + +namespace AcDream.App.Tests.UI; + +public class UiScrollableTests +{ + [Fact] + public void Clamp_KeepsScrollWithinContent() + { + var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 }; + s.SetScrollY(500); + Assert.Equal(200, s.ScrollY); + s.SetScrollY(-50); + Assert.Equal(0, s.ScrollY); + } + + [Fact] + public void FitsView_PinsToZero() + { + var s = new UiScrollable { ContentHeight = 80, ViewHeight = 100 }; + s.SetScrollY(40); + Assert.Equal(0, s.ScrollY); + Assert.False(s.HasOverflow); + } + + [Fact] + public void ThumbRatio_IsViewOverContent_ClampedToOne() + { + var s = new UiScrollable { ContentHeight = 400, ViewHeight = 100 }; + Assert.Equal(0.25f, s.ThumbRatio, 3); + var full = new UiScrollable { ContentHeight = 50, ViewHeight = 100 }; + Assert.Equal(1f, full.ThumbRatio, 3); + } + + [Fact] + public void PositionRatio_MapsScrollToZeroOne() + { + var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 }; + s.SetScrollY(100); + Assert.Equal(0.5f, s.PositionRatio, 3); + s.SetScrollY(200); + Assert.Equal(1f, s.PositionRatio, 3); + } + + [Fact] + public void SetPositionRatio_IsInverseOfPositionRatio() + { + var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 }; + s.SetPositionRatio(0.5f); + Assert.Equal(100, s.ScrollY); + } + + [Fact] + public void ScrollByLines_AdvancesByLineHeight() + { + var s = new UiScrollable { ContentHeight = 1000, ViewHeight = 100, LineHeight = 16 }; + s.ScrollByLines(-2); + Assert.Equal(0, s.ScrollY); + s.SetScrollY(50); + s.ScrollByLines(2); + Assert.Equal(82, s.ScrollY); + } + + [Fact] + public void ScrollByPage_AdvancesByViewHeight() + { + var s = new UiScrollable { ContentHeight = 1000, ViewHeight = 100, LineHeight = 16 }; + s.SetScrollY(200); + s.ScrollByPage(1); + Assert.Equal(300, s.ScrollY); + } +} diff --git a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs new file mode 100644 index 00000000..c2239732 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs @@ -0,0 +1,81 @@ +using AcDream.App.UI; +using Xunit; + +namespace AcDream.App.Tests.UI; + +/// +/// Pure unit tests for — no GL dependency. +/// +public class 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 + } +} diff --git a/tests/AcDream.App.Tests/UI/UiTextDatFontTests.cs b/tests/AcDream.App.Tests/UI/UiTextDatFontTests.cs new file mode 100644 index 00000000..11e6d1eb --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiTextDatFontTests.cs @@ -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'))); + } +} diff --git a/tests/AcDream.App.Tests/UI/UiTextTests.cs b/tests/AcDream.App.Tests/UI/UiTextTests.cs new file mode 100644 index 00000000..691dc213 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiTextTests.cs @@ -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 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 Lines(params string[] texts) + { + var list = new List(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(), + 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); + } +} diff --git a/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs b/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs index 2fdafc97..da508aab 100644 --- a/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs +++ b/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs @@ -30,6 +30,12 @@ public class PluginLoaderTests public IPluginLogger Log { get; } = new StubLogger(); public IGameState State { get; } = new StubState(); public IEvents Events { get; } = new StubEvents(); + public IUiRegistry Ui { get; } = new StubUiRegistry(); + } + + private sealed class StubUiRegistry : IUiRegistry + { + public void AddMarkupPanel(string markupPath, object binding) { } } private sealed class StubLogger : IPluginLogger diff --git a/tests/AcDream.Core.Tests/Textures/SurfaceDecoderSolidColorTests.cs b/tests/AcDream.Core.Tests/Textures/SurfaceDecoderSolidColorTests.cs new file mode 100644 index 00000000..ffb4b427 --- /dev/null +++ b/tests/AcDream.Core.Tests/Textures/SurfaceDecoderSolidColorTests.cs @@ -0,0 +1,17 @@ +using AcDream.Core.Textures; +using Xunit; + +namespace AcDream.Core.Tests.Textures; + +public class SurfaceDecoderSolidColorTests +{ + [Fact] + public void DecodeSolidColor_NullColor_ReturnsMagenta_DoesNotThrow() + { + // A malformed Base1Solid surface can carry a null ColorValue. DecodeSolidColor + // is called outside DecodeRenderSurface's try/catch (from TextureCache), so it + // must be null-safe itself — return the undecodable sentinel, never NRE. + var result = SurfaceDecoder.DecodeSolidColor(null!, 0f); + Assert.Equal(DecodedTexture.Magenta, result); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs index d5003bba..e10d56e3 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs @@ -148,27 +148,30 @@ public class InputDispatcherIsActionHeldTests } [Fact] - public void IsActionHeld_does_not_check_WantCaptureMouse() + public void IsActionHeld_gated_off_while_keyboard_captured() { - // Per-frame held-state lookup is independent of UI capture: even - // with WantCaptureMouse=true a movement key already held when - // ImGui took focus continues to read as held until KeyUp. Press - // events ARE gated (the Press wouldn't fire while UI captures), - // but IsActionHeld answers the keyboard's underlying "is the - // physical key down right now" — which the legacy IsKeyPressed - // also did. The per-frame OnUpdate guard on - // ImGui.GetIO().WantCaptureKeyboard is what suppresses movement - // when chat is focused. + // Write-mode gate (2026-06-16): a focused chat input sets + // WantCaptureKeyboard, and held-key polling then reads RELEASED so typing + // "swd" doesn't move the character. This SUPERSEDES the old design (where the + // per-frame OnUpdate guard early-returned out of the whole movement block) — + // that approach also killed AUTORUN. By gating here instead, the movement block + // keeps running, so autorun (a separate latched bool ORed into Forward at the + // call site, NOT a polled key) survives write mode. WantCaptureMouse alone does + // NOT gate held-key polling — only keyboard capture does. var (dispatcher, kb, mouse, bindings) = Build(); bindings.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward)); kb.EmitKeyDown(Key.W, ModifierMask.None); - mouse.WantCaptureMouse = true; - mouse.WantCaptureKeyboard = true; + // Held, no capture → reads held. + Assert.True(dispatcher.IsActionHeld(InputAction.MovementForward)); - // Even with both capture flags set, IsActionHeld remains true - // because W is physically held. The dispatcher only suppresses - // press transitions. + // Keyboard captured (write mode) → held-key polling reads released. + mouse.WantCaptureKeyboard = true; + Assert.False(dispatcher.IsActionHeld(InputAction.MovementForward)); + + // Mouse capture alone must NOT gate movement polling (only keyboard does). + mouse.WantCaptureKeyboard = false; + mouse.WantCaptureMouse = true; Assert.True(dispatcher.IsActionHeld(InputAction.MovementForward)); } } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs new file mode 100644 index 00000000..e0f1daad --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs @@ -0,0 +1,74 @@ +using AcDream.Core.Chat; +using AcDream.UI.Abstractions; +using AcDream.UI.Abstractions.Panels.Chat; +using Xunit; + +namespace AcDream.UI.Abstractions.Tests.Panels.Chat; + +public class ChatCommandRouterTests +{ + private sealed class CaptureBus : ICommandBus + { + public SendChatCmd? Last; + public void Publish(T command) where T : notnull + { + if (command is SendChatCmd c) Last = c; + } + } + + private static (ChatVM vm, ChatLog log, CaptureBus bus) Fixture() + { + var log = new ChatLog(); + var vm = new ChatVM(log, displayLimit: 50); + return (vm, log, new CaptureBus()); + } + + [Fact] + public void PlainText_PublishesOnDefaultChannel() + { + var (vm, _, bus) = Fixture(); + var outcome = ChatCommandRouter.Submit("hello there", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.Sent, outcome); + Assert.NotNull(bus.Last); + Assert.Equal(ChatChannelKind.Say, bus.Last!.Channel); + Assert.Equal("hello there", bus.Last.Text); + } + + [Fact] + public void DefaultChannel_IsHonored() + { + var (vm, _, bus) = Fixture(); + ChatCommandRouter.Submit("hi", vm, bus, ChatChannelKind.Fellowship); + Assert.Equal(ChatChannelKind.Fellowship, bus.Last!.Channel); + } + + [Fact] + public void ClearCommand_DrainsLog_DoesNotPublish() + { + var (vm, log, bus) = Fixture(); + log.OnSystemMessage("x", chatType: 0); + var outcome = ChatCommandRouter.Submit("/clear", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.ClientHandled, outcome); + Assert.Null(bus.Last); + Assert.Empty(log.Snapshot()); + } + + [Fact] + public void UnknownSlashVerb_ShowsSystemMessage_DoesNotPublish() + { + var (vm, log, bus) = Fixture(); + var outcome = ChatCommandRouter.Submit("/notacommand", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.UnknownCommand, outcome); + Assert.Null(bus.Last); + Assert.Contains(log.Snapshot(), e => e.Text.Contains("Unknown command")); + } + + [Fact] + public void EmptyInput_DoesNothing() + { + var (vm, _, bus) = Fixture(); + var outcome = ChatCommandRouter.Submit(" ", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.Empty, outcome); + Assert.Null(bus.Last); + } +} diff --git a/tools/cdb/chat-colors.cdb b/tools/cdb/chat-colors.cdb new file mode 100644 index 00000000..b9010838 --- /dev/null +++ b/tools/cdb/chat-colors.cdb @@ -0,0 +1,12 @@ +.symopt+ 0x40 +.reload /f acclient.exe +.echo ===BASE=== +lm m acclient +.echo ===DISASM_BuildChatColorLookupTable=== +uf acclient!ChatInterface::BuildChatColorLookupTable +.echo ===TABLE_REL_0x41c4a8=== +dd acclient+0x41c4a8 L40 +.echo ===TABLE_ABS_0x81c4a8=== +dd 0x81c4a8 L40 +.echo ===END=== +qd diff --git a/tools/cdb/chat-colors2.cdb b/tools/cdb/chat-colors2.cdb new file mode 100644 index 00000000..24b7a382 --- /dev/null +++ b/tools/cdb/chat-colors2.cdb @@ -0,0 +1,6 @@ +.echo ===COLOR_SYMS=== +x acclient!color* +.echo ===CHATCOLOR_SYMS=== +x acclient!*ChatColor* +.echo ===END=== +qd