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,
};
}

View file

@ -163,6 +163,10 @@ public sealed class GameWindow : IDisposable
// once the injected shared helpers exist as method groups (GetSetupCylinder,
// ApplyServerControlledVelocityCycle). See RemotePhysicsUpdater.
private readonly AcDream.App.Physics.RemotePhysicsUpdater _remotePhysicsUpdater;
private readonly AcDream.App.Input.LocalPlayerProjectionController _localPlayerProjection;
private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound;
private readonly AcDream.App.Input.RetailLocalPlayerFrameController _localPlayerFrame;
private readonly AcDream.App.World.RetailLiveFrameCoordinator _liveFrameCoordinator;
private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController;
// Step 7 projectile presentation. The controller owns no identity map;
// each runtime component is stored on the canonical LiveEntityRecord.
@ -1182,6 +1186,51 @@ public sealed class GameWindow : IDisposable
// handler) stay on GameWindow and are injected as delegates.
_remotePhysicsUpdater = new AcDream.App.Physics.RemotePhysicsUpdater(
_physicsEngine, GetSetupCylinder, ApplyServerControlledVelocityCycle);
_localPlayerProjection = new AcDream.App.Input.LocalPlayerProjectionController(
resolveEntity: () =>
_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity)
? entity
: null,
liveCenterX: () => _liveCenterX,
liveCenterY: () => _liveCenterY,
syncShadow: (entity, cellId) => SyncLocalPlayerShadow(entity, cellId),
rebucket: (serverGuid, landblockId) =>
_liveEntities?.RebucketLiveEntity(serverGuid, landblockId));
_localPlayerOutbound = new AcDream.App.Input.LocalPlayerOutboundController(
YawToAcQuaternion,
DumpMovementTruthOutbound);
_localPlayerFrame = new AcDream.App.Input.RetailLocalPlayerFrameController(
canPresentPlayer: CanAdvanceLocalPlayer,
getController: () => _playerController,
captureInput: () =>
{
_playerMouseDeltaX = 0f;
return CaptureMovementInput();
},
resolveLocalEntityId: () =>
_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity)
? entity.Id
: 0u,
handleTargeting: () => _playerHost?.HandleTargetting(),
isHidden: () => _liveEntities?.IsHidden(_playerServerGuid) == true,
project: (controller, movement, hidden) =>
_localPlayerProjection.Project(controller, movement, hidden),
sendPreNetwork: (controller, movement, hidden) =>
_localPlayerOutbound.SendPreNetworkActions(
_liveSession,
controller,
movement,
hidden),
sendPostNetwork: (controller, hidden) =>
_localPlayerOutbound.SendPostNetworkPosition(
_liveSession,
controller,
hidden));
_liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator(
AdvanceLiveObjectRuntime,
() => _liveSessionController?.Tick(),
_localPlayerFrame.RunPostNetworkCommandPhase,
ReconcileLiveObjectSpatialPresentation);
}
public void Run()
@ -7066,6 +7115,7 @@ public sealed class GameWindow : IDisposable
// camera path re-extends the boom. Updating directly from the stale
// source-world viewer made the destination bend across the reveal.
_retailChaseCamera?.ResetViewerToPlayer(snappedPos, _playerController.Yaw);
ReconcileLiveObjectSpatialPresentation();
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
"PLACED", resolved.CellId, $"forced={forced}");
@ -8764,11 +8814,15 @@ public sealed class GameWindow : IDisposable
{
using var _updStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Update);
double frameSeconds =
AcDream.App.World.RetailLiveFrameCoordinator.NormalizeDeltaSeconds(dt);
float frameDelta = (float)frameSeconds;
// Retail ScriptManager::AddScriptInternal (0x0051B310) stamps
// Timer::cur_time at the packet/default-script call site. Publish this
// update frame's clock before streaming and network dispatch, then
// reuse the same timestamp for animation hooks and the later drain.
_physicsScriptGameTime += Math.Max(0.0, dt);
_physicsScriptGameTime += frameSeconds;
_scriptRunner?.PublishTime(_physicsScriptGameTime);
// [FRAME-DIAG]: applies run later this frame inside _streamingController.Tick;
@ -8955,12 +9009,64 @@ public sealed class GameWindow : IDisposable
}
}
// Input callbacks feed the current object tick. Packets already read
// by Core.Net remain queued until the retail object/physics phase has
// consumed that input and published its final pose.
_inputDispatcher?.Tick();
// Phase K.2 — mouse-look is an input source for this object's
// movement tick, so sample it before the retail CPhysics/network
// barrier just like held keyboard actions.
if (_mouseLook is not null)
{
bool wantCaptureMouse = IsUiCapturingMouse();
if (wantCaptureMouse != _lastWantCaptureMouse)
{
if (wantCaptureMouse && _mouseLook.Active)
EndMouseLookAndRestoreCursor();
_mouseLook.OnWantCaptureMouseChanged(wantCaptureMouse);
_lastWantCaptureMouse = wantCaptureMouse;
}
float nowSeconds = (float)(Environment.TickCount64 / 1000.0);
if (_mouseLook.TryTakeRawSample(nowSeconds, out float rawX, out float rawY))
{
// GetInput synthesizes (0,0) only after >0.2 s idle.
// MouseLookHandler stops drift before filtering that sample;
// the filter tail may then start a smaller Rotate again.
if (rawX == 0f && rawY == 0f)
_playerController?.StopMouseDrift(CaptureMovementInput());
(float filteredX, float filteredY) =
AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera
&& _retailChaseCamera is not null
? _retailChaseCamera.FilterMouseDelta(
rawX,
rawY,
weight: 0.5f,
nowSec: nowSeconds)
: (rawX, rawY);
_mouseLook.ApplyDelta(filteredX, _sensChase);
if (_retailChaseCamera is not null)
_retailChaseCamera.AdjustPitch(filteredY * 0.003f * _sensChase);
else
_chaseCamera?.AdjustPitch(filteredY * 0.003f * _sensChase);
}
}
_combatAttackController?.Tick();
// Drain pending live-session traffic AFTER streaming so any incoming
// CreateObject events find their landblock already loaded in
// GpuWorldState. Non-blocking — returns immediately if no datagrams
// are in the kernel buffer. Fires EntitySpawned events synchronously.
// Step 2: routed through the controller; functionally identical.
_liveSessionController?.Tick();
// Retail SmartBox::UseTime (0x00455410) advances CObjectMaint and
// CPhysics before it drains the inbound event queue. Keep the complete
// live-object phase on that side of the barrier too. In particular,
// the recall action retires before ACE's Hidden SetState freezes its
// PartArray at the teleport boundary.
_liveFrameCoordinator.Tick(frameDelta);
// Usually F751 activates immediately. This no-op convergence check
// covers the session edge where the canonical player exists before
@ -8980,7 +9086,7 @@ public sealed class GameWindow : IDisposable
bool ready = haveDest && TeleportWorldReady(_pendingTeleportCell);
if (haveDest && !ready)
{
_teleportHoldSeconds += (float)dt;
_teleportHoldSeconds += frameDelta;
if (_teleportHoldSeconds >= TeleportMaxHoldSeconds)
{
ready = true;
@ -8989,7 +9095,7 @@ public sealed class GameWindow : IDisposable
}
int tunnelFrame = _portalTunnel?.CurrentAnimationFrame ?? 0;
var (snap, evts) = _teleportAnim.Tick((float)dt, ready, tunnelFrame);
var (snap, evts) = _teleportAnim.Tick(frameDelta, ready, tunnelFrame);
_teleportViewPlane.Update(snap);
foreach (var e in evts)
@ -9027,62 +9133,16 @@ public sealed class GameWindow : IDisposable
}
}
_portalTunnel?.Tick((float)dt);
_portalTunnel?.Tick(frameDelta);
}
// Phase K.1a — tick the input dispatcher so Hold-type bindings
// re-fire while their chord is held. K.1b adds the subscribers
// that actually consume the events.
_inputDispatcher?.Tick();
_combatAttackController?.Tick();
// Phase D.5.3a — advance the selected-object overlay flash (0.25s green pulse
// on selection, then revert). No-op when nothing is flashing.
// Retained panel-local timers advance through RetailUiRuntime.Tick on the draw seam.
// Phase K.2 — re-evaluate WantCaptureMouse for the MMB
// mouse-look state machine. Detect rising/falling edges so the
// state suspends correctly when ImGui claims the cursor while
// MMB is held (e.g. a tooltip pop-up over the cursor). When the
// suspend deactivates an active session, restore the cursor so
// it doesn't get stuck hidden under a panel.
if (_mouseLook is not null)
{
bool wcm = IsUiCapturingMouse();
if (wcm != _lastWantCaptureMouse)
{
if (wcm && _mouseLook.Active)
EndMouseLookAndRestoreCursor();
_mouseLook.OnWantCaptureMouseChanged(wcm);
_lastWantCaptureMouse = wcm;
}
float nowSeconds = (float)(Environment.TickCount64 / 1000.0);
if (_mouseLook.TryTakeRawSample(nowSeconds, out float rawX, out float rawY))
{
// GetInput synthesizes (0,0) only after >0.2 s idle.
// MouseLookHandler stops drift before filtering that sample;
// the filter tail may then start a smaller Rotate again.
if (rawX == 0f && rawY == 0f)
_playerController?.StopMouseDrift(CaptureMovementInput());
(float filteredX, float filteredY) =
AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera
&& _retailChaseCamera is not null
? _retailChaseCamera.FilterMouseDelta(
rawX,
rawY,
weight: 0.5f,
nowSec: nowSeconds)
: (rawX, rawY);
_mouseLook.ApplyDelta(filteredX, _sensChase);
if (_retailChaseCamera is not null)
_retailChaseCamera.AdjustPitch(filteredY * 0.003f * _sensChase);
else
_chaseCamera?.AdjustPitch(filteredY * 0.003f * _sensChase);
}
}
// Phase K.2 — auto-enter player mode at login. The guard
// returns true on the firing tick (one-shot); subsequent ticks
// are no-ops. Skipped offline (no _liveSession → IsLiveInWorld
@ -9116,7 +9176,7 @@ public sealed class GameWindow : IDisposable
// delta drives fly heading instead).
if (_inputDispatcher is null) return;
_cameraController.Fly.Update(
dt,
frameSeconds,
w: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementForward),
a: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementTurnLeft),
s: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementBackup),
@ -9142,7 +9202,7 @@ public sealed class GameWindow : IDisposable
// retail chase is selected; legacy camera ignores these.
if (AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera && _retailChaseCamera is not null)
{
float adj = AcDream.Core.Rendering.CameraDiagnostics.CameraAdjustmentSpeed * (float)dt;
float adj = AcDream.Core.Rendering.CameraDiagnostics.CameraAdjustmentSpeed * frameDelta;
if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraZoomIn))
_retailChaseCamera.AdjustDistance(-adj);
if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraZoomOut))
@ -9161,82 +9221,17 @@ public sealed class GameWindow : IDisposable
// until Q is pressed again. Handled in OnInputAction; here
// we just OR _autoRunActive into the Forward flag.
// * Mouse never drives character yaw (K.1b regression-prevention).
var input = CaptureMovementInput();
if (!_localPlayerFrame.TryGetPresentationAfterNetwork(out var playerFrame))
return;
// Fix #42 (2026-05-05): keep PlayerMovementController's
// LocalEntityId in sync with the live local player entity so
// FindObjCollisions skips its own ShadowEntry. Re-fetched per
// tick so re-spawns / character switches don't leave a stale
// id on the controller. Pre-spawn or between-character it
// stays 0 (no filter), which is harmless because there's no
// ShadowEntry registered yet.
_playerController.LocalEntityId =
_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var localEnt)
? localEnt.Id
: 0u;
// R5-V2: tick the player's TargetManager BEFORE Update ticks
// MoveToManager.UseTime (retail UpdateObjectInternal order). This is
// the load-bearing call for creature-chase: the player, as a watched
// target, pushes its position to its voyeurs (the NPCs moving-to it)
// here — that push lands in each NPC's HandleUpdateTarget the same
// frame, ahead of the NPCs' own UseTime in the per-remote loop
// (which runs after this block). Replaces the AP-79 player poll.
_playerHost?.HandleTargetting();
bool localPlayerHidden = _liveEntities?.IsHidden(_playerServerGuid) == true;
var result = localPlayerHidden
? _playerController.TickHidden((float)dt)
: _playerController.Update((float)dt, input);
// Update the player entity's position + rotation so it renders at
// the physics-resolved location each frame.
if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe))
AcDream.App.Input.MovementResult result = playerFrame.Movement;
bool localPlayerHidden = playerFrame.Hidden;
if (!playerFrame.AdvancedBeforeNetwork)
{
pe.SetPosition(result.RenderPosition); // A.5 T18: SetPosition propagates AabbDirty
pe.ParentCellId = result.CellId;
pe.Rotation = System.Numerics.Quaternion.CreateFromAxisAngle(
System.Numerics.Vector3.UnitZ, _playerController.Yaw - MathF.PI / 2f);
if (!localPlayerHidden)
SyncLocalPlayerShadow(pe, result.CellId);
// Move the player entity to its current landblock in GpuWorldState
// so it doesn't get frustum-culled when the player walks away from
// the spawn landblock. Without this, the entity stays in the spawn
// landblock's entity list and disappears when that landblock is culled.
uint currentLb;
if (result.CellId != 0 && (result.CellId & 0xFFFFu) >= 0x0100u)
{
// Indoor cell (dungeon/building EnvCell): the entity's landblock is
// the CELL's landblock. Dungeon EnvCells sit at arbitrary "ocean"
// world coords with negative local-Y, so floor(pp.Y/192) lands one
// landblock off (the Bug-A class) — relocating the player into the
// landblock the dungeon collapse unloaded, making the avatar
// invisible. The cell id is authoritative.
currentLb = (result.CellId & 0xFFFF0000u) | 0xFFFFu;
}
else
{
var pp = _playerController.Position;
int plx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f);
int ply = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f);
currentLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF);
}
// #138-B (2026-06-24): do NOT relocate the avatar while a teleport
// is in transit (PortalSpace). The controller's cell is still the
// FROZEN SOURCE until PlaceTeleportArrival materializes the
// destination, so relocating here drags the avatar — which the
// teleport's rescue/re-inject (GpuWorldState.DrainRescued) already
// placed at the destination center — back into the now-UNLOADED
// SOURCE landblock's pending bucket, where nothing recovers it
// (the old relocate path only scanned _loaded). Net: the avatar vanishes
// after teleporting out. The teleport machinery owns the avatar's
// landblock during transit; per-frame relocation resumes at
// FireLoginComplete (InWorld). Probe-confirmed: launch4 line 561
// APPEND guid=player lb=0x0007FFFF(source dungeon) -> PENDING ->
// DRAWSET ABSENT, never recovered.
if (_playerController.State != AcDream.App.Input.PlayerState.PortalSpace)
_liveEntities?.RebucketLiveEntity(pe.ServerGuid, currentLb);
// The player was materialized by this frame's inbound pass.
// Its non-advancing initial projection must publish child and
// effect anchors before the first draw too.
ReconcileLiveObjectSpatialPresentation();
}
// Update chase camera(s). The CameraController exposes whichever
@ -9249,7 +9244,7 @@ public sealed class GameWindow : IDisposable
// low-stiffness damping).
_chaseCamera.Update(result.RenderPosition, _playerController.Yaw,
isOnGround: result.IsOnGround,
dt: (float)dt);
dt: frameDelta);
// RetailChaseCamera: heading is the player's facing direction
// projected onto the contact plane when grounded, or the
// world XY plane when airborne. The contact plane normal
@ -9263,98 +9258,26 @@ public sealed class GameWindow : IDisposable
playerVelocity: _playerController.BodyVelocity,
isOnGround: result.IsOnGround,
contactPlaneNormal: _playerController.ContactPlane.Normal,
dt: (float)dt,
dt: frameDelta,
cellId: _playerController.CellId,
selfEntityId: _playerController.LocalEntityId,
trackedTargetPoint: trackedCombatTarget);
// Send outbound movement messages to the live server.
if (_liveSession is not null
&& !localPlayerHidden
&& _playerController.TryGetOutboundPosition(
YawToAcQuaternion(_playerController.Yaw),
out uint wireCellId,
out System.Numerics.Vector3 wirePos))
{
// Retail serializes CPhysicsObj::m_position directly. The
// controller owns this canonical (cell, landblock-local origin)
// pair; the render/streaming origin never enters wire coordinates.
var wireRot = YawToAcQuaternion(_playerController.Yaw);
byte contactByte = result.IsOnGround ? (byte)1 : (byte)0;
var wirePosition = new AcDream.Core.Physics.Position(
wireCellId,
wirePos,
wireRot);
// Snapshot the AP predicate before this path mutates its send
// tracker. Retail's UseTime calls ShouldSendPositionEvent then
// SendPositionEvent; MTS originates in separate input handlers,
// so their relative same-tick callback order remains TS-33.
bool positionEventDue = _playerController.ShouldSendPositionEvent(
wirePosition,
_playerController.ContactPlane,
_playerController.SimTimeSeconds);
// 2026-05-16 (issue #75) / R4-V5: user-MoveToState packets
// are ONLY for user-initiated motion intent — retail's
// architectural split between user-input motion and
// server-driven motion. Post-V5 the split holds BY
// By construction, ShouldSendMovementEvent derives from user
// input edges plus CameraSet's mouse-look start/stop/0.5-second
// cadence. MoveToManager dispatches never touch either source,
// so server-driven movement cannot echo a MoveToState. A user
// edge cancels the moveto and legitimately takes control.
if (result.ShouldSendMovementEvent)
TrySendPlayerMovementEvent(result);
// SendPositionEvent itself admits only Contact+OnWalkable.
if (positionEventDue && _playerController.CanSendPositionEvent)
{
var seq = _liveSession.NextGameActionSequence();
var body = AcDream.Core.Net.Messages.AutonomousPosition.Build(
gameActionSequence: seq,
cellId: wireCellId,
position: wirePos,
rotation: wireRot,
instanceSequence: _liveSession.InstanceSequence,
serverControlSequence: _liveSession.ServerControlSequence,
teleportSequence: _liveSession.TeleportSequence,
forcePositionSequence: _liveSession.ForcePositionSequence,
lastContact: contactByte);
DumpMovementTruthOutbound(
"AP", seq, result, wirePos, wireCellId, contactByte);
_liveSession.SendGameAction(body);
_playerController.NotePositionSent(
position: wirePosition,
contactPlane: _playerController.ContactPlane,
nowSeconds: _playerController.SimTimeSeconds);
}
if (result.JumpExtent.HasValue && result.JumpVelocity.HasValue)
{
// D4/L.2b (2026-06-30): JumpPack::Pack (0x00516d10) packs the
// full Position, not an objectGuid/spellId — pass the same
// wireCellId/wirePos/wireRot the MoveToState send above uses.
var seq = _liveSession.NextGameActionSequence();
var jumpBody = AcDream.Core.Net.Messages.JumpAction.Build(
gameActionSequence: seq,
extent: result.JumpExtent.Value,
velocity: result.JumpVelocity.Value,
cellId: wireCellId,
position: wirePos,
rotation: wireRot,
instanceSequence: _liveSession.InstanceSequence,
serverControlSequence: _liveSession.ServerControlSequence,
teleportSequence: _liveSession.TeleportSequence,
forcePositionSequence: _liveSession.ForcePositionSequence);
_liveSession.SendGameAction(jumpBody);
}
}
// Update the player entity's animation cycle to match current motion.
}
}
private bool CanAdvanceLocalPlayer() =>
_cameraController is not null
&& _input is not null
&& !_cameraController.IsFlyMode
&& _playerMode
&& _playerController is not null
&& _chaseCamera is not null
&& _inputDispatcher is not null
&& !(DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard);
private void DumpMovementTruthOutbound(
string kind,
uint sequence,
@ -9589,40 +9512,6 @@ public sealed class GameWindow : IDisposable
if (DevToolsEnabled && _imguiBootstrap is not null)
_imguiBootstrap.BeginFrame((float)deltaSeconds);
// Phase 6.4: advance per-entity animation playback before drawing
// so the renderer always sees the up-to-date per-part transforms.
// PhysicsScript timed hooks and animation hooks read the same current
// retail update-frame clock published before network dispatch. Drain
// the script pass later after final root/part poses are composed.
// Re-publish without advancing so an extra render between updates still
// retains retail's animation-hook versus object-hook phase barrier.
_scriptRunner?.PublishTime(_physicsScriptGameTime);
if (_liveEntities is { } liveEntities)
{
_remotePhysicsUpdater.TickHiddenEntities(
liveEntities,
_playerServerGuid,
(float)deltaSeconds,
entity => _effectPoses.UpdateRoot(entity));
}
_projectileController?.Tick(
_physicsScriptGameTime,
_liveCenterX,
_liveCenterY,
_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var projectileViewer)
? projectileViewer.Position
: null);
if (_animatedEntities.Count > 0)
TickAnimations((float)deltaSeconds);
_equippedChildRenderer?.Tick();
_animationHookFrames?.Drain();
// #188 — advance translucency fades UNCONDITIONALLY (not gated on
// _animatedEntities.Count): a one-shot open-cycle animation can
// finish and drop its entity from _animatedEntities before the
// fade's own Time has elapsed, but the ramp must keep running.
_translucencyFades.AdvanceAll((float)deltaSeconds);
// Phase G.1: weather state machine — deterministic per-day roll
// + transitions + lightning flash.
var cal = WorldTime.CurrentCalendar;
@ -9784,12 +9673,6 @@ public sealed class GameWindow : IDisposable
// debug-only and disabled for normal retail rendering.
if (_options.EnableSkyPesDebug)
UpdateSkyPes((float)WorldTime.DayFraction, _activeDayGroup, camPos, cameraInsideCell);
_entityEffects?.RefreshLiveOwnerPoses();
_scriptRunner?.Tick(_physicsScriptGameTime);
_particleSink?.RefreshAttachedEmitters();
_liveEntityLights?.Refresh();
_particleSystem?.Tick((float)deltaSeconds);
// Phase G.1/G.2: feed the sun, tick LightManager, build + upload
// the scene-lighting UBO once per frame. Every shader that
// consumes binding=1 reads the same data for the rest of the
@ -10737,6 +10620,71 @@ public sealed class GameWindow : IDisposable
ReportCollisionEnd: () => _physicsEngine.ShadowObjects.Suspend(localEntityId)));
}
/// <summary>
/// Advances the live object's retail UseTime phase before inbound packet
/// dispatch. Final root/part poses and their animation hooks are published
/// together so rendering consumes one coherent update snapshot.
/// </summary>
private void AdvanceLiveObjectRuntime(float dt)
{
// The local body participates in the same retail CPhysics phase as
// remote objects. Its outbound movement snapshot is completed here,
// before SmartBox dispatch can apply F751/ForcePosition.
_localPlayerFrame.AdvanceBeforeNetwork(dt);
_scriptRunner?.PublishTime(_physicsScriptGameTime);
if (_liveEntities is { } liveEntities)
{
_remotePhysicsUpdater.TickHiddenEntities(
liveEntities,
_playerServerGuid,
dt,
entity => _effectPoses.UpdateRoot(entity));
}
_projectileController?.Tick(
_physicsScriptGameTime,
_liveCenterX,
_liveCenterY,
_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var projectileViewer)
? projectileViewer.Position
: null);
if (_animatedEntities.Count > 0)
TickAnimations(dt);
_equippedChildRenderer?.Tick();
// Advance already-active FP hooks before routing hooks emitted by
// this PartArray update. A newly created translucency hook begins at
// its authored Start value this frame; it does not consume dt twice.
_translucencyFades.AdvanceAll(dt);
_animationHookFrames?.Drain();
_entityEffects?.RefreshLiveOwnerPoses();
_particleSink?.RefreshAttachedEmitters();
_liveEntityLights?.Refresh();
// Retail CPhysicsObj::UpdateObjectInternal (0x005156B0) advances
// ParticleManager before ScriptManager. A particle created by a PES
// hook therefore begins simulation on the following object frame.
_particleSystem?.Tick(dt);
_scriptRunner?.Tick(_physicsScriptGameTime);
}
/// <summary>
/// Re-composes spatial derivatives after authoritative packet handlers
/// move roots. This pass advances no time, animation, scripts, particles,
/// or fades; it only makes children and live anchors agree with the root
/// that the same frame will draw.
/// </summary>
private void ReconcileLiveObjectSpatialPresentation()
{
// Authoritative handlers mutate WorldEntity roots directly. Publish
// those roots before children read their parent's pose; child Tick
// then publishes each newly composed child pose for attached effects.
_entityEffects?.RefreshLiveOwnerPoses();
_equippedChildRenderer?.Tick();
_particleSink?.RefreshAttachedEmitters();
_liveEntityLights?.Refresh();
}
/// <summary>
/// Phase 6.4: advance every animated entity's frame counter by
/// <paramref name="dt"/> * Framerate, wrapping around the cycle's
@ -12921,79 +12869,14 @@ public sealed class GameWindow : IDisposable
}
internal static AcDream.Core.Physics.RawMotionState BuildOutboundRawMotionState(
AcDream.App.Input.MovementResult result)
{
var axisHoldKey = result.IsRunning
? AcDream.Core.Physics.HoldKey.Run
: AcDream.Core.Physics.HoldKey.None;
return new AcDream.Core.Physics.RawMotionState
{
CurrentHoldKey = axisHoldKey,
ForwardCommand = result.ForwardCommand
?? AcDream.Core.Physics.RawMotionState.Default.ForwardCommand,
ForwardHoldKey = result.ForwardCommand.HasValue
? axisHoldKey : AcDream.Core.Physics.HoldKey.Invalid,
ForwardSpeed = result.ForwardSpeed
?? AcDream.Core.Physics.RawMotionState.Default.ForwardSpeed,
SidestepCommand = result.SidestepCommand
?? AcDream.Core.Physics.RawMotionState.Default.SidestepCommand,
SidestepHoldKey = result.SidestepCommand.HasValue
? result.SidestepUsesRunHold
? AcDream.Core.Physics.HoldKey.Run
: axisHoldKey
: AcDream.Core.Physics.HoldKey.Invalid,
SidestepSpeed = result.SidestepSpeed
?? AcDream.Core.Physics.RawMotionState.Default.SidestepSpeed,
TurnCommand = result.TurnCommand
?? AcDream.Core.Physics.RawMotionState.Default.TurnCommand,
TurnHoldKey = result.TurnCommand.HasValue
? result.TurnUsesRunHold
? AcDream.Core.Physics.HoldKey.Run
: axisHoldKey
: AcDream.Core.Physics.HoldKey.Invalid,
TurnSpeed = result.TurnSpeed
?? AcDream.Core.Physics.RawMotionState.Default.TurnSpeed,
};
}
AcDream.App.Input.MovementResult result) =>
AcDream.App.Input.LocalPlayerOutboundController.BuildRawMotionState(result);
private bool TrySendPlayerMovementEvent(AcDream.App.Input.MovementResult result)
{
if (_liveSession is not { } session
|| _playerController is not { } controller)
return false;
var wireRot = YawToAcQuaternion(controller.Yaw);
if (!controller.TryGetOutboundPosition(
wireRot,
out uint wireCellId,
out System.Numerics.Vector3 wirePos))
return false;
byte contactByte = result.IsOnGround ? (byte)1 : (byte)0;
AcDream.Core.Physics.RawMotionState rawMotionState =
BuildOutboundRawMotionState(result);
uint seq = session.NextGameActionSequence();
byte[] body = AcDream.Core.Net.Messages.MoveToState.Build(
gameActionSequence: seq,
rawMotionState: rawMotionState,
cellId: wireCellId,
position: wirePos,
rotation: wireRot,
instanceSequence: session.InstanceSequence,
serverControlSequence: session.ServerControlSequence,
teleportSequence: session.TeleportSequence,
forcePositionSequence: session.ForcePositionSequence,
contact: contactByte != 0,
standingLongjump: false);
DumpMovementTruthOutbound(
"MTS", seq, result, wirePos, wireCellId, contactByte);
session.SendGameAction(body);
controller.NoteMovementSent(
controller.SimTimeSeconds,
result.IsMouseLookMovementEvent);
return true;
}
=> _localPlayerOutbound.TrySendMovement(
_liveSession,
_playerController,
result);
private uint? GetSelectedOrClosestCombatTarget()
{

View file

@ -0,0 +1,53 @@
namespace AcDream.App.World;
/// <summary>
/// Owns retail's live-object, inbound-dispatch, and command-interpreter frame
/// phases.
/// </summary>
/// <remarks>
/// Retail <c>SmartBox::UseTime</c> (<c>0x00455410</c>) advances
/// <c>CObjectMaint::UseTime</c> and <c>CPhysics::UseTime</c> before draining
/// <c>SmartBox::in_queue</c>, then calls <c>CommandInterpreter::UseTime</c>.
/// Keeping both boundaries explicit prevents an inbound Hidden transition
/// from freezing an action due to complete this frame while ensuring the
/// periodic AutonomousPosition check observes accepted inbound state.
/// </remarks>
public sealed class RetailLiveFrameCoordinator
{
private readonly Action<float> _advanceObjectRuntime;
private readonly Action _dispatchInboundNetwork;
private readonly Action _runPostNetworkCommands;
private readonly Action _reconcileSpatialPresentation;
public RetailLiveFrameCoordinator(
Action<float> advanceObjectRuntime,
Action dispatchInboundNetwork,
Action runPostNetworkCommands,
Action reconcileSpatialPresentation)
{
_advanceObjectRuntime = advanceObjectRuntime
?? throw new ArgumentNullException(nameof(advanceObjectRuntime));
_dispatchInboundNetwork = dispatchInboundNetwork
?? throw new ArgumentNullException(nameof(dispatchInboundNetwork));
_runPostNetworkCommands = runPostNetworkCommands
?? throw new ArgumentNullException(nameof(runPostNetworkCommands));
_reconcileSpatialPresentation = reconcileSpatialPresentation
?? throw new ArgumentNullException(nameof(reconcileSpatialPresentation));
}
public void Tick(float deltaSeconds)
{
float frameDelta = (float)NormalizeDeltaSeconds(deltaSeconds);
_advanceObjectRuntime(frameDelta);
_dispatchInboundNetwork();
_runPostNetworkCommands();
_reconcileSpatialPresentation();
}
public static double NormalizeDeltaSeconds(double deltaSeconds) =>
double.IsFinite(deltaSeconds)
&& deltaSeconds > 0.0
&& deltaSeconds <= float.MaxValue
? deltaSeconds
: 0.0;
}