fix #212: preserve shortcut tint in physical combat

Port the retail m_bShortcutGhosted meaning instead of treating SetShortcutNum's boolean as peace versus war. Keep occupied shortcut numbers on the gold sheet in NonCombat, Melee, and Missile, reserve the gray sheet for Magic, and lock the four-mode matrix with conformance tests and corrected research.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-13 11:21:12 +02:00
parent ab98cda26b
commit 5090aa7217
9 changed files with 197 additions and 112 deletions

View file

@ -46,9 +46,39 @@ Copy this block when adding a new issue:
---
## #211 — Login-equipped missile weapon incorrectly requests melee combat
## #212 — Toolbar shortcut numbers turn gray in physical combat
**Status:** IN-PROGRESS — implementation complete 2026-07-13, pending user gate
**Severity:** LOW
**Component:** retained UI / toolbar
**Description:** Entering melee or missile combat changed the numbered overlay
on occupied toolbar shortcuts from the gold mesh to the gray mesh.
**Root cause:** The port interpreted `UIElement_UIItem::SetShortcutNum`'s Boolean
as peace versus war. Retail names the stored value `m_bShortcutGhosted`, and
`gmToolbarUI::RecvNotice_SetCombatMode` passes true only for Magic combat mode.
The false interpretation therefore ghosted every occupied shortcut in every
combat stance.
**Resolution:** Shortcut state and digit arrays now use retail's regular/ghosted
terminology. NonCombat, Melee, and Missile select property `0x10000042`; Magic
alone selects ghosted property `0x10000043`. Empty slots continue to use the
stance-independent `0x1000005E` array.
**Research:** `docs/research/2026-07-10-retail-toolbar-interaction-pseudocode.md`
§2.4; `UIElement_UIItem::SetShortcutNum @ 0x004E1590`;
`gmToolbarUI::RecvNotice_SetCombatMode @ 0x004BD610`.
**Acceptance:** Compare an occupied numbered toolbar slot in peace, melee, and
missile modes: the mesh remains gold/yellow. Enter Magic mode: physical-item
shortcuts use the gray ghosted mesh.
---
## #211 — Login-equipped missile weapon incorrectly requests melee combat
**Status:** DONE — 2026-07-13, user confirmed combat entry works (`ab98cda2`)
**Severity:** HIGH
**Component:** inventory projection / combat mode

View file

