refactor(runtime): expose canonical gameplay state

Move character options and movement skills into the Runtime-owned character graph, expose borrowed inventory, character, and social views, and route retained UI state commands through generation-gated typed Runtime contracts. Preserve the existing synchronous wire path while deleting the App-owned option and skill mirrors and extending normalized parity checkpoints.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-26 09:12:30 +02:00
parent 9d0d9b07e0
commit dcb61efb5a
34 changed files with 2076 additions and 103 deletions

View file

@ -135,6 +135,114 @@ public interface IRuntimePortalCommands
RuntimePortalCommand command);
}
public readonly record struct RuntimeShortcutCommand(
int Index,
uint ObjectId,
uint SpellId);
public interface IRuntimeInventoryStateCommands
{
RuntimeCommandResult AddShortcut(
RuntimeGenerationToken expectedGeneration,
in RuntimeShortcutCommand command);
RuntimeCommandResult RemoveShortcut(
RuntimeGenerationToken expectedGeneration,
int index);
}
public interface IRuntimeSpellbookCommands
{
RuntimeCommandResult AddFavorite(
RuntimeGenerationToken expectedGeneration,
int tabIndex,
int position,
uint spellId);
RuntimeCommandResult RemoveFavorite(
RuntimeGenerationToken expectedGeneration,
int tabIndex,
uint spellId);
RuntimeCommandResult SetFilter(
RuntimeGenerationToken expectedGeneration,
uint filters);
RuntimeCommandResult ForgetSpell(
RuntimeGenerationToken expectedGeneration,
uint spellId);
RuntimeCommandResult SetDesiredComponent(
RuntimeGenerationToken expectedGeneration,
uint componentId,
uint amount);
RuntimeCommandResult ClearDesiredComponents(
RuntimeGenerationToken expectedGeneration);
}
public enum RuntimeAdvancementKind
{
Attribute,
Vital,
Skill,
TrainSkill,
}
public readonly record struct RuntimeAdvancementCommand(
RuntimeAdvancementKind Kind,
uint StatId,
ulong Cost);
public interface IRuntimeCharacterCommands
{
RuntimeCommandResult Advance(
RuntimeGenerationToken expectedGeneration,
in RuntimeAdvancementCommand command);
RuntimeCommandResult SetOptions1(
RuntimeGenerationToken expectedGeneration,
uint options);
}
public enum RuntimeFriendCommandKind
{
Add,
Remove,
Clear,
RequestLegacyList,
}
public readonly record struct RuntimeFriendCommand(
RuntimeFriendCommandKind Kind,
uint CharacterId = 0u,
string? Name = null);
public enum RuntimeSquelchScope
{
Character,
Account,
Global,
}
public readonly record struct RuntimeSquelchCommand(
RuntimeSquelchScope Scope,
bool Add,
uint CharacterId = 0u,
string? Name = null,
uint MessageType = 0u);
public interface IRuntimeSocialCommands
{
RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
in RuntimeFriendCommand command);
RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
in RuntimeSquelchCommand command);
}
public interface IGameRuntimeCommands
{
IRuntimeSessionCommands Session { get; }
@ -148,4 +256,12 @@ public interface IGameRuntimeCommands
IRuntimeChatCommands Chat { get; }
IRuntimePortalCommands Portal { get; }
IRuntimeInventoryStateCommands InventoryState { get; }
IRuntimeSpellbookCommands Spellbook { get; }
IRuntimeCharacterCommands Character { get; }
IRuntimeSocialCommands Social { get; }
}

View file

