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:
Erik 2026-07-11 09:17:33 +02:00
parent e65119f0c6
commit b5b230c860
21 changed files with 271 additions and 181 deletions

View file

@ -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
}

View file

@ -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]

View file

@ -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]

View file

@ -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);

View file

@ -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
}
}