@ -40,7 +40,7 @@ Plus a special case at `407546` (`0058d1ee`): **`IsThePlayer`** → `m_idIcon =
- **`src/AcDream.Core/Items/ItemInstance.cs`** — has `IconId`, `IconUnderlayId`, `IconOverlayId`, `Type`. **No `Effects`/`UiEffects` field yet.**
- **`src/AcDream.Core/Items/ItemRepository.cs`** — `EnrichItem(objectId, iconId, name, type, iconOverlayId=0, iconUnderlayId=0)` writes the typed icon ids onto an existing item + fires `ItemPropertiesUpdated`. Threaded from `WorldSession.EntitySpawned``GameWindow.OnLiveEntitySpawned`.
- **`src/AcDream.App/UI/Layout/ToolbarController.cs`** — calls `iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId)` per slot, re-runs `Populate()` on `ItemRepository.ItemAdded`/`ItemPropertiesUpdated` (so a late `CreateObject` re-binds the slot's icon).
- **(related, not icon-composite)** the **slot-number** system (`SetShortcutNum`, 3 digit arrays: occupied peace/war `0x10000042`/`0x10000043` from cell composite `0x10000346`, empty/background `0x1000005e` from composite `0x10000341`) is done — it's a separate `UIElement_UIItem` feature, not the icon composite, but lives on the same widget.
- **(related, not icon-composite)** the **slot-number** system (`SetShortcutNum`, 3 digit arrays: occupied regular/ghosted `0x10000042`/`0x10000043` from cell composite `0x10000346`, empty/background `0x1000005e` from composite `0x10000341`) is done — it's a separate `UIElement_UIItem` feature, not the icon composite, but lives on the same widget. The Boolean is `m_bShortcutGhosted`, true only in Magic mode; it is not peace/war.
---

View file

@ -224,6 +224,45 @@ parse/store/write even though this toolbar ignores it. Do not add spell icons or
spell casting to `gmToolbarUI`; implement the eight favorite-spell lists as a
separate subsystem.
### 2.4 Physical-shortcut number ghosting
The Boolean parameter of `UIElement_UIItem::SetShortcutNum @ 0x004E1590` is
not a peace/war selector. `ACCWeenieObject::SetShortcutNum @ 0x0058C100`
stores it verbatim as `m_bShortcutGhosted`, and `UIElement_UIItem::Update`
replays that named field into the widget. The digit-array selection is:
```text
UIElement_UIItem.SetShortcutNum(slot, ghosted): // 0x004E1590
if slot < 0:
hide shortcut-number element
return
if item icon is in empty state 0x1000001C:
digits = property 0x1000005E // empty slot
else if ghosted:
digits = property 0x10000043 // gray mesh
else:
digits = property 0x10000042 // gold mesh
set number image to digits[slot]
show shortcut-number element
```
`gmToolbarUI::RecvNotice_SetCombatMode @ 0x004BD610` first stores
`mode != MAGIC_COMBAT_MODE`, then passes the inverse to every occupied
shortcut cell. In direct pseudocode:
```text
shortcutsGhosted = (mode == MAGIC_COMBAT_MODE)
for each occupied shortcut slot i:
cell.SetShortcutNum(i, shortcutsGhosted)
```
Therefore NonCombat, Melee, and Missile all use property `0x10000042`; only
Magic uses the ghosted `0x10000043` sheet. Calling these arrays “peace” and
“war” reverses the meaning and incorrectly grays physical shortcut numbers in
melee and missile combat.
---
## 3. `gmToolbarUI` input, use, and selection pseudocode

View file

@ -121,8 +121,8 @@ public static class FixtureProvider
iconIds: MakeIconIds(stack),
useItem: _ => { },
combatState: null,
peaceDigits: null,
warDigits: null,
regularDigits: null,
ghostedDigits: null,
emptyDigits: null,
sendAddShortcut: null,
sendRemoveShortcut: null);

View file

@ -81,14 +81,14 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
// Digit sprite DID arrays for slot labels (top row, numbers 1-9).
// 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.
// gmToolbarUI::RecvNotice_SetCombatMode (196610-196621) re-stamps ghosting.
// Occupancy branch (decomp 229481):
// occupied → peace 0x10000042 / war 0x10000043 (split by stance)
// occupied → regular 0x10000042 / ghosted 0x10000043
// empty → background digit 0x1000005e (stance-independent)
private uint[]? _peaceDigits;
private uint[]? _warDigits;
private uint[]? _regularDigits;
private uint[]? _ghostedDigits;
private uint[]? _emptyDigits;
private bool _peace = true; // true = NonCombat (peace), false = any war stance
private bool _shortcutsGhosted;
private ToolbarController(
ImportedLayout layout,
@ -97,8 +97,8 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
Action<uint> useItem,
CombatState? combatState,
uint[]? peaceDigits,
uint[]? warDigits,
uint[]? regularDigits,
uint[]? ghostedDigits,
uint[]? emptyDigits,
ItemInteractionController? itemInteraction = null,
Action<ShortcutEntry>? sendAddShortcut = null,
@ -117,8 +117,8 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
_iconIds = iconIds;
_dragIconIds = dragIconIds;
_useItem = useItem;
_peaceDigits = peaceDigits;
_warDigits = warDigits;
_regularDigits = regularDigits;
_ghostedDigits = ghostedDigits;
_emptyDigits = emptyDigits;
_itemInteraction = itemInteraction;
_sendAddShortcut = sendAddShortcut;
@ -294,13 +294,13 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
/// combat-mode indicator elements accordingly.
/// Pass null to skip live wiring (e.g. in unit tests that don't exercise the indicator).
/// </param>
/// <param name="peaceDigits">
/// Peace-mode digit DID array (property 0x10000042 from LayoutDesc 0x21000037 element
/// <param name="regularDigits">
/// Regular 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).
/// </param>
/// <param name="warDigits">War-mode digit DID array (property 0x10000043, same element).</param>
/// <param name="ghostedDigits">Ghosted digit DID array (property 0x10000043, same element).</param>
/// <param name="emptyDigits">
/// Empty-slot background digit DID array (property 0x1000005e, stance-independent).
/// Used when a slot is EMPTY (ItemId == 0). Retail ref: UIElement_UIItem::SetShortcutNum
@ -314,8 +314,8 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
Action<uint> useItem,
CombatState? combatState = null,
uint[]? peaceDigits = null,
uint[]? warDigits = null,
uint[]? regularDigits = null,
uint[]? ghostedDigits = null,
uint[]? emptyDigits = null,
ItemInteractionController? itemInteraction = null,
Action<ShortcutEntry>? sendAddShortcut = null,
@ -329,7 +329,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
Func<ItemType, uint, uint, uint, uint, uint>? dragIconIds = null)
{
var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState,
peaceDigits, warDigits, emptyDigits, itemInteraction,
regularDigits, ghostedDigits, emptyDigits, itemInteraction,
sendAddShortcut, sendRemoveShortcut, toggleCombat, selectItem,
selectedObjectId, playerGuid, sendPutItemInContainer, ammoFont,
dragIconIds);
@ -413,7 +413,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
// Re-stamp slot number labels after any item change.
// Digit SPRITE SOURCE depends on occupancy (decomp UIElement_UIItem::SetShortcutNum:229481):
// occupied → peace 0x10000042 / war 0x10000043; empty → background 0x1000005e.
// occupied → regular 0x10000042 / ghosted 0x10000043; empty → background 0x1000005e.
// The digit is ALWAYS shown on top-row slots (SetVisible(1) at decomp 229511).
RestampShortcutNumbers();
}
@ -444,21 +444,22 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
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);
// The bool passed to SetShortcutNum is m_bShortcutGhosted, not a peace/war
// selector. Retail ghosts physical-item shortcuts only in Magic mode.
// Retail ref: gmToolbarUI::RecvNotice_SetCombatMode @ 0x004BD610.
_shortcutsGhosted = mode == CombatMode.Magic;
RestampShortcutNumbers();
}
/// <summary>
/// Push digit-array references and shortcut-number state into every slot cell.
/// Top row (indices 08): SetShortcutNum(i, _peace) — numbers 19 always shown
/// Top row (indices 08): SetShortcutNum(i, _shortcutsGhosted) — numbers 19 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 917): 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;
/// Occupancy → source: occupied → regular 0x10000042 / ghosted 0x10000043;
/// empty → background 0x1000005e (decomp 229481/229493).
/// </summary>
private void RestampShortcutNumbers()
@ -467,11 +468,11 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
{
var cell = _slots[i]?.Cell;
if (cell is null) continue;
cell.PeaceDigits = _peaceDigits;
cell.WarDigits = _warDigits;
cell.RegularDigits = _regularDigits;
cell.GhostedDigits = _ghostedDigits;
cell.EmptyDigits = _emptyDigits;
if (i < 9)
cell.SetShortcutNum(i, _peace); // top row: slot label digits 19 always shown
cell.SetShortcutNum(i, _shortcutsGhosted); // top row: slot labels 19 always shown
else
cell.ClearShortcutNum(); // bottom row: no slot labels
}

View file

@ -403,11 +403,11 @@ public sealed class RetailUiRuntime : IDisposable
return;
}
var (peace, war, empty) = LoadToolbarDigits();
var (regular, ghosted, empty) = LoadToolbarDigits();
ToolbarRuntimeBindings b = _bindings.Toolbar;
ToolbarController = Layout.ToolbarController.Bind(
layout, b.Objects, b.Shortcuts, b.ResolveIcon, b.UseItem, b.Combat,
peace, war, empty, b.ItemInteraction, b.SendAddShortcut, b.SendRemoveShortcut,
regular, ghosted, empty, b.ItemInteraction, b.SendAddShortcut, b.SendRemoveShortcut,
toggleCombat: b.ToggleCombat,
selectItem: guid => b.Selection.Select(guid, SelectionChangeSource.Toolbar),
selectedObjectId: () => b.Selection.SelectedObjectId ?? 0u,
@ -586,9 +586,9 @@ public sealed class RetailUiRuntime : IDisposable
Console.WriteLine("[D.6] retail jump bar from gmFloatyPowerBarUI LayoutDesc 0x21000072.");
}
private (uint[] Peace, uint[] War, uint[]? Empty) LoadToolbarDigits()
private (uint[] Regular, uint[] Ghosted, uint[]? Empty) LoadToolbarDigits()
{
uint[]? peace = null, war = null, empty = null;
uint[]? regular = null, ghosted = null, empty = null;
lock (_bindings.Assets.DatLock)
{
var layout = _bindings.Assets.Dats.Get<DatReaderWriter.DBObjs.LayoutDesc>(0x21000037u);
@ -597,8 +597,8 @@ public sealed class RetailUiRuntime : IDisposable
&& composite.Children.TryGetValue(0x1000034Au, out var number)
&& number.StateDesc?.Properties is { } props)
{
peace = ReadDataIds(props, 0x10000042u);
war = ReadDataIds(props, 0x10000043u);
regular = ReadDataIds(props, 0x10000042u);
ghosted = ReadDataIds(props, 0x10000043u);
}
if (layout is not null
&& layout.Elements.TryGetValue(0x10000341u, out var emptyComposite)
@ -607,18 +607,18 @@ public sealed class RetailUiRuntime : IDisposable
empty = ReadDataIds(emptyProps, 0x1000005Eu);
}
peace ??=
regular ??=
[
0x0600109Eu, 0x0600109Fu, 0x060010A0u, 0x060010A1u, 0x060010A2u,
0x060010A3u, 0x060010A4u, 0x060010A5u, 0x060010A6u,
];
war ??=
ghosted ??=
[
0x06001ACCu, 0x06001ACDu, 0x06001ACEu, 0x06001ACFu, 0x06001AD0u,
0x06001AD1u, 0x06001AD2u, 0x06001AD3u, 0x06001AD4u,
];
Console.WriteLine($"[D.5.1] toolbar digit arrays ready: peace={peace.Length}, war={war.Length}, empty={empty?.Length ?? 0} entries.");
return (peace, war, empty);
Console.WriteLine($"[D.5.1] toolbar digit arrays ready: regular={regular.Length}, ghosted={ghosted.Length}, empty={empty?.Length ?? 0} entries.");
return (regular, ghosted, empty);
}
private static uint[]? ReadDataIds(

View file

@ -166,7 +166,7 @@ public sealed class UiItemSlot : UiElement
// ── 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),
// digit image from a DID-array property: 0x10000042 (regular) / 0x10000043 (ghosted),
// 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.
@ -174,30 +174,30 @@ public sealed class UiItemSlot : UiElement
/// SetVisible(0) when edi < 0). Top row: 0..8 → digits 1..9. Bottom row: -1.</summary>
public int ShortcutNum { get; private set; } = -1;
/// <summary>True = draw peace digit set; false = war digit set.</summary>
public bool ShortcutPeace { get; private set; } = true;
/// <summary>True when retail marks the physical-item shortcut as ghosted.</summary>
public bool ShortcutGhosted { get; private set; }
/// <summary>Peace digit DID array. Index i → digit (i+1) sprite RenderSurface id.
/// <summary>Regular digit DID array. Index i → digit (i+1) sprite RenderSurface id.
/// 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.</summary>
public uint[]? PeaceDigits { get; set; }
/// property 0x10000042 when the shortcut is not ghosted.</summary>
public uint[]? RegularDigits { get; set; }
/// <summary>War digit DID array. Same layout as PeaceDigits.
/// Retail ref: UIElement_UIItem::SetShortcutNum (decomp 229493) — war stance.</summary>
public uint[]? WarDigits { get; set; }
/// <summary>Ghosted digit DID array. Same layout as RegularDigits.
/// Retail ref: UIElement_UIItem::SetShortcutNum (decomp 229485-229488).</summary>
public uint[]? GhostedDigits { get; set; }
/// <summary>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).</summary>
public uint[]? EmptyDigits { get; set; }
/// <summary>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.</summary>
public void SetShortcutNum(int index, bool peace)
/// <summary>Set the slot's shortcut position and ghosted state so the correct digit
/// is drawn. Call with index 0..8 for the top row.</summary>
public void SetShortcutNum(int index, bool ghosted)
{
ShortcutNum = index;
ShortcutPeace = peace;
ShortcutGhosted = ghosted;
}
/// <summary>Clear the shortcut number label (hides the digit).</summary>
@ -206,7 +206,7 @@ public sealed class UiItemSlot : UiElement
/// <summary>
/// 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)
/// occupied (ItemId != 0) → ShortcutGhosted ? GhostedDigits : RegularDigits (0x10000043/42)
/// 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.
@ -214,7 +214,7 @@ public sealed class UiItemSlot : UiElement
internal uint[]? ActiveDigitArray()
{
bool occupied = ItemId != 0;
return occupied ? (ShortcutPeace ? PeaceDigits : WarDigits) : EmptyDigits;
return occupied ? (ShortcutGhosted ? GhostedDigits : RegularDigits) : EmptyDigits;
}
// ── Events / draw ─────────────────────────────────────────────────────────
@ -292,7 +292,7 @@ public sealed class UiItemSlot : UiElement
// Digit overlay: UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465).
// Occupancy branch (decomp 229481):
// occupied (ItemId != 0) → peace/war digit set 0x10000042/43, split by stance
// occupied (ItemId != 0) → regular/ghosted digit set 0x10000042/43
// 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.

View file

@ -681,15 +681,15 @@ 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 regular entries (0x10..0x18), 9 ghosted 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[] FakeRegular = { 0x10u,0x11u,0x12u,0x13u,0x14u,0x15u,0x16u,0x17u,0x18u };
private static readonly uint[] FakeGhosted = { 0x20u,0x21u,0x22u,0x23u,0x24u,0x25u,0x26u,0x27u,0x28u };
private static readonly uint[] FakeEmpty = { 0x30u,0x31u,0x32u,0x33u,0x34u,0x35u,0x36u,0x37u,0x38u };
/// <summary>
/// After Bind with peace/war digit arrays, top-row cells (indices 08) have
/// ShortcutNum == i (the slot position) and ShortcutPeace == true (default NonCombat).
/// After Bind with regular/ghosted digit arrays, top-row cells (indices 08) have
/// ShortcutNum == i (the slot position) and ShortcutGhosted == false (default NonCombat).
/// Bottom-row cells (indices 917) have ShortcutNum == -1 (no label).
/// Retail: numbers are slot LABELS — shown on ALL top-row slots including empty ones.
/// </summary>
@ -702,14 +702,14 @@ public class ToolbarControllerTests
ToolbarController.Bind(layout, repo,
() => Array.Empty<ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { },
peaceDigits: FakePeace, warDigits: FakeWar);
regularDigits: FakeRegular, ghostedDigits: FakeGhosted);
// Top row: ShortcutNum == slot index, peace == true.
// Top row: ShortcutNum == slot index, ghosted == false.
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");
Assert.False(cell.ShortcutGhosted, $"top-row slot {i} should be regular at NonCombat");
}
// Bottom row: no shortcut number.
foreach (var id in Row2)
@ -717,26 +717,31 @@ public class ToolbarControllerTests
}
/// <summary>
/// After SetCombatMode(Melee), top-row cells switch to ShortcutPeace == false (war).
/// Retail keeps physical shortcut labels regular in NonCombat, Melee, and Missile.
/// gmToolbarUI::RecvNotice_SetCombatMode @ 0x004BD610 passes ghosted=true only for Magic.
/// </summary>
[Fact]
public void ShortcutNumbers_setCombatModeWar_topRowUsesWarDigits()
[Theory]
[InlineData(CombatMode.NonCombat)]
[InlineData(CombatMode.Melee)]
[InlineData(CombatMode.Missile)]
public void ShortcutNumbers_nonMagicModes_useRegularDigits(CombatMode mode)
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable();
var ctrl = ToolbarController.Bind(layout, repo,
() => Array.Empty<ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { },
peaceDigits: FakePeace, warDigits: FakeWar);
regularDigits: FakeRegular, ghostedDigits: FakeGhosted);
ctrl.SetCombatMode(CombatMode.Melee);
ctrl.SetCombatMode(mode);
// 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");
Assert.False(cell.ShortcutGhosted, $"top-row slot {i} should be regular in {mode}");
cell.SetItem((uint)(0x5000 + i), 0x99u);
Assert.Same(FakeRegular, cell.ActiveDigitArray());
}
// Bottom row still has no number.
foreach (var id in Row2)
@ -744,28 +749,38 @@ public class ToolbarControllerTests
}
/// <summary>
/// After SetCombatMode back to NonCombat, top-row switches back to peace (ShortcutPeace == true).
/// Magic mode ghosts physical shortcut labels, and returning to NonCombat restores them.
/// </summary>
[Fact]
public void ShortcutNumbers_backToNonCombat_restoresPeaceDigits()
public void ShortcutNumbers_magicGhosts_thenNonCombatRestoresRegularDigits()
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable();
var ctrl = ToolbarController.Bind(layout, repo,
() => Array.Empty<ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { },
peaceDigits: FakePeace, warDigits: FakeWar);
regularDigits: FakeRegular, ghostedDigits: FakeGhosted);
ctrl.SetCombatMode(CombatMode.Magic);
foreach (var id in Row1)
{
slots[id].Cell.SetItem(id, 0x99u);
Assert.True(slots[id].Cell.ShortcutGhosted);
Assert.Same(FakeGhosted, slots[id].Cell.ActiveDigitArray());
}
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");
{
Assert.False(slots[Row1[i]].Cell.ShortcutGhosted,
$"top-row slot {i} should be regular after returning to NonCombat");
Assert.Same(FakeRegular, slots[Row1[i]].Cell.ActiveDigitArray());
}
}
/// <summary>
/// Digit arrays are correctly injected into each cell (PeaceDigits + WarDigits references).
/// Digit arrays are correctly injected into each cell.
/// </summary>
[Fact]
public void ShortcutNumbers_digitArraysInjected()
@ -776,12 +791,12 @@ public class ToolbarControllerTests
ToolbarController.Bind(layout, repo,
() => Array.Empty<ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { },
peaceDigits: FakePeace, warDigits: FakeWar);
regularDigits: FakeRegular, ghostedDigits: FakeGhosted);
foreach (var id in Row1)
{
Assert.Same(FakePeace, slots[id].Cell.PeaceDigits);
Assert.Same(FakeWar, slots[id].Cell.WarDigits);
Assert.Same(FakeRegular, slots[id].Cell.RegularDigits);
Assert.Same(FakeGhosted, slots[id].Cell.GhostedDigits);
}
}
@ -798,7 +813,7 @@ public class ToolbarControllerTests
ToolbarController.Bind(layout, repo,
() => Array.Empty<ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { },
peaceDigits: FakePeace, warDigits: FakeWar, emptyDigits: FakeEmpty);
regularDigits: FakeRegular, ghostedDigits: FakeGhosted, emptyDigits: FakeEmpty);
foreach (var id in Row1)
Assert.Same(FakeEmpty, slots[id].Cell.EmptyDigits);
@ -819,7 +834,7 @@ public class ToolbarControllerTests
ToolbarController.Bind(layout, repo,
() => Array.Empty<ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { },
peaceDigits: FakePeace, warDigits: FakeWar, emptyDigits: null);
regularDigits: FakeRegular, ghostedDigits: FakeGhosted, emptyDigits: null);
foreach (var id in Row1)
Assert.Null(slots[id].Cell.EmptyDigits);

View file

@ -66,95 +66,95 @@ public class UiItemSlotTests
}
[Fact]
public void ShortcutPeace_defaultIsTrue()
public void ShortcutGhosted_defaultsFalse()
{
var s = new UiItemSlot();
Assert.True(s.ShortcutPeace);
Assert.False(s.ShortcutGhosted);
}
[Fact]
public void SetShortcutNum_setsIndexAndPeace()
public void SetShortcutNum_setsIndexAndGhostedState()
{
var s = new UiItemSlot();
s.SetShortcutNum(3, peace: false);
s.SetShortcutNum(3, ghosted: true);
Assert.Equal(3, s.ShortcutNum);
Assert.False(s.ShortcutPeace);
Assert.True(s.ShortcutGhosted);
}
[Fact]
public void SetShortcutNum_peaceTrue()
public void SetShortcutNum_regularState()
{
var s = new UiItemSlot();
s.SetShortcutNum(0, peace: true);
s.SetShortcutNum(0, ghosted: false);
Assert.Equal(0, s.ShortcutNum);
Assert.True(s.ShortcutPeace);
Assert.False(s.ShortcutGhosted);
}
[Fact]
public void ClearShortcutNum_setsMinusOne()
{
var s = new UiItemSlot();
s.SetShortcutNum(5, peace: true);
s.SetShortcutNum(5, ghosted: false);
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[] Regular = { 0x10u, 0x11u, 0x12u };
private static readonly uint[] Ghosted = { 0x20u, 0x21u, 0x22u };
private static readonly uint[] Empty = { 0x30u, 0x31u, 0x32u };
/// <summary>
/// When ItemId == 0 (empty slot), ActiveDigitArray returns EmptyDigits regardless
/// of ShortcutPeace. Retail ref: UIElement_UIItem::SetShortcutNum (decomp 229481) —
/// of ShortcutGhosted. Retail ref: UIElement_UIItem::SetShortcutNum (decomp 229481) —
/// else branch when m_elem_Icon->m_state == 0x1000001c (empty).
/// </summary>
[Fact]
public void ActiveDigitArray_emptySlot_returnsEmptyDigits()
{
var s = new UiItemSlot { PeaceDigits = Peace, WarDigits = War, EmptyDigits = Empty };
s.SetShortcutNum(0, peace: true);
var s = new UiItemSlot { RegularDigits = Regular, GhostedDigits = Ghosted, EmptyDigits = Empty };
s.SetShortcutNum(0, ghosted: false);
// ItemId == 0 → EmptyDigits
Assert.Same(Empty, s.ActiveDigitArray());
}
[Fact]
public void ActiveDigitArray_emptySlot_warStance_stillReturnsEmptyDigits()
public void ActiveDigitArray_emptySlot_ghosted_stillReturnsEmptyDigits()
{
var s = new UiItemSlot { PeaceDigits = Peace, WarDigits = War, EmptyDigits = Empty };
s.SetShortcutNum(0, peace: false);
var s = new UiItemSlot { RegularDigits = Regular, GhostedDigits = Ghosted, EmptyDigits = Empty };
s.SetShortcutNum(0, ghosted: true);
// ItemId == 0 → EmptyDigits regardless of stance
Assert.Same(Empty, s.ActiveDigitArray());
}
/// <summary>
/// When ItemId != 0 (occupied), ActiveDigitArray returns PeaceDigits or WarDigits
/// depending on ShortcutPeace. Retail ref: UIElement_UIItem::SetShortcutNum (decomp 229481/229493).
/// When ItemId != 0 (occupied), ActiveDigitArray returns RegularDigits or GhostedDigits
/// depending on ShortcutGhosted. Retail ref: UIElement_UIItem::SetShortcutNum (decomp 229481/229493).
/// </summary>
[Fact]
public void ActiveDigitArray_occupiedSlot_peaceStance_returnsPeaceDigits()
public void ActiveDigitArray_occupiedSlot_regular_returnsRegularDigits()
{
var s = new UiItemSlot { PeaceDigits = Peace, WarDigits = War, EmptyDigits = Empty };
var s = new UiItemSlot { RegularDigits = Regular, GhostedDigits = Ghosted, EmptyDigits = Empty };
s.SetItem(0x5001u, 0x99u);
s.SetShortcutNum(0, peace: true);
Assert.Same(Peace, s.ActiveDigitArray());
s.SetShortcutNum(0, ghosted: false);
Assert.Same(Regular, s.ActiveDigitArray());
}
[Fact]
public void ActiveDigitArray_occupiedSlot_warStance_returnsWarDigits()
public void ActiveDigitArray_occupiedSlot_ghosted_returnsGhostedDigits()
{
var s = new UiItemSlot { PeaceDigits = Peace, WarDigits = War, EmptyDigits = Empty };
var s = new UiItemSlot { RegularDigits = Regular, GhostedDigits = Ghosted, EmptyDigits = Empty };
s.SetItem(0x5001u, 0x99u);
s.SetShortcutNum(0, peace: false);
Assert.Same(War, s.ActiveDigitArray());
s.SetShortcutNum(0, ghosted: true);
Assert.Same(Ghosted, s.ActiveDigitArray());
}
[Fact]
public void ActiveDigitArray_emptySlot_nullEmptyDigits_returnsNull()
{
var s = new UiItemSlot { PeaceDigits = Peace, WarDigits = War, EmptyDigits = null };
s.SetShortcutNum(0, peace: true);
var s = new UiItemSlot { RegularDigits = Regular, GhostedDigits = Ghosted, EmptyDigits = null };
s.SetShortcutNum(0, ghosted: false);
Assert.Null(s.ActiveDigitArray());
}