diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj
index e4b69ca..7311fe0 100644
--- a/src/AcDream.App/AcDream.App.csproj
+++ b/src/AcDream.App/AcDream.App.csproj
@@ -18,6 +18,7 @@
+
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 282ef0e..b6d104f 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -28,6 +28,16 @@ public sealed class GameWindow : IDisposable
private TextureCache? _textureCache;
private IReadOnlyList _entities = Array.Empty();
+ // 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.
+ private AcDream.Core.Net.WorldSession? _liveSession;
+ private int _liveCenterX;
+ private int _liveCenterY;
+ private uint _liveEntityIdCounter = 1_000_000u; // well above any dat-hydrated id
+ private int _liveSpawnReceived; // diagnostics
+ private int _liveSpawnHydrated;
+
public GameWindow(string datDir, WorldGameState worldGameState, WorldEvents worldEvents)
{
_datDir = datDir;
@@ -441,6 +451,143 @@ public sealed class GameWindow : IDisposable
_entities = hydratedEntities;
Console.WriteLine($"hydrated {_entities.Count} entities total (stabs + buildings + scenery + interior)");
+
+ // Phase 4.7: optional live-mode startup. Connect to the ACE server,
+ // enter the world as the first character on the account, and stream
+ // CreateObject messages into _worldGameState as they arrive. Entirely
+ // gated behind ACDREAM_LIVE=1 so the default run path is unchanged.
+ _liveCenterX = centerX;
+ _liveCenterY = centerY;
+ TryStartLiveSession();
+ }
+
+ private void TryStartLiveSession()
+ {
+ if (Environment.GetEnvironmentVariable("ACDREAM_LIVE") != "1") return;
+
+ var host = Environment.GetEnvironmentVariable("ACDREAM_TEST_HOST") ?? "127.0.0.1";
+ var portStr = Environment.GetEnvironmentVariable("ACDREAM_TEST_PORT") ?? "9000";
+ var user = Environment.GetEnvironmentVariable("ACDREAM_TEST_USER");
+ var pass = Environment.GetEnvironmentVariable("ACDREAM_TEST_PASS");
+ if (string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass))
+ {
+ Console.WriteLine("live: ACDREAM_LIVE set but TEST_USER/TEST_PASS missing; skipping");
+ return;
+ }
+
+ try
+ {
+ var endpoint = new System.Net.IPEndPoint(System.Net.IPAddress.Parse(host), int.Parse(portStr));
+ Console.WriteLine($"live: connecting to {endpoint} as {user}");
+ _liveSession = new AcDream.Core.Net.WorldSession(endpoint);
+ _liveSession.EntitySpawned += OnLiveEntitySpawned;
+ _liveSession.Connect(user, pass);
+
+ 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];
+ Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}");
+ _liveSession.EnterWorld(user, characterIndex: 0);
+ Console.WriteLine($"live: in world — CreateObject stream active " +
+ $"(so far: {_liveSpawnReceived} received, {_liveSpawnHydrated} hydrated)");
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"live: session failed: {ex.Message}");
+ _liveSession?.Dispose();
+ _liveSession = null;
+ }
+ }
+
+ ///
+ /// Convert a Phase 4.7 CreateObject spawn into a WorldEntity with hydrated
+ /// mesh refs and register it in IGameState. Called from WorldSession events
+ /// on the main thread (Tick runs in the Silk.NET Update callback).
+ ///
+ private void OnLiveEntitySpawned(AcDream.Core.Net.WorldSession.EntitySpawn spawn)
+ {
+ _liveSpawnReceived++;
+ if (_dats is null || _staticMesh is null) return;
+ if (spawn.Position is null || spawn.SetupTableId is null)
+ {
+ // Can't place a mesh without both. Most of these are inventory
+ // items anyway (no position because they're held), which have no
+ // visible world presence.
+ return;
+ }
+
+ var p = spawn.Position.Value;
+
+ // Translate server position into acdream world space. The server sends
+ // (landblockId, local x/y/z). acdream's world origin is the center
+ // landblock; each neighbor landblock is offset by 192 units per step.
+ int lbX = (int)((p.LandblockId >> 24) & 0xFFu);
+ int lbY = (int)((p.LandblockId >> 16) & 0xFFu);
+ var origin = new System.Numerics.Vector3(
+ (lbX - _liveCenterX) * 192f,
+ (lbY - _liveCenterY) * 192f,
+ 0f);
+ var worldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ) + origin;
+
+ // AC quaternion wire order is (W, X, Y, Z); System.Numerics.Quaternion is (X, Y, Z, W).
+ var rot = new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
+
+ // Hydrate mesh refs from the Setup dat. This is the same code path
+ // used by the static scenery pipeline (see the Setup hydration above).
+ var setup = _dats.Get(spawn.SetupTableId.Value);
+ if (setup is null) return;
+
+ var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
+ var meshRefs = new List();
+ foreach (var mr in flat)
+ {
+ var gfx = _dats.Get(mr.GfxObjId);
+ if (gfx is null) continue;
+ var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx);
+ _staticMesh.EnsureUploaded(mr.GfxObjId, subMeshes);
+ meshRefs.Add(new AcDream.Core.World.MeshRef(mr.GfxObjId, mr.PartTransform));
+ }
+ if (meshRefs.Count == 0) return;
+
+ var entity = new AcDream.Core.World.WorldEntity
+ {
+ Id = _liveEntityIdCounter++,
+ SourceGfxObjOrSetupId = spawn.SetupTableId.Value,
+ Position = worldPos,
+ Rotation = rot,
+ MeshRefs = meshRefs,
+ };
+
+ var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot(
+ Id: entity.Id,
+ SourceId: entity.SourceGfxObjOrSetupId,
+ Position: entity.Position,
+ Rotation: entity.Rotation);
+ _worldGameState.Add(snapshot);
+ _worldEvents.FireEntitySpawned(snapshot);
+
+ // Extend the render list so the next frame picks up the new entity.
+ // We copy into a new list because _entities is typed as IReadOnlyList.
+ var extended = new List(_entities) { entity };
+ _entities = extended;
+ _liveSpawnHydrated++;
+
+ // Log the first few so we can confirm position translation is sane.
+ if (_liveSpawnHydrated <= 10)
+ {
+ Console.WriteLine($"live: spawned guid=0x{spawn.Guid:X8} setup=0x{spawn.SetupTableId:X8} " +
+ $"world=({worldPos.X:F1},{worldPos.Y:F1},{worldPos.Z:F1})");
+ }
+ if (_liveSpawnHydrated == 10)
+ {
+ Console.WriteLine("live: (suppressing further spawn logs)");
+ }
}
///
@@ -473,6 +620,11 @@ public sealed class GameWindow : IDisposable
private void OnUpdate(double dt)
{
+ // Drain any pending live-session traffic. Non-blocking — returns
+ // immediately if no datagrams are in the kernel buffer. Fires
+ // EntitySpawned events synchronously on this thread.
+ _liveSession?.Tick();
+
if (_cameraController is null || _input is null) return;
if (!_cameraController.IsFlyMode) return;
@@ -509,6 +661,7 @@ public sealed class GameWindow : IDisposable
private void OnClosing()
{
+ _liveSession?.Dispose();
_staticMesh?.Dispose();
_textureCache?.Dispose();
_meshShader?.Dispose();
diff --git a/src/AcDream.Core.Net/NetClient.cs b/src/AcDream.Core.Net/NetClient.cs
index 4c0e34c..c45a525 100644
--- a/src/AcDream.Core.Net/NetClient.cs
+++ b/src/AcDream.Core.Net/NetClient.cs
@@ -77,5 +77,33 @@ public sealed class NetClient : IDisposable
}
}
+ ///
+ /// Non-blocking receive: returns the next pending datagram if one is
+ /// already in the kernel buffer, or null if not. Never blocks,
+ /// so it's safe to call once per game-loop frame. Uses
+ /// under the hood.
+ ///
+ public byte[]? TryReceive(out IPEndPoint? from)
+ {
+ if (_udp.Available == 0)
+ {
+ from = null;
+ return null;
+ }
+
+ try
+ {
+ IPEndPoint any = new(IPAddress.Any, 0);
+ var bytes = _udp.Receive(ref any);
+ from = any;
+ return bytes;
+ }
+ catch (SocketException)
+ {
+ from = null;
+ return null;
+ }
+ }
+
public void Dispose() => _udp.Dispose();
}
diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs
new file mode 100644
index 0000000..396190f
--- /dev/null
+++ b/src/AcDream.Core.Net/WorldSession.cs
@@ -0,0 +1,251 @@
+using System.Buffers.Binary;
+using System.Net;
+using AcDream.Core.Net.Cryptography;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Net.Packets;
+
+namespace AcDream.Core.Net;
+
+///
+/// High-level AC client session: owns a , drives
+/// the full handshake + character-enter-world flow, and converts the
+/// inbound GameMessage stream into C# events that a game loop can bind.
+///
+///
+/// Intended use from GameWindow:
+///
+///
+/// var session = new WorldSession(new IPEndPoint(IPAddress.Loopback, 9000));
+/// session.EntitySpawned += snap => { /* add to IGameState */ };
+/// session.Connect("testaccount", "testpassword"); // blocks until CharacterList
+/// session.EnterWorld(characterIndex: 0); // blocks until first CreateObject
+/// // ... then every frame:
+/// session.Tick(); // non-blocking, drains any pending packets, fires events
+///
+///
+///
+/// Not yet provided (deferred): ACK pump, retransmit handling,
+/// delete-object processing, position updates, chat, disconnect detection.
+/// The current client is one-shot — connect, enter the world, stream
+/// events for a few seconds, let the test harness tear it down.
+///
+///
+public sealed class WorldSession : IDisposable
+{
+ public enum State
+ {
+ Disconnected,
+ Handshaking,
+ InCharacterSelect,
+ EnteringWorld,
+ InWorld,
+ Failed,
+ }
+
+ public readonly record struct EntitySpawn(uint Guid, CreateObject.ServerPosition? Position, uint? SetupTableId);
+
+ /// Fires when the session finishes parsing a CreateObject.
+ public event Action? EntitySpawned;
+
+ /// Raised every time the state machine transitions.
+ public event Action? StateChanged;
+
+ public State CurrentState { get; private set; } = State.Disconnected;
+ public CharacterList.Parsed? Characters { get; private set; }
+
+ private readonly NetClient _net;
+ private readonly IPEndPoint _loginEndpoint;
+ private readonly IPEndPoint _connectEndpoint;
+ private readonly FragmentAssembler _assembler = new();
+
+ private IsaacRandom? _inboundIsaac;
+ private IsaacRandom? _outboundIsaac;
+ private ushort _sessionClientId;
+ private uint _clientPacketSequence;
+ private uint _fragmentSequence = 1;
+
+ public WorldSession(IPEndPoint serverLogin)
+ {
+ _loginEndpoint = serverLogin;
+ _connectEndpoint = new IPEndPoint(serverLogin.Address, serverLogin.Port + 1);
+ _net = new NetClient(serverLogin);
+ }
+
+ ///
+ /// Do the 3-leg handshake (LoginRequest → ConnectRequest → ConnectResponse),
+ /// then drain packets until CharacterList is assembled. Blocks for up to
+ /// total.
+ ///
+ public void Connect(string account, string password, TimeSpan? timeout = null)
+ {
+ var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(10));
+ Transition(State.Handshaking);
+
+ // Step 1: LoginRequest
+ uint timestamp = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+ byte[] loginPayload = LoginRequest.Build(account, password, timestamp);
+ var loginHeader = new PacketHeader { Flags = PacketHeaderFlags.LoginRequest };
+ _net.Send(PacketCodec.Encode(loginHeader, loginPayload, null));
+
+ // Step 2: wait for ConnectRequest
+ Packet? cr = null;
+ while (DateTime.UtcNow < deadline && cr is null)
+ {
+ var bytes = _net.Receive(deadline - DateTime.UtcNow, out _);
+ if (bytes is null) break;
+ var dec = PacketCodec.TryDecode(bytes, null);
+ if (dec.IsOk && dec.Packet!.Header.HasFlag(PacketHeaderFlags.ConnectRequest))
+ cr = dec.Packet;
+ }
+ if (cr is null) { Transition(State.Failed); throw new TimeoutException("ConnectRequest not received"); }
+
+ // Step 3: seed ISAAC, send ConnectResponse to port+1, with 200ms race delay
+ var opt = cr.Optional;
+ byte[] serverSeedBytes = new byte[4];
+ BinaryPrimitives.WriteUInt32LittleEndian(serverSeedBytes, opt.ConnectRequestServerSeed);
+ byte[] clientSeedBytes = new byte[4];
+ BinaryPrimitives.WriteUInt32LittleEndian(clientSeedBytes, opt.ConnectRequestClientSeed);
+ _inboundIsaac = new IsaacRandom(serverSeedBytes);
+ _outboundIsaac = new IsaacRandom(clientSeedBytes);
+ _sessionClientId = (ushort)opt.ConnectRequestClientId;
+ _clientPacketSequence = 2;
+
+ byte[] crBody = new byte[8];
+ BinaryPrimitives.WriteUInt64LittleEndian(crBody, opt.ConnectRequestCookie);
+ var crHeader = new PacketHeader { Sequence = 1, Flags = PacketHeaderFlags.ConnectResponse, Id = 0 };
+ Thread.Sleep(200);
+ _net.Send(_connectEndpoint, PacketCodec.Encode(crHeader, crBody, null));
+
+ Transition(State.InCharacterSelect);
+
+ // Step 4: drain until CharacterList arrives
+ while (DateTime.UtcNow < deadline && Characters is null)
+ {
+ PumpOnce();
+ }
+ if (Characters is null) { Transition(State.Failed); throw new TimeoutException("CharacterList not received"); }
+ }
+
+ ///
+ /// Send CharacterEnterWorldRequest and CharacterEnterWorld for
+ /// [].
+ /// Returns once the server starts sending CreateObjects (at which point
+ /// callers should poll to stream events).
+ ///
+ public void EnterWorld(string account, int characterIndex = 0, TimeSpan? timeout = null)
+ {
+ if (Characters is null || Characters.Characters.Count == 0)
+ throw new InvalidOperationException("Connect() must complete with a non-empty CharacterList");
+ if (characterIndex < 0 || characterIndex >= Characters.Characters.Count)
+ throw new ArgumentOutOfRangeException(nameof(characterIndex));
+
+ var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(10));
+ var chosen = Characters.Characters[characterIndex];
+ Transition(State.EnteringWorld);
+
+ SendGameMessage(CharacterEnterWorld.BuildEnterWorldRequestBody());
+
+ // Wait for CharacterEnterWorldServerReady (0xF7DF)
+ bool serverReady = false;
+ while (DateTime.UtcNow < deadline && !serverReady)
+ {
+ var drained = PumpOnce(out var opcodes);
+ if (!drained) continue;
+ foreach (var op in opcodes)
+ if (op == 0xF7DFu) { serverReady = true; break; }
+ }
+ if (!serverReady) { Transition(State.Failed); throw new TimeoutException("ServerReady not received"); }
+
+ SendGameMessage(CharacterEnterWorld.BuildEnterWorldBody(chosen.Id, account));
+ Transition(State.InWorld);
+ }
+
+ ///
+ /// Non-blocking pump. Drains any datagrams currently in the kernel
+ /// buffer, parses them, and fires events. Call once per game-loop
+ /// frame. Returns the number of datagrams processed.
+ ///
+ public int Tick()
+ {
+ int processed = 0;
+ while (true)
+ {
+ var bytes = _net.TryReceive(out _);
+ if (bytes is null) break;
+ ProcessDatagram(bytes);
+ processed++;
+ }
+ return processed;
+ }
+
+ ///
+ /// Blocking single-datagram pump used during Connect/EnterWorld.
+ /// Returns true if a datagram was processed.
+ ///
+ private bool PumpOnce()
+ {
+ return PumpOnce(out _);
+ }
+
+ private bool PumpOnce(out List opcodesThisCall)
+ {
+ opcodesThisCall = new List();
+ var bytes = _net.Receive(TimeSpan.FromMilliseconds(250), out _);
+ if (bytes is null) return false;
+ ProcessDatagram(bytes, opcodesThisCall);
+ return true;
+ }
+
+ private void ProcessDatagram(byte[] bytes, List? opcodesOut = null)
+ {
+ var dec = PacketCodec.TryDecode(bytes, _inboundIsaac);
+ if (!dec.IsOk) return;
+
+ foreach (var frag in dec.Packet!.Fragments)
+ {
+ var body = _assembler.Ingest(frag, out _);
+ if (body is null || body.Length < 4) continue;
+ uint op = BinaryPrimitives.ReadUInt32LittleEndian(body);
+ opcodesOut?.Add(op);
+
+ if (op == CharacterList.Opcode && Characters is null)
+ {
+ try { Characters = CharacterList.Parse(body); }
+ catch { /* malformed — ignore and keep draining */ }
+ }
+ else if (op == CreateObject.Opcode)
+ {
+ var parsed = CreateObject.TryParse(body);
+ if (parsed is not null)
+ {
+ EntitySpawned?.Invoke(new EntitySpawn(
+ parsed.Value.Guid, parsed.Value.Position, parsed.Value.SetupTableId));
+ }
+ }
+ }
+ }
+
+ private void SendGameMessage(byte[] gameMessageBody)
+ {
+ var fragment = GameMessageFragment.BuildSingleFragment(
+ _fragmentSequence++, GameMessageGroup.UIQueue, gameMessageBody);
+ byte[] packetBody = GameMessageFragment.Serialize(fragment);
+ var header = new PacketHeader
+ {
+ Sequence = _clientPacketSequence++,
+ Flags = PacketHeaderFlags.BlobFragments | PacketHeaderFlags.EncryptedChecksum,
+ Id = _sessionClientId,
+ };
+ byte[] datagram = PacketCodec.Encode(header, packetBody, _outboundIsaac);
+ _net.Send(datagram);
+ }
+
+ private void Transition(State next)
+ {
+ if (CurrentState == next) return;
+ CurrentState = next;
+ StateChanged?.Invoke(next);
+ }
+
+ public void Dispose() => _net.Dispose();
+}