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

35 KiB
Raw Blame History

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

// 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 UiItemLists 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).

// 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
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)
// 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)
// 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
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

// 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
// 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
// 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
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

// 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)
// 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
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):
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;
}
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):
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:

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

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

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.