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