using System.Buffers;
using System.Buffers.Binary;
using System.Diagnostics;
using System.Net;
using System.Threading.Channels;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net.Cryptography;
using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets;
using AcDream.Core.Net.Transport;
namespace AcDream.Core.Net;
///
/// EnterWorld was rejected by the server while the transport remains a valid
/// character-select session. Callers may surface and let
/// the user choose another character instead of tearing down the connection.
///
public sealed class CharacterSelectionRejectedException(
CharacterError.Parsed error)
: InvalidOperationException(
$"The server rejected character entry with error 0x{error.RawErrorCode:X8}.")
{
public CharacterError.Parsed Error { get; } = error;
}
internal interface IWorldSessionTransport : IDisposable
{
void Send(ReadOnlySpan datagram);
void Send(IPEndPoint remote, ReadOnlySpan datagram);
int Receive(
Span destination,
TimeSpan timeout,
out IPEndPoint? from);
ValueTask ReceiveAsync(
Memory destination,
CancellationToken cancellationToken);
}
internal sealed class NetClientWorldSessionTransport(IPEndPoint remote)
: IWorldSessionTransport
{
private readonly NetClient _client = new(remote);
public void Send(ReadOnlySpan datagram) => _client.Send(datagram);
public void Send(IPEndPoint endpoint, ReadOnlySpan datagram) =>
_client.Send(endpoint, datagram);
public int Receive(
Span destination,
TimeSpan timeout,
out IPEndPoint? from) =>
_client.Receive(destination, timeout, out from);
public ValueTask ReceiveAsync(
Memory destination,
CancellationToken cancellationToken) =>
_client.ReceiveAsync(destination, cancellationToken);
public void Dispose() => _client.Dispose();
}
///
/// 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
///
///
///
/// Still deferred: unsolicited-disconnect recovery. The full
/// Campaign N reliable transport is live in both directions: outbound
/// sent-packet cache + resend on server NAK (N1), inbound sequence-aligned
/// ISAAC + NAK set (N2), the retail 2.0 s cumulative-ack sweep (N3), client
/// NAK emission + RejectRetransmit reclaim (N4), the N5 loss observability
/// + deterministic loss injection seam, and the N6 handshake hardening
/// (ConnectResponse 0.333 s retransmit + fragment-assembler eviction).
///
///
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,
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,
// 2026-05-15: from the WeenieHeader optional tail.
// Useability: retail ITEM_USEABLE enum (acclient.h:6478). Bit
// USEABLE_REMOTE (0x20) means the entity accepts R-key Use from
// the world; signs/banners have USEABLE_UNDEF (0x0) and should
// silently ignore Use attempts. null = weenieFlags didn't include
// the field (treat conservatively as not-useable).
// UseRadius: server's use-action reach in meters. Doubles as a
// sizing hint for tall-scenery selection indicators when the
// server publishes it for non-useable display entities.
uint? Useability = null,
float? UseRadius = null,
uint? TargetType = null,
// D.5.1: icon datId from CreateObject WeenieHeader, for toolbar rendering.
uint IconId = 0,
// D.5.1 (2026-06-17): icon overlay/underlay dat ids from the extended
// WeenieHeader optional tail. Gated by WeenieHeaderFlag.IconOverlay
// (0x40000000) and WeenieHeaderFlag2.IconUnderlay (0x01) respectively.
// Zero when the server did not send the field (common for most entities).
uint IconOverlayId = 0,
uint IconUnderlayId = 0,
// D.5.2 (2026-06-17): UiEffects bitfield (weenieFlags 0x80) — drives the icon's
// effect recolor. CreateObject-only; 0 = no effect.
uint UiEffects = 0,
// D.5.4 (2026-06-18): full item field set, forwarded to the object table.
// Quantity fields int? (ACE PropertyInt convention); id/mask fields uint?.
uint WeenieClassId = 0,
int? Value = null,
int? StackSize = null,
int? StackSizeMax = null,
int? Burden = null,
int? ItemsCapacity = null,
int? ContainersCapacity = null,
uint? ContainerId = null,
uint? WielderId = null,
uint? ValidLocations = null,
uint? CurrentWieldedLocation = null,
uint? Priority = null,
int? Structure = null,
int? MaxStructure = null,
float? Workmanship = null,
// L.2g S1 (DEV-6): PhysicsDesc timestamp-block stamps that seed the
// per-entity PhysicsTimestampGate (retail update_times INSTANCE_TS /
// MOVEMENT_TS / SERVER_CONTROLLED_MOVE_TS).
ushort InstanceSequence = 0,
ushort MovementSequence = 0,
ushort ServerControlSequence = 0,
ushort PositionSequence = 0,
uint? ParentGuid = null,
uint? ParentLocation = null,
uint? PlacementId = null,
// PublicWeenieDesc optional-tail bytes. null means the corresponding
// flag was absent; zero means the server explicitly sent the enum's
// undefined/default value.
byte? RadarBlipColor = null,
byte? RadarBehavior = null,
byte? CombatUse = null,
string? PluralName = null,
uint? PetOwnerId = null,
ushort? AmmoType = null,
uint? SpellId = null,
uint? CooldownId = null,
double? CooldownDuration = null,
PhysicsSpawnData? Physics = null,
uint? HookItemTypes = null,
uint? HookType = null,
uint? MaterialType = null,
// AP-129 (Campaign P Slice P4 review fix, 2026-07-30).
uint? HouseOwnerId = null,
uint? MonarchId = null,
HouseRestrictionRecord? Restrictions = null);
///
/// Projects the wire-level CreateObject result into the stable session
/// event payload. Kept as a focused seam so every optional field's
/// absent-versus-explicit-default semantics can be tested without a UDP
/// session.
///
internal static EntitySpawn ToEntitySpawn(CreateObject.Parsed parsed) => new(
parsed.Guid,
parsed.Position,
parsed.SetupTableId,
parsed.AnimPartChanges,
parsed.TextureChanges,
parsed.SubPalettes,
parsed.BasePaletteId,
parsed.ObjScale,
parsed.Name,
parsed.ItemType,
parsed.MotionState,
parsed.MotionTableId,
parsed.PhysicsState,
parsed.ObjectDescriptionFlags,
parsed.Friction,
parsed.Elasticity,
parsed.Useability,
parsed.UseRadius,
parsed.TargetType,
parsed.IconId,
parsed.IconOverlayId,
parsed.IconUnderlayId,
parsed.UiEffects,
parsed.WeenieClassId,
parsed.Value,
parsed.StackSize,
parsed.StackSizeMax,
parsed.Burden,
parsed.ItemsCapacity,
parsed.ContainersCapacity,
parsed.ContainerId,
parsed.WielderId,
parsed.ValidLocations,
parsed.CurrentWieldedLocation,
parsed.Priority,
parsed.Structure,
parsed.MaxStructure,
parsed.Workmanship,
InstanceSequence: parsed.InstanceSequence,
MovementSequence: parsed.MovementSequence,
ServerControlSequence: parsed.ServerControlSequence,
PositionSequence: parsed.PositionSequence,
ParentGuid: parsed.ParentGuid,
ParentLocation: parsed.ParentLocation,
PlacementId: parsed.PlacementId,
RadarBlipColor: parsed.RadarBlipColor,
RadarBehavior: parsed.RadarBehavior,
CombatUse: parsed.CombatUse,
PluralName: parsed.PluralName,
PetOwnerId: parsed.PetOwnerId,
AmmoType: parsed.AmmoType,
SpellId: parsed.SpellId,
CooldownId: parsed.CooldownId,
CooldownDuration: parsed.CooldownDuration,
Physics: parsed.Physics,
HookItemTypes: parsed.HookItemTypes,
HookType: parsed.HookType,
MaterialType: parsed.MaterialType,
HouseOwnerId: parsed.HouseOwnerId,
MonarchId: parsed.MonarchId,
Restrictions: parsed.Restrictions);
/// Fires when the session finishes parsing a CreateObject.
public event Action? EntitySpawned;
///
/// Fires when the session parses a 0xF747 ObjectDelete game message.
/// Retail routes this through
/// CM_Physics::DispatchSB_DeleteObject 0x006AC6A0 →
/// SmartBox::HandleDeleteObject 0x00451EA0; ACE emits it when
/// an object leaves the world, including the living creature object
/// after its corpse is created.
///
public event Action? EntityDeleted;
///
/// Fires for retail PickupEvent (0xF74A). Pickup advances the object's
/// shared POSITION_TS and removes only its world projection; it is not a
/// DeleteObject and does not destroy the timestamp owner or weenie.
///
public event Action? EntityPickedUp;
///
/// 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,
ushort InstanceSequence,
ushort MovementSequence,
ushort ServerControlSequence,
bool IsAutonomous);
///
/// 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,
uint? PlacementId,
bool IsGrounded,
ushort InstanceSequence,
ushort PositionSequence,
ushort TeleportSequence,
ushort ForcePositionSequence);
///
/// 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 for retail ParentEvent (0xF749), which attaches a separate
/// child object to a creature holding location (weapons, shields, ammo).
///
public event Action? ParentUpdated;
///
/// Fires when the server broadcasts a SetState (0xF74B) game
/// message — a previously-spawned entity's PhysicsState
/// bitmask changed post-CreateObject. Chiefly doors flipping
/// ETHEREAL_PS = 0x4 on Use (see ACE
/// WorldObjects/Door.cs:127, WorldObject.cs:640-660).
/// Subscribers route the new state into
/// so the
/// existing collision-exemption short-circuit honors the flip on the
/// next resolver tick.
///
public event Action? StateUpdated;
///
/// Payload for : a single PropertyInt change on
/// a visible object (from PublicUpdatePropertyInt 0x02CE). Subscribers map the
/// property to typed state (e.g. UiEffects → the item's icon effect).
///
public readonly record struct ObjectIntPropertyUpdate(uint Guid, uint Property, int Value);
///
/// Fires when the session parses a PublicUpdatePropertyInt (0x02CE) — one
/// PropertyInt updated on a visible object. D.5.2 routes UiEffects (18) to the
/// item repository so the icon re-composites live.
///
public event Action? ObjectIntPropertyUpdated;
/// Payload for : a PropertyInt change on
/// the player's OWN object (from PrivateUpdatePropertyInt 0x02CD — no guid on the wire).
public readonly record struct PlayerIntPropertyUpdate(uint Property, int Value);
/// Fires when the session parses a PrivateUpdatePropertyInt (0x02CD) — one
/// PropertyInt updated on the player. B-Wire routes EncumbranceVal (5) to the burden bar.
public event Action? PlayerIntPropertyUpdated;
/// Payload for : a signed
/// 64-bit quality change on the player's own object. Retail sends Total XP
/// (1) and Available XP (2) through PrivateUpdatePropertyInt64 (0x02CF).
public readonly record struct PlayerInt64PropertyUpdate(uint Property, long Value);
/// Fires after parsing retail PrivateUpdatePropertyInt64 (0x02CF).
/// The wire carries no guid because the local player is implicit.
public event Action? PlayerInt64PropertyUpdated;
/// Payload for : SetStackSize (0x0197) — a stack's
/// count + value after a merge / split.
public readonly record struct StackSizeUpdate(uint Guid, int StackSize, int Value);
/// Fires when the session parses a SetStackSize (0x0197) top-level GameMessage.
public event Action? StackSizeUpdated;
/// Fires when the session parses an InventoryRemoveObject (0x0024) — the guid left
/// the player's inventory view.
public event Action? InventoryObjectRemoved;
///
/// 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;
///
/// Fires when the server broadcasts an ObjDescEvent (0xF625) —
/// a creature/player's appearance changed after the initial CreateObject
/// (equip / unequip / tailoring / recipe result / character option toggle).
/// Subscribers re-apply the new ModelData 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.
///
public event Action? AppearanceUpdated;
///
/// 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? PlayPhysicsScriptReceived;
/// Fires for retail typed PhysicsScript playback (0xF755).
public event Action? PlayPhysicsScriptTypeReceived;
///
/// Fires for retail's Sound event (0xF750) — the server-driven
/// sound channel: hits, wounds, wield/unwield, pickup/drop, lockpicking,
/// lifestone bind, spell resist, trap triggers, item mana depletion.
///
///
/// Retail's chain is CM_Physics::DispatchSB_SoundEvent @
/// 0x006AC760 → SmartBox::HandleSoundEvent @ 0x00451FC0
/// → CPhysicsObj::play_sound @ 0x0050F460. Two behaviours the
/// consumer owns, both from that decode: an event for a guid the client does
/// not know yet is QUEUED against that guid and replayed when the object
/// arrives (not dropped), and an object with no SoundTable plays nothing.
/// The wire volume is authoritative — unlike the animation-hook path, retail
/// ignores the SoundTable entry's own volume here.
///
///
public event Action? SoundEventReceived;
///
/// 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; }
/// Raised every time the state machine transitions.
public event Action? StateChanged;
///
/// Pre-world character-management replies. All are decoded on the same
/// caller thread and in the same fragment order as ordinary world events.
/// ACE routes these replies on UIQueue; the queue is consumed by the
/// transport before this typed boundary.
///
public event Action? CharacterListReceived;
public event Action? CharacterDeleteAcknowledged;
public event Action? CharacterRestoreReceived;
///
/// Campaign CC CC2: fires when a 0xF643
/// () response arrives while
/// this session's awaiting-request latch says Create — i.e. the
/// reply to . See
/// 's doc comment for the
/// opcode collision with and how
/// the two are disambiguated.
///
public event Action? CharacterCreateResponseReceived;
public event Action? CharacterErrorReceived;
///
/// Campaign LA gate round 2 finding 3: ACE sends this in the same batch
/// as (right after
/// AuthConnectResponse) — see .
///
public event Action? ServerNameReceived;
///
/// 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;
///
/// Network-owned source for retail
/// LinkStatusHolder::GetConnectionStatus @ 0x00411380. The age is
/// measured from the last successfully decoded server datagram using the
/// monotonic Stopwatch clock; presentation thresholds remain in the UI.
///
public LinkStatusSnapshot LinkStatus => BuildLinkStatus(
CurrentState,
Volatile.Read(ref _lastInboundPacketTicks),
Stopwatch.GetTimestamp(),
Stopwatch.Frequency,
PingRoundTripSeconds);
internal double? PingRoundTripSeconds
{
get
{
double value = BitConverter.Int64BitsToDouble(
Volatile.Read(ref _lastPingRoundTripBits));
return double.IsFinite(value) && value >= 0d ? value : null;
}
}
internal static LinkStatusSnapshot BuildLinkStatus(
State state,
long lastInboundPacketTicks,
long nowTicks,
long frequency,
double? roundTripSeconds = null,
double packetLossPercentage = 0d)
{
bool connected = state is not State.Disconnected and not State.Failed;
if (!connected || frequency <= 0)
return LinkStatusSnapshot.Disconnected;
long elapsed = Math.Max(0, nowTicks - lastInboundPacketTicks);
return new LinkStatusSnapshot(
true,
elapsed / (double)frequency,
packetLossPercentage,
roundTripSeconds);
}
private void RecordPingResponse(long nowTicks)
{
long requestTicks = Interlocked.Exchange(ref _lastPingRequestTicks, 0L);
if (requestTicks <= 0L || nowTicks < requestTicks || Stopwatch.Frequency <= 0)
return;
double elapsed = (nowTicks - requestTicks) / (double)Stopwatch.Frequency;
Volatile.Write(
ref _lastPingRoundTripBits,
BitConverter.DoubleToInt64Bits(elapsed));
}
/// Movement sequence counters for outbound MoveToState/AutonomousPosition.
public ushort InstanceSequence => _instanceSequence;
public ushort ServerControlSequence => _serverControlSequence;
public ushort TeleportSequence => _teleportSequence;
public ushort ForcePositionSequence => _forcePositionSequence;
///
/// Publishes the local player's canonical, freshness-accepted physics
/// timestamps for subsequent outbound movement messages. The App runtime
/// calls this only after PhysicsTimestampGate commits the matching
/// CreateObject/Movement/Position event; parsing alone never changes
/// outbound authority.
///
public void PublishAcceptedLocalPhysicsTimestamps(
ushort instance,
ushort serverControlledMove,
ushort teleport,
ushort forcePosition)
{
_instanceSequence = instance;
_serverControlSequence = serverControlledMove;
_teleportSequence = teleport;
_forcePositionSequence = forcePosition;
}
public CharacterList.Parsed? Characters { get; private set; }
///
/// Campaign LA gate round 2 finding 3: last
/// (opcode 0xF7E1) received, mirroring ' shape —
/// ACE sends it in the same batch, right after AuthConnectResponse.
///
public ServerName.Parsed? ServerInfo { get; private set; }
private CharacterError.Parsed? _lastCharacterSelectionError;
///
/// Campaign CC CC2: which outbound character-generation request (if any)
/// this session is awaiting a 0xF643
/// () reply to. Restore and
/// create requests share that opcode on the wire (see
/// 's doc comment) with no
/// self-describing discriminant, so this latch is the only thing that
/// tells the dispatcher which event to fire. Retail's own discriminator
/// is structurally the same latch: Handle_CharGenVerificationResponse
/// @0x0055E8B0 case 1 branches on
/// GetVerificationState() == PENDING → new CharacterIdentity +
/// AddIdentity (create) versus not-pending → unpack into the existing
/// identity at slot (restore). Set by
/// /
/// immediately before the send; cleared the moment a matching 0xF643 is
/// dispatched (success OR parse failure — a malformed reply must not
/// wedge the latch open forever) and on session teardown
/// ().
///
/// SCOPE, stated exactly (CC2 review F1): this latch correlates
/// the SINGLE outstanding request. It does NOT refuse overlapping
/// requests — a second send while one is outstanding OVERWRITES the
/// latch and the first request's reply is then delivered to the wrong
/// event. Refusing overlap is the CALLER's job, exactly as in retail:
/// gmCharGenMainUI::DoFinish@0x004e9170 only sends when the
/// verification state is UNDEF (CC3's Runtime verification gate owns
/// that rule here). The overwrite behavior is pinned by
/// WorldSessionCharacterCreationTests so CC3 cannot silently
/// regress against it.
///
/// Read/written only from the caller's frame thread — the same
/// single-threaded invariant every other per-session field here (e.g.
/// ) relies on;
/// is never invoked concurrently with a
/// send (see 's doc comment — the
/// #260 thread-id probe note; CC2 review F5 corrected this pointer).
///
private enum PendingCharGenVerificationRequest
{
None,
Restore,
Create,
}
private PendingCharGenVerificationRequest _pendingCharGenVerification =
PendingCharGenVerificationRequest.None;
///
/// One-shot guard so an unexpected 0xF643 (no outstanding create/restore
/// request) logs exactly once per session rather than spamming on a
/// misbehaving or replaying server.
///
private bool _loggedUnexpectedCharGenVerificationResponse;
private readonly IWorldSessionTransport _net;
private long _lastInboundPacketTicks = Stopwatch.GetTimestamp();
private long _lastPingRequestTicks;
private long _lastPingRoundTripBits = BitConverter.DoubleToInt64Bits(double.NaN);
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 _seenUnhandledOpcodes = new();
private ushort _sessionClientId;
private ushort _sessionIteration;
private bool _transportNegotiated;
///
/// N6: retail's ConnectResponse resend cadence — the x87 compare against
/// 0.333333333 in ClientNet::ProcessConnection @ 0x00545450
/// (case cs_ConnectionRequestAcked at 0x0054547B; the constant
/// load at 0x00545481). The mask-0x41 status test at 0x0054548C bails on
/// less-than OR equal, so the gate opens only STRICTLY past the
/// boundary — the same strict shape as the N4 NAK gate.
///
internal const double ConnectResponseRetrySeconds = 0.333333333;
///
/// N6: true once ANY checksum-valid post-negotiation server packet has
/// been decoded — the port of retail's connection confirmation:
/// ClientNet::ProcessPacket @ 0x00545100 promotes
/// cs_ConnectionRequestAcked → cs_Connected (the
/// SetConnectionState(..., 5) vtable call at 0x00545160) on the
/// first successfully processed packet whose header lacks the
/// ConnectRequest flag (the 0x40000 test at 0x0054514E), and the resend
/// case never fires again. While false, the Connect pump resends the
/// IDENTICAL cleartext ConnectResponse (same sequence 1, same cookie —
/// no new outbound state) every
/// .
///
private bool _handshakeConfirmed;
///
/// Campaign N Slices N1+N2: the reliable transport — both ISAAC
/// keystreams, packet/fragment sequences, sent-packet cache, resend on
/// NAK (outbound), and the sequence-aligned inbound tracker + NAK set
/// (inbound). Constructed at ISAAC-seeding time in ;
/// null before negotiation (neither keystream exists yet).
///
private ReliableTransport? _transport;
/// Test seam: transport counters + cache depth for the
/// conformance/loss suites. Null before negotiation.
internal ReliableTransport? Transport => _transport;
///
/// N3 test seam: injectable monotonic source for the transport clock so
/// the conformance suite can drive the 2.0 s cumulative-ack gate (and
/// the 0.5 s interval counter) on virtual time. Must be set BEFORE
/// (the transport is born there). Null →
/// production timing.
///
internal (Func GetTimestamp, long Frequency)? TransportClockSource
{ get; set; }
// 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
// accepted UpdatePosition/UpdateMotion packets. 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;
private uint _activeCharacterId;
private int _characterLogOffConfirmed;
private int _disposeStarted;
// Retail CNetLayerPacket stores ProtoHeader separately beside
// m_Data[65484] (named-retail acclient.h, type 3705). Keep one full UDP
// receive buffer at the socket edge; only the actual byte count crosses
// the queue boundary in a right-sized ArrayPool bucket.
internal const int MaxInboundDatagramBytes = ushort.MaxValue;
// Phase A.3 / Slice H-c1: one asynchronous socket owner buffers raw UDP
// datagrams into a FIFO channel so the render thread never blocks on I/O.
private readonly Channel _inboundQueue =
Channel.CreateUnbounded(
new UnboundedChannelOptions
{ SingleReader = true, SingleWriter = true });
private Task? _netReceiveTask;
private readonly CancellationTokenSource _netCancel = new();
internal readonly record struct PooledInboundDatagram(
byte[] Buffer,
int Length)
{
public ReadOnlyMemory Memory =>
Buffer.AsMemory(0, Length);
}
/// L.2g slice 1: one-shot guard so the [setstate-hex] probe
/// emits the first SetState's body bytes only, not 5–10/sec.
private bool _setStateHexDumped;
///
/// 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)
: this(
serverLogin,
// N5: ACDREAM_NET_DROP_PCT > 0 wraps the socket transport in the
// deterministic LossyTransportDecorator (the connected loss
// gate's injection point). At the default 0 the decorator is
// structurally absent — WrapIfConfigured returns the raw
// transport and never constructs the wrapper.
static endpoint => LossyTransportDecorator.WrapIfConfigured(
new NetClientWorldSessionTransport(endpoint)))
{
}
internal WorldSession(
IPEndPoint serverLogin,
IWorldSessionTransport transport)
: this(serverLogin, _ => transport)
{
}
internal WorldSession(
IPEndPoint serverLogin,
Func transportFactory)
{
ArgumentNullException.ThrowIfNull(serverLogin);
ArgumentNullException.ThrowIfNull(transportFactory);
if (serverLogin.Port == ushort.MaxValue)
throw new ArgumentOutOfRangeException(
nameof(serverLogin),
"The login endpoint must leave room for the adjacent connect port.");
_loginEndpoint = serverLogin;
_connectEndpoint = new IPEndPoint(serverLogin.Address, serverLogin.Port + 1);
_net = transportFactory(serverLogin)
?? throw new InvalidOperationException("The session transport factory returned null.");
// 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);
});
GameEvents.Register(GameEventType.PingResponse, e =>
{
if (Messages.GameEvents.ParsePingResponse(e.Payload.Span))
RecordPingResponse(Stopwatch.GetTimestamp());
});
}
///
/// 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
bool connectRequestReceived = false;
BorrowedOptionalHeader connectRequest = default;
ushort connectRequestIteration = 0;
while (DateTime.UtcNow < deadline
&& !connectRequestReceived)
{
PooledInboundDatagram? received =
ReceiveBlocking(deadline - DateTime.UtcNow);
if (received is null)
break;
PooledInboundDatagram datagram = received.Value;
try
{
// N2: pure parse + cleartext verify. No tracker exists
// before the ISAAC seeds do, and the ConnectRequest is a
// cleartext sequence-0 handshake packet; anything encrypted
// here is undecodable and skipped, matching the pre-N2
// null-keystream behavior.
bool parsedOk = PacketCodec.TryParseBorrowed(
datagram.Memory,
out BorrowedPacket parsed,
out uint headerHash,
out uint payloadHash,
out _);
PacketHeader parsedHeader = parsed.Header;
if (parsedOk
&& !parsedHeader.HasFlag(
PacketHeaderFlags.EncryptedChecksum)
&& PacketCodec.VerifyChecksum(
in parsedHeader,
headerHash,
payloadHash,
isaacKey: null)
&& parsedHeader.HasFlag(
PacketHeaderFlags.ConnectRequest))
{
connectRequest = parsed.Optional with
{
RawBytes = ReadOnlyMemory.Empty,
RetransmitRequestBytes =
ReadOnlyMemory.Empty,
RejectRetransmitBytes =
ReadOnlyMemory.Empty,
};
connectRequestIteration =
parsedHeader.Iteration;
connectRequestReceived = true;
}
}
finally
{
ReturnInboundDatagram(datagram);
}
}
if (!connectRequestReceived)
{
Transition(State.Failed);
throw new TimeoutException(
"ConnectRequest not received");
}
// Step 3: seed ISAAC, send ConnectResponse to port+1, with 200ms race delay
BorrowedOptionalHeader opt = connectRequest;
// 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.
byte[] serverSeedBytes = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(serverSeedBytes, opt.ConnectRequestServerSeed);
byte[] clientSeedBytes = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(clientSeedBytes, opt.ConnectRequestClientSeed);
_sessionClientId = (ushort)opt.ConnectRequestClientId;
// SharedNet::SendOptionalHeader @ 0x00543160 copies this ReceiverData
// generation into connection-level control packets, including the
// final disconnect. ACE currently emits iteration 1.
_sessionIteration = connectRequestIteration;
// N1+N2+N3+N4: the reliable transport is born at ISAAC-seeding
// time, owning BOTH keystreams and the ack/NAK sweep. Outbound:
// highestIDSent starts 1 (the ConnectResponse below carries
// sequence 1), so the first reliable packet after the handshake
// keeps packet sequence 2 and fragment sequence 1. Inbound: the
// tracker owns the server keystream, the received watermark, and
// the NAK set (campaign §2.2); its watermark starts 1 (see
// InboundSequenceTracker.AceInitialWatermark). The scheduler's
// shared ack/NAK gate arms here, at connection birth
// (ReceiverData::SharedInit @ 0x00548EF0, reached from
// ReceiverData::Init @ 0x00548FA0, stamps timeStamp_ = cur_time).
_transport = new ReliableTransport(
new IsaacRandom(clientSeedBytes),
new IsaacRandom(serverSeedBytes),
_sessionClientId,
_sessionIteration,
datagram => _net.Send(datagram),
clock: TransportClockSource is { } clockSource
? new TransportClock(
clockSource.GetTimestamp,
clockSource.Frequency)
: null,
// N6: the sweep ages out abandoned fragment partials (5 s
// cadence, 60 s TTL — FragmentAssembler doc + AD-52).
assembler: _assembler);
_transportNegotiated = true;
// Publish only after the receiver identity and crypto state are fully
// committed. A synchronous App callback may throw or request teardown;
// disposal must still send the negotiated Disconnect.
LastServerTimeTicks = opt.ConnectRequestServerTime;
ServerTimeUpdated?.Invoke(opt.ConnectRequestServerTime);
byte[] crBody = new byte[8];
BinaryPrimitives.WriteUInt64LittleEndian(crBody, opt.ConnectRequestCookie);
var crHeader = new PacketHeader { Sequence = 1, Flags = PacketHeaderFlags.ConnectResponse, Id = 0 };
byte[] connectResponseDatagram = PacketCodec.Encode(crHeader, crBody, null);
Thread.Sleep(200);
_net.Send(_connectEndpoint, connectResponseDatagram);
// N6: arm the handshake resend clock. Retail stamps
// lastSentHandshake_ inside every ClientNet::SendConnectAck
// (@ 0x005440F0, the store at 0x00544102) and rebuilds the same
// ConnectResponse from the stored cookie each time; we keep the one
// encoded datagram and resend it verbatim — identical cleartext,
// sequence 1, no new state consumed. The cadence rides the
// transport clock so the conformance suite can drive it on virtual
// time; the Connect deadline stays wall-clock.
TransportClock handshakeClock = _transport.Clock;
long handshakeRetryTicks = (long)Math.Round(
ConnectResponseRetrySeconds * handshakeClock.Frequency);
long handshakeSentTimestamp = handshakeClock.GetTimestamp();
Transition(State.InCharacterSelect);
// Step 4: drain until CharacterList arrives. The transport sweep
// runs inside this blocking pump too (campaign landmine #8): the
// first server NAK can precede the first Tick(). This pump is also
// retail's cs_ConnectionRequestAcked resend window
// (ClientNet::ProcessConnection @ 0x00545450 case 0 at 0x0054547B):
// until the first decoded server packet confirms the connection, a
// lost ConnectResponse is re-sent every 0.333 s — without it, one
// dropped handshake datagram is a hang to the Connect deadline
// (the N5 loss decorator deliberately arms AFTER this window).
while (DateTime.UtcNow < deadline && Characters is null)
{
if (!_handshakeConfirmed
&& handshakeClock.GetTimestamp() - handshakeSentTimestamp
> handshakeRetryTicks)
{
_net.Send(_connectEndpoint, connectResponseDatagram);
handshakeSentTimestamp = handshakeClock.GetTimestamp();
}
PumpOnce();
SweepTransport();
}
if (Characters is null) { Transition(State.Failed); throw new TimeoutException("CharacterList not received"); }
}
///
/// Starts the sole asynchronous receive loop while the session remains at
/// character selection. Graphical hosts call this only when they actually
/// pause before ; immediate and headless entry keep
/// the original blocking handshake pump until ServerReady is accepted.
///
public void StartCharacterSelectionReceive()
{
if (CurrentState != State.InCharacterSelect)
throw new InvalidOperationException(
"character-selection receive requires InCharacterSelect state");
EnsureNetReceiveLoopStarted();
}
///
/// Send CharacterEnterWorldRequest and CharacterEnterWorld for
/// [].
/// Returns once the server starts sending CreateObjects (at which point
/// callers should poll to stream events).
///
public void EnterWorld(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");
EnterWorldSelection selection = SelectCharacterForEnterWorld(
Characters,
characterIndex);
EnterWorldCore(selection.Character.Id, selection.EnterWorldBody, timeout);
}
///
/// Send CharacterEnterWorldRequest and CharacterEnterWorld for the exact
/// (guid, accountName) identity the caller supplies, bypassing the
/// cached roster entirely. Campaign CC slice
/// CC3 review-fix round (F1): the index-based overload above assumes
/// refers to a slot in
/// — true for ordinary character-select entry,
/// but FALSE immediately after a character create. ACE never resends
/// post-create (it only appends server-side
/// and replies with the 0xF643 Ok identity —
/// references/ACE/Source/ACE.Server/Network/Handlers/CharacterHandler.cs:170-172),
/// so entering the newly created character by a re-derived index can
/// throw (zero pre-existing characters) or silently enter the WRONG
/// character (N pre-existing characters, since the caller's display
/// order need not match the wire order). Retail's own
/// CPlayerSystem::LogOnCharacter(gid) is itself guid-based, so
/// this is a more direct port of the same entry point — not a
/// deviation from retail — for the one caller (enter-straight-in after
/// create) that has an exact identity in hand and no reliable index.
///
///
/// Retail's own fallback when the freshly created name never appears in
/// its per-frame roster poll (gmCharGenMainUI::Update @
/// 0x004E8460) bounces the UI back to character management
/// (QueueUIMode(0x1000000a) @ 0x004E85D7). acdream has no
/// analogous fallback here because this entry point is driven directly
/// by the identity carried on the SAME reply that confirms the create
/// succeeded — there is no polling step that could fail to find the
/// name, so there is nothing for a fallback to catch.
///
///
public void EnterWorld(uint characterGuid, string accountName, TimeSpan? timeout = null)
{
ArgumentNullException.ThrowIfNull(accountName);
byte[] enterWorldBody = CharacterEnterWorld.BuildEnterWorldBody(characterGuid, accountName);
EnterWorldCore(characterGuid, enterWorldBody, timeout);
}
private void EnterWorldCore(uint characterGuid, byte[] enterWorldBody, TimeSpan? timeout)
{
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(10));
_activeCharacterId = characterGuid;
Transition(State.EnteringWorld);
SendGameMessage(CharacterEnterWorld.BuildEnterWorldRequestBody());
// Wait for CharacterEnterWorldServerReady (0xF7DF). Sweep inside
// the blocking pump (campaign landmine #8): the EnterWorld
// CreateObject flood — and any NAK it provokes — precedes the
// first Tick().
_lastCharacterSelectionError = null;
bool serverReady;
if (_netReceiveTask is null)
{
// Immediate/headless entry deliberately preserves the blocking
// transport pump. Besides matching the established handshake
// contract, Receive supplies the clock edge used by the reliable
// transport's resend/NAK sweep on otherwise quiet connections.
serverReady = false;
while (DateTime.UtcNow < deadline
&& !serverReady
&& _lastCharacterSelectionError is null)
{
bool drained = PumpOnce(out List opcodes);
SweepTransport();
if (!drained)
continue;
foreach (uint opcode in opcodes)
{
if (opcode == 0xF7DFu)
{
serverReady = true;
break;
}
}
}
}
else
{
TimeSpan remaining = deadline - DateTime.UtcNow;
serverReady = remaining > TimeSpan.Zero
&& WaitForCharacterLogOffConfirmation(
_inboundQueue.Reader,
remaining,
datagram =>
{
var opcodes = new List();
ProcessDatagram(datagram.Memory, opcodes);
return opcodes.Contains(0xF7DFu)
|| _lastCharacterSelectionError is not null;
},
ReturnInboundDatagram,
SweepTransport,
TimeSpan.FromMilliseconds(25));
}
if (_lastCharacterSelectionError is { } selectionError)
{
Transition(State.InCharacterSelect);
EnsureNetReceiveLoopStarted();
throw new CharacterSelectionRejectedException(selectionError);
}
if (!serverReady) { Transition(State.Failed); throw new TimeoutException("ServerReady not received"); }
// CPlayerSystem::LogOnCharacter @ 0x0055F890 passes the account
// populated by CharacterSet::UnPack, not the spelling supplied to the
// login form. ACE validates this canonical account value.
SendGameMessage(enterWorldBody);
// LoginComplete is emitted by the host only after the accepted local
// Create has completed its canonical first placement. Sending it at
// EnterWorld or merely on PlayerCreate races the server's intentional
// Hidden/pink-bubble login state.
Transition(State.InWorld);
// A paused selector already owns the socket through the background
// receiver. Immediate/headless entry starts that same sole receiver
// only after its blocking ServerReady handshake has completed.
EnsureNetReceiveLoopStarted();
}
private void EnsureNetReceiveLoopStarted() =>
_netReceiveTask ??= NetReceiveLoopAsync();
internal readonly record struct EnterWorldSelection(
CharacterList.Character Character,
byte[] EnterWorldBody);
///
/// Applies retail's selected-active-character gate and binds the F657
/// account field to the canonical CharacterSet account. Kept pure so the
/// public session invariant is conformance-tested without opening a socket.
///
internal static EnterWorldSelection SelectCharacterForEnterWorld(
CharacterList.Parsed characters,
int characterIndex)
{
ArgumentNullException.ThrowIfNull(characters);
if (characterIndex < 0 || characterIndex >= characters.Characters.Count)
throw new ArgumentOutOfRangeException(nameof(characterIndex));
CharacterList.Character chosen = characters.Characters[characterIndex];
if (!CharacterList.IsAvailableActiveIdentity(chosen))
throw new InvalidOperationException(
"selected character must be an active, non-greyed identity");
return new EnterWorldSelection(
chosen,
CharacterEnterWorld.BuildEnterWorldBody(
chosen.Id,
characters.AccountName));
}
// Per-frame inbound time budget (#2 flood timeslice). On a teleport arrival ACE floods
// a town's CreateObjects; draining them ALL in one Tick (each hydrates mesh + textures
// under _datLock on the render thread) monopolized the update loop for ~a minute and
// starved the streaming apply, so neighbor landblocks trickled in. A wall-clock budget
// self-adapts to the highly variable per-datagram cost (cheap UpdatePosition vs an
// expensive clothed CreateObject); the tail drains over the next few frames, FIFO
// preserved, nothing lost (the inbox channel is unbounded). ~4ms leaves the rest of a
// 60fps update frame for streaming + physics.
private static readonly long InboundBudgetTicks = Stopwatch.Frequency / 1000 * 4;
///
/// Non-blocking pump. Drains datagrams buffered by the background net thread (Phase A.3),
/// decodes them, and fires events. Call once per game-loop frame. Once in-world the drain
/// is bounded to ~4ms so a CreateObject flood can't monopolize the frame. Returns the
/// number of datagrams processed.
///
public int Tick()
{
int processed = 0;
bool budgetBroke = false;
long start = Stopwatch.GetTimestamp();
while (_inboundQueue.Reader.TryRead(
out PooledInboundDatagram datagram))
{
if (NetDiagnostics.ProbeNet)
Interlocked.Decrement(ref _probeInboundDepth);
try
{
ProcessDatagram(datagram.Memory);
}
finally
{
ReturnInboundDatagram(datagram);
}
processed++;
// Bound ONLY in-world: immediate/headless handshakes use blocking
// PumpOnce, while a deliberately paused selector uses Tick without
// an in-world flood budget so management replies drain promptly.
// Acks and NAKs are NOT per-packet: the end-of-Tick sweep below emits them on
// the scheduler's 2.0 s / 0.6 s gates, and it runs after the budget break, so a
// deferred inbound tail never defers a due ack, NAK, or resend. The tail itself
// stays queued (unbounded channel, FIFO) and drains next frame — its only cost
// is that the cumulative ack keeps carrying the pre-tail watermark until the
// tail is processed, well inside ACE's 120 s cache retention.
if (InboundBudgetExceeded(CurrentState, start, Stopwatch.GetTimestamp(), InboundBudgetTicks))
{
budgetBroke = true;
break;
}
}
if (NetDiagnostics.ProbeNet)
ProbeNetTickCadence(start, processed, budgetBroke);
// N1: the transport sweep runs at the end of EVERY Tick, after the
// budget break — a deferred inbound tail must not defer a due
// resend past this frame.
SweepTransport();
return processed;
}
///
/// N1+N3+N4: one reliable-transport pump slice (retail
/// PacketController::UseTime @ 0x005410D0 shape): interval clock
/// forward, NAK-xor-ack arbitration, pending NAKed resends out, acked
/// cache pruned. Gated on negotiation — ACE's
/// Session.CheckState silently discards pre-negotiation control
/// traffic (campaign landmine #8), and the transport does not exist
/// before the ISAAC seeds do.
///
private void SweepTransport()
{
if (!_transportNegotiated)
return;
_transport?.Sweep();
}
// #260 probe state — only touched when NetDiagnostics.ProbeNet is set.
// The inter-Tick gap doubles as a frame-stall witness: Tick runs once per
// frame on the frame thread, so a GC pause or saturated frame shows up
// directly as maxgap. _probeSendWindow is Interlocked so a hypothetical
// off-thread send can't corrupt the window counter (the [net-out] tid
// field is what would prove such a send exists). Acks are counted by the
// transport (Stats.AcksSent, incremented on the frame-thread sweep);
// _probeAckSeenTotal is the last cumulative value the probe printed.
private long _probeLastTickTs;
private long _probeWindowStartTs;
private long _probeMaxGapTicks;
private int _probeProcessedWindow;
private int _probeBudgetBreaks;
private int _probeSendWindow;
private long _probeAckSeenTotal;
// N5 loss-observability window baselines — the cumulative TransportStats
// value each counter had when the probe last printed, mirroring
// _probeAckSeenTotal. Only touched when NetDiagnostics.ProbeNet is set.
private long _probeResendSeenTotal;
private long _probeNakOutSeenTotal;
private long _probeNakInSeenTotal;
private long _probeRejInSeenTotal;
private long _probeDupDropSeenTotal;
private long _probeParkedSeenTotal;
private long _probeReclaimSeenTotal;
// Probe-owned queue depth: the SingleReader channel's Reader.Count
// throws NotSupportedException, so the net thread increments on
// enqueue and the frame thread decrements on dequeue instead.
private int _probeInboundDepth;
///
/// #260 probe: accumulate per-Tick cadence facts and emit one
/// [net-tick] summary line per second.
///
private void ProbeNetTickCadence(long tickStartTs, int processed, bool budgetBroke)
{
if (_probeLastTickTs != 0)
{
long gap = tickStartTs - _probeLastTickTs;
if (gap > _probeMaxGapTicks)
_probeMaxGapTicks = gap;
}
_probeLastTickTs = tickStartTs;
_probeProcessedWindow += processed;
if (budgetBroke)
_probeBudgetBreaks++;
if (_probeWindowStartTs == 0)
{
_probeWindowStartTs = tickStartTs;
return;
}
long windowTicks = tickStartTs - _probeWindowStartTs;
if (windowTicks < Stopwatch.Frequency)
return;
double windowSeconds = (double)windowTicks / Stopwatch.Frequency;
double maxGapMs = _probeMaxGapTicks * 1000.0 / Stopwatch.Frequency;
int sends = Interlocked.Exchange(ref _probeSendWindow, 0);
ReliableTransport? transport = _transport;
TransportStats? stats = transport?.Stats;
long acks = WindowDelta(stats?.AcksSent ?? 0, ref _probeAckSeenTotal);
// N5: reliable-transport window deltas (same cumulative-delta shape
// as acks/s) + the two instantaneous depths. The counters increment
// unconditionally in TransportStats; only this string work is
// probe-gated.
long resends = WindowDelta(
stats?.ResendsSent ?? 0, ref _probeResendSeenTotal);
long naksOut = WindowDelta(
stats?.NaksSent ?? 0, ref _probeNakOutSeenTotal);
long naksIn = WindowDelta(
stats?.NakRequestsReceived ?? 0, ref _probeNakInSeenTotal);
long rejsIn = WindowDelta(
stats?.RejectsReceived ?? 0, ref _probeRejInSeenTotal);
long dupDrops = WindowDelta(
stats?.InboundDupsDropped ?? 0, ref _probeDupDropSeenTotal);
long parked = WindowDelta(
stats?.KeysParked ?? 0, ref _probeParkedSeenTotal);
long reclaimed = WindowDelta(
stats?.RejectWordsReclaimed ?? 0, ref _probeReclaimSeenTotal);
Console.WriteLine(FormatNetTickLine(
windowSeconds,
_probeProcessedWindow,
Volatile.Read(ref _probeInboundDepth),
_probeBudgetBreaks,
maxGapMs,
sends,
acks,
resends,
naksOut,
naksIn,
rejsIn,
dupDrops,
parked,
reclaimed,
stats?.CacheDepth ?? 0,
transport?.Inbound.NakCount ?? 0,
CurrentState));
_probeWindowStartTs = tickStartTs;
_probeMaxGapTicks = 0;
_probeProcessedWindow = 0;
_probeBudgetBreaks = 0;
}
private static long WindowDelta(long cumulative, ref long seenTotal)
{
long delta = cumulative - seenTotal;
seenTotal = cumulative;
return delta;
}
///
/// The [net-tick] line shape, extracted so the N5 field extension
/// is string-assertable without a wall-clock window. cache and
/// nakset are instantaneous depths (the sent-packet cache is the
/// unbounded-like-retail watchdog value — campaign §4); every other
/// transport field is a per-second rate over the probe window.
///
internal static string FormatNetTickLine(
double windowSeconds,
int processed,
int queueDepth,
int budgetBreaks,
double maxGapMs,
int sends,
long acks,
long resends,
long naksOut,
long naksIn,
long rejsIn,
long dupDrops,
long parked,
long reclaimed,
int cacheDepth,
int nakSetDepth,
State state) =>
$"[net-tick] in/s={processed / windowSeconds:F0}"
+ $" q={queueDepth}"
+ $" budget-breaks={budgetBreaks}"
+ $" maxgap={maxGapMs:F0}ms"
+ $" out/s={sends / windowSeconds:F0}"
+ $" acks/s={acks / windowSeconds:F0}"
+ $" resend/s={resends / windowSeconds:F0}"
+ $" nak-out/s={naksOut / windowSeconds:F0}"
+ $" nak-in/s={naksIn / windowSeconds:F0}"
+ $" rej-in/s={rejsIn / windowSeconds:F0}"
+ $" dup-drop/s={dupDrops / windowSeconds:F0}"
+ $" parked/s={parked / windowSeconds:F0}"
+ $" reclaim/s={reclaimed / windowSeconds:F0}"
+ $" cache={cacheDepth}"
+ $" nakset={nakSetDepth}"
+ $" st={state}";
///
/// Pure, testable decision for the per-frame inbound bound: stop draining only when
/// in-world AND the elapsed Stopwatch ticks have reached the budget. Extracted so the
/// gate logic is unit-tested without the ISAAC/decode/channel machinery.
///
internal static bool InboundBudgetExceeded(State state, long startTicks, long nowTicks, long budgetTicks)
=> state == State.InWorld && nowTicks - startTicks >= budgetTicks;
///
/// Phase A.3 / Slice H-c1: asynchronous receive loop. It owns exactly
/// one outstanding socket receive from the end of
/// until cancellation, then writes pooled raw
/// datagrams 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.
///
///
/// Cancellation is wired directly into the socket receive. Idle
/// sessions therefore create neither timeout exceptions nor polling
/// wakeups. On shutdown, cancels and joins the task.
///
///
///
/// 2026-07-24 audit fix: a per-iteration
/// (anything other than the expected ,
/// which already swallows) used to be
/// caught by an OUTER try/catch that wrapped the entire while loop,
/// so a single transient error — classically Windows' WSAECONNRESET,
/// delivered on this socket's next Receive after an earlier Send hit
/// an ICMP port-unreachable — exited the loop for good. The
/// finally then completed 's writer,
/// permanently killing inbound processing for the rest of the session
/// with no log line. Non-timeout socket errors are recoverable,
/// per-datagram events, not session-fatal, so they're now caught
/// per-iteration, logged, and the loop keeps polling.
///
///
private async Task NetReceiveLoopAsync()
{
byte[] receiveBuffer = ArrayPool.Shared.Rent(
MaxInboundDatagramBytes);
try
{
while (!_netCancel.Token.IsCancellationRequested)
{
byte[]? queuedBuffer = null;
bool ownershipTransferred = false;
try
{
NetReceiveResult result =
await _net.ReceiveAsync(
receiveBuffer.AsMemory(
0,
MaxInboundDatagramBytes),
_netCancel.Token).ConfigureAwait(false);
queuedBuffer = ArrayPool.Shared.Rent(
result.Length);
receiveBuffer.AsSpan(0, result.Length).CopyTo(
queuedBuffer);
ownershipTransferred =
_inboundQueue.Writer.TryWrite(
new PooledInboundDatagram(
queuedBuffer,
result.Length));
if (ownershipTransferred && NetDiagnostics.ProbeNet)
Interlocked.Increment(ref _probeInboundDepth);
}
catch (System.Net.Sockets.SocketException ex)
{
// Transient, recoverable socket error (e.g. WSAECONNRESET
// from a stale peer's ICMP port-unreachable) — log and
// keep receiving. Does NOT tear down _inboundQueue.
Console.Error.WriteLine(
$"[net] receive error (continuing): {ex.SocketErrorCode} {ex.Message}");
}
finally
{
if (!ownershipTransferred
&& queuedBuffer is not null)
{
ArrayPool.Shared.Return(queuedBuffer);
}
}
}
}
catch (OperationCanceledException) { /* graceful shutdown */ }
catch (ObjectDisposedException) { /* NetClient disposed before thread noticed */ }
finally
{
ArrayPool.Shared.Return(receiveBuffer);
_inboundQueue.Writer.TryComplete();
}
}
private PooledInboundDatagram? ReceiveBlocking(TimeSpan timeout)
{
byte[] buffer = ArrayPool.Shared.Rent(
MaxInboundDatagramBytes);
try
{
int length = _net.Receive(
buffer.AsSpan(0, MaxInboundDatagramBytes),
timeout,
out _);
if (length < 0)
{
ArrayPool.Shared.Return(buffer);
return null;
}
return new PooledInboundDatagram(buffer, length);
}
catch
{
ArrayPool.Shared.Return(buffer);
throw;
}
}
private static void ReturnInboundDatagram(
PooledInboundDatagram datagram) =>
ArrayPool.Shared.Return(datagram.Buffer);
///
/// 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();
PooledInboundDatagram? received =
ReceiveBlocking(TimeSpan.FromMilliseconds(250));
if (received is null)
return false;
PooledInboundDatagram datagram = received.Value;
try
{
ProcessDatagram(
datagram.Memory,
opcodesThisCall);
return true;
}
finally
{
ReturnInboundDatagram(datagram);
}
}
private void ProcessDatagram(
ReadOnlyMemory bytes,
List? opcodesOut = null,
bool dispatchWorldEvents = true)
{
if (!PacketCodec.TryParseBorrowed(
bytes,
out BorrowedPacket packet,
out uint headerHash,
out uint payloadHash,
out _))
{
return;
}
PacketHeader serverHeader = packet.Header;
bool encrypted = serverHeader.HasFlag(
PacketHeaderFlags.EncryptedChecksum);
// N2: retail's inbound admission split (SharedNet::ProcessPacket
// @ 0x00544790 → ProcessNewSeqNum @ 0x00544690). Sequence-0 packets
// bypass the tracker entirely: cleartext seq-0 is handshake/control
// (verified additively, processed as before); encrypted seq-0 does
// not exist on the wire and drops before any keystream is touched.
if (serverHeader.Sequence == 0)
{
if (encrypted
|| !PacketCodec.VerifyChecksum(
in serverHeader,
headerHash,
payloadHash,
isaacKey: null))
{
return;
}
}
else if (_transport is { } inboundTransport)
{
InboundSequenceTracker.Admission admission =
inboundTransport.Inbound.Admit(
serverHeader.Sequence,
encrypted);
if (admission.Drop)
return;
if (!PacketCodec.VerifyChecksum(
in serverHeader,
headerHash,
payloadHash,
admission.VerifyKey))
{
inboundTransport.Stats.ChecksumFailures++;
// Verify failure on a sequenced encrypted packet re-parks
// the consumed key so the retransmission decodes
// (ProcessPacket @ 0x00544790 tail — campaign §2.2 step 5).
if (encrypted)
{
inboundTransport.Inbound.ReparkKey(
serverHeader.Sequence,
admission.VerifyKey!.Value,
admission.VerifyKeyDrawOrder);
}
return;
}
}
else
{
// Sequenced traffic before negotiation — the pre-N2 behavior of
// a null inbound keystream: encrypted cannot verify; cleartext
// verifies additively.
if (encrypted
|| !PacketCodec.VerifyChecksum(
in serverHeader,
headerHash,
payloadHash,
isaacKey: null))
{
return;
}
}
// Retail LinkStatusHolder::OnHeartbeat @ 0x004113D0 updates its
// last-heard clock only for valid server traffic. Record at checksum
// acceptance, before any heavy render-thread message handling.
Volatile.Write(ref _lastInboundPacketTicks, Stopwatch.GetTimestamp());
// N6: the first checksum-valid post-negotiation packet confirms the
// server accepted our ConnectResponse and stops the handshake
// resend — retail's cs_ConnectionRequestAcked → cs_Connected edge
// (ClientNet::ProcessPacket @ 0x00545100: the 0x40000 ConnectRequest
// exclusion at 0x0054514E, SetConnectionState(..., 5) at
// 0x00545160). Frame-thread only, like every reader.
if (!_handshakeConfirmed
&& _transportNegotiated
&& !serverHeader.HasFlag(PacketHeaderFlags.ConnectRequest))
{
_handshakeConfirmed = true;
}
// N1: consume the transport control surfaces. Acknowledging the
// OTHER direction is not done here: N3 deleted the Phase 4.9
// per-packet reflex ack — retail never acks per packet
// (SharedNet::EnqueuePak @ 0x00543B10 is the binary's only 0x4000
// construction site). The AckNakScheduler emits ONE cumulative
// AckSequence per 2.0 s from the SweepTransport pump instead.
if (_transport is { } transport)
{
// Server NAK (RequestRetransmit 0x1000): merge the requested
// ids into the pending-resend list; ids[0] doubles as retail's
// implicit cumulative ack (RecipientData::ProcessNaks
// @ 0x00547010). The resends go out on the next sweep.
if ((serverHeader.Flags & PacketHeaderFlags.RequestRetransmit) != 0
&& packet.Optional.RetransmitRequestCount > 0)
{
transport.Outbound.OnRetransmitRequest(
packet.Optional.RetransmitRequestBytes.Span,
packet.Optional.RetransmitRequestCount);
}
// N2: inbound RejectRetransmit (0x2000) — the server abandoned
// the ids in the BODY; drop them from the NAK set, discarding
// the parked keys (SharedNet::HandleEmptyAck @ 0x005448F0).
// Alignment holds for those ids: they were real encrypted
// packets, so their words were drawn on both sides and are
// consumed-in-place.
if ((serverHeader.Flags & PacketHeaderFlags.RejectRetransmit) != 0)
{
transport.Stats.RejectsReceived++;
if (packet.Optional.RejectRetransmitCount > 0)
{
transport.Inbound.OnRejectRetransmit(
packet.Optional.RejectRetransmitBytes.Span,
packet.Optional.RejectRetransmitCount);
}
// N4/AD-51 — the reject packet's OWN sequence is the
// opposite case: ACE consumed it fresh, cleartext, with NO
// keystream word (FlushPackets, NetworkSession.cs:722-725,
// :743-748), so the word our gap walk parked for it was
// never drawn server-side. Reclaim it (checksum already
// verified above — the trigger fires only on a VALIDATED
// packet). Retail never reaches this: its cleartext packets
// always borrow live sequences.
if (serverHeader.Sequence != 0 && !encrypted)
{
transport.Inbound.OnCleartextRejectSequence(
serverHeader.Sequence);
}
}
// Cumulative ack (AckSequence 0x4000): wrap-safe max into the
// watermark; the cache prunes strictly below it on the sweep.
if ((serverHeader.Flags & PacketHeaderFlags.AckSequence) != 0)
transport.Outbound.OnAckSequence(packet.Optional.AckSequence);
}
// 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 = packet.Optional.TimeSync;
if (t > 0)
{
LastServerTimeTicks = t;
ServerTimeUpdated?.Invoke(t);
}
}
foreach (BorrowedMessageFragment frag
in packet.Fragments)
{
if (!_assembler.TryIngest(
frag,
out ReadOnlyMemory bodyMemory,
out _)
|| bodyMemory.Length < 4)
{
continue;
}
ReadOnlySpan body = bodyMemory.Span;
uint op = BinaryPrimitives.ReadUInt32LittleEndian(
body);
opcodesOut?.Add(op);
// Retail waits for the server's opcode-only CharacterLogOff echo
// before tearing down the world connection. Record it even while
// Dispose is draining the raw receive queue without dispatching
// world callbacks to owners that are already shutting down.
if (CharacterLogOff.IsConfirmation(body))
{
Interlocked.Exchange(ref _characterLogOffConfirmed, 1);
continue;
}
if (!dispatchWorldEvents)
continue;
if (op == CharacterList.Opcode)
{
CharacterList.Parsed parsed;
try
{
parsed = CharacterList.Parse(body);
}
catch
{
// Malformed management messages do not poison the
// remaining ordered UIQueue fragments.
continue;
}
Characters = parsed;
CharacterListReceived?.Invoke(parsed);
}
else if (op == ServerName.Opcode)
{
ServerName.Parsed parsed;
try
{
parsed = ServerName.Parse(body);
}
catch
{
// Malformed management messages do not poison the
// remaining ordered UIQueue fragments.
continue;
}
ServerInfo = parsed;
ServerNameReceived?.Invoke(parsed);
}
else if (op == CharacterDelete.Opcode
&& CharacterDelete.IsAcknowledgement(body))
{
CharacterDeleteAcknowledged?.Invoke();
}
else if (op == CharGenVerificationResponse.ResponseOpcode)
{
// Campaign CC CC2: this opcode is a genuine retail reuse
// between CharacterRestore and CharacterCreate responses
// (see CharGenVerificationResponse's doc comment) — the
// awaiting-request latch is the only thing that tells us
// which family a given 0xF643 belongs to. Clear it before
// parsing (not after) so a malformed reply can never leave
// the latch stuck open, awaiting a response that will now
// never come and misattributing whatever arrives next.
PendingCharGenVerificationRequest awaited = _pendingCharGenVerification;
if (awaited == PendingCharGenVerificationRequest.None)
{
if (!_loggedUnexpectedCharGenVerificationResponse)
{
_loggedUnexpectedCharGenVerificationResponse = true;
Console.Error.WriteLine(
"[session] unexpected CharacterGenerationVerificationResponse "
+ "(0xF643) with no outstanding create/restore request — dropped.");
}
continue;
}
_pendingCharGenVerification = PendingCharGenVerificationRequest.None;
if (awaited == PendingCharGenVerificationRequest.Restore)
{
CharacterRestore.Parsed parsed;
try
{
parsed = CharacterRestore.Parse(body);
}
catch
{
continue;
}
CharacterRestoreReceived?.Invoke(parsed);
}
else
{
CharGenVerificationResponse.Parsed parsed;
try
{
parsed = CharGenVerificationResponse.Parse(body);
}
catch
{
continue;
}
CharacterCreateResponseReceived?.Invoke(parsed);
}
}
else if (op == CharacterError.Opcode)
{
CharacterError.Parsed parsed;
try
{
parsed = CharacterError.Parse(body);
}
catch
{
continue;
}
// CharacterError::NumErrors is the enum-count sentinel, not
// a server rejection. Retail never presents it, and treating
// it as an EnterWorld failure would abort either handshake
// pump before a valid ServerReady later in the same packet.
if (parsed.AsCode == CharacterError.Code.NumErrors)
continue;
_lastCharacterSelectionError = parsed;
CharacterErrorReceived?.Invoke(parsed);
}
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 == CreateObject.Opcode)
{
var parsed = CreateObject.TryParse(body);
if (parsed is not null)
{
EntitySpawned?.Invoke(ToEntitySpawn(parsed.Value));
}
}
else if (op == DeleteObject.Opcode)
{
var parsed = DeleteObject.TryParse(body);
if (parsed is not null)
EntityDeleted?.Invoke(parsed.Value);
}
else if (op == PickupEvent.Opcode)
{
// Pickup has its own POSITION_TS gate and retains the logical
// object; do not collapse it into DeleteObject.
var parsed = PickupEvent.TryParse(body);
if (parsed is not null)
EntityPickedUp?.Invoke(parsed.Value);
}
else if (op == ParentEvent.Opcode)
{
var parsed = ParentEvent.TryParse(body);
if (parsed is not null)
ParentUpdated?.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,
motion.Value.InstanceSequence,
motion.Value.MovementSequence,
motion.Value.ServerControlSequence,
motion.Value.IsAutonomous));
}
}
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)
{
PositionUpdated?.Invoke(new EntityPositionUpdate(
posUpdate.Value.Guid,
posUpdate.Value.Position,
posUpdate.Value.Velocity,
posUpdate.Value.PlacementId,
posUpdate.Value.IsGrounded,
posUpdate.Value.InstanceSequence,
posUpdate.Value.PositionSequence,
posUpdate.Value.TeleportSequence,
posUpdate.Value.ForcePositionSequence));
}
}
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 == SetState.Opcode)
{
// L.2g slice 1 (2026-05-12): server broadcasts SetState
// (0xF74B) when an entity's PhysicsState changes
// post-spawn — chiefly doors flipping ETHEREAL on Use.
// Holtburger validated wire format = 16 bytes (opcode +
// guid + state + 2×u16 sequence). One-shot probe-gated
// hex-dump (ACDREAM_PROBE_BUILDING) captures the wire
// bytes for confidence before declaring slice 1 done.
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled
&& !_setStateHexDumped)
{
_setStateHexDumped = true;
var hex = string.Join(" ", body
.Slice(0, Math.Min(body.Length, 32))
.ToArray()
.Select(b => b.ToString("X2")));
Console.WriteLine($"[setstate-hex] body.len={body.Length} first-{Math.Min(body.Length, 32)}-bytes: {hex}");
}
var parsed = SetState.TryParse(body);
if (parsed is not null)
StateUpdated?.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 == PublicUpdatePropertyInt.Opcode)
{
var p = PublicUpdatePropertyInt.TryParse(body);
if (p is not null)
ObjectIntPropertyUpdated?.Invoke(
new ObjectIntPropertyUpdate(p.Value.Guid, p.Value.Property, p.Value.Value));
}
else if (op == PrivateUpdatePropertyInt.Opcode)
{
var p = PrivateUpdatePropertyInt.TryParse(body);
if (p is not null)
PlayerIntPropertyUpdated?.Invoke(
new PlayerIntPropertyUpdate(p.Value.Property, p.Value.Value));
}
else if (op == PrivateUpdatePropertyInt64.Opcode)
{
// Retail CM_Qualities::DispatchUI_PrivateUpdateInt64 @ 0x006AEAD0.
// TotalExperience (1) and AvailableExperience (2) both arrive here.
var p = PrivateUpdatePropertyInt64.TryParse(body);
if (p is not null)
PlayerInt64PropertyUpdated?.Invoke(
new PlayerInt64PropertyUpdate(p.Value.Property, p.Value.Value));
}
else if (op == SetStackSize.Opcode)
{
var p = SetStackSize.TryParse(body);
if (p is not null)
StackSizeUpdated?.Invoke(
new StackSizeUpdate(p.Value.Guid, p.Value.StackSize, p.Value.Value));
}
else if (op == InventoryRemoveObject.Opcode)
{
var p = InventoryRemoveObject.TryParse(body);
if (p is not null) InventoryObjectRemoved?.Invoke(p.Value.Guid);
}
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.TryParseBorrowed(
bodyMemory);
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.Slice(4, 4));
EnvironChanged?.Invoke(envType);
}
}
else if (op == PlayPhysicsScript.Opcode)
{
var script = PlayPhysicsScript.TryParse(body);
if (script is not null)
PlayPhysicsScriptReceived?.Invoke(script.Value);
}
else if (op == PlayPhysicsScriptType.Opcode)
{
var script = PlayPhysicsScriptType.TryParse(body);
if (script is not null)
PlayPhysicsScriptTypeReceived?.Invoke(script.Value);
}
else if (op == SoundEvent.Opcode)
{
var sound = SoundEvent.TryParse(body);
if (sound is not null)
SoundEventReceived?.Invoke(sound.Value);
}
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.
if (body.Length >= 6)
{
ushort sequence = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(
body.Slice(4, 2));
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 |
// u16 instanceSeq | u16 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})");
}
}
}
///
/// 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);
}
///
/// Send retail CharacterDelete through the login/logon queue. The caller
/// supplies the selected entry's active CharacterSet slot, not its guid.
///
public void SendDeleteCharacter(string accountName, int activeIndex)
{
ArgumentNullException.ThrowIfNull(accountName);
if (activeIndex < 0)
throw new ArgumentOutOfRangeException(nameof(activeIndex));
SendGameMessage(
CharacterDelete.BuildRequestBody(
accountName,
checked((uint)activeIndex)),
GameMessageGroup.LoginQueue);
}
///
/// Send retail CharacterRestore through the control queue. This is
/// deliberately non-blocking because ACE silently drops unknown guids.
/// Arms the awaiting-request latch as Restore BEFORE the send;
/// the latch correlates the SINGLE outstanding request — a second
/// create/restore sent while this one is outstanding overwrites it, and
/// refusing that overlap is the caller's job (CC3's verification gate).
/// See (Campaign CC
/// CC2).
///
public void SendRestoreCharacter(uint characterId)
{
_pendingCharGenVerification = PendingCharGenVerificationRequest.Restore;
SendControlMessage(CharacterRestore.BuildRequestBody(characterId));
}
///
/// Send retail CharacterCreate (opcode 0xF656) through the
/// login/logon queue — Proto_UI::SendCharGenResult routes via
/// SendToLogon, the same queue
/// uses (see
/// 's class doc comment). Deliberately
/// non-blocking, matching — ACE
/// silently drops a request whose packed account name doesn't match the
/// session's own account. Arms the awaiting-request latch as
/// Create BEFORE the send; the latch correlates the SINGLE
/// outstanding request — overlap refusal is the caller's job (CC3's
/// verification gate; see
/// ) (Campaign CC CC2).
///
public void SendCharacterCreation(
string accountName,
CharacterCreate.Request request,
ReadOnlySpan skillAdvancementClasses)
{
byte[] body = CharacterCreate.BuildRequestBody(
accountName,
request,
skillAdvancementClasses);
_pendingCharGenVerification = PendingCharGenVerificationRequest.Create;
SendGameMessage(body, GameMessageGroup.LoginQueue);
}
///
/// 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; }
/// LA7b unit-test seam for queue-sensitive pre-world sends.
internal Action? GameMessageCapture { 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 lifestone recall (0x0063). Retail source:
/// CM_Character::Event_TeleToLifestone @ 0x006A1B90.
///
public void SendTeleportToLifestone()
{
uint seq = NextGameActionSequence();
SendGameAction(InteractRequests.BuildTeleToLifestone(seq));
}
/// Send retail marketplace recall (0x028D).
public void SendTeleportToMarketplace()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildMarketplace(seq));
}
/// Send retail full-PK arena recall (0x0027).
public void SendTeleportToPkArena()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildPkArena(seq));
}
/// Send retail PKLite arena recall (0x0026).
public void SendTeleportToPkLiteArena()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildPkLiteArena(seq));
}
/// Send retail @pklite / Enter PK Lite request (0x028F).
public void SendEnterPkLite()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildEnterPkLite(seq));
}
/// Send retail personal-house recall (0x0262).
public void SendTeleportToHouse()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildHouseRecall(seq));
}
/// Send retail allegiance-mansion recall (0x0278).
public void SendTeleportToMansion()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildMansionRecall(seq));
}
/// Query the local player's house info — either owned house
/// data (0x0225) or a no-house status (0x0226) comes back
/// (0x021E).
public void SendHouseQuery()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildHouseQuery(seq));
}
/// Query the local character's played time (0x01C2).
public void SendQueryAge()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildQueryAge(seq));
}
/// Query the local character's creation date (0x01C4).
public void SendQueryBirth()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildQueryBirth(seq));
}
/// Reply to a server confirmation request (0x0275).
public void SendConfirmationResponse(uint confirmationType, uint contextId, bool accepted)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildConfirmationResponse(
seq, confirmationType, contextId, accepted));
}
/// Send the confirmed retail suicide action (0x0279).
public void SendSuicide()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildSuicide(seq));
}
public void SendSetAfkMode(bool away)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildSetAfkMode(seq, away));
}
public void SendSetAfkMessage(string message)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildSetAfkMessage(seq, message));
}
public void SendEmote(string message)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildEmote(seq, message));
}
///
/// Send retail SetSingleCharacterOption (0x0005) — toggles one character
/// option. For the six ListenTo*Chat ids this is the message that
/// actually joins/leaves a Turbine room server-side (CH3, 2026-08-09).
///
public void SendSetSingleCharacterOption(uint optionId, bool value)
{
uint seq = NextGameActionSequence();
SendGameAction(SocialActions.BuildSetSingleCharacterOption(seq, optionId, value));
}
///
/// Send retail SetCharacterOptions (0x01A1) — the batched-option
/// module flush (Campaign OP slice OP1, 2026-08-10). Callers own the
/// dirty check (RuntimeCharacterOptionsState.TryFlush /
/// TryFlushIfAutoSaveDue); this method always sends when called,
/// matching retail's CPlayerModule::SaveToServer once its own
/// m_bDirty gate has already passed.
///
public void SendSetCharacterOptions(
uint options1,
uint options2,
IReadOnlyList shortcuts,
IReadOnlyList> favoriteSpells,
IReadOnlyDictionary desiredComponents,
uint spellbookFilters)
{
uint seq = NextGameActionSequence();
SendGameAction(SocialActions.BuildSetCharacterOptions(
seq,
options1,
options2,
shortcuts,
favoriteSpells,
desiredComponents,
spellbookFilters));
}
public void SendAddFriend(string name)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildAddFriend(seq, name));
}
public void SendRemoveFriend(uint friendId)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildRemoveFriend(seq, friendId));
}
public void SendClearFriends()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildClearFriends(seq));
}
public void SendLegacyFriendsListRequest() =>
SendControlMessage(ClientCommandRequests.BuildLegacyFriendsCommand(0u, string.Empty));
public void SendModifyCharacterSquelch(bool add, uint characterId, string name, uint messageType)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildModifyCharacterSquelch(
seq, add, characterId, name, messageType));
}
public void SendModifyAccountSquelch(bool add, string name)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildModifyAccountSquelch(seq, add, name));
}
public void SendModifyGlobalSquelch(bool add, uint messageType)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildModifyGlobalSquelch(seq, add, messageType));
}
// Campaign CH slice CH4 (2026-08-09): command-registry completion.
/// Send retail @index (0x0149).
public void SendIndexChannels()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildIndexChannels(seq));
}
/// Send retail @clist <channel> (0x0148).
public void SendListChannel(uint channelId)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildListChannel(seq, channelId));
}
/// Send retail @on <channel> (0x0145).
public void SendOnChannel(uint channelId)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildOnChannel(seq, channelId));
}
/// Send retail @off <channel> (0x0146).
public void SendOffChannel(uint channelId)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildOffChannel(seq, channelId));
}
/// Send retail @alh / @ah / "@allegiance hometown" (0x02AB).
public void SendRecallAllegianceHometown()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildRecallAllegianceHometown(seq));
}
/// Send retail "@allegiance info [name]" (0x027B).
public void SendAllegianceInfoRequest(string playerName)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildAllegianceInfoRequest(seq, playerName));
}
// ── Campaign FA slice FA2 (2026-08-12): fellowship + allegiance
// outbound wrappers. SocialActions/AllegianceRequests ship the byte
// builders (repaired/added in FA1); this is the missing
// NextGameActionSequence() + SendGameAction() link every other
// outbound family already has (docs/research/2026-08-11-fa-acdream-seams.md
// §3.2).
/// Send retail fellowship create (0x00A2).
public void SendFellowshipCreate(string fellowshipName, bool shareXp)
{
uint seq = NextGameActionSequence();
SendGameAction(SocialActions.BuildFellowshipCreate(seq, fellowshipName, shareXp));
}
/// Send retail fellowship quit / disband (0x00A3).
public void SendFellowshipQuit(bool disband)
{
uint seq = NextGameActionSequence();
SendGameAction(SocialActions.BuildFellowshipQuit(seq, disband));
}
/// Send retail fellowship dismiss (0x00A4).
public void SendFellowshipDismiss(uint targetGuid)
{
uint seq = NextGameActionSequence();
SendGameAction(SocialActions.BuildFellowshipDismiss(seq, targetGuid));
}
/// Send retail fellowship recruit (0x00A5).
public void SendFellowshipRecruit(uint targetGuid)
{
uint seq = NextGameActionSequence();
SendGameAction(SocialActions.BuildFellowshipRecruit(seq, targetGuid));
}
///
/// Send retail fellowship-panel visibility declaration (0x00A6) — D4:
/// gates ACE's 0x02C0 member-vitals stream (docs/research/
/// 2026-08-11-fa-fellowship-wire.md §4.5).
///
public void SendFellowshipUpdateRequest(bool panelOpen)
{
uint seq = NextGameActionSequence();
SendGameAction(SocialActions.BuildFellowshipUpdateRequest(seq, panelOpen));
}
/// Send retail fellowship leadership transfer (0x0290).
public void SendFellowshipAssignNewLeader(uint newLeaderGuid)
{
uint seq = NextGameActionSequence();
SendGameAction(SocialActions.BuildFellowshipAssignNewLeader(seq, newLeaderGuid));
}
/// Send retail fellowship openness toggle (0x0291).
public void SendFellowshipChangeOpenness(bool isOpen)
{
uint seq = NextGameActionSequence();
SendGameAction(SocialActions.BuildFellowshipChangeOpenness(seq, isOpen));
}
/// Send retail allegiance swear (0x001D).
public void SendAllegianceSwear(uint patronGuid)
{
uint seq = NextGameActionSequence();
SendGameAction(AllegianceRequests.BuildSwear(seq, patronGuid));
}
/// Send retail allegiance break (0x001E) — targets your own patron.
public void SendAllegianceBreak(uint targetGuid)
{
uint seq = NextGameActionSequence();
SendGameAction(AllegianceRequests.BuildBreak(seq, targetGuid));
}
/// Send retail allegiance kick (0x001E) — targets a vassal.
public void SendAllegianceKick(uint vassalGuid)
{
uint seq = NextGameActionSequence();
SendGameAction(AllegianceRequests.BuildKick(seq, vassalGuid));
}
/// Send retail allegiance-panel subscribe/unsubscribe (0x001F).
public void SendAllegianceUpdateRequest(bool on)
{
uint seq = NextGameActionSequence();
SendGameAction(AllegianceRequests.BuildAllegianceUpdateRequest(seq, on));
}
/// Send retail @hslist <type> (0x0270).
public void SendListAvailableHouses(uint houseType)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildListAvailableHouses(seq, houseType));
}
/// Send retail @permit add <name> (0x0219).
public void SendAddPlayerPermission(string playerName)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildAddPlayerPermission(seq, playerName));
}
/// Send retail @permit remove <name> (0x021A).
public void SendRemovePlayerPermission(string playerName)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildRemovePlayerPermission(seq, playerName));
}
/// Send retail "@house abandon" (0x021F).
public void SendAbandonHouse()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildAbandonHouse(seq));
}
public void SendClearConsent()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildClearConsent(seq));
}
public void SendDisplayConsent()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildDisplayConsent(seq));
}
public void SendRemoveConsent(string name)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildRemoveConsent(seq, name));
}
public void SendClearDesiredComponents()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildSetDesiredComponentLevel(
seq, componentId: 0u, amount: uint.MaxValue));
}
public void SendSetDesiredComponentLevel(uint componentId, uint amount)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildSetDesiredComponentLevel(
seq, componentId, amount));
}
public void SendAddSpellFavorite(uint spellId, int position, int tabIndex)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildAddSpellFavorite(
seq, spellId, position, tabIndex));
}
public void SendRemoveSpellFavorite(uint spellId, int tabIndex)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildRemoveSpellFavorite(
seq, spellId, tabIndex));
}
public void SendSpellbookFilter(uint filters)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildSpellbookFilter(seq, filters));
}
public void SendRemoveSpell(uint spellId)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildRemoveSpell(seq, spellId));
}
public void SendCastUntargetedSpell(uint spellId)
{
uint seq = NextGameActionSequence();
SendGameAction(CastSpellRequest.BuildUntargeted(seq, spellId));
}
public void SendCastTargetedSpell(uint targetGuid, uint spellId)
{
uint seq = NextGameActionSequence();
SendGameAction(CastSpellRequest.BuildTargeted(seq, targetGuid, spellId));
}
/// 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 RaiseAttribute (0x0045).
public void SendRaiseAttribute(uint attrId, ulong xpSpent)
{
uint seq = NextGameActionSequence();
SendGameAction(CharacterActions.BuildRaiseAttribute(seq, attrId, xpSpent));
}
/// Send retail RaiseVital (0x0044).
public void SendRaiseVital(uint vitalId, ulong xpSpent)
{
uint seq = NextGameActionSequence();
SendGameAction(CharacterActions.BuildRaiseVital(seq, vitalId, xpSpent));
}
/// Send retail RaiseSkill (0x0046).
public void SendRaiseSkill(uint skillId, ulong xpSpent)
{
uint seq = NextGameActionSequence();
SendGameAction(CharacterActions.BuildRaiseSkill(seq, skillId, xpSpent));
}
/// Send retail TrainSkill (0x0047).
public void SendTrainSkill(uint skillId, uint credits)
{
uint seq = NextGameActionSequence();
SendGameAction(CharacterActions.BuildTrainSkill(seq, skillId, credits));
}
/// Send lossless retail AddShortcut (0x019C).
public void SendAddShortcut(ShortcutEntry entry)
{
uint seq = NextGameActionSequence();
SendGameAction(InventoryActions.BuildAddShortcut(seq, entry));
}
/// Send RemoveShortcut (0x019D) — clear toolbar slot .
/// Retail: CM_Character::Event_RemoveShortCut.
public void SendRemoveShortcut(uint index)
{
uint seq = NextGameActionSequence();
SendGameAction(InventoryActions.BuildRemoveShortcut(seq, index));
}
/// Send DropItem (0x001B) — drop an item on the ground.
public void SendDropItem(uint itemGuid)
{
uint seq = NextGameActionSequence();
SendGameAction(InventoryActions.BuildDropItem(seq, itemGuid));
}
// ── Secure trade (docs/research/2026-08-14-trade-laneB-wire.md) ────────
/// Open secure trade with another player — retail
/// CM_Trade::Event_OpenTradeNegotiations @ 0x0056D300.
public void SendOpenTradeNegotiations(uint partnerGuid)
{
uint seq = NextGameActionSequence();
SendGameAction(TradeRequests.BuildOpenTradeNegotiations(seq, partnerGuid));
}
/// Close the trade window — Event_CloseTradeNegotiations
/// @ 0x0056D1E0.
public void SendCloseTradeNegotiations()
{
uint seq = NextGameActionSequence();
SendGameAction(TradeRequests.BuildCloseTradeNegotiations(seq));
}
/// Stage an item into the trade — Event_AddToTrade
/// @ 0x0056D0D0.
public void SendAddToTrade(uint itemGuid, uint tradeSlot = 0u)
{
uint seq = NextGameActionSequence();
SendGameAction(TradeRequests.BuildAddToTrade(seq, itemGuid, tradeSlot));
}
/// Accept the current offer — Event_AcceptTrade packing
/// Trade::Pack @ 0x005B9FF0's fixed fields (ACE discards the
/// payload entirely; see TradeRequests.BuildAcceptTrade).
public void SendAcceptTrade(
uint partnerGuid,
double tradeStamp,
uint tradeStatus,
uint initiatorGuid,
bool initiatorAccepts,
bool partnerAccepts)
{
uint seq = NextGameActionSequence();
SendGameAction(TradeRequests.BuildAcceptTrade(
seq, partnerGuid, tradeStamp, tradeStatus,
initiatorGuid, initiatorAccepts, partnerAccepts));
}
/// Withdraw a previous accept — Event_DeclineTrade
/// @ 0x0056D270.
public void SendDeclineTrade()
{
uint seq = NextGameActionSequence();
SendGameAction(TradeRequests.BuildDeclineTrade(seq));
}
/// Clear the trade window — Event_ResetTrade @ 0x0056D3D0.
/// ACE clears BOTH sides' staged items (lane B §quirks).
public void SendResetTrade()
{
uint seq = NextGameActionSequence();
SendGameAction(TradeRequests.BuildResetTrade(seq));
}
///
/// Send retail GiveObjectRequest (0x00CD). Retail
/// CM_Inventory::Event_GiveObjectRequest @ 0x006ABB00 writes
/// target, source item, then selected stack amount.
///
public void SendGiveObject(uint targetGuid, uint itemGuid, uint amount)
{
uint seq = NextGameActionSequence();
SendGameAction(InventoryActions.BuildGiveObjectRequest(
seq, targetGuid, itemGuid, amount));
}
/// Send GetAndWieldItem (0x001A) — equip an item to an equip slot.
public void SendGetAndWieldItem(uint itemGuid, uint equipMask)
{
uint seq = NextGameActionSequence();
SendGameAction(InventoryActions.BuildGetAndWieldItem(seq, itemGuid, equipMask));
}
/// Send NoLongerViewingContents (0x0195) — close a container view.
public void SendNoLongerViewingContents(uint containerGuid)
{
uint seq = NextGameActionSequence();
SendGameAction(InventoryActions.BuildNoLongerViewingContents(seq, containerGuid));
}
/// Send Use (0x0036) — open/use an object by guid (e.g. open a container in your
/// inventory). A direct wire send: opening a pack you already hold needs no autowalk, unlike
/// GameWindow.SendUse (the world-interaction path). Retail: CM_Physics::Event_Use.
public void SendUse(uint targetGuid)
{
uint seq = NextGameActionSequence();
SendGameAction(InteractRequests.BuildUse(seq, targetGuid));
}
/// Send UseWithTarget (0x0035) - use a source item on an acquired target.
/// Retail: CM_Inventory::Event_UseWithTargetEvent.
public void SendUseWithTarget(uint sourceGuid, uint targetGuid)
{
uint seq = NextGameActionSequence();
SendGameAction(InteractRequests.BuildUseWithTarget(seq, sourceGuid, targetGuid));
}
///
/// Slice 6.3: send retail Buy (0x005F) — a single-item purchase, retail
/// CM_Vendor::Event_Buy (pc:689288). See
/// for the wire layout and the deliberate
/// trailing alternateCurrencyId field (ported for retail
/// fidelity; ACE's server ignores it today).
///
public void SendBuy(uint vendorGuid, uint itemGuid, int amount, uint alternateCurrencyId)
{
uint seq = NextGameActionSequence();
SendGameAction(VendorRequests.BuildBuy(seq, vendorGuid, amount, itemGuid, alternateCurrencyId));
}
///
/// Slice 6b: send a batched retail Buy (0x005F) — the "Buy All" path,
/// one wire call for every staged entry. See
/// for the single-item convenience overload the "Items"/"Buying" tabs'
/// immediate Buy buttons keep using unchanged.
///
public void SendBuy(
uint vendorGuid,
IReadOnlyList<(int Amount, uint ItemGuid)> items,
uint alternateCurrencyId)
{
uint seq = NextGameActionSequence();
SendGameAction(VendorRequests.BuildBuy(seq, vendorGuid, items, alternateCurrencyId));
}
///
/// Slice 6c: send retail Sell (0x0060) — .
/// Used by both the "Sell Item" (one-entry list) and "Sell All" (n-entry
/// list) buttons; Sell has no separate single-item opcode the way Buy
/// does.
///
public void SendSell(uint vendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> items)
{
uint seq = NextGameActionSequence();
SendGameAction(VendorRequests.BuildSell(seq, vendorGuid, items));
}
/// Send retail IdentifyObject/Appraise (0x00C8).
public void SendAppraise(uint targetGuid)
{
uint seq = NextGameActionSequence();
SendGameAction(AppraiseRequest.Build(seq, targetGuid));
}
///
/// Send retail SetInscription (0x00BF). The retail client updates the
/// examination field optimistically because the server sends no success
/// response.
///
public void SendSetInscription(uint itemGuid, string inscription)
{
uint seq = NextGameActionSequence();
SendGameAction(InventoryActions.BuildSetInscription(
seq,
itemGuid,
inscription));
}
/// Send PutItemInContainer (0x0019) - move an item into a container at a slot. placement
/// = the target slot (server packs/shifts); the drag-drop drop handler computes it. Retail:
/// CM_Inventory::Event_PutItemInContainer -> ACE Player.HandleActionPutItemInContainer.
public void SendPutItemInContainer(uint itemGuid, uint containerGuid, int placement)
{
uint seq = NextGameActionSequence();
SendGameAction(InteractRequests.BuildPickUp(seq, itemGuid, containerGuid, placement));
}
/// Send StackableMerge (0x0054) with retail's already-clamped transfer amount.
public void SendStackableMerge(uint sourceGuid, uint targetGuid, uint amount)
{
uint seq = NextGameActionSequence();
SendGameAction(InventoryActions.BuildStackableMerge(seq, sourceGuid, targetGuid, amount));
}
///
/// Send retail StackableSplitToContainer (0x0055).
/// CM_Inventory::Event_StackableSplitToContainer @ 0x006AC0D0.
///
public void SendStackableSplitToContainer(
uint stackGuid, uint containerGuid, uint placement, uint amount)
{
uint seq = NextGameActionSequence();
SendGameAction(InventoryActions.BuildStackableSplitToContainer(
seq, stackGuid, containerGuid, placement, amount));
}
///
/// Send retail StackableSplitTo3D (0x0056).
/// CM_Inventory::Event_StackableSplitTo3D @ 0x006ABFC0.
///
public void SendStackableSplitTo3D(uint stackGuid, uint amount)
{
uint seq = NextGameActionSequence();
SendGameAction(InventoryActions.BuildStackableSplitTo3D(seq, stackGuid, amount));
}
/// Send retail QueryHealth (0x01BF). Server replies UpdateHealth (0x01C0).
///
/// Retail anchor: CM_Combat::Event_QueryHealth / gmToolbarUI::HandleSelectionChanged:198635
/// (docs/research/named-retail/acclient_2013_pseudo_c.txt).
///
public void SendQueryHealth(uint targetGuid)
{
uint seq = NextGameActionSequence();
byte[] body = SocialActions.BuildQueryHealth(seq, targetGuid);
SendGameAction(body);
}
///
/// Send retail QueryItemMana (0x0263), using item guid zero to cancel.
/// Retail anchor: CM_Item::Event_QueryItemMana @ 0x006A8610.
///
public void SendQueryItemMana(uint itemGuid)
{
uint seq = NextGameActionSequence();
byte[] body = SocialActions.BuildQueryItemMana(seq, itemGuid);
SendGameAction(body);
}
///
/// Request the round-trip sample displayed by retail gmLinkStatusUI.
/// The response is an empty 0x01EA GameEvent, so the session retains the
/// monotonic send timestamp rather than putting an invented id on the wire.
///
public void RequestLinkStatusPing()
{
Volatile.Write(ref _lastPingRequestTicks, Stopwatch.GetTimestamp());
uint seq = NextGameActionSequence();
SendGameAction(SocialActions.BuildPingRequest(seq));
}
/// 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) =>
SendGameMessage(gameMessageBody, GameMessageGroup.UIQueue);
///
/// Retail Proto_UI::SendToControl path used by standalone control
/// messages such as the legacy 0xF7CD friends request.
///
private void SendControlMessage(byte[] gameMessageBody) =>
SendGameMessage(gameMessageBody, GameMessageGroup.ControlQueue);
private void SendGameMessage(byte[] gameMessageBody, GameMessageGroup queue)
{
if (GameMessageCapture is { } capture)
{
capture(gameMessageBody, queue);
return;
}
// #260 probe: log the send BEFORE the sequence counters are consumed
// so the line carries the values this datagram will actually use. The
// exception filter below logs a wire-write fault WITHOUT catching it
// (the filter returns false), so behavior is byte-identical either way.
if (NetDiagnostics.ProbeNet)
ProbeNetLogOutbound(gameMessageBody, queue);
try
{
// N1: the reliable transport owns encode + send + cache. Wire
// shape is unchanged; the datagram is additionally cached for
// resend on server NAK. Pre-negotiation reliable sends were
// always impossible (no outbound keystream existed) — the
// exception simply names the state now.
ReliableTransport transport = _transport
?? throw new InvalidOperationException(
"reliable send before transport negotiation — "
+ "Connect() must seed ISAAC first");
transport.Outbound.SendGameMessage(gameMessageBody, queue);
}
catch (Exception ex) when (ProbeNetLogOutboundFault(ex))
{
// Unreachable: the filter always returns false so the original
// exception propagates to the caller exactly as before.
throw;
}
if (NetDiagnostics.ProbeNet)
Interlocked.Increment(ref _probeSendWindow);
}
///
/// #260 probe: one [net-out] line per outbound reliable game
/// message. The thread id is load-bearing evidence — every send is
/// supposed to happen on the frame thread (the ISAAC keystream is
/// single-threaded); two distinct tids across [net-out] lines would
/// prove the cross-thread-send/cipher-desync hypothesis by itself.
///
private void ProbeNetLogOutbound(byte[] body, GameMessageGroup queue)
{
uint op = body.Length >= 4
? BinaryPrimitives.ReadUInt32LittleEndian(body)
: 0u;
string detail = string.Empty;
if (op == 0xF7B1 && body.Length >= 12)
{
uint gseq = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4));
uint act = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8));
detail = $" act=0x{act:X4} gseq={gseq}";
}
Console.WriteLine(
$"[net-out] op=0x{op:X4}{detail} q={queue}"
+ $" fseq={_transport?.Outbound.FragmentSequence ?? 0}"
+ $" pseq={_transport?.Outbound.PeekNextPacketSequence ?? 0}"
+ $" len={body.Length}"
+ $" tid={Environment.CurrentManagedThreadId} st={CurrentState}");
}
private bool ProbeNetLogOutboundFault(Exception ex)
{
if (NetDiagnostics.ProbeNet)
{
Console.WriteLine(
$"[net-out-EX] {ex.GetType().Name}: {ex.Message}"
+ $" tid={Environment.CurrentManagedThreadId} st={CurrentState}");
}
return false;
}
private void Transition(State next)
{
if (CurrentState == next) return;
CurrentState = next;
StateChanged?.Invoke(next);
}
///
/// Graceful shutdown: request character logoff, wait for the server's
/// authoritative 0xF653 confirmation, and only then disconnect the
/// transport. This is retail's CPlayerSystem::RequestLogOff to
/// inbound confirmation to ExecuteLogOff ordering. It prevents a
/// replacement session from racing the old character's asynchronous
/// removal on ACE.
///
public void Dispose()
{
if (Interlocked.Exchange(ref _disposeStarted, 1) != 0)
return;
// Campaign CC CC2: a teardown mid-flight must not leave a stale
// Restore/Create latch behind it — this session object is never
// reused (a fresh WorldSession is constructed per connection
// attempt), but clearing here keeps the invariant "no outstanding
// request survives teardown" true rather than merely true-in-practice.
_pendingCharGenVerification = PendingCharGenVerificationRequest.None;
SessionShutdownPlan shutdown = BuildShutdownPlan(
CurrentState,
_transportNegotiated,
_activeCharacterId);
Interlocked.Exchange(ref _characterLogOffConfirmed, 0);
ShutdownExecutionResult result = ExecuteShutdownWire(
shutdown,
_activeCharacterId,
_sessionClientId,
_sessionIteration,
SendGameMessage,
WaitForCharacterLogOffConfirmation,
packet => _net.Send(packet),
TimeSpan.FromSeconds(35));
if (result.CharacterLogOffSent)
{
Console.WriteLine(
$"[session] graceful logout requested character=0x{_activeCharacterId:X8}");
if (result.ConfirmationReceived)
Console.WriteLine("[session] graceful logout confirmed");
else if (result.CharacterLogOffError is null)
Console.Error.WriteLine(
"[session] graceful logout confirmation timed out; disconnecting transport");
}
if (result.CharacterLogOffError is not null)
Console.Error.WriteLine(
$"[session] graceful logout failed: {result.CharacterLogOffError.Message}");
if (result.TransportDisconnectError is not null)
Console.Error.WriteLine(
$"[session] transport disconnect failed: {result.TransportDisconnectError.Message}");
// Slice H-c1: cancel the outstanding socket receive directly, join
// its task, then return every queued buffer before transport dispose.
_netCancel.Cancel();
_netReceiveTask?.GetAwaiter().GetResult();
_inboundQueue.Writer.TryComplete();
while (_inboundQueue.Reader.TryRead(
out PooledInboundDatagram queued))
{
ReturnInboundDatagram(queued);
}
_netCancel.Dispose();
// N5: one cumulative TransportStats summary so the connected loss
// gate asserts exact totals instead of reconstructing them from the
// rounded per-second [net-tick] rates.
if (NetDiagnostics.ProbeNet && _transport is { } finalTransport)
{
TransportStats finalStats = finalTransport.Stats;
Console.WriteLine(
$"[net-final] resends={finalStats.ResendsSent}"
+ $" nak-in={finalStats.NakRequestsReceived}"
+ $" nak-out={finalStats.NaksSent}"
+ $" rej-in={finalStats.RejectsReceived}"
+ $" acks-out={finalStats.AcksSent}"
+ $" acks-in={finalStats.AcksConsumed}"
+ $" dup-drop={finalStats.InboundDupsDropped}"
+ $" sanity-drop={finalStats.InboundSanityDrops}"
+ $" cksum-fail={finalStats.ChecksumFailures}"
+ $" parked={finalStats.KeysParked}"
+ $" reclaimed={finalStats.RejectWordsReclaimed}"
+ $" uncached-nak={finalStats.UncachedNakIds}"
+ $" cache={finalStats.CacheDepth}"
+ $" nakset={finalTransport.Inbound.NakCount}");
}
// N1: return every rented sent-packet cache buffer before the
// socket goes away.
_transport?.Dispose();
_net.Dispose();
Transition(State.Disconnected);
}
internal readonly record struct SessionShutdownPlan(
bool RequestCharacterLogOff,
bool SendTransportDisconnect);
internal readonly record struct ShutdownExecutionResult(
bool CharacterLogOffSent,
bool ConfirmationReceived,
bool TransportDisconnectSent,
Exception? CharacterLogOffError,
Exception? TransportDisconnectError);
internal static SessionShutdownPlan BuildShutdownPlan(
State state,
bool transportNegotiated,
uint activeCharacterId) =>
new(
RequestCharacterLogOff:
transportNegotiated
&& state == State.InWorld
&& activeCharacterId != 0,
SendTransportDisconnect: transportNegotiated);
///
/// Executes the retail wire order while keeping request and transport
/// failures independent. The delegates are the production send/wait path;
/// their narrow shape also makes exact packet ordering deterministic in
/// tests without duplicating shutdown logic.
///
internal static ShutdownExecutionResult ExecuteShutdownWire(
SessionShutdownPlan plan,
uint activeCharacterId,
ushort sessionClientId,
ushort sessionIteration,
Action sendGameMessage,
Func waitForConfirmation,
Action sendTransportDatagram,
TimeSpan confirmationTimeout)
{
ArgumentNullException.ThrowIfNull(sendGameMessage);
ArgumentNullException.ThrowIfNull(waitForConfirmation);
ArgumentNullException.ThrowIfNull(sendTransportDatagram);
bool characterLogOffSent = false;
bool confirmationReceived = false;
bool transportDisconnectSent = false;
Exception? characterError = null;
Exception? transportError = null;
if (plan.RequestCharacterLogOff)
{
try
{
// Proto_UI::LogOffCharacter @ 0x00546A20 sends the active id.
sendGameMessage(CharacterLogOff.BuildRequestBody(activeCharacterId));
characterLogOffSent = true;
confirmationReceived = waitForConfirmation(confirmationTimeout);
}
catch (Exception error)
{
characterError = error;
}
}
if (plan.SendTransportDisconnect)
{
try
{
// ClientNet::LogOffServer @ 0x00543EF0 sends Disconnect for
// every negotiated ReceiverData, independent of character
// entry state.
sendTransportDatagram(TransportDisconnect.Build(
sessionClientId,
sessionIteration));
transportDisconnectSent = true;
}
catch (Exception error)
{
transportError = error;
}
}
return new ShutdownExecutionResult(
characterLogOffSent,
confirmationReceived,
transportDisconnectSent,
characterError,
transportError);
}
private bool WaitForCharacterLogOffConfirmation(TimeSpan timeout) =>
WaitForCharacterLogOffConfirmation(
_inboundQueue.Reader,
timeout,
datagram =>
{
ProcessDatagram(
datagram.Memory,
dispatchWorldEvents: false);
// N5: keep the transport pumped while waiting for the
// logoff confirmation — retail's frame pump
// (Client::UseTime @ 0x00411C40 →
// PacketController::UseTime @ 0x005410D0) keeps running
// until LogOffServer, so the logoff wait is the third
// blocking pump the sweep must cover (after Tick and the
// handshake loops). The connected loss gate exposed the
// gap: without a sweep here, a lost S2C confirmation can
// be gap-detected (ACE's next sequenced packet arrives and
// parks a key) but the NAK that would heal it never goes
// out, and the graceful logout dies at the 35 s timeout.
// ACE's 2 s ack cadence guarantees arrivals to hang this
// callback on. (A lost C2S logoff REQUEST remains
// unrecoverable against ACE — its NAK is arrival-driven
// and a quiet client is never NAKed, campaign §3 row 1 —
// the same idle-tail constraint the N4 soak recorded.)
SweepTransport();
return Volatile.Read(ref _characterLogOffConfirmed) != 0;
},
ReturnInboundDatagram);
internal static bool WaitForCharacterLogOffConfirmation(
ChannelReader reader,
TimeSpan timeout,
Func processAndCheckConfirmation,
Action? release = null,
Action? periodicWork = null,
TimeSpan? periodicInterval = null)
{
ArgumentNullException.ThrowIfNull(reader);
ArgumentNullException.ThrowIfNull(processAndCheckConfirmation);
TimeSpan cadence = periodicInterval ?? TimeSpan.FromMilliseconds(25);
if (periodicWork is not null && cadence <= TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(periodicInterval));
using var timeoutSource = new CancellationTokenSource(timeout);
// The deadline is also read straight off the monotonic clock, not only
// off the token. CancellationTokenSource(TimeSpan) publishes its
// cancellation from a thread-pool timer callback, so when the pool is
// saturated the token can stay unsignalled well past the deadline while
// the loop below keeps draining a queue that already has items in it -
// exactly the case this method exists to bound. The token still bounds
// the asynchronous wait; the clock bounds the synchronous drain. A
// negative timeout is the framework's "infinite" and keeps its meaning.
long started = Stopwatch.GetTimestamp();
bool bounded = timeout >= TimeSpan.Zero;
bool Expired() => bounded && Stopwatch.GetElapsedTime(started) >= timeout;
try
{
while (!timeoutSource.IsCancellationRequested && !Expired())
{
while (reader.TryRead(out T? item))
{
if (timeoutSource.IsCancellationRequested || Expired())
{
release?.Invoke(item);
return false;
}
bool confirmed;
try
{
confirmed = processAndCheckConfirmation(item);
}
finally
{
release?.Invoke(item);
}
if (confirmed)
return true;
}
periodicWork?.Invoke();
if (timeoutSource.IsCancellationRequested || Expired())
return false;
bool canRead;
if (periodicWork is null)
{
canRead = reader.WaitToReadAsync(timeoutSource.Token)
.AsTask()
.GetAwaiter()
.GetResult();
}
else
{
TimeSpan wait = cadence;
if (bounded)
{
TimeSpan remaining = timeout
- Stopwatch.GetElapsedTime(started);
if (remaining <= TimeSpan.Zero)
return false;
if (remaining < wait)
wait = remaining;
}
using var sliceSource =
CancellationTokenSource.CreateLinkedTokenSource(
timeoutSource.Token);
sliceSource.CancelAfter(wait);
try
{
canRead = reader.WaitToReadAsync(sliceSource.Token)
.AsTask()
.GetAwaiter()
.GetResult();
}
catch (OperationCanceledException)
when (!timeoutSource.IsCancellationRequested
&& !Expired())
{
// This cadence is the paused selector's frame edge:
// keep reliable transport work moving even when no
// datagram arrives to wake the inbound queue.
continue;
}
}
if (!canRead)
return false;
}
}
catch (OperationCanceledException)
{
return false;
}
catch (ChannelClosedException)
{
return false;
}
return false;
}
}