using System.Buffers.Binary; using System.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Net.Packets; namespace AcDream.Core.Net.Tests.Transport; /// /// An that binds a REAL /// to an through a /// — no sockets anywhere. Outbound sends run through /// the link into the model; the model's emitted datagrams run through the /// link into the queue that Receive/ReceiveAsync serve. /// /// /// The handshake is scripted against the model's events so a test can run a /// genuine Connect() / EnterWorld() / Tick() / /// Dispose() lifecycle: /// /// LoginRequest → the model answers with a ConnectRequest carrying /// the 32-byte optional (server time, cookie, client id, both ISAAC /// seeds) — the same layout the existing negotiation fixture /// (WorldSessionNegotiationShutdownTests.BuildConnectRequest) /// pins. /// ConnectResponse (cookie match) → the model enqueues a /// CharacterList (0xF658) with one selectable character. /// CharacterEnterWorldRequest (0xF7C8) → ServerReady (0xF7DF). /// CharacterLogOff (0xF653 request) → the opcode-only 0xF653 /// confirmation, so Dispose() completes its retail graceful /// logout instead of burning the 35 s confirmation timeout. /// /// Because the model seeds its C2S verifier and S2C keystream from the same /// seeds it hands out in the ConnectRequest, post-handshake encrypted /// traffic verifies in both directions. /// /// /// /// Thread-safety: the model is single-threaded, so every model interaction /// happens under one lock. ReceiveAsync (the session's background /// receive loop) waits on a semaphore counting queued deliverables. /// /// internal sealed class FakeAceTransport : IWorldSessionTransport { public const uint DefaultClientSeed = 0x2B6D6F87u; public const uint DefaultServerSeed = 0x9A3C51E4u; public const uint DefaultClientId = 0x1234u; public const ulong DefaultCookie = 0xFEEDFACECAFEBABEUL; public const uint DefaultCharacterId = 0x50000001u; public const string DefaultCharacterName = "+Acdream"; public const string DefaultAccountName = "testaccount"; private readonly object _gate = new(); private readonly SemaphoreSlim _deliverable = new(0); private readonly Queue _toClient = new(); private readonly IPEndPoint _serverEndpoint = new(IPAddress.Loopback, 9000); public VirtualClock Clock { get; } public LossyLink Link { get; } public AceSessionModel Model { get; } /// /// N5: virtual-clock advance applied at the top of every BLOCKING /// call — the session-thread Connect()/EnterWorld() /// pump path only; the async in-world receive owner never touches the /// clock. The lossy-decorator lifecycle test needs time to move during /// the blocking handshake pumps: with the clock frozen there, a dropped /// handshake-window datagram could never be NAK-healed (the 0.6 s gate /// never opens and the model never emits a later sequenced packet to /// expose the gap) — a fixture artifact, not a transport property. /// Zero (the default) preserves the pre-N5 fixture behavior exactly. /// Single-threaded by construction: blocking receives happen on the same /// thread that owns the clock in every test that sets this. /// public TimeSpan AutoAdvanceOnBlockingReceive { get; set; } public FakeAceTransport(VirtualClock? clock = null, LossyLink? link = null) { Clock = clock ?? new VirtualClock(); Link = link ?? new LossyLink(); Model = new AceSessionModel( Clock, DefaultClientSeed, DefaultServerSeed, DefaultClientId, DefaultCookie); Model.LoginRequestReceived += () => Model.SendConnectRequest(); Model.ConnectResponseAccepted += () => Model.EnqueueGameMessage(BuildCharacterListBody(), GameMessageGroup.UIQueue); Model.MessageDispatched += OnClientMessage; } private void OnClientMessage(byte[] body) { if (body.Length < 4) return; uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body); switch (opcode) { case CharacterEnterWorld.EnterWorldRequestOpcode: // 0xF7C8 // Server replies CharacterEnterWorldServerReady (0xF7DF) — // WorldSession.EnterWorld blocks on this opcode. Model.EnqueueGameMessage(BuildOpcodeOnlyBody(0xF7DFu), GameMessageGroup.UIQueue); break; case CharacterLogOff.Opcode: // 0xF653 request (opcode + character id) // ACE echoes the opcode-only confirmation; WorldSession.Dispose // waits for it before sending the transport Disconnect. Model.EnqueueGameMessage(BuildOpcodeOnlyBody(CharacterLogOff.Opcode), GameMessageGroup.UIQueue); break; } } // ---- IWorldSessionTransport ---- public void Send(ReadOnlySpan datagram) => SendCore(datagram); // WorldSession sends the ConnectResponse to port+1; the double serves // both listeners from one model, like ACE's single-process server. public void Send(IPEndPoint remote, ReadOnlySpan datagram) => SendCore(datagram); private void SendCore(ReadOnlySpan datagram) { lock (_gate) { foreach (byte[] delivered in Link.Transmit(LinkDirection.ClientToServer, datagram)) Model.Receive(delivered); PumpServerLocked(); } } /// /// Run one server frame (model Update + S2C link delivery) without any /// client traffic — the hook tests use after advancing the clock or /// enqueuing server-side messages. /// public void PumpServer() { lock (_gate) { PumpServerLocked(); } } /// /// N2 test hook: deliver raw bytes straight into the client's receive /// queue, bypassing both the model and the link. Used for late /// byte-identical redelivery of a dropped S2C datagram (ACE's cached /// packet shape), duplicate injections, and crafted sequence-0 control /// packets. /// public void InjectServerDatagram(byte[] datagram) { lock (_gate) { _toClient.Enqueue((byte[])datagram.Clone()); _deliverable.Release(); } } private void PumpServerLocked() { Model.Update(); foreach (byte[] outbound in Model.TakePendingDatagrams()) { foreach (byte[] delivered in Link.Transmit(LinkDirection.ServerToClient, outbound)) { _toClient.Enqueue(delivered); _deliverable.Release(); } } } public int Receive(Span destination, TimeSpan timeout, out IPEndPoint? from) { if (AutoAdvanceOnBlockingReceive > TimeSpan.Zero) Clock.Advance(AutoAdvanceOnBlockingReceive); lock (_gate) { PumpServerLocked(); } if (timeout < TimeSpan.Zero) timeout = TimeSpan.Zero; if (!_deliverable.Wait(timeout)) { from = null; return -1; // NetClient.Receive's timeout contract } from = _serverEndpoint; lock (_gate) { byte[] datagram = _toClient.Dequeue(); datagram.CopyTo(destination); return datagram.Length; } } public async ValueTask ReceiveAsync( Memory destination, CancellationToken cancellationToken) { await _deliverable.WaitAsync(cancellationToken).ConfigureAwait(false); lock (_gate) { byte[] datagram = _toClient.Dequeue(); datagram.CopyTo(destination); return new NetReceiveResult(datagram.Length, _serverEndpoint); } } public void Dispose() { // WorldSession disposes the transport only after cancelling and // joining its receive task, so no waiter can be parked on the // semaphore here. SemaphoreSlim without AvailableWaitHandle holds no // unmanaged state — deliberately left to the GC to keep a hypothetical // late waiter from hitting ObjectDisposedException. } // ---- scripted server content ---- /// /// Minimal CharacterList (0xF658) matching CharacterList.Parse: /// status, active characters, deleted characters, slot count, account, /// turbine chat, ToD flag. /// private static byte[] BuildCharacterListBody() { var writer = new PacketWriter(96); writer.WriteUInt32(CharacterList.Opcode); writer.WriteUInt32(0); // status writer.WriteUInt32(1); // active count writer.WriteUInt32(DefaultCharacterId); writer.WriteString16L(DefaultCharacterName); writer.WriteUInt32(0); // secondsGreyedOut writer.WriteUInt32(0); // deleted count writer.WriteUInt32(11); // slot count writer.WriteString16L(DefaultAccountName); writer.WriteUInt32(1); // useTurbineChat writer.WriteUInt32(1); // hasThroneOfDestiny return writer.ToArray(); } private static byte[] BuildOpcodeOnlyBody(uint opcode) { byte[] body = new byte[4]; BinaryPrimitives.WriteUInt32LittleEndian(body, opcode); return body; } }