From 4f2ad98915518b4218524f383f1536337bc887cc Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 23 Jun 2026 08:40:48 +0200 Subject: [PATCH] =?UTF-8?q?docs(D.2b):=20Slice=202=20design=20=E2=80=94=20?= =?UTF-8?q?paperdoll=20doll=20viewport=20+=20Slots=20toggle=20(RTT)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brainstorm-approved design for Sub-phase C Slice 2: the 3-D doll UiViewport (dat Type 0xD) + the "Slots" toggle, extending the shipped PaperdollController. Key decisions settled in the brainstorm: - Compositing = render-to-texture (reuse ManagedGLFramebuffer + GLStateScope): the doll renders to an off-screen buffer in a pre-UI hook, then the UiViewport widget blits it as a normal sprite -> correct painter order for free, fully sealed 3-D pass. - The armor/non-armor partition is the decomp-exact 9-slot set that ListenToElementMessage (idMessage==1, 0x100005be) flips, not an EquipMask heuristic. - Doll = a dedicated WorldEntity cloned from the local player's Setup + current ObjDesc (the player IS a WorldEntity -- corrects the handoff), re-dressed on ObjDescEvent 0xF625; reuses EntitySpawnAdapter/ AnimatedEntityState. - Seam IUiViewportRenderer lives in AcDream.App.UI (intra-App decoupling), not Core -- user-approved divergence from the handoff's "Core interface". Recovered the corrupted light immediate ("ff&?" = 0x3f266666 ~= 0.65). AP-66 reworded: empty-slot frame stays (slots ring the doll, no overlay). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-23-d2b-paperdoll-slice2-design.md | 251 ++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-23-d2b-paperdoll-slice2-design.md diff --git a/docs/superpowers/specs/2026-06-23-d2b-paperdoll-slice2-design.md b/docs/superpowers/specs/2026-06-23-d2b-paperdoll-slice2-design.md new file mode 100644 index 00000000..ed7defbf --- /dev/null +++ b/docs/superpowers/specs/2026-06-23-d2b-paperdoll-slice2-design.md @@ -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 Entities, IReadOnlyDictionary? AnimatedById)>, + …, HashSet? 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.