acdream/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs
Erik eeb0f6b45c refactor(runtime): extract local teleport and player mode
Move local teleport, player-mode, animation, shadow, and sealed-dungeon lifetimes out of GameWindow. Make player-mode entry transactional and isolate approach completions by controller lifetime and approach generation so stale callbacks cannot affect replacements.

Co-authored-by: Codex <noreply@openai.com>
2026-07-22 02:50:15 +02:00

1789 lines
92 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using AcDream.App.Combat;
using AcDream.App.Input;
using AcDream.App.Interaction;
using AcDream.App.Physics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Update;
using AcDream.App.World;
using AcDream.Content;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Items;
using AcDream.Core.Physics;
using AcDream.Core.Selection;
using AcDream.Core.World;
using DatReaderWriter;
namespace AcDream.App.Physics;
/// <summary>
/// Update-thread owner of retail SmartBox's accepted Movement, Vector, State,
/// and Position presentation transactions. Identity and timestamp authority
/// remain canonical in <see cref="LiveEntityRuntime"/>; this controller only
/// routes an accepted exact incarnation into its App-layer owners.
/// </summary>
internal sealed class LiveEntityNetworkUpdateController
: ILiveEntityNetworkUpdateSink,
ILiveEntitySameGenerationUpdateSink,
ILocalPlayerLandblockSource
{
private readonly LiveEntityRuntime _liveEntities;
private readonly ClientObjectTable _objects;
private readonly LiveEntityHydrationController _liveEntityHydration;
private readonly EntityEffectController _entityEffects;
private readonly LiveEntityPresentationController _liveEntityPresentation;
private readonly LiveEntityLightController _liveEntityLights;
private readonly EquippedChildRenderController _equippedChildRenderer;
private readonly ProjectileController _projectileController;
private readonly RemoteTeleportController _remoteTeleportController;
private readonly LiveEntityAnimationRuntimeView<LiveEntityAnimationState> _animatedEntities;
private readonly LiveEntityRemoteMotionRuntimeView<RemoteMotion> _remoteDeadReckon;
private readonly RemoteMovementObservationTracker _remoteMovementObservations;
private readonly RemotePhysicsUpdater _remotePhysicsUpdater;
private readonly RemoteInboundMotionDispatcher _remoteInboundMotion;
private readonly LiveEntityMotionRuntimeController _motionRuntime;
private readonly PhysicsEngine _physicsEngine;
private readonly IDatReaderWriter _dats;
private readonly IAnimationLoader _animLoader;
private readonly CombatTargetController? _combatTargetController;
private readonly LiveWorldOriginState _origin;
private readonly AcDream.App.Streaming.ILocalPlayerTeleportNetworkSink
_localPlayerTeleport;
private readonly Func<PlayerMovementController?> _playerControllerSource;
private readonly LocalPlayerOutboundController _localPlayerOutbound;
private readonly Func<EntityPhysicsHost?> _playerHostSource;
private readonly Func<uint> _playerGuid;
private readonly IPhysicsScriptTimeSource _gameTime;
private readonly Func<WorldSession?> _session;
private readonly LiveEntityInboundAuthorityGate _authorityGate;
private readonly IMovementTruthDiagnosticSink _movementTruthDiagnostics;
private PlayerMovementController? _playerController => _playerControllerSource();
private EntityPhysicsHost? _playerHost => _playerHostSource();
private uint _playerServerGuid => _playerGuid();
private double _physicsScriptGameTime => _gameTime.CurrentScriptTime;
private IReadOnlyDictionary<uint, WorldEntity> _entitiesByServerGuid =>
_liveEntities.MaterializedWorldEntities;
private IReadOnlyDictionary<uint, WorldEntity> _visibleEntitiesByServerGuid =>
_liveEntities.WorldEntities;
internal uint? LastLivePlayerLandblockId =>
_authorityGate.LastLivePlayerLandblockId;
uint? ILocalPlayerLandblockSource.LastKnownLandblockId =>
LastLivePlayerLandblockId;
public LiveEntityNetworkUpdateController(
LiveEntityRuntime liveEntities,
ClientObjectTable objects,
LiveEntityHydrationController liveEntityHydration,
EntityEffectController entityEffects,
LiveEntityPresentationController liveEntityPresentation,
LiveEntityLightController liveEntityLights,
EquippedChildRenderController equippedChildRenderer,
ProjectileController projectileController,
RemoteTeleportController remoteTeleportController,
LiveEntityAnimationRuntimeView<LiveEntityAnimationState> animatedEntities,
LiveEntityRemoteMotionRuntimeView<RemoteMotion> remoteDeadReckon,
RemoteMovementObservationTracker remoteMovementObservations,
RemotePhysicsUpdater remotePhysicsUpdater,
RemoteInboundMotionDispatcher remoteInboundMotion,
LiveEntityMotionRuntimeController motionRuntime,
PhysicsEngine physicsEngine,
IDatReaderWriter dats,
IAnimationLoader animLoader,
CombatTargetController? combatTargetController,
LiveWorldOriginState origin,
AcDream.App.Streaming.ILocalPlayerTeleportNetworkSink localPlayerTeleport,
Func<PlayerMovementController?> playerControllerSource,
LocalPlayerOutboundController localPlayerOutbound,
Func<EntityPhysicsHost?> playerHostSource,
Func<uint> playerGuid,
IPhysicsScriptTimeSource gameTime,
Func<WorldSession?> session,
Action<uint, AcceptedPhysicsTimestamps> publishTimestamps,
IMovementTruthDiagnosticSink movementTruthDiagnostics)
{
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_liveEntityHydration = liveEntityHydration ?? throw new ArgumentNullException(nameof(liveEntityHydration));
_entityEffects = entityEffects ?? throw new ArgumentNullException(nameof(entityEffects));
_liveEntityPresentation = liveEntityPresentation ?? throw new ArgumentNullException(nameof(liveEntityPresentation));
_liveEntityLights = liveEntityLights ?? throw new ArgumentNullException(nameof(liveEntityLights));
_equippedChildRenderer = equippedChildRenderer ?? throw new ArgumentNullException(nameof(equippedChildRenderer));
_projectileController = projectileController ?? throw new ArgumentNullException(nameof(projectileController));
_remoteTeleportController = remoteTeleportController ?? throw new ArgumentNullException(nameof(remoteTeleportController));
_animatedEntities = animatedEntities ?? throw new ArgumentNullException(nameof(animatedEntities));
_remoteDeadReckon = remoteDeadReckon ?? throw new ArgumentNullException(nameof(remoteDeadReckon));
_remoteMovementObservations = remoteMovementObservations ?? throw new ArgumentNullException(nameof(remoteMovementObservations));
_remotePhysicsUpdater = remotePhysicsUpdater ?? throw new ArgumentNullException(nameof(remotePhysicsUpdater));
_remoteInboundMotion = remoteInboundMotion ?? throw new ArgumentNullException(nameof(remoteInboundMotion));
_motionRuntime = motionRuntime ?? throw new ArgumentNullException(nameof(motionRuntime));
_physicsEngine = physicsEngine ?? throw new ArgumentNullException(nameof(physicsEngine));
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
_animLoader = animLoader ?? throw new ArgumentNullException(nameof(animLoader));
_combatTargetController = combatTargetController;
_origin = origin ?? throw new ArgumentNullException(nameof(origin));
_localPlayerTeleport = localPlayerTeleport
?? throw new ArgumentNullException(nameof(localPlayerTeleport));
_playerControllerSource = playerControllerSource ?? throw new ArgumentNullException(nameof(playerControllerSource));
_localPlayerOutbound = localPlayerOutbound
?? throw new ArgumentNullException(nameof(localPlayerOutbound));
_playerHostSource = playerHostSource ?? throw new ArgumentNullException(nameof(playerHostSource));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
_gameTime = gameTime ?? throw new ArgumentNullException(nameof(gameTime));
_session = session ?? throw new ArgumentNullException(nameof(session));
_authorityGate = new LiveEntityInboundAuthorityGate(
liveEntities,
publishTimestamps);
_movementTruthDiagnostics = movementTruthDiagnostics
?? throw new ArgumentNullException(nameof(movementTruthDiagnostics));
}
internal void ResetSessionState() => _authorityGate.ResetSessionState();
private static bool IsPlayerGuid(uint guid) =>
(guid & 0xFF000000u) == 0x50000000u;
private static bool IsDoorName(string? name) => name == "Door";
private bool RunRemoteTeleportHook(
uint serverGuid,
uint localEntityId,
Func<bool> isCurrent)
{
_remoteDeadReckon.TryGetValue(serverGuid, out RemoteMotion? remote);
EntityPhysicsHost? host =
_liveEntities.TryGetPhysicsHost(serverGuid, out var registered)
? registered as EntityPhysicsHost
: null;
return RemoteTeleportHook.Execute(
new RemoteTeleportHookActions(
CancelMoveTo: error => remote?.Movement.CancelMoveTo(error),
UnStick: () => host?.PositionManager.UnStick(),
StopInterpolating: () => remote?.Interp.Clear(),
UnConstrain: () => host?.PositionManager.UnConstrain(),
NotifyTeleported: () => host?.NotifyTeleported(),
ReportCollisionEnd: () => _physicsEngine.ShadowObjects.Suspend(localEntityId)),
isCurrent);
}
public void ApplySameGeneration(
SameGenerationCreateObjectEvents refresh) =>
LiveEntitySameGenerationUpdateRouter.Apply(refresh, this);
void ILiveEntitySameGenerationUpdateSink.OnDescription(
uint ownerGuid,
PhysicsSpawnData description)
{
if (_liveEntities.TryGetEffectProfile(
ownerGuid,
out var effectProfile)
&& effectProfile is EntityEffectProfile liveProfile)
{
liveProfile.ApplyNetworkDescription(description);
_entityEffects.OnLiveEntityDescriptionChanged(ownerGuid);
}
}
void ILiveEntitySameGenerationUpdateSink.OnAppearance(
AcDream.Core.Net.Messages.ObjDescEvent.Parsed appearance) =>
_liveEntityHydration.OnAppearance(appearance);
void ILiveEntitySameGenerationUpdateSink.OnParent(CreateParentUpdate parent) =>
_liveEntityHydration.OnCreateParentAccepted(parent);
void ILiveEntitySameGenerationUpdateSink.OnPosition(
WorldSession.EntityPositionUpdate position) => OnPosition(position);
void ILiveEntitySameGenerationUpdateSink.OnPickup(
AcDream.Core.Net.Messages.PickupEvent.Parsed pickup) =>
_liveEntityHydration.OnPickup(pickup);
void ILiveEntitySameGenerationUpdateSink.OnMovement(
WorldSession.EntityMotionUpdate movement) => OnMotion(movement);
void ILiveEntitySameGenerationUpdateSink.OnState(
AcDream.Core.Net.Messages.SetState.Parsed state) => OnState(state);
void ILiveEntitySameGenerationUpdateSink.OnVector(
AcDream.Core.Net.Messages.VectorUpdate.Parsed vector) => OnVector(vector);
public void OnMotion(AcDream.Core.Net.WorldSession.EntityMotionUpdate update)
{
// L.2g S1 (DEV-6): retail staleness gate — BEFORE any state mutation.
// Retail drops stale/duplicate/superseded movement events at
// DispatchSmartBoxEvent (INSTANCE_TS, pseudo-C:357214) +
// CPhysics::SetObjectMovement (MOVEMENT_TS strictly-newer +
// SERVER_CONTROLLED_MOVE_TS, 0x00509690). Without this, a reordered
// straggler re-applies an old gait or un-stops a stop.
bool retainPayload = update.Guid != _playerServerGuid || !update.IsAutonomous;
if (!_authorityGate.TryAcceptMotion(
update,
retainPayload,
out AcceptedMotionNetworkUpdate accepted,
out bool timestampAccepted))
{
if (!timestampAccepted
&& (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1"
|| Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
)
{
Console.WriteLine(
$"[UM_STALE] guid={update.Guid:X8} inst={update.InstanceSequence} "
+ $"mov={update.MovementSequence} sc={update.ServerControlSequence} dropped");
}
return;
}
// R4-V5 (pin P1): retail CPhysics::SetObjectMovement's autonomous
// gate (0x00509690 @0050972e, raw 271370-271431) — a movement event
// whose wire autonomous byte is set is DROPPED ENTIRELY (no state
// application, no interrupt) when the addressed object IsThePlayer.
// ACE reflects the client's own outbound MoveToState back to the
// sender with IsAutonomous=1 hardcoded (MovementData.cs:162 +
// Player_Networking.cs:365) and retail never lets that echo reach
// unpack_movement — which is what makes the unconditional
// unpack-head interrupt in the player branch below safe against
// ACE. Order matches retail: the sequence gates above run FIRST.
// last_move_was_autonomous is NOT stored for dropped events (stored
// only on the unpack path). This retires the row-less "don't cancel
// on non-MoveTo UM" adaptation that lived here pre-V5 (its causal
// story was stale — V0-pins.md P1). Run-rate sync is re-anchored to
// retail's own feeds: PlayerDescription skills (SetCharacterSkills,
// K-fix7) + the mt-6/7 my_run_rate wire write below (M13) — the
// former ApplyServerRunRate echo tap is deleted, not gated.
LiveEntityRecord acceptedMotionRecord = accepted.Record;
ulong acceptedMovementAuthorityVersion =
accepted.MovementAuthorityVersion;
ulong acceptedMovementVelocityAuthorityVersion =
accepted.VelocityAuthorityVersion;
if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return;
if (!_animatedEntities.TryGetValue(entity.Id, out var ae))
{
DispatchRemoteInboundMotion(
update,
entity,
ae: null,
acceptedMotionRecord,
acceptedMovementAuthorityVersion,
acceptedMovementVelocityAuthorityVersion);
return;
}
if (_dats is null) return;
// Re-resolve using the new stance/command. Keep the setup and
// motion-table we already know about — the server's motion
// updates override state within the same table, not swap tables.
//
// IMPORTANT: stance and command are BOTH optional. Remote-player
// autonomous broadcasts frequently set only one flag (e.g. just
// ForwardCommand) with currentStyle=0x0000 meaning "no stance
// change — keep current." Treating stance=0 as "default stance"
// drops the real state; instead we preserve the sequencer's
// current style.
ushort stance = update.MotionState.Stance;
ushort? command = update.MotionState.ForwardCommand;
// A.1 (Commit A.1 2026-05-03): UM_RAW — every inbound UM, one line,
// gated on ACDREAM_REMOTE_VEL_DIAG=1. Skips the local player. Tells
// us the actual UM arrival rate per remote and which fields are set
// on each. The bug-suspect is "ACE sends UMs without ForwardCommand
// bit during running, our picker resolves to Ready, SetCycle(Ready)
// resets the cycle". This diag lets us count how often that happens.
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1"
&& update.Guid != _playerServerGuid)
{
string cmdStrRaw = command.HasValue ? $"0x{command.Value:X4}" : "null";
string sideStr = update.MotionState.SideStepCommand is { } s ? $"0x{s:X4}" : "null";
string turnStr = update.MotionState.TurnCommand is { } t ? $"0x{t:X4}" : "null";
string fwdSpdStr = update.MotionState.ForwardSpeed is { } fs ? $"{fs:F2}" : "null";
uint seqMot = ae.Sequencer?.CurrentMotion ?? 0;
System.Console.WriteLine(
$"[UM_RAW] guid={update.Guid:X8} stance=0x{stance:X4} fwd={cmdStrRaw} fwdSpd={fwdSpdStr} "
+ $"side={sideStr} turn={turnStr} mt=0x{update.MotionState.MovementType:X2} "
+ $"isMoveTo={update.MotionState.IsServerControlledMoveTo} "
+ $"seq.CurrentMotion=0x{seqMot:X8}");
}
// Diagnostic: dump every inbound UpdateMotion so we can trace why
// remote chars don't transition off RunForward when they stop.
// Enable with ACDREAM_DUMP_MOTION=1.
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1"
&& update.Guid != _playerServerGuid)
{
string cmdStr = command.HasValue ? $"0x{command.Value:X4}" : "null";
float spd = update.MotionState.ForwardSpeed
?? ((update.MotionState.MoveToSpeed ?? 0f)
* (update.MotionState.MoveToRunRate ?? 0f));
uint seqStyle = ae.Sequencer?.CurrentStyle ?? 0;
uint seqMotion = ae.Sequencer?.CurrentMotion ?? 0;
Console.WriteLine(
$"UM guid=0x{update.Guid:X8} mt=0x{update.MotionState.MovementType:X2} stance=0x{stance:X4} cmd={cmdStr} spd={spd:F2} " +
$"| seq now style=0x{seqStyle:X8} motion=0x{seqMotion:X8}");
}
// Per-Door UM dispatch trail; grep [door-cycle] in launch.log to verify door animation.
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled
&& IsDoorName(_objects.Get(update.Guid)?.Name))
{
Console.WriteLine(System.FormattableString.Invariant(
$"[door-cycle] guid=0x{update.Guid:X8} stance=0x{stance:X4} cmd=0x{(command ?? 0u):X4}"));
}
// ── Sequencer path (preferred) ──────────────────────────────────
// Call SetCycle directly. The sequencer already handles:
// - left→right / backward→forward remapping via adjust_motion
// - style and motion as u32 MotionCommand values
// - fast-path for identical state
//
// When the server omits a field (stance flag not set, or command
// flag not set), "no change" means we must preserve the sequencer's
// current state, NOT fall back to a table default.
if (ae.Sequencer is not null)
{
uint fullStyle = stance != 0
? (0x80000000u | (uint)stance)
: ae.Sequencer.CurrentStyle;
// ACE's stop signal: ForwardCommand flag CLEARED on the wire.
// Per ACE InterpretedMotionState(MovementData) ctor + BuildMovementFlags,
// when the player releases keys the InterpretedMotionState has
// ForwardCommand = Invalid (default) and BuildMovementFlags doesn't
// set bit 0x02 — so the field is absent. Retail's decompiled
// handler (FUN_005295D0 → FUN_0051F260 @ chunk_00510000.c:13957)
// bulk-copies Invalid/0 into the physics obj, which StopCompletely
// treats as "return to style default (Ready)."
//
// command == null → retail stop signal → Ready
// command.Value == 0 → explicit 0 (rare) → Ready
// otherwise → resolve class byte and use full cmd
float speedMod = update.MotionState.ForwardSpeed ?? 1f;
uint fullMotion;
// R4-V4: the PlanMoveToStart seed is DELETED — MoveTo UMs no
// longer flow through the interpreted funnel at all (retail
// unpack_movement routes types 6-9 to MoveToManager; only type
// 0 does the interpreted-state copy). The manager's own
// BeginMoveForward -> get_command -> _DoMotion produces the
// cycle through the same sink every other motion uses.
if (!command.HasValue || command.Value == 0)
{
fullMotion = 0x41000003u;
}
else
{
// Use MotionCommandResolver to restore the proper class
// byte from the wire's 16-bit ForwardCommand.
uint resolved = AcDream.Core.Physics.MotionCommandResolver
.ReconstructFullCommand(command.Value);
fullMotion = resolved != 0
? resolved
: (ae.Sequencer.CurrentMotion & 0xFF000000u) | (uint)command.Value;
if (fullMotion == (uint)command.Value) // no class bits yet
fullMotion = 0x40000000u | (uint)command.Value;
}
// ForwardSpeed from the InterpretedMotionState (flag 0x04).
// ACE omits this field when speed == 1.0 (only sets the flag
// when ForwardSpeed != 1.0 — InterpretedMotionState.cs:101).
// So:
// - field absent → default 1.0 (normal speed)
// - field present → USE THE VALUE, including zero.
//
// Zero is a VALID stop signal: when the retail client releases
// W, ACE broadcasts WalkForward with ForwardSpeed=0 (via
// apply_run_to_command). Treating zero as "unspecified / 1.0"
// produces "slow walk that never stops" — exactly what the
// stop bug looked like.
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1"
&& update.Guid != _playerServerGuid)
Console.WriteLine(
$"UM ↳ SetCycle(style=0x{fullStyle:X8}, motion=0x{fullMotion:X8}, speed={speedMod:F2})");
// No-op if same; the sequencer's fast path guards against that.
uint priorMotion = ae.Sequencer.CurrentMotion;
// The SetObjectMovement gate above already rejects local
// autonomous echoes. A local event reaching this point is
// server-authored, so retail applies it through the interpreted
// funnel. This is especially important for attacks: ACE chooses
// the exact swing and carries it in Commands[].
if (update.Guid == _playerServerGuid)
{
// B.6 slice 1 (2026-05-14): trace inbound motion for the
// local player. One line per inbound UM, gated on
// ACDREAM_PROBE_AUTOWALK=1 (name kept through R4-V5).
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
{
string cmdHex = command.HasValue ? $"0x{command.Value:X4}" : "null";
string pathStr = update.MotionState.MoveToPath is { } p
? $"path=cell=0x{p.OriginCellId:X8},xyz=({p.OriginX:F2},{p.OriginY:F2},{p.OriginZ:F2}),minDist={p.MinDistance:F2},objDist={p.DistanceToObject:F2}"
: "path=null";
string spd = update.MotionState.ForwardSpeed is { } fs
? $"fwdSpd={fs:F2}"
: "fwdSpd=null";
string mtsSpd = update.MotionState.MoveToSpeed is { } ms
? $"mtSpd={ms:F2}"
: "mtSpd=null";
string mtsRun = update.MotionState.MoveToRunRate is { } mr
? $"mtRun={mr:F2}"
: "mtRun=null";
Console.WriteLine(System.FormattableString.Invariant(
$"[autowalk-mt] stance=0x{stance:X4} cmd={cmdHex} mt=0x{update.MotionState.MovementType:X2} isMoveTo={update.MotionState.IsServerControlledMoveTo} moveTowards={update.MotionState.MoveTowards} {pathStr} {spd} {mtsSpd} {mtsRun}"));
}
// R4-V5: retail unpack_movement dispatch for the local
// player — the SAME shape the remote branch uses below.
// Head (@300566): interrupt + unstick fire for EVERY
// movement event that reached unpack (the P1 gate above
// already dropped the autonomous echoes that would have
// made this unsafe against ACE); then types 6-9 route to
// the player's MoveToManager. mt-0 falls through to the
// interpreted-state copy below; LastMoveWasAutonomous=false
// is the local equivalent of LoseControlToServer until the
// next user-input edge takes control back.
if (_playerController is not null)
{
// P1 tail (00509730): the unpack path stores the wire
// autonomous byte BEFORE unpack_movement — always false
// here (the gate above dropped autonomous events). This
// is what routes the controller's per-tick pump (A3
// dual dispatch) to the INTERPRETED branch during a
// server moveto. LOCAL PLAYER ONLY for now: remotes'
// interps have no WeenieObj, which A3 treats as
// IsThePlayer — storing a remote player's autonomous
// byte would flip their per-tick apply onto the raw
// branch and clobber their funnel state; the remote
// store lands with real remote weenies (R5+).
_playerController.SetLastMoveWasAutonomous(update.IsAutonomous);
bool IsCurrentLocalMotion() =>
_liveEntities.IsCurrentMovementAuthority(
acceptedMotionRecord,
acceptedMovementAuthorityVersion)
&& _liveEntities.IsCurrentVelocityAuthority(
acceptedMotionRecord,
acceptedMovementVelocityAuthorityVersion)
&& ReferenceEquals(
acceptedMotionRecord.WorldEntity,
entity);
if (!IsCurrentLocalMotion())
return;
// Local and remote packets now share the literal
// MovementManager::unpack_movement funnel. Besides
// removing duplicate retail ordering, the authority
// predicate is rechecked after every callback boundary;
// a nested newer packet can never be overwritten by the
// tail of this older one.
AcDream.App.Physics.RemoteInboundMotionDispatchResult localDispatch =
_remoteInboundMotion.Apply(
update,
_playerController.Movement,
_playerController.Motion.DefaultSink,
_playerHost,
_playerController.CellId,
ae.Sequencer.CurrentMotion & 0xFF000000u,
IsCurrentLocalMotion);
if (localDispatch.Superseded
|| !IsCurrentLocalMotion())
{
return;
}
if (localDispatch.RoutedMoveTo)
{
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
{
Console.WriteLine(System.FormattableString.Invariant(
$"[autowalk-begin] mt=0x{update.MotionState.MovementType:X2} movingTo={_playerController.Movement.IsMovingTo()} type={_playerController.MoveTo?.MovementTypeState}"));
}
return;
}
if (!localDispatch.AppliedInterpretedState)
return;
fullMotion = localDispatch.CurrentForwardCommand;
}
}
else
{
// One packet owner handles both PartArray-backed remotes and
// AP-77's animation-less body fallback. GameWindow performs
// only live-owner lookup and supplies the optional sink.
AcDream.App.Physics.RemoteInboundMotionDispatchResult dispatch =
DispatchRemoteInboundMotion(
update,
entity,
ae,
acceptedMotionRecord,
acceptedMovementAuthorityVersion,
acceptedMovementVelocityAuthorityVersion);
if (dispatch.Superseded
|| dispatch.RoutedMoveTo
|| !dispatch.AppliedInterpretedState)
return;
fullMotion = dispatch.CurrentForwardCommand;
}
// Authoritative Dead motion invalidates a selected combat target.
// The controller clears shared selection, whose SelectionChanged
// consumer ports retail's post-clear AutoTarget behavior.
_combatTargetController?.OnMotionApplied(
update.Guid, ae.Sequencer.CurrentMotion);
if (!_liveEntities.IsCurrentMovementAuthority(
acceptedMotionRecord,
acceptedMovementAuthorityVersion)
|| !_liveEntities.IsCurrentVelocityAuthority(
acceptedMotionRecord,
acceptedMovementVelocityAuthorityVersion))
{
return;
}
// CRITICAL: when we enter a locomotion cycle (Walk/Run/etc),
// stamp the remote observation timestamp to "now". Without this,
// the stop-detection loop in TickAnimations sees the previous
// observation timestamp (set by the last UpdatePosition,
// often >300ms ago during idle) and fires the stop signal
// IMMEDIATELY — flipping the sequencer straight back to Ready.
// The visible symptom was "remote char never animates; just
// stands there, teleporting position every UpdatePosition."
// Fresh timestamp gives the stop-timer a full 300ms window to
// observe genuine position stagnation before reverting.
uint newLo = fullMotion & 0xFFu;
bool enteringLocomotion = newLo == 0x05 || newLo == 0x06
|| newLo == 0x07
|| newLo == 0x0F || newLo == 0x10;
uint oldLo = priorMotion & 0xFFu;
bool wasLocomotion = oldLo == 0x05 || oldLo == 0x06
|| oldLo == 0x07
|| oldLo == 0x0F || oldLo == 0x10;
if (enteringLocomotion && !wasLocomotion && update.Guid != _playerServerGuid)
{
// Reset both stop signals so stop-detection starts a fresh
// window from this transition. Without this, the entity
// starts its run animation and is instantly interrupted.
var refreshedTime = System.DateTime.UtcNow;
if (_remoteMovementObservations.TryGetValue(update.Guid, out var prev))
_remoteMovementObservations[update.Guid] = (prev.Pos, refreshedTime);
if (_remoteDeadReckon.TryGetValue(update.Guid, out var dr))
dr.LastServerPosTime = (refreshedTime - System.DateTime.UnixEpoch).TotalSeconds;
}
return;
}
// ── Legacy path (entities without a sequencer) ──────────────────
// Here we DO use GetIdleCycle because the legacy tick loop needs
// a concrete Animation + frame range. Only swap when the resolver
// returns a clearly-better cycle.
var newCycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle(
ae.Setup, _dats, _animLoader!,
motionTableIdOverride: null,
stanceOverride: stance,
commandOverride: command);
bool newCycleIsGood = newCycle is not null
&& newCycle.Framerate != 0f
&& newCycle.HighFrame >= newCycle.LowFrame
&& newCycle.Animation.PartFrames.Count >= 1;
if (!newCycleIsGood) return;
ae.Animation = newCycle!.Animation;
ae.LowFrame = Math.Max(0, newCycle.LowFrame);
ae.HighFrame = Math.Min(newCycle.HighFrame, newCycle.Animation.PartFrames.Count - 1);
ae.Framerate = newCycle.Framerate;
ae.CurrFrame = ae.LowFrame;
}
/// <summary>
/// Resolves one live remote owner and delegates retail's entire
/// <c>unpack_movement</c> body to the shared animation-optional packet
/// dispatcher. The render PartArray contributes only its optional sink.
/// </summary>
private AcDream.App.Physics.RemoteInboundMotionDispatchResult
DispatchRemoteInboundMotion(
AcDream.Core.Net.WorldSession.EntityMotionUpdate update,
AcDream.Core.World.WorldEntity entity,
LiveEntityAnimationState? ae,
LiveEntityRecord acceptedRecord,
ulong acceptedMovementAuthorityVersion,
ulong acceptedVelocityAuthorityVersion)
{
if (update.Guid == _playerServerGuid)
return default;
bool IsCurrentOwner(RemoteMotion? expectedRemote = null) =>
_liveEntities is { } live
&& live.IsCurrentMovementAuthority(
acceptedRecord,
acceptedMovementAuthorityVersion)
&& live.IsCurrentVelocityAuthority(
acceptedRecord,
acceptedVelocityAuthorityVersion)
&& ReferenceEquals(acceptedRecord.WorldEntity, entity)
&& (ae is null
? acceptedRecord.AnimationRuntime is null
: ReferenceEquals(acceptedRecord.AnimationRuntime, ae))
&& (expectedRemote is null
|| ReferenceEquals(
acceptedRecord.RemoteMotionRuntime,
expectedRemote));
if (!IsCurrentOwner())
return default;
if (!_remoteDeadReckon.TryGetValue(update.Guid, out var remote))
{
remote = CreateRemoteMotion(update.Guid);
remote.Body.Orientation = entity.Rotation;
remote.Body.Position = entity.Position;
_remoteDeadReckon[update.Guid] = remote;
}
if (!IsCurrentOwner(remote))
return default;
var sink = _motionRuntime.EnsureRemoteMotionBindings(remote, ae, update.Guid);
uint commandClass = ae?.Sequencer?.CurrentMotion & 0xFF000000u
?? remote.Motion.InterpretedState.ForwardCommand & 0xFF000000u;
if (commandClass == 0u)
commandClass = 0x41000000u;
AcDream.App.Physics.RemoteInboundMotionDispatchResult result =
_remoteInboundMotion.Apply(
update,
remote.Movement,
sink,
remote.Host,
remote.CellId,
commandClass,
() => IsCurrentOwner(remote));
if (result.Superseded || !IsCurrentOwner(remote))
return result with { Superseded = true };
if (result.ForwardCommandChanged)
{
if (System.Environment.GetEnvironmentVariable(
"ACDREAM_REMOTE_VEL_DIAG") == "1")
{
System.Console.WriteLine(
$"[FWD_WIRE] guid={update.Guid:X8} "
+ $"oldCmd=0x{result.PreviousForwardCommand:X8} "
+ $"newCmd=0x{result.CurrentForwardCommand:X8} "
+ $"newLow=0x{result.CurrentForwardCommand & 0xFFu:X2} "
+ $"speed={update.MotionState.ForwardSpeed ?? 1f:F3}");
}
remote.PrevServerPosTime = 0.0;
}
if (result.AppliedInterpretedState && ae is null)
{
_combatTargetController?.OnMotionApplied(
update.Guid,
result.CurrentForwardCommand);
if (!IsCurrentOwner(remote))
return result with { Superseded = true };
}
return result;
}
/// <summary>
/// Phase 6.7: the server says an entity moved. Translate its new
/// landblock-local position into acdream world space (same math as
/// CreateObject hydration) and update the entity's Position/Rotation
/// in place so the next Draw picks up the new transform.
///
/// Phase B.3 extension: if the player controller is in PortalSpace and
/// this update is for our own character, detect a large position change
/// (different landblock or > 100 units distance). If detected, recenter
/// the streaming controller, resolve the new position through physics,
/// snap the player entity + controller, and return to InWorld. Also sends
/// LoginComplete so the server knows the client has loaded the destination.
/// </summary>
/// <summary>
/// Reports whether the exact remote component currently belongs to the
/// visible ordinary-object workset that consumes interpolation targets.
/// </summary>
private bool WillAdvanceRemoteMotion(uint serverGuid, RemoteMotion remote)
{
return _liveEntities is { } runtime
&& runtime.TryGetRecord(serverGuid, out LiveEntityRecord record)
&& ReferenceEquals(record.RemoteMotionRuntime, remote)
&& (record.FinalPhysicsState
& AcDream.Core.Physics.PhysicsStateFlags.Static) == 0
&& runtime.GetRootObjectClockDisposition(serverGuid)
is AcDream.Core.Physics.RetailObjectClockDisposition.Advance
&& runtime.IsCurrentSpatialRemoteMotion(record, remote);
}
/// <summary>
/// Creates the optional MovementManager companion for one live object.
/// A retained projectile or static-animation owner contributes the
/// already-owned CPhysicsObj body; an ordinary remote gets local storage.
/// </summary>
private RemoteMotion CreateRemoteMotion(uint serverGuid)
{
LiveEntityRecord? record = null;
if (_liveEntities is { } liveEntities
&& liveEntities.TryGetRecord(serverGuid, out var currentRecord))
{
record = currentRecord;
}
RemoteMotion remote;
if (record?.PhysicsBody is { } canonicalBody)
{
// Retail has one CPhysicsObj. A projectile or Physics-Static
// animation workset can create it before the MovementManager;
// adopt its current frame/vectors without replaying CreateObject.
remote = new RemoteMotion(canonicalBody);
}
else
{
remote = new RemoteMotion();
if (record is not null)
AcDream.App.Physics.RemotePhysicsBodyInitializer.Initialize(
remote.Body,
record);
}
RemoteMotion incarnation = remote;
remote.Movement.ActivatePhysicsObject = () =>
_liveEntities?.TryActivateOrdinaryObject(serverGuid, incarnation);
return remote;
}
/// <summary>
/// K-fix9 (2026-04-26): handle 0xF74E VectorUpdate from remote jumps.
/// The payload seeds the world-space launch velocity and angular velocity.
/// </summary>
public void OnVector(AcDream.Core.Net.Messages.VectorUpdate.Parsed update)
{
bool payloadIsValid = _projectileController?.CanAcceptVectorPayload(
update.Guid,
update.Velocity,
update.Omega) != false;
if (!_authorityGate.TryAcceptVector(
update,
payloadIsValid,
out AcceptedVectorNetworkUpdate accepted))
{
return;
}
LiveEntityRecord acceptedVectorRecord = accepted.Record;
ulong acceptedVectorAuthorityVersion =
accepted.VectorAuthorityVersion;
ulong acceptedVectorVelocityAuthorityVersion =
accepted.VelocityAuthorityVersion;
LiveEntityVectorRouter.Route(
() => _projectileController?.ApplyAuthoritativeVector(
acceptedVectorRecord,
acceptedVectorAuthorityVersion,
acceptedVectorVelocityAuthorityVersion,
update.Velocity,
update.Omega,
_physicsScriptGameTime) == true,
() =>
{
// A Physics-Static animation owner can own the canonical
// CPhysicsObj before any MovementManager exists. F74E writes
// directly to that body and must not manufacture a remote.
if (update.Guid == _playerServerGuid
|| acceptedVectorRecord.RemoteMotionRuntime is not null
|| acceptedVectorRecord.PhysicsBody is not { } canonicalBody)
{
return false;
}
_liveEntities.TryCommitAuthoritativeVector(
acceptedVectorRecord,
canonicalBody,
update.Velocity,
update.Omega,
_physicsScriptGameTime);
return true;
},
() => ApplyOrdinaryVector(
update,
acceptedVectorRecord,
acceptedVectorAuthorityVersion,
acceptedVectorVelocityAuthorityVersion));
}
private void ApplyOrdinaryVector(
AcDream.Core.Net.Messages.VectorUpdate.Parsed update,
LiveEntityRecord acceptedVectorRecord,
ulong acceptedVectorAuthorityVersion,
ulong acceptedVectorVelocityAuthorityVersion)
{
if (!_entitiesByServerGuid.ContainsKey(update.Guid)) return;
if (update.Guid == _playerServerGuid) return; // local jump uses our own physics
if (!_remoteDeadReckon.TryGetValue(update.Guid, out var rm)) return;
LiveEntityRecord remoteRecord = acceptedVectorRecord;
// World-space velocity. Apply directly to the body — the per-tick
// remote update will integrate Position += Velocity × dt + 0.5 × Accel × dt².
// L.3.1 Task 6: apply Omega too. LiveEntityRuntime commits both
// writes to the one canonical CPhysicsObj and wakes its retained
// update_time clock on the same non-Static edge.
if (!_liveEntities.TryCommitAuthoritativeVector(
remoteRecord,
rm.Body,
update.Velocity,
update.Omega,
_physicsScriptGameTime))
{
return;
}
// Mark airborne when the launch has meaningful +Z. Threshold
// 0.5 m/s rejects noise / horizontal-only updates (server might
// also use VectorUpdate for non-jump events). The per-tick
// remote update reads .Airborne to skip the ground-clamp branch
// and apply gravity instead.
if (update.Velocity.Z > 0.5f)
{
rm.Airborne = true;
// Clear ground-contact bits + enable gravity so calc_acceleration
// returns (0, 0, -9.8) instead of zero. UpdatePhysicsInternal then
// produces the parabolic arc.
rm.Body.TransientState &= ~(AcDream.Core.Physics.TransientStateFlags.Contact
| AcDream.Core.Physics.TransientStateFlags.OnWalkable);
rm.Body.State |= AcDream.Core.Physics.PhysicsStateFlags.Gravity;
// R3-W4 (J19 — K-fix10/K-fix18 DELETED): the retail mechanism.
// The remote's ground departure fires LeaveGround (0x00528b00):
// strips pending transition links (the RemoveLinkAnimations
// seam) + re-applies movement through DefaultSink, whose
// contact-gated funnel dispatch engages Falling — no forced
// SetCycle, no skip flag. The wire velocity/omega are re-applied
// AFTER so they stay authoritative over LeaveGround's
// state-derived velocity write (adaptation note: retail's
// equivalence comes from the per-tick transition-sweep order —
// R6 scope).
if (_entitiesByServerGuid.TryGetValue(update.Guid, out var ent)
&& _animatedEntities.TryGetValue(ent.Id, out var ae)
&& ae.Sequencer is not null)
{
_motionRuntime.EnsureRemoteMotionBindings(rm, ae, update.Guid);
rm.Motion.LeaveGround();
if (!_liveEntities.IsCurrentVectorAuthority(
remoteRecord,
acceptedVectorAuthorityVersion)
|| !_liveEntities.IsCurrentVelocityAuthority(
remoteRecord,
acceptedVectorVelocityAuthorityVersion)
|| !_liveEntities.TryCommitAuthoritativeVector(
remoteRecord,
rm.Body,
update.Velocity,
update.Omega,
_physicsScriptGameTime))
{
return;
}
}
}
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
{
Console.WriteLine(
$"VU guid=0x{update.Guid:X8} vel=({update.Velocity.X:F2},{update.Velocity.Y:F2},{update.Velocity.Z:F2}) airborne={rm.Airborne}");
}
}
/// <summary>
/// L.2g slice 1: inbound SetState (0xF74B) handler. Propagates the
/// new <c>PhysicsState</c> bits into ShadowObjectRegistry so the
/// existing <see cref="CollisionExemption.ShouldSkip"/> check honors
/// the flip on the next resolver tick. Chiefly doors:
/// server flips <c>ETHEREAL_PS = 0x4</c> on Use, the door's
/// cylinder collision stops blocking the threshold.
/// </summary>
public void OnState(AcDream.Core.Net.Messages.SetState.Parsed parsed)
{
if (!_authorityGate.TryAcceptState(
parsed,
out AcceptedStateNetworkUpdate accepted))
return;
LiveEntityRecord record = accepted.Record;
ulong acceptedStateAuthorityVersion = accepted.StateAuthorityVersion;
// Retail set_state order: Lighting, NoDraw, then Hidden. The live
// runtime already committed the raw/final bits and draw visibility;
// apply the ordered owners before updating motion/collision consumers.
_liveEntityLights?.OnStateChanged(parsed.Guid);
_liveEntityPresentation?.OnStateAccepted(parsed.Guid);
if (!_liveEntities.IsCurrentStateAuthority(
record,
acceptedStateAuthorityVersion))
{
return;
}
_projectileController?.ApplyAuthoritativeState(
record,
acceptedStateAuthorityVersion,
record.FinalPhysicsState,
_physicsScriptGameTime,
_origin.CenterX,
_origin.CenterY);
if (!_liveEntities.IsCurrentStateAuthority(
record,
acceptedStateAuthorityVersion))
{
return;
}
if (parsed.Guid == _playerServerGuid)
_playerController?.ApplyPhysicsState(record.FinalPhysicsState);
if (!_entitiesByServerGuid.TryGetValue(parsed.Guid, out var entity)) return;
// L.2g slice 1c (2026-05-13): the server addresses entities by
// ServerGuid (parsed.Guid, e.g. 0x7A9B4015), but
// ShadowObjectRegistry's cell index is keyed by local entity.Id
// (e.g. 0x000F4245). Translate via _entitiesByServerGuid before
// mutating the registry — otherwise the lookup misses and the
// state flip silently no-ops, leaving doors blocked even though
// ACE flipped the ETHEREAL bit.
uint registryKey = entity.Id;
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
Console.WriteLine(System.FormattableString.Invariant(
$"[setstate] guid=0x{parsed.Guid:X8} entityId=0x{registryKey:X8} raw=0x{parsed.PhysicsState:X8} final=0x{(uint)record.FinalPhysicsState:X8} instSeq={parsed.InstanceSequence} stateSeq={parsed.StateSequence}"));
}
public void OnPosition(AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
{
bool payloadIsValid = _projectileController?.CanAcceptPositionPayload(
update.Guid,
update.Position,
update.Velocity) != false;
if (!_authorityGate.TryAcceptPosition(
update,
_playerServerGuid,
update.Guid == _playerServerGuid && _playerController is not null
? _playerController.BodyOrientation
: null,
update.Guid == _playerServerGuid && _playerController is not null
? _playerController.BodyVelocity
: null,
payloadIsValid,
out AcceptedPositionNetworkUpdate accepted))
{
return;
}
var timestampDisposition = accepted.TimestampDisposition;
var acceptedSpawn = accepted.Spawn;
var timestamps = accepted.Timestamps;
LiveEntityRecord acceptedPositionRecord = accepted.Record;
ulong acceptedPositionAuthorityVersion =
accepted.PositionAuthorityVersion;
ulong acceptedPositionVelocityAuthorityVersion =
accepted.VelocityAuthorityVersion;
bool IsCurrentPositionOwner(
AcDream.Core.World.WorldEntity? expectedEntity = null) =>
_liveEntities.IsCurrentPositionAuthority(
acceptedPositionRecord,
acceptedPositionAuthorityVersion)
&& (expectedEntity is null
|| ReferenceEquals(
acceptedPositionRecord.WorldEntity,
expectedEntity));
if (!IsCurrentPositionOwner())
return;
// A PlayerDescription/CreateObject may establish the live record
// without either Position or enough render data. Bind streaming
// readiness directly to this first accepted canonical Position before
// translating it through the current world origin; projection recovery
// below is a separate concern and may never be needed for UI-only state.
if (_liveEntityHydration?.EnsureWorldOrigin(
acceptedPositionRecord,
acceptedPositionAuthorityVersion,
acceptedSpawn) != true
|| !IsCurrentPositionOwner())
return;
var p = update.Position;
int lbX = (int)((p.LandblockId >> 24) & 0xFFu);
int lbY = (int)((p.LandblockId >> 16) & 0xFFu);
var origin = new System.Numerics.Vector3(
(lbX - _origin.CenterX) * 192f,
(lbY - _origin.CenterY) * 192f,
0f);
var worldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ) + origin;
bool forceLocal = timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.ForcePosition
&& update.Guid == _playerServerGuid
&& _playerController is not null;
if (!LocalForcePositionTransaction.Apply(
forceLocal,
() => IsCurrentPositionOwner(),
() => _playerController!.BlipPosition(
worldPos,
p.LandblockId,
new System.Numerics.Vector3(
p.PositionX,
p.PositionY,
p.PositionZ)),
() => _localPlayerOutbound.SendImmediatePosition(
_session(),
_playerController)))
return;
if (!_entitiesByServerGuid.ContainsKey(update.Guid))
{
if (!IsCurrentPositionOwner())
return;
AcDream.App.Rendering.ChildUnparentDisposition unparented =
_equippedChildRenderer?.OnChildBecameUnparented(
update.Guid,
() =>
{
if (!IsCurrentPositionOwner())
return;
_liveEntityHydration!.RecoverProjection(
acceptedPositionRecord,
acceptedPositionAuthorityVersion,
acceptedSpawn);
})
?? AcDream.App.Rendering.ChildUnparentDisposition.NotAttached;
if (unparented is AcDream.App.Rendering.ChildUnparentDisposition.Superseded
or AcDream.App.Rendering.ChildUnparentDisposition.Pending)
return;
if (!IsCurrentPositionOwner())
return;
if (unparented is AcDream.App.Rendering.ChildUnparentDisposition.NotAttached)
{
_liveEntityHydration!.RecoverProjection(
acceptedPositionRecord,
acceptedPositionAuthorityVersion,
acceptedSpawn);
if (!IsCurrentPositionOwner())
return;
}
}
if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return;
if (!IsCurrentPositionOwner(entity))
return;
_entityEffects?.MarkLiveOwnerPoseDirty(update.Guid);
if (!IsCurrentPositionOwner(entity))
return;
// Phase A.1 / #135: track the PLAYER's last server-known landblock so the
// streaming controller can follow the player in the fly-camera / pre-player-mode
// (login hold) views. Filtered to our OWN character guid — resolving the original
// Phase A.1 TODO. An arbitrary NPC's UpdatePosition from a far outdoor landblock
// must NOT move the streaming observer: during a dungeon-login hold (player not
// yet placed, so _playerController is null and the PortalSpace observer branch
// can't apply) that would drift the observer off the pre-collapsed dungeon
// landblock and trip ExitDungeonExpand, re-streaming the 25×25 neighbor window
// the pre-collapse just suppressed. _playerServerGuid is set from CharacterList
// (~line 1984) before world entry, so it is valid by the time updates arrive.
if (update.Guid == _playerServerGuid)
_authorityGate.ObserveAcceptedLocalPosition(update.Position.LandblockId);
// B.6 slice 1 (2026-05-14): trace inbound UpdatePosition cadence for
// the local player. Combined with [autowalk-mt] this answers
// whether ACE's broadcast frequency during a server-initiated
// auto-walk is dense enough to drive smooth visible motion (the
// Option C viability check from the design spec). Gated on
// ACDREAM_PROBE_AUTOWALK=1; skips remote entities.
if (update.Guid == _playerServerGuid
&& AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
{
string velStr = update.Velocity is { } v
? $"vel=({v.X:F2},{v.Y:F2},{v.Z:F2})"
: "vel=null";
Console.WriteLine(System.FormattableString.Invariant(
$"[autowalk-up] cell=0x{p.LandblockId:X8} pos=({p.PositionX:F2},{p.PositionY:F2},{p.PositionZ:F2}) world=({worldPos.X:F2},{worldPos.Y:F2},{worldPos.Z:F2}) {velStr} grounded={update.IsGrounded}"));
}
var rot = timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.ForcePosition
? entity.Rotation
: new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
_movementTruthDiagnostics.OnServerEcho(update, worldPos);
bool remoteHardTeleport = update.Guid != _playerServerGuid
&& timestamps.TeleportHookRequired;
bool remotePlacementRequired = update.Guid != _playerServerGuid
&& (remoteHardTeleport
|| _remoteTeleportController?.HasPending(update.Guid) == true);
if (remoteHardTeleport)
{
if (!RunRemoteTeleportHook(
update.Guid,
entity.Id,
() => IsCurrentPositionOwner(entity)))
{
return;
}
}
// Missiles reconcile the same predicted PhysicsBody in place. The
// timestamp gate above already rejected stale corrections; returning
// here prevents the generic remote locomotion path from allocating a
// second body or interpolation owner for the projectile.
if (_projectileController?.ApplyAuthoritativePosition(
acceptedPositionRecord,
acceptedPositionAuthorityVersion,
acceptedPositionVelocityAuthorityVersion,
worldPos,
new System.Numerics.Vector3(
p.PositionX,
p.PositionY,
p.PositionZ),
rot,
acceptedSpawn.Physics?.Velocity
?? System.Numerics.Vector3.Zero,
p.LandblockId,
_physicsScriptGameTime,
_origin.CenterX,
_origin.CenterY) == true)
return;
if (!_liveEntities.TryGetRecord(
update.Guid,
out LiveEntityRecord positionRecord)
|| !ReferenceEquals(positionRecord, acceptedPositionRecord)
|| !ReferenceEquals(positionRecord.WorldEntity, entity)
|| !_liveEntities.IsCurrentPositionAuthority(
positionRecord,
acceptedPositionAuthorityVersion))
{
return;
}
// Capture the pre-update render position for the soft-snap residual
// calculation below. Assign entity.Position to the server truth up
// front; if we then compute a snap residual, we restore the rendered
// position by adding the residual back (so the visual doesn't jerk
// for one frame before the residual decay kicks in on the next tick).
System.Numerics.Vector3 preSnapPos = entity.Position;
entity.SetPosition(worldPos);
entity.ParentCellId = p.LandblockId;
entity.Rotation = rot;
if (!_liveEntities!.RebucketLiveEntity(update.Guid, p.LandblockId)
|| !_liveEntities.TryGetRecord(
update.Guid,
out LiveEntityRecord afterRebucket)
|| !ReferenceEquals(afterRebucket, positionRecord)
|| !ReferenceEquals(afterRebucket.WorldEntity, entity)
|| !_liveEntities.IsCurrentPositionAuthority(
afterRebucket,
acceptedPositionAuthorityVersion))
{
// A projection callback superseded or deleted this incarnation.
// Never let an older UpdatePosition seed the replacement's
// placement, interpolation, or collision state.
return;
}
if (remotePlacementRequired)
{
_remoteTeleportController!.BeginPlacement(
update.Guid,
acceptedSpawn.InstanceSequence);
if (!IsCurrentPositionOwner(entity))
return;
}
// Commit B 2026-04-29 — keep the shadow registry in sync with
// server-authoritative position so the player's collision broadphase
// tests against the up-to-date target body. Skip the local player
// (its body is the simulator, not a target). Retail does the
// equivalent via SetPosition → change_cell → AddShadowObject
// (acclient_2013_pseudo_c.txt:284276 / 281200 / 282862).
// #184 Slice 2b: the former players-only RAW-pos shadow sync is RETIRED.
// It was a Slice-1 stopgap while grounded player remotes (old Path A) skipped
// the sweep and tracked the server position closely. Now that Slice 2b runs
// the SAME per-tick sweep + shadow-follows-resolved for players, writing the
// raw (overlapping) server pos here would re-snap a packed player's shadow
// into overlap once per UP and fight the in-tick de-overlap (research
// finding 9). Player shadows now follow the RESOLVED body — via the DR-tick
// loop (SyncRemoteShadowToBody, pose/cell-gated) and the player UP-branch tail
// below (first-UP / no-Sequencer case), exactly like NPCs. Local-player
// broadphase still tests an up-to-date remote shadow; it is just the resolved
// body now, not the raw wire pos.
// Track remote-entity motion for stop detection. Only record the
// timestamp when position moved MEANINGFULLY (> 0.05m). Updates
// that report the same position keep the old Time, so the
// TickAnimations check can see when motion last changed.
//
// Also populate the dead-reckon state so TickAnimations can
// integrate velocity between server updates and avoid teleport jitter.
// Observed-velocity is computed from the position delta across
// consecutive updates — this is the fallback when the motion table's
// MotionData.Velocity is zero (NPCs without HasVelocity).
if (update.Guid != _playerServerGuid)
{
var now = System.DateTime.UtcNow;
if (_remoteMovementObservations.TryGetValue(update.Guid, out var prev))
{
float moveDist = System.Numerics.Vector3.Distance(prev.Pos, worldPos);
if (moveDist > 0.05f)
_remoteMovementObservations[update.Guid] = (worldPos, now);
// else: leave old entry so "Time" = last real movement time
}
else
{
_remoteMovementObservations[update.Guid] = (worldPos, now);
}
// Retail-faithful hard-snap on UpdatePosition.
// Decompile: FUN_00559030 @ chunk_00550000.c:8232 writes
// pos/rot directly into PhysicsObj+0x80..0xBC with no blending.
// Between UpdatePositions, per-tick velocity integration keeps
// the rendered position close to server truth so each snap is
// small. When HasVelocity is set, we also seed PhysicsBody
// velocity (matches retail's set_velocity call in the same
// dispatcher).
if (!_remoteDeadReckon.TryGetValue(update.Guid, out var rmState))
{
rmState = CreateRemoteMotion(update.Guid);
_remoteDeadReckon[update.Guid] = rmState;
// Hard-snap orientation on first spawn so the per-tick
// slerp doesn't visibly rotate from Identity to truth.
rmState.Body.Orientation = rot;
// #184 Slice 2b: PLACE the body at the server position on creation,
// mirroring the UM handler's seed (:5176 `Body.Position =
// entity.Position`). A UP-first RemoteMotion (created here before any
// UM) was left at the default (0,0,0). Path A never swept, so that
// stale origin was harmless — it caught up gradually. Now that Slice
// 2b runs the sweep for grounded PLAYERS too, an unplaced body would
// sweep from (0,0,0) in the server cell that does not contain it →
// garbage resolved pos → the digest's INVISIBLE/misplaced-body bug.
// Seeding here is the root-cause fix (the UP creation path should
// seed exactly like the UM path); worldPos == entity.Position (the
// unconditional snap at the top of this handler already ran).
rmState.Body.Position = worldPos;
}
// PositionPack::UnPack initializes an absent velocity to zero;
// MoveOrTeleport installs that exact vector with set_velocity.
// The canonical seam wakes the retained ObjectClock and body in
// one operation. Position-delta velocity below remains animation
// diagnostics and is never substituted into physics.
if (!_liveEntities.IsCurrentPositionAuthority(
positionRecord,
acceptedPositionAuthorityVersion))
{
return;
}
if (_liveEntities.IsCurrentVelocityAuthority(
positionRecord,
acceptedPositionVelocityAuthorityVersion)
&& !_liveEntities.TryCommitAuthoritativeVelocity(
positionRecord,
rmState.Body,
acceptedSpawn.Physics?.Velocity
?? System.Numerics.Vector3.Zero,
_physicsScriptGameTime))
{
return;
}
// Retail CPhysicsObj::MoveOrTeleport Branch A (0x00516330): a
// fresh TELEPORT_TS, or the first placement of a cell-less body,
// runs teleport_hook and SetPosition(0x1012) BEFORE the contact
// test. In particular, an airborne UP cannot veto or undo this
// authoritative destination. Do not pre-clear velocity or invent
// grounded flags here: retail SetPosition derives contact from its
// transition, while MoveOrTeleport does not consume arg5/arg6.
if (remotePlacementRequired)
{
double teleportTime =
(System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
bool projectionVisible = _liveEntities.TryGetRecord(
update.Guid,
out LiveEntityRecord teleportRecord)
&& teleportRecord.IsSpatiallyVisible;
var placement = _remoteTeleportController!.TryApply(
positionRecord,
acceptedPositionAuthorityVersion,
acceptedPositionVelocityAuthorityVersion,
rmState,
entity,
worldPos,
p.LandblockId,
new System.Numerics.Vector3(
p.PositionX,
p.PositionY,
p.PositionZ),
rot,
teleportTime,
projectionVisible,
acceptedSpawn.InstanceSequence,
acceptedSpawn.PositionSequence);
if (placement.Superseded
|| !IsCurrentPositionOwner(entity))
{
return;
}
if (!placement.Applied)
{
entity.SetPosition(rmState.Body.Position);
entity.ParentCellId = rmState.CellId;
entity.Rotation = rmState.Body.Orientation;
if (rmState.CellId != 0)
_liveEntities.RebucketLiveEntity(update.Guid, rmState.CellId);
return;
}
if (!IsCurrentPositionOwner(entity))
return;
entity.SetPosition(rmState.Body.Position);
entity.Rotation = rmState.Body.Orientation;
return;
}
// L.3 M2 (2026-05-05): retail-faithful MoveOrTeleport routing for
// player remotes. Mirrors CPhysicsObj::MoveOrTeleport
// (acclient @ 0x00516330) — airborne no-op, far-snap, near
// InterpolateTo. Gated on IsPlayerGuid so NPCs continue through
// the legacy synth-velocity branch below; their motion comes
// from ServerVelocity / ServerMoveTo which the legacy path
// already handles correctly.
//
if (IsPlayerGuid(update.Guid))
{
// InterpolationManager retains the complete target Position.
// A near correction replaces both origin and orientation via
// Position::subtract2; only placement/far branches snap here.
// Adopt server's cell ID on every UP (airborne or grounded).
// Required by the legacy airborne path's per-tick
// ResolveWithTransition gate (rm.CellId != 0); without this
// an airborne player remote falls through the floor because
// the sphere sweep is skipped. Note: enabling the sweep also
// exposes a pre-existing depenetration bug — see #42.
rmState.CellId = p.LandblockId;
// Diagnostic (ACDREAM_REMOTE_VEL_DIAG=1): roll the previous
// server-pos snapshot forward AND print the per-UP comparison
// between the max literal CSequence root-motion speed observed
// since the last UP and the actual server broadcast pace. Both are sampled
// over the same window so the ratio reflects real overshoot.
{
double nowSecDiag = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1"
&& rmState.LastServerPosTime > 0.0)
{
double dtServer = nowSecDiag - rmState.LastServerPosTime;
if (dtServer > 0.001)
{
var serverDelta = worldPos - rmState.LastServerPos;
float serverSpeed = (float)(serverDelta.Length() / dtServer);
float rootMotionSpeed = rmState.MaxRootMotionSpeedSinceLastUP;
if (serverSpeed > 0.1f || rootMotionSpeed > 0.1f)
{
System.Console.WriteLine(
$"[VEL_DIAG] guid={update.Guid:X8} maxRootMotionSpeed={rootMotionSpeed:F3} m/s "
+ $"serverSpeed={serverSpeed:F3} m/s dtServer={dtServer:F3}s "
+ $"ratio={(serverSpeed > 1e-3f ? rootMotionSpeed / serverSpeed : 0f):F3}");
}
}
}
rmState.MaxRootMotionSpeedSinceLastUP = 0f;
rmState.PrevServerPos = rmState.LastServerPos;
rmState.PrevServerPosTime = rmState.LastServerPosTime;
rmState.LastServerPos = worldPos;
rmState.LastServerPosTime = nowSecDiag;
}
// ── AIRBORNE NO-OP ────────────────────────────────────────────
// Mirrors retail CPhysicsObj::MoveOrTeleport (acclient @ 0x00516330):
// when has_contact==0, return false (don't touch body, don't queue).
// body.Velocity (set once by OnLiveVectorUpdated at jump start) keeps
// integrating gravity via per-frame UpdatePhysicsInternal. Server is
// authoritative for the arc; we don't predict it locally.
if (!update.IsGrounded)
{
// Undo the unconditional entity hard-snap at the top of the
// function (entity.SetPosition(worldPos)): the body is mid-arc
// and TickAnimations will write entity = body next frame
// anyway. Setting entity = body now prevents a 1-frame
// teleport-to-server-then-yank-back rubber-band.
entity.SetPosition(rmState.Body.Position);
return;
}
// ── LANDING TRANSITION ────────────────────────────────────────
// First IsGrounded=true UP after rmState.Airborne signals landed.
// Clear airborne flags, hard-snap to authoritative landing position,
// clear interpolation queue (any pre-jump waypoints are stale).
if (rmState.Airborne)
{
rmState.Airborne = false;
rmState.Body.Velocity = System.Numerics.Vector3.Zero;
rmState.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
| AcDream.Core.Physics.TransientStateFlags.OnWalkable;
rmState.Interp.Clear();
rmState.Body.Position = worldPos;
rmState.Body.Orientation = rot;
// #161: retail landing = MovementManager::HitGround
// (minterp → moveto, 0x00524300 — the R5-V5 facade
// relay) with the Gravity state bit STILL SET
// (CMotionInterp::HitGround gates on state&0x400). The
// re-apply dispatches the PRESERVED pre-fall forward
// command → landing link → cycle. This replaces the
// forced SetCycle, which read the then-clobbered
// ForwardCommand (Falling) and re-set the pose it meant
// to clear. See the twin block in TickAnimations
// (VU.land).
if (_animatedEntities.TryGetValue(entity.Id, out var aeForLand)
&& aeForLand.Sequencer is not null)
{
_motionRuntime.EnsureRemoteMotionBindings(rmState, aeForLand, update.Guid);
}
ulong landingStateAuthorityVersion =
positionRecord.StateAuthorityVersion;
rmState.Movement.HitGround();
if (!IsCurrentPositionOwner(entity)
|| !ReferenceEquals(
positionRecord.RemoteMotionRuntime,
rmState))
{
return;
}
// DR bookkeeping only (partner of the jump-start
// `State |= Gravity`).
if (_liveEntities.IsCurrentStateAuthority(
positionRecord,
landingStateAuthorityVersion))
{
rmState.Body.State &=
~AcDream.Core.Physics.PhysicsStateFlags.Gravity;
}
return;
}
// ── GROUNDED ROUTING (CPhysicsObj::MoveOrTeleport) ────────────
const float MaxPhysicsDistance = 96f; // retail player_distance far-snap
const float BodySnapThreshold = 4f; // large correction / teleport / unplaced -> snap
var localPlayerPos = _playerController?.Position ?? System.Numerics.Vector3.Zero;
float dist = System.Numerics.Vector3.Distance(worldPos, localPlayerPos);
// #184 Slice 2b: the player UP routing gains the SAME placement-snap
// backstop the NPC routing got in Slice 1 (AP-87). Now that grounded
// PLAYER remotes run the sweep, an unplaced / stale-cell body — a
// UM-first RemoteMotion seeded to the spawn pos (:5176) then a first UP
// in a DIFFERENT cell, which the UP-creation seed (:5720) does NOT cover
// — would enqueue and the per-tick sweep would run from a cell that does
// not contain the body -> garbage resolved pos -> the digest's
// INVISIBLE/misplaced player. The 4 m bodyToTarget guard is the
// LOAD-BEARING backstop (AP-87; firstUp via LastServerPosTime is a poor
// signal for players — it is already set by the VEL_DIAG block above);
// !willBeDrTicked snaps a no-Sequencer player whose queue nothing would
// consume; dist>96 is retail's far-snap. Placed + near corrections still
// enqueue for the smooth catch-up.
float bodyToTarget = System.Numerics.Vector3.Distance(
rmState.Body.Position, worldPos);
bool willBeDrTicked = WillAdvanceRemoteMotion(
update.Guid,
rmState);
if (dist > MaxPhysicsDistance || !willBeDrTicked
|| bodyToTarget > BodySnapThreshold)
{
// Beyond view bubble / large correction / unplaced body:
// SetPositionSimple slide-snap. Clear queue.
rmState.Interp.Clear();
rmState.Body.Position = worldPos;
rmState.Body.Orientation = rot;
}
else
{
// Within view bubble, placed + near: enqueue waypoint for
// adjust_offset to walk to. The per-frame TickAnimations player-
// remote path drives the actual body advancement via
// InterpolationManager.AdjustOffset. Pass body's current position so
// the InterpolationManager can detect a far-distance enqueue (>100 m
// from body) and pre-arm an immediate blip.
System.Numerics.Quaternion? immediateOrientation =
rmState.Interp.Enqueue(
worldPos,
rot,
isMovingTo: rmState.Movement.IsMovingTo(),
currentBodyPosition: rmState.Body.Position,
currentBodyOrientation: rmState.Body.Orientation);
if (immediateOrientation is { } closeOrientation)
rmState.Body.Orientation = closeOrientation;
}
// Track the UP-derived synth velocity for diagnostics
// ([VEL_DIAG] pace comparison). L.2g S5 (2026-07-02): the
// #39-era cycle-refinement call that used to live here is
// DELETED — retail never adapts a remote's animation from
// observed pace (deviation map DEV-2; premise refuted at
// decomp + ACE source + the S0 live capture, which shows
// explicit 0x0005↔0x0007 UMs on every Shift toggle).
// Player-remote cycles are UM-driven only.
if (rmState.PrevServerPosTime > 0.0)
{
double nowSecVel = rmState.LastServerPosTime;
double dtPos = nowSecVel - rmState.PrevServerPosTime;
if (dtPos > 0.001)
{
var synthVel = (worldPos - rmState.PrevServerPos) / (float)dtPos;
rmState.ServerVelocity = synthVel;
rmState.HasServerVelocity = true;
}
}
// Sync the visible entity to the body — overrides the unconditional
// entity.SetPosition(worldPos) snap at the top of this function.
// For the far-snap branch this is a no-op (body == worldPos); for
// the near-enqueue branch this prevents a 1-frame teleport-then-
// yank-back rubber-band as TickAnimations chases worldPos via the
// queue.
//
// #184 Slice 2b: sync the player-remote shadow to the RESOLVED/placed
// body (mirrors the NPC UP-branch tail). Now that grounded players run
// the DR-tick sweep + shadow-follows-resolved, the retired raw-pos sync
// (top of this handler) would have re-snapped the shadow into overlap
// each UP; this keeps collision == render for the first UP (before any
// DR tick) and for no-Sequencer players. rmState.CellId is the server
// cell adopted above (:5735). The per-tick loop keeps it current
// between UPs (pose-gated). Commit the complete root before the
// publication gate, matching SetPositionInternal ordering.
entity.SetPosition(rmState.Body.Position);
entity.ParentCellId = rmState.CellId;
entity.Rotation = rmState.Body.Orientation;
AcDream.App.Physics.LiveEntityShadowPublisher.TryPublishRemote(
_liveEntities,
positionRecord,
entity,
rmState,
acceptedPositionAuthorityVersion,
() => _remotePhysicsUpdater.SyncRemoteShadowToBody(
entity.Id,
rmState,
_origin.CenterX,
_origin.CenterY));
return;
}
double nowSec = (now - System.DateTime.UnixEpoch).TotalSeconds;
System.Numerics.Vector3? serverVelocity = update.Velocity;
if (serverVelocity is null
&& !IsPlayerGuid(update.Guid)
&& rmState.LastServerPosTime > 0.0)
{
double elapsed = nowSec - rmState.LastServerPosTime;
if (elapsed > 0.001)
serverVelocity = (worldPos - rmState.LastServerPos) / (float)elapsed;
}
if (serverVelocity is { } authoritativeVelocity)
{
rmState.ServerVelocity = authoritativeVelocity;
rmState.HasServerVelocity = true;
}
else if (!IsPlayerGuid(update.Guid))
{
rmState.ServerVelocity = System.Numerics.Vector3.Zero;
rmState.HasServerVelocity = false;
}
// R5-V3 #171 residual (2026-07-04 gate: "flashing/flapping",
// stale facing, pushed-into-player): while an entity is STUCK,
// the sticky steer owns its frame — retail's UP corrections flow
// through the InterpolationManager into the SAME adjust_offset
// chain where StickyManager OVERWRITES them while armed
// (PositionManager::adjust_offset 0x00555190 order; sticky
// assigns m_fOrigin 0x00555430), so a server correction can
// never fight an armed stick frame-by-frame. This legacy NPC
// path hard-snaps OUTSIDE that chain, producing a visible
// snap-out/steer-back oscillation at UP cadence (position) and
// a stale-facing stomp (orientation). Faithful translation to
// the snap architecture: suppress the position/orientation/
// velocity snaps while stuck. LastServerPos/Time bookkeeping
// still records below — server truth reasserts on the first UP
// after unstick (bounded by the 1 s sticky lease). Register
// row with TS-41/TS-44.
bool snapSuppressedByStick =
(rmState.Host?.PositionManager.GetStickyObjectId() ?? 0u) != 0u;
if (snapSuppressedByStick
&& AcDream.Core.Physics.PhysicsDiagnostics.ProbeStickyEnabled)
{
float snapDist = System.Numerics.Vector3.Distance(
worldPos, rmState.Body.Position);
Console.WriteLine(FormattableString.Invariant(
$"[sticky-snap-skip] guid=0x{update.Guid:X8} d={snapDist:F3} srv=({worldPos.X:F2},{worldPos.Y:F2}) body=({rmState.Body.Position.X:F2},{rmState.Body.Position.Y:F2})"));
}
if (!snapSuppressedByStick)
{
// #184 (2026-07-07): retail CPhysicsObj::MoveOrTeleport (0x00516330)
// for grounded NPC remotes. The body is PLACED (hard-snapped) whenever
// it is not already tracking NEAR the server position — the first UP,
// a large correction / teleport, an out-of-view creature (>96 m from
// the local player), or an entity the DR loop won't tick — otherwise the
// server point is a GENTLE dead-reckoning TARGET the per-tick interp
// catch-up walks to, and the KEPT sweep de-overlaps that movement.
//
// The placement-snap is LOAD-BEARING: the earlier attempt (reverted)
// enqueued EVERYTHING, so an unplaced body (origin, first UP) blipped
// over a huge distance into the sweep -> a resolve started in a cell that
// did not contain the body -> garbage resolved pos -> INVISIBLE monster
// while its shadow (synced to server truth) stayed put -> player stuck on
// nothing. Airborne keeps the authoritative hard-snap (arc integrates
// locally, K-fix15). Physics digest 2026-07-07 banner.
if (rmState.Airborne)
{
rmState.Body.Position = worldPos;
rmState.Body.Orientation = rot;
}
else
{
const float MaxPhysicsDistanceNpc = 96f; // retail player_distance far-snap
const float BodySnapThresholdNpc = 4f; // large correction / teleport -> snap
var localPlayerPosNpc = _playerController?.Position
?? System.Numerics.Vector3.Zero;
float distNpc = System.Numerics.Vector3.Distance(worldPos, localPlayerPosNpc);
float bodyToTargetNpc = System.Numerics.Vector3.Distance(
rmState.Body.Position, worldPos);
// The 4 m bodyToTargetNpc guard is the LOAD-BEARING placement backstop:
// it snaps any body not already near the target (an unplaced first-UP
// body sits at the origin / spawn pos, far from worldPos). firstUpNpc is
// a belt-and-suspenders hint only — it is NOT a reliable "never placed"
// signal because a UM that enters a locomotion cycle can stamp
// LastServerPosTime before the first UP (~:5340). Don't tune the 4 m
// threshold down without re-checking the unplaced-body case.
bool firstUpNpc = rmState.LastServerPosTime <= 0.0;
// Enqueue only if the canonical ordinary-object workset
// will consume the queue. Animation is optional in retail;
// LiveEntityAnimationScheduler advances a spatial
// RemoteMotion even when no LiveEntityAnimationState exists.
bool willBeDrTickedNpc = WillAdvanceRemoteMotion(
update.Guid,
rmState);
if (firstUpNpc || !willBeDrTickedNpc
|| distNpc > MaxPhysicsDistanceNpc
|| bodyToTargetNpc > BodySnapThresholdNpc)
{
// Placement / far / large-correction: SNAP + clear queue.
rmState.Interp.Clear();
rmState.Body.Position = worldPos;
rmState.Body.Orientation = rot;
}
else
{
// Near DR correction: enqueue the waypoint for the per-tick
// catch-up (Path B consumes it via ComputeOffset).
System.Numerics.Quaternion? immediateOrientation =
rmState.Interp.Enqueue(
worldPos,
rot,
isMovingTo: rmState.Movement.IsMovingTo(),
currentBodyPosition: rmState.Body.Position,
currentBodyOrientation: rmState.Body.Orientation);
if (immediateOrientation is { } closeOrientation)
rmState.Body.Orientation = closeOrientation;
}
}
}
// K-fix15 (2026-04-26): DON'T auto-clear airborne on UP.
// ACE broadcasts UPs during the arc (peak / mid-fall / land)
// at ~5-10 Hz. The previous K-fix9 logic cleared Airborne on
// the FIRST UP after the jump, which:
// * restored Contact + OnWalkable,
// * removed the Gravity flag,
// * caused the next per-tick to stomp Velocity via
// apply_current_movement (reading InterpretedState =
// Ready, so Velocity.Z went to 0),
// …so the body got stuck at the server-broadcast apex Z,
// visibly hovering. The fix: leave Airborne true; the
// per-tick post-resolve logic detects an actual landing
// (resolveResult.IsOnGround && Velocity.Z <= 0) and clears
// it then. Mirrors how PlayerMovementController re-grounds
// the local player at the bottom of its arc.
//
// The position-snap above is still authoritative — if ACE
// says the body is at Z=68 mid-arc, we render Z=68. But we
// continue integrating gravity from there, so the body
// proceeds along the parabolic path between UPs.
// Adopt the server's cell ID as the transition starting cell.
// Retail authoritatively hard-snaps cell membership here too; our
// per-tick ResolveWithTransition sweep then advances CheckCellId
// as the sphere crosses cells and writes the new cell back into
// rmState.CellId so the NEXT frame starts in the correct cell.
rmState.CellId = p.LandblockId;
// Near UpdatePosition orientation is carried by the same complete
// interpolation Frame as translation. Placement, airborne, and
// far-correction branches above install the authoritative Frame
// directly. Sticky still receives the shared Frame afterward and
// may replace it while armed.
rmState.LastServerPos = worldPos;
rmState.LastServerPosTime = nowSec;
if (rmState.HasServerVelocity
&& !snapSuppressedByStick
&& _animatedEntities.TryGetValue(entity.Id, out var aeForVelocity))
{
// NPC/monster remotes: PlanFromVelocity cycle selection from
// UP-derived velocity (ACE broadcasts NPC motion patterns the
// UM stream alone doesn't cover). Player remotes return early
// inside — their cycles are UM-driven only per retail (L.2g
// S5; DEV-2 deleted). Unification of NPCs onto the
// CMotionInterp funnel is S6.
//
// D2 (Commit A 2026-05-03): tag whether the velocity feeding
// ApplyServerControlledVelocityCycle is wire-explicit or
// synthesized from position deltas (the common case).
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
{
string velSrc = update.Velocity is null ? "synth" : "wire";
System.Console.WriteLine(
$"[UPCYCLE_SRC] guid={update.Guid:X8} src={velSrc}");
}
RemoteServerControlledVelocityCycle.Apply(
update.Guid,
aeForVelocity,
rmState,
rmState.ServerVelocity);
}
// #184: sync the NPC shadow to the resolved/placed body (NOT the raw
// server pos — the raw-pos sync was RETIRED for players too in Slice 2b)
// so collision == render and the de-overlap isn't snapped away each UP.
// Covers the first UP (before any DR tick) and no-Sequencer NPCs (which
// the per-tick loop skips). The per-tick loop keeps it current between
// UPs, pose-gated. rmState.CellId is the server cell adopted above.
// The root frame is committed before collision publication, as in
// retail SetPositionInternal.
entity.SetPosition(rmState.Body.Position);
entity.ParentCellId = rmState.CellId;
entity.Rotation = rmState.Body.Orientation;
AcDream.App.Physics.LiveEntityShadowPublisher.TryPublishRemote(
_liveEntities,
positionRecord,
entity,
rmState,
acceptedPositionAuthorityVersion,
() => _remotePhysicsUpdater.SyncRemoteShadowToBody(
entity.Id,
rmState,
_origin.CenterX,
_origin.CenterY));
}
// F751 is only a notification gate; the accepted Position may arrive
// before or after it. Canonical physics above always consumes the
// packet first. The presentation coordinator exposes exactly one
// sequence-correlated destination without reordering that state.
if (timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.Apply
&& update.Guid == _playerServerGuid)
{
_localPlayerTeleport.OfferDestination(
update,
timestamps.TeleportAdvanced);
}
}
// Retail teleport transit: the 7-state TAS drives portal/view-plane presentation, holds the
// player in PortalSpace until the destination is resident (TeleportWorldReady), then
// fires Place (materialize) and FireLoginComplete (regain control + ack the server).
// Replaces the old TeleportArrivalController hold/place machine.
}