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>
35 KiB
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). animatedIdsbuilt from_animatedEntities.Keys:GameWindow.cs:8369-8375.- Spawn animation idle setup (
SetCycle):GameWindow.cs:3514-3588.MotionCommand.Ready = 0x41000003u(AcDream.Core.Physics.MotionInterpreter). WorldEntitybuild template (palette/part overrides):GameWindow.cs:3390-3431.- Player data:
_entitiesByServerGuid[_playerServerGuid](live entity),_lastSpawnByGuid[_playerServerGuid](spawn). Player appearance update hook:OnLiveAppearanceUpdatedGameWindow.cs:3733. - Lighting UBO:
AcDream.Core.Lighting.SceneLightingUbo+AcDream.App.Rendering.SceneLightingUboBinding.Upload(binding=1); world build atGameWindow.cs:8306. - FBO:
AcDream.App.Rendering.Wb.ManagedGLFramebuffer(ctor(OpenGLGraphicsDevice, ITexture, w, h, hasDepthStencil)). GL save/restore:AcDream.App.Rendering.Wb.GLStateScope(RAII). WbDrawDispatcher.Drawsignature:WbDrawDispatcher.cs:881.ICamera:ICamera.cs:5(View,Projection,Aspect).EntitySpawnAdapter.OnCreate(WorldEntity):EntitySpawnAdapter.cs:100(requiresServerGuid != 0).UiButton.OnClick(Action):UiButton.cs:39; wiring patternChatWindowController.cs:283-289,322.- Window register:
_uiHost.RegisterWindow(WindowNames.Inventory, inventoryFrame)GameWindow.cs:2214;WindowNames.Inventory = "inventory". DatWidgetFactoryType switch:DatWidgetFactory.cs:~63.PaperdollController.Bindcall site in GameWindow: grepPaperdollController.Bind.- Camera convention to mirror:
src/AcDream.App/Rendering/ChaseCamera.cs(View/Projectionconstruction).
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
ChaseCamerauses a left-handed / custom matrix (notCreateLookAt/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
TestElementInfohelper / the exactDatWidgetFactory.Createsignature differs, readDatWidgetFactory.cs+ an existing factory test (e.g. the UiMeter/UiScrollbar tests undertests/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
UiElementvirtual +ConsumesDatChildren/OnDraw/ScreenPosition/DrawSpritesignatures againstUiElement.cs/UiRenderContext.csand an existing leaf widget (UiItemSlot/UiMeter); match them. The RTT texture is a normal GL texture id —DrawSpritealready takes auint texture(see SSOT chrome-sprite path).
- Step 5: Register Type
0xDinDatWidgetFactory
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(thePaletteOverride+PartOverride[]construction) andWorldEntity.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)), andWorldEntity's members to their real definitions (readWorldEntity.cs,PaletteOverride.cs,PartOverride.cs). Adjust the test's tuple shapes to whatever signature you giveBuild.
- 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'srequired-init in GameWindow's usage — match exactly). IfMeshRefsmust be a concreteList/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:
- Own a
ManagedGLFramebuffer(colorITexture+ depth) sized to the widget rect; lazily (re)create when(w,h)changes. Read howManagedGLFramebufferis constructed elsewhere (grepnew ManagedGLFramebuffer) for theOpenGLGraphicsDevice+ITexturecreation pattern; reuse it. - Hold the doll
WorldEntity(fromDollEntityBuilder, refreshed by Task 6) + its mesh-resolvedMeshRefs, aDollCamera, and refs toWbDrawDispatcher+SceneLightingUboBinding(injected). 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 ManagedGLFramebufferusage +OpenGLGraphicsDevice/ITexturecreation; the world object-pass GL state atGameWindow.cs:~8098(cull/front-face); confirmWbDrawDispatcher.Draw's tuple element types. Confirm whether a Setup-backed entity renders WITHOUT ananimatedEntityIdsentry (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-959explicitly warns that its indoor-probeIndoorProbeStateconstruction assumes "Draw() is called exactly once per frame" and "a second pass within the same frame needs revisiting." This only bites when anACDREAM_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_dollLandblockIdunused by streaming, e.g.0); (b) the world Draw runs BEFORE the doll pass each frame and_groupsis cleared at the top of everyDraw()(: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.
- ⚠ Known interaction — the doll pass is a SECOND
- Step 2: Implement
PaperdollViewportRenderer(ctor injectsGL,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.Bindcall site (grepPaperdollController.Bind): after binding,FindElement(0x100001D5)for theUiViewport; constructPaperdollViewportRenderer(inject_gl, theOpenGLGraphicsDevice,_wbDrawDispatcher,_sceneLightingUbo, anew DollCamera()); setuiViewport.Renderer = renderer; store the renderer in a field_paperdollViewport. (The controller already finds0x100001D5for 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-resolvedMeshRefs); callDollEntityBuilder.Build(...); resolveMeshRefsthe same way the spawn path does (reuse the player entity'sMeshRefsif present — cheapest); route the doll throughEntitySpawnAdapter.OnCreate(doll)(so its meshes background-load + a per-instanceAnimatedEntityStateexists);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(theUiViewport) as fields at the Bind site.Visibleon theUiViewportis 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), whenupdate.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'sEntitySpawnAdapterstate; 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(theSetCycle+_animatedEntities[entity.Id]setup) and howTickAnimations(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 worldanimatedIdsharmlessly 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, afterOnCreate, give the doll an_animatedEntities[doll.Id]entry withsequencer.SetCycle(style, MotionCommand.Ready)(style = the player's MotionTableDefaultStyle, or0x8000003DNonCombat — match the spawn path). Pass the doll'sIdinrenderer's_animatedEntityIdsset + the_animatedByIddict soWbDrawDispatcher.Drawposes 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.mdif 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;
IUiViewportRendererseam in App.UI not Core (intentional adaptation, rationale = spec §4C); doll lighting = one hand-built distant-light UBO (approximation iff it differs from retailCreatureModelighting). Gate-verify AP-62 (MissileAmmo) opportunistically. -
Step 2: Run the FULL suite (not filtered):
dotnet testacross 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 (T5UploadDollLight), camera (T2 mirrors ChaseCamera), idle motion (T7MotionCommand.Ready). All covered. - Type consistency:
PaperdollViewState,ArmorSlotElementIds,DollCamera,UiViewport,IUiViewportRenderer.Render(w,h)→uint,DollEntityBuilder.Build,PaperdollViewportRenderer.Render/SetDoll,DollServerGuidused consistently across tasks. - Known soft spots (resolved inline, by design): the exact
WbDrawDispatcher.Drawtuple types,WorldEntityrequiredmembers,MeshRef/PaletteOverridectors, andChaseCamera'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.