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

@ -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;
ShortcutNum = index;
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.