refactor(net): converge live session state reset

This commit is contained in:
Erik 2026-07-21 12:00:48 +02:00
parent 78a9223b65
commit 4f31a5085f
35 changed files with 1460 additions and 83 deletions

View file

@ -403,6 +403,19 @@ public sealed class CombatAttackController : IDisposable
ResetPowerBar();
}
/// <summary>
/// Restore the process-lived attack controller to retail's new-session
/// defaults without sending a cancel packet through the displaced session.
/// Mirrors <c>ClientCombatSystem::Begin @ 0x0056A460</c>.
/// </summary>
public void ResetSession()
{
Reset();
RequestedHeight = AttackHeight.Medium;
DesiredPower = InitialDesiredPower;
StateChanged?.Invoke();
}
public void Dispose()
{
if (_disposed) return;

View file

@ -386,11 +386,21 @@ internal sealed class SelectionInteractionController
public void ResetSession()
{
CancelPendingApproach();
_items.ResetSession();
_selection.Reset();
_outbound.Clear();
List<Exception> failures = [];
try { CancelPendingApproach(); }
catch (Exception error) { failures.Add(error); }
try { _items.ResetSession(); }
catch (Exception error) { failures.Add(error); }
try { _selection.Reset(); }
catch (Exception error) { failures.Add(error); }
try { _outbound.Clear(); }
catch (Exception error) { failures.Add(error); }
_pendingPostArrival = null;
if (failures.Count != 0)
throw new AggregateException(
"One or more selection-interaction reset stages failed.",
failures);
}
private bool ValidatePickupTarget(uint serverGuid, bool showToast)

View file

@ -0,0 +1,127 @@
namespace AcDream.App.Net;
using AcDream.App.World;
/// <summary>
/// Focused owner operations composed by the App host for one character-session
/// reset. Mixed world/static registries are deliberately absent: their
/// session-owned entries leave through canonical live-entity teardown.
/// </summary>
internal sealed class LiveSessionResetBindings
{
public required Action MouseCapture { get; init; }
public required Action PlayerPresentation { get; init; }
public required Action TeleportTransit { get; init; }
public required Action SessionDialogs { get; init; }
public required Action ChatCommandTargets { get; init; }
public required Action SettingsCharacterContext { get; init; }
public required Action EquippedChildren { get; init; }
public required Action ExternalContainer { get; init; }
public required Action InteractionAndSelection { get; init; }
public required Action SelectionPresentation { get; init; }
public required Action ObjectTable { get; init; }
public required Action Spellbook { get; init; }
public required Action MagicRuntime { get; init; }
public required Action CombatAttack { get; init; }
public required Action CombatState { get; init; }
public required Action ItemMana { get; init; }
public required Action LocalPlayer { get; init; }
public required Action Friends { get; init; }
public required Action Squelch { get; init; }
public required Action TurbineChat { get; init; }
public required Action ParticleVisibility { get; init; }
public required Action InboundEventFifo { get; init; }
public required Action LiveLiveness { get; init; }
public required Action LiveRuntime { get; init; }
public required Action SessionIdentity { get; init; }
public required Action RemoteTeleport { get; init; }
public required Action NetworkEffects { get; init; }
public required Action AnimationHookFrames { get; init; }
public required Action LivePresentation { get; init; }
public required Action RemoteMovementDiagnostics { get; init; }
public required Action PhysicsHostIndex { get; init; }
}
/// <summary>
/// The concrete, ordered character-session reset manifest. Retail
/// <c>CPlayerSystem::OnEndCharacterSession @ 0x00562870</c> calls End then
/// Begin; <c>ClientUISystem::OnEndCharacterSession @ 0x00564AD0</c>
/// independently resets gameplay UI. App resource dependencies impose the
/// exact projection-before-canonical-runtime order pinned here.
/// </summary>
internal static class LiveSessionResetManifest
{
public static LiveSessionResetPlan Create(LiveSessionResetBindings bindings)
{
ArgumentNullException.ThrowIfNull(bindings);
return new LiveSessionResetPlan(
[
new("mouse capture", bindings.MouseCapture),
new("player presentation", bindings.PlayerPresentation),
new("teleport transit", bindings.TeleportTransit),
new("session dialogs", bindings.SessionDialogs),
new("chat command targets", bindings.ChatCommandTargets),
new("settings character context", bindings.SettingsCharacterContext),
// Attachment projections own GL-backed registrations and must leave
// before canonical live GUID records are released.
new("equipped children", bindings.EquippedChildren),
new("external container", bindings.ExternalContainer),
new("interaction and selection", bindings.InteractionAndSelection),
new("selection presentation", bindings.SelectionPresentation),
new("object table", bindings.ObjectTable),
new("spellbook", bindings.Spellbook),
new("magic runtime", bindings.MagicRuntime),
new("combat attack", bindings.CombatAttack),
new("combat state", bindings.CombatState),
new("item mana", bindings.ItemMana),
new("local player", bindings.LocalPlayer),
new("friends", bindings.Friends),
new("squelch", bindings.Squelch),
new("turbine chat", bindings.TurbineChat),
new("particle visibility", bindings.ParticleVisibility),
new("inbound event fifo", bindings.InboundEventFifo),
new("live liveness", bindings.LiveLiveness),
new("live runtime", bindings.LiveRuntime),
// Identity must remain A until live teardown has converged so
// player-specific teardown can still classify the old record.
new("session identity", bindings.SessionIdentity),
new("remote teleport", bindings.RemoteTeleport),
// F754/F755 can precede CreateObject, so pending network effects
// must clear even when no LiveEntityRecord was constructed.
new("network effects", bindings.NetworkEffects),
new("animation hook frames", bindings.AnimationHookFrames),
new("live presentation", bindings.LivePresentation),
new("remote movement diagnostics", bindings.RemoteMovementDiagnostics),
new("physics host index", bindings.PhysicsHostIndex),
]);
}
}
/// <summary>Shared convergence gate for canonical live runtime teardown.</summary>
internal static class LiveSessionEntityRuntimeReset
{
public static void ClearAndRequireConvergence(LiveEntityRuntime? runtime)
{
if (runtime is null)
return;
runtime.Clear();
RequireConvergence(runtime);
}
public static void RequireConvergence(LiveEntityRuntime? runtime)
{
if (runtime is null)
return;
if (runtime.Count == 0
&& runtime.PendingTeardownCount == 0
&& runtime.MaterializedCount == 0)
{
return;
}
throw new InvalidOperationException(
"The live-entity runtime has not converged after session clear " +
$"(records={runtime.Count}, pending={runtime.PendingTeardownCount}, " +
$"materialized={runtime.MaterializedCount}).");
}
}

View file

@ -0,0 +1,79 @@
namespace AcDream.App.Net;
/// <summary>One named owner operation in the live-session reset transaction.</summary>
internal sealed record LiveSessionResetStage(string Name, Action Reset);
/// <summary>
/// Executes every session-state reset stage even when an earlier owner fails.
/// A failed attempt throws after the complete manifest has run; lifecycle code
/// therefore cannot construct the next session until a later attempt converges.
/// </summary>
internal sealed class LiveSessionResetPlan
{
private readonly LiveSessionResetStage[] _stages;
private int _executing;
public LiveSessionResetPlan(IEnumerable<LiveSessionResetStage> stages)
{
ArgumentNullException.ThrowIfNull(stages);
_stages = stages.ToArray();
var names = new HashSet<string>(StringComparer.Ordinal);
foreach (LiveSessionResetStage stage in _stages)
{
ArgumentNullException.ThrowIfNull(stage);
if (string.IsNullOrWhiteSpace(stage.Name))
throw new ArgumentException("Reset stage names must be non-empty.", nameof(stages));
ArgumentNullException.ThrowIfNull(stage.Reset);
if (!names.Add(stage.Name))
throw new ArgumentException(
$"Duplicate live-session reset stage '{stage.Name}'.",
nameof(stages));
}
}
public IReadOnlyList<string> StageNames =>
Array.ConvertAll(_stages, static stage => stage.Name);
public void Execute()
{
if (Interlocked.Exchange(ref _executing, 1) != 0)
throw new InvalidOperationException(
"Live-session reset cannot run concurrently or reentrantly.");
List<Exception>? failures = null;
try
{
foreach (LiveSessionResetStage stage in _stages)
{
try
{
stage.Reset();
}
catch (Exception error)
{
(failures ??= []).Add(new LiveSessionResetStageException(
stage.Name,
error));
}
}
}
finally
{
Volatile.Write(ref _executing, 0);
}
if (failures is not null)
throw new AggregateException(
"Live-session state did not converge; a new session must not start.",
failures);
}
}
internal sealed class LiveSessionResetStageException(
string stageName,
Exception innerException) : Exception(
$"Live-session reset stage '{stageName}' failed.",
innerException)
{
public string StageName { get; } = stageName;
}

View file

@ -66,10 +66,14 @@ public sealed class CameraController
public void ExitChaseMode()
{
bool wasChaseMode = IsChaseMode;
Chase = null;
RetailChase = null;
if (_mode == Mode.Orbit)
return;
_mode = Mode.Fly;
ModeChanged?.Invoke(IsFlyMode);
if (wasChaseMode)
ModeChanged?.Invoke(IsFlyMode);
}
public void SetAspect(float aspect)

View file

@ -817,6 +817,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// an ICommandBus and becomes inert before the displaced socket closes.
private AcDream.App.Net.LiveSessionEventRouter? _liveSessionEvents;
private AcDream.App.Net.LiveSessionCommandRouter? _liveSessionCommands;
private AcDream.App.Net.LiveSessionResetPlan? _liveSessionResetPlan;
// Phase G.1-G.2 world lighting/time state.
public readonly AcDream.Core.World.WorldTimeService WorldTime =
@ -1785,6 +1786,13 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// writes under the chosen character's
// name (or "default" pre-login).
settingsStore.SaveCharacter(_activeToonKey, character);
if (string.Equals(
_activeToonKey,
"default",
StringComparison.OrdinalIgnoreCase))
{
_persistedCharacter = character;
}
Console.WriteLine(
$"settings: character[{_activeToonKey}] saved to "
+ AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
@ -2946,23 +2954,125 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private void ClearInboundEntityState()
{
EndMouseLookAndRestoreCursor();
ResetTeleportTransitState(clearSession: true);
if (_playerController is not null)
_playerController.State = AcDream.App.Input.PlayerState.InWorld;
(_liveSessionResetPlan ??= CreateLiveSessionResetPlan()).Execute();
}
// Session-scoped origin readiness. A reconnect's first logical player
// CreateObject must initialize the render/streaming center anew;
// projection recovery inside one session never crosses this boundary.
private AcDream.App.Net.LiveSessionResetPlan CreateLiveSessionResetPlan() =>
AcDream.App.Net.LiveSessionResetManifest.Create(new()
{
MouseCapture = ResetSessionMouseCapture,
PlayerPresentation = ResetSessionPlayerPresentation,
TeleportTransit = () => ResetTeleportTransitState(clearSession: true),
SessionDialogs = () => _retailUiRuntime?.ResetSessionDialogs(),
ChatCommandTargets = () => _retailChatVm?.ResetSessionTargets(),
SettingsCharacterContext = () =>
_settingsVm?.LoadCharacterContext(_persistedCharacter),
EquippedChildren = () => _equippedChildRenderer?.Clear(),
ExternalContainer = () => _externalContainers.Reset(),
InteractionAndSelection = ResetSessionInteraction,
SelectionPresentation = () => _retailSelectionScene?.Reset(),
ObjectTable = Objects.Clear,
Spellbook = SpellBook.Clear,
MagicRuntime = () => _magicRuntime?.Reset(),
CombatAttack = () => _combatAttackController?.ResetSession(),
CombatState = Combat.Clear,
ItemMana = ItemMana.Clear,
LocalPlayer = LocalPlayer.Clear,
Friends = Friends.Clear,
Squelch = Squelch.Clear,
TurbineChat = TurbineChat.Reset,
ParticleVisibility = _particleVisibility.Reset,
InboundEventFifo = _inboundEntityEvents.Clear,
LiveLiveness = () => _liveEntityLiveness?.Clear(),
LiveRuntime = ResetSessionLiveRuntime,
SessionIdentity = ResetSessionIdentity,
RemoteTeleport = () => _remoteTeleportController?.Clear(),
NetworkEffects = () => _entityEffects?.ClearNetworkState(),
AnimationHookFrames = () => _animationHookFrames?.Clear(),
LivePresentation = () => _liveEntityPresentation?.Clear(),
RemoteMovementDiagnostics = _remoteLastMove.Clear,
PhysicsHostIndex = _physicsHosts.Clear,
});
private void ResetSessionMouseCapture()
{
bool wasActive = _mouseLook?.Active == true;
_mouseLook?.Release();
if (wasActive || _mouseLookSavedCursorMode.HasValue)
RestoreCursorAfterMouseLook();
}
private void ResetSessionPlayerPresentation()
{
_playerModeAutoEntry?.Cancel();
try
{
_cameraController?.ExitChaseMode();
}
finally
{
_playerMode = false;
_playerController = null;
_playerHost = null;
_chaseCamera = null;
_retailChaseCamera = null;
_playerMouseDeltaX = 0f;
_rmbHeld = false;
_autoRunActive = false;
_chaseModeEverEntered = false;
_lastMovementTruthOutbound = null;
_lastLocalPlayerShadow = null;
_spawnClaimRangeMemo = null;
}
}
private void ResetSessionIdentity()
{
AcDream.App.Net.LiveSessionEntityRuntimeReset.RequireConvergence(_liveEntities);
// PlayerModule::Clear @ 0x005D48A0 clears shortcuts, desired
// components, and character option objects. CPlayerSystem::Begin
// @ 0x0055D410 resets player identity and session fields. The option
// default comes from PlayerModule::PlayerModule @ 0x005D51F0.
_playerServerGuid = 0u;
_vitalsVm?.SetLocalPlayerGuid(0u);
Chat.ResetSessionIdentity();
AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = 0u;
_activeToonKey = "default";
_characterOptions1 =
AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1.Default;
_playerMotionTableId = null;
_lastSeenRunSkill = -1;
_lastSeenJumpSkill = -1;
_lastLivePlayerLandblockId = null;
_liveSpawnReceived = 0;
_liveSpawnHydrated = 0;
_liveDropReasonNoPos = 0;
_liveDropReasonNoSetup = 0;
_liveDropReasonSetupDatMissing = 0;
_liveDropReasonNoMeshRefs = 0;
_liveAnimRejectNoCycle = 0;
_liveAnimRejectFramerate = 0;
_liveAnimRejectSingleFrame = 0;
_liveAnimRejectPartFrames = 0;
Shortcuts = Array.Empty<AcDream.Core.Items.ShortcutEntry>();
DesiredComponents = Array.Empty<(uint Id, uint Amount)>();
_paperdollDollDirty = true;
// X/Y are ignored until the next logical player CreateObject claims a
// new origin. Keeping the last values avoids inventing a synthetic
// landblock while still forcing first-spawn initialization.
_liveCenterKnown = false;
}
// Attachment projections own GL-backed world registrations, so tear
// them down before dropping the canonical GUID/timestamp snapshots.
_equippedChildRenderer?.Clear();
_externalContainers.Reset();
Objects.Clear();
SpellBook.Clear();
_magicRuntime?.Reset();
private void ResetSessionLiveRuntime()
{
AcDream.App.Net.LiveSessionEntityRuntimeReset.ClearAndRequireConvergence(
_liveEntities);
}
private void ResetSessionInteraction()
{
if (_selectionInteractions is { } interactions)
interactions.ResetSession();
else
@ -2970,36 +3080,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_itemInteractionController?.ResetSession();
_selection.Reset();
}
_retailSelectionScene?.Reset();
_particleVisibility.Reset();
try
{
_liveEntityLiveness?.Clear();
_liveEntities?.Clear();
}
finally
{
try
{
// A pending teleport is operational state outside the live
// record. Clear it independently even if a resource callback
// failed while the canonical runtime was draining.
_remoteTeleportController?.Clear();
}
finally
{
try
{
// F754/F755 can precede CreateObject, so the mixed pending FIFO
// may contain owners with no LiveEntityRecord to tear down.
_entityEffects?.ClearNetworkState();
}
finally
{
_animationHookFrames?.Clear();
}
}
}
}
/// <summary>

View file

@ -66,6 +66,17 @@ public sealed class GameplayConfirmationController : IDisposable
return _dialogs.CloseDialog(_dialogContext);
}
/// <summary>
/// Forget any tuple left after the dialog factory has completed its normal
/// retail close/reset callbacks.
/// </summary>
public void ResetSession()
{
_dialogContext = 0u;
_serverType = 0u;
_serverContext = 0u;
}
public void Dispose()
{
if (_disposed) return;

View file

@ -42,6 +42,31 @@ public sealed class InteractionState
public bool Clear() => Set(InteractionMode.None);
/// <summary>
/// Publishes the session-reset edge even when the mode is already clear so
/// a retry can repair any observer that missed an earlier notification.
/// </summary>
public void ResetSession()
{
InteractionMode previous = Current;
Current = InteractionMode.None;
Action<InteractionModeTransition>? listeners = Changed;
if (listeners is null)
return;
var transition = new InteractionModeTransition(previous, InteractionMode.None);
List<Exception>? failures = null;
foreach (Action<InteractionModeTransition> listener in listeners.GetInvocationList())
{
try { listener(transition); }
catch (Exception error) { (failures ??= []).Add(error); }
}
if (failures is not null)
throw new AggregateException(
"One or more interaction-mode reset observers failed.",
failures);
}
private bool Set(InteractionMode mode)
{
if (Current == mode)

View file

@ -1009,7 +1009,14 @@ public sealed class ItemInteractionController : IDisposable
};
private void OnInteractionModeChanged(InteractionModeTransition _)
=> StateChanged?.Invoke();
{
List<Exception> failures = [];
DispatchAll(StateChanged, failures);
if (failures.Count != 0)
throw new AggregateException(
"One or more item-interaction observers failed.",
failures);
}
private void OnInventoryObjectMoved(ClientObjectMove move)
{
@ -1124,17 +1131,50 @@ public sealed class ItemInteractionController : IDisposable
/// </summary>
public void ResetSession()
{
CancelPendingBackpackPlacement();
bool busyChanged = _busyCount != 0 || _pendingInventoryRequest is not null;
bool modeChanged = IsAnyTargetModeActive;
PendingBackpackPlacement? pendingPlacement = _pendingBackpackPlacement;
_pendingBackpackPlacement = null;
_busyCount = 0;
_pendingInventoryRequest = null;
_lastUseMs = long.MinValue / 2;
_consumedPrimaryClickTarget = 0u;
_consumedPrimaryClickMs = long.MinValue / 2;
_interactionState.Clear();
if (busyChanged && !modeChanged)
StateChanged?.Invoke();
List<Exception> failures = [];
try { _interactionState.ResetSession(); }
catch (Exception error) { failures.Add(error); }
if (pendingPlacement is { } pending)
DispatchAll(PendingBackpackPlacementCancelled, pending, failures);
if (failures.Count != 0)
throw new AggregateException(
"One or more item-interaction reset observers failed.",
failures);
}
private static void DispatchAll(Action? listeners, List<Exception> failures)
{
if (listeners is null)
return;
foreach (Action listener in listeners.GetInvocationList())
{
try { listener(); }
catch (Exception error) { failures.Add(error); }
}
}
private static void DispatchAll<T>(
Action<T>? listeners,
T value,
List<Exception> failures)
{
if (listeners is null)
return;
foreach (Action<T> listener in listeners.GetInvocationList())
{
try { listener(value); }
catch (Exception error) { failures.Add(error); }
}
}
private bool ConsumeUseThrottle()

View file

@ -26,6 +26,7 @@ public sealed class RetailDialogFactory : IDisposable
private readonly Dictionary<uint, LinkedList<DialogInfo>> _pending = new();
private readonly List<DialogInfo> _openOrder = new();
private uint _globalContext;
private bool _resetting;
private bool _disposed;
public RetailDialogFactory(
@ -192,18 +193,46 @@ public sealed class RetailDialogFactory : IDisposable
/// </summary>
public void Reset()
{
DialogInfo[] infos = _activeNonQueued.Values
.Concat(_activeQueued.Values)
.Concat(_pending.Values.SelectMany(static queue => queue))
.Distinct()
.ToArray();
if (_resetting)
return;
_activeNonQueued.Clear();
_activeQueued.Clear();
_pending.Clear();
foreach (DialogInfo info in infos)
DialogDone(info);
RefreshModal();
_resetting = true;
List<Exception>? failures = null;
try
{
while (true)
{
DialogInfo[] infos = _activeNonQueued.Values
.Concat(_activeQueued.Values)
.Concat(_pending.Values.SelectMany(static queue => queue))
.Distinct()
.ToArray();
if (infos.Length == 0)
break;
// Callbacks may synchronously create another dialog. Retire
// this exact snapshot, then loop until those reentrant infos
// have also completed through DialogDone.
_activeNonQueued.Clear();
_activeQueued.Clear();
_pending.Clear();
foreach (DialogInfo info in infos)
{
try { DialogDone(info); }
catch (Exception error) { (failures ??= []).Add(error); }
}
}
}
finally
{
_resetting = false;
RefreshModal();
}
if (failures is not null)
throw new AggregateException(
"One or more dialogs failed while the factory reset.",
failures);
}
public void Dispose()
@ -283,10 +312,10 @@ public sealed class RetailDialogFactory : IDisposable
{
if (info.View is { } view)
{
info.View = null;
view.DetachHandlers();
_openOrder.Remove(info);
_host.RemoveChild(view.Root);
info.View = null;
_openOrder.Remove(info);
}
RefreshModal();
}

View file

@ -386,6 +386,25 @@ public sealed class RetailUiRuntime : IDisposable
data => completed(data.GetBoolean(RetailDialogProperty.ConfirmationResult))) ?? 0u;
}
/// <summary>
/// Close character-session dialogs through retail's normal callback and
/// close-notice path, then forget the gameplay confirmation tuple.
/// <c>gmGamePlayUI::~gmGamePlayUI @ 0x004EA2A0</c> closes its retained
/// contexts before <c>DialogFactory::Reset @ 0x00477950</c> drains the
/// remaining factory entries.
/// </summary>
public void ResetSessionDialogs()
{
try
{
DialogFactory?.Reset();
}
finally
{
_gameplayConfirmationController?.ResetSession();
}
}
public void UpdateCursor(IEnumerable<IMouse> mice)
{
CursorFeedback feedback = _bindings.Cursor.Feedback.Update(Host.Root);