diff --git a/src/AcDream.App/Net/LiveSessionController.cs b/src/AcDream.App/Net/LiveSessionController.cs
index 9204f8b4..13acfa40 100644
--- a/src/AcDream.App/Net/LiveSessionController.cs
+++ b/src/AcDream.App/Net/LiveSessionController.cs
@@ -216,7 +216,6 @@ public sealed class LiveSessionController : IDisposable
private SessionScope? _retiredScope;
private ILiveSessionLifecycleHost? _pendingInitialResetHost;
private PendingOperation? _pendingOperation;
- private WorldSession? _legacySession;
private int _operationDepth;
private bool _inWorld;
private bool _disposeRequested;
@@ -236,12 +235,9 @@ public sealed class LiveSessionController : IDisposable
public WorldSession? CurrentSession
{
- get { lock (_gate) return _scope?.Session ?? _legacySession; }
+ get { lock (_gate) return _scope?.Session; }
}
- /// Temporary Slice-3 compatibility alias; removed at GameWindow cutover.
- public WorldSession? Session => CurrentSession;
-
public ICommandBus Commands
{
get
@@ -319,11 +315,6 @@ public sealed class LiveSessionController : IDisposable
{
if (_disposed)
return;
- if (_legacySession is not null && _scope is null)
- {
- _legacySession.Tick();
- return;
- }
if (!_inWorld || _scope is null || _operationDepth != 0)
return;
@@ -629,11 +620,6 @@ public sealed class LiveSessionController : IDisposable
private void DisposeCore()
{
StopCore();
- if (_legacySession is { } legacy)
- {
- _operations.DisposeSession(legacy);
- _legacySession = null;
- }
_disposed = true;
}
@@ -649,40 +635,4 @@ public sealed class LiveSessionController : IDisposable
throw new ObjectDisposedException(nameof(LiveSessionController));
}
- // Temporary compatibility surface for Slice 3 Commit E. The complete
- // controller above is exercised independently before GameWindow cuts over.
- public WorldSession? CreateAndWire(RuntimeOptions options, Action wireEvents)
- {
- ArgumentNullException.ThrowIfNull(options);
- ArgumentNullException.ThrowIfNull(wireEvents);
- lock (_gate)
- {
- ThrowIfDisposing();
- if (!options.LiveMode)
- return null;
- if (string.IsNullOrEmpty(options.LiveUser) || string.IsNullOrEmpty(options.LivePass))
- {
- Console.WriteLine(
- "live: ACDREAM_LIVE set but TEST_USER/TEST_PASS missing; skipping");
- return null;
- }
-
- try
- {
- IPEndPoint endpoint = _operations.ResolveEndpoint(options.LiveHost, options.LivePort);
- Console.WriteLine($"live: connecting to {endpoint} as {options.LiveUser}");
- _legacySession = _operations.CreateSession(endpoint);
- wireEvents(_legacySession);
- return _legacySession;
- }
- catch (Exception error)
- {
- Console.WriteLine($"live: session setup failed: {error.Message}");
- if (_legacySession is not null)
- _operations.DisposeSession(_legacySession);
- _legacySession = null;
- return null;
- }
- }
- }
}
diff --git a/src/AcDream.App/Net/LiveSessionLifecycleHost.cs b/src/AcDream.App/Net/LiveSessionLifecycleHost.cs
new file mode 100644
index 00000000..87596b75
--- /dev/null
+++ b/src/AcDream.App/Net/LiveSessionLifecycleHost.cs
@@ -0,0 +1,65 @@
+using AcDream.Core.Net;
+
+namespace AcDream.App.Net;
+
+internal sealed record LiveSessionLifecycleBindings(
+ Func Bind,
+ Action Reset,
+ Action Connecting,
+ Action Connected,
+ Action Selected,
+ Action Entered);
+
+///
+/// Focused adapter between the session lifetime owner and App composition.
+/// It tracks only the exact borrowed session attached to the host; domain and
+/// presentation state remain behind the supplied lifecycle callbacks.
+///
+internal sealed class LiveSessionLifecycleHost : ILiveSessionLifecycleHost
+{
+ private readonly LiveSessionLifecycleBindings _bindings;
+ private WorldSession? _boundSession;
+
+ public LiveSessionLifecycleHost(LiveSessionLifecycleBindings bindings)
+ {
+ _bindings = bindings ?? throw new ArgumentNullException(nameof(bindings));
+ ArgumentNullException.ThrowIfNull(bindings.Bind);
+ ArgumentNullException.ThrowIfNull(bindings.Reset);
+ ArgumentNullException.ThrowIfNull(bindings.Connecting);
+ ArgumentNullException.ThrowIfNull(bindings.Connected);
+ ArgumentNullException.ThrowIfNull(bindings.Selected);
+ ArgumentNullException.ThrowIfNull(bindings.Entered);
+ }
+
+ public LiveSessionBinding BindSession(WorldSession session)
+ {
+ ArgumentNullException.ThrowIfNull(session);
+ if (_boundSession is not null)
+ throw new InvalidOperationException("A live session is already attached to this host.");
+
+ LiveSessionBinding binding = _bindings.Bind(session);
+ _boundSession = session;
+ return binding;
+ }
+
+ public void ResetSessionState() => _bindings.Reset();
+
+ public void ReportConnecting(string host, int port, string user) =>
+ _bindings.Connecting(host, port, user);
+
+ public void ReportConnected() => _bindings.Connected();
+
+ public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) =>
+ _bindings.Selected(selection);
+
+ public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) =>
+ _bindings.Entered(selection);
+
+ public void DetachSession(WorldSession session)
+ {
+ if (!ReferenceEquals(_boundSession, session))
+ throw new InvalidOperationException(
+ "The live-session controller attempted to detach a session that is not bound.");
+ _boundSession = null;
+ }
+}
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index f6d9bcf7..57f81411 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -813,10 +813,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private bool DevToolsEnabled => _options.DevTools;
private bool DumpMoveTruthEnabled => _options.DumpMoveTruth;
- // Slice 3: exact session-generation owners. The command router is itself
- // an ICommandBus and becomes inert before the displaced socket closes.
- private AcDream.App.Net.LiveSessionEventRouter? _liveSessionEvents;
- private AcDream.App.Net.LiveSessionCommandRouter? _liveSessionCommands;
+ // 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.
@@ -954,7 +952,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private bool _autoRunActive;
// Phase K.2 — auto-enter player mode after a successful login. Armed
- // by the EnterWorld branch in BeginLiveSessionAsync; ticked from
+ // by ApplyLiveSessionEnteredWorld; 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
@@ -1004,12 +1002,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// Phase 4.7: optional live connection to an ACE server. Enabled only when
// ACDREAM_LIVE=1 is in the environment — fully backward compatible with
// the offline rendering pipeline.
- // Step 2 re-attempt (2026-05-16, debug pass): the network-side lifecycle
- // (DNS, endpoint, WorldSession construction, Tick, Dispose) lives in
- // LiveSessionController. _liveSession remains as a convenience handle
- // for the ~60 outbound SendXxx call sites; it tracks Controller.Session.
+ // 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.Core.Net.WorldSession? _liveSession;
+ private AcDream.Core.Net.WorldSession? LiveSession =>
+ _liveSessionController?.CurrentSession;
private int _liveCenterX;
private int _liveCenterY;
// #192 (2026-07-09): true once the player's first canonical Position has
@@ -1184,13 +1181,13 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_localPlayerProjection.Project(controller, movement, hidden),
sendPreNetwork: (controller, movement, hidden) =>
_localPlayerOutbound.SendPreNetworkActions(
- _liveSession,
+ LiveSession,
controller,
movement,
hidden),
sendPostNetwork: (controller, hidden) =>
_localPlayerOutbound.SendPostNetworkPosition(
- _liveSession,
+ LiveSession,
controller,
hidden),
objectClockDisposition: () =>
@@ -1324,9 +1321,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// when the entity isn't ready (the third predicate
// guarantees readiness before this fires).
_playerModeAutoEntry = new AcDream.App.Input.PlayerModeAutoEntry(
- isLiveInWorld: () => _liveSession is not null
- && _liveSession.CurrentState ==
- AcDream.Core.Net.WorldSession.State.InWorld,
+ isLiveInWorld: () => _liveSessionController?.IsInWorld == true,
isPlayerEntityPresent: () => _entitiesByServerGuid.ContainsKey(_playerServerGuid),
isPlayerControllerReady: () => true,
// Retail SmartBox::UseTime (0x00455410) completes the player
@@ -1781,7 +1776,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
try
{
// _activeToonKey is updated by
- // BeginLiveSessionAsync after EnterWorld
+ // ApplyLiveSessionEnteredWorld
// so saving character settings always
// writes under the chosen character's
// name (or "default" pre-login).
@@ -2015,7 +2010,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
CanStartLiveCombatAttack,
SendLiveCombatAttack,
prepareAttackRequest: PreparePlayerForAttackRequest,
- sendCancelAttack: () => _liveSession?.SendCancelAttack(),
+ sendCancelAttack: () => LiveSession?.SendCancelAttack(),
isDualWield: () => _playerController?.Motion.InterpretedState.CurrentStyle
== AcDream.Core.Combat.CombatInputPlanner.DualWieldCombatStyle,
playerReadyForAttack: IsPlayerReadyForCombatAttack,
@@ -2028,7 +2023,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_externalContainerLifecycle = new AcDream.App.World.ExternalContainerLifecycleController(
_externalContainers,
Objects,
- guid => _liveSession?.SendNoLongerViewingContents(guid));
+ guid => LiveSession?.SendNoLongerViewingContents(guid));
AcDream.App.Spells.MagicCatalog magicCatalog = _magicCatalog!;
_itemInteractionController = new AcDream.App.UI.ItemInteractionController(
@@ -2041,25 +2036,24 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// WorldSession directly here made keyboard R approach a corpse
// without completing the same open transaction as double-click.
sendUse: SendUse,
- sendExamine: g => _liveSession?.SendAppraise(g),
- sendUseWithTarget: (source, target) => _liveSession?.SendUseWithTarget(source, target),
- sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
- sendDrop: item => _liveSession?.SendDropItem(item),
+ sendExamine: g => LiveSession?.SendAppraise(g),
+ sendUseWithTarget: (source, target) => LiveSession?.SendUseWithTarget(source, target),
+ sendWield: (item, mask) => LiveSession?.SendGetAndWieldItem(item, mask),
+ sendDrop: item => LiveSession?.SendDropItem(item),
sendGive: (target, item, amount) =>
- _liveSession?.SendGiveObject(target, item, amount),
+ LiveSession?.SendGiveObject(target, item, amount),
dragOnPlayerOpensSecureTrade: () =>
(_characterOptions1
& AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1
.DragItemOnPlayerOpensSecureTrade) != 0,
toast: text => _debugVm?.AddToast(text),
- readyForInventoryRequest: () => _liveSession is not null
- && _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld,
+ readyForInventoryRequest: () => _liveSessionController?.IsInWorld == true,
playerOnGround: GetDebugPlayerOnGround,
inNonCombatMode: () => Combat.CurrentMode
== AcDream.Core.Combat.CombatMode.NonCombat,
combatState: Combat,
sendChangeCombatMode: mode =>
- _liveSession?.SendChangeCombatMode(mode),
+ LiveSession?.SendChangeCombatMode(mode),
isComponentPack: magicCatalog.IsComponentPack,
placeInBackpack: SendPickUp,
backpackContainerId: () =>
@@ -2071,14 +2065,14 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// the retained controller remains alive.
groundObjectId: () => _externalContainers.CurrentContainerId,
sendSplitToWorld: (item, amount) =>
- _liveSession?.SendStackableSplitTo3D(item, amount),
+ LiveSession?.SendStackableSplitTo3D(item, amount),
selectedObjectId: () => _selection.SelectedObjectId ?? 0u,
stackSplitQuantity: _stackSplitQuantity,
systemMessage: text => Chat.OnSystemMessage(text, 0x1Au),
sendPutItemInContainer: (item, container, placement) =>
- _liveSession?.SendPutItemInContainer(item, container, placement),
+ LiveSession?.SendPutItemInContainer(item, container, placement),
sendSplitToContainer: (item, container, placement, amount) =>
- _liveSession?.SendStackableSplitToContainer(
+ LiveSession?.SendStackableSplitToContainer(
item, container, placement, amount),
requestExternalContainer: guid => _externalContainers.RequestOpen(guid));
@@ -2101,12 +2095,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
playerGuid: () => _playerServerGuid,
activeToonName: () => _activeToonKey,
fallbackSheet: AcDream.App.Studio.SampleData.SampleCharacter,
- canSendRaise: () => _liveSession is not null
- && _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld,
- sendRaiseAttribute: (statId, cost) => _liveSession?.SendRaiseAttribute(statId, cost),
- sendRaiseVital: (statId, cost) => _liveSession?.SendRaiseVital(statId, cost),
- sendRaiseSkill: (statId, cost) => _liveSession?.SendRaiseSkill(statId, cost),
- sendTrainSkill: (statId, credits) => _liveSession?.SendTrainSkill(statId, credits));
+ canSendRaise: () => _liveSessionController?.IsInWorld == true,
+ sendRaiseAttribute: (statId, cost) => LiveSession?.SendRaiseAttribute(statId, cost),
+ sendRaiseVital: (statId, cost) => LiveSession?.SendRaiseVital(statId, cost),
+ sendRaiseSkill: (statId, cost) => LiveSession?.SendRaiseSkill(statId, cost),
+ sendTrainSkill: (statId, credits) => LiveSession?.SendTrainSkill(statId, credits));
_magicRuntime = AcDream.App.Spells.MagicRuntime.Create(
magicCatalog,
SpellBook,
@@ -2115,16 +2108,15 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
localPlayerId: () => _playerServerGuid,
// SpellFormula::Randomize hashes the canonical account spelling
// delivered by CharacterList.
- accountName: () => _liveSession?.Characters?.AccountName
+ accountName: () => LiveSession?.Characters?.AccountName
?? _options.LiveUser
?? string.Empty,
stopCompletely: PreparePlayerForAttackRequest,
- sendUntargeted: spellId => _liveSession?.SendCastUntargetedSpell(spellId),
- sendTargeted: (target, spellId) => _liveSession?.SendCastTargetedSpell(target, spellId),
+ sendUntargeted: spellId => LiveSession?.SendCastUntargetedSpell(spellId),
+ sendTargeted: (target, spellId) => LiveSession?.SendCastTargetedSpell(target, spellId),
displayMessage: text => Chat.OnSystemMessage(text, 0x1Au),
incrementBusy: () => _itemInteractionController.IncrementBusyCount(),
- canSend: () => _liveSession is not null
- && _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld);
+ canSend: () => _liveSessionController?.IsInWorld == true);
_uiHost.Root.DragReleasedOutsideUi += OnUiDragReleasedOutside;
// Feed Silk input to the UiRoot tree so windows drag / close / select.
@@ -2223,8 +2215,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
Vitals: new AcDream.App.UI.VitalsRuntimeBindings(_vitalsVm),
Chat: new AcDream.App.UI.ChatRuntimeBindings(
retailChatVm,
- () => _liveSessionCommands ?? (AcDream.UI.Abstractions.ICommandBus)
- AcDream.UI.Abstractions.NullCommandBus.Instance),
+ () => _liveSessionController?.Commands
+ ?? AcDream.UI.Abstractions.NullCommandBus.Instance),
Radar: new AcDream.App.UI.RadarRuntimeBindings(
radarSnapshotProvider.BuildSnapshot,
_selection,
@@ -2252,13 +2244,13 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
guid, AcDream.Core.Selection.SelectionChangeSource.Inventory),
UseItemByGuid,
(tab, position, spellId) =>
- _liveSession?.SendAddSpellFavorite(spellId, position, tab),
+ LiveSession?.SendAddSpellFavorite(spellId, position, tab),
(tab, spellId) =>
- _liveSession?.SendRemoveSpellFavorite(spellId, tab),
- filters => _liveSession?.SendSpellbookFilter(filters),
- spellId => _liveSession?.SendRemoveSpell(spellId),
+ LiveSession?.SendRemoveSpellFavorite(spellId, tab),
+ filters => LiveSession?.SendSpellbookFilter(filters),
+ spellId => LiveSession?.SendRemoveSpell(spellId),
(componentId, amount) =>
- _liveSession?.SendSetDesiredComponentLevel(componentId, amount),
+ LiveSession?.SendSetDesiredComponentLevel(componentId, amount),
ClientTimerNow),
JumpPowerbar: new AcDream.App.UI.JumpPowerbarRuntimeBindings(
() => _playerController?.JumpCharge ?? default),
@@ -2284,10 +2276,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
() => LocalPlayer.GetAttribute(
AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength)
is { } strength ? (int?)strength.Current : null,
- () => _liveSession?.LinkStatus
+ () => LiveSession?.LinkStatus
?? AcDream.Core.Net.LinkStatusSnapshot.Disconnected,
ClientTimerNow,
- () => _liveSession?.RequestLinkStatusPing(),
+ () => LiveSession?.RequestLinkStatusPing(),
() => _window?.Close()),
Toolbar: new AcDream.App.UI.ToolbarRuntimeBindings(
Objects,
@@ -2299,8 +2291,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
ItemMana,
ToggleLiveCombatMode,
_itemInteractionController,
- entry => _liveSession?.SendAddShortcut(entry),
- index => _liveSession?.SendRemoveShortcut(index),
+ entry => LiveSession?.SendAddShortcut(entry),
+ index => LiveSession?.SendRemoveShortcut(index),
_selection,
handler => Combat.HealthChanged += handler,
handler => Combat.HealthChanged -= handler,
@@ -2309,11 +2301,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
Combat.GetHealthPercent,
Combat.HasHealth,
guid => (uint)(Objects.Get(guid)?.StackSize ?? 0),
- guid => _liveSession?.SendQueryHealth(guid),
- guid => _liveSession?.SendQueryItemMana(guid),
+ guid => LiveSession?.SendQueryHealth(guid),
+ guid => LiveSession?.SendQueryItemMana(guid),
() => _playerServerGuid,
(item, container, placement) =>
- _liveSession?.SendPutItemInContainer(item, container, placement)),
+ LiveSession?.SendPutItemInContainer(item, container, placement)),
Character: new AcDream.App.UI.CharacterRuntimeBindings(_characterSheetProvider),
Inventory: new AcDream.App.UI.InventoryRuntimeBindings(
Objects,
@@ -2323,14 +2315,14 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
() => LocalPlayer.GetAttribute(
AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength)
is { } strength ? (int?)strength.Current : null,
- guid => _liveSession?.SendUse(guid),
+ guid => LiveSession?.SendUse(guid),
(item, container, placement) =>
- _liveSession?.SendPutItemInContainer(item, container, placement),
+ LiveSession?.SendPutItemInContainer(item, container, placement),
(item, container, placement, amount) =>
- _liveSession?.SendStackableSplitToContainer(
+ LiveSession?.SendStackableSplitToContainer(
item, container, placement, amount),
(source, target, amount) =>
- _liveSession?.SendStackableMerge(source, target, amount),
+ LiveSession?.SendStackableMerge(source, target, amount),
_itemInteractionController,
_selection),
ExternalContainer: new AcDream.App.UI.ExternalContainerRuntimeBindings(
@@ -2342,11 +2334,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
iconComposer.GetDragIcon(type, icon, under, over, effects),
_itemInteractionController,
_selection,
- guid => _liveSession?.SendUse(guid),
+ guid => LiveSession?.SendUse(guid),
(item, container, placement) =>
- _liveSession?.SendPutItemInContainer(item, container, placement),
+ LiveSession?.SendPutItemInContainer(item, container, placement),
(item, container, placement, amount) =>
- _liveSession?.SendStackableSplitToContainer(
+ LiveSession?.SendStackableSplitToContainer(
item, container, placement, amount),
IsWithinExternalContainerUseRange),
Cursor: new AcDream.App.UI.RetailUiCursorBindings(
@@ -2354,7 +2346,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
retailCursorManager),
Confirmations: new AcDream.App.UI.ConfirmationRuntimeBindings(
(type, context, accepted) =>
- _liveSession?.SendConfirmationResponse(type, context, accepted)),
+ LiveSession?.SendConfirmationResponse(type, context, accepted)),
StackSplitQuantity: _stackSplitQuantity,
Plugins: _uiRegistry,
Persistence: persistence,
@@ -2704,7 +2696,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_worldSelectionQuery,
itemInteractions,
new AcDream.App.Interaction.WorldSessionSelectionInteractionTransport(
- () => _liveSession),
+ () => LiveSession),
new AcDream.App.Interaction.PlayerInteractionMovementSink(
() => _playerController),
text => _debugVm?.AddToast(text));
@@ -2854,107 +2846,63 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// gated behind ACDREAM_LIVE=1 so the default run path is unchanged.
_liveCenterX = centerX;
_liveCenterY = centerY;
- TryStartLiveSession();
- }
-
- private void TryStartLiveSession()
- {
- ClearInboundEntityState();
-
- // Step 2 (2026-05-16): delegate pre-Connect setup to LiveSessionController.
- // The controller owns DNS resolution + WorldSession instantiation + the
- // wireEvents callback; this method keeps the Connect → CharacterList →
- // EnterWorld → post-setup dance because those touch GameWindow state.
_liveSessionController = new AcDream.App.Net.LiveSessionController();
- _liveSession = _liveSessionController.CreateAndWire(_options, WireLiveSessionEvents);
- if (_liveSession is null)
+ AcDream.App.Net.LiveSessionStartResult liveStart =
+ _liveSessionController.Start(
+ _options,
+ CreateLiveSessionLifecycleHost());
+ switch (liveStart.Status)
{
- try
- {
- DisposeLiveSessionRouting();
- }
- finally
- {
- _liveSessionController.Dispose();
- _liveSessionController = null;
- }
- return;
- }
-
- var user = _options.LiveUser!;
- var pass = _options.LivePass!;
- var host = _options.LiveHost;
- var port = _options.LivePort;
- try
- {
- Chat.OnSystemMessage($"connecting to {host}:{port} as {user}", chatType: 1);
- _liveSession.Connect(user, pass);
- Chat.OnSystemMessage("connected — character list received", chatType: 1);
-
- if (_liveSession.Characters is null
- || !AcDream.Core.Net.Messages.CharacterList.TrySelectFirstAvailable(
- _liveSession.Characters,
- out AcDream.Core.Net.Messages.CharacterList.Selection selection))
- {
- Console.WriteLine("live: no available characters on account; disconnecting");
- try
- {
- DisposeLiveSessionRouting();
- }
- finally
- {
- _liveSessionController.Dispose();
- _liveSessionController = null;
- _liveSession = null;
- }
- ClearInboundEntityState();
- return;
- }
-
- var chosen = selection.Character;
- _playerServerGuid = chosen.Id;
- _vitalsVm?.SetLocalPlayerGuid(chosen.Id);
- Chat.SetLocalPlayerGuid(chosen.Id);
- _worldState.MarkPersistent(chosen.Id);
- AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = chosen.Id; // TEMP #138-B probe
- Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}");
- Combat.Clear();
- _liveSession.EnterWorld(characterIndex: selection.ActiveIndex);
- _liveSessionCommands?.Activate();
-
- _activeToonKey = chosen.Name;
- _retailUiRuntime?.RestoreLayout();
- SyncToolbarWindowButtons();
- if (_settingsStore is not null && _settingsVm is not null)
- {
- var toonBag = _settingsStore.LoadCharacter(_activeToonKey);
- _settingsVm.LoadCharacterContext(toonBag);
- Console.WriteLine($"settings: loaded character[{_activeToonKey}] preferences");
- }
- _playerModeAutoEntry?.Arm();
- Console.WriteLine($"live: in world — CreateObject stream active " +
- $"(so far: {_liveSpawnReceived} received, {_liveSpawnHydrated} hydrated)");
- }
- catch (Exception ex)
- {
- Console.WriteLine($"live: session failed: {ex.Message}");
- try
- {
- DisposeLiveSessionRouting();
- }
- finally
- {
- _liveSessionController?.Dispose();
- _liveSessionController = null;
- _liveSession = null;
- }
- ClearInboundEntityState();
+ case AcDream.App.Net.LiveSessionStartStatus.MissingCredentials:
+ Console.WriteLine(
+ "live: ACDREAM_LIVE set but TEST_USER/TEST_PASS missing; skipping");
+ break;
+ case AcDream.App.Net.LiveSessionStartStatus.Failed:
+ Console.WriteLine($"live: session failed: {liveStart.Error}");
+ break;
}
}
- private void ClearInboundEntityState()
+ private AcDream.App.Net.LiveSessionLifecycleHost CreateLiveSessionLifecycleHost() =>
+ new(new AcDream.App.Net.LiveSessionLifecycleBindings(
+ Bind: CreateLiveSessionBinding,
+ Reset: () =>
+ (_liveSessionResetPlan ??= CreateLiveSessionResetPlan()).Execute(),
+ Connecting: (host, port, user) =>
+ Chat.OnSystemMessage(
+ $"connecting to {host}:{port} as {user}",
+ chatType: 1),
+ Connected: () =>
+ Chat.OnSystemMessage(
+ "connected — character list received",
+ chatType: 1),
+ Selected: ApplyLiveSessionSelection,
+ Entered: ApplyLiveSessionEnteredWorld));
+
+ private void ApplyLiveSessionSelection(
+ AcDream.App.Net.LiveSessionCharacterSelection selection)
{
- (_liveSessionResetPlan ??= CreateLiveSessionResetPlan()).Execute();
+ _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);
+ _settingsVm.LoadCharacterContext(toonBag);
+ Console.WriteLine($"settings: loaded character[{_activeToonKey}] preferences");
+ }
+ _playerModeAutoEntry?.Arm();
}
private AcDream.App.Net.LiveSessionResetPlan CreateLiveSessionResetPlan() =>
@@ -3087,12 +3035,9 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
/// Registration completes before Connect; outbound commands remain inert
/// until EnterWorld succeeds.
///
- private void WireLiveSessionEvents(AcDream.Core.Net.WorldSession session)
+ private AcDream.App.Net.LiveSessionBinding CreateLiveSessionBinding(
+ AcDream.Core.Net.WorldSession session)
{
- _liveSession = session;
- if (_liveSessionEvents is not null || _liveSessionCommands is not null)
- throw new InvalidOperationException("live-session routing is already bound");
-
var skillTable = _dats?.Get(0x0E000004u);
if (_characterSheetProvider is not null && _dats is not null)
{
@@ -3123,8 +3068,12 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
commands = new AcDream.App.Net.LiveSessionCommandRouter(
CreateLiveSessionCommandBindings(session));
- _liveSessionEvents = events;
- _liveSessionCommands = commands;
+ return new AcDream.App.Net.LiveSessionBinding(
+ session,
+ commands,
+ activateCommands: commands.Activate,
+ deactivateCommands: commands.Dispose,
+ detachEvents: events.Dispose);
}
catch
{
@@ -3311,22 +3260,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
roomId, chatType, dispatchType, senderGuid, text, cookie),
Log: Console.WriteLine);
- private void DisposeLiveSessionRouting()
- {
- AcDream.App.Net.LiveSessionCommandRouter? commands = _liveSessionCommands;
- AcDream.App.Net.LiveSessionEventRouter? events = _liveSessionEvents;
- _liveSessionCommands = null;
- _liveSessionEvents = null;
- try
- {
- commands?.Dispose();
- }
- finally
- {
- events?.Dispose();
- }
- }
-
private enum LiveProjectionPurpose
{
LogicalRegistration,
@@ -4936,8 +4869,9 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
uint guid,
AcDream.App.World.AcceptedPhysicsTimestamps timestamps)
{
- if (guid != _playerServerGuid || _liveSession is null) return;
- _liveSession.PublishAcceptedLocalPhysicsTimestamps(
+ AcDream.Core.Net.WorldSession? session = LiveSession;
+ if (guid != _playerServerGuid || session is null) return;
+ session.PublishAcceptedLocalPhysicsTimestamps(
timestamps.Instance,
timestamps.ServerControlledMove,
timestamps.Teleport,
@@ -6460,7 +6394,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
///
private void SendImmediateLocalPositionEvent()
{
- if (_liveSession is null
+ AcDream.Core.Net.WorldSession? session = LiveSession;
+ if (session is null
|| _playerController is null
|| !_playerController.CanSendPositionEvent)
{
@@ -6477,18 +6412,18 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
return;
}
- uint sequence = _liveSession.NextGameActionSequence();
+ uint sequence = session.NextGameActionSequence();
var body = AcDream.Core.Net.Messages.AutonomousPosition.Build(
gameActionSequence: sequence,
cellId: cellId,
position: position,
rotation: rotation,
- instanceSequence: _liveSession.InstanceSequence,
- serverControlSequence: _liveSession.ServerControlSequence,
- teleportSequence: _liveSession.TeleportSequence,
- forcePositionSequence: _liveSession.ForcePositionSequence,
+ instanceSequence: session.InstanceSequence,
+ serverControlSequence: session.ServerControlSequence,
+ teleportSequence: session.TeleportSequence,
+ forcePositionSequence: session.ForcePositionSequence,
lastContact: 1);
- _liveSession.SendGameAction(body);
+ session.SendGameAction(body);
_playerController.NotePositionSent(
new AcDream.Core.Physics.Position(cellId, position, rotation),
_playerController.ContactPlane,
@@ -9409,8 +9344,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// The world-geometry RENDER gate (IsLiveModeWaitingForLogin at the
// draw site) is unchanged — the pre-entry screen still shows sky
// only.
- bool liveInWorld = _liveSession is not null
- && _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld;
+ bool liveInWorld = _liveSessionController?.IsInWorld == true;
// #192: liveInWorld alone used to be sufficient here — but InWorld fires
// right after the login handshake, before the player's own spawn
// CreateObject (and hence the real center) has arrived. Streaming that
@@ -9456,8 +9390,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
observerCx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f);
observerCy = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f);
}
- else if (_liveSession is not null
- && _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld)
+ else if (liveInWorld)
{
// Live, not yet in player mode: the login auto-entry hold, or a live
// fly-camera spectator. Follow the PLAYER's server-known landblock; if it
@@ -9657,7 +9590,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_playerController.State = AcDream.App.Input.PlayerState.InWorld;
// holtburger client/messages.rs:434 — re-send LoginComplete after
// each portal transition.
- _liveSession?.SendGameAction(
+ LiveSession?.SendGameAction(
AcDream.Core.Net.Messages.GameActionLoginComplete.Build());
_worldReveal?.Complete();
ResetTeleportTransitState();
@@ -9682,7 +9615,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// Phase K.2 — auto-enter player mode at login. The guard
// returns true on the firing tick (one-shot); subsequent ticks
- // are no-ops. Skipped offline (no _liveSession → IsLiveInWorld
+ // are no-ops. Skipped offline (no active session → IsLiveInWorld
// predicate stays false). Cancelled by manual fly-toggle in
// OnInputAction (Ctrl+Tab → TogglePlayerMode) or DebugPanel.
_playerModeAutoEntry?.TryEnter();
@@ -11038,8 +10971,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// is up so panel-emitted SendChatCmd actually flows server-ward.
// Fall back to NullCommandBus for offline / pre-connect renders.
AcDream.UI.Abstractions.ICommandBus bus =
- _liveSessionCommands ?? (AcDream.UI.Abstractions.ICommandBus)
- AcDream.UI.Abstractions.NullCommandBus.Instance;
+ _liveSessionController?.Commands
+ ?? AcDream.UI.Abstractions.NullCommandBus.Instance;
var ctx = new AcDream.UI.Abstractions.PanelContext(
(float)deltaSeconds,
bus);
@@ -12519,7 +12452,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private AcDream.UI.Abstractions.Panels.Settings.SettingsPanel? _settingsPanel;
private AcDream.UI.Abstractions.Panels.Settings.SettingsVM? _settingsVm;
// L.0: settings.json store + active toon key. The store is held as
- // a field so BeginLiveSessionAsync can re-load the chosen toon's
+ // a field so ApplyLiveSessionEnteredWorld 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.
@@ -12654,7 +12587,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_persistedGameplay = _settingsStore.LoadGameplay();
_persistedChat = _settingsStore.LoadChat();
// _activeToonKey is "default" pre-EnterWorld; the post-login
- // branch in BeginLiveSessionAsync swaps to the chosen toon's
+ // ApplyLiveSessionEnteredWorld swaps to the chosen toon's
// name and re-loads via SettingsVM.LoadCharacterContext.
_persistedCharacter = _settingsStore.LoadCharacter(_activeToonKey);
@@ -13153,8 +13086,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private void ToggleLiveCombatMode()
{
- if (_liveSession is null
- || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
+ AcDream.Core.Net.WorldSession? session = LiveSession;
+ if (_liveSessionController?.IsInWorld != true || session is null)
return;
IReadOnlyList orderedEquipment =
@@ -13165,7 +13098,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
Combat.CurrentMode,
defaultMode);
_itemInteractionController?.NotifyExplicitCombatModeRequest();
- _liveSession.SendChangeCombatMode(nextMode);
+ session.SendChangeCombatMode(nextMode);
Combat.SetCombatMode(nextMode);
string text = $"Combat mode {nextMode}";
Console.WriteLine($"combat: {text}");
@@ -13185,8 +13118,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private bool CanStartLiveCombatAttack()
{
- if (_liveSession is null
- || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
+ if (_liveSessionController?.IsInWorld != true)
return false;
if (!AcDream.Core.Combat.CombatInputPlanner.SupportsTargetedAttack(Combat.CurrentMode))
@@ -13214,16 +13146,20 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
if (!CanStartLiveCombatAttack())
return false;
+ AcDream.Core.Net.WorldSession? session = LiveSession;
+ if (session is null)
+ return false;
+
uint target = _selection.SelectedObjectId!.Value;
power = Math.Clamp(power, 0f, 1f);
if (Combat.CurrentMode == AcDream.Core.Combat.CombatMode.Missile)
{
- _liveSession!.SendMissileAttack(target, height, power);
+ session.SendMissileAttack(target, height, power);
Console.WriteLine($"combat: missile attack target=0x{target:X8} height={height} accuracy={power:F2}");
}
else
{
- _liveSession!.SendMeleeAttack(target, height, power);
+ session.SendMeleeAttack(target, height, power);
Console.WriteLine($"combat: melee attack target=0x{target:X8} height={height} power={power:F2}");
}
return true;
@@ -13252,7 +13188,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private bool TrySendPlayerMovementEvent(AcDream.App.Input.MovementResult result)
=> _localPlayerOutbound.TrySendMovement(
- _liveSession,
+ LiveSession,
_playerController,
result);
@@ -13291,11 +13227,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// sends directly without speculative TurnToObject/MoveToObject.
private void UseItemByGuid(uint guid)
{
- if (_liveSession is null
- || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
+ AcDream.Core.Net.WorldSession? session = LiveSession;
+ if (_liveSessionController?.IsInWorld != true || session is null)
return;
- uint sequence = _liveSession.NextGameActionSequence();
- _liveSession.SendGameAction(
+ uint sequence = session.NextGameActionSequence();
+ session.SendGameAction(
AcDream.Core.Net.Messages.InteractRequests.BuildUse(sequence, guid));
Console.WriteLine($"[D.5.1] toolbar use-item guid=0x{guid:X8} seq={sequence}");
}
@@ -13337,8 +13273,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private void TogglePlayerMode()
{
// Phase B.2 guard: only active when a live session is in-world.
- if (_liveSession is null
- || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
+ if (_liveSessionController?.IsInWorld != true)
return;
// Manual toggle pre-empts the K.2 auto-entry trigger regardless
@@ -14223,14 +14158,12 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_magicCatalog = null;
}),
new("streamer", () => _streamer?.Dispose()),
- new("live session routing", DisposeLiveSessionRouting),
- new("equipped children", () => _equippedChildRenderer?.Dispose()),
new("live session", () =>
{
_liveSessionController?.Dispose();
_liveSessionController = null;
- _liveSession = null;
}),
+ new("equipped children", () => _equippedChildRenderer?.Dispose()),
]),
// Retained tombstones are retried while every callback owner is alive.
new ResourceShutdownStage("live entities",
diff --git a/tests/AcDream.App.Tests/Net/GameWindowLiveSessionOwnershipTests.cs b/tests/AcDream.App.Tests/Net/GameWindowLiveSessionOwnershipTests.cs
new file mode 100644
index 00000000..8d65f140
--- /dev/null
+++ b/tests/AcDream.App.Tests/Net/GameWindowLiveSessionOwnershipTests.cs
@@ -0,0 +1,37 @@
+using System.Reflection;
+using AcDream.App.Rendering;
+using AcDream.App.Net;
+using AcDream.Core.Net;
+
+namespace AcDream.App.Tests.Net;
+
+public sealed class GameWindowLiveSessionOwnershipTests
+{
+ private const BindingFlags PrivateInstance =
+ BindingFlags.Instance | BindingFlags.NonPublic;
+
+ [Fact]
+ public void GameWindowRetainsOnlyTheLifecycleControllerSessionOwner()
+ {
+ FieldInfo[] fields = typeof(GameWindow).GetFields(PrivateInstance);
+
+ Assert.Contains(
+ fields,
+ field => field.Name == "_liveSessionController"
+ && field.FieldType == typeof(LiveSessionController));
+ Assert.DoesNotContain(fields, field => field.Name == "_liveSession");
+ Assert.DoesNotContain(fields, field => field.FieldType == typeof(WorldSession));
+ Assert.DoesNotContain(fields, field => field.Name == "_liveSessionEvents");
+ Assert.DoesNotContain(fields, field => field.Name == "_liveSessionCommands");
+ }
+
+ [Theory]
+ [InlineData("TryStartLiveSession")]
+ [InlineData("ClearInboundEntityState")]
+ [InlineData("WireLiveSessionEvents")]
+ [InlineData("DisposeLiveSessionRouting")]
+ public void DisplacedLifecycleBodiesAreAbsent(string methodName)
+ {
+ Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateInstance));
+ }
+}
diff --git a/tests/AcDream.App.Tests/Net/LiveSessionLifecycleHostTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionLifecycleHostTests.cs
new file mode 100644
index 00000000..a0ed5b26
--- /dev/null
+++ b/tests/AcDream.App.Tests/Net/LiveSessionLifecycleHostTests.cs
@@ -0,0 +1,110 @@
+using System.Net;
+using AcDream.App.Net;
+using AcDream.Core.Net;
+using AcDream.UI.Abstractions;
+
+namespace AcDream.App.Tests.Net;
+
+public sealed class LiveSessionLifecycleHostTests
+{
+ [Fact]
+ public void HostRoutesLifecycleAndReleasesOnlyTheExactBoundSession()
+ {
+ var calls = new List();
+ using var sessionA = CreateSession(9000);
+ using var sessionB = CreateSession(9001);
+ var host = CreateHost(calls);
+
+ LiveSessionBinding binding = host.BindSession(sessionA);
+ host.ResetSessionState();
+ host.ReportConnecting("host", 9000, "user");
+ host.ReportConnected();
+ var selection = new LiveSessionCharacterSelection(2, 3u, "toon", "account");
+ host.ApplySelectedCharacter(selection);
+ binding.ActivateCommands();
+ host.ApplyEnteredWorld(selection);
+
+ Assert.Throws(() => host.DetachSession(sessionB));
+ binding.Dispose();
+ host.DetachSession(sessionA);
+ LiveSessionBinding replacement = host.BindSession(sessionB);
+
+ Assert.Equal(
+ [
+ "bind", "reset", "connecting:host:9000:user",
+ "connected", "selected:toon", "activate", "entered:toon",
+ "deactivate", "detach-events", "bind",
+ ],
+ calls);
+ replacement.Dispose();
+ host.DetachSession(sessionB);
+ }
+
+ [Fact]
+ public void FailedBindingFactoryDoesNotClaimTheHost()
+ {
+ var calls = new List();
+ using var session = CreateSession(9000);
+ bool fail = true;
+ LiveSessionLifecycleHost host = CreateHost(calls, _ =>
+ {
+ if (fail)
+ {
+ fail = false;
+ throw new InvalidOperationException("bind failure");
+ }
+ return CreateBinding(session, calls);
+ });
+
+ Assert.Throws(() => host.BindSession(session));
+ LiveSessionBinding retry = host.BindSession(session);
+
+ retry.Dispose();
+ host.DetachSession(session);
+ }
+
+ private static LiveSessionLifecycleHost CreateHost(
+ List calls,
+ Func? bind = null) =>
+ new(new LiveSessionLifecycleBindings(
+ Bind: bind ?? (session => CreateBinding(session, calls)),
+ Reset: () => calls.Add("reset"),
+ Connecting: (host, port, user) =>
+ calls.Add($"connecting:{host}:{port}:{user}"),
+ Connected: () => calls.Add("connected"),
+ Selected: selection => calls.Add($"selected:{selection.CharacterName}"),
+ Entered: selection => calls.Add($"entered:{selection.CharacterName}")));
+
+ private static LiveSessionBinding CreateBinding(
+ WorldSession session,
+ List calls)
+ {
+ calls.Add("bind");
+ return new LiveSessionBinding(
+ session,
+ NullCommandBus.Instance,
+ activateCommands: () => calls.Add("activate"),
+ deactivateCommands: () => calls.Add("deactivate"),
+ detachEvents: () => calls.Add("detach-events"));
+ }
+
+ private static WorldSession CreateSession(int port) =>
+ new(
+ new IPEndPoint(IPAddress.Loopback, port),
+ new TestTransport());
+
+ private sealed class TestTransport : IWorldSessionTransport
+ {
+ public void Send(ReadOnlySpan datagram) { }
+
+ public void Send(IPEndPoint remote, ReadOnlySpan datagram) { }
+
+ public byte[]? Receive(TimeSpan timeout, out IPEndPoint? from)
+ {
+ from = null;
+ return null;
+ }
+
+ public void Dispose() { }
+ }
+}