@ -13,6 +13,10 @@ public enum RuntimeCommandDomain
Movement,
Chat,
Portal,
InventoryState,
Spellbook,
Character,
Social,
}
public readonly record struct RuntimeLifecycleDelta(
@ -145,6 +149,19 @@ public sealed class RuntimeTraceRecorder : IRuntimeEventObserver
System.Globalization.CultureInfo.InvariantCulture,
$"frame={checkpoint.FrameNumber};materialized={checkpoint.MaterializedEntityCount};" +
$"containers={checkpoint.InventoryContainerCount};chat={checkpoint.ChatCount};" +
$"inventory-state={checkpoint.InventoryState.ShortcutCount}:" +
$"{checkpoint.InventoryState.ShortcutRevision}:" +
$"{checkpoint.InventoryState.ItemManaCount}:" +
$"{checkpoint.InventoryState.ItemManaRevision};" +
$"character={checkpoint.Character.CharacterRevision}:" +
$"{checkpoint.Character.SpellbookRevision}:" +
$"{checkpoint.Character.LearnedSpellCount}:" +
$"{checkpoint.Character.SkillCount}:" +
$"{checkpoint.Character.Options.Revision}:" +
$"{checkpoint.Character.MovementSkills.Revision};" +
$"social={checkpoint.Social.FriendsRevision}:" +
$"{checkpoint.Social.FriendCount}:" +
$"{checkpoint.Social.SquelchRevision};" +
$"portal={checkpoint.Portal.Generation}:{(int)checkpoint.Portal.Kind}:" +
$"{checkpoint.Portal.DestinationCell:X8}:{checkpoint.Portal.IsReady}")));
}

View file

@ -0,0 +1,110 @@
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime;
public readonly record struct RuntimePendingInventoryRequestSnapshot(
ulong Token,
int Kind,
uint ItemId,
bool Dispatched);
public readonly record struct RuntimeInventoryStateSnapshot(
uint RequestedExternalContainerId,
uint CurrentExternalContainerId,
int BusyCount,
bool CanBeginRequest,
RuntimePendingInventoryRequestSnapshot? PendingRequest,
int ShortcutCount,
long ShortcutRevision,
int ItemManaCount,
long ItemManaRevision);
public readonly record struct RuntimeShortcutSnapshot(
int Index,
uint ObjectId,
uint SpellId);
public interface IRuntimeInventoryStateView
{
RuntimeInventoryStateSnapshot Snapshot { get; }
bool TryGetShortcut(int index, out RuntimeShortcutSnapshot shortcut);
bool TryGetItemMana(uint objectId, out float fraction);
}
public readonly record struct RuntimeCharacterSnapshot(
long CharacterRevision,
long SpellbookRevision,
RuntimeCharacterOptionsSnapshot Options,
RuntimeMovementSkillSnapshot MovementSkills,
int LearnedSpellCount,
int ActiveEnchantmentCount,
int DesiredComponentCount,
int SkillCount,
uint SpellbookFilters);
public readonly record struct RuntimeVitalSnapshot(
int Kind,
uint Ranks,
uint Start,
uint Experience,
uint Current,
uint Maximum);
public readonly record struct RuntimeAttributeSnapshot(
int Kind,
uint Ranks,
uint Start,
uint Experience,
uint Current);
public readonly record struct RuntimeSkillSnapshot(
uint SkillId,
uint Ranks,
uint Status,
uint Experience,
uint Initial,
uint Resistance,
double LastUsed,
uint FormulaBonus,
uint CurrentLevel);
public interface IRuntimeCharacterView
{
RuntimeCharacterSnapshot Snapshot { get; }
bool TryGetVital(int kind, out RuntimeVitalSnapshot vital);
bool TryGetAttribute(int kind, out RuntimeAttributeSnapshot attribute);
bool TryGetSkill(uint skillId, out RuntimeSkillSnapshot skill);
bool KnowsSpell(uint spellId);
bool TryGetFavorite(int tabIndex, int position, out uint spellId);
bool TryGetDesiredComponent(uint componentId, out uint amount);
}
public readonly record struct RuntimeSocialSnapshot(
long FriendsRevision,
int FriendCount,
long SquelchRevision,
int SquelchedAccountCount,
int SquelchedCharacterCount,
int GlobalSquelchTypeCount,
int NegotiatedChatRoomCount);
public readonly record struct RuntimeFriendSnapshot(
uint Id,
string Name,
bool Online,
bool AppearOffline);
public interface IRuntimeSocialView
{
RuntimeSocialSnapshot Snapshot { get; }
bool TryGetFriend(uint characterId, out RuntimeFriendSnapshot friend);
}

