refactor(runtime): close canonical gameplay ownership

Unify the toolbar shortcut manager with Runtime inventory state, route retail-ordered shortcut and spellbook command effects through the canonical owners, and make retained controllers borrow those exact instances. Remove the item-interaction transaction fallback and add graphical/no-window parity plus failure-safe terminal ownership-ledger coverage.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-26 09:48:51 +02:00
parent ce6fae7b38
commit 89e6b207f8
36 changed files with 1433 additions and 251 deletions

View file

@ -62,7 +62,6 @@ public sealed class ItemInteractionController : IDisposable
private readonly AutoWieldController _autoWield;
private readonly Action<uint, ItemUseRequestReservation>? _requestUse;
private readonly InventoryTransactionState _transactions;
private readonly bool _ownsTransactions;
private long _lastUseMs = long.MinValue / 2;
private uint _consumedPrimaryClickTarget;
@ -74,6 +73,7 @@ public sealed class ItemInteractionController : IDisposable
public ItemInteractionController(
ClientObjectTable objects,
InventoryTransactionState transactions,
Func<uint> playerGuid,
Action<uint>? sendUse,
Action<uint, uint>? sendUseWithTarget,
@ -103,8 +103,7 @@ public sealed class ItemInteractionController : IDisposable
Action<uint>? requestExternalContainer = null,
CombatState? combatState = null,
Action<CombatMode>? sendChangeCombatMode = null,
Action<uint, ItemUseRequestReservation>? requestUse = null,
InventoryTransactionState? transactions = null)
Action<uint, ItemUseRequestReservation>? requestUse = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
@ -135,34 +134,23 @@ public sealed class ItemInteractionController : IDisposable
_systemMessage = systemMessage;
_requestUse = requestUse;
_interactionState = interactionState ?? new InteractionState();
_transactions = transactions ?? new InventoryTransactionState(_objects);
_ownsTransactions = transactions is null;
_transactions = transactions
?? throw new ArgumentNullException(nameof(transactions));
if (!ReferenceEquals(_transactions.Objects, _objects))
{
if (_ownsTransactions)
_transactions.Dispose();
throw new ArgumentException(
"The inventory transaction owner must borrow the controller's exact object table.",
nameof(transactions));
}
try
{
_autoWield = new AutoWieldController(
_objects,
_playerGuid,
_sendWield,
sendPutItemInContainer,
_toast,
_systemMessage,
combatState,
sendChangeCombatMode);
}
catch
{
if (_ownsTransactions)
_transactions.Dispose();
throw;
}
_autoWield = new AutoWieldController(
_objects,
_playerGuid,
_sendWield,
sendPutItemInContainer,
_toast,
_systemMessage,
combatState,
sendChangeCombatMode);
_interactionState.Changed += OnInteractionModeChanged;
_transactions.StateChanged += OnTransactionStateChanged;
_transactions.RequestCompleted += OnInventoryRequestCompleted;
@ -1166,8 +1154,6 @@ public sealed class ItemInteractionController : IDisposable
_transactions.ObjectTableCleared -= OnInventoryObjectsCleared;
_transactions.RequestCompleted -= OnInventoryRequestCompleted;
_transactions.StateChanged -= OnTransactionStateChanged;
if (_ownsTransactions)
_transactions.Dispose();
_autoWield.Dispose();
}

View file

@ -251,7 +251,6 @@ public sealed class SpellbookWindowController : IRetainedPanelController
// gmSpellbookUI::UpdateFilter @ 0x0048B5E0 persists only the changed
// filter bitfield, rebuilds from learned spells, and returns to row zero.
uint filters = _spellbook.SpellbookFilters ^ mask;
_spellbook.SetSpellbookFilters(filters);
_sendFilter(filters);
_spellList.Scroll.SetScrollY(0);
}
@ -383,7 +382,6 @@ public sealed class SpellbookWindowController : IRetainedPanelController
}
if (parsed == committed) return;
committed = parsed;
_spellbook.SetDesiredComponent(componentId, parsed);
_setDesiredComponent(componentId, parsed);
}
desiredField.OnSubmit = Commit;

View file

