fix(world): complete recall before teleport hide

Match retail's update ordering so object animation, particles, and scripts advance before inbound teleport state is applied. Separate input-originated movement from post-network autonomous position output, and reconcile presentation without a second physics tick so recall cannot resume after arrival.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-16 06:37:29 +02:00
parent dded9e6b17
commit 75acae02d6
13 changed files with 1068 additions and 332 deletions

View file

@ -0,0 +1,209 @@
using System.Numerics;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
namespace AcDream.App.Input;
/// <summary>
/// Serializes input-originated output from the object-phase result and the
/// periodic position report from post-inbound controller state.
/// </summary>
public sealed class LocalPlayerOutboundController
{
private readonly Func<float, Quaternion> _toWireRotation;
private readonly Action<string, uint, MovementResult, Vector3, uint, byte> _diagnostic;
public LocalPlayerOutboundController(
Func<float, Quaternion> toWireRotation,
Action<string, uint, MovementResult, Vector3, uint, byte> diagnostic)
{
_toWireRotation = toWireRotation
?? throw new ArgumentNullException(nameof(toWireRotation));
_diagnostic = diagnostic
?? throw new ArgumentNullException(nameof(diagnostic));
}
/// <summary>
/// Sends input-originated movement and jump packets before inbound
/// dispatch. Retail emits these from input/object processing rather than
/// from <c>CommandInterpreter::UseTime</c>.
/// </summary>
public void SendPreNetworkActions(
WorldSession? session,
PlayerMovementController controller,
MovementResult movement,
bool hidden)
{
if (session is null || hidden)
return;
Quaternion wireRotation = _toWireRotation(controller.Yaw);
if (!controller.TryGetOutboundPosition(
wireRotation,
out uint wireCellId,
out Vector3 wirePosition))
{
return;
}
if (movement.ShouldSendMovementEvent)
TrySendMovement(session, controller, movement);
if (movement.JumpExtent.HasValue && movement.JumpVelocity.HasValue)
{
uint sequence = session.NextGameActionSequence();
byte[] body = JumpAction.Build(
gameActionSequence: sequence,
extent: movement.JumpExtent.Value,
velocity: movement.JumpVelocity.Value,
cellId: wireCellId,
position: wirePosition,
rotation: wireRotation,
instanceSequence: session.InstanceSequence,
serverControlSequence: session.ServerControlSequence,
teleportSequence: session.TeleportSequence,
forcePositionSequence: session.ForcePositionSequence);
session.SendGameAction(body);
}
}
/// <summary>
/// Ports the position-send slot in <c>CommandInterpreter::UseTime</c>
/// (<c>0x006B3BF0</c>), which SmartBox calls after draining inbound
/// events. The predicate and serialized frame therefore observe any
/// accepted ForcePosition or teleport state from this update.
/// </summary>
public void SendPostNetworkPosition(
WorldSession? session,
PlayerMovementController controller,
bool hidden)
{
if (session is null || hidden)
return;
Quaternion wireRotation = _toWireRotation(controller.Yaw);
if (!controller.TryGetOutboundPosition(
wireRotation,
out uint wireCellId,
out Vector3 wirePosition))
{
return;
}
var position = new Position(wireCellId, wirePosition, wireRotation);
if (!controller.ShouldSendPositionEvent(
position,
controller.ContactPlane,
controller.SimTimeSeconds)
|| !controller.CanSendPositionEvent)
{
return;
}
MovementResult movement = controller.CapturePresentationResult();
byte contactByte = movement.IsOnGround ? (byte)1 : (byte)0;
uint sequence = session.NextGameActionSequence();
byte[] body = AutonomousPosition.Build(
gameActionSequence: sequence,
cellId: wireCellId,
position: wirePosition,
rotation: wireRotation,
instanceSequence: session.InstanceSequence,
serverControlSequence: session.ServerControlSequence,
teleportSequence: session.TeleportSequence,
forcePositionSequence: session.ForcePositionSequence,
lastContact: contactByte);
_diagnostic(
"AP",
sequence,
movement,
wirePosition,
wireCellId,
contactByte);
session.SendGameAction(body);
controller.NotePositionSent(
position,
controller.ContactPlane,
controller.SimTimeSeconds);
}
public bool TrySendMovement(
WorldSession? session,
PlayerMovementController? controller,
MovementResult movement)
{
if (session is null || controller is null)
return false;
Quaternion wireRotation = _toWireRotation(controller.Yaw);
if (!controller.TryGetOutboundPosition(
wireRotation,
out uint wireCellId,
out Vector3 wirePosition))
{
return false;
}
byte contactByte = movement.IsOnGround ? (byte)1 : (byte)0;
RawMotionState rawMotionState = BuildRawMotionState(movement);
uint sequence = session.NextGameActionSequence();
byte[] body = MoveToState.Build(
gameActionSequence: sequence,
rawMotionState: rawMotionState,
cellId: wireCellId,
position: wirePosition,
rotation: wireRotation,
instanceSequence: session.InstanceSequence,
serverControlSequence: session.ServerControlSequence,
teleportSequence: session.TeleportSequence,
forcePositionSequence: session.ForcePositionSequence,
contact: contactByte != 0,
standingLongjump: false);
_diagnostic(
"MTS",
sequence,
movement,
wirePosition,
wireCellId,
contactByte);
session.SendGameAction(body);
controller.NoteMovementSent(
controller.SimTimeSeconds,
movement.IsMouseLookMovementEvent);
return true;
}
public static RawMotionState BuildRawMotionState(MovementResult movement)
{
HoldKey axisHoldKey = movement.IsRunning ? HoldKey.Run : HoldKey.None;
return new RawMotionState
{
CurrentHoldKey = axisHoldKey,
ForwardCommand = movement.ForwardCommand
?? RawMotionState.Default.ForwardCommand,
ForwardHoldKey = movement.ForwardCommand.HasValue
? axisHoldKey : HoldKey.Invalid,
ForwardSpeed = movement.ForwardSpeed
?? RawMotionState.Default.ForwardSpeed,
SidestepCommand = movement.SidestepCommand
?? RawMotionState.Default.SidestepCommand,
SidestepHoldKey = movement.SidestepCommand.HasValue
? movement.SidestepUsesRunHold
? HoldKey.Run
: axisHoldKey
: HoldKey.Invalid,
SidestepSpeed = movement.SidestepSpeed
?? RawMotionState.Default.SidestepSpeed,
TurnCommand = movement.TurnCommand
?? RawMotionState.Default.TurnCommand,
TurnHoldKey = movement.TurnCommand.HasValue
? movement.TurnUsesRunHold
? HoldKey.Run
: axisHoldKey
: HoldKey.Invalid,
TurnSpeed = movement.TurnSpeed
?? RawMotionState.Default.TurnSpeed,
};
}
}

