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;
namespace AcDream.Core.Net;
///
/// High-level AC client session: owns a , drives
/// the full handshake + character-enter-world flow, and converts the
/// inbound GameMessage stream into C# events that a game loop can bind.
///
///
/// Intended use from GameWindow:
///
///
/// var session = new WorldSession(new IPEndPoint(IPAddress.Loopback, 9000));
/// session.EntitySpawned += snap => { /* add to IGameState */ };
/// session.Connect("testaccount", "testpassword"); // blocks until CharacterList
/// session.EnterWorld(characterIndex: 0); // blocks until first CreateObject
/// // ... then every frame:
/// session.Tick(); // non-blocking, drains any pending packets, fires events
///
///
///
/// Not yet provided (deferred): ACK pump, retransmit handling,
/// delete-object processing, position updates, chat, disconnect detection.
/// The current client is one-shot — connect, enter the world, stream
/// events for a few seconds, let the test harness tear it down.
///
///
public sealed class WorldSession : IDisposable
{
public enum State
{
Disconnected,
Handshaking,
InCharacterSelect,
EnteringWorld,
InWorld,
Failed,
}
public readonly record struct EntitySpawn(
uint Guid,
CreateObject.ServerPosition? Position,
uint? SetupTableId,
IReadOnlyList AnimPartChanges,
IReadOnlyList TextureChanges,
IReadOnlyList SubPalettes,
uint? BasePaletteId,
float? ObjScale,
string? Name,
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,
PhysicsSpawnData? Physics = 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,
Physics: parsed.Physics);
/// 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;
///
/// Phase 5d — retail's AdminEnvirons packet (opcode
/// 0xEA60) — the one-and-only channel retail's server uses
/// for weather environment changes. Wire format:
/// [u32 opcode][u32 environChangeType]. The payload enum is
/// retail's EnvironChangeType:
///
/// -
/// 0x00..0x06 — fog presets (Clear/Red/Blue/White/Green/
/// Black/Black2). Subscribers route these to a
/// .
///
/// -
/// 0x65..0x75 — one-shot ambient sound cues
/// (Roar / Bell / Chant / etc).
///
/// -
/// 0x76..0x7B — Thunder1..Thunder6 sounds. Paired with
/// a separate from the server
/// carrying the lightning-flash PhysicsScript.
///
///
/// See docs/research/2026-04-23-lightning-crossfade.md +
/// 2026-04-23-lightning-real.md.
///
public event Action? EnvironChanged;
///
/// Phase G.1: latest server Portal Year tick count. Seeded from the
/// ConnectRequest handshake (r12 §1.3 — server sends absolute game
/// time as a double) and refreshed on every TimeSync-flagged packet.
/// Subscribers feed this into WorldTimeService.SyncFromServer
/// so client-local day/night stays in lockstep with the server clock.
///
public event Action? ServerTimeUpdated;
///
/// Latest server tick count from
/// events. 0 until the handshake completes.
///
public double LastServerTimeTicks { get; private set; }
///
/// Allow re-sending LoginComplete after a portal teleport. The normal
/// _loginCompleteSent latch prevents duplicate sends on the initial spawn
/// path; this method resets it so the teleport completion path can send
/// another LoginComplete to tell the server the client has finished loading
/// the destination cell. Pattern from holtburger's PlayerTeleport handler
/// (client/messages.rs line 434-440: call send_login_complete on teleport).
///
public void ResetLoginComplete() => _loginCompleteSent = false;
/// Raised every time the state machine transitions.
public event Action? StateChanged;
///
/// Phase F.1: inbound 0xF7B0 GameEvent dispatcher. Each sub-opcode
/// handler is registered here (by GameWindow / UI layer / chat
/// system) and routed on each incoming GameEvent. Unhandled
/// sub-opcodes are counted for diagnostic overlays.
///
public GameEventDispatcher GameEvents { get; } = new();
public State CurrentState { get; private set; } = State.Disconnected;
///
/// 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; }
private readonly NetClient _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 IsaacRandom? _inboundIsaac;
private IsaacRandom? _outboundIsaac;
private ushort _sessionClientId;
private uint _clientPacketSequence;
private uint _fragmentSequence = 1;
// Movement sequence counters — echoed back in every MoveToState and
// AutonomousPosition so the server can detect stale/reordered packets.
// Initialized from CreateObject PhysicsData timestamps, updated by
// 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;
// Phase A.3: background receive thread buffers raw UDP datagrams into
// a channel so the render thread never blocks on socket I/O.
private readonly Channel _inboundQueue =
Channel.CreateUnbounded(
new UnboundedChannelOptions
{ SingleReader = true, SingleWriter = true });
private Thread? _netThread;
private readonly CancellationTokenSource _netCancel = new();
///
/// Phase 4.10 latch — true after we've sent the LoginComplete game
/// action in response to PlayerCreate. Prevents re-sending if the
/// server emits multiple PlayerCreate messages (rare but possible
/// across recall / portal teleports).
///
private bool _loginCompleteSent;
/// 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)
{
_loginEndpoint = serverLogin;
_connectEndpoint = new IPEndPoint(serverLogin.Address, serverLogin.Port + 1);
_net = new NetClient(serverLogin);
// Phase I.6: SetTurbineChatChannels (0x0295) is a GameEvent
// sub-opcode of 0xF7B0, not a top-level opcode. Route it through
// the dispatcher and surface a typed event so downstream wiring
// (GameEventWiring → TurbineChatState) doesn't need to know the
// GameEvent envelope encoding.
GameEvents.Register(GameEventType.SetTurbineChatChannels, e =>
{
var parsed = SetTurbineChatChannels.TryParse(e.Payload.Span);
if (parsed is not null) TurbineChannelsReceived?.Invoke(parsed.Value);
});
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
Packet? cr = null;
while (DateTime.UtcNow < deadline && cr is null)
{
var bytes = _net.Receive(deadline - DateTime.UtcNow, out _);
if (bytes is null) break;
var dec = PacketCodec.TryDecode(bytes, null);
if (dec.IsOk && dec.Packet!.Header.HasFlag(PacketHeaderFlags.ConnectRequest))
cr = dec.Packet;
}
if (cr is null) { Transition(State.Failed); throw new TimeoutException("ConnectRequest not received"); }
// Step 3: seed ISAAC, send ConnectResponse to port+1, with 200ms race delay
var opt = cr.Optional;
// Phase G.1: server's initial PortalYearTicks (r12 §1.3) lives
// in the ConnectRequest optional section. Publish it to
// subscribers so WorldTimeService.SyncFromServer can seed the
// client clock.
LastServerTimeTicks = opt.ConnectRequestServerTime;
ServerTimeUpdated?.Invoke(opt.ConnectRequestServerTime);
byte[] serverSeedBytes = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(serverSeedBytes, opt.ConnectRequestServerSeed);
byte[] clientSeedBytes = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(clientSeedBytes, opt.ConnectRequestClientSeed);
_inboundIsaac = new IsaacRandom(serverSeedBytes);
_outboundIsaac = new IsaacRandom(clientSeedBytes);
_sessionClientId = (ushort)opt.ConnectRequestClientId;
_clientPacketSequence = 2;
byte[] crBody = new byte[8];
BinaryPrimitives.WriteUInt64LittleEndian(crBody, opt.ConnectRequestCookie);
var crHeader = new PacketHeader { Sequence = 1, Flags = PacketHeaderFlags.ConnectResponse, Id = 0 };
Thread.Sleep(200);
_net.Send(_connectEndpoint, PacketCodec.Encode(crHeader, crBody, null));
Transition(State.InCharacterSelect);
// Step 4: drain until CharacterList arrives
while (DateTime.UtcNow < deadline && Characters is null)
{
PumpOnce();
}
if (Characters is null) { Transition(State.Failed); throw new TimeoutException("CharacterList not received"); }
}
///
/// Send CharacterEnterWorldRequest and CharacterEnterWorld for
/// [].
/// Returns once the server starts sending CreateObjects (at which point
/// callers should poll to stream events).
///
public void EnterWorld(string account, int characterIndex = 0, TimeSpan? timeout = null)
{
if (Characters is null || Characters.Characters.Count == 0)
throw new InvalidOperationException("Connect() must complete with a non-empty CharacterList");
if (characterIndex < 0 || characterIndex >= Characters.Characters.Count)
throw new ArgumentOutOfRangeException(nameof(characterIndex));
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(10));
var chosen = Characters.Characters[characterIndex];
Transition(State.EnteringWorld);
SendGameMessage(CharacterEnterWorld.BuildEnterWorldRequestBody());
// Wait for CharacterEnterWorldServerReady (0xF7DF)
bool serverReady = false;
while (DateTime.UtcNow < deadline && !serverReady)
{
var drained = PumpOnce(out var opcodes);
if (!drained) continue;
foreach (var op in opcodes)
if (op == 0xF7DFu) { serverReady = true; break; }
}
if (!serverReady) { Transition(State.Failed); throw new TimeoutException("ServerReady not received"); }
SendGameMessage(CharacterEnterWorld.BuildEnterWorldBody(chosen.Id, account));
// NOTE: LoginComplete used to be sent here unconditionally. That was
// wrong — per holtburger's flow (see references/holtburger/.../client/
// messages.rs lines 391-422), LoginComplete is sent in response to the
// server's PlayerCreate (0xF746) game message, NOT immediately after
// EnterWorld. Sending it too early means the player object isn't
// ready and the server ignores it. The actual trigger lives in
// ProcessDatagram.
Transition(State.InWorld);
// Phase A.3: start the background receive thread now that the
// handshake is complete and the session is fully established.
// During Connect() and EnterWorld(), PumpOnce() read directly
// from the socket (blocking). From here on, Tick() drains the
// channel instead.
_netThread = new Thread(NetReceiveLoop)
{
IsBackground = true,
Name = "acdream.net-recv",
};
_netThread.Start();
}
// 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;
long start = Stopwatch.GetTimestamp();
while (_inboundQueue.Reader.TryRead(out var bytes))
{
ProcessDatagram(bytes);
processed++;
// Bound ONLY in-world: the handshake uses the blocking PumpOnce path, never Tick
// (the net thread that feeds _inboundQueue starts at Transition(State.InWorld)).
// Acks are queued per packet inside ProcessDatagram BEFORE the heavy handler, so
// deferring the tail only delays the tail's acks a few frames — within ACE's
// tolerance (holtburger defers acks on a flush cadence). The tail stays queued
// (unbounded channel, FIFO) and drains next frame.
if (InboundBudgetExceeded(CurrentState, start, Stopwatch.GetTimestamp(), InboundBudgetTicks))
break;
}
return processed;
}
///
/// 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: background receive loop. Runs on a dedicated daemon
/// thread started at the end of . Continuously
/// pulls raw UDP datagrams from the kernel buffer via
/// and writes them into
/// for the render thread to drain in
/// . Does NOT decode, reassemble, or dispatch —
/// all of that stays on the render thread to avoid ISAAC/assembler
/// thread-safety issues.
///
///
/// The 250ms receive timeout is the heartbeat: if no packet arrives
/// within 250ms, the loop re-checks the cancellation token and
/// tries again. On shutdown, cancels the token
/// and joins the thread.
///
///
private void NetReceiveLoop()
{
try
{
while (!_netCancel.Token.IsCancellationRequested)
{
var bytes = _net.Receive(TimeSpan.FromMilliseconds(250), out _);
if (bytes is not null)
_inboundQueue.Writer.TryWrite(bytes);
}
}
catch (OperationCanceledException) { /* graceful shutdown */ }
catch (System.Net.Sockets.SocketException) { /* socket closed during shutdown */ }
catch (ObjectDisposedException) { /* NetClient disposed before thread noticed */ }
finally
{
_inboundQueue.Writer.TryComplete();
}
}
///
/// Blocking single-datagram pump used during Connect/EnterWorld.
/// Returns true if a datagram was processed.
///
private bool PumpOnce()
{
return PumpOnce(out _);
}
private bool PumpOnce(out List opcodesThisCall)
{
opcodesThisCall = new List();
var bytes = _net.Receive(TimeSpan.FromMilliseconds(250), out _);
if (bytes is null) return false;
ProcessDatagram(bytes, opcodesThisCall);
return true;
}
private void ProcessDatagram(byte[] bytes, List? opcodesOut = null)
{
var dec = PacketCodec.TryDecode(bytes, _inboundIsaac);
if (!dec.IsOk) return;
// Retail LinkStatusHolder::OnHeartbeat @ 0x004113D0 updates its
// last-heard clock only for valid server traffic. Record at decode
// acceptance, before any heavy render-thread message handling.
Volatile.Write(ref _lastInboundPacketTicks, Stopwatch.GetTimestamp());
// Phase 4.9: send an ACK_SEQUENCE control packet for every received
// server packet with sequence > 0 and no ACK flag of its own. This
// is the proper holtburger pattern (every received packet gets an
// ack queued back; not periodic). Without it, ACE drops the session
// with "Network Timeout" because it sees no acks coming back —
// which surfaces in other clients' views as the player rendering
// as a stationary purple haze (loading state).
var serverHeader = dec.Packet!.Header;
if (serverHeader.Sequence > 0
&& (serverHeader.Flags & PacketHeaderFlags.AckSequence) == 0)
{
SendAck(serverHeader.Sequence);
}
// Phase G.1: propagate TimeSync-flagged server time to anyone who
// needs it (sky/day-night lerp in particular). Server sends this
// periodically — no explicit opcode, just the header flag.
if ((serverHeader.Flags & PacketHeaderFlags.TimeSync) != 0)
{
double t = dec.Packet!.Optional.TimeSync;
if (t > 0)
{
LastServerTimeTicks = t;
ServerTimeUpdated?.Invoke(t);
}
}
foreach (var frag in dec.Packet!.Fragments)
{
var body = _assembler.Ingest(frag, out _);
if (body is null || body.Length < 4) continue;
uint op = BinaryPrimitives.ReadUInt32LittleEndian(body);
opcodesOut?.Add(op);
if (op == CharacterList.Opcode && Characters is null)
{
try { Characters = CharacterList.Parse(body); }
catch { /* malformed — ignore and keep draining */ }
}
else if (op == 0xF7E5u) // DddInterrogation — server asks "what dat list versions do you have?"
{
// Phase 4.10: reply with an empty DddInterrogationResponse
// (language=1 English, count=0 lists). The server is happy
// with an empty acknowledgement; without ANY reply it keeps
// the client in a transitional state and renders us as the
// purple loading haze to other clients. Pattern from
// references/holtburger/.../client/messages.rs::DddInterrogation
SendGameMessage(DddInterrogationResponse.Build());
}
else if (op == 0xF746u && !_loginCompleteSent) // PlayerCreate — server creates our player object
{
// Phase 4.10: PlayerCreate for our character is the cue to
// send LoginComplete. Sending it earlier (right after the
// outbound CharacterEnterWorld) was wrong because the server
// hadn't finished spawning the player yet. Holtburger's
// client/messages.rs (PlayerCreate handler) confirms this is
// the correct trigger. Send once per session.
_loginCompleteSent = true;
SendGameMessage(GameActionLoginComplete.Build());
}
else if (op == CreateObject.Opcode)
{
var parsed = CreateObject.TryParse(body);
if (parsed is not null)
{
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.Take(Math.Min(body.Length, 32))
.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.TryParse(body);
if (env is not null) GameEvents.Dispatch(env.Value);
}
else if (op == 0xEA60u) // AdminEnvirons — server pushes a fog preset or sound cue
{
// Phase 5d: wire format `[u32 opcode][u32 environChangeType]`
// per chunk_006A0000.c. Dispatch the event; GameWindow
// subscribers route fog presets into WeatherSystem.Override
// and sound cues (thunder, roar, etc) into the audio engine.
if (body.Length >= 8)
{
uint envType = System.Buffers.Binary.BinaryPrimitives
.ReadUInt32LittleEndian(body.AsSpan(4, 4));
EnvironChanged?.Invoke(envType);
}
}
else if (op == 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 == 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.AsSpan(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);
}
///
/// Phase I.3: test-only hook. When non-null,
/// invokes this instead of writing to the wire. Lets unit tests verify
/// that //
/// produce the bytes they should without standing up a full handshake +
/// ISAAC keystream. Production sites never set this.
///
internal Action? GameActionCapture { get; set; }
///
/// Phase B.2: get and increment the game-action sequence counter.
/// Call once per outbound movement message; pass the returned value
/// to or
/// .
///
public uint NextGameActionSequence() => ++_gameActionSequence;
///
/// Phase I.3: send a local /say message (heard within ~20m).
/// Wraps .
///
public void SendTalk(string text)
{
ArgumentNullException.ThrowIfNull(text);
uint seq = NextGameActionSequence();
byte[] body = ChatRequests.BuildTalk(seq, text);
SendGameAction(body);
}
///
/// Phase I.3: send a /tell (whisper) by target character name.
/// Wraps .
///
public void SendTell(string targetName, string text)
{
ArgumentNullException.ThrowIfNull(targetName);
ArgumentNullException.ThrowIfNull(text);
uint seq = NextGameActionSequence();
byte[] body = ChatRequests.BuildTell(seq, targetName, text);
SendGameAction(body);
}
///
/// Phase I.3: send to a chat channel (allegiance, fellowship, etc.) by
/// the legacy ChatChannel bitflag id.
/// Wraps .
///
public void SendChannel(uint channelId, string text)
{
ArgumentNullException.ThrowIfNull(text);
uint seq = NextGameActionSequence();
byte[] body = ChatRequests.BuildChatChannel(seq, channelId, text);
SendGameAction(body);
}
///
/// Send retail 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 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 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));
}
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));
}
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));
}
///
/// 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));
}
/// Send retail IdentifyObject/Appraise (0x00C8).
public void SendAppraise(uint targetGuid)
{
uint seq = NextGameActionSequence();
SendGameAction(AppraiseRequest.Build(seq, targetGuid));
}
/// 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)
{
var fragment = GameMessageFragment.BuildSingleFragment(
_fragmentSequence++, queue, gameMessageBody);
byte[] packetBody = GameMessageFragment.Serialize(fragment);
var header = new PacketHeader
{
Sequence = _clientPacketSequence++,
Flags = PacketHeaderFlags.BlobFragments | PacketHeaderFlags.EncryptedChecksum,
Id = _sessionClientId,
};
byte[] datagram = PacketCodec.Encode(header, packetBody, _outboundIsaac);
_net.Send(datagram);
}
///
/// Phase 4.9: send a bare ACK_SEQUENCE control packet acknowledging
/// . This is a cleartext control
/// packet (no EncryptedChecksum) — the body is just the 4-byte server
/// sequence number being acknowledged. The header re-uses the most
/// recently sent client sequence (no increment) because acks aren't
/// themselves part of the reliable stream the server tracks.
///
///
/// Without sending these, ACE drops the session with
/// Network Timeout after ~60s — and during that 60s the
/// character appears to other clients as a stationary purple haze
/// (loading state) because the server hasn't seen the client confirm
/// any post-EnterWorld traffic.
///
///
///
/// Pattern ported from
/// references/holtburger/crates/holtburger-session/src/session/send.rs::send_ack
/// and the receive-side trigger at
/// .../session/receive.rs::finalize_ordered_server_packet.
///
///
private void SendAck(uint serverPacketSequence)
{
// 4-byte body: little-endian u32 of the server sequence we're acking.
Span body = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(body, serverPacketSequence);
// Holtburger uses current_client_sequence (= packet_sequence - 1) for
// ack headers. We mirror that — acks borrow the most recently issued
// client sequence rather than consuming a new one.
uint ackHeaderSequence = _clientPacketSequence > 0
? _clientPacketSequence - 1
: 0u;
var header = new PacketHeader
{
Sequence = ackHeaderSequence,
Flags = PacketHeaderFlags.AckSequence,
Id = _sessionClientId,
};
byte[] datagram = PacketCodec.Encode(header, body, outboundIsaac: null);
_net.Send(datagram);
}
private void Transition(State next)
{
if (CurrentState == next) return;
CurrentState = next;
StateChanged?.Invoke(next);
}
///
/// Graceful shutdown: tell the server we're leaving so it releases the
/// character lock immediately instead of waiting 60s for the session to
/// time out. Pattern from
/// references/holtburger/crates/holtburger-core/src/client/commands.rs
/// lines 879-892: send CharacterLogOff game message (opcode
/// 0xF653, no payload) then send a bare DISCONNECT control
/// packet (header flag 0x8000, no payload).
///
public void Dispose()
{
if (CurrentState == State.InWorld)
{
try
{
// Tell ACE "player is leaving the world" so it cleans up
// the character immediately.
var logoff = new Packets.PacketWriter(8);
logoff.WriteUInt32(0xF653u); // CharacterLogOff opcode
SendGameMessage(logoff.ToArray());
// Tell the transport layer "close this session."
var disconnectHeader = new PacketHeader
{
Sequence = _clientPacketSequence++,
Flags = PacketHeaderFlags.Disconnect,
Id = _sessionClientId,
};
byte[] disconnectPacket = PacketCodec.Encode(
disconnectHeader, ReadOnlySpan.Empty, outboundIsaac: null);
_net.Send(disconnectPacket);
}
catch
{
// Best-effort — if the socket is already dead, eat the
// exception and let Dispose finish cleaning up.
}
}
// Phase A.3: shut down the background receive thread. Cancel the
// token → the 250ms receive timeout fires → loop exits → join.
_netCancel.Cancel();
_inboundQueue.Writer.TryComplete();
_netThread?.Join(TimeSpan.FromSeconds(2));
_netCancel.Dispose();
_net.Dispose();
}
}