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 <codex@openai.com>
This commit is contained in:
parent
e65119f0c6
commit
b5b230c860
21 changed files with 271 additions and 181 deletions
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
/// <summary>Persisted hotbar shortcuts from the last PlayerDescription (D.5.1 toolbar source).</summary>
|
||||
public IReadOnlyList<AcDream.Core.Net.Messages.PlayerDescriptionParser.ShortcutEntry> Shortcuts { get; private set; }
|
||||
= System.Array.Empty<AcDream.Core.Net.Messages.PlayerDescriptionParser.ShortcutEntry>();
|
||||
public IReadOnlyList<AcDream.Core.Items.ShortcutEntry> Shortcuts { get; private set; }
|
||||
= System.Array.Empty<AcDream.Core.Items.ShortcutEntry>();
|
||||
// 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,
|
||||
|
|
|
|||
|
|
@ -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<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
shortcuts: () => System.Array.Empty<ShortcutEntry>(),
|
||||
iconIds: MakeIconIds(stack),
|
||||
useItem: _ => { },
|
||||
combatState: null,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> _shortcuts;
|
||||
private readonly Func<IReadOnlyList<ShortcutEntry>> _shortcuts;
|
||||
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex
|
||||
private readonly Action<uint> _useItem; // guid → fire UseObject
|
||||
private readonly AcDream.Core.Items.ShortcutStore _store = new();
|
||||
private readonly ShortcutStore _store = new();
|
||||
private bool _storeLoaded;
|
||||
private readonly Action<uint, uint>? _sendAddShortcut; // (index, objectGuid)
|
||||
private readonly Action<ShortcutEntry>? _sendAddShortcut;
|
||||
private readonly Action<uint>? _sendRemoveShortcut; // (index)
|
||||
private readonly ItemInteractionController? _itemInteraction;
|
||||
private readonly Action<uint>? _selectItem;
|
||||
|
|
@ -89,7 +89,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
private ToolbarController(
|
||||
ImportedLayout layout,
|
||||
ClientObjectTable repo,
|
||||
Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> shortcuts,
|
||||
Func<IReadOnlyList<ShortcutEntry>> shortcuts,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
|
||||
Action<uint> useItem,
|
||||
CombatState? combatState,
|
||||
|
|
@ -97,7 +97,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
uint[]? warDigits,
|
||||
uint[]? emptyDigits,
|
||||
ItemInteractionController? itemInteraction = null,
|
||||
Action<uint, uint>? sendAddShortcut = null,
|
||||
Action<ShortcutEntry>? sendAddShortcut = null,
|
||||
Action<uint>? sendRemoveShortcut = null,
|
||||
Action? toggleCombat = null,
|
||||
Action<uint>? 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<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> shortcuts,
|
||||
Func<IReadOnlyList<ShortcutEntry>> shortcuts,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
|
||||
Action<uint> useItem,
|
||||
CombatState? combatState = null,
|
||||
|
|
@ -234,7 +234,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
uint[]? warDigits = null,
|
||||
uint[]? emptyDigits = null,
|
||||
ItemInteractionController? itemInteraction = null,
|
||||
Action<uint, uint>? sendAddShortcut = null,
|
||||
Action<ShortcutEntry>? sendAddShortcut = null,
|
||||
Action<uint>? sendRemoveShortcut = null,
|
||||
Action? toggleCombat = null,
|
||||
Action<uint>? 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,13 +37,13 @@ public sealed record RadarRuntimeBindings(
|
|||
|
||||
public sealed record ToolbarRuntimeBindings(
|
||||
ClientObjectTable Objects,
|
||||
Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> Shortcuts,
|
||||
Func<IReadOnlyList<ShortcutEntry>> Shortcuts,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon,
|
||||
Action<uint> UseItem,
|
||||
CombatState Combat,
|
||||
Action ToggleCombat,
|
||||
ItemInteractionController ItemInteraction,
|
||||
Action<uint, uint>? SendAddShortcut,
|
||||
Action<ShortcutEntry>? SendAddShortcut,
|
||||
Action<uint>? SendRemoveShortcut,
|
||||
SelectionState Selection,
|
||||
Action<Action<uint, float>> SubscribeHealthChanged,
|
||||
|
|
|
|||
|
|
@ -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
|
|||
/// <summary>Pre-composited icon GL texture for the bound item (0 = none).</summary>
|
||||
public uint IconTexture { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public ShortcutEntry? Shortcut { get; private set; }
|
||||
|
||||
/// <summary>This cell's own index within its panel (0..17 toolbar; container slot
|
||||
/// for inventory). Distinct from <see cref="ShortcutNum"/> (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
|
|||
/// <summary>RenderSurface id -> (GL texture, w, h). Set by the factory/controller.</summary>
|
||||
public Func<uint, (uint tex, int w, int h)>? 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; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override object? GetDragPayload()
|
||||
=> ItemId != 0 ? new ItemDragPayload(ItemId, SourceKind, SlotIndex, this) : null;
|
||||
=> ItemId != 0 ? new ItemDragPayload(ItemId, SourceKind, SlotIndex, this, Shortcut) : null;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override (uint tex, int w, int h)? GetDragGhost()
|
||||
|
|
|
|||
|
|
@ -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<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>>? onShortcuts = null,
|
||||
Action<IReadOnlyList<ShortcutEntry>>? 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<uint>? playerGuid = null,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public static byte[] BuildAddShortcut(
|
||||
uint seq, uint index, uint objectGuid, ushort spellId, ushort layer)
|
||||
/// <summary>
|
||||
/// Pack retail <c>ShortCutData</c> for AddShortcut: signed index, object id,
|
||||
/// and raw 32-bit spell word. Retail uses the same three-u32 packer as
|
||||
/// <c>InventoryPlacement::Pack @ 0x005CABB0</c> through the shortcut vtable.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -197,18 +197,6 @@ public static class PlayerDescriptionParser
|
|||
SpellLists8 = 0x00000400,
|
||||
}
|
||||
|
||||
/// <summary>One shortcut bar entry. 16 bytes wire size.
|
||||
/// holtburger <c>shortcuts.rs:13-34</c>. Named <c>ShortcutEntry</c>
|
||||
/// (not <c>Shortcut</c>) to avoid a homograph with the
|
||||
/// <see cref="CharacterOptionDataFlag.Shortcut"/> flag bit, which is
|
||||
/// referenced from the same scope as instances of this type in the
|
||||
/// trailer walker.</summary>
|
||||
public readonly record struct ShortcutEntry(
|
||||
uint Index,
|
||||
uint ObjectGuid,
|
||||
ushort SpellId,
|
||||
ushort Layer);
|
||||
|
||||
/// <summary>One inventory entry — a guid plus a ContainerType
|
||||
/// discriminator (0=NonContainer, 1=Container, 2=Foci). Holtburger
|
||||
/// <c>events.rs:143-168</c> validates <c>ContainerType <= 2</c>
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
/// <summary>Send AddShortcut (0x019C) — pin an item to toolbar slot <paramref name="index"/>.
|
||||
/// Retail: CM_Character::Event_AddShortCut. Mirrors the SendChangeCombatMode pattern.</summary>
|
||||
public void SendAddShortcut(uint index, uint objectGuid, ushort spellId = 0, ushort layer = 0)
|
||||
/// <summary>Send lossless retail AddShortcut (0x019C).</summary>
|
||||
public void SendAddShortcut(ShortcutEntry entry)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(InventoryActions.BuildAddShortcut(seq, index, objectGuid, spellId, layer));
|
||||
SendGameAction(InventoryActions.BuildAddShortcut(seq, entry));
|
||||
}
|
||||
|
||||
/// <summary>Send RemoveShortcut (0x019D) — clear toolbar slot <paramref name="index"/>.
|
||||
|
|
|
|||
14
src/AcDream.Core/Items/ShortcutEntry.cs
Normal file
14
src/AcDream.Core/Items/ShortcutEntry.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
namespace AcDream.Core.Items;
|
||||
|
||||
/// <summary>
|
||||
/// Lossless retail <c>ShortCutData</c> 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:
|
||||
/// <c>ShortCutData::ShortCutData @ 0x005D55E0</c>,
|
||||
/// <c>ShortCutManager::AddShortCut @ 0x005D5790</c>, and
|
||||
/// <c>acclient.h:36484</c>.
|
||||
/// </summary>
|
||||
public readonly record struct ShortcutEntry(int Index, uint ObjectId, uint SpellId)
|
||||
{
|
||||
public ShortcutEntry WithIndex(int index) => this with { Index = index };
|
||||
}
|
||||
|
|
@ -4,31 +4,53 @@ using System.Collections.Generic;
|
|||
namespace AcDream.Core.Items;
|
||||
|
||||
/// <summary>
|
||||
/// Mutable client-side model of the 18 toolbar shortcut slots — port of retail
|
||||
/// <c>ShortCutManager::shortCuts_[18]</c> (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 <c>ShortCutManager::shortCuts_[18]</c>
|
||||
/// (<c>acclient.h:36492</c>). Every present entry retains all three raw
|
||||
/// <see cref="ShortcutEntry"/> fields, including non-object records that the object-only
|
||||
/// <c>gmToolbarUI</c> does not render.
|
||||
/// </summary>
|
||||
public sealed class ShortcutStore
|
||||
{
|
||||
public const int SlotCount = 18;
|
||||
private readonly uint[] _objIds = new uint[SlotCount];
|
||||
private readonly ShortcutEntry?[] _entries = new ShortcutEntry?[SlotCount];
|
||||
|
||||
/// <summary>Replace all slots from a (slot, objectGuid) sequence (item entries only;
|
||||
/// ObjGuid 0 and out-of-range slots are skipped).</summary>
|
||||
public void Load(IEnumerable<(int Slot, uint ObjGuid)> entries)
|
||||
/// <summary>
|
||||
/// Replace all slots from packed entries. Invalid signed indices are rejected exactly
|
||||
/// like <c>ShortCutManager::AddShortCut @ 0x005D5790</c>; valid entries are retained
|
||||
/// even when their object id is zero.
|
||||
/// </summary>
|
||||
public void Load(IEnumerable<ShortcutEntry> 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);
|
||||
}
|
||||
|
||||
/// <summary>Bound object guid at <paramref name="slot"/>, or 0 (empty / out of range).</summary>
|
||||
public uint Get(int slot) => (uint)slot < SlotCount ? _objIds[slot] : 0u;
|
||||
/// <summary>Raw entry at <paramref name="slot"/>, or null for absent/out-of-range.</summary>
|
||||
public ShortcutEntry? GetEntry(int slot)
|
||||
=> (uint)slot < SlotCount ? _entries[slot] : null;
|
||||
|
||||
/// <summary>Visible object id at <paramref name="slot"/>, or zero.</summary>
|
||||
public uint Get(int slot) => GetEntry(slot)?.ObjectId ?? 0u;
|
||||
|
||||
/// <summary>
|
||||
/// Visual availability used by <c>gmToolbarUI</c>. A retained non-object entry remains
|
||||
/// invisible and therefore counts as an empty toolbar cell.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Create/replace a retail object shortcut; toolbar-created records use spell word zero.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<PlayerDescriptionParser.ShortcutEntry>
|
||||
{ new(Index: 0, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) };
|
||||
var shortcuts = new List<ShortcutEntry>
|
||||
{ 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<PlayerDescriptionParser.ShortcutEntry>
|
||||
{ new(Index: 2, ObjectGuid: 0x5002u, SpellId: 0, Layer: 0) };
|
||||
var shortcuts = new List<ShortcutEntry>
|
||||
{ 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<PlayerDescriptionParser.ShortcutEntry>
|
||||
{ new(Index: 2, ObjectGuid: 0x5002u, SpellId: 0, Layer: 0) };
|
||||
var shortcuts = new List<ShortcutEntry>
|
||||
{ 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<PlayerDescriptionParser.ShortcutEntry>
|
||||
{ new(Index: 0, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) };
|
||||
var shortcuts = new List<ShortcutEntry>
|
||||
{ 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<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
() => Array.Empty<ShortcutEntry>(),
|
||||
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<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
() => Array.Empty<ShortcutEntry>(),
|
||||
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<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
() => Array.Empty<ShortcutEntry>(),
|
||||
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<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
() => Array.Empty<ShortcutEntry>(),
|
||||
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<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
() => Array.Empty<ShortcutEntry>(),
|
||||
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<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
() => Array.Empty<ShortcutEntry>(),
|
||||
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<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
() => Array.Empty<ShortcutEntry>(),
|
||||
iconIds: (_,_,_,_,_) =>0u, useItem: _ => { });
|
||||
|
||||
ctrl.SetCombatMode(CombatMode.Melee);
|
||||
|
|
@ -426,7 +426,7 @@ public class ToolbarControllerTests
|
|||
var combat = new CombatState();
|
||||
|
||||
ToolbarController.Bind(layout, repo,
|
||||
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
() => Array.Empty<ShortcutEntry>(),
|
||||
iconIds: (_,_,_,_,_) =>0u, useItem: _ => { },
|
||||
combatState: combat);
|
||||
|
||||
|
|
@ -465,7 +465,7 @@ public class ToolbarControllerTests
|
|||
var repo = new ClientObjectTable();
|
||||
|
||||
ToolbarController.Bind(layout, repo,
|
||||
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
() => Array.Empty<ShortcutEntry>(),
|
||||
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<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
() => Array.Empty<ShortcutEntry>(),
|
||||
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<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
() => Array.Empty<ShortcutEntry>(),
|
||||
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { },
|
||||
peaceDigits: FakePeace, warDigits: FakeWar);
|
||||
|
||||
|
|
@ -539,7 +539,7 @@ public class ToolbarControllerTests
|
|||
var repo = new ClientObjectTable();
|
||||
|
||||
ToolbarController.Bind(layout, repo,
|
||||
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
() => Array.Empty<ShortcutEntry>(),
|
||||
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { },
|
||||
peaceDigits: FakePeace, warDigits: FakeWar);
|
||||
|
||||
|
|
@ -561,7 +561,7 @@ public class ToolbarControllerTests
|
|||
var repo = new ClientObjectTable();
|
||||
|
||||
ToolbarController.Bind(layout, repo,
|
||||
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
() => Array.Empty<ShortcutEntry>(),
|
||||
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<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
() => Array.Empty<ShortcutEntry>(),
|
||||
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<PlayerDescriptionParser.ShortcutEntry>
|
||||
{ new(Index: 0, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) };
|
||||
var shortcuts = new List<ShortcutEntry>
|
||||
{ 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<PlayerDescriptionParser.ShortcutEntry>
|
||||
{ new(Index: 1, ObjectGuid: 0x5003u, SpellId: 0, Layer: 0) };
|
||||
var shortcuts = new List<ShortcutEntry>
|
||||
{ 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<PlayerDescriptionParser.ShortcutEntry>
|
||||
{ new(Index: 3, ObjectGuid: 0x5004u, SpellId: 0, Layer: 0) };
|
||||
var shortcuts = new List<ShortcutEntry>
|
||||
{ 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<PlayerDescriptionParser.ShortcutEntry>
|
||||
{ new(Index: 4, ObjectGuid: 0x5005u, SpellId: 0, Layer: 0) };
|
||||
var shortcuts = new List<ShortcutEntry>
|
||||
{ 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<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
() => Array.Empty<ShortcutEntry>(),
|
||||
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<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
() => Array.Empty<ShortcutEntry>(),
|
||||
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<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
() => Array.Empty<ShortcutEntry>(),
|
||||
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<uint> removes) NewSpies(out System.Action<uint,uint> add, out System.Action<uint> rem)
|
||||
private static (System.Collections.Generic.List<ShortcutEntry> adds,
|
||||
System.Collections.Generic.List<uint> removes) NewSpies(out System.Action<ShortcutEntry> add, out System.Action<uint> rem)
|
||||
{
|
||||
var adds = new System.Collections.Generic.List<(uint,uint)>();
|
||||
var adds = new System.Collections.Generic.List<ShortcutEntry>();
|
||||
var removes = new System.Collections.Generic.List<uint>();
|
||||
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<PlayerDescriptionParser.ShortcutEntry>
|
||||
{ new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) };
|
||||
var shortcuts = new System.Collections.Generic.List<ShortcutEntry>
|
||||
{ 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<PlayerDescriptionParser.ShortcutEntry>
|
||||
{ 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<ShortcutEntry>
|
||||
{ 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<ItemDragPayload>(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<PlayerDescriptionParser.ShortcutEntry>
|
||||
{ new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) };
|
||||
var shortcuts = new System.Collections.Generic.List<ShortcutEntry>
|
||||
{ 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<PlayerDescriptionParser.ShortcutEntry>
|
||||
{ new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) };
|
||||
var shortcuts = new System.Collections.Generic.List<ShortcutEntry>
|
||||
{ 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<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
() => System.Array.Empty<ShortcutEntry>(),
|
||||
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
|
||||
Assert.Equal(0x060011FAu, slots[Row1[0]].Cell.DragAcceptSprite); // green cross, not the ring F9
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<PlayerDescriptionParser.ShortcutEntry>? got = null;
|
||||
IReadOnlyList<ShortcutEntry>? 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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue