diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 615d31e3..1488fc3a 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -596,6 +596,9 @@ public sealed class GameWindow : IDisposable
public readonly AcDream.Core.Spells.SpellTable SpellTable = LoadSpellTable();
public readonly AcDream.Core.Spells.Spellbook SpellBook = null!;
public readonly AcDream.Core.Items.ItemRepository Items = new();
+ /// Persisted hotbar shortcuts from the last PlayerDescription (D.5.1 toolbar source).
+ public IReadOnlyList Shortcuts { get; private set; }
+ = System.Array.Empty();
// Issue #5 — caches CreatureProfile.{Stamina, Mana, *Max} from
// PlayerDescription so the Vitals HUD can render those bars.
// Issue #6 — wired to SpellBook so GetMaxApprox folds enchantment
@@ -2309,7 +2312,8 @@ public sealed class GameWindow : IDisposable
_lastSeenRunSkill, _lastSeenJumpSkill);
Console.WriteLine($"player: applied server skills run={_lastSeenRunSkill} jump={_lastSeenJumpSkill}");
}
- });
+ },
+ onShortcuts: list => Shortcuts = list);
// Phase I.7: subscribe to CombatState events and emit
// retail-faithful "You hit X for Y damage" chat lines into
diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs
index c5f61e32..1aeefe2a 100644
--- a/src/AcDream.Core.Net/GameEventWiring.cs
+++ b/src/AcDream.Core.Net/GameEventWiring.cs
@@ -61,7 +61,11 @@ public static class GameEventWiring
// (matching ACE's CreatureSkill.Current minus
// augs/multipliers/vitae which we still don't model).
Action? onSkillsUpdated = null,
- Func /*attrCurrents*/, uint /*formulaBonus*/>? resolveSkillFormulaBonus = null)
+ Func /*attrCurrents*/, uint /*formulaBonus*/>? resolveSkillFormulaBonus = null,
+ // D.5.1 Task 4: persists Shortcuts from each PlayerDescription so the
+ // toolbar can populate itself at login without keeping a parser reference.
+ // Optional so all existing callers and tests compile unchanged.
+ Action>? onShortcuts = null)
{
ArgumentNullException.ThrowIfNull(dispatcher);
ArgumentNullException.ThrowIfNull(items);
@@ -430,6 +434,10 @@ public static class GameEventWiring
newSlot: -1,
newEquipLocation: (EquipMask)eq.EquipLocation);
}
+
+ // D.5.1 Task 4: forward shortcut bar entries to the caller so the
+ // toolbar can read them without holding a parser reference.
+ onShortcuts?.Invoke(p.Value.Shortcuts);
});
}
}
diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs
index c414ddbf..daadaa1a 100644
--- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs
+++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs
@@ -375,4 +375,73 @@ public sealed class GameEventWiringTests
Assert.NotNull(items.GetItem(0x50000A02u));
}
+ [Fact]
+ public void WireAll_PlayerDescription_invokesOnShortcuts()
+ {
+ // D.5.1 Task 4: WireAll must forward parsed.Shortcuts to the onShortcuts
+ // callback so the toolbar can read them without keeping a parser reference.
+ // Mirrors PlayerDescription_RegistersInventoryEntries_InItemRepository
+ // for the harness pattern; adds the Shortcut flag (0x1) + one 12-byte
+ // entry, followed by the legacy-hotbar count (0) + spellbook_filters (0)
+ // then empty inventory and equipped.
+ IReadOnlyList? got = null;
+
+ var dispatcher = new GameEventDispatcher();
+ var items = new ItemRepository();
+ var combat = new CombatState();
+ var spellbook = new Spellbook();
+ var chat = new ChatLog();
+ GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat,
+ onShortcuts: list => got = list);
+
+ // PlayerDescription body — minimal: no property flags, ATTRIBUTE|ENCHANTMENT
+ // vectorFlags (so the parser sees has_health=1, attribute_flags=0,
+ // enchantment_mask=0 and advances past both vector blocks), then the trailer
+ // with option_flags=Shortcut (0x1).
+ //
+ // Trailer layout when option_flags=0x1 (Shortcut only, no SpellLists8):
+ // u32 option_flags = 0x1
+ // u32 options1 = 0
+ // u32 count = 1 ← shortcut block (Shortcut flag set)
+ // u32 idx = 0
+ // u32 guid = 0x5001
+ // u16 spellId = 0
+ // u16 layer = 0
+ // u32 legacyHotbar count = 0 ← SpellLists8 NOT set → legacy fallback
+ // u32 spellbook_filters = 0
+ // u32 inventory count = 0
+ // u32 equipped count = 0
+ var sb = new MemoryStream();
+ using var w = new BinaryWriter(sb);
+ w.Write(0u); // propertyFlags = 0
+ w.Write(0x52u); // weenieType
+ w.Write(0x201u); // vectorFlags = ATTRIBUTE | ENCHANTMENT
+ w.Write(1u); // has_health
+ w.Write(0u); // attribute_flags = 0 (no attrs)
+ w.Write(0u); // enchantment_mask = 0
+
+ // Trailer
+ w.Write(0x00000001u); // option_flags = Shortcut
+ w.Write(0u); // options1
+ // Shortcut block (option_flags & 0x1 set):
+ w.Write(1u); // count = 1
+ w.Write(0u); // idx = 0
+ w.Write(0x5001u); // guid = 0x5001
+ w.Write((ushort)0); // spellId = 0
+ w.Write((ushort)0); // layer = 0
+ // SpellLists8 NOT set → legacy single-list fallback:
+ w.Write(0u); // legacy hotbar list count = 0
+ // No DesiredComps, no CharacterOptions2, no GameplayOptions → strict path:
+ w.Write(0u); // spellbook_filters = 0
+ w.Write(0u); // inventory count = 0
+ w.Write(0u); // equipped count = 0
+
+ var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray()));
+ dispatcher.Dispatch(env!.Value);
+
+ Assert.NotNull(got);
+ Assert.Single(got!);
+ Assert.Equal(0x5001u, got![0].ObjectGuid);
+ }
+
}