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

@ -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.Equal(i, cell.ShortcutNum);
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());
}