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

@ -285,6 +285,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
DeferredSelectionUiAuthority selection = late.Selection;
return new ItemInteractionController(
d.Inventory.Objects,
d.Inventory.Transactions,
playerGuid: () => d.PlayerIdentity.ServerGuid,
sendUse: null,
sendExamine: guid => session.CurrentSession?.SendAppraise(guid),
@ -334,8 +335,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
{
d.Inventory.ExternalContainers.RequestOpen(guid);
},
requestUse: selection.RequestUse,
transactions: d.Inventory.Transactions);
requestUse: selection.RequestUse);
}
public RetainedUiComposition CreateRetainedUi(
@ -592,7 +592,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
d.Window.Close),
Toolbar: new ToolbarRuntimeBindings(
d.Inventory.Objects,
() => d.Inventory.Shortcuts.Items,
d.Inventory.Shortcuts,
iconComposer.GetIcon,
iconComposer.GetDragIcon,
guid => late.Session.TryUseItem(guid, d.Log),

View file

@ -254,7 +254,7 @@ internal sealed class LiveSessionRuntimeFactory
private LiveInventorySessionBindings CreateInventoryBindings() => new(
_domain.Inventory.Objects,
PlayerGuid: () => _player.Identity.ServerGuid,
OnShortcuts: _domain.Inventory.Shortcuts.Replace,
OnShortcuts: _domain.Inventory.Shortcuts.Load,
OnUseDone: error =>
{
_domain.Inventory.ExternalContainers.ApplyUseDone(error);

View file

@ -71,6 +71,8 @@ internal sealed class CurrentGameRuntimeAdapter
sessionHost,
commands,
_view,
inventory,
character,
selectionState,
selection,
gameplayInput,

View file

@ -5,6 +5,7 @@ using AcDream.App.Net;
using AcDream.Core.Selection;
using AcDream.Core.Items;
using AcDream.Runtime;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Input;
@ -31,6 +32,8 @@ internal sealed class CurrentGameRuntimeCommandAdapter
private readonly LiveSessionHost _sessionHost;
private readonly ICommandBus _commands;
private readonly CurrentGameRuntimeViewAdapter _view;
private readonly RuntimeInventoryState _inventory;
private readonly RuntimeCharacterState _character;
private readonly SelectionState _selectionState;
private readonly SelectionInteractionController _selection;
private readonly GameplayInputFrameController _gameplayInput;
@ -42,6 +45,8 @@ internal sealed class CurrentGameRuntimeCommandAdapter
LiveSessionHost sessionHost,
ICommandBus commands,
CurrentGameRuntimeViewAdapter view,
RuntimeInventoryState inventory,
RuntimeCharacterState character,
SelectionState selectionState,
SelectionInteractionController selection,
GameplayInputFrameController gameplayInput,
@ -52,6 +57,8 @@ internal sealed class CurrentGameRuntimeCommandAdapter
_sessionHost = sessionHost ?? throw new ArgumentNullException(nameof(sessionHost));
_commands = commands ?? throw new ArgumentNullException(nameof(commands));
_view = view ?? throw new ArgumentNullException(nameof(view));
_inventory = inventory ?? throw new ArgumentNullException(nameof(inventory));
_character = character ?? throw new ArgumentNullException(nameof(character));
_selectionState = selectionState
?? throw new ArgumentNullException(nameof(selectionState));
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
@ -288,8 +295,13 @@ internal sealed class CurrentGameRuntimeCommandAdapter
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
if (command.Index < 0
|| (command.ObjectId == 0u && command.SpellId == 0u))
var entry = new ShortcutEntry(
command.Index,
command.ObjectId,
command.SpellId);
if (!_inventory.TryAddShortcut(
entry,
() => _commands.Publish(new AddShortcutRuntimeCmd(entry))))
{
return EmitResult(
RuntimeCommandDomain.InventoryState,
@ -298,10 +310,6 @@ internal sealed class CurrentGameRuntimeCommandAdapter
command.ObjectId);
}
_commands.Publish(new AddShortcutRuntimeCmd(new ShortcutEntry(
command.Index,
command.ObjectId,
command.SpellId)));
return EmitResult(
RuntimeCommandDomain.InventoryState,
operation: 0,
@ -316,7 +324,10 @@ internal sealed class CurrentGameRuntimeCommandAdapter
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
if (index < 0)
if (!_inventory.TryRemoveShortcut(
index,
() => _commands.Publish(
new RemoveShortcutRuntimeCmd((uint)index))))
{
return EmitResult(
RuntimeCommandDomain.InventoryState,
@ -324,7 +335,6 @@ internal sealed class CurrentGameRuntimeCommandAdapter
RuntimeCommandStatus.Rejected);
}
_commands.Publish(new RemoveShortcutRuntimeCmd((uint)index));
return EmitResult(
RuntimeCommandDomain.InventoryState,
operation: 1,
@ -340,9 +350,14 @@ internal sealed class CurrentGameRuntimeCommandAdapter
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
if ((uint)tabIndex >= 8u
|| position < 0
|| spellId == 0u)
if (!_character.TryAddFavorite(
tabIndex,
position,
spellId,
() => _commands.Publish(new AddFavoriteRuntimeCmd(
spellId,
position,
tabIndex))))
{
return EmitResult(
RuntimeCommandDomain.Spellbook,
@ -351,10 +366,6 @@ internal sealed class CurrentGameRuntimeCommandAdapter
spellId);
}
_commands.Publish(new AddFavoriteRuntimeCmd(
spellId,
position,
tabIndex));
return EmitResult(
RuntimeCommandDomain.Spellbook,
operation: 0,
@ -370,7 +381,11 @@ internal sealed class CurrentGameRuntimeCommandAdapter
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
if ((uint)tabIndex >= 8u || spellId == 0u)
if (!_character.TryRemoveFavorite(
tabIndex,
spellId,
() => _commands.Publish(
new RemoveFavoriteRuntimeCmd(spellId, tabIndex))))
{
return EmitResult(
RuntimeCommandDomain.Spellbook,
@ -379,7 +394,6 @@ internal sealed class CurrentGameRuntimeCommandAdapter
spellId);
}
_commands.Publish(new RemoveFavoriteRuntimeCmd(spellId, tabIndex));
return EmitResult(
RuntimeCommandDomain.Spellbook,
operation: 1,
@ -394,7 +408,10 @@ internal sealed class CurrentGameRuntimeCommandAdapter
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
_commands.Publish(new SetSpellbookFilterRuntimeCmd(filters));
_character.SetSpellbookFilter(
filters,
() => _commands.Publish(
new SetSpellbookFilterRuntimeCmd(filters)));
return EmitResult(
RuntimeCommandDomain.Spellbook,
operation: 2,
@ -433,7 +450,12 @@ internal sealed class CurrentGameRuntimeCommandAdapter
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
if (componentId == 0u)
if (!_character.TrySetDesiredComponent(
componentId,
amount,
() => _commands.Publish(new SetDesiredComponentRuntimeCmd(
componentId,
amount))))
{
return EmitResult(
RuntimeCommandDomain.Spellbook,
@ -442,9 +464,6 @@ internal sealed class CurrentGameRuntimeCommandAdapter
componentId);
}
_commands.Publish(new SetDesiredComponentRuntimeCmd(
componentId,
amount));
return EmitResult(
RuntimeCommandDomain.Spellbook,
operation: 4,
@ -458,7 +477,9 @@ internal sealed class CurrentGameRuntimeCommandAdapter
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
_commands.Publish(new ClearDesiredComponentsRuntimeCmd());
_character.ClearDesiredComponents(
() => _commands.Publish(
new ClearDesiredComponentsRuntimeCmd()));
return EmitResult(
RuntimeCommandDomain.Spellbook,
operation: 5,

View file

@ -117,7 +117,7 @@ public static class FixtureProvider
return ToolbarController.Bind(
layout,
objects,
shortcuts: () => System.Array.Empty<ShortcutEntry>(),
shortcuts: new ShortcutStore(),
iconIds: MakeIconIds(stack),
useItem: _ => { },
combatState: null,
@ -141,6 +141,7 @@ public static class FixtureProvider
var selection = new AcDream.Core.Selection.SelectionState();
var itemInteraction = new ItemInteractionController(
objects,
new InventoryTransactionState(objects),
() => SampleData.PlayerGuid,
sendUse: null,
sendUseWithTarget: null,

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,

View file

@ -42,6 +42,15 @@ public sealed class ChatCommandTargetState : IDisposable
}
}
public bool IsDisposed
{
get
{
lock (_gate)
return _disposed;
}
}
/// <summary>
/// Forget character-scoped command targets without clearing visible
/// transcript history.

View file

@ -9,10 +9,40 @@ namespace AcDream.Core.Items;
/// <see cref="ShortcutEntry"/> fields, including non-object records that the object-only
/// <c>gmToolbarUI</c> does not render.
/// </summary>
public sealed class ShortcutStore
public sealed class ShortcutStore : IDisposable
{
public const int SlotCount = 18;
private readonly ShortcutEntry?[] _entries = new ShortcutEntry?[SlotCount];
private ShortcutEntry[] _items = [];
private Action? _changed;
private long _revision;
private long _dispatchFailureCount;
private bool _disposed;
/// <summary>
/// Raised synchronously after the canonical slot map changes. One failing
/// observer cannot prevent another host from seeing the same mutation.
/// </summary>
public event Action? Changed
{
add
{
ObjectDisposedException.ThrowIf(_disposed, this);
_changed += value;
}
remove => _changed -= value;
}
/// <summary>Stable packed-order view of all present shortcut records.</summary>
public IReadOnlyList<ShortcutEntry> Items => Volatile.Read(ref _items);
public int Count => Volatile.Read(ref _items).Length;
public long Revision => Interlocked.Read(ref _revision);
public int SubscriberCount => _changed?.GetInvocationList().Length ?? 0;
public long DispatchFailureCount =>
Interlocked.Read(ref _dispatchFailureCount);
public Exception? LastDispatchFailure { get; private set; }
public bool IsDisposed => _disposed;
/// <summary>
/// Replace all slots from packed entries. Invalid signed indices are rejected exactly
@ -21,9 +51,12 @@ public sealed class ShortcutStore
/// </summary>
public void Load(IEnumerable<ShortcutEntry> entries)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(entries);
Array.Clear(_entries);
foreach (var entry in entries)
Set(entry);
SetCore(entry);
PublishChanged();
}
/// <summary>Raw entry at <paramref name="slot"/>, or null for absent/out-of-range.</summary>
@ -44,8 +77,9 @@ public sealed class ShortcutStore
public void Set(ShortcutEntry entry)
{
if ((uint)entry.Index < SlotCount)
_entries[entry.Index] = entry;
ObjectDisposedException.ThrowIf(_disposed, this);
if (SetCore(entry))
PublishChanged();
}
/// <summary>Create/replace a retail object shortcut; toolbar-created records use spell word zero.</summary>
@ -53,7 +87,79 @@ public sealed class ShortcutStore
public void Remove(int slot)
{
if ((uint)slot < SlotCount)
_entries[slot] = null;
ObjectDisposedException.ThrowIf(_disposed, this);
if ((uint)slot >= SlotCount || _entries[slot] is null)
return;
_entries[slot] = null;
PublishChanged();
}
/// <summary>
/// Clear the complete retail shortcut manager while retaining this owner
/// for the next session.
/// </summary>
public void Clear()
{
ObjectDisposedException.ThrowIf(_disposed, this);
Array.Clear(_entries);
PublishChanged();
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
Array.Clear(_entries);
RebuildItems();
Interlocked.Increment(ref _revision);
DispatchChanged();
_changed = null;
}
private bool SetCore(ShortcutEntry entry)
{
if ((uint)entry.Index >= SlotCount
|| _entries[entry.Index] == entry)
{
return false;
}
_entries[entry.Index] = entry;
return true;
}
private void PublishChanged()
{
RebuildItems();
Interlocked.Increment(ref _revision);
DispatchChanged();
}
private void RebuildItems()
{
var items = new List<ShortcutEntry>(SlotCount);
for (int i = 0; i < _entries.Length; i++)
{
if (_entries[i] is ShortcutEntry entry)
items.Add(entry);
}
Volatile.Write(ref _items, items.ToArray());
}
private void DispatchChanged()
{
Delegate[] observers = _changed?.GetInvocationList() ?? [];
foreach (Delegate observer in observers)
{
try
{
((Action)observer)();
}
catch (Exception error)
{
Interlocked.Increment(ref _dispatchFailureCount);
LastDispatchFailure = error;
}
}
}
}

View file

@ -401,11 +401,25 @@ public sealed class Spellbook
public void SetDesiredComponent(uint componentId, uint amount)
{
uint current = _desiredComponents.TryGetValue(componentId, out uint existing)
? existing : 0u;
if (current == amount) return;
if (amount == 0) _desiredComponents.Remove(componentId);
else _desiredComponents[componentId] = amount;
bool exists = _desiredComponents.TryGetValue(
componentId,
out uint current);
if (exists && current == amount) return;
// PlayerModule::SetDesiredCompLevel @ 0x005D4940 retains zero as a
// real table value; only ClearDesiredCompList destroys the entry set.
_desiredComponents[componentId] = amount;
DesiredComponentsChanged?.Invoke();
StateChanged?.Invoke();
}
/// <summary>
/// Destroy the complete desired-component list, matching
/// <c>PlayerModule::ClearDesiredCompList @ 0x005D2A00</c>.
/// </summary>
public void ClearDesiredComponents()
{
if (_desiredComponents.Count == 0) return;
_desiredComponents.Clear();
DesiredComponentsChanged?.Invoke();
StateChanged?.Invoke();
}

View file

@ -1,9 +1,41 @@
using AcDream.Core.Player;
using AcDream.Core.Net.Messages;
using AcDream.Core.Spells;
using AcDream.Core.Items;
namespace AcDream.Runtime.Gameplay;
public readonly record struct RuntimeCharacterOwnershipSnapshot(
bool IsDisposed,
bool InternalSubscriptionsAttached,
int LearnedSpellCount,
int ActiveEnchantmentCount,
int DesiredComponentCount,
int FavoriteSpellCount,
int VitalCount,
int AttributeCount,
int SkillCount,
int PositionCount,
int PropertyCount,
bool OptionsAreDefaults,
bool MovementSkillsAreReset)
{
public bool IsConverged =>
IsDisposed
&& !InternalSubscriptionsAttached
&& LearnedSpellCount == 0
&& ActiveEnchantmentCount == 0
&& DesiredComponentCount == 0
&& FavoriteSpellCount == 0
&& VitalCount == 0
&& AttributeCount == 0
&& SkillCount == 0
&& PositionCount == 0
&& PropertyCount == 0
&& OptionsAreDefaults
&& MovementSkillsAreReset;
}
/// <summary>
/// Canonical presentation-independent owner for the local character's magic
/// and player-sheet state. The two objects form one lifetime group because
@ -14,6 +46,7 @@ public sealed class RuntimeCharacterState : IDisposable
private bool _disposed;
private long _characterRevision;
private long _spellbookRevision;
private bool _internalSubscriptionsAttached;
public RuntimeCharacterState(SpellTable? spellTable = null)
{
@ -26,6 +59,7 @@ public sealed class RuntimeCharacterState : IDisposable
LocalPlayer.Changed += OnVitalChanged;
LocalPlayer.AttributeChanged += OnAttributeChanged;
LocalPlayer.CharacterChanged += OnCharacterChanged;
_internalSubscriptionsAttached = true;
}
public Spellbook Spellbook { get; }
@ -35,6 +69,57 @@ public sealed class RuntimeCharacterState : IDisposable
public IRuntimeCharacterView View { get; }
public bool IsDisposed => _disposed;
public RuntimeCharacterOwnershipSnapshot CaptureOwnership()
{
int favoriteCount = 0;
for (int tab = 0; tab < 8; tab++)
favoriteCount += Spellbook.GetFavorites(tab).Count;
int vitalCount = 0;
foreach (LocalPlayerState.VitalKind kind
in Enum.GetValues<LocalPlayerState.VitalKind>())
{
if (LocalPlayer.Get(kind) is not null)
vitalCount++;
}
int attributeCount = 0;
foreach (LocalPlayerState.AttributeKind kind
in Enum.GetValues<LocalPlayerState.AttributeKind>())
{
if (LocalPlayer.GetAttribute(kind) is not null)
attributeCount++;
}
PropertyBundle properties = LocalPlayer.Properties;
int propertyCount =
properties.Bools.Count
+ properties.Ints.Count
+ properties.Int64s.Count
+ properties.Floats.Count
+ properties.Strings.Count
+ properties.DataIds.Count
+ properties.InstanceIds.Count;
RuntimeCharacterOptionsSnapshot options = Options.Snapshot;
return new RuntimeCharacterOwnershipSnapshot(
_disposed,
_internalSubscriptionsAttached,
Spellbook.LearnedCount,
Spellbook.ActiveCount,
Spellbook.DesiredComponents.Count,
favoriteCount,
vitalCount,
attributeCount,
LocalPlayer.Skills.Count,
LocalPlayer.Positions.Count,
propertyCount,
options.Options1 == RuntimeCharacterOptionsState.DefaultOptions1
&& options.Options2
== RuntimeCharacterOptionsState.DefaultOptions2,
MovementSkills.RunSkill == -1
&& MovementSkills.JumpSkill == -1);
}
/// <summary>
/// Installs immutable DAT metadata without transferring its ownership to
/// Runtime. The content host may install one table after portal.dat opens.
@ -57,6 +142,93 @@ public sealed class RuntimeCharacterState : IDisposable
LocalPlayer.Clear();
}
/// <summary>
/// Apply retail's local favorite insertion before sending the matching
/// character event.
/// </summary>
public bool TryAddFavorite(
int tabIndex,
int position,
uint spellId,
Action publishOutbound)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(publishOutbound);
if ((uint)tabIndex >= 8u || position < 0 || spellId == 0u)
return false;
Spellbook.SetFavorite(tabIndex, position, spellId);
publishOutbound();
return true;
}
public bool TryRemoveFavorite(
int tabIndex,
uint spellId,
Action publishOutbound)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(publishOutbound);
if ((uint)tabIndex >= 8u || spellId == 0u)
return false;
Spellbook.RemoveFavorite(tabIndex, spellId);
publishOutbound();
return true;
}
/// <summary>
/// Apply and publish a spellbook filter only when it differs, matching
/// <c>gmSpellbookUI::UpdateFilter @ 0x0048B5E0</c>.
/// </summary>
public void SetSpellbookFilter(
uint filters,
Action publishOutbound)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(publishOutbound);
if (Spellbook.SpellbookFilters == filters)
return;
Spellbook.SetSpellbookFilters(filters);
publishOutbound();
}
/// <summary>
/// Retail publishes the desired-component event before changing its local
/// PlayerModule table.
/// </summary>
public bool TrySetDesiredComponent(
uint componentId,
uint amount,
Action publishOutbound)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(publishOutbound);
if (componentId == 0u || amount > 5000u)
return false;
try
{
publishOutbound();
}
finally
{
Spellbook.SetDesiredComponent(componentId, amount);
}
return true;
}
public void ClearDesiredComponents(Action publishOutbound)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(publishOutbound);
try
{
publishOutbound();
}
finally
{
Spellbook.ClearDesiredComponents();
}
}
/// <summary>
/// Clears both coupled owners while retaining every failed suffix for a
/// retry. State mutation happens before the existing synchronous
@ -82,12 +254,32 @@ public sealed class RuntimeCharacterState : IDisposable
{
if (_disposed)
return;
ResetSession();
Spellbook.StateChanged -= OnSpellbookChanged;
LocalPlayer.Changed -= OnVitalChanged;
LocalPlayer.AttributeChanged -= OnAttributeChanged;
LocalPlayer.CharacterChanged -= OnCharacterChanged;
_disposed = true;
List<Exception>? failures = null;
try
{
// Do not call ResetSession as one opaque step here. Terminal
// disposal must run every suffix even when an external UI observer
// throws from one Core owner's synchronous clear notification.
Try(Spellbook.Clear, ref failures);
Try(LocalPlayer.Clear, ref failures);
Try(Options.ResetSession, ref failures);
Try(MovementSkills.ResetSession, ref failures);
}
finally
{
Spellbook.StateChanged -= OnSpellbookChanged;
LocalPlayer.Changed -= OnVitalChanged;
LocalPlayer.AttributeChanged -= OnAttributeChanged;
LocalPlayer.CharacterChanged -= OnCharacterChanged;
_internalSubscriptionsAttached = false;
_disposed = true;
}
if (failures is not null)
{
throw new AggregateException(
"Runtime character state did not converge during disposal.",
failures);
}
}
private void OnSpellbookChanged() =>

View file

@ -17,6 +17,36 @@ public interface IRuntimeCommunicationEventSource
IDisposable Subscribe(IRuntimeCommunicationObserver observer);
}
public readonly record struct RuntimeCommunicationOwnershipSnapshot(
bool IsDisposed,
bool CommandTargetsDisposed,
int StreamSubscriberCount,
int PendingDispatchCount,
bool IsDispatching,
int FriendCount,
int SquelchAccountCount,
int SquelchCharacterCount,
int SquelchGlobalTypeCount,
int NegotiatedRoomCount,
bool HasReplyTarget,
bool HasRetellTarget,
long DispatchFailureCount)
{
public bool IsConverged =>
IsDisposed
&& CommandTargetsDisposed
&& StreamSubscriberCount == 0
&& PendingDispatchCount == 0
&& !IsDispatching
&& FriendCount == 0
&& SquelchAccountCount == 0
&& SquelchCharacterCount == 0
&& SquelchGlobalTypeCount == 0
&& NegotiatedRoomCount == 0
&& !HasReplyTarget
&& !HasRetellTarget;
}
/// <summary>
/// Canonical presentation-independent owner for communication and social
/// state. Graphical, headless, plugin, and bot hosts borrow these exact
@ -59,6 +89,25 @@ public sealed class RuntimeCommunicationState : IDisposable
public long DispatchFailureCount => _events.DispatchFailureCount;
public Exception? LastDispatchFailure => _events.LastDispatchFailure;
public RuntimeCommunicationOwnershipSnapshot CaptureOwnership()
{
SquelchDatabase squelch = Squelch.Snapshot();
return new RuntimeCommunicationOwnershipSnapshot(
_disposed,
CommandTargets.IsDisposed,
SubscriberCount,
PendingDispatchCount,
IsDispatching,
Friends.Count,
squelch.Accounts.Count,
squelch.Characters.Count,
squelch.Global.MessageTypes.Count,
CountRooms(TurbineChat),
CommandTargets.LastIncomingTellSender is not null,
CommandTargets.LastOutgoingTellTarget is not null,
DispatchFailureCount);
}
public void ResetCommandTargets() => CommandTargets.ResetSession();
public void ResetChatIdentity() => Chat.ResetSessionIdentity();
public void ResetNegotiatedChannels() => TurbineChat.Reset();
@ -126,18 +175,19 @@ public sealed class RuntimeCommunicationState : IDisposable
return true;
}
private static int CountRooms(TurbineChatState state)
{
int count = 0;
if (state.AllegianceRoom != 0u) count++;
if (state.GeneralRoom != 0u) count++;
if (state.TradeRoom != 0u) count++;
if (state.LfgRoom != 0u) count++;
if (state.RoleplayRoom != 0u) count++;
if (state.SocietyRoom != 0u) count++;
if (state.OlthoiRoom != 0u) count++;
return count;
}
}
private static int CountRooms(TurbineChatState state)
{
int count = 0;
if (state.AllegianceRoom != 0u) count++;
if (state.GeneralRoom != 0u) count++;
if (state.TradeRoom != 0u) count++;
if (state.LfgRoom != 0u) count++;
if (state.RoleplayRoom != 0u) count++;
if (state.SocietyRoom != 0u) count++;
if (state.OlthoiRoom != 0u) count++;
return count;
}
}

