acdream/src/AcDream.Core.Net/WorldSession.cs
Erik e471527924 feat(net): wire 0xF625 ObjDescEvent for live appearance updates
Retail-driven players observed from acdream rendered with stale
appearance — wrong skin/hair palettes, missing clothing — because
ACE's mid-session appearance broadcasts (equip/unequip/tailoring/
recipe/option-toggle) ride opcode 0xF625 ObjDescEvent and acdream
silently dropped them. Initial CreateObject carries the appearance
at spawn time, but every later equip change only updates via 0xF625
(per Skunkwors protocol docs in ACE/.../GameMessageObjDescEvent.cs).
Retail handles via SmartBox::HandleObjDescEvent (named-retail 0x453340).

Why: the retail observer sees the *server-relayed* view of remotes,
not retail's local build, so dropping ObjDescEvent freezes appearance
at the partial state in the first CreateObject.

How:
- Extract CreateObject's ModelData parsing into reusable
  CreateObject.ReadModelData(span, ref pos) returning
  (BasePaletteId, SubPalettes, TextureChanges, AnimPartChanges).
- Add ObjDescEvent.cs (parser for 0xF625):
  body = u32 opcode | u32 guid | ModelData | u32 instanceSeq | u32 visualDescSeq.
- WorldSession.AppearanceUpdated event + dispatcher branch.
- GameWindow.OnLiveAppearanceUpdated splices new ModelData onto the
  cached spawn and replays via OnLiveEntitySpawned. The dedup at the
  start of OnLiveEntitySpawnedLocked tears down the old GPU/animated/
  collision state cleanly before rebuild.
- _lastSpawnByGuid cache populated at spawn-end and tracked through
  UpdatePosition so re-applies use current position (no pop-back to
  login spot on equip toggle).
- ACDREAM_DUMP_APPEARANCE=1 env var prints structured SP/TC/APC
  decode for every 0xF625 — replaces the earlier raw-hex preview.
- ACDREAM_DUMP_CLOTHING extended with setup.Parts.Count, flatten.Count,
  and per-part triangle counts for offline polygon-budget audit.

Tests: 4 new ObjDescEvent tests (round-trip + parser drift guard);
269 net tests green. User-verified live: skin/hair colors match
retail's character data; equip/unequip no longer pops position.

Note: a separate "puffy arms / bulky body" geometry issue remains
where base body parts visibly overlap clothing meshes — different
root cause, tracked separately.
2026-05-06 10:46:14 +02:00