@ -209,7 +209,6 @@ public sealed class SpellcastingUiController : IRetainedPanelController
}
int position = spells.Count;
_addFavorite?.Invoke(_activeTab, position, spellId);
_spellbook.SetFavorite(_activeTab, position, spellId);
SelectSpell(spellId);
}
@ -424,13 +423,15 @@ public sealed class SpellcastingUiController : IRetainedPanelController
private void BeginFavoriteDrag(SpellFavoriteDragPayload payload)
=> _removeFavorite?.Invoke(payload.SourceTab, payload.SpellId);
private void EndFavoriteDrag(SpellFavoriteDragPayload payload)
=> _spellbook.RemoveFavorite(payload.SourceTab, payload.SpellId);
private static void EndFavoriteDrag(SpellFavoriteDragPayload payload)
{
// The press-time command already performed retail's PlayerModule
// removal on the one Runtime-owned Spellbook.
}
private void DropFavorite(SpellFavoriteDragPayload payload, int targetTab, int targetPosition)
{
_addFavorite?.Invoke(targetTab, targetPosition, payload.SpellId);
_spellbook.SetFavorite(targetTab, targetPosition, payload.SpellId);
_selected[targetTab] = payload.SpellId;
}
@ -443,7 +444,6 @@ public sealed class SpellcastingUiController : IRetainedPanelController
// CM_Magic::SendNotice_AddSpellShortcut; the open spellcasting menu
// chooses the favorite tab/position and persists it.
_addFavorite?.Invoke(targetTab, targetPosition, payload.SpellId);
_spellbook.SetFavorite(targetTab, targetPosition, payload.SpellId);
_selected[targetTab] = payload.SpellId;
}

View file

