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