View file

@ -110,6 +110,9 @@ public readonly record struct RuntimeStateCheckpoint(
int MaterializedEntityCount,
int InventoryObjectCount,
int InventoryContainerCount,
RuntimeInventoryStateSnapshot InventoryState,
RuntimeCharacterSnapshot Character,
RuntimeSocialSnapshot Social,
long ChatRevision,
int ChatCount,
RuntimeMovementSnapshot Movement,
@ -127,6 +130,12 @@ public interface IGameRuntimeView
IRuntimeInventoryView Inventory { get; }
IRuntimeInventoryStateView InventoryState { get; }
IRuntimeCharacterView Character { get; }
IRuntimeSocialView Social { get; }
IRuntimeChatView Chat { get; }
IRuntimeMovementView Movement { get; }

View file

@ -1,4 +1,5 @@
using AcDream.Core.Player;
using AcDream.Core.Net.Messages;
using AcDream.Core.Spells;
namespace AcDream.Runtime.Gameplay;
@ -11,15 +12,27 @@ namespace AcDream.Runtime.Gameplay;
public sealed class RuntimeCharacterState : IDisposable
{
private bool _disposed;
private long _characterRevision;
private long _spellbookRevision;
public RuntimeCharacterState(SpellTable? spellTable = null)
{
Spellbook = new Spellbook(spellTable);
LocalPlayer = new LocalPlayerState(Spellbook);
Options = new RuntimeCharacterOptionsState();
MovementSkills = new RuntimeMovementSkillState();
View = new CharacterView(this);
Spellbook.StateChanged += OnSpellbookChanged;
LocalPlayer.Changed += OnVitalChanged;
LocalPlayer.AttributeChanged += OnAttributeChanged;
LocalPlayer.CharacterChanged += OnCharacterChanged;
}
public Spellbook Spellbook { get; }
public LocalPlayerState LocalPlayer { get; }
public RuntimeCharacterOptionsState Options { get; }
public RuntimeMovementSkillState MovementSkills { get; }
public IRuntimeCharacterView View { get; }
public bool IsDisposed => _disposed;
/// <summary>
@ -55,6 +68,8 @@ public sealed class RuntimeCharacterState : IDisposable
List<Exception>? failures = null;
Try(Spellbook.Clear, ref failures);
Try(LocalPlayer.Clear, ref failures);
Try(Options.ResetSession, ref failures);
Try(MovementSkills.ResetSession, ref failures);
if (failures is not null)
{
throw new AggregateException(
@ -68,9 +83,25 @@ public sealed class RuntimeCharacterState : IDisposable
if (_disposed)
return;
ResetSession();
Spellbook.StateChanged -= OnSpellbookChanged;
LocalPlayer.Changed -= OnVitalChanged;
LocalPlayer.AttributeChanged -= OnAttributeChanged;
LocalPlayer.CharacterChanged -= OnCharacterChanged;
_disposed = true;
}
private void OnSpellbookChanged() =>
Interlocked.Increment(ref _spellbookRevision);
private void OnVitalChanged(LocalPlayerState.VitalKind _) =>
Interlocked.Increment(ref _characterRevision);
private void OnAttributeChanged(LocalPlayerState.AttributeKind _) =>
Interlocked.Increment(ref _characterRevision);
private void OnCharacterChanged() =>
Interlocked.Increment(ref _characterRevision);
private static void Try(Action action, ref List<Exception>? failures)
{
try
@ -82,4 +113,212 @@ public sealed class RuntimeCharacterState : IDisposable
(failures ??= []).Add(error);
}
}
private sealed class CharacterView(RuntimeCharacterState owner)
: IRuntimeCharacterView
{
public RuntimeCharacterSnapshot Snapshot => new(
Interlocked.Read(ref owner._characterRevision),
Interlocked.Read(ref owner._spellbookRevision),
owner.Options.Snapshot,
owner.MovementSkills.Snapshot,
owner.Spellbook.LearnedSpells.Count,
owner.Spellbook.ActiveEnchantments.Count(),
owner.Spellbook.DesiredComponents.Count,
owner.LocalPlayer.Skills.Count,
owner.Spellbook.SpellbookFilters);
public bool TryGetVital(int kind, out RuntimeVitalSnapshot vital)
{
if (!Enum.IsDefined((LocalPlayerState.VitalKind)kind)
|| owner.LocalPlayer.Get((LocalPlayerState.VitalKind)kind)
is not LocalPlayerState.VitalSnapshot current)
{
vital = default;
return false;
}
vital = new RuntimeVitalSnapshot(
kind,
current.Ranks,
current.Start,
current.Xp,
current.Current,
owner.LocalPlayer.GetMaxApprox(
(LocalPlayerState.VitalKind)kind) ?? 0u);
return true;
}
public bool TryGetAttribute(
int kind,
out RuntimeAttributeSnapshot attribute)
{
if (!Enum.IsDefined((LocalPlayerState.AttributeKind)kind)
|| owner.LocalPlayer.GetAttribute(
(LocalPlayerState.AttributeKind)kind)
is not LocalPlayerState.AttributeSnapshot current)
{
attribute = default;
return false;
}
attribute = new RuntimeAttributeSnapshot(
kind,
current.Ranks,
current.Start,
current.Xp,
current.Current);
return true;
}
public bool TryGetSkill(uint skillId, out RuntimeSkillSnapshot skill)
{
if (owner.LocalPlayer.GetSkill(skillId)
is not LocalPlayerState.SkillSnapshot current)
{
skill = default;
return false;
}
skill = new RuntimeSkillSnapshot(
current.SkillId,
current.Ranks,
current.Status,
current.Xp,
current.Init,
current.Resistance,
current.LastUsed,
current.FormulaBonus,
current.CurrentLevel);
return true;
}
public bool KnowsSpell(uint spellId) =>
owner.Spellbook.Knows(spellId);
public bool TryGetFavorite(
int tabIndex,
int position,
out uint spellId)
{
IReadOnlyList<uint> favorites =
owner.Spellbook.GetFavorites(tabIndex);
if ((uint)position >= (uint)favorites.Count)
{
spellId = 0u;
return false;
}
spellId = favorites[position];
return true;
}
public bool TryGetDesiredComponent(
uint componentId,
out uint amount) =>
owner.Spellbook.DesiredComponents.TryGetValue(
componentId,
out amount);
}
}
public readonly record struct RuntimeCharacterOptionsSnapshot(
uint Options1,
uint Options2,
long Revision)
{
public bool DragItemOnPlayerOpensSecureTrade =>
(Options1
& (uint)PlayerDescriptionParser.CharacterOptions1
.DragItemOnPlayerOpensSecureTrade) != 0u;
}
/// <summary>
/// Canonical session-owned copy of retail's two character-option bitfields.
/// <c>PlayerModule::PlayerModule @ 0x005D51F0</c> installs the defaults.
/// Runtime reset restores the equivalent fresh-player-module state because
/// one Runtime owner survives across graphical and no-window sessions.
/// </summary>
public sealed class RuntimeCharacterOptionsState
{
public const uint DefaultOptions1 =
(uint)PlayerDescriptionParser.CharacterOptions1.Default;
public const uint DefaultOptions2 = 0x00948700u;
private uint _options1 = DefaultOptions1;
private uint _options2 = DefaultOptions2;
private long _revision;
public uint Options1 => Volatile.Read(ref _options1);
public uint Options2 => Volatile.Read(ref _options2);
public long Revision => Interlocked.Read(ref _revision);
public RuntimeCharacterOptionsSnapshot Snapshot =>
new(_options1, _options2, Revision);
public bool DragItemOnPlayerOpensSecureTrade =>
Snapshot.DragItemOnPlayerOpensSecureTrade;
public void Replace(uint options1, uint options2)
{
Volatile.Write(ref _options1, options1);
Volatile.Write(ref _options2, options2);
Interlocked.Increment(ref _revision);
}
public void ResetSession()
{
Volatile.Write(ref _options1, DefaultOptions1);
Volatile.Write(ref _options2, DefaultOptions2);
Interlocked.Increment(ref _revision);
}
}
public readonly record struct RuntimeMovementSkillSnapshot(
int RunSkill,
int JumpSkill,
long Revision)
{
public bool IsComplete => RunSkill >= 0 && JumpSkill >= 0;
}
/// <summary>
/// Server-authoritative run/jump values retained independently of any
/// graphical movement controller. App applies this borrowed state whenever
/// its presentation/physics controller exists or is rebuilt.
/// </summary>
public sealed class RuntimeMovementSkillState
{
private int _runSkill = -1;
private int _jumpSkill = -1;
private long _revision;
public int RunSkill => Volatile.Read(ref _runSkill);
public int JumpSkill => Volatile.Read(ref _jumpSkill);
public bool IsComplete => _runSkill >= 0 && _jumpSkill >= 0;
public long Revision => Interlocked.Read(ref _revision);
public RuntimeMovementSkillSnapshot Snapshot =>
new(_runSkill, _jumpSkill, Revision);
public void Update(int runSkill, int jumpSkill)
{
bool changed = false;
if (runSkill >= 0 && RunSkill != runSkill)
{
Volatile.Write(ref _runSkill, runSkill);
changed = true;
}
if (jumpSkill >= 0 && JumpSkill != jumpSkill)
{
Volatile.Write(ref _jumpSkill, jumpSkill);
changed = true;
}
if (changed)
Interlocked.Increment(ref _revision);
}
public void ResetSession()
{
Volatile.Write(ref _runSkill, -1);
Volatile.Write(ref _jumpSkill, -1);
Interlocked.Increment(ref _revision);
}
}

View file

@ -36,6 +36,10 @@ public sealed class RuntimeCommunicationState : IDisposable
Friends = new FriendsState();
Squelch = new SquelchState();
View = new CommunicationView(Chat);
SocialView = new CommunicationSocialView(
TurbineChat,
Friends,
Squelch);
}
public ChatLog Chat { get; }
@ -44,6 +48,7 @@ public sealed class RuntimeCommunicationState : IDisposable
public FriendsState Friends { get; }
public SquelchState Squelch { get; }
public IRuntimeChatView View { get; }
public IRuntimeSocialView SocialView { get; }
public IRuntimeCommunicationEventSource Events => _events;
public bool IsDisposed => _disposed;
@ -79,6 +84,61 @@ public sealed class RuntimeCommunicationState : IDisposable
public long Revision => chat.Revision;
public int Count => chat.Count;
}
private sealed class CommunicationSocialView(
TurbineChatState turbineChat,
FriendsState friends,
SquelchState squelch)
: IRuntimeSocialView
{
public RuntimeSocialSnapshot Snapshot
{
get
{
SquelchDatabase database = squelch.Snapshot();
return new RuntimeSocialSnapshot(
friends.Revision,
friends.Count,
squelch.Revision,
database.Accounts.Count,
database.Characters.Count,
database.Global.MessageTypes.Count,
CountRooms(turbineChat));
}
}
public bool TryGetFriend(
uint characterId,
out RuntimeFriendSnapshot friend)
{
if (!friends.TryGet(characterId, out FriendEntry? current)
|| current is null)
{
friend = default;
return false;
}
friend = new RuntimeFriendSnapshot(
current.Id,
current.Name,
current.Online,
current.AppearOffline);
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;
}
}
}
internal sealed class RuntimeCommunicationEventStream

