From b2a812d1fac12e2a8aa2a9f85f0f173920aedae6 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 17 Jun 2026 13:52:50 +0200 Subject: [PATCH] feat(D.5.1): faithful toolbar slot numbers 1-9 (SetShortcutNum digit sprites, peace/war) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465) and gmToolbarUI::RecvNotice_SetCombatMode (196610-196621). The 9 top-row toolbar slots show digit labels 1-9 at all times (even when empty — confirmed from the user''s retail screenshot). The digit sprites are real 32x32 PFID_A8R8G8B8 RenderSurfaces with glyphs baked into the top-left corner (rest alpha=0), drawn Alphablend over the slot icon/empty sprite. Digit DID arrays (peace: property 0x10000042, war: 0x10000043) are read at startup from LayoutDesc 0x21000037 element 0x1000034A under composite 0x10000346 using the same ArrayBaseProperty{DataIdBaseProperty} pattern as LayoutImporter.ReadState. A cited-constant fallback (same confirmed dat ids) is used if the dat navigation fails. The war glyph set (darker/golden glyphs) switches on any combat stance; peace glyphs (lighter) restore on NonCombat — re-stamped by RestampShortcutNumbers() called from both Populate() and SetCombatMode(). Changes: - UiItemSlot: ShortcutNum/ShortcutPeace/PeaceDigits/WarDigits state; SetShortcutNum/ ClearShortcutNum; OnDraw restructured (no early return) so digit draws after icon. - ToolbarController: _peaceDigits/_warDigits/_peace fields; Bind() gains peaceDigits/ warDigits optional params; RestampShortcutNumbers() helper; Populate() and SetCombatMode() both call RestampShortcutNumbers(). - GameWindow: reads digit arrays under _datLock from LayoutDesc 0x21000037, passes to Bind(); cited constants as fallback. - Tests: 5 new UiItemSlotTests (SetShortcutNum/ClearShortcutNum state); 4 new ToolbarControllerTests (top-row/bottom-row labels, peace/war switch, array injection). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 62 +++++++++- .../UI/Layout/ToolbarController.cs | 63 ++++++++++- src/AcDream.App/UI/UiItemSlot.cs | 59 +++++++++- .../UI/Layout/ToolbarControllerTests.cs | 106 ++++++++++++++++++ tests/AcDream.App.Tests/UI/UiItemSlotTests.cs | 44 ++++++++ 5 files changed, 328 insertions(+), 6 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 8bfdf628..3b034344 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1911,6 +1911,64 @@ public sealed class GameWindow : IDisposable // Phase D.5.1 — toolbar window, data-driven from LayoutDesc 0x21000016 // (gmToolbarUI). Mirrors the vitals/chat import+bind+mount pattern above. + + // Read the shortcut-slot digit sprite DID arrays from LayoutDesc 0x21000037 + // (the UIItem cell template): element 0x1000034A under composite 0x10000346, + // StateDesc.Properties[0x10000042] = peace digits, [0x10000043] = war digits. + // Retail ref: UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465); + // gmToolbarUI::RecvNotice_SetCombatMode (196610-196621) re-stamps per stance. + uint[]? toolbarPeaceDigits = null; + uint[]? toolbarWarDigits = null; + lock (_datLock) + { + var uiItemLd = _dats!.Get(0x21000037u); + if (uiItemLd is not null + && uiItemLd.Elements.TryGetValue(0x10000346u, out var composite) + && composite.Children.TryGetValue(0x1000034Au, out var shortcutNumElem) + && shortcutNumElem.StateDesc is { } sd + && sd.Properties is { } props) + { + // Mirror LayoutImporter.ReadState: Properties[key] is ArrayBaseProperty + // containing DataIdBaseProperty entries. Each DataIdBaseProperty.Value is + // the RenderSurface DID for that digit. + // Peace: property 0x10000042; War: property 0x10000043. + if (props.TryGetValue(0x10000042u, out var rawPeace) + && rawPeace is DatReaderWriter.Types.ArrayBaseProperty arrPeace) + { + toolbarPeaceDigits = new uint[arrPeace.Value.Count]; + for (int i = 0; i < arrPeace.Value.Count; i++) + if (arrPeace.Value[i] is DatReaderWriter.Types.DataIdBaseProperty d) + toolbarPeaceDigits[i] = d.Value; + } + if (props.TryGetValue(0x10000043u, out var rawWar) + && rawWar is DatReaderWriter.Types.ArrayBaseProperty arrWar) + { + toolbarWarDigits = new uint[arrWar.Value.Count]; + for (int i = 0; i < arrWar.Value.Count; i++) + if (arrWar.Value[i] is DatReaderWriter.Types.DataIdBaseProperty d) + toolbarWarDigits[i] = d.Value; + } + Console.WriteLine(toolbarPeaceDigits is not null + ? $"[D.5.1] digit arrays loaded: peace={toolbarPeaceDigits.Length}, war={toolbarWarDigits?.Length ?? 0} entries." + : "[D.5.1] digit arrays: property 0x10000042 not found in element 0x1000034A — falling back to cited constants."); + } + else + { + Console.WriteLine("[D.5.1] digit arrays: element 0x1000034A/0x10000346 not found in LayoutDesc 0x21000037 — falling back to cited constants."); + } + } + + // Cited-constant fallback (UIElement_UIItem::SetShortcutNum, decomp 229465 + dat probe). + // Used when the dat navigation above fails (e.g. missing LayoutDesc in older dat). + if (toolbarPeaceDigits is null) + toolbarPeaceDigits = new uint[] + { 0x0600109Eu, 0x0600109Fu, 0x060010A0u, 0x060010A1u, 0x060010A2u, + 0x060010A3u, 0x060010A4u, 0x060010A5u, 0x060010A6u }; + if (toolbarWarDigits is null) + toolbarWarDigits = new uint[] + { 0x06001ACCu, 0x06001ACDu, 0x06001ACEu, 0x06001ACFu, 0x06001AD0u, + 0x06001AD1u, 0x06001AD2u, 0x06001AD3u, 0x06001AD4u }; + AcDream.App.UI.Layout.ImportedLayout? toolbarLayout; lock (_datLock) toolbarLayout = AcDream.App.UI.Layout.LayoutImporter.Import( @@ -1922,7 +1980,9 @@ public sealed class GameWindow : IDisposable () => Shortcuts, iconIds: (type, icon, under, over) => iconComposer.GetIcon(type, icon, under, over), useItem: guid => UseItemByGuid(guid), - combatState: Combat); + combatState: Combat, + peaceDigits: toolbarPeaceDigits, + warDigits: toolbarWarDigits); var toolbarRoot = toolbarLayout.Root; toolbarRoot.Left = 10; toolbarRoot.Top = 300; diff --git a/src/AcDream.App/UI/Layout/ToolbarController.cs b/src/AcDream.App/UI/Layout/ToolbarController.cs index 8bfc91d9..73de76cd 100644 --- a/src/AcDream.App/UI/Layout/ToolbarController.cs +++ b/src/AcDream.App/UI/Layout/ToolbarController.cs @@ -54,18 +54,31 @@ public sealed class ToolbarController private readonly Func _iconIds; // (itemType, iconId, underlayId, overlayId) → GL tex private readonly Action _useItem; // guid → fire UseObject + // Digit sprite DID arrays for slot labels (top row, numbers 1-9). + // Peace set: property 0x10000042; war set: property 0x10000043. + // Read from LayoutDesc 0x21000037, element 0x1000034A under composite 0x10000346. + // Retail ref: UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465); + // gmToolbarUI::RecvNotice_SetCombatMode (196610-196621) re-stamps per stance. + private uint[]? _peaceDigits; + private uint[]? _warDigits; + private bool _peace = true; // true = NonCombat (peace), false = any war stance + private ToolbarController( ImportedLayout layout, ItemRepository repo, Func> shortcuts, Func iconIds, Action useItem, - CombatState? combatState) + CombatState? combatState, + uint[]? peaceDigits, + uint[]? warDigits) { _repo = repo; _shortcuts = shortcuts; _iconIds = iconIds; _useItem = useItem; + _peaceDigits = peaceDigits; + _warDigits = warDigits; for (int i = 0; i < SlotIds.Length; i++) { @@ -113,15 +126,25 @@ public sealed class ToolbarController /// combat-mode indicator elements accordingly. /// Pass null to skip live wiring (e.g. in unit tests that don't exercise the indicator). /// + /// + /// Peace-mode digit DID array (property 0x10000042 from LayoutDesc 0x21000037 element + /// 0x1000034A under composite 0x10000346). Index i → slot label digit (i+1) RenderSurface id. + /// Null if the dat lookup failed (no digits drawn). Retail reference: + /// UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465). + /// + /// War-mode digit DID array (property 0x10000043, same element). public static ToolbarController Bind( ImportedLayout layout, ItemRepository repo, Func> shortcuts, Func iconIds, Action useItem, - CombatState? combatState = null) + CombatState? combatState = null, + uint[]? peaceDigits = null, + uint[]? warDigits = null) { - var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState); + var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState, + peaceDigits, warDigits); c.Populate(); return c; } @@ -151,6 +174,11 @@ public sealed class ToolbarController uint tex = _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId); list.Cell.SetItem(sc.ObjectGuid, tex); } + + // Re-stamp slot number labels after any item change. + // Numbers show on ALL top-row slots regardless of item occupancy — + // the user's retail screenshot confirms numbers on empty top-row slots. + RestampShortcutNumbers(); } /// @@ -178,6 +206,35 @@ public sealed class ToolbarController if (_combatIndicators[i] is { } e) e.Visible = show[i]; } + + // Re-stamp digit set: peace glyphs in NonCombat, war glyphs in any combat stance. + // Retail ref: gmToolbarUI::RecvNotice_SetCombatMode (acclient_2013_pseudo_c.txt:196610-196621). + _peace = (mode == CombatMode.NonCombat); + RestampShortcutNumbers(); + } + + /// + /// Push digit-array references and shortcut-number state into every slot cell. + /// Top row (indices 0–8): SetShortcutNum(i, _peace) — numbers 1–9 on ALL slots + /// including empty ones (confirmed from user's retail screenshot; the numbers are + /// slot LABELS, not item indicators). + /// Bottom row (indices 9–17): ClearShortcutNum() — retail shows no numbers there. + /// Retail ref: UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465); + /// gmToolbarUI::RecvNotice_SetCombatMode (196610-196621). + /// + private void RestampShortcutNumbers() + { + for (int i = 0; i < _slots.Length; i++) + { + var cell = _slots[i]?.Cell; + if (cell is null) continue; + cell.PeaceDigits = _peaceDigits; + cell.WarDigits = _warDigits; + if (i < 9) + cell.SetShortcutNum(i, _peace); // top row: slot label digits 1–9 always shown + else + cell.ClearShortcutNum(); // bottom row: no slot labels + } } /// diff --git a/src/AcDream.App/UI/UiItemSlot.cs b/src/AcDream.App/UI/UiItemSlot.cs index f6dac45d..5a48857b 100644 --- a/src/AcDream.App/UI/UiItemSlot.cs +++ b/src/AcDream.App/UI/UiItemSlot.cs @@ -37,6 +37,40 @@ public sealed class UiItemSlot : UiElement public void Clear() { ItemId = 0; IconTexture = 0; } + // ── Shortcut number (slot label) ───────────────────────────────────────── + // Port of UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465). + // Retail draws the digit on the cell's ShortcutNum sub-element, picking the + // digit image from a DID-array property: 0x10000042 (peace) / 0x10000043 (war), + // indexed by slot position. Each digit is a 32×32 PFID_A8R8G8B8 RenderSurface + // with the digit baked into the top-left corner (rest alpha=0), drawn Alphablend. + + /// Slot position in the shortcut bar (0-indexed). -1 = no number (retail + /// SetVisible(0) when edi < 0). Top row: 0..8 → digits 1..9. Bottom row: -1. + public int ShortcutNum { get; private set; } = -1; + + /// True = draw peace digit set; false = war digit set. + public bool ShortcutPeace { get; private set; } = true; + + /// Peace digit DID array. Index i → digit (i+1) sprite RenderSurface id. + /// Injected by the controller after reading LayoutDesc 0x21000037. + public uint[]? PeaceDigits { get; set; } + + /// War digit DID array. Same layout as PeaceDigits. + public uint[]? WarDigits { get; set; } + + /// Set the slot's shortcut position and combat stance so the correct digit + /// is drawn. Call with index 0..8 for the top row; pass peace=true for NonCombat. + public void SetShortcutNum(int index, bool peace) + { + ShortcutNum = index; + ShortcutPeace = peace; + } + + /// Clear the shortcut number label (hides the digit). + public void ClearShortcutNum() { ShortcutNum = -1; } + + // ── Events / draw ───────────────────────────────────────────────────────── + /// Invoked by when a left-button-down lands on /// a bound slot. Wired by ToolbarController to the use-item callback. public Action? Clicked { get; set; } @@ -50,16 +84,37 @@ public sealed class UiItemSlot : UiElement protected override void OnDraw(UiRenderContext ctx) { + // Draw the icon (filled slot) or the empty-slot border. Both paths fall + // through to the digit draw below, so the slot label shows on all top-row + // slots regardless of whether they hold an item (retail screenshot confirms + // numbers on empty slots). if (ItemId != 0 && IconTexture != 0) { ctx.DrawSprite(IconTexture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); - return; } - if (SpriteResolve is not null && EmptySprite != 0) + else if (SpriteResolve is not null && EmptySprite != 0) { var (tex, _, _) = SpriteResolve(EmptySprite); if (tex != 0) ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); } + + // Digit overlay: UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465). + // Each digit image is corner-baked (glyph in top-left, rest alpha=0) so we + // draw it full-cell size and the transparent region is invisible. DrawMode=Alphablend. + if (ShortcutNum >= 0 && SpriteResolve is not null) + { + var arr = ShortcutPeace ? PeaceDigits : WarDigits; + if (arr is not null && ShortcutNum < arr.Length) + { + uint did = arr[ShortcutNum]; + if (did != 0) + { + var (tex, _, _) = SpriteResolve(did); + if (tex != 0) + ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); + } + } + } } } diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs index 95b90a46..cda769ae 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs @@ -166,4 +166,110 @@ public class ToolbarControllerTests Assert.False(indicators[0x10000194u].Visible, "missile should be hidden in magic mode"); Assert.True (indicators[0x10000195u].Visible, "magic indicator should be visible"); } + + // ── D1: Shortcut number (slot label) tests ─────────────────────────────── + // Port of UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465); + // gmToolbarUI::RecvNotice_SetCombatMode (196610-196621). + + // Fake digit arrays: 9 peace entries (0x10..0x18), 9 war entries (0x20..0x28). + private static readonly uint[] FakePeace = { 0x10u,0x11u,0x12u,0x13u,0x14u,0x15u,0x16u,0x17u,0x18u }; + private static readonly uint[] FakeWar = { 0x20u,0x21u,0x22u,0x23u,0x24u,0x25u,0x26u,0x27u,0x28u }; + + /// + /// After Bind with peace/war digit arrays, top-row cells (indices 0–8) have + /// ShortcutNum == i (the slot position) and ShortcutPeace == true (default NonCombat). + /// Bottom-row cells (indices 9–17) have ShortcutNum == -1 (no label). + /// Retail: numbers are slot LABELS — shown on ALL top-row slots including empty ones. + /// + [Fact] + public void ShortcutNumbers_afterBind_topRowHasNumbers_bottomRowEmpty() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ItemRepository(); + + ToolbarController.Bind(layout, repo, + () => Array.Empty(), + iconIds: (_,_,_,_) => 0u, useItem: _ => { }, + peaceDigits: FakePeace, warDigits: FakeWar); + + // Top row: ShortcutNum == slot index, peace == true. + for (int i = 0; i < Row1.Length; i++) + { + var cell = slots[Row1[i]].Cell; + Assert.Equal(i, cell.ShortcutNum); + Assert.True(cell.ShortcutPeace, $"top-row slot {i} should be peace at NonCombat"); + } + // Bottom row: no shortcut number. + foreach (var id in Row2) + Assert.Equal(-1, slots[id].Cell.ShortcutNum); + } + + /// + /// After SetCombatMode(Melee), top-row cells switch to ShortcutPeace == false (war). + /// + [Fact] + public void ShortcutNumbers_setCombatModeWar_topRowUsesWarDigits() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ItemRepository(); + var ctrl = ToolbarController.Bind(layout, repo, + () => Array.Empty(), + iconIds: (_,_,_,_) => 0u, useItem: _ => { }, + peaceDigits: FakePeace, warDigits: FakeWar); + + ctrl.SetCombatMode(CombatMode.Melee); + + // Top row: still ShortcutNum == i, but now peace == false. + for (int i = 0; i < Row1.Length; i++) + { + var cell = slots[Row1[i]].Cell; + Assert.Equal(i, cell.ShortcutNum); + Assert.False(cell.ShortcutPeace, $"top-row slot {i} should be war after Melee"); + } + // Bottom row still has no number. + foreach (var id in Row2) + Assert.Equal(-1, slots[id].Cell.ShortcutNum); + } + + /// + /// After SetCombatMode back to NonCombat, top-row switches back to peace (ShortcutPeace == true). + /// + [Fact] + public void ShortcutNumbers_backToNonCombat_restoresPeaceDigits() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ItemRepository(); + var ctrl = ToolbarController.Bind(layout, repo, + () => Array.Empty(), + iconIds: (_,_,_,_) => 0u, useItem: _ => { }, + peaceDigits: FakePeace, warDigits: FakeWar); + + ctrl.SetCombatMode(CombatMode.Melee); + ctrl.SetCombatMode(CombatMode.NonCombat); + + for (int i = 0; i < Row1.Length; i++) + Assert.True(slots[Row1[i]].Cell.ShortcutPeace, + $"top-row slot {i} should be peace after returning to NonCombat"); + } + + /// + /// Digit arrays are correctly injected into each cell (PeaceDigits + WarDigits references). + /// + [Fact] + public void ShortcutNumbers_digitArraysInjected() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ItemRepository(); + + ToolbarController.Bind(layout, repo, + () => Array.Empty(), + iconIds: (_,_,_,_) => 0u, useItem: _ => { }, + peaceDigits: FakePeace, warDigits: FakeWar); + + foreach (var id in Row1) + { + Assert.Same(FakePeace, slots[id].Cell.PeaceDigits); + Assert.Same(FakeWar, slots[id].Cell.WarDigits); + } + } } diff --git a/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs b/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs index 489fdf65..70e8126c 100644 --- a/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs +++ b/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs @@ -38,4 +38,48 @@ public class UiItemSlotTests Assert.Equal(0u, s.ItemId); Assert.Equal(0u, s.IconTexture); } + + // ── Shortcut number tests ──────────────────────────────────────────────── + // Port of UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465). + + [Fact] + public void ShortcutNum_defaultIsMinusOne() + { + var s = new UiItemSlot(); + Assert.Equal(-1, s.ShortcutNum); + } + + [Fact] + public void ShortcutPeace_defaultIsTrue() + { + var s = new UiItemSlot(); + Assert.True(s.ShortcutPeace); + } + + [Fact] + public void SetShortcutNum_setsIndexAndPeace() + { + var s = new UiItemSlot(); + s.SetShortcutNum(3, peace: false); + Assert.Equal(3, s.ShortcutNum); + Assert.False(s.ShortcutPeace); + } + + [Fact] + public void SetShortcutNum_peaceTrue() + { + var s = new UiItemSlot(); + s.SetShortcutNum(0, peace: true); + Assert.Equal(0, s.ShortcutNum); + Assert.True(s.ShortcutPeace); + } + + [Fact] + public void ClearShortcutNum_setsMinusOne() + { + var s = new UiItemSlot(); + s.SetShortcutNum(5, peace: true); + s.ClearShortcutNum(); + Assert.Equal(-1, s.ShortcutNum); + } }