View file

@ -0,0 +1,34 @@
namespace AcDream.Runtime.Gameplay;
/// <summary>
/// One allocation-free ledger over the complete J4 gameplay-state lifetime
/// group. It contains no state of its own; graphical and no-window hosts
/// capture the same three canonical owners.
/// </summary>
public readonly record struct RuntimeGameplayOwnershipSnapshot(
RuntimeInventoryOwnershipSnapshot Inventory,
RuntimeCharacterOwnershipSnapshot Character,
RuntimeCommunicationOwnershipSnapshot Communication)
{
public bool IsConverged =>
Inventory.IsConverged
&& Character.IsConverged
&& Communication.IsConverged;
}
public static class RuntimeGameplayOwnership
{
public static RuntimeGameplayOwnershipSnapshot Capture(
RuntimeInventoryState inventory,
RuntimeCharacterState character,
RuntimeCommunicationState communication)
{
ArgumentNullException.ThrowIfNull(inventory);
ArgumentNullException.ThrowIfNull(character);
ArgumentNullException.ThrowIfNull(communication);
return new RuntimeGameplayOwnershipSnapshot(
inventory.CaptureOwnership(),
character.CaptureOwnership(),
communication.CaptureOwnership());
}
}

View file

@ -3,6 +3,33 @@ using AcDream.Runtime.Entities;
namespace AcDream.Runtime.Gameplay;
public readonly record struct RuntimeInventoryOwnershipSnapshot(
bool IsDisposed,
bool TransactionsDisposed,
bool ShortcutsDisposed,
int BusyCount,
bool HasPendingRequest,
uint RequestedContainerId,
uint CurrentContainerId,
int ItemManaCount,
int ShortcutCount,
int ShortcutSubscriberCount,
long ShortcutDispatchFailureCount,
long TransactionDispatchFailureCount)
{
public bool IsConverged =>
IsDisposed
&& TransactionsDisposed
&& ShortcutsDisposed
&& BusyCount == 0
&& !HasPendingRequest
&& RequestedContainerId == 0u
&& CurrentContainerId == 0u
&& ItemManaCount == 0
&& ShortcutCount == 0
&& ShortcutSubscriberCount == 0;
}
/// <summary>
/// Canonical presentation-independent owner for inventory gameplay state.
/// The object collection is borrowed directly from J3's entity/object
@ -19,7 +46,7 @@ public sealed class RuntimeInventoryState : IDisposable
?? throw new ArgumentNullException(nameof(entityObjects));
ExternalContainers = new ExternalContainerState();
ItemMana = new ItemManaState();
Shortcuts = new ShortcutState();
Shortcuts = new ShortcutStore();
Transactions = new InventoryTransactionState(_entityObjects.Objects);
View = new InventoryStateView(this);
}
@ -27,11 +54,25 @@ public sealed class RuntimeInventoryState : IDisposable
public ClientObjectTable Objects => _entityObjects.Objects;
public ExternalContainerState ExternalContainers { get; }
public ItemManaState ItemMana { get; }
public ShortcutState Shortcuts { get; }
public ShortcutStore Shortcuts { get; }
public InventoryTransactionState Transactions { get; }
public IRuntimeInventoryStateView View { get; }
public bool IsDisposed => _disposed;
public RuntimeInventoryOwnershipSnapshot CaptureOwnership() => new(
_disposed,
Transactions.IsDisposed,
Shortcuts.IsDisposed,
Transactions.BusyCount,
Transactions.HasPendingRequest,
ExternalContainers.RequestedContainerId,
ExternalContainers.CurrentContainerId,
ItemMana.Count,
Shortcuts.Count,
Shortcuts.SubscriberCount,
Shortcuts.DispatchFailureCount,
Transactions.DispatchFailureCount);
public void ResetExternalContainer() => ExternalContainers.Reset();
public void ResetTransactions() => Transactions.ResetSession();
public void ResetItemMana() => ItemMana.Clear();
@ -41,20 +82,82 @@ public sealed class RuntimeInventoryState : IDisposable
Shortcuts.Clear();
}
/// <summary>
/// Send and apply one retail toolbar add on the canonical shortcut
/// manager. Retail emits <c>Event_AddShortCut</c> before mutating
/// <c>PlayerModule</c>.
/// </summary>
public bool TryAddShortcut(
ShortcutEntry entry,
Action publishOutbound)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(publishOutbound);
if ((uint)entry.Index >= ShortcutStore.SlotCount
|| (entry.ObjectId == 0u && entry.SpellId == 0u))
{
return false;
}
try
{
publishOutbound();
}
finally
{
Shortcuts.Set(entry);
}
return true;
}
/// <summary>
/// Send and apply one retail toolbar removal on the canonical shortcut
/// manager.
/// </summary>
public bool TryRemoveShortcut(
int index,
Action publishOutbound)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(publishOutbound);
if ((uint)index >= ShortcutStore.SlotCount)
return false;
try
{
publishOutbound();
}
finally
{
Shortcuts.Remove(index);
}
return true;
}
public void Dispose()
{
if (_disposed)
return;
List<Exception>? failures = null;
Try(() => ExternalContainers.Reset(), ref failures);
Try(ItemMana.Clear, ref failures);
Try(Shortcuts.Clear, ref failures);
Try(Transactions.Dispose, ref failures);
try
{
Try(() => ExternalContainers.Reset(), ref failures);
Try(ItemMana.Clear, ref failures);
Try(Shortcuts.Dispose, ref failures);
Try(Transactions.Dispose, ref failures);
}
finally
{
// Disposal is terminal even when a borrowed presentation observer
// fails. Every owned leaf above mutates before dispatching, so the
// full suffix is safe to run and the Runtime root must never remain
// half-alive over already-disposed children.
_disposed = true;
}
if (failures is not null)
throw new AggregateException(
"Runtime inventory state did not converge during disposal.",
failures);
_disposed = true;
}
private static void Try(Action action, ref List<Exception>? failures)
@ -93,7 +196,7 @@ public sealed class RuntimeInventoryState : IDisposable
owner.Transactions.BusyCount,
owner.Transactions.CanBeginRequest,
pending,
owner.Shortcuts.Items.Count,
owner.Shortcuts.Count,
owner.Shortcuts.Revision,
owner.ItemMana.Count,
owner.ItemMana.Revision);
@ -124,22 +227,3 @@ public sealed class RuntimeInventoryState : IDisposable
owner.ItemMana.TryGetManaPercent(objectId, out fraction);
}
}
public sealed class ShortcutState
{
private IReadOnlyList<ShortcutEntry> _items = Array.Empty<ShortcutEntry>();
private long _revision;
public IReadOnlyList<ShortcutEntry> Items => Volatile.Read(ref _items);
public long Revision => Interlocked.Read(ref _revision);
public void Replace(IReadOnlyList<ShortcutEntry>? items)
{
Volatile.Write(
ref _items,
items ?? Array.Empty<ShortcutEntry>());
Interlocked.Increment(ref _revision);
}
public void Clear() => Replace(Array.Empty<ShortcutEntry>());
}