Code-quality review (needs-changes) on Task 3: WieldItemOptimistic wrote item.WielderId directly, but RollbackMove (via MoveItem) never cleared it → a wield-rollback left a pack item with a stale WielderId=player. Root-cause fix (vs the reviewer's snapshot-WielderId suggestion): acdream's existing WieldObject 0x0023 confirm models a wielded item as ContainerId= wielder + equip=mask and does NOT touch WielderId. So the optimistic path must match — drop the WielderId write entirely. Optimistic state now equals the confirmed state, rollback fully restores through MoveItem alone, and the stale-state class is structurally eliminated. The paperdoll's (WielderId==p || ContainerId==p) filter still matches optimistic wields via ContainerId (login-equipped items match via WielderId from their CreateObject). Also: + the wield+move outstanding-count combo test (spec §8), MoveItem doc note that it doesn't manage WielderId, and the test pins WielderId==0 post- optimistic-wield to document the model. Core 74/74 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
38 KiB
Paperdoll Slice 1 (Equip Slots) 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.
Goal: Bind the ~21 mounted paperdoll equip slots to live equipped-item data and make them drag-drop wield/unwield targets (no 3D doll — that's Slice 2).
Architecture: A new PaperdollController (mirrors the shipped InventoryController) maps each equip-slot element-id → EquipMask, populates each single-cell UiItemList from the item whose CurrentlyEquippedLocation intersects the slot mask, and is the slots' IItemListDragHandler (drop = wield via the existing GetAndWieldItem 0x001A wire + a new optimistic-equip Core method). Two Core prerequisites: correct the misaligned EquipMask enum to canonical ACE/retail values, and add WieldItemOptimistic + an equip-aware rollback snapshot. Unwield is already handled by InventoryController (dragging an equipped item onto the grid).
Tech Stack: C# / .NET 10, xUnit, the acdream UiHost retained-mode toolkit, the ClientObjectTable data model.
Spec: docs/superpowers/specs/2026-06-22-d2b-paperdoll-slice1-equip-slots-design.md
Task 1: De-risk probe — equip slots resolve to UiItemList
The whole plan assumes the imported paperdoll subtree materializes each equip slot as a UiItemList
(factory 0x10000031). Verify against the live dat FIRST. If it fails, STOP and report — the importer
would need to factory-build the slots (out of this plan's scope).
Files:
-
Modify:
tests/AcDream.App.Tests/UI/Layout/InventoryFrameImportProbe.cs -
Step 1: Add the using + the probe test
At the top of the file, ensure using AcDream.App.UI; is present (add it after using AcDream.App.UI.Layout;). Then add this method inside the InventoryFrameImportProbe class:
[Fact]
public void Paperdoll_equip_slots_resolve_to_item_lists()
{
var datDir = DatDir();
if (datDir is null) return; // CI: no live dat — skip
using var dats = new DatCollection(datDir, DatAccessType.Read);
var layout = LayoutImporter.Import(dats, Frame, _ => (0u, 0, 0), null);
Assert.NotNull(layout);
// A representative spread across the slot grid (head, shield, the weapon composite, cloak,
// trinket, a finger). All inherit base 0x100001E4 → 0x2100003D (Type 0x10000031 = UIElement_ItemList).
foreach (uint id in new[] { 0x100005ABu, 0x100001E1u, 0x100001DFu, 0x100005E9u, 0x1000058Eu, 0x100001DCu })
{
var el = layout!.FindElement(id);
Assert.True(el is UiItemList,
$"equip slot 0x{id:X8} resolved to {el?.GetType().Name ?? "null"}, expected UiItemList");
}
}
- Step 2: Run the probe against the live dat
Run (PowerShell, the dat dir is the user's):
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~Paperdoll_equip_slots_resolve_to_item_lists"
Expected: PASS (1 test). If it reports 0 run, the env var didn't take — confirm the dat dir exists.
If it FAILS (a slot resolved to UiDatElement/null), STOP: the importer does not factory-build the
paperdoll slots. Report this — the plan needs an importer fix prepended before continuing.
- Step 3: Commit
git add tests/AcDream.App.Tests/UI/Layout/InventoryFrameImportProbe.cs
git commit -m "test(D.2b): probe — paperdoll equip slots resolve to UiItemList"
Task 2: Correct the EquipMask enum (Core) + numeric-pin test
acdream's EquipMask diverges from canonical AC (acclient.h:3193 INVENTORY_LOC) from bit 0x2000 up
(phantom HandArmor/FootArmor). Replace it with the verbatim retail values and lock them with a test.
Files:
-
Create:
tests/AcDream.Core.Tests/Items/EquipMaskTests.cs -
Modify:
src/AcDream.Core/Items/ClientObject.cs:60-100(theEquipMaskenum) -
Step 1: Write the failing numeric-pin test
Create tests/AcDream.Core.Tests/Items/EquipMaskTests.cs:
using AcDream.Core.Items;
using Xunit;
namespace AcDream.Core.Tests.Items;
/// <summary>
/// Pins every EquipMask member to the verbatim retail INVENTORY_LOC value
/// (docs/research/named-retail/acclient.h:3193). The wire delivers these exact bits
/// (ACE EquipMask == retail INVENTORY_LOC); any drift desyncs the paperdoll element-id→mask
/// map and the GetAndWieldItem wire. This test is the anti-regression lock.
/// </summary>
public sealed class EquipMaskTests
{
[Theory]
[InlineData(0x00000000u, EquipMask.None)]
[InlineData(0x00000001u, EquipMask.HeadWear)]
[InlineData(0x00000002u, EquipMask.ChestWear)]
[InlineData(0x00000004u, EquipMask.AbdomenWear)]
[InlineData(0x00000008u, EquipMask.UpperArmWear)]
[InlineData(0x00000010u, EquipMask.LowerArmWear)]
[InlineData(0x00000020u, EquipMask.HandWear)]
[InlineData(0x00000040u, EquipMask.UpperLegWear)]
[InlineData(0x00000080u, EquipMask.LowerLegWear)]
[InlineData(0x00000100u, EquipMask.FootWear)]
[InlineData(0x00000200u, EquipMask.ChestArmor)]
[InlineData(0x00000400u, EquipMask.AbdomenArmor)]
[InlineData(0x00000800u, EquipMask.UpperArmArmor)]
[InlineData(0x00001000u, EquipMask.LowerArmArmor)]
[InlineData(0x00002000u, EquipMask.UpperLegArmor)]
[InlineData(0x00004000u, EquipMask.LowerLegArmor)]
[InlineData(0x00008000u, EquipMask.NeckWear)]
[InlineData(0x00010000u, EquipMask.WristWearLeft)]
[InlineData(0x00020000u, EquipMask.WristWearRight)]
[InlineData(0x00040000u, EquipMask.FingerWearLeft)]
[InlineData(0x00080000u, EquipMask.FingerWearRight)]
[InlineData(0x00100000u, EquipMask.MeleeWeapon)]
[InlineData(0x00200000u, EquipMask.Shield)]
[InlineData(0x00400000u, EquipMask.MissileWeapon)]
[InlineData(0x00800000u, EquipMask.MissileAmmo)]
[InlineData(0x01000000u, EquipMask.Held)]
[InlineData(0x02000000u, EquipMask.TwoHanded)]
[InlineData(0x04000000u, EquipMask.TrinketOne)]
[InlineData(0x08000000u, EquipMask.Cloak)]
[InlineData(0x10000000u, EquipMask.SigilOne)]
[InlineData(0x20000000u, EquipMask.SigilTwo)]
[InlineData(0x40000000u, EquipMask.SigilThree)]
public void Member_has_canonical_retail_value(uint expected, EquipMask member)
=> Assert.Equal(expected, (uint)member);
[Fact]
public void Weapon_ready_slot_composite_is_0x3500000() // acclient.h:3235 WEAPON_READY_SLOT_LOC
=> Assert.Equal(0x3500000u,
(uint)(EquipMask.MeleeWeapon | EquipMask.MissileWeapon | EquipMask.Held | EquipMask.TwoHanded));
}
- Step 2: Run it to verify it fails
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~EquipMaskTests"
Expected: FAIL — e.g. EquipMask.UpperLegArmor is 0x4000 but the test expects 0x2000; Shield is 0x800000 but expected 0x200000. (Several Member_has_canonical_retail_value cases fail; some won't even compile if a member name is gone — proceed to Step 3.)
- Step 3: Replace the enum body with the canonical values
In src/AcDream.Core/Items/ClientObject.cs, replace the entire EquipMask enum (the [Flags] public enum EquipMask : uint { … } block, currently lines ~64-100) and its doc comment with:
/// <summary>
/// Equipment slot bitmask — the verbatim retail <c>INVENTORY_LOC</c> enum
/// (docs/research/named-retail/acclient.h:3193; identical to ACE's EquipMask).
/// The wire (ValidLocations / CurrentWieldedLocation / WieldObject EquipLoc) delivers
/// these exact bits. Pinned by EquipMaskTests — do NOT renumber.
/// </summary>
[Flags]
public enum EquipMask : uint
{
None = 0x00000000,
HeadWear = 0x00000001,
ChestWear = 0x00000002,
AbdomenWear = 0x00000004,
UpperArmWear = 0x00000008,
LowerArmWear = 0x00000010,
HandWear = 0x00000020,
UpperLegWear = 0x00000040,
LowerLegWear = 0x00000080,
FootWear = 0x00000100,
ChestArmor = 0x00000200,
AbdomenArmor = 0x00000400,
UpperArmArmor = 0x00000800,
LowerArmArmor = 0x00001000,
UpperLegArmor = 0x00002000,
LowerLegArmor = 0x00004000,
NeckWear = 0x00008000,
WristWearLeft = 0x00010000,
WristWearRight = 0x00020000,
FingerWearLeft = 0x00040000,
FingerWearRight= 0x00080000,
MeleeWeapon = 0x00100000,
Shield = 0x00200000,
MissileWeapon = 0x00400000,
MissileAmmo = 0x00800000,
Held = 0x01000000,
TwoHanded = 0x02000000,
TrinketOne = 0x04000000,
Cloak = 0x08000000,
SigilOne = 0x10000000,
SigilTwo = 0x20000000,
SigilThree = 0x40000000,
}
- Step 4: Run the pin test + the full Core suite
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj
Expected: PASS (EquipMaskTests green; the existing ClientObjectTableTests round-trip tests using EquipMask.MeleeWeapon still pass — they're value-agnostic). If a NON-test file fails to compile because it used a removed member (HandArmor/Necklace/LeftRing/AetheriaRed/etc.), that is a real consumer the §3 blast-radius scan missed — STOP and report it (do not invent a replacement).
- Step 5: Run the Core.Net suite too (the wire round-trip lives there)
dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj
Expected: PASS (GameEventWiringTests WieldObject round-trips are value-agnostic).
- Step 6: Commit
git add src/AcDream.Core/Items/ClientObject.cs tests/AcDream.Core.Tests/Items/EquipMaskTests.cs
git commit -m "fix(D.2b): correct EquipMask to canonical retail INVENTORY_LOC + numeric-pin test"
Task 3: WieldItemOptimistic + equip-aware rollback snapshot (Core)
Add the wield sibling to MoveItemOptimistic and extend the pending-move snapshot to remember the
pre-move equip location (so rollback is faithful in both directions).
Files:
-
Modify:
src/AcDream.Core/Items/ClientObjectTable.cs -
Modify:
tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs -
Step 1: Write the failing tests
Add to ClientObjectTableTests.cs (the class already has a FullWeenie helper; these tests use the simpler AddOrUpdate+MoveItem seed path like the existing optimistic tests):
[Fact]
public void WieldItemOptimistic_equipsInstantly_andSetsWielderAndContainer()
{
var table = new ClientObjectTable();
const uint player = 0x50000001u, pack = 0x40000005u;
table.AddOrUpdate(new ClientObject { ObjectId = 0x940u });
table.MoveItem(0x940u, pack, newSlot: 2); // a pack item
table.WieldItemOptimistic(0x940u, player, EquipMask.HeadWear);
var o = table.Get(0x940u)!;
Assert.Equal(EquipMask.HeadWear, o.CurrentlyEquippedLocation); // equipped now (instant)
Assert.Equal(player, o.WielderId);
Assert.Equal(player, o.ContainerId);
}
[Fact]
public void WieldItemOptimistic_rollback_unequips_andReturnsToPack()
{
var table = new ClientObjectTable();
const uint player = 0x50000001u, pack = 0x40000005u;
table.AddOrUpdate(new ClientObject { ObjectId = 0x941u });
table.MoveItem(0x941u, pack, newSlot: 2);
table.WieldItemOptimistic(0x941u, player, EquipMask.HeadWear);
Assert.True(table.RollbackMove(0x941u)); // server rejected the wield
var o = table.Get(0x941u)!;
Assert.Equal(EquipMask.None, o.CurrentlyEquippedLocation); // un-equipped
Assert.Equal(pack, o.ContainerId); // back in the pack
Assert.Equal(2, o.ContainerSlot); // at its original slot
}
[Fact]
public void RollbackMove_restoresPreMoveEquipLocation() // unwield-reject must snap back to EQUIPPED
{
var table = new ClientObjectTable();
const uint player = 0x50000001u;
table.AddOrUpdate(new ClientObject { ObjectId = 0x942u });
table.MoveItem(0x942u, player, newSlot: -1, newEquipLocation: EquipMask.Shield); // equipped
table.MoveItemOptimistic(0x942u, player, 0); // optimistic UNWIELD into the pack (clears equip)
Assert.Equal(EquipMask.None, table.Get(0x942u)!.CurrentlyEquippedLocation);
Assert.True(table.RollbackMove(0x942u)); // server rejected the unwield
Assert.Equal(EquipMask.Shield, table.Get(0x942u)!.CurrentlyEquippedLocation); // restored to equipped
}
[Fact]
public void WieldItemOptimistic_unknownItem_false()
=> Assert.False(new ClientObjectTable().WieldItemOptimistic(0xDEADu, 0x1u, EquipMask.HeadWear));
- Step 2: Run to verify they fail
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableTests"
Expected: FAIL to compile (WieldItemOptimistic does not exist) — that counts as red; proceed.
- Step 3: Extend the snapshot tuple + add the helper +
WieldItemOptimistic
In src/AcDream.Core/Items/ClientObjectTable.cs:
(a) Change the _pendingMoves field (line ~50) to carry the equip location:
private readonly Dictionary<uint, (uint container, int slot, EquipMask equip, int outstanding)> _pendingMoves = new();
(b) In MoveItemOptimistic, replace the inline snapshot block (the if (_pendingMoves.TryGetValue(itemId, out var p)) … else … that currently records (item.ContainerId, item.ContainerSlot, 1)) with a single call:
RecordPending(itemId, item);
(c) Add the shared helper (place it just above MoveItemOptimistic):
/// <summary>Snapshot the item's pre-move (container, slot, equip) the FIRST time it moves, and
/// bump the OUTSTANDING count on subsequent in-flight moves of the same item (so an early confirm
/// can't clear a still-pending later move — the I1 hardening). Shared by MoveItemOptimistic (unwield/
/// move) and WieldItemOptimistic (wield).</summary>
private void RecordPending(uint itemId, ClientObject item)
{
if (_pendingMoves.TryGetValue(itemId, out var p))
_pendingMoves[itemId] = (p.container, p.slot, p.equip, p.outstanding + 1);
else
_pendingMoves[itemId] = (item.ContainerId, item.ContainerSlot,
item.CurrentlyEquippedLocation, 1);
}
(d) In ConfirmMove, fix the decrement line to carry equip:
else _pendingMoves[itemId] = (p.container, p.slot, p.equip, p.outstanding - 1);
(e) In RollbackMove, restore the equip location too:
return MoveItem(itemId, pre.container, pre.slot, pre.equip);
(f) Add WieldItemOptimistic (place it just after MoveItemOptimistic):
/// <summary>Optimistic (instant) wield: snapshot the pre-wield (container, slot, equip), then set
/// ContainerId = WielderId = wielderGuid and CurrentlyEquippedLocation = equipMask (matching the
/// server's WieldObject 0x0023 confirm), firing ObjectMoved for an immediate repaint. The caller
/// sends GetAndWieldItem; ConfirmMove (on the 0x0023 echo) / RollbackMove (on 0x00A0) reconcile.</summary>
public bool WieldItemOptimistic(uint itemId, uint wielderGuid, EquipMask equipMask)
{
if (!_objects.TryGetValue(itemId, out var item)) return false;
RecordPending(itemId, item);
item.WielderId = wielderGuid; // unambiguously the player's gear during the optimistic window
return MoveItem(itemId, wielderGuid, newSlot: -1, newEquipLocation: equipMask);
}
- Step 4: Run the full Core suite
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj
Expected: PASS — the 4 new tests + all existing optimistic-move tests (MoveItemOptimistic_*, ConfirmMove_*, the I1/I2 tests) stay green (the tuple gained a field but their behavior is unchanged).
- Step 5: Commit
git add src/AcDream.Core/Items/ClientObjectTable.cs tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs
git commit -m "feat(D.2b): WieldItemOptimistic + equip-aware rollback snapshot (Core)"
Task 4: Confirm an optimistic wield on the WieldObject 0x0023 echo (Core.Net)
The WieldObject handler updates state but never calls ConfirmMove, so an optimistic wield's snapshot
would linger. Add the confirm (mirrors the InventoryPutObjInContainer handler).
Files:
-
Modify:
src/AcDream.Core.Net/GameEventWiring.cs(theWieldObjecthandler, ~line 238) -
Modify:
tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs -
Step 1: Write the failing test
Add to GameEventWiringTests.cs (mirror the existing WireAll_WieldObject_RoutesToClientObjectTable payload format — itemGuid, EquipLoc, WielderGuid):
[Fact]
public void WireAll_WieldObject_ConfirmsOptimisticWield()
{
var (d, items, _, _, _) = MakeAll();
const uint player = 0x2000u;
items.AddOrUpdate(new ClientObject { ObjectId = 0x1500, WeenieClassId = 1 });
items.MoveItem(0x1500, 0x9999u, newSlot: 0); // start in a pack
items.WieldItemOptimistic(0x1500, player, EquipMask.MeleeWeapon); // optimistic wield → snapshot pending
byte[] payload = new byte[12];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1500);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)EquipMask.MeleeWeapon);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), player);
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WieldObject, payload));
d.Dispatch(env!.Value); // server confirms the wield
Assert.False(items.RollbackMove(0x1500)); // pending snapshot was cleared by ConfirmMove
}
- Step 2: Run to verify it fails
dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~WireAll_WieldObject_ConfirmsOptimisticWield"
Expected: FAIL — RollbackMove returns true (the snapshot was never confirmed), so the assertion fails.
- Step 3: Add the
ConfirmMovecall
In src/AcDream.Core.Net/GameEventWiring.cs, the WieldObject handler currently reads:
dispatcher.Register(GameEventType.WieldObject, e =>
{
var p = GameEvents.ParseWieldObject(e.Payload.Span);
if (p is not null) items.MoveItem(
p.Value.ItemGuid,
newContainerId: p.Value.WielderGuid,
newEquipLocation: (AcDream.Core.Items.EquipMask)p.Value.EquipLoc);
});
Change the body so it confirms an optimistic wield after applying the move:
dispatcher.Register(GameEventType.WieldObject, e =>
{
var p = GameEvents.ParseWieldObject(e.Payload.Span);
if (p is null) return;
items.MoveItem(
p.Value.ItemGuid,
newContainerId: p.Value.WielderGuid,
newEquipLocation: (AcDream.Core.Items.EquipMask)p.Value.EquipLoc);
items.ConfirmMove(p.Value.ItemGuid); // Slice 1: confirm an optimistic wield (mirrors the 0x0022 handler)
});
- Step 4: Run the full Core.Net suite
dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj
Expected: PASS (the new test + the existing WireAll_WieldObject_RoutesToClientObjectTable both green).
- Step 5: Commit
git add src/AcDream.Core.Net/GameEventWiring.cs tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs
git commit -m "fix(D.2b): ConfirmMove on the WieldObject 0x0023 echo (clears optimistic wield)"
Task 5: PaperdollController + tests + divergence rows (App)
The controller: element-id→mask map, bind each slot, populate icons, be the wield drag handler.
Files:
-
Create:
src/AcDream.App/UI/Layout/PaperdollController.cs -
Create:
tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs -
Modify:
docs/architecture/retail-divergence-register.md -
Step 1: Write the failing tests
Create tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs:
using System.Collections.Generic;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using Xunit;
namespace AcDream.App.Tests.UI.Layout;
public class PaperdollControllerTests
{
private const uint Player = 0x50000001u;
private const uint Pack = 0x40000005u;
private const uint HeadSlot = 0x100005ABu; // HeadWear 0x1
private const uint ShieldSlot = 0x100001E1u; // Shield 0x200000
private const uint WeaponSlot = 0x100001DFu; // composite 0x3500000
private const uint FingerLSlot= 0x100001DCu; // FingerWearLeft 0x40000
private sealed class RootElement : UiElement { }
private static (ImportedLayout layout, Dictionary<uint, UiItemList> lists) BuildLayout()
{
var ids = new[] { HeadSlot, ShieldSlot, WeaponSlot, FingerLSlot };
var lists = new Dictionary<uint, UiItemList>();
var byId = new Dictionary<uint, UiElement>();
var root = new RootElement { Width = 224, Height = 214 };
foreach (var id in ids)
{
var list = new UiItemList { Width = 32, Height = 32 };
lists[id] = list; byId[id] = list; root.AddChild(list);
}
return (new ImportedLayout(root, byId), lists);
}
private static PaperdollController Bind(ImportedLayout layout, ClientObjectTable objects,
List<(uint item, uint mask)>? wields = null)
=> PaperdollController.Bind(layout, objects, () => Player,
iconIds: (_, _, _, _, _) => 0x1234u,
sendWield: wields is null ? null : (i, m) => wields.Add((i, m)));
private static void SeedEquipped(ClientObjectTable t, uint guid, EquipMask loc)
{
t.AddOrUpdate(new ClientObject { ObjectId = guid, WielderId = Player });
t.MoveItem(guid, Player, newSlot: -1, newEquipLocation: loc);
}
private static void SeedPackItem(ClientObjectTable t, uint guid, EquipMask validLocations)
{
t.AddOrUpdate(new ClientObject { ObjectId = guid, ValidLocations = validLocations });
t.MoveItem(guid, Pack, newSlot: 0);
}
[Fact]
public void Populate_shows_equipped_item_in_its_slot()
{
var (layout, lists) = BuildLayout();
var objects = new ClientObjectTable();
SeedEquipped(objects, 0xA01u, EquipMask.HeadWear);
Bind(layout, objects);
Assert.Equal(0xA01u, lists[HeadSlot].Cell.ItemId); // helm in the head slot
Assert.Equal(0u, lists[ShieldSlot].Cell.ItemId); // shield slot empty
}
[Fact]
public void Populate_matches_a_weapon_into_the_composite_slot()
{
var (layout, lists) = BuildLayout();
var objects = new ClientObjectTable();
SeedEquipped(objects, 0xB01u, EquipMask.MeleeWeapon); // a sword's actual equip loc
Bind(layout, objects);
Assert.Equal(0xB01u, lists[WeaponSlot].Cell.ItemId); // matched via (loc & composite) != 0
}
[Fact]
public void OnDragOver_accepts_only_valid_locations()
{
var (layout, lists) = BuildLayout();
var objects = new ClientObjectTable();
SeedPackItem(objects, 0xC01u, EquipMask.HeadWear); // a helm in the pack
var ctrl = Bind(layout, objects);
var payload = new ItemDragPayload(0xC01u, ItemDragSource.Inventory, 0, lists[HeadSlot].Cell);
Assert.True(ctrl.OnDragOver(lists[HeadSlot], lists[HeadSlot].Cell, payload)); // helm → head OK
Assert.False(ctrl.OnDragOver(lists[ShieldSlot], lists[ShieldSlot].Cell, payload)); // helm → shield NO
}
[Fact]
public void HandleDropRelease_wields_optimistically_and_sends_wire()
{
var (layout, lists) = BuildLayout();
var objects = new ClientObjectTable();
SeedPackItem(objects, 0xD01u, EquipMask.HeadWear);
var wields = new List<(uint item, uint mask)>();
var ctrl = Bind(layout, objects, wields);
var payload = new ItemDragPayload(0xD01u, ItemDragSource.Inventory, 0, lists[HeadSlot].Cell);
ctrl.HandleDropRelease(lists[HeadSlot], lists[HeadSlot].Cell, payload);
Assert.Equal(EquipMask.HeadWear, objects.Get(0xD01u)!.CurrentlyEquippedLocation); // equipped instantly
Assert.Equal(Player, objects.Get(0xD01u)!.ContainerId); // contained-by-wielder (the optimistic wield is ContainerId-based; it does NOT write WielderId)
Assert.Single(wields);
Assert.Equal((0xD01u, (uint)EquipMask.HeadWear), wields[0]); // GetAndWieldItem wire
}
[Fact]
public void HandleDropRelease_resolves_the_finger_bit_for_a_dual_finger_ring()
{
var (layout, lists) = BuildLayout();
var objects = new ClientObjectTable();
SeedPackItem(objects, 0xE01u, EquipMask.FingerWearLeft | EquipMask.FingerWearRight);
var wields = new List<(uint item, uint mask)>();
var ctrl = Bind(layout, objects, wields);
var payload = new ItemDragPayload(0xE01u, ItemDragSource.Inventory, 0, lists[FingerLSlot].Cell);
ctrl.HandleDropRelease(lists[FingerLSlot], lists[FingerLSlot].Cell, payload);
Assert.Equal((uint)EquipMask.FingerWearLeft, wields[0].mask); // ValidLocations & slotMask = left finger only
}
[Fact]
public void Empty_equip_slot_is_transparent() // EmptySprite=0 ⇒ nothing drawn (no doll behind it in Slice 1)
{
var (layout, lists) = BuildLayout();
Bind(layout, new ClientObjectTable());
Assert.Equal(0u, lists[HeadSlot].Cell.EmptySprite);
}
}
- Step 2: Run to verify they fail
dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~PaperdollControllerTests"
Expected: FAIL to compile (PaperdollController does not exist) — counts as red; proceed.
- Step 3: Implement
PaperdollController
Create src/AcDream.App/UI/Layout/PaperdollController.cs:
using System;
using System.Collections.Generic;
using AcDream.App.UI;
using AcDream.Core.Items;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Binds the ~21 equip slots mounted under the paperdoll (gmPaperDollUI 0x21000024, nested in the
/// inventory frame 0x21000023) to live equipped-item data and makes them drag-drop WIELD targets.
/// The acdream analogue of gmPaperDollUI::PostInit + GetLocationInfoFromElementID (named-retail decomp
/// 175480 / 173620). Slice 1: equip slots only — no 3D doll viewport (that's Slice 2).
/// Unwield is handled by InventoryController (dragging an equipped item onto the pack grid).
/// </summary>
public sealed class PaperdollController : IItemListDragHandler
{
// WEAPON_READY_SLOT_LOC (acclient.h:3235): the weapon-hand doll slot accepts any wieldable weapon.
private const EquipMask WeaponSlotMask =
EquipMask.MeleeWeapon | EquipMask.MissileWeapon | EquipMask.Held | EquipMask.TwoHanded;
// element-id → EquipMask (verified: dump paperdoll-0x21000024.txt ↔ deep-dive §3a ↔ acclient.h:3193).
// The 3 Aetheria sigil slots (0x10000595/96/97) are SetVisible(0) in retail — skipped (Slice 1 scope).
private static readonly (uint Element, EquipMask Mask)[] SlotMap =
{
(0x100005ABu, EquipMask.HeadWear), // 0x1
(0x100001E2u, EquipMask.ChestWear), // 0x2
(0x100001E3u, EquipMask.UpperLegWear), // 0x40
(0x100005B0u, EquipMask.HandWear), // 0x20
(0x100005B3u, EquipMask.FootWear), // 0x100
(0x100005ACu, EquipMask.ChestArmor), // 0x200
(0x100005ADu, EquipMask.AbdomenArmor), // 0x400
(0x100005AEu, EquipMask.UpperArmArmor), // 0x800
(0x100005AFu, EquipMask.LowerArmArmor), // 0x1000
(0x100005B1u, EquipMask.UpperLegArmor), // 0x2000
(0x100005B2u, EquipMask.LowerLegArmor), // 0x4000
(0x100001DAu, EquipMask.NeckWear), // 0x8000
(0x100001DBu, EquipMask.WristWearLeft), // 0x10000
(0x100001DDu, EquipMask.WristWearRight), // 0x20000
(0x100001DCu, EquipMask.FingerWearLeft), // 0x40000
(0x100001DEu, EquipMask.FingerWearRight), // 0x80000
(0x100001E1u, EquipMask.Shield), // 0x200000
(0x100001E0u, EquipMask.MissileAmmo), // 0x800000 (LIKELY — gate-verify, AP row)
(0x100001DFu, WeaponSlotMask), // 0x3500000 composite
(0x1000058Eu, EquipMask.TrinketOne), // 0x4000000
(0x100005E9u, EquipMask.Cloak), // 0x8000000
};
private readonly ClientObjectTable _objects;
private readonly Func<uint> _playerGuid;
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds;
private readonly Action<uint, uint>? _sendWield; // (itemGuid, equipMask) → GetAndWieldItem 0x001A
private readonly List<(EquipMask Mask, UiItemList List)> _slots = new();
private PaperdollController(
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
Func<ItemType, uint, uint, uint, uint, uint> iconIds, Action<uint, uint>? sendWield)
{
_objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _sendWield = sendWield;
int index = 0;
foreach (var (element, mask) in SlotMap)
{
if (layout.FindElement(element) is not UiItemList list) { index++; continue; }
list.RegisterDragHandler(this);
list.Cell.SourceKind = ItemDragSource.Equipment;
list.Cell.SlotIndex = index++; // a stable per-slot index (routing uses the list, not this)
list.Cell.EmptySprite = 0u; // TRANSPARENT empty slot (brainstorm decision; no doll behind it)
_slots.Add((mask, list));
}
_objects.ObjectAdded += OnObjectChanged;
_objects.ObjectMoved += OnObjectMoved;
_objects.ObjectRemoved += OnObjectChanged;
_objects.ObjectUpdated += OnObjectChanged;
Populate();
}
public static PaperdollController Bind(
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
Func<ItemType, uint, uint, uint, uint, uint> iconIds, Action<uint, uint>? sendWield = null)
=> new PaperdollController(layout, objects, playerGuid, iconIds, sendWield);
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
private void OnObjectMoved(ClientObject o, uint from, uint to)
{ if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); }
/// <summary>The object is (or just became / ceased to be) the player's equipped gear.</summary>
private bool Concerns(ClientObject o)
{
uint p = _playerGuid();
return o.CurrentlyEquippedLocation != EquipMask.None || o.WielderId == p || o.ContainerId == p;
}
public void Populate()
{
uint p = _playerGuid();
// The player's equipped gear (one pass). Scoped to the player so a vendor's/NPC's wielded item
// (which can also carry CurrentWieldedLocation) can't leak into the doll.
var equipped = new List<ClientObject>();
foreach (var o in _objects.Objects)
if (o.CurrentlyEquippedLocation != EquipMask.None && (o.WielderId == p || o.ContainerId == p))
equipped.Add(o);
foreach (var (mask, list) in _slots)
{
ClientObject? worn = null;
foreach (var o in equipped)
if ((o.CurrentlyEquippedLocation & mask) != EquipMask.None) { worn = o; break; }
if (worn is null) { list.Cell.Clear(); continue; }
uint tex = _iconIds(worn.Type, worn.IconId, worn.IconUnderlayId, worn.IconOverlayId, worn.Effects);
list.Cell.SetItem(worn.ObjectId, tex);
}
}
private EquipMask MaskFor(UiItemList list)
{
foreach (var (mask, l) in _slots) if (ReferenceEquals(l, list)) return mask;
return EquipMask.None;
}
// ── IItemListDragHandler ──────────────────────────────────────────────────────────────────────
/// <summary>No-op: a wielded item stays put until the server confirms (same as the inventory grid,
/// unlike the toolbar's remove-on-lift). Unwield happens on DROP onto the pack grid — InventoryController.</summary>
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { }
/// <summary>Accept iff the dragged item can be worn here (its ValidLocations intersects the slot mask).
/// Retail: gmPaperDollUI::OnItemListDragOver (decomp 174302).</summary>
public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
{
var item = _objects.Get(payload.ObjId);
if (item is null) return false;
return (item.ValidLocations & MaskFor(targetList)) != EquipMask.None;
}
/// <summary>Wield the dropped item: optimistic local equip (instant) + GetAndWieldItem 0x001A.
/// wieldMask = ValidLocations & slotMask resolves the specific bit (the composite weapon slot, the
/// chosen finger). Retail: gmPaperDollUI HandleDropRelease → GetAndWieldItem.</summary>
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
{
var item = _objects.Get(payload.ObjId);
if (item is null) return;
EquipMask wieldMask = item.ValidLocations & MaskFor(targetList);
if (wieldMask == EquipMask.None) return; // not wieldable here (defensive; OnDragOver already rejected)
_objects.WieldItemOptimistic(payload.ObjId, _playerGuid(), wieldMask);
_sendWield?.Invoke(payload.ObjId, (uint)wieldMask);
}
/// <summary>Detach event handlers (idempotent).</summary>
public void Dispose()
{
_objects.ObjectAdded -= OnObjectChanged;
_objects.ObjectMoved -= OnObjectMoved;
_objects.ObjectRemoved -= OnObjectChanged;
_objects.ObjectUpdated -= OnObjectChanged;
}
}
- Step 4: Run the controller tests + the full App suite
dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj
Expected: PASS (the 6 PaperdollControllerTests + the whole App suite green; the EquipMask correction is value-agnostic for existing App tests).
- Step 5: Add the divergence-register rows
Read docs/architecture/retail-divergence-register.md, find the highest existing AP-NN, and append three rows using the next sequential numbers (the B-Drag rows AP-60/AP-61 are the current tail, so expect AP-62..AP-64 — but use whatever the file's actual max+1 is). One-line rows in the file's existing format:
- MissileAmmo slot mask LIKELY —
0x100001E0 → MissileAmmo 0x800000is inferred (the decomp immediate atGetLocationInfoFromElementID173676 is corrupted to a string pointer); risk if wrong: dropping ammo onto that slot wields to the wrong location. Source:PaperdollController.SlotMap. Gate-verify. - Dual-wield-into-shield-slot special not implemented — retail
OnItemListDragOver(decomp 174302) lets a melee-capable item also drop on the Shield slot; acdream'swieldMask = ValidLocations & slotMaskrejects it. Risk: a dual-wielder can't off-hand a melee weapon via the doll. Source:PaperdollController.HandleDropRelease. - Wield-reject rollback assumes
InventoryServerSaveFailed 0x00A0— an optimistic wield rolls back only if ACE emits0x00A0for aGetAndWieldItemrejection; otherwise it's corrected by the next authoritative message. Source:GameEventWiring0x00A0 handler. Gate-verify via WireMCP.
- Step 6: Commit
git add src/AcDream.App/UI/Layout/PaperdollController.cs tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs docs/architecture/retail-divergence-register.md
git commit -m "feat(D.2b): PaperdollController — equip slots bind + wield drag handler (Slice 1)"
Task 6: Wire PaperdollController into GameWindow (App)
Files:
-
Modify:
src/AcDream.App/Rendering/GameWindow.cs(the field declaration near_inventoryController; the bind site at ~line 2245, right after theInventoryController.Bind(...)call) -
Step 1: Add the field
Find the _inventoryController field declaration (grep _inventoryController in GameWindow.cs — it's a private … InventoryController? _inventoryController;). Add directly beneath it:
private AcDream.App.UI.Layout.PaperdollController? _paperdollController;
- Step 2: Add the Bind call
In the inventory-frame block, immediately after the _inventoryController = AcDream.App.UI.Layout.InventoryController.Bind(...); statement ends (the sendPutItemInContainer: line that closes with ));, ~line 2245) and before the following Console.WriteLine("[D.2b-B] retail inventory window …");, insert:
// Slice 1: bind the paperdoll equip slots (same imported subtree) — show equipped gear +
// wield-on-drop. Unwield is handled by InventoryController (drag an equipped item to the grid).
_paperdollController = AcDream.App.UI.Layout.PaperdollController.Bind(
invLayout, Objects,
playerGuid: () => _playerServerGuid,
iconIds: (type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects),
sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask));
- Step 3: Build the whole solution
dotnet build
Expected: build succeeds (0 errors).
- Step 4: Run the full test suite (all projects)
dotnet test
Expected: all green across Core / Core.Net / App / UI.Abstractions. This is the phase-boundary full-suite gate (the B-Wire process lesson — don't trust filtered subsets).
- Step 5: Commit
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "feat(D.2b): wire PaperdollController into the inventory frame (Slice 1)"
Visual gate (user)
Launch the client (plain dotnet run — do NOT auto-kill a running client; if a rebuild is locked, ASK the user to close it), log in, press F12:
- Equipped gear shows as icons in the correct doll slots; empty slots are transparent.
- Drag a wieldable item from the pack onto a matching slot → it wields instantly (icon appears) and survives the server echo; an invalid slot shows red and rejects.
- Drag an equipped item from a slot to the pack → it unwields.
While testing, run WireMCP to confirm GetAndWieldItem 0x001A goes out on a wield and whether 0x00A0 comes back on a rejected wield (divergence row 3).
Post-gate (after visual confirmation)
- Update
claude-memory/project_d2b_retail_ui.mdwith the Slice 1 SHIPPED entry (key facts + DO-NOT-RETRY). - Update
docs/plans/2026-04-11-roadmap.md(Sub-phase C Slice 1 shipped). - Note in
docs/ISSUES.mdany gate-verify follow-ups (the AP rows).