View file

@ -0,0 +1,68 @@
using AcDream.Core.World;
namespace AcDream.App.Input;
/// <summary>
/// Projects the canonical local physics body into world rendering and spatial
/// buckets without advancing it.
/// </summary>
public sealed class LocalPlayerProjectionController
{
private readonly Func<WorldEntity?> _resolveEntity;
private readonly Func<int> _liveCenterX;
private readonly Func<int> _liveCenterY;
private readonly Action<WorldEntity, uint> _syncShadow;
private readonly Action<uint, uint> _rebucket;
public LocalPlayerProjectionController(
Func<WorldEntity?> resolveEntity,
Func<int> liveCenterX,
Func<int> liveCenterY,
Action<WorldEntity, uint> syncShadow,
Action<uint, uint> rebucket)
{
_resolveEntity = resolveEntity ?? throw new ArgumentNullException(nameof(resolveEntity));
_liveCenterX = liveCenterX ?? throw new ArgumentNullException(nameof(liveCenterX));
_liveCenterY = liveCenterY ?? throw new ArgumentNullException(nameof(liveCenterY));
_syncShadow = syncShadow ?? throw new ArgumentNullException(nameof(syncShadow));
_rebucket = rebucket ?? throw new ArgumentNullException(nameof(rebucket));
}
public void Project(
PlayerMovementController controller,
MovementResult movement,
bool hidden)
{
WorldEntity? entity = _resolveEntity();
if (entity is null)
return;
entity.SetPosition(movement.RenderPosition);
entity.ParentCellId = movement.CellId;
entity.Rotation = System.Numerics.Quaternion.CreateFromAxisAngle(
System.Numerics.Vector3.UnitZ,
controller.Yaw - MathF.PI / 2f);
if (!hidden)
_syncShadow(entity, movement.CellId);
uint currentLandblock;
if (movement.CellId != 0 && (movement.CellId & 0xFFFFu) >= 0x0100u)
{
// Indoor positions belong to the cell's landblock. Dungeon frame
// origins can be negative, so render XYZ is not a landblock key.
currentLandblock = (movement.CellId & 0xFFFF0000u) | 0xFFFFu;
}
else
{
System.Numerics.Vector3 position = controller.Position;
int landblockX = _liveCenterX() + (int)Math.Floor(position.X / 192f);
int landblockY = _liveCenterY() + (int)Math.Floor(position.Y / 192f);
currentLandblock = (uint)((landblockX << 24) | (landblockY << 16) | 0xFFFF);
}
// The teleport owner alone projects the destination while the local
// controller deliberately retains its frozen source cell.
if (controller.State != PlayerState.PortalSpace)
_rebucket(entity.ServerGuid, currentLandblock);
}
}

