refactor(net): cut GameWindow over to session owner

This commit is contained in:
Erik 2026-07-21 12:37:22 +02:00
parent 783ef1d6db
commit 6a5d9e2e6a
5 changed files with 363 additions and 268 deletions

View file

@ -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; }
}
/// <summary>Temporary Slice-3 compatibility alias; removed at GameWindow cutover.</summary>
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<WorldSession> 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;
}
}
}
}

View file

@ -0,0 +1,65 @@
using AcDream.Core.Net;
namespace AcDream.App.Net;
internal sealed record LiveSessionLifecycleBindings(
Func<WorldSession, LiveSessionBinding> Bind,
Action Reset,
Action<string, int, string> Connecting,
Action Connected,
Action<LiveSessionCharacterSelection> Selected,
Action<LiveSessionCharacterSelection> Entered);
/// <summary>
/// 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.
/// </summary>
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;
}
}

View file

@ -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.
/// </summary>
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<DatReaderWriter.DBObjs.SkillTable>(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
/// </summary>
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<AcDream.Core.Items.ClientObject> 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",

View file

@ -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));
}
}

View file

@ -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<string>();
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<InvalidOperationException>(() => 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<string>();
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<InvalidOperationException>(() => host.BindSession(session));
LiveSessionBinding retry = host.BindSession(session);
retry.Dispose();
host.DetachSession(session);
}
private static LiveSessionLifecycleHost CreateHost(
List<string> calls,
Func<WorldSession, LiveSessionBinding>? 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<string> 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<byte> datagram) { }
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram) { }
public byte[]? Receive(TimeSpan timeout, out IPEndPoint? from)
{
from = null;
return null;
}
public void Dispose() { }
}
}