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>
1789 lines
92 KiB
C#
1789 lines
92 KiB
C#
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.
|
||
}
|