From 6c485c2f06ad1179907ef1e8f7ef53233f275355 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 22:01:40 +0200 Subject: [PATCH] feat(D.5.1): persist PlayerDescription shortcuts (were parsed then discarded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add optional `onShortcuts` callback to `GameEventWiring.WireAll`; invoke it with `parsed.Shortcuts` after the inventory/equipped loops in the PlayerDescription handler. `GameWindow` holds the list in a new `Shortcuts` property (initialized to empty) so the toolbar (D.5.1 Task 5) can read hotbar slots without keeping a parser reference. Existing callers compile unchanged — the parameter defaults to null. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 6 +- src/AcDream.Core.Net/GameEventWiring.cs | 10 ++- .../GameEventWiringTests.cs | 69 +++++++++++++++++++ 3 files changed, 83 insertions(+), 2 deletions(-) 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); + } + }