authored gmSecureTradeUI window, and both retail open paths
Three-lane research first (docs/research/2026-08-14-trade-lane{A,B,C}):
retail gmSecureTradeUI decode, the byte-exact ACE/decomp/holtburger
three-way wire agreement, and the acdream seam map (which found both
open paths ALREADY classified by the ported policy - OpenSecureTrade on
Use-a-player, StartSecureTrade on drag-item-onto-player with the
DragItemOnPlayerOpensSecureTrade option - dead-ending at a stub toast).
- Core.Net: TradeRequests builders (0x1F6-0x204, retail's CM_Trade
senders byte-checked against ACE's readers; the ACE-discarded
AcceptTrade echo carries zero-count item lists - AD-94), corrected +
completed inbound parsers (0x1FD-0x208; the old AddToTrade parser
missed the SIDE dword, TradeFailure missed the reason), delegate-hole
registrars, six WorldSession sends. 10 golden-byte tests.
- Runtime: RuntimeTradeState, the third sibling J-owner (fellowship/
allegiance shape): session-scoped, clears at generation reset (new
stage Trade=14), staged teardown stage 11 (Identity/EntityObjects
shift 12/13, TeardownStageCount 14 - the FA2-era per-stage-flag test
caught the mapping exactly as designed), combined ownership ledger,
event routing with ACE's wrong-initiator RegisterTrade landmine
honored (partner = whichever guid is not mine). 7 conformance tests.
- App: SecureTradeUiController binds the dedicated authored LayoutDesc
0x2100000D (root 0x1000007A - gmSecureTradeUI::PostInit's exact ids):
partner name/status/count/grid, the authored 'Trade' accept toggle
(accept <-> decline withdraw), 'Clear All' (ACE clears BOTH sides -
surfaced honestly), the X close, drop-on-your-grid staging, per-mode
accept cues (partner icon's authored Highlight state + Trade button
Selected latch). Mounted via the vendor recipe (nine-slice chrome,
hidden until RegisterTrade). ItemInteractionController's two policy
arms now raise SecureTradeRequested instead of the stub toast; the
drag path queues the dragged item until the window registers
(ClientTradeSystem::AttemptToTradeItem @0x0056DF80's shape).
Register: AD-94 (accept-echo zero-count lists), AD-95 (numeric-only
count texts pending template verification).
Suites: App 4,990/3, Core.Net 905, Runtime 1,626 - all green. The
panel itself is user-gate acceptance (two-client connected trade), the
#372-class lesson: fixture-green alone is not acceptance for a mount.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3245 lines
137 KiB
C#
3245 lines
137 KiB
C#
using System.Buffers;
|
||
using System.Buffers.Binary;
|
||
using System.Diagnostics;
|
||
using System.Net;
|
||
using System.Threading.Channels;
|
||
using AcDream.Core.Combat;
|
||
using AcDream.Core.Items;
|
||
using AcDream.Core.Net.Cryptography;
|
||
using AcDream.Core.Net.Messages;
|
||
using AcDream.Core.Net.Packets;
|
||
using AcDream.Core.Net.Transport;
|
||
|
||
namespace AcDream.Core.Net;
|
||
|
||
internal interface IWorldSessionTransport : IDisposable
|
||
{
|
||
void Send(ReadOnlySpan<byte> datagram);
|
||
void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram);
|
||
int Receive(
|
||
Span<byte> destination,
|
||
TimeSpan timeout,
|
||
out IPEndPoint? from);
|
||
ValueTask<NetReceiveResult> ReceiveAsync(
|
||
Memory<byte> destination,
|
||
CancellationToken cancellationToken);
|
||
}
|
||
|
||
internal sealed class NetClientWorldSessionTransport(IPEndPoint remote)
|
||
: IWorldSessionTransport
|
||
{
|
||
private readonly NetClient _client = new(remote);
|
||
|
||
public void Send(ReadOnlySpan<byte> datagram) => _client.Send(datagram);
|
||
|
||
public void Send(IPEndPoint endpoint, ReadOnlySpan<byte> datagram) =>
|
||
_client.Send(endpoint, datagram);
|
||
|
||
public int Receive(
|
||
Span<byte> destination,
|
||
TimeSpan timeout,
|
||
out IPEndPoint? from) =>
|
||
_client.Receive(destination, timeout, out from);
|
||
|
||
public ValueTask<NetReceiveResult> ReceiveAsync(
|
||
Memory<byte> destination,
|
||
CancellationToken cancellationToken) =>
|
||
_client.ReceiveAsync(destination, cancellationToken);
|
||
|
||
public void Dispose() => _client.Dispose();
|
||
}
|
||
|
||
/// <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>Still deferred:</b> unsolicited-disconnect recovery. The full
|
||
/// Campaign N reliable transport is live in both directions: outbound
|
||
/// sent-packet cache + resend on server NAK (N1), inbound sequence-aligned
|
||
/// ISAAC + NAK set (N2), the retail 2.0 s cumulative-ack sweep (N3), client
|
||
/// NAK emission + RejectRetransmit reclaim (N4), the N5 loss observability
|
||
/// + deterministic loss injection seam, and the N6 handshake hardening
|
||
/// (ConnectResponse 0.333 s retransmit + fragment-assembler eviction).
|
||
/// </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,
|
||
// 2026-05-15: from the WeenieHeader optional tail.
|
||
// Useability: retail ITEM_USEABLE enum (acclient.h:6478). Bit
|
||
// USEABLE_REMOTE (0x20) means the entity accepts R-key Use from
|
||
// the world; signs/banners have USEABLE_UNDEF (0x0) and should
|
||
// silently ignore Use attempts. null = weenieFlags didn't include
|
||
// the field (treat conservatively as not-useable).
|
||
// UseRadius: server's use-action reach in meters. Doubles as a
|
||
// sizing hint for tall-scenery selection indicators when the
|
||
// server publishes it for non-useable display entities.
|
||
uint? Useability = null,
|
||
float? UseRadius = null,
|
||
uint? TargetType = null,
|
||
// D.5.1: icon datId from CreateObject WeenieHeader, for toolbar rendering.
|
||
uint IconId = 0,
|
||
// D.5.1 (2026-06-17): icon overlay/underlay dat ids from the extended
|
||
// WeenieHeader optional tail. Gated by WeenieHeaderFlag.IconOverlay
|
||
// (0x40000000) and WeenieHeaderFlag2.IconUnderlay (0x01) respectively.
|
||
// Zero when the server did not send the field (common for most entities).
|
||
uint IconOverlayId = 0,
|
||
uint IconUnderlayId = 0,
|
||
// D.5.2 (2026-06-17): UiEffects bitfield (weenieFlags 0x80) — drives the icon's
|
||
// effect recolor. CreateObject-only; 0 = no effect.
|
||
uint UiEffects = 0,
|
||
// D.5.4 (2026-06-18): full item field set, forwarded to the object table.
|
||
// Quantity fields int? (ACE PropertyInt convention); id/mask fields uint?.
|
||
uint WeenieClassId = 0,
|
||
int? Value = null,
|
||
int? StackSize = null,
|
||
int? StackSizeMax = null,
|
||
int? Burden = null,
|
||
int? ItemsCapacity = null,
|
||
int? ContainersCapacity = null,
|
||
uint? ContainerId = null,
|
||
uint? WielderId = null,
|
||
uint? ValidLocations = null,
|
||
uint? CurrentWieldedLocation = null,
|
||
uint? Priority = null,
|
||
int? Structure = null,
|
||
int? MaxStructure = null,
|
||
float? Workmanship = null,
|
||
// L.2g S1 (DEV-6): PhysicsDesc timestamp-block stamps that seed the
|
||
// per-entity PhysicsTimestampGate (retail update_times INSTANCE_TS /
|
||
// MOVEMENT_TS / SERVER_CONTROLLED_MOVE_TS).
|
||
ushort InstanceSequence = 0,
|
||
ushort MovementSequence = 0,
|
||
ushort ServerControlSequence = 0,
|
||
ushort PositionSequence = 0,
|
||
uint? ParentGuid = null,
|
||
uint? ParentLocation = null,
|
||
uint? PlacementId = null,
|
||
// PublicWeenieDesc optional-tail bytes. null means the corresponding
|
||
// flag was absent; zero means the server explicitly sent the enum's
|
||
// undefined/default value.
|
||
byte? RadarBlipColor = null,
|
||
byte? RadarBehavior = null,
|
||
byte? CombatUse = null,
|
||
string? PluralName = null,
|
||
uint? PetOwnerId = null,
|
||
ushort? AmmoType = null,
|
||
uint? SpellId = null,
|
||
uint? CooldownId = null,
|
||
double? CooldownDuration = null,
|
||
PhysicsSpawnData? Physics = null,
|
||
uint? HookItemTypes = null,
|
||
uint? HookType = null,
|
||
uint? MaterialType = null,
|
||
// AP-129 (Campaign P Slice P4 review fix, 2026-07-30).
|
||
uint? HouseOwnerId = null,
|
||
uint? MonarchId = null,
|
||
HouseRestrictionRecord? Restrictions = null);
|
||
|
||
/// <summary>
|
||
/// Projects the wire-level CreateObject result into the stable session
|
||
/// event payload. Kept as a focused seam so every optional field's
|
||
/// absent-versus-explicit-default semantics can be tested without a UDP
|
||
/// session.
|
||
/// </summary>
|
||
internal static EntitySpawn ToEntitySpawn(CreateObject.Parsed parsed) => new(
|
||
parsed.Guid,
|
||
parsed.Position,
|
||
parsed.SetupTableId,
|
||
parsed.AnimPartChanges,
|
||
parsed.TextureChanges,
|
||
parsed.SubPalettes,
|
||
parsed.BasePaletteId,
|
||
parsed.ObjScale,
|
||
parsed.Name,
|
||
parsed.ItemType,
|
||
parsed.MotionState,
|
||
parsed.MotionTableId,
|
||
parsed.PhysicsState,
|
||
parsed.ObjectDescriptionFlags,
|
||
parsed.Friction,
|
||
parsed.Elasticity,
|
||
parsed.Useability,
|
||
parsed.UseRadius,
|
||
parsed.TargetType,
|
||
parsed.IconId,
|
||
parsed.IconOverlayId,
|
||
parsed.IconUnderlayId,
|
||
parsed.UiEffects,
|
||
parsed.WeenieClassId,
|
||
parsed.Value,
|
||
parsed.StackSize,
|
||
parsed.StackSizeMax,
|
||
parsed.Burden,
|
||
parsed.ItemsCapacity,
|
||
parsed.ContainersCapacity,
|
||
parsed.ContainerId,
|
||
parsed.WielderId,
|
||
parsed.ValidLocations,
|
||
parsed.CurrentWieldedLocation,
|
||
parsed.Priority,
|
||
parsed.Structure,
|
||
parsed.MaxStructure,
|
||
parsed.Workmanship,
|
||
InstanceSequence: parsed.InstanceSequence,
|
||
MovementSequence: parsed.MovementSequence,
|
||
ServerControlSequence: parsed.ServerControlSequence,
|
||
PositionSequence: parsed.PositionSequence,
|
||
ParentGuid: parsed.ParentGuid,
|
||
ParentLocation: parsed.ParentLocation,
|
||
PlacementId: parsed.PlacementId,
|
||
RadarBlipColor: parsed.RadarBlipColor,
|
||
RadarBehavior: parsed.RadarBehavior,
|
||
CombatUse: parsed.CombatUse,
|
||
PluralName: parsed.PluralName,
|
||
PetOwnerId: parsed.PetOwnerId,
|
||
AmmoType: parsed.AmmoType,
|
||
SpellId: parsed.SpellId,
|
||
CooldownId: parsed.CooldownId,
|
||
CooldownDuration: parsed.CooldownDuration,
|
||
Physics: parsed.Physics,
|
||
HookItemTypes: parsed.HookItemTypes,
|
||
HookType: parsed.HookType,
|
||
MaterialType: parsed.MaterialType,
|
||
HouseOwnerId: parsed.HouseOwnerId,
|
||
MonarchId: parsed.MonarchId,
|
||
Restrictions: parsed.Restrictions);
|
||
|
||
/// <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>
|
||
/// Fires for retail PickupEvent (0xF74A). Pickup advances the object's
|
||
/// shared POSITION_TS and removes only its world projection; it is not a
|
||
/// DeleteObject and does not destroy the timestamp owner or weenie.
|
||
/// </summary>
|
||
public event Action<PickupEvent.Parsed>? EntityPickedUp;
|
||
|
||
/// <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,
|
||
ushort InstanceSequence,
|
||
ushort MovementSequence,
|
||
ushort ServerControlSequence,
|
||
bool IsAutonomous);
|
||
|
||
/// <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,
|
||
uint? PlacementId,
|
||
bool IsGrounded,
|
||
ushort InstanceSequence,
|
||
ushort PositionSequence,
|
||
ushort TeleportSequence,
|
||
ushort ForcePositionSequence);
|
||
|
||
/// <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 for retail <c>ParentEvent (0xF749)</c>, which attaches a separate
|
||
/// child object to a creature holding location (weapons, shields, ammo).
|
||
/// </summary>
|
||
public event Action<ParentEvent.Parsed>? ParentUpdated;
|
||
|
||
/// <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>
|
||
/// Payload for <see cref="ObjectIntPropertyUpdated"/>: a single PropertyInt change on
|
||
/// a visible object (from PublicUpdatePropertyInt 0x02CE). Subscribers map the
|
||
/// property to typed state (e.g. UiEffects → the item's icon effect).
|
||
/// </summary>
|
||
public readonly record struct ObjectIntPropertyUpdate(uint Guid, uint Property, int Value);
|
||
|
||
/// <summary>
|
||
/// Fires when the session parses a PublicUpdatePropertyInt (0x02CE) — one
|
||
/// PropertyInt updated on a visible object. D.5.2 routes UiEffects (18) to the
|
||
/// item repository so the icon re-composites live.
|
||
/// </summary>
|
||
public event Action<ObjectIntPropertyUpdate>? ObjectIntPropertyUpdated;
|
||
|
||
/// <summary>Payload for <see cref="PlayerIntPropertyUpdated"/>: a PropertyInt change on
|
||
/// the player's OWN object (from PrivateUpdatePropertyInt 0x02CD — no guid on the wire).</summary>
|
||
public readonly record struct PlayerIntPropertyUpdate(uint Property, int Value);
|
||
|
||
/// <summary>Fires when the session parses a PrivateUpdatePropertyInt (0x02CD) — one
|
||
/// PropertyInt updated on the player. B-Wire routes EncumbranceVal (5) to the burden bar.</summary>
|
||
public event Action<PlayerIntPropertyUpdate>? PlayerIntPropertyUpdated;
|
||
|
||
/// <summary>Payload for <see cref="PlayerInt64PropertyUpdated"/>: a signed
|
||
/// 64-bit quality change on the player's own object. Retail sends Total XP
|
||
/// (1) and Available XP (2) through PrivateUpdatePropertyInt64 (0x02CF).</summary>
|
||
public readonly record struct PlayerInt64PropertyUpdate(uint Property, long Value);
|
||
|
||
/// <summary>Fires after parsing retail PrivateUpdatePropertyInt64 (0x02CF).
|
||
/// The wire carries no guid because the local player is implicit.</summary>
|
||
public event Action<PlayerInt64PropertyUpdate>? PlayerInt64PropertyUpdated;
|
||
|
||
/// <summary>Payload for <see cref="StackSizeUpdated"/>: SetStackSize (0x0197) — a stack's
|
||
/// count + value after a merge / split.</summary>
|
||
public readonly record struct StackSizeUpdate(uint Guid, int StackSize, int Value);
|
||
|
||
/// <summary>Fires when the session parses a SetStackSize (0x0197) top-level GameMessage.</summary>
|
||
public event Action<StackSizeUpdate>? StackSizeUpdated;
|
||
|
||
/// <summary>Fires when the session parses an InventoryRemoveObject (0x0024) — the guid left
|
||
/// the player's inventory view.</summary>
|
||
public event Action<uint>? InventoryObjectRemoved;
|
||
|
||
/// <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<PlayPhysicsScript>? PlayPhysicsScriptReceived;
|
||
|
||
/// <summary>Fires for retail typed PhysicsScript playback (0xF755).</summary>
|
||
public event Action<PlayPhysicsScriptType>? PlayPhysicsScriptTypeReceived;
|
||
|
||
/// <summary>
|
||
/// Fires for retail's <c>Sound</c> event (<c>0xF750</c>) — the server-driven
|
||
/// sound channel: hits, wounds, wield/unwield, pickup/drop, lockpicking,
|
||
/// lifestone bind, spell resist, trap triggers, item mana depletion.
|
||
///
|
||
/// <para>
|
||
/// Retail's chain is <c>CM_Physics::DispatchSB_SoundEvent</c> @
|
||
/// <c>0x006AC760</c> → <c>SmartBox::HandleSoundEvent</c> @ <c>0x00451FC0</c>
|
||
/// → <c>CPhysicsObj::play_sound</c> @ <c>0x0050F460</c>. Two behaviours the
|
||
/// consumer owns, both from that decode: an event for a guid the client does
|
||
/// not know yet is QUEUED against that guid and replayed when the object
|
||
/// arrives (not dropped), and an object with no SoundTable plays nothing.
|
||
/// The wire volume is authoritative — unlike the animation-hook path, retail
|
||
/// ignores the SoundTable entry's own volume here.
|
||
/// </para>
|
||
/// </summary>
|
||
public event Action<SoundEvent>? SoundEventReceived;
|
||
|
||
/// <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="PlayPhysicsScriptReceived"/> 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>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>
|
||
/// Network-owned source for retail
|
||
/// <c>LinkStatusHolder::GetConnectionStatus @ 0x00411380</c>. The age is
|
||
/// measured from the last successfully decoded server datagram using the
|
||
/// monotonic Stopwatch clock; presentation thresholds remain in the UI.
|
||
/// </summary>
|
||
public LinkStatusSnapshot LinkStatus => BuildLinkStatus(
|
||
CurrentState,
|
||
Volatile.Read(ref _lastInboundPacketTicks),
|
||
Stopwatch.GetTimestamp(),
|
||
Stopwatch.Frequency,
|
||
PingRoundTripSeconds);
|
||
|
||
internal double? PingRoundTripSeconds
|
||
{
|
||
get
|
||
{
|
||
double value = BitConverter.Int64BitsToDouble(
|
||
Volatile.Read(ref _lastPingRoundTripBits));
|
||
return double.IsFinite(value) && value >= 0d ? value : null;
|
||
}
|
||
}
|
||
|
||
internal static LinkStatusSnapshot BuildLinkStatus(
|
||
State state,
|
||
long lastInboundPacketTicks,
|
||
long nowTicks,
|
||
long frequency,
|
||
double? roundTripSeconds = null,
|
||
double packetLossPercentage = 0d)
|
||
{
|
||
bool connected = state is not State.Disconnected and not State.Failed;
|
||
if (!connected || frequency <= 0)
|
||
return LinkStatusSnapshot.Disconnected;
|
||
|
||
long elapsed = Math.Max(0, nowTicks - lastInboundPacketTicks);
|
||
return new LinkStatusSnapshot(
|
||
true,
|
||
elapsed / (double)frequency,
|
||
packetLossPercentage,
|
||
roundTripSeconds);
|
||
}
|
||
|
||
private void RecordPingResponse(long nowTicks)
|
||
{
|
||
long requestTicks = Interlocked.Exchange(ref _lastPingRequestTicks, 0L);
|
||
if (requestTicks <= 0L || nowTicks < requestTicks || Stopwatch.Frequency <= 0)
|
||
return;
|
||
|
||
double elapsed = (nowTicks - requestTicks) / (double)Stopwatch.Frequency;
|
||
Volatile.Write(
|
||
ref _lastPingRoundTripBits,
|
||
BitConverter.DoubleToInt64Bits(elapsed));
|
||
}
|
||
|
||
/// <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;
|
||
|
||
/// <summary>
|
||
/// Publishes the local player's canonical, freshness-accepted physics
|
||
/// timestamps for subsequent outbound movement messages. The App runtime
|
||
/// calls this only after <c>PhysicsTimestampGate</c> commits the matching
|
||
/// CreateObject/Movement/Position event; parsing alone never changes
|
||
/// outbound authority.
|
||
/// </summary>
|
||
public void PublishAcceptedLocalPhysicsTimestamps(
|
||
ushort instance,
|
||
ushort serverControlledMove,
|
||
ushort teleport,
|
||
ushort forcePosition)
|
||
{
|
||
_instanceSequence = instance;
|
||
_serverControlSequence = serverControlledMove;
|
||
_teleportSequence = teleport;
|
||
_forcePositionSequence = forcePosition;
|
||
}
|
||
|
||
public CharacterList.Parsed? Characters { get; private set; }
|
||
|
||
private readonly IWorldSessionTransport _net;
|
||
private long _lastInboundPacketTicks = Stopwatch.GetTimestamp();
|
||
private long _lastPingRequestTicks;
|
||
private long _lastPingRoundTripBits = BitConverter.DoubleToInt64Bits(double.NaN);
|
||
private readonly IPEndPoint _loginEndpoint;
|
||
private readonly IPEndPoint _connectEndpoint;
|
||
private readonly FragmentAssembler _assembler = new();
|
||
|
||
// Issue #5 diagnostics (env-var-gated):
|
||
// ACDREAM_DUMP_OPCODES=1 → log first occurrence of each unhandled opcode
|
||
// ACDREAM_DUMP_VITALS=1 → log every PrivateUpdateVital(Current) parse
|
||
// ACDREAM_DUMP_APPEARANCE=1 → log every 0xF625 ObjDescEvent + 0xF7DB UpdateObject
|
||
// with body len, target guid, hex preview. Used to
|
||
// debug remote-player appearance asymmetry (retail
|
||
// observer in acdream renders wrong skin/hair).
|
||
private static readonly bool DumpOpcodesEnabled =
|
||
Environment.GetEnvironmentVariable("ACDREAM_DUMP_OPCODES") == "1";
|
||
private static readonly bool DumpVitalsEnabled =
|
||
Environment.GetEnvironmentVariable("ACDREAM_DUMP_VITALS") == "1";
|
||
private static readonly bool DumpAppearanceEnabled =
|
||
Environment.GetEnvironmentVariable("ACDREAM_DUMP_APPEARANCE") == "1";
|
||
private readonly System.Collections.Generic.HashSet<uint> _seenUnhandledOpcodes = new();
|
||
|
||
private ushort _sessionClientId;
|
||
private ushort _sessionIteration;
|
||
private bool _transportNegotiated;
|
||
|
||
/// <summary>
|
||
/// N6: retail's ConnectResponse resend cadence — the x87 compare against
|
||
/// 0.333333333 in <c>ClientNet::ProcessConnection @ 0x00545450</c>
|
||
/// (case <c>cs_ConnectionRequestAcked</c> at 0x0054547B; the constant
|
||
/// load at 0x00545481). The mask-0x41 status test at 0x0054548C bails on
|
||
/// less-than OR equal, so the gate opens only STRICTLY past the
|
||
/// boundary — the same strict shape as the N4 NAK gate.
|
||
/// </summary>
|
||
internal const double ConnectResponseRetrySeconds = 0.333333333;
|
||
|
||
/// <summary>
|
||
/// N6: true once ANY checksum-valid post-negotiation server packet has
|
||
/// been decoded — the port of retail's connection confirmation:
|
||
/// <c>ClientNet::ProcessPacket @ 0x00545100</c> promotes
|
||
/// <c>cs_ConnectionRequestAcked → cs_Connected</c> (the
|
||
/// <c>SetConnectionState(..., 5)</c> vtable call at 0x00545160) on the
|
||
/// first successfully processed packet whose header lacks the
|
||
/// ConnectRequest flag (the 0x40000 test at 0x0054514E), and the resend
|
||
/// case never fires again. While false, the Connect pump resends the
|
||
/// IDENTICAL cleartext ConnectResponse (same sequence 1, same cookie —
|
||
/// no new outbound state) every
|
||
/// <see cref="ConnectResponseRetrySeconds"/>.
|
||
/// </summary>
|
||
private bool _handshakeConfirmed;
|
||
|
||
/// <summary>
|
||
/// Campaign N Slices N1+N2: the reliable transport — both ISAAC
|
||
/// keystreams, packet/fragment sequences, sent-packet cache, resend on
|
||
/// NAK (outbound), and the sequence-aligned inbound tracker + NAK set
|
||
/// (inbound). Constructed at ISAAC-seeding time in <see cref="Connect"/>;
|
||
/// null before negotiation (neither keystream exists yet).
|
||
/// </summary>
|
||
private ReliableTransport? _transport;
|
||
|
||
/// <summary>Test seam: transport counters + cache depth for the
|
||
/// conformance/loss suites. Null before negotiation.</summary>
|
||
internal ReliableTransport? Transport => _transport;
|
||
|
||
/// <summary>
|
||
/// N3 test seam: injectable monotonic source for the transport clock so
|
||
/// the conformance suite can drive the 2.0 s cumulative-ack gate (and
|
||
/// the 0.5 s interval counter) on virtual time. Must be set BEFORE
|
||
/// <see cref="Connect"/> (the transport is born there). Null →
|
||
/// production <see cref="Stopwatch"/> timing.
|
||
/// </summary>
|
||
internal (Func<long> GetTimestamp, long Frequency)? TransportClockSource
|
||
{ get; set; }
|
||
|
||
// Movement sequence counters — echoed back in every MoveToState and
|
||
// AutonomousPosition so the server can detect stale/reordered packets.
|
||
// Initialized from CreateObject PhysicsData timestamps, updated by
|
||
// accepted UpdatePosition/UpdateMotion packets. Per holtburger:
|
||
// instance=slot 8, teleport=slot 4, serverControl=slot 5, forcePosition=slot 6.
|
||
private ushort _instanceSequence;
|
||
private ushort _serverControlSequence;
|
||
private ushort _teleportSequence;
|
||
private ushort _forcePositionSequence;
|
||
private uint _activeCharacterId;
|
||
private int _characterLogOffConfirmed;
|
||
private int _disposeStarted;
|
||
|
||
// Retail CNetLayerPacket stores ProtoHeader separately beside
|
||
// m_Data[65484] (named-retail acclient.h, type 3705). Keep one full UDP
|
||
// receive buffer at the socket edge; only the actual byte count crosses
|
||
// the queue boundary in a right-sized ArrayPool bucket.
|
||
internal const int MaxInboundDatagramBytes = ushort.MaxValue;
|
||
|
||
// Phase A.3 / Slice H-c1: one asynchronous socket owner buffers raw UDP
|
||
// datagrams into a FIFO channel so the render thread never blocks on I/O.
|
||
private readonly Channel<PooledInboundDatagram> _inboundQueue =
|
||
Channel.CreateUnbounded<PooledInboundDatagram>(
|
||
new UnboundedChannelOptions
|
||
{ SingleReader = true, SingleWriter = true });
|
||
private Task? _netReceiveTask;
|
||
private readonly CancellationTokenSource _netCancel = new();
|
||
|
||
internal readonly record struct PooledInboundDatagram(
|
||
byte[] Buffer,
|
||
int Length)
|
||
{
|
||
public ReadOnlyMemory<byte> Memory =>
|
||
Buffer.AsMemory(0, Length);
|
||
}
|
||
|
||
/// <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)
|
||
: this(
|
||
serverLogin,
|
||
// N5: ACDREAM_NET_DROP_PCT > 0 wraps the socket transport in the
|
||
// deterministic LossyTransportDecorator (the connected loss
|
||
// gate's injection point). At the default 0 the decorator is
|
||
// structurally absent — WrapIfConfigured returns the raw
|
||
// transport and never constructs the wrapper.
|
||
static endpoint => LossyTransportDecorator.WrapIfConfigured(
|
||
new NetClientWorldSessionTransport(endpoint)))
|
||
{
|
||
}
|
||
|
||
internal WorldSession(
|
||
IPEndPoint serverLogin,
|
||
IWorldSessionTransport transport)
|
||
: this(serverLogin, _ => transport)
|
||
{
|
||
}
|
||
|
||
internal WorldSession(
|
||
IPEndPoint serverLogin,
|
||
Func<IPEndPoint, IWorldSessionTransport> transportFactory)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(serverLogin);
|
||
ArgumentNullException.ThrowIfNull(transportFactory);
|
||
if (serverLogin.Port == ushort.MaxValue)
|
||
throw new ArgumentOutOfRangeException(
|
||
nameof(serverLogin),
|
||
"The login endpoint must leave room for the adjacent connect port.");
|
||
|
||
_loginEndpoint = serverLogin;
|
||
_connectEndpoint = new IPEndPoint(serverLogin.Address, serverLogin.Port + 1);
|
||
_net = transportFactory(serverLogin)
|
||
?? throw new InvalidOperationException("The session transport factory returned null.");
|
||
|
||
// Phase I.6: SetTurbineChatChannels (0x0295) is a GameEvent
|
||
// sub-opcode of 0xF7B0, not a top-level opcode. Route it through
|
||
// the dispatcher and surface a typed event so downstream wiring
|
||
// (GameEventWiring → TurbineChatState) doesn't need to know the
|
||
// GameEvent envelope encoding.
|
||
GameEvents.Register(GameEventType.SetTurbineChatChannels, e =>
|
||
{
|
||
var parsed = SetTurbineChatChannels.TryParse(e.Payload.Span);
|
||
if (parsed is not null) TurbineChannelsReceived?.Invoke(parsed.Value);
|
||
});
|
||
GameEvents.Register(GameEventType.PingResponse, e =>
|
||
{
|
||
if (Messages.GameEvents.ParsePingResponse(e.Payload.Span))
|
||
RecordPingResponse(Stopwatch.GetTimestamp());
|
||
});
|
||
}
|
||
|
||
/// <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
|
||
bool connectRequestReceived = false;
|
||
BorrowedOptionalHeader connectRequest = default;
|
||
ushort connectRequestIteration = 0;
|
||
while (DateTime.UtcNow < deadline
|
||
&& !connectRequestReceived)
|
||
{
|
||
PooledInboundDatagram? received =
|
||
ReceiveBlocking(deadline - DateTime.UtcNow);
|
||
if (received is null)
|
||
break;
|
||
|
||
PooledInboundDatagram datagram = received.Value;
|
||
try
|
||
{
|
||
// N2: pure parse + cleartext verify. No tracker exists
|
||
// before the ISAAC seeds do, and the ConnectRequest is a
|
||
// cleartext sequence-0 handshake packet; anything encrypted
|
||
// here is undecodable and skipped, matching the pre-N2
|
||
// null-keystream behavior.
|
||
bool parsedOk = PacketCodec.TryParseBorrowed(
|
||
datagram.Memory,
|
||
out BorrowedPacket parsed,
|
||
out uint headerHash,
|
||
out uint payloadHash,
|
||
out _);
|
||
PacketHeader parsedHeader = parsed.Header;
|
||
if (parsedOk
|
||
&& !parsedHeader.HasFlag(
|
||
PacketHeaderFlags.EncryptedChecksum)
|
||
&& PacketCodec.VerifyChecksum(
|
||
in parsedHeader,
|
||
headerHash,
|
||
payloadHash,
|
||
isaacKey: null)
|
||
&& parsedHeader.HasFlag(
|
||
PacketHeaderFlags.ConnectRequest))
|
||
{
|
||
connectRequest = parsed.Optional with
|
||
{
|
||
RawBytes = ReadOnlyMemory<byte>.Empty,
|
||
RetransmitRequestBytes =
|
||
ReadOnlyMemory<byte>.Empty,
|
||
RejectRetransmitBytes =
|
||
ReadOnlyMemory<byte>.Empty,
|
||
};
|
||
connectRequestIteration =
|
||
parsedHeader.Iteration;
|
||
connectRequestReceived = true;
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
ReturnInboundDatagram(datagram);
|
||
}
|
||
}
|
||
if (!connectRequestReceived)
|
||
{
|
||
Transition(State.Failed);
|
||
throw new TimeoutException(
|
||
"ConnectRequest not received");
|
||
}
|
||
|
||
// Step 3: seed ISAAC, send ConnectResponse to port+1, with 200ms race delay
|
||
BorrowedOptionalHeader opt = connectRequest;
|
||
|
||
// Phase G.1: server's initial PortalYearTicks (r12 §1.3) lives
|
||
// in the ConnectRequest optional section. Publish it to
|
||
// subscribers so WorldTimeService.SyncFromServer can seed the
|
||
// client clock.
|
||
byte[] serverSeedBytes = new byte[4];
|
||
BinaryPrimitives.WriteUInt32LittleEndian(serverSeedBytes, opt.ConnectRequestServerSeed);
|
||
byte[] clientSeedBytes = new byte[4];
|
||
BinaryPrimitives.WriteUInt32LittleEndian(clientSeedBytes, opt.ConnectRequestClientSeed);
|
||
_sessionClientId = (ushort)opt.ConnectRequestClientId;
|
||
// SharedNet::SendOptionalHeader @ 0x00543160 copies this ReceiverData
|
||
// generation into connection-level control packets, including the
|
||
// final disconnect. ACE currently emits iteration 1.
|
||
_sessionIteration = connectRequestIteration;
|
||
// N1+N2+N3+N4: the reliable transport is born at ISAAC-seeding
|
||
// time, owning BOTH keystreams and the ack/NAK sweep. Outbound:
|
||
// highestIDSent starts 1 (the ConnectResponse below carries
|
||
// sequence 1), so the first reliable packet after the handshake
|
||
// keeps packet sequence 2 and fragment sequence 1. Inbound: the
|
||
// tracker owns the server keystream, the received watermark, and
|
||
// the NAK set (campaign §2.2); its watermark starts 1 (see
|
||
// InboundSequenceTracker.AceInitialWatermark). The scheduler's
|
||
// shared ack/NAK gate arms here, at connection birth
|
||
// (ReceiverData::SharedInit @ 0x00548EF0, reached from
|
||
// ReceiverData::Init @ 0x00548FA0, stamps timeStamp_ = cur_time).
|
||
_transport = new ReliableTransport(
|
||
new IsaacRandom(clientSeedBytes),
|
||
new IsaacRandom(serverSeedBytes),
|
||
_sessionClientId,
|
||
_sessionIteration,
|
||
datagram => _net.Send(datagram),
|
||
clock: TransportClockSource is { } clockSource
|
||
? new TransportClock(
|
||
clockSource.GetTimestamp,
|
||
clockSource.Frequency)
|
||
: null,
|
||
// N6: the sweep ages out abandoned fragment partials (5 s
|
||
// cadence, 60 s TTL — FragmentAssembler doc + AD-52).
|
||
assembler: _assembler);
|
||
_transportNegotiated = true;
|
||
|
||
// Publish only after the receiver identity and crypto state are fully
|
||
// committed. A synchronous App callback may throw or request teardown;
|
||
// disposal must still send the negotiated Disconnect.
|
||
LastServerTimeTicks = opt.ConnectRequestServerTime;
|
||
ServerTimeUpdated?.Invoke(opt.ConnectRequestServerTime);
|
||
|
||
byte[] crBody = new byte[8];
|
||
BinaryPrimitives.WriteUInt64LittleEndian(crBody, opt.ConnectRequestCookie);
|
||
var crHeader = new PacketHeader { Sequence = 1, Flags = PacketHeaderFlags.ConnectResponse, Id = 0 };
|
||
byte[] connectResponseDatagram = PacketCodec.Encode(crHeader, crBody, null);
|
||
Thread.Sleep(200);
|
||
_net.Send(_connectEndpoint, connectResponseDatagram);
|
||
|
||
// N6: arm the handshake resend clock. Retail stamps
|
||
// lastSentHandshake_ inside every ClientNet::SendConnectAck
|
||
// (@ 0x005440F0, the store at 0x00544102) and rebuilds the same
|
||
// ConnectResponse from the stored cookie each time; we keep the one
|
||
// encoded datagram and resend it verbatim — identical cleartext,
|
||
// sequence 1, no new state consumed. The cadence rides the
|
||
// transport clock so the conformance suite can drive it on virtual
|
||
// time; the Connect deadline stays wall-clock.
|
||
TransportClock handshakeClock = _transport.Clock;
|
||
long handshakeRetryTicks = (long)Math.Round(
|
||
ConnectResponseRetrySeconds * handshakeClock.Frequency);
|
||
long handshakeSentTimestamp = handshakeClock.GetTimestamp();
|
||
|
||
Transition(State.InCharacterSelect);
|
||
|
||
// Step 4: drain until CharacterList arrives. The transport sweep
|
||
// runs inside this blocking pump too (campaign landmine #8): the
|
||
// first server NAK can precede the first Tick(). This pump is also
|
||
// retail's cs_ConnectionRequestAcked resend window
|
||
// (ClientNet::ProcessConnection @ 0x00545450 case 0 at 0x0054547B):
|
||
// until the first decoded server packet confirms the connection, a
|
||
// lost ConnectResponse is re-sent every 0.333 s — without it, one
|
||
// dropped handshake datagram is a hang to the Connect deadline
|
||
// (the N5 loss decorator deliberately arms AFTER this window).
|
||
while (DateTime.UtcNow < deadline && Characters is null)
|
||
{
|
||
if (!_handshakeConfirmed
|
||
&& handshakeClock.GetTimestamp() - handshakeSentTimestamp
|
||
> handshakeRetryTicks)
|
||
{
|
||
_net.Send(_connectEndpoint, connectResponseDatagram);
|
||
handshakeSentTimestamp = handshakeClock.GetTimestamp();
|
||
}
|
||
|
||
PumpOnce();
|
||
SweepTransport();
|
||
}
|
||
if (Characters is null) { Transition(State.Failed); throw new TimeoutException("CharacterList not received"); }
|
||
}
|
||
|
||
/// <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(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");
|
||
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(10));
|
||
EnterWorldSelection selection = SelectCharacterForEnterWorld(
|
||
Characters,
|
||
characterIndex);
|
||
CharacterList.Character chosen = selection.Character;
|
||
_activeCharacterId = chosen.Id;
|
||
Transition(State.EnteringWorld);
|
||
|
||
SendGameMessage(CharacterEnterWorld.BuildEnterWorldRequestBody());
|
||
|
||
// Wait for CharacterEnterWorldServerReady (0xF7DF). Sweep inside
|
||
// the blocking pump (campaign landmine #8): the EnterWorld
|
||
// CreateObject flood — and any NAK it provokes — precedes the
|
||
// first Tick().
|
||
bool serverReady = false;
|
||
while (DateTime.UtcNow < deadline && !serverReady)
|
||
{
|
||
var drained = PumpOnce(out var opcodes);
|
||
SweepTransport();
|
||
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"); }
|
||
|
||
// CPlayerSystem::LogOnCharacter @ 0x0055F890 passes the account
|
||
// populated by CharacterSet::UnPack, not the spelling supplied to the
|
||
// login form. ACE validates this canonical account value.
|
||
SendGameMessage(selection.EnterWorldBody);
|
||
|
||
// LoginComplete is emitted by the host only after the accepted local
|
||
// Create has completed its canonical first placement. Sending it at
|
||
// EnterWorld or merely on PlayerCreate races the server's intentional
|
||
// Hidden/pink-bubble login state.
|
||
Transition(State.InWorld);
|
||
|
||
// 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.
|
||
_netReceiveTask = NetReceiveLoopAsync();
|
||
}
|
||
|
||
internal readonly record struct EnterWorldSelection(
|
||
CharacterList.Character Character,
|
||
byte[] EnterWorldBody);
|
||
|
||
/// <summary>
|
||
/// Applies retail's selected-active-character gate and binds the F657
|
||
/// account field to the canonical CharacterSet account. Kept pure so the
|
||
/// public session invariant is conformance-tested without opening a socket.
|
||
/// </summary>
|
||
internal static EnterWorldSelection SelectCharacterForEnterWorld(
|
||
CharacterList.Parsed characters,
|
||
int characterIndex)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(characters);
|
||
if (characterIndex < 0 || characterIndex >= characters.Characters.Count)
|
||
throw new ArgumentOutOfRangeException(nameof(characterIndex));
|
||
|
||
CharacterList.Character chosen = characters.Characters[characterIndex];
|
||
if (!CharacterList.IsAvailableActiveIdentity(chosen))
|
||
throw new InvalidOperationException(
|
||
"selected character must be an active, non-greyed identity");
|
||
|
||
return new EnterWorldSelection(
|
||
chosen,
|
||
CharacterEnterWorld.BuildEnterWorldBody(
|
||
chosen.Id,
|
||
characters.AccountName));
|
||
}
|
||
|
||
// Per-frame inbound time budget (#2 flood timeslice). On a teleport arrival ACE floods
|
||
// a town's CreateObjects; draining them ALL in one Tick (each hydrates mesh + textures
|
||
// under _datLock on the render thread) monopolized the update loop for ~a minute and
|
||
// starved the streaming apply, so neighbor landblocks trickled in. A wall-clock budget
|
||
// self-adapts to the highly variable per-datagram cost (cheap UpdatePosition vs an
|
||
// expensive clothed CreateObject); the tail drains over the next few frames, FIFO
|
||
// preserved, nothing lost (the inbox channel is unbounded). ~4ms leaves the rest of a
|
||
// 60fps update frame for streaming + physics.
|
||
private static readonly long InboundBudgetTicks = Stopwatch.Frequency / 1000 * 4;
|
||
|
||
/// <summary>
|
||
/// Non-blocking pump. Drains datagrams buffered by the background net thread (Phase A.3),
|
||
/// decodes them, and fires events. Call once per game-loop frame. Once in-world the drain
|
||
/// is bounded to ~4ms so a CreateObject flood can't monopolize the frame. Returns the
|
||
/// number of datagrams processed.
|
||
/// </summary>
|
||
public int Tick()
|
||
{
|
||
int processed = 0;
|
||
bool budgetBroke = false;
|
||
long start = Stopwatch.GetTimestamp();
|
||
while (_inboundQueue.Reader.TryRead(
|
||
out PooledInboundDatagram datagram))
|
||
{
|
||
if (NetDiagnostics.ProbeNet)
|
||
Interlocked.Decrement(ref _probeInboundDepth);
|
||
try
|
||
{
|
||
ProcessDatagram(datagram.Memory);
|
||
}
|
||
finally
|
||
{
|
||
ReturnInboundDatagram(datagram);
|
||
}
|
||
processed++;
|
||
// Bound ONLY in-world: the handshake uses the blocking PumpOnce path, never Tick
|
||
// (the async receive owner starts at Transition(State.InWorld)).
|
||
// Acks and NAKs are NOT per-packet: the end-of-Tick sweep below emits them on
|
||
// the scheduler's 2.0 s / 0.6 s gates, and it runs after the budget break, so a
|
||
// deferred inbound tail never defers a due ack, NAK, or resend. The tail itself
|
||
// stays queued (unbounded channel, FIFO) and drains next frame — its only cost
|
||
// is that the cumulative ack keeps carrying the pre-tail watermark until the
|
||
// tail is processed, well inside ACE's 120 s cache retention.
|
||
if (InboundBudgetExceeded(CurrentState, start, Stopwatch.GetTimestamp(), InboundBudgetTicks))
|
||
{
|
||
budgetBroke = true;
|
||
break;
|
||
}
|
||
}
|
||
if (NetDiagnostics.ProbeNet)
|
||
ProbeNetTickCadence(start, processed, budgetBroke);
|
||
// N1: the transport sweep runs at the end of EVERY Tick, after the
|
||
// budget break — a deferred inbound tail must not defer a due
|
||
// resend past this frame.
|
||
SweepTransport();
|
||
return processed;
|
||
}
|
||
|
||
/// <summary>
|
||
/// N1+N3+N4: one reliable-transport pump slice (retail
|
||
/// <c>PacketController::UseTime @ 0x005410D0</c> shape): interval clock
|
||
/// forward, NAK-xor-ack arbitration, pending NAKed resends out, acked
|
||
/// cache pruned. Gated on negotiation — ACE's
|
||
/// <c>Session.CheckState</c> silently discards pre-negotiation control
|
||
/// traffic (campaign landmine #8), and the transport does not exist
|
||
/// before the ISAAC seeds do.
|
||
/// </summary>
|
||
private void SweepTransport()
|
||
{
|
||
if (!_transportNegotiated)
|
||
return;
|
||
_transport?.Sweep();
|
||
}
|
||
|
||
// #260 probe state — only touched when NetDiagnostics.ProbeNet is set.
|
||
// The inter-Tick gap doubles as a frame-stall witness: Tick runs once per
|
||
// frame on the frame thread, so a GC pause or saturated frame shows up
|
||
// directly as maxgap. _probeSendWindow is Interlocked so a hypothetical
|
||
// off-thread send can't corrupt the window counter (the [net-out] tid
|
||
// field is what would prove such a send exists). Acks are counted by the
|
||
// transport (Stats.AcksSent, incremented on the frame-thread sweep);
|
||
// _probeAckSeenTotal is the last cumulative value the probe printed.
|
||
private long _probeLastTickTs;
|
||
private long _probeWindowStartTs;
|
||
private long _probeMaxGapTicks;
|
||
private int _probeProcessedWindow;
|
||
private int _probeBudgetBreaks;
|
||
private int _probeSendWindow;
|
||
private long _probeAckSeenTotal;
|
||
// N5 loss-observability window baselines — the cumulative TransportStats
|
||
// value each counter had when the probe last printed, mirroring
|
||
// _probeAckSeenTotal. Only touched when NetDiagnostics.ProbeNet is set.
|
||
private long _probeResendSeenTotal;
|
||
private long _probeNakOutSeenTotal;
|
||
private long _probeNakInSeenTotal;
|
||
private long _probeRejInSeenTotal;
|
||
private long _probeDupDropSeenTotal;
|
||
private long _probeParkedSeenTotal;
|
||
private long _probeReclaimSeenTotal;
|
||
// Probe-owned queue depth: the SingleReader channel's Reader.Count
|
||
// throws NotSupportedException, so the net thread increments on
|
||
// enqueue and the frame thread decrements on dequeue instead.
|
||
private int _probeInboundDepth;
|
||
|
||
/// <summary>
|
||
/// #260 probe: accumulate per-Tick cadence facts and emit one
|
||
/// <c>[net-tick]</c> summary line per second.
|
||
/// </summary>
|
||
private void ProbeNetTickCadence(long tickStartTs, int processed, bool budgetBroke)
|
||
{
|
||
if (_probeLastTickTs != 0)
|
||
{
|
||
long gap = tickStartTs - _probeLastTickTs;
|
||
if (gap > _probeMaxGapTicks)
|
||
_probeMaxGapTicks = gap;
|
||
}
|
||
_probeLastTickTs = tickStartTs;
|
||
_probeProcessedWindow += processed;
|
||
if (budgetBroke)
|
||
_probeBudgetBreaks++;
|
||
|
||
if (_probeWindowStartTs == 0)
|
||
{
|
||
_probeWindowStartTs = tickStartTs;
|
||
return;
|
||
}
|
||
long windowTicks = tickStartTs - _probeWindowStartTs;
|
||
if (windowTicks < Stopwatch.Frequency)
|
||
return;
|
||
|
||
double windowSeconds = (double)windowTicks / Stopwatch.Frequency;
|
||
double maxGapMs = _probeMaxGapTicks * 1000.0 / Stopwatch.Frequency;
|
||
int sends = Interlocked.Exchange(ref _probeSendWindow, 0);
|
||
ReliableTransport? transport = _transport;
|
||
TransportStats? stats = transport?.Stats;
|
||
long acks = WindowDelta(stats?.AcksSent ?? 0, ref _probeAckSeenTotal);
|
||
// N5: reliable-transport window deltas (same cumulative-delta shape
|
||
// as acks/s) + the two instantaneous depths. The counters increment
|
||
// unconditionally in TransportStats; only this string work is
|
||
// probe-gated.
|
||
long resends = WindowDelta(
|
||
stats?.ResendsSent ?? 0, ref _probeResendSeenTotal);
|
||
long naksOut = WindowDelta(
|
||
stats?.NaksSent ?? 0, ref _probeNakOutSeenTotal);
|
||
long naksIn = WindowDelta(
|
||
stats?.NakRequestsReceived ?? 0, ref _probeNakInSeenTotal);
|
||
long rejsIn = WindowDelta(
|
||
stats?.RejectsReceived ?? 0, ref _probeRejInSeenTotal);
|
||
long dupDrops = WindowDelta(
|
||
stats?.InboundDupsDropped ?? 0, ref _probeDupDropSeenTotal);
|
||
long parked = WindowDelta(
|
||
stats?.KeysParked ?? 0, ref _probeParkedSeenTotal);
|
||
long reclaimed = WindowDelta(
|
||
stats?.RejectWordsReclaimed ?? 0, ref _probeReclaimSeenTotal);
|
||
Console.WriteLine(FormatNetTickLine(
|
||
windowSeconds,
|
||
_probeProcessedWindow,
|
||
Volatile.Read(ref _probeInboundDepth),
|
||
_probeBudgetBreaks,
|
||
maxGapMs,
|
||
sends,
|
||
acks,
|
||
resends,
|
||
naksOut,
|
||
naksIn,
|
||
rejsIn,
|
||
dupDrops,
|
||
parked,
|
||
reclaimed,
|
||
stats?.CacheDepth ?? 0,
|
||
transport?.Inbound.NakCount ?? 0,
|
||
CurrentState));
|
||
_probeWindowStartTs = tickStartTs;
|
||
_probeMaxGapTicks = 0;
|
||
_probeProcessedWindow = 0;
|
||
_probeBudgetBreaks = 0;
|
||
}
|
||
|
||
private static long WindowDelta(long cumulative, ref long seenTotal)
|
||
{
|
||
long delta = cumulative - seenTotal;
|
||
seenTotal = cumulative;
|
||
return delta;
|
||
}
|
||
|
||
/// <summary>
|
||
/// The <c>[net-tick]</c> line shape, extracted so the N5 field extension
|
||
/// is string-assertable without a wall-clock window. <c>cache</c> and
|
||
/// <c>nakset</c> are instantaneous depths (the sent-packet cache is the
|
||
/// unbounded-like-retail watchdog value — campaign §4); every other
|
||
/// transport field is a per-second rate over the probe window.
|
||
/// </summary>
|
||
internal static string FormatNetTickLine(
|
||
double windowSeconds,
|
||
int processed,
|
||
int queueDepth,
|
||
int budgetBreaks,
|
||
double maxGapMs,
|
||
int sends,
|
||
long acks,
|
||
long resends,
|
||
long naksOut,
|
||
long naksIn,
|
||
long rejsIn,
|
||
long dupDrops,
|
||
long parked,
|
||
long reclaimed,
|
||
int cacheDepth,
|
||
int nakSetDepth,
|
||
State state) =>
|
||
$"[net-tick] in/s={processed / windowSeconds:F0}"
|
||
+ $" q={queueDepth}"
|
||
+ $" budget-breaks={budgetBreaks}"
|
||
+ $" maxgap={maxGapMs:F0}ms"
|
||
+ $" out/s={sends / windowSeconds:F0}"
|
||
+ $" acks/s={acks / windowSeconds:F0}"
|
||
+ $" resend/s={resends / windowSeconds:F0}"
|
||
+ $" nak-out/s={naksOut / windowSeconds:F0}"
|
||
+ $" nak-in/s={naksIn / windowSeconds:F0}"
|
||
+ $" rej-in/s={rejsIn / windowSeconds:F0}"
|
||
+ $" dup-drop/s={dupDrops / windowSeconds:F0}"
|
||
+ $" parked/s={parked / windowSeconds:F0}"
|
||
+ $" reclaim/s={reclaimed / windowSeconds:F0}"
|
||
+ $" cache={cacheDepth}"
|
||
+ $" nakset={nakSetDepth}"
|
||
+ $" st={state}";
|
||
|
||
/// <summary>
|
||
/// Pure, testable decision for the per-frame inbound bound: stop draining only when
|
||
/// in-world AND the elapsed Stopwatch ticks have reached the budget. Extracted so the
|
||
/// gate logic is unit-tested without the ISAAC/decode/channel machinery.
|
||
/// </summary>
|
||
internal static bool InboundBudgetExceeded(State state, long startTicks, long nowTicks, long budgetTicks)
|
||
=> state == State.InWorld && nowTicks - startTicks >= budgetTicks;
|
||
|
||
/// <summary>
|
||
/// Phase A.3 / Slice H-c1: asynchronous receive loop. It owns exactly
|
||
/// one outstanding socket receive from the end of
|
||
/// <see cref="EnterWorld"/> until cancellation, then writes pooled raw
|
||
/// datagrams 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>
|
||
/// Cancellation is wired directly into the socket receive. Idle
|
||
/// sessions therefore create neither timeout exceptions nor polling
|
||
/// wakeups. On shutdown, <see cref="Dispose"/> cancels and joins the task.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// 2026-07-24 audit fix: a per-iteration <see cref="SocketException"/>
|
||
/// (anything other than the expected <see cref="SocketError.TimedOut"/>,
|
||
/// which <see cref="NetClient.Receive"/> already swallows) used to be
|
||
/// caught by an OUTER try/catch that wrapped the entire while loop,
|
||
/// so a single transient error — classically Windows' WSAECONNRESET,
|
||
/// delivered on this socket's next Receive after an earlier Send hit
|
||
/// an ICMP port-unreachable — exited the loop for good. The
|
||
/// <c>finally</c> then completed <see cref="_inboundQueue"/>'s writer,
|
||
/// permanently killing inbound processing for the rest of the session
|
||
/// with no log line. Non-timeout socket errors are recoverable,
|
||
/// per-datagram events, not session-fatal, so they're now caught
|
||
/// per-iteration, logged, and the loop keeps polling.
|
||
/// </para>
|
||
/// </summary>
|
||
private async Task NetReceiveLoopAsync()
|
||
{
|
||
byte[] receiveBuffer = ArrayPool<byte>.Shared.Rent(
|
||
MaxInboundDatagramBytes);
|
||
try
|
||
{
|
||
while (!_netCancel.Token.IsCancellationRequested)
|
||
{
|
||
byte[]? queuedBuffer = null;
|
||
bool ownershipTransferred = false;
|
||
try
|
||
{
|
||
NetReceiveResult result =
|
||
await _net.ReceiveAsync(
|
||
receiveBuffer.AsMemory(
|
||
0,
|
||
MaxInboundDatagramBytes),
|
||
_netCancel.Token).ConfigureAwait(false);
|
||
queuedBuffer = ArrayPool<byte>.Shared.Rent(
|
||
result.Length);
|
||
receiveBuffer.AsSpan(0, result.Length).CopyTo(
|
||
queuedBuffer);
|
||
ownershipTransferred =
|
||
_inboundQueue.Writer.TryWrite(
|
||
new PooledInboundDatagram(
|
||
queuedBuffer,
|
||
result.Length));
|
||
if (ownershipTransferred && NetDiagnostics.ProbeNet)
|
||
Interlocked.Increment(ref _probeInboundDepth);
|
||
}
|
||
catch (System.Net.Sockets.SocketException ex)
|
||
{
|
||
// Transient, recoverable socket error (e.g. WSAECONNRESET
|
||
// from a stale peer's ICMP port-unreachable) — log and
|
||
// keep receiving. Does NOT tear down _inboundQueue.
|
||
Console.Error.WriteLine(
|
||
$"[net] receive error (continuing): {ex.SocketErrorCode} {ex.Message}");
|
||
}
|
||
finally
|
||
{
|
||
if (!ownershipTransferred
|
||
&& queuedBuffer is not null)
|
||
{
|
||
ArrayPool<byte>.Shared.Return(queuedBuffer);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
catch (OperationCanceledException) { /* graceful shutdown */ }
|
||
catch (ObjectDisposedException) { /* NetClient disposed before thread noticed */ }
|
||
finally
|
||
{
|
||
ArrayPool<byte>.Shared.Return(receiveBuffer);
|
||
_inboundQueue.Writer.TryComplete();
|
||
}
|
||
}
|
||
|
||
private PooledInboundDatagram? ReceiveBlocking(TimeSpan timeout)
|
||
{
|
||
byte[] buffer = ArrayPool<byte>.Shared.Rent(
|
||
MaxInboundDatagramBytes);
|
||
try
|
||
{
|
||
int length = _net.Receive(
|
||
buffer.AsSpan(0, MaxInboundDatagramBytes),
|
||
timeout,
|
||
out _);
|
||
if (length < 0)
|
||
{
|
||
ArrayPool<byte>.Shared.Return(buffer);
|
||
return null;
|
||
}
|
||
|
||
return new PooledInboundDatagram(buffer, length);
|
||
}
|
||
catch
|
||
{
|
||
ArrayPool<byte>.Shared.Return(buffer);
|
||
throw;
|
||
}
|
||
}
|
||
|
||
private static void ReturnInboundDatagram(
|
||
PooledInboundDatagram datagram) =>
|
||
ArrayPool<byte>.Shared.Return(datagram.Buffer);
|
||
|
||
/// <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>();
|
||
PooledInboundDatagram? received =
|
||
ReceiveBlocking(TimeSpan.FromMilliseconds(250));
|
||
if (received is null)
|
||
return false;
|
||
|
||
PooledInboundDatagram datagram = received.Value;
|
||
try
|
||
{
|
||
ProcessDatagram(
|
||
datagram.Memory,
|
||
opcodesThisCall);
|
||
return true;
|
||
}
|
||
finally
|
||
{
|
||
ReturnInboundDatagram(datagram);
|
||
}
|
||
}
|
||
|
||
private void ProcessDatagram(
|
||
ReadOnlyMemory<byte> bytes,
|
||
List<uint>? opcodesOut = null,
|
||
bool dispatchWorldEvents = true)
|
||
{
|
||
if (!PacketCodec.TryParseBorrowed(
|
||
bytes,
|
||
out BorrowedPacket packet,
|
||
out uint headerHash,
|
||
out uint payloadHash,
|
||
out _))
|
||
{
|
||
return;
|
||
}
|
||
|
||
PacketHeader serverHeader = packet.Header;
|
||
bool encrypted = serverHeader.HasFlag(
|
||
PacketHeaderFlags.EncryptedChecksum);
|
||
|
||
// N2: retail's inbound admission split (SharedNet::ProcessPacket
|
||
// @ 0x00544790 → ProcessNewSeqNum @ 0x00544690). Sequence-0 packets
|
||
// bypass the tracker entirely: cleartext seq-0 is handshake/control
|
||
// (verified additively, processed as before); encrypted seq-0 does
|
||
// not exist on the wire and drops before any keystream is touched.
|
||
if (serverHeader.Sequence == 0)
|
||
{
|
||
if (encrypted
|
||
|| !PacketCodec.VerifyChecksum(
|
||
in serverHeader,
|
||
headerHash,
|
||
payloadHash,
|
||
isaacKey: null))
|
||
{
|
||
return;
|
||
}
|
||
}
|
||
else if (_transport is { } inboundTransport)
|
||
{
|
||
InboundSequenceTracker.Admission admission =
|
||
inboundTransport.Inbound.Admit(
|
||
serverHeader.Sequence,
|
||
encrypted);
|
||
if (admission.Drop)
|
||
return;
|
||
|
||
if (!PacketCodec.VerifyChecksum(
|
||
in serverHeader,
|
||
headerHash,
|
||
payloadHash,
|
||
admission.VerifyKey))
|
||
{
|
||
inboundTransport.Stats.ChecksumFailures++;
|
||
// Verify failure on a sequenced encrypted packet re-parks
|
||
// the consumed key so the retransmission decodes
|
||
// (ProcessPacket @ 0x00544790 tail — campaign §2.2 step 5).
|
||
if (encrypted)
|
||
{
|
||
inboundTransport.Inbound.ReparkKey(
|
||
serverHeader.Sequence,
|
||
admission.VerifyKey!.Value,
|
||
admission.VerifyKeyDrawOrder);
|
||
}
|
||
|
||
return;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// Sequenced traffic before negotiation — the pre-N2 behavior of
|
||
// a null inbound keystream: encrypted cannot verify; cleartext
|
||
// verifies additively.
|
||
if (encrypted
|
||
|| !PacketCodec.VerifyChecksum(
|
||
in serverHeader,
|
||
headerHash,
|
||
payloadHash,
|
||
isaacKey: null))
|
||
{
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Retail LinkStatusHolder::OnHeartbeat @ 0x004113D0 updates its
|
||
// last-heard clock only for valid server traffic. Record at checksum
|
||
// acceptance, before any heavy render-thread message handling.
|
||
Volatile.Write(ref _lastInboundPacketTicks, Stopwatch.GetTimestamp());
|
||
|
||
// N6: the first checksum-valid post-negotiation packet confirms the
|
||
// server accepted our ConnectResponse and stops the handshake
|
||
// resend — retail's cs_ConnectionRequestAcked → cs_Connected edge
|
||
// (ClientNet::ProcessPacket @ 0x00545100: the 0x40000 ConnectRequest
|
||
// exclusion at 0x0054514E, SetConnectionState(..., 5) at
|
||
// 0x00545160). Frame-thread only, like every reader.
|
||
if (!_handshakeConfirmed
|
||
&& _transportNegotiated
|
||
&& !serverHeader.HasFlag(PacketHeaderFlags.ConnectRequest))
|
||
{
|
||
_handshakeConfirmed = true;
|
||
}
|
||
|
||
// N1: consume the transport control surfaces. Acknowledging the
|
||
// OTHER direction is not done here: N3 deleted the Phase 4.9
|
||
// per-packet reflex ack — retail never acks per packet
|
||
// (SharedNet::EnqueuePak @ 0x00543B10 is the binary's only 0x4000
|
||
// construction site). The AckNakScheduler emits ONE cumulative
|
||
// AckSequence per 2.0 s from the SweepTransport pump instead.
|
||
if (_transport is { } transport)
|
||
{
|
||
// Server NAK (RequestRetransmit 0x1000): merge the requested
|
||
// ids into the pending-resend list; ids[0] doubles as retail's
|
||
// implicit cumulative ack (RecipientData::ProcessNaks
|
||
// @ 0x00547010). The resends go out on the next sweep.
|
||
if ((serverHeader.Flags & PacketHeaderFlags.RequestRetransmit) != 0
|
||
&& packet.Optional.RetransmitRequestCount > 0)
|
||
{
|
||
transport.Outbound.OnRetransmitRequest(
|
||
packet.Optional.RetransmitRequestBytes.Span,
|
||
packet.Optional.RetransmitRequestCount);
|
||
}
|
||
|
||
// N2: inbound RejectRetransmit (0x2000) — the server abandoned
|
||
// the ids in the BODY; drop them from the NAK set, discarding
|
||
// the parked keys (SharedNet::HandleEmptyAck @ 0x005448F0).
|
||
// Alignment holds for those ids: they were real encrypted
|
||
// packets, so their words were drawn on both sides and are
|
||
// consumed-in-place.
|
||
if ((serverHeader.Flags & PacketHeaderFlags.RejectRetransmit) != 0)
|
||
{
|
||
transport.Stats.RejectsReceived++;
|
||
if (packet.Optional.RejectRetransmitCount > 0)
|
||
{
|
||
transport.Inbound.OnRejectRetransmit(
|
||
packet.Optional.RejectRetransmitBytes.Span,
|
||
packet.Optional.RejectRetransmitCount);
|
||
}
|
||
|
||
// N4/AD-51 — the reject packet's OWN sequence is the
|
||
// opposite case: ACE consumed it fresh, cleartext, with NO
|
||
// keystream word (FlushPackets, NetworkSession.cs:722-725,
|
||
// :743-748), so the word our gap walk parked for it was
|
||
// never drawn server-side. Reclaim it (checksum already
|
||
// verified above — the trigger fires only on a VALIDATED
|
||
// packet). Retail never reaches this: its cleartext packets
|
||
// always borrow live sequences.
|
||
if (serverHeader.Sequence != 0 && !encrypted)
|
||
{
|
||
transport.Inbound.OnCleartextRejectSequence(
|
||
serverHeader.Sequence);
|
||
}
|
||
}
|
||
|
||
// Cumulative ack (AckSequence 0x4000): wrap-safe max into the
|
||
// watermark; the cache prunes strictly below it on the sweep.
|
||
if ((serverHeader.Flags & PacketHeaderFlags.AckSequence) != 0)
|
||
transport.Outbound.OnAckSequence(packet.Optional.AckSequence);
|
||
}
|
||
|
||
// Phase G.1: propagate TimeSync-flagged server time to anyone who
|
||
// needs it (sky/day-night lerp in particular). Server sends this
|
||
// periodically — no explicit opcode, just the header flag.
|
||
if ((serverHeader.Flags & PacketHeaderFlags.TimeSync) != 0)
|
||
{
|
||
double t = packet.Optional.TimeSync;
|
||
if (t > 0)
|
||
{
|
||
LastServerTimeTicks = t;
|
||
ServerTimeUpdated?.Invoke(t);
|
||
}
|
||
}
|
||
|
||
foreach (BorrowedMessageFragment frag
|
||
in packet.Fragments)
|
||
{
|
||
if (!_assembler.TryIngest(
|
||
frag,
|
||
out ReadOnlyMemory<byte> bodyMemory,
|
||
out _)
|
||
|| bodyMemory.Length < 4)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
ReadOnlySpan<byte> body = bodyMemory.Span;
|
||
uint op = BinaryPrimitives.ReadUInt32LittleEndian(
|
||
body);
|
||
opcodesOut?.Add(op);
|
||
|
||
// Retail waits for the server's opcode-only CharacterLogOff echo
|
||
// before tearing down the world connection. Record it even while
|
||
// Dispose is draining the raw receive queue without dispatching
|
||
// world callbacks to owners that are already shutting down.
|
||
if (CharacterLogOff.IsConfirmation(body))
|
||
{
|
||
Interlocked.Exchange(ref _characterLogOffConfirmed, 1);
|
||
continue;
|
||
}
|
||
|
||
if (!dispatchWorldEvents)
|
||
continue;
|
||
|
||
if (op == CharacterList.Opcode && 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 == CreateObject.Opcode)
|
||
{
|
||
var parsed = CreateObject.TryParse(body);
|
||
if (parsed is not null)
|
||
{
|
||
EntitySpawned?.Invoke(ToEntitySpawn(parsed.Value));
|
||
}
|
||
}
|
||
else if (op == DeleteObject.Opcode)
|
||
{
|
||
var parsed = DeleteObject.TryParse(body);
|
||
if (parsed is not null)
|
||
EntityDeleted?.Invoke(parsed.Value);
|
||
}
|
||
else if (op == PickupEvent.Opcode)
|
||
{
|
||
// Pickup has its own POSITION_TS gate and retains the logical
|
||
// object; do not collapse it into DeleteObject.
|
||
var parsed = PickupEvent.TryParse(body);
|
||
if (parsed is not null)
|
||
EntityPickedUp?.Invoke(parsed.Value);
|
||
}
|
||
else if (op == ParentEvent.Opcode)
|
||
{
|
||
var parsed = ParentEvent.TryParse(body);
|
||
if (parsed is not null)
|
||
ParentUpdated?.Invoke(parsed.Value);
|
||
}
|
||
else if (op == UpdateMotion.Opcode)
|
||
{
|
||
// Phase 6.6: the server sends UpdateMotion (0xF74C) whenever an
|
||
// already-spawned entity changes its motion state — NPCs
|
||
// starting a walk cycle, creatures entering combat, doors
|
||
// opening, etc. We dispatch a lightweight event with the
|
||
// new (stance, forward-command) pair so the animation
|
||
// system can swap the entity's cycle.
|
||
var motion = UpdateMotion.TryParse(body);
|
||
if (motion is not null)
|
||
{
|
||
MotionUpdated?.Invoke(new EntityMotionUpdate(
|
||
motion.Value.Guid,
|
||
motion.Value.MotionState,
|
||
motion.Value.InstanceSequence,
|
||
motion.Value.MovementSequence,
|
||
motion.Value.ServerControlSequence,
|
||
motion.Value.IsAutonomous));
|
||
}
|
||
}
|
||
else if (op == UpdatePosition.Opcode)
|
||
{
|
||
// Phase 6.7: the server sends UpdatePosition (0xF748) every
|
||
// time an entity moves through the world — NPC patrols,
|
||
// creatures hunting, other players walking past, projectiles
|
||
// tracking. Without this, everything stays at its
|
||
// CreateObject spawn point forever.
|
||
var posUpdate = UpdatePosition.TryParse(body);
|
||
if (posUpdate is not null)
|
||
{
|
||
PositionUpdated?.Invoke(new EntityPositionUpdate(
|
||
posUpdate.Value.Guid,
|
||
posUpdate.Value.Position,
|
||
posUpdate.Value.Velocity,
|
||
posUpdate.Value.PlacementId,
|
||
posUpdate.Value.IsGrounded,
|
||
posUpdate.Value.InstanceSequence,
|
||
posUpdate.Value.PositionSequence,
|
||
posUpdate.Value.TeleportSequence,
|
||
posUpdate.Value.ForcePositionSequence));
|
||
}
|
||
}
|
||
else if (op == VectorUpdate.Opcode)
|
||
{
|
||
// K-fix9 (2026-04-26): server-broadcast remote jump
|
||
// velocity. ACE Player.cs:954 enqueues this on every
|
||
// jump in addition to the bracketing UpdateMotion. The
|
||
// payload's velocity field is the world-space launch
|
||
// velocity (post-rotation in
|
||
// GameMessageVectorUpdate.cs:20-24); subscribers feed
|
||
// it into the remote PhysicsBody so the dead-reckoning
|
||
// tick can integrate the arc.
|
||
var parsed = VectorUpdate.TryParse(body);
|
||
if (parsed is not null)
|
||
VectorUpdated?.Invoke(parsed.Value);
|
||
}
|
||
else if (op == SetState.Opcode)
|
||
{
|
||
// L.2g slice 1 (2026-05-12): server broadcasts SetState
|
||
// (0xF74B) when an entity's PhysicsState changes
|
||
// post-spawn — chiefly doors flipping ETHEREAL on Use.
|
||
// Holtburger validated wire format = 16 bytes (opcode +
|
||
// guid + state + 2×u16 sequence). One-shot probe-gated
|
||
// hex-dump (ACDREAM_PROBE_BUILDING) captures the wire
|
||
// bytes for confidence before declaring slice 1 done.
|
||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled
|
||
&& !_setStateHexDumped)
|
||
{
|
||
_setStateHexDumped = true;
|
||
var hex = string.Join(" ", body
|
||
.Slice(0, Math.Min(body.Length, 32))
|
||
.ToArray()
|
||
.Select(b => b.ToString("X2")));
|
||
Console.WriteLine($"[setstate-hex] body.len={body.Length} first-{Math.Min(body.Length, 32)}-bytes: {hex}");
|
||
}
|
||
|
||
var parsed = SetState.TryParse(body);
|
||
if (parsed is not null)
|
||
StateUpdated?.Invoke(parsed.Value);
|
||
}
|
||
else if (op == HearSpeech.LocalOpcode || op == HearSpeech.RangedOpcode)
|
||
{
|
||
// Phase H.1: local/ranged chat. Standalone GameMessage
|
||
// (NOT wrapped in 0xF7B0). Payload layout is documented
|
||
// on HearSpeech.TryParse.
|
||
var parsed = HearSpeech.TryParse(body);
|
||
if (parsed is not null)
|
||
SpeechHeard?.Invoke(parsed.Value);
|
||
}
|
||
else if (op == EmoteText.Opcode)
|
||
{
|
||
// Phase I.5: server-driven third-person emote
|
||
// ("The Olthoi growls at you."). Standalone GameMessage,
|
||
// not wrapped in 0xF7B0. Holtburger opcodes.rs:155.
|
||
var parsed = EmoteText.TryParse(body);
|
||
if (parsed is not null)
|
||
EmoteHeard?.Invoke(parsed.Value);
|
||
}
|
||
else if (op == SoulEmote.Opcode)
|
||
{
|
||
// Phase I.5: complex emote (chat + paired animation).
|
||
// Wire layout identical to EmoteText. Holtburger
|
||
// opcodes.rs:158.
|
||
var parsed = SoulEmote.TryParse(body);
|
||
if (parsed is not null)
|
||
SoulEmoteHeard?.Invoke(parsed.Value);
|
||
}
|
||
else if (op == ServerMessage.Opcode)
|
||
{
|
||
// Phase I.5: server announcement / system message.
|
||
// Holtburger opcodes.rs:167.
|
||
var parsed = ServerMessage.TryParse(body);
|
||
if (parsed is not null)
|
||
ServerMessageReceived?.Invoke(parsed.Value);
|
||
}
|
||
else if (op == PlayerKilled.Opcode)
|
||
{
|
||
// Phase I.5: death announcement. Holtburger opcodes.rs:150.
|
||
var parsed = PlayerKilled.TryParse(body);
|
||
if (parsed is not null)
|
||
PlayerKilledReceived?.Invoke(parsed.Value);
|
||
}
|
||
else if (op == TurbineChat.Opcode)
|
||
{
|
||
// Phase I.6: 0xF7DE TurbineChat — global community chat
|
||
// (General / Trade / LFG / Roleplay / Society / Olthoi).
|
||
// Three payload variants live inside the same opcode;
|
||
// dispatch to subscribers by raising a typed event.
|
||
// SetTurbineChatChannels (0x0295) is NOT here — it's a
|
||
// sub-opcode of the 0xF7B0 GameEvent envelope and rides
|
||
// the dispatcher path (registered in the ctor).
|
||
var parsed = TurbineChat.TryParse(body);
|
||
if (parsed is not null) TurbineChatReceived?.Invoke(parsed.Value);
|
||
}
|
||
else if (op == PrivateUpdateVital.FullOpcode)
|
||
{
|
||
// Issue #5: full per-vital snapshot from the server. Wire
|
||
// format per holtburger UpdateVital<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 == PublicUpdatePropertyInt.Opcode)
|
||
{
|
||
var p = PublicUpdatePropertyInt.TryParse(body);
|
||
if (p is not null)
|
||
ObjectIntPropertyUpdated?.Invoke(
|
||
new ObjectIntPropertyUpdate(p.Value.Guid, p.Value.Property, p.Value.Value));
|
||
}
|
||
else if (op == PrivateUpdatePropertyInt.Opcode)
|
||
{
|
||
var p = PrivateUpdatePropertyInt.TryParse(body);
|
||
if (p is not null)
|
||
PlayerIntPropertyUpdated?.Invoke(
|
||
new PlayerIntPropertyUpdate(p.Value.Property, p.Value.Value));
|
||
}
|
||
else if (op == PrivateUpdatePropertyInt64.Opcode)
|
||
{
|
||
// Retail CM_Qualities::DispatchUI_PrivateUpdateInt64 @ 0x006AEAD0.
|
||
// TotalExperience (1) and AvailableExperience (2) both arrive here.
|
||
var p = PrivateUpdatePropertyInt64.TryParse(body);
|
||
if (p is not null)
|
||
PlayerInt64PropertyUpdated?.Invoke(
|
||
new PlayerInt64PropertyUpdate(p.Value.Property, p.Value.Value));
|
||
}
|
||
else if (op == SetStackSize.Opcode)
|
||
{
|
||
var p = SetStackSize.TryParse(body);
|
||
if (p is not null)
|
||
StackSizeUpdated?.Invoke(
|
||
new StackSizeUpdate(p.Value.Guid, p.Value.StackSize, p.Value.Value));
|
||
}
|
||
else if (op == InventoryRemoveObject.Opcode)
|
||
{
|
||
var p = InventoryRemoveObject.TryParse(body);
|
||
if (p is not null) InventoryObjectRemoved?.Invoke(p.Value.Guid);
|
||
}
|
||
else if (op == GameEventEnvelope.Opcode)
|
||
{
|
||
// Phase F.1: 0xF7B0 is the GameEvent envelope. Parse the
|
||
// header (guid + sequence + eventType) and dispatch to the
|
||
// registered handler for that sub-opcode. Unregistered
|
||
// types get counted for diagnostic overlays.
|
||
var env = GameEventEnvelope.TryParseBorrowed(
|
||
bodyMemory);
|
||
if (env is not null) GameEvents.Dispatch(env.Value);
|
||
}
|
||
else if (op == 0xEA60u) // AdminEnvirons — server pushes a fog preset or sound cue
|
||
{
|
||
// Phase 5d: wire format `[u32 opcode][u32 environChangeType]`
|
||
// per chunk_006A0000.c. Dispatch the event; GameWindow
|
||
// subscribers route fog presets into WeatherSystem.Override
|
||
// and sound cues (thunder, roar, etc) into the audio engine.
|
||
if (body.Length >= 8)
|
||
{
|
||
uint envType = System.Buffers.Binary.BinaryPrimitives
|
||
.ReadUInt32LittleEndian(body.Slice(4, 4));
|
||
EnvironChanged?.Invoke(envType);
|
||
}
|
||
}
|
||
else if (op == PlayPhysicsScript.Opcode)
|
||
{
|
||
var script = PlayPhysicsScript.TryParse(body);
|
||
if (script is not null)
|
||
PlayPhysicsScriptReceived?.Invoke(script.Value);
|
||
}
|
||
else if (op == PlayPhysicsScriptType.Opcode)
|
||
{
|
||
var script = PlayPhysicsScriptType.TryParse(body);
|
||
if (script is not null)
|
||
PlayPhysicsScriptTypeReceived?.Invoke(script.Value);
|
||
}
|
||
else if (op == SoundEvent.Opcode)
|
||
{
|
||
var sound = SoundEvent.TryParse(body);
|
||
if (sound is not null)
|
||
SoundEventReceived?.Invoke(sound.Value);
|
||
}
|
||
else if (op == 0xF751u) // PlayerTeleport — server is moving us through a portal
|
||
{
|
||
// Phase B.3: holtburger opcodes.rs confirms 0xF751 is the
|
||
// PlayerTeleport standalone GameMessage (NOT wrapped in 0xF7B0).
|
||
// Wire layout (teleport.rs): u16 teleport_sequence, then
|
||
// aligned to 4 bytes. Per holtburger's client handler, the
|
||
// correct response is send_login_complete() at the destination.
|
||
// Here we fire TeleportStarted so GameWindow can freeze
|
||
// movement; the LoginComplete is sent from GameWindow once
|
||
// the destination UpdatePosition is received and the player
|
||
// has been snapped to the new cell.
|
||
if (body.Length >= 6)
|
||
{
|
||
ushort sequence = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(
|
||
body.Slice(4, 2));
|
||
TeleportStarted?.Invoke(sequence);
|
||
}
|
||
}
|
||
else if (op == ObjDescEvent.Opcode)
|
||
{
|
||
// 0xF625 ObjDescEvent — per-entity appearance update. ACE
|
||
// broadcasts on equip/unequip/tailoring/recipe/option-change
|
||
// (Creature_Equipment.cs:365, Tailoring.cs:504,
|
||
// RecipeManager.cs:403, GameActionSetSingleCharacterOption.cs:27).
|
||
// Retail handler: SmartBox::HandleObjDescEvent (named-retail
|
||
// 0x453340). Body layout: u32 opcode | u32 guid | ModelData |
|
||
// u16 instanceSeq | u16 visualDescSeq.
|
||
var parsed = ObjDescEvent.TryParse(body);
|
||
if (parsed is not null)
|
||
{
|
||
if (DumpAppearanceEnabled)
|
||
{
|
||
var md = parsed.Value.ModelData;
|
||
Console.WriteLine($"appearance: 0xF625 guid=0x{parsed.Value.Guid:X8} basePal=0x{(md.BasePaletteId ?? 0):X8} subPals={md.SubPalettes.Count} texChanges={md.TextureChanges.Count} animParts={md.AnimPartChanges.Count}");
|
||
foreach (var sp in md.SubPalettes)
|
||
Console.WriteLine($" SP id=0x{sp.SubPaletteId:X8} offset={sp.Offset} length={sp.Length}");
|
||
foreach (var tc in md.TextureChanges)
|
||
Console.WriteLine($" TC part={tc.PartIndex:D2} oldTex=0x{tc.OldTexture:X8} -> newTex=0x{tc.NewTexture:X8}");
|
||
foreach (var apc in md.AnimPartChanges)
|
||
Console.WriteLine($" APC part={apc.PartIndex:D2} -> gfx=0x{apc.NewModelId:X8}");
|
||
}
|
||
AppearanceUpdated?.Invoke(parsed.Value);
|
||
}
|
||
else if (DumpAppearanceEnabled)
|
||
{
|
||
Console.WriteLine($"appearance: 0xF625 PARSE FAILED body.len={body.Length}");
|
||
}
|
||
}
|
||
else if (DumpOpcodesEnabled)
|
||
{
|
||
// ACDREAM_DUMP_OPCODES=1 — emit a one-line trace per
|
||
// genuinely-unhandled opcode (deduped to first occurrence).
|
||
// MUST be the LAST else-if so it doesn't intercept handled
|
||
// opcodes when the env var is set.
|
||
if (_seenUnhandledOpcodes.Add(op))
|
||
Console.WriteLine($"opcodes: unhandled 0x{op:X4} (body.len={body.Length})");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <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 lifestone recall (0x0063). Retail source:
|
||
/// <c>CM_Character::Event_TeleToLifestone @ 0x006A1B90</c>.
|
||
/// </summary>
|
||
public void SendTeleportToLifestone()
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(InteractRequests.BuildTeleToLifestone(seq));
|
||
}
|
||
|
||
/// <summary>Send retail marketplace recall (0x028D).</summary>
|
||
public void SendTeleportToMarketplace()
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildMarketplace(seq));
|
||
}
|
||
|
||
/// <summary>Send retail full-PK arena recall (0x0027).</summary>
|
||
public void SendTeleportToPkArena()
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildPkArena(seq));
|
||
}
|
||
|
||
/// <summary>Send retail PKLite arena recall (0x0026).</summary>
|
||
public void SendTeleportToPkLiteArena()
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildPkLiteArena(seq));
|
||
}
|
||
|
||
/// <summary>Send retail @pklite / Enter PK Lite request (0x028F).</summary>
|
||
public void SendEnterPkLite()
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildEnterPkLite(seq));
|
||
}
|
||
|
||
/// <summary>Send retail personal-house recall (0x0262).</summary>
|
||
public void SendTeleportToHouse()
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildHouseRecall(seq));
|
||
}
|
||
|
||
/// <summary>Send retail allegiance-mansion recall (0x0278).</summary>
|
||
public void SendTeleportToMansion()
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildMansionRecall(seq));
|
||
}
|
||
|
||
/// <summary>Query the local character's played time (0x01C2).</summary>
|
||
public void SendQueryAge()
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildQueryAge(seq));
|
||
}
|
||
|
||
/// <summary>Query the local character's creation date (0x01C4).</summary>
|
||
public void SendQueryBirth()
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildQueryBirth(seq));
|
||
}
|
||
|
||
/// <summary>Reply to a server confirmation request (0x0275).</summary>
|
||
public void SendConfirmationResponse(uint confirmationType, uint contextId, bool accepted)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildConfirmationResponse(
|
||
seq, confirmationType, contextId, accepted));
|
||
}
|
||
|
||
/// <summary>Send the confirmed retail suicide action (0x0279).</summary>
|
||
public void SendSuicide()
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildSuicide(seq));
|
||
}
|
||
|
||
public void SendSetAfkMode(bool away)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildSetAfkMode(seq, away));
|
||
}
|
||
|
||
public void SendSetAfkMessage(string message)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildSetAfkMessage(seq, message));
|
||
}
|
||
|
||
public void SendEmote(string message)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildEmote(seq, message));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Send retail SetSingleCharacterOption (0x0005) — toggles one character
|
||
/// option. For the six <c>ListenTo*Chat</c> ids this is the message that
|
||
/// actually joins/leaves a Turbine room server-side (CH3, 2026-08-09).
|
||
/// </summary>
|
||
public void SendSetSingleCharacterOption(uint optionId, bool value)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(SocialActions.BuildSetSingleCharacterOption(seq, optionId, value));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Send retail <c>SetCharacterOptions (0x01A1)</c> — the batched-option
|
||
/// module flush (Campaign OP slice OP1, 2026-08-10). Callers own the
|
||
/// dirty check (<c>RuntimeCharacterOptionsState.TryFlush</c> /
|
||
/// <c>TryFlushIfAutoSaveDue</c>); this method always sends when called,
|
||
/// matching retail's <c>CPlayerModule::SaveToServer</c> once its own
|
||
/// <c>m_bDirty</c> gate has already passed.
|
||
/// </summary>
|
||
public void SendSetCharacterOptions(
|
||
uint options1,
|
||
uint options2,
|
||
IReadOnlyList<ShortcutEntry> shortcuts,
|
||
IReadOnlyList<IReadOnlyList<uint>> favoriteSpells,
|
||
IReadOnlyDictionary<uint, uint> desiredComponents,
|
||
uint spellbookFilters)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(SocialActions.BuildSetCharacterOptions(
|
||
seq,
|
||
options1,
|
||
options2,
|
||
shortcuts,
|
||
favoriteSpells,
|
||
desiredComponents,
|
||
spellbookFilters));
|
||
}
|
||
|
||
public void SendAddFriend(string name)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildAddFriend(seq, name));
|
||
}
|
||
|
||
public void SendRemoveFriend(uint friendId)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildRemoveFriend(seq, friendId));
|
||
}
|
||
|
||
public void SendClearFriends()
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildClearFriends(seq));
|
||
}
|
||
|
||
public void SendLegacyFriendsListRequest() =>
|
||
SendControlMessage(ClientCommandRequests.BuildLegacyFriendsCommand(0u, string.Empty));
|
||
|
||
public void SendModifyCharacterSquelch(bool add, uint characterId, string name, uint messageType)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildModifyCharacterSquelch(
|
||
seq, add, characterId, name, messageType));
|
||
}
|
||
|
||
public void SendModifyAccountSquelch(bool add, string name)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildModifyAccountSquelch(seq, add, name));
|
||
}
|
||
|
||
public void SendModifyGlobalSquelch(bool add, uint messageType)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildModifyGlobalSquelch(seq, add, messageType));
|
||
}
|
||
|
||
// Campaign CH slice CH4 (2026-08-09): command-registry completion.
|
||
/// <summary>Send retail @index (0x0149).</summary>
|
||
public void SendIndexChannels()
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildIndexChannels(seq));
|
||
}
|
||
|
||
/// <summary>Send retail @clist <channel> (0x0148).</summary>
|
||
public void SendListChannel(uint channelId)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildListChannel(seq, channelId));
|
||
}
|
||
|
||
/// <summary>Send retail @on <channel> (0x0145).</summary>
|
||
public void SendOnChannel(uint channelId)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildOnChannel(seq, channelId));
|
||
}
|
||
|
||
/// <summary>Send retail @off <channel> (0x0146).</summary>
|
||
public void SendOffChannel(uint channelId)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildOffChannel(seq, channelId));
|
||
}
|
||
|
||
/// <summary>Send retail @alh / @ah / "@allegiance hometown" (0x02AB).</summary>
|
||
public void SendRecallAllegianceHometown()
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildRecallAllegianceHometown(seq));
|
||
}
|
||
|
||
/// <summary>Send retail "@allegiance info [name]" (0x027B).</summary>
|
||
public void SendAllegianceInfoRequest(string playerName)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildAllegianceInfoRequest(seq, playerName));
|
||
}
|
||
|
||
// ── Campaign FA slice FA2 (2026-08-12): fellowship + allegiance
|
||
// outbound wrappers. SocialActions/AllegianceRequests ship the byte
|
||
// builders (repaired/added in FA1); this is the missing
|
||
// NextGameActionSequence() + SendGameAction() link every other
|
||
// outbound family already has (docs/research/2026-08-11-fa-acdream-seams.md
|
||
// §3.2).
|
||
|
||
/// <summary>Send retail fellowship create (0x00A2).</summary>
|
||
public void SendFellowshipCreate(string fellowshipName, bool shareXp)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(SocialActions.BuildFellowshipCreate(seq, fellowshipName, shareXp));
|
||
}
|
||
|
||
/// <summary>Send retail fellowship quit / disband (0x00A3).</summary>
|
||
public void SendFellowshipQuit(bool disband)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(SocialActions.BuildFellowshipQuit(seq, disband));
|
||
}
|
||
|
||
/// <summary>Send retail fellowship dismiss (0x00A4).</summary>
|
||
public void SendFellowshipDismiss(uint targetGuid)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(SocialActions.BuildFellowshipDismiss(seq, targetGuid));
|
||
}
|
||
|
||
/// <summary>Send retail fellowship recruit (0x00A5).</summary>
|
||
public void SendFellowshipRecruit(uint targetGuid)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(SocialActions.BuildFellowshipRecruit(seq, targetGuid));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Send retail fellowship-panel visibility declaration (0x00A6) — D4:
|
||
/// gates ACE's <c>0x02C0</c> member-vitals stream (docs/research/
|
||
/// 2026-08-11-fa-fellowship-wire.md §4.5).
|
||
/// </summary>
|
||
public void SendFellowshipUpdateRequest(bool panelOpen)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(SocialActions.BuildFellowshipUpdateRequest(seq, panelOpen));
|
||
}
|
||
|
||
/// <summary>Send retail fellowship leadership transfer (0x0290).</summary>
|
||
public void SendFellowshipAssignNewLeader(uint newLeaderGuid)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(SocialActions.BuildFellowshipAssignNewLeader(seq, newLeaderGuid));
|
||
}
|
||
|
||
/// <summary>Send retail fellowship openness toggle (0x0291).</summary>
|
||
public void SendFellowshipChangeOpenness(bool isOpen)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(SocialActions.BuildFellowshipChangeOpenness(seq, isOpen));
|
||
}
|
||
|
||
/// <summary>Send retail allegiance swear (0x001D).</summary>
|
||
public void SendAllegianceSwear(uint patronGuid)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(AllegianceRequests.BuildSwear(seq, patronGuid));
|
||
}
|
||
|
||
/// <summary>Send retail allegiance break (0x001E) — targets your own patron.</summary>
|
||
public void SendAllegianceBreak(uint targetGuid)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(AllegianceRequests.BuildBreak(seq, targetGuid));
|
||
}
|
||
|
||
/// <summary>Send retail allegiance kick (0x001E) — targets a vassal.</summary>
|
||
public void SendAllegianceKick(uint vassalGuid)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(AllegianceRequests.BuildKick(seq, vassalGuid));
|
||
}
|
||
|
||
/// <summary>Send retail allegiance-panel subscribe/unsubscribe (0x001F).</summary>
|
||
public void SendAllegianceUpdateRequest(bool on)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(AllegianceRequests.BuildAllegianceUpdateRequest(seq, on));
|
||
}
|
||
|
||
/// <summary>Send retail @hslist <type> (0x0270).</summary>
|
||
public void SendListAvailableHouses(uint houseType)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildListAvailableHouses(seq, houseType));
|
||
}
|
||
|
||
/// <summary>Send retail @permit add <name> (0x0219).</summary>
|
||
public void SendAddPlayerPermission(string playerName)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildAddPlayerPermission(seq, playerName));
|
||
}
|
||
|
||
/// <summary>Send retail @permit remove <name> (0x021A).</summary>
|
||
public void SendRemovePlayerPermission(string playerName)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildRemovePlayerPermission(seq, playerName));
|
||
}
|
||
|
||
/// <summary>Send retail "@house abandon" (0x021F).</summary>
|
||
public void SendAbandonHouse()
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildAbandonHouse(seq));
|
||
}
|
||
|
||
public void SendClearConsent()
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildClearConsent(seq));
|
||
}
|
||
|
||
public void SendDisplayConsent()
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildDisplayConsent(seq));
|
||
}
|
||
|
||
public void SendRemoveConsent(string name)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildRemoveConsent(seq, name));
|
||
}
|
||
|
||
public void SendClearDesiredComponents()
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildSetDesiredComponentLevel(
|
||
seq, componentId: 0u, amount: uint.MaxValue));
|
||
}
|
||
|
||
public void SendSetDesiredComponentLevel(uint componentId, uint amount)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildSetDesiredComponentLevel(
|
||
seq, componentId, amount));
|
||
}
|
||
|
||
public void SendAddSpellFavorite(uint spellId, int position, int tabIndex)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildAddSpellFavorite(
|
||
seq, spellId, position, tabIndex));
|
||
}
|
||
|
||
public void SendRemoveSpellFavorite(uint spellId, int tabIndex)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildRemoveSpellFavorite(
|
||
seq, spellId, tabIndex));
|
||
}
|
||
|
||
public void SendSpellbookFilter(uint filters)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildSpellbookFilter(seq, filters));
|
||
}
|
||
|
||
public void SendRemoveSpell(uint spellId)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(ClientCommandRequests.BuildRemoveSpell(seq, spellId));
|
||
}
|
||
|
||
public void SendCastUntargetedSpell(uint spellId)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(CastSpellRequest.BuildUntargeted(seq, spellId));
|
||
}
|
||
|
||
public void SendCastTargetedSpell(uint targetGuid, uint spellId)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(CastSpellRequest.BuildTargeted(seq, targetGuid, spellId));
|
||
}
|
||
|
||
/// <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 RaiseAttribute (0x0045).</summary>
|
||
public void SendRaiseAttribute(uint attrId, ulong xpSpent)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(CharacterActions.BuildRaiseAttribute(seq, attrId, xpSpent));
|
||
}
|
||
|
||
/// <summary>Send retail RaiseVital (0x0044).</summary>
|
||
public void SendRaiseVital(uint vitalId, ulong xpSpent)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(CharacterActions.BuildRaiseVital(seq, vitalId, xpSpent));
|
||
}
|
||
|
||
/// <summary>Send retail RaiseSkill (0x0046).</summary>
|
||
public void SendRaiseSkill(uint skillId, ulong xpSpent)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(CharacterActions.BuildRaiseSkill(seq, skillId, xpSpent));
|
||
}
|
||
|
||
/// <summary>Send retail TrainSkill (0x0047).</summary>
|
||
public void SendTrainSkill(uint skillId, uint credits)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(CharacterActions.BuildTrainSkill(seq, skillId, credits));
|
||
}
|
||
|
||
/// <summary>Send lossless retail AddShortcut (0x019C).</summary>
|
||
public void SendAddShortcut(ShortcutEntry entry)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(InventoryActions.BuildAddShortcut(seq, entry));
|
||
}
|
||
|
||
/// <summary>Send RemoveShortcut (0x019D) — clear toolbar slot <paramref name="index"/>.
|
||
/// Retail: CM_Character::Event_RemoveShortCut.</summary>
|
||
public void SendRemoveShortcut(uint index)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(InventoryActions.BuildRemoveShortcut(seq, index));
|
||
}
|
||
|
||
/// <summary>Send DropItem (0x001B) — drop an item on the ground.</summary>
|
||
public void SendDropItem(uint itemGuid)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(InventoryActions.BuildDropItem(seq, itemGuid));
|
||
}
|
||
|
||
// ── Secure trade (docs/research/2026-08-14-trade-laneB-wire.md) ────────
|
||
|
||
/// <summary>Open secure trade with another player — retail
|
||
/// <c>CM_Trade::Event_OpenTradeNegotiations @ 0x0056D300</c>.</summary>
|
||
public void SendOpenTradeNegotiations(uint partnerGuid)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(TradeRequests.BuildOpenTradeNegotiations(seq, partnerGuid));
|
||
}
|
||
|
||
/// <summary>Close the trade window — <c>Event_CloseTradeNegotiations
|
||
/// @ 0x0056D1E0</c>.</summary>
|
||
public void SendCloseTradeNegotiations()
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(TradeRequests.BuildCloseTradeNegotiations(seq));
|
||
}
|
||
|
||
/// <summary>Stage an item into the trade — <c>Event_AddToTrade
|
||
/// @ 0x0056D0D0</c>.</summary>
|
||
public void SendAddToTrade(uint itemGuid, uint tradeSlot = 0u)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(TradeRequests.BuildAddToTrade(seq, itemGuid, tradeSlot));
|
||
}
|
||
|
||
/// <summary>Accept the current offer — <c>Event_AcceptTrade</c> packing
|
||
/// <c>Trade::Pack @ 0x005B9FF0</c>'s fixed fields (ACE discards the
|
||
/// payload entirely; see TradeRequests.BuildAcceptTrade).</summary>
|
||
public void SendAcceptTrade(
|
||
uint partnerGuid,
|
||
double tradeStamp,
|
||
uint tradeStatus,
|
||
uint initiatorGuid,
|
||
bool initiatorAccepts,
|
||
bool partnerAccepts)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(TradeRequests.BuildAcceptTrade(
|
||
seq, partnerGuid, tradeStamp, tradeStatus,
|
||
initiatorGuid, initiatorAccepts, partnerAccepts));
|
||
}
|
||
|
||
/// <summary>Withdraw a previous accept — <c>Event_DeclineTrade
|
||
/// @ 0x0056D270</c>.</summary>
|
||
public void SendDeclineTrade()
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(TradeRequests.BuildDeclineTrade(seq));
|
||
}
|
||
|
||
/// <summary>Clear the trade window — <c>Event_ResetTrade @ 0x0056D3D0</c>.
|
||
/// ACE clears BOTH sides' staged items (lane B §quirks).</summary>
|
||
public void SendResetTrade()
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(TradeRequests.BuildResetTrade(seq));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Send retail GiveObjectRequest (0x00CD). Retail
|
||
/// <c>CM_Inventory::Event_GiveObjectRequest @ 0x006ABB00</c> writes
|
||
/// target, source item, then selected stack amount.
|
||
/// </summary>
|
||
public void SendGiveObject(uint targetGuid, uint itemGuid, uint amount)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(InventoryActions.BuildGiveObjectRequest(
|
||
seq, targetGuid, itemGuid, amount));
|
||
}
|
||
|
||
/// <summary>Send GetAndWieldItem (0x001A) — equip an item to an equip slot.</summary>
|
||
public void SendGetAndWieldItem(uint itemGuid, uint equipMask)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(InventoryActions.BuildGetAndWieldItem(seq, itemGuid, equipMask));
|
||
}
|
||
|
||
/// <summary>Send NoLongerViewingContents (0x0195) — close a container view.</summary>
|
||
public void SendNoLongerViewingContents(uint containerGuid)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(InventoryActions.BuildNoLongerViewingContents(seq, containerGuid));
|
||
}
|
||
|
||
/// <summary>Send Use (0x0036) — open/use an object by guid (e.g. open a container in your
|
||
/// inventory). A direct wire send: opening a pack you already hold needs no autowalk, unlike
|
||
/// GameWindow.SendUse (the world-interaction path). Retail: CM_Physics::Event_Use.</summary>
|
||
public void SendUse(uint targetGuid)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(InteractRequests.BuildUse(seq, targetGuid));
|
||
}
|
||
|
||
/// <summary>Send UseWithTarget (0x0035) - use a source item on an acquired target.
|
||
/// Retail: CM_Inventory::Event_UseWithTargetEvent.</summary>
|
||
public void SendUseWithTarget(uint sourceGuid, uint targetGuid)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(InteractRequests.BuildUseWithTarget(seq, sourceGuid, targetGuid));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Slice 6.3: send retail Buy (0x005F) — a single-item purchase, retail
|
||
/// <c>CM_Vendor::Event_Buy</c> (<c>pc:689288</c>). See
|
||
/// <see cref="VendorRequests"/> for the wire layout and the deliberate
|
||
/// trailing <c>alternateCurrencyId</c> field (ported for retail
|
||
/// fidelity; ACE's server ignores it today).
|
||
/// </summary>
|
||
public void SendBuy(uint vendorGuid, uint itemGuid, int amount, uint alternateCurrencyId)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(VendorRequests.BuildBuy(seq, vendorGuid, amount, itemGuid, alternateCurrencyId));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Slice 6b: send a batched retail Buy (0x005F) — the "Buy All" path,
|
||
/// one wire call for every staged entry. See <see cref="SendBuy(uint, uint, int, uint)"/>
|
||
/// for the single-item convenience overload the "Items"/"Buying" tabs'
|
||
/// immediate Buy buttons keep using unchanged.
|
||
/// </summary>
|
||
public void SendBuy(
|
||
uint vendorGuid,
|
||
IReadOnlyList<(int Amount, uint ItemGuid)> items,
|
||
uint alternateCurrencyId)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(VendorRequests.BuildBuy(seq, vendorGuid, items, alternateCurrencyId));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Slice 6c: send retail Sell (0x0060) — <see cref="VendorRequests.BuildSell"/>.
|
||
/// Used by both the "Sell Item" (one-entry list) and "Sell All" (n-entry
|
||
/// list) buttons; Sell has no separate single-item opcode the way Buy
|
||
/// does.
|
||
/// </summary>
|
||
public void SendSell(uint vendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> items)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(VendorRequests.BuildSell(seq, vendorGuid, items));
|
||
}
|
||
|
||
/// <summary>Send retail IdentifyObject/Appraise (0x00C8).</summary>
|
||
public void SendAppraise(uint targetGuid)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(AppraiseRequest.Build(seq, targetGuid));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Send retail SetInscription (0x00BF). The retail client updates the
|
||
/// examination field optimistically because the server sends no success
|
||
/// response.
|
||
/// </summary>
|
||
public void SendSetInscription(uint itemGuid, string inscription)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(InventoryActions.BuildSetInscription(
|
||
seq,
|
||
itemGuid,
|
||
inscription));
|
||
}
|
||
|
||
/// <summary>Send PutItemInContainer (0x0019) - move an item into a container at a slot. placement
|
||
/// = the target slot (server packs/shifts); the drag-drop drop handler computes it. Retail:
|
||
/// CM_Inventory::Event_PutItemInContainer -> ACE Player.HandleActionPutItemInContainer.</summary>
|
||
public void SendPutItemInContainer(uint itemGuid, uint containerGuid, int placement)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(InteractRequests.BuildPickUp(seq, itemGuid, containerGuid, placement));
|
||
}
|
||
|
||
/// <summary>Send StackableMerge (0x0054) with retail's already-clamped transfer amount.</summary>
|
||
public void SendStackableMerge(uint sourceGuid, uint targetGuid, uint amount)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(InventoryActions.BuildStackableMerge(seq, sourceGuid, targetGuid, amount));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Send retail StackableSplitToContainer (0x0055).
|
||
/// <c>CM_Inventory::Event_StackableSplitToContainer @ 0x006AC0D0</c>.
|
||
/// </summary>
|
||
public void SendStackableSplitToContainer(
|
||
uint stackGuid, uint containerGuid, uint placement, uint amount)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(InventoryActions.BuildStackableSplitToContainer(
|
||
seq, stackGuid, containerGuid, placement, amount));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Send retail StackableSplitTo3D (0x0056).
|
||
/// <c>CM_Inventory::Event_StackableSplitTo3D @ 0x006ABFC0</c>.
|
||
/// </summary>
|
||
public void SendStackableSplitTo3D(uint stackGuid, uint amount)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(InventoryActions.BuildStackableSplitTo3D(seq, stackGuid, amount));
|
||
}
|
||
|
||
/// <summary>Send retail QueryHealth (0x01BF). Server replies UpdateHealth (0x01C0).</summary>
|
||
/// <remarks>
|
||
/// Retail anchor: <c>CM_Combat::Event_QueryHealth</c> / <c>gmToolbarUI::HandleSelectionChanged:198635</c>
|
||
/// (docs/research/named-retail/acclient_2013_pseudo_c.txt).
|
||
/// </remarks>
|
||
public void SendQueryHealth(uint targetGuid)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
byte[] body = SocialActions.BuildQueryHealth(seq, targetGuid);
|
||
SendGameAction(body);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Send retail QueryItemMana (0x0263), using item guid zero to cancel.
|
||
/// Retail anchor: <c>CM_Item::Event_QueryItemMana @ 0x006A8610</c>.
|
||
/// </summary>
|
||
public void SendQueryItemMana(uint itemGuid)
|
||
{
|
||
uint seq = NextGameActionSequence();
|
||
byte[] body = SocialActions.BuildQueryItemMana(seq, itemGuid);
|
||
SendGameAction(body);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Request the round-trip sample displayed by retail <c>gmLinkStatusUI</c>.
|
||
/// The response is an empty 0x01EA GameEvent, so the session retains the
|
||
/// monotonic send timestamp rather than putting an invented id on the wire.
|
||
/// </summary>
|
||
public void RequestLinkStatusPing()
|
||
{
|
||
Volatile.Write(ref _lastPingRequestTicks, Stopwatch.GetTimestamp());
|
||
uint seq = NextGameActionSequence();
|
||
SendGameAction(SocialActions.BuildPingRequest(seq));
|
||
}
|
||
|
||
/// <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) =>
|
||
SendGameMessage(gameMessageBody, GameMessageGroup.UIQueue);
|
||
|
||
/// <summary>
|
||
/// Retail <c>Proto_UI::SendToControl</c> path used by standalone control
|
||
/// messages such as the legacy <c>0xF7CD</c> friends request.
|
||
/// </summary>
|
||
private void SendControlMessage(byte[] gameMessageBody) =>
|
||
SendGameMessage(gameMessageBody, GameMessageGroup.ControlQueue);
|
||
|
||
private void SendGameMessage(byte[] gameMessageBody, GameMessageGroup queue)
|
||
{
|
||
// #260 probe: log the send BEFORE the sequence counters are consumed
|
||
// so the line carries the values this datagram will actually use. The
|
||
// exception filter below logs a wire-write fault WITHOUT catching it
|
||
// (the filter returns false), so behavior is byte-identical either way.
|
||
if (NetDiagnostics.ProbeNet)
|
||
ProbeNetLogOutbound(gameMessageBody, queue);
|
||
try
|
||
{
|
||
// N1: the reliable transport owns encode + send + cache. Wire
|
||
// shape is unchanged; the datagram is additionally cached for
|
||
// resend on server NAK. Pre-negotiation reliable sends were
|
||
// always impossible (no outbound keystream existed) — the
|
||
// exception simply names the state now.
|
||
ReliableTransport transport = _transport
|
||
?? throw new InvalidOperationException(
|
||
"reliable send before transport negotiation — "
|
||
+ "Connect() must seed ISAAC first");
|
||
transport.Outbound.SendGameMessage(gameMessageBody, queue);
|
||
}
|
||
catch (Exception ex) when (ProbeNetLogOutboundFault(ex))
|
||
{
|
||
// Unreachable: the filter always returns false so the original
|
||
// exception propagates to the caller exactly as before.
|
||
throw;
|
||
}
|
||
if (NetDiagnostics.ProbeNet)
|
||
Interlocked.Increment(ref _probeSendWindow);
|
||
}
|
||
|
||
/// <summary>
|
||
/// #260 probe: one <c>[net-out]</c> line per outbound reliable game
|
||
/// message. The thread id is load-bearing evidence — every send is
|
||
/// supposed to happen on the frame thread (the ISAAC keystream is
|
||
/// single-threaded); two distinct tids across [net-out] lines would
|
||
/// prove the cross-thread-send/cipher-desync hypothesis by itself.
|
||
/// </summary>
|
||
private void ProbeNetLogOutbound(byte[] body, GameMessageGroup queue)
|
||
{
|
||
uint op = body.Length >= 4
|
||
? BinaryPrimitives.ReadUInt32LittleEndian(body)
|
||
: 0u;
|
||
string detail = string.Empty;
|
||
if (op == 0xF7B1 && body.Length >= 12)
|
||
{
|
||
uint gseq = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4));
|
||
uint act = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8));
|
||
detail = $" act=0x{act:X4} gseq={gseq}";
|
||
}
|
||
Console.WriteLine(
|
||
$"[net-out] op=0x{op:X4}{detail} q={queue}"
|
||
+ $" fseq={_transport?.Outbound.FragmentSequence ?? 0}"
|
||
+ $" pseq={_transport?.Outbound.PeekNextPacketSequence ?? 0}"
|
||
+ $" len={body.Length}"
|
||
+ $" tid={Environment.CurrentManagedThreadId} st={CurrentState}");
|
||
}
|
||
|
||
private bool ProbeNetLogOutboundFault(Exception ex)
|
||
{
|
||
if (NetDiagnostics.ProbeNet)
|
||
{
|
||
Console.WriteLine(
|
||
$"[net-out-EX] {ex.GetType().Name}: {ex.Message}"
|
||
+ $" tid={Environment.CurrentManagedThreadId} st={CurrentState}");
|
||
}
|
||
return false;
|
||
}
|
||
|
||
private void Transition(State next)
|
||
{
|
||
if (CurrentState == next) return;
|
||
CurrentState = next;
|
||
StateChanged?.Invoke(next);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Graceful shutdown: request character logoff, wait for the server's
|
||
/// authoritative <c>0xF653</c> confirmation, and only then disconnect the
|
||
/// transport. This is retail's <c>CPlayerSystem::RequestLogOff</c> to
|
||
/// inbound confirmation to <c>ExecuteLogOff</c> ordering. It prevents a
|
||
/// replacement session from racing the old character's asynchronous
|
||
/// removal on ACE.
|
||
/// </summary>
|
||
public void Dispose()
|
||
{
|
||
if (Interlocked.Exchange(ref _disposeStarted, 1) != 0)
|
||
return;
|
||
|
||
SessionShutdownPlan shutdown = BuildShutdownPlan(
|
||
CurrentState,
|
||
_transportNegotiated,
|
||
_activeCharacterId);
|
||
|
||
Interlocked.Exchange(ref _characterLogOffConfirmed, 0);
|
||
ShutdownExecutionResult result = ExecuteShutdownWire(
|
||
shutdown,
|
||
_activeCharacterId,
|
||
_sessionClientId,
|
||
_sessionIteration,
|
||
SendGameMessage,
|
||
WaitForCharacterLogOffConfirmation,
|
||
packet => _net.Send(packet),
|
||
TimeSpan.FromSeconds(35));
|
||
|
||
if (result.CharacterLogOffSent)
|
||
{
|
||
Console.WriteLine(
|
||
$"[session] graceful logout requested character=0x{_activeCharacterId:X8}");
|
||
if (result.ConfirmationReceived)
|
||
Console.WriteLine("[session] graceful logout confirmed");
|
||
else if (result.CharacterLogOffError is null)
|
||
Console.Error.WriteLine(
|
||
"[session] graceful logout confirmation timed out; disconnecting transport");
|
||
}
|
||
if (result.CharacterLogOffError is not null)
|
||
Console.Error.WriteLine(
|
||
$"[session] graceful logout failed: {result.CharacterLogOffError.Message}");
|
||
if (result.TransportDisconnectError is not null)
|
||
Console.Error.WriteLine(
|
||
$"[session] transport disconnect failed: {result.TransportDisconnectError.Message}");
|
||
|
||
// Slice H-c1: cancel the outstanding socket receive directly, join
|
||
// its task, then return every queued buffer before transport dispose.
|
||
_netCancel.Cancel();
|
||
_netReceiveTask?.GetAwaiter().GetResult();
|
||
_inboundQueue.Writer.TryComplete();
|
||
while (_inboundQueue.Reader.TryRead(
|
||
out PooledInboundDatagram queued))
|
||
{
|
||
ReturnInboundDatagram(queued);
|
||
}
|
||
_netCancel.Dispose();
|
||
|
||
// N5: one cumulative TransportStats summary so the connected loss
|
||
// gate asserts exact totals instead of reconstructing them from the
|
||
// rounded per-second [net-tick] rates.
|
||
if (NetDiagnostics.ProbeNet && _transport is { } finalTransport)
|
||
{
|
||
TransportStats finalStats = finalTransport.Stats;
|
||
Console.WriteLine(
|
||
$"[net-final] resends={finalStats.ResendsSent}"
|
||
+ $" nak-in={finalStats.NakRequestsReceived}"
|
||
+ $" nak-out={finalStats.NaksSent}"
|
||
+ $" rej-in={finalStats.RejectsReceived}"
|
||
+ $" acks-out={finalStats.AcksSent}"
|
||
+ $" acks-in={finalStats.AcksConsumed}"
|
||
+ $" dup-drop={finalStats.InboundDupsDropped}"
|
||
+ $" sanity-drop={finalStats.InboundSanityDrops}"
|
||
+ $" cksum-fail={finalStats.ChecksumFailures}"
|
||
+ $" parked={finalStats.KeysParked}"
|
||
+ $" reclaimed={finalStats.RejectWordsReclaimed}"
|
||
+ $" uncached-nak={finalStats.UncachedNakIds}"
|
||
+ $" cache={finalStats.CacheDepth}"
|
||
+ $" nakset={finalTransport.Inbound.NakCount}");
|
||
}
|
||
|
||
// N1: return every rented sent-packet cache buffer before the
|
||
// socket goes away.
|
||
_transport?.Dispose();
|
||
_net.Dispose();
|
||
Transition(State.Disconnected);
|
||
}
|
||
|
||
internal readonly record struct SessionShutdownPlan(
|
||
bool RequestCharacterLogOff,
|
||
bool SendTransportDisconnect);
|
||
|
||
internal readonly record struct ShutdownExecutionResult(
|
||
bool CharacterLogOffSent,
|
||
bool ConfirmationReceived,
|
||
bool TransportDisconnectSent,
|
||
Exception? CharacterLogOffError,
|
||
Exception? TransportDisconnectError);
|
||
|
||
internal static SessionShutdownPlan BuildShutdownPlan(
|
||
State state,
|
||
bool transportNegotiated,
|
||
uint activeCharacterId) =>
|
||
new(
|
||
RequestCharacterLogOff:
|
||
transportNegotiated
|
||
&& state == State.InWorld
|
||
&& activeCharacterId != 0,
|
||
SendTransportDisconnect: transportNegotiated);
|
||
|
||
/// <summary>
|
||
/// Executes the retail wire order while keeping request and transport
|
||
/// failures independent. The delegates are the production send/wait path;
|
||
/// their narrow shape also makes exact packet ordering deterministic in
|
||
/// tests without duplicating shutdown logic.
|
||
/// </summary>
|
||
internal static ShutdownExecutionResult ExecuteShutdownWire(
|
||
SessionShutdownPlan plan,
|
||
uint activeCharacterId,
|
||
ushort sessionClientId,
|
||
ushort sessionIteration,
|
||
Action<byte[]> sendGameMessage,
|
||
Func<TimeSpan, bool> waitForConfirmation,
|
||
Action<byte[]> sendTransportDatagram,
|
||
TimeSpan confirmationTimeout)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(sendGameMessage);
|
||
ArgumentNullException.ThrowIfNull(waitForConfirmation);
|
||
ArgumentNullException.ThrowIfNull(sendTransportDatagram);
|
||
|
||
bool characterLogOffSent = false;
|
||
bool confirmationReceived = false;
|
||
bool transportDisconnectSent = false;
|
||
Exception? characterError = null;
|
||
Exception? transportError = null;
|
||
|
||
if (plan.RequestCharacterLogOff)
|
||
{
|
||
try
|
||
{
|
||
// Proto_UI::LogOffCharacter @ 0x00546A20 sends the active id.
|
||
sendGameMessage(CharacterLogOff.BuildRequestBody(activeCharacterId));
|
||
characterLogOffSent = true;
|
||
confirmationReceived = waitForConfirmation(confirmationTimeout);
|
||
}
|
||
catch (Exception error)
|
||
{
|
||
characterError = error;
|
||
}
|
||
}
|
||
|
||
if (plan.SendTransportDisconnect)
|
||
{
|
||
try
|
||
{
|
||
// ClientNet::LogOffServer @ 0x00543EF0 sends Disconnect for
|
||
// every negotiated ReceiverData, independent of character
|
||
// entry state.
|
||
sendTransportDatagram(TransportDisconnect.Build(
|
||
sessionClientId,
|
||
sessionIteration));
|
||
transportDisconnectSent = true;
|
||
}
|
||
catch (Exception error)
|
||
{
|
||
transportError = error;
|
||
}
|
||
}
|
||
|
||
return new ShutdownExecutionResult(
|
||
characterLogOffSent,
|
||
confirmationReceived,
|
||
transportDisconnectSent,
|
||
characterError,
|
||
transportError);
|
||
}
|
||
|
||
private bool WaitForCharacterLogOffConfirmation(TimeSpan timeout) =>
|
||
WaitForCharacterLogOffConfirmation(
|
||
_inboundQueue.Reader,
|
||
timeout,
|
||
datagram =>
|
||
{
|
||
ProcessDatagram(
|
||
datagram.Memory,
|
||
dispatchWorldEvents: false);
|
||
// N5: keep the transport pumped while waiting for the
|
||
// logoff confirmation — retail's frame pump
|
||
// (Client::UseTime @ 0x00411C40 →
|
||
// PacketController::UseTime @ 0x005410D0) keeps running
|
||
// until LogOffServer, so the logoff wait is the third
|
||
// blocking pump the sweep must cover (after Tick and the
|
||
// handshake loops). The connected loss gate exposed the
|
||
// gap: without a sweep here, a lost S2C confirmation can
|
||
// be gap-detected (ACE's next sequenced packet arrives and
|
||
// parks a key) but the NAK that would heal it never goes
|
||
// out, and the graceful logout dies at the 35 s timeout.
|
||
// ACE's 2 s ack cadence guarantees arrivals to hang this
|
||
// callback on. (A lost C2S logoff REQUEST remains
|
||
// unrecoverable against ACE — its NAK is arrival-driven
|
||
// and a quiet client is never NAKed, campaign §3 row 1 —
|
||
// the same idle-tail constraint the N4 soak recorded.)
|
||
SweepTransport();
|
||
return Volatile.Read(ref _characterLogOffConfirmed) != 0;
|
||
},
|
||
ReturnInboundDatagram);
|
||
|
||
internal static bool WaitForCharacterLogOffConfirmation<T>(
|
||
ChannelReader<T> reader,
|
||
TimeSpan timeout,
|
||
Func<T, bool> processAndCheckConfirmation,
|
||
Action<T>? release = null)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(reader);
|
||
ArgumentNullException.ThrowIfNull(processAndCheckConfirmation);
|
||
using var timeoutSource = new CancellationTokenSource(timeout);
|
||
|
||
// The deadline is also read straight off the monotonic clock, not only
|
||
// off the token. CancellationTokenSource(TimeSpan) publishes its
|
||
// cancellation from a thread-pool timer callback, so when the pool is
|
||
// saturated the token can stay unsignalled well past the deadline while
|
||
// the loop below keeps draining a queue that already has items in it -
|
||
// exactly the case this method exists to bound. The token still bounds
|
||
// the asynchronous wait; the clock bounds the synchronous drain. A
|
||
// negative timeout is the framework's "infinite" and keeps its meaning.
|
||
long started = Stopwatch.GetTimestamp();
|
||
bool bounded = timeout >= TimeSpan.Zero;
|
||
bool Expired() => bounded && Stopwatch.GetElapsedTime(started) >= timeout;
|
||
|
||
try
|
||
{
|
||
while (!timeoutSource.IsCancellationRequested && !Expired())
|
||
{
|
||
while (reader.TryRead(out T? item))
|
||
{
|
||
if (timeoutSource.IsCancellationRequested || Expired())
|
||
{
|
||
release?.Invoke(item);
|
||
return false;
|
||
}
|
||
|
||
bool confirmed;
|
||
try
|
||
{
|
||
confirmed = processAndCheckConfirmation(item);
|
||
}
|
||
finally
|
||
{
|
||
release?.Invoke(item);
|
||
}
|
||
if (confirmed)
|
||
return true;
|
||
}
|
||
|
||
bool canRead = reader.WaitToReadAsync(timeoutSource.Token)
|
||
.AsTask()
|
||
.GetAwaiter()
|
||
.GetResult();
|
||
if (!canRead)
|
||
return false;
|
||
}
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
return false;
|
||
}
|
||
catch (ChannelClosedException)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
}
|