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