From 9f187c3e31bac8e5213e25a72b65fa41a72bc1bb Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 22:24:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(D.2b):=20PaperdollController=20=E2=80=94?= =?UTF-8?q?=20equip=20slots=20bind=20+=20wield=20drag=20handler=20(Slice?= =?UTF-8?q?=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../retail-divergence-register.md | 3 + .../UI/Layout/PaperdollController.cs | 159 ++++++++++++++++++ .../UI/Layout/PaperdollControllerTests.cs | 121 +++++++++++++ 3 files changed, 283 insertions(+) create mode 100644 src/AcDream.App/UI/Layout/PaperdollController.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 2f9e8dba..34aa1c6c 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -160,6 +160,9 @@ accepted-divergence entries (#96, #49, #50). | AP-59 | The per-cell container **capacity bar** (retail `UIElement_UIItem::UpdateCapacityDisplay 0x004e16e0`, element `0x10000347`) is drawn as a **procedural `UiItemSlot` overlay** (track `0x06004D22` full + fill `0x06004D23` clipped bottom-up to `GetContents(guid).Count / ItemsCapacity`), not via a real dat `UIElement_Meter` child. **Right-anchored flush** (the dat rect X=26 in a 36px cell sat ~5px off the edge — flush per the visual gate) and **bottom-up fill assumed** (the dat `m_eDirection` 0x6f isn't read, cf. AP-50). A CLOSED side bag reads empty until opened (its contents aren't indexed until `ViewContents`) — faithful to retail's known-children count, divergent if retail pre-loads. | `src/AcDream.App/UI/UiItemSlot.cs` (`CapacityFill` draw); `src/AcDream.App/UI/Layout/InventoryController.cs` (`SetCapacityBar`) | UiItemSlot is a behavioral leaf that paints overlays procedurally (cf. AP-57); the meter sprites + fill formula are the faithful port. Right-anchor + bottom-up were visual-gate calls. Further visual polish is deferred — ISSUES #146. | Fill direction / exact bar rect could differ from retail's `m_eDirection` + dat X; closed-bag bars read empty if retail pre-loads container counts. | `UIElement_UIItem::UpdateCapacityDisplay` 0x004e16e0; element `0x10000347` (back `0x06004D22` / front `0x06004D23`); `GetNumContainedItems` | | AP-60 | Inventory drag **`OnDragLift` is a no-op** + the source cell is **not dimmed**: retail dims the lifted item's source cell (`RecvNotice_ItemListBeginDrag`); acdream leaves the item in place during the drag + the floating ghost. | `src/AcDream.App/UI/Layout/InventoryController.cs` | Cosmetic only — the dragged item is still unambiguously identifiable; dimming the source requires tracking the source cell reference across drag events, deferred to a polish pass. | A drag in progress doesn't visually mark its origin — cosmetic only, no functional effect. | `RecvNotice_ItemListBeginDrag` acclient_2013_pseudo_c.txt | | AP-61 | Drop on a **CLOSED side bag is advisory-accept**: the client can't know a closed bag's item count (contents aren't indexed until opened), so `OnDragOver` shows green + relies on the server's `InventoryServerSaveFailed` reject + the rollback; retail knows the count when loaded. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`IsContainerFull`) | A drop into a closed-but-full bag shows green then snaps back (a flicker) instead of pre-showing red. Faithful only when the bag has been opened (then `GetContents` is populated). The server's `0x00A0` + optimistic rollback is the authoritative safety net. | A drop into a closed-but-full bag shows green → brief flicker → snap-back instead of retail's pre-emptive red circle. | `UIElement_ItemList::InqDropIconInfo 0x004e26f0` | +| AP-62 | **MissileAmmo slot mask LIKELY** — `0x100001E0 → MissileAmmo 0x800000` is inferred; the decomp immediate at `GetLocationInfoFromElementID` 173676 is corrupted to a string pointer, so the mapping was not directly confirmed from the named-retail source. | `src/AcDream.App/UI/Layout/PaperdollController.cs` (`SlotMap`) | Dropping ammo onto that slot wields to the wrong location if the mapping is wrong. Gate-verify via dat dump + cdb. | Ammo wields to the wrong equip location — functional gap if wrong. | `GetLocationInfoFromElementID` decomp 173676; `acclient.h:3193` INVENTORY_LOC | +| AP-63 | **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. | `src/AcDream.App/UI/Layout/PaperdollController.cs` (`HandleDropRelease`) | A dual-wielder cannot off-hand a melee weapon via the doll. | Dual-wield via drag-onto-shield-slot is blocked — functional gap for dual-wield characters. | `gmPaperDollUI::OnItemListDragOver` decomp 174302 | +| AP-64 | **Wield-reject rollback assumes `InventoryServerSaveFailed 0x00A0`** — an optimistic wield rolls back only if ACE emits `0x00A0` for a `GetAndWieldItem` rejection; otherwise corrected by the next authoritative message. Gate-verify via WireMCP. | `src/AcDream.Core.Net/GameEventWiring.cs` (0x00A0 handler) | If ACE uses a different reject opcode for wield, the optimistic state is left dangling until the next full update. | Rejected wield briefly shows the item as equipped in the doll — cosmetic flicker or stuck state if `0x00A0` is not the rejection path. | `InventoryServerSaveFailed 0x00A0`; WireMCP gate-verify | --- diff --git a/src/AcDream.App/UI/Layout/PaperdollController.cs b/src/AcDream.App/UI/Layout/PaperdollController.cs new file mode 100644 index 00000000..fac6dff0 --- /dev/null +++ b/src/AcDream.App/UI/Layout/PaperdollController.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using AcDream.App.UI; +using AcDream.Core.Items; + +namespace AcDream.App.UI.Layout; + +/// +/// 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). +/// +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 _playerGuid; + private readonly Func _iconIds; + private readonly Action? _sendWield; // (itemGuid, equipMask) → GetAndWieldItem 0x001A + private readonly List<(EquipMask Mask, UiItemList List)> _slots = new(); + + private PaperdollController( + ImportedLayout layout, ClientObjectTable objects, Func playerGuid, + Func iconIds, Action? 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 playerGuid, + Func iconIds, Action? 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(); } + + /// The object is (or just became / ceased to be) the player's equipped gear. + 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(); + 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 ────────────────────────────────────────────────────────────────────── + /// 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. + public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { } + + /// Accept iff the dragged item can be worn here (its ValidLocations intersects the slot mask). + /// Retail: gmPaperDollUI::OnItemListDragOver (decomp 174302). + 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; + } + + /// 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. + 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); + } + + /// Detach event handlers (idempotent). + public void Dispose() + { + _objects.ObjectAdded -= OnObjectChanged; + _objects.ObjectMoved -= OnObjectMoved; + _objects.ObjectRemoved -= OnObjectChanged; + _objects.ObjectUpdated -= OnObjectChanged; + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs new file mode 100644 index 00000000..526176bb --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs @@ -0,0 +1,121 @@ +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 lists) BuildLayout() + { + var ids = new[] { HeadSlot, ShieldSlot, WeaponSlot, FingerLSlot }; + var lists = new Dictionary(); + var byId = new Dictionary(); + 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); + } +}