using System.Buffers.Binary;
using System.Net;
using System.Threading.Channels;
using AcDream.Core.Combat;
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,
IReadOnlyList AnimPartChanges,
IReadOnlyList TextureChanges,
IReadOnlyList SubPalettes,
uint? BasePaletteId,
float? ObjScale,
string? Name,
CreateObject.ServerMotionState? MotionState,
uint? MotionTableId);
/// Fires when the session finishes parsing a CreateObject.
public event Action? EntitySpawned;
///
/// Payload for : the server guid of the entity
/// whose motion changed and its new server-side stance + forward command.
/// The renderer uses these to drive per-entity cycle switching.
///
public readonly record struct EntityMotionUpdate(
uint Guid,
CreateObject.ServerMotionState MotionState);
///
/// Fires when the session parses a 0xF74C UpdateMotion game message.
/// Subscribers can look up the entity by guid and transition its
/// animation cycle to the new (stance, forward-command) pair.
///
public event Action? MotionUpdated;
///
/// Payload for : the server guid plus a
/// full describing the
/// entity's new world position and rotation. Subscribers translate
/// the landblock-local position into acdream world space and reseat
/// the corresponding WorldEntity.
///
public readonly record struct EntityPositionUpdate(
uint Guid,
CreateObject.ServerPosition Position,
System.Numerics.Vector3? Velocity);
///
/// Fires when the session parses a 0xF748 UpdatePosition game message.
///
public event Action? PositionUpdated;
///
/// Fires when the session parses a 0xF74E VectorUpdate game message.
/// ACE broadcasts this whenever a remote entity's velocity / omega
/// changes outside the normal UpdatePosition cadence — the canonical
/// case is a remote player JUMPING (Player.cs:954
/// EnqueueBroadcast(new GameMessageVectorUpdate(this));).
/// Subscribers update the remote's PhysicsBody velocity + airborne
/// state so the dead-reckoning produces a proper jump arc.
///
public event Action? VectorUpdated;
///
/// Fires when the server sends a PlayerTeleport (0xF751) game message,
/// signalling that the player is entering portal space. The uint payload
/// is the teleport sequence number parsed from the message body (u16,
/// aligned to 4 bytes — per holtburger's teleport.rs wire layout).
/// Subscribers should freeze movement input until the destination
/// UpdatePosition arrives.
///
public event Action? TeleportStarted;
///
/// Phase H.1: fires when a local or ranged speech message (0x02BB /
/// 0x02BC) is received. Subscribers typically feed these into a
/// ChatLog.
///
public event Action? SpeechHeard;
///
/// Phase I.5: fires when an EmoteText (0x01E0) top-level
/// GameMessage is received — server-driven third-person emote
/// announcement (e.g. "The Olthoi growls at you."). Standalone
/// GameMessage, NOT wrapped in 0xF7B0. Subscribers typically feed
/// ChatLog.OnEmote.
///
public event Action? EmoteHeard;
///
/// Phase I.5: fires when a SoulEmote (0x01E2) top-level
/// GameMessage is received — complex emote with optional animation
/// pairing. Wire layout matches EmoteText.
///
public event Action? SoulEmoteHeard;
///
/// Phase I.5: fires when a ServerMessage (0xF7E0) top-level
/// GameMessage is received — general server-broadcast text used
/// for announcements, combat logs, and routine error messages.
/// Subscribers typically feed ChatLog.OnSystemMessage.
///
public event Action? ServerMessageReceived;
///
/// Phase I.5: fires when a PlayerKilled (0x019E) top-level
/// GameMessage is received — server announcement that a player
/// was killed in combat. Subscribers typically feed
/// ChatLog.OnPlayerKilled.
///
public event Action? PlayerKilledReceived;
///
/// Phase I.6: fires when a TurbineChat (0xF7DE) top-level
/// GameMessage is received. Carries the unified
/// envelope (header + payload
/// variant). Subscribers typically switch on the payload variant
/// and route EventSendToRoom into ChatLog.OnChannelBroadcast.
///
public event Action? TurbineChatReceived;
///
/// Phase I.6: fires when a SetTurbineChatChannels (0x0295)
/// GameEvent (sub-opcode of 0xF7B0) is received — listing the
/// runtime room ids assigned to General / Trade / LFG / Roleplay /
/// Society / Olthoi (and the optional Allegiance Turbine room).
/// Subscribers typically feed TurbineChatState.OnChannelsReceived.
///
public event Action? TurbineChannelsReceived;
///
/// Issue #5: fires when a PrivateUpdateVital (0x02E7) arrives
/// — full per-vital snapshot (ranks / start / xp / current).
/// Subscribers typically feed
/// .
/// Wire layout: see .
///
public event Action? VitalUpdated;
///
/// Issue #5: fires when a PrivateUpdateVitalCurrent (0x02E9)
/// arrives — current-only delta (regen ticks, drains).
/// Subscribers typically feed
/// .
///
public event Action? VitalCurrentUpdated;
///
/// Phase 6 — server-broadcast PhysicsScript trigger. Fires when the
/// server sends a PlayScriptId (opcode 0xF754) packet —
/// wire format [u32 opcode][u32 guid][u32 scriptId].
///
///
/// This is retail's ONLY general-purpose "make a visual thing
/// happen" channel: spell casts, emote gestures, combat flinches,
/// portal storms, and lightning flashes during stormy weather all
/// flow through this opcode. Subscribers (typically
/// GameWindow) resolve the guid to the appropriate entity
/// position and dispatch to a PhysicsScriptRunner.
///
///
///
/// Trail: chunk_006A0000.c:12320-12336 opcode dispatch →
/// FUN_00452060 → FUN_00511800 → FUN_005117a0
/// (PhysicsObj::RunScript) → FUN_0051bed0 (PhysicsScript
/// runtime). See docs/research/2026-04-23-lightning-real.md.
///
///
public event Action? PlayScriptReceived;
///
/// Phase 5d — retail's AdminEnvirons packet (opcode
/// 0xEA60) — the one-and-only channel retail's server uses
/// for weather environment changes. Wire format:
/// [u32 opcode][u32 environChangeType]. The payload enum is
/// retail's EnvironChangeType:
///
/// -
/// 0x00..0x06 — fog presets (Clear/Red/Blue/White/Green/
/// Black/Black2). Subscribers route these to a
/// .
///
/// -
/// 0x65..0x75 — one-shot ambient sound cues
/// (Roar / Bell / Chant / etc).
///
/// -
/// 0x76..0x7B — Thunder1..Thunder6 sounds. Paired with
/// a separate from the server
/// carrying the lightning-flash PhysicsScript.
///
///
/// See docs/research/2026-04-23-lightning-crossfade.md +
/// 2026-04-23-lightning-real.md.
///
public event Action? EnvironChanged;
///
/// Phase G.1: latest server Portal Year tick count. Seeded from the
/// ConnectRequest handshake (r12 §1.3 — server sends absolute game
/// time as a double) and refreshed on every TimeSync-flagged packet.
/// Subscribers feed this into WorldTimeService.SyncFromServer
/// so client-local day/night stays in lockstep with the server clock.
///
public event Action? ServerTimeUpdated;
///
/// Latest server tick count from
/// events. 0 until the handshake completes.
///
public double LastServerTimeTicks { get; private set; }
///
/// Allow re-sending LoginComplete after a portal teleport. The normal
/// _loginCompleteSent latch prevents duplicate sends on the initial spawn
/// path; this method resets it so the teleport completion path can send
/// another LoginComplete to tell the server the client has finished loading
/// the destination cell. Pattern from holtburger's PlayerTeleport handler
/// (client/messages.rs line 434-440: call send_login_complete on teleport).
///
public void ResetLoginComplete() => _loginCompleteSent = false;
/// Raised every time the state machine transitions.
public event Action? StateChanged;
///
/// Phase F.1: inbound 0xF7B0 GameEvent dispatcher. Each sub-opcode
/// handler is registered here (by GameWindow / UI layer / chat
/// system) and routed on each incoming GameEvent. Unhandled
/// sub-opcodes are counted for diagnostic overlays.
///
public GameEventDispatcher GameEvents { get; } = new();
public State CurrentState { get; private set; } = State.Disconnected;
/// Movement sequence counters for outbound MoveToState/AutonomousPosition.
public ushort InstanceSequence => _instanceSequence;
public ushort ServerControlSequence => _serverControlSequence;
public ushort TeleportSequence => _teleportSequence;
public ushort ForcePositionSequence => _forcePositionSequence;
public CharacterList.Parsed? Characters { get; private set; }
private readonly NetClient _net;
private readonly IPEndPoint _loginEndpoint;
private readonly IPEndPoint _connectEndpoint;
private readonly FragmentAssembler _assembler = new();
// Issue #5 diagnostics (env-var-gated):
// ACDREAM_DUMP_OPCODES=1 → log first occurrence of each unhandled opcode
// ACDREAM_DUMP_VITALS=1 → log every PrivateUpdateVital(Current) parse
private static readonly bool DumpOpcodesEnabled =
Environment.GetEnvironmentVariable("ACDREAM_DUMP_OPCODES") == "1";
private static readonly bool DumpVitalsEnabled =
Environment.GetEnvironmentVariable("ACDREAM_DUMP_VITALS") == "1";
private readonly System.Collections.Generic.HashSet _seenUnhandledOpcodes = new();
private IsaacRandom? _inboundIsaac;
private IsaacRandom? _outboundIsaac;
private ushort _sessionClientId;
private uint _clientPacketSequence;
private uint _fragmentSequence = 1;
// Movement sequence counters — echoed back in every MoveToState and
// AutonomousPosition so the server can detect stale/reordered packets.
// Initialized from CreateObject PhysicsData timestamps, updated by
// UpdatePosition/UpdateMotion/PlayerTeleport. Per holtburger:
// instance=slot 8, teleport=slot 4, serverControl=slot 5, forcePosition=slot 6.
private ushort _instanceSequence;
private ushort _serverControlSequence;
private ushort _teleportSequence;
private ushort _forcePositionSequence;
// Phase A.3: background receive thread buffers raw UDP datagrams into
// a channel so the render thread never blocks on socket I/O.
private readonly Channel _inboundQueue =
Channel.CreateUnbounded(
new UnboundedChannelOptions
{ SingleReader = true, SingleWriter = true });
private Thread? _netThread;
private readonly CancellationTokenSource _netCancel = new();
///
/// Phase 4.10 latch — true after we've sent the LoginComplete game
/// action in response to PlayerCreate. Prevents re-sending if the
/// server emits multiple PlayerCreate messages (rare but possible
/// across recall / portal teleports).
///
private bool _loginCompleteSent;
///
/// Phase B.2: per-session game-action sequence counter. Monotonically
/// incremented by and embedded in
/// every outbound MoveToState / AutonomousPosition GameAction message.
/// ACE's GameActionPacket.HandleGameAction reads the sequence field but
/// currently only uses it for logging — however retail clients do
/// increment it, so we match that behaviour.
///
private uint _gameActionSequence;
public WorldSession(IPEndPoint serverLogin)
{
_loginEndpoint = serverLogin;
_connectEndpoint = new IPEndPoint(serverLogin.Address, serverLogin.Port + 1);
_net = new NetClient(serverLogin);
// Phase I.6: SetTurbineChatChannels (0x0295) is a GameEvent
// sub-opcode of 0xF7B0, not a top-level opcode. Route it through
// the dispatcher and surface a typed event so downstream wiring
// (GameEventWiring → TurbineChatState) doesn't need to know the
// GameEvent envelope encoding.
GameEvents.Register(GameEventType.SetTurbineChatChannels, e =>
{
var parsed = SetTurbineChatChannels.TryParse(e.Payload.Span);
if (parsed is not null) TurbineChannelsReceived?.Invoke(parsed.Value);
});
}
///
/// 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;
// Phase G.1: server's initial PortalYearTicks (r12 §1.3) lives
// in the ConnectRequest optional section. Publish it to
// subscribers so WorldTimeService.SyncFromServer can seed the
// client clock.
LastServerTimeTicks = opt.ConnectRequestServerTime;
ServerTimeUpdated?.Invoke(opt.ConnectRequestServerTime);
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));
// NOTE: LoginComplete used to be sent here unconditionally. That was
// wrong — per holtburger's flow (see references/holtburger/.../client/
// messages.rs lines 391-422), LoginComplete is sent in response to the
// server's PlayerCreate (0xF746) game message, NOT immediately after
// EnterWorld. Sending it too early means the player object isn't
// ready and the server ignores it. The actual trigger lives in
// ProcessDatagram.
Transition(State.InWorld);
// Phase A.3: start the background receive thread now that the
// handshake is complete and the session is fully established.
// During Connect() and EnterWorld(), PumpOnce() read directly
// from the socket (blocking). From here on, Tick() drains the
// channel instead.
_netThread = new Thread(NetReceiveLoop)
{
IsBackground = true,
Name = "acdream.net-recv",
};
_netThread.Start();
}
///
/// Non-blocking pump. Drains any datagrams buffered by the background
/// net thread (Phase A.3), decodes them, and fires events. Call once
/// per game-loop frame. Returns the number of datagrams processed.
///
public int Tick()
{
int processed = 0;
while (_inboundQueue.Reader.TryRead(out var bytes))
{
ProcessDatagram(bytes);
processed++;
}
return processed;
}
///
/// Phase A.3: background receive loop. Runs on a dedicated daemon
/// thread started at the end of . Continuously
/// pulls raw UDP datagrams from the kernel buffer via
/// and writes them into
/// for the render thread to drain in
/// . Does NOT decode, reassemble, or dispatch —
/// all of that stays on the render thread to avoid ISAAC/assembler
/// thread-safety issues.
///
///
/// The 250ms receive timeout is the heartbeat: if no packet arrives
/// within 250ms, the loop re-checks the cancellation token and
/// tries again. On shutdown, cancels the token
/// and joins the thread.
///
///
private void NetReceiveLoop()
{
try
{
while (!_netCancel.Token.IsCancellationRequested)
{
var bytes = _net.Receive(TimeSpan.FromMilliseconds(250), out _);
if (bytes is not null)
_inboundQueue.Writer.TryWrite(bytes);
}
}
catch (OperationCanceledException) { /* graceful shutdown */ }
catch (System.Net.Sockets.SocketException) { /* socket closed during shutdown */ }
catch (ObjectDisposedException) { /* NetClient disposed before thread noticed */ }
finally
{
_inboundQueue.Writer.TryComplete();
}
}
///
/// 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;
// Phase 4.9: send an ACK_SEQUENCE control packet for every received
// server packet with sequence > 0 and no ACK flag of its own. This
// is the proper holtburger pattern (every received packet gets an
// ack queued back; not periodic). Without it, ACE drops the session
// with "Network Timeout" because it sees no acks coming back —
// which surfaces in other clients' views as the player rendering
// as a stationary purple haze (loading state).
var serverHeader = dec.Packet!.Header;
if (serverHeader.Sequence > 0
&& (serverHeader.Flags & PacketHeaderFlags.AckSequence) == 0)
{
SendAck(serverHeader.Sequence);
}
// Phase G.1: propagate TimeSync-flagged server time to anyone who
// needs it (sky/day-night lerp in particular). Server sends this
// periodically — no explicit opcode, just the header flag.
if ((serverHeader.Flags & PacketHeaderFlags.TimeSync) != 0)
{
double t = dec.Packet!.Optional.TimeSync;
if (t > 0)
{
LastServerTimeTicks = t;
ServerTimeUpdated?.Invoke(t);
}
}
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 == 0xF7E5u) // DddInterrogation — server asks "what dat list versions do you have?"
{
// Phase 4.10: reply with an empty DddInterrogationResponse
// (language=1 English, count=0 lists). The server is happy
// with an empty acknowledgement; without ANY reply it keeps
// the client in a transitional state and renders us as the
// purple loading haze to other clients. Pattern from
// references/holtburger/.../client/messages.rs::DddInterrogation
SendGameMessage(DddInterrogationResponse.Build());
}
else if (op == 0xF746u && !_loginCompleteSent) // PlayerCreate — server creates our player object
{
// Phase 4.10: PlayerCreate for our character is the cue to
// send LoginComplete. Sending it earlier (right after the
// outbound CharacterEnterWorld) was wrong because the server
// hadn't finished spawning the player yet. Holtburger's
// client/messages.rs (PlayerCreate handler) confirms this is
// the correct trigger. Send once per session.
_loginCompleteSent = true;
SendGameMessage(GameActionLoginComplete.Build());
}
else if (op == CreateObject.Opcode)
{
var parsed = CreateObject.TryParse(body);
if (parsed is not null)
{
// Initialize sequence counters from the player's own CreateObject.
if (parsed.Value.Guid == Characters?.Characters.FirstOrDefault().Id)
{
_instanceSequence = parsed.Value.InstanceSequence;
_teleportSequence = parsed.Value.TeleportSequence;
_serverControlSequence = parsed.Value.ServerControlSequence;
_forcePositionSequence = parsed.Value.ForcePositionSequence;
}
EntitySpawned?.Invoke(new EntitySpawn(
parsed.Value.Guid,
parsed.Value.Position,
parsed.Value.SetupTableId,
parsed.Value.AnimPartChanges,
parsed.Value.TextureChanges,
parsed.Value.SubPalettes,
parsed.Value.BasePaletteId,
parsed.Value.ObjScale,
parsed.Value.Name,
parsed.Value.MotionState,
parsed.Value.MotionTableId));
}
}
else if (op == UpdateMotion.Opcode)
{
// Phase 6.6: the server sends UpdateMotion (0xF74C) whenever an
// already-spawned entity changes its motion state — NPCs
// starting a walk cycle, creatures entering combat, doors
// opening, etc. We dispatch a lightweight event with the
// new (stance, forward-command) pair so the animation
// system can swap the entity's cycle.
var motion = UpdateMotion.TryParse(body);
if (motion is not null)
{
MotionUpdated?.Invoke(new EntityMotionUpdate(
motion.Value.Guid,
motion.Value.MotionState));
}
}
else if (op == UpdatePosition.Opcode)
{
// Phase 6.7: the server sends UpdatePosition (0xF748) every
// time an entity moves through the world — NPC patrols,
// creatures hunting, other players walking past, projectiles
// tracking. Without this, everything stays at its
// CreateObject spawn point forever.
var posUpdate = UpdatePosition.TryParse(body);
if (posUpdate is not null)
{
// Update sequence counters from the player's own position updates.
if (posUpdate.Value.Guid == Characters?.Characters.FirstOrDefault().Id)
{
_instanceSequence = posUpdate.Value.InstanceSequence;
_teleportSequence = posUpdate.Value.TeleportSequence;
_forcePositionSequence = posUpdate.Value.ForcePositionSequence;
}
PositionUpdated?.Invoke(new EntityPositionUpdate(
posUpdate.Value.Guid,
posUpdate.Value.Position,
posUpdate.Value.Velocity));
}
}
else if (op == VectorUpdate.Opcode)
{
// K-fix9 (2026-04-26): server-broadcast remote jump
// velocity. ACE Player.cs:954 enqueues this on every
// jump in addition to the bracketing UpdateMotion. The
// payload's velocity field is the world-space launch
// velocity (post-rotation in
// GameMessageVectorUpdate.cs:20-24); subscribers feed
// it into the remote PhysicsBody so the dead-reckoning
// tick can integrate the arc.
var parsed = VectorUpdate.TryParse(body);
if (parsed is not null)
VectorUpdated?.Invoke(parsed.Value);
}
else if (op == HearSpeech.LocalOpcode || op == HearSpeech.RangedOpcode)
{
// Phase H.1: local/ranged chat. Standalone GameMessage
// (NOT wrapped in 0xF7B0). Payload layout is documented
// on HearSpeech.TryParse.
var parsed = HearSpeech.TryParse(body);
if (parsed is not null)
SpeechHeard?.Invoke(parsed.Value);
}
else if (op == EmoteText.Opcode)
{
// Phase I.5: server-driven third-person emote
// ("The Olthoi growls at you."). Standalone GameMessage,
// not wrapped in 0xF7B0. Holtburger opcodes.rs:155.
var parsed = EmoteText.TryParse(body);
if (parsed is not null)
EmoteHeard?.Invoke(parsed.Value);
}
else if (op == SoulEmote.Opcode)
{
// Phase I.5: complex emote (chat + paired animation).
// Wire layout identical to EmoteText. Holtburger
// opcodes.rs:158.
var parsed = SoulEmote.TryParse(body);
if (parsed is not null)
SoulEmoteHeard?.Invoke(parsed.Value);
}
else if (op == ServerMessage.Opcode)
{
// Phase I.5: server announcement / system message.
// Holtburger opcodes.rs:167.
var parsed = ServerMessage.TryParse(body);
if (parsed is not null)
ServerMessageReceived?.Invoke(parsed.Value);
}
else if (op == PlayerKilled.Opcode)
{
// Phase I.5: death announcement. Holtburger opcodes.rs:150.
var parsed = PlayerKilled.TryParse(body);
if (parsed is not null)
PlayerKilledReceived?.Invoke(parsed.Value);
}
else if (op == TurbineChat.Opcode)
{
// Phase I.6: 0xF7DE TurbineChat — global community chat
// (General / Trade / LFG / Roleplay / Society / Olthoi).
// Three payload variants live inside the same opcode;
// dispatch to subscribers by raising a typed event.
// SetTurbineChatChannels (0x0295) is NOT here — it's a
// sub-opcode of the 0xF7B0 GameEvent envelope and rides
// the dispatcher path (registered in the ctor).
var parsed = TurbineChat.TryParse(body);
if (parsed is not null) TurbineChatReceived?.Invoke(parsed.Value);
}
else if (op == PrivateUpdateVital.FullOpcode)
{
// Issue #5: full per-vital snapshot from the server. Wire
// format per holtburger UpdateVital — see
// PrivateUpdateVital.TryParseFull.
var parsed = PrivateUpdateVital.TryParseFull(body);
if (DumpVitalsEnabled)
Console.WriteLine($"vitals: 0x02E7 PrivateUpdateVital body.len={body.Length} parsed={(parsed is null ? "null" : $"v{parsed.Value.VitalId} ranks={parsed.Value.Ranks} start={parsed.Value.Start} cur={parsed.Value.Current}")}");
if (parsed is not null)
VitalUpdated?.Invoke(parsed.Value);
}
else if (op == PrivateUpdateVital.CurrentOpcode)
{
// Issue #5: current-only delta (regen ticks / drains).
// Wire format per holtburger UpdateVitalCurrent.
var parsed = PrivateUpdateVital.TryParseCurrent(body);
if (DumpVitalsEnabled)
Console.WriteLine($"vitals: 0x02E9 PrivateUpdateVitalCurrent body.len={body.Length} parsed={(parsed is null ? "null" : $"v{parsed.Value.VitalId} cur={parsed.Value.Current}")}");
if (parsed is not null)
VitalCurrentUpdated?.Invoke(parsed.Value);
}
else if (op == GameEventEnvelope.Opcode)
{
// Phase F.1: 0xF7B0 is the GameEvent envelope. Parse the
// header (guid + sequence + eventType) and dispatch to the
// registered handler for that sub-opcode. Unregistered
// types get counted for diagnostic overlays.
var env = GameEventEnvelope.TryParse(body);
if (env is not null) GameEvents.Dispatch(env.Value);
}
else if (op == 0xEA60u) // AdminEnvirons — server pushes a fog preset or sound cue
{
// Phase 5d: wire format `[u32 opcode][u32 environChangeType]`
// per chunk_006A0000.c. Dispatch the event; GameWindow
// subscribers route fog presets into WeatherSystem.Override
// and sound cues (thunder, roar, etc) into the audio engine.
if (body.Length >= 8)
{
uint envType = System.Buffers.Binary.BinaryPrimitives
.ReadUInt32LittleEndian(body.AsSpan(4, 4));
EnvironChanged?.Invoke(envType);
}
}
else if (op == 0xF754u) // PlayScript — server triggers a PhysicsScript on a target guid
{
// Phase 6b: wire format `[u32 opcode][u32 guid][u32 scriptId]`
// per chunk_006A0000.c:12320 disassembly. Dispatch the
// event; GameWindow subscribes and feeds its
// PhysicsScriptRunner. This is the channel retail uses for
// lightning flashes, spell casts, emotes, combat FX, etc.
if (body.Length >= 12)
{
uint targetGuid = System.Buffers.Binary.BinaryPrimitives
.ReadUInt32LittleEndian(body.AsSpan(4, 4));
uint scriptId = System.Buffers.Binary.BinaryPrimitives
.ReadUInt32LittleEndian(body.AsSpan(8, 4));
PlayScriptReceived?.Invoke(targetGuid, scriptId);
}
}
else if (op == 0xF751u) // PlayerTeleport — server is moving us through a portal
{
// Phase B.3: holtburger opcodes.rs confirms 0xF751 is the
// PlayerTeleport standalone GameMessage (NOT wrapped in 0xF7B0).
// Wire layout (teleport.rs): u16 teleport_sequence, then
// aligned to 4 bytes. Per holtburger's client handler, the
// correct response is send_login_complete() at the destination.
// Here we fire TeleportStarted so GameWindow can freeze
// movement; the LoginComplete is sent from GameWindow once
// the destination UpdatePosition is received and the player
// has been snapped to the new cell.
ushort sequence = 0;
if (body.Length >= 6)
sequence = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(
body.AsSpan(4, 2));
_teleportSequence = sequence; // track for outbound movement messages
TeleportStarted?.Invoke(sequence);
}
else if (DumpOpcodesEnabled)
{
// ACDREAM_DUMP_OPCODES=1 — emit a one-line trace per
// genuinely-unhandled opcode (deduped to first occurrence).
// MUST be the LAST else-if so it doesn't intercept handled
// opcodes when the env var is set.
if (_seenUnhandledOpcodes.Add(op))
Console.WriteLine($"opcodes: unhandled 0x{op:X4} (body.len={body.Length})");
}
}
}
///
/// Phase B.2: send a pre-built GameAction body (which already contains
/// the 0xF7B1 envelope + sequence + action-type header). Used by the
/// PlayerMovementController for MoveToState and AutonomousPosition.
///
public void SendGameAction(byte[] gameActionBody)
{
// Phase I.3 test seam: when set, intercept the body before the
// wire-write path runs (which would otherwise NPE on an unseeded
// ISAAC keystream during unit tests). Production callers leave
// this null and the body proceeds to the framed/encrypted UDP send.
if (GameActionCapture is not null)
{
GameActionCapture(gameActionBody);
return;
}
SendGameMessage(gameActionBody);
}
///
/// Phase I.3: test-only hook. When non-null,
/// invokes this instead of writing to the wire. Lets unit tests verify
/// that //
/// produce the bytes they should without standing up a full handshake +
/// ISAAC keystream. Production sites never set this.
///
internal Action? GameActionCapture { get; set; }
///
/// Phase B.2: get and increment the game-action sequence counter.
/// Call once per outbound movement message; pass the returned value
/// to or
/// .
///
public uint NextGameActionSequence() => ++_gameActionSequence;
///
/// Phase I.3: send a local /say message (heard within ~20m).
/// Wraps .
///
public void SendTalk(string text)
{
ArgumentNullException.ThrowIfNull(text);
uint seq = NextGameActionSequence();
byte[] body = ChatRequests.BuildTalk(seq, text);
SendGameAction(body);
}
///
/// Phase I.3: send a /tell (whisper) by target character name.
/// Wraps .
///
public void SendTell(string targetName, string text)
{
ArgumentNullException.ThrowIfNull(targetName);
ArgumentNullException.ThrowIfNull(text);
uint seq = NextGameActionSequence();
byte[] body = ChatRequests.BuildTell(seq, targetName, text);
SendGameAction(body);
}
///
/// Phase I.3: send to a chat channel (allegiance, fellowship, etc.) by
/// the legacy ChatChannel bitflag id.
/// Wraps .
///
public void SendChannel(uint channelId, string text)
{
ArgumentNullException.ThrowIfNull(text);
uint seq = NextGameActionSequence();
byte[] body = ChatRequests.BuildChatChannel(seq, channelId, text);
SendGameAction(body);
}
/// Send retail ChangeCombatMode (0x0053).
public void SendChangeCombatMode(CombatMode mode)
{
uint seq = NextGameActionSequence();
byte[] body = CharacterActions.BuildChangeCombatMode(
seq,
(CharacterActions.CombatMode)(uint)mode);
SendGameAction(body);
}
/// Send retail TargetedMeleeAttack (0x0008).
public void SendMeleeAttack(uint targetGuid, AttackHeight attackHeight, float powerLevel)
{
uint seq = NextGameActionSequence();
byte[] body = AttackTargetRequest.BuildMelee(
seq,
targetGuid,
(uint)attackHeight,
powerLevel);
SendGameAction(body);
}
/// Send retail TargetedMissileAttack (0x000A).
public void SendMissileAttack(uint targetGuid, AttackHeight attackHeight, float accuracyLevel)
{
uint seq = NextGameActionSequence();
byte[] body = AttackTargetRequest.BuildMissile(
seq,
targetGuid,
(uint)attackHeight,
accuracyLevel);
SendGameAction(body);
}
/// Send retail CancelAttack (0x01B7).
public void SendCancelAttack()
{
uint seq = NextGameActionSequence();
byte[] body = AttackTargetRequest.BuildCancel(seq);
SendGameAction(body);
}
///
/// Phase I.6: send a TurbineChat RequestSendToRoomById to a
/// global community room (General / Trade / LFG / Roleplay /
/// Society / Olthoi). Unlike this is a
/// top-level GameMessage (0xF7DE), not a 0xF7B1 GameAction — so it
/// rides 's capture seam (test-friendly)
/// but skips the GameAction sequence counter.
///
///
/// must come from the parent's
/// TurbineChatState.NextContextId() — WorldSession does not
/// own that state because it lives at the GameWindow / chat-runtime
/// level. is the local player's guid
/// (the server uses it to attribute messages on the chat-server side).
///
///
public void SendTurbineChatTo(
uint roomId,
uint chatType,
uint dispatchType,
uint senderGuid,
string text,
uint cookie)
{
ArgumentNullException.ThrowIfNull(text);
// Holtburger always sets target_type=1, target_id=0, transport_type=0,
// transport_id=0 for outbound RequestSendToRoomById (commands.rs:288-291)
// — those fields are populated by ACE on inbound events but are
// semantically empty for client-issued requests.
_ = dispatchType; // outbound is always RequestSendToRoomById; see comment
var payload = new TurbineChat.Payload.RequestSendToRoomById(
ContextId: cookie,
RoomId: roomId,
Message: text,
ExtraDataSize: 0x0Cu, // ACE-side magic per holtburger commands.rs:297
SenderId: senderGuid,
HResult: 0,
ChatType: chatType);
byte[] body = TurbineChat.Build(
blobType: TurbineChat.BlobType.RequestBinary,
dispatchType: TurbineChat.DispatchType.SendToRoomById,
targetType: 1u,
targetId: 0u,
transportType: 0u,
transportId: 0u,
cookie: 0u, // outer header cookie is 0; inner context_id is the user-visible cookie
payload: payload);
SendGameAction(body);
}
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);
}
///
/// Phase 4.9: send a bare ACK_SEQUENCE control packet acknowledging
/// . This is a cleartext control
/// packet (no EncryptedChecksum) — the body is just the 4-byte server
/// sequence number being acknowledged. The header re-uses the most
/// recently sent client sequence (no increment) because acks aren't
/// themselves part of the reliable stream the server tracks.
///
///
/// Without sending these, ACE drops the session with
/// Network Timeout after ~60s — and during that 60s the
/// character appears to other clients as a stationary purple haze
/// (loading state) because the server hasn't seen the client confirm
/// any post-EnterWorld traffic.
///
///
///
/// Pattern ported from
/// references/holtburger/crates/holtburger-session/src/session/send.rs::send_ack
/// and the receive-side trigger at
/// .../session/receive.rs::finalize_ordered_server_packet.
///
///
private void SendAck(uint serverPacketSequence)
{
// 4-byte body: little-endian u32 of the server sequence we're acking.
Span body = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(body, serverPacketSequence);
// Holtburger uses current_client_sequence (= packet_sequence - 1) for
// ack headers. We mirror that — acks borrow the most recently issued
// client sequence rather than consuming a new one.
uint ackHeaderSequence = _clientPacketSequence > 0
? _clientPacketSequence - 1
: 0u;
var header = new PacketHeader
{
Sequence = ackHeaderSequence,
Flags = PacketHeaderFlags.AckSequence,
Id = _sessionClientId,
};
byte[] datagram = PacketCodec.Encode(header, body, outboundIsaac: null);
_net.Send(datagram);
}
private void Transition(State next)
{
if (CurrentState == next) return;
CurrentState = next;
StateChanged?.Invoke(next);
}
///
/// Graceful shutdown: tell the server we're leaving so it releases the
/// character lock immediately instead of waiting 60s for the session to
/// time out. Pattern from
/// references/holtburger/crates/holtburger-core/src/client/commands.rs
/// lines 879-892: send CharacterLogOff game message (opcode
/// 0xF653, no payload) then send a bare DISCONNECT control
/// packet (header flag 0x8000, no payload).
///
public void Dispose()
{
if (CurrentState == State.InWorld)
{
try
{
// Tell ACE "player is leaving the world" so it cleans up
// the character immediately.
var logoff = new Packets.PacketWriter(8);
logoff.WriteUInt32(0xF653u); // CharacterLogOff opcode
SendGameMessage(logoff.ToArray());
// Tell the transport layer "close this session."
var disconnectHeader = new PacketHeader
{
Sequence = _clientPacketSequence++,
Flags = PacketHeaderFlags.Disconnect,
Id = _sessionClientId,
};
byte[] disconnectPacket = PacketCodec.Encode(
disconnectHeader, ReadOnlySpan.Empty, outboundIsaac: null);
_net.Send(disconnectPacket);
}
catch
{
// Best-effort — if the socket is already dead, eat the
// exception and let Dispose finish cleaning up.
}
}
// Phase A.3: shut down the background receive thread. Cancel the
// token → the 250ms receive timeout fires → loop exits → join.
_netCancel.Cancel();
_inboundQueue.Writer.TryComplete();
_netThread?.Join(TimeSpan.FromSeconds(2));
_netCancel.Dispose();
_net.Dispose();
}
}