View file

@ -21,6 +21,7 @@ public sealed class RuntimeInventoryState : IDisposable
ItemMana = new ItemManaState();
Shortcuts = new ShortcutState();
Transactions = new InventoryTransactionState(_entityObjects.Objects);
View = new InventoryStateView(this);
}
public ClientObjectTable Objects => _entityObjects.Objects;
@ -28,6 +29,7 @@ public sealed class RuntimeInventoryState : IDisposable
public ItemManaState ItemMana { get; }
public ShortcutState Shortcuts { get; }
public InventoryTransactionState Transactions { get; }
public IRuntimeInventoryStateView View { get; }
public bool IsDisposed => _disposed;
public void ResetExternalContainer() => ExternalContainers.Reset();
@ -66,6 +68,61 @@ public sealed class RuntimeInventoryState : IDisposable
(failures ??= []).Add(error);
}
}
private sealed class InventoryStateView(RuntimeInventoryState owner)
: IRuntimeInventoryStateView
{
public RuntimeInventoryStateSnapshot Snapshot
{
get
{
RuntimePendingInventoryRequestSnapshot? pending = null;
if (owner.Transactions.TryGetPending(
out PendingInventoryRequest request))
{
pending = new RuntimePendingInventoryRequestSnapshot(
request.Token,
(int)request.Kind,
request.ItemId,
request.Dispatched);
}
return new RuntimeInventoryStateSnapshot(
owner.ExternalContainers.RequestedContainerId,
owner.ExternalContainers.CurrentContainerId,
owner.Transactions.BusyCount,
owner.Transactions.CanBeginRequest,
pending,
owner.Shortcuts.Items.Count,
owner.Shortcuts.Revision,
owner.ItemMana.Count,
owner.ItemMana.Revision);
}
}
public bool TryGetShortcut(
int index,
out RuntimeShortcutSnapshot shortcut)
{
IReadOnlyList<ShortcutEntry> shortcuts = owner.Shortcuts.Items;
for (int i = 0; i < shortcuts.Count; i++)
{
if (shortcuts[i].Index != index)
continue;
ShortcutEntry current = shortcuts[i];
shortcut = new RuntimeShortcutSnapshot(
current.Index,
current.ObjectId,
current.SpellId);
return true;
}
shortcut = default;
return false;
}
public bool TryGetItemMana(uint objectId, out float fraction) =>
owner.ItemMana.TryGetManaPercent(objectId, out fraction);
}
}
public sealed class ShortcutState

