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:
parent
9e4313f3d3
commit
713bec256b
4 changed files with 433 additions and 0 deletions
|
|
@ -18,6 +18,7 @@
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />
|
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\AcDream.Core.Net\AcDream.Core.Net.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Update="Rendering\Shaders\*.*">
|
<None Update="Rendering\Shaders\*.*">
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,16 @@ public sealed class GameWindow : IDisposable
|
||||||
private TextureCache? _textureCache;
|
private TextureCache? _textureCache;
|
||||||
private IReadOnlyList<AcDream.Core.World.WorldEntity> _entities = Array.Empty<AcDream.Core.World.WorldEntity>();
|
private IReadOnlyList<AcDream.Core.World.WorldEntity> _entities = Array.Empty<AcDream.Core.World.WorldEntity>();
|
||||||
|
|
||||||
|
// 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)
|
public GameWindow(string datDir, WorldGameState worldGameState, WorldEvents worldEvents)
|
||||||
{
|
{
|
||||||
_datDir = datDir;
|
_datDir = datDir;
|
||||||
|
|
@ -441,6 +451,143 @@ public sealed class GameWindow : IDisposable
|
||||||
|
|
||||||
_entities = hydratedEntities;
|
_entities = hydratedEntities;
|
||||||
Console.WriteLine($"hydrated {_entities.Count} entities total (stabs + buildings + scenery + interior)");
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
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<DatReaderWriter.DBObjs.Setup>(spawn.SetupTableId.Value);
|
||||||
|
if (setup is null) return;
|
||||||
|
|
||||||
|
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
|
||||||
|
var meshRefs = new List<AcDream.Core.World.MeshRef>();
|
||||||
|
foreach (var mr in flat)
|
||||||
|
{
|
||||||
|
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(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<AcDream.Core.World.WorldEntity>(_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)");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -473,6 +620,11 @@ public sealed class GameWindow : IDisposable
|
||||||
|
|
||||||
private void OnUpdate(double dt)
|
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 is null || _input is null) return;
|
||||||
if (!_cameraController.IsFlyMode) return;
|
if (!_cameraController.IsFlyMode) return;
|
||||||
|
|
||||||
|
|
@ -509,6 +661,7 @@ public sealed class GameWindow : IDisposable
|
||||||
|
|
||||||
private void OnClosing()
|
private void OnClosing()
|
||||||
{
|
{
|
||||||
|
_liveSession?.Dispose();
|
||||||
_staticMesh?.Dispose();
|
_staticMesh?.Dispose();
|
||||||
_textureCache?.Dispose();
|
_textureCache?.Dispose();
|
||||||
_meshShader?.Dispose();
|
_meshShader?.Dispose();
|
||||||
|
|
|
||||||
|
|
@ -77,5 +77,33 @@ public sealed class NetClient : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Non-blocking receive: returns the next pending datagram if one is
|
||||||
|
/// already in the kernel buffer, or <c>null</c> if not. Never blocks,
|
||||||
|
/// so it's safe to call once per game-loop frame. Uses
|
||||||
|
/// <see cref="UdpClient.Available"/> under the hood.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
public void Dispose() => _udp.Dispose();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
251
src/AcDream.Core.Net/WorldSession.cs
Normal file
251
src/AcDream.Core.Net/WorldSession.cs
Normal 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 => { /* 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();
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue