docs(D.2b): paperdoll Slice 1 (equip slots) design spec
Sub-phase C, Slice 1 — bind the ~25 paperdoll equip slots to live equipped-item data + make them drag-drop wield/unwield targets. No 3D doll (that's Slice 2). Brainstorm findings baked in: - The handoff's "build the wire gap" premise is STALE: BuildGetAndWieldItem + SendGetAndWieldItem already shipped in B-Wire. The whole optimistic-move machine + InventoryController:IItemListDragHandler already exist — Slice 1 mirrors them. Unwield is free (the inventory grid handler already does it). - Found a latent bug: acdream's EquipMask enum diverges from canonical AC (acclient.h:3193 INVENTORY_LOC) from bit 0x2000 up (phantom HandArmor/ FootArmor). Correct it to the verbatim retail values + a numeric-pin test. Blast radius is safe (4 round-trip test refs). - Empty equip slots are TRANSPARENT (EmptySprite=0), per the user — faithful, zero Slice-2 rework. Real scope: correct EquipMask (Core) + WieldItemOptimistic & equip-aware rollback snapshot (Core) + ConfirmMove on the WieldObject 0x0023 handler (Core.Net) + PaperdollController (App) + GameWindow wiring. Element-id→mask map verified dump ↔ deep-dive §3a ↔ acclient.h. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
702058f7d4
commit
2d7ea7446d
1 changed files with 337 additions and 0 deletions
|
|
@ -0,0 +1,337 @@
|
|||
# D.2b Sub-phase C, Slice 1 — Paperdoll equip slots (design)
|
||||
|
||||
**Date:** 2026-06-22
|
||||
**Branch:** `claude/hopeful-maxwell-214a12` (tip `702058f`; `main` is a clean ff ancestor)
|
||||
**Status:** DESIGN — approved in brainstorm; spec under review before the implementation plan.
|
||||
**Supersedes the stale framing in:** `docs/research/2026-06-22-paperdoll-handoff.md` §"THE WIRE GAP"
|
||||
(the handoff quoted the 2026-06-16 deep-dive, which pre-dated B-Wire; the wire already exists — see §2).
|
||||
**Authoritative research:** `docs/research/2026-06-16-equipment-paperdoll-deep-dive.md` (§3a element-id→mask, §4 wire).
|
||||
**Line numbers drift — grep the symbol.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal + scope
|
||||
|
||||
Bind the ~25 mounted paperdoll equip slots (under `0x100001CD` / `gmPaperDollUI 0x21000024`,
|
||||
already imported + positioned inside the inventory frame) to live equipped-item data and make them
|
||||
drag-drop **wield**/**unwield** targets. After this slice: you SEE your equipped gear as icons in the
|
||||
correct doll slots, dragging a wieldable item onto a slot wields it (optimistic + rollback), and
|
||||
dragging a slot's item to the pack unwields it.
|
||||
|
||||
**This is the lighter, functional half of Sub-phase C.** No 3D doll.
|
||||
|
||||
### Non-goals (Slice 2 or later — do NOT build here)
|
||||
|
||||
- The 3D doll `UIElement_Viewport` (Type `0xD`) + the Core→App `IUiViewportRenderer` seam.
|
||||
- Part-selection highlight, doll rotation, auto-wield-on-doll-body (drop on the body, not a slot).
|
||||
- Aetheria sigil slots (`0x10000595/96/97`; `SetVisible(0)` by default in retail — left unbound).
|
||||
- The richer `InventoryPlacement` priority list (PlayerDescription equipped section, deep-dive §3c) —
|
||||
the per-item `CurrentlyEquippedLocation` is sufficient for slot icons (each item has one location).
|
||||
- Dual-wield-into-shield-slot special-case (deep-dive §3b line 174302).
|
||||
|
||||
---
|
||||
|
||||
## 2. What already exists (reuse, do NOT rebuild)
|
||||
|
||||
Verified against source this session — the handoff's "build the wire gap" premise is **stale**.
|
||||
|
||||
| Capability | Where | Status |
|
||||
|---|---|---|
|
||||
| `GetAndWieldItem 0x001A` builder | `InventoryActions.BuildGetAndWieldItem(seq,itemGuid,equipMask)` `InventoryActions.cs:153` (20-byte body) | EXISTS |
|
||||
| `GetAndWieldItem` sender | `WorldSession.SendGetAndWieldItem(itemGuid,equipMask)` `WorldSession.cs:1205` | EXISTS |
|
||||
| Unwield wire | `WorldSession.SendPutItemInContainer(item,container,placement)` `:1230` → `BuildPickUp 0x0019` | EXISTS, wired |
|
||||
| Optimistic move + rollback | `ClientObjectTable.MoveItemOptimistic/ConfirmMove/RollbackMove` + `_pendingMoves` (outstanding-count, I1/I2-hardened) | EXISTS |
|
||||
| Drag-drop spine | `IItemListDragHandler`; `UiItemSlot.OnEvent` (DragBegin→OnDragLift, DragEnter→OnDragOver, DropReleased→HandleDropRelease) | EXISTS |
|
||||
| Pattern to mirror | `InventoryController : IItemListDragHandler` `InventoryController.cs` | EXISTS |
|
||||
| Equip-state per item | `ClientObject.CurrentlyEquippedLocation` + `ValidLocations` (parsed `CreateObject.cs:752-760` → `ObjectTableWiring` → `Ingest`) | EXISTS |
|
||||
| Wield confirm parse | `WieldObject 0x0023` handler `GameEventWiring.cs:238` → `MoveItem(item,wielder,equip)` | EXISTS (gap: see §5) |
|
||||
| Slots imported + positioned | inventory frame `GameWindow.cs:2145`; paperdoll mount `PinTopLeft(0x100001CD)` `:2208`; `InventoryController.Bind` `:2231` | EXISTS |
|
||||
| Transparent empty slot | `UiItemSlot.OnDraw:222` — `EmptySprite != 0` gates the draw; `EmptySprite = 0` ⇒ nothing drawn | EXISTS |
|
||||
|
||||
**Unwield is therefore free:** dragging an equipped item (a valid drag source, `IsDragSource ⇒ ItemId != 0`)
|
||||
onto the inventory grid already hits `InventoryController.HandleDropRelease` →
|
||||
`MoveItemOptimistic` (clears equip) + `SendPutItemInContainer`. The PaperdollController only implements **wield**.
|
||||
|
||||
---
|
||||
|
||||
## 3. The `EquipMask` enum is wrong — correct it first (Core)
|
||||
|
||||
acdream's `EquipMask` (`ClientObject.cs:65`) **diverges from canonical AC** starting at bit `0x2000`.
|
||||
It invented two phantom bits — `HandArmor = 0x2000` and `FootArmor = 0x10000` — that do not exist in
|
||||
retail's `INVENTORY_LOC` enum (verbatim header `docs/research/named-retail/acclient.h:3193`), shifting
|
||||
every slot above `0x1000` out of alignment. It has not bitten yet only because nothing compares against a
|
||||
**named** mask (`InventoryController` checks `!= None`, numeric-agnostic; wire values are stored as raw
|
||||
`(EquipMask)uint` casts that preserve the numeric bits). The paperdoll is the first code to compare
|
||||
against named masks, so it would expose the bug.
|
||||
|
||||
**Wrong vs right (sample):** acdream `0x200000 = RightRing`; retail `0x200000 = SHIELD_LOC`. acdream
|
||||
`0x800000 = Shield`; retail `0x800000 = MISSILE_AMMO_LOC`.
|
||||
|
||||
### Fix: replace the enum body with the verbatim retail `INVENTORY_LOC` values
|
||||
|
||||
```csharp
|
||||
[Flags]
|
||||
public enum EquipMask : uint
|
||||
{
|
||||
None = 0,
|
||||
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, // was wrongly 0x4000 (phantom HandArmor at 0x2000)
|
||||
LowerLegArmor = 0x00004000, // was wrongly 0x8000
|
||||
NeckWear = 0x00008000, // acdream had no NeckWear (called it "Necklace" at 0x20000)
|
||||
WristWearLeft = 0x00010000, // was wrongly "FootArmor"
|
||||
WristWearRight= 0x00020000,
|
||||
FingerWearLeft= 0x00040000,
|
||||
FingerWearRight=0x00080000,
|
||||
MeleeWeapon = 0x00100000, // was wrongly 0x400000
|
||||
Shield = 0x00200000, // was wrongly 0x800000
|
||||
MissileWeapon = 0x00400000,
|
||||
MissileAmmo = 0x00800000,
|
||||
Held = 0x01000000,
|
||||
TwoHanded = 0x02000000, // acdream lacked it (had Held here)
|
||||
TrinketOne = 0x04000000, // was wrongly 0x10000000
|
||||
Cloak = 0x08000000, // the ONLY high bit acdream had right
|
||||
SigilOne = 0x10000000,
|
||||
SigilTwo = 0x20000000,
|
||||
SigilThree = 0x40000000,
|
||||
}
|
||||
```
|
||||
|
||||
Removed (no longer exist): `HandArmor`, `FootArmor`, `Necklace`, `LeftBracelet`, `RightBracelet`,
|
||||
`LeftRing`, `RightRing`, `AetheriaRed/Yellow/Blue`. **Blast radius is safe:** the only references in
|
||||
the tree are 4 test files using `EquipMask.MeleeWeapon` in **round-trips** (write `(uint)…` to the wire,
|
||||
parse back, assert equality) — value-agnostic, so they stay green. No test pins a wrong numeric value
|
||||
against external truth.
|
||||
|
||||
**Anti-regression: a numeric-pin test** (`tests/AcDream.Core.Tests/Items/EquipMaskTests.cs`) asserting the
|
||||
exact value of every member against `acclient.h:3193` (e.g. `Assert.Equal(0x200000u, (uint)EquipMask.Shield)`).
|
||||
This converts the "named mask == canonical bit" contract into a hard test so it can never silently drift.
|
||||
|
||||
---
|
||||
|
||||
## 4. Optimistic wield (Core — `ClientObjectTable`)
|
||||
|
||||
`MoveItemOptimistic` is **unwield-shaped**: it hardcodes `CurrentlyEquippedLocation = None` (`:162`) and
|
||||
does a gapless container-index insert. Wield is the opposite. Add a sibling.
|
||||
|
||||
### 4a. Extend the pending-move snapshot to remember the pre-move equip location
|
||||
|
||||
Today `_pendingMoves` is `Dictionary<uint,(uint container,int slot,int outstanding)>`. Extend to
|
||||
`(uint container, int slot, EquipMask equip, int outstanding)`. Extract a shared private helper:
|
||||
|
||||
```csharp
|
||||
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);
|
||||
}
|
||||
```
|
||||
|
||||
`MoveItemOptimistic` calls `RecordPending` (unchanged behavior — it records `equip` which is `None` for
|
||||
pack items). `RollbackMove` restores all three via the existing `MoveItem` overload that already takes an
|
||||
`EquipMask`:
|
||||
|
||||
```csharp
|
||||
return MoveItem(itemId, pre.container, pre.slot, pre.equip);
|
||||
```
|
||||
|
||||
This makes rollback faithful in **both** directions (today an unwield-reject loses the item's slot mask).
|
||||
|
||||
### 4b. `WieldItemOptimistic`
|
||||
|
||||
```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);
|
||||
}
|
||||
```
|
||||
|
||||
`MoveItem` already sets `ContainerId` + `CurrentlyEquippedLocation` + reindexes + fires `ObjectMoved`.
|
||||
Setting `WielderId` here matches the steady state (the item's own CreateObject carries `Wielder`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Close the wield-confirm gap (Core.Net — `GameEventWiring`)
|
||||
|
||||
The `WieldObject 0x0023` handler (`GameEventWiring.cs:238`) does `MoveItem(item, wielder, equip)` but —
|
||||
unlike the `InventoryPutObjInContainer` handler at `:251` — does **not** call `ConfirmMove`. Add it so an
|
||||
optimistic wield's snapshot clears on the server echo (decrement the outstanding count). No-op when nothing
|
||||
is pending (server-initiated / login wields), so it's safe and unconditional:
|
||||
|
||||
```csharp
|
||||
items.MoveItem(p.Value.ItemGuid, newContainerId: p.Value.WielderGuid,
|
||||
newEquipLocation: (EquipMask)p.Value.EquipLoc);
|
||||
items.ConfirmMove(p.Value.ItemGuid); // Slice 1: confirm an optimistic wield (mirrors the 0x0022 handler)
|
||||
```
|
||||
|
||||
Wield **rollback** rides the existing `InventoryServerSaveFailed 0x00A0` handler (`:280` →
|
||||
`RollbackMove`) unchanged — verify ACE emits `0x00A0` for `GetAndWieldItem` rejections at the gate (§9).
|
||||
|
||||
---
|
||||
|
||||
## 6. `PaperdollController` (App — `UI/Layout/PaperdollController.cs`)
|
||||
|
||||
Mirrors `InventoryController`: a `gm*UI::PostInit`-style find-by-id binder that owns the equip slots,
|
||||
populates them from `ClientObjectTable`, and is their `IItemListDragHandler`.
|
||||
|
||||
### 6a. Element-id → EquipMask map (verified: dump `paperdoll-0x21000024.txt` ↔ deep-dive §3a ↔ `acclient.h:3193`)
|
||||
|
||||
A `static readonly (uint Element, EquipMask Mask)[]` of the 21 functional slots:
|
||||
|
||||
| element | mask | element | mask |
|
||||
|---|---|---|---|
|
||||
| `0x100005AB` HeadWear `0x1` | | `0x100005B2` LowerLegArmor `0x4000` | |
|
||||
| `0x100001E2` ChestWear `0x2` | | `0x100001DA` NeckWear `0x8000` | |
|
||||
| `0x100001E3` UpperLegWear `0x40` | | `0x100001DB` WristWearLeft `0x10000` | |
|
||||
| `0x100005B0` HandWear `0x20` | | `0x100001DD` WristWearRight `0x20000` | |
|
||||
| `0x100005B3` FootWear `0x100` | | `0x100001DC` FingerWearLeft `0x40000` | |
|
||||
| `0x100005AC` ChestArmor `0x200` | | `0x100001DE` FingerWearRight `0x80000` | |
|
||||
| `0x100005AD` AbdomenArmor `0x400` | | `0x100001E1` Shield `0x200000` | |
|
||||
| `0x100005AE` UpperArmArmor `0x800` | | `0x100001E0` MissileAmmo `0x800000` (LIKELY) | |
|
||||
| `0x100005AF` LowerArmArmor `0x1000` | | `0x100001DF` weapon composite `0x3500000` | |
|
||||
| `0x100005B1` UpperLegArmor `0x2000` | | `0x1000058E` TrinketOne `0x4000000` | |
|
||||
| | | `0x100005E9` Cloak `0x8000000` | |
|
||||
|
||||
Weapon composite `0x3500000` = `WEAPON_READY_SLOT_LOC` (`acclient.h:3235`) =
|
||||
`MeleeWeapon|MissileWeapon|Held|TwoHanded`. `0x100001E0`'s mask is LIKELY (the decomp immediate was
|
||||
corrupted; inferred MissileAmmo from the gap + neighbors — gate-verify, §9).
|
||||
|
||||
### 6b. Construction / binding
|
||||
|
||||
For each `(element, mask)`: `layout.FindElement(element) as UiItemList`; if non-null:
|
||||
`list.RegisterDragHandler(this)`; `list.Cell.SourceKind = ItemDragSource.Equipment`;
|
||||
`list.Cell.SlotIndex = <index in the map>`; **`list.Cell.EmptySprite = 0`** (transparent, per the
|
||||
brainstorm); `list.Cell.SpriteResolve = …` (the same chrome resolver the factory set). Leave the
|
||||
`UiItemSlot` **default** accept/reject sprites — the retail `ItemSlot_DragOver_Accept`/`_Reject`
|
||||
`0x060011F9`/`0x060011F8` (the discrete-slot ring/circle, NOT the inventory grid's insert-arrow
|
||||
`0x060011F7`, which is for insert-between-cells). Keep a `mask → UiItemList` map for populate (so
|
||||
`MaskFor(list)` in §6d is the reverse lookup). Subscribe `ObjectAdded/Moved/Removed/Updated`.
|
||||
|
||||
`Bind(layout, objects, playerGuid, iconIds, sendWield)` static factory mirroring `InventoryController.Bind`.
|
||||
|
||||
### 6c. Populate
|
||||
|
||||
One pass over `_objects.Objects`, building `equipped: List<(EquipMask loc, ClientObject item)>` of the
|
||||
**player's** gear: `o.CurrentlyEquippedLocation != None && (o.WielderId == player || o.ContainerId == player)`.
|
||||
Then for each slot: find the item whose `(loc & slotMask) != 0` (handles the weapon composite + paired
|
||||
jewelry — a single equip bit intersects exactly one slot mask). If found, `cell.SetItem(guid,
|
||||
iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects))`; else
|
||||
`cell.Clear()` (transparent — nothing drawn).
|
||||
|
||||
`Concerns(o)`: repaint when `o.CurrentlyEquippedLocation != None || o.WielderId == player || o.ContainerId == player`;
|
||||
`OnObjectMoved` also repaints when from/to is the player (an item being wielded/unwielded). Mirror
|
||||
`InventoryController`'s debounce.
|
||||
|
||||
### 6d. `IItemListDragHandler`
|
||||
|
||||
```csharp
|
||||
void OnDragLift(...) { } // no-op — item stays until the server confirms (same as inventory)
|
||||
|
||||
bool OnDragOver(UiItemList list, UiItemSlot cell, ItemDragPayload payload)
|
||||
{
|
||||
var item = _objects.Get(payload.ObjId);
|
||||
if (item is null) return false;
|
||||
return (item.ValidLocations & MaskFor(list)) != EquipMask.None;
|
||||
}
|
||||
|
||||
void HandleDropRelease(UiItemList list, UiItemSlot cell, ItemDragPayload payload)
|
||||
{
|
||||
var item = _objects.Get(payload.ObjId);
|
||||
if (item is null) return;
|
||||
EquipMask wieldMask = item.ValidLocations & MaskFor(list); // resolves the specific weapon/finger bit
|
||||
if (wieldMask == EquipMask.None) return;
|
||||
_objects.WieldItemOptimistic(payload.ObjId, _playerGuid(), wieldMask);
|
||||
_sendWield?.Invoke(payload.ObjId, (uint)wieldMask);
|
||||
}
|
||||
```
|
||||
|
||||
`wieldMask = item.ValidLocations & slotMask` is the uniform rule: a head item on the head slot → `0x1`; a
|
||||
sword on the weapon slot → `MeleeWeapon`; a ring valid in both fingers dropped on the left-finger slot →
|
||||
`FingerWearLeft`. (Holtburger's `resolve_and_clear_slots` intent.)
|
||||
|
||||
---
|
||||
|
||||
## 7. Wire it up (App — `GameWindow.cs`)
|
||||
|
||||
At the inventory bind site (`:2231`, right after `InventoryController.Bind`), add:
|
||||
|
||||
```csharp
|
||||
_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));
|
||||
```
|
||||
|
||||
(`invLayout` already contains the paperdoll subtree via the sub-window mount.)
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing
|
||||
|
||||
- **Probe (plan task 1 — de-risk):** extend `InventoryFrameImportProbe` to assert every equip-slot id
|
||||
(`0x100005AB`, `0x100001E1`, `0x100001DF`, …) resolves to a `UiItemList` in the imported tree. High
|
||||
confidence (the contents grid proves the same `0x2100003D` base path), but load-bearing — if it fails,
|
||||
fix the importer/factory before continuing.
|
||||
- **Core — `EquipMaskTests`:** numeric pin of every member vs `acclient.h:3193`.
|
||||
- **Core — `ClientObjectTableTests`:** `WieldItemOptimistic` sets equip + WielderId + container; `RollbackMove`
|
||||
restores the pre-wield equip mask (the new snapshot field); outstanding-count across wield+move of the same item.
|
||||
- **Core.Net — `GameEventWiringTests`:** `WieldObject 0x0023` now calls `ConfirmMove` (an optimistic wield's
|
||||
snapshot clears on the echo).
|
||||
- **App — `PaperdollControllerTests`:** element-id→mask map matches the dump; populate shows the equipped item
|
||||
in the right slot (incl. weapon composite + paired finger); `OnDragOver` gates on `ValidLocations`;
|
||||
`HandleDropRelease` computes the correct `wieldMask`, optimistically equips, and sends `SendGetAndWieldItem`.
|
||||
Reuse the slot-inside-a-Draggable-frame topology from the B-Drag tests.
|
||||
- Full suite green at the phase boundary (not just filtered subsets — the B-Wire process lesson).
|
||||
|
||||
---
|
||||
|
||||
## 9. Divergence register rows (add in the implementing commit)
|
||||
|
||||
- **AP-xx:** `0x100001E0` MissileAmmo `0x800000` mask is LIKELY (corrupted decomp immediate, deep-dive §7) — gate-verify.
|
||||
- **AP-xx:** dual-wield-into-shield-slot special (deep-dive §3b line 174302) not implemented; a melee weapon
|
||||
cannot be dropped on the Shield slot in acdream.
|
||||
- **AP-xx:** wield-reject rollback assumes ACE emits `InventoryServerSaveFailed 0x00A0` for `GetAndWieldItem`
|
||||
rejections; if it does not, an optimistic wield is corrected only by the next authoritative message — gate-verify via WireMCP.
|
||||
|
||||
(The `EquipMask` correction removes a latent bug rather than adding a deviation — it's locked by the
|
||||
numeric-pin test, no register row.)
|
||||
|
||||
---
|
||||
|
||||
## 10. Risks / gate-verify
|
||||
|
||||
1. **Equip slots resolve to `UiItemList`** — mitigated by probe task 1.
|
||||
2. **ACE wield-reject message** — gate-verify (§9); graceful fallback (next authoritative correction).
|
||||
3. **`0x100001E0` MissileAmmo mask** — gate-verify; ammo slot is low-priority for MVP.
|
||||
|
||||
---
|
||||
|
||||
## 11. Acceptance criteria
|
||||
|
||||
- F12 inventory: 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 (icon appears, instant), survives the
|
||||
server echo, and a wrong-slot/invalid drop is rejected (red) or rolled back.
|
||||
- Drag an equipped item from a slot to the pack → it unwields (via the existing inventory grid handler).
|
||||
- `dotnet build` + full `dotnet test` green. Visual gate by the user. Divergence rows added.
|
||||
Loading…
Add table
Add a link
Reference in a new issue