View file

@ -916,6 +916,21 @@ public sealed class PlayerMovementController
IsMouseLookMovementEvent: mouseLookEvent);
}
/// <summary>
/// Captures the current controller state for presentation without
/// advancing physics or requesting an outbound movement event.
/// </summary>
public MovementResult CapturePresentationResult()
{
MovementResult current = CaptureMovementResult(mouseLookEvent: false);
return current with
{
MotionStateChanged = false,
ShouldSendMovementEvent = false,
IsMouseLookMovementEvent = false,
};
}
/// <summary>
/// Retail <c>CommandInterpreter::SendPositionEvent</c> (0x006B4770)
/// stamps the send time, complete cell-local frame (origin and

View file

@ -0,0 +1,155 @@
namespace AcDream.App.Input;
/// <summary>
/// Owns the local player's once-per-frame retail object phase.
/// </summary>
/// <remarks>
/// Retail advances the player and sends input-originated movement/jump output
/// before <c>SmartBox::UseTime</c> drains inbound events. Its later
/// <c>CommandInterpreter::UseTime</c> position check uses current post-inbound
/// state. The cached input result is presentation-only after the barrier: its
/// one-shot commands are never replayed against a later authoritative cell.
/// </remarks>
public sealed class RetailLocalPlayerFrameController
{
private readonly Func<bool> _canPresentPlayer;
private readonly Func<PlayerMovementController?> _getController;
private readonly Func<MovementInput> _captureInput;
private readonly Func<uint> _resolveLocalEntityId;
private readonly Action _handleTargeting;
private readonly Func<bool> _isHidden;
private readonly Action<PlayerMovementController, MovementResult, bool> _project;
private readonly Action<PlayerMovementController, MovementResult, bool> _sendPreNetwork;
private readonly Action<PlayerMovementController, bool> _sendPostNetwork;
private AdvancedFrame? _advancedFrame;
private readonly record struct AdvancedFrame(
PlayerMovementController Controller,
MovementResult Movement);
public readonly record struct PresentationFrame(
MovementResult Movement,
bool Hidden,
bool AdvancedBeforeNetwork);
public RetailLocalPlayerFrameController(
Func<bool> canPresentPlayer,
Func<PlayerMovementController?> getController,
Func<MovementInput> captureInput,
Func<uint> resolveLocalEntityId,
Action handleTargeting,
Func<bool> isHidden,
Action<PlayerMovementController, MovementResult, bool> project,
Action<PlayerMovementController, MovementResult, bool> sendPreNetwork,
Action<PlayerMovementController, bool> sendPostNetwork)
{
_canPresentPlayer = canPresentPlayer
?? throw new ArgumentNullException(nameof(canPresentPlayer));
_getController = getController
?? throw new ArgumentNullException(nameof(getController));
_captureInput = captureInput
?? throw new ArgumentNullException(nameof(captureInput));
_resolveLocalEntityId = resolveLocalEntityId
?? throw new ArgumentNullException(nameof(resolveLocalEntityId));
_handleTargeting = handleTargeting
?? throw new ArgumentNullException(nameof(handleTargeting));
_isHidden = isHidden
?? throw new ArgumentNullException(nameof(isHidden));
_project = project
?? throw new ArgumentNullException(nameof(project));
_sendPreNetwork = sendPreNetwork
?? throw new ArgumentNullException(nameof(sendPreNetwork));
_sendPostNetwork = sendPostNetwork
?? throw new ArgumentNullException(nameof(sendPostNetwork));
}
/// <summary>
/// Advances the existing local object exactly once on the object side of
/// the inbound-network barrier.
/// </summary>
public void AdvanceBeforeNetwork(float deltaSeconds)
{
_advancedFrame = null;
PlayerMovementController? controller = _getController();
if (!_canPresentPlayer() || controller is null)
return;
controller.LocalEntityId = _resolveLocalEntityId();
_handleTargeting();
bool hidden = _isHidden();
MovementResult movement = hidden
? controller.TickHidden(deltaSeconds)
: controller.Update(deltaSeconds, _captureInput());
_project(controller, movement, hidden);
_sendPreNetwork(controller, movement, hidden);
_advancedFrame = new AdvancedFrame(controller, movement);
}
/// <summary>
/// Runs retail's post-inbound command-interpreter phase. The split
/// controller/render projection is reconciled without advancing physics,
/// then AutonomousPosition is evaluated from that current state.
/// </summary>
public void RunPostNetworkCommandPhase()
{
PlayerMovementController? controller = _getController();
if (!_canPresentPlayer() || controller is null)
return;
bool hidden = _isHidden();
if (_advancedFrame is { } advanced
&& ReferenceEquals(advanced.Controller, controller))
{
MovementResult movement = RefreshSpatialFields(
advanced.Movement,
controller);
_project(controller, movement, hidden);
_advancedFrame = new AdvancedFrame(controller, movement);
}
_sendPostNetwork(controller, hidden);
}
/// <summary>
/// Produces the post-network presentation sample without advancing
/// physics. An owner first created by the inbound pass is seeded from its
/// current state and receives its first real object tick next frame.
/// </summary>
public bool TryGetPresentationAfterNetwork(out PresentationFrame frame)
{
frame = default;
PlayerMovementController? controller = _getController();
if (!_canPresentPlayer() || controller is null)
return false;
bool hidden = _isHidden();
if (_advancedFrame is { } advanced
&& ReferenceEquals(advanced.Controller, controller))
{
MovementResult movement = RefreshSpatialFields(
advanced.Movement,
controller);
frame = new PresentationFrame(movement, hidden, AdvancedBeforeNetwork: true);
return true;
}
MovementResult initial = controller.CapturePresentationResult();
_project(controller, initial, hidden);
frame = new PresentationFrame(initial, hidden, AdvancedBeforeNetwork: false);
return true;
}
private static MovementResult RefreshSpatialFields(
MovementResult movement,
PlayerMovementController controller) =>
movement with
{
Position = controller.Position,
RenderPosition = controller.RenderPosition,
CellId = controller.CellId,
IsOnGround = controller.CanSendPositionEvent,
};
}