1213 lines
55 KiB
C#

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;
/// <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,
IReadOnlyList<CreateObject.AnimPartChange> AnimPartChanges,
IReadOnlyList<CreateObject.TextureChange> TextureChanges,
IReadOnlyList<CreateObject.SubPaletteSwap> SubPalettes,
uint? BasePaletteId,
float? ObjScale,
string? Name,
uint? ItemType,
CreateObject.ServerMotionState? MotionState,
uint? MotionTableId,
// Commit A 2026-04-29 — live-entity collision plumbing.
// PhysicsState: retail acclient.h:2815 (ETHEREAL_PS=0x4,
// IGNORE_COLLISIONS_PS=0x10, HAS_PHYSICS_BSP_PS=0x10000, ...).
// ObjectDescriptionFlags: retail PWD._bitfield (acclient.h:6431-6463)
// — drives IsPlayer/IsPK/IsPKLite/IsImpenetrable for PvP gating.
uint? PhysicsState = null,
uint? ObjectDescriptionFlags = null,
// L.3b (2026-04-30): per-object physics tuning from the wire.
// Friction defaults to PhysicsBody constructor value (0.5f).
// Elasticity defaults to 0.05f. When set, drives the velocity-
// reflection bounce magnitude (clamped to [0, 0.1] retail-side).
float? Friction = null,
float? Elasticity = null);
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
public event Action<EntitySpawn>? EntitySpawned;
/// <summary>
/// Fires when the session parses a 0xF747 ObjectDelete game message.
/// Retail routes this through
/// <c>CM_Physics::DispatchSB_DeleteObject</c> 0x006AC6A0 →
/// <c>SmartBox::HandleDeleteObject</c> 0x00451EA0; ACE emits it when
/// an object leaves the world, including the living creature object
/// after its corpse is created.
/// </summary>
public event Action<DeleteObject.Parsed>? EntityDeleted;
/// <summary>
/// Payload for <see cref="MotionUpdated"/>: 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.
/// </summary>
public readonly record struct EntityMotionUpdate(
uint Guid,
CreateObject.ServerMotionState MotionState);
/// <summary>
/// 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.
/// </summary>
public event Action<EntityMotionUpdate>? MotionUpdated;
/// <summary>
/// Payload for <see cref="PositionUpdated"/>: the server guid plus a
/// full <see cref="CreateObject.ServerPosition"/> describing the
/// entity's new world position and rotation. Subscribers translate
/// the landblock-local position into acdream world space and reseat
/// the corresponding <c>WorldEntity</c>.
/// </summary>
public readonly record struct EntityPositionUpdate(
uint Guid,
CreateObject.ServerPosition Position,
System.Numerics.Vector3? Velocity,
bool IsGrounded);
/// <summary>
/// Fires when the session parses a 0xF748 UpdatePosition game message.
/// </summary>
public event Action<EntityPositionUpdate>? PositionUpdated;
/// <summary>
/// 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
/// <c>EnqueueBroadcast(new GameMessageVectorUpdate(this));</c>).
/// Subscribers update the remote's PhysicsBody velocity + airborne
/// state so the dead-reckoning produces a proper jump arc.
/// </summary>
public event Action<VectorUpdate.Parsed>? VectorUpdated;
/// <summary>
/// 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.
/// </summary>
public event Action<uint>? TeleportStarted;
/// <summary>
/// Fires when the server broadcasts an <c>ObjDescEvent (0xF625)</c> —
/// a creature/player's appearance changed after the initial CreateObject
/// (equip / unequip / tailoring / recipe result / character option toggle).
/// Subscribers re-apply the new <c>ModelData</c> to the existing entity:
/// AnimPartChanges replace mesh refs, TextureChanges update per-part
/// surface texture overrides, and SubPalettes rebuild the palette
/// override (the channel that carries skin/hair tone). Without this,
/// retail-driven characters observed from acdream end up "stuck" at
/// whatever appearance was in their first CreateObject — see issue
/// notes in commit history around 2026-05-06.
/// </summary>
public event Action<ObjDescEvent.Parsed>? AppearanceUpdated;
/// <summary>
/// Phase H.1: fires when a local or ranged speech message (0x02BB /
/// 0x02BC) is received. Subscribers typically feed these into a
/// <c>ChatLog</c>.
/// </summary>
public event Action<HearSpeech.Parsed>? SpeechHeard;
/// <summary>
/// Phase I.5: fires when an <c>EmoteText (0x01E0)</c> 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
/// <c>ChatLog.OnEmote</c>.
/// </summary>
public event Action<EmoteText.Parsed>? EmoteHeard;
/// <summary>
/// Phase I.5: fires when a <c>SoulEmote (0x01E2)</c> top-level
/// GameMessage is received — complex emote with optional animation
/// pairing. Wire layout matches EmoteText.
/// </summary>
public event Action<SoulEmote.Parsed>? SoulEmoteHeard;
/// <summary>
/// Phase I.5: fires when a <c>ServerMessage (0xF7E0)</c> top-level
/// GameMessage is received — general server-broadcast text used
/// for announcements, combat logs, and routine error messages.
/// Subscribers typically feed <c>ChatLog.OnSystemMessage</c>.
/// </summary>
public event Action<ServerMessage.Parsed>? ServerMessageReceived;
/// <summary>
/// Phase I.5: fires when a <c>PlayerKilled (0x019E)</c> top-level
/// GameMessage is received — server announcement that a player
/// was killed in combat. Subscribers typically feed
/// <c>ChatLog.OnPlayerKilled</c>.
/// </summary>
public event Action<PlayerKilled.Parsed>? PlayerKilledReceived;
/// <summary>
/// Phase I.6: fires when a <c>TurbineChat (0xF7DE)</c> top-level
/// GameMessage is received. Carries the unified
/// <see cref="TurbineChat.Parsed"/> envelope (header + payload
/// variant). Subscribers typically switch on the payload variant
/// and route <c>EventSendToRoom</c> into <c>ChatLog.OnChannelBroadcast</c>.
/// </summary>
public event Action<TurbineChat.Parsed>? TurbineChatReceived;
/// <summary>
/// Phase I.6: fires when a <c>SetTurbineChatChannels (0x0295)</c>
/// 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 <c>TurbineChatState.OnChannelsReceived</c>.
/// </summary>
public event Action<SetTurbineChatChannels.Parsed>? TurbineChannelsReceived;
/// <summary>
/// Issue #5: fires when a <c>PrivateUpdateVital (0x02E7)</c> arrives
/// — full per-vital snapshot (ranks / start / xp / current).
/// Subscribers typically feed
/// <see cref="AcDream.Core.Player.LocalPlayerState.OnVitalUpdate"/>.
/// Wire layout: see <see cref="PrivateUpdateVital"/>.
/// </summary>
public event Action<PrivateUpdateVital.ParsedFull>? VitalUpdated;
/// <summary>
/// Issue #5: fires when a <c>PrivateUpdateVitalCurrent (0x02E9)</c>
/// arrives — current-only delta (regen ticks, drains).
/// Subscribers typically feed
/// <see cref="AcDream.Core.Player.LocalPlayerState.OnVitalCurrent"/>.
/// </summary>
public event Action<PrivateUpdateVital.ParsedCurrent>? VitalCurrentUpdated;
/// <summary>
/// Phase 6 — server-broadcast PhysicsScript trigger. Fires when the
/// server sends a <c>PlayScriptId</c> (opcode 0xF754) packet —
/// wire format <c>[u32 opcode][u32 guid][u32 scriptId]</c>.
///
/// <para>
/// 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
/// <c>GameWindow</c>) resolve the guid to the appropriate entity
/// position and dispatch to a <c>PhysicsScriptRunner</c>.
/// </para>
///
/// <para>
/// Trail: <c>chunk_006A0000.c:12320-12336</c> opcode dispatch →
/// <c>FUN_00452060</c> → <c>FUN_00511800</c> → <c>FUN_005117a0</c>
/// (PhysicsObj::RunScript) → <c>FUN_0051bed0</c> (PhysicsScript
/// runtime). See <c>docs/research/2026-04-23-lightning-real.md</c>.
/// </para>
/// </summary>
public event Action<uint /*guid*/, uint /*scriptId*/>? PlayScriptReceived;
/// <summary>
/// Phase 5d — retail's <c>AdminEnvirons</c> packet (opcode
/// <c>0xEA60</c>) — the one-and-only channel retail's server uses
/// for weather environment changes. Wire format:
/// <c>[u32 opcode][u32 environChangeType]</c>. The payload enum is
/// retail's <c>EnvironChangeType</c>:
/// <list type="bullet">
/// <item><description>
/// <c>0x00..0x06</c> — fog presets (Clear/Red/Blue/White/Green/
/// Black/Black2). Subscribers route these to a
/// <see cref="AcDream.Core.World.WeatherSystem.Override"/>.
/// </description></item>
/// <item><description>
/// <c>0x65..0x75</c> — one-shot ambient sound cues
/// (Roar / Bell / Chant / etc).
/// </description></item>
/// <item><description>
/// <c>0x76..0x7B</c> — Thunder1..Thunder6 sounds. Paired with
/// a separate <see cref="PlayScriptReceived"/> from the server
/// carrying the lightning-flash PhysicsScript.
/// </description></item>
/// </list>
/// See <c>docs/research/2026-04-23-lightning-crossfade.md</c> +
/// <c>2026-04-23-lightning-real.md</c>.
/// </summary>
public event Action<uint /*environChangeType*/>? EnvironChanged;
/// <summary>
/// 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 <c>WorldTimeService.SyncFromServer</c>
/// so client-local day/night stays in lockstep with the server clock.
/// </summary>
public event Action<double>? ServerTimeUpdated;
/// <summary>
/// Latest server tick count from <see cref="ServerTimeUpdated"/>
/// events. 0 until the handshake completes.
/// </summary>
public double LastServerTimeTicks { get; private set; }
/// <summary>
/// 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).
/// </summary>
public void ResetLoginComplete() => _loginCompleteSent = false;
/// <summary>Raised every time the state machine transitions.</summary>
public event Action<State>? StateChanged;
/// <summary>
/// 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.
/// </summary>
public GameEventDispatcher GameEvents { get; } = new();
public State CurrentState { get; private set; } = State.Disconnected;
/// <summary>Movement sequence counters for outbound MoveToState/AutonomousPosition.</summary>
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
// ACDREAM_DUMP_APPEARANCE=1 → log every 0xF625 ObjDescEvent + 0xF7DB UpdateObject
// with body len, target guid, hex preview. Used to
// debug remote-player appearance asymmetry (retail
// observer in acdream renders wrong skin/hair).
private static readonly bool DumpOpcodesEnabled =
Environment.GetEnvironmentVariable("ACDREAM_DUMP_OPCODES") == "1";
private static readonly bool DumpVitalsEnabled =
Environment.GetEnvironmentVariable("ACDREAM_DUMP_VITALS") == "1";
private static readonly bool DumpAppearanceEnabled =
Environment.GetEnvironmentVariable("ACDREAM_DUMP_APPEARANCE") == "1";
private readonly System.Collections.Generic.HashSet<uint> _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<byte[]> _inboundQueue =
Channel.CreateUnbounded<byte[]>(
new UnboundedChannelOptions
{ SingleReader = true, SingleWriter = true });
private Thread? _netThread;
private readonly CancellationTokenSource _netCancel = new();
/// <summary>
/// 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).
/// </summary>
private bool _loginCompleteSent;
/// <summary>
/// Phase B.2: per-session game-action sequence counter. Monotonically
/// incremented by <see cref="NextGameActionSequence"/> 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.
/// </summary>
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);
});
}
/// <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;
// 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"); }
}
/// <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));
// 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();
}
/// <summary>
/// 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.
/// </summary>
public int Tick()
{
int processed = 0;
while (_inboundQueue.Reader.TryRead(out var bytes))
{
ProcessDatagram(bytes);
processed++;
}
return processed;
}
/// <summary>
/// Phase A.3: background receive loop. Runs on a dedicated daemon
/// thread started at the end of <see cref="EnterWorld"/>. Continuously
/// pulls raw UDP datagrams from the kernel buffer via
/// <see cref="NetClient.Receive"/> and writes them into
/// <see cref="_inboundQueue"/> for the render thread to drain in
/// <see cref="Tick"/>. Does NOT decode, reassemble, or dispatch —
/// all of that stays on the render thread to avoid ISAAC/assembler
/// thread-safety issues.
///
/// <para>
/// 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, <see cref="Dispose"/> cancels the token
/// and joins the thread.
/// </para>
/// </summary>
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();
}
}
/// <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;
// 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.ItemType,
parsed.Value.MotionState,
parsed.Value.MotionTableId,
parsed.Value.PhysicsState,
parsed.Value.ObjectDescriptionFlags,
parsed.Value.Friction,
parsed.Value.Elasticity));
}
}
else if (op == DeleteObject.Opcode)
{
var parsed = DeleteObject.TryParse(body);
if (parsed is not null)
EntityDeleted?.Invoke(parsed.Value);
}
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,
posUpdate.Value.IsGrounded));
}
}
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<false> — 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<false>.
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 (op == ObjDescEvent.Opcode)
{
// 0xF625 ObjDescEvent — per-entity appearance update. ACE
// broadcasts on equip/unequip/tailoring/recipe/option-change
// (Creature_Equipment.cs:365, Tailoring.cs:504,
// RecipeManager.cs:403, GameActionSetSingleCharacterOption.cs:27).
// Retail handler: SmartBox::HandleObjDescEvent (named-retail
// 0x453340). Body layout: u32 opcode | u32 guid | ModelData |
// u32 instanceSeq | u32 visualDescSeq.
var parsed = ObjDescEvent.TryParse(body);
if (parsed is not null)
{
if (DumpAppearanceEnabled)
{
var md = parsed.Value.ModelData;
Console.WriteLine($"appearance: 0xF625 guid=0x{parsed.Value.Guid:X8} basePal=0x{(md.BasePaletteId ?? 0):X8} subPals={md.SubPalettes.Count} texChanges={md.TextureChanges.Count} animParts={md.AnimPartChanges.Count}");
foreach (var sp in md.SubPalettes)
Console.WriteLine($" SP id=0x{sp.SubPaletteId:X8} offset={sp.Offset} length={sp.Length}");
foreach (var tc in md.TextureChanges)
Console.WriteLine($" TC part={tc.PartIndex:D2} oldTex=0x{tc.OldTexture:X8} -> newTex=0x{tc.NewTexture:X8}");
foreach (var apc in md.AnimPartChanges)
Console.WriteLine($" APC part={apc.PartIndex:D2} -> gfx=0x{apc.NewModelId:X8}");
}
AppearanceUpdated?.Invoke(parsed.Value);
}
else if (DumpAppearanceEnabled)
{
Console.WriteLine($"appearance: 0xF625 PARSE FAILED body.len={body.Length}");
}
}
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})");
}
}
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Phase I.3: test-only hook. When non-null, <see cref="SendGameAction"/>
/// invokes this instead of writing to the wire. Lets unit tests verify
/// that <see cref="SendTalk"/>/<see cref="SendTell"/>/<see cref="SendChannel"/>
/// produce the bytes they should without standing up a full handshake +
/// ISAAC keystream. Production sites never set this.
/// </summary>
internal Action<byte[]>? GameActionCapture { get; set; }
/// <summary>
/// Phase B.2: get and increment the game-action sequence counter.
/// Call once per outbound movement message; pass the returned value
/// to <see cref="Messages.MoveToState.Build"/> or
/// <see cref="Messages.AutonomousPosition.Build"/>.
/// </summary>
public uint NextGameActionSequence() => ++_gameActionSequence;
/// <summary>
/// Phase I.3: send a local /say message (heard within ~20m).
/// Wraps <see cref="ChatRequests.BuildTalk"/>.
/// </summary>
public void SendTalk(string text)
{
ArgumentNullException.ThrowIfNull(text);
uint seq = NextGameActionSequence();
byte[] body = ChatRequests.BuildTalk(seq, text);
SendGameAction(body);
}
/// <summary>
/// Phase I.3: send a /tell (whisper) by target character name.
/// Wraps <see cref="ChatRequests.BuildTell"/>.
/// </summary>
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);
}
/// <summary>
/// Phase I.3: send to a chat channel (allegiance, fellowship, etc.) by
/// the legacy <c>ChatChannel</c> bitflag id.
/// Wraps <see cref="ChatRequests.BuildChatChannel"/>.
/// </summary>
public void SendChannel(uint channelId, string text)
{
ArgumentNullException.ThrowIfNull(text);
uint seq = NextGameActionSequence();
byte[] body = ChatRequests.BuildChatChannel(seq, channelId, text);
SendGameAction(body);
}
/// <summary>Send retail ChangeCombatMode (0x0053).</summary>
public void SendChangeCombatMode(CombatMode mode)
{
uint seq = NextGameActionSequence();
byte[] body = CharacterActions.BuildChangeCombatMode(
seq,
(CharacterActions.CombatMode)(uint)mode);
SendGameAction(body);
}
/// <summary>Send retail TargetedMeleeAttack (0x0008).</summary>
public void SendMeleeAttack(uint targetGuid, AttackHeight attackHeight, float powerLevel)
{
uint seq = NextGameActionSequence();
byte[] body = AttackTargetRequest.BuildMelee(
seq,
targetGuid,
(uint)attackHeight,
powerLevel);
SendGameAction(body);
}
/// <summary>Send retail TargetedMissileAttack (0x000A).</summary>
public void SendMissileAttack(uint targetGuid, AttackHeight attackHeight, float accuracyLevel)
{
uint seq = NextGameActionSequence();
byte[] body = AttackTargetRequest.BuildMissile(
seq,
targetGuid,
(uint)attackHeight,
accuracyLevel);
SendGameAction(body);
}
/// <summary>Send retail CancelAttack (0x01B7).</summary>
public void SendCancelAttack()
{
uint seq = NextGameActionSequence();
byte[] body = AttackTargetRequest.BuildCancel(seq);
SendGameAction(body);
}
/// <summary>
/// Phase I.6: send a TurbineChat <c>RequestSendToRoomById</c> to a
/// global community room (General / Trade / LFG / Roleplay /
/// Society / Olthoi). Unlike <see cref="SendChannel"/> this is a
/// top-level GameMessage (0xF7DE), not a 0xF7B1 GameAction — so it
/// rides <see cref="SendGameAction"/>'s capture seam (test-friendly)
/// but skips the GameAction sequence counter.
///
/// <para>
/// <paramref name="cookie"/> must come from the parent's
/// <c>TurbineChatState.NextContextId()</c> — WorldSession does not
/// own that state because it lives at the GameWindow / chat-runtime
/// level. <paramref name="senderGuid"/> is the local player's guid
/// (the server uses it to attribute messages on the chat-server side).
/// </para>
/// </summary>
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);
}
/// <summary>
/// Phase 4.9: send a bare ACK_SEQUENCE control packet acknowledging
/// <paramref name="serverPacketSequence"/>. 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.
///
/// <para>
/// Without sending these, ACE drops the session with
/// <c>Network Timeout</c> 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.
/// </para>
///
/// <para>
/// Pattern ported from
/// <c>references/holtburger/crates/holtburger-session/src/session/send.rs::send_ack</c>
/// and the receive-side trigger at
/// <c>.../session/receive.rs::finalize_ordered_server_packet</c>.
/// </para>
/// </summary>
private void SendAck(uint serverPacketSequence)
{
// 4-byte body: little-endian u32 of the server sequence we're acking.
Span<byte> 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);
}
/// <summary>
/// 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
/// <c>references/holtburger/crates/holtburger-core/src/client/commands.rs</c>
/// lines 879-892: send <c>CharacterLogOff</c> game message (opcode
/// 0xF653, no payload) then send a bare <c>DISCONNECT</c> control
/// packet (header flag 0x8000, no payload).
/// </summary>
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<byte>.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();
}
}