feat(net+app): WorldSession class + GameWindow live-mode wiring (Phase 4.7e/f)

The end-to-end pipeline. acdream can now connect to a live ACE server,
complete the full handshake + character-select + enter-world flow, and
stream CreateObject messages straight into the existing IGameState and
static mesh renderer. Gated behind ACDREAM_LIVE=1 so the default
offline run path is untouched.

Added:
  - AcDream.Core.Net.WorldSession: high-level session type that owns a
    NetClient, drives the 3-leg handshake, parses CharacterList, sends
    CharacterEnterWorldRequest + CharacterEnterWorld, and converts the
    post-login fragment stream into C# events. State machine:
    Disconnected → Handshaking → InCharacterSelect → EnteringWorld →
    InWorld (or Failed). Public API:
      * Connect(user, pass)  — blocks until CharacterList received
      * EnterWorld(user, characterIndex) — blocks until ServerReady
      * Tick() — non-blocking, call per game-loop frame
      * event EntitySpawned
      * event StateChanged
      * Characters property (populated after Connect)

  - NetClient.TryReceive: non-blocking variant that returns immediately
    with null if the kernel buffer is empty. Enables draining packets
    per frame from the main thread without stalling.

  - GameWindow live-mode hookup:
      * AcDream.Core.Net project reference
      * TryStartLiveSession() called after dat hydration, gated behind
        ACDREAM_LIVE=1 + ACDREAM_TEST_USER/ACDREAM_TEST_PASS env vars
      * Subscribes EntitySpawned to OnLiveEntitySpawned
      * Calls Connect() then EnterWorld(0) synchronously on startup
      * OnLiveEntitySpawned hydrates mesh refs from the Setup dat
        (same SetupMesh.Flatten + GfxObjMesh.Build + StaticMesh.EnsureUploaded
        path used by scenery), publishes a WorldEntitySnapshot via
        _worldGameState.Add + _worldEvents.FireEntitySpawned, and
        appends to _entities so the next frame picks it up
      * OnUpdate calls _liveSession?.Tick() each frame
      * OnClosing disposes the session
      * Position translation: server sends (LandblockId, local XYZ +
        quaternion); we map landblock to world origin relative to the
        rendered 3x3 center, add local XYZ, translate AC's (W,X,Y,Z)
        quaternion wire order to System.Numerics.Quaternion (X,Y,Z,W)

LIVE RUN OUTPUT (ACDREAM_LIVE=1 against localhost ACE, testaccount):

  [dats loaded, 1133 static entities hydrated]
  live: connecting to 127.0.0.1:9000 as testaccount
  live: entering world as 0x5000000A +Acdream
  live: in world — CreateObject stream active (so far: 0 received, 0 hydrated)
  live: spawned guid=0x5000000A setup=0x02000001 world=(104.9,15.1,94.0)
  live: spawned guid=0x7A9B4013 setup=0x0200007C world=(135.7,9.9,97.0)
  live: spawned guid=0x7A9B4014 setup=0x0200007C world=(132.5,9.9,97.0)
  live: spawned guid=0x7A9B4015 setup=0x020019FF world=(132.6,17.1,94.1)
  live: spawned guid=0x7A9B4016 setup=0x020019FF world=(136.3,5.2,94.1)
  live: spawned guid=0x7A9B4017 setup=0x020019FF world=(104.1,31.0,94.1)
  live: spawned guid=0x7A9B4037 setup=0x02000975 world=(109.7,33.0,95.0)
  live: spawned guid=0x7A9B4018 setup=0x020019FF world=(110.9,31.0,94.1)
  live: spawned guid=0x7A9B4019 setup=0x020019FF world=(107.5,31.5,94.1)
  live: spawned guid=0x7A9B403B setup=0x02000B8E world=(150.5,17.9,94.0)
  live: (suppressing further spawn logs)

First line: +Acdream himself. setup=0x02000001 is ACE's default humanoid
player mesh. world coords match Holtburg (landblock 0xA9B4 local
space). Subsequent spawns are weenies at various setup ids — likely
the foundry statue, street lamps, drums, etc. The 0x7A9B4xxx GUID
pattern is ACE's convention: scenery-type (0x7) + landblock (0xA9B4) +
per-object index.

All spawns flow through the SAME SetupMesh/GfxObjMesh/StaticMeshRenderer
pipeline used by scenery and interiors today. The plugin system's
EntitySpawned event fires on every new entity, so plugins can see
them without any networking awareness.

Tests: 160 passing offline (77 core + 83 net). The live handshake and
enter-world tests are gated and still pass when ACDREAM_LIVE=1.

User visual verification is the final acceptance for Phase 4. Run
with ACDREAM_DAT_DIR + ACDREAM_LIVE=1 + ACDREAM_TEST_USER=testaccount
+ ACDREAM_TEST_PASS=testpassword and look for +Acdream's model + the
foundry statue standing on top of the Holtburg foundry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-11 15:25:41 +02:00
parent 9e4313f3d3
commit 713bec256b
4 changed files with 433 additions and 0 deletions

View file

@ -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;
/// <summary>
/// High-level AC client session: owns a <see cref="NetClient"/>, drives
/// the full handshake + character-enter-world flow, and converts the
/// inbound GameMessage stream into C# events that a game loop can bind.
///
/// <para>
/// Intended use from <c>GameWindow</c>:
/// </para>
/// <code>
/// var session = new WorldSession(new IPEndPoint(IPAddress.Loopback, 9000));
/// session.EntitySpawned += snap =&gt; { /* 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
/// </code>
///
/// <para>
/// <b>Not yet provided</b> (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.
/// </para>
/// </summary>
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);
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
public event Action<EntitySpawn>? EntitySpawned;
/// <summary>Raised every time the state machine transitions.</summary>
public event Action<State>? 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);
}
/// <summary>
/// Do the 3-leg handshake (LoginRequest → ConnectRequest → ConnectResponse),
/// then drain packets until CharacterList is assembled. Blocks for up to
/// <paramref name="timeout"/> total.
/// </summary>
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"); }
}
/// <summary>
/// Send CharacterEnterWorldRequest and CharacterEnterWorld for
/// <see cref="Characters"/>[<paramref name="characterIndex"/>].
/// Returns once the server starts sending CreateObjects (at which point
/// callers should poll <see cref="Tick"/> to stream events).
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
public int Tick()
{
int processed = 0;
while (true)
{
var bytes = _net.TryReceive(out _);
if (bytes is null) break;
ProcessDatagram(bytes);
processed++;
}
return processed;
}
/// <summary>
/// Blocking single-datagram pump used during Connect/EnterWorld.
/// Returns true if a datagram was processed.
/// </summary>
private bool PumpOnce()
{
return PumpOnce(out _);
}
private bool PumpOnce(out List<uint> opcodesThisCall)
{
opcodesThisCall = new List<uint>();
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<uint>? 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();
}