diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 195f7046..c8e37e75 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -146,11 +146,11 @@ accepted-divergence entries (#96, #49, #50). | AP-42 | `UiMenu` item model is flat (label + opaque payload, single-level popup); retail `UIElement_Menu::MakePopup @0x46d310` supports hierarchical nested submenus via recursive popup chain | `src/AcDream.App/UI/UiMenu.cs` | The chat talk-focus menu is single-level (14 rows, 2 columns, no submenu); hierarchy is latent and unreachable through the chat window — no behavioral difference in the current usage | A future menu with nested submenus would render flat (only the top-level items drawn, no drill-down) | `UIElement_Menu::MakePopup` @0x46d310 | | AP-45 | `PublicUpdatePropertyInt (0x02CE)` sequence byte parsed-past but not honored; last update wins (no freshness check against sequence number) | `src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs` | Loopback ACE rarely reorders; latest-wins matches `PrivateUpdateVital`/`UpdatePosition`'s existing non-sequence behavior. Sequence tracking added when needed alongside TS-26. | A reordered 0x02CE on a real network could apply a stale UiEffects value — item icon temporarily shows the wrong effect state, corrected on next update | `PublicUpdatePropertyInt` sequence byte (ACE GameMessagePublicUpdatePropertyInt) | | AP-46 | Health-meter gate approximation: retail shows the health meter for `IsPlayer() || pet_owner || ClientCombatSystem::ObjectIsAttackable()` (full PK/faction logic); acdream's `GameWindow.IsHealthBarTarget` uses the server PWD bits `BF_ATTACKABLE (0x10)` OR `BF_PLAYER (0x8)` | `src/AcDream.App/Rendering/GameWindow.cs` (`IsHealthBarTarget`) → `SelectedObjectController` | The PWD `BF_ATTACKABLE`/`BF_PLAYER` bits distinguish monsters + players (bar) from friendly/vendor NPCs (name-only) for the M1.5 dev loop; the pet case and the full ObjectIsAttackable PK/faction refinement (free-PK, PK-vs-PK, PKLite) are not ported | A PK/faction edge (e.g. a hostile-flagged player whose `BF_ATTACKABLE` is unset, or a pet) could show/hide the bar where retail differs — no impact on the non-PK PvE dev loop | `ClientCombatSystem::ObjectIsAttackable` acclient_2013_pseudo_c.txt:375385; `BF_ATTACKABLE` acclient.h:6437 | -| AP-47 | Cursor drag ghost reuses the full composited `m_pIcon` at reduced alpha (`GhostAlpha=0.6`) instead of retail's dedicated `m_pDragIcon` (base + custom-overlay, NO type-default underlay) | `src/AcDream.App/UI/UiRoot.cs` (`DrawDragGhost`/`GhostAlpha`) → `src/AcDream.App/UI/UiItemSlot.cs` (`GetDragGhost`) | Cosmetic only — the dragged item is still unambiguously identifiable; building the second underlay-less composite is deferred polish; the ghost is item-agnostic in UiRoot via `GetDragGhost()` | The ghost shows the opaque type-default underlay backing rather than retail's underlay-less translucent copy — a subtle look difference while dragging, no functional effect | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407594-407625 (m_pDragIcon path); deep-dive §3.2/§5.5 | +| AP-47 | Cursor drag ghost reuses the full composited `m_pIcon` (incl. type-default underlay) instead of retail's dedicated `m_pDragIcon` (base + custom-overlay, NO type-default underlay). Opacity now matches retail (full). | `src/AcDream.App/UI/UiRoot.cs` (`DrawDragGhost`/`GhostAlpha`) → `src/AcDream.App/UI/UiItemSlot.cs` (`GetDragGhost`) | Cosmetic only — the dragged item is still unambiguously identifiable; building the second underlay-less composite is deferred polish; the ghost is item-agnostic in UiRoot via `GetDragGhost()` | The ghost carries the opaque type-default underlay backing rather than retail's underlay-less copy — a subtle look difference while dragging, no functional effect. | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407594-407625 (m_pDragIcon path); deep-dive §3.2/§5.5 | --- -## 4. Temporary stopgap (TS) — 32 rows +## 4. Temporary stopgap (TS) — 31 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -186,7 +186,6 @@ accepted-divergence entries (#96, #49, #50). | TS-30 | Numbered chat tabs (element ids `0x10000522`–`0x10000525`) render as clickable buttons but do not switch channel filter or affect the transcript — tab state is a no-op | `src/AcDream.App/UI/Layout/ChatWindowController.cs:210` | Retail's tab switching routes transcript lines by chat channel (`gmMainChatUI::gmScrollWindow` sub-windows per tab); the tab wiring is D.5 scope | Tab clicks produce no visible transcript change; retail would filter to the selected channel — all chat always shows in all tabs | `gmMainChatUI::PostInit` tab setup @0x4ce2a0; holtburger chat tab handling | | TS-31 | Squelch toggle absent (no `/squelch` slash command, no clickable name-tags to silence); retail's squelch list filters incoming chat lines | `src/AcDream.Core/Chat/ChatLog.cs` | Squelch is a social / moderation feature deferred to post-M1.5; the data structure (`ChatLog`) has no squelch set today | Any player can spam all clients; clickable-name-tag contextual menu (used in retail to squelch, tell, add-to-friends) is absent | `ChatFilter::IsSquelched`; retail right-click player name → Squelch menu | | TS-32 | `ClientObjectTable` has no pre-queue for a child `CreateObject` that arrives before its parent (out-of-order PARENTED create); such objects are ingested as root objects and their `ContainerId` links a not-yet-known container. Retail's `null_object_table` + `null_weenie_object_table` hold unresolvable objects until the parent arrives | `src/AcDream.Core/Items/ClientObjectTable.cs` (`Ingest`) | PD↔`CreateObject` ordering is handled (upsert semantics); out-of-order PARENTED creates are observed only at high packet loss or in vendor/corpse multi-object bursts on non-loopback links; deferred to D.5.5+ | A container's child object arriving before the container is ingested as a root item — it won't appear in `GetContents` until the next `RecordMembership` or a move event corrects the parent link | `CObjectMaint::null_object_table` / `null_weenie_object_table` (acclient.h / named-retail pc) | -| TS-33 | Toolbar `IItemListDragHandler.HandleDropRelease` is a logging stub — no `AddShortcut 0x019C` / `RemoveShortcut 0x019D` wire, no mutable `ShortcutStore`; a drop onto the bar logs and does nothing | `src/AcDream.App/UI/Layout/ToolbarController.cs` (`HandleDropRelease`) | The B.1 spine ships the drag machine (payload/ghost/overlay/dispatch) independent of the wire; the shortcut store + add/remove/reorder sends are Stream B.2, which needs the inventory window as a drag source too | A player dragging onto/within the hotbar sees the ghost + accept overlay but the drop is inert until B.2 — add/remove/reorder don't persist | `gmToolbarUI::HandleDropRelease` acclient_2013_pseudo_c.txt:197971; `AddShortcut`/`RemoveShortcut` 0x019C/0x019D | --- diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 95191d49..197bdd07 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2018,7 +2018,9 @@ public sealed class GameWindow : IDisposable combatState: Combat, peaceDigits: toolbarPeaceDigits, warDigits: toolbarWarDigits, - emptyDigits: toolbarEmptyDigits); + emptyDigits: toolbarEmptyDigits, + sendAddShortcut: (i, g) => _liveSession?.SendAddShortcut(i, g), + sendRemoveShortcut: i => _liveSession?.SendRemoveShortcut(i)); // Phase D.5.3a — selected-object strip (name, overlay state, health meter). // Analogue of retail gmToolbarUI::HandleSelectionChanged diff --git a/src/AcDream.App/UI/Layout/ToolbarController.cs b/src/AcDream.App/UI/Layout/ToolbarController.cs index ef84a558..7143e1b1 100644 --- a/src/AcDream.App/UI/Layout/ToolbarController.cs +++ b/src/AcDream.App/UI/Layout/ToolbarController.cs @@ -60,6 +60,10 @@ public sealed class ToolbarController : IItemListDragHandler private readonly Func> _shortcuts; private readonly Func _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex private readonly Action _useItem; // guid → fire UseObject + private readonly AcDream.Core.Net.Items.ShortcutStore _store = new(); + private bool _storeLoaded; + private readonly Action? _sendAddShortcut; // (index, objectGuid) + private readonly Action? _sendRemoveShortcut; // (index) // Digit sprite DID arrays for slot labels (top row, numbers 1-9). // Read from LayoutDesc 0x21000037, element 0x1000034A under composite 0x10000346. @@ -82,7 +86,9 @@ public sealed class ToolbarController : IItemListDragHandler CombatState? combatState, uint[]? peaceDigits, uint[]? warDigits, - uint[]? emptyDigits) + uint[]? emptyDigits, + Action? sendAddShortcut = null, + Action? sendRemoveShortcut = null) { _repo = repo; _shortcuts = shortcuts; @@ -91,6 +97,8 @@ public sealed class ToolbarController : IItemListDragHandler _peaceDigits = peaceDigits; _warDigits = warDigits; _emptyDigits = emptyDigits; + _sendAddShortcut = sendAddShortcut; + _sendRemoveShortcut = sendRemoveShortcut; for (int i = 0; i < SlotIds.Length; i++) { @@ -104,6 +112,7 @@ public sealed class ToolbarController : IItemListDragHandler list.RegisterDragHandler(this); list.Cell.SlotIndex = i; list.Cell.SourceKind = ItemDragSource.ShortcutBar; + list.Cell.DragAcceptSprite = 0x060011FAu; // green cross (toolbar), not the ring 0x060011F9 (inventory) } } @@ -133,11 +142,20 @@ public sealed class ToolbarController : IItemListDragHandler } /// - /// Returns true if is one of the currently-active shortcut guids. + /// Returns true if is one of the currently-active shortcut guids + /// (i.e., present in the live ). /// Used to gate repo-event subscriptions so we don't re-populate on every creature spawn. + /// Falls back to scanning _shortcuts() before the store is loaded (pre-PD window). /// private bool IsShortcutGuid(uint guid) { + if (_storeLoaded) + { + for (int s = 0; s < AcDream.Core.Net.Items.ShortcutStore.SlotCount; s++) + if (_store.Get(s) == guid) return true; + return false; + } + // Store not yet loaded — fall back to the shortcuts provider (pre-PD window). foreach (var sc in _shortcuts()) if (sc.ObjectGuid == guid) return true; return false; @@ -182,10 +200,13 @@ public sealed class ToolbarController : IItemListDragHandler CombatState? combatState = null, uint[]? peaceDigits = null, uint[]? warDigits = null, - uint[]? emptyDigits = null) + uint[]? emptyDigits = null, + Action? sendAddShortcut = null, + Action? sendRemoveShortcut = null) { var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState, - peaceDigits, warDigits, emptyDigits); + peaceDigits, warDigits, emptyDigits, + sendAddShortcut, sendRemoveShortcut); c.Populate(); return c; } @@ -196,24 +217,27 @@ public sealed class ToolbarController : IItemListDragHandler /// Entries whose item is not yet in the repo are silently skipped here; the /// ObjectAdded event re-fires this method when the item arrives /// (matching retail's SetDelayedShortcutNum deferred-rebind path). + /// As of B.2: the is the + /// authoritative in-session slot map; _shortcuts() seeds it on first call. /// public void Populate() { - // Clear all slot cells first (flush). + // Lazy-load the store from the login shortcut list the first time it's present (PD arrives + // after Bind); thereafter the store is authoritative and drag ops mutate it directly. + if (!_storeLoaded && _shortcuts().Count > 0) { _store.Load(_shortcuts()); _storeLoaded = true; } + foreach (var list in _slots) list?.Cell.Clear(); - foreach (var sc in _shortcuts()) + for (int slot = 0; slot < _slots.Length; slot++) { - if (sc.ObjectGuid == 0) continue; // spell-only shortcut — inventory phase - if (sc.Index >= (uint)_slots.Length) continue; - var list = _slots[(int)sc.Index]; + uint guid = _store.Get(slot); + if (guid == 0) continue; + var list = _slots[slot]; if (list is null) continue; - - var item = _repo.Get(sc.ObjectGuid); - if (item is null) continue; // deferred: ObjectAdded will re-call Populate - + var item = _repo.Get(guid); + if (item is null) continue; // deferred: ObjectAdded re-calls Populate uint tex = _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects); - list.Cell.SetItem(sc.ObjectGuid, tex); + list.Cell.SetItem(guid, tex); } // Re-stamp slot number labels after any item change. @@ -296,13 +320,24 @@ public sealed class ToolbarController : IItemListDragHandler }; } - // ── IItemListDragHandler (B.1 spine) ───────────────────────────────────── + // ── IItemListDragHandler (B.2 live handler) ────────────────────────────── // Retail: gmToolbarUI is the m_dragHandler for every shortcut slot list. - // The accept/reject gate (IsShortcutEligible) and the AddShortcut/RemoveShortcut - // wire are Stream B.2; this stub accepts any real item and LOGS the drop (TS-33). + // Retail model (remove-on-lift / place-on-drop / no-restore): + // lift → RemoveShortcut (0x019D) + store.Remove (slot empties immediately) + // drop → AddShortcut (0x019C) + optional swap of evicted item into source + // off-bar release → the lift's removal stands (no restore) + // Retail ref: gmToolbarUI::HandleDropRelease acclient_2013_pseudo_c.txt:197971 /// - public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { /* Task 3: remove + wire */ } + public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) + { + // Retail RecvNotice_ItemListBeginDrag → RemoveShortcut (0x004bd930/0x004bd450): the lifted + // shortcut leaves the bar (+ wire) the instant the drag starts; it's re-placed only on a + // drop onto a slot. Off-bar release leaves it removed. + _store.Remove(payload.SourceSlot); + _sendRemoveShortcut?.Invoke((uint)payload.SourceSlot); + Populate(); + } /// public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) @@ -311,9 +346,19 @@ public sealed class ToolbarController : IItemListDragHandler /// public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) { - // TS-33: no wire yet. Stream B.2 replaces this with the ShortcutStore mutation + - // AddShortcut 0x019C / RemoveShortcut 0x019D sends (gmToolbarUI::HandleDropRelease :197971). - Console.WriteLine($"[B.2 TODO] drop obj 0x{payload.ObjId:X8} from {payload.SourceKind} " + - $"slot {payload.SourceSlot} → toolbar slot {targetCell.SlotIndex}"); + // Retail gmToolbarUI::HandleDropRelease (0x004be7c0) within-bar reorder branch (deep-dive §5): + // evict the target's occupant, place the dragged item there, and bump the evicted item into + // the (now-vacated) source slot if it's free. + int target = targetCell.SlotIndex; + uint evicted = _store.Get(target); // RemoveShortcutInSlotNum(target) + if (evicted != 0) { _store.Remove(target); _sendRemoveShortcut?.Invoke((uint)target); } + _store.Set(target, payload.ObjId); // AddShortcut(dragged, target) + _sendAddShortcut?.Invoke((uint)target, payload.ObjId); + if (evicted != 0 && evicted != payload.ObjId && _store.IsEmpty(payload.SourceSlot)) + { // displaced → vacated source slot + _store.Set(payload.SourceSlot, evicted); + _sendAddShortcut?.Invoke((uint)payload.SourceSlot, evicted); + } + Populate(); } } diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs index 7401eb93..c3e84dd4 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs @@ -480,21 +480,116 @@ public class ToolbarControllerTests Assert.False(ctrl.OnDragOver(list, list.Cell, payload)); } - /// HandleDropRelease is a logging stub (TS-33): it must not throw and must not - /// mutate the target slot (no wire / no store yet). + // ── B.2: live drag handler (store + reorder/remove + wire) ─────────────── + private static (System.Collections.Generic.List<(uint i,uint g)> adds, + System.Collections.Generic.List removes) NewSpies(out System.Action add, out System.Action rem) + { + var adds = new System.Collections.Generic.List<(uint,uint)>(); + var removes = new System.Collections.Generic.List(); + add = (i, g) => adds.Add((i, g)); + rem = i => removes.Add(i); + return (adds, removes); + } + [Fact] - public void HandleDropRelease_isInertStub() + public void OnDragLift_removesSourceSlot_sendsRemove_emptiesCell() { var (layout, slots, _) = FakeToolbar(); - var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(), - () => Array.Empty(), - iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); + var repo = new ClientObjectTable(); + repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); + var shortcuts = new System.Collections.Generic.List + { new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) }; + var (adds, removes) = NewSpies(out var add, out var rem); - var list = slots[Row1[2]]; - list.Cell.SetItem(0x5001u, 0x77u); // populate so "unchanged" is a meaningful assertion - var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 0, new UiItemSlot()); - var ex = Record.Exception(() => ctrl.HandleDropRelease(list, list.Cell, payload)); - Assert.Null(ex); - Assert.Equal(0x5001u, list.Cell.ItemId); // unchanged — inert stub did not clear/mutate + var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, + iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }, + sendAddShortcut: add, sendRemoveShortcut: rem); + Assert.Equal(0x5001u, slots[Row1[3]].Cell.ItemId); + + var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell); + ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload); + + Assert.Contains(3u, removes); + Assert.Equal(0u, slots[Row1[3]].Cell.ItemId); + } + + [Fact] + public void HandleDropRelease_ontoOccupied_swaps_andSendsRetailSequence() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); // A + repo.AddOrUpdate(new ClientObject { ObjectId = 0x5002u, WeenieClassId = 1u, IconId = 0x06005678u }); // B + var shortcuts = new System.Collections.Generic.List + { new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0), + new(Index: 5, ObjectGuid: 0x5002u, SpellId: 0, Layer: 0) }; + var (adds, removes) = NewSpies(out var add, out var rem); + var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, + iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }, + sendAddShortcut: add, sendRemoveShortcut: rem); + + var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell); + ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload); + ctrl.HandleDropRelease(slots[Row1[5]], slots[Row1[5]].Cell, payload); + + Assert.Equal(0x5001u, slots[Row1[5]].Cell.ItemId); + Assert.Equal(0x5002u, slots[Row1[3]].Cell.ItemId); + Assert.Equal(new[] { 3u, 5u }, removes.ToArray()); + Assert.Contains((5u, 0x5001u), adds); + Assert.Contains((3u, 0x5002u), adds); + } + + [Fact] + public void HandleDropRelease_ontoEmpty_placesOnly_sourceStaysEmpty() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); + var shortcuts = new System.Collections.Generic.List + { new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) }; + var (adds, removes) = NewSpies(out var add, out var rem); + var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, + iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }, + sendAddShortcut: add, sendRemoveShortcut: rem); + + var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell); + ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload); + ctrl.HandleDropRelease(slots[Row1[7]], slots[Row1[7]].Cell, payload); + + Assert.Equal(0x5001u, slots[Row1[7]].Cell.ItemId); + Assert.Equal(0u, slots[Row1[3]].Cell.ItemId); + Assert.Contains((7u, 0x5001u), adds); + Assert.DoesNotContain(adds, a => a.i == 3u); + } + + [Fact] + public void HandleDropRelease_ontoSelf_reAddsToSource() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); + var shortcuts = new System.Collections.Generic.List + { new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) }; + var (adds, removes) = NewSpies(out var add, out var rem); + var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, + iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }, + sendAddShortcut: add, sendRemoveShortcut: rem); + + var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell); + ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload); // slot 3 emptied + ctrl.HandleDropRelease(slots[Row1[3]], slots[Row1[3]].Cell, payload); // drop back on slot 3 + + Assert.Equal(0x5001u, slots[Row1[3]].Cell.ItemId); // re-added to source (net no-op) + Assert.Contains((3u, 0x5001u), adds); // AddShortcut(3, A) sent + } + + [Fact] + public void ToolbarSlots_useGreenCrossAcceptSprite() + { + var (layout, slots, _) = FakeToolbar(); + ToolbarController.Bind(layout, new ClientObjectTable(), + () => System.Array.Empty(), + iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); + Assert.Equal(0x060011FAu, slots[Row1[0]].Cell.DragAcceptSprite); // green cross, not the ring F9 } }