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

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)
**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-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-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.
- **✓ 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`.
- **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.
- **☐ 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.

View file

@ -454,7 +454,7 @@ Open the inventory panel — retail-skinned with the right font, icons, and
local lighting).
- **C.3** — Palette range tuning (skin/hair/eye colors match retail).
- **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 +
cursor manager.
- **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).