Merge claude/hopeful-maxwell-214a12 — D.2b UI Studio + faithful importer + Character window

UI Studio (preview panels through the production renderer), importer dat-fidelity (Fix A/B/C/4/5:
the importer carries dat font/justification/color; boundary look=importer / state=runtime), and the
Character window Attributes tab (reads as retail). 3062 tests green.
Handoff: docs/research/2026-06-26-mockup-stage-handoff.md.

# Conflicts:
#	docs/ISSUES.md
#	docs/architecture/retail-divergence-register.md
This commit is contained in:
Erik 2026-06-26 12:33:51 +02:00
commit ca94b479bf
61 changed files with 125881 additions and 44 deletions

1
.gitignore vendored
View file

@ -90,3 +90,4 @@ C[€-
# Junction to Claude Code per-project memory (Obsidian vault visibility) # Junction to Claude Code per-project memory (Obsidian vault visibility)
claude-memory claude-memory
studio-shots/

View file

@ -46,6 +46,45 @@ Copy this block when adding a new issue:
--- ---
## #158 — Character window — deferred polish
**Status:** OPEN
**Severity:** LOW
**Filed:** 2026-06-26
**Component:** ui
**Description:** The Character window (`LayoutDesc 0x2100002E`, `CharacterStatController`) was user-accepted 2026-06-26 as good-enough for the D.2b track, but the user noted "still needs some polish for later." Known candidates (user will enumerate the full list later): level-number glyph fidelity vs retail's exact large dat font; icon crispness in the attribute rows; heritage/title strings are sample data, not wired from the live character; exact string-table wording not ported (caption text hardcoded vs sourced from dat string tables).
**Root cause / status:** Not a bug — the Attributes tab is functionally complete + visually confirmed. Deferred post-M5.
**Files:** `src/AcDream.App/UI/Layout/CharacterStatController.cs`, `src/AcDream.App/UI/Layout/LayoutImporter.cs`.
## #157 — Live game (GameWindow) doesn't thread the per-element dat-font resolver (Fix C is studio-only)
**Status:** OPEN
**Severity:** LOW
**Filed:** 2026-06-26
**Component:** ui
**Description:** Fix C (commit `a0d3395`) added a per-element dat `FontDid` resolver to `LayoutImporter.Import` + `DatWidgetFactory` — each text element gets its own dat font instead of one global font. Wired into the STUDIO path (StudioWindow → LayoutSource → LayoutImporter), but GameWindow's four `LayoutImporter.Import`/`Build` calls explicitly pass `null`, so the LIVE game still uses a single global font.
**Fix:** Build a `Func<uint,UiDatFont?>` closure over a `ConcurrentDictionary<uint, UiDatFont?>` near where `vitalsDatFont`/`largeDatFont` load (~`GameWindow.cs:1820`), reusing `UiDatFont.Load` via `GetOrAdd` (same pattern as `RenderStack.ResolveDatFont`), and pass it to the four Import sites: vitals `0x2100006C` (~1820-1835), chat (~1875), toolbar `0x21000016` (~2019), inventory `0x21000023` (~2157). `CharacterStatController.Bind` already has null-means-keep-dat-font semantics, so it benefits automatically. Backward-compat: resolver applies the dat font as a default; explicit `DatFont` still overrides.
**Files:** `src/AcDream.App/Rendering/GameWindow.cs` (4 import sites), `src/AcDream.App/Rendering/RenderBootstrap.cs` (`RenderStack.ResolveDatFont` = pattern to mirror). **Relates to:** Fix C `a0d3395`; register row AP-75.
## #156 — Studio: inventory window (0x21000023) renders all-black in preview
**Status:** OPEN
**Severity:** MEDIUM
**Filed:** 2026-06-25
**Component:** ui (studio)
**Description:** `AcDream.App ui-studio "<datdir>" --layout 0x21000023 --screenshot out.png` renders a flat ~1.2 KB dark-canvas image — the inventory frame / grid / paperdoll don't draw. No exception, no console output. Other studio panels render fine; the inventory works in the live game (F12). Matters for the planned multi-window "full mockup" studio mode.
**Root cause / status:** Pre-existing (identical at commits 9021600 + eccacc5; NOT a regression). The binding does not throw. Likely the studio host/render-stack omits state the inventory's whole-window Draggable frame + sub-window mounts need, or a sprite-resolve / z-order gap specific to the studio host.
**Files:** `src/AcDream.App/Studio/FixtureProvider.cs` (case 0x21000023u), `src/AcDream.App/Studio/StudioWindow.cs`, `src/AcDream.App/Studio/PanelFbo.cs`.
## #155 — Outdoor terrain textures look stretched + blurry vs retail (missing landscape DETAIL-texture overlay) ## #155 — Outdoor terrain textures look stretched + blurry vs retail (missing landscape DETAIL-texture overlay)
**Status:** OPEN — **root cause VERIFIED (oracle-first); fix attempt implemented + REVERTED** (rendered the ground black). Ready for a focused fresh implementation pass. **Status:** OPEN — **root cause VERIFIED (oracle-first); fix attempt implemented + REVERTED** (rendered the ground black). Ready for a focused fresh implementation pass.

View file

@ -171,6 +171,7 @@ accepted-divergence entries (#96, #49, #50).
| AP-69 | On a landblock (re)load, acdream re-projects its retained server-object spawns (`GameWindow._lastSpawnByGuid` — our `weenie_object_table` for world objects, carrying position + Setup + appearance) into the render world via `LandblockEntityRehydrator`. The dungeon collapse (#133/#135) and Near→Far demote drop a landblock's RENDER entities for FPS but keep the parsed spawns; ACE will NOT re-broadcast objects whose guid is still in its per-player `KnownObjects` set (never cleared on a normal teleport — ACE relies on the client retaining its table + doing its own visibility cull). Re-projecting from our own table is the retail "client keeps its weenie_object_table and re-renders from it" model. DIVERGENCE: acdream has NO retail 25 s / 384 m visibility cull — the retained spawn table is pruned ONLY by an explicit server `DeleteObject` (0xF747) or a spawn de-dup, never by distance/time. (#138) | `src/AcDream.App/Streaming/LandblockEntityRehydrator.cs` + `GameWindow.RehydrateServerEntitiesForLandblock` + `StreamingController` (`onLandblockLoaded`, Loaded + Promoted) | Re-projection from our own table is retail-faithful AND the only reliable path (ACE won't re-send a known object); fixes the #138 "doors/NPCs/portals gone after a dungeon exit" symptom independent of any server re-broadcast | An object the server silently stopped tracking WITHOUT a `DeleteObject` (e.g. a creature that wandered out of PVS) is re-hydrated at its last-known position on reload, where retail's 25 s cull would have dropped it — a stale ghost until a real `DeleteObject` or the next session. Close it by porting holtburger's 25 s/384 m `ACE_DESTRUCTION_TIMEOUT` self-cull | ACE `ObjectMaint.KnownObjects` never cleared on teleport (`Physics/Common/ObjectMaint.cs`, `WorldObjects/Player_Tracking.cs`); holtburger client 25 s cull `liveness.rs` `ACE_DESTRUCTION_TIMEOUT_SECS`; retail client weenie_object_table re-render | | AP-69 | On a landblock (re)load, acdream re-projects its retained server-object spawns (`GameWindow._lastSpawnByGuid` — our `weenie_object_table` for world objects, carrying position + Setup + appearance) into the render world via `LandblockEntityRehydrator`. The dungeon collapse (#133/#135) and Near→Far demote drop a landblock's RENDER entities for FPS but keep the parsed spawns; ACE will NOT re-broadcast objects whose guid is still in its per-player `KnownObjects` set (never cleared on a normal teleport — ACE relies on the client retaining its table + doing its own visibility cull). Re-projecting from our own table is the retail "client keeps its weenie_object_table and re-renders from it" model. DIVERGENCE: acdream has NO retail 25 s / 384 m visibility cull — the retained spawn table is pruned ONLY by an explicit server `DeleteObject` (0xF747) or a spawn de-dup, never by distance/time. (#138) | `src/AcDream.App/Streaming/LandblockEntityRehydrator.cs` + `GameWindow.RehydrateServerEntitiesForLandblock` + `StreamingController` (`onLandblockLoaded`, Loaded + Promoted) | Re-projection from our own table is retail-faithful AND the only reliable path (ACE won't re-send a known object); fixes the #138 "doors/NPCs/portals gone after a dungeon exit" symptom independent of any server re-broadcast | An object the server silently stopped tracking WITHOUT a `DeleteObject` (e.g. a creature that wandered out of PVS) is re-hydrated at its last-known position on reload, where retail's 25 s cull would have dropped it — a stale ghost until a real `DeleteObject` or the next session. Close it by porting holtburger's 25 s/384 m `ACE_DESTRUCTION_TIMEOUT` self-cull | ACE `ObjectMaint.KnownObjects` never cleared on teleport (`Physics/Common/ObjectMaint.cs`, `WorldObjects/Player_Tracking.cs`); holtburger client 25 s cull `liveness.rs` `ACE_DESTRUCTION_TIMEOUT_SECS`; retail client weenie_object_table re-render |
| AP-70 | `TeleportAnimSequencer.ComputeFadeAlpha` uses a smoothstep curve for all fade states; retail's `gmSmartBoxUI::UseTime` drives fade alpha through a 1024-entry `GetAnimLevel` lookup table whose contents are unrecovered | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`ComputeFadeAlpha`) | `GetAnimLevel` table address and contents not yet extracted via cdb; smoothstep is a perceptually-reasonable S-curve that closely approximates a typical gamma-corrected fade; the visual difference is imperceptible under normal teleport conditions | Fade timing differs subtly from retail — ramp-in or ramp-out may be slightly too fast/slow at the black-fade edges; retire by reading the `GetAnimLevel` table via cdb and replacing smoothstep with a direct 1024-entry lookup (spec §8) | `gmSmartBoxUI::UseTime` 0x004d6e30; `GetAnimLevel` 1024-entry table (address unrecovered — spec §8 cdb trace) | | AP-70 | `TeleportAnimSequencer.ComputeFadeAlpha` uses a smoothstep curve for all fade states; retail's `gmSmartBoxUI::UseTime` drives fade alpha through a 1024-entry `GetAnimLevel` lookup table whose contents are unrecovered | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`ComputeFadeAlpha`) | `GetAnimLevel` table address and contents not yet extracted via cdb; smoothstep is a perceptually-reasonable S-curve that closely approximates a typical gamma-corrected fade; the visual difference is imperceptible under normal teleport conditions | Fade timing differs subtly from retail — ramp-in or ramp-out may be slightly too fast/slow at the black-fade edges; retire by reading the `GetAnimLevel` table via cdb and replacing smoothstep with a direct 1024-entry lookup (spec §8) | `gmSmartBoxUI::UseTime` 0x004d6e30; `GetAnimLevel` 1024-entry table (address unrecovered — spec §8 cdb trace) |
| AP-71 | **`CEnvCell::find_env_collisions` omits the `CObjCell::check_entry_restrictions` gate** — retail's `CEnvCell::find_env_collisions` (pc:309576) calls `CObjCell::check_entry_restrictions` (pc:309576) FIRST and returns `COLLIDED` when an access-locked cell's `restriction_obj` entity rejects the mover (`CanMoveInto` fails AND `CanBypassMoveRestrictions` is false). acdream's `FindEnvCollisions` (`src/AcDream.Core/Physics/TransitionTypes.cs:2088`) goes straight to BSP collision. | `src/AcDream.Core/Physics/TransitionTypes.cs:2088` | **No access-restriction cell data exists in acdream.** `CellPhysics` (`src/AcDream.Core/Physics/PhysicsDataCache.cs:540`) has no `restriction_obj` field; `DatReaderWriter` does not model per-cell access locks; no weenie-object-table exists on the client for the gate's `CanMoveInto` / `CanBypassMoveRestrictions` dispatch. The gap is **inert** in all dev content (ACE starter area has no access-locked env cells). | If access-locked dungeon cells are ever modeled (dat + wire), the player will walk through the restriction barrier without being blocked — wrong PK/housing gating. | `CObjCell::check_entry_restrictions` pc:309576; `CEnvCell::find_env_collisions` pc:309573309597 | | AP-71 | **`CEnvCell::find_env_collisions` omits the `CObjCell::check_entry_restrictions` gate** — retail's `CEnvCell::find_env_collisions` (pc:309576) calls `CObjCell::check_entry_restrictions` (pc:309576) FIRST and returns `COLLIDED` when an access-locked cell's `restriction_obj` entity rejects the mover (`CanMoveInto` fails AND `CanBypassMoveRestrictions` is false). acdream's `FindEnvCollisions` (`src/AcDream.Core/Physics/TransitionTypes.cs:2088`) goes straight to BSP collision. | `src/AcDream.Core/Physics/TransitionTypes.cs:2088` | **No access-restriction cell data exists in acdream.** `CellPhysics` (`src/AcDream.Core/Physics/PhysicsDataCache.cs:540`) has no `restriction_obj` field; `DatReaderWriter` does not model per-cell access locks; no weenie-object-table exists on the client for the gate's `CanMoveInto` / `CanBypassMoveRestrictions` dispatch. The gap is **inert** in all dev content (ACE starter area has no access-locked env cells). | If access-locked dungeon cells are ever modeled (dat + wire), the player will walk through the restriction barrier without being blocked — wrong PK/housing gating. | `CObjCell::check_entry_restrictions` pc:309576; `CEnvCell::find_env_collisions` pc:309573309597 |
| AP-72 | **Per-element dat-font resolver (Fix C) wired into the STUDIO path only**`LayoutImporter.Import` + `DatWidgetFactory` support a `Func<uint,UiDatFont?>` resolver that gives each text element its own dat font; `StudioWindow` supplies it. `GameWindow`'s four `Import`/`Build` call sites pass `null`, so the live game still uses a single global font. **Retire with #157.** | `src/AcDream.App/Rendering/RenderBootstrap.cs` (`RenderStack.ResolveDatFont` — pattern to mirror into GameWindow) + `src/AcDream.App/Studio/StudioWindow.cs` (resolver wired) + `src/AcDream.App/Rendering/GameWindow.cs` (~4 import sites pass null) | Studio is the proving-ground path; GameWindow threading is Issue #157 (small, safe, orthogonal to M5 gameplay). The live panels still render with the global dat font (same as before Fix C), so behavior is unchanged in the shipped game. | Text elements whose dat `FontDid` differs from the global font render with the wrong size/face in the live game — cosmetic; the studio shows the correct dat font. | `DatWidgetFactory.BuildText`; `LayoutImporter.Import(fontResolver)`; commit `a0d3395` (Fix C) |
--- ---

View file

@ -433,7 +433,10 @@ behavior. Estimated 1726 days focused work, 35 weeks calendar.
- **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.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.
- **✓ SHIPPED — D.5.1 — Toolbar (action bar).** Shipped 2026-06-16/17 (`30b28c2``0e7a083`, branch claude/hopeful-maxwell-214a12). First data-driven *game* panel: `gmToolbarUI` (`LayoutDesc 0x21000016`) — 18 shortcut slots from the persisted `PlayerDescription` SHORTCUT block, real **composited** item icons (opaque type-default underlay via the `EnumIDMap 0x10000004` resolve), **occupancy-gated slot numbers 19** (occupied = dark-box peace/war `0x10000042/43`; empty = background `0x1000005e` from cell composite `0x10000341`), **click-to-use** (`ItemHolder::UseObject``0x0036`), **peace/war stance** indicator live-wired to `CombatState`, **movable**, and a **chrome frame** (UiNineSlicePanel drawn over content via the new `UiElement.OnDrawAfterChildren` hook). New shared widgets `UiItemSlot` (`UIElement_UIItem` 0x10000032, procedural leaf) + `UiItemList` (`UIElement_ItemList` 0x10000031, factory branch) + `IconComposer` (CPU layered composite). `CreateObject.TryParse` extended to the full ACE-order weenie-header tail to capture `IconId`/`IconOverlay`/`IconUnderlay``ItemRepository.EnrichItem` → re-render. Spec/plan `docs/superpowers/{specs,plans}/2026-06-16-d2b-toolbar-phase1*.md`; research drop `docs/research/2026-06-16-*deep-dive.md` + synthesis. Divergence IA-16/IA-17 added. **User-confirmed** (numbers, icons, frame). Per-task spec+code-review throughout. - **✓ SHIPPED — D.5.1 — Toolbar (action bar).** Shipped 2026-06-16/17 (`30b28c2``0e7a083`, branch claude/hopeful-maxwell-214a12). First data-driven *game* panel: `gmToolbarUI` (`LayoutDesc 0x21000016`) — 18 shortcut slots from the persisted `PlayerDescription` SHORTCUT block, real **composited** item icons (opaque type-default underlay via the `EnumIDMap 0x10000004` resolve), **occupancy-gated slot numbers 19** (occupied = dark-box peace/war `0x10000042/43`; empty = background `0x1000005e` from cell composite `0x10000341`), **click-to-use** (`ItemHolder::UseObject``0x0036`), **peace/war stance** indicator live-wired to `CombatState`, **movable**, and a **chrome frame** (UiNineSlicePanel drawn over content via the new `UiElement.OnDrawAfterChildren` hook). New shared widgets `UiItemSlot` (`UIElement_UIItem` 0x10000032, procedural leaf) + `UiItemList` (`UIElement_ItemList` 0x10000031, factory branch) + `IconComposer` (CPU layered composite). `CreateObject.TryParse` extended to the full ACE-order weenie-header tail to capture `IconId`/`IconOverlay`/`IconUnderlay``ItemRepository.EnrichItem` → re-render. Spec/plan `docs/superpowers/{specs,plans}/2026-06-16-d2b-toolbar-phase1*.md`; research drop `docs/research/2026-06-16-*deep-dive.md` + synthesis. Divergence IA-16/IA-17 added. **User-confirmed** (numbers, icons, frame). Per-task spec+code-review throughout.
- **✓ SHIPPED — D.5.2 — Stateful item-icon system.** Shipped 2026-06-17/18 (`419c3ac`..`fb288ad`, branch claude/hopeful-maxwell-214a12; **visually verified on a live Coldeve server**). Faithful retail icon composite (`IconData::RenderIcons` @0x0058d180): (1) `UiEffects` bitfield captured from the `CreateObject` weenie header (was discarded) → `ItemInstance.Effects`; (2) `IconComposer.GetIcon` rewritten as a 2-stage composite — Stage 1 = drag icon (base + custom overlay) + the effect treatment, Stage 2 = type-default underlay + custom underlay + drag. The effect treatment ports the **surface overload** of `SurfaceWindow::ReplaceColor` (`0x004415b0`): the textured effect tile (`EnumIDMap 0x10000005` by `LowestSetBit(effects)+1`, fallback `0x21` solid-black) is copied **per-pixel** into the icon's pure-white pixels — magical items take the tile's GRADIENT hue, mundane items go black; (3) `PublicUpdatePropertyInt (0x02CE)` parser + `WorldSession.ObjectIntPropertyUpdated` event + `GameWindow` subscription → `ItemRepository.UpdateIntProperty` → icon re-composites live. **Appraise (`0x00C9`) carries NO icon data** (ACE proof: `Icon`/`IconOverlay`/`IconUnderlay`/`UiEffects` all lack `[AssessmentProperty]`) — dropped as a no-op. **Two visual-verification fixes landed after the subagent build:** the `effects==0` recolor MUST run (mundane white edges → black, `40c97a5`) and the tint is a per-pixel GRADIENT not a flat color (the surface overload, `fb288ad`) — both confirmed via clean Ghidra + named decomp. Divergence: IA-16 retired; IA-18 (per-pixel surface-copy anti-regression) + AP-45 (0x02CE sequence) added; **AP-43/AP-44 retired by the visual fixes**. Spec/plan/research: `docs/superpowers/{specs,plans}/2026-06-17-d2b-stateful-icon*.md`, `docs/research/2026-06-17-stateful-icon-RESOLVED.md`. - **✓ SHIPPED — D.5.2 — Stateful item-icon system.** Shipped 2026-06-17/18 (`419c3ac`..`fb288ad`, branch claude/hopeful-maxwell-214a12; **visually verified on a live Coldeve server**). Faithful retail icon composite (`IconData::RenderIcons` @0x0058d180): (1) `UiEffects` bitfield captured from the `CreateObject` weenie header (was discarded) → `ItemInstance.Effects`; (2) `IconComposer.GetIcon` rewritten as a 2-stage composite — Stage 1 = drag icon (base + custom overlay) + the effect treatment, Stage 2 = type-default underlay + custom underlay + drag. The effect treatment ports the **surface overload** of `SurfaceWindow::ReplaceColor` (`0x004415b0`): the textured effect tile (`EnumIDMap 0x10000005` by `LowestSetBit(effects)+1`, fallback `0x21` solid-black) is copied **per-pixel** into the icon's pure-white pixels — magical items take the tile's GRADIENT hue, mundane items go black; (3) `PublicUpdatePropertyInt (0x02CE)` parser + `WorldSession.ObjectIntPropertyUpdated` event + `GameWindow` subscription → `ItemRepository.UpdateIntProperty` → icon re-composites live. **Appraise (`0x00C9`) carries NO icon data** (ACE proof: `Icon`/`IconOverlay`/`IconUnderlay`/`UiEffects` all lack `[AssessmentProperty]`) — dropped as a no-op. **Two visual-verification fixes landed after the subagent build:** the `effects==0` recolor MUST run (mundane white edges → black, `40c97a5`) and the tint is a per-pixel GRADIENT not a flat color (the surface overload, `fb288ad`) — both confirmed via clean Ghidra + named decomp. Divergence: IA-16 retired; IA-18 (per-pixel surface-copy anti-regression) + AP-45 (0x02CE sequence) added; **AP-43/AP-44 retired by the visual fixes**. Spec/plan/research: `docs/superpowers/{specs,plans}/2026-06-17-d2b-stateful-icon*.md`, `docs/research/2026-06-17-stateful-icon-RESOLVED.md`.
- **D.5 remaining — sub-phase ledger.** D.5.1 (toolbar + the `UiItemSlot`/`UiItemList`/`IconComposer` spine) ✅, D.5.2 (stateful icons) ✅, D.5.4 (client object/item data model) ✅, D.2b-B window manager (`abbd97b`) ✅, D.2b-B B-Grid (inventory sub-window mount + `UiItemList` grid) ✅, D.2b-B B-Controller (inventory population + burden meter + captions, `03fbf44`; **visually confirmed 2026-06-21** — two render bugs fixed at the gate `417b137`: backdrop wash-out [a #145 continuation — mounted sub-window slots must keep their own frame ZLevel, not inherit the base root's 1000] + captions [drive the host UiText directly]) ✅ are shipped. Build order from here: **(b) B-Wire (parse `EncumbranceVal`/PropertyInt 5 to retire AP-48) → (c) Sub-phase C (paperdoll `UiItemSlot` + `UiViewport` 3D doll) → (d) finish the bar: D.5.3 remaining (mana meter, stack entry/slider) + spell shortcuts.** Each ☐ below gets its own brainstorm → spec → plan. - **D.5 remaining — sub-phase ledger.** D.5.1 (toolbar + the `UiItemSlot`/`UiItemList`/`IconComposer` spine) ✅, D.5.2 (stateful icons) ✅, D.5.4 (client object/item data model) ✅, D.2b-B window manager (`abbd97b`) ✅, D.2b-B B-Grid (inventory sub-window mount + `UiItemList` grid) ✅, D.2b-B B-Controller (inventory population + burden meter + captions, `03fbf44`; **visually confirmed 2026-06-21** — two render bugs fixed at the gate `417b137`: backdrop wash-out [a #145 continuation — mounted sub-window slots must keep their own frame ZLevel, not inherit the base root's 1000] + captions [drive the host UiText directly]) ✅, D.2b-B B-Wire (`EncumbranceVal`/PropertyInt 5 wire delivery, AP-48/49 retired) ✅, inventory window finish Stage 1 (scroll/frame/vertical-resize/102-slot grid) ✅, empty-slot art ✅, container-switching + open/selected indicators + main-pack icon + per-bag capacity bar ✅, B-Drag (inventory drag-drop / item moving) ✅, **Sub-phase C (paperdoll): Slice 1 equip slots ✅ + Slice 2 3-D doll `UiViewport` (Type 0xD via the `IUiViewportRenderer` RTT seam) + Slots toggle ✅ (visually confirmed 2026-06-25, `8fa66c2` — pose/camera/heading all retail-verbatim)** ✅, **UI Studio** (`AcDream.App ui-studio`) ✅, **importer dat-fidelity (Fix A/B/C)** ✅, and **Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`) ✅ are shipped. Build order from here: **(d) finish the bar — D.5.3 remaining (selected-object meter/mana, stack entry/slider) + spell shortcuts + the selection→selected-object-bar wiring (AP-58); ISSUES #146/#147/#148.** Each ☐ below gets its own brainstorm → spec → plan.
- **✓ SHIPPED — UI Studio** (2026-06-26, branch `claude/hopeful-maxwell-214a12`, ~33693c6→HEAD). Standalone `AcDream.App ui-studio <datdir> [--layout 0xNNNN | --dump <slug>] [--screenshot <png>]` Silk tool that previews any panel through the **production renderer** (`RenderBootstrap.cs` + GameWindow untouched). Sources: 26-window retail dump (`docs/research/2026-06-25-retail-ui-layout-dump.json`) via `--dump`, or live dat import + fixtures via `--layout`. Interactive canvas (click-routing to `UiHost`, Interact/Inspect ImGui toggle), headless `--screenshot`. Architecture: `src/AcDream.App/Studio/` (`StudioWindow`, `PanelFbo`, `FixtureProvider`, `LayoutSource`) + `src/AcDream.App/Rendering/RenderBootstrap.cs`. ISSUES #156 (inventory all-black in studio) and #157 (GameWindow font resolver) filed.
- **✓ SHIPPED — Importer dat-fidelity** (Fix A/B/C, 2026-06-26, same branch). **Boundary established: look = importer (font/justification/color from dat); state/behavior = runtime controller logic.** Fix A: justification property `0x14`/`0x15``UiText` Centered/RightAligned/VerticalJustify. Fix B: FontColor property `0x1B``UiText.DefaultColor`; character colors proved RUNTIME (no importer fix). Fix C: FontDid → per-element dat font via a resolver in `DatWidgetFactory`**STUDIO path only**; GameWindow passes `null` (Issue #157). Fix 4 (UIState-group activation): INVESTIGATED — no importer fix exists; the dat has no Visible encoding; runtime `gm*UI` is correct.
- **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158.
- **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository``ClientObjectTable` / `ItemInstance``ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green. - **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository``ClientObjectTable` / `ItemInstance``ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green.
- **☐ D.5.3 — Toolbar selected-object display (issue #140) + spell shortcuts.** Wire the B.4 `WorldPicker`/selection state → the two hidden meters (`0x100001A1` health / `0x100001A2` mana) + the stack slider (`0x100001A4`) + the object-name line, so the bar shows the player's currently-selected world object. Plus **spell shortcuts** — pinned *spells* (vs items) don't render their glyphs yet (`ToolbarController.Populate` skips `ObjectGuid==0`). Together these finish "the bar." (Click-to-use + the peace/war stance indicator landed in D.5.1.) - **☐ D.5.3 — Toolbar selected-object display (issue #140) + spell shortcuts.** Wire the B.4 `WorldPicker`/selection state → the two hidden meters (`0x100001A1` health / `0x100001A2` mana) + the stack slider (`0x100001A4`) + the object-name line, so the bar shows the player's currently-selected world object. Plus **spell shortcuts** — pinned *spells* (vs items) don't render their glyphs yet (`ToolbarController.Populate` skips `ObjectGuid==0`). Together these finish "the bar." (Click-to-use + the peace/war stance indicator landed in D.5.1.)
- **☐ D.5.5+ — Core panels.** Inventory (`gmInventoryUI`/`gmBackpackUI`), equipment/paperdoll (`gmPaperDollUI`/`gm3DItemsUI` + the `UiViewport` 3D doll), vendor, trade, spellbook. Research drop done (`docs/research/2026-06-16-*`). Depends on **D.5.4** (data model) + the item-slot/list/icon spine (D.5.1/D.5.2) + the **window manager** (Plan 2: open/close/z-order/persist + faithful grip/dragbar drag-resize) + the drag-drop spine wired (`UiRoot` has the chain; the per-cell accept/drop hooks are still stubs in `UiField`). Also deferred from D.5.1: drag/reorder + the `AddShortcut`/`RemoveShortcut` mutate wire. - **☐ D.5.5+ — Core panels.** Inventory (`gmInventoryUI`/`gmBackpackUI`), equipment/paperdoll (`gmPaperDollUI`/`gm3DItemsUI` + the `UiViewport` 3D doll), vendor, trade, spellbook. Research drop done (`docs/research/2026-06-16-*`). Depends on **D.5.4** (data model) + the item-slot/list/icon spine (D.5.1/D.5.2) + the **window manager** (Plan 2: open/close/z-order/persist + faithful grip/dragbar drag-resize) + the drag-drop spine wired (`UiRoot` has the chain; the per-cell accept/drop hooks are still stubs in `UiField`). Also deferred from D.5.1: drag/reorder + the `AddShortcut`/`RemoveShortcut` mutate wire.

View file

@ -454,7 +454,7 @@ Open the inventory panel — retail-skinned with the right font, icons, and
local lighting). local lighting).
- **C.3** — Palette range tuning (skin/hair/eye colors match retail). - **C.3** — Palette range tuning (skin/hair/eye colors match retail).
- **C.4** — Double-sided translucent polys. - **C.4** — Double-sided translucent polys.
- **D.2b** — Custom retail-look UI backend. - **D.2b** — Custom retail-look UI backend. **In progress:** vitals + chat + toolbar + inventory (window manager, B-Grid, B-Controller, B-Wire, drag-drop, container-switching) + paperdoll (equip slots, 3-D doll UiViewport, Slots toggle) + **Character window** (Attributes tab, visually confirmed 2026-06-26) + **UI Studio** (standalone panel previewer + 26-window retail dump + interactive click-routing + headless screenshot) + **importer dat-fidelity** (Fix A/B/C: justification, FontColor, per-element FontDid resolver [studio-only; #157 tracks live-game path]) are shipped. Next: D.5.3 selected-object bar + spell shortcuts; #158 Character window polish deferred.
- **D.3D.7** — AcFont + dat sprites + core panels reskinned + HUD orbs + - **D.3D.7** — AcFont + dat sprites + core panels reskinned + HUD orbs +
cursor manager. cursor manager.
- **L.1f** — NPC/monster + item-use animation coverage. - **L.1f** — NPC/monster + item-use animation coverage.

View file

@ -0,0 +1,82 @@
# Character window — faithful element spec (LayoutDesc 0x2100002E)
Decomp-grounded blueprint for the Character window, produced to REDO the guessed pilot.
Sources: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (PDB-named decomp),
the user UI dump `docs/research/2026-06-25-retail-ui-layout-dump.json`, and acdream's own
importer-resolved tree (dumped live in the studio). **No guessing** — every row cites a
decomp address or a confirmed `FindElement` result.
## What 0x2100002E actually is
A **tabbed Attributes / Skills / Titles window** (300×600, root panel `0x10000227`, Type 8).
It is NOT a text report. Three tab-content slots each mount a sub-layout via
`BaseElement`+`BaseLayoutId` inheritance (acdream's `ShouldMountBaseChildren` path):
| Slot | BaseElement | BaseLayoutId | gm*UI | Register |
|---|---|---|---|---|
| `0x1000022B` (Attributes) | `0x10000225` | `0x2100002C` | gmAttributeUI (root type 0x1000002A) | 0x0049db50 |
| `0x1000022C` (Skills) | `0x1000022E` | `0x2100002D` | gmSkillUI | 0x0049adb0 |
| `0x10000539` (Titles) | `0x1000052D` | `0x2100005E` | gmCharacterTitleUI | 0x0049aba0 |
Both Attributes + Skills tabs share a header strip filled by the base class
**`gmStatManagementUI`** (`UpdateCharacterInfo` 0x004f0770, `UpdateExperience` 0x004f0a70,
`UpdatePKStatus` 0x004f00a0). The tab sub-layout `0x2100002C` nests `0x10000226` which
chains again into `0x21000045` — the real header/list content is **two inheritance levels deep**.
The **text report** ("birth/age/deaths, innate attributes, augmentations, load") the earlier
pilot guessed is a SEPARATE sub-panel: LayoutDesc `0x2100001A`, `gmCharacterInfoUI`
(register 0x004b8c90), whose `m_pMainText` (`0x1000011d`) is created at RUNTIME. That window
is handled by `CharacterController` (create-the-element path) — different layout, not 0x2100002E.
## Importer reality (dumped live, 2026-06-25)
- `dump-vitals-layout` prints the RAW dat (no inheritance) → the content elements look "missing".
That is an artifact. The **importer** mounts them. Confirmed by `FindElement` on the studio's
imported 0x2100002E:
- `0x10000231` name → `UiText`
- `0x10000232` heritage → `UiText`
- `0x10000233` PK status → `UiText`
- `0x1000023B` level → `UiText`
- `0x10000235` total XP → `UiText`
- `0x10000236` XP-to-level meter → `UiMeter`
- `0x1000023D` list box → `UiDatElement` (generic container) ✓
- `0x10000238` XP-to-level text → **NULL** (nested variant; not mounted — minor)
- `UiElement.EventId` is NOT the dat element id (root shows 0x10000001, not 0x10000227). The dat
id lives in `ImportedLayout._byId` (set from `info.Id`, LayoutImporter:107). Bind via
`FindElement(id)`, never by reading `widget.EventId`.
## Header element map (gmStatManagementUI)
| Id | Field | Content source | Rect (in sub-layout) |
|---|---|---|---|
| `0x10000231` | m_pNameText | player full name | 0,25,230,20 |
| `0x10000232` | m_pHeritageText | gender+heritage display (InqGenderHeritageDisplay) | 0,45,230,15 |
| `0x10000233` | m_pPKStatusText | PK status string (StringTable) | 0,60,230,15 |
| `0x1000023B` | m_pLevelText | PropertyInt 0x19 (level) | 235,60,65,50 |
| `0x10000234` | (label) | "Total Experience:" | 0,95,130,18 |
| `0x10000235` | m_pTotalXPText | PropertyInt64 1 | 130,95,100,18 |
| `0x10000236` | m_pXPToLevelMeter | (curbase)/(capbase), img 0x060011A6 | 0,113,230,17 |
| `0x10000237` | (label) | "To next level:" | 0,113,130,17 |
| `0x10000238` | m_pXPToLevelText | XP remaining | 130,113,100,17 |
| `0x1000023D` | m_pListBox | attribute/skill rows (runtime) | 0,137,300,398 |
Attribute list rows: gmAttributeUI builds `AttributeInfoRegion` rows (name + base/buffed value +
raise button). Canonical AC display order: Strength, Endurance, Coordination, Quickness, Focus, Self.
## StringTable
`DatReaderWriter` CAN read `StringTable` (CLI uses `GetAllIdsOfType<StringTable>`). The decomp's
`StringInfo::SetStringIDandTableEnum(…, stringId, 0x10000001)` references table enum 0x10000001 →
the game string table dat (0x31000001 family); StringId=0 is a placeholder overwritten by a
preceding `compute_str_hash("ID_…")`. To reproduce exact wording: read the StringTable + a
StringInfo helper (hash → id → lookup, `AddVariable_*` substitution). NOT yet ported — current
controller uses canonical AC labels.
## Status (this session)
- **DONE (commit 0e644b5):** `CharacterStatController` binds name/heritage/PK/level/total-XP +
the XP meter fill + the six attributes into the real elements. Studio renders the real panel.
- **Follow-ups (known sources):** (1) tab-button active/inactive STATE so the 3 tabs draw their
sprites (0x06005D92-97); (2) StringTable wording; (3) full AttributeInfoRegion row template
(column-aligned values + per-attribute raise buttons); (4) wire `0x10000234`/`0x10000237`
labels + the right-side level area placement.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,45 @@
# UI Studio — dump-panel render sweep (2026-06-25)
All 26 windows in `docs/research/2026-06-25-retail-ui-layout-dump.json` were rendered through
the studio's dump LayoutSource (`AcDream.App ui-studio --dump <slug> --screenshot <png>`) and
read back as PNGs. Goal: verify the generic dump source renders every retail window, identify
real render bugs vs. expected gaps.
## Verdict
The generic dump source renders **all 26 panels with no crash**. Render fidelity tracks how
much of a panel is **static chrome** (present in the dump, so it draws) vs **runtime content**
(slots, stats, appraise text — NOT in the dump, so it can't draw). This is the dump's nature,
not a studio bug.
## Per-panel status (read from `studio-shots/`)
| Status | Panels |
|--------|--------|
| **Full / great** (frame + static content) | `vitals` (3 bars + heart/glyphs), `side_by_side_vitals`, `toolbar` (all spell/combat icons + backpack), `map`, `main_panel`, `radar` (disc), `character` (frame+tabs+scrollbar), `combat`, `powerbar`, `negative_effects`, `positive_effects`, `options`, `quests`, `social`, `spellbook`, `main_chat`, `floating_chat_1..4`, `environment_panel` |
| **Partial** (chrome only; runtime content absent) | `inventory` — stone backdrop + top frame render, but the nested sub-window chrome (gm3DItemsUI / gmBackpackUI / paperdoll) is sparse and the slots are runtime-filled. **The one panel with a possible real gap — investigate the nested-layout / sub-window node handling.** |
| **Blank** (runtime-only window, no static sprites in dump) | `examine` (appraise window — populated only on examine), and likely the small `indicators` / `smartbox` / `vitae` |
## Tooling shipped this sweep
- `--screenshot <path>` headless mode (`f778b10`): render the loaded panel (dump or dat) to a
PNG off-screen via `PanelFbo` + `glReadPixels` + ImageSharp, then exit. Hidden window
(`IsVisible=false`). The autonomous self-verification primitive.
- Contact-sheet montage via PowerShell `System.Drawing` (one image, 26 thumbs) — the cheap
per-sweep overview (`studio-shots/_contact.png`, gitignored).
## Rect basis (confirmed in `DumpLayout`)
Dump `rect` fields are ABSOLUTE retail screen coords; `DumpLayout` converts to parent-relative
(`child.Left = child.Rect.X - parent.Rect.X`) and places the root at (0,0). Verified correct by
the toolbar (icons land precisely) + vitals.
## Next (loop)
1. **Inventory nested chrome** — determine whether the sub-window content is missing because
(a) those nodes are `Group`s with no static sprite (expected), or (b) `DumpLayout` isn't
expanding a `base_layout_id` sub-layout reference (a fixable bug). Dump the inventory panel's
node `widget_kind`/`rect`/`image_id` list and compare to what rendered.
2. Spot-confirm the remaining blanks (`indicators`/`smartbox`/`vitae`) are runtime-only.
3. The dump source is functionally complete as a static previewer; richer (live-data) previews
are the dat-import + fixture path (the parked Task 4 bags/equip), not the dump path.

View file

@ -0,0 +1,51 @@
# Character window (Attributes tab) — retail reference + polish targets
Transcribed from two user-provided retail screenshots (2026-06-26): the **not-selected** state and the
**Strength-selected** state. The PNGs live in the user's chat; this doc is the durable target spec for
the remaining polish on acdream's `CharacterStatController` (LayoutDesc 0x2100002E). "✗" = acdream gap.
## State 1 — nothing selected
- **Tabs:** Attributes (active, gold label), Skills, Titles.
- **Name:** "Horan" — **WHITE** (✗ acdream renders the name gold).
- **Heritage line:** "Female Aluvian Adventurer" — white.
- **PK line:** "Non-Player Killer" — white.
- **Level area (right):** small caption "Character Level" (two lines: "Character" / "Level"), then the
number **"240" as LARGE GOLD SPRITE DIGITS** — a number-sprite font, not plain text (✗ acdream draws
"126" as plain gold text, and the caption truncates to "Character Le").
- **"Total Experience (XP):"** caption + value "100,000,017,999" (✗ caption missing in acdream).
- **"XP for next level:"** caption + value "99,738,801" with the red fill bar behind it (✗ caption +
value missing — the value element is consumed by the UiMeter at import).
- **Attribute list — 9 rows**, each = icon + name + right-aligned value:
- Row text is **LARGER** (roughly the icon height ~24px) and rows are **TIGHTER** (less vertical gap)
than acdream's current small-text / wide-spacing.
- Order: Strength 200, Endurance 10, Coordination 10, Quickness 200, Focus 10, Self 10,
Health 5/5, Stamina 10/10, Mana 10/10.
- **Footer:** "Select an Attribute to Improve" (title) / "Skill Credits Available: 96" /
"Unassigned Experience: 87,757,321,741". (acdream now matches — confirm the title says **Attribute**,
not skill.)
## State 2 — Strength selected
- Same header.
- **Selected row (Strength):** highlighted with a **DARKER background + bars above/below** — retail's
selected-row sprite `0x06001397` (Button state 6). (✗ acdream uses a translucent GOLD tint — replace
with the dark-bar sprite.)
- **Footer flips to:**
- Title: **"Strength: 200"** — **WHITE** text (✗ acdream uses the body/gold color).
- "Experience To Raise:" + **"Infinity!"** (Strength is maxed → cost is infinite; ✗ acdream shows a
numeric/"maxed" placeholder — show "Infinity!" when the attribute is at max).
- "Unassigned Experience:" + "87,757,321,741".
- Raise buttons (≜10 + triangle) shown at the selected row's right.
## Polish checklist (acdream)
1. [ ] Name color → white.
2. [ ] Level → gold sprite digits (find the number-sprite font/ids); caption "Character"/"Level" 2-line.
3. [ ] Add "Total Experience (XP):" caption.
4. [ ] Add "XP for next level:" caption + value (un-consume from the meter, or render alongside).
5. [ ] Row text larger (≈icon height) + rows tighter.
6. [ ] Selection highlight → sprite 0x06001397 (dark bars), not gold tint.
7. [ ] Selected footer title → white.
8. [ ] Maxed attribute → "Experience To Raise: Infinity!".
9. [ ] Footer title wording = "Select an Attribute to Improve" (Attribute).

View file

@ -0,0 +1,126 @@
# Handoff — the full multi-window UI mockup stage (2026-06-26)
For the next agent. This session built the **UI Studio**, made the **importer faithful to the dat**,
and brought the **Character window** to a retail look. The next stage is the user's actual goal: turn the
studio into a **full interactive multi-window mockup** — several windows live in one host, draggable +
resizable + clickable — so the whole UI can be tested without the game.
Read this, then `claude-memory/project_ui_studio.md` (the SSOT) and `project_d2b_retail_ui.md`.
---
## 1. Where we are (what's done + works)
### The UI Studio (the dev tool)
`AcDream.App ui-studio <datDir> [--layout 0xNNNN | --dump <slug>] [--screenshot <png>]` — a standalone
Silk.NET/GL window that renders a UI panel **through the production renderer** (`RenderBootstrap.Create`
builds a `RenderStack` = the subset of GameWindow's render stack the UI needs; **GameWindow is untouched**).
- Code: `src/AcDream.App/Studio/` (`StudioWindow`, `FixtureProvider`, `PanelFbo`, `DumpLayout`, `UiDumpModel`,
`StudioInspector`, `LayoutSource`) + `src/AcDream.App/Rendering/RenderBootstrap.cs`.
- **Two panel sources:** `--layout 0xNNNN` (real dat-import via `LayoutImporter`, populated by the PRODUCTION
controllers through `FixtureProvider` + `SampleData`), and `--dump <slug>` (the 26-window retail dump,
`docs/research/2026-06-25-retail-ui-layout-dump.json`, static structure only).
- **Headless** `--screenshot <png>` (PanelFbo → glReadPixels → PNG) — the autonomous self-verify primitive.
Use it constantly.
- **Interactive:** click-routing forwards the ImGui canvas mouse → the panel's `UiHost` (coord map =
`mouse ImGui.GetItemRectMin()`, no extra Y-flip). An **Interact / Inspect** toggle in the toolbar:
Interact = clicks drive the panel; Inspect = clicks select elements in the tree.
### The faithful importer (the "better way" — the load-bearing win)
The treadmill of hand-tuning each panel's look in C# was because the importer DROPPED the dat's per-element
visual properties. The dat carries them in `StateDesc.Properties`: **0x1A FontDID**, **0x14/0x15** H/V
justification, **0x1B FontColor**. We wired them in, **backward-compatibly** (the importer applies them as
DEFAULTS at build time; a controller that still sets them explicitly overrides → existing panels
byte-unchanged):
- **Fix A** `4143042` — justification → `UiText.Centered/RightAligned/VerticalJustify`.
- **Fix B** `6e0be4b` — FontColor → `UiText.DefaultColor` (character colors proved RUNTIME — the dat carries none).
- **Fix C** `a0d3395` — FontDid → per-element font via a resolver (`Func<uint,UiDatFont?>`) threaded through
`LayoutImporter.Import``DatWidgetFactory`. **STUDIO path only**; GameWindow passes null (issue **#157**).
- **Fix 5** `ad4ed51``LayoutImporter.BuildWidget` builds a `UiMeter`'s non-slice (text) children while
still consuming the Type-3 slice containers (vitals unaffected).
- **Fix 4** `1b9dd6c` — investigated, NO fix: the dat has no Visible flag; runtime gm*UI is correct.
**THE BOUNDARY (the key conceptual result):** the importer faithfully carries an element's **LOOK**
(font, justification, color, position) from the dat. **STATE + BEHAVIOR** (which group is visible, tab /
selection activation) is genuinely **runtime gm*UI logic** and belongs in the controller — it is NOT an
importer gap (Fix 4 + the Fix-5 tab-skip proved this). Don't try to push state/visibility into the importer.
### The Character window (the proof panel)
LayoutDesc `0x2100002E`, `src/AcDream.App/UI/Layout/CharacterStatController.cs` — Attributes tab reads as
retail: 3 tabs, header, big-gold level number (its own dat FontDid), captions, 9-row attribute list
(icons + right-aligned values + Health/Stamina/Mana), click-to-select (top/bottom selection bars + footer
State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered
footer. **User accepted it as good-enough 2026-06-26** ("still needs some polish for later" — see issue for
deferred items). Specs: `docs/research/2026-06-25-character-window-faithful-spec.md`,
`2026-06-25-attributes-tab-interactive-spec.md`, `2026-06-26-character-window-retail-reference.md`.
### Other panels with production controllers (the mockup's building blocks)
`VitalsController` (0x2100006C), `ToolbarController` (0x21000016), `InventoryController` + `PaperdollController`
(0x21000023), `ChatWindowController`, `CharacterStatController` (0x2100002E). All bind real importer-mounted
elements via `FindElement(datId)` (note: `UiElement.EventId` ≠ the dat id — the dat id lives in
`ImportedLayout._byId`).
---
## 2. The mockup goal + proposed approach
**Goal:** the studio shows ONE panel today. The mockup shows the **whole UI at once** — multiple windows in
one host, each draggable + resizable + clickable — a real testbed.
**Why it's reachable:** the production `UiHost` (`src/AcDream.App/UI/UiHost.cs`) ALREADY manages multiple
draggable/resizable windows and routes input — it's the game's actual UI layer. The studio just needs to host
the full thing instead of one FBO'd panel. (Per memory, the game has a window manager: F12 toggles inventory;
there's a `UiHost.ToggleWindow` / `WindowNames` API.)
**Proposed path (a new studio "mockup mode"):**
1. Load SEVERAL panels into ONE `UiHost` (not one panel per FBO) — a "desktop." Render the host directly to
the studio GL window (not the inspector FBO), so native input/drag/resize work.
2. Window open/close/arrange (reuse the game's window-manager API).
3. Drag + resize via the production frame chrome (already exists for the retail windows).
4. Keep the inspector available as an optional overlay.
The studio is the THIN host; the production `UiHost` is the engine. Don't reimplement window management —
drive the production one.
---
## 3. Must-fix before / during the mockup
- **#156** — the **inventory window (0x21000023) renders all-black in the studio** (pre-existing fixture gap,
NOT a regression). It's a key window for the mockup; fix the studio's inventory fixture/sub-window setup.
- **#157** — GameWindow doesn't thread the Fix-C font resolver (live game = global font). Turnkey (the issue
has the exact 4 import sites + the `ConcurrentDictionary` + `GetOrAdd` pattern).
- **Character deferred polish** — the user flagged "some polish for later" (level-glyph fidelity, icon
crispness, sample strings). Filed as an issue; the user will enumerate.
## 4. Patterns + lessons (don't relearn these the hard way)
- **Faithful-panel pattern:** bind real importer-mounted elements via `FindElement(datId)`; `EventId` ≠ dat id.
- **Importer dat-fidelity:** the look comes from the dat; when re-deriving fonts/colors/positions in code feels
like a treadmill, AUDIT what the dat element carries vs what the importer drops, then wire it backward-compat.
- **The boundary:** look = importer; state/behavior = runtime controller. Don't fight it.
- **Apparatus over speculation:** for a layout bug, DUMP the actual rendered rects (walk the tree, print
abs rect + visible + text). One position dump cracked the footer-overlap after FIVE speculative passes.
cdb is for runtime *behavior* questions, not "why is our element at the wrong place."
- **Watch subagents for scope creep:** the Fix-C subagent finished its task then committed unrequested AP-58 +
#148 (reverted per the user, kept in reflog `46c5cd2`/`82e13b7`). Constrain dispatches to the assigned task.
- **Shared-infra changes:** regression-screenshot vitals + toolbar (the canaries) every time; chat is the one
panel the studio can't easily preview, so never change global `UiText`/`UiMeter` behavior without verifying it.
## 5. Reference map
- Studio spec/plan: `docs/superpowers/specs/2026-06-25-ui-studio-previewer-design.md`,
`docs/superpowers/plans/2026-06-25-ui-studio-previewer.md`; dump sweep `docs/research/2026-06-25-studio-dump-panel-sweep.md`.
- Character window: the three specs above + the retail reference.
- Importer audit (this session): the dat carries FontDID/justification/FontColor in `StateDesc.Properties`;
`ElementReader`/`LayoutImporter`/`DatWidgetFactory`/`UiText` are the wiring path.
- The retail UI dump: `docs/research/2026-06-25-retail-ui-layout-dump.json` (26 windows, real ids + rects + sprites).
- Deferred studio features (the original plan, Tasks 5-8): live 3-D doll, markup hot-reload, editable props,
camera sliders — secondary to the mockup.
- Memory SSOT: `claude-memory/project_ui_studio.md`, `project_d2b_retail_ui.md`.
## 6. First concrete steps for the next agent
1. Fix **#156** (inventory studio render) — you need the inventory window for any real mockup.
2. Prototype **mockup mode**: render the production `UiHost` (with vitals + toolbar + character + chat opened)
directly to the studio GL window, native input. Confirm drag + resize + click work through the production host.
3. Then iterate window open/close/arrange + the desktop layout.

View file

@ -0,0 +1,671 @@
# D.2b Sub-phase C Slice 2 — Paperdoll Doll + Slots Toggle — 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.
>
> **⚠ Execution split (project rule — CLAUDE.md "Do not integrate via subagent unless the subagent has full context"):** Tasks 1-4 are isolated/testable → safe for fresh subagents. **Tasks 5-7 are GL/animation/GameWindow integration → do INLINE** (the session that has the render-pipeline context), build **static-doll-first then animate**, verify at build + screenshot/visual-gate. Task 8 is wrap-up.
**Goal:** Render the local player as a live 3-D "doll" inside the inventory paperdoll panel and wire the "Slots" button to toggle between doll-view (doll + 12 non-armor slots) and slot-view (9 armor slots).
**Architecture:** Render-to-texture — a `PaperdollViewportRenderer` draws a re-dressed clone of the player (a dedicated `WorldEntity`) through the existing `WbDrawDispatcher` into an off-screen `ManagedGLFramebuffer` with a fixed doll camera + one distant light, inside a `GLStateScope`; the new `UiViewport` widget (dat Type `0xD`) blits that texture as a normal 2-D sprite so it lands in correct painter order. The shipped `PaperdollController` is extended (not rewritten) with the armor/non-armor partition + the toggle.
**Tech Stack:** C# .NET 10, Silk.NET OpenGL, the acdream `AcDream.App.UI` retained-mode toolkit + `AcDream.App.Rendering.Wb` mesh pipeline.
**Spec:** `docs/superpowers/specs/2026-06-23-d2b-paperdoll-slice2-design.md`. Read it first.
**Reference call sites (verified this session — line numbers drift, grep the symbol):**
- World single-entity draw to mirror: `GameWindow.cs:8756` `_wbDrawDispatcher.Draw(camera, _worldState.LandblockEntries, frustum, neverCullLandblockId: playerLb, visibleCellIds: null, animatedEntityIds: animatedIds)`.
- `animatedIds` built from `_animatedEntities.Keys`: `GameWindow.cs:8369-8375`.
- Spawn animation idle setup (`SetCycle`): `GameWindow.cs:3514-3588`. `MotionCommand.Ready = 0x41000003u` (`AcDream.Core.Physics.MotionInterpreter`).
- `WorldEntity` build template (palette/part overrides): `GameWindow.cs:3390-3431`.
- Player data: `_entitiesByServerGuid[_playerServerGuid]` (live entity), `_lastSpawnByGuid[_playerServerGuid]` (spawn). Player appearance update hook: `OnLiveAppearanceUpdated` `GameWindow.cs:3733`.
- Lighting UBO: `AcDream.Core.Lighting.SceneLightingUbo` + `AcDream.App.Rendering.SceneLightingUboBinding.Upload` (binding=1); world build at `GameWindow.cs:8306`.
- FBO: `AcDream.App.Rendering.Wb.ManagedGLFramebuffer` (ctor `(OpenGLGraphicsDevice, ITexture, w, h, hasDepthStencil)`). GL save/restore: `AcDream.App.Rendering.Wb.GLStateScope` (RAII).
- `WbDrawDispatcher.Draw` signature: `WbDrawDispatcher.cs:881`. `ICamera`: `ICamera.cs:5` (`View`, `Projection`, `Aspect`).
- `EntitySpawnAdapter.OnCreate(WorldEntity)`: `EntitySpawnAdapter.cs:100` (requires `ServerGuid != 0`).
- `UiButton.OnClick` (Action): `UiButton.cs:39`; wiring pattern `ChatWindowController.cs:283-289,322`.
- Window register: `_uiHost.RegisterWindow(WindowNames.Inventory, inventoryFrame)` `GameWindow.cs:2214`; `WindowNames.Inventory = "inventory"`.
- `DatWidgetFactory` Type switch: `DatWidgetFactory.cs:~63`.
- `PaperdollController.Bind` call site in GameWindow: grep `PaperdollController.Bind`.
- Camera convention to mirror: `src/AcDream.App/Rendering/ChaseCamera.cs` (`View`/`Projection` construction).
**Decomp anchors:** spec §2. The 9 armor element-ids (the toggle set): `0x100005ab, 0x100005ac, 0x100005ad, 0x100005ae, 0x100005af, 0x100005b0, 0x100005b1, 0x100005b2, 0x100005b3`. Camera eye `(0.12, 2.4, 0.88)` → origin. Light `DISTANT_LIGHT` dir `(0.3, 1.9, 0.65)` intensity `2.0`. Heading `191.367905°`.
---
## Task 1: Armor/non-armor partition + Slots toggle (`PaperdollController`)
**Files:**
- Modify: `src/AcDream.App/UI/Layout/PaperdollController.cs`
- Test: `tests/AcDream.App.Tests/UI/Layout/PaperdollToggleTests.cs` (create)
This task is pure logic. The doll viewport is injected as a plain `UiElement?` (the concrete `UiViewport` arrives in Task 3/6); the toggle just flips `Visible`.
- [ ] **Step 1: Write the failing test**
```csharp
// tests/AcDream.App.Tests/UI/Layout/PaperdollToggleTests.cs
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using Xunit;
public class PaperdollToggleTests
{
// The 9 armor element-ids that gmPaperDollUI::ListenToElementMessage flips
// (decomp 175674-175706). Doll-view hides these; slot-view shows them.
private static readonly uint[] ArmorIds =
{
0x100005abu, 0x100005acu, 0x100005adu, 0x100005aeu, 0x100005afu,
0x100005b0u, 0x100005b1u, 0x100005b2u, 0x100005b3u,
};
[Fact]
public void ArmorSlotIds_match_the_decomp_nine()
{
Assert.Equal(ArmorIds, PaperdollController.ArmorSlotElementIds);
}
[Fact]
public void DollView_default_shows_doll_hides_armor()
{
var v = new PaperdollViewState();
// default = doll-view (checkbox attr 0xe = 0)
Assert.False(v.SlotView);
Assert.True(v.DollVisible);
Assert.False(v.ArmorSlotsVisible);
}
[Fact]
public void Toggle_to_slotview_hides_doll_shows_armor_and_back()
{
var v = new PaperdollViewState();
v.Toggle();
Assert.True(v.SlotView);
Assert.False(v.DollVisible);
Assert.True(v.ArmorSlotsVisible);
v.Toggle();
Assert.False(v.SlotView);
Assert.True(v.DollVisible);
Assert.False(v.ArmorSlotsVisible);
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter PaperdollToggleTests`
Expected: FAIL — `PaperdollController.ArmorSlotElementIds` and `PaperdollViewState` don't exist.
- [ ] **Step 3: Add the partition + a testable view-state struct to `PaperdollController.cs`**
Add the armor-id set as a public static (the test pins it). Add a small `PaperdollViewState` class that holds the boolean state + `Toggle()` (testable without UI). The controller will own a `PaperdollViewState` and project it onto the real widgets in `ApplyView`.
```csharp
// In PaperdollController.cs, inside the AcDream.App.UI.Layout namespace.
/// <summary>The 9 equip slots the Slots button toggles — the body-covering set that
/// gmPaperDollUI::PostInit starts hidden and ListenToElementMessage (idMessage==1,
/// 0x100005be) flips (decomp 175412-175508 / 175674-175706). NOTE: this is the exact
/// element-id set, NOT an EquipMask heuristic — it includes HeadWear/HandWear/FootWear
/// and excludes the shirt/pants clothing slots.</summary>
public static readonly uint[] ArmorSlotElementIds =
{
0x100005abu, 0x100005acu, 0x100005adu, 0x100005aeu, 0x100005afu,
0x100005b0u, 0x100005b1u, 0x100005b2u, 0x100005b3u,
};
/// <summary>Pure boolean view-state for the doll/slot toggle (testable without widgets).
/// Default = doll-view, mirroring retail SetAttribute_Bool(m_SlotCheckbox, 0xe, 0).</summary>
public sealed class PaperdollViewState
{
public bool SlotView { get; private set; } // false = doll-view (default)
public bool DollVisible => !SlotView; // doll shown only in doll-view
public bool ArmorSlotsVisible => SlotView; // 9 armor slots shown only in slot-view
public void Toggle() => SlotView = !SlotView;
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter PaperdollToggleTests`
Expected: PASS.
- [ ] **Step 5: Wire the state onto the real widgets (find armor slots + button + viewport, apply on click)**
In `PaperdollController`, after the existing slot-binding loop in the constructor: collect the 9 armor-slot `UiItemList`s by element-id; find the Slots button (`0x100005BE`) and the doll viewport element (`0x100001D5`); store a `PaperdollViewState`; on button click, `Toggle()` then `ApplyView()`. `ApplyView()` sets `viewport.Visible = state.DollVisible` and each armor slot `.Visible = state.ArmorSlotsVisible`. Apply once at the end of construction so the initial state matches default (doll-view).
```csharp
// Fields:
private readonly PaperdollViewState _viewState = new();
private readonly List<UiItemList> _armorSlots = new();
private AcDream.App.UI.UiElement? _dollViewport; // set in ctor; the UiViewport (Task 3/6)
// In the constructor, after the SlotMap binding loop:
foreach (var id in ArmorSlotElementIds)
if (layout.FindElement(id) is UiItemList armor) _armorSlots.Add(armor);
_dollViewport = layout.FindElement(0x100001D5u); // the UiViewport widget (may be null pre-Task 6)
if (layout.FindElement(0x100005BEu) is AcDream.App.UI.UiButton slotsBtn)
slotsBtn.OnClick = () => { _viewState.Toggle(); ApplyView(); };
ApplyView(); // initial: doll-view
// Method:
private void ApplyView()
{
if (_dollViewport is not null) _dollViewport.Visible = _viewState.DollVisible;
foreach (var a in _armorSlots) a.Visible = _viewState.ArmorSlotsVisible;
}
```
- [ ] **Step 6: Build + commit**
```bash
dotnet build src/AcDream.App/AcDream.App.csproj
git add src/AcDream.App/UI/Layout/PaperdollController.cs tests/AcDream.App.Tests/UI/Layout/PaperdollToggleTests.cs
git commit -m "feat(D.2b): Slice 2 — paperdoll armor/non-armor partition + Slots toggle state"
```
---
## Task 2: `DollCamera` — the fixed `ICamera`
**Files:**
- Create: `src/AcDream.App/Rendering/DollCamera.cs`
- Test: `tests/AcDream.App.Tests/Rendering/DollCameraTests.cs` (create)
Mirror `ChaseCamera`'s `View`/`Projection` construction convention (read `src/AcDream.App/Rendering/ChaseCamera.cs` first) so winding/culling match the world. Fixed eye/target from the decomp; up = AC `+Z`.
- [ ] **Step 1: Write the failing test** (convention-independent: recover the eye from the inverse view; check aspect)
```csharp
// tests/AcDream.App.Tests/Rendering/DollCameraTests.cs
using System.Numerics;
using AcDream.App.Rendering;
using Xunit;
public class DollCameraTests
{
[Fact]
public void Eye_position_is_the_retail_camera_offset()
{
var cam = new DollCamera { Aspect = 100f / 214f };
Assert.True(Matrix4x4.Invert(cam.View, out var inv));
var eye = inv.Translation;
Assert.Equal(0.12f, eye.X, 3);
Assert.Equal(-2.4f, eye.Y, 3);
Assert.Equal(0.88f, eye.Z, 3);
}
[Fact]
public void Projection_is_finite_and_uses_aspect()
{
var cam = new DollCamera { Aspect = 1.5f };
// A perspective projection has a non-zero [3][2] (w = -z) term and finite entries.
Assert.True(float.IsFinite(cam.Projection.M11));
Assert.NotEqual(0f, cam.Projection.M34);
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter DollCameraTests`
Expected: FAIL — `DollCamera` does not exist.
- [ ] **Step 3: Implement `DollCamera`** (mirror ChaseCamera's matrix calls; substitute the fixed eye/target/up)
```csharp
// src/AcDream.App/Rendering/DollCamera.cs
using System.Numerics;
namespace AcDream.App.Rendering;
/// <summary>Fixed camera for the paperdoll mini-scene. Eye/target from
/// gmPaperDollUI::PostInit (decomp 175521-175527): eye (0.12,-2.4,0.88) looking at the
/// origin, AC up = +Z. Uses the same View/Projection convention as ChaseCamera so the
/// doll's triangle winding + back-face culling match the world pass. Per-race camera
/// distance (UpdateForRace) is deferred polish.</summary>
public sealed class DollCamera : ICamera
{
private static readonly Vector3 Eye = new(0.12f, -2.4f, 0.88f);
private static readonly Vector3 Target = Vector3.Zero;
private static readonly Vector3 Up = new(0f, 0f, 1f);
// Match ChaseCamera: <copy its handedness + FOV/near/far helper calls here>.
// Default FOV/near/far below are starting values — tune at the visual gate.
public float FovRadians { get; set; } = 0.6f; // ~34°; refine vs CreatureMode::SetFOVRad
public float Near { get; set; } = 0.1f;
public float Far { get; set; } = 50f;
public float Aspect { get; set; } = 1f;
public Matrix4x4 View => Matrix4x4.CreateLookAt(Eye, Target, Up);
public Matrix4x4 Projection =>
Matrix4x4.CreatePerspectiveFieldOfView(FovRadians, Aspect <= 0 ? 1f : Aspect, Near, Far);
}
```
> **Note:** if `ChaseCamera` uses a left-handed / custom matrix (not `CreateLookAt`/`CreatePerspectiveFieldOfView`), copy ITS exact calls instead — the doll must share the world's winding so culling isn't inverted. The test only pins the eye + aspect, which both conventions satisfy.
- [ ] **Step 4: Run test to verify it passes**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter DollCameraTests`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.App/Rendering/DollCamera.cs tests/AcDream.App.Tests/Rendering/DollCameraTests.cs
git commit -m "feat(D.2b): Slice 2 — DollCamera (fixed paperdoll ICamera)"
```
---
## Task 3: `UiViewport` widget + Type `0xD` registration
**Files:**
- Create: `src/AcDream.App/UI/UiViewport.cs`
- Create: `src/AcDream.App/UI/IUiViewportRenderer.cs`
- Modify: `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (the Type switch)
- Test: `tests/AcDream.App.Tests/UI/Layout/UiViewportFactoryTests.cs` (create)
- [ ] **Step 1: Write the failing test**
```csharp
// tests/AcDream.App.Tests/UI/Layout/UiViewportFactoryTests.cs
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using Xunit;
public class UiViewportFactoryTests
{
[Fact]
public void Factory_builds_UiViewport_for_dat_type_0xD()
{
var info = TestElementInfo.WithType(0xD); // helper: minimal ElementInfo, Type=0xD
var widget = DatWidgetFactory.Create(info, _ => 0u); // resolve stub
Assert.IsType<UiViewport>(widget);
Assert.True(widget.ConsumesDatChildren);
}
}
```
> If a `TestElementInfo` helper / the exact `DatWidgetFactory.Create` signature differs, read `DatWidgetFactory.cs` + an existing factory test (e.g. the UiMeter/UiScrollbar tests under `tests/AcDream.App.Tests/UI/Layout/`) and match their construction exactly.
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter UiViewportFactoryTests`
Expected: FAIL — no `0xD` case / no `UiViewport`.
- [ ] **Step 3: Define the seam interface**
```csharp
// src/AcDream.App/UI/IUiViewportRenderer.cs
namespace AcDream.App.UI;
/// <summary>Renders a 3-D mini-scene into an off-screen buffer and returns the GL color-
/// texture handle. Called by the per-frame pre-UI hook (GameWindow), NOT from
/// UiViewport.OnDraw. Implemented by PaperdollViewportRenderer in AcDream.App.Rendering.
/// Intra-App decoupling so the UI widget doesn't depend on WbDrawDispatcher/GameWindow.</summary>
public interface IUiViewportRenderer
{
/// <summary>Render at (width,height); return the color-texture GL handle, or 0 if nothing rendered.</summary>
uint Render(int width, int height);
}
```
- [ ] **Step 4: Implement the widget**
```csharp
// src/AcDream.App/UI/UiViewport.cs
namespace AcDream.App.UI;
/// <summary>Leaf widget for dat Type 0xD (UIElement_Viewport). Blits the texture produced
/// by its IUiViewportRenderer (run in the pre-UI hook) as a single sprite at its own rect.
/// The 3-D render does NOT happen here (OnDraw only has a 2-D context).</summary>
public sealed class UiViewport : UiElement
{
public override bool ConsumesDatChildren => true;
/// <summary>Set by GameWindow wiring (Task 6). When null, the widget draws nothing.</summary>
public IUiViewportRenderer? Renderer { get; set; }
/// <summary>Last color-texture handle the pre-UI hook produced (0 = none).</summary>
public uint TextureHandle { get; set; }
protected override void OnDraw(UiRenderContext ctx)
{
if (!Visible || TextureHandle == 0) return;
var p = ScreenPosition;
ctx.DrawSprite(TextureHandle, p.X, p.Y, Width, Height);
}
}
```
> Confirm the exact `UiElement` virtual + `ConsumesDatChildren`/`OnDraw`/`ScreenPosition`/`DrawSprite` signatures against `UiElement.cs`/`UiRenderContext.cs` and an existing leaf widget (`UiItemSlot`/`UiMeter`); match them. The RTT texture is a normal GL texture id — `DrawSprite` already takes a `uint texture` (see SSOT chrome-sprite path).
- [ ] **Step 5: Register Type `0xD` in `DatWidgetFactory`**
In the Type switch (`DatWidgetFactory.cs:~63`), add: `0xD => new UiViewport(),`. Match the surrounding arm style (some arms pass `info`/`resolve`; `UiViewport` needs neither for construction — it's wired post-build).
- [ ] **Step 6: Run test to verify it passes**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter UiViewportFactoryTests`
Expected: PASS.
- [ ] **Step 7: Build + commit**
```bash
dotnet build src/AcDream.App/AcDream.App.csproj
git add src/AcDream.App/UI/UiViewport.cs src/AcDream.App/UI/IUiViewportRenderer.cs src/AcDream.App/UI/Layout/DatWidgetFactory.cs tests/AcDream.App.Tests/UI/Layout/UiViewportFactoryTests.cs
git commit -m "feat(D.2b): Slice 2 — UiViewport widget (dat Type 0xD) + IUiViewportRenderer seam"
```
---
## Task 4: Doll-entity builder (player Setup + ObjDesc → a `WorldEntity`)
**Files:**
- Create: `src/AcDream.App/Rendering/DollEntityBuilder.cs`
- Test: `tests/AcDream.App.Tests/Rendering/DollEntityBuilderTests.cs` (create)
Extract the palette/part-override mapping from `GameWindow.cs:3390-3431` into a reusable pure helper that turns a player's spawn-derived inputs into the doll `WorldEntity` (synthetic guid, scene-origin position, heading 191.37°). Tests feed plain inputs (no dats).
- [ ] **Step 1: Read** `GameWindow.cs:3390-3431` (the `PaletteOverride` + `PartOverride[]` construction) and `WorldEntity.cs` (the record's settable members) so the builder's output matches what the dispatcher consumes.
- [ ] **Step 2: Write the failing test**
```csharp
// tests/AcDream.App.Tests/Rendering/DollEntityBuilderTests.cs
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.Core.World;
using Xunit;
public class DollEntityBuilderTests
{
[Fact]
public void Builds_doll_entity_with_synthetic_guid_and_player_setup()
{
var meshRefs = new List<MeshRef>(); // empty is fine for this mapping test
var doll = DollEntityBuilder.Build(
setupId: 0x0200_0001u,
meshRefs: meshRefs,
basePaletteId: 0x04000ABCu,
subPalettes: new (uint Id, uint Off, uint Len)[] { (0x0F00_0001u, 0, 8) },
partOverrides: new (byte Part, uint GfxObj)[] { (2, 0x0100_0042u) });
Assert.Equal(0x0200_0001u, doll.SourceGfxObjOrSetupId);
Assert.NotEqual(0u, doll.ServerGuid); // synthetic non-zero (EntitySpawnAdapter requires it)
Assert.Single(doll.PartOverrides);
Assert.Equal((byte)2, doll.PartOverrides[0].PartIndex);
Assert.Equal(0x0100_0042u, doll.PartOverrides[0].GfxObjId);
Assert.NotNull(doll.PaletteOverride);
Assert.Equal(0x04000ABCu, doll.PaletteOverride!.BasePaletteId);
}
[Fact]
public void Heading_faces_the_viewer()
{
var doll = DollEntityBuilder.Build(0x0200_0001u, new List<MeshRef>(), null, null, null);
// 191.367905° about +Z → quaternion; just assert it's set (non-identity) + normalized.
Assert.True(System.MathF.Abs(doll.Rotation.LengthSquared() - 1f) < 1e-3f);
}
}
```
> Match `MeshRef`, `PaletteOverride` (ctor `(BasePaletteId, SubPaletteRange[])`), `PartOverride` (`(byte PartIndex, uint GfxObjId)`), and `WorldEntity`'s members to their real definitions (read `WorldEntity.cs`, `PaletteOverride.cs`, `PartOverride.cs`). Adjust the test's tuple shapes to whatever signature you give `Build`.
- [ ] **Step 3: Run test to verify it fails**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter DollEntityBuilderTests`
Expected: FAIL — `DollEntityBuilder` does not exist.
- [ ] **Step 4: Implement `DollEntityBuilder`** (mirror GameWindow.cs:3390-3431; add the fixed pose + synthetic guid)
```csharp
// src/AcDream.App/Rendering/DollEntityBuilder.cs
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.World;
namespace AcDream.App.Rendering;
/// <summary>Builds the dedicated paperdoll WorldEntity (retail makeObject(player) clone):
/// the player's Setup + current ObjDesc, posed at the scene origin facing the viewer.
/// Mirrors the palette/part-override mapping at GameWindow.cs:3390-3431. The synthetic
/// guid keeps it distinct from the live player entity and lets EntitySpawnAdapter.OnCreate
/// accept it (ServerGuid != 0).</summary>
public static class DollEntityBuilder
{
/// <summary>Reserved synthetic guid for the paperdoll clone (high, collision-free).</summary>
public const uint DollServerGuid = 0xDA11_D011u;
// 191.367905° about +Z (retail set_heading), in radians.
private static readonly float HeadingRad = 191.367905f * (MathF.PI / 180f);
public static WorldEntity Build(
uint setupId,
IReadOnlyList<MeshRef> meshRefs,
uint? basePaletteId = null,
IReadOnlyList<(uint Id, uint Off, uint Len)>? subPalettes = null,
IReadOnlyList<(byte Part, uint GfxObj)>? partOverrides = null)
{
PaletteOverride? pal = null;
if (subPalettes is { Count: > 0 })
{
var ranges = new PaletteOverride.SubPaletteRange[subPalettes.Count];
for (int i = 0; i < subPalettes.Count; i++)
ranges[i] = new PaletteOverride.SubPaletteRange(
subPalettes[i].Id, subPalettes[i].Off, subPalettes[i].Len);
pal = new PaletteOverride(basePaletteId ?? 0, ranges);
}
PartOverride[] parts = Array.Empty<PartOverride>();
if (partOverrides is { Count: > 0 })
{
parts = new PartOverride[partOverrides.Count];
for (int i = 0; i < partOverrides.Count; i++)
parts[i] = new PartOverride(partOverrides[i].Part, partOverrides[i].GfxObj);
}
return new WorldEntity
{
Id = 0, // assigned by the caller if it needs a render id
ServerGuid = DollServerGuid,
SourceGfxObjOrSetupId = setupId,
Position = Vector3.Zero, // scene origin
Rotation = Quaternion.CreateFromAxisAngle(new Vector3(0, 0, 1), HeadingRad),
MeshRefs = meshRefs,
PaletteOverride = pal,
PartOverrides = parts,
ParentCellId = 0,
};
}
}
```
> Adjust property names/types to `WorldEntity`'s real definition (it's `required`-init in GameWindow's usage — match exactly). If `MeshRefs` must be a concrete `List`/array, convert.
- [ ] **Step 5: Run test to verify it passes**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter DollEntityBuilderTests`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add src/AcDream.App/Rendering/DollEntityBuilder.cs tests/AcDream.App.Tests/Rendering/DollEntityBuilderTests.cs
git commit -m "feat(D.2b): Slice 2 — DollEntityBuilder (player Setup+ObjDesc -> doll WorldEntity)"
```
---
## Task 5: `PaperdollViewportRenderer` — the RTT pass (INLINE; static doll first)
**Files:**
- Create: `src/AcDream.App/Rendering/PaperdollViewportRenderer.cs`
This is GL integration — **do inline**, build a **static doll first** (no live animation), verify with a screenshot, THEN Task 7 adds idle motion. Implements `IUiViewportRenderer`.
**Responsibilities:**
1. Own a `ManagedGLFramebuffer` (color `ITexture` + depth) sized to the widget rect; lazily (re)create when `(w,h)` changes. Read how `ManagedGLFramebuffer` is constructed elsewhere (grep `new ManagedGLFramebuffer`) for the `OpenGLGraphicsDevice` + `ITexture` creation pattern; reuse it.
2. Hold the doll `WorldEntity` (from `DollEntityBuilder`, refreshed by Task 6) + its mesh-resolved `MeshRefs`, a `DollCamera`, and refs to `WbDrawDispatcher` + `SceneLightingUboBinding` (injected).
3. `Render(w,h)`:
```csharp
public uint Render(int width, int height)
{
if (_dollEntity is null || width <= 0 || height <= 0) return 0;
EnsureFramebuffer(width, height); // (re)create FBO on size change
_camera.Aspect = width / (float)height;
using var _ = new GLStateScope(_gl); // RAII: restores ALL gl state on dispose
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, (uint)_fbo.NativeHandle.ToInt32());
_gl.Viewport(0, 0, (uint)width, (uint)height);
_gl.Disable(EnableCap.ScissorTest);
_gl.ClearColor(0f, 0f, 0f, 0f); // transparent — window backdrop shows through
_gl.ClearDepth(1f);
_gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
_gl.Enable(EnableCap.DepthTest);
_gl.DepthMask(true);
// cull/front-face: match the world object pass (read GameWindow.cs ~8098). The doll
// shares mesh winding, so use the SAME CullFace + FrontFace the world sets.
UploadDollLight(); // overwrite binding=1 contents (safe — rebuilt next frame)
_wbDrawDispatcher.Draw(
_camera,
new[] { (_dollLandblockId, _aabbMin, _aabbMax,
(IReadOnlyList<WorldEntity>)new[] { _dollEntity },
(IReadOnlyDictionary<uint, WorldEntity>?)_animatedById) },
frustum: null,
neverCullLandblockId: _dollLandblockId,
visibleCellIds: null,
animatedEntityIds: _animatedEntityIds); // empty for static doll; {dollId} after Task 7
return _fboColorTextureHandle;
}
```
```csharp
private void UploadDollLight()
{
var ubo = new AcDream.Core.Lighting.SceneLightingUbo();
var dir = System.Numerics.Vector3.Normalize(new System.Numerics.Vector3(0.3f, 1.9f, 0.65f));
ubo.Light0 = new AcDream.Core.Lighting.UboLight
{
PosAndKind = new System.Numerics.Vector4(0, 0, 0, 0), // kind 0 = directional
DirAndRange = new System.Numerics.Vector4(dir, 1e9f), // huge range = no cutoff
ColorAndIntensity = new System.Numerics.Vector4(1f, 1f, 1f, 2.0f), // white @ retail intensity 2.0
ConeAngleEtc = System.Numerics.Vector4.Zero,
};
ubo.CellAmbient = new System.Numerics.Vector4(0.35f, 0.35f, 0.35f, 1f); // 1 active light; ambient tunable
_sceneLightingUbo.Upload(ubo);
}
```
**Steps:**
- [ ] **Step 1:** Read `new ManagedGLFramebuffer` usage + `OpenGLGraphicsDevice`/`ITexture` creation; the world object-pass GL state at `GameWindow.cs:~8098` (cull/front-face); confirm `WbDrawDispatcher.Draw`'s tuple element types. Confirm whether a Setup-backed entity renders WITHOUT an `animatedEntityIds` entry (static pose) — if the dispatcher needs an animation entry to pose parts, note it for Task 7 and use a minimal pose; otherwise static is fine.
- **⚠ Known interaction — the doll pass is a SECOND `Draw()` per frame.** `WbDrawDispatcher.cs:951-959` explicitly warns that its indoor-probe `IndoorProbeState` construction assumes "Draw() is called exactly once per frame" and "a second pass within the same frame needs revisiting." This only bites when an `ACDREAM_PROBE_*`/indoor diagnostic is active (not normal play), but verify: (a) the Tier-1 cache (#53) is keyed by entity+owning-landblock, so the doll's synthetic guid/landblock must NOT collide with any world entity/landblock (use a reserved `_dollLandblockId` unused by streaming, e.g. `0`); (b) the world Draw runs BEFORE the doll pass each frame and `_groups` is cleared at the top of every `Draw()` (`:926`), so the doll pass doesn't corrupt the world's next frame. If probes are on and you see doubled probe lines, that's expected/benign for this slice — note it, don't chase it.
- [ ] **Step 2:** Implement `PaperdollViewportRenderer` (ctor injects `GL`, `OpenGLGraphicsDevice`, `WbDrawDispatcher`, `SceneLightingUboBinding`, `DollCamera`; `EnsureFramebuffer`, `UploadDollLight`, `Render`, `SetDoll(WorldEntity, meshRefs)`, `Dispose`). `_dollLandblockId` = a synthetic constant (e.g. `0`); `_aabbMin/Max` = the doll entity's local bounds (or a generous fixed box).
- [ ] **Step 3:** Build: `dotnet build src/AcDream.App/AcDream.App.csproj` — green.
- [ ] **Step 4:** Commit (renderer compiles; wiring is Task 6):
```bash
git add src/AcDream.App/Rendering/PaperdollViewportRenderer.cs
git commit -m "feat(D.2b): Slice 2 — PaperdollViewportRenderer RTT pass (static doll)"
```
---
## Task 6: GameWindow wiring + pre-UI hook + re-dress (INLINE)
**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
**Steps:**
- [ ] **Step 1 — construct the renderer + give the controller the viewport.** At the `PaperdollController.Bind` call site (grep `PaperdollController.Bind`): after binding, `FindElement(0x100001D5)` for the `UiViewport`; construct `PaperdollViewportRenderer` (inject `_gl`, the `OpenGLGraphicsDevice`, `_wbDrawDispatcher`, `_sceneLightingUbo`, a `new DollCamera()`); set `uiViewport.Renderer = renderer`; store the renderer in a field `_paperdollViewport`. (The controller already finds `0x100001D5` for its toggle in Task 1 — the same element; ensure both see it.)
- [ ] **Step 2 — build the doll entity from the player + resolve its meshes.** Add `RefreshPaperdollDoll()`: read `_lastSpawnByGuid[_playerServerGuid]` (+ `_entitiesByServerGuid[_playerServerGuid]` for already-resolved `MeshRefs`); call `DollEntityBuilder.Build(...)`; resolve `MeshRefs` the same way the spawn path does (reuse the player entity's `MeshRefs` if present — cheapest); route the doll through `EntitySpawnAdapter.OnCreate(doll)` (so its meshes background-load + a per-instance `AnimatedEntityState` exists); `renderer.SetDoll(doll, meshRefs)`. Call it once when the inventory opens and on re-dress.
- [ ] **Step 3 — the pre-UI hook.** Immediately before `_uiHost.Tick/Draw` (`GameWindow.cs:9009`), add:
```csharp
// Paperdoll doll RTT pass — only when the inventory window is open AND in doll-view.
if (_options.RetailUi && _paperdollViewport is not null && _inventoryFrame is { Visible: true }
&& _paperdollDollViewport is { Visible: true } uvp) // the UiViewport; Visible == doll-view
{
if (_lastSpawnByGuid.ContainsKey(_playerServerGuid))
{
if (_paperdollDollDirty) { RefreshPaperdollDoll(); _paperdollDollDirty = false; }
uvp.TextureHandle = _paperdollViewport.Render((int)uvp.Width, (int)uvp.Height);
}
}
```
> Capture `_inventoryFrame` (the registered window root) + `_paperdollDollViewport` (the `UiViewport`) as fields at the Bind site. `Visible` on the `UiViewport` is set by the controller's toggle (Task 1) — so the gate "doll-view" == `uvp.Visible`.
- [ ] **Step 4 — re-dress hook.** In `OnLiveAppearanceUpdated` (`GameWindow.cs:3733`), when `update.Guid == _playerServerGuid`, set `_paperdollDollDirty = true` (rebuild lazily next doll pass). Also set it true on inventory-open.
- [ ] **Step 5 — teardown.** In the logout/teleport `Clear()` path, dispose `_paperdollViewport` (FBO) + drop the doll's `EntitySpawnAdapter` state; null the fields so they rebuild on next login.
- [ ] **Step 6 — build + launch + screenshot (visual checkpoint).** Build green. Launch (`dotnet run`, plain — do NOT kill a running client; if the rebuild is locked, ASK the user to close it). Open the inventory; confirm a (static) re-dressed doll renders in the paperdoll panel and the Slots button toggles doll ↔ armor-slots.
- [ ] **Step 7 — commit.**
```bash
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "feat(D.2b): Slice 2 — wire paperdoll doll RTT pass + re-dress into GameWindow"
```
---
## Task 7: Idle animation (INLINE refinement)
**Files:**
- Modify: `src/AcDream.App/Rendering/PaperdollViewportRenderer.cs`, `src/AcDream.App/Rendering/GameWindow.cs`
Make the doll idle-animate, mirroring the spawn path.
- [ ] **Step 1:** Read `GameWindow.cs:3514-3588` (the `SetCycle` + `_animatedEntities[entity.Id]` setup) and how `TickAnimations` (`GameWindow.cs:8114`) advances them. Decide the doll's tick owner: either (a) add the doll to `_animatedEntities` (free ticking, its Id flows into the world `animatedIds` harmlessly since it's not in `_worldState.LandblockEntries`), or (b) tick the doll sequencer in the doll pass. Prefer (a) — least new machinery.
- [ ] **Step 2:** In `RefreshPaperdollDoll`, after `OnCreate`, give the doll an `_animatedEntities[doll.Id]` entry with `sequencer.SetCycle(style, MotionCommand.Ready)` (style = the player's MotionTable `DefaultStyle`, or `0x8000003D` NonCombat — match the spawn path). Pass the doll's `Id` in `renderer`'s `_animatedEntityIds` set + the `_animatedById` dict so `WbDrawDispatcher.Draw` poses it.
- [ ] **Step 3:** Build + launch + screenshot: the doll idles (subtle breathing/sway).
- [ ] **Step 4:** Commit.
```bash
git add src/AcDream.App/Rendering/PaperdollViewportRenderer.cs src/AcDream.App/Rendering/GameWindow.cs
git commit -m "feat(D.2b): Slice 2 — paperdoll doll idle animation"
```
---
## Task 8: Wrap-up — divergence rows, SSOT, full suite, visual gate
**Files:**
- Modify: `docs/architecture/retail-divergence-register.md`
- Modify: `claude-memory/project_d2b_retail_ui.md` (the D.2b SSOT) + `MEMORY.md` if needed
- Modify: `docs/plans/2026-04-11-roadmap.md` (mark Slice 2)
- [ ] **Step 1:** Reword **AP-66** (empty-slot frame stays in both views — slots ring the doll, no overlay/transparent). Add rows: per-race camera deferred; idle-DID per-race deferred; `IUiViewportRenderer` seam in App.UI not Core (intentional adaptation, rationale = spec §4C); doll lighting = one hand-built distant-light UBO (approximation iff it differs from retail `CreatureMode` lighting). Gate-verify AP-62 (MissileAmmo) opportunistically.
- [ ] **Step 2:** Run the FULL suite (not filtered): `dotnet test` across Core / Core.Net / App / UI. All green. (PROCESS lesson: a filtered subset can hide a cross-file regression.)
- [ ] **Step 3:** Update the D.2b SSOT with a "SUB-PHASE C SLICE 2 SHIPPED" entry (the doll + toggle + RTT seam + the key DO-NOT-RETRY facts) once the visual gate passes.
- [ ] **Step 4 — VISUAL GATE (stop for the user):** the user compares to retail — doll renders the re-dressed local player (race/gender/gear; naked if bare), faces the viewer, idles; Slots toggles doll-view ↔ armor-slots; live re-dress on wield/unwield. **This is the acceptance test; do not declare done before it passes.**
- [ ] **Step 5:** Commit the docs.
```bash
git add docs/architecture/retail-divergence-register.md claude-memory/project_d2b_retail_ui.md docs/plans/2026-04-11-roadmap.md
git commit -m "docs(D.2b): Slice 2 shipped — paperdoll doll + Slots toggle; divergence + SSOT"
```
---
## Self-review notes (author)
- **Spec coverage:** §4A→T1, §4B→T3, §4C→T3+T5, §4D→T4+T6+T7, §4E→T6, §4F→T1-4 tests + T8 gate. §7 pin-items resolved: UiButton (T1), inventory-open (T6 `_inventoryFrame.Visible`), animation contract (T5 step1 + T7), lighting UBO (T5 `UploadDollLight`), camera (T2 mirrors ChaseCamera), idle motion (T7 `MotionCommand.Ready`). All covered.
- **Type consistency:** `PaperdollViewState`, `ArmorSlotElementIds`, `DollCamera`, `UiViewport`, `IUiViewportRenderer.Render(w,h)→uint`, `DollEntityBuilder.Build`, `PaperdollViewportRenderer.Render/SetDoll`, `DollServerGuid` used consistently across tasks.
- **Known soft spots (resolved inline, by design):** the exact `WbDrawDispatcher.Draw` tuple types, `WorldEntity` `required` members, `MeshRef`/`PaletteOverride` ctors, and `ChaseCamera`'s matrix convention are confirmed against source DURING Tasks 4-5 (each task's step 1 says "read X first") — they're "mirror this exact existing code" directives, not open requirements. GL output is verified at the visual gate (Tasks 6-7), which is the acceptance test per the spec.

View file

@ -0,0 +1,332 @@
# acdream UI Studio 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:** Build a standalone Silk.NET dev tool that renders any acdream UI panel through the production render stack, with an ImGui click-to-inspect inspector, sample-data fixtures, the live 3-D doll, markup hot-reload + write-back, and doll-camera sliders.
**Architecture:** A new `StudioWindow` (Silk GL app, launched via a `ui-studio` subcommand in `Program.cs`) builds its render stack through a new `RenderBootstrap` (a clean construction of just the subset the studio needs — GL, `DatCollection`, `TextureCache`, the WB mesh pipeline, `UiHost` — leaving `GameWindow` untouched). A `LayoutSource` loads a panel (dat `LayoutDesc` id or markup file) into a `UiElement` tree; a `FixtureProvider` populates it via the production controllers; a `StudioInspector` (ImGui) renders the tool chrome with the panel shown as an FBO image.
**Tech Stack:** C# .NET 10, Silk.NET (Windowing/Input/OpenGL), ImGui.NET 1.91.6.1 + Silk.NET.OpenGL.Extensions.ImGui 2.23.0 (via the existing `AcDream.UI.ImGui` project), the existing `AcDream.App.UI` retained-mode tree + `LayoutImporter`/`DatWidgetFactory`, `DatReaderWriter`.
**Spec:** `docs/superpowers/specs/2026-06-25-ui-studio-previewer-design.md`
**Subagent note (project policy):** implementers are Sonnet and INHERIT CLAUDE.md; they read the cited code themselves. Tasks give the contract, the key code, the files to read, the test, the gate, and the commit message — not every line. GL/window tasks verify via a visual gate (the studio is itself the visual tool); pure-logic tasks (LayoutSource infos, FixtureProvider, MarkupWriteBack) use real unit tests.
**Branch:** `claude/hopeful-maxwell-214a12`. Commit after every task. Full build (`dotnet build src/AcDream.App/AcDream.App.csproj`) green before any commit; relevant tests green.
**Launch (for every visual gate):**
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug -- ui-studio "$env:USERPROFILE\Documents\Asheron's Call" --layout 0x2100006C
```
Do NOT auto-kill a running client; if a rebuild is locked, ask the user to close it ([[feedback_dont_kill_clients_before_launch]]).
---
## File structure
New, all under `src/AcDream.App/`:
- `Rendering/RenderBootstrap.cs``RenderStack Create(GL, RenderBootstrapOptions)`; the shared render-stack builder + the `RenderStack` record.
- `Studio/StudioWindow.cs` — the Silk window + frame loop + owns the stack/inspector/sources.
- `Studio/StudioOptions.cs` — parsed `ui-studio` args (layout id | markup path | dat dir).
- `Studio/LayoutSource.cs` — load/reload a panel from a dat id or a markup file.
- `Studio/FixtureProvider.cs` — sample data + per-layout controller binding.
- `Studio/SampleData.cs` — the canned items / vitals / creature fixture constants.
- `Studio/StudioInspector.cs` — the ImGui chrome (canvas/tree/properties/render-config).
- `Studio/PanelFbo.cs` — render-to-texture target for the previewed panel (mirrors `PaperdollViewportRenderer`'s FBO).
- `Studio/MarkupWriteBack.cs` — targeted in-place markup attribute edits.
- `Studio/HotReloadWatcher.cs` — debounced `FileSystemWatcher`.
- Modify: `src/AcDream.App/Program.cs``ui-studio` subcommand dispatch.
Tests under `tests/AcDream.App.Tests/Studio/`:
- `LayoutSourceTests.cs`, `FixtureProviderTests.cs`, `MarkupWriteBackTests.cs`.
---
## Task 1: RenderBootstrap (shared render stack)
**Files:**
- Create: `src/AcDream.App/Rendering/RenderBootstrap.cs`
- Read first: `src/AcDream.App/Rendering/GameWindow.cs:1026-1815,2313-2408` (the construction order), `src/AcDream.App/UI/UiHost.cs`, `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (ctor), `src/AcDream.App/UI/Layout/UiDatFont.cs`.
**Contract:**
```csharp
namespace AcDream.App.Rendering;
public sealed record RenderStack(
GL Gl,
DatReaderWriter.DatCollection Dats,
string ShaderDir,
BindlessSupport Bindless,
TextureCache TextureCache,
Shader MeshShader,
Wb.WbMeshAdapter MeshAdapter,
Wb.EntitySpawnAdapter EntitySpawnAdapter,
Wb.WbDrawDispatcher DrawDispatcher,
SceneLightingUboBinding LightingUbo,
AcDream.App.UI.UiHost UiHost,
AcDream.App.UI.Layout.UiDatFont? VitalsDatFont)
{
/// <summary>sprite id (0x06xxxxxx) → (GL handle, width, height) for the importer's resolve func.</summary>
public (uint, int, int) ResolveChrome(uint spriteId)
=> TextureCache.GetOrUploadRenderSurface(spriteId, out var w, out var h) is var h2
? (h2, w, h) : (0u, 0, 0); // match GameWindow.ResolveChrome — read its exact body first
}
public sealed record RenderBootstrapOptions(QualitySettings Quality);
public static class RenderBootstrap
{
/// <summary>Build the subset of the production render stack the UI Studio needs — same classes,
/// same construction order as GameWindow.OnLoad, minus terrain/sky/physics/streaming. Throws
/// NotSupportedException if bindless/draw-params are missing (same as the game).</summary>
public static RenderStack Create(GL gl, DatReaderWriter.DatCollection dats, RenderBootstrapOptions opts);
}
```
- [ ] **Step 1:** Read the cited GameWindow construction lines and `ResolveChrome` (grep it) so the order + the exact `GetOrUploadRenderSurface`/sequencer-factory signatures are copied, not guessed.
- [ ] **Step 2:** Write `RenderBootstrap.Create` constructing, in order: `BindlessSupport.TryCreate` (throw if absent/no draw-params, copy the GameWindow message), `shadersDir = Path.Combine(AppContext.BaseDirectory,"Rendering","Shaders")`, `SceneLightingUboBinding(gl)`, `MeshShader = new Shader(gl, mesh_modern.vert/.frag)`, `TextureCache(gl, dats, bindless)`, `WbMeshAdapter(gl, dats, NullLogger<WbMeshAdapter>.Instance)`, the `SequencerFactory` lambda (copy from GameWindow:2335-2361, capturing `dats` + an `AnimLoader`), `EntitySpawnAdapter(textureCache, sequencerFactory, meshAdapter)`, `WbDrawDispatcher(gl, meshShader, textureCache, meshAdapter, entitySpawnAdapter, bindless, new <classificationCache>)` with `AlphaToCoverage = opts.Quality.AlphaToCoverage`, the vitals dat font (copy GameWindow's load of `0x40000000` via `UiDatFont`), and `UiHost(gl, shadersDir, defaultFont)`. Return the `RenderStack`.
- [ ] **Step 3:** Build: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`. Expected: green (no test yet — GL construction is verified by Task 2's boot gate).
- [ ] **Step 4:** Commit.
**Gate:** Build green. (Functional gate is Task 2.)
**Commit:** `feat(studio): RenderBootstrap — shared render stack for the UI previewer`
---
## Task 2: StudioWindow + LayoutSource (dat id) + Program dispatch
**Files:**
- Create: `src/AcDream.App/Studio/StudioOptions.cs`, `src/AcDream.App/Studio/LayoutSource.cs`, `src/AcDream.App/Studio/StudioWindow.cs`
- Modify: `src/AcDream.App/Program.cs` (before `new GameWindow`)
- Test: `tests/AcDream.App.Tests/Studio/LayoutSourceTests.cs`
- Read first: `src/AcDream.App/UI/Layout/LayoutImporter.cs:57-190`, `src/AcDream.App/Rendering/GameWindow.cs:972-1017` (window+Run), `src/AcDream.App/UI/UiHost.cs:65-96` (Draw/WireMouse/WireKeyboard).
**`StudioOptions` contract:**
```csharp
namespace AcDream.App.Studio;
public sealed record StudioOptions(string DatDir, uint? LayoutId, string? MarkupPath)
{
public static StudioOptions Parse(string[] args); // args AFTER the "ui-studio" token
}
```
Parse: positional dat dir (or `ACDREAM_DAT_DIR`), `--layout 0xNNNN` (hex), `--markup <path>`. Default layout when neither given: `0x2100006Cu` (vitals).
**`LayoutSource` contract:**
```csharp
public enum LayoutSourceKind { DatLayout, Markup }
public sealed class LayoutSource
{
public LayoutSource(DatCollection dats, Func<uint,(uint,int,int)> resolve, UiDatFont? datFont);
public LayoutSourceKind Kind { get; }
public uint? LayoutId { get; }
public string? MarkupPath { get; }
public string? LastError { get; private set; }
/// <summary>Imported tree, or null on error (LastError set). Dat → LayoutImporter.Import; markup → MarkupDocument.Build with an empty binding.</summary>
public ImportedLayout? CurrentLayout { get; private set; }
public UiElement? Load(StudioOptions opts); // sets Kind/LayoutId/MarkupPath + CurrentLayout
public UiElement? Reload(); // re-run the current source; markup re-reads the file
}
```
- [ ] **Step 1 (test, GL-free):** `LayoutSourceTests.LoadsDatLayout_byId`. Open real dats via `ConformanceDats.ResolveDatDir()` (skip if null), build a `LayoutSource` with a STUB resolve `_ => (1u,1,1)` + null font, `Load(new StudioOptions(dir, 0x2100006Cu, null))`, assert `CurrentLayout` non-null and `FindElement(0x2100006Cu) != null` and `Kind == DatLayout`. (Use `LayoutImporter.ImportInfos` is GL-free; `Import`'s resolve is only called for sprites — the stub is fine for tree-shape.)
- [ ] **Step 2:** Run it — expect FAIL (LayoutSource missing). `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter LayoutSourceTests`.
- [ ] **Step 3:** Implement `StudioOptions.Parse` + `LayoutSource` (dat path only; markup path returns LastError "markup unsupported" for now — wired in Task 6). Dat path = `LayoutImporter.Import(dats, id, resolve, datFont)`.
- [ ] **Step 4:** Run the test — expect PASS.
- [ ] **Step 5:** Implement `StudioWindow`: mirror `GameWindow.Run()` window creation (1280×720, GL 4.3 Core, stencil 8). On `Load`: `gl = GL.GetApi(window)`, `input = window.CreateInput()`, `dats = new DatCollection(opts.DatDir, DatAccessType.Read)`, `stack = RenderBootstrap.Create(gl, dats, new(QualitySettings...))`, wire `stack.UiHost.WireMouse/WireKeyboard` for each mouse/keyboard, `source = new LayoutSource(dats, stack.ResolveChrome, stack.VitalsDatFont)`, `root = source.Load(opts)`, and if non-null add it to `stack.UiHost.Root` (use the same add-child API GameWindow uses — grep `_uiHost.Root` adds). On `Render`: `gl.ClearColor` mid-grey, `Clear`, `stack.UiHost.Tick(dt)` + `stack.UiHost.Draw(new Vector2(window.Size.X, window.Size.Y))`. On `Closing`: dispose.
- [ ] **Step 6:** `Program.cs`: before `new GameWindow`, add `if (args.Length >= 1 && args[0] == "ui-studio") { var so = AcDream.App.Studio.StudioOptions.Parse(args[1..]); using var sw = new AcDream.App.Studio.StudioWindow(so); sw.Run(); return 0; }`.
- [ ] **Step 7:** Build green, then VISUAL GATE (launch command above). Expect: a window showing the vitals panel chrome (three empty bars + frame) rendered by the real UiHost.
- [ ] **Step 8:** Commit.
**Gate (visual):** `ui-studio --layout 0x2100006C` opens a window showing the vitals panel.
**Commit:** `feat(studio): StudioWindow + LayoutSource — render a dat panel standalone`
---
## Task 3: StudioInspector (ImGui) + canvas FBO + tree + props + click-to-inspect
**Files:**
- Create: `src/AcDream.App/Studio/PanelFbo.cs`, `src/AcDream.App/Studio/StudioInspector.cs`
- Modify: `StudioWindow.cs` (init ImGui, render panel→FBO, draw inspector)
- Read first: `src/AcDream.UI.ImGui/ImGuiBootstrapper.cs:35-46`, `src/AcDream.App/Rendering/PaperdollViewportRenderer.cs` (the FBO RTT pattern + `GLStateScope`), `src/AcDream.App/UI/UiRoot.cs` (grep `HitTestTopDown`), `src/AcDream.App/UI/UiElement.cs` (Id/Type/Left/Top/Width/Height/Anchors/ZOrder/Children accessors).
**`PanelFbo`:** mirror `PaperdollViewportRenderer`'s FBO (RGBA8 + Depth24Stencil8, lazy resize). `uint Render(int w, int h, UiHost host)` = bind FBO, viewport, clear transparent, `host.Draw(new Vector2(w,h))` inside a `GLStateScope`, return color tex. Reuse the exact FBO setup from `PaperdollViewportRenderer.EnsureFramebuffer`.
**`StudioInspector`:** ImGui windows, drawn between `BeginFrame`/`Render`:
```csharp
public sealed class StudioInspector
{
public UiElement? Selected { get; set; }
// Canvas window: ImGui.Image(panelTex) sized to the panel; capture click → return panel-local (x,y)
public (int x,int y)? DrawCanvas(nint panelTex, int w, int h);
public void DrawTree(UiElement root); // recursive ImGui.TreeNode; click sets Selected
public void DrawProperties(); // Selected's Id/Type/Rect/Anchors/ZOrder/sprites (read-only here)
}
```
Click-to-inspect: when `DrawCanvas` returns a coord, call `root` `HitTestTopDown(x,y)` (grep its exact signature/return) → set `Selected`.
- [ ] **Step 1:** Implement `PanelFbo` (copy `PaperdollViewportRenderer` FBO lifecycle; swap the doll draw for `host.Draw`).
- [ ] **Step 2:** `StudioWindow.Load`: construct `new ImGuiBootstrapper(gl, window, input)` (no `DevToolsEnabled` gate — the studio is always devtools) + `new PanelFbo(gl)` + `new StudioInspector()`.
- [ ] **Step 3:** `StudioWindow.Render`: `panelTex = panelFbo.Render(w,h, stack.UiHost)`; `imgui.BeginFrame(dt)`; `var click = inspector.DrawCanvas((nint)panelTex, w, h); if (click is {} c) inspector.Selected = root.HitTestTopDown(c.x, c.y); inspector.DrawTree(root); inspector.DrawProperties();`; `imgui.Render()`.
- [ ] **Step 4:** Implement `StudioInspector` windows (ImGui.NET: `ImGui.Begin`, `ImGui.Image`, `ImGui.TreeNodeEx`, `ImGui.Text`). Tree recurses `element.Children`; each node label = `$"{name} 0x{Id:X8} [{Type}]"`. Properties: Id, Type, Rect `(Left,Top,Width,Height)`, Anchors, ZOrder, and each state sprite id (grep how `UiDatElement`/`UiButton` expose their sprite ids; if not trivially exposed, list "—" for v1 and note follow-up).
- [ ] **Step 5:** Build green, VISUAL GATE: window shows a Canvas pane (the panel) + a Tree pane + a Properties pane; clicking the panel or a tree node selects an element and shows its id/rect.
- [ ] **Step 6:** Commit.
**Gate (visual):** click an element in the canvas or tree → its id / Type / rect appear in Properties.
**Commit:** `feat(studio): ImGui inspector — canvas FBO, element tree, props, click-to-inspect`
---
## Task 4: FixtureProvider — populate 2-D panels with sample data
**Files:**
- Create: `src/AcDream.App/Studio/SampleData.cs`, `src/AcDream.App/Studio/FixtureProvider.cs`
- Modify: `StudioWindow.cs` (call `FixtureProvider.Populate` after `Load`)
- Test: `tests/AcDream.App.Tests/Studio/FixtureProviderTests.cs`
- Read first: `src/AcDream.Core/Items/ClientObjectTable.cs:41-420` (`AddOrUpdate`/`Get`/`GetContents`/`ClientObject` fields), the four controllers' `Bind` (signatures in the plan header research), `src/AcDream.Core/Items/ItemType.cs` + `EquipMask`.
**`SampleData`:** static factory building a `ClientObjectTable` seeded with: a player object (guid `0xPLAYER`, `ItemsCapacity=102`, `ContainersCapacity=7`), ~6 loose items in the main pack (varied `Type`/`IconId` — pick real icon ids from existing tests or a known wcid; `ContainerId = player`, gapless `ContainerSlot`), ~2 side bags (`Type=Container`, `ItemsCapacity>0`), and a few equipped items (`CurrentlyEquippedLocation` set to Head/Chest/etc). Plus sample vitals (`health=0.8f, stam=0.6f, mana=0.9f` + text). Constants only — no wire.
**`FixtureProvider.Populate(uint layoutId, ImportedLayout layout, RenderStack stack)`** switch on `layoutId`:
- `0x2100006C``VitalsController.Bind(layout, ()=>0.8f, ()=>0.6f, ()=>0.9f, ()=>"80/100", ()=>"60/100", ()=>"90/100")`.
- `0x21000016``ToolbarController.Bind(layout, sampleTable, ()=>PLAYER, IconIds, _=>{}, ...digits from GameWindow's load...)`.
- `0x21000023``InventoryController.Bind(layout, sampleTable, ()=>PLAYER, IconIds, ()=>100, stack.VitalsDatFont, contents/sideBag/mainPack sprites, ...)`.
- default → no-op (structural preview).
`IconIds` = reuse GameWindow's `iconIds` lambda (grep it — `IconComposer`-backed); if heavy, a stub returning the passed base id is acceptable for v1 (note it).
- [ ] **Step 1 (test):** `FixtureProviderTests.SampleTable_hasPackContents`. Build `SampleData.BuildObjectTable()`, assert `table.GetContents(PLAYER).Count >= 6` and a known item's `Type`/`IconId` set.
- [ ] **Step 2:** Run — FAIL (missing).
- [ ] **Step 3:** Implement `SampleData` + the table seeding.
- [ ] **Step 4:** Run — PASS.
- [ ] **Step 5:** Implement `FixtureProvider.Populate`; call it from `StudioWindow` after `source.Load`.
- [ ] **Step 6:** Build green, VISUAL GATE: `--layout 0x21000023` shows the inventory with sample items in the grid + a non-zero burden bar; `--layout 0x21000016` shows toolbar slots populated; `--layout 0x2100006C` shows filled vital bars.
- [ ] **Step 7:** Commit.
**Gate (visual):** inventory / toolbar / vitals panels render POPULATED (sample items, filled bars).
**Commit:** `feat(studio): FixtureProvider — sample data populates the 2-D panels`
---
## Task 5: Live doll in the paperdoll viewport
**Files:**
- Modify: `FixtureProvider.cs` (build a sample creature entity for `0x21000023`/paperdoll), `StudioWindow.cs` (wire `PaperdollViewportRenderer` + the pre-UI doll render, mirroring GameWindow:9148-9160)
- Read first: `src/AcDream.App/Rendering/GameWindow.cs:3786-3897` (`RefreshPaperdollDoll`/`ApplyPaperdollPose`), `9148-9160` (the pre-UI doll hook), `src/AcDream.App/Rendering/DollEntityBuilder.cs` (`Build`), `src/AcDream.App/Rendering/PaperdollViewportRenderer.cs` (ctor + `SetDoll`/`Render`), `EntitySpawnAdapter.OnCreate`.
**Sample creature:** a base human Setup id (grep the test character / a known humanoid Setup `0x0200....`; or reuse the player Setup constant if one exists in tests). Build a `WorldEntity { SourceGfxObjOrSetupId = setupId, MeshRefs = <resolved from Setup via WbMeshAdapter>, ... }`, run it through `stack.EntitySpawnAdapter.OnCreate` to resolve MeshRefs (same path GameWindow uses), then `DollEntityBuilder.Build` + `ApplyPaperdollPose`-equivalent. Keep a small `StudioDoll` helper if this grows.
- [ ] **Step 1:** In `StudioWindow.Load`, construct `new PaperdollViewportRenderer(gl, stack.DrawDispatcher, stack.LightingUbo)`.
- [ ] **Step 2:** When the layout is the inventory/paperdoll, have `FixtureProvider` build the sample creature, call `RefreshPaperdollDoll`-equivalent (clone → `ApplyPaperdollPose`) → `paperdollRenderer.SetDoll(doll)`; expose the doll-viewport widget (FindElement `0x100001D5`).
- [ ] **Step 3:** In `StudioWindow.Render`, BEFORE the panel FBO draw, render the doll into the viewport widget's texture exactly as GameWindow:9156-9159 (`widget.TextureHandle = paperdollRenderer.Render(w,h)`), gated on the viewport widget being present + visible.
- [ ] **Step 4:** Build green, VISUAL GATE: `--layout 0x21000023` shows the paperdoll with a DRESSED 3-D doll in the held pose (reuses the camera/heading/pose shipped in `8fa66c2`).
- [ ] **Step 5:** Commit.
**Gate (visual):** the paperdoll viewport shows the sample creature as a posed 3-D doll.
**Commit:** `feat(studio): live 3-D doll in the paperdoll preview`
---
## Task 6: Markup source + hot-reload
**Files:**
- Create: `src/AcDream.App/Studio/HotReloadWatcher.cs`
- Modify: `LayoutSource.cs` (markup path via `MarkupDocument.Build`), `StudioWindow.cs` (watch + reload), `StudioOptions` already parses `--markup`
- Read first: `src/AcDream.App/UI/MarkupDocument.cs:21-82`, an existing first-party markup file (grep `assets/*.xml`, e.g. the retired `vitals.xml` in git history or a current plugin panel) for the schema.
- Test: extend `LayoutSourceTests`.
**LayoutSource markup path:** `Kind=Markup`; `Load` reads the file → `MarkupDocument.Build(xml, EmptyBinding, resolve, null)` → wrap its `UiNineSlicePanel` as the root; `Reload` re-reads the file. `EmptyBinding` = `new object()` (no `{Binding}` resolution needed for preview; unresolved bindings render literally or empty — acceptable).
**HotReloadWatcher:**
```csharp
public sealed class HotReloadWatcher : IDisposable
{
public HotReloadWatcher(string filePath, Action onChanged); // debounce ~150ms; marshal onChanged via a thread-safe flag the window polls
}
```
The watcher sets a `volatile bool _dirty`; `StudioWindow.Render` checks it and calls `source.Reload()` + `FixtureProvider.Populate` + re-add to the UiRoot on the GL thread (FileSystemWatcher fires on a worker thread — never touch GL off-thread).
- [ ] **Step 1 (test):** `LayoutSourceTests.LoadsMarkupFile`. Write a temp `.xml` with a minimal `UiNineSlicePanel`, `Load(new StudioOptions(dir, null, path))`, assert non-null root + `Kind==Markup`.
- [ ] **Step 2:** Run — FAIL.
- [ ] **Step 3:** Implement the markup path in `LayoutSource` + `HotReloadWatcher`.
- [ ] **Step 4:** Run — PASS.
- [ ] **Step 5:** Wire the watcher in `StudioWindow` (poll `_dirty` in Render; reload on the GL thread).
- [ ] **Step 6:** Build green, VISUAL GATE: launch `--markup <path>`; edit the file in an editor + save → the studio reloads the panel within ~1s.
- [ ] **Step 7:** Commit.
**Gate (visual):** edit a markup file externally → studio re-renders it live.
**Commit:** `feat(studio): markup source + hot-reload`
---
## Task 7: Editable markup props + write-back
**Files:**
- Create: `src/AcDream.App/Studio/MarkupWriteBack.cs`
- Modify: `StudioInspector.cs` (editable rect/anchor fields when `Kind==Markup`), `StudioWindow.cs` (apply edit live + persist)
- Test: `tests/AcDream.App.Tests/Studio/MarkupWriteBackTests.cs`
**`MarkupWriteBack`:** targeted in-place attribute edits — NOT a full re-serialize (preserve formatting/comments):
```csharp
public static class MarkupWriteBack
{
/// <summary>In the markup text, find the element whose id attribute == elementId and set attr=value
/// (add the attribute if absent). Returns the new text. Leaves all other bytes untouched.</summary>
public static string SetAttribute(string markup, uint elementId, string attr, string value);
}
```
Implement by locating the element's opening tag (match the id attribute literal `Id="0x...."` — grep the markup schema for the exact id attribute name), then regex-replace just that attribute within that tag span.
- [ ] **Step 1 (test):** `MarkupWriteBackTests.SetAttribute_updatesInPlace`. Given a 2-element markup string, `SetAttribute(m, id, "Width", "200")` → re-parse, assert the target's Width==200 AND the sibling element + any comment are byte-identical.
- [ ] **Step 2:** Run — FAIL.
- [ ] **Step 3:** Implement `MarkupWriteBack.SetAttribute`.
- [ ] **Step 4:** Run — PASS (add an absent-attribute case + a not-found case returning the input unchanged).
- [ ] **Step 5:** `StudioInspector.DrawProperties`: when `Kind==Markup`, render rect (`Left/Top/Width/Height`) + anchors as ImGui `InputInt`/combo; on change, set the live `UiElement` field AND call back to the window to `MarkupWriteBack.SetAttribute(...)` + write the file (the watcher then reloads, or skip the watcher for self-writes via a guard flag).
- [ ] **Step 6:** Build green, VISUAL GATE: select an element in a markup panel, change its Width in Properties → the panel updates AND the file on disk shows the new value.
- [ ] **Step 7:** Commit.
**Gate (visual):** edit a rect in the inspector → live panel updates + the markup file is rewritten.
**Commit:** `feat(studio): editable markup props with in-place write-back`
---
## Task 8: Render-config sliders (doll camera)
**Files:**
- Modify: `src/AcDream.App/Rendering/DollCamera.cs` (make Eye/Target/Fov instance-settable — currently `static readonly`), `StudioInspector.cs` (sliders), `PaperdollViewportRenderer.cs` (expose its `DollCamera` or accept overrides)
- Read first: `DollCamera.cs` (current static fields), `PaperdollViewportRenderer.cs:35` (`private readonly DollCamera _camera = new();`).
**DollCamera change:** convert the `static readonly` Eye/Target/Up/FovRadians to instance properties with the retail defaults as initializers (keep the decomp citations). This is a safe, behavior-preserving change (same defaults) — verify the existing `DollCameraTests` still pass (they read `cam.View`).
**Inspector:** a "Render config" ImGui window shown when a doll viewport is selected: `SliderFloat3("eye", ...)`, `SliderFloat("fov", ...)`, `SliderFloat("heading", ...)` bound to the `PaperdollViewportRenderer`'s camera (expose it) + the doll's heading. A "Copy to clipboard" button emits the C# literals.
- [ ] **Step 1:** Make `DollCamera` Eye/Target/Up/FovRadians instance properties (defaults = current retail values). Build + run `DollCameraTests` — expect PASS (defaults unchanged).
- [ ] **Step 2:** Expose the camera from `PaperdollViewportRenderer` (a `public DollCamera Camera => _camera;`).
- [ ] **Step 3:** `StudioInspector`: render the Render-config window with sliders mutating `renderer.Camera.{Eye,FovRadians}` + the doll heading; a copy button.
- [ ] **Step 4:** Build green, VISUAL GATE: select the doll, drag the eye/FOV sliders → the doll re-frames live; copy emits the literals.
- [ ] **Step 5:** Commit.
**Gate (visual):** dragging the camera sliders re-frames the doll in real time.
**Commit:** `feat(studio): render-config sliders for the doll camera`
---
## Wrap-up (after Task 8)
- [ ] Update `claude-memory/project_d2b_retail_ui.md` (or a new `project_ui_studio.md`) with the studio's architecture + the launch command + DO-NOT-RETRY notes; add a `MEMORY.md` pointer.
- [ ] Update the roadmap: note the UI Studio tool shipped (it's tooling, not a milestone phase — a one-line ledger entry).
- [ ] Full suite green; final commit.
## Self-review notes
- **Spec coverage:** both sources (Tasks 2 dat / 6 markup) ✓; full fixtures (Task 4 + 5) ✓; inspector read (3) + editable markup (7) + render sliders (8) ✓; hot-reload (6) ✓; live doll (5) ✓; RenderBootstrap (1) ✓; ImGui chrome (3) ✓.
- **GL-untestable tasks** (1,2-window,3,5,6-window,8) use visual gates by design — the studio is a visual tool; pure-logic units (LayoutSource infos, FixtureProvider, MarkupWriteBack) have unit tests.
- **Type consistency:** `RenderStack`/`ImportedLayout`/`StudioOptions`/`LayoutSource`/`ClientObjectTable` member names match the research reference; `ResolveChrome` body must be copied from GameWindow (flagged in Task 1 Step 1).
- **Risk:** Task 1 builds NEW code (no GameWindow edit) → zero game-regression risk, the de-risk decision from the spec's open risk. Task 5's sample Setup id is the one unknown literal — Task 5 Step reads the test fixtures to find a real humanoid Setup before coding.

View file

@ -0,0 +1,251 @@
# D.2b Sub-phase C, Slice 2 — paperdoll 3-D doll + the "Slots" toggle (design)
**Date:** 2026-06-23
**Branch:** `claude/hopeful-maxwell-214a12` (Slice 1 shipped; `main` ff-merged to tip).
**Status:** APPROVED design (brainstorm complete). Next: writing-plans → subagent-driven implementation → visual gate.
**Predecessor:** `docs/research/2026-06-23-paperdoll-slice2-handoff.md`; Slice-1 spec `2026-06-22-d2b-paperdoll-slice1-equip-slots-design.md`.
**Read with:** the corrected model in the D.2b SSOT (`claude-memory/project_d2b_retail_ui.md`, the SLICE 1 entry) + `docs/research/2026-06-16-equipment-paperdoll-deep-dive.md` §5.
---
## 1. Goal + corrected model (user axiom)
The paperdoll panel has **two mutually-exclusive views** toggled by the **"Slots" button**
(`0x100005BE` = `m_SlotCheckbox`):
- **Doll-view (default, button OFF):** the **live 3-D character** (the "doll" — naked if nothing
equipped) + the **12 non-armor** equip slots. The 9 armor slots are hidden.
- **Slot-view (button ON):** the doll disappears; the **9 armor slots** become visible.
The "figure" **IS** the live 3-D character (a re-dressed clone of the local player), **not** per-slot
silhouette sprites. Slice 1 already shipped the 21 equip slots + wield/unwield + visible empty-slot
frames, showing all slots at once. **Slice 2 adds (A) the toggle and (B) the 3-D doll viewport on top,
extending — never rewriting — `PaperdollController`.**
### Scope
**In:** the Slots toggle; the `UiViewport` (dat Type `0xD`) hosting a re-dressed player clone;
render-to-texture compositing; live re-dress on equip/unequip (ObjDescEvent `0xF625`); fixed camera,
one distant light, heading 191.37°, idle animation.
**Deferred to polish (NOT this slice):** per-race camera framing (`UpdateForRace`), part-selection
lighting ("which piece is this?" highlight), click-to-rotate, the doll click-map (`m_paperDollDragMask`
interactivity), Aetheria sigil slots.
---
## 2. Decomp anchors (named-retail `acclient_2013_pseudo_c.txt`) — do not re-derive
| What | Function / addr | Lines | Facts |
|---|---|---|---|
| **The toggle** | `gmPaperDollUI::ListenToElementMessage` `0x004a5c30` | 175674-175706 | `idMessage==1 && idElement==0x100005be`: read checkbox attr `0xe`. **Checked → slot-view:** `m_pPaperDoll.SetVisible(0)`, `m_paperDollDragMask.SetVisible(0)`, then `SetVisible(1)` on the 9 armor slots. **Unchecked → doll-view:** the inverse. |
| **The 9 armor slots** | `gmPaperDollUI::PostInit` `0x004a5360` | 175412-175508 | The exact set `PostInit` calls `SetVisible(0)` on AND the toggle flips: head `0x100005ab`, chest(armor) `0x100005ac`, abdomen `0x100005ad`, upper-arm `0x100005ae`, lower-arm `0x100005af`, hand `0x100005b0`, upper-leg(armor) `0x100005b1`, lower-leg(armor) `0x100005b2`, foot `0x100005b3`. The other 12 slots are **never** hidden at init. |
| **Viewport init** | `gmPaperDollUI::PostInit` | 175509-175535 | Find `0x100001d5` → cast Type `0xd`; `SetCamera(&dir,&pos)`; `SetLight(DISTANT_LIGHT, 2.0, &dir)`; `CreatureMode::UseSharpMode`; `RedressCreature`. |
| **Camera immediates** | `PostInit` | 175521-175527 | cam pos `(0x3df5c28f, 0xc019999a, 0x3f6147ae)` = **(0.12, 2.4, 0.88)**; target/look `(0,0,0)`. (Arg order pos-vs-dir confirmed at impl via `UIElement_Viewport::SetCamera`.) |
| **Light immediates** | `PostInit` | 175529-175533 | dir `(0x3e99999a, 0x3ff33333, 0x3f266666)` = **(0.3, 1.9, 0.65)**; intensity **2.0**; type `DISTANT_LIGHT`. (3rd float recovered from the `"ff&?"` strncpy artifact = `0x3f266666`.) |
| **The checkbox default** | `PostInit` | 175585 | `SetAttribute_Bool(m_SlotCheckbox, 0xe, 0)` → start unchecked = doll-view. |
| **Re-dress** | `gmPaperDollUI::RedressCreature` `0x004a3bc0` | 173997-174012 | Built **once** (lazy): `makeObject(player)` clone → `set_heading(191.367905°)``set_sequence_animation(m_didAnimation.id, 1,1,0)``CreatureMode::AddObject`. **Every call:** `DoObjDescChangesFromDefault(clone, get_player_visualdesc())` = re-dress with the same ObjDesc apply the in-world renderer uses. |
| **Per-race camera (DEFERRED)** | `gmPaperDollUI::UpdateForRace` `0x004a3ed0` | 174138-174180 | Camera distance varies per body-type: case 6/7 y=`0xc0400000`=3.0; case 8 y=`0xc059999a`=3.4, z=`0x3f800000`=1.0; etc. Polish. |
---
## 3. acdream seams (verified against source this session)
- **Render order:** `GameWindow.OnRender` runs all 3-D world passes, then `_uiHost.Tick()`/`_uiHost.Draw()`
at **GameWindow.cs:9009-9012**. The doll RTT pass slots in after the world passes, before the UI pass.
- **Single-entity draw:** `WbDrawDispatcher.Draw(ICamera camera, IEnumerable<(LandblockId, AabbMin,
AabbMax, IReadOnlyList<WorldEntity> Entities, IReadOnlyDictionary<uint,WorldEntity>? AnimatedById)>,
…, HashSet<uint>? animatedEntityIds, …)` (WbDrawDispatcher.cs:881). Sets `uViewProjection` from
`camera.View*camera.Projection` (`:894`) and `uLightingMode=0` (Lambert sun + per-entity point sets,
`:898`). **A second pass with a doll camera + a one-entry landblock tuple is supported.**
- **Camera:** `ICamera` is `{ Matrix4x4 View; Matrix4x4 Projection; float Aspect; }` (ICamera.cs:5).
A fixed `DollCamera` impl is trivial.
- **RTT infra exists:** `ManagedGLFramebuffer` (color `ITexture` + Depth24Stencil8 renderbuffer,
completeness-checked, `GpuMemoryTracker`-tracked) and `GLStateScope` (RAII save/restore of viewport,
scissor, depth, cull, blend, program, VAO, FBO bindings, UBO binding 0, …). Both in
`src/AcDream.App/Rendering/Wb/`.
- **Animated-char pipeline:** `EntitySpawnAdapter.OnCreate(WorldEntity)` (EntitySpawnAdapter.cs:100)
requires `ServerGuid != 0`, builds `AnimatedEntityState` via the injected sequencer factory, applies
`HiddenPartsMask` + `PartOverrides`, registers each `MeshRef.GfxObjId` + override GfxObj with the mesh
adapter (so meshes background-load), and stores `_stateByGuid[ServerGuid]`.
- **Player model data is available:** the local player **does** get a fully-resolved `WorldEntity`
(`_entitiesByServerGuid[_playerServerGuid]`, GameWindow.cs:12700) with MeshRefs/PaletteOverride/
PartOverrides AND an `AnimatedEntityState` (`_animatedEntities[playerEntity.Id]`, `:12805`); its spawn
is cached in `_lastSpawnByGuid[_playerServerGuid]`. **(Corrects the handoff's "the local player isn't a
WorldEntity" — it is.)** The WorldEntity build template is GameWindow.cs:3390-3431. Appearance updates
flow through `OnLiveAppearanceUpdated` (ObjDescEvent `0xF625`, GameWindow.cs:3733).
---
## 4. Design
### A. The Slots toggle — `PaperdollController` extension
Extend `PaperdollController` (`src/AcDream.App/UI/Layout/PaperdollController.cs`); do not rewrite the
slot bindings / wield / unwield.
- **Partition the existing `SlotMap` by element-id** into the decomp's **9 armor** ids
(`0x100005ab/ac/ad/ae/af/b0/b1/b2/b3`) vs the **12 non-armor** ids. (Keyed by element-id, exactly the
set the decomp toggles — NOT an `EquipMask` heuristic; HeadWear/HandWear/FootWear are in the armor set,
ChestWear/UpperLegWear (shirt/pants) are non-armor.)
- Keep refs to the 9 armor-slot `UiItemList`s + the `UiViewport` widget.
- Find the Slots button (`0x100005BE`, a `UiButton`) via `layout.FindElement`. On click, flip
`_slotView` and apply: doll-view → `viewport.Visible=true`, 9 armor `.Visible=false`; slot-view →
`viewport.Visible=false`, 9 armor `.Visible=true`. Non-armor slots untouched.
- Default `_slotView=false` (doll-view), mirroring `SetAttribute_Bool(0xe,0)`.
**AP-66 resolved:** the Slice-1 empty-slot frame **stays** in both views. The corrected model arranges
the slots *beside* the doll (the viewport is a ~100-px column), so nothing overlays the doll and nothing
flips to transparent. The toggle only changes `Visible`, never the empty-cell art. Reword the AP-66 row.
### B. The `UiViewport` widget (Type `0xD`)
New leaf widget `UiViewport : UiElement` (`src/AcDream.App/UI/UiViewport.cs`):
- Register in `DatWidgetFactory` (`src/AcDream.App/UI/Layout/DatWidgetFactory.cs`, the Type switch):
`0xD => new UiViewport(...)`. `ConsumesDatChildren => true` (leaf).
- Holds a reference to its **doll scene** (a `UiViewportScene` handle, §C) and the latest rendered
color-texture handle. In `OnDraw(UiRenderContext)` it simply **blits that cached handle** as one sprite
at its own `ScreenPosition`/`Width`/`Height`. The 3-D render itself happens in the pre-UI hook (§E),
*not* in `OnDraw` (which only has a 2-D context). Because the blit is an ordinary sprite in the 2-D
pass, it lands in correct painter order (backdrop behind, slots/chrome in front).
- When `Visible == false` (slot-view), it draws nothing and the pre-UI hook skips its 3-D pass.
### C. RTT path + the `IUiViewportRenderer` seam (the crux)
The 3-D doll renders into an off-screen buffer in the **pre-UI hook** (GameWindow, after the world
passes, before `_uiHost.Draw`); the `UiViewport` widget then blits the result during the 2-D UI pass.
**Seam (layering decision — diverges from the handoff's "Core interface", with user approval):**
define `IUiViewportRenderer` in the **`AcDream.App.UI` namespace** (where `UiViewport` lives),
implemented by `PaperdollViewportRenderer` in `AcDream.App.Rendering`. `App.UI` is a namespace **within
the `AcDream.App` project**, not a separate project — so this is an **intra-App decoupling** (keep the UI
widget from depending directly on `WbDrawDispatcher`/`GameWindow`), not a cross-project seam.
Code-Structure Rule 2 keeps **Core** free of GL; Core has no consumer for "render an entity into a rect",
so a Core interface would over-apply the rule. Narrow surface:
```csharp
// AcDream.App.UI
public interface IUiViewportRenderer
{
// Renders the scene into an internal FBO sized (w,h); returns the GL color-texture handle.
// Called by the per-frame pre-UI hook, NOT from UiViewport.OnDraw.
uint Render(UiViewportScene scene, int width, int height);
}
```
`UiViewportScene` carries the doll entity ref + camera params (plain data; no GL). The pre-UI hook calls
`Render` and stores the returned handle on the widget; `UiViewport.OnDraw` blits that handle (§B).
**`PaperdollViewportRenderer` (App rendering layer)** owns:
1. A `ManagedGLFramebuffer` sized to the widget rect, recreated when the rect changes.
2. The doll `DollCamera` (`ICamera`): eye `(0.12, 2.4, 0.88)`, look-at origin, perspective with the
rect's aspect. Decode/confirm the exact float→matrix mapping against `UIElement_Viewport::SetCamera`.
3. Per frame, gated on **inventory-open ∧ doll-view**: open a `GLStateScope`; bind the FBO; set viewport
to (w,h); clear color to transparent `(0,0,0,0)` + clear depth; write a **doll lighting UBO** (one
`DISTANT_LIGHT` = retail `(0.3,1.9,0.65)`@2.0, fixed ambient) reusing the scene-lighting UBO builder
(GameWindow ~8289); call `WbDrawDispatcher.Draw(DollCamera, [dollLandblockTuple],
animatedEntityIds:{dollGuid})`; dispose the scope (restores all GL state). Overwriting the shared
lighting UBO is safe — nothing 3-D draws after the doll this frame, and GameWindow rebuilds it next
frame.
### D. The doll entity — build, re-dress, animate
The doll is a **dedicated `WorldEntity`** (retail's `makeObject(player)` clone), distinct from the live
player entity (that one is posed/animated in the world):
- **Build** from `_lastSpawnByGuid[_playerServerGuid]` + `_entitiesByServerGuid[_playerServerGuid]`: same
Setup id, `MeshRefs`, `PaletteOverride`, `PartOverrides` — but at the **scene origin**, heading
**191.37°**. Assign a **reserved synthetic `ServerGuid`** (non-zero, distinct from every real guid) so
`EntitySpawnAdapter.OnCreate` accepts it and registers its meshes. Extract the WorldEntity-build
helper from GameWindow.cs:3390-3431 so both paths share it.
- **Animate:** route the doll through `EntitySpawnAdapter.OnCreate(dollEntity)` to get a private
`AnimatedEntityState`; play the idle animation (`m_didAnimation` analog — the Setup's idle motion).
Tick the doll sequencer each frame the doll is visible. Draw via a synthetic one-entry landblock tuple
`(dollLandblockId, aabb, [dollEntity], {dollGuid: dollEntity})`.
- **Re-dress** (C# `RedressCreature`): hook `OnLiveAppearanceUpdated` for `update.Guid ==
_playerServerGuid` to rebuild the doll entity's `PaletteOverride`/`PartOverrides` from the new spawn +
refresh the adapter state + re-register meshes. Naked when nothing equipped; re-geared on wield.
**Idle animation source:** confirm the Setup's idle motion id at impl (retail `m_didAnimation` set by
`UpdateForRace` via `DBObj::GetDIDByEnum` per body-type). MVP may use the Setup's default idle; per-race
idle DID swap is polish.
### E. Wiring + lifecycle (GameWindow)
- Extend the existing `PaperdollController.Bind` call site: also `FindElement(0x100001d5)` for the
`UiViewport`, construct `PaperdollViewportRenderer` (closing over `WbDrawDispatcher`, the
`EntitySpawnAdapter`, the player spawn/entity accessors, the lighting-UBO builder), give the controller
the viewport ref for the toggle, and register the viewport with the per-frame doll-render hook.
- **Per-frame:** drive the doll RTT pass only when the **inventory window is open** AND the paperdoll is
in **doll-view** AND the player spawn is available. Otherwise no FBO work.
- **Teardown:** dispose the FBO + the doll entity's adapter state on logout/teleport (`Clear()`), and
rebuild lazily on next show.
### F. Testing + gate
- **Unit (App.UI / App.Tests, no GL/dat):** the armor partition is exactly the decomp's 9 ids; toggling
flips the correct `Visible` set on {viewport, 9 armor slots} and leaves the 12 non-armor untouched;
default = doll-view. `DollCamera` golden View/Projection values. FBO-resize recreation logic
(rect-change → new framebuffer). The WorldEntity-build helper produces the expected
Setup/PaletteOverride/PartOverrides from a player spawn fixture.
- **Visual gate (the acceptance test):** user compares to retail — the doll renders the re-dressed local
player (correct race/gender/gear, naked if bare), faces the viewer, idles; the Slots button toggles
doll-view ↔ armor-slots; live re-dress on wield/unwield via `0xF625`.
- **Full suite green** (Core / Core.Net / App / UI) at the phase boundary, not just filtered subsets
([[feedback-ui-resolve-zero-magenta]] process lesson).
---
## 5. Component boundaries
| Unit | Project / file | Responsibility | Depends on |
|---|---|---|---|
| `PaperdollController` (extend) | `AcDream.App.UI.Layout` | armor/non-armor partition; Slots-button click → toggle `Visible`; hold the viewport ref | `UiButton`, `UiViewport`, `UiItemList` |
| `UiViewport` | `AcDream.App.UI` | leaf widget; blit its scene texture at its rect; gate the 3-D pass on `Visible` | `IUiViewportRenderer`, `UiRenderContext` |
| `IUiViewportRenderer` + `UiViewportScene` | `AcDream.App.UI` | the narrow UI↔3-D seam (data in, texture handle out) | BCL + Core `WorldEntity` only |
| `PaperdollViewportRenderer` | `AcDream.App.Rendering` | own the FBO + `DollCamera` + lighting; run the scissor-free RTT pass via `WbDrawDispatcher` inside a `GLStateScope` | `ManagedGLFramebuffer`, `GLStateScope`, `WbDrawDispatcher`, `EntitySpawnAdapter` |
| `DollCamera` | `AcDream.App.Rendering` | fixed `ICamera` from the retail immediates | `ICamera` |
| Doll-entity builder | `AcDream.App.Rendering` (shared helper) | build/refresh the doll `WorldEntity` from the player spawn | `WorldEntity`, player spawn/entity accessors |
| `DatWidgetFactory` (extend) | `AcDream.App.UI.Layout` | register Type `0xD → UiViewport` | — |
---
## 6. Divergence register impact
- **Reword AP-66** (empty-slot frame): stays in both views (no doll-through / transparent); the toggle
changes `Visible` only.
- **New rows (as applicable):** per-race camera framing deferred (single default camera); idle-DID
per-race swap deferred; the `IUiViewportRenderer` seam placed in App.UI rather than Core (intentional
adaptation, with rationale §4C); doll lighting = one distant light written to the shared UBO
(approximation iff it differs from retail's `CreatureMode` lighting).
- **Gate-verify AP-62** (MissileAmmo `0x100001E0` mask) carried from Slice 1 — opportunistic.
---
## 7. Items to pin during the plan (scoped, not open-ended)
1. `UiButton` click/checkbox API — how `PaperdollController` subscribes to the Slots-button click and
reads/sets its checked state.
2. The inventory window's **open/closed** query for the per-frame gate (the F12 window manager / the
frame's `Visible`).
3. The exact `WbDrawDispatcher.Draw` animation contract for a single entity — whether `animatedEntityIds`
+ the `AnimatedById` tuple is sufficient, or the doll's `AnimatedEntityState` must also be reachable
via the shared `_animatedEntities`/sequencer-tick path; and where the doll sequencer is ticked.
4. The scene-lighting UBO layout at GameWindow ~8289 — to write the doll's single distant light.
5. `UIElement_Viewport::SetCamera` arg order (pos vs dir) + the exact float→view-matrix construction, so
`DollCamera` frames the doll like retail.
6. The idle motion id for the player Setup (MVP default vs per-race DID).
These are mechanism confirmations with identified sources — not unresolved requirements.
---
## 8. Acceptance criteria
- Slots button toggles doll-view (3-D character + 12 non-armor slots) ↔ slot-view (9 armor slots),
matching the user's two retail screenshots.
- The doll renders the re-dressed local player (race/gender/equipped gear; naked if bare), faces the
viewer, idles; updates live on equip/unequip via `0xF625`.
- Build + full suite green; **visual gate** passed (user). Divergence rows updated; the seam respects
the layering decision in §4C.

View file

@ -0,0 +1,195 @@
# acdream UI Studio — design (live previewer + inspector)
- Date: 2026-06-25
- Status: approved (user pre-approved full v1 scope 2026-06-25)
- Branch: claude/hopeful-maxwell-214a12
- Supersedes/foundation for: the deferred phase-2 drag-drop *designer*
## Goal
A standalone, fast-booting dev tool that renders **any acdream UI panel** through the
**production renderer**, with a click-to-inspect element inspector, sample-data fixtures,
markup hot-reload + write-back editing, and render-config sliders.
It collapses the panel-iteration loop — today `edit → build → login to ACE → wait ~8s →
F12 → eyeball vs retail → close → repeat` (~3060 s/iteration) — into `edit → glance`
(~1 s). Everything we did on the paperdoll this session (confirming the pose, framing the
camera, catching the mirrored heading) would have been a few clicks here instead of a dozen
relaunches.
**Fidelity is the whole point.** The previewed panel is drawn by the SAME
`UiHost` / `LayoutImporter` / `DatWidgetFactory` / dat-sprite path that ships, so what you
see is what the game renders. This is the decisive reason NOT to build the tool in a
separate stack (Godot/HTML) and NOT to rely on the existing `render-vitals-mockup` (a
separate CPU `SurfaceDecoder` composite that can drift from the GL path).
## Scope — v1 is the maximum capability
IN:
- Load a panel from EITHER a dat `LayoutDesc` id OR a markup file (`MarkupDocument`).
- Render via the production GL `UiHost` (2-D panels) AND the full WB mesh pipeline (the
3-D paperdoll **doll**).
- **Sample-data fixtures** so data-bound panels look populated: canned inventory items,
sample vital values, a dressed sample creature for the doll.
- **Inspector (ImGui):** element tree, click-to-inspect, read-only props
(id / Type / rect / anchors / state-sprites / ZOrder), **editable** markup props
(write back to the file), and **render-config sliders** (the doll camera eye / FOV /
heading — values that live in code, not markup).
- **Hot-reload:** file-watch the markup source; reload-on-demand (id picker / reload button)
for dat layouts.
OUT (phase 2): the drag-drop visual **designer** (compose *new* layouts on a canvas and
emit markup). The previewer is the foundation that phase sits on. Its natural home is the
plugin-UI story, not the retail-panel work.
Not faithfulness-gated: this is a DEV tool. The previewed *panels* are faithful (same
renderer); the tool *chrome* (the ImGui inspector) is dev-only. No
`retail-divergence-register` rows are created by this work.
## Architecture
Seven isolated, single-purpose units. The previewed panel is drawn by the production
renderer; the tool chrome is **ImGui** (already in `AcDream.App` for the `ACDREAM_DEVTOOLS`
overlay — ideal for a tree / sliders / editable fields at near-zero cost).
```
Program.cs ──"ui-studio"──▶ StudioWindow
│ owns: Silk window, ImGui ctx, frame loop
RenderBootstrap.Create(gl, opts) ──▶ RenderStack
│ (GL + DatCollection + TextureCache + UiHost
│ + ObjectMeshManager + WbDrawDispatcher + lighting UBO)
│ ALSO consumed by GameWindow (shared = de-tangles OnLoad)
┌─────────────────────┼───────────────────────────────┐
▼ ▼ ▼
LayoutSource FixtureProvider StudioInspector (ImGui)
dat id | markup sample item table / vitals / tree · props(read+edit) ·
→ UiElement tree dressed creature → controllers render sliders · canvas(FBO)
│ │ click → HitTestTopDown
└────────── HotReloadWatcher (markup) ─────────────────┘
MarkupWriteBack (edited props → file)
```
### 1. `RenderBootstrap` — NEW, `src/AcDream.App/Rendering/`
Extracts the **render-stack construction** currently tangled inside `GameWindow.OnLoad`
(the GL api, dat-dir → `DatCollection`, `TextureCache`, the WB mesh pipeline —
`ObjectMeshManager` / `WbDrawDispatcher` / `SceneLightingUboBinding` — and `UiHost`) into a
reusable unit that BOTH `GameWindow` and `StudioWindow` build through.
- Interface: `RenderStack Create(GL gl, RenderBootstrapOptions opts)`; `RenderStack` is a
record of the constructed pieces (dats, textureCache, meshManager, drawDispatcher,
lightingUbo, uiHost, shaderDir).
- `GameWindow.OnLoad` is refactored to consume it — **behavior-preserving**, locked by the
existing App/Core suites + a visual gate that the GAME still renders.
- Highest-risk step → extract the SMALLEST cohesive slice only; do it first, gate it, build
the studio on it second. Fallback if extraction is too entangled: `StudioWindow` builds a
studio-local duplicate of the mesh-pipeline setup (more drift, lower regression risk) — to
be decided at implementation, not assumed away.
### 2. `StudioWindow` — NEW, `src/AcDream.App/Studio/`
A Silk.NET windowed GL app — the studio entry. Owns the window, the `RenderStack`, the ImGui
context, the inspector, the hot-reload watcher, and the per-frame loop. **No** ACE /
world-streaming / physics / movement input. Launched from `Program.cs`:
`AcDream.App ui-studio <datdir> [--layout 0x21000023 | --markup path.xml]`. Config flows
through `RuntimeOptions` (one env/arg read), per the code-structure rules.
### 3. `LayoutSource` — NEW, `src/AcDream.App/Studio/`
Loads a panel into a `UiElement` subtree from a dat id (`LayoutImporter.Import`) or a markup
file (`MarkupDocument.Build`). Owns the current source + `Reload()`.
- Interface: `UiElement Load()`, `void Reload()`, `SourceKind Kind`, `string? MarkupPath`,
`uint? LayoutId`, plus a `string? LastError` for the inspector to surface parse failures.
### 4. `FixtureProvider` — NEW, `src/AcDream.App/Studio/`
Supplies sample data so data-bound panels render populated. A fake `ClientObjectTable`
seeded with a handful of canned items (icon ids + types); sample vital values; a sample
creature (Setup + ObjDesc) for the doll. Maps a known `layoutId` → its real controller
(`VitalsController` / `InventoryController` / `ToolbarController` / `PaperdollController`)
bound to the fixtures — REUSING the production controllers, not re-implementing them.
- Interface: `void Populate(uint layoutId, UiElement root, RenderStack stack)`. Unknown
layout id → no fixture (structural preview only), not an error.
### 5. `StudioInspector` — NEW, `src/AcDream.App/Studio/`
The ImGui tool chrome, four docked windows:
- **Canvas** — the panel rendered to an FBO (RTT, the `PaperdollViewportRenderer` technique
applied to the whole panel) shown via `ImGui.Image`. Click → map canvas-local coords →
`UiRoot.HitTestTopDown` → select. The FBO route gives a clean dockable IDE layout +
accurate click mapping, and isolates the panel's GL state from ImGui's.
- **Tree** — the live `UiElement` hierarchy with each node's registered Type; select syncs
with the canvas highlight.
- **Properties** — the selected element's id / Type / rect / anchors / state-sprites (with
swatches) / ZOrder. Rect/anchor fields are **editable** (markup source) → applied live +
queued to `MarkupWriteBack`.
- **Render config** — sliders for the doll's `DollCamera` eye / FOV / heading, mutating the
live camera so the doll updates next frame; a "copy values" button emits the literals to
paste into code.
### 6. `MarkupWriteBack` — NEW, `src/AcDream.App/Studio/`
Serializes an edited element's changed props back to the markup XML file (markup source
only; dat layouts are read-only). v1 = **targeted in-place attribute updates** (find the
element by id, rewrite the changed attribute values), NOT a full tree re-serialize — so
hand-authored formatting/comments survive.
### 7. `HotReloadWatcher` — NEW, `src/AcDream.App/Studio/`
`FileSystemWatcher` on the markup file → debounced (≈150 ms) reload event → `LayoutSource.Reload`
`FixtureProvider.Populate` → re-select the previously-selected element by id when possible.
## Data flow
- **Boot:** `StudioWindow``RenderBootstrap.Create``LayoutSource.Load(initial)`
`FixtureProvider.Populate` → loop.
- **Frame:** bind panel FBO → `UiHost.Tick` + `Draw` (panel, incl. the doll's own nested
RTT) → unbind → ImGui frame (Canvas image + Tree + Properties + Render-config) → present.
- **Click canvas:** map coords → `HitTestTopDown` → select → Properties shows props.
- **Edit prop:** apply to the live `UiElement` (instant) + (markup) queue write-back.
- **File change:** watcher → `Reload``Populate` → re-select by id.
- **Render slider:** mutate `DollCamera` → next frame the doll re-renders.
## Error handling
- Bad layout id / unparseable markup → `LayoutSource.LastError` shown in the inspector; the
previous panel stays (or an empty canvas), no crash.
- Missing dats at boot → clear error + exit non-zero.
- Panel build/draw wrapped in try/catch → a thrown panel surfaces its exception text in the
inspector; the tool stays alive (you're often previewing half-broken markup).
- Write-back failure (file locked) → non-fatal inline notice, edit stays applied in-memory.
## Testing
- `LayoutSource`: load a known dat id + a markup string → assert the `UiElement` tree shape
(reuse the existing golden-fixture pattern, e.g. `vitals_2100006C.json`).
- `FixtureProvider`: assert the seeded `ClientObjectTable` + vitals shapes.
- `MarkupWriteBack`: round-trip — parse → edit a rect → serialize → re-parse → assert the
changed attribute + that siblings/formatting are untouched.
- `RenderBootstrap`: the existing suites stay green + a manual game visual gate (the refactor
is behavior-preserving).
- Window / ImGui / doll render: manual visual gate — the studio is itself the visual tool.
- Tests live in `tests/AcDream.App.Tests/Studio/`.
## Build order (staged; each ≈ one subagent chunk + a gate)
1. **`RenderBootstrap` extraction** — behavior-preserving. GATE: game renders unchanged;
suites green.
2. **`StudioWindow` skeleton + `LayoutSource`(dat id)** — render a 2-D panel to the window.
GATE: `ui-studio --layout 0x2100006C` shows vitals.
3. **`StudioInspector`** — ImGui + canvas-FBO + tree + read-only props + click-to-inspect.
GATE: click an element → see its id/rect/sprites.
4. **`FixtureProvider`** — 2-D panels populated (vitals / toolbar / inventory). GATE:
inventory shows sample items.
5. **Live doll**`PaperdollViewportRenderer` in the studio + sample creature. GATE: the
paperdoll shows a dressed doll.
6. **Markup source + `HotReloadWatcher`** — GATE: edit a markup file → studio reloads.
7. **Editable markup props + `MarkupWriteBack`** — GATE: edit a rect in the inspector → file
updates → reloads.
8. **Render-config sliders** — GATE: drag the eye/FOV → doll updates live.
Steps 14 deliver the high-value 2-D previewer + inspector + fixtures fast; 58 add the doll
and the editing power. The end-state is the full maximum-capability tool.
## Open risks
- **`RenderBootstrap` extraction from the tangled `GameWindow.OnLoad`** is the riskiest step.
Mitigation: smallest cohesive slice, behavior-preserving, gated by the game visual + the
suites BEFORE the studio depends on it; documented fallback (studio-local duplicate).
- **`MarkupWriteBack` formatting preservation** — keep v1 to in-place attribute edits, never
a full re-serialize, so hand-authored markup isn't clobbered.
- **ImGui + UiHost GL-state interplay** — the panel renders to its own FBO and ImGui owns the
default framebuffer, so their GL state can't fight (the same seal the paperdoll RTT uses).

View file

@ -4,6 +4,14 @@ using AcDream.App.Rendering;
using AcDream.Core.Plugins; using AcDream.Core.Plugins;
using Serilog; using Serilog;
if (args.Length >= 1 && args[0] == "ui-studio")
{
var so = AcDream.App.Studio.StudioOptions.Parse(args[1..]);
using var sw = new AcDream.App.Studio.StudioWindow(so);
sw.Run();
return 0;
}
Log.Logger = new LoggerConfiguration() Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug() .MinimumLevel.Debug()
.WriteTo.Console() .WriteTo.Console()

View file

@ -0,0 +1,53 @@
using System;
using System.Numerics;
namespace AcDream.App.Rendering;
/// <summary>
/// Fixed camera for the paperdoll mini-scene — retail-exact, ported from the gmPaperDollUI viewport
/// setup (decomp 0x004a5a390x004a5a69). The viewport (element <c>0x100001d5</c>) is configured by
/// <c>UIElement_Viewport::SetCamera(position, direction)</c> with:
/// <list type="bullet">
/// <item>position = (0.12, 2.4, 0.88) [hex 0x3df5c28f, 0xc019999a, 0x3f6147ae]</item>
/// <item>direction = (0, 0, 0) ⇒ <c>CreatureMode::SetCameraDirection</c> resets the view frame to
/// IDENTITY (<c>euler_set_rotate(0,0,0)</c> then <c>rotate(0,0,0)</c>), so the camera looks
/// straight down +Y with +Z up — NO yaw, NO pitch.</item>
/// </list>
/// The doll is framed purely by camera POSITION + FOV, NOT by aiming at the body: at distance 2.4 with
/// a π/4 (45°) vertical FOV the visible vertical band is z≈[0.11, 1.87], which covers the whole ~1.6 m
/// figure even though the model origin sits at the FEET (z=0). FOV π/4 is <c>CreatureMode</c>'s default
/// <c>m_fFOVRadians</c> (ctor 0x004543cf, hex 0x3f490fdb); default ambient is (0.3,0.3,0.3); the
/// paperdoll uses <c>UseSharpMode</c> (not SmartboxFOV) so <c>Render::SetFOVRad(m_fFOVRadians)</c> applies.
///
/// <para>An earlier hand-tune aimed the camera at mid-body to "fit the figure", which introduced a
/// spurious yaw (Target.x ≠ Eye.x) that rotated the view and turned the doll's face away from the
/// viewer. This restores retail's zero-yaw frame, where full-body framing comes from the eye height +
/// FOV, not from aiming.</para>
///
/// Uses the same <see cref="Matrix4x4.CreateLookAt"/> / <see cref="Matrix4x4.CreatePerspectiveFieldOfView"/>
/// convention as <see cref="ChaseCamera"/> so the doll's triangle winding + back-face culling match the
/// world render pass. AC up-axis = +Z.
/// </summary>
public sealed class DollCamera : ICamera
{
// Retail paperdoll camera origin (decomp 0x004a5a510x004a5a61).
private static readonly Vector3 Eye = new(0.12f, -2.4f, 0.88f);
// Identity view orientation ⇒ look straight down +Y (no yaw/pitch). Target = Eye + (0,1,0).
private static readonly Vector3 Target = new(0.12f, -1.4f, 0.88f);
private static readonly Vector3 Up = Vector3.UnitZ; // AC up-axis = +Z, same as ChaseCamera
/// <summary>Vertical field of view — retail <c>CreatureMode</c> default <c>m_fFOVRadians</c> = π/4 (45°).</summary>
public float FovRadians { get; set; } = MathF.PI / 4f;
public float Near { get; set; } = 0.1f; // same near plane as ChaseCamera / retail znear
public float Far { get; set; } = 50f; // doll scene is small; 50 m is ample
public float Aspect { get; set; } = 1f;
/// <inheritdoc/>
public Matrix4x4 View =>
Matrix4x4.CreateLookAt(Eye, Target, Up);
/// <inheritdoc/>
public Matrix4x4 Projection =>
Matrix4x4.CreatePerspectiveFieldOfView(FovRadians, Aspect <= 0f ? 1f : Aspect, Near, Far);
}

View file

@ -0,0 +1,133 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.World;
namespace AcDream.App.Rendering;
/// <summary>
/// Builds the dedicated paperdoll WorldEntity — retail's <c>makeObject(player)</c>
/// clone: the player's Setup id + current ObjDesc (base palette + subpalette overlays
/// + part overrides), posed at the scene origin facing the viewer.
///
/// <para>
/// The palette / part-override mapping mirrors the inline construction in
/// <c>GameWindow.cs</c> around lines 33903431. Extracted here so it is
/// unit-testable without dats and so the paperdoll renderer owns a clean
/// seam: it calls <see cref="Build"/> with fresh player state each time the
/// ObjDesc changes, and the renderer only has to swap the entity into its
/// dedicated mini-scene.
/// </para>
///
/// <para>
/// The <see cref="DollServerGuid"/> is a reserved synthetic guid that is:
/// (a) non-zero (so <c>EntitySpawnAdapter.OnCreate</c>'s <c>ServerGuid != 0</c>
/// guard accepts it), and (b) high enough that it never collides with a real
/// server-assigned guid.
/// </para>
/// </summary>
public static class DollEntityBuilder
{
/// <summary>
/// Reserved synthetic guid for the paperdoll clone. High, deliberately
/// outside the server's assignable range, and non-zero.
/// </summary>
public const uint DollServerGuid = 0xDA11_D011u;
/// <summary>
/// Reserved render-local entity id for the doll. FIXED (the doll is a singleton)
/// and high enough never to collide with <c>_liveEntityIdCounter</c> ids. The
/// renderer passes this in <c>animatedEntityIds</c> so the dispatcher treats the
/// doll as animated — which BYPASSES the Tier-1 classification cache
/// (WbDrawDispatcher.cs:1142). That matters because a re-dress builds a NEW
/// WorldEntity with this SAME id; without the cache bypass the dispatcher would
/// serve the previous doll's cached batches and the new gear wouldn't appear.
/// </summary>
public const uint DollRenderId = 0xDA11_D012u;
// retail RedressCreature: CPhysicsObj::set_heading(191.367905°) (decomp 0x004a3c0a). Frame::set_heading(h)
// (decomp 0x00535e40) builds the facing vector as (sin h°, cos h°, 0): at h=0 the creature faces +Y (AC
// North), the heading increasing CLOCKWISE toward +X. We rotate the doll's default +Y-forward body about
// +Z; System.Numerics rotates +Y → (sin θ, cos θ), so to land on retail's (sin h, cos h) the angle is
// NEGATED (θ = h). Using +h mirrors the X-lean (~22° off → the face reads as turned away from the viewer).
private const float _headingDegrees = 191.367905f;
private static readonly float _headingRad = -_headingDegrees * (MathF.PI / 180f);
private static readonly Quaternion _dollRotation =
Quaternion.CreateFromAxisAngle(new Vector3(0f, 0f, 1f), _headingRad);
/// <summary>
/// Builds a synthetic <see cref="WorldEntity"/> for the paperdoll mini-scene.
/// </summary>
/// <param name="setupId">The player's Setup dat id (0x02xxxxxx).</param>
/// <param name="meshRefs">Pre-resolved mesh refs (may be empty; caller fills them in).</param>
/// <param name="basePaletteId">
/// ObjDesc base palette id (0x04xxxxxx). Passed only when subpalettes are
/// also present — mirrors GameWindow which only builds a PaletteOverride
/// when <c>SubPalettes.Count &gt; 0</c>.
/// </param>
/// <param name="subPalettes">
/// Subpalette overlays from the server ObjDesc. Each tuple carries the
/// subpalette dat id, byte offset into the base palette, and byte length.
/// Null or empty → <c>PaletteOverride</c> on the returned entity is null.
/// </param>
/// <param name="partOverrides">
/// AnimPartChange swaps from the server ObjDesc. Each tuple is a
/// (PartIndex, replacement GfxObj id) pair. Null or empty → empty array.
/// </param>
public static WorldEntity Build(
uint setupId,
IReadOnlyList<MeshRef> meshRefs,
uint? basePaletteId = null,
IReadOnlyList<(uint SubPaletteId, byte Offset, byte Length)>? subPalettes = null,
IReadOnlyList<(byte PartIndex, uint GfxObjId)>? partOverrides = null)
{
// --- palette override (mirrors GameWindow:3395-3405) ---
// Only build when there are sub-palette overlays — same gate as GameWindow.
PaletteOverride? paletteOverride = null;
if (subPalettes is { Count: > 0 } spList)
{
var ranges = new PaletteOverride.SubPaletteRange[spList.Count];
for (int i = 0; i < spList.Count; i++)
ranges[i] = new PaletteOverride.SubPaletteRange(
spList[i].SubPaletteId,
spList[i].Offset,
spList[i].Length);
paletteOverride = new PaletteOverride(
BasePaletteId: basePaletteId ?? 0u,
SubPalettes: ranges);
}
// --- part overrides (mirrors GameWindow:3407-3418) ---
PartOverride[] entityPartOverrides;
if (partOverrides is null or { Count: 0 })
{
entityPartOverrides = Array.Empty<PartOverride>();
}
else
{
entityPartOverrides = new PartOverride[partOverrides.Count];
for (int i = 0; i < partOverrides.Count; i++)
entityPartOverrides[i] = new PartOverride(
partOverrides[i].PartIndex,
partOverrides[i].GfxObjId);
}
// --- assemble entity (mirrors GameWindow:3420-3431) ---
// Id=0: the paperdoll renderer assigns its own render-local id when it
// registers this entity with the mini-scene GpuWorldState. We set zero
// here so the builder stays pure (no static counter). The caller may
// replace it before registration.
return new WorldEntity
{
Id = DollRenderId,
ServerGuid = DollServerGuid,
SourceGfxObjOrSetupId = setupId,
Position = Vector3.Zero,
Rotation = _dollRotation,
MeshRefs = meshRefs,
PaletteOverride = paletteOverride,
PartOverrides = entityPartOverrides,
ParentCellId = null, // paperdoll mini-scene has no parent cell
};
}
}

View file

@ -667,6 +667,12 @@ public sealed class GameWindow : IDisposable
private AcDream.App.UI.Layout.InventoryController? _inventoryController; private AcDream.App.UI.Layout.InventoryController? _inventoryController;
// Phase D.2b Sub-phase C — paperdoll equip-slot controller (wield drag handler). // Phase D.2b Sub-phase C — paperdoll equip-slot controller (wield drag handler).
private AcDream.App.UI.Layout.PaperdollController? _paperdollController; private AcDream.App.UI.Layout.PaperdollController? _paperdollController;
// Phase D.2b Sub-phase C Slice 2 — the 3-D doll viewport: an off-screen RTT renderer, the UiViewport
// widget that blits it, the inventory frame (for the open-gate), and a dirty flag (re-dress on 0xF625).
private AcDream.App.Rendering.PaperdollViewportRenderer? _paperdollViewportRenderer;
private AcDream.App.UI.UiViewport? _paperdollViewportWidget;
private AcDream.App.UI.UiNineSlicePanel? _inventoryFrame;
private bool _paperdollDollDirty = true;
// Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad. // Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad.
private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry; private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry;
// Phase I.2: ImGui debug panel ViewModel. Lives for as long as // Phase I.2: ImGui debug panel ViewModel. Lives for as long as
@ -2296,7 +2302,15 @@ public sealed class GameWindow : IDisposable
sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask), sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
// Empty equip slots show a visible frame (same square as the inventory grid) so every // Empty equip slots show a visible frame (same square as the inventory grid) so every
// slot position is seen + usable; the live 3D character (the doll) is Slice 2. // slot position is seen + usable; the live 3D character (the doll) is Slice 2.
emptySlotSprite: contentsEmpty); emptySlotSprite: contentsEmpty,
datFont: vitalsDatFont); // Slice 2: caption the "Slots" toggle button
// Slice 2: capture the doll viewport widget (Type 0xD, element 0x100001D5) + the inventory
// frame so the per-frame pre-UI hook can render the 3-D doll into the widget's texture only
// when the window is open and in doll-view. The renderer itself is built after the
// WbDrawDispatcher exists (further down this method).
_paperdollViewportWidget = invLayout.FindElement(0x100001D5u) as AcDream.App.UI.UiViewport;
_inventoryFrame = inventoryFrame;
Console.WriteLine("[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023)."); Console.WriteLine("[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023).");
} }
@ -2407,6 +2421,16 @@ public sealed class GameWindow : IDisposable
// A.5 T22.5: apply A2C gate from quality preset. // A.5 T22.5: apply A2C gate from quality preset.
_wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage; _wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage;
// Slice 2: now that the dispatcher exists, build the paperdoll doll RTT renderer and hand it to
// the viewport widget. Reuses the dispatcher + the scene-lighting UBO + _gl. The widget only
// blits the texture; the pre-UI hook drives the 3-D pass (see OnRender).
if (_paperdollViewportWidget is not null && _sceneLightingUbo is not null)
{
_paperdollViewportRenderer = new AcDream.App.Rendering.PaperdollViewportRenderer(
_gl, _wbDrawDispatcher, _sceneLightingUbo);
_paperdollViewportWidget.Renderer = _paperdollViewportRenderer;
}
// Phase A8: EnvCellRenderer init. Shares _meshShader (mesh_modern.{vert,frag}) // Phase A8: EnvCellRenderer init. Shares _meshShader (mesh_modern.{vert,frag})
// with the dispatcher — both consume the same global mesh buffer (VAO/IBO) // with the dispatcher — both consume the same global mesh buffer (VAO/IBO)
// from ObjectMeshManager.GlobalBuffer. // from ObjectMeshManager.GlobalBuffer.
@ -3898,6 +3922,121 @@ public sealed class GameWindow : IDisposable
BasePaletteId = md.BasePaletteId, BasePaletteId = md.BasePaletteId,
}; };
OnLiveEntitySpawned(newSpawn); OnLiveEntitySpawned(newSpawn);
// Slice 2: a player appearance change (equip / unequip) rebuilt _entitiesByServerGuid[player]
// above; flag the paperdoll doll to re-clone from it on the next doll pass (the C# analog of
// RedressCreature). Cheap flag — the rebuild is deferred to the pre-UI hook when visible.
if (update.Guid == _playerServerGuid)
_paperdollDollDirty = true;
}
/// <summary>
/// Rebuilds the paperdoll doll from the live player entity (retail makeObject(player) +
/// DoObjDescChangesFromDefault). Clones the player's already-resolved Setup / MeshRefs / palette /
/// part overrides into a dedicated <see cref="DollEntityBuilder"/> entity posed at the scene origin
/// facing the viewer. The MeshRefs are COPIED so the doll holds a stable pose independent of the
/// player's live in-world animation. Returns false (leaving the dirty flag set to retry) when the
/// player entity isn't available yet.
/// </summary>
private bool RefreshPaperdollDoll()
{
if (_paperdollViewportRenderer is null) return false;
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe) || pe.MeshRefs.Count == 0)
{
_paperdollViewportRenderer.SetDoll(null);
return false; // player not ready — retry next frame
}
uint? basePal = null;
List<(uint, byte, byte)>? subs = null;
if (pe.PaletteOverride is { } po)
{
basePal = po.BasePaletteId;
subs = new List<(uint, byte, byte)>();
foreach (var r in po.SubPalettes) subs.Add((r.SubPaletteId, r.Offset, r.Length));
}
List<(byte, uint)>? parts = null;
if (pe.PartOverrides.Count > 0)
{
parts = new List<(byte, uint)>(pe.PartOverrides.Count);
foreach (var p in pe.PartOverrides) parts.Add((p.PartIndex, p.GfxObjId));
}
var meshRefsCopy = new List<AcDream.Core.World.MeshRef>(pe.MeshRefs); // dressed parts (player's live frame)
var doll = AcDream.App.Rendering.DollEntityBuilder.Build(
pe.SourceGfxObjOrSetupId, meshRefsCopy, basePal, subs, parts);
ApplyPaperdollPose(doll, pe.SourceGfxObjOrSetupId); // re-pose into the paperdoll "posing" stance
_paperdollViewportRenderer.SetDoll(doll);
return true;
}
/// <summary>
/// Resolve the paperdoll "posing" stance DID (the model pose — left leg back) exactly as retail:
/// <c>gmPaperDollUI</c> ctor (decomp 174243) sets <c>m_didAnimation = DBObj::GetDIDByEnum(0x10000005, 7)</c>,
/// which is <c>DBCache::GetDIDFromEnumStatic</c> (decomp 20380) = <c>master[MasterMapId][0x10000005]</c>
/// → submap <c>0x25000009</c> → key <c>7</c>. (Icon effects use keys 1-6 of the same submap; key 7 is the
/// paperdoll pose.) Per-race <c>UpdateForRace</c> override deferred — the ctor default applies to all
/// body-types for now. Returns 0 if the chain can't resolve.
/// </summary>
private uint ResolvePaperdollPoseDid()
{
var dats = _dats;
if (dats is null) return 0u;
uint masterDid = (uint)dats.Portal.Header.MasterMapId;
if (masterDid == 0) return 0u;
if (!dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(masterDid, out var master)) return 0u;
// DBCache::GetDIDFromEnum (decomp 20380) resolves master[arg4] → submap, then submap[arg3].
// GetDIDFromEnumStatic(0x10000005, 7) ⇒ arg3=0x10000005, arg4=7 ⇒ master[7] → submap, submap[0x10000005].
if (!master.ClientEnumToID.TryGetValue(7u, out var subDid)) return 0u; // master-level key = 7
if (!dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(subDid, out var sub)) return 0u;
return sub.ClientEnumToID.TryGetValue(0x10000005u, out var did) ? did : 0u; // submap index = 0x10000005
}
/// <summary>
/// Re-pose the doll's already-dressed parts into the paperdoll "posing" stance — the C# analog of
/// retail <c>set_sequence_animation(doll, m_didAnimation, …)</c>. Keeps each cloned part's GfxObjId +
/// surface overrides (the dressed appearance), but replaces its transform with the pose animation's
/// frame-0 per-part transform, using the same Scale·Rotate·Translate composition as the per-frame
/// animation tick (<see cref="TickAnimations"/>, GameWindow.cs ~9999). Static (the pose is fixed). If the
/// DID does not resolve to an <c>Animation</c>, the doll keeps its cloned pose (no regression) — the log
/// line surfaces what resolved so the pose is verified, not guessed.
/// </summary>
private void ApplyPaperdollPose(AcDream.Core.World.WorldEntity doll, uint setupId)
{
var dats = _dats;
if (dats is null) return;
// Retail's paperdoll doll is STATIC — it holds the pose animation's frame, it does NOT loop.
_animatedEntities.Remove(doll.Id);
// poseDID is cdb-CONFIRMED = 0x030003C0 (== retail gmPaperDollUI m_didAnimation for Horan; UpdateForRace
// did not override). Crash guard: only load a 0x03xxxxxx (Animation) DID (a non-animation id parses a
// garbage frame count → OOM).
uint poseDid = ResolvePaperdollPoseDid();
if ((poseDid >> 24) != 0x03u) return;
var anim = dats.Get<DatReaderWriter.DBObjs.Animation>(poseDid);
var setup = dats.Get<DatReaderWriter.DBObjs.Setup>(setupId);
if (anim is null || setup is null || anim.PartFrames.Count == 0) return;
// Retail plays the pose animation once and HOLDS the last frame (set_sequence_animation framerate=0,
// RedressCreature decomp 0x004a3c22). For 0x030003C0 the animation has only two distinct keyframes —
// frame 0 (a transitional, raised/bent arm) and frames 1..N (all byte-identical: the settled stance,
// arms down + one leg back) — verified by dumping the dat. The held pose is therefore the last frame.
int frameIdx = anim.PartFrames.Count - 1;
var frame = anim.PartFrames[frameIdx];
var src = doll.MeshRefs;
var reposed = new List<AcDream.Core.World.MeshRef>(src.Count);
for (int i = 0; i < src.Count; i++)
{
var scale = i < setup.DefaultScale.Count ? setup.DefaultScale[i] : System.Numerics.Vector3.One;
System.Numerics.Vector3 origin = System.Numerics.Vector3.Zero;
System.Numerics.Quaternion orient = System.Numerics.Quaternion.Identity;
if (i < frame.Frames.Count) { origin = frame.Frames[i].Origin; orient = frame.Frames[i].Orientation; }
var transform = System.Numerics.Matrix4x4.CreateScale(scale)
* System.Numerics.Matrix4x4.CreateFromQuaternion(orient)
* System.Numerics.Matrix4x4.CreateTranslation(origin);
reposed.Add(new AcDream.Core.World.MeshRef(src[i].GfxObjId, transform) { SurfaceOverrides = src[i].SurfaceOverrides });
}
doll.MeshRefs = reposed;
} }
/// <summary> /// <summary>
@ -9329,6 +9468,20 @@ public sealed class GameWindow : IDisposable
SkipWorldGeometry: ; SkipWorldGeometry: ;
} }
// Phase D.2b Sub-phase C Slice 2 — paperdoll 3-D doll: render the re-dressed player clone into the
// viewport's off-screen texture BEFORE the 2-D UI pass blits it, but only when the inventory window
// is open AND the paperdoll is in doll-view (the widget's Visible is driven by the Slots toggle).
// The 3-D pass is fully sealed in a GLStateScope so it doesn't disturb the world/UI state.
if (_options.RetailUi && _paperdollViewportRenderer is not null
&& _paperdollViewportWidget is { Visible: true } dollWidget
&& _inventoryFrame is { Visible: true })
{
if (_paperdollDollDirty && RefreshPaperdollDoll())
_paperdollDollDirty = false;
dollWidget.TextureHandle = _paperdollViewportRenderer.Render(
(int)dollWidget.Width, (int)dollWidget.Height);
}
// Phase D.2b — retail-look UI tree (render-only; input integration deferred). // Phase D.2b — retail-look UI tree (render-only; input integration deferred).
// Self-contained 2D pass: UiHost.Draw → TextRenderer.Flush sets its own // Self-contained 2D pass: UiHost.Draw → TextRenderer.Flush sets its own
// blend/depth state and restores. Drawn before ImGui so the devtools // blend/depth state and restores. Drawn before ImGui so the devtools
@ -13469,6 +13622,7 @@ public sealed class GameWindow : IDisposable
_terrain?.Dispose(); _terrain?.Dispose();
_terrainModernShader?.Dispose(); _terrainModernShader?.Dispose();
_sceneLightingUbo?.Dispose(); _sceneLightingUbo?.Dispose();
_paperdollViewportRenderer?.Dispose(); // Slice 2: the doll's off-screen FBO + textures
_particleRenderer?.Dispose(); _particleRenderer?.Dispose();
_debugLines?.Dispose(); _debugLines?.Dispose();
_uiHost?.Dispose(); _uiHost?.Dispose();

View file

@ -0,0 +1,193 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.App.UI;
using AcDream.Core.Lighting;
using AcDream.Core.World;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// Render-to-texture renderer for the paperdoll 3-D doll (the C# analog of retail
/// <c>CreatureMode::Render</c>). Draws ONE re-dressed player clone (a <see cref="WorldEntity"/>,
/// built by <see cref="DollEntityBuilder"/> + wired by GameWindow) into a private off-screen
/// framebuffer with a fixed <see cref="DollCamera"/> + one distant light, then hands the color
/// texture to the <see cref="UiViewport"/> widget which blits it as a normal 2-D sprite.
///
/// <para>The whole 3-D pass is sealed in a <see cref="GLStateScope"/> so it can't disturb the
/// surrounding world / UI GL state ([[feedback_render_self_contained_gl_state]]). It runs in the
/// per-frame pre-UI hook (GameWindow), gated on inventory-open ∧ doll-view — NOT from the widget's
/// 2-D OnDraw.</para>
///
/// <para>Reuses the existing <see cref="WbDrawDispatcher"/> + the player's already-resolved,
/// already-GPU-resident MeshRefs (the doll clones them), so no separate mesh/texture upload is
/// needed. With <c>frustum: null</c> the doll's synthetic landblock is always "visible" and the
/// entity is walked from <c>entry.Entities</c> and drawn from its current MeshRefs — a STATIC pose
/// for now; idle animation (TickAnimations rebuilding the doll's MeshRefs) is a later slice.</para>
/// </summary>
public sealed unsafe class PaperdollViewportRenderer : IUiViewportRenderer, IDisposable
{
private readonly GL _gl;
private readonly WbDrawDispatcher _dispatcher;
private readonly SceneLightingUboBinding _lightUbo;
private readonly DollCamera _camera = new();
// Off-screen target (lazily (re)created on size change).
private uint _fbo;
private uint _colorTex;
private uint _depthRbo;
private int _fbW;
private int _fbH;
// The doll WorldEntity (held by reference: when GameWindow's TickAnimations later rebuilds its
// MeshRefs each frame, this renderer sees the updated pose automatically). null = nothing to draw.
private WorldEntity? _doll;
// Synthetic landblock for the doll's one-entry Draw tuple. With frustum:null + this as
// neverCullLandblockId the entity is never culled. 0 is unused by live streaming on the doll path.
private const uint DollLandblockId = 0u;
// The doll's render id, passed as "animated" so the dispatcher bypasses the Tier-1 classification
// cache (WbDrawDispatcher.cs:1142) and re-classifies from the current MeshRefs every frame. This is
// what makes a re-dress (a new WorldEntity built with the same DollRenderId) actually take effect,
// AND what lets the idle animation (later slice, TickAnimations rebuilds MeshRefs) show.
private static readonly HashSet<uint> _dollAnimatedIds = new() { DollEntityBuilder.DollRenderId };
public PaperdollViewportRenderer(GL gl, WbDrawDispatcher dispatcher, SceneLightingUboBinding lightUbo)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
_lightUbo = lightUbo ?? throw new ArgumentNullException(nameof(lightUbo));
}
/// <summary>Set (or clear) the doll entity. GameWindow builds/re-dresses it from the live player.</summary>
public void SetDoll(WorldEntity? doll) => _doll = doll;
/// <inheritdoc/>
public uint Render(int width, int height)
{
var doll = _doll;
if (doll is null || doll.MeshRefs.Count == 0 || width <= 0 || height <= 0)
return 0u;
EnsureFramebuffer(width, height);
if (_fbo == 0) return 0u;
_camera.Aspect = width / (float)height;
// Seal the entire 3-D pass — GLStateScope restores viewport/scissor/depth/cull/blend/program/
// FBO bindings on dispose, so the surrounding world + 2-D UI passes are untouched.
using var scope = new GLStateScope(_gl);
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, _fbo);
_gl.Viewport(0, 0, (uint)width, (uint)height);
_gl.Disable(EnableCap.ScissorTest);
_gl.ClearColor(0f, 0f, 0f, 0f); // transparent — the window backdrop shows through behind the doll
_gl.ClearDepth(1.0);
_gl.DepthMask(true); // depth clears honor glDepthMask
_gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
_gl.Enable(EnableCap.DepthTest);
_gl.DepthFunc(DepthFunction.Less);
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.Ccw); // matches the world object pass; if the doll renders inside-out at the gate, flip to Cw
_gl.Disable(EnableCap.Blend);
UploadDollLight();
// One synthetic landblock entry holding just the doll. frustum:null ⇒ always "visible" ⇒
// walked from entry.Entities and drawn from doll.MeshRefs (WbDrawDispatcher.cs:692,1190).
var entities = new WorldEntity[] { doll };
var entries = new (uint, Vector3, Vector3, IReadOnlyList<WorldEntity>, IReadOnlyDictionary<uint, WorldEntity>?)[]
{
(DollLandblockId, new Vector3(-4f, -4f, -4f), new Vector3(4f, 4f, 4f), entities, null),
};
_dispatcher.Draw(
_camera,
entries,
frustum: null,
neverCullLandblockId: DollLandblockId,
visibleCellIds: null,
animatedEntityIds: _dollAnimatedIds); // doll treated as animated ⇒ cache-bypass (re-dress correctness)
return _colorTex;
}
/// <summary>Overwrite the shared scene-lighting UBO (binding=1) with the doll's single distant
/// light. Safe: nothing else draws 3-D after the doll this frame, and GameWindow rebuilds the
/// world UBO at the start of the next frame. Retail values: DISTANT_LIGHT dir (0.3,1.9,0.65)
/// intensity 2.0 (gmPaperDollUI::PostInit decomp 175529-175533).</summary>
private void UploadDollLight()
{
var dir = Vector3.Normalize(new Vector3(0.3f, 1.9f, 0.65f));
var ubo = new SceneLightingUbo
{
Light0 = new UboLight
{
PosAndKind = new Vector4(0f, 0f, 0f, 0f), // kind 0 = directional
DirAndRange = new Vector4(dir, 1e9f), // huge range = no distance cutoff
ColorAndIntensity = new Vector4(1f, 1f, 1f, 2.0f), // white @ retail intensity 2.0
ConeAngleEtc = Vector4.Zero,
},
CellAmbient = new Vector4(0.30f, 0.30f, 0.30f, 1f), // retail CreatureMode default ambient (ctor 0x004543cf = 0.3,0.3,0.3)
FogParams = new Vector4(1e9f, 1e9f, 0f, 0f), // fog pushed to infinity = no fog on the doll
FogColor = Vector4.Zero,
CameraAndTime = new Vector4(0.12f, -2.4f, 0.88f, 0f), // camera world pos (matches DollCamera eye)
};
_lightUbo.Upload(ubo);
}
/// <summary>(Re)create the FBO + color texture + depth-stencil renderbuffer when the requested
/// size changes. Color is RGBA8 with linear filtering + clamp; depth is Depth24Stencil8.</summary>
private void EnsureFramebuffer(int width, int height)
{
if (_fbo != 0 && width == _fbW && height == _fbH) return;
DeleteFramebuffer();
_fbW = width;
_fbH = height;
_colorTex = _gl.GenTexture();
_gl.BindTexture(TextureTarget.Texture2D, _colorTex);
_gl.TexImage2D(TextureTarget.Texture2D, 0, InternalFormat.Rgba8, (uint)width, (uint)height, 0,
PixelFormat.Rgba, PixelType.UnsignedByte, (void*)0);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMinFilter.Linear);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.ClampToEdge);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.ClampToEdge);
_gl.BindTexture(TextureTarget.Texture2D, 0);
_depthRbo = _gl.GenRenderbuffer();
_gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, _depthRbo);
_gl.RenderbufferStorage(RenderbufferTarget.Renderbuffer, InternalFormat.Depth24Stencil8, (uint)width, (uint)height);
_gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, 0);
_fbo = _gl.GenFramebuffer();
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, _fbo);
_gl.FramebufferTexture2D(FramebufferTarget.Framebuffer, FramebufferAttachment.ColorAttachment0,
TextureTarget.Texture2D, _colorTex, 0);
_gl.FramebufferRenderbuffer(FramebufferTarget.Framebuffer, FramebufferAttachment.DepthStencilAttachment,
RenderbufferTarget.Renderbuffer, _depthRbo);
var status = _gl.CheckFramebufferStatus(FramebufferTarget.Framebuffer);
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, 0);
if (status != GLEnum.FramebufferComplete)
{
Console.WriteLine($"[paperdoll] framebuffer incomplete: {status} ({width}x{height})");
DeleteFramebuffer();
}
}
private void DeleteFramebuffer()
{
if (_fbo != 0) { _gl.DeleteFramebuffer(_fbo); _fbo = 0; }
if (_colorTex != 0) { _gl.DeleteTexture(_colorTex); _colorTex = 0; }
if (_depthRbo != 0) { _gl.DeleteRenderbuffer(_depthRbo); _depthRbo = 0; }
_fbW = _fbH = 0;
}
public void Dispose() => DeleteFramebuffer();
}

View file

@ -0,0 +1,231 @@
using System.Collections.Concurrent;
using DatReaderWriter;
using Microsoft.Extensions.Logging.Abstractions;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// The subset of the production render stack that the UI Studio needs:
/// GL + dats + UiHost + the WB mesh pipeline. Constructed from the same
/// classes and the same order as <see cref="GameWindow.OnLoad"/>, minus
/// terrain / sky / physics / streaming.
/// </summary>
public sealed record RenderStack(
GL Gl,
DatReaderWriter.DatCollection Dats,
string ShaderDir,
Wb.BindlessSupport Bindless,
TextureCache TextureCache,
Shader MeshShader,
Wb.WbMeshAdapter MeshAdapter,
Wb.EntitySpawnAdapter EntitySpawnAdapter,
Wb.WbDrawDispatcher DrawDispatcher,
SceneLightingUboBinding LightingUbo,
AcDream.App.UI.UiHost UiHost,
AcDream.App.UI.UiDatFont? VitalsDatFont,
AcDream.App.UI.UiDatFont? LargeDatFont) : System.IDisposable
{
/// <summary>Dispose the GL pieces this stack OWNS (everything created in
/// <see cref="RenderBootstrap.Create"/>). <see cref="Dats"/> + <see cref="Gl"/> are caller-owned
/// and NOT disposed here. Called once at studio teardown.</summary>
public void Dispose()
{
DrawDispatcher.Dispose();
MeshAdapter.Dispose();
TextureCache.Dispose();
MeshShader.Dispose();
LightingUbo.Dispose();
UiHost.Dispose();
}
/// <summary>
/// Resolves a sprite id (0x06xxxxxx) to a (GL handle, width, height) triple.
/// Copied verbatim from GameWindow's ResolveChrome closure — it calls
/// TextureCache.GetOrUploadRenderSurface(id, out w, out h).
/// </summary>
public (uint handle, int width, int height) ResolveChrome(uint spriteId)
{
uint t = TextureCache.GetOrUploadRenderSurface(spriteId, out int w, out int h);
return (t, w, h);
}
// ── Font cache (per-stack, keyed by FontDid) ─────────────────────────────
/// <summary>
/// Cache of loaded dat fonts keyed by FontDid (0x40000000-range).
/// Populated lazily by <see cref="ResolveDatFont"/>. Thread-safe for
/// concurrent reads from the studio render loop; writes happen only
/// during the first load of each distinct FontDid.
/// </summary>
private readonly ConcurrentDictionary<uint, AcDream.App.UI.UiDatFont?> _fontCache = new();
/// <summary>
/// Lazily load and cache a dat font by its FontDid. Returns null (and
/// caches null) when the Font DBObj is absent or has no foreground surface —
/// callers fall back to the global font in that case.
///
/// <para>Pre-seeds <see cref="VitalsDatFont"/> (0x40000000) and
/// <see cref="LargeDatFont"/> (0x40000001) from the already-loaded instances
/// to avoid a redundant upload on those two ids.</para>
/// </summary>
public AcDream.App.UI.UiDatFont? ResolveDatFont(uint fontDid)
{
return _fontCache.GetOrAdd(fontDid, id =>
AcDream.App.UI.UiDatFont.Load(Dats, TextureCache, id));
}
/// <summary>
/// Pre-seeds the font cache from the two already-loaded font instances
/// (VitalsDatFont = 0x40000000, LargeDatFont = 0x40000001) so that
/// <see cref="ResolveDatFont"/> returns them without a redundant GL upload.
/// Called once by <see cref="RenderBootstrap.Create"/> after the stack is
/// fully constructed.
/// </summary>
internal void SeedFontCache()
{
if (VitalsDatFont is not null)
_fontCache.TryAdd(AcDream.App.UI.UiDatFont.DefaultFontId, VitalsDatFont);
if (LargeDatFont is not null)
_fontCache.TryAdd(0x40000001u, LargeDatFont);
}
}
/// <summary>Options for <see cref="RenderBootstrap.Create"/>.</summary>
public sealed record RenderBootstrapOptions(
AcDream.UI.Abstractions.Settings.QualitySettings Quality);
/// <summary>
/// Constructs the UI Studio's render stack from the production classes,
/// in the same order as <see cref="GameWindow.OnLoad"/>.
/// </summary>
public static class RenderBootstrap
{
/// <summary>
/// Build the studio's render stack. Throws <see cref="NotSupportedException"/>
/// (same message as GameWindow) if GL_ARB_bindless_texture or
/// GL_ARB_shader_draw_parameters are absent — the modern path is mandatory.
/// </summary>
public static RenderStack Create(
GL gl,
DatReaderWriter.DatCollection dats,
RenderBootstrapOptions opts)
{
// --- Bindless detection (GameWindow ~1701-1723) ---
if (!Wb.BindlessSupport.TryCreate(gl, out var bindless)
|| bindless is null
|| !bindless.HasShaderDrawParameters(gl))
{
throw new NotSupportedException(
"acdream requires GL_ARB_bindless_texture + GL_ARB_shader_draw_parameters " +
"(GL 4.3+ with bindless support). Your GPU/driver does not expose these extensions. " +
"If this is unexpected, please file a bug report with your GPU vendor + driver version.");
}
// --- Shared infra (GameWindow ~1198, ~1211) ---
string shaderDir = Path.Combine(AppContext.BaseDirectory, "Rendering", "Shaders");
var lightingUbo = new SceneLightingUboBinding(gl);
// --- Mesh shader (GameWindow ~1769-1771) ---
var meshShader = new Shader(gl,
Path.Combine(shaderDir, "mesh_modern.vert"),
Path.Combine(shaderDir, "mesh_modern.frag"));
// --- TextureCache (GameWindow ~1774) ---
var textureCache = new TextureCache(gl, dats, bindless);
// --- AnimLoader (GameWindow ~1240) ---
var animLoader = new AcDream.Core.Physics.DatCollectionLoader(dats);
// --- WbMeshAdapter (GameWindow ~2286-2287) ---
var wbLogger = NullLogger<Wb.WbMeshAdapter>.Instance;
var meshAdapter = new Wb.WbMeshAdapter(gl, dats, wbLogger);
// --- SequencerFactory (GameWindow ~2306-2334) ---
var capturedDats = dats;
var capturedAnimLoader = animLoader;
AcDream.Core.Physics.AnimationSequencer SequencerFactory(AcDream.Core.World.WorldEntity e)
{
if (capturedDats is not null && capturedAnimLoader is not null)
{
var setup = capturedDats.Get<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId);
if (setup is not null)
{
uint mtableId = (uint)setup.DefaultMotionTable;
if (mtableId != 0)
{
var mtable = capturedDats.Get<DatReaderWriter.DBObjs.MotionTable>(mtableId);
if (mtable is not null)
return new AcDream.Core.Physics.AnimationSequencer(
setup, mtable, capturedAnimLoader);
}
// Setup exists but no motion table — no-op sequencer.
return new AcDream.Core.Physics.AnimationSequencer(
setup,
new DatReaderWriter.DBObjs.MotionTable(),
capturedAnimLoader);
}
}
// Complete fallback: empty setup + empty motion table + null loader.
return new AcDream.Core.Physics.AnimationSequencer(
new DatReaderWriter.DBObjs.Setup(),
new DatReaderWriter.DBObjs.MotionTable(),
new NullAnimLoader());
}
// --- EntitySpawnAdapter (GameWindow ~2335-2336) ---
var entitySpawnAdapter = new Wb.EntitySpawnAdapter(
textureCache, SequencerFactory, meshAdapter);
// --- EntityClassificationCache (GameWindow ~217 — field initializer, new()) ---
var classificationCache = new Wb.EntityClassificationCache();
// --- WbDrawDispatcher (GameWindow ~2377-2381) ---
var drawDispatcher = new Wb.WbDrawDispatcher(
gl, meshShader, textureCache, meshAdapter, entitySpawnAdapter,
bindless, classificationCache);
drawDispatcher.AlphaToCoverage = opts.Quality.AlphaToCoverage;
// --- Vitals dat font (GameWindow ~1820-1822) ---
var vitalsDatFont = AcDream.App.UI.UiDatFont.Load(dats, textureCache);
// --- Larger retail font (0x40000001, MaxCharHeight=18) for attribute row text.
// The default font (0x40000000, 16px) renders the row names too small; the 18px
// variant (confirmed in client_portal.dat 2026-06-26) matches the retail character
// window list more closely (≈ icon height ≈ 24px target, 18px is best available).
var largeDatFont = AcDream.App.UI.UiDatFont.Load(dats, textureCache, 0x40000001u);
// --- UiHost (GameWindow ~1790); pass null for debugFont (only used as
// a fallback BitmapFont for the world-space HUD — not needed for the
// UI Studio, and BitmapFont requires a system font byte array) ---
var uiHost = new AcDream.App.UI.UiHost(gl, shaderDir, defaultFont: null);
var stack = new RenderStack(
Gl: gl,
Dats: dats,
ShaderDir: shaderDir,
Bindless: bindless,
TextureCache: textureCache,
MeshShader: meshShader,
MeshAdapter: meshAdapter,
EntitySpawnAdapter: entitySpawnAdapter,
DrawDispatcher: drawDispatcher,
LightingUbo: lightingUbo,
UiHost: uiHost,
VitalsDatFont: vitalsDatFont,
LargeDatFont: largeDatFont);
// Pre-seed the font cache with the two already-uploaded atlas instances
// so ResolveDatFont(0x40000000) and ResolveDatFont(0x40000001) hit the cache
// rather than re-uploading the same GL texture a second time.
stack.SeedFontCache();
return stack;
}
// NullAnimLoader mirrors GameWindow's private NullAnimLoader (GameWindow ~13327-13330).
private sealed class NullAnimLoader : AcDream.Core.Physics.IAnimationLoader
{
public DatReaderWriter.DBObjs.Animation? LoadAnimation(uint id) => null;
}
}

View file

@ -0,0 +1,235 @@
using System.Numerics;
using AcDream.App.UI;
namespace AcDream.App.Studio;
// ─────────────────────────────────────────────────────────────────────────────
// DumpLayout — load a panel from the retail UI layout dump
//
// The dump stores every node's rect in ABSOLUTE screen coordinates (the
// panel's design position in the retail UI, not relative to its parent).
// Evidence: for the "inventory" panel, the root node is at x=500,y=138 and
// its direct children are also at x=500,y=161 — the child y=161 is only
// 23 pixels below the parent y=138, which makes sense as a child offset
// (the header row), not as the raw rect. If the rects were parent-relative,
// (500,161) would place the child way off the window.
//
// DumpLayout converts absolute → parent-relative by computing:
// child.Left = child.Rect.X - parent.Rect.X
// child.Top = child.Rect.Y - parent.Rect.Y
//
// The root node (ParentTraversalIndex == null) is placed at (0,0) so the
// whole tree sits at the UiHost origin rather than at the panel's retail
// screen position.
// ─────────────────────────────────────────────────────────────────────────────
/// <summary>
/// Builds a static <see cref="UiElement"/> tree from the retail UI layout dump
/// JSON. The tree is a hierarchy of <see cref="DumpSpriteElement"/> (draws its
/// sprite) or plain <see cref="UiElement"/> containers (Group nodes), with each
/// node's <see cref="UiElement.EventId"/> set to the dump's element_id and
/// <see cref="UiElement.Name"/> set to the widget_kind string.
///
/// <para>This source is STATIC — no controllers, no FixtureProvider, no live
/// game data. It is a build reference for the UI Studio showing any of the 26
/// retail windows without needing the production panel wired up.</para>
/// </summary>
public static class DumpLayout
{
/// <summary>
/// Parse the dump at <paramref name="dumpPath"/>, find the panel whose slug
/// matches <paramref name="slug"/>, and build a <see cref="UiElement"/> tree.
///
/// <para>
/// <paramref name="resolve"/> maps a RenderSurface id (0x06xxxxxx) to a
/// (GL texture handle, native width, native height) triple — pass
/// <c>RenderStack.ResolveChrome</c> from the studio, or a stub returning
/// (1,1,1) for tests.
/// </para>
///
/// <para>Returns null and sets <paramref name="error"/> on failure.</para>
/// </summary>
public static UiElement? Load(
string dumpPath,
string slug,
Func<uint, (uint, int, int)> resolve,
out string? error)
{
// ── 1. Parse the dump JSON ────────────────────────────────────────
var dump = UiDumpModel.Parse(dumpPath);
if (dump is null)
{
error = $"[dump] Failed to parse '{dumpPath}'.";
return null;
}
// ── 2. Find the requested panel ───────────────────────────────────
var panel = dump.Panels.FirstOrDefault(
p => string.Equals(p.Slug, slug, StringComparison.OrdinalIgnoreCase));
if (panel is null)
{
error = $"[dump] Panel slug '{slug}' not found. " +
$"Available: {string.Join(", ", dump.Panels.Select(p => p.Slug))}";
return null;
}
if (panel.Nodes.Count == 0)
{
error = $"[dump] Panel '{slug}' has no nodes.";
return null;
}
// ── 3. Build a traversal-index → node lookup ──────────────────────
var byIndex = new Dictionary<int, DumpNode>(panel.Nodes.Count);
foreach (var n in panel.Nodes)
byIndex[n.TraversalIndex] = n;
// ── 4. Create UiElement objects for every node ────────────────────
var elements = new Dictionary<int, UiElement>(panel.Nodes.Count);
foreach (var node in panel.Nodes)
{
var el = BuildElement(node, resolve);
elements[node.TraversalIndex] = el;
}
// ── 5. Wire parentchild relationships + set parent-relative coords ─
UiElement? root = null;
foreach (var node in panel.Nodes)
{
var el = elements[node.TraversalIndex];
if (node.ParentTraversalIndex is null)
{
// Root node — place at (0,0) so the tree sits at the UiHost origin.
// The panel's absolute rect offset is discarded here (it was the
// retail design position inside the retail screen, which we don't need).
el.Left = 0f;
el.Top = 0f;
root = el;
}
else
{
// Non-root: convert absolute → parent-relative by subtracting parent rect.
// child.Left = child.Rect.X - parent.Rect.X
// child.Top = child.Rect.Y - parent.Rect.Y
// This preserves the visual layout inside each group without placing the
// entire panel at its retail screen origin.
var parentNode = byIndex[node.ParentTraversalIndex.Value];
el.Left = node.Rect.X - parentNode.Rect.X;
el.Top = node.Rect.Y - parentNode.Rect.Y;
var parentEl = elements[node.ParentTraversalIndex.Value];
parentEl.AddChild(el);
}
}
if (root is null)
{
error = $"[dump] Panel '{slug}': no root node found (all nodes have a parent).";
return null;
}
// Give the root the full panel dimensions (from the dump's width/height record).
root.Width = panel.Width;
root.Height = panel.Height;
error = null;
return root;
}
// ── Private helpers ───────────────────────────────────────────────────────
private static UiElement BuildElement(
DumpNode node,
Func<uint, (uint, int, int)> resolve)
{
uint imageId = UiDumpModel.PickImageId(node);
var kind = node.WidgetKind ?? "Group";
UiElement el;
if (imageId != 0 && !string.Equals(kind, "Group", StringComparison.OrdinalIgnoreCase))
{
// Sprite/Button/Scrollbar/Slider — create a sprite-drawing element.
el = new DumpSpriteElement(imageId, resolve)
{
Name = kind,
ClickThrough = true, // static mockup; no behavior
Anchors = AnchorEdges.None,
};
}
else
{
// Group (or sprite without an image) — plain container, no own draw.
el = new DumpGroupElement()
{
Name = kind,
ClickThrough = true,
Anchors = AnchorEdges.None,
};
}
// EventId is set from the dump's element_id (cast to uint — the decimal
// values in the JSON represent the same dat handle used at runtime).
el.EventId = (uint)node.ElementId;
el.Left = node.Rect.X; // overwritten by caller per root/child logic
el.Top = node.Rect.Y;
el.Width = node.Rect.Width;
el.Height = node.Rect.Height;
return el;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// DumpSpriteElement — minimal element that draws a single sprite
// ─────────────────────────────────────────────────────────────────────────────
/// <summary>
/// Draws a single sprite at its native size tiled to fill <see cref="UiElement.Width"/>
/// × <see cref="UiElement.Height"/>. Used for Sprite/Button/Scrollbar/Slider nodes from
/// the retail UI dump.
///
/// <para>We do NOT reuse <see cref="AcDream.App.UI.Layout.UiDatElement"/> here because
/// that class requires an <c>ElementInfo</c> with a populated <c>StateMedia</c>
/// dictionary — the dat-import plumbing — which is not needed for a static dump
/// preview. A minimal subclass keeps the code simpler and the dependency surface
/// smaller.</para>
/// </summary>
internal sealed class DumpSpriteElement : UiElement
{
private readonly uint _imageId;
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
public DumpSpriteElement(uint imageId, Func<uint, (uint tex, int w, int h)> resolve)
{
_imageId = imageId;
_resolve = resolve;
}
protected override void OnDraw(UiRenderContext ctx)
{
if (_imageId == 0) return;
var (tex, tw, th) = _resolve(_imageId);
if (tex == 0 || tw == 0 || th == 0) return;
// Tile at native resolution (same as UiDatElement.OnDraw — UV-repeat on both
// axes via GL_REPEAT, Width/tw and Height/th tile the texture).
ctx.DrawSprite(tex, 0, 0, Width, Height,
0, 0, Width / tw, Height / th, Vector4.One);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// DumpGroupElement — pure container (Group nodes from the dump)
// ─────────────────────────────────────────────────────────────────────────────
/// <summary>
/// Container element for dump Group nodes — no own draw, just hosts children.
/// Extending UiElement directly (no OnDraw override) gives transparent groups,
/// which matches Group nodes in the retail layout that have no background sprite.
/// </summary>
internal sealed class DumpGroupElement : UiElement
{
// No OnDraw — completely transparent container.
}

View file

@ -0,0 +1,159 @@
using AcDream.App.Rendering;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using DatReaderWriter;
namespace AcDream.App.Studio;
/// <summary>
/// Populates a loaded panel with sample data by calling the production
/// controller Bind methods against <see cref="SampleData"/>.
///
/// <para>
/// The studio is intentionally thin — there is no live game session, no
/// server connection, and no network. FixtureProvider bridges that gap by
/// feeding static fixtures (vitals percentages, a fake inventory, empty
/// shortcut lists) so the bound widgets show plausible state instead of
/// empty zeroes.
/// </para>
///
/// <para>
/// <b>IconIds approach:</b> raw-resolve stub — resolve the base <c>iconId</c>
/// via <see cref="RenderStack.ResolveChrome"/> and return the GL handle
/// directly. This is intentionally simpler than GameWindow's full
/// <see cref="IconComposer"/> (5-layer composite). The raw icon is enough
/// to confirm the grid cells draw something in the studio; the full
/// compositor is the live-game concern, not the layout preview concern.
/// </para>
/// </summary>
public static class FixtureProvider
{
/// <summary>
/// Populate <paramref name="layout"/> with sample data appropriate for
/// <paramref name="layoutId"/>. Calls the production controller Bind
/// methods so the panel's widgets drive off the same code path as the
/// full game.
/// </summary>
/// <param name="layoutId">The LayoutDesc dat id used to import the layout.</param>
/// <param name="layout">The imported layout whose widgets to populate.</param>
/// <param name="stack">The live render stack (for <see cref="RenderStack.ResolveChrome"/>
/// and <see cref="RenderStack.VitalsDatFont"/>).</param>
/// <param name="objects">A <see cref="ClientObjectTable"/> already seeded by
/// <see cref="SampleData.BuildObjectTable"/>.</param>
/// <param name="dats">The live DAT collection used to resolve per-list empty-slot sprites
/// (same lookup GameWindow.OnLoad performs for the production binding).</param>
public static void Populate(
uint layoutId,
ImportedLayout layout,
RenderStack stack,
ClientObjectTable objects,
DatCollection dats)
{
switch (layoutId)
{
case 0x2100006Cu: // vitals
VitalsController.Bind(layout,
healthPct: () => SampleData.HealthPct,
staminaPct: () => SampleData.StaminaPct,
manaPct: () => SampleData.ManaPct,
healthText: () => "80/100",
staminaText: () => "60/100",
manaText: () => "90/100");
break;
case 0x21000016u: // toolbar
ToolbarController.Bind(
layout,
objects,
shortcuts: () => System.Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
iconIds: MakeIconIds(stack),
useItem: _ => { },
combatState: null,
peaceDigits: null,
warDigits: null,
emptyDigits: null,
sendAddShortcut: null,
sendRemoveShortcut: null);
break;
case 0x21000023u: // inventory + paperdoll
{
// Resolve the per-list empty-slot art from the dat cell template, matching the
// exact lookup GameWindow.OnLoad performs (UIElement_ItemList::InternalCreateItem
// 0x004e3570 → attr 0x1000000e → catalog 0x21000037 → ItemSlot_Empty).
uint contentsEmpty = ItemListCellTemplate.ResolveEmptySprite(dats, 0x21000021u, 0x100001C6u);
uint sideBagEmpty = ItemListCellTemplate.ResolveEmptySprite(dats, 0x21000022u, 0x100001CAu);
uint mainPackEmpty = ItemListCellTemplate.ResolveEmptySprite(dats, 0x21000022u, 0x100001C9u);
var iconIds = MakeIconIds(stack);
InventoryController.Bind(
layout,
objects,
playerGuid: () => SampleData.PlayerGuid,
iconIds: iconIds,
strength: () => 100,
datFont: stack.VitalsDatFont,
contentsEmptySprite: contentsEmpty,
sideBagEmptySprite: sideBagEmpty,
mainPackEmptySprite: mainPackEmpty);
// Bind the paperdoll equip slots (same imported subtree as the inventory).
// Mirrors GameWindow:2257-2265: PaperdollController.Bind with contentsEmpty as
// emptySlotSprite (each slot shows the same square-frame placeholder as the grid).
PaperdollController.Bind(
layout,
objects,
playerGuid: () => SampleData.PlayerGuid,
iconIds: iconIds,
sendWield: null, // no live session in the studio
emptySlotSprite: contentsEmpty,
datFont: stack.VitalsDatFont);
break;
}
case 0x2100002Eu: // gmStatManagementUI — Attributes/Skills/Titles window (LayoutDesc 0x2100002E)
// Bind the REAL importer-mounted header + list elements (name/heritage/PK/level/
// total-XP/XP-meter + the 9-row attribute list + footer State-A). NOT the text-report
// sub-panel (that is gmCharacterInfoUI 0x2100001A → CharacterController).
// LargeDatFont (0x40000001, MaxCharHeight=18) is used for the attribute row text;
// fallback to VitalsDatFont (0x40000000, 16px) if unavailable.
CharacterStatController.Bind(
layout,
data: SampleData.SampleCharacter,
datFont: stack.VitalsDatFont,
rowDatFont: stack.LargeDatFont ?? stack.VitalsDatFont,
spriteResolve: stack.ResolveChrome);
break;
default:
// Unknown layout — no-op; the panel renders structurally.
break;
}
}
// ── Helpers ─────────────────────────────────────────────────────────────
/// <summary>
/// Build the <c>iconIds</c> delegate for toolbar / inventory controllers.
///
/// <para>
/// Raw-resolve stub: resolve the base <paramref name="iconId"/> (arg 2)
/// via <see cref="RenderStack.ResolveChrome"/> and return its GL handle.
/// The remaining args (type, underlayId, overlayId, effects) are ignored
/// for the studio — a single-layer icon is sufficient for layout preview.
/// </para>
///
/// <para>This is what the task spec calls "v1 raw-resolve stub".</para>
/// </summary>
private static Func<ItemType, uint, uint, uint, uint, uint> MakeIconIds(RenderStack stack)
=> (_, iconId, _, _, _) =>
{
if (iconId == 0u) return 0u;
var (handle, _, _) = stack.ResolveChrome(iconId);
return handle;
};
}

View file

@ -0,0 +1,123 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using DatReaderWriter;
namespace AcDream.App.Studio;
/// <summary>Which kind of source the studio is currently previewing.</summary>
public enum LayoutSourceKind { DatLayout, Markup }
/// <summary>
/// Wraps the two ways the UI Studio can load a panel to preview:
/// a LayoutDesc dat id, or a KSML markup file path (Task 6 — unsupported now).
///
/// <para>Call <see cref="Load"/> with the current <see cref="StudioOptions"/> to
/// import the layout and get the root <see cref="UiElement"/>. The result is also
/// cached in <see cref="CurrentLayout"/> so <see cref="Reload"/> can re-run the same
/// source without re-reading the options.</para>
/// </summary>
public sealed class LayoutSource
{
private readonly DatCollection _dats;
private readonly Func<uint, (uint, int, int)> _resolve;
private readonly UiDatFont? _datFont;
private readonly Func<uint, UiDatFont?>? _fontResolve;
public LayoutSourceKind Kind { get; private set; }
public uint? LayoutId { get; private set; }
public string? MarkupPath { get; private set; }
public string? LastError { get; private set; }
public ImportedLayout? CurrentLayout { get; private set; }
/// <summary>
/// Create a LayoutSource.
/// </summary>
/// <param name="fontResolve">Optional per-element font resolver: FontDid →
/// <see cref="UiDatFont"/> (null when the font isn't in the dats). When supplied,
/// elements with a non-zero FontDid receive their own dat font at build time
/// instead of the shared <paramref name="datFont"/> global. Controllers that
/// explicitly set <see cref="UiText.DatFont"/> after
/// <see cref="ImportedLayout.FindElement"/> still override the build-time value.
/// Pass null (default) for the original single-font behavior — the live
/// <see cref="GameWindow"/> path passes null so it is provably unchanged.</param>
public LayoutSource(
DatCollection dats,
Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont,
Func<uint, UiDatFont?>? fontResolve = null)
{
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
_resolve = resolve ?? throw new ArgumentNullException(nameof(resolve));
_datFont = datFont;
_fontResolve = fontResolve;
}
/// <summary>
/// Load the layout described by <paramref name="opts"/>. For a dat layout
/// (<see cref="StudioOptions.LayoutId"/> is non-null) calls
/// <see cref="LayoutImporter.Import"/>. For a markup path sets
/// <see cref="LastError"/> and returns null (Task 6, not yet implemented).
///
/// Returns the root <see cref="UiElement"/> on success, or null on failure
/// (check <see cref="LastError"/>).
/// </summary>
public UiElement? Load(StudioOptions opts)
{
LastError = null;
CurrentLayout = null;
if (opts.MarkupPath is not null)
{
Kind = LayoutSourceKind.Markup;
MarkupPath = opts.MarkupPath;
LastError = "markup unsupported (Task 6)";
return null;
}
if (opts.LayoutId is null)
{
LastError = "ui-studio: no layout id or markup path specified.";
return null;
}
Kind = LayoutSourceKind.DatLayout;
LayoutId = opts.LayoutId;
return LoadDat(opts.LayoutId.Value);
}
/// <summary>Re-run the most-recently-configured source without re-reading options.</summary>
public UiElement? Reload()
{
LastError = null;
CurrentLayout = null;
if (Kind == LayoutSourceKind.Markup)
{
LastError = "markup unsupported (Task 6)";
return null;
}
if (LayoutId is null)
{
LastError = "ui-studio: no layout id to reload.";
return null;
}
return LoadDat(LayoutId.Value);
}
// ── Private ──────────────────────────────────────────────────────────────────
private UiElement? LoadDat(uint layoutId)
{
var imported = LayoutImporter.Import(_dats, layoutId, _resolve, _datFont, _fontResolve);
if (imported is null)
{
LastError = $"ui-studio: LayoutDesc 0x{layoutId:X8} not found in dats.";
return null;
}
CurrentLayout = imported;
return imported.Root;
}
}

View file

@ -0,0 +1,156 @@
using System;
using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.App.UI;
using Silk.NET.OpenGL;
namespace AcDream.App.Studio;
/// <summary>
/// Renders a <see cref="UiHost"/> into an off-screen FBO each frame and
/// returns the color texture handle for display in ImGui.
///
/// <para>Pattern lifted verbatim from <see cref="AcDream.App.Rendering.PaperdollViewportRenderer"/>:
/// RGBA8 color texture + Depth24Stencil8 renderbuffer, lazily (re)created on
/// size change. The entire 2-D UI pass is sealed in a <see cref="GLStateScope"/>
/// so it cannot disturb the surrounding ImGui GL state.</para>
///
/// <para>FBO origin is bottom-left (GL convention). The caller must flip V when
/// displaying the texture in ImGui (pass uv0=(0,1), uv1=(1,0) to ImGui.Image)
/// so the image appears right-side-up in ImGui's top-left coordinate system.</para>
/// </summary>
public sealed unsafe class PanelFbo : IDisposable
{
private readonly GL _gl;
// Off-screen target — lazily (re)created when the requested size changes.
private uint _fbo;
private uint _colorTex;
private uint _depthRbo;
private int _fbW;
private int _fbH;
public PanelFbo(GL gl)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
}
/// <summary>
/// Render <paramref name="host"/> (a full <see cref="UiHost"/> draw pass) into a
/// private FBO at <paramref name="width"/> × <paramref name="height"/> pixels.
/// Returns the GL color texture handle (0 on failure). The texture is valid until
/// the next call to <see cref="Render"/> with a different size, or until <see cref="Dispose"/>.
/// </summary>
public uint Render(int width, int height, UiHost host)
{
if (width <= 0 || height <= 0 || host is null) return 0u;
EnsureFramebuffer(width, height);
if (_fbo == 0) return 0u;
// Seal the entire pass: GLStateScope saves + restores every GL state the
// UI draw touches (viewport, blend, FBO binding, etc.) so ImGui's own state
// — set up by BeginFrame and expected intact by Render — is untouched.
using var scope = new GLStateScope(_gl);
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, _fbo);
_gl.Viewport(0, 0, (uint)width, (uint)height);
_gl.Disable(EnableCap.ScissorTest);
_gl.ClearColor(0.18f, 0.18f, 0.18f, 1f); // opaque dark-grey canvas background (the FBO IS the canvas)
_gl.ClearDepth(1.0);
_gl.DepthMask(true);
_gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
host.Draw(new Vector2(width, height));
// FBO stays bound here; GLStateScope.Dispose() restores the previous binding.
return _colorTex;
}
/// <summary>
/// Read the FBO color attachment back to CPU as a flat RGBA8 byte array.
/// Must be called AFTER <see cref="Render"/> for the same <paramref name="width"/> and
/// <paramref name="height"/> (so the FBO exists and is the right size).
///
/// <para>FBO origin is bottom-left (GL convention). The caller is responsible for
/// flipping rows vertically before saving as a top-left-origin image format (PNG).</para>
///
/// <para>Returns an empty array when the FBO is not ready.</para>
/// </summary>
public unsafe byte[] ReadColorRgba(int width, int height)
{
if (_fbo == 0 || width <= 0 || height <= 0) return Array.Empty<byte>();
int byteCount = width * height * 4;
var buf = new byte[byteCount];
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, _fbo);
fixed (byte* p = buf)
{
_gl.ReadPixels(0, 0, (uint)width, (uint)height,
PixelFormat.Rgba, PixelType.UnsignedByte, p);
}
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, 0);
return buf;
}
// ── FBO lifecycle (mirrors PaperdollViewportRenderer.EnsureFramebuffer) ──────
private void EnsureFramebuffer(int width, int height)
{
if (_fbo != 0 && width == _fbW && height == _fbH) return;
DeleteFramebuffer();
_fbW = width;
_fbH = height;
_colorTex = _gl.GenTexture();
_gl.BindTexture(TextureTarget.Texture2D, _colorTex);
_gl.TexImage2D(TextureTarget.Texture2D, 0, InternalFormat.Rgba8,
(uint)width, (uint)height, 0,
PixelFormat.Rgba, PixelType.UnsignedByte, (void*)0);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter,
(int)TextureMinFilter.Linear);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter,
(int)TextureMinFilter.Linear);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS,
(int)TextureWrapMode.ClampToEdge);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT,
(int)TextureWrapMode.ClampToEdge);
_gl.BindTexture(TextureTarget.Texture2D, 0);
_depthRbo = _gl.GenRenderbuffer();
_gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, _depthRbo);
_gl.RenderbufferStorage(RenderbufferTarget.Renderbuffer,
InternalFormat.Depth24Stencil8, (uint)width, (uint)height);
_gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, 0);
_fbo = _gl.GenFramebuffer();
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, _fbo);
_gl.FramebufferTexture2D(FramebufferTarget.Framebuffer,
FramebufferAttachment.ColorAttachment0,
TextureTarget.Texture2D, _colorTex, 0);
_gl.FramebufferRenderbuffer(FramebufferTarget.Framebuffer,
FramebufferAttachment.DepthStencilAttachment,
RenderbufferTarget.Renderbuffer, _depthRbo);
var status = _gl.CheckFramebufferStatus(FramebufferTarget.Framebuffer);
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, 0);
if (status != GLEnum.FramebufferComplete)
{
Console.WriteLine($"[studio] PanelFbo incomplete: {status} ({width}x{height})");
DeleteFramebuffer();
}
}
private void DeleteFramebuffer()
{
if (_fbo != 0) { _gl.DeleteFramebuffer(_fbo); _fbo = 0; }
if (_colorTex != 0) { _gl.DeleteTexture(_colorTex); _colorTex = 0; }
if (_depthRbo != 0) { _gl.DeleteRenderbuffer(_depthRbo); _depthRbo = 0; }
_fbW = _fbH = 0;
}
public void Dispose() => DeleteFramebuffer();
}

View file

@ -0,0 +1,227 @@
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
namespace AcDream.App.Studio;
/// <summary>
/// Static sample data for the UI Studio fixture provider.
/// Provides a pre-built <see cref="ClientObjectTable"/> populated with a
/// representative player, their main-pack items, side bags, and equipped gear,
/// so the 2-D inventory / paperdoll panels render populated when previewed in
/// the studio without a live game session.
///
/// Icon ids used (all real 0x06xxxxxx RenderSurface ids confirmed in the dats):
/// Inventory empty-slot sprite : 0x06004D20
/// Generic "misc" item : 0x060011D4 (icon_misc_underlay / fallback)
/// Iron Sword (melee weapon) : 0x060011CBu (acclient default weapon underlay)
/// Leather Breastplate (armor) : 0x060011CFu (acclient default armor underlay)
/// Leather Gloves : 0x060011F3u (acclient default clothing underlay)
/// Steel Ring : 0x060011D5u (acclient default jewelry underlay)
/// Healing Kit : 0x060011D4u (generic misc fallback)
/// Spell components : 0x060011D4u (generic misc fallback)
/// Side-bag 1 (Container) : 0x06004D20u
/// Side-bag 2 (Container) : 0x06004D20u
/// Equipped helm (HeadWear) : 0x060011F3u
/// Equipped chest armor : 0x060011CFu
/// Equipped melee weapon : 0x060011CBu
///
/// These are the icon *base* RenderSurface ids — the same ids GameWindow passes
/// as `iconId` into the iconIds lambda. FixtureProvider resolves them via
/// <see cref="Rendering.RenderStack.ResolveChrome"/> and returns the raw GL handle.
/// </summary>
public static class SampleData
{
// ── Guids ────────────────────────────────────────────────────────────────
/// <summary>Fake server guid for the studio's synthetic player.</summary>
public const uint PlayerGuid = 0x50000001u;
// Items in main pack (slots 05).
private const uint SwordGuid = 0x50000010u;
private const uint ChestGuid = 0x50000011u;
private const uint GlovesGuid = 0x50000012u;
private const uint RingGuid = 0x50000013u;
private const uint HealKitGuid = 0x50000014u;
private const uint CompGuid = 0x50000015u;
// Side bags (also in main pack, ContainerId = PlayerGuid; slots 6 & 7).
private const uint Bag1Guid = 0x50000020u;
private const uint Bag2Guid = 0x50000021u;
// Equipped items (ContainerId = PlayerGuid, CurrentlyEquippedLocation set).
private const uint HelmGuid = 0x50000030u;
private const uint ChestEqGuid = 0x50000031u;
private const uint WeaponEqGuid = 0x50000032u;
// ── Icon ids (0x06xxxxxx RenderSurface dat ids) ───────────────────────────
// These are the same underlay/fallback icon ids the IconComposer tests pin.
private const uint IconWeapon = 0x060011CBu; // weapon underlay
private const uint IconArmor = 0x060011CFu; // armor underlay
private const uint IconClothing = 0x060011F3u; // clothing underlay
private const uint IconJewelry = 0x060011D5u; // jewelry underlay
private const uint IconMisc = 0x060011D4u; // misc / fallback underlay
// ── Public API ──────────────────────────────────────────────────────────
/// <summary>
/// Build a fresh <see cref="ClientObjectTable"/> populated with the
/// studio's sample player + a realistic inventory snapshot.
/// The table is owned by the caller and should be kept alive for the
/// window's lifetime.
/// </summary>
public static ClientObjectTable BuildObjectTable()
{
var t = new ClientObjectTable();
// ── Player object ─────────────────────────────────────────────────
t.AddOrUpdate(new ClientObject
{
ObjectId = PlayerGuid,
Name = "Studio Player",
Type = ItemType.Creature,
ItemsCapacity = 102,
ContainersCapacity = 7,
});
// ── Loose items in main pack (slots 05) ──────────────────────────
AddItem(t, SwordGuid, ItemType.MeleeWeapon, IconWeapon, "Iron Sword", PlayerGuid, 0, burden: 60);
AddItem(t, ChestGuid, ItemType.Armor, IconArmor, "Leather Breastplate", PlayerGuid, 1, burden: 200);
AddItem(t, GlovesGuid, ItemType.Clothing, IconClothing, "Leather Gloves", PlayerGuid, 2, burden: 50);
AddItem(t, RingGuid, ItemType.Jewelry, IconJewelry, "Steel Ring", PlayerGuid, 3, burden: 10);
AddItem(t, HealKitGuid, ItemType.Misc, IconMisc, "Healing Kit", PlayerGuid, 4, burden: 30);
AddItem(t, CompGuid, ItemType.SpellComponents, IconMisc, "Spell Comps", PlayerGuid, 5, burden: 25, stackSize: 50, stackMax: 100);
// ── Side bags (Container items in main pack, slots 6 & 7) ─────────
AddItem(t, Bag1Guid, ItemType.Container, IconMisc, "Small Pack 1",
containerId: PlayerGuid, slot: 6, burden: 20, itemsCapacity: 24);
AddItem(t, Bag2Guid, ItemType.Container, IconMisc, "Small Pack 2",
containerId: PlayerGuid, slot: 7, burden: 20, itemsCapacity: 24);
// ── Equipped items (ContainerId = PlayerGuid, CurrentlyEquippedLocation set) ──
AddEquipped(t, HelmGuid, ItemType.Armor, IconClothing, "Tin Helm", EquipMask.HeadWear);
AddEquipped(t, ChestEqGuid, ItemType.Armor, IconArmor, "Chain Coat", EquipMask.ChestArmor);
AddEquipped(t, WeaponEqGuid, ItemType.MeleeWeapon, IconWeapon, "Wooden Sword", EquipMask.MeleeWeapon);
return t;
}
// ── Sample vital constants (used by FixtureProvider) ────────────────────
public const float HealthPct = 0.8f;
public const float StaminaPct = 0.6f;
public const float ManaPct = 0.9f;
// ── Sample character sheet (used by CharacterController in the Studio) ───
/// <summary>
/// Returns a representative <see cref="CharacterSheet"/> for the studio's
/// synthetic character. Values are plausible retail-scale numbers so the
/// report renders with well-proportioned text in all sections.
/// </summary>
public static CharacterSheet SampleCharacter() => new()
{
Name = "Studio Player",
Level = 126,
Race = "Aluvian",
Heritage = "Aluvian Heritage",
Title = "the Adventurer",
BirthDate = "January 5, 2001",
PlayTime = "2 years, 114 days, 4 hours",
Deaths = 42,
PkStatus = "Non-Player Killer",
TotalXp = 1_250_000_000,
XpToNextLevel = 42_000_000,
XpFraction = 0.63f,
// Vitals: retail screenshot spec (Pass 1 acceptance criteria §Goal).
HealthCurrent = 5, HealthMax = 5,
StaminaCurrent = 10, StaminaMax = 10,
ManaCurrent = 10, ManaMax = 10,
// Attributes: Strength + Quickness = 200; all others = 10 (retail screenshot spec §Goal).
Strength = 200,
Endurance = 10,
Quickness = 200,
Coordination = 10,
Focus = 10,
Self = 10,
UnspentSkillCredits = 12,
SpecializedSkillCredits = 4,
// Available skill credits (retail InqInt(0x18)); shown in Attributes tab footer State-A.
SkillCredits = 96,
// Unassigned (banked) XP (retail InqInt64(2)); footer State-A line-2 value.
UnassignedXp = 87_757_321_741L,
// Raise costs in retail display order (Strength, Endurance, Coordination, Quickness,
// Focus, Self, Health, Stamina, Mana).
// Str@200 = maxed → 0 (disabled). Quickness@200 = maxed → 0. Others @10 → affordable.
// Focus@10 → 110 matches the authoritative retail screenshot (spec §4).
// Formula bracket at value=10: ExperienceToAttributeLevel(11) ExperienceToAttributeLevel(10).
AttributeRaiseCosts = new long[] { 0L, 95L, 100L, 0L, 110L, 105L, 90L, 88L, 112L },
AugmentationName = "Swords",
BurdenCurrent = 1200,
BurdenMax = 4500,
};
// ── Helpers ─────────────────────────────────────────────────────────────
private static void AddItem(
ClientObjectTable t,
uint guid,
ItemType type,
uint iconId,
string name,
uint containerId,
int slot,
int burden = 0,
int stackSize = 1,
int stackMax = 1,
int itemsCapacity = 0)
{
t.AddOrUpdate(new ClientObject
{
ObjectId = guid,
Name = name,
Type = type,
IconId = iconId,
Burden = burden,
StackSize = stackSize,
StackSizeMax = stackMax,
ItemsCapacity = itemsCapacity,
});
t.MoveItem(guid, containerId, slot);
}
private static void AddEquipped(
ClientObjectTable t,
uint guid,
ItemType type,
uint iconId,
string name,
EquipMask equipMask)
{
t.AddOrUpdate(new ClientObject
{
ObjectId = guid,
Name = name,
Type = type,
IconId = iconId,
ValidLocations = equipMask,
CurrentlyEquippedLocation = equipMask,
ContainerId = PlayerGuid,
});
// Update the equip location on the object via MoveItem (sets ContainerId +
// CurrentlyEquippedLocation via the equip overload).
t.MoveItem(guid, PlayerGuid, newSlot: -1, newEquipLocation: equipMask);
}
}

View file

@ -0,0 +1,295 @@
using System.Numerics;
using AcDream.App.UI;
using ImGuiNET;
namespace AcDream.App.Studio;
/// <summary>
/// All canvas mouse events gathered by <see cref="StudioInspector.DrawCanvas"/> in one frame.
/// All coordinates are already mapped to panel-local pixels (origin top-left, same as UiRoot).
/// </summary>
public readonly struct CanvasInputEvent
{
/// <summary>Mouse is currently hovering the canvas image. When false all other fields are 0 / false.</summary>
public readonly bool IsHovered;
/// <summary>Panel-local pixel coordinate of the mouse this frame (valid when <see cref="IsHovered"/>).</summary>
public readonly int MouseX;
/// <summary>Panel-local pixel coordinate of the mouse this frame (valid when <see cref="IsHovered"/>).</summary>
public readonly int MouseY;
/// <summary>Left-button went down this frame.</summary>
public readonly bool LeftDown;
/// <summary>Left-button came up this frame.</summary>
public readonly bool LeftUp;
/// <summary>Mouse-wheel scroll delta (lines, positive = up). Zero when no scroll.</summary>
public readonly int ScrollDelta;
public CanvasInputEvent(bool hovered, int mx, int my, bool ld, bool lu, int scroll)
{
IsHovered = hovered;
MouseX = mx;
MouseY = my;
LeftDown = ld;
LeftUp = lu;
ScrollDelta = scroll;
}
}
/// <summary>
/// Four-pane ImGui IDE for the acdream UI Studio:
/// <list type="bullet">
/// <item><b>Toolbar</b> — panel picker (slug combo) across the top.</item>
/// <item><b>Canvas</b> — shows the panel FBO texture; in Interact mode mouse events
/// are forwarded to the panel UiHost (buttons/tabs respond); in Inspect mode a
/// left-click hit-tests and selects the element under the cursor.</item>
/// <item><b>Tree</b> — recursive ImGui tree of the element hierarchy; clicking a node
/// sets <see cref="Selected"/>.</item>
/// <item><b>Properties</b> — shows the <see cref="Selected"/> element's geometry,
/// anchors, and z-order.</item>
/// </list>
///
/// <para><b>Coordinate mapping for the canvas:</b>
/// The FBO is rendered at the full window size and displayed 1:1 inside the Canvas ImGui
/// sub-window. After <c>ImGui.Image</c> we call <c>ImGui.GetItemRectMin()</c> to get the
/// screen-space top-left of the drawn image (accounting for the sub-window's title bar,
/// padding, and any scrolling). Subtracting that from the raw mouse screen position gives
/// panel-local pixels directly — no additional scale factor is needed because the image is
/// drawn 1:1.</para>
///
/// <para><b>V-flip — no extra Y inversion needed:</b>
/// The FBO origin is bottom-left (GL convention), so we pass uv0=(0,1), uv1=(1,0) to
/// <c>ImGui.Image</c> to flip V. After this flip, displayed row 0 (top of the image on
/// screen) corresponds to panel Y=0 (the top of the UI panel), matching UiRoot's
/// top-left origin. Therefore the panel-local Y computed above maps directly into UiRoot
/// without further inversion — do NOT flip Y again.</para>
///
/// <para>Layout: the four panes call <c>SetNextWindowPos</c> + <c>SetNextWindowSize</c>
/// with <c>ImGuiCond.FirstUseEver</c> so they start docked but can be freely dragged.</para>
/// </summary>
public sealed class StudioInspector
{
/// <summary>Currently selected element (set by tree-click or canvas-click in Inspect mode).</summary>
public UiElement? Selected { get; set; }
/// <summary>
/// When true (default) canvas mouse events are forwarded to the panel UiHost so elements
/// respond to clicks. When false a canvas click hit-tests and selects an element in the
/// inspector tree instead. Toggle via the "Interact / Inspect" checkbox in the toolbar.
/// </summary>
public bool InteractMode { get; set; } = true;
// ── Toolbar ───────────────────────────────────────────────────────────────────
/// <summary>
/// Draw the "Studio" toolbar window (top strip) containing a slug combo-box and
/// the Interact / Inspect mode toggle. Returns the newly-selected slug when the
/// user picks a different panel, or null when unchanged.
/// <para>The mode toggle sets <see cref="InteractMode"/>: checked = Interact (panel
/// elements respond to clicks), unchecked = Inspect (clicks select elements in the
/// tree).</para>
/// </summary>
/// <param name="slugs">All available panel slugs (from <c>UiDumpModel.ListSlugs</c>).</param>
/// <param name="current">The slug of the panel currently loaded.</param>
/// <param name="windowW">Studio window width (pixels).</param>
public string? DrawToolbar(IReadOnlyList<string> slugs, string? current, int windowW)
{
ImGui.SetNextWindowPos(new Vector2(0f, 0f), ImGuiCond.FirstUseEver);
ImGui.SetNextWindowSize(new Vector2(windowW, 40f), ImGuiCond.FirstUseEver);
ImGui.Begin("Studio",
ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse);
ImGui.SetNextItemWidth(300f);
string? result = null;
string preview = current ?? "(none)";
if (ImGui.BeginCombo("Panel", preview))
{
foreach (var slug in slugs)
{
bool selected = string.Equals(slug, current, StringComparison.OrdinalIgnoreCase);
if (ImGui.Selectable(slug, selected) && !selected)
result = slug;
if (selected)
ImGui.SetItemDefaultFocus();
}
ImGui.EndCombo();
}
ImGui.SameLine();
bool interact = InteractMode;
if (ImGui.Checkbox("Interact", ref interact))
InteractMode = interact;
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Interact: canvas clicks reach the panel (buttons/tabs respond).\nUncheck to Inspect: clicks select elements in the tree.");
ImGui.End();
return result;
}
// ── Canvas ────────────────────────────────────────────────────────────────────
/// <summary>
/// Draw the "Canvas" ImGui window containing the panel FBO texture and return all
/// canvas mouse events for this frame as a <see cref="CanvasInputEvent"/>.
///
/// <para><b>Coordinate mapping:</b> After <c>ImGui.Image</c>, <c>GetItemRectMin()</c>
/// returns the actual screen-space top-left of the drawn image (accounting for the
/// sub-window title bar, padding, and scrolling). Subtracting that from the raw ImGui
/// mouse position gives panel-local pixels directly — no scale factor because the
/// image is drawn 1:1.</para>
///
/// <para><b>V-flip — no extra Y inversion:</b> we pass uv0=(0,1) / uv1=(1,0) so the
/// GL bottom-left origin is flipped to top-left on screen. After the flip, screen
/// row 0 = panel Y 0 (top of the UI), so the computed Y already matches UiRoot's
/// top-left origin — do NOT flip Y again.</para>
///
/// <para>If <see cref="Selected"/> is non-null a bright-green 2-pixel outline is
/// drawn over it using the window draw list.</para>
/// </summary>
public CanvasInputEvent DrawCanvas(nint panelTex, int width, int height,
int windowX, int windowW, int windowY, int windowH)
{
ImGui.SetNextWindowPos(new Vector2(windowX, windowY), ImGuiCond.FirstUseEver);
ImGui.SetNextWindowSize(new Vector2(windowW, windowH), ImGuiCond.FirstUseEver);
ImGui.Begin("Canvas");
var imageSize = new Vector2(width, height);
// V-flip: FBO origin is bottom-left; ImGui images expect top-left.
// uv0 = bottom-left of texture = top of the panel in screen space.
// uv1 = top-right of texture = bottom of the panel in screen space.
var uv0 = new Vector2(0f, 1f);
var uv1 = new Vector2(1f, 0f);
ImGui.Image(panelTex, imageSize, uv0, uv1);
// rectMin: screen-space top-left of the image AFTER ImGui.Image + any chrome offset.
// This is what lets us translate raw mouse screen coords into panel-local pixels.
var rectMin = ImGui.GetItemRectMin();
// ── Selection highlight ───────────────────────────────────────────────
var el = Selected;
if (el is not null && el.Width > 0f && el.Height > 0f)
{
var sp = el.ScreenPosition;
var p0 = new Vector2(rectMin.X + sp.X, rectMin.Y + sp.Y);
var p1 = new Vector2(p0.X + el.Width, p0.Y + el.Height);
var dl = ImGui.GetWindowDrawList();
dl.AddRect(p0, p1,
ImGui.GetColorU32(new Vector4(0.2f, 1f, 0.4f, 1f)),
0f, ImDrawFlags.None, 2f);
}
// ── Gather canvas mouse events ────────────────────────────────────────
// IsItemHovered is true when the mouse is over the Image item (not just the window).
bool hovered = ImGui.IsItemHovered();
int mx = 0, my = 0;
bool leftDown = false, leftUp = false;
int scroll = 0;
if (hovered)
{
var mousePos = ImGui.GetMousePos();
// Panel-local pixel = mouse offset from the image's screen-space top-left.
// Scale is 1:1 (image drawn at full FBO size). Y needs no extra flip — see summary.
int ix = (int)(mousePos.X - rectMin.X);
int iy = (int)(mousePos.Y - rectMin.Y);
// Clamp to image bounds (mouse can be on the image edge pixel).
if (ix >= 0 && ix < width && iy >= 0 && iy < height)
{
mx = ix;
my = iy;
leftDown = ImGui.IsMouseClicked(ImGuiMouseButton.Left);
leftUp = ImGui.IsMouseReleased(ImGuiMouseButton.Left);
float wheelY = ImGui.GetIO().MouseWheel;
scroll = (int)wheelY; // positive = scroll up
}
else
{
// Mouse is over ImGui chrome (title bar, padding) adjacent to image — not over the panel.
hovered = false;
}
}
ImGui.End();
return new CanvasInputEvent(hovered, mx, my, leftDown, leftUp, scroll);
}
// ── Tree ──────────────────────────────────────────────────────────────────────
/// <summary>Draw the "Tree" ImGui window. Clicking a node sets <see cref="Selected"/>.</summary>
public void DrawTree(UiElement root, int windowX, int windowY, int windowW, int windowH)
{
ImGui.SetNextWindowPos(new Vector2(windowX, windowY), ImGuiCond.FirstUseEver);
ImGui.SetNextWindowSize(new Vector2(windowW, windowH), ImGuiCond.FirstUseEver);
ImGui.Begin("Tree");
DrawTreeNode(root);
ImGui.End();
}
private void DrawTreeNode(UiElement el)
{
// Label: EventId (hex) + C# type name, e.g. "0x10000001 [UiDatElement]"
string label = $"0x{el.EventId:X8} [{el.GetType().Name}]";
bool isSelected = ReferenceEquals(el, Selected);
bool hasChildren = el.Children.Count > 0;
ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags.OpenOnArrow
| ImGuiTreeNodeFlags.SpanAvailWidth;
if (!hasChildren)
flags |= ImGuiTreeNodeFlags.Leaf;
if (isSelected)
flags |= ImGuiTreeNodeFlags.Selected;
bool open = ImGui.TreeNodeEx(label, flags);
// Click on the node label (not the arrow) selects it.
if (ImGui.IsItemClicked(ImGuiMouseButton.Left))
Selected = el;
if (open)
{
foreach (var child in el.Children)
DrawTreeNode(child);
ImGui.TreePop();
}
}
// ── Properties ───────────────────────────────────────────────────────────────
/// <summary>Draw the "Properties" ImGui window for <see cref="Selected"/>.</summary>
public void DrawProperties(int windowX, int windowY, int windowW, int windowH)
{
ImGui.SetNextWindowPos(new Vector2(windowX, windowY), ImGuiCond.FirstUseEver);
ImGui.SetNextWindowSize(new Vector2(windowW, windowH), ImGuiCond.FirstUseEver);
ImGui.Begin("Properties");
var el = Selected;
if (el is null)
{
ImGui.TextUnformatted("(nothing selected)");
ImGui.End();
return;
}
ImGui.TextUnformatted($"Id (EventId): 0x{el.EventId:X8}");
ImGui.TextUnformatted($"Type: {el.GetType().Name}");
ImGui.TextUnformatted($"Name: {el.Name ?? "(null)"}");
ImGui.Separator();
ImGui.TextUnformatted($"Rect: ({el.Left}, {el.Top}, {el.Width} x {el.Height})");
ImGui.TextUnformatted($"Anchors: {el.Anchors}");
ImGui.TextUnformatted($"ZOrder: {el.ZOrder}");
ImGui.Separator();
ImGui.TextUnformatted($"Visible: {el.Visible}");
ImGui.TextUnformatted($"Enabled: {el.Enabled}");
ImGui.TextUnformatted($"ClickThrough: {el.ClickThrough}");
ImGui.TextUnformatted($"Draggable: {el.Draggable}");
ImGui.TextUnformatted($"Resizable: {el.Resizable}");
ImGui.TextUnformatted($"IsDragSource: {el.IsDragSource}");
ImGui.TextUnformatted($"HandlesClick: {el.HandlesClick}");
ImGui.TextUnformatted($"Opacity: {el.Opacity:F2}");
ImGui.Separator();
var sp = el.ScreenPosition;
ImGui.TextUnformatted($"ScreenPos: ({sp.X:F1}, {sp.Y:F1})");
ImGui.TextUnformatted($"Children: {el.Children.Count}");
ImGui.End();
}
}

View file

@ -0,0 +1,116 @@
namespace AcDream.App.Studio;
/// <summary>
/// Parsed options for the acdream UI Studio standalone tool.
/// Constructed by <see cref="Parse"/> from the command-line tokens that follow
/// the <c>ui-studio</c> dispatch token.
/// </summary>
public sealed record StudioOptions(
string DatDir,
uint? LayoutId,
string? MarkupPath,
string? DumpSlug = null,
string? DumpFile = null,
string? ScreenshotPath = null)
{
/// <summary>
/// Parse studio options from the args that come AFTER the <c>ui-studio</c> token.
///
/// <para>Positional (first non-flag arg): dat directory. Falls back to
/// <c>ACDREAM_DAT_DIR</c> when omitted.</para>
/// <para><c>--layout 0xNNNN</c>: hex LayoutDesc dat id to preview.</para>
/// <para><c>--markup &lt;path&gt;</c>: path to a KSML markup file (Task 6, unsupported for now).</para>
/// <para><c>--dump &lt;slug&gt;</c>: load a panel from the retail UI dump JSON by slug
/// (e.g. <c>inventory</c>, <c>radar</c>, <c>toolbar</c>). Static mockup — no controllers.</para>
/// <para><c>--dump-file &lt;path&gt;</c>: override the default dump file path
/// (<c>docs/research/2026-06-25-retail-ui-layout-dump.json</c> from the solution root).
/// Only meaningful when <c>--dump</c> is also given.</para>
/// <para><c>--screenshot &lt;path&gt;</c>: headless mode — render the loaded panel to a PNG
/// at <paramref name="path"/> and exit without showing an interactive window.
/// Combines with <c>--dump</c> or <c>--layout</c>.</para>
/// <para>When neither <c>--layout</c>, <c>--markup</c>, nor <c>--dump</c> is given the
/// default layout <c>0x2100006C</c> (vitals) is used.</para>
/// </summary>
public static StudioOptions Parse(string[] args)
{
string? datDir = null;
uint? layoutId = null;
string? markupPath = null;
string? dumpSlug = null;
string? dumpFile = null;
string? screenshotPath = null;
for (int i = 0; i < args.Length; i++)
{
if (args[i] == "--layout" && i + 1 < args.Length)
{
var raw = args[++i];
// Accept 0xNNNN or plain hex.
if (raw.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
raw = raw[2..];
if (uint.TryParse(raw, System.Globalization.NumberStyles.HexNumber, null, out var id))
layoutId = id;
}
else if (args[i] == "--markup" && i + 1 < args.Length)
{
markupPath = args[++i];
}
else if (args[i] == "--dump" && i + 1 < args.Length)
{
dumpSlug = args[++i];
}
else if (args[i] == "--dump-file" && i + 1 < args.Length)
{
dumpFile = args[++i];
}
else if (args[i] == "--screenshot" && i + 1 < args.Length)
{
screenshotPath = args[++i];
}
else if (!args[i].StartsWith('-'))
{
datDir ??= args[i];
}
}
// Fall back to ACDREAM_DAT_DIR when no positional dat dir was given.
datDir ??= Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (string.IsNullOrWhiteSpace(datDir))
throw new InvalidOperationException(
"ui-studio: dat directory required — pass as first arg or set ACDREAM_DAT_DIR.");
// Default layout: vitals (0x2100006C), unless a dump slug or markup is requested.
if (layoutId is null && markupPath is null && dumpSlug is null)
layoutId = 0x2100006Cu;
return new StudioOptions(datDir, layoutId, markupPath, dumpSlug, dumpFile, screenshotPath);
}
/// <summary>
/// Resolve the dump file path for this session:
/// <list type="bullet">
/// <item><see cref="DumpFile"/> if explicitly set.</item>
/// <item>Otherwise <c>&lt;solutionRoot&gt;/docs/research/2026-06-25-retail-ui-layout-dump.json</c>.</item>
/// </list>
/// Returns null when neither the override nor the default file exists.
/// </summary>
public string? ResolveDumpFile()
{
if (!string.IsNullOrEmpty(DumpFile))
return DumpFile;
// Walk up from the App binary to the solution root (same approach as
// ConformanceDats.SolutionRoot in the test project).
var dir = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(dir))
{
var candidate = Path.Combine(dir, "docs", "research",
"2026-06-25-retail-ui-layout-dump.json");
if (File.Exists(candidate))
return candidate;
dir = Path.GetDirectoryName(dir);
}
return null;
}
}

View file

@ -0,0 +1,458 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.UI;
using DatReaderWriter;
using DatReaderWriter.Options;
using Silk.NET.Input;
using Silk.NET.Maths;
using Silk.NET.OpenGL;
using Silk.NET.Windowing;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using static Silk.NET.OpenGL.ClearBufferMask;
namespace AcDream.App.Studio;
/// <summary>
/// Standalone Silk.NET window that boots the production render stack
/// (<see cref="RenderBootstrap"/>) and previews a single UI panel
/// identified by a <see cref="LayoutSource"/>.
///
/// Usage: dotnet run -- ui-studio [dat-dir] [--layout 0xNNNN] [--markup path]
///
/// Task 3 adds an ImGui IDE on top of the panel FBO:
/// <list type="bullet">
/// <item>Canvas pane — the panel rendered off-screen via <see cref="PanelFbo"/>.</item>
/// <item>Tree pane — the element hierarchy; click-to-select.</item>
/// <item>Properties pane — geometry/anchors/flags of the selected element.</item>
/// <item>Click-to-inspect — a left-click in the canvas selects the topmost
/// element under the cursor via <see cref="UiRoot.Pick"/>.</item>
/// </list>
///
/// The window is intentionally thin: no game world, no physics, no streaming —
/// just GL + UiHost + the layout under test, identical to how the panel
/// appears inside <c>GameWindow</c>.
/// </summary>
public sealed class StudioWindow : IDisposable
{
private readonly StudioOptions _opts;
// Created in OnLoad, released in OnClosing.
private IWindow? _window;
private DatCollection? _dats;
private RenderStack? _stack;
private LayoutSource? _source;
// Task 3 additions.
private AcDream.UI.ImGui.ImGuiBootstrapper? _imgui;
private PanelFbo? _panelFbo;
private StudioInspector? _inspector;
private UiElement? _panelRoot; // top-level element added to UiRoot (for hit-test + tree)
// UX-pass additions: panel picker + current-slug tracking.
private string? _currentSlug; // slug of the panel currently displayed (null = non-dump mode)
private string? _dumpFile; // resolved dump file path (once, in OnLoad)
private IReadOnlyList<string> _dumpSlugs = Array.Empty<string>(); // all slugs from the dump
// Task 4: sample data table — built once in OnLoad and kept alive for the window's lifetime
// so the controller subscriptions (ObjectAdded/ObjectMoved etc.) fire correctly.
private AcDream.Core.Items.ClientObjectTable? _objects;
// Headless screenshot mode: set when --screenshot was passed.
// True after the first OnRender fires the screenshot (guard against repeat).
private bool _screenshotDone;
public StudioWindow(StudioOptions opts)
{
_opts = opts ?? throw new ArgumentNullException(nameof(opts));
}
/// <summary>
/// Open the window and block until it is closed.
/// Mirrors <c>GameWindow.Run()</c>.
/// </summary>
public void Run()
{
// Resolve quality settings the same way GameWindow.Run() does
// (SettingsStore → QualitySettings.From → WithEnvOverrides).
var startupStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(
AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
var startupDisplay = startupStore.LoadDisplay();
var startupBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(startupDisplay.Quality);
var startupQuality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(startupBase);
var options = WindowOptions.Default with
{
Size = new Vector2D<int>(1280, 720),
Title = "acdream UI Studio",
API = new GraphicsAPI(
ContextAPI.OpenGL,
ContextProfile.Core,
ContextFlags.ForwardCompatible,
new APIVersion(4, 3)),
VSync = false,
// MSAA from quality preset — must be baked into the GL context at creation.
Samples = startupQuality.MsaaSamples,
PreferredStencilBufferBits = 8,
// Headless screenshot mode: hide the window so no desktop flash occurs.
// The GL context is still fully valid on a hidden window; FBO rendering
// is off-screen and independent of window visibility.
IsVisible = _opts.ScreenshotPath is null,
};
_window = Window.Create(options);
_window.Load += OnLoad;
_window.Update += OnUpdate;
_window.Render += OnRender;
_window.Closing += OnClosing;
_window.Run();
}
private void OnLoad()
{
var gl = GL.GetApi(_window!);
_dats = new DatCollection(_opts.DatDir, DatAccessType.Read);
// Build QualitySettings for RenderBootstrap (same as Run() above — re-read
// after the GL context is confirmed, mirroring GameWindow.OnLoad).
var store = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(
AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
var display = store.LoadDisplay();
var quality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(
AcDream.UI.Abstractions.Settings.QualitySettings.From(display.Quality));
_stack = RenderBootstrap.Create(gl, _dats, new RenderBootstrapOptions(quality));
// Load the panel described by options and add it to the UI tree.
// Task 4b: --dump <slug> uses DumpLayout (static retail mockup, no controllers).
// All other modes use LayoutSource + FixtureProvider (production path).
//
// Fix C: pass the per-element font resolver into LayoutSource so that elements
// with a non-zero FontDid get their own dat font at build time. This is wired
// ONLY in the studio path; GameWindow's Import calls continue to pass null so the
// live game path is provably unchanged (follow-up: GameWindow font-resolver wire-up).
_source = new LayoutSource(_dats, _stack.ResolveChrome, _stack.VitalsDatFont,
fontResolve: _stack.ResolveDatFont);
// Resolve the dump file once (used by OnLoad + by LoadDumpPanel at runtime).
_dumpFile = _opts.ResolveDumpFile();
if (_dumpFile is not null)
_dumpSlugs = UiDumpModel.ListSlugs(_dumpFile)
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase).ToList();
UiElement? root;
if (_opts.DumpSlug is not null)
{
if (_dumpFile is null)
{
Console.Error.WriteLine("[studio] --dump: retail UI dump file not found. " +
"Expected docs/research/2026-06-25-retail-ui-layout-dump.json in the source tree, " +
"or pass --dump-file <path>.");
root = null;
}
else
{
root = DumpLayout.Load(_dumpFile, _opts.DumpSlug, _stack.ResolveChrome, out var dumpErr);
if (root is null)
Console.Error.WriteLine($"[studio] dump load failed: {dumpErr}");
else
_currentSlug = _opts.DumpSlug;
}
}
else
{
root = _source.Load(_opts);
if (root is null)
Console.Error.WriteLine($"[studio] panel load failed: {_source.LastError}");
}
_panelRoot = root;
if (root is not null)
{
_stack.UiHost.Root.AddChild(root);
// Task 4: populate the panel with sample data via production controllers,
// so inventory / vitals / toolbar panels render with plausible content.
// Dump source is static — no FixtureProvider needed.
if (_opts.DumpSlug is null && _source.CurrentLayout is not null)
{
uint layoutId = _opts.LayoutId ?? 0x2100006Cu;
_objects = SampleData.BuildObjectTable();
FixtureProvider.Populate(layoutId, _source.CurrentLayout, _stack, _objects, _dats);
}
}
// Task 3: ImGui IDE — interactive mode only.
// Headless screenshot mode needs only PanelFbo; ImGui/inspector/input are skipped.
_panelFbo = new PanelFbo(gl);
if (_opts.ScreenshotPath is null)
{
var input = _window!.CreateInput();
// Wire KEYBOARD input into UiHost (keyboard coords are not spatial, so no remapping needed).
// Do NOT wire mouse here — raw Silk window coords would be offset by the canvas sub-window's
// position (tree pane width + ImGui chrome) and land in the wrong panel-local location.
// Mouse is forwarded manually from DrawCanvas with correct panel-local mapping below.
foreach (var kb in input.Keyboards)
_stack.UiHost.WireKeyboard(kb);
_imgui = new AcDream.UI.ImGui.ImGuiBootstrapper(gl, _window!, input);
_inspector = new StudioInspector();
}
}
private void OnUpdate(double dt) { }
private void OnRender(double dt)
{
if (_stack is null || _panelFbo is null) return;
// ── HEADLESS SCREENSHOT PATH ──────────────────────────────────────────────
if (_opts.ScreenshotPath is not null)
{
if (_screenshotDone) return; // fire exactly once
_screenshotDone = true;
// Pick render size from the loaded root's bounds (clamped to sane limits).
// Fall back to 1280×720 when the root has no explicit size.
int w = 1280, h = 720;
if (_panelRoot is not null)
{
float rw = _panelRoot.Width;
float rh = _panelRoot.Height;
if (rw >= 1f && rh >= 1f)
{
w = Math.Clamp((int)rw, 256, 2048);
h = Math.Clamp((int)rh, 256, 2048);
}
}
// Tick once so widget state is initialised (e.g. bar fills).
_stack.UiHost.Tick(dt);
// Render the panel into the FBO.
_panelFbo.Render(w, h, _stack.UiHost);
// Read back RGBA pixels (FBO origin = bottom-left).
byte[] pixels = _panelFbo.ReadColorRgba(w, h);
if (pixels.Length == 0)
{
Console.Error.WriteLine("[studio-screenshot] FBO readback returned no pixels.");
_window?.Close();
return;
}
// Flip rows vertically: FBO bottom-left → PNG top-left.
int stride = w * 4;
byte[] flipped = new byte[pixels.Length];
for (int row = 0; row < h; row++)
{
System.Buffer.BlockCopy(pixels, row * stride, flipped, (h - 1 - row) * stride, stride);
}
// Build ImageSharp image and save as PNG.
var path = _opts.ScreenshotPath;
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
using var img = Image.LoadPixelData<Rgba32>(flipped, w, h);
img.SaveAsPng(path);
Console.WriteLine($"[studio-screenshot] wrote {path} ({w}x{h})");
_window?.Close();
return;
}
// ── INTERACTIVE PATH ──────────────────────────────────────────────────────
if (_imgui is null || _inspector is null) return;
var gl = _stack.Gl;
int iw = _window!.Size.X;
int ih = _window!.Size.Y;
// 1. Tick the UI widgets (OnRender's own dt — Update + Render fire with the same delta).
_stack.UiHost.Tick(dt);
// 2. Render the panel into the off-screen FBO; get the color texture.
// The FBO is the same logical size as the window, so element rects map 1:1 to
// FBO pixels — no scale factor needed when displaying the canvas at full size.
uint panelTex = _panelFbo.Render(iw, ih, _stack.UiHost);
// 3. Clear the window back-buffer (the dark ImGui background shows behind panes).
gl.ClearColor(0.1f, 0.1f, 0.1f, 1f);
gl.Clear(ColorBufferBit | DepthBufferBit);
// 4. Begin the ImGui frame.
_imgui.BeginFrame((float)dt);
// ── Layout constants (fixed pane arrangement, FirstUseEver) ──────────────
// MenuBar: always-on-top main menu bar (~22px) — panel picker lives here so it
// is never covered by the floating panes (replaces the old 40px toolbar).
// Tree: 280px wide on the left, below menu bar.
// Canvas: centre strip between tree and properties.
// Props: 340px wide on the right, below menu bar.
const int kMenuBarH = 22; // ImGui default main menu bar height
const int kTreeW = 280;
const int kPropsW = 340;
int canvasX = kTreeW;
int canvasW = Math.Max(1, iw - kTreeW - kPropsW);
int propsX = iw - kPropsW;
int paneY = kMenuBarH;
int paneH = Math.Max(1, ih - kMenuBarH);
// 5. Main menu bar — panel picker combo pinned to the window top.
// BeginMainMenuBar returns true when the bar is visible (always is); the combo
// inside it is always-on-top and is never occluded by Tree/Canvas/Props panes.
string? pickedSlug = null;
if (ImGuiNET.ImGui.BeginMainMenuBar())
{
ImGuiNET.ImGui.SetNextItemWidth(300f);
string preview = _currentSlug ?? "(none)";
if (ImGuiNET.ImGui.BeginCombo("Panel", preview))
{
foreach (var slug in _dumpSlugs)
{
bool selected = string.Equals(slug, _currentSlug,
System.StringComparison.OrdinalIgnoreCase);
if (ImGuiNET.ImGui.Selectable(slug, selected) && !selected)
pickedSlug = slug;
if (selected)
ImGuiNET.ImGui.SetItemDefaultFocus();
}
ImGuiNET.ImGui.EndCombo();
}
ImGuiNET.ImGui.EndMainMenuBar();
}
if (pickedSlug is not null)
LoadDumpPanel(pickedSlug);
// 6. Canvas pane — show the FBO texture; gather canvas mouse events.
var canvasEvt = default(CanvasInputEvent);
if (panelTex != 0)
canvasEvt = _inspector.DrawCanvas(
(nint)panelTex, iw, ih,
canvasX, canvasW, paneY, paneH);
// 7. Forward canvas mouse events to the panel UiHost or the inspector tree.
//
// Coordinate mapping (see StudioInspector.DrawCanvas summary):
// panel-local pixel = raw_mouse - ImGui.GetItemRectMin() (1:1 scale, no Y flip)
// The image is drawn V-flipped (uv0.Y=1, uv1.Y=0) so screen top = panel Y=0.
//
// Interact mode (default): canvas mouse events go directly to UiRoot so elements respond.
// OnMouseMove + OnMouseDown/Up + OnScroll are all forwarded.
// A Console.WriteLine confirms each forwarded left-click for live verification.
//
// Inspect mode: left-click hit-tests and selects the element in the tree (old behavior).
// OnMouseMove is still forwarded so hover/tooltip state in the panel stays live.
if (canvasEvt.IsHovered)
{
int mx = canvasEvt.MouseX;
int my = canvasEvt.MouseY;
var root = _stack.UiHost.Root;
// Always forward mouse-move so hover highlights / tooltips in the panel work.
root.OnMouseMove(mx, my);
if (_inspector.InteractMode)
{
// ── Interact: live panel interaction ──────────────────────────────
if (canvasEvt.LeftDown)
{
Console.WriteLine($"[studio] canvas click → panel ({mx}, {my})");
root.OnMouseDown(UiMouseButton.Left, mx, my);
}
if (canvasEvt.LeftUp)
root.OnMouseUp(UiMouseButton.Left, mx, my);
if (canvasEvt.ScrollDelta != 0)
root.OnScroll(canvasEvt.ScrollDelta);
}
else
{
// ── Inspect: click selects an element in the tree ─────────────────
if (canvasEvt.LeftDown)
{
var hit = root.Pick(mx, my);
if (hit is not null)
_inspector.Selected = hit;
}
}
}
// 8. Element tree pane.
if (_panelRoot is not null)
_inspector.DrawTree(_panelRoot, 0, paneY, kTreeW, paneH);
// 9. Properties pane.
_inspector.DrawProperties(propsX, paneY, kPropsW, paneH);
// 9. Finalise ImGui and flush draw data to the window.
_imgui.Render();
}
/// <summary>
/// Load a different dump panel at runtime (no relaunch required).
/// Removes the current <see cref="_panelRoot"/> from the UI tree,
/// loads the named slug from the dump, and installs the new root.
/// Resets <see cref="StudioInspector.Selected"/> to null.
/// No-op when the dump file is not available or the slug fails to load.
/// </summary>
public void LoadDumpPanel(string slug)
{
if (_stack is null || _inspector is null) return;
if (_dumpFile is null)
{
Console.Error.WriteLine("[studio] LoadDumpPanel: dump file not available.");
return;
}
// Remove the existing panel root from the tree.
if (_panelRoot is not null)
{
_stack.UiHost.Root.RemoveChild(_panelRoot);
_panelRoot = null;
}
// Load the new panel.
var newRoot = DumpLayout.Load(_dumpFile, slug, _stack.ResolveChrome, out var err);
if (newRoot is null)
{
Console.Error.WriteLine($"[studio] LoadDumpPanel('{slug}') failed: {err}");
_currentSlug = null;
return;
}
_stack.UiHost.Root.AddChild(newRoot);
_panelRoot = newRoot;
_currentSlug = slug;
_inspector.Selected = null;
}
private void OnClosing()
{
_imgui?.Dispose();
_panelFbo?.Dispose();
_imgui = null;
_panelFbo = null;
_stack?.Dispose(); // whole render stack: dispatcher/mesh/textures/shader/ubo/uihost
_dats?.Dispose();
_dats = null;
_stack = null;
}
public void Dispose()
{
_imgui?.Dispose();
_panelFbo?.Dispose();
_imgui = null;
_panelFbo = null;
_window?.Dispose();
_window = null;
// If OnClosing wasn't called (e.g. an exception before Run() completed), dispose the FULL
// stack anyway — the review flagged that disposing only UiHost here leaked the rest.
_stack?.Dispose();
_dats?.Dispose();
_dats = null;
_stack = null;
}
}

View file

@ -0,0 +1,198 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AcDream.App.Studio;
// ─────────────────────────────────────────────────────────────────────────────
// UiDumpModel — POCOs for docs/research/2026-06-25-retail-ui-layout-dump.json
//
// Schema (v1):
// { "version":1, "panels":[ { "id":int, "slug":string, "title":string,
// "bucket":string, "parent_slug":string|null,
// "width":int, "height":int,
// "nodes":[ { "traversal_index":int, "element_id":int,
// "layout_id":int, "parent_layout_id":int|null,
// "parent_traversal_index":int|null, "base_layout_id":int,
// "rect":{x,y,width,height},
// "widget_kind":"Group"|"Sprite"|"Button"|"Scrollbar"|"Slider",
// "state_set":{ "default_image":{image_id,alpha_image_id}|null,
// "states":[{state_id,image:{...}}] }
// } ] } ] }
//
// All ids in the dump are DECIMAL ints (e.g. element_id=268435925 = 0x100001D5,
// image_id=100693194 = 0x060074CA). Cast to uint before use in dat/GL APIs.
//
// Rect coordinates are ABSOLUTE (screen-space origin = panel's design position
// in retail layout, NOT relative to the parent). DumpLayout.Load converts them
// to parent-relative when building the UiElement tree.
// ─────────────────────────────────────────────────────────────────────────────
/// <summary>Top-level container for the retail UI layout dump.</summary>
public sealed class UiDump
{
[JsonPropertyName("version")]
public int Version { get; set; }
[JsonPropertyName("panels")]
public List<DumpPanel> Panels { get; set; } = new();
}
/// <summary>One panel (window) exported from the retail UI.</summary>
public sealed class DumpPanel
{
[JsonPropertyName("id")]
public long Id { get; set; }
[JsonPropertyName("slug")]
public string Slug { get; set; } = "";
[JsonPropertyName("title")]
public string Title { get; set; } = "";
[JsonPropertyName("bucket")]
public string Bucket { get; set; } = "";
[JsonPropertyName("parent_slug")]
public string? ParentSlug { get; set; }
[JsonPropertyName("width")]
public float Width { get; set; }
[JsonPropertyName("height")]
public float Height { get; set; }
[JsonPropertyName("nodes")]
public List<DumpNode> Nodes { get; set; } = new();
}
/// <summary>One element node within a panel's traversal list.</summary>
public sealed class DumpNode
{
[JsonPropertyName("traversal_index")]
public int TraversalIndex { get; set; }
[JsonPropertyName("element_id")]
public long ElementId { get; set; }
[JsonPropertyName("layout_id")]
public long LayoutId { get; set; }
[JsonPropertyName("parent_layout_id")]
public long? ParentLayoutId { get; set; }
[JsonPropertyName("parent_traversal_index")]
public int? ParentTraversalIndex { get; set; }
[JsonPropertyName("base_layout_id")]
public long BaseLayoutId { get; set; }
[JsonPropertyName("rect")]
public DumpRect Rect { get; set; } = new();
[JsonPropertyName("widget_kind")]
public string WidgetKind { get; set; } = "Group";
[JsonPropertyName("state_set")]
public DumpStateSet StateSet { get; set; } = new();
}
/// <summary>Absolute screen-space rect (see comment above — must subtract parent rect for UiElement).</summary>
public sealed class DumpRect
{
[JsonPropertyName("x")]
public float X { get; set; }
[JsonPropertyName("y")]
public float Y { get; set; }
[JsonPropertyName("width")]
public float Width { get; set; }
[JsonPropertyName("height")]
public float Height { get; set; }
}
/// <summary>State set for a node — default image plus per-state overrides.</summary>
public sealed class DumpStateSet
{
[JsonPropertyName("default_image")]
public DumpImage? DefaultImage { get; set; }
[JsonPropertyName("states")]
public List<DumpState> States { get; set; } = new();
}
/// <summary>Image reference (RenderSurface dat id + optional separate alpha surface).</summary>
public sealed class DumpImage
{
[JsonPropertyName("image_id")]
public long ImageId { get; set; }
[JsonPropertyName("alpha_image_id")]
public long? AlphaImageId { get; set; }
}
/// <summary>A named state override.</summary>
public sealed class DumpState
{
[JsonPropertyName("state_id")]
public int StateId { get; set; }
[JsonPropertyName("image")]
public DumpImage Image { get; set; } = new();
}
// ─────────────────────────────────────────────────────────────────────────────
// Helper statics
// ─────────────────────────────────────────────────────────────────────────────
/// <summary>
/// Parsing helpers for the retail UI dump JSON.
/// </summary>
public static class UiDumpModel
{
private static readonly JsonSerializerOptions _opts = new()
{
PropertyNameCaseInsensitive = true,
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip,
};
/// <summary>Parse the full dump from a file path. Returns null on failure.</summary>
public static UiDump? Parse(string path)
{
try
{
using var stream = File.OpenRead(path);
return JsonSerializer.Deserialize<UiDump>(stream, _opts);
}
catch
{
return null;
}
}
/// <summary>
/// Return the list of slugs in the dump (for smoke-testing every panel).
/// Returns an empty list if the file cannot be parsed.
/// </summary>
public static IReadOnlyList<string> ListSlugs(string path)
{
var dump = Parse(path);
if (dump is null) return Array.Empty<string>();
return dump.Panels.Select(p => p.Slug).ToList();
}
/// <summary>
/// Pick the sprite id to use for a node: prefer default_image.image_id;
/// fall back to states[0].image.image_id; return 0 if neither exists.
/// </summary>
public static uint PickImageId(DumpNode node)
{
if (node.StateSet.DefaultImage is { ImageId: > 0 } di)
return (uint)di.ImageId;
if (node.StateSet.States.Count > 0 && node.StateSet.States[0].Image.ImageId > 0)
return (uint)node.StateSet.States[0].Image.ImageId;
return 0u;
}
}

View file

@ -0,0 +1,11 @@
namespace AcDream.App.UI;
/// <summary>Renders a 3-D mini-scene into an off-screen buffer and returns the GL color-texture
/// handle. Called by the per-frame pre-UI hook (GameWindow), NOT from UiViewport.OnDraw. Implemented
/// by PaperdollViewportRenderer in AcDream.App.Rendering. Intra-App decoupling so the UI widget
/// doesn't depend on WbDrawDispatcher/GameWindow.</summary>
public interface IUiViewportRenderer
{
/// <summary>Render at (width,height); return the color-texture GL handle, or 0 if nothing rendered.</summary>
uint Render(int width, int height);
}

View file

@ -0,0 +1,182 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.UI;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Per-window controller for the Character window (LayoutDesc 0x2100002E, gmCharacterInfoUI).
///
/// <para>Retail fills a single scrollable <c>UIElement_Text</c> element (id 0x1000011d,
/// <c>m_pMainText</c>) by calling a sequence of Update* methods that each
/// <c>AppendStringInfo</c> into the same element. This controller replicates that report
/// structure using acdream's <see cref="UiText.LinesProvider"/>.</para>
///
/// <para>Section order (ported from <c>gmCharacterInfoUI::Update</c> 0x004ba790):
/// <list type="number">
/// <item>Birth / age / deaths (<c>UpdatePlayerBirthAgeDeaths</c> 0x004b8cb0)</item>
/// <item>Vitals / endurance (<c>UpdateEnduranceInfo</c> 0x004b8eb0)</item>
/// <item>Innate attributes (<c>UpdateInnateAttributeInfo</c> 0x004b87e0):
/// InqAttribute 1,2,4,3,5,6 = Strength, Endurance, Quickness, Coordination, Focus, Self</item>
/// <item>Skills summary (<c>UpdateFakeSkills</c> 0x004b8930)</item>
/// <item>Augmentations (<c>UpdateAugmentations</c> 0x004b9000)</item>
/// <item>Burden / load (<c>UpdateLoad</c> 0x004b8a20)</item>
/// </list>
/// Each section is separated by a blank line (retail: <c>AppendStringInfo(m_pMainText, &amp;var_120)</c>
/// with an empty StringInfo between sections).</para>
///
/// <para>StringInfo resolution is NOT ported — the full dat string-table lookup is a
/// later refinement. For this pilot, labels are composed directly from the well-known
/// AC attribute / stat names confirmed in the decomp string literals and data identifiers.</para>
/// </summary>
public static class CharacterController
{
/// <summary>Dat element id for the main report text (m_pMainText). Confirmed in
/// <c>gmCharacterInfoUI::PostInit</c> 0x004b86f0: <c>GetChildRecursive(this, 0x1000011d)</c>.</summary>
public const uint MainTextId = 0x1000011du;
/// <summary>White body text — matches retail's default UIElement_Text foreground.</summary>
private static readonly Vector4 BodyColor = new(1f, 1f, 1f, 1f);
/// <summary>Yellow section header — retail uses a different StringInfo color per section header.</summary>
private static readonly Vector4 HeaderColor = new(1f, 0.85f, 0.35f, 1f);
/// <summary>
/// Bind the character report text element found in <paramref name="layout"/> to
/// <paramref name="data"/>. The report is regenerated each frame via
/// <see cref="UiText.LinesProvider"/> so the Studio or a live session can push a fresh
/// <see cref="CharacterSheet"/> without re-binding.
///
/// <para>The element must resolve as a <see cref="UiText"/> (Type 12 in the dat).
/// If 0x1000011d is absent from the layout the bind is a no-op — partial layouts
/// (e.g. test fakes) don't cause errors.</para>
/// </summary>
/// <param name="layout">Imported 0x2100002E layout tree.</param>
/// <param name="data">Provider returning the current <see cref="CharacterSheet"/>.</param>
/// <param name="datFont">Retail dat font forwarded from the render stack. May be null
/// (falls back to the BitmapFont debug path).</param>
public static void Bind(
ImportedLayout layout,
Func<CharacterSheet> data,
UiDatFont? datFont = null)
{
// Retail's gmCharacterInfoUI CREATES m_pMainText (0x1000011d) at RUNTIME — it is NOT a static
// element in any LayoutDesc (confirmed: acdream's dat-import of 0x2100002E AND 0x2100006E both
// lack it; only a runtime UI-tree capture has it). So replicate that: build the report text
// element ourselves and place it in the panel body, exactly as the gm*UI does at runtime.
var root = layout.Root;
if (root is null) return;
var text = new UiText
{
EventId = MainTextId,
Name = "m_pMainText",
Left = 12f,
Top = 44f, // below the window header row
Width = System.Math.Max(40f, root.Width - 24f),
Height = System.Math.Max(40f, root.Height - 56f),
Anchors = AnchorEdges.None,
ZOrder = 1_000_000, // draw above the static chrome
DatFont = datFont,
ClickThrough = false,
};
text.LinesProvider = () => BuildReport(data());
root.AddChild(text);
}
// ── Report builder ────────────────────────────────────────────────────────
/// <summary>
/// Build the complete character report as a line list.
/// Order mirrors <c>gmCharacterInfoUI::Update</c> (0x004ba790).
/// </summary>
private static IReadOnlyList<UiText.Line> BuildReport(CharacterSheet s)
{
var lines = new List<UiText.Line>();
// ── Section 1: Birth / age / deaths (UpdatePlayerBirthAgeDeaths 0x004b8cb0) ──
// Retail InqInt(0x62) = DateOfBirth (unix timestamp); InqInt(0x7d) = TotalPlayTime;
// InqInt(0x2b) = NumDeaths.
AppendHeader(lines, s.Name);
Append(lines, $"Level {s.Level}");
if (s.Race is not null)
Append(lines, s.Race);
if (s.Heritage is not null)
Append(lines, s.Heritage);
if (s.Title is not null)
Append(lines, s.Title);
AppendBlank(lines);
if (s.BirthDate is not null)
Append(lines, $"Birth: {s.BirthDate}");
if (s.PlayTime is not null)
Append(lines, $"Age: {s.PlayTime}");
Append(lines, $"Deaths: {s.Deaths}");
AppendBlank(lines);
// ── Section 2: Vitals / endurance (UpdateEnduranceInfo 0x004b8eb0) ──
// Retail InqAttribute(1) = Strength base, InqAttribute(2) = Endurance base.
// Computes max-stamina tier and max-health tier from (Str+End) and (End+2*End) sums.
AppendHeader(lines, "Vitals");
Append(lines, $"Health: {s.HealthCurrent} / {s.HealthMax}");
Append(lines, $"Stamina: {s.StaminaCurrent} / {s.StaminaMax}");
Append(lines, $"Mana: {s.ManaCurrent} / {s.ManaMax}");
AppendBlank(lines);
// ── Section 3: Innate attributes (UpdateInnateAttributeInfo 0x004b87e0) ──
// InqAttribute order: 1,2,4,3,5,6 = Strength, Endurance, Quickness, Coordination,
// Focus, Self (confirmed from the AddVariable_Int sequence in the decomp).
AppendHeader(lines, "Attributes");
Append(lines, $"Strength: {s.Strength}");
Append(lines, $"Endurance: {s.Endurance}");
Append(lines, $"Quickness: {s.Quickness}");
Append(lines, $"Coordination: {s.Coordination}");
Append(lines, $"Focus: {s.Focus}");
Append(lines, $"Self: {s.Self}");
AppendBlank(lines);
// ── Section 4: Skills summary (UpdateFakeSkills 0x004b8930) ──
// Retail InqInt(0xb5) = SkillCredits remaining; InqInt(0xc0) = AvailableSkillCredits.
AppendHeader(lines, "Skills");
Append(lines, $"Unspent skill credits: {s.UnspentSkillCredits}");
if (s.SpecializedSkillCredits > 0)
Append(lines, $"Specialized credits: {s.SpecializedSkillCredits}");
AppendBlank(lines);
// ── Section 5: Augmentations (UpdateAugmentations 0x004b9000) ──
// Retail InqInt(0x162) = AugmentationStat; string-switch on value 1..0xb + Unknown.
AppendHeader(lines, "Augmentations");
if (s.AugmentationName is not null)
Append(lines, s.AugmentationName);
else
Append(lines, "None");
AppendBlank(lines);
// ── Section 6: Burden / load (UpdateLoad 0x004b8a20) ──
// Retail InqLoad → EncumbranceCapacity(Strength, AugEncumbrance).
// When load >= 1.0 (overloaded): shows "X burden over capacity; Y% speed penalty".
// When aug > 0: shows "N augmentations (X.X% bonus)".
AppendHeader(lines, "Encumbrance");
Append(lines, $"Burden: {s.BurdenCurrent}");
Append(lines, $"Capacity: {s.BurdenMax}");
if (s.BurdenMax > 0)
{
int pct = (int)Math.Round(100.0 * s.BurdenCurrent / s.BurdenMax);
Append(lines, $"Load: {pct}%");
}
return lines;
}
// ── Line helpers ──────────────────────────────────────────────────────────
private static void AppendHeader(List<UiText.Line> lines, string text)
=> lines.Add(new UiText.Line(text, HeaderColor));
private static void Append(List<UiText.Line> lines, string text)
=> lines.Add(new UiText.Line(text, BodyColor));
private static void AppendBlank(List<UiText.Line> lines)
=> lines.Add(new UiText.Line(string.Empty, BodyColor));
}

View file

@ -0,0 +1,137 @@
using System;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Snapshot of all character data the Character window report uses.
/// Passed to <see cref="CharacterController.Bind"/> as a provider delegate
/// so the window can be rebound to live data in GameWindow or a static
/// fixture in the UI Studio.
///
/// <para>Field names and retail property ids confirmed from
/// <c>gmCharacterInfoUI::UpdatePlayerBirthAgeDeaths</c> (0x004b8cb0),
/// <c>UpdateEnduranceInfo</c> (0x004b8eb0),
/// <c>UpdateInnateAttributeInfo</c> (0x004b87e0),
/// <c>UpdateFakeSkills</c> (0x004b8930),
/// <c>UpdateAugmentations</c> (0x004b9000), and
/// <c>UpdateLoad</c> (0x004b8a20).</para>
/// </summary>
public sealed class CharacterSheet
{
// ── Identity ──────────────────────────────────────────────────────────────
/// <summary>Character name (first line of the report).</summary>
public string Name { get; init; } = string.Empty;
/// <summary>Character level.</summary>
public int Level { get; init; }
/// <summary>Race string, e.g. "Aluvian". Null = omit.</summary>
public string? Race { get; init; }
/// <summary>Heritage string, e.g. "Aluvian Heritage". Null = omit.</summary>
public string? Heritage { get; init; }
/// <summary>Title string, e.g. "the Adventurer". Null = omit.</summary>
public string? Title { get; init; }
// ── Experience / PK (gmStatManagementUI::UpdateExperience 0x004f0a70,
// UpdatePKStatus 0x004f00a0) — the Attributes-tab header strip ──────────
/// <summary>Total accrued experience (retail PropertyInt64 1). Header value
/// element 0x10000235 (m_pTotalXPText).</summary>
public long TotalXp { get; init; }
/// <summary>Experience remaining to the next level. Header value element
/// 0x10000238 (m_pXPToLevelText).</summary>
public long XpToNextLevel { get; init; }
/// <summary>XP-to-next-level meter fill, 0..1 (retail (curbase)/(capbase)).
/// Drives the header meter 0x10000236 (m_pXPToLevelMeter).</summary>
public float XpFraction { get; init; }
/// <summary>PK status display string, e.g. "Non-Player Killer". Header element
/// 0x10000233 (m_pPKStatusText). Null = omit.</summary>
public string? PkStatus { get; init; }
// ── Birth / age / deaths (UpdatePlayerBirthAgeDeaths 0x004b8cb0) ─────────
/// <summary>Formatted birth date string (retail InqInt(0x62) → strftime).
/// Null = omit the birth line.</summary>
public string? BirthDate { get; init; }
/// <summary>Formatted play-time duration (retail InqInt(0x7d) → QueryDuration).
/// Null = omit the age line.</summary>
public string? PlayTime { get; init; }
/// <summary>Total deaths (retail InqInt(0x2b) = NumDeaths).</summary>
public int Deaths { get; init; }
// ── Vitals (UpdateEnduranceInfo 0x004b8eb0) ─────────────────────────────
public int HealthCurrent { get; init; }
public int HealthMax { get; init; }
public int StaminaCurrent { get; init; }
public int StaminaMax { get; init; }
public int ManaCurrent { get; init; }
public int ManaMax { get; init; }
// ── Innate attributes (UpdateInnateAttributeInfo 0x004b87e0) ────────────
// InqAttribute order: 1,2,4,3,5,6 = Strength, Endurance, Quickness, Coordination, Focus, Self.
public int Strength { get; init; }
public int Endurance { get; init; }
public int Quickness { get; init; }
public int Coordination { get; init; }
public int Focus { get; init; }
public int Self { get; init; }
// ── Skills (UpdateFakeSkills 0x004b8930) ────────────────────────────────
// Retail InqInt(0xb5) = SkillCredits; InqInt(0xc0) = AvailableSkillCredits.
// InqInt(0x18) = available skill credits — footer 0x10000245 in the Attributes tab.
public int UnspentSkillCredits { get; init; }
public int SpecializedSkillCredits { get; init; }
/// <summary>
/// Available (unspent) skill credits shown in the Attributes tab footer State-A.
/// Retail InqInt(0x18) — gmStatManagementUI::DisplayDefaultFooter (0x0049cde0).
/// Element 0x10000243 (footer line-1 value in the studio's 3-line layout).
/// </summary>
public int SkillCredits { get; init; }
/// <summary>
/// Unassigned (banked) experience points.
/// Retail InqInt64(2) — shown in footer line-2 in State-A display.
/// Element 0x10000245 (footer line-2 value).
/// </summary>
public long UnassignedXp { get; init; }
// ── Attribute raise costs (ExperienceToAttributeLevel, gmAttributeUI::PostInit) ──
// Retail formula: ExperienceToAttributeLevel(value + 1) ExperienceToAttributeLevel(value).
// We store pre-computed per-attribute costs for the fixture; cost == 0 → attribute is
// at max or not trainable (raise button ghosted/hidden). Ordered to match AttrRows
// (Strength, Endurance, Coordination, Quickness, Focus, Self, Health, Stamina, Mana).
// Source: gmAttributeUI::AttributeInfoRegion::Update (0x004f1910) + CM_Train::Event_TrainAttribute.
/// <summary>
/// XP cost to raise each of the 9 attributes/vitals by 1, in retail display order:
/// [0]=Strength, [1]=Endurance, [2]=Coordination, [3]=Quickness, [4]=Focus, [5]=Self,
/// [6]=Health, [7]=Stamina, [8]=Mana.
/// Cost 0 means the attribute is at max (raise button ghosted).
/// </summary>
public long[] AttributeRaiseCosts { get; init; } = Array.Empty<long>();
// ── Augmentations (UpdateAugmentations 0x004b9000) ─────────────────────
// Retail InqInt(0x162) = AugmentationStat; string-switch 1..0xb.
/// <summary>Augmentation name from the switch in UpdateAugmentations (0x004b9000),
/// e.g. "Swords", "Two Handed Weapons". Null = "None".</summary>
public string? AugmentationName { get; init; }
// ── Burden / load (UpdateLoad 0x004b8a20) ───────────────────────────────
// Retail InqLoad + EncumbranceCapacity(Strength, AugEncumbrance).
public int BurdenCurrent { get; init; }
public int BurdenMax { get; init; }
}

File diff suppressed because it is too large Load diff

View file

@ -45,9 +45,15 @@ public static class DatWidgetFactory
/// Returns (0,0,0) when the texture is not yet uploaded.</param> /// Returns (0,0,0) when the texture is not yet uploaded.</param>
/// <param name="datFont">Retail UI font for the meter's "cur/max" number overlay. /// <param name="datFont">Retail UI font for the meter's "cur/max" number overlay.
/// May be null pre-load — the meter falls back to the debug bitmap font.</param> /// May be null pre-load — the meter falls back to the debug bitmap font.</param>
/// <param name="fontResolve">Optional font resolver: FontDid → <see cref="UiDatFont"/>
/// (or null when the font can't be loaded). When non-null, any element whose
/// <see cref="ElementInfo.FontDid"/> is non-zero gets ITS OWN dat font applied instead of
/// the shared <paramref name="datFont"/> fallback. Null = original behavior (use
/// <paramref name="datFont"/> for every element).</param>
/// <returns>The widget for this element. Never null — every type produces a widget.</returns> /// <returns>The widget for this element. Never null — every type produces a widget.</returns>
public static UiElement? Create(ElementInfo info, public static UiElement? Create(ElementInfo info,
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont) Func<uint, (uint, int, int)> resolve, UiDatFont? datFont,
Func<uint, UiDatFont?>? fontResolve = null)
{ {
// Retail Type 3 = UIElement_Field (reg :126190), but in acdream's CURRENT layouts // Retail Type 3 = UIElement_Field (reg :126190), but in acdream's CURRENT layouts
// (vitals 0x2100006C / chat 0x21000006) Type-3 elements are sprite-bearing chrome + // (vitals 0x2100006C / chat 0x21000006) Type-3 elements are sprite-bearing chrome +
@ -60,15 +66,23 @@ public static class DatWidgetFactory
// actually carries a factory-built editable Type-3 field (and UiField grows a // actually carries a factory-built editable Type-3 field (and UiField grows a
// background-media draw + an opt-in editable flag at that point). UiField (the widget) // background-media draw + an opt-in editable flag at that point). UiField (the widget)
// still ships — it just isn't wired into the factory switch yet. // still ships — it just isn't wired into the factory switch yet.
// Resolve this element's own dat font if a resolver is provided and the element
// has a FontDid. Falls back to the shared datFont when not set (FontDid==0) or
// when the resolver returns null (font missing from dats).
UiDatFont? elementFont = datFont;
if (fontResolve is not null && info.FontDid != 0)
elementFont = fontResolve(info.FontDid) ?? datFont;
UiElement e = info.Type switch UiElement e = info.Type switch
{ {
1 => new UiButton(info, resolve), // UIElement_Button (reg :125828) 1 => new UiButton(info, resolve), // UIElement_Button (reg :125828)
6 => new UiMenu(), // UIElement_Menu (reg :120163) 6 => new UiMenu(), // UIElement_Menu (reg :120163)
7 => BuildMeter(info, resolve, datFont), // UIElement_Meter 7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter
11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137) 0xD => new UiViewport(), // UIElement_Viewport — 3-D mini-scene blit leaf
12 => BuildText(info, resolve), // UIElement_Text (reg :115655) 11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137)
0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots 12 => BuildText(info, resolve, elementFont), // UIElement_Text (reg :115655)
_ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers) 0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots
_ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers)
}; };
// Propagate position + size (pixel-exact from the dat). // Propagate position + size (pixel-exact from the dat).
@ -238,13 +252,63 @@ public static class DatWidgetFactory
/// own Direct/Normal media (if any) becomes the background sprite, drawn under the text — /// 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 /// so a Type-12 element that previously rendered via UiDatElement keeps its sprite. Lines
/// are bound later by the controller (LinesProvider). An unbound UiText draws nothing /// are bound later by the controller (LinesProvider). An unbound UiText draws nothing
/// because <see cref="UiText.BackgroundColor"/> defaults to transparent.</summary> /// because <see cref="UiText.BackgroundColor"/> defaults to transparent.
private static UiText BuildText(ElementInfo info, Func<uint, (uint, int, int)> resolve) ///
/// <para>
/// Justification from the dat (<see cref="ElementInfo.HJustify"/> /
/// <see cref="ElementInfo.VJustify"/>) is applied here at build time so that controllers
/// that subsequently call <see cref="UiText.Centered"/> / <see cref="UiText.RightAligned"/>
/// on dat-origin elements can be simplified. Controllers that <em>explicitly</em> set those
/// properties after <see cref="ImportedLayout.FindElement"/> still override the build-time
/// defaults — the build-time value is just the starting point, not a lock.
/// </para>
/// </summary>
/// <param name="elementFont">The font to seed on the widget. When a font resolver was
/// provided and the element's FontDid resolved successfully, this is that element-specific
/// font; otherwise it is the shared global fallback. Controllers that call
/// <see cref="ImportedLayout.FindElement"/> and set <see cref="UiText.DatFont"/> afterward
/// still override this — the build-time value is just the starting point.</param>
private static UiText BuildText(ElementInfo info, Func<uint, (uint, int, int)> resolve,
UiDatFont? elementFont = null)
{ {
uint bg = info.StateMedia.TryGetValue( uint bg = info.StateMedia.TryGetValue(
!string.IsNullOrEmpty(info.DefaultStateName) ? info.DefaultStateName !string.IsNullOrEmpty(info.DefaultStateName) ? info.DefaultStateName
: info.StateMedia.ContainsKey("Normal") ? "Normal" : "", out var m) : info.StateMedia.ContainsKey("Normal") ? "Normal" : "", out var m)
? m.File : 0u; ? m.File : 0u;
return new UiText { BackgroundSprite = bg, SpriteResolve = resolve };
// Apply horizontal + vertical justification from the dat at build time.
// Controllers that call FindElement and set Centered/RightAligned/VerticalJustify
// afterward will override these — this is only the dat-driven default.
bool centered = info.HJustify == HJustify.Center;
bool rightAligned = info.HJustify == HJustify.Right;
var vJustify = info.VJustify switch
{
VJustify.Top => VJustify.Top,
VJustify.Bottom => VJustify.Bottom,
_ => VJustify.Center,
};
var t = new UiText
{
BackgroundSprite = bg,
SpriteResolve = resolve,
Centered = centered,
RightAligned = rightAligned,
VerticalJustify = vJustify,
// Seed the dat-driven font. When a font resolver was supplied and the element
// carries a non-zero FontDid, elementFont is the element-specific dat font; otherwise
// it is the shared global fallback. Controllers that call FindElement and explicitly
// set DatFont afterward STILL override this (backward-compat guarantee).
DatFont = elementFont,
};
// Font color from dat property 0x1B (ColorBaseProperty).
// When present, seed DefaultColor so controllers that read it don't have to hard-code colors.
// Controllers that supply explicit per-line colors via LinesProvider still win — this is only
// the build-time default.
if (info.FontColor.HasValue)
t.DefaultColor = info.FontColor.Value;
return t;
} }
} }

View file

@ -1,7 +1,20 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.UI.Layout; namespace AcDream.App.UI.Layout;
/// <summary>
/// Horizontal text justification read from dat property 0x14 (UIElement HorizontalJustification).
/// Values match the retail enum: 0=Left, 1=Center, 3/5=Right.
/// </summary>
public enum HJustify : byte { Left = 0, Center = 1, Right = 2 }
/// <summary>
/// Vertical text justification read from dat property 0x15 (UIElement VerticalJustification).
/// Values: 2=Top, 4=Bottom; absent/other = Center.
/// </summary>
public enum VJustify : byte { Top = 0, Center = 1, Bottom = 2 }
/// <summary> /// <summary>
/// GL-free, dat-free snapshot of a resolved layout element. /// GL-free, dat-free snapshot of a resolved layout element.
/// Populated by the LayoutDesc importer from <c>DatReaderWriter.ElementDesc</c> /// Populated by the LayoutDesc importer from <c>DatReaderWriter.ElementDesc</c>
@ -51,6 +64,30 @@ public sealed class ElementInfo
/// </summary> /// </summary>
public uint FontDid; public uint FontDid;
/// <summary>
/// Horizontal text justification from dat <c>Properties[0x14]</c>
/// (<c>EnumBaseProperty</c>: 0=Left, 1=Center, 3/5=Right).
/// Default is <see cref="HJustify.Center"/> to preserve existing behavior where
/// controllers set <c>Centered=true</c> and no property was read.
/// </summary>
public HJustify HJustify = HJustify.Center;
/// <summary>
/// Vertical text justification from dat <c>Properties[0x15]</c>
/// (<c>EnumBaseProperty</c>: 2=Top, 4=Bottom; absent/other = Center).
/// Default is <see cref="VJustify.Center"/> to preserve existing behavior.
/// </summary>
public VJustify VJustify = VJustify.Center;
/// <summary>
/// Font color from dat <c>Properties[0x1B]</c> (<c>ColorBaseProperty</c>, ARGB bytes).
/// Null when the dat carries no color for this element; the factory then leaves the
/// widget at its default white (<see cref="System.Numerics.Vector4.One"/>).
/// Propagated in <see cref="ElementReader.Merge"/> with the same "non-null derived wins"
/// rule used for <see cref="FontDid"/> and <see cref="HJustify"/>.
/// </summary>
public Vector4? FontColor;
/// <summary> /// <summary>
/// Sprite per state: state name → (RenderSurface file id, DrawMode int). /// Sprite per state: state name → (RenderSurface file id, DrawMode int).
/// The <c>""</c> key represents the unnamed DirectState (<c>ElementDesc.StateDesc</c>). /// The <c>""</c> key represents the unnamed DirectState (<c>ElementDesc.StateDesc</c>).
@ -160,6 +197,15 @@ public static class ElementReader
ReadOrder = derived.ReadOrder, ReadOrder = derived.ReadOrder,
ZLevel = derived.ZLevel != 0 ? derived.ZLevel : base_.ZLevel, ZLevel = derived.ZLevel != 0 ? derived.ZLevel : base_.ZLevel,
FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid, FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid,
// HJustify/VJustify: derived wins when it carries an explicit non-Center value
// (the dat property was present and read); otherwise inherit the base prototype's value.
// Center is the default (= "not set by this element") so Center-derived never overrides
// a non-Center base — matching the FontDid "non-zero wins" convention.
HJustify = derived.HJustify != HJustify.Center ? derived.HJustify : base_.HJustify,
VJustify = derived.VJustify != VJustify.Center ? derived.VJustify : base_.VJustify,
// FontColor: derived wins when it has an explicit (non-null) color; otherwise inherit the base.
// Null means "dat carried no 0x1B property" — so null-derived does NOT override a non-null base.
FontColor = derived.FontColor ?? base_.FontColor,
// DefaultStateName: derived wins if set; otherwise inherit the base's default. // DefaultStateName: derived wins if set; otherwise inherit the base's default.
DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName, DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName,
// Children come from the derived element's own tree, not the base prototype's. // Children come from the derived element's own tree, not the base prototype's.

View file

@ -68,25 +68,33 @@ public static class LayoutImporter
ElementInfo rootInfo, ElementInfo rootInfo,
IEnumerable<ElementInfo> children, IEnumerable<ElementInfo> children,
Func<uint, (uint, int, int)> resolve, Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont) UiDatFont? datFont,
Func<uint, UiDatFont?>? fontResolve = null)
{ {
rootInfo.Children = new List<ElementInfo>(children); rootInfo.Children = new List<ElementInfo>(children);
return Build(rootInfo, resolve, datFont); return Build(rootInfo, resolve, datFont, fontResolve);
} }
/// <summary> /// <summary>
/// Pure builder: produce the widget tree from a fully resolved /// Pure builder: produce the widget tree from a fully resolved
/// <see cref="ElementInfo"/> tree (children already attached). /// <see cref="ElementInfo"/> tree (children already attached).
/// </summary> /// </summary>
/// <param name="fontResolve">Optional per-element font resolver — FontDid →
/// <see cref="UiDatFont"/> (or null if the font can't be loaded). When supplied,
/// elements with a non-zero <see cref="ElementInfo.FontDid"/> get their own dat
/// font at build time instead of the shared <paramref name="datFont"/> fallback.
/// Null preserves the original single-font behavior for all callers that don't
/// pass it — no behavior change for the live game path.</param>
public static ImportedLayout Build( public static ImportedLayout Build(
ElementInfo rootInfo, ElementInfo rootInfo,
Func<uint, (uint, int, int)> resolve, Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont) UiDatFont? datFont,
Func<uint, UiDatFont?>? fontResolve = null)
{ {
var byId = new Dictionary<uint, UiElement>(); var byId = new Dictionary<uint, UiElement>();
// Root is never a Type-12 prototype in practice; fall back to a generic // Root is never a Type-12 prototype in practice; fall back to a generic
// container if the factory returns null for an exotic root type. // container if the factory returns null for an exotic root type.
var root = BuildWidget(rootInfo, resolve, datFont, byId); var root = BuildWidget(rootInfo, resolve, datFont, fontResolve, byId);
if (root is null) if (root is null)
{ {
Console.WriteLine($"[D.2b] LayoutImporter: root element 0x{rootInfo.Id:X8} (type {rootInfo.Type}) produced no widget — using empty container fallback."); Console.WriteLine($"[D.2b] LayoutImporter: root element 0x{rootInfo.Id:X8} (type {rootInfo.Type}) produced no widget — using empty container fallback.");
@ -99,9 +107,10 @@ public static class LayoutImporter
ElementInfo info, ElementInfo info,
Func<uint, (uint, int, int)> resolve, Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont, UiDatFont? datFont,
Func<uint, UiDatFont?>? fontResolve,
Dictionary<uint, UiElement> byId) Dictionary<uint, UiElement> byId)
{ {
var w = DatWidgetFactory.Create(info, resolve, datFont); var w = DatWidgetFactory.Create(info, resolve, datFont, fontResolve);
if (w is null) return null; // Type-12 style prototype — skip if (w is null) return null; // Type-12 style prototype — skip
if (info.Id != 0) byId[info.Id] = w; if (info.Id != 0) byId[info.Id] = w;
@ -117,7 +126,33 @@ public static class LayoutImporter
{ {
foreach (var child in info.Children) foreach (var child in info.Children)
{ {
var cw = BuildWidget(child, resolve, datFont, byId); var cw = BuildWidget(child, resolve, datFont, fontResolve, byId);
if (cw is not null) w.AddChild(cw);
}
}
else if (w is UiMeter)
{
// Fix 5: UiMeter.ConsumesDatChildren=true swallows ALL children, including text
// label/value overlays that are separate renderable widgets (not part of the bar
// art). BuildMeter in DatWidgetFactory already consumed the Type-3 slice containers
// (reads their grandchild sprite ids to populate Back*/Front* properties). The
// remaining non-Type-3 children (typically Type-12 UIElement_Text overlays such
// as the XP meter's 0x10000237 label + 0x10000238 value) ARE renderable and belong
// in the widget tree. We build them here explicitly, registered in byId so
// FindElement can locate them, and attached as children of the meter so they render
// as overlays at their dat-local coordinates. The controller can then locate these
// widgets via FindElement and bind LinesProvider without injecting new runtime nodes.
//
// Type-3 children are SKIPPED here because BuildMeter already consumed them (they
// carry the 3-slice sprite ids, not text content; building them again would
// double-draw the bar art). All other child types are built normally.
//
// Safe for vitals: the health/stamina/mana meters have ONLY Type-3 slice children
// (no text children). This loop finds nothing for them → no change to vitals.
foreach (var child in info.Children)
{
if (child.Type == 3) continue; // slice containers: already consumed by BuildMeter
var cw = BuildWidget(child, resolve, datFont, fontResolve, byId);
if (cw is not null) w.AddChild(cw); if (cw is not null) w.AddChild(cw);
} }
} }
@ -177,16 +212,38 @@ public static class LayoutImporter
/// Dat shell: load the LayoutDesc, resolve inheritance for every top-level /// Dat shell: load the LayoutDesc, resolve inheritance for every top-level
/// element, and build the widget tree. Returns null if the layout is absent /// element, and build the widget tree. Returns null if the layout is absent
/// from the dats. /// from the dats.
///
/// <para>
/// <b>Dat UIState visibility model (2026-06-26 audit):</b>
/// The dat's <c>ElementDesc.DefaultState</c> field specifies which SPRITE/MEDIA
/// state an element starts in (e.g., <c>Normal</c>, <c>Minimized</c>). It does
/// NOT encode visibility of sibling Group containers. The <c>StateDesc</c>'s
/// <see cref="DatReaderWriter.Enums.IncorporationFlags"/> contains X/Y/Width/Height/
/// ZLevel/PassToChildren — there is no Visible flag.
/// </para>
///
/// <para>
/// Windows that display multiple sibling Group containers at the same position
/// (the character footer's three state-groups; the tab-page content areas) manage
/// visibility purely at runtime via C++ controller code. Retail uses
/// <c>UIElement::SetState(stateId)</c> on the parent to propagate state, then
/// C++ getters access the right sub-group by element id. All groups are shipped
/// as visible in the imported widget tree; the relevant controllers
/// (<see cref="CharacterStatController"/>) perform the initial show/hide.
/// </para>
/// </summary> /// </summary>
/// <param name="fontResolve">Optional per-element font resolver (see
/// <see cref="Build"/> for details). Null = original single-font behavior.</param>
public static ImportedLayout? Import( public static ImportedLayout? Import(
DatCollection dats, DatCollection dats,
uint layoutId, uint layoutId,
Func<uint, (uint, int, int)> resolve, Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont) UiDatFont? datFont,
Func<uint, UiDatFont?>? fontResolve = null)
{ {
var rootInfo = ImportInfos(dats, layoutId); var rootInfo = ImportInfos(dats, layoutId);
if (rootInfo is null) return null; if (rootInfo is null) return null;
return Build(rootInfo, resolve, datFont); return Build(rootInfo, resolve, datFont, fontResolve);
} }
// ── Inheritance resolution ──────────────────────────────────────────────── // ── Inheritance resolution ────────────────────────────────────────────────
@ -255,6 +312,43 @@ public static class LayoutImporter
// ZLevel 0 so it escaped). Restore the slot's own frame-layer so the panel sits in // ZLevel 0 so it escaped). Restore the slot's own frame-layer so the panel sits in
// FRONT of the backdrop. (B-Controller debug 2026-06-21; continuation of #145.) // FRONT of the backdrop. (B-Controller debug 2026-06-21; continuation of #145.)
result.ZLevel = self.ZLevel; result.ZLevel = self.ZLevel;
// Sub-layout slot sizing: when a sub-layout is mounted into a slot that is TALLER
// (or wider) than the sub-layout's own design height, the cascade of Bottom/Right
// anchored elements must be resized to fill the slot. Without this step, the
// background element (0x10000226 for the Attributes tab, designed at 337px) captures
// _amB = slotH - designH = 238 and permanently stays at 337px, and every element
// within it with Bottom anchor stays at its design size too (list box at 160px
// instead of the correct 398px).
//
// Retail reference: UIElement::UpdateForParentSizeChange (C++ runtime) propagates a
// new parent height down the whole tree, updating each Bottom-anchored child's
// rect by maintaining its bottom margin. We replicate that cascade here at import
// time on the base-children subtree so the anchor-capture during the first render
// frame sees zero (or unchanged) margins — not the spurious "slot bigger than design"
// margin.
//
// Safe guard: only fire when the slot has explicit size AND the base children are
// smaller (i.e., this is the "slot bigger than design" case).
// Inventory/paperdoll slots are unaffected because their slot H already matches the
// sub-layout design H, so no cascade occurs.
if (result.Width > 0 && result.Height > 0)
{
foreach (var child in baseChildren)
{
var childAnchors = ElementReader.ToAnchors(child.Left, child.Top, child.Right, child.Bottom);
const AnchorEdges StretchAll = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom;
if (child.X == 0 && child.Y == 0
&& (childAnchors & StretchAll) == StretchAll // full-stretch background
&& child.Width <= result.Width
&& child.Height > 0 && child.Height < result.Height) // smaller than slot
{
// Cascade UpdateForParentSizeChange down the subtree: maintain each
// Bottom-anchored element's bottom margin while growing the parent height.
CascadeHeight(child, child.Height, result.Height);
}
}
}
} }
return result; return result;
@ -325,6 +419,53 @@ public static class LayoutImporter
{ {
info.FontDid = did.Value; info.FontDid = did.Value;
} }
if (sd.Properties is not null)
{
// HorizontalJustification (0x14): EnumBaseProperty.
// Retail values: 0=Left, 1=Center, 3=Right, 5=Right (treat 5 as Right per spec).
// Only update if still at the default (Center); derived-wins handled in Merge.
if (info.HJustify == HJustify.Center
&& sd.Properties.TryGetValue(0x14u, out var hRaw)
&& hRaw is EnumBaseProperty hEnum)
{
info.HJustify = hEnum.Value switch
{
0u => HJustify.Left,
1u => HJustify.Center,
3u => HJustify.Right,
5u => HJustify.Right,
_ => HJustify.Center, // unknown → center (safe default)
};
}
// VerticalJustification (0x15): EnumBaseProperty.
// Retail values: 2=Top, 4=Bottom; absent/other = Center.
if (info.VJustify == VJustify.Center
&& sd.Properties.TryGetValue(0x15u, out var vRaw)
&& vRaw is EnumBaseProperty vEnum)
{
info.VJustify = vEnum.Value switch
{
2u => VJustify.Top,
4u => VJustify.Bottom,
_ => VJustify.Center,
};
}
// ColorBaseProperty (0x1B): ARGB bytes → normalized [0,1] Vector4 (R,G,B,A).
// Only read when not already set (first dat state wins; Merge propagates from base).
if (info.FontColor is null
&& sd.Properties.TryGetValue(0x1Bu, out var cRaw)
&& cRaw is ColorBaseProperty cProp)
{
var c = cProp.Value;
// ColorARGB stores components as bytes (0255); normalize to [0,1] for Vector4.
// Alpha=0 in the dat typically means fully opaque (retail convention: 0 → 255).
float a = c.Alpha == 0 ? 1f : c.Alpha / 255f;
info.FontColor = new System.Numerics.Vector4(c.Red / 255f, c.Green / 255f, c.Blue / 255f, a);
}
}
} }
// ── Prototype detection helpers ─────────────────────────────────────────── // ── Prototype detection helpers ───────────────────────────────────────────
@ -384,4 +525,60 @@ public static class LayoutImporter
} }
return null; return null;
} }
// ── Sub-layout slot sizing helpers ────────────────────────────────────────
/// <summary>
/// Recursively propagates a parent-height change from <paramref name="oldParentH"/>
/// to <paramref name="newParentH"/> through <paramref name="el"/> and all its
/// descendants, replicating <c>UIElement::UpdateForParentSizeChange</c> from the
/// retail runtime.
///
/// <para>Three anchor cases for the vertical axis:
/// <list type="bullet">
/// <item>Top+Bottom (stretch): maintain top margin AND bottom margin; height grows.</item>
/// <item>Bottom only (pin-to-bottom, fixed height): maintain bottom margin; Y moves, H unchanged.</item>
/// <item>Top only or None: Y and H unchanged (pinned to top with fixed height).</item>
/// </list>
/// Recurse with the element's new height so grandchildren see the correct parent size.</para>
///
/// <para>This is called once at import time on base-children that are mounted into a
/// slot taller than the sub-layout's design height.</para>
/// </summary>
private static void CascadeHeight(ElementInfo el, float oldParentH, float newParentH)
{
float oldH = el.Height;
float newH = el.Height;
float oldY = el.Y;
var anchors = ElementReader.ToAnchors(el.Left, el.Top, el.Right, el.Bottom);
bool hasTop = (anchors & AnchorEdges.Top) != 0;
bool hasBottom = (anchors & AnchorEdges.Bottom) != 0;
if (hasTop && hasBottom)
{
// Stretch: maintain both top and bottom margins.
// amT = el.Y (pinned to top, unchanged)
// amB = oldParentH - (el.Y + el.H)
float amB = oldParentH - (el.Y + el.Height);
newH = newParentH - el.Y - amB;
if (newH < 0f) newH = 0f;
el.Height = newH;
}
else if (hasBottom && !hasTop)
{
// Pin to bottom: maintain bottom margin, height fixed, Y moves.
// amB = oldParentH - (el.Y + el.H)
float amB = oldParentH - (el.Y + el.Height);
float newY = newParentH - amB - el.Height;
el.Y = newY;
// Height unchanged; newH = oldH for recursion.
}
// else: Top-only or None — element is top-pinned with fixed height; nothing moves.
// Recurse into children with the element's new height as their parent.
foreach (var child in el.Children)
CascadeHeight(child, oldH, newH);
}
} }

View file

@ -14,6 +14,32 @@ namespace AcDream.App.UI.Layout;
/// </summary> /// </summary>
public sealed class PaperdollController : IItemListDragHandler public sealed class PaperdollController : IItemListDragHandler
{ {
// ── Slots-toggle public surface ───────────────────────────────────────────────────────────────
/// <summary>
/// The 9 armor-slot element-ids whose Visible state the Slots button (0x100005BE) toggles.
/// Doll-view: hidden. Slot-view: shown. Source: gmPaperDollUI::ListenToElementMessage decomp
/// 175674-175706 — these are the only 9 ids that element flips; the other ~12 equip slots
/// (jewelry, weapon, wrists, feet…) are NON-armor and remain visible in both views.
/// </summary>
public static readonly uint[] ArmorSlotElementIds =
{
0x100005abu, 0x100005acu, 0x100005adu, 0x100005aeu, 0x100005afu,
0x100005b0u, 0x100005b1u, 0x100005b2u, 0x100005b3u,
};
/// <summary>
/// View-state model for the Slots toggle (pure logic, no UI coupling).
/// Default is doll-view (SlotView == false): the 3-D character is visible,
/// the 9 armor slots are hidden. Calling Toggle() alternates between views.
/// </summary>
public sealed class PaperdollViewState
{
public bool SlotView { get; private set; } // false = doll-view (default)
public bool DollVisible => !SlotView;
public bool ArmorSlotsVisible => SlotView;
public void Toggle() => SlotView = !SlotView;
}
// WEAPON_READY_SLOT_LOC (acclient.h:3235): the weapon-hand doll slot accepts any wieldable weapon. // WEAPON_READY_SLOT_LOC (acclient.h:3235): the weapon-hand doll slot accepts any wieldable weapon.
private const EquipMask WeaponSlotMask = private const EquipMask WeaponSlotMask =
EquipMask.MeleeWeapon | EquipMask.MissileWeapon | EquipMask.Held | EquipMask.TwoHanded; EquipMask.MeleeWeapon | EquipMask.MissileWeapon | EquipMask.Held | EquipMask.TwoHanded;
@ -51,10 +77,15 @@ public sealed class PaperdollController : IItemListDragHandler
private readonly Action<uint, uint>? _sendWield; // (itemGuid, equipMask) → GetAndWieldItem 0x001A private readonly Action<uint, uint>? _sendWield; // (itemGuid, equipMask) → GetAndWieldItem 0x001A
private readonly List<(EquipMask Mask, UiItemList List)> _slots = new(); private readonly List<(EquipMask Mask, UiItemList List)> _slots = new();
// ── Slots-toggle state ────────────────────────────────────────────────────────────────────────
private readonly PaperdollViewState _viewState = new();
private readonly List<UiItemList> _armorSlots = new();
private UiElement? _dollViewport; // UiViewport wired in Slice 3; UiElement? keeps this task independent
private PaperdollController( private PaperdollController(
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid, ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
Func<ItemType, uint, uint, uint, uint, uint> iconIds, Action<uint, uint>? sendWield, Func<ItemType, uint, uint, uint, uint, uint> iconIds, Action<uint, uint>? sendWield,
uint emptySlotSprite) uint emptySlotSprite, UiDatFont? datFont)
{ {
_objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _sendWield = sendWield; _objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _sendWield = sendWield;
@ -80,14 +111,37 @@ public sealed class PaperdollController : IItemListDragHandler
_objects.ObjectRemoved += OnObjectChanged; _objects.ObjectRemoved += OnObjectChanged;
_objects.ObjectUpdated += OnObjectChanged; _objects.ObjectUpdated += OnObjectChanged;
// ── Slots-toggle wiring ───────────────────────────────────────────────────────────────────
foreach (var id in ArmorSlotElementIds)
if (layout.FindElement(id) is UiItemList armor) _armorSlots.Add(armor);
_dollViewport = layout.FindElement(0x100001D5u); // doll viewport (may be null until Slice 3)
var slotsBtnEl = layout.FindElement(0x100005BEu);
if (slotsBtnEl is UiButton slotsBtn)
{
slotsBtn.OnClick = () => { _viewState.Toggle(); ApplyView(); };
// The dat element has no face sprite, so without a label the button is invisible. Give it a
// left-aligned WHITE "Slots" caption (retail is white, not gold) so it's findable + clickable.
if (datFont is not null)
{
slotsBtn.Label = "Slots";
slotsBtn.LabelFont = datFont;
slotsBtn.LabelColor = System.Numerics.Vector4.One; // white (was gold)
slotsBtn.LabelAlign = UiButton.LabelAlignment.Left; // sit at the left, before the slots
}
}
ApplyView(); // initial state = doll-view (armor slots hidden)
Populate(); Populate();
} }
public static PaperdollController Bind( public static PaperdollController Bind(
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid, ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
Func<ItemType, uint, uint, uint, uint, uint> iconIds, Action<uint, uint>? sendWield = null, Func<ItemType, uint, uint, uint, uint, uint> iconIds, Action<uint, uint>? sendWield = null,
uint emptySlotSprite = 0u) uint emptySlotSprite = 0u, UiDatFont? datFont = null)
=> new PaperdollController(layout, objects, playerGuid, iconIds, sendWield, emptySlotSprite); => new PaperdollController(layout, objects, playerGuid, iconIds, sendWield, emptySlotSprite, datFont);
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); } private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
private void OnObjectMoved(ClientObject o, uint from, uint to) private void OnObjectMoved(ClientObject o, uint from, uint to)
@ -161,6 +215,17 @@ public sealed class PaperdollController : IItemListDragHandler
_sendWield?.Invoke(payload.ObjId, (uint)wieldMask); _sendWield?.Invoke(payload.ObjId, (uint)wieldMask);
} }
/// <summary>
/// Pushes the current <see cref="PaperdollViewState"/> onto the live widgets:
/// hides/shows the doll viewport and each armor slot according to the toggle.
/// Called once at construction (doll-view default) and on every button click.
/// </summary>
private void ApplyView()
{
if (_dollViewport is not null) _dollViewport.Visible = _viewState.DollVisible;
foreach (var a in _armorSlots) a.Visible = _viewState.ArmorSlotsVisible;
}
/// <summary>Detach event handlers (idempotent).</summary> /// <summary>Detach event handlers (idempotent).</summary>
public void Dispose() public void Dispose()
{ {

View file

@ -42,6 +42,11 @@ public sealed class UiDatElement : UiElement
private readonly ElementInfo _info; private readonly ElementInfo _info;
private readonly Func<uint, (uint tex, int w, int h)> _resolve; private readonly Func<uint, (uint tex, int w, int h)> _resolve;
/// <summary>The dat element id from <see cref="ElementInfo.Id"/>. Exposed so controllers
/// can identify which logical element a UiDatElement represents when walking subtrees
/// (e.g. footer state groups that appear once per tab page but share the same dat id).</summary>
public uint ElementId => _info.Id;
/// <summary>Which state name to render. <c>""</c> = the unnamed DirectState. /// <summary>Which state name to render. <c>""</c> = the unnamed DirectState.
/// Falls back to DirectState if the named state is absent.</summary> /// Falls back to DirectState if the named state is absent.</summary>
public string ActiveState { get; set; } = ""; public string ActiveState { get; set; } = "";

View file

@ -47,6 +47,13 @@ public sealed class UiButton : UiElement
/// <summary>Label color (default white).</summary> /// <summary>Label color (default white).</summary>
public Vector4 LabelColor { get; set; } = Vector4.One; public Vector4 LabelColor { get; set; } = Vector4.One;
/// <summary>Horizontal alignment of <see cref="Label"/>. Center (default) for normal buttons;
/// Left for the paperdoll "Slots" caption that sits at the left edge, before the slots.</summary>
public LabelAlignment LabelAlign { get; set; } = LabelAlignment.Center;
/// <summary>Label horizontal alignment options.</summary>
public enum LabelAlignment { Center, Left }
/// <summary> /// <summary>
/// Active state name, runtime-settable (e.g. Max/Min toggling Normal ↔ Minimized). /// Active state name, runtime-settable (e.g. Max/Min toggling Normal ↔ Minimized).
/// Matches <see cref="UiDatElement.ActiveState"/>. /// Matches <see cref="UiDatElement.ActiveState"/>.
@ -75,6 +82,11 @@ public sealed class UiButton : UiElement
/// procedurally, so the importer must not build the button's children as widgets.</summary> /// procedurally, so the importer must not build the button's children as widgets.</summary>
public override bool ConsumesDatChildren => true; public override bool ConsumesDatChildren => true;
/// <summary>A button is interactive — it must receive its Click even inside a whole-window-Draggable
/// frame (e.g. the paperdoll "Slots" toggle in the inventory window), so it opts out of the
/// IA-12 whole-window-drag that would otherwise swallow the press.</summary>
public override bool HandlesClick => true;
/// <summary> /// <summary>
/// Returns the File id for the current <see cref="ActiveState"/>, falling back to /// Returns the File id for the current <see cref="ActiveState"/>, falling back to
/// the DirectState ("" key) if the named state is absent. /// the DirectState ("" key) if the named state is absent.
@ -101,8 +113,10 @@ public sealed class UiButton : UiElement
if (Label is { Length: > 0 } label && LabelFont is { } lf) if (Label is { Length: > 0 } label && LabelFont is { } lf)
{ {
float tx = (Width - lf.MeasureWidth(label)) * 0.5f; float tx = LabelAlign == LabelAlignment.Left
float ty = (Height - lf.LineHeight) * 0.5f; ? 3f // small left pad (room for a future checkbox box)
: (Width - lf.MeasureWidth(label)) * 0.5f; // centered (default)
float ty = (Height - lf.LineHeight) * 0.5f;
ctx.DrawStringDat(lf, label, tx, ty, LabelColor); ctx.DrawStringDat(lf, label, tx, ty, LabelColor);
} }
} }

View file

@ -121,6 +121,15 @@ public abstract class UiElement
/// false; overridden by drag sources (e.g. an occupied <see cref="UiItemSlot"/>).</summary> /// false; overridden by drag sources (e.g. an occupied <see cref="UiItemSlot"/>).</summary>
public virtual bool IsDragSource => false; public virtual bool IsDragSource => false;
/// <summary>If true, a left-press on this element is handled BY the element (it receives the Click
/// on release) instead of being captured as a whole-window move on a Draggable ancestor. Set by
/// interactive leaf widgets (e.g. <see cref="UiButton"/>) so they stay clickable inside a
/// whole-window-Draggable frame like the inventory window — where, without this, the IA-12
/// whole-window-drag swallows the press and the Click is never emitted. Distinct from
/// <see cref="IsDragSource"/> (starts a drag-drop) and <see cref="CapturesPointerDrag"/> (a
/// self-driven interior drag such as text selection). Default false.</summary>
public virtual bool HandlesClick => false;
/// <summary>Minimum size enforced while resizing.</summary> /// <summary>Minimum size enforced while resizing.</summary>
public float MinWidth { get; set; } = 40f; public float MinWidth { get; set; } = 40f;
public float MinHeight { get; set; } = 40f; public float MinHeight { get; set; } = 40f;

View file

@ -1,3 +1,4 @@
using System;
using System.Numerics; using System.Numerics;
namespace AcDream.App.UI; namespace AcDream.App.UI;
@ -23,10 +24,28 @@ public class UiPanel : UiElement
public float BorderThickness { get; set; } = 1f; public float BorderThickness { get; set; } = 1f;
/// <summary>Optional dat RenderSurface id for the panel background sprite, drawn
/// in place of (or alongside) <see cref="BackgroundColor"/>. 0 = none.
/// When set, the sprite is stretched to fill the panel rect.
/// Used by the attribute-list selected-row highlight (sprite 0x06001397 = Button state 6).</summary>
public uint BackgroundSprite { get; set; }
/// <summary>Resolves a dat RenderSurface id to (GL tex handle, pixel width, pixel height).
/// Required when <see cref="BackgroundSprite"/> is non-zero.</summary>
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
protected override void OnDraw(UiRenderContext ctx) protected override void OnDraw(UiRenderContext ctx)
{ {
if (BackgroundColor.W > 0f) 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);
}
else if (BackgroundColor.W > 0f)
{
ctx.DrawRect(0, 0, Width, Height, BackgroundColor); ctx.DrawRect(0, 0, Width, Height, BackgroundColor);
}
if (BorderColor.W > 0f && BorderThickness > 0f) if (BorderColor.W > 0f && BorderThickness > 0f)
ctx.DrawRectOutline(0, 0, Width, Height, BorderColor, BorderThickness); ctx.DrawRectOutline(0, 0, Width, Height, BorderColor, BorderThickness);
@ -94,3 +113,90 @@ public class UiSimpleButton : UiPanel
ctx.DrawString(Text, tx, ty, TextColor); ctx.DrawString(Text, tx, ty, TextColor);
} }
} }
/// <summary>
/// A <see cref="UiPanel"/> that fires an <see cref="OnClick"/> callback when the user
/// left-clicks it. Used for the attribute-list rows in the Character window — each row
/// is a transparent container that needs to respond to pointer hits while its children
/// (icon, name, value) are ClickThrough decorations.
///
/// <para>Retail analog: the <c>AttributeInfoRegion</c> row widget in <c>gmAttributeUI</c>
/// catches <c>UIEvent_LeftClick</c> (0x01) and calls <c>SetSelectedAttribute</c> on the
/// parent window. In acdream we wire the equivalent via this action callback instead of
/// the retail message bus.</para>
///
/// <para>When <see cref="UseSelectionBars"/> is true and <see cref="UiPanel.BackgroundSprite"/>
/// is non-zero, draws the sprite as a thin full-width bar at the TOP and BOTTOM edges of
/// the row (not stretched to fill). This matches retail's selection highlight which shows
/// a horizontal dark bar on both the top and bottom edge of the selected attribute row,
/// with NO left/right end-caps. Bar height is <see cref="SelectionBarHeight"/> pixels
/// (default 3px).</para>
/// </summary>
public class UiClickablePanel : UiPanel
{
/// <summary>Called when the user releases the left mouse button over this panel.</summary>
public Action? OnClick { get; set; }
/// <summary>When true and <see cref="UiPanel.BackgroundSprite"/> is non-zero, draws
/// the sprite as a thin horizontal bar at the top AND bottom edges of the panel,
/// NOT as a full-height stretched fill. Matches retail's selected-row highlight
/// (sprite 0x06001397 — 300×32 px — shown as bars, not a block fill).
/// Default false (preserves legacy full-stretch behavior).</summary>
public bool UseSelectionBars { get; set; }
/// <summary>Height in pixels of each selection bar (top and bottom). Default 3px.
/// Ignored when <see cref="UseSelectionBars"/> is false.</summary>
public float SelectionBarHeight { get; set; } = 3f;
public UiClickablePanel()
{
// Rows must receive pointer events — override the UiPanel default (ClickThrough=false,
// which is the UiElement base default). Explicit for clarity.
ClickThrough = false;
}
/// <summary>HandlesClick = true ensures this row receives its own Click even when
/// it is nested inside a Draggable ancestor window frame (e.g. the future character
/// window with a whole-window drag handle). Without this, the press would be consumed
/// by the ancestor's drag logic and the Click would never fire.</summary>
public override bool HandlesClick => true;
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.Click && Enabled)
{
OnClick?.Invoke();
return true;
}
return false;
}
protected override void OnDraw(UiRenderContext ctx)
{
if (UseSelectionBars && BackgroundSprite != 0 && SpriteResolve is { } sr)
{
// Draw the selection highlight as a thin bar at the TOP and BOTTOM of the row.
// The sprite (0x06001397) is 300×32 px — we draw it as horizontal strips at
// native height (SelectionBarHeight), stretched to full panel width (UV tile
// horizontally). No left/right end-caps: u0=0, u1=Width/nativeW (UV repeat).
var (tex, tw, th) = sr(BackgroundSprite);
if (tex != 0 && tw > 0 && th > 0)
{
float barH = SelectionBarHeight;
float uTile = tw > 0 ? Width / tw : 1f;
// Top bar: shows the top barH px of the sprite (v = 0 → barH/th).
float vBot = th > 0 ? barH / th : 1f;
ctx.DrawSprite(tex, 0f, 0f, Width, barH, 0f, 0f, uTile, vBot, Vector4.One);
// Bottom bar: shows the bottom barH px of the sprite (v = 1barH/th → 1).
float vTop2 = th > 0 ? 1f - barH / th : 0f;
ctx.DrawSprite(tex, 0f, Height - barH, Width, barH, 0f, vTop2, uTile, 1f, Vector4.One);
}
// Selection-bar mode draws no border (rows have BorderColor=Zero by design).
}
else
{
// Default UiPanel draw: handles BackgroundSprite, BackgroundColor, AND border.
base.OnDraw(ctx);
}
}
}

View file

@ -275,12 +275,13 @@ public sealed class UiRoot : UiElement
// the bar movable by its empty cells / chrome. // the bar movable by its empty cells / chrome.
_dragCandidate = true; _dragCandidate = true;
} }
else if (target.CapturesPointerDrag) else if (target.CapturesPointerDrag || target.HandlesClick)
{ {
// The pressed widget owns interior drags (e.g. text selection): // The pressed widget owns its pointer interaction — either an interior drag (e.g. text
// do NOT move the ancestor window. The already-dispatched MouseDown // selection, CapturesPointerDrag) or a click it must receive (e.g. a UiButton,
// event + SetCapture(target) let the target drive its own drag via // HandlesClick). Either way do NOT move the ancestor window. The already-dispatched
// the MouseMove events it receives while captured. // MouseDown + SetCapture(target) let the target handle it; on release OnMouseUp emits
// the Click over the same element. (A HandlesClick widget is not a drag candidate.)
_dragCandidate = false; _dragCandidate = false;
} }
else if (window.Draggable) else if (window.Draggable)
@ -654,6 +655,10 @@ public sealed class UiRoot : UiElement
return (null, 0, 0); return (null, 0, 0);
} }
/// <summary>Public hit-test for tooling (the UI Studio inspector): the topmost element under
/// (x,y) in root space, honoring modal exclusivity + Z-order. Wraps the private HitTestTopDown.</summary>
public UiElement? Pick(int x, int y) => HitTestTopDown(x, y).element;
private static UiElement? FindWindow(UiElement? e) private static UiElement? FindWindow(UiElement? e)
{ {
while (e is not null) while (e is not null)

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using System.Text; using System.Text;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.UI.Layout;
namespace AcDream.App.UI; namespace AcDream.App.UI;
@ -44,6 +45,19 @@ public sealed class UiText : UiElement
/// the host from <see cref="UiHost.Keyboard"/>.</summary> /// the host from <see cref="UiHost.Keyboard"/>.</summary>
public Silk.NET.Input.IKeyboard? Keyboard { get; set; } public Silk.NET.Input.IKeyboard? Keyboard { get; set; }
/// <summary>
/// Default line color used by controllers when they do not supply a per-line
/// <see cref="Vector4"/> color explicitly. Set by <c>DatWidgetFactory.BuildText</c>
/// from <c>ElementInfo.FontColor</c> when the dat carries a 0x1B ColorBaseProperty;
/// otherwise white (<see cref="Vector4.One"/>).
///
/// <para>Controllers that supply a per-line color via <see cref="LinesProvider"/>
/// (e.g. <c>new UiText.Line(text, explicitColor)</c>) are unaffected — they always
/// win over this default. This property is only a convenience starting point for
/// controllers that want to read the dat color rather than hard-code it.</para>
/// </summary>
public Vector4 DefaultColor { get; set; } = Vector4.One;
/// <summary>Backing fill behind the text. Defaults to transparent so an unbound /// <summary>Backing fill behind the text. Defaults to transparent so an unbound
/// UiText (no controller) draws nothing. Set to the retail translucent value by /// UiText (no controller) draws nothing. Set to the retail translucent value by
/// the controller (e.g. <c>ChatWindowController</c>).</summary> /// the controller (e.g. <c>ChatWindowController</c>).</summary>
@ -71,6 +85,30 @@ public sealed class UiText : UiElement
/// after the rewire. Pair with <c>ClickThrough = true</c> for non-interactive labels.</summary> /// after the rewire. Pair with <c>ClickThrough = true</c> for non-interactive labels.</summary>
public bool Centered { get; set; } public bool Centered { get; set; }
/// <summary>Static right-aligned single-line mode: draws the FIRST line right-justified
/// within the element rect, vertically centered, with NO scroll/selection machinery.
/// Used for value labels in attribute/skill rows where the number must hug the right edge.
/// Mutually exclusive with <see cref="Centered"/> — if both are true, Centered takes
/// precedence. Pair with <c>ClickThrough = true</c> for non-interactive labels.</summary>
public bool RightAligned { get; set; }
/// <summary>
/// Vertical position of the text within the element rect in single-line mode
/// (<see cref="Centered"/> or <see cref="RightAligned"/>).
/// <list type="bullet">
/// <item><description><b>Center</b> (default) — vertically centered, matching the original
/// behavior of the centered/right-aligned paths.</description></item>
/// <item><description><b>Top</b> — text is placed at <c>y = Padding</c> (top of the content
/// area), so the text sits at the top of the element rather than centering in it.
/// Used for footer title elements whose dat box is the full footer height (55 px) but
/// the text should render near the top.</description></item>
/// <item><description><b>Bottom</b> — text is placed at <c>y = Height - lineHeight - Padding</c>.</description></item>
/// </list>
/// Only meaningful when <see cref="Centered"/> or <see cref="RightAligned"/> is true.
/// Has no effect on the scrollable multi-line path.
/// </summary>
public VJustify VerticalJustify { get; set; } = VJustify.Center;
/// <summary>The scroll model — also read by the linked UiScrollbar.</summary> /// <summary>The scroll model — also read by the linked UiScrollbar.</summary>
public UiScrollable Scroll { get; } = new(); public UiScrollable Scroll { get; } = new();
@ -131,8 +169,8 @@ public sealed class UiText : UiElement
ctx.DrawFill(0, 0, Width, Height, BackgroundColor); ctx.DrawFill(0, 0, Width, Height, BackgroundColor);
// Static centered single-line mode (vitals cur/max numbers etc.): draw the first // Static centered single-line mode (vitals cur/max numbers etc.): draw the first
// line centered H+V with the SAME formula UIElement_Meter used for its label, then // line centered H+V (or H+Top/Bottom per VerticalJustify) with the SAME formula
// skip the scroll/selection machinery entirely. // UIElement_Meter used for its label, then skip the scroll/selection machinery entirely.
if (Centered) if (Centered)
{ {
var cLines = LinesProvider(); var cLines = LinesProvider();
@ -141,18 +179,40 @@ public sealed class UiText : UiElement
if (DatFont is { } cdf) if (DatFont is { } cdf)
{ {
float cx = (Width - cdf.MeasureWidth(line0.Text)) * 0.5f; float cx = (Width - cdf.MeasureWidth(line0.Text)) * 0.5f;
float cy = (Height - cdf.LineHeight) * 0.5f; float cy = VOffset(Height, cdf.LineHeight, Padding, VerticalJustify);
ctx.DrawStringDat(cdf, line0.Text, cx, cy, line0.Color); ctx.DrawStringDat(cdf, line0.Text, cx, cy, line0.Color);
} }
else if ((Font ?? ctx.DefaultFont) is { } cbf) else if ((Font ?? ctx.DefaultFont) is { } cbf)
{ {
float cx = (Width - cbf.MeasureWidth(line0.Text)) * 0.5f; float cx = (Width - cbf.MeasureWidth(line0.Text)) * 0.5f;
float cy = (Height - cbf.LineHeight) * 0.5f; float cy = VOffset(Height, cbf.LineHeight, Padding, VerticalJustify);
ctx.DrawString(line0.Text, cx, cy, line0.Color, cbf); ctx.DrawString(line0.Text, cx, cy, line0.Color, cbf);
} }
return; return;
} }
// Static right-aligned single-line mode: draw the first line flush with the right
// edge, vertical position per VerticalJustify, then skip the scroll/selection machinery.
if (RightAligned)
{
var rLines = LinesProvider();
if (rLines.Count == 0) return;
var line0 = rLines[0];
if (DatFont is { } rdf)
{
float rx = Width - rdf.MeasureWidth(line0.Text) - Padding;
float ry = VOffset(Height, rdf.LineHeight, Padding, VerticalJustify);
ctx.DrawStringDat(rdf, line0.Text, rx, ry, line0.Color);
}
else if ((Font ?? ctx.DefaultFont) is { } rbf)
{
float rx = Width - rbf.MeasureWidth(line0.Text) - Padding;
float ry = VOffset(Height, rbf.LineHeight, Padding, VerticalJustify);
ctx.DrawString(line0.Text, rx, ry, line0.Color, rbf);
}
return;
}
// Prefer the retail dat font when set; fall back to BitmapFont. // Prefer the retail dat font when set; fall back to BitmapFont.
var datFont = DatFont; var datFont = DatFont;
var bitmapFont = datFont is null ? (Font ?? ctx.DefaultFont) : null; var bitmapFont = datFont is null ? (Font ?? ctx.DefaultFont) : null;
@ -337,6 +397,23 @@ public sealed class UiText : UiElement
// ── Pure, testable logic (no GL / no font texture) ─────────────────── // ── Pure, testable logic (no GL / no font texture) ───────────────────
/// <summary>
/// Compute the Y offset (local space) for a single line in the Centered/RightAligned
/// single-line path, given the element height, font line-height, padding, and
/// vertical justification.
/// </summary>
/// <param name="height">Element height in pixels.</param>
/// <param name="lineHeight">Font line height in pixels.</param>
/// <param name="padding">Content padding.</param>
/// <param name="vj">Vertical justification.</param>
public static float VOffset(float height, float lineHeight, float padding, VJustify vj)
=> vj switch
{
VJustify.Top => padding,
VJustify.Bottom => height - lineHeight - padding,
_ => (height - lineHeight) * 0.5f, // Center (default)
};
/// <summary>Order two caret positions so the first is <= the second (by line, /// <summary>Order two caret positions so the first is <= the second (by line,
/// then column).</summary> /// then column).</summary>
public static (Pos start, Pos end) Order(Pos a, Pos b) public static (Pos start, Pos end) Order(Pos a, Pos b)

View file

@ -0,0 +1,27 @@
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>Leaf widget for dat Type 0xD (UIElement_Viewport). Blits the texture produced by its
/// IUiViewportRenderer (run in the pre-UI hook) as a single sprite at its own rect. The 3-D render
/// does NOT happen here (OnDraw only has a 2-D context).</summary>
public sealed class UiViewport : UiElement
{
public override bool ConsumesDatChildren => true;
/// <summary>Renderer that produces the off-screen texture. Set by GameWindow wiring (later task).</summary>
public IUiViewportRenderer? Renderer { get; set; }
/// <summary>Last GL color-texture handle produced by the pre-UI hook. 0 = nothing to blit.</summary>
public uint TextureHandle { get; set; }
protected override void OnDraw(UiRenderContext ctx)
{
if (!Visible || TextureHandle == 0) return;
// Local origin is already at this widget's Left/Top (PushTransform applied by DrawSelfAndChildren).
// V is FLIPPED (v0=1, v1=0): TextureHandle is an off-screen FBO color texture, whose origin is
// bottom-left (GL), while the UI sprite convention is top-left. Without the flip the doll renders
// upside-down. (If the doll appears upside-down at the visual gate, this is the line to revisit.)
ctx.DrawSprite(TextureHandle, 0f, 0f, Width, Height, 0f, 1f, 1f, 0f, Vector4.One);
}
}

View file

@ -0,0 +1,41 @@
using System.Numerics;
using AcDream.App.Rendering;
using Xunit;
namespace AcDream.App.Tests.Rendering;
public class DollCameraTests
{
[Fact]
public void Eye_position_is_retail_verbatim()
{
var cam = new DollCamera { Aspect = 100f / 214f };
Assert.True(Matrix4x4.Invert(cam.View, out var inv));
var eye = inv.Translation;
// Retail UIElement_Viewport::SetCamera position (decomp 0x004a5a51-0x004a5a61).
Assert.Equal(0.12f, eye.X, 3);
Assert.Equal(-2.4f, eye.Y, 3);
Assert.Equal(0.88f, eye.Z, 3);
}
[Fact]
public void Look_axis_is_pure_plus_y_zero_yaw()
{
// retail SetCameraDirection(0,0,0) ⇒ IDENTITY view frame ⇒ camera looks straight down +Y.
// System.Numerics CreateLookAt: forward = -(M13, M23, M33). Any yaw (Target.x≠Eye.x) would put a
// non-zero X here and turn the doll's face away — the bug this guards against.
var cam = new DollCamera { Aspect = 100f / 214f };
var forward = -new Vector3(cam.View.M13, cam.View.M23, cam.View.M33);
Assert.Equal(0f, forward.X, 4);
Assert.Equal(1f, forward.Y, 4);
Assert.Equal(0f, forward.Z, 4);
}
[Fact]
public void Projection_is_finite_and_uses_aspect()
{
var cam = new DollCamera { Aspect = 1.5f };
Assert.True(float.IsFinite(cam.Projection.M11));
Assert.NotEqual(0f, cam.Projection.M34); // perspective w = -z term
}
}

View file

@ -0,0 +1,84 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.Core.World;
using Xunit;
namespace AcDream.App.Tests.Rendering;
public class DollEntityBuilderTests
{
[Fact]
public void Builds_doll_entity_with_synthetic_guid_and_player_setup()
{
// SubPaletteRange uses: SubPaletteId, Offset (byte), Length (byte)
var doll = DollEntityBuilder.Build(
setupId: 0x0200_0001u,
meshRefs: new List<MeshRef>(),
basePaletteId: 0x04000ABCu,
subPalettes: new (uint SubPaletteId, byte Offset, byte Length)[] { (0x0F00_0001u, 0, 8) },
partOverrides: new (byte PartIndex, uint GfxObjId)[] { (2, 0x0100_0042u) });
Assert.Equal(0x0200_0001u, doll.SourceGfxObjOrSetupId);
Assert.Equal(DollEntityBuilder.DollServerGuid, doll.ServerGuid);
Assert.NotEqual(0u, doll.ServerGuid);
Assert.Single(doll.PartOverrides);
Assert.Equal((byte)2, doll.PartOverrides[0].PartIndex);
Assert.Equal(0x0100_0042u, doll.PartOverrides[0].GfxObjId);
Assert.NotNull(doll.PaletteOverride);
Assert.Equal(0x04000ABCu, doll.PaletteOverride!.BasePaletteId);
Assert.Single(doll.PaletteOverride.SubPalettes);
Assert.Equal(0x0F00_0001u, doll.PaletteOverride.SubPalettes[0].SubPaletteId);
}
[Fact]
public void Null_overrides_give_empty_collections_not_null()
{
var doll = DollEntityBuilder.Build(0x0200_0001u, new List<MeshRef>(), null, null, null);
// No subpalettes => no PaletteOverride (mirrors GameWindow: only built when Count > 0)
Assert.Null(doll.PaletteOverride);
Assert.Empty(doll.PartOverrides);
}
[Fact]
public void Empty_subpalettes_give_null_palette_override()
{
// Mirror GameWindow: SubPalettes with Count == 0 => no override
var doll = DollEntityBuilder.Build(
0x0200_0001u,
new List<MeshRef>(),
basePaletteId: 0x04000001u,
subPalettes: System.Array.Empty<(uint, byte, byte)>(),
partOverrides: null);
Assert.Null(doll.PaletteOverride);
}
[Fact]
public void Heading_is_normalized_quaternion_facing_viewer()
{
var doll = DollEntityBuilder.Build(0x0200_0001u, new List<MeshRef>(), null, null, null);
Assert.True(System.MathF.Abs(doll.Rotation.LengthSquared() - 1f) < 1e-3f);
}
[Fact]
public void Position_is_world_origin()
{
var doll = DollEntityBuilder.Build(0x0200_0001u, new List<MeshRef>(), null, null, null);
Assert.Equal(Vector3.Zero, doll.Position);
}
[Fact]
public void ParentCellId_is_null_for_doll_scene()
{
var doll = DollEntityBuilder.Build(0x0200_0001u, new List<MeshRef>(), null, null, null);
Assert.Null(doll.ParentCellId);
}
[Fact]
public void MeshRefs_are_passed_through()
{
var refs = new List<MeshRef> { new MeshRef(0x01000001u, Matrix4x4.Identity) };
var doll = DollEntityBuilder.Build(0x0200_0001u, refs, null, null, null);
Assert.Same(refs, doll.MeshRefs);
}
}

View file

@ -0,0 +1,118 @@
using AcDream.App.Studio;
namespace AcDream.App.Tests.Studio;
/// <summary>
/// Pure-math tests for the canvas → panel-local coordinate mapping used by
/// <see cref="StudioInspector.DrawCanvas"/>.
///
/// <para>No GL context required — we're just verifying the formula:
/// panel_local = (raw_mouse_screen) - (image_screen_top_left)</para>
///
/// <para>The image_screen_top_left is what ImGui.GetItemRectMin() returns after
/// ImGui.Image: the sub-window top-left + title-bar height + inner padding + any
/// scrolling. We model it as a constant offset in these tests.</para>
///
/// <para>V-flip: the image is drawn with uv0=(0,1) / uv1=(1,0) so GL's bottom-left
/// origin is flipped to top-left on screen. After the flip, screen Y=0 (top of image)
/// = panel Y=0 (top of the UI), so NO additional Y inversion is applied.</para>
/// </summary>
public class CanvasCoordMappingTests
{
// Simulates the mapping DrawCanvas performs:
// panel pixel = mouse_screen - image_rectMin
// Returns null when the result falls outside [0, width) x [0, height).
private static (int px, int py)? Map(
float mouseScreenX, float mouseScreenY,
float imageOriginX, float imageOriginY,
int panelWidth, int panelHeight)
{
int ix = (int)(mouseScreenX - imageOriginX);
int iy = (int)(mouseScreenY - imageOriginY);
if (ix < 0 || ix >= panelWidth || iy < 0 || iy >= panelHeight)
return null;
return (ix, iy);
}
[Fact]
public void TopLeft_of_image_maps_to_panel_origin()
{
// The canvas image starts at screen (300, 50) (after sub-window chrome).
// A click exactly at the image's screen top-left → panel (0, 0).
var result = Map(mouseScreenX: 300f, mouseScreenY: 50f,
imageOriginX: 300f, imageOriginY: 50f,
panelWidth: 1280, panelHeight: 720);
Assert.Equal((0, 0), result);
}
[Fact]
public void Interior_point_maps_correctly()
{
// Image origin at screen (300, 50). Mouse at screen (780, 230).
// Expected panel coord: (780-300, 230-50) = (480, 180).
var result = Map(mouseScreenX: 780f, mouseScreenY: 230f,
imageOriginX: 300f, imageOriginY: 50f,
panelWidth: 1280, panelHeight: 720);
Assert.Equal((480, 180), result);
}
[Fact]
public void Bottom_right_corner_maps_to_last_valid_pixel()
{
// Image is 1280×720, origin at screen (300, 50).
// Last pixel in bottom-right is panel (1279, 719) → screen (1579, 769).
var result = Map(mouseScreenX: 1579f, mouseScreenY: 769f,
imageOriginX: 300f, imageOriginY: 50f,
panelWidth: 1280, panelHeight: 720);
Assert.Equal((1279, 719), result);
}
[Fact]
public void Mouse_on_ImGui_chrome_above_image_returns_null()
{
// The ImGui window title-bar / padding is above rectMin, i.e. at screenY < 50.
// The mouse there should NOT produce a panel event.
var result = Map(mouseScreenX: 400f, mouseScreenY: 40f, // 10px above image origin
imageOriginX: 300f, imageOriginY: 50f,
panelWidth: 1280, panelHeight: 720);
Assert.Null(result);
}
[Fact]
public void Mouse_below_image_returns_null()
{
// screenY = 50 + 720 = 770 → iy = 720 which is >= panelHeight (720).
var result = Map(mouseScreenX: 400f, mouseScreenY: 770f,
imageOriginX: 300f, imageOriginY: 50f,
panelWidth: 1280, panelHeight: 720);
Assert.Null(result);
}
[Fact]
public void Y_is_not_inverted_after_vflip()
{
// Confirm the "no extra Y inversion" contract:
// The image is V-flipped in ImGui (uv0.Y=1, uv1.Y=0), so screen top row = panel Y=0.
// A click near the TOP of the image should give a SMALL panel Y, not a large one.
// Image origin at (300, 50). Click at (400, 55) → panel (100, 5). Y is small (near top).
var result = Map(mouseScreenX: 400f, mouseScreenY: 55f,
imageOriginX: 300f, imageOriginY: 50f,
panelWidth: 1280, panelHeight: 720);
Assert.Equal((100, 5), result);
// NOT (100, 715) — which would be the result if Y were incorrectly inverted.
Assert.True(result!.Value.py < 720 / 2, "Y near screen top should map to small panel Y, not near bottom");
}
[Fact]
public void LargeChrome_offset_is_fully_absorbed()
{
// Simulate a canvas sub-window with large chrome: ImGui title (20px) + padding (8px)
// puts the image origin at screenY = menuBar(22) + titleBar(20) + padding(8) = 50.
// Also a wide tree pane puts imageOriginX = 280 + padding.
// A click at screen (400, 110) with origin (290, 50) → panel (110, 60).
var result = Map(mouseScreenX: 400f, mouseScreenY: 110f,
imageOriginX: 290f, imageOriginY: 50f,
panelWidth: 1280, panelHeight: 720);
Assert.Equal((110, 60), result);
}
}

View file

@ -0,0 +1,188 @@
using AcDream.App.Studio;
using AcDream.App.UI;
namespace AcDream.App.Tests.Studio;
/// <summary>
/// Tests for <see cref="DumpLayout"/> — parsing the retail UI dump JSON and
/// building a <see cref="UiElement"/> tree from it.
///
/// These tests load the real dump file from the source tree
/// (<c>docs/research/2026-06-25-retail-ui-layout-dump.json</c>). The test
/// skips cleanly when the file is absent (should not happen in a normal dev
/// checkout, but guards against stripped CI machines).
/// </summary>
public class DumpLayoutTests
{
private static string DumpPath()
{
// Walk up from the test output directory to the solution root,
// mirroring ConformanceDats.SolutionRoot().
var dir = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(dir))
{
if (File.Exists(Path.Combine(dir, "AcDream.slnx")))
return Path.Combine(dir, "docs", "research",
"2026-06-25-retail-ui-layout-dump.json");
dir = Path.GetDirectoryName(dir);
}
// Fallback: try a relative path (won't find it but skip rather than throw)
return Path.Combine(AppContext.BaseDirectory,
"docs", "research", "2026-06-25-retail-ui-layout-dump.json");
}
private static (uint, int, int) NoTex(uint _) => (1u, 1, 1);
// ── Helpers ──────────────────────────────────────────────────────────
/// <summary>Depth-first search for an element with the given EventId.</summary>
private static UiElement? FindById(UiElement root, uint id)
{
if (root.EventId == id) return root;
foreach (var c in root.Children)
{
var found = FindById(c, id);
if (found is not null) return found;
}
return null;
}
/// <summary>Count the total elements in the tree (self + all descendants).</summary>
private static int CountAll(UiElement root)
{
int n = 1;
foreach (var c in root.Children) n += CountAll(c);
return n;
}
// ── Tests ─────────────────────────────────────────────────────────────
/// <summary>
/// Loading the "inventory" slug should succeed and the returned tree should
/// contain an element with EventId == 0x100001D5 (the doll viewport node)
/// and at least 40 elements in total.
/// </summary>
[Fact]
public void Load_Inventory_ReturnsTreeWithDollViewport()
{
var path = DumpPath();
if (!File.Exists(path))
return; // Skip: dump not available.
var root = DumpLayout.Load(path, "inventory", NoTex, out var err);
Assert.NotNull(root);
Assert.Null(err);
// The doll viewport element must appear somewhere in the tree.
const uint dollViewportId = 0x100001D5u;
var found = FindById(root!, dollViewportId);
Assert.NotNull(found);
// The full tree must be reasonably deep — 59 dump nodes → >= 40 elements.
int total = CountAll(root!);
Assert.True(total >= 40,
$"Expected >= 40 elements in inventory tree; got {total}");
}
/// <summary>
/// Loading an unknown slug must return null and a non-empty error string.
/// </summary>
[Fact]
public void Load_UnknownSlug_ReturnsNullWithError()
{
var path = DumpPath();
if (!File.Exists(path))
return; // Skip.
var root = DumpLayout.Load(path, "this_slug_does_not_exist", NoTex, out var err);
Assert.Null(root);
Assert.NotNull(err);
Assert.NotEmpty(err!);
}
/// <summary>
/// The root element's Left/Top should be (0,0) (the panel's rect offset has
/// been stripped so the tree sits at the window origin), and its Width/Height
/// should match the dump panel dimensions.
/// </summary>
[Fact]
public void Load_Inventory_RootAtOrigin()
{
var path = DumpPath();
if (!File.Exists(path))
return; // Skip.
var root = DumpLayout.Load(path, "inventory", NoTex, out _);
Assert.NotNull(root);
// Root always placed at (0,0) by DumpLayout (origin of the UiHost).
Assert.Equal(0f, root!.Left);
Assert.Equal(0f, root.Top);
// Width/Height come from the panel record in the dump.
Assert.True(root.Width > 0, "Root width must be > 0");
Assert.True(root.Height > 0, "Root height must be > 0");
}
/// <summary>
/// Children must use parent-relative coordinates (the dump rects are absolute;
/// DumpLayout subtracts the parent rect to produce parent-local offsets).
/// Verify that at least the direct children of the root have Left/Top values
/// that are NOT equal to the absolute rect they had in the dump (since the root
/// was at x>0 in screen space but we place it at 0,0).
/// </summary>
[Fact]
public void Load_Inventory_ChildrenAreParentRelative()
{
var path = DumpPath();
if (!File.Exists(path))
return; // Skip.
var root = DumpLayout.Load(path, "inventory", NoTex, out _);
Assert.NotNull(root);
// If the dump has children at absolute x>=500 but the root is at 0,
// a correct parent-relative placement will give children x < 500.
// (The inventory panel root is at absolute x=500; children in the dump
// also start at x=500 — after subtraction they should land near x=0.)
bool anyChildAtAbsoluteX = false;
foreach (var child in root!.Children)
{
if (child.Left >= 490f) // would indicate absolute not relative
{
anyChildAtAbsoluteX = true;
break;
}
}
Assert.False(anyChildAtAbsoluteX,
"Children appear to have absolute coords (Left >= 490) — " +
"DumpLayout must subtract the parent rect.");
}
/// <summary>
/// Every panel slug known in the dump must load without error.
/// This is a smoke test that the JSON parse + tree build does not
/// crash on any of the 26 panels.
/// </summary>
[Fact]
public void Load_AllSlugs_Succeed()
{
var path = DumpPath();
if (!File.Exists(path))
return; // Skip.
var slugs = UiDumpModel.ListSlugs(path);
Assert.True(slugs.Count >= 20,
$"Expected >= 20 panel slugs in dump; got {slugs.Count}");
foreach (var slug in slugs)
{
var root = DumpLayout.Load(path, slug, NoTex, out var err);
Assert.True(root is not null || err is not null,
$"Load('{slug}') returned both null root AND null error — one must be set.");
if (root is null)
Assert.Fail($"Slug '{slug}' failed with error: {err}");
}
}
}

View file

@ -0,0 +1,160 @@
using AcDream.App.Studio;
using AcDream.Core.Items;
namespace AcDream.App.Tests.Studio;
/// <summary>
/// Unit tests for <see cref="FixtureProvider"/> and <see cref="SampleData"/>.
/// These tests have NO GL/dat dependency — they only exercise the in-memory
/// ClientObjectTable population that FixtureProvider uses.
/// </summary>
public class FixtureProviderTests
{
/// <summary>
/// SampleData.BuildObjectTable() must place at least 6 items in the
/// player's main pack (ContainerId == PlayerGuid) and the player object
/// itself must exist with the right capacities.
/// </summary>
[Fact]
public void SampleTable_hasPackContents()
{
var t = SampleData.BuildObjectTable();
// Player object must exist.
var player = t.Get(SampleData.PlayerGuid);
Assert.NotNull(player);
Assert.Equal(102, player!.ItemsCapacity);
Assert.Equal(7, player.ContainersCapacity);
// At least 6 loose items must be in the main pack.
var contents = t.GetContents(SampleData.PlayerGuid);
Assert.True(contents.Count >= 6,
$"Expected >= 6 items in player pack; got {contents.Count}");
// Verify the first item has a non-zero IconId and a recognised Type.
var firstId = contents[0];
var first = t.Get(firstId);
Assert.NotNull(first);
Assert.NotEqual(0u, first!.IconId);
Assert.NotEqual(ItemType.None, first.Type);
}
/// <summary>
/// Spot-check that the sample table also seeds a sword-like item
/// (MeleeWeapon) and a piece of armor so icon resolution has
/// recognisable item types to work with.
/// </summary>
[Fact]
public void SampleTable_hasWeaponAndArmor()
{
var t = SampleData.BuildObjectTable();
var contents = t.GetContents(SampleData.PlayerGuid);
bool hasMelee = false, hasArmor = false;
foreach (var guid in contents)
{
var obj = t.Get(guid);
if (obj is null) continue;
if ((obj.Type & ItemType.MeleeWeapon) != 0) hasMelee = true;
if ((obj.Type & ItemType.Armor) != 0) hasArmor = true;
}
Assert.True(hasMelee, "Expected at least one MeleeWeapon in sample pack");
Assert.True(hasArmor, "Expected at least one Armor item in sample pack");
}
/// <summary>
/// Sample table must include at least 1 equipped item whose
/// CurrentlyEquippedLocation is non-None.
/// </summary>
[Fact]
public void SampleTable_hasEquippedItems()
{
var t = SampleData.BuildObjectTable();
bool anyEquipped = false;
foreach (var obj in t.Objects)
{
if (obj.CurrentlyEquippedLocation != EquipMask.None)
{ anyEquipped = true; break; }
}
Assert.True(anyEquipped, "Expected at least one equipped item in sample table");
}
/// <summary>
/// Sample table must include at least 2 side-bag containers in the
/// player's pack (ContainerId == PlayerGuid, Type has Container bit).
/// </summary>
[Fact]
public void SampleTable_hasSideBags()
{
var t = SampleData.BuildObjectTable();
int bagCount = 0;
foreach (var guid in t.GetContents(SampleData.PlayerGuid))
{
var obj = t.Get(guid);
if (obj is not null && (obj.Type & ItemType.Container) != 0)
bagCount++;
}
Assert.True(bagCount >= 2,
$"Expected >= 2 side-bag containers in player pack; got {bagCount}");
}
/// <summary>
/// Side bags must match the InventoryController filter: ItemType.Container OR ItemsCapacity &gt; 0.
/// This ensures they appear in the side-bag column even if the exact Type flag changes.
/// </summary>
[Fact]
public void SampleTable_sideBags_matchInventoryControllerFilter()
{
var t = SampleData.BuildObjectTable();
int bagCount = 0;
foreach (var guid in t.GetContents(SampleData.PlayerGuid))
{
var obj = t.Get(guid);
if (obj is null) continue;
// InventoryController.Populate line ~203: Type.HasFlag(Container) OR ItemsCapacity > 0
bool isBag = obj.Type.HasFlag(ItemType.Container) || obj.ItemsCapacity > 0;
if (isBag) bagCount++;
}
Assert.True(bagCount >= 2,
$"Expected >= 2 items matching the side-bag filter; got {bagCount}");
}
/// <summary>
/// Equipped items must BOTH (a) retain CurrentlyEquippedLocation != None after
/// seeding AND (b) appear in GetContents(PlayerGuid) so PaperdollController.Populate
/// can find them. These are the two conditions PaperdollController checks on every
/// equipped item (PaperdollController.Populate: ContainerId == playerGuid AND
/// CurrentlyEquippedLocation != None).
/// </summary>
[Fact]
public void SampleTable_equippedItems_retainLocationAndAreInContents()
{
var t = SampleData.BuildObjectTable();
var contents = new System.Collections.Generic.HashSet<uint>(t.GetContents(SampleData.PlayerGuid));
int equippedCount = 0;
foreach (var obj in t.Objects)
{
if (obj.CurrentlyEquippedLocation == EquipMask.None) continue;
equippedCount++;
// Must appear in GetContents so the controller can pick them up.
Assert.True(contents.Contains(obj.ObjectId),
$"Equipped item 0x{obj.ObjectId:X8} ('{obj.Name}', loc={obj.CurrentlyEquippedLocation}) " +
$"is NOT in GetContents(PlayerGuid). PaperdollController will miss it.");
// The equip location must survive the MoveItem call.
Assert.NotEqual(EquipMask.None, obj.CurrentlyEquippedLocation);
}
Assert.True(equippedCount >= 3,
$"Expected >= 3 equipped items in sample table; got {equippedCount}");
}
}

View file

@ -0,0 +1,60 @@
using AcDream.App.Studio;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using DatReaderWriter;
using DatReaderWriter.Options;
namespace AcDream.App.Tests.Studio;
/// <summary>
/// Unit tests for <see cref="LayoutSource"/>. The dat-backed test skips cleanly
/// when the real dats are not present (CI / dev machines without AC installed).
/// </summary>
public class LayoutSourceTests
{
private static (uint handle, int width, int height) NoTex(uint _) => (1u, 1, 1);
/// <summary>Resolve the client dat directory, or null if unavailable (skip the test).</summary>
private static string? ResolveDatDir()
{
var fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv))
return fromEnv;
var def = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
return Directory.Exists(def) ? def : null;
}
/// <summary>
/// Load the vitals LayoutDesc (0x2100006C) from the real dats and verify
/// that LayoutSource returns a non-null root with the layout id findable.
/// Skips when the dats are unavailable.
///
/// Note: the spec asserts FindElement(0x2100006Cu) — that is the layout dat
/// id, not an element id in the widget tree. The actual vitals root element id
/// is 0x100005F9 (confirmed from vitals_2100006C.json fixture). We assert
/// FindElement(0x100005F9) here which verifies the same integration path: the
/// layout was successfully loaded and the element dict was populated.
/// </summary>
[Fact]
public void LoadsDatLayout_byId()
{
var dir = ResolveDatDir();
if (dir is null)
return; // Skip: dats not available on this machine / CI.
using var dats = new DatCollection(dir, DatAccessType.Read);
var src = new LayoutSource(dats, NoTex, datFont: null);
var root = src.Load(new StudioOptions(dir, 0x2100006Cu, null));
// root must be non-null: Import found the LayoutDesc and built the tree.
Assert.NotNull(root);
Assert.NotNull(src.CurrentLayout);
// The vitals root element id is 0x100005F9 (layout dat id 0x2100006C ≠ element id).
// Asserting FindElement verifies the byId dict was populated by the importer.
Assert.NotNull(src.CurrentLayout!.FindElement(0x100005F9u));
Assert.Equal(LayoutSourceKind.DatLayout, src.Kind);
}
}

View file

@ -0,0 +1,147 @@
using AcDream.App.Studio;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Unit tests for <see cref="CharacterController"/> and <see cref="SampleData.SampleCharacter"/>.
/// The controller CREATES the m_pMainText report element (0x1000011d) at bind time — retail's
/// gm*UI builds it at runtime; it is not a static dat element — and attaches it to the layout root.
/// No dats, no GL — pure data-wiring + report-content tests.
/// </summary>
public class CharacterControllerTests
{
/// <summary>Bind on an empty layout, then return the created m_pMainText element.</summary>
private static UiText BindAndGetText(System.Func<CharacterSheet> data)
{
var layout = FakeLayout();
CharacterController.Bind(layout, data);
return layout.Root.Children.OfType<UiText>()
.First(t => t.EventId == CharacterController.MainTextId);
}
private static string Report(System.Func<CharacterSheet> data)
=> string.Join("\n", BindAndGetText(data).LinesProvider().Select(l => l.Text));
// ── Test 1: Bind creates m_pMainText with a non-empty report ──────────────
[Fact]
public void Bind_CreatesMainTextWithReport()
{
var text = BindAndGetText(SampleData.SampleCharacter);
Assert.True(text.LinesProvider().Count > 0, "Expected non-empty report after Bind");
}
// ── Test 2: Report contains all six attribute names (decomp order 1,2,4,3,5,6) ───
[Fact]
public void Bind_ReportContainsAllSixAttributes()
{
var allText = Report(SampleData.SampleCharacter);
Assert.Contains("Strength", allText);
Assert.Contains("Endurance", allText);
Assert.Contains("Quickness", allText);
Assert.Contains("Coordination", allText);
Assert.Contains("Focus", allText);
Assert.Contains("Self", allText);
}
// ── Test 3: Report contains vitals section ────────────────────────────────
[Fact]
public void Bind_ReportContainsVitals()
{
var allText = Report(SampleData.SampleCharacter);
Assert.Contains("Health", allText);
Assert.Contains("Stamina", allText);
Assert.Contains("Mana", allText);
}
// ── Test 4: Report contains encumbrance / burden section ─────────────────
[Fact]
public void Bind_ReportContainsBurden()
{
var allText = Report(SampleData.SampleCharacter);
Assert.Contains("Burden", allText);
Assert.Contains("Capacity", allText);
}
// ── Test 5: Report contains the sample character's name ──────────────────
[Fact]
public void Bind_ReportContainsSampleName()
=> Assert.Contains("Studio Player", Report(SampleData.SampleCharacter));
// ── Test 6: Bind on a layout WITHOUT the element creates it (no throw) ────
[Fact]
public void Bind_OnEmptyLayout_CreatesElement()
{
var layout = FakeLayout(); // no MainTextId present
CharacterController.Bind(layout, SampleData.SampleCharacter);
Assert.Contains(layout.Root.Children.OfType<UiText>(),
t => t.EventId == CharacterController.MainTextId);
}
// ── Test 7: The created element carries the m_pMainText id ───────────────
[Fact]
public void Bind_CreatedElementHasMainTextId()
=> Assert.Equal(CharacterController.MainTextId, BindAndGetText(SampleData.SampleCharacter).EventId);
// ── Test 8: SampleCharacter fixture has non-zero attribute values ─────────
[Fact]
public void SampleCharacter_AttributesAreNonZero()
{
var s = SampleData.SampleCharacter();
Assert.True(s.Strength > 0, "Strength must be > 0");
Assert.True(s.Endurance > 0, "Endurance must be > 0");
Assert.True(s.Quickness > 0, "Quickness must be > 0");
Assert.True(s.Coordination > 0, "Coordination must be > 0");
Assert.True(s.Focus > 0, "Focus must be > 0");
Assert.True(s.Self > 0, "Self must be > 0");
}
// ── Test 9: SampleCharacter fixture has coherent vitals ──────────────────
[Fact]
public void SampleCharacter_VitalsAreCoherent()
{
var s = SampleData.SampleCharacter();
Assert.True(s.HealthCurrent > 0 && s.HealthCurrent <= s.HealthMax, "Health cur must be in [1, max]");
Assert.True(s.StaminaCurrent > 0 && s.StaminaCurrent <= s.StaminaMax, "Stamina cur must be in [1, max]");
Assert.True(s.ManaCurrent > 0 && s.ManaCurrent <= s.ManaMax, "Mana cur must be in [1, max]");
}
// ── Test 10: Report contains attribute VALUES from the fixture ────────────
[Fact]
public void Bind_ReportContainsSampleAttributeValues()
{
var sheet = SampleData.SampleCharacter();
var allText = Report(() => sheet);
Assert.Contains(sheet.Strength.ToString(), allText);
Assert.Contains(sheet.Endurance.ToString(), allText);
Assert.Contains(sheet.Quickness.ToString(), allText);
Assert.Contains(sheet.Coordination.ToString(), allText);
Assert.Contains(sheet.Focus.ToString(), allText);
Assert.Contains(sheet.Self.ToString(), allText);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static ImportedLayout FakeLayout(params (uint id, UiElement e)[] items)
{
var dict = new Dictionary<uint, UiElement>();
var root = new UiPanel();
foreach (var (id, e) in items)
{
root.AddChild(e);
dict[id] = e;
}
return new ImportedLayout(root, dict);
}
}

View file

@ -0,0 +1,920 @@
using AcDream.App.Studio;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Unit tests for <see cref="CharacterStatController"/> — the Attributes-tab controller that
/// binds the REAL importer-mounted header + 9-row list + footer elements (no created overlay).
/// Pure data wiring against fake layouts; no dats, no GL.
///
/// <para>Pass 1 tests verify header/XP meter/attribute list/footer State-A binding.
/// Pass 2 tests verify: (a) row click → selection toggle; (b) footer State-B content;
/// (c) raise-button affordability; (d) tab button states.</para>
/// </summary>
public class CharacterStatControllerTests
{
// ── Header labels bind to the sheet ──────────────────────────────────────
[Fact]
public void Bind_SetsNameLabel()
{
var name = new UiText();
var layout = Fake((CharacterStatController.NameId, name));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.Equal("Studio Player", name.LinesProvider()[0].Text);
}
[Fact]
public void Bind_SetsLevelLabel()
{
var level = new UiText();
var layout = Fake((CharacterStatController.LevelId, level));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.Equal("126", level.LinesProvider()[0].Text);
}
[Fact]
public void Bind_SetsHeritageAndPkLabels()
{
var heritage = new UiText();
var pk = new UiText();
var layout = Fake((CharacterStatController.HeritageId, heritage),
(CharacterStatController.PkStatusId, pk));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.Equal("Aluvian Heritage", heritage.LinesProvider()[0].Text);
Assert.Equal("Non-Player Killer", pk.LinesProvider()[0].Text);
}
// ── XP meter fill ────────────────────────────────────────────────────────
[Fact]
public void Bind_SetsXpMeterFill()
{
var meter = new UiMeter();
var layout = Fake((CharacterStatController.XpMeterId, meter));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var fill = meter.Fill();
Assert.NotNull(fill);
Assert.True(System.MathF.Abs(fill!.Value - 0.63f) < 0.001f, $"expected ~0.63, got {fill}");
}
// ── Attribute list — 9 rows in list box 0x1000023D ───────────────────────
[Fact]
public void Bind_AttributeList_Has9Rows()
{
var list = new UiPanel();
var layout = Fake((CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
// Rows are UiClickablePanel which inherits UiPanel, so OfType<UiPanel> matches.
var rows = list.Children.OfType<UiPanel>().ToList();
Assert.Equal(9, rows.Count);
}
[Fact]
public void Bind_AttributeList_RowsAreClickablePanels()
{
var list = new UiPanel();
var layout = Fake((CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var rows = list.Children.OfType<UiClickablePanel>().ToList();
Assert.Equal(9, rows.Count);
// All rows must have an OnClick wired (not null) and ClickThrough = false.
foreach (var row in rows)
{
Assert.NotNull(row.OnClick);
Assert.False(row.ClickThrough, "clickable row must accept pointer hits");
}
}
[Fact]
public void Bind_AttributeList_EachRowHasRightAlignedValueLabel()
{
var list = new UiPanel();
var layout = Fake((CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var rows = list.Children.OfType<UiPanel>().ToList();
Assert.Equal(9, rows.Count);
foreach (var row in rows)
{
var texts = row.Children.OfType<UiText>().ToList();
Assert.True(texts.Count >= 2, "each row must have at least name + value UiText");
var valueEl = texts[^1];
Assert.True(valueEl.RightAligned, "value label must be RightAligned");
}
}
[Fact]
public void Bind_AttributeList_RowNamesInRetailOrder()
{
var list = new UiPanel();
var layout = Fake((CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var rows = list.Children.OfType<UiPanel>().ToList();
Assert.Equal(9, rows.Count);
string[] expectedNames =
{
"Strength", "Endurance", "Coordination", "Quickness", "Focus", "Self",
"Health", "Stamina", "Mana",
};
for (int i = 0; i < 9; i++)
{
var texts = rows[i].Children.OfType<UiText>().ToList();
Assert.True(texts.Count >= 2, $"row {i} must have name + value");
string rowName = texts[1].LinesProvider()[0].Text;
Assert.Equal(expectedNames[i], rowName);
}
}
[Fact]
public void Bind_AttributeList_RowValues_AttributeIntegersAndVitalsCurMax()
{
var list = new UiPanel();
var layout = Fake((CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var rows = list.Children.OfType<UiPanel>().ToList();
string ValueOf(UiPanel row) => row.Children.OfType<UiText>().ToList()[^1].LinesProvider()[0].Text;
Assert.Equal("200", ValueOf(rows[0])); // Strength
Assert.Equal("10", ValueOf(rows[1])); // Endurance
Assert.Equal("10", ValueOf(rows[2])); // Coordination
Assert.Equal("200", ValueOf(rows[3])); // Quickness
Assert.Equal("10", ValueOf(rows[4])); // Focus
Assert.Equal("10", ValueOf(rows[5])); // Self
Assert.Equal("5/5", ValueOf(rows[6])); // Health
Assert.Equal("10/10", ValueOf(rows[7])); // Stamina
Assert.Equal("10/10", ValueOf(rows[8])); // Mana
}
[Fact]
public void Bind_AttributeList_IconHasBackgroundSpriteWhenResolverProvided()
{
var list = new UiPanel();
var layout = Fake((CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
spriteResolve: id => (id, 16, 16));
var rows = list.Children.OfType<UiPanel>().ToList();
Assert.Equal(9, rows.Count);
var iconEl = rows[0].Children.OfType<UiText>().First();
Assert.Equal(0x060002C8u, iconEl.BackgroundSprite);
var healthIcon = rows[6].Children.OfType<UiText>().First();
Assert.Equal(0x06004C3Bu, healthIcon.BackgroundSprite);
}
[Fact]
public void Bind_AttributeList_IconBackgroundSprite_ZeroWhenNoResolver()
{
var list = new UiPanel();
var layout = Fake((CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: null);
var rows = list.Children.OfType<UiPanel>().ToList();
Assert.Equal(9, rows.Count);
foreach (var row in rows)
{
var iconEl = row.Children.OfType<UiText>().First();
Assert.Equal(0u, iconEl.BackgroundSprite);
}
}
// ── Footer State A ────────────────────────────────────────────────────────
[Fact]
public void Bind_FooterStateA_TitleIsSelectPrompt()
{
var title = new UiText();
var layout = Fake((CharacterStatController.FooterTitleId, title));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
// Initial state (nothing selected): State-A title.
Assert.Equal("Select an Attribute to Improve", title.LinesProvider()[0].Text);
}
[Fact]
public void Bind_FooterStateA_Line1LabelIsSkillCreditsAvailable()
{
var lbl = new UiText();
var layout = Fake((CharacterStatController.FooterLine1Label, lbl));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.Equal("Skill Credits Available:", lbl.LinesProvider()[0].Text);
}
[Fact]
public void Bind_FooterStateA_Line1ValueIsSkillCredits()
{
var val = new UiText();
var layout = Fake((CharacterStatController.FooterLine1Value, val));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.Equal("96", val.LinesProvider()[0].Text);
}
[Fact]
public void Bind_FooterStateA_Line2LabelIsUnassignedExperience()
{
var lbl = new UiText();
var layout = Fake((CharacterStatController.FooterLine2Label, lbl));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.Equal("Unassigned Experience:", lbl.LinesProvider()[0].Text);
}
[Fact]
public void Bind_FooterStateA_Line2ValueIsUnassignedXp()
{
var val = new UiText();
var layout = Fake((CharacterStatController.FooterLine2Value, val));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var expected = (87_757_321_741L).ToString("N0");
Assert.Equal(expected, val.LinesProvider()[0].Text);
}
// ── Pass 2: Row selection → Footer State B ───────────────────────────────
[Fact]
public void RowClick_SelectRow4Focus_FooterStateBShowsFocusTitle()
{
// Focus is index 4 in AttrRows. SampleData Focus = 10. Cost = 110.
var title = new UiText();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.FooterTitleId, title),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
// Simulate a click on row 4 (Focus).
var rows = list.Children.OfType<UiClickablePanel>().ToList();
Assert.Equal(9, rows.Count);
rows[4].OnClick!();
// Footer title should now be "Focus: 10".
Assert.Equal("Focus: 10", title.LinesProvider()[0].Text);
}
[Fact]
public void RowClick_SelectRow4Focus_FooterLine1LabelIsExperienceToRaise()
{
var lbl = new UiText();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.FooterLine1Label, lbl),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
Assert.Equal("Experience To Raise:", lbl.LinesProvider()[0].Text);
}
[Fact]
public void RowClick_SelectRow4Focus_FooterLine1ValueIsRaiseCost()
{
var val = new UiText();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.FooterLine1Value, val),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
// Focus raise cost = 110 (SampleData fixture).
Assert.Equal((110L).ToString("N0"), val.LinesProvider()[0].Text);
}
[Fact]
public void RowClick_SelectRow4Focus_FooterLine2LabelIsUnassignedExperience()
{
var lbl = new UiText();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.FooterLine2Label, lbl),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
Assert.Equal("Unassigned Experience:", lbl.LinesProvider()[0].Text);
}
[Fact]
public void RowClick_SelectRow4Focus_FooterLine2ValueIsUnassignedXp()
{
var val = new UiText();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.FooterLine2Value, val),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
// UnassignedXp = 87_757_321_741L
var expected = (87_757_321_741L).ToString("N0");
Assert.Equal(expected, val.LinesProvider()[0].Text);
}
// ── Pass 2: Toggle deselects ──────────────────────────────────────────────
[Fact]
public void RowClick_ToggleSameRow_ReturnsToFooterStateA()
{
var title = new UiText();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.FooterTitleId, title),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var rows = list.Children.OfType<UiClickablePanel>().ToList();
// Select Focus (row 4).
rows[4].OnClick!();
Assert.Equal("Focus: 10", title.LinesProvider()[0].Text);
// Click the same row again → deselect.
rows[4].OnClick!();
Assert.Equal("Select an Attribute to Improve", title.LinesProvider()[0].Text);
}
[Fact]
public void RowClick_SwitchRow_UpdatesToNewRow()
{
var title = new UiText();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.FooterTitleId, title),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var rows = list.Children.OfType<UiClickablePanel>().ToList();
// Select Endurance (row 1, value=10).
rows[1].OnClick!();
Assert.Equal("Endurance: 10", title.LinesProvider()[0].Text);
// Select Self (row 5, value=10).
rows[5].OnClick!();
Assert.Equal("Self: 10", title.LinesProvider()[0].Text);
}
// ── Pass 2: Row highlight ─────────────────────────────────────────────────
[Fact]
public void RowClick_SelectRow_HighlightsSelectedAndClearsOthers()
{
var list = new UiPanel();
var layout = Fake((CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var rows = list.Children.OfType<UiClickablePanel>().ToList();
// All rows start transparent.
Assert.All(rows, r => Assert.Equal(0f, r.BackgroundColor.W));
// Select row 2 (Coordination).
rows[2].OnClick!();
Assert.NotEqual(0f, rows[2].BackgroundColor.W); // highlighted
Assert.Equal(0f, rows[0].BackgroundColor.W); // others cleared
Assert.Equal(0f, rows[1].BackgroundColor.W);
}
[Fact]
public void RowClick_Deselect_ClearsHighlight()
{
var list = new UiPanel();
var layout = Fake((CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var rows = list.Children.OfType<UiClickablePanel>().ToList();
rows[2].OnClick!(); // select
rows[2].OnClick!(); // deselect
Assert.Equal(0f, rows[2].BackgroundColor.W);
}
[Fact]
public void RowClick_WithSpriteResolve_SelectedRowHasHighlightSprite()
{
// When spriteResolve is provided, the selected row must use sprite 0x06001397
// (retail Button-state-6 dark bar) instead of the translucent gold BackgroundColor.
var list = new UiPanel();
var layout = Fake((CharacterStatController.ListBoxId, list));
// Minimal sprite resolver — returns a fake non-zero handle so UiPanel draws it.
static (uint, int, int) FakeResolve(uint id) => (1u, 32, 8);
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
spriteResolve: FakeResolve);
var rows = list.Children.OfType<UiClickablePanel>().ToList();
rows[2].OnClick!();
Assert.Equal(0x06001397u, rows[2].BackgroundSprite); // selected → sprite
Assert.Equal(0f, rows[2].BackgroundColor.W); // no tint
Assert.Equal(0u, rows[0].BackgroundSprite); // others cleared
Assert.Equal(0u, rows[1].BackgroundSprite);
}
[Fact]
public void RowClick_WithSpriteResolve_Deselect_ClearsSprite()
{
var list = new UiPanel();
var layout = Fake((CharacterStatController.ListBoxId, list));
static (uint, int, int) FakeResolve(uint id) => (1u, 32, 8);
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
spriteResolve: FakeResolve);
var rows = list.Children.OfType<UiClickablePanel>().ToList();
rows[2].OnClick!(); // select
rows[2].OnClick!(); // deselect
Assert.Equal(0u, rows[2].BackgroundSprite);
}
// ── Pass 2: Raise button affordability ───────────────────────────────────
[Fact]
public void RaiseButtons_InitiallyHidden()
{
var btn1 = MakeButton();
var btn10 = MakeButton();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.RaiseOneId, btn1),
(CharacterStatController.RaiseTenId, btn10),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.False(btn1.Visible, "raise×1 must start hidden");
Assert.False(btn10.Visible, "raise×10 must start hidden");
}
[Fact]
public void RaiseButtons_AffordableRow_ShowsNormalState()
{
// Focus row (index 4) cost=110, UnassignedXp=87_757_321_741 → affordable.
var btn1 = MakeButton();
var btn10 = MakeButton();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.RaiseOneId, btn1),
(CharacterStatController.RaiseTenId, btn10),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!(); // select Focus
Assert.True(btn1.Visible, "raise×1 visible on selection");
Assert.True(btn10.Visible, "raise×10 visible on selection");
Assert.Equal("Normal", btn1.ActiveState);
Assert.Equal("Normal", btn10.ActiveState);
}
[Fact]
public void RaiseButtons_MaxedRow_ShowsGhostedState()
{
// Strength row (index 0) cost=0 → disabled. UnassignedXp is irrelevant.
var btn1 = MakeButton();
var btn10 = MakeButton();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.RaiseOneId, btn1),
(CharacterStatController.RaiseTenId, btn10),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
list.Children.OfType<UiClickablePanel>().ToList()[0].OnClick!(); // select Strength (cost=0)
Assert.True(btn1.Visible, "raise button visible even when disabled");
Assert.Equal("Ghosted", btn1.ActiveState);
Assert.Equal("Ghosted", btn10.ActiveState);
}
[Fact]
public void RaiseButtons_Deselect_HidesButtons()
{
var btn1 = MakeButton();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.RaiseOneId, btn1),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var rows = list.Children.OfType<UiClickablePanel>().ToList();
rows[4].OnClick!(); // select
Assert.True(btn1.Visible);
rows[4].OnClick!(); // deselect
Assert.False(btn1.Visible, "raise button hidden after deselect");
}
// ── Pass 2: Tab button states ─────────────────────────────────────────────
// Tab sprites are added to layout.Root (NOT to the tab group elements), so
// the tab groups keep Children.Count==0 and survive the page-visibility pass.
// AddTabSpritesToRoot() adds 3 sprite UiTexts + 1 label UiText per tab to the
// root when a spriteResolve is provided; nothing is added when spriteResolve=null.
[Fact]
public void TabButtons_NoSpriteResolve_AddsNoSpriteChildrenToRoot()
{
// When spriteResolve is null, AddTabSpritesToRoot is not called — no sprites added.
var layout = Fake(); // empty fake layout with just the root UiPanel
int rootChildCountBefore = layout.Root.Children.Count;
CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: null);
// No extra children added to root when spriteResolve is null.
Assert.Equal(rootChildCountBefore, layout.Root.Children.Count);
}
[Fact]
public void TabButtons_AttributesGroup_AddsOpenSpriteIdsToRoot()
{
// Attributes tab (isOpen=true): 3 sprite children with Open sprite ids on ROOT.
// The tab group element itself has 0 children (sprites go to root, not the group).
var layout = Fake(); // minimal fake
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
spriteResolve: id => (id, 16, 16));
// Root should contain the Open sprite ids among all tab sprite children.
var sprites = layout.Root.Children.OfType<UiText>()
.Where(t => t.BackgroundSprite != 0u).ToList();
Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D92u); // Open left-cap
Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D94u); // Open center
Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D96u); // Open right-cap
}
[Fact]
public void TabButtons_SkillsAndTitles_AddClosedSpriteIdsToRoot()
{
// Skills + Titles tabs (isOpen=false): Closed sprite ids appear on root.
var layout = Fake();
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
spriteResolve: id => (id, 16, 16));
var sprites = layout.Root.Children.OfType<UiText>()
.Where(t => t.BackgroundSprite != 0u).ToList();
Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D93u); // Closed left-cap
Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D95u); // Closed center
Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D97u); // Closed right-cap
}
[Fact]
public void TabButtons_WithSpriteResolve_AddsAllThreeTabsToRoot()
{
// With spriteResolve: all 3 tabs inject 4 children each (3 sprites + 1 label)
// = 12 sprite children total across 3 tabs on the root.
var layout = Fake();
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
spriteResolve: id => (id, 16, 16));
// 3 tabs × 3 sprites = 9 sprite UiTexts on root.
var sprites = layout.Root.Children.OfType<UiText>()
.Where(t => t.BackgroundSprite != 0u).ToList();
Assert.Equal(9, sprites.Count);
}
// ── Affordability helpers (GetRaiseCost) ──────────────────────────────────
[Fact]
public void GetRaiseCost_Index4Focus_Returns110()
{
var sheet = SampleData.SampleCharacter();
Assert.Equal(110L, CharacterStatController.GetRaiseCost(sheet, 4));
}
[Fact]
public void GetRaiseCost_Index0Strength_Returns0()
{
var sheet = SampleData.SampleCharacter();
Assert.Equal(0L, CharacterStatController.GetRaiseCost(sheet, 0));
}
[Fact]
public void GetRaiseCost_OutOfRange_Returns0()
{
var sheet = SampleData.SampleCharacter();
Assert.Equal(0L, CharacterStatController.GetRaiseCost(sheet, 99));
}
// ── GetRowName helper ─────────────────────────────────────────────────────
[Fact]
public void GetRowName_Index0_ReturnsStrength()
=> Assert.Equal("Strength", CharacterStatController.GetRowName(0));
[Fact]
public void GetRowName_Index4_ReturnsFocus()
=> Assert.Equal("Focus", CharacterStatController.GetRowName(4));
[Fact]
public void GetRowName_Index6_ReturnsHealth()
=> Assert.Equal("Health", CharacterStatController.GetRowName(6));
[Fact]
public void GetRowName_NegativeIndex_ReturnsEmpty()
=> Assert.Equal(string.Empty, CharacterStatController.GetRowName(-1));
// ── SampleData sanity ─────────────────────────────────────────────────────
[Fact]
public void SampleCharacter_SkillCredits_Is96()
=> Assert.Equal(96, SampleData.SampleCharacter().SkillCredits);
[Fact]
public void SampleCharacter_UnassignedXp_IsSet()
=> Assert.Equal(87_757_321_741L, SampleData.SampleCharacter().UnassignedXp);
[Fact]
public void SampleCharacter_AttributeRaiseCosts_HasNineEntries()
{
var costs = SampleData.SampleCharacter().AttributeRaiseCosts;
Assert.NotNull(costs);
Assert.Equal(9, costs.Length);
}
[Fact]
public void SampleCharacter_AttributeRaiseCosts_FocusAt110()
=> Assert.Equal(110L, SampleData.SampleCharacter().AttributeRaiseCosts[4]);
[Fact]
public void SampleCharacter_AttributeRaiseCosts_StrengthAt0()
=> Assert.Equal(0L, SampleData.SampleCharacter().AttributeRaiseCosts[0]);
// ── UiText flag sanity ────────────────────────────────────────────────────
[Fact]
public void UiText_RightAligned_DefaultFalse()
{
var t = new UiText();
Assert.False(t.RightAligned);
}
[Fact]
public void UiText_RightAligned_CanBeSetTrue()
{
var t = new UiText { RightAligned = true };
Assert.True(t.RightAligned);
}
// ── Header captions (new — LevelCaptionId + TotalXpLabelId) ─────────────
[Fact]
public void Bind_LevelCaptionId_SetsCharacterLevelText()
{
var caption = new UiText();
var layout = Fake((CharacterStatController.LevelCaptionId, caption));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
// 2-line caption: "Character" (top) / "Level" (bottom) so it fits the 65px element.
var lines = caption.LinesProvider();
Assert.True(lines.Count >= 1, "LevelCaption must provide at least one line");
Assert.Equal("Character", lines[0].Text);
Assert.False(caption.Centered, "LevelCaption must be left-justified (Centered=false)");
Assert.False(caption.RightAligned, "LevelCaption must be left-justified (RightAligned=false)");
}
[Fact]
public void Bind_TotalXpLabelId_SetsTotalExperienceXpText()
{
var lbl = new UiText();
var layout = Fake((CharacterStatController.TotalXpLabelId, lbl));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.Equal("Total Experience (XP):", lbl.LinesProvider()[0].Text);
Assert.False(lbl.Centered, "TotalXpLabel must be left-justified (Centered=false)");
Assert.False(lbl.RightAligned, "TotalXpLabel must be left-justified (RightAligned=false)");
}
// ── Polish Commit 1: name white, Infinity!, white footer title ─────────────
[Fact]
public void Bind_NameColor_IsWhite()
{
// Retail: name "Horan" is WHITE, not gold. (2026-06-26 ref)
var name = new UiText();
var layout = Fake((CharacterStatController.NameId, name));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var color = name.LinesProvider()[0].Color;
Assert.Equal(1f, color.X, precision: 3);
Assert.Equal(1f, color.Y, precision: 3);
Assert.Equal(1f, color.Z, precision: 3);
Assert.Equal(1f, color.W, precision: 3);
}
[Fact]
public void RowClick_MaxedRow_FooterLine1ValueIsInfinity()
{
// Strength (index 0) cost=0 → "Infinity!" per retail spec.
var val = new UiText();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.FooterLine1Value, val),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
list.Children.OfType<UiClickablePanel>().ToList()[0].OnClick!(); // Strength
Assert.Equal("Infinity!", val.LinesProvider()[0].Text);
}
[Fact]
public void RowClick_SelectedFooterTitle_IsWhite()
{
// State B footer title must be WHITE (retail 2026-06-26 ref).
var title = new UiText();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.FooterTitleId, title),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!(); // Focus
var color = title.LinesProvider()[0].Color;
Assert.Equal(1f, color.X, precision: 3);
Assert.Equal(1f, color.Y, precision: 3);
Assert.Equal(1f, color.Z, precision: 3);
Assert.Equal(1f, color.W, precision: 3);
}
[Fact]
public void Bind_FooterStateA_TitleColor_IsBodyNotWhite()
{
// State A title is body (parchment), not white.
var title = new UiText();
var layout = Fake((CharacterStatController.FooterTitleId, title));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
// Body = (0.92, 0.90, 0.82, 1.0) — check it's not pure white.
var color = title.LinesProvider()[0].Color;
Assert.True(color.X < 1f || color.Y < 1f || color.Z < 1f,
"State-A title should be body/parchment color, not pure white");
}
[Fact]
public void Bind_LevelCaptionId_SetsTwoLines()
{
// Retail: level caption is "Character" / "Level" on two lines (not truncated).
var caption = new UiText();
var layout = Fake((CharacterStatController.LevelCaptionId, caption));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var lines = caption.LinesProvider();
Assert.Equal(2, lines.Count);
Assert.Equal("Character", lines[0].Text);
Assert.Equal("Level", lines[1].Text);
}
// ── Robustness ────────────────────────────────────────────────────────────
[Fact]
public void Bind_MissingElements_DoesNotThrow()
=> CharacterStatController.Bind(Fake(), SampleData.SampleCharacter);
// ── Fix 5: XP meter text children bound via FindElement ──────────────────
/// <summary>
/// Fix 5: the XP label (0x10000237) is a dat-origin UiText child of the XP meter
/// (now built by the importer). After Bind(), FindElement must return a UiText with
/// a LinesProvider that emits "XP for next level:".
/// </summary>
[Fact]
public void Bind_XpMeter_XpNextLabel_IsBoundViaFindElement()
{
var meter = new UiMeter();
var xpLabel = new UiText();
// Attach the label as a child of the meter so it matches the real importer layout.
meter.AddChild(xpLabel);
var layout = Fake(
(CharacterStatController.XpMeterId, meter),
(CharacterStatController.XpNextLabelId, xpLabel));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.NotNull(xpLabel.LinesProvider);
var lines = xpLabel.LinesProvider();
Assert.Single(lines);
Assert.Equal("XP for next level:", lines[0].Text);
}
/// <summary>
/// Fix 5: the XP value (0x10000238) is bound to the XpToNextLevel string.
/// ClickThrough=true and RightAligned=true must be set by the controller.
/// </summary>
[Fact]
public void Bind_XpMeter_XpNextValue_IsBoundViaFindElement()
{
var meter = new UiMeter();
var xpValue = new UiText();
meter.AddChild(xpValue);
var layout = Fake(
(CharacterStatController.XpMeterId, meter),
(CharacterStatController.XpNextValueId, xpValue));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.NotNull(xpValue.LinesProvider);
Assert.True(xpValue.ClickThrough, "XP value overlay must be ClickThrough");
Assert.True(xpValue.RightAligned, "XP value overlay must be RightAligned");
var lines = xpValue.LinesProvider();
Assert.Single(lines);
// XpToNextLevel from SampleData = 42_000_000L formatted as "42,000,000"
Assert.Equal((42_000_000L).ToString("N0"), lines[0].Text);
}
/// <summary>
/// Fix 5: if XpNextLabelId / XpNextValueId are absent from the layout (the old
/// ConsumesDatChildren path, or a test layout that doesn't include them), Bind()
/// must not throw — the meter Fill is still bound.
/// </summary>
[Fact]
public void Bind_XpMeter_MissingTextChildren_DoesNotThrow()
{
var meter = new UiMeter();
var layout = Fake((CharacterStatController.XpMeterId, meter));
// No XpNextLabelId or XpNextValueId in the layout.
var ex = Record.Exception(() =>
CharacterStatController.Bind(layout, SampleData.SampleCharacter));
Assert.Null(ex);
// Fill must still be bound.
Assert.NotNull(meter.Fill());
}
// ── Helpers ──────────────────────────────────────────────────────────────
/// <summary>Manufacture a minimal UiButton with a fake ElementInfo (no dat sprites).</summary>
private static UiButton MakeButton()
{
var info = new ElementInfo { Id = 0, Type = 1 };
return new UiButton(info, static _ => (0u, 0, 0));
}
private static ImportedLayout Fake(params (uint id, UiElement e)[] items)
{
var dict = new Dictionary<uint, UiElement>();
var root = new UiPanel();
foreach (var (id, e) in items)
{
root.AddChild(e);
dict[id] = e;
}
return new ImportedLayout(root, dict);
}
}

View file

@ -0,0 +1,194 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Unit tests for Fix C: per-element dat FontDid resolver in <see cref="DatWidgetFactory"/>.
///
/// <para>
/// <see cref="UiDatFont"/> cannot be constructed in pure unit tests (GL + dat required),
/// so these tests verify the plumbing using null/non-null identity and sentinel resolvers:
/// <list type="bullet">
/// <item>Null resolver → DatFont unchanged (backward-compat: same as before Fix C).</item>
/// <item>Resolver present, FontDid == 0 → resolver NOT called; DatFont == global datFont.</item>
/// <item>Resolver returns null for an id (font missing) → DatFont falls back to global datFont.</item>
/// <item>Controller sets DatFont after build → controller value wins.</item>
/// <item><see cref="LayoutImporter.Build"/> threads fontResolve to the factory.</item>
/// </list>
/// </para>
/// </summary>
public class DatWidgetFactoryFontResolveTests
{
private static (uint, int, int) NoTex(uint _) => (0, 0, 0);
// ── Test 1: null fontResolve → DatFont == datFont (backward-compat) ─────
/// <summary>
/// When fontResolve is null (the live GameWindow path), BuildText must NOT
/// touch DatFont beyond setting it to the global datFont. Null in → null out.
/// </summary>
[Fact]
public void NullFontResolve_DatFont_EqualsGlobalFont()
{
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontDid = 0x40000001u };
// datFont=null, fontResolve=null → backward-compat: DatFont == null
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null, fontResolve: null));
Assert.Null(t.DatFont);
}
// ── Test 2: FontDid == 0 → resolver NOT called ────────────────────────────
/// <summary>
/// When the element has FontDid == 0, the fontResolve delegate must NOT be
/// invoked (the element has no dat font; fall through to global datFont).
/// </summary>
[Fact]
public void FontDidZero_ResolverNotCalled()
{
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontDid = 0u };
bool called = false;
UiDatFont? Resolver(uint id) { called = true; return null; }
// FontDid=0 → resolver should never fire, even though one is provided.
Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null, fontResolve: Resolver));
Assert.False(called, "fontResolve must NOT be called when FontDid == 0");
}
// ── Test 3: resolver returns null (font missing) → fallback to datFont ───
/// <summary>
/// When the element has a non-zero FontDid but the resolver returns null
/// (font not found in dats), DatFont must fall back to the shared global datFont.
/// Since datFont is null in unit tests, this verifies the fallback chain.
/// </summary>
[Fact]
public void ResolverReturnsNull_FallsBackToGlobalFont()
{
const uint SomeFontDid = 0x40000005u;
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontDid = SomeFontDid };
int callCount = 0;
UiDatFont? Resolver(uint id) { callCount++; return null; } // always returns null (font missing)
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, datFont: null, fontResolve: Resolver));
// Resolver was called exactly once for this element.
Assert.Equal(1, callCount);
// Fallback: DatFont == global datFont (null in unit tests).
Assert.Null(t.DatFont);
}
// ── Test 4: resolver called with correct FontDid ──────────────────────────
/// <summary>
/// When fontResolve is provided and FontDid is non-zero, the resolver is called
/// with the element's exact FontDid value.
/// </summary>
[Fact]
public void ResolverCalledWithElementFontDid()
{
const uint ExpectedFontDid = 0x40000002u;
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontDid = ExpectedFontDid };
uint? capturedId = null;
UiDatFont? Resolver(uint id) { capturedId = id; return null; }
DatWidgetFactory.Create(info, NoTex, datFont: null, fontResolve: Resolver);
Assert.Equal(ExpectedFontDid, capturedId);
}
// ── Test 5: controller DatFont override wins over build-time value ───────
/// <summary>
/// A controller that calls FindElement and sets DatFont afterward MUST override
/// whatever the factory set at build time. This is the backward-compat guarantee.
/// The DatFont property is settable — a controller can override it after build.
/// </summary>
[Fact]
public void ControllerDatFontOverride_WinsOverBuildTimeFont()
{
const uint SomeFontDid = 0x40000003u;
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontDid = SomeFontDid };
UiDatFont? Resolver(uint _) => null; // returns null → build-time DatFont == null
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, datFont: null, fontResolve: Resolver));
Assert.Null(t.DatFont); // build-time value set by factory
// Controller simulated override: set DatFont to null explicitly.
// The real guarantee is that DatFont is a settable property.
// (We can't construct a real UiDatFont here — it requires GL + dats.)
t.DatFont = null; // controller overrides after build
Assert.Null(t.DatFont);
// ✓ DatFont is settable — controller override mechanism is in place.
}
// ── Test 6: LayoutImporter.Build threads fontResolve to factory ──────────
/// <summary>
/// When <see cref="LayoutImporter.Build"/> is given a fontResolve, it must pass
/// it to <see cref="DatWidgetFactory.Create"/> for EVERY element in the tree.
/// Verified by checking that a Type-12 child with FontDid triggers the resolver.
/// </summary>
[Fact]
public void LayoutImporter_Build_ThreadsFontResolve_ToFactory()
{
const uint FontDid = 0x40000004u;
var root = new ElementInfo { Id = 1u, Type = 3, Width = 200, Height = 100 };
var child = new ElementInfo { Id = 2u, Type = 12, Width = 100, Height = 20, FontDid = FontDid };
root.Children.Add(child);
int resolveCallCount = 0;
UiDatFont? Resolver(uint id) { resolveCallCount++; return null; }
var layout = LayoutImporter.Build(root, NoTex, datFont: null, fontResolve: Resolver);
// The child element has a non-zero FontDid — resolver must have been called for it.
Assert.True(resolveCallCount > 0, "fontResolve was not called for the child element");
// The child widget was built (findable in the layout).
Assert.NotNull(layout.FindElement(2u));
}
// ── Test 7: Meter element also receives element font from resolver ────────
/// <summary>
/// Type-7 (UiMeter) elements with a FontDid must also go through the resolver.
/// Verified by checking the resolver fires for a meter element with a non-zero FontDid.
/// </summary>
[Fact]
public void FontResolve_CalledForMeter_WhenFontDidPresent()
{
const uint MeterFontDid = 0x40000006u;
var meter = new ElementInfo { Type = 7, Width = 150, Height = 16, FontDid = MeterFontDid };
int callCount = 0;
UiDatFont? Resolver(uint id) { callCount++; return null; }
var w = DatWidgetFactory.Create(meter, NoTex, datFont: null, fontResolve: Resolver);
var m = Assert.IsType<UiMeter>(w);
// Resolver was called for the meter element's FontDid.
Assert.True(callCount > 0, "fontResolve was not called for meter element");
// Meter DatFont: resolver returned null, so DatFont falls back to global datFont (null).
Assert.Null(m.DatFont);
}
// ── Test 8: LayoutImporter.BuildFromInfos signature is backward-compat ────
/// <summary>
/// <see cref="LayoutImporter.BuildFromInfos"/> without fontResolve (default null)
/// must still work for all callers that don't pass it — verifies the optional
/// parameter default is correct and no existing callers broke.
/// </summary>
[Fact]
public void BuildFromInfos_WithoutFontResolve_WorksAsBeforeFix()
{
var root = new ElementInfo { Id = 10u, Type = 3, Width = 200, Height = 100 };
var child = new ElementInfo { Id = 11u, Type = 12, Width = 100, Height = 20, FontDid = 0x40000001u };
// Call without fontResolve (the old signature shape).
var layout = LayoutImporter.BuildFromInfos(root, new[] { child }, NoTex, datFont: null);
var t = Assert.IsType<UiText>(layout.FindElement(11u));
// No resolver → DatFont == global datFont (null in unit tests).
Assert.Null(t.DatFont);
}
}

View file

@ -1,3 +1,4 @@
using System.Numerics;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.App.UI.Layout; using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout; namespace AcDream.App.Tests.UI.Layout;
@ -228,4 +229,126 @@ public class DatWidgetFactoryTests
Assert.NotEqual(OverlayFile, m.FrontRight); Assert.NotEqual(OverlayFile, m.FrontRight);
Assert.NotEqual(OverlayFile, m.FrontTile); Assert.NotEqual(OverlayFile, m.FrontTile);
} }
// ── Justification build-time application (new for importer Fix A) ────────
/// <summary>
/// A Type-12 text element with HJustify=Center (the default) must produce a
/// UiText with Centered=true and RightAligned=false at build time.
/// This proves BuildText applies the dat's HJustify at construction without a
/// controller binding step.
/// </summary>
[Fact]
public void BuildText_HJustifyCenter_SetsCentered()
{
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Center };
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
Assert.True(t.Centered);
Assert.False(t.RightAligned);
Assert.Equal(VJustify.Center, t.VerticalJustify);
}
/// <summary>
/// A Type-12 text element with HJustify=Right must produce a UiText with
/// Centered=false and RightAligned=true at build time.
/// </summary>
[Fact]
public void BuildText_HJustifyRight_SetsRightAligned()
{
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Right };
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
Assert.False(t.Centered);
Assert.True(t.RightAligned);
}
/// <summary>
/// A Type-12 text element with HJustify=Left must produce a UiText with
/// Centered=false and RightAligned=false at build time.
/// </summary>
[Fact]
public void BuildText_HJustifyLeft_SetsNeitherCenteredNorRight()
{
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Left };
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
Assert.False(t.Centered);
Assert.False(t.RightAligned);
}
/// <summary>
/// A Type-12 text element with VJustify=Top must produce a UiText with
/// VerticalJustify=Top at build time.
/// </summary>
[Fact]
public void BuildText_VJustifyTop_SetsVerticalJustifyTop()
{
var info = new ElementInfo { Type = 12, Width = 100, Height = 55, VJustify = VJustify.Top };
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
Assert.Equal(VJustify.Top, t.VerticalJustify);
}
/// <summary>
/// A controller override: build-time sets Centered=true (HJustify=Center), then
/// a controller sets Centered=false afterward. The controller's override wins —
/// this is the backward-compatibility guarantee.
/// </summary>
[Fact]
public void BuildText_ControllerOverrideWinsAfterBuild()
{
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Center };
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
Assert.True(t.Centered); // build-time value
t.Centered = false; // controller override
Assert.False(t.Centered); // override wins
}
// ── DefaultColor from dat FontColor (importer Fix B) ─────────────────────
/// <summary>
/// When ElementInfo.FontColor is set (dat carried 0x1B ColorBaseProperty),
/// DatWidgetFactory.BuildText must copy it onto UiText.DefaultColor.
/// </summary>
[Fact]
public void BuildText_FontColorPresent_SetsDefaultColor()
{
var gold = new Vector4(1f, 0.82f, 0.36f, 1f);
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontColor = gold };
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
Assert.Equal(gold, t.DefaultColor);
}
/// <summary>
/// When ElementInfo.FontColor is null (dat carried no 0x1B), DefaultColor must
/// stay at white (Vector4.One) — the backward-compatible default.
/// </summary>
[Fact]
public void BuildText_FontColorAbsent_DefaultColorIsWhite()
{
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontColor = null };
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
Assert.Equal(Vector4.One, t.DefaultColor);
}
/// <summary>
/// Backward-compat guarantee: a controller that sets LinesProvider with an
/// explicit per-line color AFTER build is unaffected by DefaultColor.
/// The controller's per-line color is stored in the Line record, not DefaultColor,
/// so DefaultColor changing from dat does not touch existing controller bindings.
/// </summary>
[Fact]
public void BuildText_ControllerExplicitLineColor_UnaffectedByDefaultColor()
{
// Dat sets a parchment color.
var parchment = new Vector4(0.92f, 0.90f, 0.82f, 1f);
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontColor = parchment };
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
// Controller overrides with an explicit gold color in the Line record.
var gold = new Vector4(1f, 0.82f, 0.36f, 1f);
t.LinesProvider = () => new[] { new UiText.Line("text", gold) };
// The line's color is gold (controller wins), NOT the dat parchment.
Assert.Equal(gold, t.LinesProvider()[0].Color);
// DefaultColor still holds the dat parchment (unmodified by the controller binding).
Assert.Equal(parchment, t.DefaultColor);
}
} }

View file

@ -1,3 +1,4 @@
using System.Numerics;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.App.UI.Layout; using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout; namespace AcDream.App.Tests.UI.Layout;
@ -161,4 +162,98 @@ public class ElementReaderTests
Assert.Single(merged.Children); Assert.Single(merged.Children);
Assert.Equal(0x2u, merged.Children[0].Id); Assert.Equal(0x2u, merged.Children[0].Id);
} }
// ── HJustify / VJustify — Merge propagation ─────────────────────────────
/// <summary>
/// When the derived element has a non-Center HJustify, the derived value wins
/// (same "non-default wins" rule as FontDid).
/// </summary>
[Fact]
public void Merge_DerivedHJustifyRight_OverridesBaseCenter()
{
var base_ = new ElementInfo { HJustify = HJustify.Center };
var derived = new ElementInfo { HJustify = HJustify.Right };
var merged = ElementReader.Merge(base_, derived);
Assert.Equal(HJustify.Right, merged.HJustify);
}
/// <summary>
/// When the derived element has the default HJustify (Center), the base value
/// is inherited — Center from the derived does NOT override a Left base.
/// </summary>
[Fact]
public void Merge_DerivedHJustifyCenter_InheritsBaseLeft()
{
var base_ = new ElementInfo { HJustify = HJustify.Left };
var derived = new ElementInfo { HJustify = HJustify.Center }; // default — no explicit dat property
var merged = ElementReader.Merge(base_, derived);
Assert.Equal(HJustify.Left, merged.HJustify);
}
/// <summary>
/// VJustify=Top from the base propagates when the derived element has no explicit
/// (Center) vertical justification.
/// </summary>
[Fact]
public void Merge_BaseVJustifyTop_InheritedWhenDerivedIsCenter()
{
var base_ = new ElementInfo { VJustify = VJustify.Top };
var derived = new ElementInfo { VJustify = VJustify.Center }; // default
var merged = ElementReader.Merge(base_, derived);
Assert.Equal(VJustify.Top, merged.VJustify);
}
/// <summary>
/// VJustify=Bottom from the derived element wins over a Center base.
/// </summary>
[Fact]
public void Merge_DerivedVJustifyBottom_OverridesBaseCenter()
{
var base_ = new ElementInfo { VJustify = VJustify.Center };
var derived = new ElementInfo { VJustify = VJustify.Bottom };
var merged = ElementReader.Merge(base_, derived);
Assert.Equal(VJustify.Bottom, merged.VJustify);
}
// ── FontColor — Merge propagation (Fix B) ────────────────────────────────
/// <summary>
/// When the derived element has an explicit (non-null) FontColor, the derived value
/// wins in Merge — same "non-null derived wins" rule used for FontDid and HJustify.
/// </summary>
[Fact]
public void Merge_DerivedFontColor_OverridesBaseNull()
{
var base_ = new ElementInfo { FontColor = null };
var derived = new ElementInfo { FontColor = new Vector4(1f, 0f, 0f, 1f) };
var merged = ElementReader.Merge(base_, derived);
Assert.Equal(new Vector4(1f, 0f, 0f, 1f), merged.FontColor);
}
/// <summary>
/// When the derived element has no FontColor (null), the base's FontColor is inherited.
/// A null-derived must NOT override a non-null base.
/// </summary>
[Fact]
public void Merge_DerivedFontColorNull_InheritsBaseColor()
{
var gold = new Vector4(1f, 0.82f, 0.36f, 1f);
var base_ = new ElementInfo { FontColor = gold };
var derived = new ElementInfo { FontColor = null }; // default — no dat property
var merged = ElementReader.Merge(base_, derived);
Assert.Equal(gold, merged.FontColor);
}
/// <summary>
/// When neither base nor derived carries a FontColor, the merged result is null.
/// </summary>
[Fact]
public void Merge_BothFontColorNull_MergedIsNull()
{
var base_ = new ElementInfo { FontColor = null };
var derived = new ElementInfo { FontColor = null };
var merged = ElementReader.Merge(base_, derived);
Assert.Null(merged.FontColor);
}
} }

View file

@ -55,15 +55,15 @@ public class LayoutImporterTests
Assert.NotNull(tree.FindElement(0x20000002)); Assert.NotNull(tree.FindElement(0x20000002));
} }
// ── Test 3: Meter consumes its children — child ids not in byId ────────── // ── Test 3: Meter consumes Type-3 slice children — child ids not in byId ────
/// <summary> /// <summary>
/// A meter (Type 7) whose children are the 3-slice back/front containers. /// A meter (Type 7) whose children are ONLY the 3-slice back/front containers (Type 3).
/// The meter itself must be findable; its direct children must NOT appear as /// The meter itself must be findable; its Type-3 children must NOT appear as separate
/// separate nodes in the tree (meters own their children, not the generic tree). /// nodes in the tree (Fix 5: only non-Type-3 children are built as separate widgets).
/// </summary> /// </summary>
[Fact] [Fact]
public void BuildFromInfos_MeterWithChildren_MeterPresent_ChildrenNotInTree() public void BuildFromInfos_MeterWithSliceChildren_MeterPresent_SliceChildrenNotInTree()
{ {
const uint MeterId = 0x100000E6u; const uint MeterId = 0x100000E6u;
const uint BackLayerId = 0x100000E7u; const uint BackLayerId = 0x100000E7u;
@ -85,10 +85,103 @@ public class LayoutImporterTests
// The meter widget is present. // The meter widget is present.
Assert.IsType<UiMeter>(tree.FindElement(MeterId)); Assert.IsType<UiMeter>(tree.FindElement(MeterId));
// The meter's dat-children are NOT separate UiElement nodes. // The meter's Type-3 slice children are NOT separate UiElement nodes.
Assert.Null(tree.FindElement(BackLayerId)); Assert.Null(tree.FindElement(BackLayerId));
Assert.Null(tree.FindElement(FrontLayerId)); Assert.Null(tree.FindElement(FrontLayerId));
// The UiMeter itself has no Ui children (meters consume their children internally). // The UiMeter itself has no UiElement children (all children were Type-3, consumed).
var uiMeter = (UiMeter)tree.FindElement(MeterId)!;
Assert.Empty(uiMeter.Children);
}
// ── Test 5: Fix 5 — meter text children (Type-12) are built and registered ─
/// <summary>
/// Fix 5: a meter (Type 7) with BOTH Type-3 slice containers AND a Type-12 text
/// child. The Type-3 containers must be consumed (not in byId); the Type-12 text
/// child must be built as a UiText, registered in byId, and attached as a UiElement
/// child of the meter (so it renders as an overlay).
/// </summary>
[Fact]
public void BuildFromInfos_MeterWithTextChild_TextChildInTreeAsUiTextChildOfMeter()
{
const uint MeterId = 0x10000236u;
const uint BackId = 0x10000237u; // Type-3 slice
const uint FrontId = 0x10000238u; // Type-3 slice
const uint LabelId = 0x10000239u; // Type-12 text child
const uint ValueId = 0x1000023Au; // Type-12 text child
var backContainer = BuildSliceContainer(BackId, ReadOrder: 0,
l: 0x0600747Eu, t: 0x0600747Fu, r: 0x06007480u);
var frontContainer = BuildSliceContainer(FrontId, ReadOrder: 1,
l: 0x06007481u, t: 0x06007482u, r: 0x06007483u);
// Two Type-12 text children (the XP "label" and "value" overlay).
var labelInfo = new ElementInfo { Id = LabelId, Type = 12, X = 0, Y = 0, Width = 120, Height = 13 };
var valueInfo = new ElementInfo { Id = ValueId, Type = 12, X = 0, Y = 0, Width = 200, Height = 13,
HJustify = HJustify.Right };
var meter = new ElementInfo { Id = MeterId, Type = 7, Width = 200, Height = 13 };
meter.Children.Add(backContainer);
meter.Children.Add(frontContainer);
meter.Children.Add(labelInfo);
meter.Children.Add(valueInfo);
var root = new ElementInfo { Id = 0x100005F9, Type = 3, Width = 210, Height = 20 };
var tree = LayoutImporter.BuildFromInfos(root, new[] { meter }, NoTex, null);
// Meter is present.
Assert.IsType<UiMeter>(tree.FindElement(MeterId));
// Type-3 slice containers are NOT in byId (consumed by BuildMeter).
Assert.Null(tree.FindElement(BackId));
Assert.Null(tree.FindElement(FrontId));
// Type-12 text children ARE in byId as UiText.
Assert.IsType<UiText>(tree.FindElement(LabelId));
Assert.IsType<UiText>(tree.FindElement(ValueId));
// The UiMeter has exactly 2 UiElement children (the two text overlays).
var uiMeter = (UiMeter)tree.FindElement(MeterId)!;
Assert.Equal(2, uiMeter.Children.Count);
Assert.All(uiMeter.Children, c => Assert.IsType<UiText>(c));
// The value child should have RightAligned=true (HJustify.Right in the dat).
var valueWidget = (UiText)tree.FindElement(ValueId)!;
Assert.True(valueWidget.RightAligned, "Type-12 text child with HJustify.Right must build as RightAligned=true");
}
// ── Test 6: Fix 5 — vitals meters (Type-3 only) are unaffected ────────────
/// <summary>
/// Fix 5 regression guard: vitals health/stamina/mana meters have ONLY Type-3 slice
/// children (no Type-12 text children). The meter must have zero UiElement children
/// after build — VitalsController injects its number overlay at runtime, not the importer.
/// </summary>
[Fact]
public void BuildFromInfos_VitalsMeter_NoTextChildren_MeterHasNoUiChildren()
{
const uint MeterId = 0x100000E6u; // vitals health meter
const uint BackId = 0x100000E7u;
const uint FrontId = 0x100000E8u;
var backContainer = BuildSliceContainer(BackId, ReadOrder: 0,
l: 0x0600747Eu, t: 0x0600747Fu, r: 0x06007480u);
var frontContainer = BuildSliceContainer(FrontId, ReadOrder: 1,
l: 0x06007481u, t: 0x06007482u, r: 0x06007483u);
var meter = new ElementInfo { Id = MeterId, Type = 7, Width = 150, Height = 16 };
meter.Children.Add(backContainer);
meter.Children.Add(frontContainer);
var root = new ElementInfo { Id = 0x100005F9, Type = 3, Width = 160, Height = 58 };
var tree = LayoutImporter.BuildFromInfos(root, new[] { meter }, NoTex, null);
Assert.IsType<UiMeter>(tree.FindElement(MeterId));
// Vitals meter has no UiElement children — only Type-3 children were present,
// all consumed, none built as UiText overlays.
var uiMeter = (UiMeter)tree.FindElement(MeterId)!; var uiMeter = (UiMeter)tree.FindElement(MeterId)!;
Assert.Empty(uiMeter.Children); Assert.Empty(uiMeter.Children);
} }

View file

@ -0,0 +1,44 @@
using AcDream.App.UI.Layout;
using Xunit;
namespace AcDream.App.Tests.UI.Layout;
public class PaperdollToggleTests
{
// The 9 armor element-ids that gmPaperDollUI::ListenToElementMessage flips
// (decomp 175674-175706). Doll-view hides these; slot-view shows them.
private static readonly uint[] ArmorIds =
{
0x100005abu, 0x100005acu, 0x100005adu, 0x100005aeu, 0x100005afu,
0x100005b0u, 0x100005b1u, 0x100005b2u, 0x100005b3u,
};
[Fact]
public void ArmorSlotIds_match_the_decomp_nine()
{
Assert.Equal(ArmorIds, PaperdollController.ArmorSlotElementIds);
}
[Fact]
public void DollView_default_shows_doll_hides_armor()
{
var v = new PaperdollController.PaperdollViewState();
Assert.False(v.SlotView);
Assert.True(v.DollVisible);
Assert.False(v.ArmorSlotsVisible);
}
[Fact]
public void Toggle_to_slotview_hides_doll_shows_armor_and_back()
{
var v = new PaperdollController.PaperdollViewState();
v.Toggle();
Assert.True(v.SlotView);
Assert.False(v.DollVisible);
Assert.True(v.ArmorSlotsVisible);
v.Toggle();
Assert.False(v.SlotView);
Assert.True(v.DollVisible);
Assert.False(v.ArmorSlotsVisible);
}
}

View file

@ -0,0 +1,29 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
public class UiViewportFactoryTests
{
private static (uint, int, int) NoTex(uint _) => (0, 0, 0);
[Fact]
public void Factory_builds_UiViewport_for_dat_type_0xD()
{
var info = new ElementInfo { Type = 0xD, Width = 200, Height = 300 };
var widget = DatWidgetFactory.Create(info, NoTex, null);
var viewport = Assert.IsType<UiViewport>(widget);
Assert.True(viewport.ConsumesDatChildren);
}
[Fact]
public void UiViewport_rect_set_from_ElementInfo()
{
var info = new ElementInfo { Type = 0xD, X = 10, Y = 20, Width = 180, Height = 240 };
var widget = DatWidgetFactory.Create(info, NoTex, null)!;
Assert.Equal(10f, widget.Left);
Assert.Equal(20f, widget.Top);
Assert.Equal(180f, widget.Width);
Assert.Equal(240f, widget.Height);
}
}

View file

@ -14,6 +14,53 @@ public class UiRootInputTests
Assert.Equal(AnchorEdges.None, panel.Anchors); Assert.Equal(AnchorEdges.None, panel.Anchors);
} }
private sealed class ClickRecorder : UiElement
{
private readonly bool _handlesClick;
public bool Clicked;
public ClickRecorder(bool handlesClick) => _handlesClick = handlesClick;
public override bool HandlesClick => _handlesClick;
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.Click) { Clicked = true; return true; }
return false;
}
}
[Fact]
public void HandlesClickWidget_insideDraggableWindow_stillEmitsClick()
{
// Regression (paperdoll "Slots" toggle): a HandlesClick widget (e.g. a UiButton) inside a
// whole-window-Draggable frame (the inventory window, IA-12 whole-window-drag) must still
// receive its Click. Before the fix the window-move branch captured the press and OnMouseUp
// returned before emitting Click, so the toggle button did nothing.
var root = new UiRoot { Width = 800, Height = 600 };
var frame = new UiPanel { Left = 10, Top = 300, Width = 200, Height = 60, Draggable = true };
var btn = new ClickRecorder(handlesClick: true) { Left = 5, Top = 5, Width = 120, Height = 14 };
frame.AddChild(btn);
root.AddChild(frame);
root.OnMouseDown(UiMouseButton.Left, 20, 310); // press over the button (screen rect 15,305..135,319)
root.OnMouseUp(UiMouseButton.Left, 20, 310); // release same spot → Click
Assert.True(btn.Clicked);
}
[Fact]
public void PlainWidget_insideDraggableWindow_doesNotEmitClick()
{
// Contrast that locks the distinction: a NON-HandlesClick child inside the Draggable frame is
// captured as a whole-window-drag, so no Click is emitted (the frame is draggable by it).
var root = new UiRoot { Width = 800, Height = 600 };
var frame = new UiPanel { Left = 10, Top = 300, Width = 200, Height = 60, Draggable = true };
var plain = new ClickRecorder(handlesClick: false) { Left = 5, Top = 5, Width = 120, Height = 14 };
frame.AddChild(plain);
root.AddChild(frame);
root.OnMouseDown(UiMouseButton.Left, 20, 310);
root.OnMouseUp(UiMouseButton.Left, 20, 310);
Assert.False(plain.Clicked);
}
private sealed class CoordRecorder : UiElement private sealed class CoordRecorder : UiElement
{ {
public (int x, int y)? Down, Move; public (int x, int y)? Down, Move;

View file

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI; namespace AcDream.App.Tests.UI;
@ -140,4 +141,57 @@ public class UiTextTests
Assert.Equal(new UiText.Pos(1, 2), s2); Assert.Equal(new UiText.Pos(1, 2), s2);
Assert.Equal(new UiText.Pos(1, 8), e2); Assert.Equal(new UiText.Pos(1, 8), e2);
} }
// ── VOffset: vertical positioning for single-line mode ───────────────────
/// <summary>
/// VJustify.Center: vertical offset = (height - lineHeight) / 2.
/// This is the original formula used by both the Centered and RightAligned paths
/// before VerticalJustify was introduced. Must remain unchanged (backward compat).
/// </summary>
[Fact]
public void VOffset_Center_ReturnsHalfHeightMinusLineHeight()
{
float y = UiText.VOffset(height: 55f, lineHeight: 12f, padding: 0f, VJustify.Center);
Assert.Equal((55f - 12f) * 0.5f, y);
}
/// <summary>
/// VJustify.Top: vertical offset = padding (top of the content area).
/// Used for footer title elements whose dat box is the full footer height (55 px)
/// but the text should render near the top.
/// </summary>
[Fact]
public void VOffset_Top_ReturnsPadding()
{
float y = UiText.VOffset(height: 55f, lineHeight: 12f, padding: 0f, VJustify.Top);
Assert.Equal(0f, y);
float yWithPadding = UiText.VOffset(height: 55f, lineHeight: 12f, padding: 4f, VJustify.Top);
Assert.Equal(4f, yWithPadding);
}
/// <summary>
/// VJustify.Bottom: vertical offset = height - lineHeight - padding.
/// </summary>
[Fact]
public void VOffset_Bottom_ReturnsHeightMinusLineHeightMinusPadding()
{
float y = UiText.VOffset(height: 55f, lineHeight: 12f, padding: 2f, VJustify.Bottom);
Assert.Equal(55f - 12f - 2f, y);
}
/// <summary>
/// Center with non-zero padding: padding does NOT affect the center formula —
/// VJustify.Center always uses (height - lineHeight) / 2 regardless of padding,
/// matching the original behavior.
/// </summary>
[Fact]
public void VOffset_Center_IgnoresPadding()
{
float yNoPad = UiText.VOffset(height: 40f, lineHeight: 12f, padding: 0f, VJustify.Center);
float yWithPad = UiText.VOffset(height: 40f, lineHeight: 12f, padding: 4f, VJustify.Center);
Assert.Equal(yNoPad, yWithPad); // Center is padding-independent
Assert.Equal((40f - 12f) * 0.5f, yNoPad);
}
} }

View file

@ -0,0 +1,9 @@
.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\hopeful-maxwell-214a12\paperdoll-pose-v2.log
.sympath C:\Users\erikn\source\repos\acdream\refs
.symopt+ 0x40
.reload /f acclient.exe
.echo ==== PAPERDOLL POSE TRACE v2 (reads m_didAnimation VALUE at +0x66c) ARMED ====
r $t0 = 0
bp acclient!gmPaperDollUI::RedressCreature ".echo === RedressCreature: poseDID value ===; ? poi(@ecx+0x66c); dt acclient!gmPaperDollUI @ecx m_didAnimation.id; r $t0 = @$t0 + 1; .if (@$t0 >= 3) { .echo ==== DETACHING (qd) ====; qd } .else { gc }"
bp acclient!gmPaperDollUI::UpdateForRace ".echo === UpdateForRace: bodyType then poseDID value ===; ? poi(@esp+4); ? poi(@ecx+0x66c); r $t0 = @$t0 + 1; .if (@$t0 >= 3) { .echo ==== DETACHING (qd) ====; qd } .else { gc }"
g