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

@ -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>());
}