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>
17 KiB
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.OnRenderruns 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). SetsuViewProjectionfromcamera.View*camera.Projection(:894) anduLightingMode=0(Lambert sun + per-entity point sets,:898). A second pass with a doll camera + a one-entry landblock tuple is supported. - Camera:
ICamerais{ Matrix4x4 View; Matrix4x4 Projection; float Aspect; }(ICamera.cs:5). A fixedDollCameraimpl is trivial. - RTT infra exists:
ManagedGLFramebuffer(colorITexture+ Depth24Stencil8 renderbuffer, completeness-checked,GpuMemoryTracker-tracked) andGLStateScope(RAII save/restore of viewport, scissor, depth, cull, blend, program, VAO, FBO bindings, UBO binding 0, …). Both insrc/AcDream.App/Rendering/Wb/. - Animated-char pipeline:
EntitySpawnAdapter.OnCreate(WorldEntity)(EntitySpawnAdapter.cs:100) requiresServerGuid != 0, buildsAnimatedEntityStatevia the injected sequencer factory, appliesHiddenPartsMask+PartOverrides, registers eachMeshRef.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 anAnimatedEntityState(_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 throughOnLiveAppearanceUpdated(ObjDescEvent0xF625, 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
SlotMapby 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 anEquipMaskheuristic; HeadWear/HandWear/FootWear are in the armor set, ChestWear/UpperLegWear (shirt/pants) are non-armor.) - Keep refs to the 9 armor-slot
UiItemLists + theUiViewportwidget. - Find the Slots button (
0x100005BE, aUiButton) vialayout.FindElement. On click, flip_slotViewand 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), mirroringSetAttribute_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
UiViewportScenehandle, §C) and the latest rendered color-texture handle. InOnDraw(UiRenderContext)it simply blits that cached handle as one sprite at its ownScreenPosition/Width/Height. The 3-D render itself happens in the pre-UI hook (§E), not inOnDraw(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:
// 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:
- A
ManagedGLFramebuffersized to the widget rect, recreated when the rect changes. - 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 againstUIElement_Viewport::SetCamera. - 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 (oneDISTANT_LIGHT= retail(0.3,1.9,0.65)@2.0, fixed ambient) reusing the scene-lighting UBO builder (GameWindow ~8289); callWbDrawDispatcher.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 syntheticServerGuid(non-zero, distinct from every real guid) soEntitySpawnAdapter.OnCreateaccepts 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 privateAnimatedEntityState; play the idle animation (m_didAnimationanalog — 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): hookOnLiveAppearanceUpdatedforupdate.Guid == _playerServerGuidto rebuild the doll entity'sPaletteOverride/PartOverridesfrom 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.Bindcall site: alsoFindElement(0x100001d5)for theUiViewport, constructPaperdollViewportRenderer(closing overWbDrawDispatcher, theEntitySpawnAdapter, 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
Visibleset on {viewport, 9 armor slots} and leaves the 12 non-armor untouched; default = doll-view.DollCameragolden 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
Visibleonly. - New rows (as applicable): per-race camera framing deferred (single default camera); idle-DID
per-race swap deferred; the
IUiViewportRendererseam 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'sCreatureModelighting). - Gate-verify AP-62 (MissileAmmo
0x100001E0mask) carried from Slice 1 — opportunistic.
7. Items to pin during the plan (scoped, not open-ended)
UiButtonclick/checkbox API — howPaperdollControllersubscribes to the Slots-button click and reads/sets its checked state.- The inventory window's open/closed query for the per-frame gate (the F12 window manager / the
frame's
Visible). - The exact
WbDrawDispatcher.Drawanimation contract for a single entity — whetheranimatedEntityIds- the
AnimatedByIdtuple is sufficient, or the doll'sAnimatedEntityStatemust also be reachable via the shared_animatedEntities/sequencer-tick path; and where the doll sequencer is ticked.
- the
- The scene-lighting UBO layout at GameWindow ~8289 — to write the doll's single distant light.
UIElement_Viewport::SetCameraarg order (pos vs dir) + the exact float→view-matrix construction, soDollCameraframes the doll like retail.- 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.