acdream/docs/superpowers/specs/2026-06-23-d2b-paperdoll-slice2-design.md
Erik 4f2ad98915 docs(D.2b): Slice 2 design — paperdoll doll viewport + Slots toggle (RTT)
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) <noreply@anthropic.com>
2026-06-23 08:40:48 +02:00

251 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.