From 11974c209902acc8d308e517a3ba55fee0373831 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 13 Apr 2026 13:45:39 +0200 Subject: [PATCH] feat(net): track + echo movement sequence counters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sprint 1a of the audit remediation plan. Extracts the 4 movement sequence counters from inbound server messages and echoes them in outbound MoveToState + AutonomousPosition instead of hardcoded zeros: - instanceSequence (slot 8 in CreateObject PhysicsData timestamps) - teleportSequence (slot 4, also from PlayerTeleport 0xF751) - serverControlSequence (slot 5) - forcePositionSequence (slot 6, also from UpdatePosition 0xF748) Source: holtburger player/types.rs:237-245, mutations.rs:182-706. The server uses these to detect stale/reordered movement packets. Previously all zeros → server couldn't distinguish epoch boundaries. Changes: - CreateObject.Parsed: +4 sequence fields extracted from timestamps - UpdatePosition.Parsed: +3 sequence fields from trailing u16s - WorldSession: tracks 4 counters, updates from CreateObject/ UpdatePosition/PlayerTeleport for the player's own GUID - GameWindow: passes tracked values to MoveToState.Build and AutonomousPosition.Build Co-Authored-By: Claude Opus 4.6 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 16 ++++----- src/AcDream.Core.Net/Messages/CreateObject.cs | 18 ++++++++-- .../Messages/UpdatePosition.cs | 22 ++++++++---- src/AcDream.Core.Net/WorldSession.cs | 36 ++++++++++++++++++- 4 files changed, 74 insertions(+), 18 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index cf89590..2968ece 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1753,10 +1753,10 @@ public sealed class GameWindow : IDisposable cellId: wireCellId, position: wirePos, rotation: wireRot, - instanceSequence: 0, - serverControlSequence: 0, - teleportSequence: 0, - forcePositionSequence: 0); + instanceSequence: _liveSession.InstanceSequence, + serverControlSequence: _liveSession.ServerControlSequence, + teleportSequence: _liveSession.TeleportSequence, + forcePositionSequence: _liveSession.ForcePositionSequence); _liveSession.SendGameAction(body); } @@ -1768,10 +1768,10 @@ public sealed class GameWindow : IDisposable cellId: wireCellId, position: wirePos, rotation: wireRot, - instanceSequence: 0, - serverControlSequence: 0, - teleportSequence: 0, - forcePositionSequence: 0); + instanceSequence: _liveSession.InstanceSequence, + serverControlSequence: _liveSession.ServerControlSequence, + teleportSequence: _liveSession.TeleportSequence, + forcePositionSequence: _liveSession.ForcePositionSequence); _liveSession.SendGameAction(body); } } diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs index 4d6691d..aa891f6 100644 --- a/src/AcDream.Core.Net/Messages/CreateObject.cs +++ b/src/AcDream.Core.Net/Messages/CreateObject.cs @@ -92,7 +92,11 @@ public static class CreateObject float? ObjScale, string? Name, ServerMotionState? MotionState, - uint? MotionTableId); + uint? MotionTableId, + ushort InstanceSequence = 0, + ushort TeleportSequence = 0, + ushort ServerControlSequence = 0, + ushort ForcePositionSequence = 0); /// /// The relevant subset of the server-sent MovementData / @@ -332,8 +336,15 @@ public static class CreateObject if ((physicsFlags & PhysicsDescriptionFlag.DefaultScriptIntensity) != 0) pos += 4; // 9 sequence timestamps, always present at end of PhysicsData. + // Indices per holtburger: 0=position, 4=teleport, 5=serverControl, + // 6=forcePosition, 8=instance. if (body.Length - pos < 9 * 2) return PartialResult(); - pos += 9 * 2; // each sequence is a ushort + var seqSpan = body.Slice(pos, 9 * 2); + ushort instanceSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(8 * 2)); + ushort teleportSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(4 * 2)); + ushort serverControlSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(5 * 2)); + ushort forcePositionSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(6 * 2)); + pos += 9 * 2; AlignTo4(ref pos); // --- WeenieHeader: read just the Name field (second after flags). --- @@ -349,7 +360,8 @@ public static class CreateObject } return new Parsed(guid, position, setupTableId, animParts, - textureChanges, subPalettes, basePaletteId, objScale, name, motionState, motionTableId); + textureChanges, subPalettes, basePaletteId, objScale, name, motionState, motionTableId, + instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq); // Local helper: if we ran out of fields past PhysicsData, still // return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion). diff --git a/src/AcDream.Core.Net/Messages/UpdatePosition.cs b/src/AcDream.Core.Net/Messages/UpdatePosition.cs index 77217c2..cccfe4c 100644 --- a/src/AcDream.Core.Net/Messages/UpdatePosition.cs +++ b/src/AcDream.Core.Net/Messages/UpdatePosition.cs @@ -63,7 +63,10 @@ public static class UpdatePosition uint Guid, CreateObject.ServerPosition Position, System.Numerics.Vector3? Velocity, - uint? PlacementId); + uint? PlacementId, + ushort InstanceSequence = 0, + ushort TeleportSequence = 0, + ushort ForcePositionSequence = 0); /// /// Parse a reassembled UpdatePosition body. @@ -144,17 +147,24 @@ public static class UpdatePosition pos += 4; } - // We deliberately skip the four u16 sequence numbers that - // follow; subscribers don't need them for simple rendering, - // and if the message is a deliberate out-of-order teleport the - // server always follows up with a fresher update anyway. + // Four u16 sequence numbers: instance, position, teleport, forcePosition. + ushort instSeq = 0, teleSeq = 0, forceSeq = 0; + if (body.Length - pos >= 8) + { + instSeq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); + // pos+2 = positionSequence (not tracked by movement) + teleSeq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos + 4)); + forceSeq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos + 6)); + pos += 8; + } var serverPos = new CreateObject.ServerPosition( LandblockId: cellId, PositionX: px, PositionY: py, PositionZ: pz, RotationW: rw, RotationX: rx, RotationY: ry, RotationZ: rz); - return new Parsed(guid, serverPos, velocity, placementId); + return new Parsed(guid, serverPos, velocity, placementId, + instSeq, teleSeq, forceSeq); } catch { diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 3c34631..17d4356 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -116,6 +116,12 @@ public sealed class WorldSession : IDisposable public event Action? StateChanged; public State CurrentState { get; private set; } = State.Disconnected; + + /// Movement sequence counters for outbound MoveToState/AutonomousPosition. + public ushort InstanceSequence => _instanceSequence; + public ushort ServerControlSequence => _serverControlSequence; + public ushort TeleportSequence => _teleportSequence; + public ushort ForcePositionSequence => _forcePositionSequence; public CharacterList.Parsed? Characters { get; private set; } private readonly NetClient _net; @@ -129,6 +135,16 @@ public sealed class WorldSession : IDisposable private uint _clientPacketSequence; private uint _fragmentSequence = 1; + // Movement sequence counters — echoed back in every MoveToState and + // AutonomousPosition so the server can detect stale/reordered packets. + // Initialized from CreateObject PhysicsData timestamps, updated by + // UpdatePosition/UpdateMotion/PlayerTeleport. Per holtburger: + // instance=slot 8, teleport=slot 4, serverControl=slot 5, forcePosition=slot 6. + private ushort _instanceSequence; + private ushort _serverControlSequence; + private ushort _teleportSequence; + private ushort _forcePositionSequence; + // Phase A.3: background receive thread buffers raw UDP datagrams into // a channel so the render thread never blocks on socket I/O. private readonly Channel _inboundQueue = @@ -400,6 +416,15 @@ public sealed class WorldSession : IDisposable var parsed = CreateObject.TryParse(body); if (parsed is not null) { + // Initialize sequence counters from the player's own CreateObject. + if (parsed.Value.Guid == Characters?.Characters.FirstOrDefault().Id) + { + _instanceSequence = parsed.Value.InstanceSequence; + _teleportSequence = parsed.Value.TeleportSequence; + _serverControlSequence = parsed.Value.ServerControlSequence; + _forcePositionSequence = parsed.Value.ForcePositionSequence; + } + EntitySpawned?.Invoke(new EntitySpawn( parsed.Value.Guid, parsed.Value.Position, @@ -440,6 +465,14 @@ public sealed class WorldSession : IDisposable var posUpdate = UpdatePosition.TryParse(body); if (posUpdate is not null) { + // Update sequence counters from the player's own position updates. + if (posUpdate.Value.Guid == Characters?.Characters.FirstOrDefault().Id) + { + _instanceSequence = posUpdate.Value.InstanceSequence; + _teleportSequence = posUpdate.Value.TeleportSequence; + _forcePositionSequence = posUpdate.Value.ForcePositionSequence; + } + PositionUpdated?.Invoke(new EntityPositionUpdate( posUpdate.Value.Guid, posUpdate.Value.Position, @@ -457,10 +490,11 @@ public sealed class WorldSession : IDisposable // movement; the LoginComplete is sent from GameWindow once // the destination UpdatePosition is received and the player // has been snapped to the new cell. - uint sequence = 0; + ushort sequence = 0; if (body.Length >= 6) sequence = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian( body.AsSpan(4, 2)); + _teleportSequence = sequence; // track for outbound movement messages TeleportStarted?.Invoke(sequence); } }