merge: bring main (UN-7, #140 filing, D.2b UI rows) into A7 Fix D round-2 branch
Resolves the divergence-register conflict: kept the accurate per-VERTEX AP-35
(Fix A shipped per-vertex; main's row was the stale pre-Fix-A per-pixel text),
kept main's UI rows AP-37..AP-42, and renumbered this branch's torch-gate row
AP-37 -> AP-43 (AP-37 was taken by main's LayoutDesc row). AP count 41 -> 42.
Retargeted the AP-37 references in WbDrawDispatcher + the CHECKPOINT to AP-43.
Marked ISSUES #140 RESOLVED (b7d655b) with the corrected root cause.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
c83fd02642
94 changed files with 16216 additions and 199 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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.)**
|
||||
|
|
|
|||
135
docs/research/2026-06-15-chat-window-redrive-handoff.md
Normal file
135
docs/research/2026-06-15-chat-window-redrive-handoff.md
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
# Chat-window re-drive — session handoff (2026-06-15)
|
||||
|
||||
**Status:** brainstorm STARTED (context gathered, design questions open) — not yet
|
||||
designed or implemented. Resume with `superpowers:brainstorming`.
|
||||
|
||||
**Branch:** `claude/hopeful-maxwell-214a12` — **continue UI work HERE** (the user's
|
||||
call: UI stays on this branch; dungeon lighting / M1.5 goes to a *separate* branch
|
||||
off `main`, it's unrelated and easy to merge). This branch is already current with
|
||||
`main` (merged `5ac9d8c`).
|
||||
|
||||
---
|
||||
|
||||
## Where we are (what shipped this session)
|
||||
|
||||
**D.2b LayoutDesc importer — Plan 1 SHIPPED + flipped to default + post-flip fixes.**
|
||||
The vitals window is now data-driven from the dat `LayoutDesc 0x2100006C` (no
|
||||
per-window graphics code). Read **`claude-memory/project_d2b_retail_ui.md`** (the
|
||||
SSOT crib) FIRST — it has the full architecture + every correction. Key commits:
|
||||
|
||||
- `bf77a23` — flip: importer is the default vitals at `ACDREAM_RETAIL_UI=1`; the
|
||||
hand-authored `vitals.xml` + the `ACDREAM_RETAIL_UI_IMPORTER` flag were retired.
|
||||
- `8aa643f` — horizontal resize: edge-anchor mapping corrected to retail
|
||||
`UIElement::UpdateForParentSizeChange @0x00462640` (`RightEdge==1`=stretch).
|
||||
- `43064ba` — stamina/mana numbers: `TextRenderer` now draws sprites in
|
||||
**submission (painter) order** (was per-texture batched → later bars overpainted
|
||||
their own numbers).
|
||||
- `34243f2` — number sharpness: `DrawStringDat` **pixel-snaps** each glyph dest.
|
||||
|
||||
**The importer toolkit to REUSE (all in `src/AcDream.App/UI/Layout/`):**
|
||||
- `ElementReader` — `ElementInfo` POCO + `Merge` (BaseElement/BaseLayoutId
|
||||
inheritance) + `ToAnchors` (edge-flag → AnchorEdges, decomp-correct).
|
||||
- `UiDatElement` — generic per-DrawMode sprite renderer (the fallback widget).
|
||||
- `DatWidgetFactory` — `Type → widget` hybrid: Type 7→`UiMeter`, 12→skip, else
|
||||
generic; sets rect + anchors + `ZOrder=ReadOrder`. **Behavioral Types map to a
|
||||
dedicated widget; the widget CONSUMES the element's children (leaf — importer
|
||||
does not recurse them).** This is the pattern the chat re-drive extends.
|
||||
- `LayoutImporter` — `Import`/`ImportInfos`/`Build`/`BuildFromInfos` + cycle-guarded
|
||||
`Resolve`. `ImportedLayout.FindElement(id)` for binding by id.
|
||||
- `VitalsController` — binds live data to widgets by element id (mirrors retail
|
||||
`gmVitalsUI::PostInit`). The chat controller will mirror this.
|
||||
- Format reference: **`docs/research/2026-06-15-layoutdesc-format.md`** (ElementDesc
|
||||
API, Type table, DrawMode, inheritance). NOTE its §4 edge-flag history: the FIRST
|
||||
reading was inverted; the CORRECT model (per `@0x00462640`) is now in the doc +
|
||||
`ToAnchors` — `RightEdge==1`=stretch, `LeftEdge==2`=track-right.
|
||||
|
||||
---
|
||||
|
||||
## Next task: re-drive the chat window through the importer (Plan 2 chat piece)
|
||||
|
||||
Today the chat window is **hand-authored**, not data-driven. The goal mirrors the
|
||||
vitals re-drive: read the chat window's dat `LayoutDesc`, build it via
|
||||
`LayoutImporter`, and bind the live chat through a `ChatController`.
|
||||
|
||||
### Current chat window (what to reproduce / replace)
|
||||
- Built in `src/AcDream.App/Rendering/GameWindow.cs` in the `if (_options.RetailUi)`
|
||||
block (~line 1836, "Retail chat window").
|
||||
- `UiNineSlicePanel` (hand-authored 8-piece chrome) at `(10,432)`, `440×184`,
|
||||
`MinWidth 180 / MinHeight 80`, draggable + resizable.
|
||||
- Hosts a `UiChatView` (`src/AcDream.App/UI/UiChatView.cs`): scrollable transcript,
|
||||
**bottom-pinned**, mouse-wheel scrollback, **drag-select + Ctrl+C copy + Ctrl+A**,
|
||||
whole-line vertical clipping. **READ-ONLY** (no input box). Uses the **debug
|
||||
bitmap font** (`_debugFont`), NOT the dat font. `LinesProvider` polled each frame.
|
||||
- Data: `ChatVM` (`displayLimit: 200`) → `RecentLinesDetailed()` → per-`ChatKind`
|
||||
colour via `RetailChatColor(...)` (local static in GameWindow).
|
||||
|
||||
### Chat pipeline (already shipped, Phase I — reuse, don't rebuild)
|
||||
`ChatLog (Core) → ChatVM (UI.Abstractions) → view`; outbound `input →
|
||||
ChatInputParser → LiveCommandBus → WorldSession`. See
|
||||
`claude-memory/project_chat_pipeline.md`. The re-drive is a VIEW/layout change; the
|
||||
pipeline stays.
|
||||
|
||||
### Retail chat UI classes (decomp oracles — analogous to gmVitalsUI)
|
||||
`gmMainChatUI`, `gmFloatyMainChatUI`, `gmFloatyChatUI`, `gmChatOptionsUI`
|
||||
(`docs/research/named-retail/acclient.h` ~line 54923; `symbols.json` has
|
||||
`gmMainChatUI::Register` etc.). Chat-layout notes:
|
||||
`docs/research/retail-ui/05-panels.md:120` (chat window layout) +
|
||||
`06-hud-and-assets.md:651` (every chat window layout is a `LayoutDesc`).
|
||||
|
||||
### FIRST research step (the Task-1 analogue): identify the chat `LayoutDesc` id
|
||||
The vitals id was `0x2100006C`; the chat window's id is **NOT yet known**. Find it:
|
||||
- `dump-vitals-layout <datdir> [0xId]` enumerates LayoutDescs (it already lists all
|
||||
layouts containing given element ids). Use it to scan for the chat window, or grep
|
||||
the decomp for the layout id referenced by `gmMainChatUI`/`gmFloatyMainChatUI`.
|
||||
- Then dump it and enumerate its element Types (expect a scroll/list region +
|
||||
scrollbar, maybe a text-input/edit element + channel tabs) — this drives the
|
||||
factory/widget work.
|
||||
|
||||
---
|
||||
|
||||
## Open design questions (resume the brainstorm here)
|
||||
|
||||
1. **Scope.** Re-drive the EXISTING read-only window (frame from dat + reuse
|
||||
`UiChatView` for the transcript, parity with today), OR expand to the FULL retail
|
||||
chat (input box for typing, channel tabs)? Recommendation to discuss: do the
|
||||
frame re-drive + transcript first (parity), defer input/tabs to a follow-up —
|
||||
but confirm with the user.
|
||||
2. **Behavioral widgets.** The chat dat layout introduces the long-tail Types the
|
||||
vitals didn't have — Type 5 `ListBox`, Type 0xB `Scrollbar`, maybe Type 0xC
|
||||
`Text`/edit. Two options:
|
||||
- **(A, recommended) Hybrid reuse** — like Type-7→`UiMeter`: map the transcript
|
||||
region's Type → the existing `UiChatView` (which already scrolls/selects/copies);
|
||||
a `ChatController` binds the tail by element id. Minimal new code; fastest parity.
|
||||
- **(B) Port faithful widgets** — implement `UiScrollbar`/`UiListBox` per the
|
||||
decomp so the dat's scrollbar element drives scrolling. More faithful, more work;
|
||||
better as a later step.
|
||||
3. **Dat font for the transcript.** Switch `UiChatView` from the debug bitmap font
|
||||
to the dat font (`UiDatFont`, faithful + now pixel-snapped) — OR keep the debug
|
||||
font for parity first? `UiChatView`'s measure/selection logic is `BitmapFont`-based,
|
||||
so a dat-font port is non-trivial (a `UiDatFont` measure/advance path + selection
|
||||
hit-test rework). Likely a follow-up, not the first cut.
|
||||
|
||||
---
|
||||
|
||||
## Watchouts / lessons (don't regress these)
|
||||
- **`TextRenderer` draws sprites in submission order** (`_spriteSegs`). Do NOT revert
|
||||
to per-texture batching — it overpaints later same-atlas text (the stamina/mana bug).
|
||||
- **`DrawStringDat` pixel-snaps glyphs.** Keep it (sharp text on resize).
|
||||
- **Edge-flag/anchor model is `@0x00462640`** (`RightEdge==1`=stretch). The format
|
||||
doc §4's first reading was inverted; trust the corrected `ToAnchors`.
|
||||
- **Behavioral widgets are leaf** — the factory's widget consumes the element's dat
|
||||
children; the importer doesn't recurse into them. Apply the same to the chat
|
||||
transcript widget.
|
||||
- **Don't fabricate dat reader internals** — `Chorizite.DatReaderWriter` is a NuGet
|
||||
package (not in `references/`); verify member names via the dump tool / reflection.
|
||||
|
||||
## Process for the next session
|
||||
1. Read `claude-memory/project_d2b_retail_ui.md`, this handoff, and
|
||||
`docs/research/2026-06-15-layoutdesc-format.md`.
|
||||
2. Resume `superpowers:brainstorming` — settle scope + behavioral-widget approach
|
||||
(the 3 questions above), present a design, write the spec.
|
||||
3. Then `superpowers:writing-plans` → `superpowers:subagent-driven-development`
|
||||
(same flow that shipped the vitals importer cleanly).
|
||||
4. Stay on `claude/hopeful-maxwell-214a12`. Visual checks: launch live (ACE on
|
||||
`127.0.0.1:9000`) with `ACDREAM_RETAIL_UI=1`; test accounts `testaccount2 /
|
||||
testpassword2` or `notan / MittSnus81!` (character `+Je`).
|
||||
491
docs/research/2026-06-15-layoutdesc-format.md
Normal file
491
docs/research/2026-06-15-layoutdesc-format.md
Normal file
|
|
@ -0,0 +1,491 @@
|
|||
# LayoutDesc Format Enumeration Reference
|
||||
|
||||
**Date:** 2026-06-15
|
||||
**Author:** Task 1 of the LayoutDesc Importer plan (`docs/superpowers/plans/2026-06-15-layoutdesc-importer.md`)
|
||||
**Sources:**
|
||||
- Dat dumps: `dump-vitals-layout` on `0x2100006C`, `0x21000014`, `0x21000075`, `0x2100003F`
|
||||
- Retail decomp: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR PDB)
|
||||
- DatReaderWriter 2.1.7 reflection probe (deleted after use)
|
||||
|
||||
This doc is the ground-truth API table for Tasks 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<UIStateId, StateDesc>` | named states (e.g. `HideDetail`, `ShowDetail`) |
|
||||
| `Children` | **field** | `Dictionary<uint, ElementDesc>` | child elements keyed by their `ElementId` |
|
||||
|
||||
**Important:** `X`, `Y`, `Width`, `Height`, `LeftEdge`, etc. are all `uint`, not `int` or `float`. Cast to `float`/`int` when constructing `ElementInfo`.
|
||||
|
||||
The dump tool iterates both properties and fields; the scalars (`X`, `Y`, etc.) are found as **fields**.
|
||||
|
||||
---
|
||||
|
||||
## 2. `StateDesc` — exact API
|
||||
|
||||
| Member | Kind | Type | Notes |
|
||||
|--------|------|------|-------|
|
||||
| `StateId` | **field** | `uint` | redundant with the dict key |
|
||||
| `PassToChildren` | **field** | `bool` | |
|
||||
| `IncorporationFlags` | **field** | `IncorporationFlags` | |
|
||||
| `Properties` | **field** | `Dictionary<uint, BaseProperty>` | keyed by property-id (uint); see §3 |
|
||||
| `Media` | **field** | `List<MediaDesc>` | polymorphic list of media items |
|
||||
|
||||
### States dictionary key type
|
||||
|
||||
`ElementDesc.States` is `Dictionary<UIStateId, StateDesc>`. The dump shows string names like `"HideDetail"` and `"ShowDetail"` because the dump tool calls `.Key.ToString()` on the `UIStateId` enum values. The actual key is a `UIStateId` enum:
|
||||
|
||||
```csharp
|
||||
// Key: UIStateId.HideDetail = 268435462 (0x10000006)
|
||||
// Key: UIStateId.ShowDetail = 268435463 (0x10000007)
|
||||
```
|
||||
|
||||
See §6 for the full `UIStateId` enum.
|
||||
|
||||
**Iterating in code:**
|
||||
```csharp
|
||||
foreach (var s in d.States)
|
||||
ReadState(s.Value, s.Key.ToString(), info); // s.Key is UIStateId; .ToString() gives "HideDetail" etc.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Properties (`StateDesc.Properties`) — how font DID and fill are stored
|
||||
|
||||
`StateDesc.Properties` is `Dictionary<uint, BaseProperty>`. The `BaseProperty` base class has:
|
||||
- `BasePropertyType PropertyType` (enum)
|
||||
- `uint MasterPropertyId`
|
||||
- `bool ShouldPackMasterPropertyId`
|
||||
|
||||
Concrete subclasses (`DatReaderWriter.Types.*`):
|
||||
|
||||
| Subclass | Field | Type | Notes |
|
||||
|----------|-------|------|-------|
|
||||
| `BoolBaseProperty` | `Value` | `bool` | |
|
||||
| `IntegerBaseProperty` | `Value` | `int` | |
|
||||
| `FloatBaseProperty` | `Value` | `float` | |
|
||||
| `EnumBaseProperty` | `Value` | `uint` | |
|
||||
| `DataIdBaseProperty` | `Value` | `uint` | a dat object DID |
|
||||
| `ArrayBaseProperty` | `Value` | `List<BaseProperty>` | array of sub-properties |
|
||||
| `ColorBaseProperty` | `Value` | `ColorARGB` | `struct { byte Blue, Green, Red, Alpha }` |
|
||||
| `StringInfoBaseProperty` | `Value` | `StringInfo` | |
|
||||
| `VectorBaseProperty` | `Value` | `Vector3` | |
|
||||
| `Bitfield32BaseProperty` | `Value` | `uint` | |
|
||||
| `Bitfield64BaseProperty` | `Value` | `ulong` | |
|
||||
| `InstanceIdBaseProperty` | `Value` | `uint` | |
|
||||
| `StructBaseProperty` | `Value` | `Dictionary<uint, BaseProperty>` | |
|
||||
|
||||
### Property key meanings (confirmed from decomp + dat inspection)
|
||||
|
||||
| Key | Type found in dat | Meaning | Decomp ref |
|
||||
|-----|-------------------|---------|-----------|
|
||||
| `0x1A` | `ArrayBaseProperty` (contains `DataIdBaseProperty`) | **Font DID** — array with one item; the inner `DataIdBaseProperty.Value` is the font dat object id | `UIElement_Text::SetFontDIDHelper(this, 0x1a, ...)` @`0x46829e` |
|
||||
| `0x1B` | `ArrayBaseProperty` (contains `ColorBaseProperty`) | **Font color** — array with one item; `ColorARGB {R,G,B,A}` | `UIElement_Text::SetFontColorHelper(this, 0x1b, ...)` @`0x4682c2` |
|
||||
| `0x14` | `EnumBaseProperty` | **Horizontal justification** | `UIElement_Text::SetHorizontalJustification` @`0x467200` |
|
||||
| `0x15` | `EnumBaseProperty` | **Vertical justification** | `UIElement_Text::SetVerticalJustification` @`0x467230` |
|
||||
| `0x1C` / `0x1D` | `ArrayBaseProperty` | Tag font color / tag font | (secondary font style for in-text tags) |
|
||||
| `0x16` | `BoolBaseProperty` | Some text flag | |
|
||||
| `0x21` | `BoolBaseProperty` | One-line mode | |
|
||||
| `0x23` | `IntegerBaseProperty` | Left margin | |
|
||||
| `0x24` | `IntegerBaseProperty` | Top margin | |
|
||||
| `0x25` | `IntegerBaseProperty` | Right margin | |
|
||||
| `0x26` | `IntegerBaseProperty` | Bottom margin | |
|
||||
| `0x27` | `BoolBaseProperty` | Some text option | |
|
||||
| `0x20` | `BoolBaseProperty` | Some text option | |
|
||||
| `0x69` | — (NOT in dat) | **Fill percent** — set at runtime via `UIElement::SetAttribute_Float(meter, 0x69, fillRatio)` | `gmVitalsUI::Update` @`0x4bff2a` |
|
||||
| `0xCB` | `BoolBaseProperty` | Some text option | |
|
||||
|
||||
**Critical point for font DID extraction:**
|
||||
Property `0x1A` is an `ArrayBaseProperty` containing ONE `DataIdBaseProperty`. To read the font DID:
|
||||
```csharp
|
||||
if (sd.Properties.TryGetValue(0x1Au, out var raw) && raw is ArrayBaseProperty arr && arr.Value.Count > 0)
|
||||
if (arr.Value[0] is DataIdBaseProperty did)
|
||||
fontDid = did.Value; // e.g. 0x40000000
|
||||
```
|
||||
|
||||
**Confirmed for element `0x10000376` (the vitals text prototype):**
|
||||
- Property `0x1A` → `DataIdBaseProperty.Value = 0x40000000` (font DID)
|
||||
- Property `0x1B` → `ColorBaseProperty.Value = {B=255,G=255,R=255,A=255}` (white)
|
||||
|
||||
**The fill (`0x69`) is NOT in the dat.** It is pushed at runtime by `gmVitalsUI::Update` calling `UIElement::SetAttribute_Float(meter, 0x69, ratio)`. The importer does not read this from the dat — the `VitalsController` sets it via `UiMeter.Fill` after binding.
|
||||
|
||||
---
|
||||
|
||||
## 4. Edge-anchor flags (`LeftEdge`/`TopEdge`/`RightEdge`/`BottomEdge`)
|
||||
|
||||
These are `uint` fields on `ElementDesc`. The values found across all four vitals layouts are:
|
||||
|
||||
| Value | Meaning | Where observed |
|
||||
|-------|---------|---------------|
|
||||
| `0` | Not present / no constraint | Base layout `0x2100003F` (zero-size elements) |
|
||||
| `1` | **Stretch / track-far** — for LeftEdge: pin left (near); for RightEdge: stretch (track parent's right edge); for TopEdge: pin top; for BottomEdge: stretch (track parent's bottom) | Most vitals pieces |
|
||||
| `2` | **Track-right (for LeftEdge) / fixed-far (for RightEdge)** — LeftEdge=2 means the element's LEFT side tracks the parent's RIGHT edge (fixed-width piece that moves right); RightEdge=2 means the right edge is fixed relative to the parent right (no stretch) | Corners/right-side pieces |
|
||||
| `3` | **Centered / floating** — contributes no anchor on that axis | The expand-detail overlay child `0x100004A9` |
|
||||
| `4` | **Both-sides** — both near AND far edges fire simultaneously | Seen in child layout meter elements |
|
||||
|
||||
### Anchor logic (retail-faithful, per `UIElement::UpdateForParentSizeChange @0x00462640`)
|
||||
|
||||
The **far-axis fields** (RightEdge, BottomEdge) drive stretch:
|
||||
- **RightEdge==1** ⇒ the right edge tracks the parent's right edge (**STRETCH**; designRight+delta)
|
||||
- **RightEdge==2** ⇒ designRight is fixed (no stretch)
|
||||
- **LeftEdge==2** ⇒ a fixed-width piece's left side tracks the parent's right edge (it **moves right**)
|
||||
- **LeftEdge==1** ⇒ pin left at designX (near-pin)
|
||||
- **value==4** ⇒ both near AND far fire simultaneously (stretch + keep near)
|
||||
- **value==3** ⇒ centered / floating (no anchor on that axis)
|
||||
- **value==0** ⇒ no anchor (prototype-only)
|
||||
|
||||
This is the INVERSE of the earlier §Corrections reading ("1=near, 2=far"), which was wrong. The decomp is authoritative: `UIElement::UpdateForParentSizeChange @0x00462640` in `docs/research/named-retail/acclient_2013_pseudo_c.txt` lines 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<MediaDesc>`. The concrete types found across the vitals layouts:
|
||||
|
||||
| Subclass | Fields | Used in vitals? | Notes |
|
||||
|----------|--------|----------------|-------|
|
||||
| `MediaDescImage` | `uint File`, `DrawModeType DrawMode`, `MediaType Type` | YES — all sprite images | The primary media type |
|
||||
| `MediaDescCursor` | `uint File`, `uint XHotspot`, `uint YHotspot`, `MediaType Type` | YES — grip/dragbar cursor | Sets the mouse cursor when hovering the element |
|
||||
| `MediaDescAnimation` | `float Duration`, `DrawModeType DrawMode`, `List<BaseProperty> Frames`, `MediaType Type` | not in vitals | Animated sprite |
|
||||
| `MediaDescAlpha` | `uint File`, `MediaType Type` | not in vitals | Alpha overlay |
|
||||
| `MediaDescFade` | `float StartAlpha, EndAlpha, Duration`, `MediaType Type` | not in vitals | Fade transition |
|
||||
| `MediaDescSound` | `uint File`, ... | not in vitals | |
|
||||
| `MediaDescState` | `UIStateId StateId`, ... | not in vitals | State transition |
|
||||
| `MediaDescJump` | `uint JumpItemIndex`, ... | not in vitals | |
|
||||
| `MediaDescMessage` | `uint Id`, ... | not in vitals | |
|
||||
| `MediaDescPause` | `float MinDuration, MaxDuration`, ... | not in vitals | |
|
||||
| `MediaDescMovie` | `PStringBase<char> FileName`, ... | not in vitals | |
|
||||
|
||||
Elements can have **multiple media items** in the same `StateDesc.Media` list — e.g. a grip element has both a `MediaDescImage` (the sprite) and a `MediaDescCursor` (the cursor shape). Iterate all items; for rendering pick the `MediaDescImage`; for cursor behavior pick `MediaDescCursor`.
|
||||
|
||||
---
|
||||
|
||||
## 6. `DrawModeType` enum (confirmed from reflection)
|
||||
|
||||
`DatReaderWriter.Enums.DrawModeType` (the type on `MediaDescImage.DrawMode`):
|
||||
|
||||
| Name | Value | Behavior | Used in vitals? |
|
||||
|------|-------|----------|----------------|
|
||||
| `Undefined` | 0 | (not used) | no |
|
||||
| `Normal` | 1 | **Tile at native width** (UV-repeat; matches `ImgTex::TileCSI` @`0x53e740`) | YES — all bar sprites, chrome |
|
||||
| `Overlay` | 2 | Blended overlay (not observed in vitals) | no |
|
||||
| `Alphablend` | 3 | **Blended overlay** — used for the "ShowDetail" expand panels | YES — `ShowDetail` state sprites |
|
||||
|
||||
**The vitals window uses only `Normal` (1) and `Alphablend` (3).** No `Stretch` value exists in `DrawModeType` — the plan's mention of a "Stretch" draw-mode is NOT a value in this enum. There is a `MediaType.Stretch = 12` in a separate enum but that refers to a different concept (animation sequence? not a blit mode). Do not branch on `Stretch` in `UiDatElement`.
|
||||
|
||||
---
|
||||
|
||||
## 7. `UIStateId` enum (key type for `ElementDesc.States`)
|
||||
|
||||
`DatReaderWriter.Enums.UIStateId`. Key values relevant to the vitals window:
|
||||
|
||||
| Name | Value |
|
||||
|------|-------|
|
||||
| `Undef` | 0 |
|
||||
| `Normal` | 1 |
|
||||
| `HideDetail` | 268435462 (= `0x10000006`) |
|
||||
| `ShowDetail` | 268435463 (= `0x10000007`) |
|
||||
| `IsCharacter` | 268435542 (= `0x10000056`) |
|
||||
| `IsAccount` | 268435543 (= `0x10000057`) |
|
||||
|
||||
The dump prints these as strings ("HideDetail", "ShowDetail") via `UIStateId.ToString()`. When iterating `d.States`, `s.Key.ToString()` gives the readable name.
|
||||
|
||||
---
|
||||
|
||||
## 8. Type → meaning → render method → widget bucket
|
||||
|
||||
From `UIElement::RegisterElementClass` calls in the decomp. The mapping is CONFIRMED by retail:
|
||||
|
||||
| Type (uint) | Class registered | Render method | Widget bucket | Vitals? |
|
||||
|-------------|-----------------|---------------|---------------|---------|
|
||||
| 0 | — (no registration) | text label; inherits from `UIElement_Text` behavior via `UIElement_Scrollable` | **behavioral** → dat-font label widget | YES — the text overlay (e.g. `0x100000EB/ED/EF`) |
|
||||
| 1 | `UIElement_Button::Register()` | `UIRegion::DrawHere` (vtable) | **behavioral** → button widget | no |
|
||||
| 2 | `UIElement_Dragbar::Register()` | `UIRegion::DrawHere` | **generic** → `UiDatElement` (drag region) | YES — top/bottom drag bars |
|
||||
| 3 | `UIElement_Field::Register()` | `UIRegion::DrawHere` | **generic** → `UiDatElement` | YES — container/group elements, chrome corners/edges |
|
||||
| 4 | (unregistered in stdlib; may be custom) | — | generic fallback | no |
|
||||
| 5 | `UIElement_ListBox::Register()` | `UIRegion::DrawHere` | **behavioral** → list widget | no |
|
||||
| 6 | `UIElement_Menu::Register()` | `UIRegion::DrawHere` | **behavioral** → menu widget | no |
|
||||
| **7** | `UIElement_Meter::Register()` | **`UIElement_Meter::DrawChildren`** @`0x46fbd0` | **behavioral** → `UiMeter` | **YES — the three vitals bars** |
|
||||
| 8 | `UIElement_Panel::Register()` | `UIRegion::DrawHere` | generic → `UiDatElement` | no |
|
||||
| 9 | `UIElement_Resizebar::Register()` | `UIRegion::DrawHere` | **generic** → `UiDatElement` (grip) | YES — resize grips (corners + edges) |
|
||||
| 0xB | `UIElement_Scrollbar::Register()` | `UIRegion::DrawHere` | **behavioral** → scrollbar | no |
|
||||
| **0xC** | `UIElement_Text::Register()` | `UIElement_Text::DrawSelf` @`0x467aa0` | **behavioral** → dat-font label | YES — Type=0 elements have BaseElement which resolves to a Type=0x0C in the base |
|
||||
| 0xD | `UIElement_Viewport::Register()` | — | behavioral → 3D viewport | no |
|
||||
| 0xE | `UIElement_Browser::Register()` | — | behavioral → browser | no |
|
||||
| 0x10 | `UIElement_ColorPicker::Register()` | — | behavioral → color picker | no |
|
||||
| 0x11 | `UIElement_GroupBox::Register()` | — | behavioral → group box | no |
|
||||
| **0x12** | — (Type=12 in base layout) | No render method registered — these are **style prototypes** (zero-size elements used as `BaseElement` sources, never instantiated directly) | skip/omit | YES — `0x2100003F` is full of Type=12 elements |
|
||||
| 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<uint, ElementDesc>` (DRW-internal type) | top-level elements, keyed by `ElementId`. Iterable with `foreach (var kv in ld.Elements)`. |
|
||||
|
||||
---
|
||||
|
||||
## 10. Inheritance chain for vitals number-text elements
|
||||
|
||||
All three vitals text labels (`0x100000EB` health, `0x100000ED` stamina, `0x100000EF` mana) share:
|
||||
- `Type = 0` (text element, no render registration — renders via inherited machinery)
|
||||
- `BaseElement = 268436342 = 0x10000376`
|
||||
- `BaseLayoutId = 553648191 = 0x2100003F`
|
||||
|
||||
The base element `0x10000376` in `0x2100003F`:
|
||||
- `Type = 12` (style prototype — zero-size, never rendered directly)
|
||||
- `StateDesc.Properties`:
|
||||
- `0x1A` → `ArrayBaseProperty[ DataIdBaseProperty{Value=0x40000000} ]` — **font DID = `0x40000000`**
|
||||
- `0x1B` → `ArrayBaseProperty[ ColorBaseProperty{R=255,G=255,B=255,A=255} ]` — white
|
||||
- `0x14` → `EnumBaseProperty{Value=1}` — horizontal justification = 1
|
||||
- `0x15` → `EnumBaseProperty{Value=1}` — vertical justification = 1
|
||||
- `0x23`, `0x25` → `IntegerBaseProperty{Value=0}` — margins
|
||||
|
||||
The inheritance chain for the text element in the importer is:
|
||||
```
|
||||
derived (Type=0, no StateDesc media, no font prop itself)
|
||||
inherits from base 0x10000376 in layout 0x2100003F (Type=12)
|
||||
→ font DID = 0x40000000 (from property 0x1A)
|
||||
→ font color = white ARGB(255,255,255,255) (from property 0x1B)
|
||||
```
|
||||
|
||||
The derived text element overrides `Width/Height/X/Y` (from the dat element's fields) but inherits the font DID and color from the base element's `Properties`.
|
||||
|
||||
**There is no `StateDesc.Media` on the text elements** — the text is rendered by the `UIElement_Text::DrawSelf` algorithm using the font DID from properties, not a sprite. In Plan 1, the text elements are **skipped entirely**: `Type = 0` (derived) inherits `Type = 12` from the base prototype `0x10000376` via `ElementReader.Merge` (zero-wins-nothing rule — the derived Type 0 inherits the base's Type 12), and `DatWidgetFactory` returns null for Type 12. This means no `UiDatElement` is created for them. For the vitals window this is correct: the numbers render via `UiMeter.Label` bound by the `VitalsController`, not a dat text node. A dedicated dat-text widget (Type 0) is Plan 2.
|
||||
|
||||
---
|
||||
|
||||
## 11. Vitals window `0x2100006C` — confirmed element map
|
||||
|
||||
Root: `0x100005F9` (160×58, Type=`0x1000004D`, LeftEdge=1, TopEdge=1, RightEdge=1, BottomEdge=2)
|
||||
|
||||
### Chrome (all Type=3, `DrawMode=Normal`)
|
||||
|
||||
| Id | X | Y | W | H | LeftEdge | TopEdge | RightEdge | BottomEdge | Sprite |
|
||||
|----|---|---|---|---|----------|---------|-----------|------------|--------|
|
||||
| `0x10000633` | 0 | 0 | 5 | 5 | 1 | 1 | 2 | 2 | `0x060074C3` (TL corner) |
|
||||
| `0x10000634` | 5 | 0 | 150 | 5 | 1 | 1 | 1 | 2 | `0x060074BF` (top edge) |
|
||||
| `0x10000635` | 155 | 0 | 5 | 5 | 2 | 1 | 1 | 2 | `0x060074C4` (TR corner) |
|
||||
| `0x10000636` | 0 | 5 | 5 | 48 | 1 | 1 | 2 | 1 | `0x060074C0` (left edge) |
|
||||
| `0x10000637` | 0 | 53 | 5 | 5 | 1 | 2 | 2 | 1 | `0x060074C5` (BL corner) |
|
||||
| `0x10000638` | 5 | 53 | 150 | 5 | 1 | 2 | 1 | 1 | `0x060074C1` (bottom edge) |
|
||||
| `0x10000639` | 155 | 53 | 5 | 5 | 2 | 2 | 1 | 1 | `0x060074C6` (BR corner) |
|
||||
| `0x1000063A` | 155 | 5 | 5 | 48 | 2 | 1 | 1 | 1 | `0x060074C2` (right edge) |
|
||||
|
||||
### Drag bars (Type=2)
|
||||
|
||||
| Id | X | Y | W | H | Notes |
|
||||
|----|---|---|---|---|-------|
|
||||
| `0x1000063C` | 5 | 0 | 150 | 5 | top drag bar; also has `MediaDescCursor` cursor `0x06006119` |
|
||||
| `0x10000640` | 5 | 53 | 150 | 5 | bottom drag bar; same cursor |
|
||||
|
||||
### Resize grips (Type=9 — corners + edges)
|
||||
|
||||
| Id | X | Y | W | H | Corner/Edge |
|
||||
|----|---|---|---|---|-------------|
|
||||
| `0x1000063B` | 0 | 0 | 5 | 5 | TL grip |
|
||||
| `0x1000063D` | 155 | 0 | 5 | 5 | TR grip |
|
||||
| `0x1000063E` | 0 | 5 | 5 | 48 | left grip |
|
||||
| `0x1000063F` | 0 | 53 | 5 | 5 | BL grip |
|
||||
| `0x10000641` | 155 | 53 | 5 | 5 | BR grip |
|
||||
| `0x10000642` | 155 | 5 | 5 | 48 | right grip |
|
||||
|
||||
Each grip has a `MediaDescImage` + a `MediaDescCursor` in its `StateDesc.Media` list.
|
||||
|
||||
### Meter elements (Type=7 — `UiMeter`)
|
||||
|
||||
| Id | X | Y | W | H | Purpose |
|
||||
|----|---|---|---|---|---------|
|
||||
| `0x100000E6` | 5 | 5 | 150 | 16 | Health meter |
|
||||
| `0x100000EC` | 5 | 21 | 150 | 16 | Stamina meter |
|
||||
| `0x100000EE` | 5 | 37 | 150 | 16 | Mana meter |
|
||||
|
||||
Each meter has:
|
||||
- Child `0x100000E7` (back layer, Type=3): three sub-children `E8`/`E9`/`EA` (left/center/right slices, back sprites)
|
||||
- `E8` has `RightEdge=2` (pin far right), `EA` has `LeftEdge=2` (pin far left) — the classic 3-slice anchor pattern
|
||||
- Child `0x00000002` (front layer container, Type=3): three sub-children `E8`/`E9`/`EA` (front sprites), plus child `0x100004A9` (expand detail overlay, HideDetail/ShowDetail states)
|
||||
- Child `0x100000EB/ED/EF` (text label, Type=0): BaseElement=`0x10000376`, BaseLayoutId=`0x2100003F` → inherits font `0x40000000`
|
||||
|
||||
### Sprite ids confirmed from dump
|
||||
|
||||
**Health bar** (back=`E7` layer / front=`00000002.E8-EA` layer):
|
||||
- Back left: `0x0600747E`, center: `0x0600747F`, right: `0x06007480`
|
||||
- Front left: `0x06007481`, center: `0x06007482`, right: `0x06007483`
|
||||
- ShowDetail overlay: `0x06007490` (back) / `0x06007491` (front)
|
||||
|
||||
**Stamina bar:**
|
||||
- Back left: `0x06007484`, center: `0x06007485`, right: `0x06007486`
|
||||
- Front left: `0x06007487`, center: `0x06007488`, right: `0x06007489`
|
||||
- ShowDetail: `0x06007492` / `0x06007493`
|
||||
|
||||
**Mana bar:**
|
||||
- Back left: `0x0600748A`, center: `0x0600748B`, right: `0x0600748C`
|
||||
- Front left: `0x0600748D`, center: `0x0600748E`, right: `0x0600748F`
|
||||
- ShowDetail: `0x06007494` / `0x06007495`
|
||||
|
||||
---
|
||||
|
||||
## 12. Inheritance resolution rules
|
||||
|
||||
1. If `d.BaseElement != 0 && d.BaseLayoutId != 0`: load base layout, find base element, call `Resolve()` recursively on it, then `Merge(base, derived)`.
|
||||
2. Merge semantics: **derived overrides, base is the default**. `Width`/`Height`/`X`/`Y` come from the derived element's fields (even if zero — zero is a valid override for prototypes). `FontDid` is inherited if the derived element's base chain provides it and the derived doesn't explicitly set it.
|
||||
3. Type=12 elements in the base layout (`0x2100003F`) are pure property stores — **never render them**. They exist only to be referenced as `BaseElement`.
|
||||
4. Cycle-guard: track already-visited `(BaseLayoutId, BaseElement)` pairs to avoid infinite loops.
|
||||
|
||||
---
|
||||
|
||||
## § Corrections to plan assumptions
|
||||
|
||||
### 1. Edge-flag semantics are INVERTED from the earlier §4 reading
|
||||
|
||||
**Original §4 reading (Task 2 shipped):** `1=near, 2=far, 4=stretch` → `right==2||right==4` for Right anchor.
|
||||
**That was wrong.** The correct semantics, per `UIElement::UpdateForParentSizeChange @0x00462640`:
|
||||
|
||||
| Edge value | LeftEdge meaning | RightEdge meaning |
|
||||
|-----------|-----------------|------------------|
|
||||
| 0 | no anchor | no anchor |
|
||||
| 1 | pin left (near) → **Left** | track parent's right edge (stretch) → **Right** |
|
||||
| 2 | track parent's right edge (moves right) → **Right** | fixed right (no stretch) |
|
||||
| 3 | centered / floating (no anchor) | centered / floating (no anchor) |
|
||||
| 4 | both-sides → **Left + Right** | both-sides → **Left + Right** |
|
||||
|
||||
The far-axis field (RightEdge, BottomEdge) value `1` means **stretch** (track the parent's far edge), NOT "near-pin." This is the INVERSE of what was documented in the original §4.
|
||||
|
||||
**Correct `ToAnchors` (as fixed in `ElementReader.cs` 2026-06-15):**
|
||||
```csharp
|
||||
// Per UIElement::UpdateForParentSizeChange @0x00462640
|
||||
public static AnchorEdges ToAnchors(uint left, uint top, uint right, uint bottom)
|
||||
{
|
||||
var a = AnchorEdges.None;
|
||||
if (left == 1 || left == 4) a |= AnchorEdges.Left;
|
||||
if (right == 1 || right == 4 || left == 2) a |= AnchorEdges.Right;
|
||||
if (top == 1 || top == 4) a |= AnchorEdges.Top;
|
||||
if (bottom == 1 || bottom == 4 || top == 2) a |= AnchorEdges.Bottom;
|
||||
if (a == AnchorEdges.None) a = AnchorEdges.Left | AnchorEdges.Top;
|
||||
return a;
|
||||
}
|
||||
```
|
||||
|
||||
Also: the `ElementReader.ToAnchors` signature in the plan uses `(int left, ...)` but the fields are `uint`. Use `(uint left, ...)` or cast at call site.
|
||||
|
||||
### 2. `X`, `Y`, `Width`, `Height`, `LeftEdge`, etc. are `uint`, not `float` or `int`
|
||||
|
||||
The plan's `ToInfo()` code uses `d.X, d.Y` etc. as though they are already numeric-assignable. They are `uint`, so the assignment `X = d.X` etc. requires an explicit cast `(float)d.X` in the `ElementInfo` struct.
|
||||
|
||||
### 3. `ElementDesc.Type` is `uint`, not an enum
|
||||
|
||||
The plan writes `(int)d.Type`. `d.Type` is `uint`, so `(int)d.Type` is valid C# (checked context would overflow for values > `int.MaxValue`, but the registered types are all small or `0x10000009` which fits in int). Better: store `Type` as `uint` in `ElementInfo` to avoid signed overflow on game-specific ids like `0x1000004D`.
|
||||
|
||||
### 4. `DrawModeType` has no `Stretch` value
|
||||
|
||||
The plan mentions handling `Stretch` in `UiDatElement`. The `DrawModeType` enum has only `{Undefined=0, Normal=1, Overlay=2, Alphablend=3}`. There is no `Stretch` draw mode in this enum. Drop the `Stretch` branch.
|
||||
|
||||
### 5. `d.States` key is `UIStateId`, not `string`
|
||||
|
||||
The plan writes `foreach (var s in d.States) ReadState(s.Value, s.Key, info);` treating `s.Key` as a string. The key is `UIStateId` (an enum). Use `s.Key.ToString()` for the string name, or compare directly via `UIStateId.HideDetail` etc.
|
||||
|
||||
### 6. Font DID is in `ArrayBaseProperty`, not a direct property
|
||||
|
||||
The plan's `// font DID (property 0x1A) read here once the format doc confirms the property API.` comment is the right place. The actual read is:
|
||||
```csharp
|
||||
if (sd.Properties.TryGetValue(0x1Au, out var raw) && raw is ArrayBaseProperty arr && arr.Value.Count > 0)
|
||||
if (arr.Value[0] is DataIdBaseProperty did)
|
||||
info.FontDid = did.Value;
|
||||
```
|
||||
|
||||
### 7. Fill (`0x69`) is NOT in the dat
|
||||
|
||||
The plan says `SetAttribute_Float(meter, 0x69, fillRatio)` is a runtime operation. Confirmed: property `0x69` does not appear in any dat layout. The fill is set at runtime by the controller. The importer should not attempt to read it.
|
||||
|
||||
### 8. Type=12 elements are style prototypes — skip them entirely
|
||||
|
||||
Elements with `Type=12` in the base layout `0x2100003F` are zero-size property bags used as `BaseElement` sources. They should not be instantiated as widgets. The `DatWidgetFactory` switch should have a `12 => null` (skip) case, or the importer should skip top-level elements with `Width==0 && Height==0 && Type==12` — though the safest check is just `Type == 12`.
|
||||
|
||||
---
|
||||
|
||||
## § Plan 1 surface vs long tail
|
||||
|
||||
**Plan 1 (vitals conformance) uses:**
|
||||
- Types: 2, 3, 7, 9, 12 (skip), 0 (text, generic fallback), 0x10000009/0x1000004D (root window — treat as container)
|
||||
- DrawModes: `Normal` (1), `Alphablend` (3)
|
||||
- Media: `MediaDescImage`, `MediaDescCursor`
|
||||
- Properties: `0x1A` (font DID, from inheritance), `0x1B` (font color, from inheritance)
|
||||
- States: `HideDetail`, `ShowDetail`
|
||||
|
||||
**Plan 2 (long tail):**
|
||||
- Types: 1 (button), 5 (listbox), 6 (menu), 8 (panel), 0xB (scrollbar), 0xC (text widget proper), 0xD (viewport), 0x10 (color picker), 0x11 (groupbox), dialog types (0x13–0x19), all `gm*UI` custom types
|
||||
- DrawModes: `Overlay` (2), any future additions
|
||||
- Media: `MediaDescAnimation`, `MediaDescFade`, `MediaDescSound`, `MediaDescState`, etc.
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
1322
docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md
Normal file
1322
docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md
Normal file
File diff suppressed because it is too large
Load diff
1484
docs/superpowers/plans/2026-06-15-chat-window-redrive.md
Normal file
1484
docs/superpowers/plans/2026-06-15-chat-window-redrive.md
Normal file
File diff suppressed because it is too large
Load diff
760
docs/superpowers/plans/2026-06-15-layoutdesc-importer.md
Normal file
760
docs/superpowers/plans/2026-06-15-layoutdesc-importer.md
Normal file
|
|
@ -0,0 +1,760 @@
|
|||
# LayoutDesc Importer — Implementation Plan (Plan 1: foundation + vitals conformance)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Read the retail vitals `LayoutDesc` (`0x2100006C`) from the dat and build a `UiElement` tree that reproduces the hand-built vitals window — proving a data-driven importer that needs no per-window graphics code.
|
||||
|
||||
**Architecture:** A `LayoutImporter` reads a layout, resolves `BaseElement`/`BaseLayoutId` inheritance, and walks the `ElementDesc` tree. A hybrid factory maps each element's `Type` to either a dedicated behavioral widget (meter → `UiMeter`, text → dat-font label) or a generic `UiDatElement` that draws any element's media by draw-mode (reusing the proven tiling primitive). A per-window `VitalsController` binds live data to elements by id, mirroring retail's `gmVitalsUI`. Everything renders through the existing `UiRoot` + primitives — nothing is deleted.
|
||||
|
||||
**Tech Stack:** C# .NET 10, Silk.NET, `Chorizite.DatReaderWriter` 2.1.7, xUnit. Spec: `docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md`.
|
||||
|
||||
**Scope of Plan 1:** rollout steps 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;
|
||||
|
||||
/// <summary>GL-free, dat-free snapshot of a resolved layout element. Populated by the
|
||||
/// importer from DatReaderWriter.ElementDesc (after inheritance); the pure transforms
|
||||
/// below operate on it so they unit-test without the dats.</summary>
|
||||
public sealed class ElementInfo
|
||||
{
|
||||
public uint Id;
|
||||
public int Type;
|
||||
public float X, Y, Width, Height;
|
||||
public int Left, Top, Right, Bottom; // edge-anchor flags
|
||||
public uint FontDid; // 0 = none (inherited via Merge)
|
||||
// sprite per state: state name -> (file, drawMode). "" = DirectState.
|
||||
public Dictionary<string, (uint File, int DrawMode)> StateMedia = new();
|
||||
}
|
||||
|
||||
public static class ElementReader
|
||||
{
|
||||
/// <summary>Edge-anchor flags → AnchorEdges. Flag value 4 (per format doc) = "pinned
|
||||
/// to that side"; any other value = not pinned. Left+Right ⇒ width stretches.</summary>
|
||||
public static AnchorEdges ToAnchors(int left, int top, int right, int bottom)
|
||||
{
|
||||
var a = AnchorEdges.None;
|
||||
if (left == 4) a |= AnchorEdges.Left;
|
||||
if (top == 4) a |= AnchorEdges.Top;
|
||||
if (right == 4) a |= AnchorEdges.Right;
|
||||
if (bottom == 4) a |= AnchorEdges.Bottom;
|
||||
if (a == AnchorEdges.None) a = AnchorEdges.Left | AnchorEdges.Top; // default: pin top-left
|
||||
return a;
|
||||
}
|
||||
|
||||
/// <summary>Merge a base element with a derived override: start from base, apply any
|
||||
/// non-default field the derived element sets. Mirrors BaseElement/BaseLayoutId.</summary>
|
||||
public static ElementInfo Merge(ElementInfo base_, ElementInfo derived)
|
||||
{
|
||||
var m = new ElementInfo
|
||||
{
|
||||
Id = derived.Id != 0 ? derived.Id : base_.Id,
|
||||
Type = derived.Type != 0 ? derived.Type : base_.Type,
|
||||
X = derived.X, Y = derived.Y, // position is the derived placement
|
||||
Width = derived.Width != 0 ? derived.Width : base_.Width,
|
||||
Height = derived.Height != 0 ? derived.Height : base_.Height,
|
||||
Left = derived.Left, Top = derived.Top, Right = derived.Right, Bottom = derived.Bottom,
|
||||
FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid,
|
||||
StateMedia = new Dictionary<string, (uint, int)>(base_.StateMedia),
|
||||
};
|
||||
foreach (var kv in derived.StateMedia) m.StateMedia[kv.Key] = kv.Value; // derived overrides
|
||||
return m;
|
||||
}
|
||||
}
|
||||
```
|
||||
> NOTE: confirm the edge-flag "pinned" value (4) and the font-property key against Task 1's doc; adjust the `== 4` test if the doc says otherwise.
|
||||
|
||||
- [ ] **Step 4: Run to verify pass**
|
||||
|
||||
Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~ElementReaderTests"`
|
||||
Expected: PASS (2 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
git add src/AcDream.App/UI/Layout/ElementReader.cs tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs
|
||||
git commit -m "feat(D.2b): ElementReader — layout inheritance merge + edge-flag anchors"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: UiDatElement — generic element + draw-mode render
|
||||
|
||||
**Files:**
|
||||
- Create: `src/AcDream.App/UI/Layout/UiDatElement.cs`
|
||||
|
||||
Generic widget: holds an `ElementInfo` + the active state name, draws that state's media by draw-mode. Reuses the proven tiling render (UV-repeat at native width; UI textures are `GL_REPEAT`-wrapped).
|
||||
|
||||
- [ ] **Step 1: Write the failing test (active-state selection is pure)**
|
||||
|
||||
```csharp
|
||||
using AcDream.App.UI.Layout;
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public class UiDatElementTests
|
||||
{
|
||||
[Fact]
|
||||
public void ActiveMedia_PrefersNamedStateOverDirect()
|
||||
{
|
||||
var info = new ElementInfo();
|
||||
info.StateMedia[""] = (0x06000001, 0); // DirectState
|
||||
info.StateMedia["ShowDetail"] = (0x06000002, 1); // named
|
||||
var e = new UiDatElement(info, (_, _) => (0, 0, 0)) { ActiveState = "ShowDetail" };
|
||||
Assert.Equal(0x06000002u, e.ActiveMedia().File);
|
||||
e.ActiveState = "";
|
||||
Assert.Equal(0x06000001u, e.ActiveMedia().File);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify failure**
|
||||
|
||||
Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~UiDatElementTests"`
|
||||
Expected: FAIL — `UiDatElement` not defined.
|
||||
|
||||
- [ ] **Step 3: Implement UiDatElement**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>Generic dat element: draws its active state's media by DrawMode (Normal=tile,
|
||||
/// Alphablend=blended overlay). The fallback renderer for every element type without a
|
||||
/// dedicated behavioral widget; faithful because retail's base element render is exactly
|
||||
/// "stamp the media per draw-mode".</summary>
|
||||
public sealed class UiDatElement : UiElement
|
||||
{
|
||||
private readonly ElementInfo _info;
|
||||
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
|
||||
public string ActiveState { get; set; } = "";
|
||||
|
||||
public UiDatElement(ElementInfo info, Func<uint, (uint, int, int)> resolve)
|
||||
{
|
||||
_info = info; _resolve = resolve;
|
||||
ClickThrough = true; // generic decoration; behavioral widgets opt back in
|
||||
}
|
||||
|
||||
public (uint File, int DrawMode) ActiveMedia()
|
||||
=> _info.StateMedia.TryGetValue(ActiveState, out var m) ? m
|
||||
: _info.StateMedia.TryGetValue("", out var d) ? d
|
||||
: (0u, 0);
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
var (file, drawMode) = ActiveMedia();
|
||||
if (file == 0) return;
|
||||
var (tex, tw, th) = _resolve(file);
|
||||
if (tex == 0 || tw == 0 || th == 0) return;
|
||||
// DrawMode 0 = Normal → TILE at native size (UV-repeat; GL_REPEAT-wrapped UI texture),
|
||||
// matching ImgTex::TileCSI. (Alphablend/others are the same blit with a blend state;
|
||||
// the sprite shader already alpha-blends, so the quad is identical here.)
|
||||
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
|
||||
}
|
||||
}
|
||||
```
|
||||
> NOTE: confirm `DrawMode` enum values against Task 1; if a value needs a non-tiled blit (e.g. a true Stretch), branch here. For the vitals surface (Normal + Alphablend) the tiled UV-repeat quad is correct.
|
||||
|
||||
- [ ] **Step 4: Run to verify pass**
|
||||
|
||||
Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~UiDatElementTests"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
git add src/AcDream.App/UI/Layout/UiDatElement.cs tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs
|
||||
git commit -m "feat(D.2b): UiDatElement — generic per-drawmode element renderer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: DatWidgetFactory — Type → widget (else generic)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`
|
||||
- Test: `tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
```csharp
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public class DatWidgetFactoryTests
|
||||
{
|
||||
private static (uint, int, int) NoTex(uint _) => (0, 0, 0);
|
||||
|
||||
[Fact]
|
||||
public void Type7_Meter_MakesUiMeter()
|
||||
{
|
||||
var e = DatWidgetFactory.Create(new ElementInfo { Type = 7, Width = 150, Height = 16 }, NoTex, null);
|
||||
Assert.IsType<UiMeter>(e);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownType_FallsBackToGeneric()
|
||||
{
|
||||
var e = DatWidgetFactory.Create(new ElementInfo { Type = 999 }, NoTex, null);
|
||||
Assert.IsType<UiDatElement>(e);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify failure**
|
||||
|
||||
Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~DatWidgetFactoryTests"`
|
||||
Expected: FAIL — `DatWidgetFactory` not defined.
|
||||
|
||||
- [ ] **Step 3: Implement DatWidgetFactory**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>Hybrid factory: behavioral element Types map to dedicated widgets (verbatim
|
||||
/// algorithm ports); everything else (and unknown Types) falls back to UiDatElement.
|
||||
/// The Type→bucket assignment comes from the format enumeration (Task 1).</summary>
|
||||
public static class DatWidgetFactory
|
||||
{
|
||||
/// <param name="resolve">RenderSurface id → (GL tex, w, h).</param>
|
||||
/// <param name="datFont">Retail UI font for text elements (may be null pre-load).</param>
|
||||
public static UiElement Create(ElementInfo info,
|
||||
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont)
|
||||
{
|
||||
var e = info.Type switch
|
||||
{
|
||||
7 => BuildMeter(info, resolve), // UIElement_Meter
|
||||
_ => new UiDatElement(info, resolve),
|
||||
};
|
||||
e.Left = info.X; e.Top = info.Y; e.Width = info.Width; e.Height = info.Height;
|
||||
e.Anchors = ElementReader.ToAnchors(info.Left, info.Top, info.Right, info.Bottom);
|
||||
return e;
|
||||
}
|
||||
|
||||
private static UiElement BuildMeter(ElementInfo info, Func<uint, (uint, int, int)> resolve)
|
||||
=> new UiMeter { SpriteResolve = resolve }; // back/front slice ids + binding set by the controller
|
||||
}
|
||||
```
|
||||
> NOTE: text (Type 0) keeps using the generic element for now; the dat-font label binding happens in the controller via `UiDatFont`. Add a dedicated text widget in Plan 2 if the enumeration shows behavior beyond "draw a bound string".
|
||||
|
||||
- [ ] **Step 4: Run to verify pass**
|
||||
|
||||
Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~DatWidgetFactoryTests"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
git add src/AcDream.App/UI/Layout/DatWidgetFactory.cs tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
|
||||
git commit -m "feat(D.2b): DatWidgetFactory — Type→widget hybrid mapping"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: LayoutImporter — read layout, resolve inheritance, build tree
|
||||
|
||||
**Files:**
|
||||
- Create: `src/AcDream.App/UI/Layout/LayoutImporter.cs`
|
||||
|
||||
Reads a `LayoutDesc` via `DatCollection`, converts each `ElementDesc` to `ElementInfo` (resolving `BaseElement`/`BaseLayoutId` via `ElementReader.Merge`), builds the widget tree via the factory, and recurses into children. Exposes `FindElement(uint id)`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test (uses the committed fixture, no dats)**
|
||||
|
||||
Create `tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json` by serializing the dumped tree (a list of `ElementInfo`-shaped records). Test that the importer's pure `BuildFromInfos` produces the right tree:
|
||||
```csharp
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public class LayoutImporterTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildFromInfos_HealthMeter_IsUiMeterAtRect()
|
||||
{
|
||||
// health meter element 0x100000E6: X=5,Y=5,150x16,Type=7
|
||||
var root = new ElementInfo { Id = 0x100005F9, Type = 3, Width = 160, Height = 58 };
|
||||
var health = new ElementInfo { Id = 0x100000E6, Type = 7, X = 5, Y = 5, Width = 150, Height = 16 };
|
||||
var tree = LayoutImporter.BuildFromInfos(root, new[] { health }, (_, _) => (0, 0, 0), null);
|
||||
var found = tree.FindElement(0x100000E6);
|
||||
Assert.IsType<UiMeter>(found);
|
||||
Assert.Equal(5f, found!.Left); Assert.Equal(150f, found.Width);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify failure**
|
||||
|
||||
Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~LayoutImporterTests"`
|
||||
Expected: FAIL — `LayoutImporter` not defined.
|
||||
|
||||
- [ ] **Step 3: Implement LayoutImporter**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>Reads a retail LayoutDesc into a UiElement tree. Pure tree-building
|
||||
/// (BuildFromInfos) is dat-free + testable; Import(dats, id, ...) is the dat shell.</summary>
|
||||
public sealed class ImportedLayout
|
||||
{
|
||||
public required UiElement Root { get; init; }
|
||||
private readonly Dictionary<uint, UiElement> _byId;
|
||||
public ImportedLayout(UiElement root, Dictionary<uint, UiElement> byId) { Root = root; _byId = byId; }
|
||||
public UiElement? FindElement(uint id) => _byId.TryGetValue(id, out var e) ? e : null;
|
||||
}
|
||||
|
||||
public static class LayoutImporter
|
||||
{
|
||||
/// <summary>Dat shell: load the layout, convert ElementDescs to ElementInfo (resolving
|
||||
/// inheritance), then BuildFromInfos. Returns null if the layout is missing.</summary>
|
||||
public static ImportedLayout? Import(DatCollection dats, uint layoutId,
|
||||
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont)
|
||||
{
|
||||
var ld = dats.Get<LayoutDesc>(layoutId);
|
||||
if (ld is null) return null;
|
||||
// Convert top-level + nested ElementDescs to resolved ElementInfo.
|
||||
ElementInfo Convert(ElementDesc d) => Resolve(dats, d);
|
||||
// Build a synthetic root that holds the top-level elements as children.
|
||||
var rootInfo = new ElementInfo { Id = 0, Type = 3 };
|
||||
var children = new List<ElementInfo>();
|
||||
var nested = new Dictionary<ElementInfo, ElementDesc>();
|
||||
foreach (var kv in ld.Elements) { var info = Convert(kv.Value); children.Add(info); nested[info] = kv.Value; }
|
||||
return BuildFromInfosRecursive(rootInfo, ld, dats, resolve, datFont);
|
||||
}
|
||||
|
||||
/// <summary>Pure builder used by tests + the shell: build a tree from a root info + its
|
||||
/// direct children infos. (The recursive dat variant handles real nested trees.)</summary>
|
||||
public static ImportedLayout BuildFromInfos(ElementInfo rootInfo, IEnumerable<ElementInfo> children,
|
||||
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont)
|
||||
{
|
||||
var byId = new Dictionary<uint, UiElement>();
|
||||
var root = DatWidgetFactory.Create(rootInfo, resolve, datFont);
|
||||
if (rootInfo.Id != 0) byId[rootInfo.Id] = root;
|
||||
foreach (var c in children)
|
||||
{
|
||||
var w = DatWidgetFactory.Create(c, resolve, datFont);
|
||||
root.AddChild(w);
|
||||
if (c.Id != 0) byId[c.Id] = w;
|
||||
}
|
||||
return new ImportedLayout(root, byId);
|
||||
}
|
||||
|
||||
// ---- dat-side helpers ----
|
||||
|
||||
private static ImportedLayout BuildFromInfosRecursive(ElementInfo rootInfo, LayoutDesc ld,
|
||||
DatCollection dats, Func<uint, (uint, int, int)> resolve, UiDatFont? datFont)
|
||||
{
|
||||
var byId = new Dictionary<uint, UiElement>();
|
||||
var root = DatWidgetFactory.Create(rootInfo, resolve, datFont);
|
||||
foreach (var kv in ld.Elements)
|
||||
AddElement(root, kv.Value, dats, resolve, datFont, byId);
|
||||
return new ImportedLayout(root, byId);
|
||||
}
|
||||
|
||||
private static void AddElement(UiElement parent, ElementDesc d, DatCollection dats,
|
||||
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont, Dictionary<uint, UiElement> byId)
|
||||
{
|
||||
var info = Resolve(dats, d);
|
||||
var w = DatWidgetFactory.Create(info, resolve, datFont);
|
||||
parent.AddChild(w);
|
||||
if (info.Id != 0) byId[info.Id] = w;
|
||||
foreach (var kv in d.Children)
|
||||
AddElement(w, kv.Value, dats, resolve, datFont, byId);
|
||||
}
|
||||
|
||||
/// <summary>ElementDesc → ElementInfo, resolving BaseElement/BaseLayoutId inheritance.</summary>
|
||||
private static ElementInfo Resolve(DatCollection dats, ElementDesc d)
|
||||
{
|
||||
var self = ToInfo(d);
|
||||
if (d.BaseElement != 0 && d.BaseLayoutId != 0)
|
||||
{
|
||||
var baseLd = dats.Get<LayoutDesc>(d.BaseLayoutId);
|
||||
var baseDesc = baseLd is null ? null : FindDesc(baseLd, d.BaseElement);
|
||||
if (baseDesc is not null) return ElementReader.Merge(Resolve(dats, baseDesc), self); // recursive base chain
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
private static ElementDesc? FindDesc(LayoutDesc ld, uint id)
|
||||
{
|
||||
foreach (var kv in ld.Elements) { var f = FindDescIn(kv.Value, id); if (f is not null) return f; }
|
||||
return null;
|
||||
}
|
||||
private static ElementDesc? FindDescIn(ElementDesc d, uint id)
|
||||
{
|
||||
if (d.ElementId == id) return d;
|
||||
foreach (var kv in d.Children) { var f = FindDescIn(kv.Value, id); if (f is not null) return f; }
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Read the verified ElementDesc fields into ElementInfo (no inheritance).</summary>
|
||||
private static ElementInfo ToInfo(ElementDesc d)
|
||||
{
|
||||
var info = new ElementInfo
|
||||
{
|
||||
Id = d.ElementId, Type = (int)d.Type,
|
||||
X = d.X, Y = d.Y, Width = d.Width, Height = d.Height,
|
||||
Left = (int)d.LeftEdge, Top = (int)d.TopEdge, Right = (int)d.RightEdge, Bottom = (int)d.BottomEdge,
|
||||
};
|
||||
if (d.StateDesc is not null) ReadState(d.StateDesc, "", info);
|
||||
foreach (var s in d.States) ReadState(s.Value, s.Key, info);
|
||||
return info;
|
||||
}
|
||||
|
||||
private static void ReadState(StateDesc sd, string name, ElementInfo info)
|
||||
{
|
||||
foreach (var m in sd.Media)
|
||||
if (m is MediaDescImage img && img.File != 0)
|
||||
info.StateMedia[name] = (img.File, (int)img.DrawMode);
|
||||
// font DID (property 0x1A) read here once the format doc confirms the property API.
|
||||
}
|
||||
}
|
||||
```
|
||||
> NOTE: the exact `ElementDesc`/`StateDesc` member access (`d.X`, `d.Type`, `d.States`, `sd.Media`, `img.DrawMode`, the font property) must match Task 1's verified API; `dump-vitals-layout` confirms these members exist. Adjust casts/names to the real API.
|
||||
|
||||
- [ ] **Step 4: Run to verify pass**
|
||||
|
||||
Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~LayoutImporterTests"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
git add src/AcDream.App/UI/Layout/LayoutImporter.cs tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json
|
||||
git commit -m "feat(D.2b): LayoutImporter — read layout + resolve inheritance + build tree"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: VitalsController — bind live data by id
|
||||
|
||||
**Files:**
|
||||
- Create: `src/AcDream.App/UI/Layout/VitalsController.cs`
|
||||
- Test: `tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs`
|
||||
|
||||
Mirrors `gmVitalsUI`: grab the meter elements by id and wire their fill + numbers + the correct per-vital sprite slice ids (which are dat-driven, but the back/front-slice split + the live data binding are the controller's job).
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```csharp
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public class VitalsBindingTests
|
||||
{
|
||||
[Fact]
|
||||
public void Bind_SetsHealthMeterFillFromProvider()
|
||||
{
|
||||
var health = new UiMeter();
|
||||
var layout = FakeLayout(("0x100000E6", health));
|
||||
float hp = 0.42f;
|
||||
VitalsController.Bind(layout, healthPct: () => hp, staminaPct: () => 1, manaPct: () => 1,
|
||||
healthText: () => "42/100", staminaText: () => "", manaText: () => "");
|
||||
Assert.Equal(0.42f, health.Fill());
|
||||
}
|
||||
|
||||
private static ImportedLayout FakeLayout(params (string idHex, UiElement e)[] items)
|
||||
{
|
||||
var dict = new System.Collections.Generic.Dictionary<uint, UiElement>();
|
||||
var root = new UiPanel();
|
||||
foreach (var (idHex, e) in items)
|
||||
{ uint id = System.Convert.ToUInt32(idHex, 16); root.AddChild(e); dict[id] = e; }
|
||||
return new ImportedLayout(root, dict);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify failure**
|
||||
|
||||
Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~VitalsBindingTests"`
|
||||
Expected: FAIL — `VitalsController` not defined.
|
||||
|
||||
- [ ] **Step 3: Implement VitalsController**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>Per-window controller for the vitals layout (0x2100006C). Mirrors retail
|
||||
/// gmVitalsUI::PostInit: grab the meter elements by id and bind live data. The ONLY
|
||||
/// per-window code — data wiring, not graphics.</summary>
|
||||
public static class VitalsController
|
||||
{
|
||||
public const uint Health = 0x100000E6, Stamina = 0x100000EC, Mana = 0x100000EE;
|
||||
|
||||
public static void Bind(ImportedLayout layout,
|
||||
Func<float> healthPct, Func<float> staminaPct, Func<float> manaPct,
|
||||
Func<string> healthText, Func<string> staminaText, Func<string> manaText)
|
||||
{
|
||||
BindMeter(layout, Health, healthPct, healthText);
|
||||
BindMeter(layout, Stamina, staminaPct, staminaText);
|
||||
BindMeter(layout, Mana, manaPct, manaText);
|
||||
}
|
||||
|
||||
private static void BindMeter(ImportedLayout layout, uint id, Func<float> pct, Func<string> text)
|
||||
{
|
||||
if (layout.FindElement(id) is UiMeter m)
|
||||
{
|
||||
m.Fill = () => pct();
|
||||
m.Label = () => text();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
> NOTE: the per-vital back/front 3-slice sprite ids live on the meter's child image elements in the dat; the importer sets them on the `UiMeter` (extend `DatWidgetFactory.BuildMeter` to read the meter's `E8/E9/EA` + back/front child sprites once the tree is built). For Plan 1 conformance, the controller binds the dynamic data; the static slice ids come from the dat via the importer.
|
||||
|
||||
- [ ] **Step 4: Run to verify pass**
|
||||
|
||||
Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~VitalsBindingTests"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
git add src/AcDream.App/UI/Layout/VitalsController.cs tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs
|
||||
git commit -m "feat(D.2b): VitalsController — bind live vitals data by element id"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Wire the importer into GameWindow behind a flag
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (the `_options.RetailUi` block where the vitals panel is built)
|
||||
- Modify: `src/AcDream.App/RuntimeOptions.cs` (add `RetailUiImporter` flag from `ACDREAM_RETAIL_UI_IMPORTER`)
|
||||
|
||||
Run the importer-built vitals window when `ACDREAM_RETAIL_UI_IMPORTER=1`, ALONGSIDE the existing hand-authored path (which stays the default). This is the conformance harness + the eventual switch-over.
|
||||
|
||||
- [ ] **Step 1: Add the RuntimeOptions flag**
|
||||
|
||||
In `RuntimeOptions.cs`, add `public bool RetailUiImporter { get; init; }` and read it in `Program.cs` from `ACDREAM_RETAIL_UI_IMPORTER == "1"` (follow the existing `RetailUi` pattern).
|
||||
|
||||
- [ ] **Step 2: Wire the importer in the RetailUi block**
|
||||
|
||||
In `GameWindow.cs`, in the `if (_options.RetailUi)` block, after the existing vitals panel is built, add:
|
||||
```csharp
|
||||
if (_options.RetailUiImporter)
|
||||
{
|
||||
var imported = AcDream.App.UI.Layout.LayoutImporter.Import(
|
||||
_dats, 0x2100006Cu, ResolveChrome, _datFont);
|
||||
if (imported is not null)
|
||||
{
|
||||
AcDream.App.UI.Layout.VitalsController.Bind(imported,
|
||||
healthPct: () => _vitalsVm!.HealthPercent ?? 0f,
|
||||
staminaPct: () => _vitalsVm!.StaminaPercent ?? 0f,
|
||||
manaPct: () => _vitalsVm!.ManaPercent ?? 0f,
|
||||
healthText: () => $"{_vitalsVm!.HealthCurrent}/{_vitalsVm.HealthMax}",
|
||||
staminaText: () => $"{_vitalsVm!.StaminaCurrent}/{_vitalsVm.StaminaMax}",
|
||||
manaText: () => $"{_vitalsVm!.ManaCurrent}/{_vitalsVm.ManaMax}");
|
||||
imported.Root.Left = 240; imported.Root.Top = 30; // offset so it sits beside the hand-built one for A/B
|
||||
_uiHost.Root.AddChild(imported.Root);
|
||||
Console.WriteLine("[D.2b] importer vitals window active (A/B vs hand-authored).");
|
||||
}
|
||||
}
|
||||
```
|
||||
> NOTE: confirm `_dats` (the `DatCollection`) + `_datFont` (the `UiDatFont`) field names in `GameWindow`; both already exist (the chrome resolve + the dat-font load use them).
|
||||
|
||||
- [ ] **Step 3: Build**
|
||||
|
||||
Run: `dotnet build src\AcDream.App\AcDream.App.csproj -c Debug`
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```
|
||||
git add src/AcDream.App/Rendering/GameWindow.cs src/AcDream.App/RuntimeOptions.cs src/AcDream.App/Program.cs
|
||||
git commit -m "feat(D.2b): run importer-built vitals window under ACDREAM_RETAIL_UI_IMPORTER (A/B)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Vitals conformance — golden tree checks + headless render diff
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs`
|
||||
- Modify: `src/AcDream.Cli/VitalsMockup.cs` (add an importer-render mode if needed for the visual diff)
|
||||
|
||||
- [ ] **Step 1: Write the golden tree conformance test (against the fixture)**
|
||||
|
||||
```csharp
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public class LayoutConformanceTests
|
||||
{
|
||||
[Fact]
|
||||
public void VitalsTree_HasThreeMetersAtExpectedRects()
|
||||
{
|
||||
var layout = FixtureLoader.LoadVitals(); // deserializes vitals_2100006C.json → ImportedLayout via BuildFromInfos
|
||||
(uint id, float y)[] expected = { (0x100000E6, 5), (0x100000EC, 21), (0x100000EE, 37) };
|
||||
foreach (var (id, y) in expected)
|
||||
{
|
||||
var m = layout.FindElement(id);
|
||||
Assert.IsType<UiMeter>(m);
|
||||
Assert.Equal(5f, m!.Left);
|
||||
Assert.Equal(150f, m.Width);
|
||||
Assert.Equal(16f, m.Height);
|
||||
Assert.Equal(y, m.Top);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Add a tiny `FixtureLoader` that reads the committed JSON into `ElementInfo`s and calls `LayoutImporter.BuildFromInfos`.
|
||||
|
||||
- [ ] **Step 2: Run to verify failure, then implement FixtureLoader, then pass**
|
||||
|
||||
Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~LayoutConformanceTests"`
|
||||
Expected: FAIL → implement `FixtureLoader` → PASS.
|
||||
|
||||
- [ ] **Step 3: Headless visual diff**
|
||||
|
||||
Launch the client with both windows (`ACDREAM_RETAIL_UI=1 ACDREAM_RETAIL_UI_IMPORTER=1`, testaccount2) and confirm the importer window (offset) is pixel-identical to the hand-authored one. (Manual visual gate — the user confirms. No assertion.)
|
||||
|
||||
- [ ] **Step 4: Full test sweep**
|
||||
|
||||
Run: `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj --filter "FullyQualifiedName~UI"`
|
||||
Expected: PASS (all prior UI tests + the new Layout tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
git add tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs
|
||||
git commit -m "test(D.2b): vitals importer conformance (golden tree + A/B render gate)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## After Plan 1
|
||||
|
||||
**Plan 1 status: SHIPPED 2026-06-15, pixel-identical.**
|
||||
|
||||
**Default flip DONE 2026-06-15 (`bf77a23`):** the importer is now the default vitals window at `ACDREAM_RETAIL_UI=1`. The hand-authored `vitals.xml` and the `ACDREAM_RETAIL_UI_IMPORTER` flag were retired (`vitals.xml` is recoverable from git history). The window is movable (Anchors=None + Draggable) AND horizontally resizable (Resizable/ResizeX, `8aa643f`): on a width change the dat edge-anchors reflow the pieces (top/bottom edges + bars stretch, corners fixed 5px, right side tracks) per retail `UIElement::UpdateForParentSizeChange @0x00462640`. (The earlier "fixed-size" note was wrong — it came from an inverted edge-flag reading, now corrected; stretch is `RightEdge==1`.) Faithful grip/dragbar-*driven* drag/resize INPUT for the whole toolkit is Plan 2. Post-flip number-render fixes (`43064ba`, `34243f2`): submission-order sprite draw (stamina/mana numbers had been overpainted by their own bar sprites) + glyph pixel-snap (numbers stay sharp at all resize widths). `MarkupDocument`/`UiNineSlicePanel` remain for the chat window + plugin panels.
|
||||
|
||||
**Plan 2** covers: the `WindowManager` (open/close/z-order/persist, drag via Type-2 drag bars, resize via Type-9 resize grips for the whole toolkit), re-driving the chat window (`ChatController`), and extending the factory/renderer to the full long-tail of element types per the Task 1 enumeration. Register Plan 2 in the roadmap before starting it.
|
||||
|
||||
## Self-review
|
||||
|
||||
- **Spec coverage:** enumeration (Task 1) ✓, importer + inheritance (Tasks 2,5) ✓, generic renderer (Task 3) ✓, hybrid factory (Task 4) ✓, controller/binding (Task 6) ✓, coexistence/flag (Task 7) ✓, conformance (Task 8) ✓. Window manager + chat + full long-tail = explicitly deferred to Plan 2 (spec rollout 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.
|
||||
992
docs/superpowers/plans/2026-06-16-d2b-widget-generalization.md
Normal file
992
docs/superpowers/plans/2026-06-16-d2b-widget-generalization.md
Normal file
|
|
@ -0,0 +1,992 @@
|
|||
# D.2b Widget Generalization Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Refactor the hand-named chat widgets and the Send/Max-Min click-wiring into generic, Type-registered widgets built by `DatWidgetFactory`, collapsing the controllers to a thin retail `gm*UI::PostInit`-style find-by-id binder.
|
||||
|
||||
**Architecture:** `DatWidgetFactory.Create` grows a faithful `switch(Type)` registering the real retail `UIElement` classes (Button=1, Field=3, Menu=6, Meter=7, Scrollbar=11, Text=12) as generic widgets; everything else stays `UiDatElement`. The importer's base-chain Type resolution already surfaces each element's real Type, so this is a *registration* task. The chat-specific knowledge (channel list, colors, command routing) moves out of widgets into `ChatWindowController`. Migrate one widget per commit; chat stays visually identical through Tasks 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~<ClassName>"`.
|
||||
- **Commit style:** `feat(D.2b): <widget> — <what>` / `test(D.2b): …` / `refactor(D.2b): …`, ending with the project's `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>` trailer.
|
||||
- **Every generic widget cites its retail class + `RegisterElementClass` line** in a doc comment (per spec §8).
|
||||
- **Divergence register:** `docs/architecture/retail-divergence-register.md` — amend AP-37 / re-check AP-41 in the same commit that lands the relevant widget (per spec §7).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Created:**
|
||||
- `src/AcDream.App/UI/UiButton.cs` — generic Type-1 button (Task 3).
|
||||
- `src/AcDream.App/UI/UiText.cs` — generic Type-12 scrollable colored-line text (rename of `UiChatView`, Task 5).
|
||||
- `src/AcDream.App/UI/UiField.cs` — generic Type-3 editable one-line field (rename of `UiChatInput`, Task 6).
|
||||
- `src/AcDream.App/UI/UiScrollbar.cs` — generic Type-11 scrollbar (rename of `UiChatScrollbar`, Task 2).
|
||||
- `src/AcDream.App/UI/UiMenu.cs` — generic Type-6 dropdown menu (genericized `UiChannelMenu`, Task 4).
|
||||
- `tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json` — golden resolved chat tree (Task 1).
|
||||
- `tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs` — skip-by-default fixture generator (Task 1).
|
||||
- `tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs` — resolved-tree + factory-class conformance (Task 1, grown per widget).
|
||||
- `tests/AcDream.App.Tests/UI/UiButtonTests.cs` (Task 3).
|
||||
|
||||
**Renamed (git mv + class/namespace-internal rename):**
|
||||
- `UiChatScrollbar.cs` → `UiScrollbar.cs`; `UiChatScrollbarTests.cs` → `UiScrollbarTests.cs` (Task 2).
|
||||
- `UiChatView.cs` → `UiText.cs`; `UiChatViewTests.cs` → `UiTextTests.cs`; `UiChatViewDatFontTests.cs` → `UiTextDatFontTests.cs` (Task 5).
|
||||
- `UiChatInput.cs` → `UiField.cs`; `UiChatInputTests.cs` → `UiFieldTests.cs` (Task 6).
|
||||
- `UiChannelMenu.cs` → `UiMenu.cs`; `UiChannelMenuTests.cs` → `UiMenuTests.cs` (Task 4).
|
||||
|
||||
**Modified:**
|
||||
- `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` — the `switch(Type)` + `BuildButton`/`BuildMenu`/`BuildText`/`BuildField`/`BuildScrollbar` (Tasks 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;
|
||||
|
||||
/// <summary>
|
||||
/// One-off generator for the committed chat golden fixture. Skipped by default —
|
||||
/// run manually with the real dats present (set ACDREAM_DAT_DIR) to regenerate
|
||||
/// chat_21000006.json, then commit it. Mirrors how vitals_2100006C.json was made.
|
||||
/// </summary>
|
||||
public class ChatLayoutFixtureGenerator
|
||||
{
|
||||
[Fact(Skip = "manual: regenerates the committed chat fixture; needs the real dats (ACDREAM_DAT_DIR)")]
|
||||
public void GenerateChatFixture()
|
||||
{
|
||||
var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Documents", "Asheron's Call");
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var info = LayoutImporter.ImportInfos(dats, 0x21000006u);
|
||||
Assert.NotNull(info);
|
||||
|
||||
var json = JsonSerializer.Serialize(info, new JsonSerializerOptions
|
||||
{
|
||||
IncludeFields = true,
|
||||
WriteIndented = true,
|
||||
});
|
||||
File.WriteAllText(FixturePath(), json);
|
||||
}
|
||||
|
||||
// Resolve the SOURCE fixtures dir (not bin/) from this file's compile-time path.
|
||||
private static string FixturePath([CallerFilePath] string thisFile = "")
|
||||
=> Path.Combine(Path.GetDirectoryName(thisFile)!, "fixtures", "chat_21000006.json");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Generate the fixture (manual, dats present).**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ChatLayoutFixtureGenerator.GenerateChatFixture" -e ACDREAM_DAT_DIR="%USERPROFILE%\Documents\Asheron's Call"` after temporarily removing the `Skip` (or use an IDE run). Confirm `tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json` is written and non-empty, then restore the `Skip`.
|
||||
Expected: a JSON tree rooted at id `0x10000006`-family with the chat elements. **Record the resolved `Type` of `0x10000016` (input) and `0x10000011` (transcript)** — these drive Task 5/6 decisions.
|
||||
|
||||
- [ ] **Step 3: Add `FixtureLoader.LoadChat()` + `LoadChatInfos()`.**
|
||||
|
||||
In `FixtureLoader.cs`, add (mirroring `LoadVitals`/`LoadVitalsInfos`):
|
||||
```csharp
|
||||
public static ImportedLayout LoadChat()
|
||||
=> LayoutImporter.Build(LoadChatInfos(), _ => (0u, 0, 0), null);
|
||||
|
||||
public static AcDream.App.UI.Layout.ElementInfo LoadChatInfos()
|
||||
=> LoadInfos("chat_21000006.json");
|
||||
|
||||
// Shared loader (refactor LoadVitalsInfos to call this with "vitals_2100006C.json").
|
||||
private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName)
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "UI", "Layout", "fixtures", fileName);
|
||||
if (!File.Exists(path)) throw new FileNotFoundException($"fixture not found at: {path}");
|
||||
var bytes = File.ReadAllBytes(path);
|
||||
ReadOnlySpan<byte> span = bytes;
|
||||
if (span.Length >= 3 && span[0] == 0xEF && span[1] == 0xBB && span[2] == 0xBF) span = span[3..];
|
||||
return JsonSerializer.Deserialize<AcDream.App.UI.Layout.ElementInfo>(span, _opts)
|
||||
?? throw new InvalidOperationException($"fixture deserialized to null: {path}");
|
||||
}
|
||||
```
|
||||
Then make `LoadVitalsInfos()` delegate: `public static ElementInfo LoadVitalsInfos() => LoadInfos("vitals_2100006C.json");`
|
||||
|
||||
- [ ] **Step 4: Write the resolved-tree conformance test (fails until the fixture exists).**
|
||||
|
||||
`ChatLayoutConformanceTests.cs`:
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using AcDream.App.UI.Layout;
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public class ChatLayoutConformanceTests
|
||||
{
|
||||
private static ElementInfo Find(ElementInfo n, uint id)
|
||||
{
|
||||
if (n.Id == id) return n;
|
||||
foreach (var c in n.Children) { var f = Find(c, id); if (f is not null) return f; }
|
||||
return null!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChatFixture_ResolvesKnownElements()
|
||||
{
|
||||
var root = FixtureLoader.LoadChatInfos();
|
||||
// These ids come from ChatWindowController; the resolved Type proves the base-chain merge.
|
||||
Assert.NotNull(Find(root, 0x10000011u)); // transcript
|
||||
Assert.NotNull(Find(root, 0x10000016u)); // input
|
||||
Assert.NotNull(Find(root, 0x10000012u)); // scrollbar track
|
||||
Assert.NotNull(Find(root, 0x10000014u)); // channel menu
|
||||
Assert.NotNull(Find(root, 0x10000019u)); // send button
|
||||
Assert.NotNull(Find(root, 0x1000046Fu)); // max/min button
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChatFixture_ResolvedTypes_MatchRetailRegistry()
|
||||
{
|
||||
var root = FixtureLoader.LoadChatInfos();
|
||||
Assert.Equal(6u, Find(root, 0x10000014u).Type); // Menu
|
||||
Assert.Equal(11u, Find(root, 0x10000012u).Type); // Scrollbar
|
||||
Assert.Equal(1u, Find(root, 0x10000019u).Type); // Button (Send)
|
||||
Assert.Equal(1u, Find(root, 0x1000046Fu).Type); // Button (Max/Min)
|
||||
// transcript + input: assert the ACTUAL resolved Type recorded in Step 2.
|
||||
// From the Map trace both resolve to 12 (Text); if Step 2 shows otherwise, update these.
|
||||
Assert.Equal(12u, Find(root, 0x10000011u).Type); // Text (transcript)
|
||||
Assert.Equal(12u, Find(root, 0x10000016u).Type); // Text (input — see Task 6 wrinkle)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the conformance tests.**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ChatLayoutConformanceTests"`
|
||||
Expected: PASS. If `ChatFixture_ResolvedTypes_MatchRetailRegistry` shows input `0x10000016` Type ≠ 12, **update the assert to the real value and note it in Task 6 Step 1** (decides factory-built vs controller-placed `UiField`).
|
||||
|
||||
- [ ] **Step 6: Commit.**
|
||||
```bash
|
||||
git add tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs \
|
||||
tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json \
|
||||
tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs \
|
||||
tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs
|
||||
git commit -m "test(D.2b): chat golden fixture + resolved-Type conformance (widget-generalization Task 1)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `UiScrollbar` (Type 11) — promote the already-generic scrollbar
|
||||
|
||||
`UiChatScrollbar` has zero chat-specific code; this is a rename + factory registration.
|
||||
|
||||
**Files:**
|
||||
- Rename: `src/AcDream.App/UI/UiChatScrollbar.cs` → `src/AcDream.App/UI/UiScrollbar.cs`
|
||||
- Rename: `tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cs` → `tests/AcDream.App.Tests/UI/UiScrollbarTests.cs`
|
||||
- Modify: `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`
|
||||
- Modify: `src/AcDream.App/UI/Layout/ChatWindowController.cs`
|
||||
- Modify: `tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs`, `ChatLayoutConformanceTests.cs`
|
||||
|
||||
- [ ] **Step 1: Rename the widget file + class.**
|
||||
```bash
|
||||
git mv src/AcDream.App/UI/UiChatScrollbar.cs src/AcDream.App/UI/UiScrollbar.cs
|
||||
git mv tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cs tests/AcDream.App.Tests/UI/UiScrollbarTests.cs
|
||||
```
|
||||
In `UiScrollbar.cs`: rename `class UiChatScrollbar` → `class UiScrollbar`; update the doc summary to "Generic scrollbar. Ports retail `UIElement_Scrollbar` (RegisterElementClass(0xb) @ acclient_2013_pseudo_c.txt:124137)…"; keep all body/fields/methods unchanged.
|
||||
In `UiScrollbarTests.cs`: rename the test class to `UiScrollbarTests`; replace every `UiChatScrollbar` with `UiScrollbar`. (Keep the test bodies.)
|
||||
|
||||
- [ ] **Step 2: Write the failing factory test.**
|
||||
|
||||
In `DatWidgetFactoryTests.cs` add:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Type11_Scrollbar_MakesUiScrollbar()
|
||||
{
|
||||
var e = DatWidgetFactory.Create(new ElementInfo { Type = 11, Width = 16, Height = 68 }, NoTex, null);
|
||||
Assert.IsType<UiScrollbar>(e);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run it — verify it fails.**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DatWidgetFactoryTests.Type11_Scrollbar_MakesUiScrollbar"`
|
||||
Expected: FAIL (`Create` returns `UiDatElement`, not `UiScrollbar`).
|
||||
|
||||
- [ ] **Step 4: Register Type 11 in the factory.**
|
||||
|
||||
In `DatWidgetFactory.Create`, add to the switch (before `_`):
|
||||
```csharp
|
||||
11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build + run factory + scrollbar tests.**
|
||||
|
||||
Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DatWidgetFactoryTests|FullyQualifiedName~UiScrollbarTests"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Point the controller at the factory-built scrollbar (still functional).**
|
||||
|
||||
The factory now builds a `UiScrollbar` for the Type-11 track element. In `ChatWindowController.cs`, in the "Scrollbar — replace the imported track placeholder" block, change the construction to bind the factory widget instead of building a fresh one. Replace the block (currently `c.Scrollbar = new UiChatScrollbar { … }; trackParent.RemoveChild(track); trackParent.AddChild(c.Scrollbar);`) with:
|
||||
```csharp
|
||||
// The factory built the Type-11 track element as a UiScrollbar. Find it, bind it.
|
||||
if (layout.FindElement(TrackId) is UiScrollbar bar)
|
||||
{
|
||||
bar.Top = 0f; // pull up to the panel top (resize-bar reclaim)
|
||||
bar.Height = bar.Height + bar.Top; // NOTE: capture old Top before zeroing — see Step 6a
|
||||
bar.Model = c.Transcript.Scroll;
|
||||
bar.SpriteResolve = resolve;
|
||||
bar.TrackSprite = TrackSprite;
|
||||
bar.ThumbSprite = ThumbSprite;
|
||||
bar.ThumbTopSprite = ThumbTopSprite;
|
||||
bar.ThumbBotSprite = ThumbBotSprite;
|
||||
bar.UpSprite = UpSprite;
|
||||
bar.DownSprite = DownSprite;
|
||||
c.Scrollbar = bar;
|
||||
}
|
||||
```
|
||||
- [ ] **Step 6a: Fix the Top/Height order bug introduced above.** The old code added `track.Top` to height *before* zeroing Top. Write it correctly:
|
||||
```csharp
|
||||
if (layout.FindElement(TrackId) is UiScrollbar bar)
|
||||
{
|
||||
float oldTop = bar.Top;
|
||||
bar.Top = 0f;
|
||||
bar.Height = bar.Height + oldTop;
|
||||
bar.Model = c.Transcript.Scroll;
|
||||
bar.SpriteResolve = resolve;
|
||||
bar.TrackSprite = TrackSprite; bar.ThumbSprite = ThumbSprite;
|
||||
bar.ThumbTopSprite = ThumbTopSprite; bar.ThumbBotSprite = ThumbBotSprite;
|
||||
bar.UpSprite = UpSprite; bar.DownSprite = DownSprite;
|
||||
c.Scrollbar = bar;
|
||||
}
|
||||
```
|
||||
Change the `Scrollbar` property type: `public UiScrollbar Scrollbar { get; private set; } = null!;`
|
||||
|
||||
- [ ] **Step 7: Update the conformance Type assert (already Type 11) + run full UI suite.**
|
||||
|
||||
`ChatLayoutConformanceTests` already asserts Type 11 for the track. Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
|
||||
Expected: PASS (whole UI suite).
|
||||
|
||||
- [ ] **Step 8: Re-check AP-41 in the divergence register.**
|
||||
|
||||
The controller passes `ThumbTopSprite`/`ThumbBotSprite` (3-slice caps), so AP-41 ("thumb single stretched sprite") is stale. In `docs/architecture/retail-divergence-register.md`, update the AP-41 `file:line` from `UiChatScrollbar.cs:37` to `UiScrollbar.cs` and narrow/retire it (the 3-slice path now draws caps; retire only if the fallback single-tile path is no longer reachable — it is reachable when caps are 0, so narrow the wording to "fallback only").
|
||||
|
||||
- [ ] **Step 9: Commit.**
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(D.2b): UiScrollbar (Type 11) — promote the generic chat scrollbar (widget-generalization Task 2)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `UiButton` (Type 1) — Send + Max/Min
|
||||
|
||||
The factory currently builds Send/Max-Min as `UiDatElement` and the controller sets `OnClick`/`Label`. Introduce a dedicated `UiButton` mirroring that behavior exactly (so clicks don't regress) and register Type 1.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/AcDream.App/UI/UiButton.cs`
|
||||
- Create: `tests/AcDream.App.Tests/UI/UiButtonTests.cs`
|
||||
- Modify: `DatWidgetFactory.cs`, `ChatWindowController.cs`, `DatWidgetFactoryTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing button-behavior test.**
|
||||
|
||||
`UiButtonTests.cs`:
|
||||
```csharp
|
||||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public class UiButtonTests
|
||||
{
|
||||
private static (uint, int, int) NoTex(uint _) => (0, 0, 0);
|
||||
|
||||
[Fact]
|
||||
public void Click_InvokesOnClick()
|
||||
{
|
||||
var info = new ElementInfo { Type = 1, Width = 46, Height = 18 };
|
||||
var b = new UiButton(info, NoTex) { OnClick = () => Clicked = true };
|
||||
b.OnEvent(new UiEvent(UiEventType.Click, 0, 0, 0));
|
||||
Assert.True(Clicked);
|
||||
}
|
||||
private bool Clicked;
|
||||
|
||||
[Fact]
|
||||
public void NotClickThrough_SoItReceivesClicks()
|
||||
{
|
||||
var b = new UiButton(new ElementInfo { Type = 1 }, NoTex);
|
||||
Assert.False(b.ClickThrough);
|
||||
}
|
||||
}
|
||||
```
|
||||
> Confirm the `UiEvent` constructor signature in `src/AcDream.App/UI/UiEvent.cs` before finalizing the `new UiEvent(...)` call; adjust arg order if needed.
|
||||
|
||||
- [ ] **Step 2: Run it — verify it fails (UiButton does not exist).**
|
||||
|
||||
Run: `dotnet test … --filter "FullyQualifiedName~UiButtonTests"`
|
||||
Expected: FAIL (compile error: `UiButton` not found).
|
||||
|
||||
- [ ] **Step 3: Write `UiButton`.**
|
||||
|
||||
`UiButton.cs`:
|
||||
```csharp
|
||||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.App.UI.Layout;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Generic clickable button. Ports retail UIElement_Button
|
||||
/// (RegisterElementClass(1, UIElement_Button::Create) @ acclient_2013_pseudo_c.txt:125828):
|
||||
/// a per-state sprite face + an optional centered caption + a click action. Built by
|
||||
/// DatWidgetFactory for Type-1 elements (chat Send 0x10000019, Max/Min 0x1000046F).
|
||||
/// The controller binds OnClick and the caption. State selection mirrors UiDatElement
|
||||
/// so existing Send/Max-Min behavior is preserved exactly.
|
||||
/// </summary>
|
||||
public sealed class UiButton : UiElement
|
||||
{
|
||||
private readonly ElementInfo _info;
|
||||
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
|
||||
|
||||
public Action? OnClick { get; set; }
|
||||
public string? Label { get; set; }
|
||||
public UiDatFont? LabelFont { get; set; }
|
||||
public Vector4 LabelColor { get; set; } = Vector4.One;
|
||||
|
||||
/// <summary>Active state name, runtime-settable (e.g. Max/Min toggling Normal↔Minimized).</summary>
|
||||
public string ActiveState { get; set; } = "";
|
||||
|
||||
public UiButton(ElementInfo info, Func<uint, (uint tex, int w, int h)> resolve)
|
||||
{
|
||||
_info = info;
|
||||
_resolve = resolve;
|
||||
ClickThrough = false; // buttons are interactive
|
||||
if (!string.IsNullOrEmpty(info.DefaultStateName)) ActiveState = info.DefaultStateName;
|
||||
else if (info.StateMedia.ContainsKey("Normal")) ActiveState = "Normal";
|
||||
}
|
||||
|
||||
private uint ActiveFile()
|
||||
=> _info.StateMedia.TryGetValue(ActiveState, out var m) ? m.File
|
||||
: _info.StateMedia.TryGetValue("", out var d) ? d.File : 0u;
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
uint file = ActiveFile();
|
||||
if (file != 0)
|
||||
{
|
||||
var (tex, tw, th) = _resolve(file);
|
||||
if (tex != 0 && tw != 0 && th != 0)
|
||||
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
|
||||
}
|
||||
if (Label is { Length: > 0 } label && LabelFont is { } lf)
|
||||
{
|
||||
float tx = (Width - lf.MeasureWidth(label)) * 0.5f;
|
||||
float ty = (Height - lf.LineHeight) * 0.5f;
|
||||
ctx.DrawStringDat(lf, label, tx, ty, LabelColor);
|
||||
}
|
||||
}
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (e.Type == UiEventType.Click && OnClick is not null) { OnClick(); return true; }
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the button tests — verify they pass.**
|
||||
|
||||
Run: `dotnet test … --filter "FullyQualifiedName~UiButtonTests"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Write the failing factory test + register Type 1.**
|
||||
|
||||
In `DatWidgetFactoryTests.cs`:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Type1_Button_MakesUiButton()
|
||||
{
|
||||
var e = DatWidgetFactory.Create(new ElementInfo { Type = 1, Width = 46, Height = 18 }, NoTex, null);
|
||||
Assert.IsType<UiButton>(e);
|
||||
}
|
||||
```
|
||||
In `DatWidgetFactory.Create` switch:
|
||||
```csharp
|
||||
1 => new UiButton(info, resolve), // UIElement_Button (reg :125828)
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Update the controller to bind the factory-built buttons.**
|
||||
|
||||
In `ChatWindowController.cs`, the Send block currently does `if (layout.FindElement(SendId) is UiDatElement sendEl) { sendEl.ClickThrough = false; sendEl.OnClick = …; sendEl.Label = "Send"; sendEl.LabelFont = datFont; sendEl.LabelColor = …; }`. Change the cast to `UiButton`:
|
||||
```csharp
|
||||
if (layout.FindElement(SendId) is UiButton sendEl)
|
||||
{
|
||||
sendEl.OnClick = () => c.Input.Submit();
|
||||
sendEl.Label = "Send";
|
||||
sendEl.LabelFont = datFont;
|
||||
sendEl.LabelColor = new Vector4(1f, 0.92f, 0.72f, 1f);
|
||||
}
|
||||
```
|
||||
And the Max/Min block: change `if (layout.FindElement(MaxMinId) is UiDatElement maxMinEl)` → `is UiButton maxMinEl`, drop the now-unneeded `maxMinEl.ClickThrough = false;` (UiButton is interactive by construction), keep the `maxMinEl.Left = track.Left - maxMinEl.Width;` and `maxMinEl.OnClick = c.ToggleMaximize;`.
|
||||
|
||||
- [ ] **Step 7: Build + run the full UI suite.**
|
||||
|
||||
Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 8: Commit.**
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(D.2b): UiButton (Type 1) — Send + Max/Min as generic buttons (widget-generalization Task 3)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `UiMenu` (Type 6) — genericize the channel menu
|
||||
|
||||
`UiChannelMenu` is the one heavy genericization: move `ChatChannelKind`, the 14-item array, the button-text map, and the availability defaults into `ChatWindowController`; keep all drawing/geometry/event mechanics in a generic `UiMenu` keyed on `object? Payload`.
|
||||
|
||||
**Files:**
|
||||
- Rename: `src/AcDream.App/UI/UiChannelMenu.cs` → `src/AcDream.App/UI/UiMenu.cs`
|
||||
- Rename: `tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs` → `tests/AcDream.App.Tests/UI/UiMenuTests.cs`
|
||||
- Modify: `DatWidgetFactory.cs`, `ChatWindowController.cs`, `DatWidgetFactoryTests.cs`
|
||||
|
||||
- [ ] **Step 1: Rename file + class.**
|
||||
```bash
|
||||
git mv src/AcDream.App/UI/UiChannelMenu.cs src/AcDream.App/UI/UiMenu.cs
|
||||
git mv tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs tests/AcDream.App.Tests/UI/UiMenuTests.cs
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace the chat-specific members with the generic surface.**
|
||||
|
||||
In `UiMenu.cs`, rename `class UiChannelMenu` → `class UiMenu`; remove `using AcDream.UI.Abstractions;`. Replace the chat-specific members — the `Item` record, the static `Items` array, `Selected` (ChatChannelKind), `OnChannelChanged`, `AvailabilityProvider`, `IsAvailable`, and `ButtonText` — with these generic members:
|
||||
```csharp
|
||||
/// <summary>One menu row: its label + an opaque payload the controller maps back.</summary>
|
||||
public readonly record struct MenuItem(string Label, object? Payload);
|
||||
|
||||
/// <summary>The rows, populated by the controller. Laid out column-major:
|
||||
/// rows 0..RowsPerColumn-1 in column 0, then the next group in column 1, etc.</summary>
|
||||
public IReadOnlyList<MenuItem> Items { get; set; } = System.Array.Empty<MenuItem>();
|
||||
|
||||
/// <summary>The currently-selected payload (drives the highlighted row).</summary>
|
||||
public object? Selected { get; set; }
|
||||
|
||||
/// <summary>Fired with the picked item's payload when a row is chosen.</summary>
|
||||
public Action<object?>? OnSelect { get; set; }
|
||||
|
||||
/// <summary>Per-payload enabled gate (disabled rows render greyed + are inert).
|
||||
/// Null ⇒ all rows enabled.</summary>
|
||||
public Func<object?, bool>? EnabledProvider { get; set; }
|
||||
|
||||
/// <summary>Button-face caption (the active target). Null ⇒ blank face.</summary>
|
||||
public Func<string>? ButtonLabelProvider { get; set; }
|
||||
```
|
||||
Make the geometry constants settable so a controller/factory can match the dat:
|
||||
```csharp
|
||||
public int RowsPerColumn { get; set; } = 7; // items per column (dat item template)
|
||||
public float RowHeight { get; set; } = 17f; // dat item template 0x1000001E H=17
|
||||
public float ColumnWidth { get; set; } = 191f; // dat item template W=191
|
||||
```
|
||||
Replace the `private const int Rows`/`ItemH`/`ColW` usages with `RowsPerColumn`/`RowHeight`/`ColumnWidth`, and make the derived sizes instance members:
|
||||
```csharp
|
||||
private int ColumnCount => (Items.Count + RowsPerColumn - 1) / System.Math.Max(1, RowsPerColumn);
|
||||
private float InteriorW => ColumnCount * ColumnWidth;
|
||||
private float InteriorH => RowsPerColumn * RowHeight;
|
||||
private float OuterW => InteriorW + 2 * Border;
|
||||
private float OuterH => InteriorH + 2 * Border;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Genericize the draw/event logic (mechanical swaps).**
|
||||
|
||||
In the same file, in `OnDrawOverlay`, `OnEvent`, `OnHitTest`, and `DrawButtonFace`/label:
|
||||
- Replace `Items[i].Channel is { } c && c == Selected` (selected-row test) with `Equals(Items[i].Payload, Selected)`.
|
||||
- Replace `Items[i].Channel is not { } c || IsAvailable(c)` (availability) with `EnabledProvider?.Invoke(Items[i].Payload) ?? true`.
|
||||
- Replace the button caption `ButtonText` with `ButtonLabelProvider?.Invoke() ?? ""` in both `OnDraw` (the `DrawLabel(ctx, ButtonText, …)` call) and `NaturalButtonWidth()` (the `MeasureWidth(ButtonText)`).
|
||||
- In `OnEvent`'s pick branch, replace the channel-specific selection
|
||||
```csharp
|
||||
if (… && Items[idx].Channel is { } ch && IsAvailable(ch)) { Selected = ch; OnChannelChanged?.Invoke(ch); }
|
||||
```
|
||||
with
|
||||
```csharp
|
||||
if (row >= 0 && row < RowsPerColumn && idx >= 0 && idx < Items.Count
|
||||
&& (EnabledProvider?.Invoke(Items[idx].Payload) ?? true))
|
||||
{
|
||||
Selected = Items[idx].Payload;
|
||||
OnSelect?.Invoke(Selected);
|
||||
}
|
||||
```
|
||||
- Replace the column/row math `int col = i / Rows, row = i % Rows;` with `RowsPerColumn` and `Items.Length` → `Items.Count`.
|
||||
Keep `DrawBevel`, `DrawButtonFace`, `DrawSprite`, `DrawLabel`, the sprite-id properties, the colors, and `NaturalButtonWidth()` otherwise unchanged. Update the doc comment to cite `UIElement_Menu (RegisterElementClass(6) @ :120163)` + `MakePopup @0x46d310`.
|
||||
|
||||
- [ ] **Step 4: Update the menu tests for the generic surface.**
|
||||
|
||||
In `UiMenuTests.cs`, rename the class to `UiMenuTests`, replace `UiChannelMenu` → `UiMenu`. Where tests referenced `ChatChannelKind`/`Selected`/`OnChannelChanged`, rewrite them against the generic surface, e.g.:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void ClickingRow_FiresOnSelect_WithPayload()
|
||||
{
|
||||
object? picked = null;
|
||||
var m = new UiMenu
|
||||
{
|
||||
Width = 46, Height = 18,
|
||||
Items = new UiMenu.MenuItem[] { new("Chat to All", "say"), new("Trade", "trade") },
|
||||
OnSelect = p => picked = p,
|
||||
};
|
||||
// open, then click row 0 (geometry per RowsPerColumn/RowHeight — mirror the
|
||||
// existing test's click coords, which used the same 17px rows).
|
||||
m.OnEvent(new UiEvent(UiEventType.MouseDown, 0, 0, 0)); // toggle open
|
||||
// … click into row 0 of the open popup (reuse the prior test's local coords) …
|
||||
Assert.Equal("say", picked);
|
||||
}
|
||||
```
|
||||
> Reuse the exact open/click coordinates from the original `UiChannelMenuTests` (they map into the same popup geometry); only the payload/selection assertions change.
|
||||
|
||||
- [ ] **Step 5: Run the menu tests — green.**
|
||||
|
||||
Run: `dotnet test … --filter "FullyQualifiedName~UiMenuTests"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Failing factory test + register Type 6.**
|
||||
|
||||
In `DatWidgetFactoryTests.cs`:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Type6_Menu_MakesUiMenu()
|
||||
{
|
||||
var e = DatWidgetFactory.Create(new ElementInfo { Type = 6, Width = 46, Height = 18 }, NoTex, null);
|
||||
Assert.IsType<UiMenu>(e);
|
||||
}
|
||||
```
|
||||
In `DatWidgetFactory.Create` switch:
|
||||
```csharp
|
||||
6 => new UiMenu(), // UIElement_Menu (reg :120163)
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Move the channel knowledge into `ChatWindowController`.**
|
||||
|
||||
In `ChatWindowController.cs`, add the channel item table + maps (ported verbatim from the old `UiChannelMenu`):
|
||||
```csharp
|
||||
// Talk-focus channels (ported from the old UiChannelMenu — gmMainChatUI::InitTalkFocusMenu @0x4cdc50).
|
||||
private static readonly (string Label, ChatChannelKind? Channel)[] ChannelItems =
|
||||
{
|
||||
("Squelch (ignore)", null),
|
||||
("Tell to Selected", null),
|
||||
("Chat to All", ChatChannelKind.Say),
|
||||
("Tell to Fellows", ChatChannelKind.Fellowship),
|
||||
("Tell to General Chat", ChatChannelKind.General),
|
||||
("Tell to LFG Chat", ChatChannelKind.Lfg),
|
||||
("Tell to Society Chat", ChatChannelKind.Society),
|
||||
("Tell to Monarch", ChatChannelKind.Monarch),
|
||||
("Tell to Patron", ChatChannelKind.Patron),
|
||||
("Tell to Vassals", ChatChannelKind.Vassals),
|
||||
("Tell to Allegiance", ChatChannelKind.Allegiance),
|
||||
("Tell to Trade Chat", ChatChannelKind.Trade),
|
||||
("Tell to Roleplay Chat", ChatChannelKind.Roleplay),
|
||||
("Tell to Olthoi Chat", ChatChannelKind.Olthoi),
|
||||
};
|
||||
|
||||
private static string ChannelButtonLabel(ChatChannelKind k) => k switch
|
||||
{
|
||||
ChatChannelKind.Say => "Chat", ChatChannelKind.General => "General",
|
||||
ChatChannelKind.Trade => "Trade", ChatChannelKind.Lfg => "LFG",
|
||||
ChatChannelKind.Fellowship => "Fellow", ChatChannelKind.Allegiance => "Alleg",
|
||||
ChatChannelKind.Patron => "Patron", ChatChannelKind.Vassals => "Vassals",
|
||||
ChatChannelKind.Monarch => "Monarch", ChatChannelKind.Roleplay => "Roleplay",
|
||||
ChatChannelKind.Society => "Society", ChatChannelKind.Olthoi => "Olthoi",
|
||||
_ => "Chat",
|
||||
};
|
||||
|
||||
private static bool ChannelAvailable(ChatChannelKind k)
|
||||
=> k is ChatChannelKind.Say or ChatChannelKind.General or ChatChannelKind.Trade or ChatChannelKind.Lfg;
|
||||
```
|
||||
Replace the "Channel menu — replace the imported menu placeholder" block. The factory now builds the Type-6 element as a `UiMenu`; find it and populate it:
|
||||
```csharp
|
||||
if (layout.FindElement(MenuId) is UiMenu menu)
|
||||
{
|
||||
menu.DatFont = datFont; menu.Font = debugFont; menu.SpriteResolve = resolve;
|
||||
menu.NormalSprite = MenuNormal; menu.PressedSprite = MenuPressed;
|
||||
menu.PopupBgSprite = MenuPopupBg;
|
||||
menu.ItemNormalSprite = MenuItemRow; menu.ItemHighlightSprite = MenuItemSelected;
|
||||
menu.Items = System.Array.ConvertAll(ChannelItems,
|
||||
t => new UiMenu.MenuItem(t.Label, (object?)t.Channel));
|
||||
menu.Selected = (object?)c._activeChannel;
|
||||
menu.EnabledProvider = p => p is not ChatChannelKind ch || ChannelAvailable(ch);
|
||||
menu.ButtonLabelProvider = () => ChannelButtonLabel(c._activeChannel);
|
||||
menu.OnSelect = p =>
|
||||
{
|
||||
if (p is ChatChannelKind ch) { c._activeChannel = ch; menu.Selected = p; }
|
||||
};
|
||||
c.Menu = menu;
|
||||
}
|
||||
```
|
||||
Update the `Menu` property type: `public UiMenu Menu { get; private set; } = null!;` Update the reflow block (`ReflowInputRow`) — it calls `c.Menu.NaturalButtonWidth()`, `c.Menu.ResetAnchorCapture()` (both still exist on `UiMenu`), and wraps `c.Menu.OnChannelChanged`. Replace the `OnChannelChanged` wrap with the generic `OnSelect`:
|
||||
```csharp
|
||||
var onSelect = c.Menu.OnSelect;
|
||||
c.Menu.OnSelect = p => { onSelect?.Invoke(p); ReflowInputRow(); };
|
||||
```
|
||||
> `_activeChannel` already exists on the controller; the old per-menu `OnChannelChanged = k => c._activeChannel = k;` is now folded into `OnSelect`.
|
||||
|
||||
- [ ] **Step 8: Build + run the full UI suite.**
|
||||
|
||||
Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 9: Add a divergence row if the generic menu lost fidelity.**
|
||||
|
||||
The generic `UiMenu` item model is flat (label+payload, no submenu/hierarchical popup). If that is a new approximation vs `UIElement_Menu::MakePopup`'s nested popups, add a row to `docs/architecture/retail-divergence-register.md` (Adaptation) citing `src/AcDream.App/UI/UiMenu.cs` + `MakePopup @0x46d310`. (The chat menu is single-level, so this is a latent note, not a behavior change.)
|
||||
|
||||
- [ ] **Step 10: Commit.**
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(D.2b): UiMenu (Type 6) — generic dropdown; channel knowledge moves to controller (widget-generalization Task 4)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `UiText` (Type 12) — transcript + the Type-12 flip
|
||||
|
||||
Rename `UiChatView` → `UiText`, default its background to transparent + add an optional dat state-sprite background (so any Type-12-with-sprite element keeps rendering its sprite), register Type 12, flip the two factory Type-12 tests, and have the controller bind the factory-built transcript. An **unbound `UiText` must draw nothing** so vitals stays frozen.
|
||||
|
||||
**Files:**
|
||||
- Rename: `src/AcDream.App/UI/UiChatView.cs` → `src/AcDream.App/UI/UiText.cs`
|
||||
- Rename: `tests/AcDream.App.Tests/UI/UiChatViewTests.cs` → `UiTextTests.cs`; `UiChatViewDatFontTests.cs` → `UiTextDatFontTests.cs`
|
||||
- Modify: `DatWidgetFactory.cs`, `LayoutImporter.cs` (none needed — Text recurses normally), `ChatWindowController.cs`, `DatWidgetFactoryTests.cs`, `GameWindow.cs`
|
||||
|
||||
- [ ] **Step 1: Rename file + class + tests.**
|
||||
```bash
|
||||
git mv src/AcDream.App/UI/UiChatView.cs src/AcDream.App/UI/UiText.cs
|
||||
git mv tests/AcDream.App.Tests/UI/UiChatViewTests.cs tests/AcDream.App.Tests/UI/UiTextTests.cs
|
||||
git mv tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs tests/AcDream.App.Tests/UI/UiTextDatFontTests.cs
|
||||
```
|
||||
In `UiText.cs`: rename `class UiChatView` → `class UiText`; the nested `Line`/`Pos` records, `LinesProvider`, selection, and scroll stay. Update the doc to cite `UIElement_Text (RegisterElementClass(0xc) @ :115655)`. In the test files, rename classes + replace `UiChatView` → `UiText`.
|
||||
|
||||
- [ ] **Step 2: Default the background to transparent (so an unbound UiText is invisible).**
|
||||
|
||||
In `UiText.cs`, change:
|
||||
```csharp
|
||||
public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0f); // transparent by default
|
||||
```
|
||||
(was `(0,0,0,0.35)`). `OnDraw`'s `ctx.DrawFill(0,0,Width,Height,BackgroundColor)` then draws nothing when transparent. The chat controller will set the translucent value explicitly (Step 6).
|
||||
|
||||
- [ ] **Step 3: Add an optional dat state-sprite background (faithful UIElement_Text media).**
|
||||
|
||||
So a Type-12 element that carries its own sprite (currently rendered by `UiDatElement`) does not lose it. Add to `UiText`:
|
||||
```csharp
|
||||
/// <summary>Optional dat state-sprite background (the element's own media), drawn
|
||||
/// UNDER the text. Set by DatWidgetFactory.BuildText from the ElementInfo. 0 = none.</summary>
|
||||
public uint BackgroundSprite { get; set; }
|
||||
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
||||
```
|
||||
At the very top of `OnDraw`, before `DrawFill`:
|
||||
```csharp
|
||||
if (BackgroundSprite != 0 && SpriteResolve is { } sr)
|
||||
{
|
||||
var (tex, tw, th) = sr(BackgroundSprite);
|
||||
if (tex != 0 && tw != 0 && th != 0)
|
||||
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Write the failing factory test (and flip the two existing Type-12 tests).**
|
||||
|
||||
In `DatWidgetFactoryTests.cs`:
|
||||
- Add:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Type12_Text_MakesUiText()
|
||||
{
|
||||
var e = DatWidgetFactory.Create(new ElementInfo { Type = 12, Width = 100, Height = 40 }, NoTex, null);
|
||||
Assert.IsType<UiText>(e);
|
||||
}
|
||||
```
|
||||
- Replace `Type12_StylePrototype_ReturnsNull` (delete it — Type 12 is no longer skipped).
|
||||
- Replace `DatWidgetFactory_Type12WithMedia_Renders` body to assert `UiText` for both media and no-media:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void DatWidgetFactory_Type12_AlwaysMakesUiText()
|
||||
{
|
||||
var withMedia = new ElementInfo { Type = 12, Width = 32, Height = 16,
|
||||
StateMedia = { ["Normal"] = (0x00001234u, 1) } };
|
||||
Assert.IsType<UiText>(DatWidgetFactory.Create(withMedia, NoTex, null));
|
||||
Assert.IsType<UiText>(DatWidgetFactory.Create(new ElementInfo { Type = 12 }, NoTex, null));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run — verify the new/flipped tests fail.**
|
||||
|
||||
Run: `dotnet test … --filter "FullyQualifiedName~DatWidgetFactoryTests"`
|
||||
Expected: FAIL on the Type-12 asserts (factory still returns null / UiDatElement).
|
||||
|
||||
- [ ] **Step 6: Register Type 12 + add `BuildText`; remove the skip.**
|
||||
|
||||
In `DatWidgetFactory.cs`:
|
||||
- Delete the skip line `if (info.Type == 12 && info.StateMedia.Count == 0) return null;`.
|
||||
- Add to the switch:
|
||||
```csharp
|
||||
12 => BuildText(info, resolve), // UIElement_Text (reg :115655)
|
||||
```
|
||||
- Add the builder:
|
||||
```csharp
|
||||
/// <summary>Type-12 UIElement_Text: a scrollable colored-line text view. The
|
||||
/// element's own Direct/Normal media (if any) becomes the background sprite, drawn
|
||||
/// under the text — so a Type-12 element that previously rendered via UiDatElement
|
||||
/// keeps its sprite. Lines are bound later by the controller (LinesProvider).</summary>
|
||||
private static UiText BuildText(ElementInfo info, Func<uint, (uint, int, int)> resolve)
|
||||
{
|
||||
uint bg = info.StateMedia.TryGetValue(
|
||||
!string.IsNullOrEmpty(info.DefaultStateName) ? info.DefaultStateName
|
||||
: info.StateMedia.ContainsKey("Normal") ? "Normal" : "", out var m)
|
||||
? m.File : 0u;
|
||||
return new UiText { BackgroundSprite = bg, SpriteResolve = resolve };
|
||||
}
|
||||
```
|
||||
> Update the `Create` summary/`<returns>` doc that referenced Type-12 returning null.
|
||||
|
||||
- [ ] **Step 7: Verify factory + vitals fixture still green (vitals frozen).**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DatWidgetFactoryTests|FullyQualifiedName~LayoutConformanceTests|FullyQualifiedName~VitalsBindingTests"`
|
||||
Expected: PASS. The vitals number text elements are meter-children (consumed, never built — `LayoutImporter.cs:113`), and any other vitals Type-12 element now builds as an unbound, transparent `UiText` (draws only its own sprite, if it had one — same as before). **Spec verification #2:** if a vitals conformance test fails, a standalone Type-12 element changed class — inspect it; its sprite must still draw via `BackgroundSprite`.
|
||||
|
||||
- [ ] **Step 8: Controller binds the factory-built transcript (instead of constructing it).**
|
||||
|
||||
In `ChatWindowController.cs`, the factory now builds the Type-12 transcript element `0x10000011` as a `UiText`. Replace the "Transcript" block (which read `tInfo` and `new UiChatView { … }; transcriptPanel.AddChild(...)`) with find-and-bind:
|
||||
```csharp
|
||||
// The factory built the Type-12 transcript as a UiText; find + bind it.
|
||||
c.Transcript = layout.FindElement(TranscriptId) as UiText
|
||||
?? throw new InvalidOperationException("chat transcript 0x10000011 not built as UiText");
|
||||
c.Transcript.DatFont = datFont;
|
||||
c.Transcript.Font = debugFont;
|
||||
c.Transcript.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); // retail translucent transcript
|
||||
c.Transcript.LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont);
|
||||
```
|
||||
Change the `Transcript` property type to `public UiText Transcript { get; private set; } = null!;`. Remove the now-unused `tInfo` lookup + the `transcriptPanel.AddChild` (the transcript is already in the tree at its dat position). Keep the `transcriptPanel.Top/Height` resize-bar reclaim.
|
||||
|
||||
Also in `ChatWindowController.cs`, replace **every** `UiChatView.Line` with `UiText.Line` — this hits `BuildLines` (its `UiText view` parameter, its `IReadOnlyList<UiText.Line>` return type, the `Array.Empty<UiText.Line>()`, and the `new UiText.Line(frag, color)` inside the wrap loop). `WrapText`/`RetailChatColor` are unaffected (they return `string`/`Vector4`).
|
||||
|
||||
Finally, repoint the `Bind` early-guard: it currently does `var tInfo = FindInfo(rootInfo, TranscriptId);` and checks `tInfo is null`. The transcript is now found via `layout.FindElement(TranscriptId)`; change the guard to null-check the factory-built widgets it needs (`layout.FindElement(TranscriptPanelId)` for the panel, plus the transcript/input found in their Steps). The `iInfo` lookup stays only for Task 6 Variant B. (Full guard tidy lands in Task 7.)
|
||||
|
||||
- [ ] **Step 9: GameWindow follow-through.**
|
||||
|
||||
`GameWindow.cs:1860` (`chatController.Transcript.Keyboard = …`) still compiles (`UiText.Keyboard` exists). Build to confirm.
|
||||
|
||||
- [ ] **Step 10: Build + full UI suite.**
|
||||
|
||||
Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 11: Amend AP-37 (Type-0 text skip retired).**
|
||||
|
||||
In `docs/architecture/retail-divergence-register.md`, edit AP-37: remove the "Standalone Type-0 text elements are also skipped (… a dedicated dat-text widget is Plan 2)" clause (now shipped as `UiText`). Keep the meter-collapse clause and the vitals-numbers-via-`UiMeter.Label` clause (retired in Task 8).
|
||||
|
||||
- [ ] **Step 12: Commit.**
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(D.2b): UiText (Type 12) — generic text + Type-12 flip; transcript factory-built (widget-generalization Task 5)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: `UiField` (Type 3) — editable input
|
||||
|
||||
Rename `UiChatInput` → `UiField`, register Type 3, and wire the input. **Input handling depends on Task 1 Step 5's recorded resolved Type** for `0x10000016`:
|
||||
- **If it resolved to Type 3:** the factory builds `UiField` directly; the controller finds + binds it.
|
||||
- **If it resolved to Type 12** (per the Map trace): the factory built it as a `UiText`; the controller *replaces* it with a `UiField` at the same rect (the existing replace pattern).
|
||||
|
||||
**Files:**
|
||||
- Rename: `src/AcDream.App/UI/UiChatInput.cs` → `src/AcDream.App/UI/UiField.cs`; `UiChatInputTests.cs` → `UiFieldTests.cs`
|
||||
- Modify: `DatWidgetFactory.cs`, `ChatWindowController.cs`, `DatWidgetFactoryTests.cs`, `GameWindow.cs`
|
||||
|
||||
- [ ] **Step 1: Confirm the input's resolved Type from Task 1, choose the path.**
|
||||
|
||||
Re-read `ChatLayoutConformanceTests.ChatFixture_ResolvedTypes_MatchRetailRegistry` (Task 1) for `0x10000016`. Note "Type 3 → direct build" or "Type 12 → controller-place". Proceed with the matching variant in Step 6.
|
||||
|
||||
- [ ] **Step 2: Rename file + class + tests.**
|
||||
```bash
|
||||
git mv src/AcDream.App/UI/UiChatInput.cs src/AcDream.App/UI/UiField.cs
|
||||
git mv tests/AcDream.App.Tests/UI/UiChatInputTests.cs tests/AcDream.App.Tests/UI/UiFieldTests.cs
|
||||
```
|
||||
In `UiField.cs`: rename `class UiChatInput` → `class UiField`; body unchanged. Update doc to cite `UIElement_Field (RegisterElementClass(3) @ :126190)` + the drag-drop hooks (`CatchDroppedItem`/`MouseOverTop`) it will host for future item windows. In `UiFieldTests.cs`: rename class, replace `UiChatInput` → `UiField`.
|
||||
|
||||
- [ ] **Step 3: Default the background to transparent (consistency with UiText).**
|
||||
|
||||
Change `UiField.BackgroundColor` default to `new(0f, 0f, 0f, 0f)`. The controller sets the translucent value (Step 6).
|
||||
|
||||
- [ ] **Step 4: Failing factory test + register Type 3.**
|
||||
|
||||
In `DatWidgetFactoryTests.cs`:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Type3_Field_MakesUiField()
|
||||
{
|
||||
var e = DatWidgetFactory.Create(new ElementInfo { Type = 3, Width = 200, Height = 16 }, NoTex, null);
|
||||
Assert.IsType<UiField>(e);
|
||||
}
|
||||
```
|
||||
In `DatWidgetFactory.Create` switch:
|
||||
```csharp
|
||||
3 => new UiField(), // UIElement_Field (reg :126190)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run — verify pass.**
|
||||
|
||||
Run: `dotnet test … --filter "FullyQualifiedName~DatWidgetFactoryTests.Type3_Field_MakesUiField|FullyQualifiedName~UiFieldTests"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Wire the input in the controller (variant per Step 1).**
|
||||
|
||||
Replace the "Input" block (`new UiChatInput { … }; inputBar.AddChild(c.Input); c.Input.OnSubmit = …`).
|
||||
|
||||
**Variant A — input resolved to Type 3 (factory-built):**
|
||||
```csharp
|
||||
c.Input = layout.FindElement(InputId) as UiField
|
||||
?? throw new InvalidOperationException("chat input 0x10000016 not built as UiField");
|
||||
c.Input.DatFont = datFont; c.Input.Font = debugFont;
|
||||
c.Input.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f);
|
||||
c.Input.SpriteResolve = resolve; c.Input.FocusFieldSprite = InputFocusField;
|
||||
c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel);
|
||||
```
|
||||
|
||||
**Variant B — input resolved to Type 12 (controller-placed UiField over the UiText):**
|
||||
```csharp
|
||||
// 0x10000016 resolves to Type-12 Text in this layout; the editable entry is a
|
||||
// controller-placed UiField at the dat element's rect (retail authors a separate Field).
|
||||
var iInfo = FindInfo(rootInfo, InputId)
|
||||
?? throw new InvalidOperationException("chat input info 0x10000016 missing");
|
||||
if (layout.FindElement(InputId) is { Parent: { } iparent } placeholder)
|
||||
iparent.RemoveChild(placeholder); // drop the read-only Text placeholder
|
||||
c.Input = new UiField
|
||||
{
|
||||
Left = iInfo.X, Top = iInfo.Y, Width = iInfo.Width, Height = iInfo.Height,
|
||||
Anchors = ElementReader.ToAnchors(iInfo.Left, iInfo.Top, iInfo.Right, iInfo.Bottom),
|
||||
DatFont = datFont, Font = debugFont,
|
||||
BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f),
|
||||
SpriteResolve = resolve, FocusFieldSprite = InputFocusField,
|
||||
};
|
||||
(inputBar).AddChild(c.Input);
|
||||
c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel);
|
||||
```
|
||||
Change the `Input` property type to `public UiField Input { get; private set; } = null!;` (Keep `FindInfo` for Variant B; it may become unused in Variant A — remove it then.)
|
||||
|
||||
- [ ] **Step 7: GameWindow follow-through.**
|
||||
|
||||
`GameWindow.cs:1861` (`chatController.Input.Keyboard = …`) still compiles (`UiField.Keyboard` exists). Build to confirm.
|
||||
|
||||
- [ ] **Step 8: Build + full UI suite.**
|
||||
|
||||
Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 9: Commit.**
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(D.2b): UiField (Type 3) — editable input as a generic field (widget-generalization Task 6)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Thin + verify the controller; remove dead construction
|
||||
|
||||
After Tasks 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.
|
||||
|
|
@ -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<float>` reading
|
||||
`VitalsVM`.
|
||||
- The XML markup format (mirrors `ElementDesc`) + a `MarkupDocument` parser that
|
||||
**instantiates a `UiElement` subtree** + a minimal `controls.ini` stylesheet
|
||||
loader.
|
||||
- The plugin-facing contract: plugins contribute a `UiElement`/markup subtree
|
||||
added to `UiRoot` (§9) — designed now, first consumer first-party.
|
||||
|
||||
**Deferred to later sub-phases (explicitly OUT):**
|
||||
- **Wiring `UiHost`'s input** (`WireMouse`/`WireKeyboard`) into the existing
|
||||
Phase-K `InputDispatcher`. The `UiRoot` input *machinery* exists; *integrating*
|
||||
two input consumers (route unconsumed `WorldMouseFallThrough` back to the game)
|
||||
is its own concern. Spec 1 is **render-only** (`Tick` + `Draw`), so the frame +
|
||||
live bars show but the close button isn't clicked and the window isn't dragged.
|
||||
- The dat A8 glyph font loader (`AcFont`) → numeric overlays.
|
||||
- The full anchor solver (`StateDesc::UpdateSizeAndPosition` port).
|
||||
- The `LayoutDesc` binary importer (sub-project 3).
|
||||
- Reskinning Chat / Debug / Settings.
|
||||
- Login / char-select / chargen screens (raw-JPEG backgrounds, sub-project 4).
|
||||
|
||||
## 3. Source-verified facts (do-not-trust list)
|
||||
|
||||
The grounding caught several load-bearing "facts" that were wrong/unverified.
|
||||
These are binding:
|
||||
|
||||
| Claimed (memory / first draft) | Reality (source-verified) |
|
||||
|---|---|
|
||||
| Build a retained-mode toolkit + `RetailPanelHost`/`RetailPanelRenderer` | The toolkit **exists** in `src/AcDream.App/UI/` (§0); the retail UI is the `UiRoot` tree, not an `IPanelRenderer` backend |
|
||||
| `VitalsVM` is `record VitalsVM(int HpCurrent, …)` | Sealed class: `HealthPercent` (float), `StaminaPercent`/`ManaPercent` (float?), `*Current`/`*Max` (uint?), ctor `(CombatState, LocalPlayerState?)`, `SetLocalPlayerGuid(uint)` — [VitalsVM.cs:35](../../../src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs) |
|
||||
| Chrome sprite IDs `0x06004CC2`/`0x21000040`/`0x060074BF..C6` are known | **Unverified + contradictory.** `0x06001125` (cited elsewhere) is the char-select highlight. **No chrome ID is trusted — Step 0 dat prove-out resolves them empirically.** |
|
||||
| `#FFDBD6A8` "parchment cream" is the panel background | It is `[editbox]`/`[treeview]` **text** color. Real frame tokens: `[title]` bg `#FFFFFFFF`, font `Verdana-10-bold`, height 19; `[body]` bg `#00000000` (transparent), `color_border=#FF4F657D` |
|
||||
| `DatCollection` is NOT thread-safe | Concurrent **reads are safe** since v2.1.7 ([2026-06-09 investigation](../../../docs/research/2026-06-09-dat-reader-thread-safety-investigation.md)). No UI-specific lock. |
|
||||
| KSML is the panel-layout language | KSML is **rich-text-in-text-regions**; the panel layout format is the binary `LayoutDesc`/`ElementDesc` tree ([acclient.h:33693](../../../docs/research/named-retail/acclient.h)). Our markup mirrors `ElementDesc`. |
|
||||
|
||||
## 4. Architecture & placement
|
||||
|
||||
The retail UI lives in **`src/AcDream.App/UI/`** (where the scaffold already is).
|
||||
New widgets/parsers join it as dedicated files. Code-Structure Rule 1 is honored
|
||||
(nothing substantial added to `GameWindow.cs` — only a few wiring lines); Rule 2
|
||||
(Core stays GL-free) and Rule 3 (panels target `AcDream.UI.Abstractions`) are
|
||||
unaffected because the retail UI is a *separate* tree, not an `IPanelRenderer`
|
||||
panel.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ retail dat (read-only fidelity source) │
|
||||
│ controls.ini → style tokens · RenderSurface 0x06xxxxxx │
|
||||
│ → sprites · Font 0x40xxxxxx → glyphs (deferred) │
|
||||
└───────────────┬──────────────────────────────────────────┘
|
||||
│ TextureCache.GetOrUpload(id) → Texture2D
|
||||
┌───────────────▼──────────────────────────────────────────┐
|
||||
│ src/AcDream.App/UI/ (scaffold EXISTS; + = new in Spec 1) │
|
||||
│ UiHost (exists, dormant) ─ wire into GameWindow │
|
||||
│ UiRoot/UiElement (exist) ─ input + tree + hit-test │
|
||||
│ UiRenderContext (exists) + DrawSprite(UV-rect) │
|
||||
│ UiPanel/UiLabel/UiButton (exist) │
|
||||
│ + UiNineSlicePanel : UiPanel (8-piece dat chrome) │
|
||||
│ + UiMeter : UiElement (vital bar) │
|
||||
│ + MarkupDocument (XML → UiElement subtree) │
|
||||
│ + ControlsIni (stylesheet loader) │
|
||||
│ uses Rendering/TextRenderer (+ sprite path, + DepthMask) │
|
||||
└───────────────┬──────────────────────────────────────────┘
|
||||
│ UiMeter.Fill = () => vm.HealthPercent
|
||||
┌───────────────▼──────────────────────────────────────────┐
|
||||
│ AcDream.UI.Abstractions (exists) — VitalsVM (unchanged) │
|
||||
│ ↑ ImGui IPanelHost/IPanelRenderer path stays for │
|
||||
│ ACDREAM_DEVTOOLS, fully independent of the above │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Coexistence.** Two UI systems run side by side, independently:
|
||||
`ACDREAM_DEVTOOLS=1` → the ImGui overlay (unchanged); `ACDREAM_RETAIL_UI=1` →
|
||||
the `UiHost` tree. The retail pass renders in the post-3D slot
|
||||
([GameWindow.cs:8232 region](../../../src/AcDream.App/Rendering/GameWindow.cs))
|
||||
with deterministic ordering relative to ImGui. `UiHost.Draw` already does
|
||||
`TextRenderer.Begin → Root.Draw(ctx) → TextRenderer.Flush`
|
||||
([UiHost.cs:58](../../../src/AcDream.App/UI/UiHost.cs)).
|
||||
|
||||
## 5. Render foundation — extend the existing 2D path
|
||||
|
||||
`UiHost` draws the `UiRoot` tree through a `UiRenderContext` backed by the shared
|
||||
`TextRenderer` ([UiHost.cs:58-67](../../../src/AcDream.App/UI/UiHost.cs)). That
|
||||
`TextRenderer` does solid rects + R8 text today but **not** textured RGBA sprites
|
||||
([ui_text.frag:9](../../../src/AcDream.App/Rendering/Shaders/ui_text.frag),
|
||||
[TextRenderer.cs](../../../src/AcDream.App/Rendering/TextRenderer.cs)). Spec 1
|
||||
adds the sprite path:
|
||||
|
||||
- **`ui_text.frag`** += a `uUseTexture==2` branch: `FragColor = texture(uTex,
|
||||
vUv) * vColor;` (the existing `0`=solid and `1`=R8-coverage branches are
|
||||
untouched).
|
||||
- **`TextRenderer`** += `DrawSprite(uint texture, float x,y,w,h, float
|
||||
u0,v0,u1,v1, Vector4 tint)` accumulating into **per-texture** sprite buffers
|
||||
(`Dictionary<uint, List<float>>`), and a `Flush` pass that, after rects+text,
|
||||
draws each texture's batch with `uUseTexture=2`. Reuses the existing
|
||||
`AppendQuad` (which already takes `u0,v0,u1,v1`) + `UploadBuffer` machinery.
|
||||
- **`TextRenderer.Flush`** += explicit **`DepthMask(false)`** (queried + restored)
|
||||
— it disables `DepthTest` today but never sets `DepthMask`
|
||||
([TextRenderer.cs:171](../../../src/AcDream.App/Rendering/TextRenderer.cs)).
|
||||
Per the project's "render self-contained GL state" rule.
|
||||
- **`UiRenderContext`** += `DrawSprite(uint texture, float x,y,w,h, float
|
||||
u0,v0,u1,v1, Vector4 tint)` that adds the current transform and forwards to
|
||||
`TextRenderer.DrawSprite` (mirrors the existing `DrawRect` forwarder at
|
||||
[UiRenderContext.cs:50](../../../src/AcDream.App/UI/UiRenderContext.cs)).
|
||||
|
||||
No new shader class, VAO, or batcher — we extend the proven path the scaffold
|
||||
already uses. (`Shader` is the simple file-based class
|
||||
[Shader.cs](../../../src/AcDream.App/Rendering/Shader.cs); `GLSLShader`'s bindless
|
||||
machinery is not needed.)
|
||||
|
||||
## 6. Dat assets & the Step-0 prove-out gate
|
||||
|
||||
`TextureCache.GetOrUpload(uint surfaceId)` returns a conventional `Texture2D`
|
||||
GL handle (1×1 magenta on failure) — exactly right for the UI batch
|
||||
([TextureCache.cs:70](../../../src/AcDream.App/Rendering/TextureCache.cs)). The
|
||||
decode chain + `PFID_*` formats already work
|
||||
([SurfaceDecoder.cs:39](../../../src/AcDream.Core/Textures/SurfaceDecoder.cs)).
|
||||
`GameWindow` already holds a `TextureCache`; `UiHost`/`UiNineSlicePanel` receive
|
||||
it (or a `Func<uint,uint>` sprite-resolver) by injection.
|
||||
|
||||
**Step 0 is empirical and comes first.** Because no chrome sprite ID is verified,
|
||||
the first implementation task draws each candidate ID
|
||||
(`0x06004CC2`, `0x060074BF..C6`, `0x0600129C`, …) as a raw quad and visually
|
||||
confirms which decode to frame-shaped art vs magenta vs the wrong sprite. The
|
||||
confirmed IDs are recorded in code comments before any chrome layout is written.
|
||||
**No ID is hardcoded on faith.**
|
||||
|
||||
The frame is **8 quads + a center fill** (4 corner + 4 edge sprites + center),
|
||||
not one stretched 9-slice texture. Slice/edge metrics are a **documented stopgap
|
||||
constant** (with a divergence row) until the `LayoutDesc` tree is parsed
|
||||
(sub-project 3).
|
||||
|
||||
## 7. Markup + stylesheet model
|
||||
|
||||
**Markup** mirrors `ElementDesc` 1:1 ([acclient.h:33693](../../../docs/research/named-retail/acclient.h));
|
||||
`MarkupDocument` parses it and **instantiates a `UiElement` subtree** (a
|
||||
`UiNineSlicePanel` with child `UiLabel`/`UiMeter`/`UiButton`). Authoring shape:
|
||||
|
||||
```xml
|
||||
<panel id="acdream.vitals" x="10" y="30" w="220" h="96" title="Vitals">
|
||||
<meter id="health" x="8" y="24" w="200" h="13" fill="{HealthPercent}" color="#FF0000"/>
|
||||
<meter id="stamina" x="8" y="44" w="200" h="13" fill="{StaminaPercent}" color="#D9A626"/>
|
||||
<meter id="mana" x="8" y="64" w="200" h="13" fill="{ManaPercent}" color="#0000FF"/>
|
||||
</panel>
|
||||
```
|
||||
|
||||
This is the shape the future `LayoutDesc` importer will *emit*, so authoring and
|
||||
imported formats converge. It is **not** KSML (rich-text, deferred). `{Binding}`
|
||||
expressions resolve against a supplied binding object (the `VitalsVM`) via
|
||||
reflection on the property name.
|
||||
|
||||
**Anchor codes** are *defined* now (0=fixed, 1=stretch, 2=proportional-translate,
|
||||
3=center, 4=proportional-scale — from `StateDesc::UpdateSizeAndPosition`
|
||||
@`0x0069BF20`) but the **solver is deferred**: the Vitals window is fixed-size
|
||||
(placed via the existing `UiElement.Left/Top`), so Spec 1 needs no solver.
|
||||
|
||||
**Stylesheet.** A small INI loader parses `controls.ini` keyed by element-type
|
||||
section, honoring `#AARRGGBB` (alpha-first) and `font://Face-Pt[-style]`. Cascade:
|
||||
element-type defaults → per-element `class=` → inline attributes. **Optional**
|
||||
(§10): absent AC install → source-verified `[title]`/`[body]` fallback tokens.
|
||||
|
||||
## 8. VM binding (the Vitals slice)
|
||||
|
||||
The vitals panel is a `UiNineSlicePanel` (chrome) containing a `UiLabel` (title)
|
||||
and three `UiMeter`s. Each `UiMeter` holds a `Func<float?> Fill` bound to the
|
||||
real `VitalsVM` ([VitalsVM.cs:67](../../../src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs)):
|
||||
`() => vm.HealthPercent`, `() => vm.StaminaPercent`, `() => vm.ManaPercent`. The
|
||||
VM already does all server plumbing, so we do **not** re-derive vitals from the
|
||||
retail `gmVitalsUI`/`CACQualities` decomp.
|
||||
|
||||
`UiMeter.OnDraw` draws the empty bar (`ctx.DrawRect`), the filled portion as a
|
||||
**partial-size rect** (`width = pct * Width`), and a centered `current/max` numeric
|
||||
overlay (`Func<string?> Label`). **Retail's vitals ARE exactly this — three stacked
|
||||
horizontal bars (confirmed against a live retail client 2026-06-14), NOT orbs.**
|
||||
Colors: Health red, **Stamina gold** (the earlier `#10F0F0` cyan research note was
|
||||
wrong), Mana blue. A `null` fill/label (pre-`PlayerDescription`) renders gracefully.
|
||||
The remaining gap to pixel-retail is the **glassy gradient bar fill sprite** + the
|
||||
**retail dat font** for the numbers (today the stub `BitmapFont` draws them) — both
|
||||
polish, deferred to §15.
|
||||
|
||||
The `VitalsVM` is constructed and given the player GUID the same way as today
|
||||
([GameWindow.cs:1330](../../../src/AcDream.App/Rendering/GameWindow.cs) ctor,
|
||||
:1984 `SetLocalPlayerGuid`); the retail-UI build path reuses that same VM
|
||||
instance.
|
||||
|
||||
## 9. Plugin contract (designed now, first consumer first-party)
|
||||
|
||||
The plugin API is a day-1 constraint; plugin authors must be able to add retail
|
||||
UI. The natural unit is a **`UiElement`/markup subtree added to `UiRoot`** (not
|
||||
`IPanel`/`IPanelRenderer`, which is the ImGui devtools path). Spec 1 adds:
|
||||
|
||||
- A small `IUiRegistry` (in `AcDream.Plugin.Abstractions`) — `void
|
||||
AddMarkupPanel(string markupPath, object binding)` (and/or `void
|
||||
AddElement(UiElement)` once a plugin-safe element surface is decided). For
|
||||
Spec 1, `AddMarkupPanel` is enough.
|
||||
- `IPluginHost` gains `IUiRegistry Ui { get; }`
|
||||
([IPluginHost.cs:8](../../../src/AcDream.Plugin.Abstractions/IPluginHost.cs)
|
||||
has none today); `AppPluginHost` implements it
|
||||
([AppPluginHost.cs:5](../../../src/AcDream.App/Plugins/AppPluginHost.cs)).
|
||||
- Because plugin `Enable()` runs in `Program.cs` **before** the GL window opens
|
||||
([Program.cs:55-60](../../../src/AcDream.App/Program.cs)), `AddMarkupPanel`
|
||||
**buffers** registrations into a list that `GameWindow` drains into `UiRoot`
|
||||
after `UiHost` is constructed. The threading/timing concern lives in the host;
|
||||
the plugin call is unconditional.
|
||||
- The first consumer is the first-party vitals panel (built directly in
|
||||
`GameWindow`, not through the registry). Wiring an actual plugin-supplied markup
|
||||
panel end-to-end is exercised by a smoke test but is otherwise the thin
|
||||
follow-up. This task group is the **last** in the plan so the visible vitals
|
||||
slice can land first if it slips.
|
||||
|
||||
## 10. Confirmed decisions (approved 2026-06-14)
|
||||
|
||||
1. **Render-only first slice.** `Tick` + `Draw` only; the `UiHost` input wiring
|
||||
(`WireMouse`/`WireKeyboard`) is **not** connected to the existing Phase-K
|
||||
`InputDispatcher` yet, so the close button isn't clickable and the window
|
||||
isn't draggable. Rationale (corrected): the `UiRoot` input *machinery* already
|
||||
exists — what's deferred is *integrating two input consumers* (routing
|
||||
unconsumed `WorldMouseFallThrough` back to the game's dispatcher), which is its
|
||||
own sub-phase.
|
||||
2. **`controls.ini` optional.** Assume `C:\Turbine\Asheron's Call\` may or may not
|
||||
exist. Add an `ACDREAM_AC_DIR` `RuntimeOptions` field; when absent, fall back
|
||||
to the source-verified `[title]`/`[body]` token values. The build never fails
|
||||
on a missing AC install.
|
||||
|
||||
## 11. Build sequence
|
||||
|
||||
| Step | Deliverable | Proves |
|
||||
|---|---|---|
|
||||
| 0 | Dat prove-out harness: draw candidate chrome IDs, confirm the real ones | Resolves the chrome-ID contradiction empirically |
|
||||
| 1 | `ui_text.frag` `uUseTexture=2` + `TextRenderer.DrawSprite` + `DepthMask` + `UiRenderContext.DrawSprite` | A dat sprite composites over the 3D scene |
|
||||
| 2 | `UiNineSlicePanel` draws an empty titled frame from confirmed dat sprites (stopgap insets) | Retail-shaped chrome renders |
|
||||
| 3 | `UiMeter` + a hand-built vitals `UiNineSlicePanel` subtree bound to `VitalsVM`, wired via `UiHost` under `ACDREAM_RETAIL_UI` (render-only) | End-to-end live data + the scaffold lights up |
|
||||
| 4 | `ControlsIni` parser (TDD) feeding the panel's title/colors | Stylesheet cascade |
|
||||
| 5 | `MarkupDocument` parser (TDD) → builds the same vitals subtree from `vitals.xml` | The Approach-C markup engine |
|
||||
| 6 *(last)* | `IUiRegistry` on `IPluginHost` + buffered drain into `UiRoot` + smoke test | Plugin-ready |
|
||||
|
||||
## 12. Error handling & edge cases
|
||||
|
||||
- **Missing/undecodable sprite** → `GetOrUpload` magenta fallback is visible;
|
||||
Step 0 catches it. A null/zero DataID in markup logs a warning, draws nothing.
|
||||
- **AC install absent** → `controls.ini` load skipped, baked fallback tokens used.
|
||||
- **Vitals null percents** → empty bar (`UiMeter.Fill` returns null).
|
||||
- **Window resize** → `UiHost.Draw` already sets `Root.Width/Height` to the
|
||||
current screen size each frame ([UiHost.cs:61](../../../src/AcDream.App/UI/UiHost.cs));
|
||||
fixed-coord panels stay put. No DPI scaling (known out-of-scope gap).
|
||||
- **Both toggles on** → ImGui Vitals and retail Vitals may both show (fine in dev).
|
||||
|
||||
## 13. Testing
|
||||
|
||||
- **`ControlsIni` parser** (pure, no GL) — unit tests for `#AARRGGBB`, `font://`,
|
||||
cascade order. Lives in `src/AcDream.App/UI/`, tested in `tests/AcDream.App.Tests/`
|
||||
(App-layer, Rule 6).
|
||||
- **`MarkupDocument` parser** — unit tests for XML → `UiElement` tree shape
|
||||
(types, geometry) and `{Binding}` resolution against a fake binding object.
|
||||
- **`UiMeter` fill geometry** — unit test that fill fraction → partial rect width
|
||||
(pure math; `UiMeter.ComputeFillRect(pct, w, h)` as a static helper so it's
|
||||
testable without GL).
|
||||
- **`UiNineSlice` geometry** — unit test that a frame size + insets → the 9 dst
|
||||
rects (`UiNineSlicePanel.ComputeSliceRects` static helper).
|
||||
- **Plugin smoke** — a test plugin calls `host.Ui.AddMarkupPanel` and the buffered
|
||||
registration is drained (assert the panel is added to `UiRoot`).
|
||||
- **Visual acceptance** (user) — retail-shaped Vitals frame with live bars under
|
||||
`ACDREAM_RETAIL_UI=1`; ImGui path unaffected under `ACDREAM_DEVTOOLS=1`.
|
||||
- `dotnet build` + `dotnet test` green.
|
||||
|
||||
## 14. Bookkeeping
|
||||
|
||||
- **Phase D.2b**, Milestone **M5** (parallelizable; NOT on the M1.5 critical
|
||||
path). The CLAUDE.md "Current state" line stays on M1.5.
|
||||
- **Divergence register:** in the commit that ships `UiNineSlicePanel` rendering a
|
||||
real dat sprite, **delete row TS-30** ([retail-divergence-register.md:166](../../../docs/architecture/retail-divergence-register.md))
|
||||
— its cited file (`UiPanel.cs`) is upgraded by the subclass — and **add one**
|
||||
new IA-row (Intentional Architecture; keystone.dll has no PDB/decomp) for the
|
||||
markup/serialization layer. Assign the next sequential IA number at commit time.
|
||||
Retail oracle: "LayoutDesc 0x21xxxxxx; controls.ini panel-property vocabulary;
|
||||
keystone.dll layout evaluation (no PDB)". Do **not** duplicate IA-12 (UI
|
||||
toolkit *behavioral* approximation). A second row for the stopgap slice insets
|
||||
is added when they ship.
|
||||
- **Spec file:** this document.
|
||||
|
||||
## 15. Open gaps & deferred sub-projects
|
||||
|
||||
- **Input integration** — connect `UiHost.WireMouse`/`WireKeyboard` to the Phase-K
|
||||
`InputDispatcher`, routing unconsumed `WorldMouseFallThrough`/`WorldKeyFallThrough`
|
||||
back to the game. Next sub-phase (lights up the close button + window drag that
|
||||
`UiRoot` already supports).
|
||||
- **`AcFont`** — dat A8 glyph loader (Font `0x40000xxx` → `ForegroundSurfaceDataId`
|
||||
→ RenderSurface, upload as **R8** so `ui_text.frag`'s `.r`-coverage branch works
|
||||
unchanged) → numeric overlays + retail fonts. (Today `UiLabel` uses the
|
||||
stb_truetype `BitmapFont`.)
|
||||
- **Anchor solver** — port `StateDesc::UpdateSizeAndPosition`; with the importer.
|
||||
- **`LayoutDesc` binary importer** (sub-project 3) — bulk-transpile retail layouts
|
||||
→ our markup, supplying real insets + coords. Symbols: `LayoutDesc::InqFullDesc`
|
||||
@`0x0069A520`, `ElementDesc::Incorporate` @`0x0069B5A0`
|
||||
([2026-05-08 pseudocode](../../../docs/research/2026-05-08-retail-ui-layout-resolution-pseudocode.md)).
|
||||
- **`PFID_CUSTOM_RAW_JPEG`** decode + login/char-select/chargen (sub-project 4).
|
||||
|
||||
## 16. Acceptance criteria
|
||||
|
||||
- [ ] Step 0 prove-out done; real chrome sprite IDs confirmed + recorded in code.
|
||||
- [ ] In `ACDREAM_RETAIL_UI=1`: a retail-shaped Vitals window renders via the wired
|
||||
`UiHost` — `UiNineSlicePanel` 8-piece dat-sprite border + title + drawn close
|
||||
button — with three `UiMeter` bars tracking HP/Stam/Mana live as the
|
||||
character takes damage / regens.
|
||||
- [ ] In `ACDREAM_DEVTOOLS=1`: ImGui Vitals/Chat/Debug/Settings unchanged.
|
||||
- [ ] `controls.ini` loads when present, falls back cleanly when absent.
|
||||
- [ ] `MarkupDocument` builds the vitals subtree from `vitals.xml`; pure parsers
|
||||
unit-tested; plugin smoke test drains a buffered `AddMarkupPanel`.
|
||||
- [ ] TS-30 deleted + one new IA-row added, same commit as the chrome.
|
||||
- [ ] `dotnet build` green, `dotnet test` green.
|
||||
- [ ] Visual verification by the user.
|
||||
267
docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md
Normal file
267
docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
# D.2b — Chat-window re-drive (LayoutDesc importer, Plan 2 chat piece) — design
|
||||
|
||||
**Date:** 2026-06-15
|
||||
**Branch:** `claude/hopeful-maxwell-214a12` (D.2b retail-UI track; lighting/M1.5 is a separate branch off main)
|
||||
**Status:** design — approved scope, pending spec review
|
||||
**Predecessor:** the LayoutDesc importer + the vitals re-drive
|
||||
(`docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md`,
|
||||
`docs/research/2026-06-15-layoutdesc-format.md`,
|
||||
`claude-memory/project_d2b_retail_ui.md`).
|
||||
**Handoff that opened this work:** `docs/research/2026-06-15-chat-window-redrive-handoff.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Replace the hand-authored retail chat window (a `UiNineSlicePanel` hosting a
|
||||
`UiChatView` at a guessed rect, built inline in `GameWindow.cs` under
|
||||
`if (_options.RetailUi)`) with the **data-driven retail chat window** read from
|
||||
the dat `LayoutDesc 0x21000006` (`gmMainChatUI`) via the existing `LayoutImporter`,
|
||||
with **faithful behavioral widgets ported from the named retail decomp** and the
|
||||
**dat font** — the same way the vitals window became data-driven.
|
||||
|
||||
**The code is modern. The behavior is retail.** Every widget algorithm is ported
|
||||
from `docs/research/named-retail/acclient_2013_pseudo_c.txt` with a cited
|
||||
`class::method @address`.
|
||||
|
||||
## 2. Approved scope
|
||||
|
||||
**In scope (faithful core):**
|
||||
- Data-driven window frame from `0x21000006` (bg sprites, resize bar, grip chrome,
|
||||
translucency).
|
||||
- Transcript: dat-font `UIElement_Text` port — scrollable, bottom-pinned,
|
||||
per-line chat-kind color, 10k-glyph behead cap.
|
||||
- Scrollbar: right-side track + thumb + up/down buttons, **pixel-based** scroll,
|
||||
`thumbRatio = view/content`, wheel = **1 line per notch**.
|
||||
- Input: editable one-line field — caret, insert/delete, 100-entry command
|
||||
history (up/down arrow), focus sprite, Enter→submit.
|
||||
- Channel menu (`Chat ▸`): dropdown of channels; selection sets the active
|
||||
outbound channel (the `ChatInputParser` default channel).
|
||||
- Send button + max/min button.
|
||||
- `ChatCommandRouter`: the shared submit pipeline, extracted from `ChatPanel`
|
||||
so the ImGui devtools chat and the retail chat share one routing path.
|
||||
|
||||
**Deferred (each gets a `retail-divergence-register.md` row — these need *non-UI*
|
||||
plumbing acdream lacks, they are NOT UI scope cuts):**
|
||||
- **Numbered chat tabs (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<string>? OnSubmit` |
|
||||
| `UiScrollable` | new (`App/UI/`) | `UIElement_Scrollable` | pixel scroll math: `ScrollY`, `ContentHeight`, `ViewHeight`, `ClampScroll`, `ThumbRatio`, `ThumbOffsetRatio`, line/page delta |
|
||||
| `UiChatScrollbar` | new (`App/UI/`) | composed scrollbar | own the imported track/thumb/up-down sprites; size+place thumb from `UiScrollable`; clicks/drag → `UiScrollable` |
|
||||
| `UiChannelMenu` | new (`App/UI/`) | `UIElement_Menu` | dropdown popup of channels; selection → active `ChatChannelKind`; label reflects selection |
|
||||
| `ChatCommandRouter` | new (`UI.Abstractions/Panels/Chat/`) | `ProcessCommand` | shared submit: client-command intercept → unknown-verb guard → `ChatInputParser.Parse(text, channel, lastTell, lastOutgoing)` → `Publish(SendChatCmd)` |
|
||||
| `UiDatFont` | no change | `Font` | already implements retail glyph advance |
|
||||
|
||||
**Why two widget classes (`UiChatView` + `UiChatInput`) when retail uses one
|
||||
`UIElement_Text` with mode bits:** acdream's retained-mode widget layer predates
|
||||
D.2b; the behavioral contract (read-only multiline scroll vs editable one-line) is
|
||||
identical, only the class split differs. Accepted **ADAPTATION** divergence; both
|
||||
classes share the `UiDatFont.GlyphAdvance` measure seam so geometry is consistent.
|
||||
|
||||
**Placeholder swap:** the transcript (`0x10000011`) and input (`0x10000016`)
|
||||
render no background sprite of their own (bg comes from parent panels
|
||||
`0x10000010` / `0x10000013`), so `ChatWindowController` reads each placeholder's
|
||||
rect + anchors, instantiates `UiChatView` / `UiChatInput` there, adds it to the
|
||||
placeholder's parent, and removes the placeholder. Mirrors `GetChildRecursive(id)`
|
||||
binding in `ChatInterface::PostInit`.
|
||||
|
||||
## 5. Data flow
|
||||
|
||||
- **Inbound:** `ChatLog → ChatVM.RecentLinesDetailed()` (200-deep tail) →
|
||||
`UiChatView.LinesProvider` (per-`ChatKind` color via `RetailChatColor`). Pipeline
|
||||
unchanged. Bottom-pin + 10k cap are `UiChatView`/`UiScrollable` behavior.
|
||||
- **Outbound:** `UiChatInput.OnSubmit(text)` →
|
||||
`ChatCommandRouter.Submit(text, vm, commandBus, activeChannel)` → `SendChatCmd`
|
||||
→ `LiveCommandBus` → `WorldSession`. `activeChannel` comes from `UiChannelMenu`.
|
||||
- **Channel:** `UiChannelMenu` selection → `ChatWindowController._activeChannel`
|
||||
(→ `ChatInputParser` default channel) + menu label update.
|
||||
- **Scroll:** transcript content height → `UiScrollable` → `UiChatScrollbar` thumb;
|
||||
wheel/buttons/drag → `UiScrollable.ScrollY` → transcript draw offset.
|
||||
|
||||
## 6. Faithfulness decisions / divergence-register rows
|
||||
|
||||
Add on landing (category in parens):
|
||||
1. **(Adaptation)** Transcript + input are two classes (`UiChatView`/`UiChatInput`)
|
||||
not one mode-flagged `UIElement_Text`. Behavior identical.
|
||||
2. **(Approximation)** Transcript renders pre-split `ChatLog` lines 1:1; no
|
||||
in-element word-wrap at panel width. Symptom: long lines not re-wrapped on
|
||||
horizontal resize. `file:line` = `UiChatView.cs`.
|
||||
3. **(Approximation)** One color per display line, not per-glyph styled runs.
|
||||
4. **(Stopgap)** Numbered chat tabs render but don't switch / filter chat kinds.
|
||||
5. **(Stopgap)** Squelch toggle + clickable name-tags render/parse-absent.
|
||||
6. **(Approximation)** Single default translucency; no focused/unfocused opacity
|
||||
transition; default dat font face+size (no `sm_nFontFace` config).
|
||||
|
||||
Retire nothing (no existing register row is fixed by this work).
|
||||
|
||||
## 7. Build sequence (tasks for the plan)
|
||||
|
||||
Pipelineable where independent; `ChatWindowController` (G) and the `GameWindow`
|
||||
cutover (H) are the integration barrier.
|
||||
|
||||
- **A. `ChatCommandRouter`** — extract the submit flow from `ChatPanel` into a
|
||||
pure `UI.Abstractions` helper; `ChatPanel` calls it; tests for client-command /
|
||||
unknown-verb / parse / publish parity. *(UI.Abstractions; no GL.)*
|
||||
- **B. `UiChatView` dat-font seam** — add `UiDatFont? DatFont`; prefer it in draw +
|
||||
`HitChar` advance + selection measure + `LineHeight`; change `WheelLines` 3→1;
|
||||
keep `BitmapFont` fallback. Tests: advance/hit-test with a synthetic dat font.
|
||||
- **C. `UiScrollable`** — port `UIElement_Scrollable` math (pixel clamp, thumb
|
||||
ratio/offset, line/page delta). Pure, fully unit-tested (no GL).
|
||||
- **D. `UiChatScrollbar`** — own imported track/thumb/up-down sprites; size+place
|
||||
thumb from `UiScrollable`; wheel/button/drag → scroll. Locate the right-side
|
||||
up/down button ids in the dat here.
|
||||
- **E. `UiChatInput`** — editable one-line widget: caret (`MeasurePrefix` =
|
||||
`UiDatFont.MeasureWidth(text[..caret])`), insert/delete, Home/End/arrows,
|
||||
100-entry history with `−1`=live sentinel, focus sprite swap, `OnSubmit`. Tests
|
||||
for caret math + history.
|
||||
- **F. `UiChannelMenu`** — channel dropdown (port `UIElement_Menu` minimally);
|
||||
13 channels → `ChatChannelKind`; selection event + label.
|
||||
- **G. `ChatWindowController`** — `LayoutImporter.Import(0x21000006)`; bind by id;
|
||||
swap transcript/input; wire scrollbar/menu/send/max-min; route inbound (ChatVM)
|
||||
+ outbound (`ChatCommandRouter`); translucency.
|
||||
- **H. `GameWindow` cutover** — replace the hand-authored
|
||||
`UiNineSlicePanel`+`UiChatView` block with `ChatWindowController`; default
|
||||
bottom-left position + resizable; remove dead code; add divergence rows;
|
||||
`dotnet build` + `dotnet test` green.
|
||||
|
||||
## 8. Testing strategy
|
||||
|
||||
- **Pure/unit (no GL, no dats):** `ChatCommandRouter` parity; `UiScrollable`
|
||||
clamp/thumb/delta golden values from the decomp; `UiChatInput` caret index ↔
|
||||
pixel + history navigation; `UiChatView` dat-font advance/hit-test via the
|
||||
`Func<char,FontCharDesc?>` seam.
|
||||
- **Layout/import (dat-free fixture):** extend the importer fixture pattern with a
|
||||
`chat_21000006.json` tree (via `ImportInfos`) asserting the element→role map and
|
||||
rects.
|
||||
- **Real-dat smoke:** `LayoutImporter.Import(0x21000006)` against the live dat
|
||||
resolves the root + all bound ids before wiring (guarded, like the vitals smoke).
|
||||
- **Visual acceptance (user):** launch live `ACDREAM_RETAIL_UI=1`; compare to the
|
||||
retail screenshot — transcript scrolls, input types + sends, channel menu
|
||||
switches, Send works, scrollbar drags, window moves/resizes, translucency.
|
||||
|
||||
## 9. Acceptance criteria
|
||||
|
||||
- [ ] Chat window is built from `LayoutDesc 0x21000006` via `LayoutImporter` — no
|
||||
hand-authored chat rect remains in `GameWindow.cs`.
|
||||
- [ ] Transcript renders inbound chat in the **dat font**, per-`ChatKind` color,
|
||||
bottom-pinned, 10k-cap, mouse-wheel = 1 line/notch, drag-select + Ctrl+C kept.
|
||||
- [ ] Right-side scrollbar: thumb sizes to content, drag + up/down scroll the
|
||||
transcript.
|
||||
- [ ] Input: type, caret moves, backspace/delete, up/down history, **Enter and the
|
||||
Send button both submit** through `ChatCommandRouter` → wire.
|
||||
- [ ] `Chat ▸` menu opens, lists channels, selection changes the outbound channel
|
||||
+ updates the label.
|
||||
- [ ] Max/min toggles window height; window moves + resizes; translucent frame.
|
||||
- [ ] Every ported widget cites a `class::method @address`; every deferral has a
|
||||
divergence-register row.
|
||||
- [ ] `dotnet build` + `dotnet test` green; user visual sign-off.
|
||||
|
||||
## 10. Deferred / follow-ups (filed, not built)
|
||||
|
||||
In-element word-wrap (+ selection rework); numbered-tab switching + per-tab chat
|
||||
filtering; squelch; clickable name-tags; per-glyph styled runs; configurable font
|
||||
face/size; active/inactive opacity transition; the unidentified top-level Type-5
|
||||
ListBox `0x1000001D` (not bound by `ChatInterface`; likely a floaty/options element).
|
||||
216
docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md
Normal file
216
docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
# LayoutDesc Importer — Design
|
||||
|
||||
**Date:** 2026-06-15
|
||||
**Status:** Approved (brainstorm) — pending spec review → implementation plan
|
||||
**Track:** D.2b retail UI engine (next sub-phase; register the phase id in the roadmap before implementation)
|
||||
**Supersedes nothing. Deletes nothing.** Coexists with the existing hand-authored path.
|
||||
|
||||
## Context
|
||||
|
||||
D.2b shipped a working retail vitals window and a scrollable chat window, but each was
|
||||
built by **hand**: dump the dat `LayoutDesc`, transcribe sprite ids + rects into
|
||||
`vitals.xml` / `UiMeter` / `UiNineSlicePanel`, then discover-and-patch missing details
|
||||
(the bar fill model, the dat-font, the tiling, the resize-grip overlay) one at a time.
|
||||
That archaeology does not scale to AC's dozens of windows, and it keeps *missing* details
|
||||
that are already in the dat (the grip overlay was found only because the user spotted it).
|
||||
|
||||
The `LayoutDesc` dat is a **complete, declarative description of every window** — element
|
||||
tree, positions, sizes, anchors, sprites per state, draw-modes, fonts, borders, grips,
|
||||
meters, labels, inheritance. It is retail's "HTML for windows." The fix is to **render the
|
||||
dat** with one faithful interpreter rather than transcribe it per window.
|
||||
|
||||
## Goal
|
||||
|
||||
Build a faithful `LayoutDesc` interpreter that reads a retail layout from the dat and
|
||||
produces a `UiElement` tree the existing toolkit renders — so opening any retail window is
|
||||
one call, with **no per-window graphics/layout code**. The only per-window code is live
|
||||
**data wiring** (which is inherently per-window and tiny).
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Re-porting Keystone's C++ framework (its own renderer, string/container classes, vtable
|
||||
dispatch, D3D blits). We port retail's **render algorithms**, not its framework — that is
|
||||
what Silk.NET + .NET already provide. (See "Decisions → Structure".)
|
||||
- Deleting or rewriting the existing toolkit/widgets/markup. They are reused.
|
||||
|
||||
## Decisions (from brainstorm 2026-06-15)
|
||||
|
||||
1. **Proof target = re-drive vitals.** Point the importer at the vitals `LayoutDesc`
|
||||
(`0x2100006C`) and make it reproduce the hand-built window. Known-good baseline → clean
|
||||
pass/fail. The hand-authored vitals path stays as the reference until the importer matches.
|
||||
2. **Scope = full faithful interpreter.** Interpret the *complete* `LayoutDesc` format
|
||||
(every element type, full `BaseElement`/`BaseLayoutId` inheritance, all draw-modes,
|
||||
states, properties) — not just the slice vitals uses. Matches the project's
|
||||
"behavior is retail" ethos.
|
||||
3. **Structure = hybrid (Approach C).** Port each element type's render **algorithm**
|
||||
verbatim from the decomp, onto our modern draw primitives. A single generic renderer
|
||||
handles the trivial "stamp the sprite per draw-mode" types (the long tail, including
|
||||
types not yet catalogued); dedicated widgets handle types with real behavior (meter,
|
||||
text, scrollbar/chat, button). The decomp's render method for each type *decides* which
|
||||
bucket it falls in — we do not guess. Faithfulness comes from porting the algorithms;
|
||||
the hybrid is only about C# packaging.
|
||||
4. **Coexistence, don't-delete.** `MarkupDocument` stays as the path for plugin/custom
|
||||
panels (no dat layout). The existing widgets (`UiMeter`, `UiNineSlicePanel`,
|
||||
`UiChatView`, `UiDatFont`) and primitives (tiling, scissor-fill, dat-font, nine-slice)
|
||||
become the importer's behavioral renderers.
|
||||
|
||||
## Architecture & data flow
|
||||
|
||||
```
|
||||
RETAIL WINDOWS (data-driven from the dat)
|
||||
client_portal.dat ─► LayoutImporter ─► UiElement tree ─► UiRoot ─► renderers ─► screen
|
||||
(LayoutDesc 0x21..) │ (UiDatElement +
|
||||
│ behavioral widgets)
|
||||
├─ resolve BaseElement / BaseLayoutId inheritance
|
||||
├─ walk ElementDesc tree → widget (hybrid factory)
|
||||
└─ apply rect / anchors / states / media / props from the dat
|
||||
|
||||
per-window Controller ─► binds LIVE data to elements by id (mirrors retail gm*UI)
|
||||
WindowManager ─► open/close by layout id, z-order, focus, position persistence
|
||||
|
||||
PLUGIN / CUSTOM PANELS (hand-authored, unchanged)
|
||||
*.xml ─► MarkupDocument ─► UiElement tree ─► (same UiRoot + renderers)
|
||||
```
|
||||
|
||||
Two input paths (dat importer for retail windows, markup for custom/plugin), one rendering
|
||||
toolkit. Nothing in the bottom (`UiHost`/`UiRoot`/`UiElement`) or the render primitives
|
||||
changes.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Format enumeration (Step 0 — foundational groundwork)
|
||||
|
||||
Because we chose "full faithful," the first deliverable is a **documented map** of the
|
||||
complete format, not code. Sources, cross-checked against each other:
|
||||
|
||||
- **DatReaderWriter types** — `ElementDesc`, `StateDesc`, `MediaDesc*` and their enums
|
||||
(`Type`, `DrawMode`, media kinds, state keys). Reflect/inspect as `dump-vitals-layout`
|
||||
already does (props **and** fields).
|
||||
- **Retail decomp** — the `UIElement_*` class hierarchy + each type's render method; the
|
||||
property-key meanings; the **KSML keyword registrations** (the parser registers every
|
||||
property name — the canonical vocabulary, e.g. `KW_DRAWMODE`, `KW_DURATION`, …).
|
||||
- **Real layouts** — scan a sample of `LayoutDesc`s to confirm which Types/properties
|
||||
actually occur and catch anything the above missed.
|
||||
|
||||
Output: a reference doc mapping each `Type` → meaning + render method, each property key →
|
||||
meaning, each `DrawMode` → behavior, and the inheritance rules. This doc drives every other
|
||||
component and is committed alongside the importer.
|
||||
|
||||
### 2. `LayoutImporter`
|
||||
|
||||
Reads a `LayoutDesc` by id and returns a `UiElement` subtree:
|
||||
- Walk the `ElementDesc` tree.
|
||||
- For each element: resolve inheritance (§3), pick a widget via the factory (§4), set its
|
||||
rect (`X/Y/W/H`), anchors (edge flags → `AnchorEdges`), z-order, states, media, and
|
||||
properties from the (resolved) element.
|
||||
- Recurse into children.
|
||||
- Expose `FindElement(uint id)` on the result so controllers wire by id.
|
||||
|
||||
Depends on: `DatCollection` (read layouts), the factory, the inheritance resolver,
|
||||
`TextureCache.GetOrUploadRenderSurface` (sprites), `UiDatFont` (text). No GL itself — it
|
||||
builds `UiElement`s; rendering stays in the toolkit.
|
||||
|
||||
### 3. Inheritance resolution
|
||||
|
||||
An element with `BaseElement`/`BaseLayoutId` inherits the base element's properties / states
|
||||
/ media; the derived element overrides. Resolve by loading the base layout, finding the base
|
||||
element, and merging (base first, then derived overrides) **before** instantiating.
|
||||
Required even for vitals: the number-text element inherits its font/style from base layout
|
||||
`0x2100003F`. Cycle-guard the resolution.
|
||||
|
||||
### 4. Hybrid widget factory (`Type` → renderer)
|
||||
|
||||
- **Behavioral** types → dedicated widgets (verbatim-algorithm ports): meter → `UiMeter`,
|
||||
text → dat-font label, scrollable/list region → `UiChatView`/list widget, button →
|
||||
`UiButton`, resizable window root → `UiNineSlicePanel`.
|
||||
- **Trivial** types (image, container, border piece, grip) → `UiDatElement` (generic).
|
||||
- **Unknown** type → `UiDatElement` (faithful fallback — still draws its media).
|
||||
|
||||
The Step-0 enumeration assigns each `Type` to a bucket by reading its retail render method
|
||||
(trivial blit → generic; real algorithm → widget).
|
||||
|
||||
### 5. `UiDatElement` (generic renderer)
|
||||
|
||||
A `UiElement` holding the resolved element's active-state media + draw-mode + rect. Its
|
||||
`OnDraw` ports retail's base blit branch:
|
||||
- `Normal` → **tile** at native size (UV-repeat; the UI texture is `GL_REPEAT`-wrapped) —
|
||||
the mechanism already proven for the bars + chrome.
|
||||
- `Alphablend` → blended overlay.
|
||||
- `Stretch` (if present) → scale.
|
||||
- image → sprite; cursor → hover cursor.
|
||||
Reuses the tiling, dat-font, nine-slice draw primitives.
|
||||
|
||||
### 6. Per-window controllers (live-data binding)
|
||||
|
||||
Mirror retail's `gm*UI` classes. A small controller per window grabs elements by id from the
|
||||
imported tree and pushes live data in: `VitalsController` binds `HealthPercent` → meter fill,
|
||||
`cur/max` → number text; `ChatController` binds the chat tail → the chat region. **This is
|
||||
the only per-window code, and it is data wiring, not graphics.** Retail-faithful: e.g.
|
||||
`gmVitalsUI::PostInit` grabs child meter elements by id and sets attribute `0x69` (fill).
|
||||
|
||||
### 7. `WindowManager`
|
||||
|
||||
`OpenWindow(layoutId, controller)` → import + attach to `UiRoot`, place at the dat's default
|
||||
position (then persist user move/resize), manage z-order / focus / close. Orchestrates the
|
||||
focus/drag/resize mechanics `UiRoot` already provides.
|
||||
|
||||
### 8. States / expand / hover
|
||||
|
||||
Each element carries its named states (`HideDetail`/`ShowDetail`, normal/hover/pressed) from
|
||||
the dat; the active state selects which media draws. A click or hover flips the active state.
|
||||
Click-to-expand and hover highlight fall out generically — no per-window code.
|
||||
|
||||
## Rollout order (milestones)
|
||||
|
||||
1. **Enumerate the format** (§1) → reference doc.
|
||||
2. **`LayoutImporter`** + **inheritance resolution** (read + resolve + walk).
|
||||
3. **`UiDatElement`** generic renderer (port the draw-mode blit branch).
|
||||
4. **Hybrid factory** (Type → widget/generic).
|
||||
5. **`VitalsController`** (bind by id).
|
||||
6. **Re-drive vitals → diff against the current window.** ✅ conformance gate.
|
||||
7. **`WindowManager`** (open/close/persist).
|
||||
8. **Extend** to chat (`ChatController`), then new windows for free.
|
||||
|
||||
## Testing / conformance
|
||||
|
||||
- **Golden tree checks** — the importer-built vitals tree has the expected element rects,
|
||||
resolved sprites, and active states (assert against the known `0x2100006C` values).
|
||||
- **Inheritance unit tests** — base+override merge, cycle-guard.
|
||||
- **Draw-mode unit tests** — the UV math for tile vs stretch vs the partial last tile.
|
||||
- **Bind-by-id unit tests** — controller wires the right element.
|
||||
- **Headless visual diff** — `render-vitals-mockup` / a tree-render comparison vs the
|
||||
hand-built reference (no live server needed).
|
||||
- **Final** — in-client visual verification (the user) once the gate passes.
|
||||
|
||||
## Coexistence / don't-delete (restated)
|
||||
|
||||
- `MarkupDocument` + `*.xml` stay for plugin/custom panels.
|
||||
- `UiMeter`, `UiNineSlicePanel`, `UiChatView`, `UiDatFont`, the tiling/scissor-fill/dat-font/
|
||||
nine-slice primitives stay — reused as the importer's behavioral renderers.
|
||||
- The hand-authored vitals path stays as the conformance reference until the importer
|
||||
matches it; only then is vitals flipped to the importer.
|
||||
|
||||
## Risks & open questions
|
||||
|
||||
- **The format enumeration is the foundational unknown.** If a Type/property/draw-mode is
|
||||
mis-mapped, faithfulness breaks. Mitigation: Step 0 cross-checks three sources + real
|
||||
layouts; the vitals conformance gate catches regressions.
|
||||
- **Some behavioral types may need new widgets** (list, scrollbar, edit box). These are
|
||||
generic, written once — not per-window. The generic fallback means an un-widgeted type
|
||||
still renders its sprites in the meantime.
|
||||
- **Position persistence** scope (per-window saved rects) — minimal at first (dat default +
|
||||
in-session move/resize); durable persistence can follow.
|
||||
- **Phase id** — register this as the next D.2b sub-phase in the roadmap before implementing.
|
||||
|
||||
## Reference anchors
|
||||
|
||||
- **Dat layouts:** vitals (stacked) `0x2100006C`; floaty row `0x21000014`; horizontal row
|
||||
`0x21000075`; vitals number-text base layout `0x2100003F`.
|
||||
- **Decomp:** `gmVitalsUI::PostInit` @`0x4bfce0` (bind by id), `UIElement_Meter::DrawChildren`
|
||||
@`0x46fbd0` (scissor-fill), `SurfaceWindow::DrawCharacter` @`0x442bd0` (dat-font),
|
||||
`ImgTex::TileCSI` @`0x53e740` (tiling), `UIRegion::DrawHere` @`0x69fa30` (element draw order),
|
||||
the KSML keyword registrations (~`0x71b540`+).
|
||||
- **Tools:** `AcDream.Cli dump-vitals-layout` (reflective full tree dump),
|
||||
`dump-sprite-sheet` (composite sprite ids), `render-vitals-mockup` (headless window render).
|
||||
- **Memory:** `project_d2b_retail_ui.md` (the two-layout lesson, sprite ids, render model,
|
||||
dat-font, tools).
|
||||
|
|
@ -0,0 +1,410 @@
|
|||
# D.2b — Widget generalization (LayoutDesc importer, Plan 2 widget piece) — design
|
||||
|
||||
**Date:** 2026-06-16
|
||||
**Branch:** `claude/hopeful-maxwell-214a12` (D.2b retail-UI track)
|
||||
**Status:** design — approved scope ("full registry, vitals last & gated"), pending spec review
|
||||
**Predecessor:** the LayoutDesc importer, the vitals re-drive, and the chat-window re-drive
|
||||
(`docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md`,
|
||||
`docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md`,
|
||||
`docs/research/2026-06-15-layoutdesc-format.md`,
|
||||
`claude-memory/project_d2b_retail_ui.md`).
|
||||
**Opening context:** the "GENERALIZATION PASS — START-COLD CONTEXT" note in
|
||||
`claude-memory/project_d2b_retail_ui.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Refactor the hand-named chat widgets (`UiChatView` / `UiChatInput` /
|
||||
`UiChatScrollbar` / `UiChannelMenu`) and the inline Send/Max-Min `UiDatElement`
|
||||
click-wiring into **generic, Type-registered widgets** built by
|
||||
`DatWidgetFactory`, so that `ChatWindowController` (and, as the final gated step,
|
||||
`VitalsController`) collapses to a thin **find-widget-by-id → bind-data/behavior**
|
||||
controller — the acdream analogue of retail `gm*UI::PostInit`.
|
||||
|
||||
**The code is modern. The behavior is retail.** This pass changes the
|
||||
*construction path* of widgets, not their on-screen behavior. The chat window
|
||||
must stay visually and behaviorally identical through every step except the final
|
||||
(gated) vitals rewire.
|
||||
|
||||
### 1.1 Why this is mostly already done
|
||||
|
||||
The trace that opened this work (re-confirmed in this design session) established
|
||||
two facts that make the generalization a *registration* task, not a new mechanism:
|
||||
|
||||
1. **The importer's base-chain Type resolution is already retail-faithful.**
|
||||
`ElementReader.Merge` resolves a Type-0 placement element up its
|
||||
`BaseElement`/`BaseLayoutId` chain to the base's real registered Type
|
||||
(`ElementReader.cs:137-140`). Every chat/vitals element therefore already
|
||||
resolves to the retail class it would instantiate.
|
||||
|
||||
2. **Type 12 is `UIElement_Text` — a real behavioral class, not a "style
|
||||
prototype to skip."** Verified directly in the decomp:
|
||||
`UIElement::RegisterElementClass(0xc, UIElement_Text::Create)`
|
||||
(`docs/research/named-retail/acclient_2013_pseudo_c.txt:115655`). The
|
||||
`Type==12 → return null` rule in `DatWidgetFactory` is a *vitals-only Plan-1
|
||||
expedient* (AP-37: skip the vitals number elements so they render via
|
||||
`UiMeter.Label`), **not** a structural truth.
|
||||
|
||||
So the "wrinkle" feared in the start-cold note (Type-0/12 elements hiding their
|
||||
real widget type) **dissolves**: the resolved Type is already correct. The factory
|
||||
just needs to *register* generic widgets for those Types instead of skipping them
|
||||
or dropping to `UiDatElement`.
|
||||
|
||||
### 1.2 Why this matters beyond chat (the strategic purpose)
|
||||
|
||||
Chat is the **proving ground**, not the destination. The payoff is that every
|
||||
future panel — **inventory, spell bar, vendor, character sheet, trade, skills** —
|
||||
becomes *assembled from dat data + a thin controller* instead of being hand-built
|
||||
from scratch. That is exactly how retail did it (`gm*UI::PostInit` everywhere on a
|
||||
shared `UIElement` toolkit), and it is the reason to do this pass carefully now.
|
||||
|
||||
**What this pass gives all future windows (the foundation):**
|
||||
- The **generic widget toolkit** — `UiButton`, `UiField`, `UiScrollbar`, `UiText`,
|
||||
`UiMenu` — built automatically by `DatWidgetFactory` from the dat layout.
|
||||
- The **thin-controller pattern** — find-widget-by-id → bind-live-data — proven and
|
||||
cemented on chat. Inventory's controller, vendor's controller, etc. all take the
|
||||
same shape.
|
||||
|
||||
**What those specific windows additionally need (out of scope here; cheap once the
|
||||
pattern exists):**
|
||||
- **A few more widget Types** — inventory/vendor lists want `UiListBox` (Type 5)
|
||||
and `UiPanel` (Type 8); item slots want **drag-drop**, which retail builds into
|
||||
`UIElement_Field` (the decomp shows `Field` has `CatchDroppedItem` /
|
||||
`MouseOverTop` drag-drop hooks — so drag-drop rides on the Field widget this pass
|
||||
already builds). Each gets *registered when that window needs it* — which is
|
||||
exactly why §3 bounds "full registry" to the Types chat+vitals use today rather
|
||||
than speculatively building all 14 retail classes.
|
||||
- **The window manager** — open/close/z-order/persist, drag-bars (Type 2),
|
||||
resize-grips (Type 9). This is the *other* half of Plan 2 — a sibling piece to
|
||||
this one — and lands alongside, because pop-up/stackable windows (inventory,
|
||||
vendor) need it.
|
||||
- **Per-domain data plumbing** — item icons, live container contents, vendor stock
|
||||
lists. Game-state work, separate from the UI toolkit.
|
||||
|
||||
This pass is therefore the **reusable toolkit + assembly pattern** that makes those
|
||||
later windows mostly-free to build. It is the load-bearing first half of the road
|
||||
to inventory/vendor/spell-bar, not the whole road.
|
||||
|
||||
---
|
||||
|
||||
## 2. Retail reference (the registry + the PostInit pattern)
|
||||
|
||||
### 2.1 The Type → class registry (`UIElement::RegisterElementClass`)
|
||||
|
||||
Confirmed verbatim from `acclient_2013_pseudo_c.txt` (line numbers cited):
|
||||
|
||||
| Type | Retail class | Reg. line | | Type | Retail class | Reg. line |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | `UIElement_Button` | :125828 | | 9 | `UIElement_Resizebar` | :118938 |
|
||||
| 2 | `UIElement_Dragbar` | :119926 | | 0xb (11) | `UIElement_Scrollbar` | :124137 |
|
||||
| 3 | `UIElement_Field` (editable) | :126190 | | 0xc (12) | `UIElement_Text` | :115655 |
|
||||
| 5 | `UIElement_ListBox` | :121788 | | 0xd (13) | `UIElement_Viewport` | :119126 |
|
||||
| 6 | `UIElement_Menu` | :120163 | | 0xe (14) | `UIElement_Browser` | :118718 |
|
||||
| 7 | `UIElement_Meter` | :123316 | | 0x10/0x11 | `ColorPicker`/`GroupBox` | :118396/:118177 |
|
||||
| 8 | `UIElement_Panel` | :119820 | | — | **Type 0 and 4: NOT registered** | — |
|
||||
|
||||
Type 0 has no class of its own — a Type-0 element is a placement/override that
|
||||
inherits its class from its base. That is exactly what `ElementReader.Merge`
|
||||
already does.
|
||||
|
||||
> **Implementation correction (2026-06-16, settled during execution).** Two of
|
||||
> this design's registration assumptions changed once the empirical resolved
|
||||
> Types were in hand (Task 1):
|
||||
> 1. **The editable input `0x10000016` resolves to Type 12 (Text), not Type 3.**
|
||||
> So the input is **Variant B** — the factory builds it as a `UiText`
|
||||
> placeholder and `ChatWindowController` removes that and controller-places a
|
||||
> `UiField` at its rect. (Confirmed by the chat golden fixture.)
|
||||
> 2. **Type 3 is NOT registered → `UiField` in this pass.** In acdream's vitals
|
||||
> (`0x2100006C`) and chat (`0x21000006`) layouts, Type-3 dat elements are
|
||||
> sprite-bearing **chrome** (the 8-piece bevel corners/edges, e.g. vitals
|
||||
> `0x10000633` → sprite `0x060074C3`) and the transcript/input **container**
|
||||
> panels — NOT editable fields. Retail draws those as inert media-bearing
|
||||
> Fields, which our generic `UiDatElement` reproduces pixel-for-pixel and
|
||||
> without a spurious focus/edit affordance. Registering Type 3 → `UiField`
|
||||
> (which draws no dat sprite) would blank the vitals bevel. So the factory
|
||||
> switch registers **Button (1), Menu (6), Meter (7), Scrollbar (11), Text
|
||||
> (12)**; Type 3 stays on the `UiDatElement` fallback. `UiField` still ships
|
||||
> (the renamed editable widget) — it is just controller-placed, not
|
||||
> factory-wired. Register Type 3 → `UiField` only when a window carries a
|
||||
> factory-built editable Type-3 field (and `UiField` then grows a
|
||||
> background-media draw + an opt-in editable flag). Guarded by
|
||||
> `VitalsTree_ChromeCornerHasExpectedSprite` (asserts the corner stays a
|
||||
> `UiDatElement` drawing its sprite).
|
||||
|
||||
### 2.2 The `gm*UI::PostInit` binding pattern (the controller target)
|
||||
|
||||
`gmVitalsUI::PostInit` (`acclient_2013_pseudo_c.txt:199170-199228`) and
|
||||
`gmMainChatUI::PostInit` (`:212585-212636`) do, per child widget:
|
||||
|
||||
```
|
||||
UIElement* e = UIElement::GetChildRecursive(this, 0x100000e6); // find by id
|
||||
UIElement_Meter* m = e->vtable->DynamicCast(7); // cast to Type
|
||||
this->m_pHealthMeter = m; // store
|
||||
if (!m) { /* skip */ } // null-check
|
||||
```
|
||||
|
||||
acdream analogue (already half-present in `ChatWindowController`):
|
||||
|
||||
```csharp
|
||||
var send = layout.FindElement(SendId) as UiButton; // GetChildRecursive + DynamicCast
|
||||
if (send is not null) send.OnClick = () => input.Submit(); // bind behavior
|
||||
```
|
||||
|
||||
The faithful end-state is: **the factory builds every widget from the dat; the
|
||||
controller only finds-by-id and binds data/callbacks** — it never constructs a
|
||||
widget.
|
||||
|
||||
### 2.3 Empirically resolved Types of the chat elements (`LayoutDesc 0x21000006`)
|
||||
|
||||
Traced against the live dat (HIGH confidence; base ids in parentheses):
|
||||
|
||||
| Element | Resolves to | Retail class | Today |
|
||||
|---|---|---|---|
|
||||
| `0x10000014` channel menu | **6** (own Type 6, no base) | Menu | `UiDatElement` → controller replaces w/ `UiChannelMenu` |
|
||||
| `0x10000012` scrollbar track | **11** (base `0x10000367` in `0x2100003E`) | Scrollbar | `UiDatElement` → controller replaces w/ `UiChatScrollbar` |
|
||||
| `0x10000011` transcript | **12** (base `0x10000372` in `0x2100003F`) | Text | skipped → controller adds `UiChatView` |
|
||||
| `0x10000016` input | **12** (base `0x10000372` in `0x2100003F`) | Text | skipped → controller adds `UiChatInput` |
|
||||
| `0x10000019` send | **1** (base chain → `0x1000047F` Type 1) | Button | `UiDatElement` + `OnClick` |
|
||||
| `0x1000046F` max/min | **1** (base `0x1000047F` Type 1 in `0x21000040`) | Button | `UiDatElement` + `OnClick` |
|
||||
|
||||
> **Plan-phase verification #1 (load-bearing):** the editable **input**
|
||||
> `0x10000016` traced to **Type 12 (Text)**, the same base as the read-only
|
||||
> transcript — surprising for an editable field (retail's editable text is
|
||||
> Field=3). Element ids are layout-*local*, so the decomp's `ChatInterface`
|
||||
> Field-id does **not** cross-map; re-dump `0x10000016`'s exact resolved Type and
|
||||
> the `0x10000372` base prototype's Type before relying on it. The design is
|
||||
> robust either way — see §4.3(a).
|
||||
|
||||
---
|
||||
|
||||
## 3. Approved scope
|
||||
|
||||
**Decision (this session):** *Full registry, chat-first, vitals rewire as the
|
||||
final, separately-committed, separately-gated step.*
|
||||
|
||||
**In scope:**
|
||||
- Register generic widgets for the Types the chat + vitals windows actually use:
|
||||
**Button (1), Field (3), Menu (6), Scrollbar (11), Text (12)** — plus Meter (7)
|
||||
already done.
|
||||
- Delete the `Type==12 → return null` skip; Type 12 becomes `UiText`.
|
||||
- Collapse `ChatWindowController.Bind` to a find-by-id binder (no widget
|
||||
construction).
|
||||
- **Final gated step:** rewire `VitalsController` to bind generic `UiText` for the
|
||||
vitals numbers (retail-faithful: vitals numbers *are* `UIElement_Text`),
|
||||
retiring `UiMeter.Label` for vitals.
|
||||
|
||||
**Explicitly NOT in scope ("full registry" is bounded to what these windows use):**
|
||||
- The long tail retail also registers — `Panel` (8), `Dragbar` (2), `Resizebar`
|
||||
(9), `ListBox` (5), `Viewport` (13), `Browser` (14), `ColorPicker` (16),
|
||||
`GroupBox` (17). Those elements **continue to render correctly as
|
||||
`UiDatElement`** (the universal fallback is non-negotiable). No
|
||||
`UIElement_ColorPicker` port for a window that has no color picker. When a future
|
||||
window needs one of these, it gets registered then.
|
||||
- No new chat *features* (tabs/squelch/name-tags/word-wrap remain as the chat
|
||||
re-drive deferred them — see that spec's §2).
|
||||
- `UiMeter.Label` is **not deleted** — it stays for plugin/markup panels; vitals
|
||||
simply stops using it.
|
||||
|
||||
---
|
||||
|
||||
## 4. Design
|
||||
|
||||
### 4.1 `DatWidgetFactory` — the faithful Type switch
|
||||
|
||||
`DatWidgetFactory.Create` grows from `{7 → UiMeter, _ → UiDatElement}` to:
|
||||
|
||||
```csharp
|
||||
UiElement e = info.Type switch
|
||||
{
|
||||
1 => BuildButton(info, resolve, datFont), // UIElement_Button
|
||||
3 => BuildField(info, resolve, datFont), // UIElement_Field (see §4.3a)
|
||||
6 => BuildMenu(info, resolve, datFont), // UIElement_Menu
|
||||
7 => BuildMeter(info, resolve, datFont), // UIElement_Meter (unchanged)
|
||||
11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar
|
||||
12 => BuildText(info, resolve, datFont), // UIElement_Text
|
||||
_ => new UiDatElement(info, resolve), // generic fallback (unchanged)
|
||||
};
|
||||
```
|
||||
|
||||
The rect/anchor/z-order propagation at the bottom of `Create` is unchanged. The
|
||||
`Type==12 && StateMedia.Count==0` skip is **removed** — but a *pure base
|
||||
prototype* (Type 12 with no own geometry that is only referenced via
|
||||
`BaseLayoutId`, never placed) must still not draw. In practice such prototypes are
|
||||
never top-level placed elements in `0x21000006`/`0x2100006C`; the importer only
|
||||
builds placed elements. **Plan-phase verification #2:** confirm no Type-12
|
||||
prototype is double-built after the skip is removed (the chat/vitals golden
|
||||
fixtures catch this).
|
||||
|
||||
Each `BuildX` extracts the widget's dat-derived data (sprite ids per state, label
|
||||
font) the same way `BuildMeter` extracts its 3-slice grandchild sprites. The
|
||||
controller binds providers/callbacks afterward.
|
||||
|
||||
### 4.2 The generic widgets
|
||||
|
||||
Each generic widget extends `UiElement`, is constructed by the factory from
|
||||
`ElementInfo`, and exposes **data providers + callbacks** for the controller to
|
||||
bind. The chat-specific knowledge moves *out* of the widgets and *into* the
|
||||
controller (faithful: retail's `gmMainChatUI`, not `UIElement_Menu`, owns the
|
||||
talk-focus channel list).
|
||||
|
||||
| Generic widget | Type | Derived from | Generic surface (dat-built + provider-bound) | Controller binds |
|
||||
|---|---|---|---|---|
|
||||
| `UiScrollbar` | 11 | `UiChatScrollbar` (already 100% generic) | track/thumb/cap/arrow sprite ids from dat; `Model : UiScrollable` | `Model = transcript.Scroll` |
|
||||
| `UiButton` | 1 | `UiDatElement`+`OnClick` | state sprites (Normal/Pressed/Disabled), `Label`, `LabelFont`, `LabelColor`, `OnClick`, `NaturalWidth()` autosize | `OnClick`, caption |
|
||||
| `UiMenu` | 6 | `UiChannelMenu` | popup toggle, 2-col layout, 8-piece bevel, row highlight; `Items : IReadOnlyList<(string label, bool enabled, object payload)>`, `OnSelect : Action<object>`, `Selected`, `NaturalButtonWidth()` | populate 14 channel `Items`; map payload↔`ChatChannelKind`; `AvailabilityProvider` |
|
||||
| `UiText` | 12 | `UiChatView` | scrollable + selectable multi-color line list, clipboard, dat-font; `LinesProvider : Func<IReadOnlyList<(string,Vector4)>>`; shares `UiScrollable` (`Scroll`) | `LinesProvider` → ChatVM + per-kind colors |
|
||||
| `UiField` | 3 | `UiChatInput` | editable one-line: caret/selection/clipboard/history/auto-repeat/focus-sprite-swap; `Text`, `OnSubmit`, `MaxCharacters` | `OnSubmit` → `ChatCommandRouter` |
|
||||
|
||||
**Placement.** The generic widgets live in `src/AcDream.App/UI/` alongside
|
||||
`UiMeter` (toolkit widgets). The factory in `src/AcDream.App/UI/Layout/`
|
||||
references them. This matches the current split (`UiMeter` in `UI/`,
|
||||
`UiDatElement` in `UI/Layout/`).
|
||||
|
||||
**Naming.** `UiX` mirrors retail `UIElement_X`. The old chat-prefixed names are
|
||||
removed (or kept as thin obsolete aliases only if needed mid-migration).
|
||||
|
||||
### 4.3 The two wrinkles
|
||||
|
||||
**(a) The editable input (Type 12 vs Type 3).** Robust to either resolution:
|
||||
- If `0x10000016` resolves to **Type 3** → factory builds `UiField` directly; the
|
||||
controller only binds `OnSubmit`.
|
||||
- If it resolves to **Type 12** → the dat element is a display Text in this
|
||||
layout; the controller *replaces* it with a controller-placed `UiField` at its
|
||||
rect (today's pattern for the track/menu). `UiField` exists as a registered
|
||||
generic widget regardless; only *who places it* differs.
|
||||
|
||||
Editing behavior (caret/clipboard/history) is never purely dat-derivable, so the
|
||||
input is always provider-bound — the open question only affects whether the
|
||||
factory or the controller *instantiates* it.
|
||||
|
||||
**(b) Vitals rewire — the final gated step.** Removing the Type-12 skip means the
|
||||
vitals number elements (Type-0 → base Type-12 Text) *could* build as real
|
||||
`UiText`. Today they are **meter children, consumed** (the importer does not
|
||||
recurse a meter's children — `LayoutImporter.cs:113`), rendered via
|
||||
`UiMeter.Label`. The faithful move: `VitalsController` constructs/binds a `UiText`
|
||||
for each number (matching retail `UIElement_Text` vitals numbers) and drops
|
||||
`UiMeter.Label` for vitals.
|
||||
|
||||
This is **step 7 — the last commit, separately gated**, with its own fixture
|
||||
update and the user's visual sign-off, because vitals shipped pixel-identical and
|
||||
is fixture-locked (`vitals_2100006C.json`). If the rewire risks the pixel-identical
|
||||
result, we **stop and keep the meter-label path** for vitals — a smaller,
|
||||
documented divergence (AP-37 narrowed, not retired). The decision to land step 7
|
||||
is the user's, made on the running client.
|
||||
|
||||
### 4.4 The thin controller (after step 6)
|
||||
|
||||
`ChatWindowController.Bind` collapses to: for each known element id, `FindElement(id)
|
||||
as UiX`, null-check, bind data/callback. The reflow/maximize/resize-bar-drop logic
|
||||
(`ChatWindowController.cs:155-297`) stays — it is window-layout policy, not widget
|
||||
construction. The `BuildLines` / `WrapText` / `RetailChatColor` helpers stay (chat
|
||||
data shaping). What *leaves* the controller: the construction of `UiChatView`,
|
||||
`UiChatInput`, `UiChatScrollbar`, `UiChannelMenu` (now factory-built) — the
|
||||
controller binds them instead.
|
||||
|
||||
---
|
||||
|
||||
## 5. Migration sequence (one widget per commit; build + test green each step)
|
||||
|
||||
Ordered least-risk → most-risk; the chat window is fully generalized before vitals
|
||||
is touched. Each step: `dotnet build` green, `dotnet test` (AcDream.App.Tests)
|
||||
green, its own commit naming the widget; the live chat window stays visually
|
||||
identical through steps 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).
|
||||
Loading…
Add table
Add a link
Reference in a new issue