docs(D.2b): paperdoll Slice 1 implementation plan
Six TDD tasks: (1) de-risk probe (equip slots resolve to UiItemList) → (2) correct EquipMask to canonical retail INVENTORY_LOC + numeric-pin test → (3) WieldItemOptimistic + equip-aware rollback snapshot → (4) ConfirmMove on the WieldObject 0x0023 echo → (5) PaperdollController + tests + divergence rows → (6) GameWindow wiring + full-suite gate. Then the user visual gate. Full no-placeholder code; all signatures verified against source. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2d7ea7446d
commit
cf472bd8db
1 changed files with 862 additions and 0 deletions
|
|
@ -0,0 +1,862 @@
|
||||||
|
# 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:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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` (the `EquipMask` enum)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing numeric-pin test**
|
||||||
|
|
||||||
|
Create `tests/AcDream.Core.Tests/Items/EquipMaskTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
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:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[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:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
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:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
RecordPending(itemId, item);
|
||||||
|
```
|
||||||
|
|
||||||
|
(c) Add the shared helper (place it just above `MoveItemOptimistic`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <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`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
else _pendingMoves[itemId] = (p.container, p.slot, p.equip, p.outstanding - 1);
|
||||||
|
```
|
||||||
|
|
||||||
|
(e) In `RollbackMove`, restore the equip location too:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
return MoveItem(itemId, pre.container, pre.slot, pre.equip);
|
||||||
|
```
|
||||||
|
|
||||||
|
(f) Add `WieldItemOptimistic` (place it just after `MoveItemOptimistic`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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` (the `WieldObject` handler, ~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):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[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 `ConfirmMove` call**
|
||||||
|
|
||||||
|
In `src/AcDream.Core.Net/GameEventWiring.cs`, the `WieldObject` handler currently reads:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
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:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
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)!.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`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
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:
|
||||||
|
|
||||||
|
1. **MissileAmmo slot mask LIKELY** — `0x100001E0 → MissileAmmo 0x800000` is inferred (the decomp immediate at `GetLocationInfoFromElementID` 173676 is corrupted to a string pointer); risk if wrong: dropping ammo onto that slot wields to the wrong location. Source: `PaperdollController.SlotMap`. Gate-verify.
|
||||||
|
2. **Dual-wield-into-shield-slot special not implemented** — retail `OnItemListDragOver` (decomp 174302) lets a melee-capable item also drop on the Shield slot; acdream's `wieldMask = ValidLocations & slotMask` rejects it. Risk: a dual-wielder can't off-hand a melee weapon via the doll. Source: `PaperdollController.HandleDropRelease`.
|
||||||
|
3. **Wield-reject rollback assumes `InventoryServerSaveFailed 0x00A0`** — an optimistic wield rolls back only if ACE emits `0x00A0` for a `GetAndWieldItem` rejection; otherwise it's corrected by the next authoritative message. Source: `GameEventWiring` 0x00A0 handler. Gate-verify via WireMCP.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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 the `InventoryController.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:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
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:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// 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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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.md` with 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.md` any gate-verify follow-ups (the AP rows).
|
||||||
Loading…
Add table
Add a link
Reference in a new issue