diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 34c14a31..195f7046 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -150,7 +150,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 4. Temporary stopgap (TS) — 31 rows +## 4. Temporary stopgap (TS) — 32 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -186,6 +186,7 @@ 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/UI/Layout/ToolbarController.cs b/src/AcDream.App/UI/Layout/ToolbarController.cs index 1279328a..9cb43471 100644 --- a/src/AcDream.App/UI/Layout/ToolbarController.cs +++ b/src/AcDream.App/UI/Layout/ToolbarController.cs @@ -23,7 +23,7 @@ namespace AcDream.App.UI.Layout; /// CreateObject resolves a formerly-unknown guid. /// /// -public sealed class ToolbarController +public sealed class ToolbarController : IItemListDragHandler { // Slot element ids in slot-index order (toolbar LayoutDesc 0x21000016, pre-dump). // Row 1 = slots 0-8 (0x100001A7..0x100001AF), Row 2 = slots 9-17 (0x100006B7..0x100006BF). @@ -96,7 +96,15 @@ public sealed class ToolbarController { _slots[i] = layout.FindElement(SlotIds[i]) as UiItemList; if (_slots[i] is { } list) + { WireClick(list); + // B.1 drag-drop spine: this controller is the drop handler for every + // toolbar slot list; each cell knows its slot index + that it's a + // shortcut-bar source (retail UIElement_ItemList::RegisterItemListDragHandler). + list.RegisterDragHandler(this); + list.Cell.SlotIndex = i; + list.Cell.SourceKind = ItemDragSource.ShortcutBar; + } } // Cache the four mutually-exclusive combat-mode indicator elements. @@ -287,4 +295,22 @@ public sealed class ToolbarController _useItem(list.Cell.ItemId); }; } + + // ── IItemListDragHandler (B.1 spine) ───────────────────────────────────── + // 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). + + /// + public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) + => payload.ObjId != 0; + + /// + 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}"); + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs index b4204f4d..6a599ecd 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs @@ -421,4 +421,61 @@ public class ToolbarControllerTests Assert.Equal(callsAfterBind, iconCallCount); // unchanged } + + // ── B.1: drag-drop spine wiring ────────────────────────────────────────── + + /// Bind registers the controller as each slot list's drag handler and + /// stamps every cell's SlotIndex + SourceKind=ShortcutBar. + [Fact] + public void Bind_registersDragHandler_andStampsSlots() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + + var ctrl = ToolbarController.Bind(layout, repo, + () => Array.Empty(), + iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); + + for (int i = 0; i < Row1.Length; i++) + { + Assert.Same(ctrl, slots[Row1[i]].DragHandler); + Assert.Equal(i, slots[Row1[i]].Cell.SlotIndex); + Assert.Equal(ItemDragSource.ShortcutBar, slots[Row1[i]].Cell.SourceKind); + } + // Bottom row slots are indices 9..17. + for (int j = 0; j < Row2.Length; j++) + Assert.Equal(9 + j, slots[Row2[j]].Cell.SlotIndex); + } + + /// OnDragOver accepts a real item (ObjId != 0). Eligibility (IsShortcutEligible) + /// is Stream B.2; the stub accepts any non-empty payload. + [Fact] + public void OnDragOver_acceptsRealItem() + { + var (layout, slots, _) = FakeToolbar(); + var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(), + () => Array.Empty(), + iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); + + var list = slots[Row1[0]]; + var payload = new ItemDragPayload(0x5001u, ItemDragSource.Inventory, 0, new UiItemSlot()); + Assert.True(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). + [Fact] + public void HandleDropRelease_isInertStub() + { + var (layout, slots, _) = FakeToolbar(); + var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(), + () => Array.Empty(), + iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); + + var list = slots[Row1[2]]; + 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(0u, list.Cell.ItemId); // unchanged — inert stub + } }