@ -63,12 +63,10 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
private readonly List<(uint PanelId, UiButton Button)> _panelButtons = new();
private readonly ClientObjectTable _repo;
private readonly CombatState? _combatState;
private readonly Func<IReadOnlyList<ShortcutEntry>> _shortcuts;
private readonly ShortcutStore _store;
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex
private readonly Func<ItemType, uint, uint, uint, uint, uint>? _dragIconIds;
private readonly Action<uint> _useItem; // guid → fire UseObject
private readonly ShortcutStore _store = new();
private bool _storeLoaded;
private readonly Action<ShortcutEntry>? _sendAddShortcut;
private readonly Action<uint>? _sendRemoveShortcut; // (index)
private readonly ItemInteractionController? _itemInteraction;
@ -95,7 +93,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
private ToolbarController(
ImportedLayout layout,
ClientObjectTable repo,
Func<IReadOnlyList<ShortcutEntry>> shortcuts,
ShortcutStore shortcuts,
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
Action<uint> useItem,
CombatState? combatState,
@ -116,7 +114,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
{
_repo = repo;
_combatState = combatState;
_shortcuts = shortcuts;
_store = shortcuts ?? throw new ArgumentNullException(nameof(shortcuts));
_iconIds = iconIds;
_dragIconIds = dragIconIds;
_useItem = useItem;
@ -213,6 +211,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
repo.ObjectRemoved += OnRepositoryObjectChanged;
repo.ObjectMoved += OnRepositoryObjectMoved;
repo.Cleared += OnRepositoryCleared;
_store.Changed += Populate;
RefreshAmmo();
RefreshUseButton();
}
@ -296,19 +295,11 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
/// Returns true if <paramref name="guid"/> is one of the currently-active shortcut guids
/// (i.e., present in the live <see cref="AcDream.Core.Items.ShortcutStore"/>).
/// Used to gate repo-event subscriptions so we don't re-populate on every creature spawn.
/// Falls back to scanning <c>_shortcuts()</c> before the store is loaded (pre-PD window).
/// </summary>
private bool IsShortcutGuid(uint guid)
{
if (_storeLoaded)
{
for (int s = 0; s < AcDream.Core.Items.ShortcutStore.SlotCount; s++)
if (_store.Get(s) == guid) return true;
return false;
}
// Store not yet loaded — fall back to the shortcuts provider (pre-PD window).
foreach (var sc in _shortcuts())
if (sc.ObjectId == guid) return true;
for (int s = 0; s < ShortcutStore.SlotCount; s++)
if (_store.Get(s) == guid) return true;
return false;
}
@ -320,7 +311,10 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
/// </summary>
/// <param name="layout">Imported toolbar layout (LayoutDesc 0x21000016).</param>
/// <param name="repo">Live item repository — must stay alive for the controller's lifetime.</param>
/// <param name="shortcuts">Provider for the current shortcut bar list.</param>
/// <param name="shortcuts">
/// Runtime-owned retail shortcut manager. The toolbar borrows this exact
/// mutable owner; it never reconstructs another slot map.
/// </param>
/// <param name="iconIds">Resolves (itemType, iconId, underlayId, overlayId, effects) → GL texture handle.</param>
/// <param name="useItem">Callback fired when a bound slot is clicked; receives the item guid.</param>
/// <param name="combatState">
@ -345,7 +339,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
public static ToolbarController Bind(
ImportedLayout layout,
ClientObjectTable repo,
Func<IReadOnlyList<ShortcutEntry>> shortcuts,
ShortcutStore shortcuts,
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
Action<uint> useItem,
CombatState? combatState = null,
@ -417,19 +411,11 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
/// Entries whose item is not yet in the repo are silently skipped here; the
/// <c>ObjectAdded</c> event re-fires this method when the item arrives
/// (matching retail's <c>SetDelayedShortcutNum</c> deferred-rebind path).
/// As of B.2: the <see cref="AcDream.Core.Items.ShortcutStore"/> is the
/// authoritative in-session slot map; <c>_shortcuts()</c> seeds it on first call.
/// The Runtime-owned <see cref="AcDream.Core.Items.ShortcutStore"/> is the
/// authoritative in-session slot map.
/// </summary>
public void Populate()
{
// Lazy-load the store from the login shortcut list the first time it's present (PD arrives
// after Bind); thereafter the store is authoritative and drag ops mutate it directly.
if (!_storeLoaded && _shortcuts().Count > 0)
{
_store.Load(_shortcuts());
_storeLoaded = true;
}
foreach (var list in _slots) list?.Cell.Clear();
for (int slot = 0; slot < _slots.Length; slot++)
@ -654,7 +640,6 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
if (_itemInteraction?.IsOwnedByPlayer(itemId) != true)
return false;
EnsureStoreLoadedForMutation();
for (int slot = 0; slot < _slots.Length; slot++)
if (_store.Get(slot) == itemId)
return false;
@ -672,20 +657,11 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
return false;
var entry = new ShortcutEntry(empty, itemId, 0u);
_store.Set(entry);
_sendAddShortcut?.Invoke(entry);
Populate();
_store.Set(entry);
return true;
}
private void EnsureStoreLoadedForMutation()
{
if (_storeLoaded)
return;
_store.Load(_shortcuts());
_storeLoaded = true;
}
// ── IItemListDragHandler (B.2 live handler) ──────────────────────────────
// Retail: gmToolbarUI is the m_dragHandler for every shortcut slot list.
// Retail model (remove-on-lift / place-on-drop / no-restore):
@ -705,9 +681,8 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
// Retail RecvNotice_ItemListBeginDrag → RemoveShortcut (0x004bd930/0x004bd450): the lifted
// shortcut leaves the bar (+ wire) the instant the drag starts; it's re-placed only on a
// drop onto a slot. Off-bar release leaves it removed.
_store.Remove(payload.SourceSlot);
_sendRemoveShortcut?.Invoke((uint)payload.SourceSlot);
Populate();
_store.Remove(payload.SourceSlot);
}
/// <inheritdoc/>
@ -717,7 +692,6 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
/// <inheritdoc/>
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
{
EnsureStoreLoadedForMutation();
ShortcutDropSource source = payload.SourceKind == ItemDragSource.ShortcutBar
? ShortcutDropSource.ShortcutAlias
: ShortcutDropSource.FreshItem;
@ -727,7 +701,6 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
_store.Snapshot(), source, payload.SourceSlot, targetCell.SlotIndex, dragged);
ApplyShortcutPlan(plan);
Populate();
}
/// <summary>
@ -736,32 +709,29 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
/// </summary>
public void ReplaceFullyMergedShortcut(uint oldObjectId, uint newObjectId)
{
EnsureStoreLoadedForMutation();
ShortcutMutation[] plan = ShortcutDropPlanner.PlanFullStackMerge(
_store.Snapshot(), oldObjectId, newObjectId);
ApplyShortcutPlan(plan);
if (plan.Length > 0)
Populate();
}
private void ApplyShortcutPlan(IReadOnlyList<ShortcutMutation> plan)
{
// Apply the already-validated transaction locally first, then reproduce the
// planner's exact Remove/Add wire order. Shortcut events have no rejection reply.
foreach (var mutation in plan)
{
if (mutation.Kind == ShortcutMutationKind.Remove)
_store.Remove(mutation.Slot);
else if (mutation.Entry is { } entry)
_store.Set(entry);
}
// Retail sends each shortcut event immediately before applying the same
// PlayerModule mutation (gmToolbarUI @ 0x004BD450/0x004BD9A0).
// Production commands mutate this exact Runtime-owned store; the
// idempotent local call also keeps isolated controller fixtures useful.
foreach (var mutation in plan)
{
if (mutation.Kind == ShortcutMutationKind.Remove)
{
_sendRemoveShortcut?.Invoke((uint)mutation.Slot);
_store.Remove(mutation.Slot);
}
else if (mutation.Entry is { } entry)
{
_sendAddShortcut?.Invoke(entry);
_store.Set(entry);
}
}
}
@ -800,5 +770,6 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
_repo.ObjectRemoved -= OnRepositoryObjectChanged;
_repo.ObjectMoved -= OnRepositoryObjectMoved;
_repo.Cleared -= OnRepositoryCleared;
_store.Changed -= Populate;
}
}

View file

@ -90,7 +90,7 @@ public sealed record IndicatorRuntimeBindings(
public sealed record ToolbarRuntimeBindings(
ClientObjectTable Objects,
Func<IReadOnlyList<ShortcutEntry>> Shortcuts,
ShortcutStore Shortcuts,
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon,
Func<ItemType, uint, uint, uint, uint, uint> ResolveDragIcon,
Action<uint> UseItem,