refactor(net): converge live session state reset
This commit is contained in:
parent
78a9223b65
commit
4f31a5085f
35 changed files with 1460 additions and 83 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
127
src/AcDream.App/Net/LiveSessionResetManifest.cs
Normal file
127
src/AcDream.App/Net/LiveSessionResetManifest.cs
Normal 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}).");
|
||||
}
|
||||
}
|
||||
79
src/AcDream.App/Net/LiveSessionResetPlan.cs
Normal file
79
src/AcDream.App/Net/LiveSessionResetPlan.cs
Normal 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;
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -62,14 +62,27 @@ public sealed class ChatLog
|
|||
|
||||
/// <summary>
|
||||
/// Push the authoritative local-player GUID from <c>WorldSession</c>.
|
||||
/// One-way setter — only <c>GameWindow</c> should call it, exactly
|
||||
/// once per live session. Used by <see cref="OnLocalSpeech"/> to
|
||||
/// The host sets it after character selection and resets it at session
|
||||
/// teardown. Used by <see cref="OnLocalSpeech"/> to
|
||||
/// recognize ACE's HearSpeech echo of our own /say (server's
|
||||
/// HandleActionTalk broadcasts to all in range INCLUDING the
|
||||
/// sender) and re-render it as <c>"You say, ..."</c>.
|
||||
/// </summary>
|
||||
public void SetLocalPlayerGuid(uint guid) => _localPlayerGuid = guid;
|
||||
|
||||
/// <summary>
|
||||
/// Reset per-session speaker classification and duplicate suppression
|
||||
/// while preserving the visible transcript. Retail has an explicit
|
||||
/// <c>ChatInterface::RecvNotice_ClearChatBuffer @ 0x004F2F60</c> path;
|
||||
/// character-session teardown does not use that clear-buffer notice.
|
||||
/// </summary>
|
||||
public void ResetSessionIdentity()
|
||||
{
|
||||
_localPlayerGuid = 0u;
|
||||
_lastSystemText = string.Empty;
|
||||
_lastSystemAt = DateTime.MinValue;
|
||||
}
|
||||
|
||||
// ── Inbound adapters ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Local or ranged HearSpeech (0x02BB / 0x02BC).</summary>
|
||||
|
|
|
|||
|
|
@ -93,6 +93,33 @@ public sealed class TurbineChatState
|
|||
SocietyRadiantBloodRoom = societyRadiantBloodRoom;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return the server-assigned room table and outbound context sequence to
|
||||
/// their pre-login values. Room ids and cookies belong to one negotiated
|
||||
/// character session and must never cross a reconnect boundary.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Retail <c>CCommunicationSystem</c> construction at <c>0x005566C0</c>
|
||||
/// starts Turbine chat disabled; SetTurbineChatChannels (0x0295) supplies
|
||||
/// rooms per character. Cookie 1 is the Holtburger protocol-reference
|
||||
/// initial value, with zero reserved for no context.
|
||||
/// </remarks>
|
||||
public void Reset()
|
||||
{
|
||||
Enabled = false;
|
||||
AllegianceRoom = 0u;
|
||||
GeneralRoom = 0u;
|
||||
TradeRoom = 0u;
|
||||
LfgRoom = 0u;
|
||||
RoleplayRoom = 0u;
|
||||
OlthoiRoom = 0u;
|
||||
SocietyRoom = 0u;
|
||||
SocietyCelestialHandRoom = 0u;
|
||||
SocietyEldrytchWebRoom = 0u;
|
||||
SocietyRadiantBloodRoom = 0u;
|
||||
_nextContextId = 1u;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Look up the runtime room id for a Turbine
|
||||
/// <see cref="ChatChannelKindLite"/>. Returns 0 if the channel is not
|
||||
|
|
|
|||
|
|
@ -178,6 +178,21 @@ public sealed class CombatState
|
|||
public void Clear()
|
||||
{
|
||||
_healthByGuid.Clear();
|
||||
SetCombatMode(CombatMode.NonCombat);
|
||||
CurrentMode = CombatMode.NonCombat;
|
||||
|
||||
Action<CombatMode>? listeners = CombatModeChanged;
|
||||
if (listeners is null)
|
||||
return;
|
||||
|
||||
List<Exception>? failures = null;
|
||||
foreach (Action<CombatMode> listener in listeners.GetInvocationList())
|
||||
{
|
||||
try { listener(CombatMode.NonCombat); }
|
||||
catch (Exception error) { (failures ??= []).Add(error); }
|
||||
}
|
||||
if (failures is not null)
|
||||
throw new AggregateException(
|
||||
"One or more combat reset observers failed.",
|
||||
failures);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,12 +101,24 @@ public sealed class ExternalContainerState
|
|||
bool changed = previous != 0u || RequestedContainerId != 0u;
|
||||
CurrentContainerId = 0u;
|
||||
RequestedContainerId = 0u;
|
||||
if (changed)
|
||||
|
||||
var transition = new ExternalContainerTransition(
|
||||
ExternalContainerTransitionKind.Reset,
|
||||
previous,
|
||||
0u);
|
||||
Action<ExternalContainerTransition>? listeners = Changed;
|
||||
if (listeners is not null)
|
||||
{
|
||||
Changed?.Invoke(new ExternalContainerTransition(
|
||||
ExternalContainerTransitionKind.Reset,
|
||||
previous,
|
||||
0u));
|
||||
List<Exception>? failures = null;
|
||||
foreach (Action<ExternalContainerTransition> listener in listeners.GetInvocationList())
|
||||
{
|
||||
try { listener(transition); }
|
||||
catch (Exception error) { (failures ??= []).Add(error); }
|
||||
}
|
||||
if (failures is not null)
|
||||
throw new AggregateException(
|
||||
"One or more external-container reset observers failed.",
|
||||
failures);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -454,6 +454,39 @@ public sealed class LocalPlayerState
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return the character snapshot to its pre-login state. The object itself
|
||||
/// is process-lived because UI view models subscribe to it once; session
|
||||
/// replacement clears its contents instead of replacing the owner.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Retail: <c>CPlayerSystem::OnEndCharacterSession @ 0x00562870</c>
|
||||
/// calls <c>End @ 0x005606C0</c>, which invokes
|
||||
/// <c>PlayerModule::Clear @ 0x005D48A0</c>, then immediately calls
|
||||
/// <c>CPlayerSystem::Begin @ 0x0055D410</c>. Re-publishing invalidation
|
||||
/// events is acdream's process-lived-view adaptation.
|
||||
/// </remarks>
|
||||
public void Clear()
|
||||
{
|
||||
_health = null;
|
||||
_stamina = null;
|
||||
_mana = null;
|
||||
_attrs.Clear();
|
||||
_skills.Clear();
|
||||
_positions.Clear();
|
||||
_properties = new PropertyBundle();
|
||||
|
||||
// Process-lived views pull through this object and use these events as
|
||||
// their invalidation edge. Publish every category even when Clear is
|
||||
// repeated so a failed reset attempt can safely converge on retry.
|
||||
Changed?.Invoke(VitalKind.Health);
|
||||
Changed?.Invoke(VitalKind.Stamina);
|
||||
Changed?.Invoke(VitalKind.Mana);
|
||||
foreach (AttributeKind kind in Enum.GetValues<AttributeKind>())
|
||||
AttributeChanged?.Invoke(kind);
|
||||
CharacterChanged?.Invoke();
|
||||
}
|
||||
|
||||
private static uint SaturatingAdd(uint value, uint delta)
|
||||
=> uint.MaxValue - value < delta ? uint.MaxValue : value + delta;
|
||||
|
||||
|
|
|
|||
|
|
@ -78,20 +78,25 @@ public sealed class SelectionState : ISelectionService
|
|||
public bool Reset(SelectionChangeSource source = SelectionChangeSource.System)
|
||||
{
|
||||
uint? old = SelectedObjectId;
|
||||
bool changed = old is not null
|
||||
|| PreviousObjectId is not null
|
||||
|| PreviousValidObjectId is not null;
|
||||
SelectedObjectId = null;
|
||||
PreviousObjectId = null;
|
||||
PreviousValidObjectId = null;
|
||||
if (old is null)
|
||||
return false;
|
||||
|
||||
var transition = new SelectionTransition(
|
||||
old,
|
||||
null,
|
||||
source,
|
||||
SelectionChangeReason.SessionReset);
|
||||
Changed?.Invoke(transition);
|
||||
List<Exception>? failures = DispatchChanged(transition);
|
||||
DispatchPluginChanged(new SelectionChangedEvent(old, null));
|
||||
return true;
|
||||
if (failures is not null)
|
||||
throw new AggregateException(
|
||||
"One or more selection reset observers failed.",
|
||||
failures);
|
||||
return changed;
|
||||
}
|
||||
|
||||
bool ISelectionService.Select(uint objectId)
|
||||
|
|
@ -120,6 +125,21 @@ public sealed class SelectionState : ISelectionService
|
|||
return true;
|
||||
}
|
||||
|
||||
private List<Exception>? DispatchChanged(SelectionTransition transition)
|
||||
{
|
||||
Action<SelectionTransition>? listeners = Changed;
|
||||
if (listeners is null)
|
||||
return null;
|
||||
|
||||
List<Exception>? failures = null;
|
||||
foreach (Action<SelectionTransition> listener in listeners.GetInvocationList())
|
||||
{
|
||||
try { listener(transition); }
|
||||
catch (Exception error) { (failures ??= []).Add(error); }
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
private void DispatchPluginChanged(SelectionChangedEvent change)
|
||||
{
|
||||
Action<SelectionChangedEvent>? listeners = PluginChanged;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,17 @@ public sealed class SquelchState
|
|||
ArgumentNullException.ThrowIfNull(database);
|
||||
lock (_gate) _database = database;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drop the server-owned squelch database for the old session. Retail
|
||||
/// <c>CPlayerSystem::LogOnCharacter @ 0x0055F890</c> calls
|
||||
/// <c>gmCCommunicationSystem::ClearSquelchDB @ 0x00589240</c> before
|
||||
/// entering the next character.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
lock (_gate) _database = SquelchDatabase.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record SquelchInfo(
|
||||
|
|
|
|||
|
|
@ -116,6 +116,17 @@ public sealed class ChatVM
|
|||
/// </summary>
|
||||
public void Clear() => _log.Clear();
|
||||
|
||||
/// <summary>
|
||||
/// Forget per-session reply/retell destinations while retaining the shared
|
||||
/// transcript. Old character names must not become command targets after a
|
||||
/// reconnect.
|
||||
/// </summary>
|
||||
public void ResetSessionTargets()
|
||||
{
|
||||
LastIncomingTellSender = null;
|
||||
LastOutgoingTellTarget = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Print the current framerate into chat. Used by
|
||||
/// <c>/framerate</c> / <c>@framerate</c>. Falls back to a
|
||||
|
|
|
|||
|
|
@ -55,8 +55,8 @@ public sealed class VitalsVM
|
|||
|
||||
/// <summary>
|
||||
/// Push the authoritative local-player GUID from <c>WorldSession</c>.
|
||||
/// One-way setter — only <c>GameWindow</c> should call it, exactly once
|
||||
/// per live session.
|
||||
/// The host assigns it after character selection and resets it to zero at
|
||||
/// session teardown.
|
||||
/// </summary>
|
||||
public void SetLocalPlayerGuid(uint guid) => _localPlayerGuid = guid;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue