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>
165 lines
5.2 KiB
C#
165 lines
5.2 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
|
|
namespace AcDream.Core.Items;
|
|
|
|
/// <summary>
|
|
/// Mutable client-side model of retail <c>ShortCutManager::shortCuts_[18]</c>
|
|
/// (<c>acclient.h:36492</c>). Every present entry retains all three raw
|
|
/// <see cref="ShortcutEntry"/> fields, including non-object records that the object-only
|
|
/// <c>gmToolbarUI</c> does not render.
|
|
/// </summary>
|
|
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
|
|
/// like <c>ShortCutManager::AddShortCut @ 0x005D5790</c>; valid entries are retained
|
|
/// even when their object id is zero.
|
|
/// </summary>
|
|
public void Load(IEnumerable<ShortcutEntry> entries)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
ArgumentNullException.ThrowIfNull(entries);
|
|
Array.Clear(_entries);
|
|
foreach (var entry in entries)
|
|
SetCore(entry);
|
|
PublishChanged();
|
|
}
|
|
|
|
/// <summary>Raw entry at <paramref name="slot"/>, or null for absent/out-of-range.</summary>
|
|
public ShortcutEntry? GetEntry(int slot)
|
|
=> (uint)slot < SlotCount ? _entries[slot] : null;
|
|
|
|
/// <summary>Detached raw snapshot for planning a complete mutation before applying it.</summary>
|
|
public ShortcutEntry?[] Snapshot() => (ShortcutEntry?[])_entries.Clone();
|
|
|
|
/// <summary>Visible object id at <paramref name="slot"/>, or zero.</summary>
|
|
public uint Get(int slot) => GetEntry(slot)?.ObjectId ?? 0u;
|
|
|
|
/// <summary>
|
|
/// Visual availability used by <c>gmToolbarUI</c>. A retained non-object entry remains
|
|
/// invisible and therefore counts as an empty toolbar cell.
|
|
/// </summary>
|
|
public bool IsEmpty(int slot) => Get(slot) == 0u;
|
|
|
|
public void Set(ShortcutEntry entry)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
if (SetCore(entry))
|
|
PublishChanged();
|
|
}
|
|
|
|
/// <summary>Create/replace a retail object shortcut; toolbar-created records use spell word zero.</summary>
|
|
public void SetItem(int slot, uint objectId) => Set(new ShortcutEntry(slot, objectId, 0u));
|
|
|
|
public void Remove(int slot)
|
|
{
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|