From b5b230c8604a0e8b202e2cc1e97391120b72eb98 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 11 Jul 2026 09:17:33 +0200 Subject: [PATCH] feat(ui): preserve exact retail shortcut records Carry signed index, object id, and raw spell word losslessly through PlayerDescription, session storage, drag mutation, and AddShortcut wire serialization while keeping gmToolbarUI object-only. Retire AP-103 and record the live ACE relog persistence gate. Co-Authored-By: Codex --- .../retail-divergence-register.md | 1 - docs/plans/2026-04-11-roadmap.md | 3 +- ...2026-06-16-action-bar-toolbar-deep-dive.md | 23 ++- ...026-07-10-retail-ui-fidelity-completion.md | 15 +- src/AcDream.App/Rendering/GameWindow.cs | 6 +- src/AcDream.App/Studio/FixtureProvider.cs | 3 +- src/AcDream.App/UI/ItemDragPayload.cs | 5 +- .../UI/Layout/ToolbarController.cs | 44 +++--- src/AcDream.App/UI/RetailUiRuntime.cs | 4 +- src/AcDream.App/UI/UiItemSlot.cs | 14 +- src/AcDream.Core.Net/GameEventWiring.cs | 2 +- .../Messages/InventoryActions.cs | 19 +-- .../Messages/PlayerDescriptionParser.cs | 21 +-- src/AcDream.Core.Net/WorldSession.cs | 8 +- src/AcDream.Core/Items/ShortcutEntry.cs | 14 ++ src/AcDream.Core/Items/ShortcutStore.cs | 58 +++++--- .../UI/Layout/ToolbarControllerTests.cs | 138 +++++++++++------- .../GameEventWiringTests.cs | 4 +- .../Messages/InventoryActionsTests.cs | 21 +-- .../PlayerDescriptionParserTests.cs | 28 ++-- .../Items/ShortcutStoreTests.cs | 21 ++- 21 files changed, 271 insertions(+), 181 deletions(-) create mode 100644 src/AcDream.Core/Items/ShortcutEntry.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index e4559047..c316f062 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -206,7 +206,6 @@ AP-94..AP-110 for the confirmed retail-UI completion gaps. | ~~AP-99~~ | **RETIRED 2026-07-11 (Wave 3.2)** — Core `ItemInteractionPolicy` ports the complete ordered `DetermineUseResult`/`UseObject`/`AttemptPlaceIn3D` matrix; PWD flags and `CombatUse` survive CreateObject; component-pack membership comes from portal.dat; App owns throttle, UseDone-balanced busy state, target mode, wire/optimistic dispatch, and typed confirmation/auxiliary seams. The two corrupt decompiler operands were pinned from matching x86 as `BF_REQUIRES_PACKSLOT` and `BF_VENDOR`. | `src/AcDream.Core/Items/ItemInteractionPolicy.cs`; `src/AcDream.App/UI/ItemInteractionController.cs` | — | — | `ItemHolder::DetermineUseResult @ 0x00588460`; `UseObject @ 0x00588A80`; `AttemptPlaceIn3D @ 0x00588600` | | AP-101 | Most toolbar panel/Use/Examine controls are not fully dispatched; selected-object mana and stack entry/slider are absent | `src/AcDream.App/UI/Layout/ToolbarController.cs`; `GameWindow.OnInputAction` | Item click/use, health/name, quick-slot use/select/create actions, some panel toggles, and all four combat indicator buttons work; combat clicks and the bound key share `ToggleLiveCombatMode` | Remaining bound retail buttons do nothing and item/stack selections show incomplete state | `gmToolbarUI @ 0x004BD0C0..0x004BF380`; LayoutDesc `0x21000016` | | AP-102 | Inventory-to-occupied toolbar drop treats the inventory source index as though it were a vacated toolbar slot for the displaced shortcut | `src/AcDream.App/UI/Layout/ToolbarController.cs` drop planner | Normal empty-slot and toolbar-reorder paths work; Wave 4 makes the transaction source-aware | The displaced shortcut is lost or moved to an unrelated slot | `gmToolbarUI::HandleDropRelease @ 0x004BE7C0` | -| AP-103 | Shortcut session storage keeps only object guid and discards raw non-object fields; parser/builder names the final uint as `u16 spellId/u16 layer` instead of preserving retail `spellID_` as raw data | `src/AcDream.Core/Items/ShortcutStore.cs`; `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs`; `InventoryActions.cs` | `gmToolbarUI` itself displays only nonzero object ids, so current item shortcuts work | Round-trip mutation corrupts unknown/raw fields and encourages invented spell support in the item toolbar | `ShortCutData` in `acclient.h:36484`; `gmToolbarUI::UpdateFromPlayerDesc @ 0x004BF810` | | AP-104 | Vitals detail element `0x100004A9` and root `HideDetail`/`ShowDetail` transitions are not wired | `src/AcDream.App/UI/Layout/VitalsController.cs` | Compact vitals values/bars are correct | Detail click does nothing and expanded retail state is unreachable | `gmVitalsUI::ListenToElementMessage @ 0x004BFC00`; `PostInit @ 0x004BFCE0` | | AP-105 | **PARTIAL 2026-07-10 (Wave 2.2)** — inherited scrollbar media/roles now come from DAT (decrement/top `0x06004C69`, increment/bottom `0x06004C6C`). Retained chat still lacks complete tab/filter/unread, social availability, squelch, and focus-opacity behavior; a second ChatVM still loses FPS/position providers. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`; `ChatWindowController.cs`; chat mount in `GameWindow.cs` | Shared log/send path, wrapping, scrollbar roles, and outer maximize geometry work; Wave 5 consolidates remaining chat state | Commands degrade, tabs are no-ops, moderation/channel state is wrong, and focus visuals diverge | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; `UIElement_Scrollbar::OnSetAttribute @ 0x004714D0`; `ChatInterface` methods | | AP-106 | The retained UI has no external/ground-container window lifecycle, while the original owned-side-bag `NoLongerViewingContents` premise was incorrect (#196) | `src/AcDream.App/UI`; window runtime; absent external-container controller | Owned inventory navigation remains usable and must not gain an unproven `0x0195` send; Wave 2/6 adds lifecycle ownership and the separate external surface | External container replacement/close cannot notify the server exactly once; adding the packet to owned bag close would itself diverge from the named retail call graph | `ClientUISystem.groundObject`; `CM_Inventory::Event_NoLongerViewingContents @ 0x006ABC50`; `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` | diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 86435775..f8726b0d 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -486,11 +486,12 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. - **✓ SHIPPED — Importer dat-fidelity** (Fix A/B/C, 2026-06-26, same branch). **Boundary established: look = importer (font/justification/color from dat); state/behavior = runtime controller logic.** Fix A: justification property `0x14`/`0x15` → `UiText` Centered/RightAligned/VerticalJustify. Fix B: FontColor property `0x1B` → `UiText.DefaultColor`; character colors proved RUNTIME (no importer fix). Fix C: FontDid → per-element dat font via a resolver in `DatWidgetFactory` — **STUDIO path only**; GameWindow passes `null` (Issue #157). Fix 4 (UIState-group activation): INVESTIGATED — no importer fix exists; the dat has no Visible encoding; runtime `gm*UI` is correct. - **✓ SHIPPED — Wave 3.3 unified primary-click router.** `ItemInteractionController.OfferPrimaryClick` returns `NotActive` / `ConsumedSuccess` / `ConsumedRejected`; inventory, bags, main pack, paperdoll, toolbar, radar, and world clicks all offer target mode before local fallback. Rejected targets cannot drift selection or open/use the clicked object. Issue #197 and AP-107 retired; retained matrix and live ACE-provisioned healing gate user-confirmed. The 3-D doll routes through authored hit mask `0x100001D6` (resolved `UiButton` above viewport `0x100001D5`), and local health prefers authoritative private-vital state. - **✓ SHIPPED — Wave 4.1 retail quick-slot input.** Bare `1..9` use toolbar slots 0..8, Ctrl+`1..9` selects them, Alt+`5..9` uses slots 13..17, and `0` creates a shortcut to the selected owned/eligible object in retail's first empty slot. `ToolbarInputController` is the focused semantic adapter; `ToolbarController` owns `UseShortcut` target-before-use/select ordering and auto-slot eligibility. Keybinding schema v2 migrates only the exact old default Ctrl+number chords, so custom bindings retain their chosen action. Full automated conformance and live user gate passed 2026-07-11. +- **✓ SHIPPED — Wave 4.2 exact shortcut records.** One Core-owned `ShortcutEntry` preserves retail's signed `index_`, unsigned `objectID_`, and raw 32-bit `spellID_` through PlayerDescription parse, the nullable 18-slot session store, drag snapshots/reindexing, WorldSession, and exact 12-byte AddShortcut packing. The visible `gmToolbarUI` remains object-only; `favorite_spells_[8]` remains a separate future spell-bar source. AP-103 retired. Full automated conformance and live mutate→clean-relog persistence gate passed 2026-07-11. - **✓ SHIPPED — Wave 4.3a shortcut-alias boundary.** Drag feedback now preserves retail's neutral/accept/reject distinction. Inventory and paperdoll apply the `(DropItemFlags & 0xE) == 0` physical-item gate, so a toolbar alias can reorder within the toolbar but can never move, unwield, wield, or drop the object it references. Live equipped-helmet gate passed 2026-07-11: inventory drop removed only the shortcut. - **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158. - **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository`→`ClientObjectTable` / `ItemInstance`→`ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green. - **Roadmap correction (2026-07-10):** the completion order is now the architecture-first campaign in `docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md`. Retail `gmToolbarUI` is object-only: preserve `ShortCutData.index_`, `objectID_`, and `spellID_`, but do not invent spell glyphs on this bar. `PlayerModule::favorite_spells_[8]` feeds separate spell bars. -- **☐ D.5.3 — Toolbar selected-object display (issue #141) + exact shortcut preservation.** Wire unified selection to the hidden mana meter (`0x100001A2`), stack entry (`0x100001A3`), stack slider (`0x100001A4`), and complete health/name behavior. Preserve all raw `ShortCutData` fields while keeping `gmToolbarUI` object-only, matching `UpdateFromPlayerDesc @ 0x004BF810`. The eight `favorite_spells_` lists belong to the separate spellbook/spell-bar phase. (Click-to-use + peace/war + health/name already landed.) +- **☐ D.5.3 — Toolbar selected-object display (issue #141).** Wire unified selection to the hidden mana meter (`0x100001A2`), stack entry (`0x100001A3`), stack slider (`0x100001A4`), and complete health/name behavior. Exact raw `ShortCutData` preservation is already shipped in Wave 4.2; the eight `favorite_spells_` lists belong to the separate spellbook/spell-bar phase. (Click-to-use + peace/war + health/name already landed.) - **☐ D.5.5+ — Core panels.** Inventory (`gmInventoryUI`/`gmBackpackUI`), equipment/paperdoll (`gmPaperDollUI`/`gm3DItemsUI` + the `UiViewport` 3D doll), vendor, trade, spellbook. Research drop done (`docs/research/2026-06-16-*`). Depends on **D.5.4** (data model) + the item-slot/list/icon spine (D.5.1/D.5.2) + the **window manager** (Plan 2: open/close/z-order/persist + faithful grip/dragbar drag-resize) + the drag-drop spine wired (`UiRoot` has the chain; the per-cell accept/drop hooks are still stubs in `UiField`). Also deferred from D.5.1: drag/reorder + the `AddShortcut`/`RemoveShortcut` mutate wire. - **D.6 — HUD.** **Radar/compass IMPLEMENTED 2026-07-10; live-world visual gate pending.** It is the retained retail `gmRadarUI` (`LayoutDesc 0x21000074`): a 120×140 circular radar (`0x06004CC1`) with N/E/S/W token sprites orbiting the face, a fixed bright-green player marker, and the coordinate footer (`0x06004CC0`). Projection is `radarPixelRadius / radarRange` with 75 m outdoor / 25 m indoor ranges — there is no 1.18 factor or scrolling compass strip. It lives in `AcDream.App/UI` beside the other imported retail panels. Remaining D.6: target name plate, damage floaters, and other world-space HUD elements through the raw `TextRenderer` path. See slice 06 §A.2, `docs/research/2026-07-10-retail-radar-pseudocode.md`, and the named `gmRadarUI` decomp. - **✓ SHIPPED — D.7 — Cursor manager (user-confirmed 2026-07-11).** All reachable `ClientUISystem::UpdateCursorState @ 0x00564630` branches resolve through production DAT EnumIDMap table 6, with event-ordered global-default versus captured/hovered `MediaDescCursor` layering from `UIElementManager::CheckCursor @ 0x0045ABF0`. OS fallback remains only AP-72 and emits a visible diagnostic. The live gate covered world/object, combat, targeted-use, text, move/resize, and drag contexts; combat-indicator mouse clicks and the quick key share the same toggle command. diff --git a/docs/research/2026-06-16-action-bar-toolbar-deep-dive.md b/docs/research/2026-06-16-action-bar-toolbar-deep-dive.md index de3e30de..49f74694 100644 --- a/docs/research/2026-06-16-action-bar-toolbar-deep-dive.md +++ b/docs/research/2026-06-16-action-bar-toolbar-deep-dive.md @@ -128,19 +128,26 @@ Client-side restore: `gmToolbarUI::UpdateFromPlayerDesc` (decomp 198838) → `Fl Opcode values triple-confirmed: decomp `Event_AddShortCut` packs `*(uint32_t*)var_c = 0x19c` (decomp 679733) and `Event_RemoveShortCut` packs `0x19d` (decomp 680332); ACE `GameActionType.cs:77-78` (`AddShortCut=0x019C, RemoveShortCut=0x019D`); holtburger `opcodes.rs:371-374` (commented, same values). -**Wire field order — `ShortCutData` payload (16 bytes), CONFIRMED across 3 refs:** +**Correction (2026-07-11) — packed `ShortCutData` is 12 bytes, while the C++ +object is 16 bytes only because it includes a vtable pointer.** Retail's header, +constructor, `ShortCutManager::AddShortCut`, and vtable-routed packer establish +three native 32-bit fields: ``` -Index : u32 (slot 0..17) +Index : i32 (slot 0..17 when valid) ObjectId : u32 (item guid; 0 for spell) -SpellId : u16 (LayeredSpell.id; 0 for item) -Layer : u16 (LayeredSpell.layer; 0 for item) +SpellId : u32 (raw retail spell word; 0 for toolbar-created items) ``` -- Chorizite `ShortCutData.generated.cs:41-46` (`Index`, `ObjectId`, then `LayeredSpellId.Read` = u16 id + u16 layer). -- ACE `Shortcut.cs:33-42` `ReadShortcut` (`Index`, `ObjectId`, `ReadLayeredSpell`). -- holtburger `shortcuts.rs:13-34` (`index u32`, `object_id Guid`, `spell_id u16`, `layer u16`). +- Retail `ShortCutData` in `acclient.h:36484`, constructor `0x005D55E0`, + manager copy `0x005D5790`, and 12-byte packer `0x005CABB0` are authoritative. +- Chorizite, ACE, and holtburger split the final four bytes into a layered-spell + `u16/u16` view. That view is wire-compatible but is not retail's field model; + acdream preserves the complete raw `uint` losslessly. RemoveShortCut payload = just `Index:u32` (Chorizite `Character_RemoveShortCut.generated.cs:33`; ACE `GameActionRemoveShortcut.cs:9`; decomp packs `*(uint32_t*)eax_3 = arg1` at 680335). -**⚠ acdream builder field-naming bug to fix at port time (not a wire bug).** `InventoryActions.BuildAddShortcut(seq, slotIndex, objectType, targetId)` (`InventoryActions.cs:99-110`) writes 24 bytes = 8-byte envelope (`0xF7B1` + seq) + `slotIndex`(u32) + `objectType`(u32) + `targetId`(u32). The **byte layout is correct for item shortcuts** (slot, then guid, then a final dword that for items is `0` = SpellId|Layer), but the parameter names are wrong/misleading: the 2nd field is `Index`, the 3rd is `ObjectId`, and the 4th dword is `SpellId(u16)|Layer(u16)` — there is no separate "objectType". A faithful builder should take `(seq, uint index, uint objectGuid, ushort spellId, ushort layer)` and pack the spell as two u16s. For the toolbar's item-only use, callers must pass `objectGuid` as the 3rd arg and `0` as the 4th. LIKELY a latent bug if anyone wired a "objectType" semantic; flag in the divergence register when the toolbar lands. (CONFIRMED file contents; the "bug" judgment is mine.) +**Resolved 2026-07-11:** `AcDream.Core.Items.ShortcutEntry` now owns the exact +signed-index/object-id/raw-spell-word value. Parser, store, toolbar mutation, and +`InventoryActions.BuildAddShortcut` all carry that value without a layered-spell +reinterpretation. The production toolbar still renders only nonzero object ids. **ACE's reorder note (important UX contract):** *"When a shortcut is added on top of an existing item, the client automatically sends the RemoveShortcut command for that existing item first, then will add the new item, and re-add the existing item to the appropriate place."* (`Player_Character.cs:254`). This is exactly the `HandleDropRelease` sequence in §5. CONFIRMED. diff --git a/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md b/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md index eb16dd09..8dc44e69 100644 --- a/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md +++ b/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md @@ -450,16 +450,25 @@ skipped. ### 4.2 Preserve the exact shortcut model; keep the toolbar object-only -- [ ] Model the 18 raw `ShortCutData` entries exactly as signed `index_`, unsigned +- [x] Model the 18 raw `ShortCutData` entries exactly as signed `index_`, unsigned `objectID_`, and unsigned `spellID_`; verify packing against the retail packer and protocol captures before changing the existing builder. -- [ ] Preserve all three fields through parse, session storage, mutation, and wire +- [x] Preserve all three fields through parse, session storage, mutation, and wire serialization even though `gmToolbarUI::UpdateFromPlayerDesc @ 0x004BF810` restores only entries with a nonzero `objectID_`. -- [ ] Keep the main toolbar item/object-only. Feed `PlayerModule::favorite_spells_[8]` +- [x] Keep the main toolbar item/object-only. Feed `PlayerModule::favorite_spells_[8]` and the already parsed `HotbarSpells` into the separate spell-bar subsystem in Wave 10. +**Wave 4.2 implementation (2026-07-11):** Core-owned `ShortcutEntry` now models +the exact signed index, object id, and raw 32-bit spell word. PlayerDescription +parsing, the 18-slot nullable store, retained-cell drag snapshots, displaced-item +reindexing, runtime delegates, WorldSession, and AddShortcut serialization carry +the same lossless value. The visible toolbar continues to bind only entries with +nonzero object ids. AP-103 is retired; automated conformance is green and the +live relog/persistence gate passed: ACE restored the mutated shortcut in the +same slot after a clean reconnect. + ### 4.3 Port every drag/drop branch - [ ] Add a pure `ShortcutDropPlanner` keyed by source kind: toolbar reorder, diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 7c4d28b2..15039dd5 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -737,8 +737,8 @@ public sealed class GameWindow : IDisposable public readonly AcDream.Core.Spells.Spellbook SpellBook = null!; public readonly AcDream.Core.Items.ClientObjectTable Objects = new(); /// Persisted hotbar shortcuts from the last PlayerDescription (D.5.1 toolbar source). - public IReadOnlyList Shortcuts { get; private set; } - = System.Array.Empty(); + public IReadOnlyList Shortcuts { get; private set; } + = System.Array.Empty(); // Issue #5 — caches CreatureProfile.{Stamina, Mana, *Max} from // PlayerDescription so the Vitals HUD can render those bars. // Issue #6 — wired to SpellBook so GetMaxApprox folds enchantment @@ -2114,7 +2114,7 @@ public sealed class GameWindow : IDisposable Combat, ToggleLiveCombatMode, _itemInteractionController, - (index, guid) => _liveSession?.SendAddShortcut(index, guid), + entry => _liveSession?.SendAddShortcut(entry), index => _liveSession?.SendRemoveShortcut(index), _selection, handler => Combat.HealthChanged += handler, diff --git a/src/AcDream.App/Studio/FixtureProvider.cs b/src/AcDream.App/Studio/FixtureProvider.cs index 3d8e130f..61c3d79b 100644 --- a/src/AcDream.App/Studio/FixtureProvider.cs +++ b/src/AcDream.App/Studio/FixtureProvider.cs @@ -2,7 +2,6 @@ using AcDream.App.Rendering; using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Core.Items; -using AcDream.Core.Net.Messages; using DatReaderWriter; namespace AcDream.App.Studio; @@ -92,7 +91,7 @@ public static class FixtureProvider ToolbarController.Bind( layout, objects, - shortcuts: () => System.Array.Empty(), + shortcuts: () => System.Array.Empty(), iconIds: MakeIconIds(stack), useItem: _ => { }, combatState: null, diff --git a/src/AcDream.App/UI/ItemDragPayload.cs b/src/AcDream.App/UI/ItemDragPayload.cs index b06728d4..f4f93234 100644 --- a/src/AcDream.App/UI/ItemDragPayload.cs +++ b/src/AcDream.App/UI/ItemDragPayload.cs @@ -1,3 +1,5 @@ +using AcDream.Core.Items; + namespace AcDream.App.UI; /// @@ -21,4 +23,5 @@ public sealed record ItemDragPayload( uint ObjId, // dragged weenie guid (retail itemID, +0x5FC) ItemDragSource SourceKind, // what kind of slot it left int SourceSlot, // the source cell's SlotIndex (retail m_lastShortcutNumDragged) - UiItemSlot SourceCell); // back-ref: reorder-restore / clear source state / ghost + UiItemSlot SourceCell, // back-ref: reorder-restore / clear source state / ghost + ShortcutEntry? Shortcut = null); // lossless raw entry for shortcut-alias mutation diff --git a/src/AcDream.App/UI/Layout/ToolbarController.cs b/src/AcDream.App/UI/Layout/ToolbarController.cs index f22e697d..c9d0fe62 100644 --- a/src/AcDream.App/UI/Layout/ToolbarController.cs +++ b/src/AcDream.App/UI/Layout/ToolbarController.cs @@ -63,12 +63,12 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont private readonly UiButton? _inventoryButton; private readonly ClientObjectTable _repo; private readonly CombatState? _combatState; - private readonly Func> _shortcuts; + 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.Items.ShortcutStore _store = new(); + private readonly ShortcutStore _store = new(); private bool _storeLoaded; - private readonly Action? _sendAddShortcut; // (index, objectGuid) + private readonly Action? _sendAddShortcut; private readonly Action? _sendRemoveShortcut; // (index) private readonly ItemInteractionController? _itemInteraction; private readonly Action? _selectItem; @@ -89,7 +89,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont private ToolbarController( ImportedLayout layout, ClientObjectTable repo, - Func> shortcuts, + Func> shortcuts, Func iconIds, Action useItem, CombatState? combatState, @@ -97,7 +97,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont uint[]? warDigits, uint[]? emptyDigits, ItemInteractionController? itemInteraction = null, - Action? sendAddShortcut = null, + Action? sendAddShortcut = null, Action? sendRemoveShortcut = null, Action? toggleCombat = null, Action? selectItem = null) @@ -189,7 +189,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont } // Store not yet loaded — fall back to the shortcuts provider (pre-PD window). foreach (var sc in _shortcuts()) - if (sc.ObjectGuid == guid) return true; + if (sc.ObjectId == guid) return true; return false; } @@ -226,7 +226,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont public static ToolbarController Bind( ImportedLayout layout, ClientObjectTable repo, - Func> shortcuts, + Func> shortcuts, Func iconIds, Action useItem, CombatState? combatState = null, @@ -234,7 +234,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont uint[]? warDigits = null, uint[]? emptyDigits = null, ItemInteractionController? itemInteraction = null, - Action? sendAddShortcut = null, + Action? sendAddShortcut = null, Action? sendRemoveShortcut = null, Action? toggleCombat = null, Action? selectItem = null) @@ -288,7 +288,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont // after Bind); thereafter the store is authoritative and drag ops mutate it directly. if (!_storeLoaded && _shortcuts().Count > 0) { - _store.Load(System.Linq.Enumerable.Select(_shortcuts(), e => ((int)e.Index, e.ObjectGuid))); + _store.Load(_shortcuts()); _storeLoaded = true; } @@ -296,14 +296,15 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont for (int slot = 0; slot < _slots.Length; slot++) { - uint guid = _store.Get(slot); + ShortcutEntry? entry = _store.GetEntry(slot); + uint guid = entry?.ObjectId ?? 0u; if (guid == 0) continue; var list = _slots[slot]; if (list is null) continue; 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(guid, tex); + list.Cell.SetItem(guid, tex, entry); } // Re-stamp slot number labels after any item change. @@ -466,8 +467,9 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont if (empty < 0) return false; - _store.Set(empty, itemId); - _sendAddShortcut?.Invoke((uint)empty, itemId); + var entry = new ShortcutEntry(empty, itemId, 0u); + _store.Set(entry); + _sendAddShortcut?.Invoke(entry); Populate(); return true; } @@ -476,7 +478,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont { if (_storeLoaded) return; - _store.Load(System.Linq.Enumerable.Select(_shortcuts(), e => ((int)e.Index, e.ObjectGuid))); + _store.Load(_shortcuts()); _storeLoaded = true; } @@ -510,14 +512,18 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont // 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) + ShortcutEntry? evictedEntry = _store.GetEntry(target); // RemoveShortCutInSlotNum(target) + uint evicted = evictedEntry?.ObjectId ?? 0u; if (evicted != 0) { _store.Remove(target); _sendRemoveShortcut?.Invoke((uint)target); } - _store.Set(target, payload.ObjId); // AddShortcut(dragged, target) - _sendAddShortcut?.Invoke((uint)target, payload.ObjId); + ShortcutEntry dragged = (payload.Shortcut ?? new ShortcutEntry(payload.SourceSlot, payload.ObjId, 0u)) + .WithIndex(target); + _store.Set(dragged); // AddShortcut(dragged, target) + _sendAddShortcut?.Invoke(dragged); 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); + ShortcutEntry displaced = evictedEntry!.Value.WithIndex(payload.SourceSlot); + _store.Set(displaced); + _sendAddShortcut?.Invoke(displaced); } Populate(); } diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index b8ebbf52..31881c9b 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -37,13 +37,13 @@ public sealed record RadarRuntimeBindings( public sealed record ToolbarRuntimeBindings( ClientObjectTable Objects, - Func> Shortcuts, + Func> Shortcuts, Func ResolveIcon, Action UseItem, CombatState Combat, Action ToggleCombat, ItemInteractionController ItemInteraction, - Action? SendAddShortcut, + Action? SendAddShortcut, Action? SendRemoveShortcut, SelectionState Selection, Action> SubscribeHealthChanged, diff --git a/src/AcDream.App/UI/UiItemSlot.cs b/src/AcDream.App/UI/UiItemSlot.cs index e655b0c6..c54945d9 100644 --- a/src/AcDream.App/UI/UiItemSlot.cs +++ b/src/AcDream.App/UI/UiItemSlot.cs @@ -1,5 +1,6 @@ using System; using System.Numerics; +using AcDream.Core.Items; namespace AcDream.App.UI; @@ -21,6 +22,12 @@ public sealed class UiItemSlot : UiElement /// Pre-composited icon GL texture for the bound item (0 = none). public uint IconTexture { get; private set; } + /// + /// Lossless shortcut record when this cell belongs to the toolbar. Physical item + /// lists leave it null. Snapshotted into the drag payload before remove-on-lift. + /// + public ShortcutEntry? Shortcut { get; private set; } + /// This cell's own index within its panel (0..17 toolbar; container slot /// for inventory). Distinct from (the 1–9 label, -1 on the /// bottom row). Set by the controller; used as the drag payload's SourceSlot and to @@ -79,17 +86,18 @@ public sealed class UiItemSlot : UiElement /// RenderSurface id -> (GL texture, w, h). Set by the factory/controller. public Func? SpriteResolve { get; set; } - public void SetItem(uint itemId, uint iconTexture) + public void SetItem(uint itemId, uint iconTexture, ShortcutEntry? shortcut = null) { ItemId = itemId; IconTexture = iconTexture; + Shortcut = shortcut; } - public void Clear() { ItemId = 0; IconTexture = 0; } + public void Clear() { ItemId = 0; IconTexture = 0; Shortcut = null; } /// public override object? GetDragPayload() - => ItemId != 0 ? new ItemDragPayload(ItemId, SourceKind, SlotIndex, this) : null; + => ItemId != 0 ? new ItemDragPayload(ItemId, SourceKind, SlotIndex, this, Shortcut) : null; /// public override (uint tex, int w, int h)? GetDragGhost() diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index 962295c4..c1910d33 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -65,7 +65,7 @@ public static class GameEventWiring // D.5.1 Task 4: persists Shortcuts from each PlayerDescription so the // toolbar can populate itself at login without keeping a parser reference. // Optional so all existing callers and tests compile unchanged. - Action>? onShortcuts = null, + Action>? onShortcuts = null, // B-Wire: the local player's server guid. When provided, the PD handler upserts // the player's own PropertyBundle (EncumbranceVal etc.) into the player ClientObject. Func? playerGuid = null, diff --git a/src/AcDream.Core.Net/Messages/InventoryActions.cs b/src/AcDream.Core.Net/Messages/InventoryActions.cs index cebe9a82..1989c491 100644 --- a/src/AcDream.Core.Net/Messages/InventoryActions.cs +++ b/src/AcDream.Core.Net/Messages/InventoryActions.cs @@ -1,4 +1,5 @@ using System.Buffers.Binary; +using AcDream.Core.Items; namespace AcDream.Core.Net.Messages; @@ -98,20 +99,20 @@ public static class InventoryActions return body; } - /// Pin an item/spell to a quickbar slot. ShortCutData = Index(u32), ObjectId(u32), - /// SpellId(u16), Layer(u16) — CONFIRMED across ACE/Chorizite/holtburger (action-bar deep-dive - /// §131-145). For an ITEM: objectGuid = item guid, spellId = layer = 0. - public static byte[] BuildAddShortcut( - uint seq, uint index, uint objectGuid, ushort spellId, ushort layer) + /// + /// Pack retail ShortCutData for AddShortcut: signed index, object id, + /// and raw 32-bit spell word. Retail uses the same three-u32 packer as + /// InventoryPlacement::Pack @ 0x005CABB0 through the shortcut vtable. + /// + public static byte[] BuildAddShortcut(uint seq, ShortcutEntry entry) { byte[] body = new byte[24]; BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), AddShortcutOpcode); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), index); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), objectGuid); - BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(20), spellId); - BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(22), layer); + BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(12), entry.Index); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), entry.ObjectId); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(20), entry.SpellId); return body; } diff --git a/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs b/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs index 73cb9f43..34433492 100644 --- a/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs +++ b/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs @@ -197,18 +197,6 @@ public static class PlayerDescriptionParser SpellLists8 = 0x00000400, } - /// One shortcut bar entry. 16 bytes wire size. - /// holtburger shortcuts.rs:13-34. Named ShortcutEntry - /// (not Shortcut) to avoid a homograph with the - /// flag bit, which is - /// referenced from the same scope as instances of this type in the - /// trailer walker. - public readonly record struct ShortcutEntry( - uint Index, - uint ObjectGuid, - ushort SpellId, - ushort Layer); - /// One inventory entry — a guid plus a ContainerType /// discriminator (0=NonContainer, 1=Container, 2=Foci). Holtburger /// events.rs:143-168 validates ContainerType <= 2 @@ -348,11 +336,10 @@ public static class PlayerDescriptionParser if (count > 10_000) throw new FormatException("unreasonable shortcut count"); for (uint i = 0; i < count; i++) { - uint idx = ReadU32(payload, ref pos); - uint guid = ReadU32(payload, ref pos); - ushort spellId = ReadU16(payload, ref pos); - ushort layer = ReadU16(payload, ref pos); - shortcuts.Add(new ShortcutEntry(idx, guid, spellId, layer)); + int index = unchecked((int)ReadU32(payload, ref pos)); + uint objectId = ReadU32(payload, ref pos); + uint spellId = ReadU32(payload, ref pos); + shortcuts.Add(new ShortcutEntry(index, objectId, spellId)); } } diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 4446c09e..1003f8c5 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Net; using System.Threading.Channels; using AcDream.Core.Combat; +using AcDream.Core.Items; using AcDream.Core.Net.Cryptography; using AcDream.Core.Net.Messages; using AcDream.Core.Net.Packets; @@ -1273,12 +1274,11 @@ public sealed class WorldSession : IDisposable SendGameAction(CharacterActions.BuildTrainSkill(seq, skillId, credits)); } - /// Send AddShortcut (0x019C) — pin an item to toolbar slot . - /// Retail: CM_Character::Event_AddShortCut. Mirrors the SendChangeCombatMode pattern. - public void SendAddShortcut(uint index, uint objectGuid, ushort spellId = 0, ushort layer = 0) + /// Send lossless retail AddShortcut (0x019C). + public void SendAddShortcut(ShortcutEntry entry) { uint seq = NextGameActionSequence(); - SendGameAction(InventoryActions.BuildAddShortcut(seq, index, objectGuid, spellId, layer)); + SendGameAction(InventoryActions.BuildAddShortcut(seq, entry)); } /// Send RemoveShortcut (0x019D) — clear toolbar slot . diff --git a/src/AcDream.Core/Items/ShortcutEntry.cs b/src/AcDream.Core/Items/ShortcutEntry.cs new file mode 100644 index 00000000..f498f550 --- /dev/null +++ b/src/AcDream.Core/Items/ShortcutEntry.cs @@ -0,0 +1,14 @@ +namespace AcDream.Core.Items; + +/// +/// Lossless retail ShortCutData value: signed slot index, object id, +/// and raw 32-bit spell word. The C++ object is 16 bytes including its vtable; +/// the packed value is exactly 12 bytes. Retail anchors: +/// ShortCutData::ShortCutData @ 0x005D55E0, +/// ShortCutManager::AddShortCut @ 0x005D5790, and +/// acclient.h:36484. +/// +public readonly record struct ShortcutEntry(int Index, uint ObjectId, uint SpellId) +{ + public ShortcutEntry WithIndex(int index) => this with { Index = index }; +} diff --git a/src/AcDream.Core/Items/ShortcutStore.cs b/src/AcDream.Core/Items/ShortcutStore.cs index 0f581a2d..636609b4 100644 --- a/src/AcDream.Core/Items/ShortcutStore.cs +++ b/src/AcDream.Core/Items/ShortcutStore.cs @@ -4,31 +4,53 @@ using System.Collections.Generic; namespace AcDream.Core.Items; /// -/// Mutable client-side model of the 18 toolbar shortcut slots — port of retail -/// ShortCutManager::shortCuts_[18] (acclient.h:36492). Holds the bound object guid per -/// slot (0 = empty). Loaded from the login shortcut list, then mutated by drag-drop (lift -/// removes, drop places); the server is notified via AddShortcut/RemoveShortcut so the store -/// stays the client's source of truth within a session (retail: client owns the array). -/// Item shortcuts only — entries with ObjGuid 0 (spell-only) are skipped on Load. Pure model -/// (no Core.Net dependency): callers project their wire entries to (slot, objGuid) pairs. +/// Mutable client-side model of retail ShortCutManager::shortCuts_[18] +/// (acclient.h:36492). Every present entry retains all three raw +/// fields, including non-object records that the object-only +/// gmToolbarUI does not render. /// public sealed class ShortcutStore { public const int SlotCount = 18; - private readonly uint[] _objIds = new uint[SlotCount]; + private readonly ShortcutEntry?[] _entries = new ShortcutEntry?[SlotCount]; - /// Replace all slots from a (slot, objectGuid) sequence (item entries only; - /// ObjGuid 0 and out-of-range slots are skipped). - public void Load(IEnumerable<(int Slot, uint ObjGuid)> entries) + /// + /// Replace all slots from packed entries. Invalid signed indices are rejected exactly + /// like ShortCutManager::AddShortCut @ 0x005D5790; valid entries are retained + /// even when their object id is zero. + /// + public void Load(IEnumerable entries) { - Array.Clear(_objIds); - foreach (var (slot, objGuid) in entries) - if ((uint)slot < SlotCount && objGuid != 0) _objIds[slot] = objGuid; + Array.Clear(_entries); + foreach (var entry in entries) + Set(entry); } - /// Bound object guid at , or 0 (empty / out of range). - public uint Get(int slot) => (uint)slot < SlotCount ? _objIds[slot] : 0u; + /// Raw entry at , or null for absent/out-of-range. + public ShortcutEntry? GetEntry(int slot) + => (uint)slot < SlotCount ? _entries[slot] : null; + + /// Visible object id at , or zero. + public uint Get(int slot) => GetEntry(slot)?.ObjectId ?? 0u; + + /// + /// Visual availability used by gmToolbarUI. A retained non-object entry remains + /// invisible and therefore counts as an empty toolbar cell. + /// public bool IsEmpty(int slot) => Get(slot) == 0u; - public void Set(int slot, uint objId) { if ((uint)slot < SlotCount) _objIds[slot] = objId; } - public void Remove(int slot) { if ((uint)slot < SlotCount) _objIds[slot] = 0u; } + + public void Set(ShortcutEntry entry) + { + if ((uint)entry.Index < SlotCount) + _entries[entry.Index] = entry; + } + + /// Create/replace a retail object shortcut; toolbar-created records use spell word zero. + public void SetItem(int slot, uint objectId) => Set(new ShortcutEntry(slot, objectId, 0u)); + + public void Remove(int slot) + { + if ((uint)slot < SlotCount) + _entries[slot] = null; + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs index 2abd14c4..0679432e 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs @@ -71,8 +71,8 @@ public class ToolbarControllerTests var (layout, slots, _) = FakeToolbar(); var repo = new ClientObjectTable(); repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); - var shortcuts = new List - { new(Index: 0, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) }; + var shortcuts = new List + { new(Index: 0, ObjectId: 0x5001u, SpellId: 0) }; ToolbarController.Bind(layout, repo, () => shortcuts, iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }); @@ -87,8 +87,8 @@ public class ToolbarControllerTests { var (layout, slots, _) = FakeToolbar(); var repo = new ClientObjectTable(); // item NOT present yet - var shortcuts = new List - { new(Index: 2, ObjectGuid: 0x5002u, SpellId: 0, Layer: 0) }; + var shortcuts = new List + { new(Index: 2, ObjectId: 0x5002u, SpellId: 0) }; ToolbarController.Bind(layout, repo, () => shortcuts, iconIds: (_,_,_,_,_) => 0x88u, useItem: _ => { }); @@ -104,8 +104,8 @@ public class ToolbarControllerTests { var (layout, slots, _) = FakeToolbar(); var repo = new ClientObjectTable(); - var shortcuts = new List - { new(Index: 2, ObjectGuid: 0x5002u, SpellId: 0, Layer: 0) }; + var shortcuts = new List + { new(Index: 2, ObjectId: 0x5002u, SpellId: 0) }; var controller = ToolbarController.Bind(layout, repo, () => shortcuts, iconIds: (_,_,_,_,_) => 0x88u, useItem: _ => { }); @@ -127,8 +127,8 @@ public class ToolbarControllerTests var (layout, slots, _) = FakeToolbar(); var repo = new ClientObjectTable(); repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); - var shortcuts = new List - { new(Index: 0, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) }; + var shortcuts = new List + { new(Index: 0, ObjectId: 0x5001u, SpellId: 0) }; uint used = 0; ToolbarController.Bind(layout, repo, () => shortcuts, @@ -148,7 +148,7 @@ public class ToolbarControllerTests int characterClicks = 0; var ctrl = ToolbarController.Bind(layout, repo, - () => Array.Empty(), + () => Array.Empty(), iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); ctrl.BindWindowToggles( toggleInventory: () => inventoryClicks++, @@ -191,7 +191,7 @@ public class ToolbarControllerTests int inventoryClicks = 0; var ctrl = ToolbarController.Bind(layout, repo, - () => Array.Empty(), + () => Array.Empty(), iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }, itemInteraction: interaction); @@ -213,7 +213,7 @@ public class ToolbarControllerTests var (layout, _, _) = FakeToolbar(); var repo = new ClientObjectTable(); var ctrl = ToolbarController.Bind(layout, repo, - () => Array.Empty(), + () => Array.Empty(), iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); var inventoryButton = (UiButton)layout.FindElement(InventoryButtonId)!; var characterButton = (UiButton)layout.FindElement(CharacterButtonId)!; @@ -243,7 +243,7 @@ public class ToolbarControllerTests ToolbarController.Bind( layout, repo, - () => Array.Empty(), + () => Array.Empty(), iconIds: (_, _, _, _, _) => 0u, useItem: _ => { }, toggleCombat: () => toggles++); @@ -263,7 +263,7 @@ public class ToolbarControllerTests repo.AddOrUpdate(new ClientObject { ObjectId = itemId, Type = ItemType.Misc }); var shortcuts = new[] { - new PlayerDescriptionParser.ShortcutEntry(0, itemId, 0, 0), + new ShortcutEntry(0, itemId, 0), }; uint used = 0; uint selected = 0; @@ -316,7 +316,7 @@ public class ToolbarControllerTests nowMs: () => 1_000); var shortcuts = new[] { - new PlayerDescriptionParser.ShortcutEntry(0, target, 0, 0), + new ShortcutEntry(0, target, 0), }; var controller = ToolbarController.Bind( layout, @@ -358,11 +358,11 @@ public class ToolbarControllerTests var controller = ToolbarController.Bind( layout, repo, - () => Array.Empty(), + () => Array.Empty(), iconIds: (_, _, _, _, _) => 1u, useItem: _ => { }, itemInteraction: interaction, - sendAddShortcut: (slot, id) => sends.Add((slot, id))); + sendAddShortcut: entry => sends.Add(((uint)entry.Index, entry.ObjectId))); Assert.True(controller.CreateShortcutToItem(item)); Assert.Equal(item, slots[Row1[0]].Cell.ItemId); @@ -384,7 +384,7 @@ public class ToolbarControllerTests var repo = new ClientObjectTable(); ToolbarController.Bind(layout, repo, - () => Array.Empty(), + () => Array.Empty(), iconIds: (_,_,_,_,_) =>0u, useItem: _ => { }); // Only peace indicator (index 0 = 0x10000192) is visible. @@ -404,7 +404,7 @@ public class ToolbarControllerTests var repo = new ClientObjectTable(); var ctrl = ToolbarController.Bind(layout, repo, - () => Array.Empty(), + () => Array.Empty(), iconIds: (_,_,_,_,_) =>0u, useItem: _ => { }); ctrl.SetCombatMode(CombatMode.Melee); @@ -426,7 +426,7 @@ public class ToolbarControllerTests var combat = new CombatState(); ToolbarController.Bind(layout, repo, - () => Array.Empty(), + () => Array.Empty(), iconIds: (_,_,_,_,_) =>0u, useItem: _ => { }, combatState: combat); @@ -465,7 +465,7 @@ public class ToolbarControllerTests var repo = new ClientObjectTable(); ToolbarController.Bind(layout, repo, - () => Array.Empty(), + () => Array.Empty(), iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }, peaceDigits: FakePeace, warDigits: FakeWar); @@ -490,7 +490,7 @@ public class ToolbarControllerTests var (layout, slots, _) = FakeToolbar(); var repo = new ClientObjectTable(); var ctrl = ToolbarController.Bind(layout, repo, - () => Array.Empty(), + () => Array.Empty(), iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }, peaceDigits: FakePeace, warDigits: FakeWar); @@ -517,7 +517,7 @@ public class ToolbarControllerTests var (layout, slots, _) = FakeToolbar(); var repo = new ClientObjectTable(); var ctrl = ToolbarController.Bind(layout, repo, - () => Array.Empty(), + () => Array.Empty(), iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }, peaceDigits: FakePeace, warDigits: FakeWar); @@ -539,7 +539,7 @@ public class ToolbarControllerTests var repo = new ClientObjectTable(); ToolbarController.Bind(layout, repo, - () => Array.Empty(), + () => Array.Empty(), iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }, peaceDigits: FakePeace, warDigits: FakeWar); @@ -561,7 +561,7 @@ public class ToolbarControllerTests var repo = new ClientObjectTable(); ToolbarController.Bind(layout, repo, - () => Array.Empty(), + () => Array.Empty(), iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }, peaceDigits: FakePeace, warDigits: FakeWar, emptyDigits: FakeEmpty); @@ -582,7 +582,7 @@ public class ToolbarControllerTests var repo = new ClientObjectTable(); ToolbarController.Bind(layout, repo, - () => Array.Empty(), + () => Array.Empty(), iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }, peaceDigits: FakePeace, warDigits: FakeWar, emptyDigits: null); @@ -604,8 +604,8 @@ public class ToolbarControllerTests var (layout, _, _) = FakeToolbar(); var repo = new ClientObjectTable(); repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); - var shortcuts = new List - { new(Index: 0, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) }; + var shortcuts = new List + { new(Index: 0, ObjectId: 0x5001u, SpellId: 0) }; int iconCallCount = 0; ToolbarController.Bind(layout, repo, () => shortcuts, @@ -629,8 +629,8 @@ public class ToolbarControllerTests { var (layout, slots, _) = FakeToolbar(); var repo = new ClientObjectTable(); // item NOT present yet - var shortcuts = new List - { new(Index: 1, ObjectGuid: 0x5003u, SpellId: 0, Layer: 0) }; + var shortcuts = new List + { new(Index: 1, ObjectId: 0x5003u, SpellId: 0) }; int iconCallCount = 0; ToolbarController.Bind(layout, repo, () => shortcuts, @@ -656,8 +656,8 @@ public class ToolbarControllerTests var (layout, slots, _) = FakeToolbar(); var repo = new ClientObjectTable(); repo.AddOrUpdate(new ClientObject { ObjectId = 0x5004u, WeenieClassId = 1u, IconId = 0x06001234u }); - var shortcuts = new List - { new(Index: 3, ObjectGuid: 0x5004u, SpellId: 0, Layer: 0) }; + var shortcuts = new List + { new(Index: 3, ObjectId: 0x5004u, SpellId: 0) }; ToolbarController.Bind(layout, repo, () => shortcuts, iconIds: (_,_,_,_,_) => 0xAAu, useItem: _ => { }); @@ -682,8 +682,8 @@ public class ToolbarControllerTests var repo = new ClientObjectTable(); repo.AddOrUpdate(new ClientObject { ObjectId = 0x5005u, WeenieClassId = 1u, IconId = 0x06001234u }); repo.AddOrUpdate(new ClientObject { ObjectId = 0xCAFEBABEu, WeenieClassId = 99u, IconId = 0u }); - var shortcuts = new List - { new(Index: 4, ObjectGuid: 0x5005u, SpellId: 0, Layer: 0) }; + var shortcuts = new List + { new(Index: 4, ObjectId: 0x5005u, SpellId: 0) }; int iconCallCount = 0; ToolbarController.Bind(layout, repo, () => shortcuts, @@ -708,7 +708,7 @@ public class ToolbarControllerTests var repo = new ClientObjectTable(); var ctrl = ToolbarController.Bind(layout, repo, - () => Array.Empty(), + () => Array.Empty(), iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); for (int i = 0; i < Row1.Length; i++) @@ -733,7 +733,7 @@ public class ToolbarControllerTests { var (layout, slots, _) = FakeToolbar(); var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(), - () => Array.Empty(), + () => Array.Empty(), iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); var list = slots[Row1[0]]; @@ -748,7 +748,7 @@ public class ToolbarControllerTests { var (layout, slots, _) = FakeToolbar(); var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(), - () => Array.Empty(), + () => Array.Empty(), iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); var list = slots[Row1[0]]; var payload = new ItemDragPayload(0u, ItemDragSource.Inventory, 0, new UiItemSlot()); @@ -756,12 +756,12 @@ public class ToolbarControllerTests } // ── 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) + private static (System.Collections.Generic.List 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 adds = new System.Collections.Generic.List(); var removes = new System.Collections.Generic.List(); - add = (i, g) => adds.Add((i, g)); + add = entry => adds.Add(entry); rem = i => removes.Add(i); return (adds, removes); } @@ -772,8 +772,8 @@ public class ToolbarControllerTests 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 shortcuts = new System.Collections.Generic.List + { new(Index: 3, ObjectId: 0x5001u, SpellId: 0) }; var (adds, removes) = NewSpies(out var add, out var rem); var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, @@ -795,9 +795,9 @@ public class ToolbarControllerTests 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 shortcuts = new System.Collections.Generic.List + { new(Index: 3, ObjectId: 0x5001u, SpellId: 0), + new(Index: 5, ObjectId: 0x5002u, SpellId: 0) }; var (adds, removes) = NewSpies(out var add, out var rem); var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }, @@ -810,8 +810,36 @@ public class ToolbarControllerTests 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); + Assert.Contains(new ShortcutEntry(5, 0x5001u, 0u), adds); + Assert.Contains(new ShortcutEntry(3, 0x5002u, 0u), adds); + } + + [Fact] + public void HandleDropRelease_reindexesWithoutLosingRawShortcutFields() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, IconId = 1u }); + repo.AddOrUpdate(new ClientObject { ObjectId = 0x5002u, IconId = 2u }); + var shortcuts = new[] + { + new ShortcutEntry(3, 0x5001u, 0x11223344u), + new ShortcutEntry(5, 0x5002u, 0xA5C31234u), + }; + var (adds, _) = NewSpies(out var add, out var remove); + var controller = ToolbarController.Bind( + layout, repo, () => shortcuts, + iconIds: (_, _, _, _, _) => 1u, + useItem: _ => { }, + sendAddShortcut: add, + sendRemoveShortcut: remove); + + var payload = Assert.IsType(slots[Row1[3]].Cell.GetDragPayload()); + controller.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload); + controller.HandleDropRelease(slots[Row1[5]], slots[Row1[5]].Cell, payload); + + Assert.Contains(new ShortcutEntry(5, 0x5001u, 0x11223344u), adds); + Assert.Contains(new ShortcutEntry(3, 0x5002u, 0xA5C31234u), adds); } [Fact] @@ -820,8 +848,8 @@ public class ToolbarControllerTests 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 shortcuts = new System.Collections.Generic.List + { new(Index: 3, ObjectId: 0x5001u, SpellId: 0) }; var (adds, removes) = NewSpies(out var add, out var rem); var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }, @@ -833,8 +861,8 @@ public class ToolbarControllerTests 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); + Assert.Contains(new ShortcutEntry(7, 0x5001u, 0u), adds); + Assert.DoesNotContain(adds, a => a.Index == 3); } [Fact] @@ -843,8 +871,8 @@ public class ToolbarControllerTests 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 shortcuts = new System.Collections.Generic.List + { new(Index: 3, ObjectId: 0x5001u, SpellId: 0) }; var (adds, removes) = NewSpies(out var add, out var rem); var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }, @@ -855,7 +883,7 @@ public class ToolbarControllerTests 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 + Assert.Contains(new ShortcutEntry(3, 0x5001u, 0u), adds); // AddShortcut(3, A) sent Assert.Single(removes); // exactly RemoveShortcut(3) [from the lift] Assert.Single(adds); // exactly AddShortcut(3, A) [the re-place] } @@ -865,7 +893,7 @@ public class ToolbarControllerTests { var (layout, slots, _) = FakeToolbar(); ToolbarController.Bind(layout, new ClientObjectTable(), - () => System.Array.Empty(), + () => System.Array.Empty(), iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); Assert.Equal(0x060011FAu, slots[Row1[0]].Cell.DragAcceptSprite); // green cross, not the ring F9 } diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs index 455b6c4d..658cd99b 100644 --- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs @@ -572,7 +572,7 @@ public sealed class GameEventWiringTests // for the harness pattern; adds the Shortcut flag (0x1) + one 12-byte // entry, followed by the legacy-hotbar count (0) + spellbook_filters (0) // then empty inventory and equipped. - IReadOnlyList? got = null; + IReadOnlyList? got = null; var dispatcher = new GameEventDispatcher(); var items = new ClientObjectTable(); @@ -629,7 +629,7 @@ public sealed class GameEventWiringTests Assert.NotNull(got); Assert.Single(got!); - Assert.Equal(0x5001u, got![0].ObjectGuid); + Assert.Equal(0x5001u, got![0].ObjectId); } [Fact] diff --git a/tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs index 750147d0..fc4bcb9f 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs @@ -1,5 +1,6 @@ using System; using System.Buffers.Binary; +using AcDream.Core.Items; using AcDream.Core.Net.Messages; using Xunit; @@ -76,23 +77,23 @@ public sealed class InventoryActionsTests [Fact] public void BuildAddShortcut_ItemShortcut_FieldLayout() { - // ShortCutData = Index(u32), ObjectId(u32), SpellId(u16), Layer(u16). Item → spell/layer 0. - byte[] body = InventoryActions.BuildAddShortcut(seq: 1, index: 0, objectGuid: 0x3E1, spellId: 0, layer: 0); + var entry = new ShortcutEntry(Index: 0, ObjectId: 0x3E1u, SpellId: 0u); + byte[] body = InventoryActions.BuildAddShortcut(seq: 1, entry); Assert.Equal(24, body.Length); Assert.Equal(InventoryActions.AddShortcutOpcode, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); - Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); // index - Assert.Equal(0x3E1u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16))); // objectGuid - Assert.Equal((ushort)0, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(20))); // spellId - Assert.Equal((ushort)0, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(22))); // layer + Assert.Equal(0, BinaryPrimitives.ReadInt32LittleEndian(body.AsSpan(12))); + Assert.Equal(0x3E1u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16))); + Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(20))); } [Fact] - public void BuildAddShortcut_SpellShortcut_PacksSpellAndLayerAsU16s() + public void BuildAddShortcut_PreservesSignedIndexAndRawSpellWord() { - byte[] body = InventoryActions.BuildAddShortcut(seq: 1, index: 2, objectGuid: 0, spellId: 0x1234, layer: 3); - Assert.Equal(0x1234, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(20))); - Assert.Equal(3, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(22))); + var entry = new ShortcutEntry(Index: -1, ObjectId: 0u, SpellId: 0xA5C31234u); + byte[] body = InventoryActions.BuildAddShortcut(seq: 1, entry); + Assert.Equal(-1, BinaryPrimitives.ReadInt32LittleEndian(body.AsSpan(12))); + Assert.Equal(0xA5C31234u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(20))); } [Fact] diff --git a/tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs b/tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs index c74df049..9c08931a 100644 --- a/tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs +++ b/tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs @@ -390,21 +390,20 @@ public sealed class PlayerDescriptionParserTests writer.Write(0x01u); // option_flags = SHORTCUT writer.Write(0xCAFEu); // options1 sentinel - // Shortcut count + 2 entries (16 B each). + // Shortcut count + 2 packed ShortCutData entries (12 B each). writer.Write(2u); - writer.Write(0u); writer.Write(0xAABBCCDDu); writer.Write((ushort)0); writer.Write((ushort)0); - writer.Write(7u); writer.Write(0u); writer.Write((ushort)1234); writer.Write((ushort)5); + writer.Write(0); writer.Write(0xAABBCCDDu); writer.Write(0u); + writer.Write(7); writer.Write(0u); writer.Write(0x000504D2u); var parsed = PlayerDescriptionParser.TryParse(sb.ToArray()); Assert.NotNull(parsed); Assert.Equal(2, parsed!.Value.Shortcuts.Count); - Assert.Equal(0u, parsed.Value.Shortcuts[0].Index); - Assert.Equal(0xAABBCCDDu, parsed.Value.Shortcuts[0].ObjectGuid); - Assert.Equal((ushort)0, parsed.Value.Shortcuts[0].SpellId); - Assert.Equal(7u, parsed.Value.Shortcuts[1].Index); - Assert.Equal((ushort)1234, parsed.Value.Shortcuts[1].SpellId); - Assert.Equal((ushort)5, parsed.Value.Shortcuts[1].Layer); + Assert.Equal(0, parsed.Value.Shortcuts[0].Index); + Assert.Equal(0xAABBCCDDu, parsed.Value.Shortcuts[0].ObjectId); + Assert.Equal(0u, parsed.Value.Shortcuts[0].SpellId); + Assert.Equal(7, parsed.Value.Shortcuts[1].Index); + Assert.Equal(0x000504D2u, parsed.Value.Shortcuts[1].SpellId); } [Fact] @@ -424,9 +423,9 @@ public sealed class PlayerDescriptionParserTests writer.Write(3u); // claimed shortcut count = 3 // First entry complete (16 B). writer.Write(1u); writer.Write(0xAAAAu); writer.Write((ushort)10); writer.Write((ushort)1); - // Second entry truncated to 8 bytes — ReadU16 will throw FormatException. + // Second entry truncated to 8 bytes — the raw spell-word read throws. writer.Write(2u); writer.Write(0xBBBBu); - // (no SpellId/Layer — payload ends here) + // (no raw SpellId word — payload ends here) var parsed = PlayerDescriptionParser.TryParse(sb.ToArray()); @@ -435,7 +434,7 @@ public sealed class PlayerDescriptionParserTests Assert.True(parsed!.Value.TrailerTruncated); // First entry survives in the partial list. Assert.Single(parsed.Value.Shortcuts); - Assert.Equal(1u, parsed.Value.Shortcuts[0].Index); + Assert.Equal(1, parsed.Value.Shortcuts[0].Index); } [Fact] @@ -725,7 +724,7 @@ public sealed class PlayerDescriptionParserTests // Shortcuts: count=1 writer.Write(1u); - writer.Write(3u); writer.Write(0xCAFEFACEu); writer.Write((ushort)100); writer.Write((ushort)2); + writer.Write(3); writer.Write(0xCAFEFACEu); writer.Write(0x00020064u); // 8 hotbars, all empty for brevity. for (int i = 0; i < 8; i++) writer.Write(0u); @@ -754,7 +753,8 @@ public sealed class PlayerDescriptionParserTests Assert.Equal(0xBB000002u, v.Options2); Assert.Equal(0xF11Du, v.SpellbookFilters); Assert.Single(v.Shortcuts); - Assert.Equal(0xCAFEFACEu, v.Shortcuts[0].ObjectGuid); + Assert.Equal(0xCAFEFACEu, v.Shortcuts[0].ObjectId); + Assert.Equal(0x00020064u, v.Shortcuts[0].SpellId); Assert.Equal(8, v.HotbarSpells.Count); Assert.All(v.HotbarSpells, l => Assert.Empty(l)); Assert.Single(v.DesiredComps); diff --git a/tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs b/tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs index a58dabd9..c927daee 100644 --- a/tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs +++ b/tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs @@ -6,34 +6,39 @@ namespace AcDream.Core.Tests.Items; public class ShortcutStoreTests { [Fact] - public void Load_mapsSlotToObjId_skipsEmptyAndOutOfRange() + public void Load_preservesAllRawFieldsAndRejectsOutOfRangeIndex() { var store = new ShortcutStore(); - store.Load(new (int, uint)[] + store.Load(new[] { - (0, 0x5001u), - (3, 0x5002u), - (5, 0u), // empty/spell → skipped - (99, 0x5003u), // out of range → skipped + new ShortcutEntry(0, 0x5001u, 0x11223344u), + new ShortcutEntry(3, 0x5002u, 0u), + new ShortcutEntry(5, 0u, 0xA5C31234u), + new ShortcutEntry(-1, 0x5003u, 1u), + new ShortcutEntry(18, 0x5004u, 2u), }); Assert.Equal(0x5001u, store.Get(0)); Assert.Equal(0x5002u, store.Get(3)); Assert.Equal(0u, store.Get(5)); + Assert.Equal(new ShortcutEntry(5, 0u, 0xA5C31234u), store.GetEntry(5)); + Assert.Equal(0x11223344u, store.GetEntry(0)!.Value.SpellId); Assert.True(store.IsEmpty(1)); + Assert.Null(store.GetEntry(-1)); + Assert.Null(store.GetEntry(18)); } [Fact] public void SetRemoveGet_roundtrip_andBoundsSafe() { var store = new ShortcutStore(); - store.Set(4, 0xABCDu); + store.SetItem(4, 0xABCDu); Assert.Equal(0xABCDu, store.Get(4)); Assert.False(store.IsEmpty(4)); store.Remove(4); Assert.True(store.IsEmpty(4)); Assert.Equal(0u, store.Get(-1)); Assert.Equal(0u, store.Get(18)); - store.Set(18, 1u); // no-op, no throw + store.SetItem(18, 1u); // no-op, no throw store.Remove(-1); // no-op, no throw } }