diff --git a/src/AcDream.App/Net/LiveSessionController.cs b/src/AcDream.App/Net/LiveSessionController.cs
new file mode 100644
index 0000000..eb714f0
--- /dev/null
+++ b/src/AcDream.App/Net/LiveSessionController.cs
@@ -0,0 +1,114 @@
+using System;
+using System.Net;
+using System.Net.Sockets;
+using AcDream.Core.Net;
+
+namespace AcDream.App.Net;
+
+///
+/// Owns the network-side lifecycle of a live —
+/// DNS resolution, endpoint construction, session instantiation, per-frame
+/// Tick, and disposal. The post-construction work (event wiring,
+/// Connect, character validation, EnterWorld, post-login UI
+/// state setup) stays in GameWindow for now because it touches
+/// renderer / chat / player-controller state that hasn't been extracted yet.
+///
+///
+///
+/// Step 2 of the extraction sequence described in
+/// docs/architecture/code-structure.md §4. Future expansions can
+/// fold more of TryStartLiveSession into this controller as the
+/// surrounding state (event handlers, command bus, settings VM) gets
+/// extracted in later steps.
+///
+///
+/// Behavior preservation contract: this class produces
+/// byte-identical console output and event-wireup sequencing to the
+/// pre-refactor inline code path. The DNS-resolution lines, the
+/// "live: connecting to ..." line, and the wiring-vs-Connect ordering
+/// all match the previous flow.
+///
+///
+public sealed class LiveSessionController : IDisposable
+{
+ ///
+ /// Active session, or when offline / before
+ /// succeeded / after .
+ ///
+ public WorldSession? Session { get; private set; }
+
+ ///
+ /// Resolves the endpoint, instantiates the ,
+ /// hands it to for caller-side event
+ /// subscriptions, and returns the live session. The caller is
+ /// responsible for the subsequent Connect /
+ /// EnterWorld dance.
+ ///
+ public WorldSession? CreateAndWire(RuntimeOptions options, Action wireEvents)
+ {
+ if (options is null) throw new ArgumentNullException(nameof(options));
+ if (wireEvents is null) throw new ArgumentNullException(nameof(wireEvents));
+
+ 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
+ {
+ var endpoint = ResolveEndpoint(options.LiveHost, options.LivePort);
+ Console.WriteLine($"live: connecting to {endpoint} as {options.LiveUser}");
+ Session = new WorldSession(endpoint);
+ wireEvents(Session);
+ return Session;
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"live: session setup failed: {ex.Message}");
+ Session?.Dispose();
+ Session = null;
+ return null;
+ }
+ }
+
+ ///
+ /// Drains the inbound network queue. Proxies to
+ /// ; no-op when
+ /// is .
+ ///
+ public void Tick() => Session?.Tick();
+
+ ///
+ /// Tears down the live session. Safe to call multiple times.
+ ///
+ public void Dispose()
+ {
+ Session?.Dispose();
+ Session = null;
+ }
+
+ ///
+ /// Resolve a host string (literal IP or DNS name) to an
+ /// . Pre-refactor logic preserved exactly:
+ /// try first, fall back to
+ /// , prefer IPv4 (ACE + retail use
+ /// IPv4 UDP exclusively), throw on empty resolution.
+ ///
+ private static IPEndPoint ResolveEndpoint(string host, int port)
+ {
+ IPAddress ip;
+ if (!IPAddress.TryParse(host, out ip!))
+ {
+ var addrs = Dns.GetHostAddresses(host);
+ ip = Array.Find(addrs, a => a.AddressFamily == AddressFamily.InterNetwork)
+ ?? (addrs.Length > 0
+ ? addrs[0]
+ : throw new Exception($"DNS resolved no addresses for '{host}'"));
+ Console.WriteLine($"live: resolved {host} → {ip}");
+ }
+ return new IPEndPoint(ip, port);
+ }
+}
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 6f5b9af..d1b0bef 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -738,6 +738,11 @@ public sealed class GameWindow : IDisposable
// 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.
+ private AcDream.App.Net.LiveSessionController? _liveSessionController;
private AcDream.Core.Net.WorldSession? _liveSession;
private int _liveCenterX;
private int _liveCenterY;
@@ -1819,38 +1824,80 @@ public sealed class GameWindow : IDisposable
private void TryStartLiveSession()
{
- if (!_options.LiveMode) return;
-
- var host = _options.LiveHost;
- var port = _options.LivePort;
- var user = _options.LiveUser;
- var pass = _options.LivePass;
- if (string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass))
+ // 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)
{
- Console.WriteLine("live: ACDREAM_LIVE set but TEST_USER/TEST_PASS missing; skipping");
+ _combatChatTranslator?.Dispose();
+ _combatChatTranslator = null;
+ _liveSessionController = null;
return;
}
+ var user = _options.LiveUser!;
+ var pass = _options.LivePass!;
+ var host = _options.LiveHost;
+ var port = _options.LivePort;
try
{
- // Resolve DNS names (e.g. play.coldeve.ac) as well as literal
- // IP addresses. `IPAddress.Parse` throws on hostnames; fall
- // back to `Dns.GetHostAddresses` and prefer IPv4 (ACE + retail
- // use IPv4 UDP exclusively).
- System.Net.IPAddress ip;
- if (!System.Net.IPAddress.TryParse(host, out ip!))
+ 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 || _liveSession.Characters.Characters.Count == 0)
{
- var addrs = System.Net.Dns.GetHostAddresses(host);
- ip = System.Array.Find(addrs,
- a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
- ?? (addrs.Length > 0 ? addrs[0] : throw new System.Exception(
- $"DNS resolved no addresses for '{host}'"));
- Console.WriteLine($"live: resolved {host} → {ip}");
+ Console.WriteLine("live: no characters on account; disconnecting");
+ _liveSessionController.Dispose();
+ _liveSessionController = null;
+ _liveSession = null;
+ return;
}
- var endpoint = new System.Net.IPEndPoint(ip, port);
- Console.WriteLine($"live: connecting to {endpoint} as {user}");
- _liveSession = new AcDream.Core.Net.WorldSession(endpoint);
- _liveSession.EntitySpawned += OnLiveEntitySpawned;
+
+ var chosen = _liveSession.Characters.Characters[0];
+ _playerServerGuid = chosen.Id;
+ _vitalsVm?.SetLocalPlayerGuid(chosen.Id);
+ Chat.SetLocalPlayerGuid(chosen.Id);
+ _worldState.MarkPersistent(chosen.Id);
+ Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}");
+ _liveSession.EnterWorld(user, characterIndex: 0);
+
+ _activeToonKey = chosen.Name;
+ 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}");
+ _combatChatTranslator?.Dispose();
+ _combatChatTranslator = null;
+ _liveSessionController?.Dispose();
+ _liveSessionController = null;
+ _liveSession = null;
+ }
+ }
+
+ ///
+ /// Step 2 helper: subscribes the live to all
+ /// the parsers / handlers / translators that GameWindow needs.
+ /// Called once by
+ /// immediately after the
+ /// is constructed and BEFORE any network I/O.
+ ///
+ private void WireLiveSessionEvents(AcDream.Core.Net.WorldSession session)
+ {
+ _liveSession = session;
+ _liveSession.EntitySpawned += OnLiveEntitySpawned;
_liveSession.EntityDeleted += OnLiveEntityDeleted;
_liveSession.MotionUpdated += OnLiveMotionUpdated;
_liveSession.PositionUpdated += OnLivePositionUpdated;
@@ -2011,7 +2058,11 @@ public sealed class GameWindow : IDisposable
// plus a local echo into ChatLog so the player sees their own
// message immediately. Closes over _liveSession + Chat so this
// wiring only exists for the lifetime of the live session.
- var liveSession = _liveSession;
+ // Step 2: capture the non-null `session` parameter rather than
+ // the nullable _liveSession field. Semantically identical (the
+ // field WAS set to `session` at the top of WireLiveSessionEvents)
+ // but the compiler can prove non-null for the lambda body.
+ var liveSession = session;
var chat = Chat;
_commandBus = new AcDream.UI.Abstractions.LiveCommandBus();
var turbineChat = TurbineChat;
@@ -2119,58 +2170,6 @@ public sealed class GameWindow : IDisposable
LocalPlayer.OnVitalUpdate(v.VitalId, v.Ranks, v.Start, v.Xp, v.Current);
_liveSession.VitalCurrentUpdated += v =>
LocalPlayer.OnVitalCurrent(v.VitalId, v.Current);
-
- 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 || _liveSession.Characters.Characters.Count == 0)
- {
- Console.WriteLine("live: no characters on account; disconnecting");
- _liveSession.Dispose();
- _liveSession = null;
- return;
- }
-
- var chosen = _liveSession.Characters.Characters[0];
- _playerServerGuid = chosen.Id; // Phase B.2: store for Tab-key player-mode entry
- _vitalsVm?.SetLocalPlayerGuid(chosen.Id); // Phase D.2a — devtools HP bar tracks this guid
- Chat.SetLocalPlayerGuid(chosen.Id); // Phase J — recognize own /say echo from ACE's HearSpeech broadcast
- _worldState.MarkPersistent(chosen.Id); // player entity survives landblock unloads
- Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}");
- _liveSession.EnterWorld(user, characterIndex: 0);
-
- // L.0 Character tab: swap the SettingsVM's character bag
- // from the "default" pre-login bag to the actual chosen
- // toon's bag. Every Save from now on writes under the
- // chosen toon's name. LoadCharacterContext rebinds BOTH
- // persisted + draft so HasUnsavedChanges doesn't flag the
- // swap as a pending edit.
- _activeToonKey = chosen.Name;
- if (_settingsStore is not null && _settingsVm is not null)
- {
- var toonBag = _settingsStore.LoadCharacter(_activeToonKey);
- _settingsVm.LoadCharacterContext(toonBag);
- Console.WriteLine($"settings: loaded character[{_activeToonKey}] preferences");
- }
- // Phase K.2: arm auto-entry. The guard's predicates won't
- // pass yet — the entity stream hasn't started — but the
- // OnUpdate tick re-checks every frame and fires once
- // everything converges (typically 100-300 ms after EnterWorld
- // returns). User can pre-empt via DebugPanel "Toggle
- // Free-Fly Mode" or Tab; both call Cancel() first.
- _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}");
- _combatChatTranslator?.Dispose();
- _combatChatTranslator = null;
- _liveSession?.Dispose();
- _liveSession = null;
- }
}
///
@@ -6248,7 +6247,8 @@ public sealed class GameWindow : IDisposable
// CreateObject events find their landblock already loaded in
// GpuWorldState. Non-blocking — returns immediately if no datagrams
// are in the kernel buffer. Fires EntitySpawned events synchronously.
- _liveSession?.Tick();
+ // Step 2: routed through the controller; functionally identical.
+ _liveSessionController?.Tick();
// Phase K.1a — tick the input dispatcher so Hold-type bindings
// re-fire while their chord is held. K.1b adds the subscribers
@@ -10194,7 +10194,8 @@ public sealed class GameWindow : IDisposable
// Phase I.7: unsubscribe combat → chat translator before the
// session it depends on goes away.
_combatChatTranslator?.Dispose();
- _liveSession?.Dispose();
+ _liveSessionController?.Dispose();
+ _liveSession = null;
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
_wbDrawDispatcher?.Dispose();
_skyRenderer?.Dispose(); // depends on sampler cache; dispose first