From a7cad5566ba1b32ff2fd3f85b160e3216f5985b0 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 17 Jun 2026 14:27:27 +0200 Subject: [PATCH] =?UTF-8?q?=EF=BB=BFfix(D.5.1):=20occupancy-gated=20slot?= =?UTF-8?q?=20numbers=20(empty=3D0x1000005e=20bg=20digit)=20+=20bottom-rig?= =?UTF-8?q?ht=20rect=20probe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FIX 1: UIElement_UIItem::SetShortcutNum (decomp 229481) has a three-way source branch: occupied+peace -> 0x10000042 (peace digit set), occupied+war -> 0x10000043 (war digit set), empty (ItemId==0) -> 0x1000005e (background digit, stance-independent). acdream previously only had the peace/war pair and drew them regardless of occupancy. Changes: - GameWindow.cs: read property 0x1000005e into toolbarEmptyDigits (no fallback; null is safe). Logs entry count. Passes emptyDigits to Bind. Adds [D.5.1 probe] block logging screen pos + size of 7 bottom-right element ids via ScreenPosition. - ToolbarController.cs: adds _emptyDigits field, emptyDigits ctor+Bind param (null default). RestampShortcutNumbers sets cell.EmptyDigits. Comments cite decomp 229481. - UiItemSlot.cs: adds EmptyDigits property + ActiveDigitArray() internal testable seam (occupied -> peace/war by stance; empty -> EmptyDigits). OnDraw uses it. Comment updated with three-way source table. - Tests: 5 new UiItemSlotTests (ActiveDigitArray occupancy), 2 new ToolbarControllerTests (emptyDigits injection + null-safe). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 46 +++++++++++++-- .../UI/Layout/ToolbarController.cs | 34 ++++++++--- src/AcDream.App/UI/UiItemSlot.cs | 41 ++++++++++--- .../UI/Layout/ToolbarControllerTests.cs | 44 +++++++++++++- tests/AcDream.App.Tests/UI/UiItemSlotTests.cs | 59 +++++++++++++++++++ 5 files changed, 200 insertions(+), 24 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 99478954..57419a17 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1913,12 +1913,15 @@ public sealed class GameWindow : IDisposable // (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. + // (the UIItem cell template): 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. + // Occupancy branch (decomp 229481): + // occupied → StateDesc.Properties[0x10000042] (peace) / [0x10000043] (war) + // empty → StateDesc.Properties[0x1000005e] (background digit, stance-independent) uint[]? toolbarPeaceDigits = null; uint[]? toolbarWarDigits = null; + uint[]? toolbarEmptyDigits = null; lock (_datLock) { var uiItemLd = _dats!.Get(0x21000037u); @@ -1948,6 +1951,19 @@ public sealed class GameWindow : IDisposable if (arrWar.Value[i] is DatReaderWriter.Types.DataIdBaseProperty d) toolbarWarDigits[i] = d.Value; } + // Empty-slot background digit: property 0x1000005e, stance-independent. + // Retail ref: UIElement_UIItem::SetShortcutNum (decomp 229481) — + // else branch when m_elem_Icon->m_state == 0x1000001c (empty state). + // No fallback constants — if absent, empty slots draw no digit (safe). + if (props.TryGetValue(0x1000005Eu, out var rawEmpty) + && rawEmpty is DatReaderWriter.Types.ArrayBaseProperty arrEmpty) + { + toolbarEmptyDigits = new uint[arrEmpty.Value.Count]; + for (int i = 0; i < arrEmpty.Value.Count; i++) + if (arrEmpty.Value[i] is DatReaderWriter.Types.DataIdBaseProperty d) + toolbarEmptyDigits[i] = d.Value; + } + Console.WriteLine($"[D.5.1] empty digit array: {toolbarEmptyDigits?.Length ?? 0} entries."); } else { @@ -1966,7 +1982,7 @@ public sealed class GameWindow : IDisposable { 0x06001ACCu, 0x06001ACDu, 0x06001ACEu, 0x06001ACFu, 0x06001AD0u, 0x06001AD1u, 0x06001AD2u, 0x06001AD3u, 0x06001AD4u }; // Report the arrays actually used (after any fallback substitution). - Console.WriteLine($"[D.5.1] toolbar digit arrays ready: peace={toolbarPeaceDigits.Length}, war={toolbarWarDigits.Length} entries."); + Console.WriteLine($"[D.5.1] toolbar digit arrays ready: peace={toolbarPeaceDigits.Length}, war={toolbarWarDigits.Length}, empty={toolbarEmptyDigits?.Length ?? 0} entries."); AcDream.App.UI.Layout.ImportedLayout? toolbarLayout; lock (_datLock) @@ -1980,8 +1996,9 @@ public sealed class GameWindow : IDisposable iconIds: (type, icon, under, over) => iconComposer.GetIcon(type, icon, under, over), useItem: guid => UseItemByGuid(guid), combatState: Combat, - peaceDigits: toolbarPeaceDigits, - warDigits: toolbarWarDigits); + peaceDigits: toolbarPeaceDigits, + warDigits: toolbarWarDigits, + emptyDigits: toolbarEmptyDigits); var toolbarRoot = toolbarLayout.Root; toolbarRoot.Left = 10; toolbarRoot.Top = 300; @@ -1993,6 +2010,25 @@ public sealed class GameWindow : IDisposable toolbarRoot.ClickThrough = false; toolbarRoot.Draggable = true; _uiHost.Root.AddChild(toolbarRoot); + + // [D.5.1 PROBE] Bottom-right geometry rect dump — temporary diagnostic. + // Localises the bottom-right mismatch reported by the user; remove once fixed. + // ScreenPosition walks Parent chain (UiElement.cs:54-63); Left/Top are parent-relative. + // IDs: root=0x10000191, backpack-btn=0x100001B1, backpack-drag=0x1000046C, + // last top slot=0x100001AF, last bottom slot=0x100006BF, + // row1 right-cap=0x100001B0, row2 right-cap=0x100006C0. + { + uint[] probeIds = { 0x10000191u, 0x100001B1u, 0x1000046Cu, 0x100001AFu, 0x100006BFu, 0x100001B0u, 0x100006C0u }; + foreach (var pid in probeIds) + { + var pe = toolbarLayout.FindElement(pid); + if (pe is not null) + Console.WriteLine($"[D.5.1 probe] 0x{pid:X8} ({pe.GetType().Name}): screen=({pe.ScreenPosition.X:F1},{pe.ScreenPosition.Y:F1}) left={pe.Left:F1} top={pe.Top:F1} w={pe.Width:F1} h={pe.Height:F1}"); + else + Console.WriteLine($"[D.5.1 probe] 0x{pid:X8}: not found in layout"); + } + } + Console.WriteLine("[D.5.1] retail toolbar window from LayoutDesc importer (0x21000016)."); } else Console.WriteLine("[D.5.1] toolbar: LayoutDesc 0x21000016 not found."); diff --git a/src/AcDream.App/UI/Layout/ToolbarController.cs b/src/AcDream.App/UI/Layout/ToolbarController.cs index 73de76cd..5ebd61da 100644 --- a/src/AcDream.App/UI/Layout/ToolbarController.cs +++ b/src/AcDream.App/UI/Layout/ToolbarController.cs @@ -55,12 +55,15 @@ public sealed class ToolbarController 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. + // Occupancy branch (decomp 229481): + // occupied → peace 0x10000042 / war 0x10000043 (split by stance) + // empty → background digit 0x1000005e (stance-independent) private uint[]? _peaceDigits; private uint[]? _warDigits; + private uint[]? _emptyDigits; private bool _peace = true; // true = NonCombat (peace), false = any war stance private ToolbarController( @@ -71,7 +74,8 @@ public sealed class ToolbarController Action useItem, CombatState? combatState, uint[]? peaceDigits, - uint[]? warDigits) + uint[]? warDigits, + uint[]? emptyDigits) { _repo = repo; _shortcuts = shortcuts; @@ -79,6 +83,7 @@ public sealed class ToolbarController _useItem = useItem; _peaceDigits = peaceDigits; _warDigits = warDigits; + _emptyDigits = emptyDigits; for (int i = 0; i < SlotIds.Length; i++) { @@ -133,6 +138,12 @@ public sealed class ToolbarController /// UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465). /// /// War-mode digit DID array (property 0x10000043, same element). + /// + /// Empty-slot background digit DID array (property 0x1000005e, stance-independent). + /// Used when a slot is EMPTY (ItemId == 0). Retail ref: UIElement_UIItem::SetShortcutNum + /// (decomp 229481) — else branch when m_elem_Icon->m_state == 0x1000001c (empty state). + /// Null if the dat lookup failed (empty slots draw no digit, which is safe). + /// public static ToolbarController Bind( ImportedLayout layout, ItemRepository repo, @@ -141,10 +152,11 @@ public sealed class ToolbarController Action useItem, CombatState? combatState = null, uint[]? peaceDigits = null, - uint[]? warDigits = null) + uint[]? warDigits = null, + uint[]? emptyDigits = null) { var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState, - peaceDigits, warDigits); + peaceDigits, warDigits, emptyDigits); c.Populate(); return c; } @@ -176,8 +188,9 @@ public sealed class ToolbarController } // 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. + // Digit SPRITE SOURCE depends on occupancy (decomp UIElement_UIItem::SetShortcutNum:229481): + // occupied → peace 0x10000042 / war 0x10000043; empty → background 0x1000005e. + // The digit is ALWAYS shown on top-row slots (SetVisible(1) at decomp 229511). RestampShortcutNumbers(); } @@ -215,12 +228,14 @@ public sealed class ToolbarController /// /// 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). + /// Top row (indices 0–8): SetShortcutNum(i, _peace) — numbers 1–9 always shown + /// (the digit is ALWAYS visible, SetVisible(1) at decomp 229511; only the sprite + /// SOURCE differs by occupancy — see UIElement_UIItem::SetShortcutNum decomp 229481). /// 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). + /// Occupancy → source: occupied → peace 0x10000042 / war 0x10000043; + /// empty → background 0x1000005e (decomp 229481/229493). /// private void RestampShortcutNumbers() { @@ -230,6 +245,7 @@ public sealed class ToolbarController if (cell is null) continue; cell.PeaceDigits = _peaceDigits; cell.WarDigits = _warDigits; + cell.EmptyDigits = _emptyDigits; if (i < 9) cell.SetShortcutNum(i, _peace); // top row: slot label digits 1–9 always shown else diff --git a/src/AcDream.App/UI/UiItemSlot.cs b/src/AcDream.App/UI/UiItemSlot.cs index 5a48857b..d3ff3b7d 100644 --- a/src/AcDream.App/UI/UiItemSlot.cs +++ b/src/AcDream.App/UI/UiItemSlot.cs @@ -52,12 +52,20 @@ public sealed class UiItemSlot : UiElement 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. + /// Injected by the controller after reading LayoutDesc 0x21000037. + /// Retail ref: UIElement_UIItem::SetShortcutNum (decomp 229481) — occupied slot picks + /// property 0x10000042 (peace) or 0x10000043 (war) by stance. public uint[]? PeaceDigits { get; set; } - /// War digit DID array. Same layout as PeaceDigits. + /// War digit DID array. Same layout as PeaceDigits. + /// Retail ref: UIElement_UIItem::SetShortcutNum (decomp 229493) — war stance. public uint[]? WarDigits { get; set; } + /// Empty-slot digit DID array (property 0x1000005e, stance-independent). + /// Used when the slot is EMPTY (ItemId == 0). Retail ref: UIElement_UIItem::SetShortcutNum + /// (decomp 229481) — else branch when m_elem_Icon->m_state == 0x1000001c (empty). + public uint[]? EmptyDigits { 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) @@ -69,6 +77,20 @@ public sealed class UiItemSlot : UiElement /// Clear the shortcut number label (hides the digit). public void ClearShortcutNum() { ShortcutNum = -1; } + /// + /// Returns the digit DID array that OnDraw will use, following the retail occupancy + /// branch in UIElement_UIItem::SetShortcutNum (decomp 229481): + /// occupied (ItemId != 0) → ShortcutPeace ? PeaceDigits : WarDigits (0x10000042/43) + /// empty (ItemId == 0) → EmptyDigits (0x1000005e, stance-independent) + /// Exposed as an internal method so unit tests can assert array selection without + /// needing a real render context. + /// + internal uint[]? ActiveDigitArray() + { + bool occupied = ItemId != 0; + return occupied ? (ShortcutPeace ? PeaceDigits : WarDigits) : EmptyDigits; + } + // ── Events / draw ───────────────────────────────────────────────────────── /// Invoked by when a left-button-down lands on @@ -84,10 +106,8 @@ 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). + // Draw the icon (filled slot) or the empty-slot border. Both paths fall through + // to the digit draw below; the slot label always shows on top-row slots. if (ItemId != 0 && IconTexture != 0) { ctx.DrawSprite(IconTexture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); @@ -100,11 +120,14 @@ public sealed class UiItemSlot : UiElement } // 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. + // Occupancy branch (decomp 229481): + // occupied (ItemId != 0) → peace/war digit set 0x10000042/43, split by stance + // empty (ItemId == 0) → background digit set 0x1000005e, stance-independent + // Each digit image is corner-baked (glyph in top-left, rest alpha=0); drawn + // full-cell Alphablend so the transparent region is invisible. if (ShortcutNum >= 0 && SpriteResolve is not null) { - var arr = ShortcutPeace ? PeaceDigits : WarDigits; + var arr = ActiveDigitArray(); if (arr is not null && ShortcutNum < arr.Length) { uint did = arr[ShortcutNum]; diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs index cda769ae..91c14e46 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs @@ -171,9 +171,11 @@ public class ToolbarControllerTests // 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). + // Fake digit arrays: 9 peace entries (0x10..0x18), 9 war entries (0x20..0x28), + // 9 empty (background) entries (0x30..0x38). 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 }; + private static readonly uint[] FakeEmpty = { 0x30u,0x31u,0x32u,0x33u,0x34u,0x35u,0x36u,0x37u,0x38u }; /// /// After Bind with peace/war digit arrays, top-row cells (indices 0–8) have @@ -272,4 +274,44 @@ public class ToolbarControllerTests Assert.Same(FakeWar, slots[id].Cell.WarDigits); } } + + /// + /// EmptyDigits (0x1000005e background digit) is injected into every slot cell. + /// Retail ref: UIElement_UIItem::SetShortcutNum (decomp 229481) — empty-slot branch. + /// + [Fact] + public void ShortcutNumbers_emptyDigitArrayInjected() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ItemRepository(); + + ToolbarController.Bind(layout, repo, + () => Array.Empty(), + iconIds: (_,_,_,_) => 0u, useItem: _ => { }, + peaceDigits: FakePeace, warDigits: FakeWar, emptyDigits: FakeEmpty); + + foreach (var id in Row1) + Assert.Same(FakeEmpty, slots[id].Cell.EmptyDigits); + foreach (var id in Row2) + Assert.Same(FakeEmpty, slots[id].Cell.EmptyDigits); + } + + /// + /// When emptyDigits is null, cells have EmptyDigits == null (no digit on empty slots). + /// This is the safe fallback when the dat property 0x1000005e is absent. + /// + [Fact] + public void ShortcutNumbers_nullEmptyDigits_cellsHaveNullEmptyDigits() + { + var (layout, slots, _) = FakeToolbar(); + var repo = new ItemRepository(); + + ToolbarController.Bind(layout, repo, + () => Array.Empty(), + iconIds: (_,_,_,_) => 0u, useItem: _ => { }, + peaceDigits: FakePeace, warDigits: FakeWar, emptyDigits: null); + + foreach (var id in Row1) + Assert.Null(slots[id].Cell.EmptyDigits); + } } diff --git a/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs b/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs index 70e8126c..f99e9dc5 100644 --- a/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs +++ b/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs @@ -82,4 +82,63 @@ public class UiItemSlotTests s.ClearShortcutNum(); Assert.Equal(-1, s.ShortcutNum); } + + // ── ActiveDigitArray occupancy gating (decomp UIElement_UIItem::SetShortcutNum:229481) ── + + private static readonly uint[] Peace = { 0x10u, 0x11u, 0x12u }; + private static readonly uint[] War = { 0x20u, 0x21u, 0x22u }; + private static readonly uint[] Empty = { 0x30u, 0x31u, 0x32u }; + + /// + /// When ItemId == 0 (empty slot), ActiveDigitArray returns EmptyDigits regardless + /// of ShortcutPeace. Retail ref: UIElement_UIItem::SetShortcutNum (decomp 229481) — + /// else branch when m_elem_Icon->m_state == 0x1000001c (empty). + /// + [Fact] + public void ActiveDigitArray_emptySlot_returnsEmptyDigits() + { + var s = new UiItemSlot { PeaceDigits = Peace, WarDigits = War, EmptyDigits = Empty }; + s.SetShortcutNum(0, peace: true); + // ItemId == 0 → EmptyDigits + Assert.Same(Empty, s.ActiveDigitArray()); + } + + [Fact] + public void ActiveDigitArray_emptySlot_warStance_stillReturnsEmptyDigits() + { + var s = new UiItemSlot { PeaceDigits = Peace, WarDigits = War, EmptyDigits = Empty }; + s.SetShortcutNum(0, peace: false); + // ItemId == 0 → EmptyDigits regardless of stance + Assert.Same(Empty, s.ActiveDigitArray()); + } + + /// + /// When ItemId != 0 (occupied), ActiveDigitArray returns PeaceDigits or WarDigits + /// depending on ShortcutPeace. Retail ref: UIElement_UIItem::SetShortcutNum (decomp 229481/229493). + /// + [Fact] + public void ActiveDigitArray_occupiedSlot_peaceStance_returnsPeaceDigits() + { + var s = new UiItemSlot { PeaceDigits = Peace, WarDigits = War, EmptyDigits = Empty }; + s.SetItem(0x5001u, 0x99u); + s.SetShortcutNum(0, peace: true); + Assert.Same(Peace, s.ActiveDigitArray()); + } + + [Fact] + public void ActiveDigitArray_occupiedSlot_warStance_returnsWarDigits() + { + var s = new UiItemSlot { PeaceDigits = Peace, WarDigits = War, EmptyDigits = Empty }; + s.SetItem(0x5001u, 0x99u); + s.SetShortcutNum(0, peace: false); + Assert.Same(War, s.ActiveDigitArray()); + } + + [Fact] + public void ActiveDigitArray_emptySlot_nullEmptyDigits_returnsNull() + { + var s = new UiItemSlot { PeaceDigits = Peace, WarDigits = War, EmptyDigits = null }; + s.SetShortcutNum(0, peace: true); + Assert.Null(s.ActiveDigitArray()); + } }