refactor(net): own live session composition

Extract reset, selection, entered-world, and route construction behind LiveSessionHost while preserving the sole LiveSessionController authority. Retain partial route and subscription cleanup for retry, and replace the embedded ACE-only shortcut with the exact named-retail unsigned skill formula.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-22 10:36:06 +02:00
parent 18d4b999de
commit 557eb7ef6b
22 changed files with 1430 additions and 236 deletions

View file

@ -21,7 +21,7 @@ internal sealed record LiveSessionCommandBindings(
/// itself is the published bus, so a retained reference becomes inert before
/// the displaced transport is disposed.
/// </summary>
internal sealed class LiveSessionCommandRouter : ICommandBus, IDisposable
internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
{
private readonly object _gate = new();
private LiveCommandBus? _commands;

View file

@ -57,12 +57,19 @@ internal sealed record LiveSocialSessionBindings(
/// Owns every inbound subscription for one exact live session. Domain state
/// remains in the supplied sinks; this class owns only routing and teardown.
/// </summary>
internal sealed class LiveSessionEventRouter : IDisposable
internal sealed class LiveSessionEventRouter : ILiveSessionEventRouting
{
private readonly LiveSessionSubscriptionSet _subscriptions = new();
private readonly Action<int>? _constructionCheckpoint;
private readonly WorldSession _session;
private readonly LiveEntitySessionSink _entities;
private readonly LiveEnvironmentSessionSink _environment;
private readonly LiveInventorySessionBindings _inventory;
private readonly LiveCharacterSessionBindings _character;
private readonly LiveSocialSessionBindings _social;
private int _constructionStep;
private int _accepting = 1;
private int _accepting;
private int _lifecycleState; // 0 created, 1 attaching, 2 attached, 3 disposed
public LiveSessionEventRouter(
WorldSession session,
@ -75,7 +82,28 @@ internal sealed class LiveSessionEventRouter : IDisposable
{
ArgumentNullException.ThrowIfNull(session);
Validate(entities, environment, inventory, character, social);
_session = session;
_entities = entities;
_environment = environment;
_inventory = inventory;
_character = character;
_social = social;
_constructionCheckpoint = constructionCheckpoint;
}
public void Attach()
{
if (Interlocked.CompareExchange(ref _lifecycleState, 1, 0) != 0)
throw new InvalidOperationException(
"Live-session event routing can only attach once.");
Interlocked.Exchange(ref _accepting, 1);
WorldSession session = _session;
LiveEntitySessionSink entities = _entities;
LiveEnvironmentSessionSink environment = _environment;
LiveInventorySessionBindings inventory = _inventory;
LiveCharacterSessionBindings character = _character;
LiveSocialSessionBindings social = _social;
try
{
@ -182,22 +210,14 @@ internal sealed class LiveSessionEventRouter : IDisposable
h => session.VitalCurrentUpdated += h,
h => session.VitalCurrentUpdated -= h,
vital => inventory.LocalPlayer.OnVitalCurrent(vital.VitalId, vital.Current));
if (Interlocked.CompareExchange(ref _lifecycleState, 2, 1) != 1)
throw new ObjectDisposedException(nameof(LiveSessionEventRouter));
}
catch (Exception constructionError)
catch
{
Interlocked.Exchange(ref _accepting, 0);
try
{
_subscriptions.Dispose();
}
catch (Exception cleanupError)
{
throw new AggregateException(
"live-session event routing failed and cleanup also failed",
constructionError,
cleanupError);
}
Interlocked.Exchange(ref _lifecycleState, 3);
throw;
}
}
@ -207,6 +227,7 @@ internal sealed class LiveSessionEventRouter : IDisposable
public void Dispose()
{
Interlocked.Exchange(ref _accepting, 0);
Interlocked.Exchange(ref _lifecycleState, 3);
_subscriptions.Dispose();
}
@ -222,15 +243,10 @@ internal sealed class LiveSessionEventRouter : IDisposable
};
attach(handler);
try
{
_subscriptions.Add(() => detach(handler));
}
catch
{
detach(handler);
throw;
}
// Add assumes cleanup ownership before it can call an external detach.
// If the set is already closing, it retains/retries that exact edge;
// calling detach again here would replay a successful removal.
_subscriptions.Add(() => detach(handler));
ConstructionCheckpoint();
}
@ -290,31 +306,48 @@ internal sealed class LiveSessionEventRouter : IDisposable
internal sealed class LiveSessionSubscriptionSet : IDisposable
{
private List<IDisposable>? _subscriptions = [];
private readonly object _gate = new();
private readonly List<RetryableSubscription> _subscriptions = [];
private bool _disposeRequested;
public void Add(IDisposable subscription)
{
ArgumentNullException.ThrowIfNull(subscription);
List<IDisposable>? subscriptions = _subscriptions;
if (subscriptions is null)
{
subscription.Dispose();
throw new ObjectDisposedException(nameof(LiveSessionSubscriptionSet));
}
subscriptions.Add(subscription);
AddRetained(new RetryableSubscription(subscription.Dispose));
}
public void Add(Action unsubscribe) => Add(new ActionSubscription(unsubscribe));
public void Add(Action unsubscribe)
{
ArgumentNullException.ThrowIfNull(unsubscribe);
AddRetained(new RetryableSubscription(unsubscribe));
}
private void AddRetained(RetryableSubscription retained)
{
bool disposeNow;
lock (_gate)
{
disposeNow = _disposeRequested;
_subscriptions.Add(retained);
}
if (!disposeNow)
return;
retained.Dispose();
throw new ObjectDisposedException(nameof(LiveSessionSubscriptionSet));
}
public void Dispose()
{
List<IDisposable>? subscriptions = Interlocked.Exchange(ref _subscriptions, null);
if (subscriptions is null)
return;
RetryableSubscription[] subscriptions;
lock (_gate)
{
_disposeRequested = true;
subscriptions = _subscriptions.ToArray();
}
List<Exception>? errors = null;
for (int index = subscriptions.Count - 1; index >= 0; index--)
for (int index = subscriptions.Length - 1; index >= 0; index--)
{
try
{
@ -332,10 +365,59 @@ internal sealed class LiveSessionSubscriptionSet : IDisposable
errors);
}
private sealed class ActionSubscription(Action unsubscribe) : IDisposable
private sealed class RetryableSubscription(Action dispose) : IDisposable
{
private Action? _unsubscribe = unsubscribe;
private readonly object _gate = new();
private Action? _dispose = dispose;
private bool _executing;
private int _executingThreadId;
public void Dispose() => Interlocked.Exchange(ref _unsubscribe, null)?.Invoke();
public void Dispose()
{
Action? operation;
int threadId = Environment.CurrentManagedThreadId;
lock (_gate)
{
while (_executing)
{
if (_executingThreadId == threadId)
{
throw new InvalidOperationException(
"Live-session subscription cleanup cannot complete reentrantly.");
}
Monitor.Wait(_gate);
}
operation = _dispose;
if (operation is null)
return;
_executing = true;
_executingThreadId = threadId;
}
try
{
operation();
}
catch
{
CompleteAttempt(succeeded: false);
throw;
}
CompleteAttempt(succeeded: true);
}
private void CompleteAttempt(bool succeeded)
{
lock (_gate)
{
if (succeeded)
_dispose = null;
_executing = false;
_executingThreadId = 0;
Monitor.PulseAll(_gate);
}
}
}
}

View file

@ -0,0 +1,242 @@
using System.Runtime.ExceptionServices;
using AcDream.Core.Net;
using AcDream.UI.Abstractions;
namespace AcDream.App.Net;
internal interface ILiveSessionEventRouting : IDisposable
{
void Attach();
}
internal interface ILiveSessionCommandRouting : ICommandBus, IDisposable
{
void Activate();
}
internal sealed record LiveSessionRoutingFactories(
Func<WorldSession, ILiveSessionEventRouting> CreateEvents,
Func<WorldSession, ILiveSessionCommandRouting> CreateCommands);
internal sealed record LiveSessionSelectionBindings(
Action<uint> SetPlayerIdentity,
Action<uint> SetVitalsIdentity,
Action<uint> SetChatIdentity,
Action<uint> MarkPersistent,
Action<uint> SetVanishProbeIdentity,
Action ClearCombat);
internal sealed record LiveSessionEnteredWorldBindings(
Action<string> SetActiveCharacter,
Action RestoreLayout,
Action SyncToolbar,
Action<string> LoadCharacterSettings,
Action ArmPlayerModeAutoEntry);
internal sealed record LiveSessionHostBindings(
LiveSessionRoutingFactories Routing,
LiveSessionResetBindings Reset,
LiveSessionSelectionBindings Selection,
LiveSessionEnteredWorldBindings EnteredWorld,
Action<string, int, string> Connecting,
Action Connected);
/// <summary>
/// App composition owner for the one canonical <see cref="LiveSessionController"/>.
/// It owns callback ordering and per-generation route factories, but never
/// mirrors session, generation, identity, routing, or command state.
/// </summary>
internal sealed class LiveSessionHost
{
private sealed class PendingRouteRollback(
ILiveSessionCommandRouting? commands,
ILiveSessionEventRouting? events)
{
private ILiveSessionCommandRouting? _commands = commands;
private ILiveSessionEventRouting? _events = events;
public bool IsComplete => _commands is null && _events is null;
public void Drain()
{
List<Exception>? failures = null;
TryDispose(ref _commands, ref failures);
TryDispose(ref _events, ref failures);
if (failures is not null)
{
throw new AggregateException(
"Live-session route rollback did not converge.",
failures);
}
}
private static void TryDispose<TOwner>(
ref TOwner? owner,
ref List<Exception>? failures)
where TOwner : class, IDisposable
{
if (owner is null)
return;
try
{
owner.Dispose();
owner = null;
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
}
}
private readonly LiveSessionController _controller;
private readonly LiveSessionRoutingFactories _routing;
private readonly LiveSessionSelectionBindings _selection;
private readonly LiveSessionEnteredWorldBindings _enteredWorld;
private readonly LiveSessionResetPlan _resetPlan;
private readonly LiveSessionLifecycleHost _lifecycle;
private PendingRouteRollback? _pendingRouteRollback;
public LiveSessionHost(
LiveSessionController controller,
LiveSessionHostBindings bindings)
{
_controller = controller ?? throw new ArgumentNullException(nameof(controller));
ArgumentNullException.ThrowIfNull(bindings);
_routing = bindings.Routing ?? throw new ArgumentNullException(nameof(bindings.Routing));
_selection = bindings.Selection ?? throw new ArgumentNullException(nameof(bindings.Selection));
_enteredWorld = bindings.EnteredWorld
?? throw new ArgumentNullException(nameof(bindings.EnteredWorld));
ArgumentNullException.ThrowIfNull(_routing.CreateEvents);
ArgumentNullException.ThrowIfNull(_routing.CreateCommands);
ArgumentNullException.ThrowIfNull(bindings.Connecting);
ArgumentNullException.ThrowIfNull(bindings.Connected);
Validate(_selection, _enteredWorld);
_resetPlan = LiveSessionResetManifest.Create(bindings.Reset);
_lifecycle = new LiveSessionLifecycleHost(new LiveSessionLifecycleBindings(
Bind: BindSession,
Reset: ResetSessionState,
Connecting: bindings.Connecting,
Connected: bindings.Connected,
Selected: ApplySelection,
Entered: ApplyEnteredWorld));
}
public WorldSession? CurrentSession => _controller.CurrentSession;
public ICommandBus Commands => _controller.Commands;
public bool IsInWorld => _controller.IsInWorld;
public LiveSessionStartResult Start(RuntimeOptions options) =>
_controller.Start(options, _lifecycle);
public LiveSessionStartResult Reconnect(RuntimeOptions options) =>
_controller.Reconnect(options, _lifecycle);
private LiveSessionBinding BindSession(WorldSession session)
{
DrainPendingRouteRollback();
ILiveSessionEventRouting? events = null;
ILiveSessionCommandRouting? commands = null;
try
{
events = _routing.CreateEvents(session)
?? throw new InvalidOperationException(
"The live-session event factory returned null.");
events.Attach();
commands = _routing.CreateCommands(session)
?? throw new InvalidOperationException(
"The live-session command factory returned null.");
return new LiveSessionBinding(
session,
commands,
activateCommands: commands.Activate,
deactivateCommands: commands.Dispose,
detachEvents: events.Dispose);
}
catch (Exception creationError)
{
RethrowWithRetryableRollback(creationError, commands, events);
throw;
}
}
private void ResetSessionState()
{
// An incompletely detached route can still deliver callbacks into the
// state below. Treat physical route convergence as the same hard
// barrier used by normal LiveSessionBinding teardown.
DrainPendingRouteRollback();
_resetPlan.Execute();
}
private void ApplySelection(LiveSessionCharacterSelection selection)
{
uint id = selection.CharacterId;
_selection.SetPlayerIdentity(id);
_selection.SetVitalsIdentity(id);
_selection.SetChatIdentity(id);
_selection.MarkPersistent(id);
_selection.SetVanishProbeIdentity(id);
_selection.ClearCombat();
}
private void ApplyEnteredWorld(LiveSessionCharacterSelection selection)
{
string name = selection.CharacterName;
_enteredWorld.SetActiveCharacter(name);
_enteredWorld.RestoreLayout();
_enteredWorld.SyncToolbar();
_enteredWorld.LoadCharacterSettings(name);
_enteredWorld.ArmPlayerModeAutoEntry();
}
private void RethrowWithRetryableRollback(
Exception creationError,
ILiveSessionCommandRouting? commands,
ILiveSessionEventRouting? events)
{
_pendingRouteRollback = new PendingRouteRollback(commands, events);
try
{
DrainPendingRouteRollback();
}
catch (AggregateException cleanupError)
{
var failures = new List<Exception> { creationError };
failures.AddRange(cleanupError.InnerExceptions);
throw new AggregateException(
"Live-session route construction and rollback both failed.",
failures);
}
ExceptionDispatchInfo.Capture(creationError).Throw();
}
private void DrainPendingRouteRollback()
{
if (_pendingRouteRollback is not { } rollback)
return;
rollback.Drain();
if (rollback.IsComplete)
_pendingRouteRollback = null;
}
private static void Validate(
LiveSessionSelectionBindings selection,
LiveSessionEnteredWorldBindings entered)
{
ArgumentNullException.ThrowIfNull(selection.SetPlayerIdentity);
ArgumentNullException.ThrowIfNull(selection.SetVitalsIdentity);
ArgumentNullException.ThrowIfNull(selection.SetChatIdentity);
ArgumentNullException.ThrowIfNull(selection.MarkPersistent);
ArgumentNullException.ThrowIfNull(selection.SetVanishProbeIdentity);
ArgumentNullException.ThrowIfNull(selection.ClearCombat);
ArgumentNullException.ThrowIfNull(entered.SetActiveCharacter);
ArgumentNullException.ThrowIfNull(entered.RestoreLayout);
ArgumentNullException.ThrowIfNull(entered.SyncToolbar);
ArgumentNullException.ThrowIfNull(entered.LoadCharacterSettings);
ArgumentNullException.ThrowIfNull(entered.ArmPlayerModeAutoEntry);
}
}

View file

@ -0,0 +1,72 @@
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.App.Net;
/// <summary>
/// Exact unsigned formula used by retail <c>SkillFormula::Calculate @
/// 0x00591960</c>. DAT reader fields are signed storage views, so their bits
/// are deliberately reinterpreted as retail's unsigned W/X/Y/Z words.
/// </summary>
internal static class RetailSkillFormula
{
public static bool TryCalculate(
SkillFormula formula,
uint attribute1,
uint attribute2,
out uint result)
{
ArgumentNullException.ThrowIfNull(formula);
uint divisor = unchecked((uint)formula.Divisor);
if (divisor == 0u)
{
result = 0u;
return false;
}
uint x = unchecked((uint)formula.Attribute1Multiplier);
uint y = unchecked((uint)formula.Attribute2Multiplier);
uint w = unchecked((uint)formula.AdditiveBonus);
uint numerator = unchecked(x * attribute1 + y * attribute2 + w);
result = (uint)Math.Floor((double)numerator / divisor + 0.5d);
return true;
}
}
/// <summary>
/// Named live-session resolver for the attribute contribution to one skill.
/// Table lookup and missing-property policy stay separate from retail math.
/// </summary>
internal sealed class LiveSkillCreditResolver(SkillTable? skillTable)
{
public uint Resolve(
uint skillId,
IReadOnlyDictionary<uint, uint> attributeCurrents)
{
ArgumentNullException.ThrowIfNull(attributeCurrents);
if (skillTable?.Skills is null
|| !skillTable.Skills.TryGetValue(
(DatReaderWriter.Enums.SkillId)skillId,
out var skillBase))
{
return 0u;
}
SkillFormula formula = skillBase.Formula;
attributeCurrents.TryGetValue(
(uint)formula.Attribute1,
out uint attribute1);
attributeCurrents.TryGetValue(
(uint)formula.Attribute2,
out uint attribute2);
return RetailSkillFormula.TryCalculate(
formula,
attribute1,
attribute2,
out uint result)
? result
: 0u;
}
}

View file

@ -380,10 +380,6 @@ public sealed class GameWindow : IDisposable
// DevToolsEnabled reads through typed RuntimeOptions.
private bool DevToolsEnabled => _options.DevTools;
// Slice 3: the reset transaction remains cached by the host, while the
// lifecycle controller owns each exact event/command/session generation.
private AcDream.App.Net.LiveSessionResetPlan? _liveSessionResetPlan;
// Phase G.1-G.2 world lighting/time state.
public readonly AcDream.Core.World.WorldTimeService WorldTime =
new AcDream.Core.World.WorldTimeService(
@ -485,7 +481,7 @@ public sealed class GameWindow : IDisposable
}
// Phase K.2 — auto-enter player mode after a successful login. Armed
// by ApplyLiveSessionEnteredWorld; ticked from
// by LiveSessionHost's entered-world transition; ticked from
// OnUpdate; disarmed if the user manually enters fly mode (or any
// other path that pre-empts the chase camera). Skipped entirely
// offline (orbit camera stays the foreground). The class internally
@ -529,8 +525,9 @@ public sealed class GameWindow : IDisposable
// Slice 3: the controller is the sole App owner. Outbound feature
// callbacks resolve this borrowed handle at call time and never cache it.
private AcDream.App.Net.LiveSessionController? _liveSessionController;
private AcDream.App.Net.LiveSessionHost? _liveSessionHost;
private AcDream.Core.Net.WorldSession? LiveSession =>
_liveSessionController?.CurrentSession;
_liveSessionHost?.CurrentSession;
private readonly AcDream.App.World.LiveWorldOriginState _liveWorldOrigin = new();
private int _liveCenterX => _liveWorldOrigin.CenterX;
private int _liveCenterY => _liveWorldOrigin.CenterY;
@ -1194,7 +1191,7 @@ public sealed class GameWindow : IDisposable
try
{
// _activeToonKey is updated by
// ApplyLiveSessionEnteredWorld
// LiveSessionHost entered-world transition
// so saving character settings always
// writes under the chosen character's
// name (or "default" pre-login).
@ -1483,7 +1480,7 @@ public sealed class GameWindow : IDisposable
& AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1
.DragItemOnPlayerOpensSecureTrade) != 0,
toast: text => _debugVm?.AddToast(text),
readyForInventoryRequest: () => _liveSessionController?.IsInWorld == true,
readyForInventoryRequest: () => _liveSessionHost?.IsInWorld == true,
playerOnGround: GetDebugPlayerOnGround,
inNonCombatMode: () => Combat.CurrentMode
== AcDream.Core.Combat.CombatMode.NonCombat,
@ -1534,7 +1531,7 @@ public sealed class GameWindow : IDisposable
playerGuid: () => _playerServerGuid,
activeToonName: () => _activeToonKey,
fallbackSheet: AcDream.App.Studio.SampleData.SampleCharacter,
canSendRaise: () => _liveSessionController?.IsInWorld == true,
canSendRaise: () => _liveSessionHost?.IsInWorld == true,
sendRaiseAttribute: (statId, cost) => LiveSession?.SendRaiseAttribute(statId, cost),
sendRaiseVital: (statId, cost) => LiveSession?.SendRaiseVital(statId, cost),
sendRaiseSkill: (statId, cost) => LiveSession?.SendRaiseSkill(statId, cost),
@ -1555,7 +1552,7 @@ public sealed class GameWindow : IDisposable
sendTargeted: (target, spellId) => LiveSession?.SendCastTargetedSpell(target, spellId),
displayMessage: text => Chat.OnSystemMessage(text, 0x1Au),
incrementBusy: () => _itemInteractionController.IncrementBusyCount(),
canSend: () => _liveSessionController?.IsInWorld == true);
canSend: () => _liveSessionHost?.IsInWorld == true);
_uiHost.Root.DragReleasedOutsideUi += OnUiDragReleasedOutside;
// Feed Silk input to the UiRoot tree so windows drag / close / select.
@ -1653,7 +1650,7 @@ public sealed class GameWindow : IDisposable
Vitals: new AcDream.App.UI.VitalsRuntimeBindings(_vitalsVm),
Chat: new AcDream.App.UI.ChatRuntimeBindings(
retailChatVm,
() => _liveSessionController?.Commands
() => _liveSessionHost?.Commands
?? AcDream.UI.Abstractions.NullCommandBus.Instance),
Radar: new AcDream.App.UI.RadarRuntimeBindings(
radarSnapshotProvider.BuildSnapshot,
@ -2960,10 +2957,9 @@ public sealed class GameWindow : IDisposable
new AcDream.App.Update.PlayerModeAutoEntryFramePhase(
_playerModeAutoEntry),
cameraFrame);
_liveSessionHost = CreateLiveSessionHost();
AcDream.App.Net.LiveSessionStartResult liveStart =
_liveSessionController.Start(
_options,
CreateLiveSessionLifecycleHost());
_liveSessionHost.Start(_options);
switch (liveStart.Status)
{
case AcDream.App.Net.LiveSessionStartStatus.MissingCredentials:
@ -2976,11 +2972,27 @@ public sealed class GameWindow : IDisposable
}
}
private AcDream.App.Net.LiveSessionLifecycleHost CreateLiveSessionLifecycleHost() =>
new(new AcDream.App.Net.LiveSessionLifecycleBindings(
Bind: CreateLiveSessionBinding,
Reset: () =>
(_liveSessionResetPlan ??= CreateLiveSessionResetPlan()).Execute(),
private AcDream.App.Net.LiveSessionHost CreateLiveSessionHost() =>
new(_liveSessionController!, new AcDream.App.Net.LiveSessionHostBindings(
Routing: new(
CreateLiveSessionEventRouter,
session => new AcDream.App.Net.LiveSessionCommandRouter(
CreateLiveSessionCommandBindings(session))),
Reset: CreateLiveSessionResetBindings(),
Selection: new(
SetPlayerIdentity: id => _playerServerGuid = id,
SetVitalsIdentity: id => _vitalsVm?.SetLocalPlayerGuid(id),
SetChatIdentity: Chat.SetLocalPlayerGuid,
MarkPersistent: _worldState.MarkPersistent,
SetVanishProbeIdentity: id =>
AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = id,
ClearCombat: Combat.Clear),
EnteredWorld: new(
SetActiveCharacter: name => _activeToonKey = name,
RestoreLayout: () => _retailUiRuntime?.RestoreLayout(),
SyncToolbar: SyncToolbarWindowButtons,
LoadCharacterSettings: LoadLiveSessionCharacterSettings,
ArmPlayerModeAutoEntry: () => _playerModeAutoEntry?.Arm()),
Connecting: (host, port, user) =>
Chat.OnSystemMessage(
$"connecting to {host}:{port} as {user}",
@ -2988,38 +3000,21 @@ public sealed class GameWindow : IDisposable
Connected: () =>
Chat.OnSystemMessage(
"connected — character list received",
chatType: 1),
Selected: ApplyLiveSessionSelection,
Entered: ApplyLiveSessionEnteredWorld));
chatType: 1)));
private void ApplyLiveSessionSelection(
AcDream.App.Net.LiveSessionCharacterSelection selection)
private void LoadLiveSessionCharacterSettings(string characterName)
{
_playerServerGuid = selection.CharacterId;
_vitalsVm?.SetLocalPlayerGuid(selection.CharacterId);
Chat.SetLocalPlayerGuid(selection.CharacterId);
_worldState.MarkPersistent(selection.CharacterId);
AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = selection.CharacterId;
Combat.Clear();
}
private void ApplyLiveSessionEnteredWorld(
AcDream.App.Net.LiveSessionCharacterSelection selection)
{
_activeToonKey = selection.CharacterName;
_retailUiRuntime?.RestoreLayout();
SyncToolbarWindowButtons();
if (_settingsStore is not null && _settingsVm is not null)
{
var toonBag = _settingsStore.LoadCharacter(_activeToonKey);
var toonBag = _settingsStore.LoadCharacter(characterName);
_settingsVm.LoadCharacterContext(toonBag);
Console.WriteLine($"settings: loaded character[{_activeToonKey}] preferences");
Console.WriteLine(
$"settings: loaded character[{characterName}] preferences");
}
_playerModeAutoEntry?.Arm();
}
private AcDream.App.Net.LiveSessionResetPlan CreateLiveSessionResetPlan() =>
AcDream.App.Net.LiveSessionResetManifest.Create(new()
private AcDream.App.Net.LiveSessionResetBindings
CreateLiveSessionResetBindings() => new()
{
MouseCapture = ResetSessionMouseCapture,
PlayerPresentation = ResetSessionPlayerPresentation,
@ -3052,7 +3047,7 @@ public sealed class GameWindow : IDisposable
AnimationHookFrames = () => _animationHookFrames?.Clear(),
LivePresentation = () => _liveEntityPresentation?.Clear(),
RemoteMovementDiagnostics = _remoteMovementObservations.Clear,
});
};
private void ResetSessionMouseCapture()
=> _gameplayInputFrame?.ResetSession();
@ -3108,12 +3103,7 @@ public sealed class GameWindow : IDisposable
}
}
/// <summary>
/// Composes the exact inbound and outbound owners for one live session.
/// Registration completes before Connect; outbound commands remain inert
/// until EnterWorld succeeds.
/// </summary>
private AcDream.App.Net.LiveSessionBinding CreateLiveSessionBinding(
private AcDream.App.Net.LiveSessionEventRouter CreateLiveSessionEventRouter(
AcDream.Core.Net.WorldSession session)
{
var skillTable = _dats?.Get<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u);
@ -3126,39 +3116,19 @@ public sealed class GameWindow : IDisposable
Console.WriteLine);
}
AcDream.App.Net.LiveSessionEventRouter? events = null;
AcDream.App.Net.LiveSessionCommandRouter? commands = null;
try
{
events = new AcDream.App.Net.LiveSessionEventRouter(
session,
_liveEntitySessionEvents.CreateSink(),
new AcDream.App.Net.LiveEnvironmentSessionSink(
OnEnvironChanged,
ticks =>
{
WorldTime.SyncFromServer(ticks);
RefreshSkyForCurrentDay();
}),
CreateLiveInventorySessionBindings(),
CreateLiveCharacterSessionBindings(skillTable),
CreateLiveSocialSessionBindings());
commands = new AcDream.App.Net.LiveSessionCommandRouter(
CreateLiveSessionCommandBindings(session));
return new AcDream.App.Net.LiveSessionBinding(
session,
commands,
activateCommands: commands.Activate,
deactivateCommands: commands.Dispose,
detachEvents: events.Dispose);
}
catch
{
commands?.Dispose();
events?.Dispose();
throw;
}
return new AcDream.App.Net.LiveSessionEventRouter(
session,
_liveEntitySessionEvents.CreateSink(),
new AcDream.App.Net.LiveEnvironmentSessionSink(
OnEnvironChanged,
ticks =>
{
WorldTime.SyncFromServer(ticks);
RefreshSkyForCurrentDay();
}),
CreateLiveInventorySessionBindings(),
CreateLiveCharacterSessionBindings(skillTable),
CreateLiveSocialSessionBindings());
}
private AcDream.App.Net.LiveInventorySessionBindings
@ -3178,37 +3148,14 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Net.LiveCharacterSessionBindings
CreateLiveCharacterSessionBindings(
DatReaderWriter.DBObjs.SkillTable? skillTable) => new(
DatReaderWriter.DBObjs.SkillTable? skillTable)
{
var skillCreditResolver =
new AcDream.App.Net.LiveSkillCreditResolver(skillTable);
return new(
Combat,
SpellBook,
ResolveSkillFormulaBonus: (skillId, attributeCurrents) =>
{
// ACE AttributeFormula.GetFormula
// (references/ACE/Source/ACE.Entity/Models/AttributeFormula.cs:55-):
// Attribute1Multiplier == 0 means no attribute contribution.
// Otherwise the retail data formula is
// (attr1 * mult1 + attr2 * mult2) / divisor + additive.
// Guard a zero divisor because malformed/custom DAT input must not
// take down the live-session dispatcher.
if (skillTable?.Skills is null)
return 0u;
if (!skillTable.Skills.TryGetValue(
(DatReaderWriter.Enums.SkillId)skillId,
out var skillBase))
return 0u;
var formula = skillBase.Formula;
if (formula.Attribute1Multiplier == 0 || formula.Divisor == 0)
return 0u;
attributeCurrents.TryGetValue((uint)formula.Attribute1, out uint attribute1);
attributeCurrents.TryGetValue((uint)formula.Attribute2, out uint attribute2);
long numerator =
(long)attribute1 * formula.Attribute1Multiplier +
(long)attribute2 * formula.Attribute2Multiplier;
long bonus = numerator / formula.Divisor + formula.AdditiveBonus;
return bonus < 0 ? 0u : (uint)bonus;
},
ResolveSkillFormulaBonus: skillCreditResolver.Resolve,
OnSkillsUpdated: (runSkill, jumpSkill) =>
{
_localPlayerSkills.Update(runSkill, jumpSkill, _playerController);
@ -3228,6 +3175,7 @@ public sealed class GameWindow : IDisposable
(AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1)
options1,
ClientTime: ClientTimerNow);
}
private AcDream.App.Net.LiveSocialSessionBindings
CreateLiveSocialSessionBindings() => new(
@ -3676,7 +3624,7 @@ public sealed class GameWindow : IDisposable
// optional SettingsPanel and its visibility/input operations.
private AcDream.UI.Abstractions.Panels.Settings.SettingsVM? _settingsVm;
// L.0: settings.json store + active toon key. The store is held as
// a field so ApplyLiveSessionEnteredWorld can re-load the chosen toon's
// a field so LiveSessionHost can re-load the chosen toon's
// bag once we know its name (post-EnterWorld). Toon key starts as
// "default" and gets swapped to the actual character name on the
// first EnterWorld.
@ -3816,7 +3764,7 @@ public sealed class GameWindow : IDisposable
_persistedGameplay = _settingsStore.LoadGameplay();
_persistedChat = _settingsStore.LoadChat();
// _activeToonKey is "default" pre-EnterWorld; the post-login
// ApplyLiveSessionEnteredWorld swaps to the chosen toon's
// LiveSessionHost swaps to the chosen toon's
// name and re-loads via SettingsVM.LoadCharacterContext.
_persistedCharacter = _settingsStore.LoadCharacter(_activeToonKey);
@ -4159,7 +4107,7 @@ public sealed class GameWindow : IDisposable
private void ToggleLiveCombatMode()
{
AcDream.Core.Net.WorldSession? session = LiveSession;
if (_liveSessionController?.IsInWorld != true || session is null)
if (_liveSessionHost?.IsInWorld != true || session is null)
return;
IReadOnlyList<AcDream.Core.Items.ClientObject> orderedEquipment =
@ -4193,7 +4141,7 @@ public sealed class GameWindow : IDisposable
private void UseItemByGuid(uint guid)
{
AcDream.Core.Net.WorldSession? session = LiveSession;
if (_liveSessionController?.IsInWorld != true || session is null)
if (_liveSessionHost?.IsInWorld != true || session is null)
return;
uint sequence = session.NextGameActionSequence();
session.SendGameAction(
@ -4418,7 +4366,10 @@ public sealed class GameWindow : IDisposable
}
if (ReferenceEquals(_liveSessionController, controller))
{
_liveSessionHost = null;
_liveSessionController = null;
}
}),
]),
// Frame composition borrows equipped, effect, audio, and render

View file

@ -7,36 +7,48 @@ namespace AcDream.Core.Net;
/// </summary>
internal sealed class SubscriptionSet : IDisposable
{
private List<IDisposable>? _subscriptions = [];
private readonly object _gate = new();
private readonly List<RetryableSubscription> _subscriptions = [];
private bool _disposeRequested;
public void Add(IDisposable subscription)
{
ArgumentNullException.ThrowIfNull(subscription);
List<IDisposable>? subscriptions = _subscriptions;
if (subscriptions is null)
{
subscription.Dispose();
throw new ObjectDisposedException(nameof(SubscriptionSet));
}
subscriptions.Add(subscription);
AddRetained(new RetryableSubscription(subscription.Dispose));
}
public void Add(Action unsubscribe)
{
ArgumentNullException.ThrowIfNull(unsubscribe);
Add(new ActionSubscription(unsubscribe));
AddRetained(new RetryableSubscription(unsubscribe));
}
private void AddRetained(RetryableSubscription retained)
{
bool disposeNow;
lock (_gate)
{
disposeNow = _disposeRequested;
_subscriptions.Add(retained);
}
if (!disposeNow)
return;
retained.Dispose();
throw new ObjectDisposedException(nameof(SubscriptionSet));
}
public void Dispose()
{
List<IDisposable>? subscriptions =
Interlocked.Exchange(ref _subscriptions, null);
if (subscriptions is null)
return;
RetryableSubscription[] subscriptions;
lock (_gate)
{
_disposeRequested = true;
subscriptions = _subscriptions.ToArray();
}
List<Exception>? errors = null;
for (int i = subscriptions.Count - 1; i >= 0; i--)
for (int i = subscriptions.Length - 1; i >= 0; i--)
{
try
{
@ -52,10 +64,59 @@ internal sealed class SubscriptionSet : IDisposable
throw new AggregateException("one or more subscriptions failed to detach", errors);
}
private sealed class ActionSubscription(Action unsubscribe) : IDisposable
private sealed class RetryableSubscription(Action dispose) : IDisposable
{
private Action? _unsubscribe = unsubscribe;
private readonly object _gate = new();
private Action? _dispose = dispose;
private bool _executing;
private int _executingThreadId;
public void Dispose() => Interlocked.Exchange(ref _unsubscribe, null)?.Invoke();
public void Dispose()
{
Action? operation;
int threadId = Environment.CurrentManagedThreadId;
lock (_gate)
{
while (_executing)
{
if (_executingThreadId == threadId)
{
throw new InvalidOperationException(
"Subscription cleanup cannot complete reentrantly.");
}
Monitor.Wait(_gate);
}
operation = _dispose;
if (operation is null)
return;
_executing = true;
_executingThreadId = threadId;
}
try
{
operation();
}
catch
{
CompleteAttempt(succeeded: false);
throw;
}
CompleteAttempt(succeeded: true);
}
private void CompleteAttempt(bool succeeded)
{
lock (_gate)
{
if (succeeded)
_dispose = null;
_executing = false;
_executingThreadId = 0;
Monitor.PulseAll(_gate);
}
}
}
}