View file

@ -44,7 +44,6 @@ public sealed record LiveCharacterSessionBindings(
Action<int, int>? OnSkillsUpdated,
Action<GameEvents.CharacterConfirmationRequest>? OnConfirmationRequest,
Action<GameEvents.CharacterConfirmationDone>? OnConfirmationDone,
Action<uint, uint>? OnCharacterOptions,
Func<double>? ClientTime);
public sealed record LiveSocialSessionBindings(
@ -152,7 +151,13 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
social.Chat,
character.Character.LocalPlayer,
social.TurbineChat,
onSkillsUpdated: character.OnSkillsUpdated,
onSkillsUpdated: (runSkill, jumpSkill) =>
{
character.Character.MovementSkills.Update(
runSkill,
jumpSkill);
character.OnSkillsUpdated?.Invoke(runSkill, jumpSkill);
},
resolveSkillFormulaBonus: character.ResolveSkillFormulaBonus,
onShortcuts: inventory.OnShortcuts,
playerGuid: inventory.PlayerGuid,
@ -164,7 +169,7 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
friends: social.Friends,
squelch: social.Squelch,
onDesiredComponents: null,
onCharacterOptions: character.OnCharacterOptions,
onCharacterOptions: character.Character.Options.Replace,
clientTime: character.ClientTime,
externalContainers: inventory.ExternalContainers,
accepting: IsAccepting));