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(); +}