acdream/docs/superpowers/plans/2026-06-23-d2b-paperdoll-slice2.md
Erik 1c7f20fd08 docs(D.2b): Slice 2 implementation plan — paperdoll doll + Slots toggle
8-task plan. Tasks 1-4 isolated/TDD (partition+toggle, DollCamera,
UiViewport+Type-0xD, DollEntityBuilder) -> safe for subagents. Tasks 5-7
GL/animation/GameWindow integration -> INLINE per the CLAUDE.md
"no integrate-via-subagent without full context" rule, static-doll-first
then animate, verified at build + visual gate. Task 8 wrap-up + gate.

Concrete API refs pinned this session: WbDrawDispatcher.Draw call site
(8756), animatedIds assembly (8369), SetCycle idle (3514-3571),
WorldEntity build template (3390-3431), SceneLightingUbo/Binding,
ManagedGLFramebuffer + GLStateScope, UiButton.OnClick, inventory frame
.Visible. Flagged the second-Draw-per-frame dispatcher caveat (951-959).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 08:53:12 +02:00

671 lines
35 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 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.