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:
parent
dded9e6b17
commit
75acae02d6
13 changed files with 1068 additions and 332 deletions
209
src/AcDream.App/Input/LocalPlayerOutboundController.cs
Normal file
209
src/AcDream.App/Input/LocalPlayerOutboundController.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
68
src/AcDream.App/Input/LocalPlayerProjectionController.cs
Normal file
68
src/AcDream.App/Input/LocalPlayerProjectionController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
155
src/AcDream.App/Input/RetailLocalPlayerFrameController.cs
Normal file
155
src/AcDream.App/Input/RetailLocalPlayerFrameController.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue