Removes a duplicate placement authority for local-player portal arrival. Portalling worked before this change and works after it — this is not a bug fix, EXCEPT that it found and fixed one dead-code production bug. THE PRODUCTION BUG: TryExecuteCanonicalPortalPlacement re-read the accepted destination at Place time, but TryBeginPortalReveal already consumes that slot at Aim time — so the arm was 100% dead code and every real portal Place refused with host-token-unavailable. Found only because we refused to accept 7 skipped tests instead of chasing the count to zero. RETAIL IS THE GENERIC PATH FOR THE THIRD ROUTE RUNNING: SmartBox::TeleportPlayer @0x00453910 = SetPositionSimple(dest, 1) with flags 0x1012, followed by PlayerPositionUpdated. BOTH INVERSIONS, WITH THEIR ANCHORS: unlike route 2, the leash IS armed here (ConstrainTo @0x0045418A) and velocity is zeroed (set_velocity @0x004541B4); unlike route 4b-3, the local teleport_hook runs AFTER placement (@0x004538AE). THE THREE-ROUND DEFECT CHAIN, HONESTLY: - Round 1 released the player at the pre-teleport position while the anim stream marched on — the contract wrongly assumed Place re-fires (process rule 1's third occurrence this campaign). - Round 2's fix inferred commit from a global PendingCount, which three non-committing paths also clear — making the SAME bug complete cleanly and silently. Strictly worse than round 1: round 1 at least tripped portal-complete-before-materialized. - Round 3 latches the commit where it actually happens (ReconcileAndAcknowledgePortal), keyed on reveal generation and teleport sequence, via TryConsumePortalCommit. Two of the three required regression tests landed and are sabotage-verified on both hosts (ParkedPlace_ForgottenByOrdinaryMergeDoesNotLatchAsCommitted / HeadlessPortalPrepareDestinationForgottenByOrdinaryMergeDoesNotLatchAsCommitted). The third (force-arm-takes-the-slot) was judged unnecessary on review: with the inference gone, PendingCount is only a "don't ask yet" guard at both gates, so a force operation occupying or vacating the slot no longer changes an input the commit decision reads — the case collapses into what the landed test already discriminates. THE B2/P3 RESOLUTION: both round-2 reviews were right about different branches of the same synchronous call. RuntimePlacementProjectionSubscription .OnPlacement acknowledges the FIFO head only when TryApply returns true; a Place whose portal authority went stale (transit ended/superseded while parked) used to return false, wedging every later entity's placement receipt behind it forever. Both sinks (RuntimePlacementPresentationSink, HeadlessRuntimePlacementProjectionSink) now acknowledge-and-ignore a stale-authority Place instead of refusing it. The regression test (RuntimePlacementPresentationSinkTests .PortalPlace_StaleTransitHostOrSequenceIsAcknowledgedAndIgnored) had been asserting the old, wrong `false` behaviour; it now asserts and sabotage-verifies the fix. Also lands: AP-144 (register discipline — the portal movement-event send reuses the stricter UsePositionFromServer gate where retail's SendMovementEvent is the looser autonomy_level != 0 test, diverging only at level 1, currently unreachable), AP-145 + issue #318 (the local-player collision-shadow presentation write bypasses its own publisher's ShadowObjects write via a direct cache .Set(), self-healing only once dedup diverges — filed, not fixed, pending a composition test), AD-42 deleted (its last citation retired by the canonical portal arm), AD-2 updated (the wait-cue's trigger predicate now covers a second cause), and two documentation corrections: the enter_world misattribution (both call sites are in SmartBox::HandleCreateObject, only one in the player branch — portal arrival is TeleportPlayer, not enter_world) and the stale "local player never reaches this path" comment on the generic-remote-render-pose write. Suite: 11,090 passed / 4 skipped / 0 failed. No new skips, nothing weakened. STILL OWED: the connected two-client gate, with ACDREAM_PROBE_LOCAL_TELEPORT=1, scored only if [local-tp] lines actually appear in the capture — and explicitly NOT scored as covering issue #318 (no composition test yet asserts PhysicsEngine.ShadowObjects directly). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2986 lines
159 KiB
C#
2986 lines
159 KiB
C#
using AcDream.App.Combat;
|
||
using AcDream.App.Input;
|
||
using AcDream.App.Interaction;
|
||
using AcDream.App.Net;
|
||
using AcDream.App.Physics;
|
||
using AcDream.App.Rendering;
|
||
using AcDream.App.Rendering.Vfx;
|
||
using AcDream.App.Streaming;
|
||
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.Runtime.Entities;
|
||
using AcDream.Runtime.Gameplay;
|
||
using AcDream.Runtime.Session;
|
||
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 LiveEntityAnimationRuntimeView<LiveEntityAnimationState> _animatedEntities;
|
||
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 RuntimeCombatTargetState? _combatTargetController;
|
||
private readonly LiveWorldOriginState _origin;
|
||
private readonly AcDream.App.Streaming.ILocalPlayerTeleportNetworkSink
|
||
_localPlayerTeleport;
|
||
private readonly IRuntimeLocalPlayerControllerSource _playerControllerSource;
|
||
private readonly LocalPlayerOutboundController _localPlayerOutbound;
|
||
private readonly ILocalPlayerPhysicsHostSource _playerHostSource;
|
||
private readonly ILocalPlayerIdentitySource _playerIdentity;
|
||
private readonly IPhysicsScriptTimeSource _gameTime;
|
||
private readonly ILiveWorldSessionSource _session;
|
||
private readonly LiveEntityInboundAuthorityGate _authorityGate;
|
||
private readonly IMovementTruthDiagnosticSink _movementTruthDiagnostics;
|
||
private readonly InventoryWorldDropProjectionController?
|
||
_worldDropProjection;
|
||
private readonly RuntimeAcceptedPositionDriveController _acceptedPositionDrive;
|
||
/// <summary>
|
||
/// C4 route 4b-2: the Runtime-owned remote placement seam. Route 4b-1
|
||
/// landed it with no production caller; the remote far snap
|
||
/// (<c>SetPositionSimple</c>, <c>player_distance >= 96 m</c>) is its
|
||
/// first, so its ownership ledger stops being tautologically zero here.
|
||
/// </summary>
|
||
private readonly RuntimeRemotePlacementDriveController _remotePlacementDrive;
|
||
|
||
/// <summary>
|
||
/// #315 (closed by the OnPosition collapse, 2026-08-04): scratch fields
|
||
/// backing <see cref="_remoteArmCallbacks"/> — the two delegates
|
||
/// <see cref="RunRemoteArmTail"/> passes into
|
||
/// <see cref="ApplyRemoteContactRouting"/> every accepted remote Position
|
||
/// (5-10 Hz per remote). Before the collapse there were three duplicated
|
||
/// call sites, each allocating a fresh closure per packet regardless of
|
||
/// whether the packet was a teleport; the collapse converged them to one,
|
||
/// which is what makes caching worthwhile — one cached pair now serves
|
||
/// every remote guid. <see cref="RunRemoteArmTail"/> stamps these
|
||
/// fields from its own parameters immediately before use; nothing reads
|
||
/// them between calls, so last-remote staleness between packets is
|
||
/// harmless (mirrors the existing per-instance scratch-field pattern,
|
||
/// e.g. <c>RemoteMotion.PositionManagerDeltaScratch</c>).
|
||
/// </summary>
|
||
private RuntimeEntityRecord? _remoteArmCanonical;
|
||
private RemoteMotion? _remoteArmMotion;
|
||
private LiveEntityRecord? _remoteArmPositionRecord;
|
||
private ulong _remoteArmPositionAuthorityVersion;
|
||
private AcDream.Core.World.WorldEntity? _remoteArmExpectedEntity;
|
||
|
||
/// <summary>
|
||
/// B4 fix (C4 route 5 round-2 architecture review): the same #315
|
||
/// cached-delegate discipline for the adopted-body missile arm's
|
||
/// teleport-hook currency check
|
||
/// (<see cref="RemoteArmCallbacks.IsCurrentProjectilePositionOwner"/>).
|
||
/// The missile dispatch block stamps these scratch fields immediately
|
||
/// before calling <see cref="RunRemoteTeleportHook"/> instead of
|
||
/// allocating a fresh <c>Func<bool></c> closure every accepted
|
||
/// missile packet — the same defect class the remote arm's own #315
|
||
/// collapse already closed, flagged independently by both round-2
|
||
/// reviews for this arm.
|
||
/// </summary>
|
||
private LiveEntityRecord? _projectileArmPositionRecord;
|
||
private ulong _projectileArmPositionAuthorityVersion;
|
||
|
||
/// <summary>
|
||
/// #315: the two per-packet delegates cached ONCE (constructed here,
|
||
/// reused for every accepted remote Position) rather than allocated
|
||
/// fresh every packet. Deliberately its own small type, not two bare
|
||
/// <c>Func<bool></c> fields directly on this class:
|
||
/// <c>tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs</c>'s
|
||
/// <c>ProductionFrameAdaptersRetainTypedOwnersWithoutWindowCallbacks</c>
|
||
/// asserts every typed production owner (this class included) carries
|
||
/// ZERO <c>Delegate</c>-typed fields — the GameWindow decomposition
|
||
/// campaign's guard against a callback silently smuggling a window
|
||
/// reference back in. Neither delegate here touches a window (both are
|
||
/// bound to this controller alone), but the rule is written as a
|
||
/// blanket field-type check, not a window-specific one, so the cache
|
||
/// lives in its own named type instead of tripping it.
|
||
/// </summary>
|
||
private sealed class RemoteArmCallbacks
|
||
{
|
||
internal readonly Func<bool> IsCurrentPositionOwner;
|
||
internal readonly Func<bool> RunTeleportHook;
|
||
|
||
/// <summary>
|
||
/// B4 fix: the projectile (missile) arm's currency check, cached
|
||
/// the same way as the two remote-arm delegates above rather than
|
||
/// allocated fresh per accepted missile packet. Bound to
|
||
/// <see cref="IsCurrentProjectileArmPositionOwner"/>, which reads
|
||
/// the <c>_projectileArmPosition*</c> scratch fields the missile
|
||
/// dispatch block stamps immediately before use.
|
||
/// </summary>
|
||
internal readonly Func<bool> IsCurrentProjectilePositionOwner;
|
||
|
||
internal RemoteArmCallbacks(LiveEntityNetworkUpdateController owner)
|
||
{
|
||
IsCurrentPositionOwner = owner.IsCurrentRemoteArmPositionOwner;
|
||
RunTeleportHook = owner.RunCachedRemoteTeleportHook;
|
||
IsCurrentProjectilePositionOwner =
|
||
owner.IsCurrentProjectileArmPositionOwner;
|
||
}
|
||
}
|
||
|
||
private readonly RemoteArmCallbacks _remoteArmCallbacks;
|
||
|
||
private PlayerMovementController? _playerController => _playerControllerSource.Controller;
|
||
private EntityPhysicsHost? _playerHost => _playerHostSource.Host;
|
||
private uint _playerServerGuid => _playerIdentity.ServerGuid;
|
||
private double _physicsScriptGameTime => _gameTime.CurrentScriptTime;
|
||
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,
|
||
LiveEntityAnimationRuntimeView<LiveEntityAnimationState> animatedEntities,
|
||
RemoteMovementObservationTracker remoteMovementObservations,
|
||
RemotePhysicsUpdater remotePhysicsUpdater,
|
||
RemoteInboundMotionDispatcher remoteInboundMotion,
|
||
LiveEntityMotionRuntimeController motionRuntime,
|
||
PhysicsEngine physicsEngine,
|
||
IDatReaderWriter dats,
|
||
IAnimationLoader animLoader,
|
||
RuntimeCombatTargetState? combatTargetController,
|
||
LiveWorldOriginState origin,
|
||
AcDream.App.Streaming.ILocalPlayerTeleportNetworkSink localPlayerTeleport,
|
||
IRuntimeLocalPlayerControllerSource playerControllerSource,
|
||
LocalPlayerOutboundController localPlayerOutbound,
|
||
ILocalPlayerPhysicsHostSource playerHostSource,
|
||
ILocalPlayerIdentitySource playerIdentity,
|
||
IPhysicsScriptTimeSource gameTime,
|
||
ILiveWorldSessionSource session,
|
||
Action<uint, AcceptedPhysicsTimestamps> publishTimestamps,
|
||
IMovementTruthDiagnosticSink movementTruthDiagnostics,
|
||
RuntimeAcceptedPositionDriveController acceptedPositionDrive,
|
||
RuntimeRemotePlacementDriveController remotePlacementDrive,
|
||
InventoryWorldDropProjectionController? worldDropProjection = null)
|
||
{
|
||
_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));
|
||
_animatedEntities = animatedEntities ?? throw new ArgumentNullException(nameof(animatedEntities));
|
||
_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));
|
||
_playerIdentity = playerIdentity ?? throw new ArgumentNullException(nameof(playerIdentity));
|
||
_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));
|
||
_acceptedPositionDrive = acceptedPositionDrive
|
||
?? throw new ArgumentNullException(nameof(acceptedPositionDrive));
|
||
_remotePlacementDrive = remotePlacementDrive
|
||
?? throw new ArgumentNullException(nameof(remotePlacementDrive));
|
||
_worldDropProjection = worldDropProjection;
|
||
// #315: cached once, reused for every accepted remote Position — see
|
||
// the field docs above _remoteArmCanonical.
|
||
_remoteArmCallbacks = new RemoteArmCallbacks(this);
|
||
}
|
||
|
||
internal void ResetSessionState() => _authorityGate.ResetSessionState();
|
||
|
||
private static bool IsPlayerGuid(uint guid) =>
|
||
(guid & 0xFF000000u) == 0x50000000u;
|
||
|
||
private static bool IsDoorName(string? name) => name == "Door";
|
||
|
||
/// <summary>
|
||
/// #270 (2026-07-30): retail spawns run the placement transition
|
||
/// (<c>CPhysicsObj::SetPosition</c> → <c>SetPositionInternal</c>
|
||
/// 0x00515330), which establishes CONTACT/ON_WALKABLE from the floor the
|
||
/// creature stands on. A raw position seed leaves the fresh body
|
||
/// airborne-flagged, and <c>contact_allows_move</c> (0x00528dd0) then
|
||
/// silently refuses every action animation — a spawned-standing monster's
|
||
/// attack swings never played until it first moved (the [MT-FAIL]
|
||
/// Falling-substitution spam was the same body state surfacing through
|
||
/// <c>apply_interpreted_movement</c>). Mirrors the canonical Runtime
|
||
/// placement commit (<c>RuntimeSetPositionState</c>) with spawn-shaped
|
||
/// inputs (no prior contact).
|
||
/// </summary>
|
||
private void SeedRemoteSpawnPlacement(
|
||
RemoteMotion remote,
|
||
uint serverGuid,
|
||
AcDream.Core.World.WorldEntity entity,
|
||
System.Numerics.Vector3 worldPos,
|
||
uint cellId)
|
||
{
|
||
var (radius, height) = _motionRuntime.GetSetupCylinder(serverGuid, entity);
|
||
if (radius < 0.05f)
|
||
{
|
||
radius = 0.48f;
|
||
height = 1.835f;
|
||
}
|
||
|
||
var moverFlags = IsPlayerGuid(serverGuid)
|
||
? AcDream.Core.Physics.ObjectInfoState.IsPlayer
|
||
| AcDream.Core.Physics.ObjectInfoState.EdgeSlide
|
||
: AcDream.Core.Physics.ObjectInfoState.EdgeSlide;
|
||
|
||
// Retail's spawn contact comes from the FIRST GRAVITY FRAME, not the
|
||
// placement itself: every retail CPhysicsObj simulates, so a freshly
|
||
// placed creature falls the few centimetres onto the floor and the
|
||
// transition's touch grants the contact plane. Our remotes reach that
|
||
// state SLOWLY or not at all — the DR tick only sweeps when the
|
||
// composed candidate actually moved, so a remote spawned exactly on
|
||
// its floor never sweeps and a remote spawned above one needs however
|
||
// many ticks gravity takes to close the gap. The settle is therefore
|
||
// compressed here: a short downward sweep from the server position.
|
||
// Its touch handler produces exactly the state retail's first frame
|
||
// would (position snapped onto the floor, contact plane +
|
||
// CONTACT/ON_WALKABLE committed below). A sweep that finds no floor
|
||
// (true airborne spawn) leaves the body airborne.
|
||
//
|
||
// Bug B (2026-08-04) weakened — but did not remove — the reason this
|
||
// exists. The deleted per-tick `Contact | OnWalkable` forge used to
|
||
// make a stationary remote's transients permanent, so a contact-free
|
||
// remote could NEVER settle on its own; now gravity survives and one
|
||
// WILL settle by itself after a few ticks of falling. This compressed
|
||
// settle is what keeps it from spending those ticks visibly
|
||
// contact-free, which is the #270 window (`contact_allows_move`
|
||
// @0x00528dd0 refuses action animations without both transients).
|
||
if (!AcDream.Core.Physics.SpawnPlacementSettler.TrySettle(
|
||
_physicsEngine,
|
||
remote.Body,
|
||
worldPos,
|
||
cellId,
|
||
radius,
|
||
height,
|
||
moverFlags,
|
||
entity.Id,
|
||
remote.Movement.HitGround,
|
||
remote.Motion.LeaveGround))
|
||
{
|
||
return; // no floor within reach — stays airborne like retail's fall
|
||
}
|
||
remote.Airborne = !remote.Body.OnWalkable;
|
||
}
|
||
|
||
/// <summary>
|
||
/// R6/R2 fix round (2026-08-04). Two independent fixes:
|
||
///
|
||
/// <para>
|
||
/// <b>R6.</b> Previously re-resolved the <c>RemoteMotion</c>/host BY
|
||
/// GUID instead of using the <c>rmState</c> the caller already holds and
|
||
/// hands to <see cref="ApplyRemoteContactRouting"/>. Every action was
|
||
/// null-conditional (<c>remote?.</c>/<c>host?.</c>), so a resolution
|
||
/// mismatch silently no-op'd all six actions while
|
||
/// <see cref="RemoteTeleportHook.Execute"/> still returned <c>true</c> —
|
||
/// and <c>PhysicsDiagnostics.LogRemoteTeleport</c> printed
|
||
/// <c>hookRan=True</c> for a hook that did nothing. <paramref name="remote"/>
|
||
/// is now the caller's own <c>rmState</c> directly (never null, no
|
||
/// lookup, no mismatch risk) and <c>host</c> comes from
|
||
/// <see cref="RemoteMotion.Host"/> — the SAME bound reference, not a
|
||
/// second independent resolution. A null host is now a genuine "the
|
||
/// manager doesn't exist yet" case (retail's own "each guarded on the
|
||
/// manager existing"), not a resolution bug.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// <b>R2.</b> <c>ReportCollisionEnd</c> — retail
|
||
/// <c>report_collision_end(this, 1)</c> @0x00514F31 → @0x00514620, a
|
||
/// force-end-all of the collision TABLE with bidirectional
|
||
/// <c>DoCollisionEnd</c> — used to call <c>ShadowObjects.Suspend</c>,
|
||
/// which ports a DIFFERENT retail function
|
||
/// (<c>remove_shadows_from_cells</c>) that <c>teleport_hook</c> never
|
||
/// calls, and which retail does NOT do at this call site (a real, if
|
||
/// frames-scale, added divergence — the shadow un-suspends again inside
|
||
/// the arm tail's own <c>LiveEntityShadowPublisher.TryPublishRemote</c>,
|
||
/// exactly like every other placement, so nothing else needs to
|
||
/// compensate for dropping it here). Now routes through
|
||
/// <see cref="LiveEntityRuntime.ForceEndCollisionReporting"/>, which
|
||
/// forwards to <c>RuntimeCollisionReportingState.LeaveWorld</c> — the
|
||
/// existing, unreached-until-now, exact port of this retail call.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Round-2 architecture review B2 — do NOT record this as a closed
|
||
/// observable delta. The plumbing is retail-correct, but
|
||
/// <c>IRuntimeCollisionReportObserver</c> has ZERO production
|
||
/// implementations, so retail's bidirectional <c>DoCollisionEnd</c> half
|
||
/// still reaches no gameplay consumer. What this fix closes is the wrong
|
||
/// retail-function binding; what stays open is that nobody listens. That
|
||
/// remains true until an observer ships.
|
||
/// </para>
|
||
/// </summary>
|
||
private bool RunRemoteTeleportHook(
|
||
RuntimeEntityRecord canonical,
|
||
RemoteMotion remote,
|
||
Func<bool> isCurrent)
|
||
{
|
||
EntityPhysicsHost? host = remote.Host;
|
||
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: () =>
|
||
_liveEntities.ForceEndCollisionReporting(canonical)),
|
||
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 (!_liveEntities.TryGetWorldEntity(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 (acceptedMotionRecord.ProjectionKey is { } motionKey
|
||
&& _remoteMovementObservations.TryGetValue(
|
||
motionKey,
|
||
out var prev))
|
||
{
|
||
_remoteMovementObservations[motionKey] =
|
||
(prev.Pos, refreshedTime);
|
||
}
|
||
if (_liveEntities.TryGetRemoteMotionRuntime(
|
||
update.Guid,
|
||
out IRuntimeRemoteMotion? remoteRuntime)
|
||
&& remoteRuntime is RemoteMotion 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 (!_liveEntities.TryGetRemoteMotionRuntime(
|
||
update.Guid,
|
||
out IRuntimeRemoteMotion? remoteRuntime)
|
||
|| remoteRuntime is not RemoteMotion remote)
|
||
{
|
||
remote = _liveEntities.GetOrCreateRemoteMotionRuntime(
|
||
update.Guid);
|
||
remote.Body.Orientation = entity.Rotation;
|
||
remote.Body.Position = entity.Position;
|
||
}
|
||
// #270: run the retail spawn settle so the body has real ground
|
||
// contact BEFORE the funnel below dispatches this packet's actions —
|
||
// an attack swing needs contact_allows_move true to animate. Retried
|
||
// (not creation-only) while the body lacks the CONTACT transient
|
||
// (the flag contact_allows_move reads — NOT ContactPlaneValid, which
|
||
// a DR writeback can set from last-known plane data without real
|
||
// contact): creation during the login flood can precede streaming
|
||
// residency or cell hydration, and retail's own answer to "object
|
||
// addressed before its cell exists" is the CObjectMaint lost-cell
|
||
// list — park it, re-place when the cell is available. One grounded
|
||
// settle ends the retries; a genuinely airborne remote (mid-jump
|
||
// player) fails the floor probe harmlessly until it lands.
|
||
if (!remote.Body.InContact)
|
||
{
|
||
SeedRemoteSpawnPlacement(
|
||
remote,
|
||
update.Guid,
|
||
entity,
|
||
remote.Body.Position,
|
||
// #282: one owner for "which cell is this in" (see
|
||
// WorldEntity.VisibilityCellId). 0 → helper no-ops.
|
||
entity.VisibilityCellId ?? 0u);
|
||
}
|
||
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>
|
||
/// C4 route 4a: asks Runtime to classify one remote's accepted Position.
|
||
/// App contributes only retail's <c>player_distance</c> — the live
|
||
/// physics-controller distance, and <see langword="null"/> (never a
|
||
/// fabricated <c>Vector3.Zero</c>) when no controller exists yet, which
|
||
/// makes Runtime decline. Callers must only invoke this for a genuinely
|
||
/// remote (never local-player) entity.
|
||
///
|
||
/// <para>
|
||
/// There is no legacy path: the duplicated App-side near/far blocks were
|
||
/// deleted at C4 route 4b-2, and a declined classification takes the
|
||
/// stated <c>UnroutedCatchUp</c> policy (AP-137) through
|
||
/// <c>ApplyRemoteContactRouting</c>'s default arm. The
|
||
/// <see langword="null"/> return is still "Runtime has no opinion", never
|
||
/// "rejected"; what the caller does with it is
|
||
/// <c>ApplyRemoteContactRouting</c>'s job, not this method's.
|
||
/// </para>
|
||
/// </summary>
|
||
private RuntimeAuthoritativePositionRoute? ClassifyRemoteAcceptedPosition(
|
||
AcDream.Core.Net.WorldSession.EntityPositionUpdate update,
|
||
RuntimeEntityRecord canonical,
|
||
AcDream.Core.Physics.PositionTimestampDisposition timestampDisposition,
|
||
AcceptedPhysicsTimestamps timestamps,
|
||
System.Numerics.Vector3 worldPos) =>
|
||
_liveEntities.ClassifyRemoteAcceptedPosition(
|
||
canonical,
|
||
update,
|
||
timestampDisposition,
|
||
timestamps,
|
||
_playerController is { } controller
|
||
? System.Numerics.Vector3.Distance(worldPos, controller.Position)
|
||
: null);
|
||
|
||
/// <summary>
|
||
/// C4 route 4a: the generic top-of-<c>OnPosition</c> render-pose write and
|
||
/// its ONE suppression rule, extracted so the rule is exercised by
|
||
/// production and by test through the same entry point rather than
|
||
/// restated in a test body.
|
||
///
|
||
/// <para>
|
||
/// For the two classifications route 4a owns, the canonical body — not
|
||
/// the raw wire packet — is the only writer of the render entity: the
|
||
/// near-interpolate branch's tail syncs the entity to the resolved body,
|
||
/// and the airborne no-op writes nothing at all (retail
|
||
/// <c>MoveOrTeleport</c> 0x00516330 returns 0 @0x0051636D). Writing the
|
||
/// wire pose here first would be the second writer route 2's original
|
||
/// defect consisted of.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// The gate is <c>OwnsSteadyState</c> — unchanged by C4 routes 4b-2 or
|
||
/// 4b-3. The far snap AND the teleport arm both DO take the wire-pose
|
||
/// write here even though each goes on to place canonically. That is
|
||
/// deliberate and is not a second writer in the route 2 sense: both
|
||
/// arms' tails re-sync the render entity from the RESOLVED body
|
||
/// afterwards, so this write only covers the window before the placement
|
||
/// commits.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// The spatial bucket transaction deliberately does NOT live behind this
|
||
/// gate: unlike route 2, neither 4a branch performs a placement, so there
|
||
/// is no committed placement receipt to project in its stead. The per-UP
|
||
/// <c>RebucketLiveEntity</c> is the only site that moves an ordinary
|
||
/// moving remote's draw bucket, commits its canonical <c>FullCellId</c>,
|
||
/// and recovers a pending bucket promotion, and it must keep running for
|
||
/// both 4a classifications.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// The <see langword="bool"/> result is this seam's observable outcome and
|
||
/// is what the acceptance tests assert on; production does not need it.
|
||
/// Do not delete it as dead — returning nothing would leave the
|
||
/// suppression rule unobservable, which is the #292 gap this closes.
|
||
/// Returns true when the wire pose was written.
|
||
/// </para>
|
||
/// </summary>
|
||
internal static bool TryApplyGenericRemoteRenderPose(
|
||
AcDream.Core.World.WorldEntity entity,
|
||
RuntimeAuthoritativePositionRoute? route,
|
||
System.Numerics.Vector3 worldPos,
|
||
uint landblockId,
|
||
System.Numerics.Quaternion rotation)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(entity);
|
||
if (RuntimeRemoteSteadyStatePosition.OwnsSteadyState(route))
|
||
return false;
|
||
|
||
entity.SetPosition(worldPos);
|
||
entity.ParentCellId = landblockId;
|
||
entity.Rotation = rotation;
|
||
return true;
|
||
}
|
||
|
||
/// <summary>Which arm of the remote contact routing claimed a packet. The
|
||
/// value is the seam's observable outcome, asserted by the acceptance
|
||
/// tests; production distinguishes
|
||
/// <see cref="RemoteContactArm.FarSnapPlacement"/> and (C4 route 4b-3)
|
||
/// <see cref="RemoteContactArm.TeleportPlacement"/> — the two arms that
|
||
/// execute a canonical placement and are therefore re-entrant — but the
|
||
/// finer result is what makes the PRECEDENCE and the arm selection
|
||
/// testable and must not be collapsed to a bool.</summary>
|
||
internal enum RemoteContactArm : byte
|
||
{
|
||
/// <summary>The body was in free flight — NOT in contact with any
|
||
/// surface. Hard-snapped. Gated on the body's own
|
||
/// <c>Body.InContact</c> ALONE (AP-140, retired 2026-08-04) — the wire
|
||
/// contact bit is never read here. The common case is the landing
|
||
/// packet, but a not-in-contact packet also reaches this arm whenever
|
||
/// the classification is one route 4a does not own (null, cell-less
|
||
/// <c>SetPosition</c>, or rejected), because the
|
||
/// <c>IsAirborneNoOperation</c> early return fires only for
|
||
/// classifications it does own. That matches pre-4a behaviour.
|
||
///
|
||
/// <para>The name is retained for continuity with route 4a; read
|
||
/// "airborne" here as retail's CONTACT_TS-clear, not as the client
|
||
/// <c>RemoteMotion.Airborne</c> flag, which is
|
||
/// <c>!Body.OnWalkable</c> — a strictly WIDER set. The two disagree on
|
||
/// a body in contact with a non-walkable face (a steep slide), which
|
||
/// retail interpolates.</para></summary>
|
||
AirborneSnap,
|
||
|
||
/// <summary>Route 4a's near InterpolateTo branch.</summary>
|
||
SteadyStateInterpolate,
|
||
|
||
/// <summary>C4 route 4b-2: retail's far snap — <c>StopInterpolating</c>
|
||
/// @0x005163CB then <c>SetPositionSimple</c> @0x005163D9 — executed
|
||
/// through the canonical Runtime placement owner.</summary>
|
||
FarSnapPlacement,
|
||
|
||
/// <summary>
|
||
/// C4 route 4b-3: retail's teleport/cell-less branch —
|
||
/// <c>teleport_hook</c> @0x00514ED0 then <c>SetPosition</c>
|
||
/// @0x00516420 — executed through the canonical Runtime placement
|
||
/// owner. Decided AHEAD of the contact test (D5): a teleport packet
|
||
/// never takes <see cref="AirborneSnap"/> regardless of wire or body
|
||
/// contact.
|
||
/// </summary>
|
||
TeleportPlacement,
|
||
|
||
/// <summary>The acdream-only leftover set (null, <c>Rejected*</c>).
|
||
/// See <see cref="RuntimeRemoteAcceptedPositionArm.UnroutedCatchUp"/>
|
||
/// for the stated policy.</summary>
|
||
UnroutedCatchUp,
|
||
}
|
||
|
||
/// <summary>
|
||
/// The complete observable outcome of one
|
||
/// <see cref="ApplyRemoteContactRouting"/> call: which arm claimed the
|
||
/// packet, and — for
|
||
/// <see cref="RemoteContactArm.FarSnapPlacement"/> alone — what the
|
||
/// canonical Runtime placement actually did.
|
||
///
|
||
/// <para>
|
||
/// C4 route 4b-2 review fix: the placement status used to be discarded at
|
||
/// the call site (<c>_ = placementDrive.…</c>), which made the far arm's
|
||
/// non-commit outcomes invisible from outside and hid the freeze
|
||
/// the review found. Production still takes no DECISION from it —
|
||
/// retail's <c>MoveOrTeleport</c> likewise discards
|
||
/// <c>SetPositionSimple</c>'s <c>SetPositionError</c> and returns 1
|
||
/// @0x005163E8 — but the value is now carried out of the seam so the
|
||
/// acceptance tests assert the commit path and the
|
||
/// <c>store_position</c> fallback path apart from each other.
|
||
/// <see cref="Placement"/> is <see langword="null"/> for every arm that
|
||
/// performs no placement.
|
||
/// </para>
|
||
/// </summary>
|
||
internal readonly record struct RemoteContactRouting(
|
||
RemoteContactArm Arm,
|
||
RuntimeRemotePlacementExecutionStatus? Placement);
|
||
|
||
/// <summary>
|
||
/// The complete remote grounded/contact routing for ONE accepted Position,
|
||
/// shared by every remote guid through the single collapsed
|
||
/// <c>OnPosition</c> tail (C4 route 4b-3 collapse, 2026-08-04) — retail's
|
||
/// <c>CPhysicsObj::MoveOrTeleport</c> (0x00516330) makes no
|
||
/// <c>this == player</c> distinction on any of these branches.
|
||
///
|
||
/// <para>
|
||
/// C4 route 4a contributed the ORDERING carve-out: an airborne body's
|
||
/// contact packet keeps its pre-existing authoritative hard-snap and is
|
||
/// decided BEFORE the near-Interpolate branch can claim it. A landing
|
||
/// packet classifies <c>Interpolate</c>, so if the 4a test came first it
|
||
/// would ENQUEUE a body that must PLANT, and a creature knocked off a
|
||
/// ledge would glide down over a packet interval. Before the collapse the
|
||
/// player-remote caller had its own pre-check (the standalone LANDING
|
||
/// TRANSITION block) that reached this method only with
|
||
/// <c>Body.InContact == true</c>, making the carve-out inert for that
|
||
/// caller specifically. That pre-check is deleted: the single caller now
|
||
/// reaches this method with either contact state for either guid, so the
|
||
/// carve-out is live for both — its <c>AirborneSnap</c> result is exactly
|
||
/// the dissolved landing scenario, for every guid (see
|
||
/// <c>ToConstraintArm</c>'s A1 mapping and <c>OnPosition</c>'s own
|
||
/// <c>arm is AirborneSnap</c> handling for the two guid-preserved
|
||
/// extras — #316's shadow-publish skip and the interp-clear — that ride
|
||
/// along with it).
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// C4 route 4b-2 added <see cref="RemoteContactArm.FarSnapPlacement"/> and
|
||
/// deleted the two duplicated App-side near/far blocks that used to follow
|
||
/// this call; C4 route 4b-3 added
|
||
/// <see cref="RemoteContactArm.TeleportPlacement"/>, decided AHEAD of
|
||
/// everything else (see below). <b>The far and teleport arms are the only
|
||
/// re-entrant ones</b> — a canonical placement publishes its
|
||
/// <c>Place</c> receipt synchronously, and a non-commit outcome publishes
|
||
/// a cancellation receipt just as synchronously, and the production
|
||
/// placement-projection sink can delete or replace the incarnation from
|
||
/// inside either — so a caller MUST re-validate position ownership after
|
||
/// this returns either arm, on EVERY placement status, before writing
|
||
/// anything else for the packet. That includes the <c>ConstrainTo</c>
|
||
/// leash: every caller therefore runs the re-validation FIRST and arms
|
||
/// second (see AP-138).
|
||
/// </para>
|
||
/// </summary>
|
||
internal static RemoteContactRouting ApplyRemoteContactRouting(
|
||
RuntimeRemotePlacementDriveController placementDrive,
|
||
RuntimeEntityRecord canonical,
|
||
RemoteMotion remote,
|
||
RuntimeAuthoritativePositionRoute? route,
|
||
System.Numerics.Vector3 worldPos,
|
||
System.Numerics.Quaternion rotation,
|
||
bool willBeDrTicked,
|
||
Func<bool> runTeleportHook)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(placementDrive);
|
||
ArgumentNullException.ThrowIfNull(canonical);
|
||
ArgumentNullException.ThrowIfNull(remote);
|
||
ArgumentNullException.ThrowIfNull(runTeleportHook);
|
||
|
||
// C4 route 4b-3 (D5): retail decides the teleport/cell-less branch
|
||
// BEFORE reading arg4 (the wire contact bit) —
|
||
// `MoveOrTeleport`'s @0x00516375-@0x00516386 test runs before
|
||
// @0x0051638E. A teleport-classified packet therefore places
|
||
// unconditionally, ahead of the free-flight carve-out below — an
|
||
// airborne-body teleport packet places, it does not AirborneSnap.
|
||
// teleport_hook @0x00514ED0 (the caller-supplied delegate) runs
|
||
// BEFORE the placement, regardless of what the placement then
|
||
// yields, exactly like retail's ordering
|
||
// (@0x005163EF before @0x00516420).
|
||
if (RuntimeRemoteTeleportPosition.OwnsTeleportPlacement(route))
|
||
{
|
||
// R7 (2026-08-04): hookRan is consumed ONLY by the probe below —
|
||
// routing proceeds to the placement regardless of its value.
|
||
// This is retail-faithful, not a dropped result: retail has no
|
||
// currency concept and runs teleport_hook unconditionally before
|
||
// ever knowing the placement outcome (@0x005163EF is called
|
||
// regardless of what @0x00516420 later yields).
|
||
bool hookRan = runTeleportHook();
|
||
RuntimeRemotePlacementExecutionStatus teleportStatus =
|
||
placementDrive.ApplyAcceptedRemoteTeleport(
|
||
canonical,
|
||
remote,
|
||
route!.Value);
|
||
// Live-execution proof (process rule 5): confirms the arm
|
||
// actually ran rather than inferring it from a clean-looking
|
||
// session. TEMPORARY — strip with ACDREAM_PROBE_REMOTE_TELEPORT.
|
||
//
|
||
// A6 fix round (2026-08-04): guarded at the call site now — the
|
||
// probe's own self-guard inside LogRemoteTeleport did not stop
|
||
// teleportStatus.ToString() from being evaluated (and allocated)
|
||
// on every teleport regardless of whether the probe was enabled.
|
||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeRemoteTeleportEnabled)
|
||
{
|
||
AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteTeleport(
|
||
canonical.ServerGuid,
|
||
cause: route.Value.Authority.TeleportAdvanced
|
||
? "teleport-ts"
|
||
: "cellless",
|
||
hookRan,
|
||
teleportStatus.ToString());
|
||
}
|
||
return new RemoteContactRouting(
|
||
RemoteContactArm.TeleportPlacement,
|
||
teleportStatus);
|
||
}
|
||
|
||
// Bug B (2026-08-04): stamp the GUID that any [remote-slide-*] line
|
||
// emitted from inside this synchronous routing window belongs to —
|
||
// ApplyInterpolate (blip producer Candidate 1) has no GUID of its own.
|
||
// TEMPORARY — strip with the ACDREAM_PROBE_REMOTE_SLIDE family.
|
||
AcDream.Core.Physics.PhysicsDiagnostics.BeginRemoteSlideAttribution(
|
||
canonical.ServerGuid);
|
||
// AP-140 (retired 2026-08-04): retail's predicate for exactly this
|
||
// decision is CONTACT, not walkability.
|
||
// `InterpolationManager::adjust_offset` @0x00555D30 gates its ENTIRE
|
||
// body on `physics_obj->transient_state & 1` @0x00555D52 — and
|
||
// TransientState bit 0 is `CONTACT_TS` (acclient.h:3690), NOT
|
||
// `ON_WALKABLE_TS` (0x2). A retail body in contact with a non-walkable
|
||
// face therefore still walks toward its queued waypoint. This gate used
|
||
// to read `remote.Airborne`, which is `!Body.OnWalkable` — WALKABILITY,
|
||
// a strictly wider set. The two disagree on precisely one state (in
|
||
// contact, not on walkable ground), and Bug B (`204d0ae0`) turned that
|
||
// state from unreachable into ordinary by deleting the per-tick
|
||
// `TransientState |= Contact | OnWalkable` forge that had made every
|
||
// non-airborne remote walkable by construction. A remote sliding on a
|
||
// steep roof lives in it, and was being hard-snapped at UpdatePosition
|
||
// cadence instead of interpolated.
|
||
//
|
||
// `Airborne` itself is deliberately NOT re-derived from CONTACT: it has
|
||
// four writers (C4 route 4b-3 retired the fifth, the deleted
|
||
// `RemoteTeleportPlacement.Apply` — its derivation is now the
|
||
// canonical placement commit's, already on the list), all spelling
|
||
// `!Body.OnWalkable`, and the per-tick updater's ground-clamp branch
|
||
// depends on the walkability reading. Only the two ROUTING gates move
|
||
// (this one and `OnPosition`'s player-remote landing block).
|
||
if (!remote.Body.InContact)
|
||
{
|
||
// Verbatim from the pre-4a branch, queue deliberately NOT
|
||
// cleared: the arc integrates locally (K-fix15), and clearing
|
||
// stale waypoints is owned by the per-tick LANDING detection —
|
||
// the `!previousOnWalkable && finalOnWalkable` arm of
|
||
// RuntimeRemotePhysicsUpdater.Tick's SetPositionInternal commit,
|
||
// whose `rm.Interp.Clear()` is register row AP-139 — not by this
|
||
// snap. Cited by SYMBOL on purpose: the same reference was a line
|
||
// range twice and went stale both times, once within a single
|
||
// review round.
|
||
//
|
||
// Do NOT restate this as "the queue is already empty here" — it
|
||
// is not. Nothing that CLEARS the body's CONTACT bit clears the
|
||
// queue: it is dropped by the per-tick sweep's SetPositionInternal
|
||
// commit (and by TickHidden's resolve) whenever the contact plane
|
||
// stops being valid, and by nothing else — the teleport hook's
|
||
// StopInterpolating is the only thing that empties the queue on a
|
||
// leave-ground edge, and the 0xF74E VectorUpdate (OnVector, below)
|
||
// does not. A walking NPC can enqueue a near waypoint and then step
|
||
// off a lip, arriving here with a populated queue. That is exactly
|
||
// why the landing clear exists, and a reader who believes the queue
|
||
// is empty here could delete it.
|
||
remote.Body.Position = worldPos;
|
||
remote.Body.Orientation = rotation;
|
||
return new RemoteContactRouting(
|
||
RemoteContactArm.AirborneSnap, Placement: null);
|
||
}
|
||
|
||
switch (RuntimeRemoteFarSnapPosition.ResolveArm(route))
|
||
{
|
||
case RuntimeRemoteAcceptedPositionArm.FarSnapPlacement:
|
||
return new RemoteContactRouting(
|
||
RemoteContactArm.FarSnapPlacement,
|
||
placementDrive.ApplyAcceptedRemoteFarSnap(
|
||
canonical,
|
||
remote,
|
||
route!.Value));
|
||
|
||
case RuntimeRemoteAcceptedPositionArm.NearInterpolate:
|
||
RuntimeRemoteSteadyStatePosition.ApplyInterpolate(
|
||
remote,
|
||
worldPos,
|
||
rotation,
|
||
isMovingTo: remote.Movement.IsMovingTo(),
|
||
willBeDrTicked);
|
||
return new RemoteContactRouting(
|
||
RemoteContactArm.SteadyStateInterpolate, Placement: null);
|
||
|
||
case RuntimeRemoteAcceptedPositionArm.AirborneNoOperation:
|
||
// R10 review fix: explicit rather than folded into `default`,
|
||
// where the comment ASSERTED unreachability that no code
|
||
// enforced. Retail's arg4 == 0 branch writes NOTHING at all
|
||
// (@0x0051636D returns 0), so there is no operation this
|
||
// method could perform; the single production caller
|
||
// (`OnPosition`'s collapsed unified tail, since the 2026-08-04
|
||
// OnPosition collapse — cited by name because line numbers
|
||
// went stale within one review round) early-returns on
|
||
// `IsAirborneNoOperation` before it ever routes. Reaching here
|
||
// means a caller skipped that gate, and the only faithful
|
||
// answer is to say so — ApplyInterpolate's own doc likewise
|
||
// forbids being called for this disposition.
|
||
throw new InvalidOperationException(
|
||
"A NoPositionOperation (airborne no-op) classification "
|
||
+ "must be handled by the caller's own early return "
|
||
+ "before routing; retail's MoveOrTeleport writes nothing "
|
||
+ "at all on that branch (@0x0051636D).");
|
||
|
||
default:
|
||
// UnroutedCatchUp takes the SAME AP-87 catch-up the near
|
||
// branch uses — the stated policy (AP-137), and the reason
|
||
// the App's two duplicated 96 m / 4 m constant pairs and both
|
||
// fabricated Vector3.Zero player positions are gone.
|
||
RuntimeRemoteSteadyStatePosition.ApplyInterpolate(
|
||
remote,
|
||
worldPos,
|
||
rotation,
|
||
isMovingTo: remote.Movement.IsMovingTo(),
|
||
willBeDrTicked);
|
||
return new RemoteContactRouting(
|
||
RemoteContactArm.UnroutedCatchUp, Placement: null);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// C4 route 4b-3 (D2): retail's <c>arg4 != 0</c>/<c>== 0</c> return-0
|
||
/// shape, applied to the two acdream-only leftover classifications that
|
||
/// remain wire-airborne after <c>IsAirborneNoOperation</c>
|
||
/// (<c>NoPositionOperation</c>, handled by each caller's own early
|
||
/// return before this is ever reached) and the teleport arm (D5 routes
|
||
/// it ahead of every contact carve-out — a teleport-classified route
|
||
/// must never be caught here, which is why every caller passes
|
||
/// <c>isTeleportRoute</c> rather than re-deriving it) — null during the
|
||
/// login window, or <c>RejectedAuthority</c>/<c>RejectedData</c>.
|
||
/// AP-135's bookkeeping (the server-cell adopt and the
|
||
/// <c>LastServerPos</c>/<c>LastServerPosTime</c> sample) is 4a-owned
|
||
/// free-fall-sweep/first-grounded-velocity state, not a retail
|
||
/// <c>CPhysicsObj</c> field, so it stays; nothing else does — no body
|
||
/// write, no queue write, no render write, no leash arm.
|
||
///
|
||
/// <para>
|
||
/// Fix round (2026-08-04, R1/A7): shared by both remote branches so they
|
||
/// could not re-diverge on this shape the way they had before that
|
||
/// round — the player arm had it, the NPC arm did not, so a wire-airborne
|
||
/// null/<c>Rejected*</c> NPC packet fell through to
|
||
/// <see cref="ApplyRemoteContactRouting"/>'s own free-flight carve-out
|
||
/// and received a body write, an arm, and a render/shadow publish
|
||
/// retail's <c>return 0</c> never produces. The OnPosition collapse
|
||
/// (2026-08-04) went further: there is now exactly ONE call site per
|
||
/// early-return (the <c>IsAirborneNoOperation</c> return and the D2
|
||
/// wire-airborne return), reached by every guid, so <paramref
|
||
/// name="wireCellId"/> is load-bearing at both — no caller writes it
|
||
/// redundantly beforehand any more (the former player-guid pre-routing
|
||
/// write this paragraph used to describe is deleted; proven a true
|
||
/// no-op by the round-2 architecture review's call-site check, since
|
||
/// <c>RebucketLiveEntity</c> commits the identical value earlier in the
|
||
/// SAME packet's processing regardless of guid).
|
||
/// </para>
|
||
/// </summary>
|
||
private static void ApplyWireAirborneLeftoverBookkeeping(
|
||
RemoteMotion remote,
|
||
uint wireCellId,
|
||
System.Numerics.Vector3 worldPos,
|
||
double nowSec)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(remote);
|
||
remote.CellId = wireCellId;
|
||
remote.LastServerPos = worldPos;
|
||
remote.LastServerPosTime = nowSec;
|
||
}
|
||
|
||
/// <summary>
|
||
/// C4 route 4b-3 fix round (2026-08-04). The routing-decision-plus-
|
||
/// currency sequence every remote arm tail performs identically around
|
||
/// <see cref="ApplyRemoteContactRouting"/>: run the routing (which
|
||
/// itself decides D5's teleport-before-contact ordering and D3's hook
|
||
/// timing), then re-validate ownership before writing anything further
|
||
/// for this packet (R5's guard-before-arm shape — the far and teleport
|
||
/// arms are re-entrant, because their canonical placement publishes its
|
||
/// Place or cancellation receipt synchronously and the production
|
||
/// projection sink can delete or replace this incarnation from inside
|
||
/// either).
|
||
///
|
||
/// <para>
|
||
/// Extracted after two independent reviews found the three hand-written
|
||
/// App-layer copies of this sequence that existed at the time (the
|
||
/// player arm's teleport dispatch, the player arm's grounded-routing
|
||
/// dispatch, the NPC arm's single dispatch) had begun to drift in ways
|
||
/// this class's own duplication made invisible: A2/R3 is precisely the
|
||
/// player copy's teleport block returning before its synth-velocity code
|
||
/// while the NPC copy had no such boundary at all, and A1 is
|
||
/// <c>ToConstraintArm</c> having been written against only the player
|
||
/// copy's reachable arm set. The OnPosition collapse (2026-08-04) went
|
||
/// further and removed the guid-conditional dispatch shape itself: this
|
||
/// method now has exactly ONE call site in production, in the unified
|
||
/// remote routing tail, reached by every guid — so the "narrow or widen
|
||
/// one copy's guard relative to the others'" failure mode this
|
||
/// extraction was built to prevent can no longer arise structurally,
|
||
/// not merely by convention.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Returns <see langword="null"/> when nothing further should be
|
||
/// written for this packet — the currency guard tripped — and the
|
||
/// caller must return without arming, adopting the wire cell, or
|
||
/// publishing the render entity/collision shadow. Does NOT itself arm
|
||
/// the leash (<see cref="ToConstraintArm"/>'s corrected mapping is
|
||
/// applied by the caller, which also needs it for the sticky-suppressed
|
||
/// default-arm case where this method is never called) and does NOT
|
||
/// itself gate the synth-velocity install (the caller's own
|
||
/// <c>isTeleportRoute</c> guard, unconditional for every guid since the
|
||
/// collapse).
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// #315 (closed here): <paramref name="positionAuthorityVersion"/> and
|
||
/// <paramref name="expectedEntity"/> replace what used to be a
|
||
/// caller-constructed <c>Func<bool> isCurrentPositionOwner</c> —
|
||
/// this method stamps the shared <c>_remoteArm*</c> scratch fields from
|
||
/// its own parameters and passes the two CACHED delegates
|
||
/// (<see cref="_remoteArmCallbacks"/>) into
|
||
/// <see cref="ApplyRemoteContactRouting"/> instead of allocating a fresh
|
||
/// closure over <c>canonical</c>/<c>remote</c>/the currency check every
|
||
/// packet. Observably identical: the currency check reads the exact same
|
||
/// <c>positionRecord</c>/<c>positionAuthorityVersion</c>/<c>expectedEntity</c>
|
||
/// triple either way, just from fields instead of a closure.
|
||
/// </para>
|
||
/// </summary>
|
||
private RemoteContactRouting? RunRemoteArmTail(
|
||
RuntimeEntityRecord canonical,
|
||
LiveEntityRecord positionRecord,
|
||
RemoteMotion remote,
|
||
RuntimeAuthoritativePositionRoute? route,
|
||
uint guid,
|
||
System.Numerics.Vector3 worldPos,
|
||
System.Numerics.Quaternion rotation,
|
||
ulong positionAuthorityVersion,
|
||
AcDream.Core.World.WorldEntity? expectedEntity)
|
||
{
|
||
_remoteArmCanonical = canonical;
|
||
_remoteArmMotion = remote;
|
||
_remoteArmPositionRecord = positionRecord;
|
||
_remoteArmPositionAuthorityVersion = positionAuthorityVersion;
|
||
_remoteArmExpectedEntity = expectedEntity;
|
||
|
||
RemoteContactRouting routing = ApplyRemoteContactRouting(
|
||
_remotePlacementDrive,
|
||
canonical,
|
||
remote,
|
||
route,
|
||
worldPos,
|
||
rotation,
|
||
willBeDrTicked: WillAdvanceRemoteMotion(guid, remote),
|
||
runTeleportHook: _remoteArmCallbacks.RunTeleportHook);
|
||
|
||
if ((routing.Arm is RemoteContactArm.FarSnapPlacement
|
||
or RemoteContactArm.TeleportPlacement)
|
||
&& (!_remoteArmCallbacks.IsCurrentPositionOwner()
|
||
|| !ReferenceEquals(positionRecord.RemoteMotionRuntime, remote)))
|
||
{
|
||
return null;
|
||
}
|
||
|
||
return routing;
|
||
}
|
||
|
||
/// <summary>
|
||
/// #315: the cached backing method for
|
||
/// <see cref="RemoteArmCallbacks.IsCurrentPositionOwner"/> — reads the
|
||
/// scratch fields <see cref="RunRemoteArmTail"/> just stamped rather than
|
||
/// closing over per-packet locals. Identical logic to the local-function
|
||
/// <c>IsCurrentPositionOwner</c> pattern used elsewhere in
|
||
/// <c>OnPosition</c> for the local-player paths, which this method does
|
||
/// not replace (those stay untouched, per contract invariant 10).
|
||
/// </summary>
|
||
private bool IsCurrentRemoteArmPositionOwner() =>
|
||
_remoteArmPositionRecord is { } record
|
||
&& _liveEntities.IsCurrentPositionAuthority(
|
||
record, _remoteArmPositionAuthorityVersion)
|
||
&& (_remoteArmExpectedEntity is null
|
||
|| ReferenceEquals(record.WorldEntity, _remoteArmExpectedEntity));
|
||
|
||
/// <summary>
|
||
/// B4 fix: the cached backing method for
|
||
/// <see cref="RemoteArmCallbacks.IsCurrentProjectilePositionOwner"/>.
|
||
/// Reads the <c>_projectileArmPosition*</c> scratch fields the missile
|
||
/// dispatch block in <c>OnPosition</c> stamps immediately before calling
|
||
/// <see cref="RunRemoteTeleportHook"/> for the adopted-body case. No
|
||
/// expected-entity check here — unlike the remote arm, the missile
|
||
/// dispatch block never captured one; parity with the remote arm's
|
||
/// extra guard is not required because the caller already re-validates
|
||
/// <c>ReferenceEquals(positionRecord.WorldEntity, entity)</c> earlier in
|
||
/// the SAME accepted-Position dispatch for the non-missile tail, and
|
||
/// the missile tail's own record is the SAME instance stamped here —
|
||
/// there is no second entity to disagree with.
|
||
/// </summary>
|
||
private bool IsCurrentProjectileArmPositionOwner() =>
|
||
_projectileArmPositionRecord is { } record
|
||
&& _liveEntities.IsCurrentPositionAuthority(
|
||
record, _projectileArmPositionAuthorityVersion);
|
||
|
||
/// <summary>
|
||
/// #315: the cached backing method for
|
||
/// <see cref="RemoteArmCallbacks.RunTeleportHook"/>. Only ever invoked on
|
||
/// the teleport-classified path, inside
|
||
/// <see cref="ApplyRemoteContactRouting"/>; reads the scratch fields
|
||
/// <see cref="RunRemoteArmTail"/> just stamped for THIS packet.
|
||
/// </summary>
|
||
private bool RunCachedRemoteTeleportHook() =>
|
||
_remoteArmCanonical is { } canonical
|
||
&& _remoteArmMotion is { } motion
|
||
&& RunRemoteTeleportHook(
|
||
canonical, motion, _remoteArmCallbacks.IsCurrentPositionOwner);
|
||
|
||
/// <summary>
|
||
/// C4 route 4b-2: the post-routing wire-cell adoption, extracted so its
|
||
/// ONE suppression rule is exercised by production and by test through
|
||
/// the same entry point rather than restated in a test body. Since the
|
||
/// OnPosition collapse (2026-08-04) this is the ONLY wire-cell adopt site
|
||
/// for every guid — the former player-guid pre-routing write (which set
|
||
/// the identical value unconditionally, before classification, and was
|
||
/// proven a true no-op by the round-2 architecture review's call-site
|
||
/// check) is deleted.
|
||
///
|
||
/// <para>
|
||
/// <see cref="RemoteMotion.CellId"/> writes THROUGH to the canonical
|
||
/// <c>FullCellId</c> (<c>RuntimePhysicsState.CommitCanonicalCell</c>).
|
||
/// After a far snap OR a teleport (C4 route 4b-3, D6) the canonical
|
||
/// placement is the cell authority — retail
|
||
/// <c>CPhysicsObj::SetPositionInternal</c> (0x00515BD0) resolves the
|
||
/// destination cell through <c>AdjustPosition</c>/<c>set_cell</c> and
|
||
/// nothing writes the wire cell over it afterwards — so this write is
|
||
/// suppressed for both arms. This call sits AFTER routing (row 3 of the
|
||
/// collapse contract, resolved to unify on this shape); leaving it
|
||
/// unguarded would discard a resolved cell that differs from the wire
|
||
/// cell. Every other arm performs no placement, so the wire cell is still
|
||
/// the newest truth there.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// <b>Scope, stated precisely (C4 route 4b-2 review; corrected at the
|
||
/// delta review).</b> The suppression bites whenever the canonical
|
||
/// placement RESOLVED a cell different from the wire cell. That is the
|
||
/// commit, and also the <c>RejectedByPlacement</c> shape where
|
||
/// <c>CommitCanonical</c> settled the body (and wrote
|
||
/// <c>record.FullCellId</c>) before the projection ownership was
|
||
/// displaced — the earlier "only when the placement COMMITTED" wording
|
||
/// missed that one. It also bites on <c>Deferred</c>, which round 3
|
||
/// (correction m1) adds to this enumeration: <c>ParkDeferred</c> snaps
|
||
/// the body to the PARKED result cell and
|
||
/// <c>RestoreParkWithdrawal</c> re-commits residency from
|
||
/// <c>body.CellPosition.ObjCellId</c>, which for a post-sweep park is the
|
||
/// swept/settled cell and need not be the wire cell. The remaining
|
||
/// outcomes — <c>Refused</c>, <c>Contention</c>,
|
||
/// <c>RejectedPreparation</c>, <c>NotApplicable</c>, and the
|
||
/// <c>RejectedByPlacement</c> shape the engine's own sweep refused —
|
||
/// resolve no cell, and there the suppression is a no-op: the per-UP
|
||
/// <c>RebucketLiveEntity</c> above already committed the wire full cell
|
||
/// to canonical, and <c>RemoteMotion.CellId</c> reads through to the same
|
||
/// <c>FullCellId</c>, so the suppressed write would have written the
|
||
/// value that is already there. Keying on the ARM rather than the
|
||
/// placement status is therefore exact as well as simpler — and the
|
||
/// body/cell divergence a refusal used to produce was the frozen body,
|
||
/// which the <c>store_position</c> fallback fixes at its source.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// <b>Completion of the "already there" justification for the park case
|
||
/// (2026-08-04).</b> The no-op argument above is a claim about the four
|
||
/// cell-resolving-nothing outcomes only, and it does NOT extend to
|
||
/// <c>Deferred</c>. A park runs <c>WithdrawCanonical</c>, which ZEROES
|
||
/// <c>record.FullCellId</c>; <c>CommitCanonicalCell</c> early-returns only
|
||
/// on equality, so nothing about "the value is already there" survives a
|
||
/// park. <c>Deferred</c> is nevertheless suppressed correctly, but for the
|
||
/// FIRST reason in this doc rather than the second: the park snapped the
|
||
/// body to a resolved cell that need not be the wire cell, and
|
||
/// <c>RestoreParkWithdrawal</c> re-commits residency from that body cell
|
||
/// (or, in this controller's shipped order, leaves in place the full cell
|
||
/// the per-UP <c>RebucketLiveEntity</c> above committed before routing).
|
||
/// Adopting the wire cell into <c>RemoteMotion.CellId</c> afterwards would
|
||
/// contradict whichever of those two the entity actually holds.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Returns true when the wire cell was adopted.
|
||
/// </para>
|
||
/// </summary>
|
||
internal static bool TryAdoptWireCellAfterRouting(
|
||
RemoteMotion remote,
|
||
RemoteContactArm arm,
|
||
uint wireCellId)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(remote);
|
||
if (arm is RemoteContactArm.FarSnapPlacement
|
||
or RemoteContactArm.TeleportPlacement)
|
||
return false;
|
||
remote.CellId = wireCellId;
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// C4 route 4b-3 (D4): translates this App-layer routing outcome into the
|
||
/// Runtime arm value
|
||
/// <see cref="RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation"/>
|
||
/// consumes — the single post-operation <c>ConstrainTo</c> site.
|
||
///
|
||
/// <para>
|
||
/// A1 fix round (2026-08-04): this table previously mapped
|
||
/// <see cref="RemoteContactArm.AirborneSnap"/> to
|
||
/// <see cref="RuntimeRemoteAcceptedPositionArm.AirborneNoOperation"/> —
|
||
/// the one value that never arms — on the claim that "production never
|
||
/// calls this with that arm". That claim was false on the NPC branch:
|
||
/// <see cref="ApplyRemoteContactRouting"/>'s free-flight carve-out
|
||
/// (<c>!remote.Body.InContact</c>) returns <c>AirborneSnap</c> for ANY
|
||
/// non-teleport classification whenever the body lacks a contact
|
||
/// plane — including an ordinary landing packet (wire IS grounded, so
|
||
/// retail's <c>arg4 != 0</c>, so retail's <c>MoveOrTeleport</c> returns
|
||
/// nonzero and arms @0x00454272 regardless of the body's own contact
|
||
/// state, which is an acdream-only concept for snap-vs-interpolate
|
||
/// selection, not retail's arming predicate). Mapping it to
|
||
/// <c>AirborneNoOperation</c> silently dropped the leash's per-packet
|
||
/// re-anchor to zero for a creature knocked off a ledge. It now maps to
|
||
/// <see cref="RuntimeRemoteAcceptedPositionArm.NearInterpolate"/> — the
|
||
/// SAME arming value the player arm's own LANDING TRANSITION block
|
||
/// already uses explicitly for the identical scenario (grounded wire,
|
||
/// body not in contact). Retail's true airborne no-op
|
||
/// (<c>arg4 == 0</c>, acdream's <see cref="RemoteContactArm"/> has no
|
||
/// dedicated case for it because it is never routed through
|
||
/// <c>ApplyRemoteContactRouting</c> at all — both callers return before
|
||
/// reaching it, via <c>IsAirborneNoOperation</c>/D2) is the only case
|
||
/// this table cannot express, which is why the switch stays total via a
|
||
/// throwing default rather than a silent zero-arm fallback.
|
||
/// </para>
|
||
/// </summary>
|
||
private static RuntimeRemoteAcceptedPositionArm ToConstraintArm(
|
||
RemoteContactArm arm) => arm switch
|
||
{
|
||
RemoteContactArm.TeleportPlacement =>
|
||
RuntimeRemoteAcceptedPositionArm.TeleportPlacement,
|
||
RemoteContactArm.FarSnapPlacement =>
|
||
RuntimeRemoteAcceptedPositionArm.FarSnapPlacement,
|
||
RemoteContactArm.SteadyStateInterpolate =>
|
||
RuntimeRemoteAcceptedPositionArm.NearInterpolate,
|
||
RemoteContactArm.AirborneSnap =>
|
||
RuntimeRemoteAcceptedPositionArm.NearInterpolate,
|
||
RemoteContactArm.UnroutedCatchUp =>
|
||
RuntimeRemoteAcceptedPositionArm.UnroutedCatchUp,
|
||
_ => throw new ArgumentOutOfRangeException(
|
||
nameof(arm), arm, "Unhandled RemoteContactArm in ToConstraintArm."),
|
||
};
|
||
|
||
/// <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 (!_liveEntities.ContainsWorldEntity(update.Guid)) return;
|
||
|
||
if (update.Guid == _playerServerGuid) return; // local jump uses our own physics
|
||
if (!_liveEntities.TryGetRemoteMotionRuntime(
|
||
update.Guid,
|
||
out IRuntimeRemoteMotion? remoteRuntime)
|
||
|| remoteRuntime is not RemoteMotion 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;
|
||
}
|
||
|
||
// Bug B (2026-08-04) — [remote-slide-vec]. NOT ESTABLISHED #4 asks
|
||
// whether ACE relays a 0xF74E at all while a sender slides; the
|
||
// ABSENCE of these lines across a captured slide window is the
|
||
// answer, so this sits on the committed path rather than inside the
|
||
// +Z airborne branch below (a downhill slide has Velocity.Z < 0 and
|
||
// would never reach it). willMarkAirborne restates that branch's own
|
||
// test so the log states the outcome rather than making the reader
|
||
// re-derive it. Pure reads. TEMPORARY — strip with the probe family.
|
||
if (AcDream.Core.Physics.PhysicsDiagnostics.ShouldLogRemoteSlide(
|
||
update.Guid))
|
||
{
|
||
AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteSlideVector(
|
||
guid: update.Guid,
|
||
wireVelocity: update.Velocity,
|
||
wireOmega: update.Omega,
|
||
willMarkAirborne: update.Velocity.Z > 0.5f,
|
||
airborneBefore: rm.Airborne,
|
||
contact: rm.Body.InContact,
|
||
onWalkable: rm.Body.OnWalkable,
|
||
gravity: rm.Body.HasGravity,
|
||
bodyVelocity: rm.Body.Velocity,
|
||
contactPlaneValid: rm.Body.ContactPlaneValid,
|
||
contactPlaneNormalZ: rm.Body.ContactPlane.Normal.Z);
|
||
}
|
||
|
||
// 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 the ground-contact transients so calc_acceleration
|
||
// (0x00510950) releases gravity and UpdatePhysicsInternal produces
|
||
// the parabolic arc. Retail reaches the same state one frame later
|
||
// through check_contact (0x0050F5B0) failing on the ascending
|
||
// velocity; clearing them here is the AP-81 head start, and it is
|
||
// what keeps the per-tick `set_on_walkable` edge from ALSO firing
|
||
// LeaveGround for this same departure.
|
||
//
|
||
// Bug B (2026-08-04): the `State |= Gravity` that used to follow is
|
||
// DELETED. GRAVITY_PS is a persistent object property owned by the
|
||
// wire — retail's CPhysicsObj constructor seeds it (state 0x400C08
|
||
// @0x00512508) and set_description's set_state (0x00514DD0) assigns
|
||
// the description's state wholesale without ever masking it. Now
|
||
// that neither landing block clears the bit, manufacturing it here
|
||
// would be the only remaining non-retail gravity write, and it
|
||
// would mask a server that genuinely sent a gravity-free state.
|
||
// ACE agrees: PhysicsGlobals.DefaultState and the player login
|
||
// state both carry PhysicsState.Gravity.
|
||
rm.Body.TransientState &= ~(AcDream.Core.Physics.TransientStateFlags.Contact
|
||
| AcDream.Core.Physics.TransientStateFlags.OnWalkable);
|
||
|
||
// 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 (_liveEntities.TryGetWorldEntity(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)
|
||
{
|
||
// C3c-F1 (2026-08-02): route through the owner's
|
||
// lifecycle-deciding typed entry. The publication lifecycle —
|
||
// not this inbound handler — decides whether the push lands:
|
||
// a dormant first-entry controller drops it (the activation
|
||
// transaction re-reads the same canonical FinalPhysicsState
|
||
// itself; the accepted SetState is queued behind the initial
|
||
// residence so this value is unchanged), and a terminal
|
||
// controller treats it as a displaced push instead of faulting
|
||
// the session (the second connected-gate crash chain,
|
||
// logs/connected-world-gate-20260802-125907).
|
||
_ = _playerController?.ApplyServerPhysicsState(
|
||
record.FinalPhysicsState);
|
||
}
|
||
|
||
if (!_liveEntities.TryGetWorldEntity(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 through the canonical Runtime directory 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)
|
||
{
|
||
if (_worldDropProjection?.TryRecoverUnknownPosition(update) == true)
|
||
{
|
||
return;
|
||
}
|
||
|
||
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;
|
||
RuntimeEntityRecord acceptedPositionCanonical = accepted.Canonical;
|
||
ulong acceptedPositionAuthorityVersion =
|
||
accepted.PositionAuthorityVersion;
|
||
ulong acceptedPositionVelocityAuthorityVersion =
|
||
accepted.VelocityAuthorityVersion;
|
||
if (!_liveEntities.TryGetProjection(
|
||
acceptedPositionCanonical,
|
||
out LiveEntityRecord acceptedPositionRecord)
|
||
&& !_liveEntityHydration.RecoverCanonicalProjection(
|
||
acceptedPositionCanonical,
|
||
acceptedPositionAuthorityVersion,
|
||
out acceptedPositionRecord))
|
||
{
|
||
return;
|
||
}
|
||
|
||
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 (forceLocal)
|
||
{
|
||
if (!IsCurrentPositionOwner())
|
||
return;
|
||
|
||
// C4 route 2 (2026-08-03): the Runtime-owned accepted-Position
|
||
// execution seam replaces the deleted LocalForcePositionTransaction.
|
||
// Ownership validation is the operation's own currency check,
|
||
// the body commit is Runtime's canonical SetPosition transaction
|
||
// (retail CPhysicsObj::SetPositionSimple @0x005162B0, called from
|
||
// SmartBox::BlipPlayer @0x00453940), and the outbound ack is an
|
||
// OUTPUT of that committed route, fired strictly after it.
|
||
RuntimeAcceptedPositionExecutionStatus forceStatus =
|
||
_acceptedPositionDrive.TryExecuteAcceptedLocalPosition(
|
||
acceptedPositionCanonical,
|
||
update,
|
||
timestampDisposition,
|
||
timestamps,
|
||
timestamps.PreviousTeleport);
|
||
if (forceStatus is RuntimeAcceptedPositionExecutionStatus.Committed
|
||
or RuntimeAcceptedPositionExecutionStatus.DeferredCell)
|
||
{
|
||
// App projects the committed (or parked-toward) result
|
||
// through the existing Runtime placement sink
|
||
// (RuntimePlacementPresentationSink), which observes the
|
||
// SAME Runtime SetPosition FIFO every other placement uses —
|
||
// it must not ALSO independently mutate the render-facing
|
||
// WorldEntity here (the retired duplicate-write authority:
|
||
// the generic tail below). The two local-player side effects
|
||
// this neighbourhood still owns independently of WorldEntity
|
||
// position (owned-VFX pose-dirty tracking and the
|
||
// pre-player-mode streaming landblock tracker) are
|
||
// preserved — DeferredCell included, since following the
|
||
// destination is what lets its streaming/collision window
|
||
// eventually publish the generation the park is waiting on.
|
||
_entityEffects?.MarkLiveOwnerPoseDirty(update.Guid);
|
||
_authorityGate.ObserveAcceptedLocalPosition(
|
||
update.Position.LandblockId);
|
||
return;
|
||
}
|
||
if (forceStatus is not RuntimeAcceptedPositionExecutionStatus.NotApplicable)
|
||
{
|
||
// R9 review fix (2026-08-03): Rejected/Contention — no
|
||
// correction was applied or parked for this exact packet.
|
||
// Do NOT move the streaming observer or mark the render pose
|
||
// dirty for a landblock this route explicitly declined to
|
||
// place into, and do not fall through to the generic tail
|
||
// below either (that would resurrect the retired duplicate-
|
||
// write authority this whole route exists to remove).
|
||
return;
|
||
}
|
||
// NotApplicable — e.g. an initial-Create residence still owns
|
||
// this record (route 1's job). Fall through to the pre-existing
|
||
// path unchanged, exactly as every other disposition does.
|
||
}
|
||
|
||
// A leave-world transition deliberately retains WorldEntity as the
|
||
// logical/render-resource owner while IsSpatiallyProjected is false.
|
||
// A fresh retail Position is the re-entry edge; testing only for the
|
||
// retained object reference leaves dropped inventory permanently
|
||
// invisible after InventoryPutObjectIn3D.
|
||
if (RequiresSpatialProjectionRecovery(acceptedPositionRecord))
|
||
{
|
||
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 (!_liveEntities.TryGetWorldEntity(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);
|
||
|
||
// C4 route 5 (D-P1/D-P6, REVISED after the review round — A2/R1,
|
||
// A9): classify ONCE, kind-aware. This single call now decides both
|
||
// the remote route (unchanged for a non-missile packet — see the
|
||
// reuse below) AND whether this packet is a missile packet,
|
||
// replacing the former ApplyAuthoritativePosition short-circuit.
|
||
// The null-classification arm's test is the SAME conjunctive
|
||
// predicate the classifier itself applies
|
||
// (RuntimeEntityObjectLifetime.ClassifyRemoteAcceptedPosition,
|
||
// D-P1) — Missile bit AND a bound RuntimeProjectile whose Body is
|
||
// the canonical PhysicsBody, never the bit alone, so an unbindable
|
||
// or not-yet-bound missile takes the ordinary remote tail exactly
|
||
// as it did before this route (the deleted method's TryGetCurrent
|
||
// fall-through) — and explicitly fenced off the local player (A9):
|
||
// update.Guid == _playerServerGuid always takes the null branch
|
||
// below, and ACE never sets Missile on a player, but the fence
|
||
// makes that structurally true rather than incidentally true.
|
||
RuntimeAuthoritativePositionRoute? earlyRemoteRoute =
|
||
update.Guid != _playerServerGuid
|
||
? ClassifyRemoteAcceptedPosition(
|
||
update,
|
||
acceptedPositionCanonical,
|
||
timestampDisposition,
|
||
timestamps,
|
||
worldPos)
|
||
: null;
|
||
bool isMissilePacket = earlyRemoteRoute is { } classifiedRoute
|
||
? classifiedRoute.OperationKind
|
||
is RuntimeSetPositionOperationKind.ProjectileAuthoritative
|
||
: update.Guid != _playerServerGuid
|
||
&& (acceptedPositionCanonical.FinalPhysicsState
|
||
& AcDream.Core.Physics.PhysicsStateFlags.Missile) != 0
|
||
&& acceptedPositionCanonical.Projectile is { } boundProjectile
|
||
&& ReferenceEquals(
|
||
acceptedPositionCanonical.PhysicsBody,
|
||
boundProjectile.Body);
|
||
if (isMissilePacket)
|
||
{
|
||
// The projectile arm over the canonical Runtime placement owner
|
||
// (RuntimeRemotePlacementDriveController.ApplyAcceptedProjectilePosition)
|
||
// — never the generic remote locomotion path below, which would
|
||
// allocate a second body or interpolation owner for the
|
||
// projectile. A null classification (login-window shape) or a
|
||
// RejectedAuthority/RejectedData disposition is swallowed:
|
||
// nothing to route, the shared authority gate above already
|
||
// rejected an invalid payload.
|
||
if (earlyRemoteRoute is { } route)
|
||
{
|
||
// A3/R2 fix: retail's teleport_hook @0x00514ED0 runs five
|
||
// manager-guarded actions BEFORE the placement, in addition
|
||
// to the collision force-end the Runtime seam performs on
|
||
// its own (action 6). For a bare arrow/bolt (no RemoteMotion
|
||
// adopted) all five are structurally absent no-ops through
|
||
// retail's own per-manager guards — matching what the
|
||
// Runtime seam already does unaided. For the ADOPTED-BODY
|
||
// case (TryBind's shared-body branch: an ordinary remote
|
||
// whose Missile bit was set by a later State packet, so it
|
||
// still carries a live RemoteMotion with a populated Interp
|
||
// queue and possibly an armed ConstrainTo leash) those five
|
||
// actions are LIVE and must run — using the SAME ordered
|
||
// hook seam and per-packet currency check the remote
|
||
// teleport arm already uses (RunRemoteTeleportHook, #315
|
||
// pattern), so retail's per-manager guards decide for
|
||
// themselves rather than being re-derived here.
|
||
if (route.Disposition
|
||
is RuntimeAuthoritativePositionDisposition.SetPosition
|
||
&& acceptedPositionCanonical.RemoteMotion is RemoteMotion adoptedRemote)
|
||
{
|
||
// B4 fix (round-2 review): cache the currency-check
|
||
// delegate the same way the remote arm's #315 collapse
|
||
// already does, instead of allocating a fresh closure
|
||
// every accepted missile packet. Scratch fields stamped
|
||
// immediately before use; nothing reads them between
|
||
// calls, so last-packet staleness is harmless.
|
||
_projectileArmPositionRecord = acceptedPositionRecord;
|
||
_projectileArmPositionAuthorityVersion =
|
||
acceptedPositionAuthorityVersion;
|
||
RunRemoteTeleportHook(
|
||
acceptedPositionCanonical,
|
||
adoptedRemote,
|
||
_remoteArmCallbacks.IsCurrentProjectilePositionOwner);
|
||
}
|
||
|
||
RuntimeRemotePlacementExecutionStatus? placementStatus =
|
||
_remotePlacementDrive.ApplyAcceptedProjectilePosition(
|
||
acceptedPositionCanonical,
|
||
route);
|
||
// A1/R5 fix: mirror Runtime's OWN presentation gate
|
||
// (ApplyAcceptedProjectilePosition/SyncProjectilePresentation
|
||
// — every outcome except Deferred/RejectedByPlacement) rather
|
||
// than acknowledging unconditionally. Deferred already
|
||
// snapped the body to the PARKED result and withdrew the
|
||
// entity; RejectedByPlacement leaves the body exactly where
|
||
// it was. Acknowledging either would move the render entity
|
||
// to (or through) a pose/cell Runtime explicitly declined to
|
||
// publish — the concrete Interpolate/RejectedByPlacement
|
||
// wrong-cell scenario the review found.
|
||
if (placementStatus is not null
|
||
and not RuntimeRemotePlacementExecutionStatus.Deferred
|
||
and not RuntimeRemotePlacementExecutionStatus.RejectedByPlacement)
|
||
{
|
||
_projectileController?.SyncPresentationFromResolvedBody(
|
||
acceptedPositionRecord,
|
||
_physicsScriptGameTime);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!_liveEntities.TryGetRecord(
|
||
update.Guid,
|
||
out LiveEntityRecord positionRecord)
|
||
|| !ReferenceEquals(positionRecord, acceptedPositionRecord)
|
||
|| !ReferenceEquals(positionRecord.WorldEntity, entity)
|
||
|| !_liveEntities.IsCurrentPositionAuthority(
|
||
positionRecord,
|
||
acceptedPositionAuthorityVersion))
|
||
{
|
||
return;
|
||
}
|
||
|
||
// C4 route 4a/5: `earlyRemoteRoute` was already classified above
|
||
// (D-P6) — this is the SAME value, reused so a remote whose accepted
|
||
// Position resolves to NoPositionOperation (retail's airborne
|
||
// no-op — writes nothing at all) or Interpolate (retail's near
|
||
// InterpolateTo queue — no direct body write here) never receives
|
||
// the generic write below. C4 route 4b-2 routes the >=96 m far snap
|
||
// and C4 route 4b-3 routes the teleport/cell-less classification
|
||
// through the canonical Runtime placement owner
|
||
// (ApplyRemoteContactRouting); a rejected authority or payload, and
|
||
// "no classification at all", take the stated UnroutedCatchUp
|
||
// policy (RuntimeRemoteFarSnapPosition.ResolveArm).
|
||
//
|
||
// CORRECTED 2026-08-04 (C4 route 3, process rule 6 — this comment
|
||
// used to claim "the local player never reaches this generic-remote
|
||
// code path at all", which is FALSE): for the local player,
|
||
// `earlyRemoteRoute` is null and `OwnsSteadyState(null)` is false
|
||
// (RuntimeRemoteSteadyStatePosition.cs — both pattern matches fail
|
||
// on null), so every accepted local Apply — including the portal
|
||
// DESTINATION Position itself — reaches and runs the generic write
|
||
// + rebucket below, writing the raw wire pose onto the local
|
||
// player's WorldEntity while portal space still covers the
|
||
// viewport. This is pre-existing, tolerated (AD-2/AP-131/#275
|
||
// territory — see the D-T7 discussion in
|
||
// docs/research/2026-08-04-c4-route-3-contract.md), and overwritten
|
||
// by the committed portal Place receipt's presentation suffix
|
||
// (LocalPlayerTeleportPlacement.Place) once the canonical Runtime
|
||
// commit lands. Route 3 does not suppress it.
|
||
|
||
TryApplyGenericRemoteRenderPose(
|
||
entity,
|
||
earlyRemoteRoute,
|
||
worldPos,
|
||
p.LandblockId,
|
||
rot);
|
||
// The spatial bucket transaction runs for EVERY classification,
|
||
// including both 4a branches: it is the only site that moves an
|
||
// ordinary moving remote's draw bucket, commits its canonical
|
||
// FullCellId (the ConstraintDistance cell key), and recovers a
|
||
// pending bucket promotion.
|
||
//
|
||
// R4 fix round (2026-08-04): the parenthetical used to also claim
|
||
// this commit "feeds back as the classifier's own CommittedCellId" —
|
||
// false since D1 (this file, above): the remote classifier's
|
||
// CommittedCellId is the PRE-merge value threaded on
|
||
// AcceptedPhysicsTimestamps, never this post-routing rebucket's
|
||
// commit, which runs well after classification has already
|
||
// happened for this packet.
|
||
//
|
||
// C4 route 4b-2 review fix — this used to end "Neither 4a branch
|
||
// performs a placement, so unlike route 2 there is no committed
|
||
// placement receipt that could project this in its stead". The far
|
||
// arm DOES perform a placement now, and this call still runs ahead of
|
||
// it for every classification. That ordering is what makes the NPC
|
||
// arm's post-routing wire-cell suppression a no-op on a non-commit
|
||
// outcome (TryAdoptWireCellAfterRouting): the wire full cell is
|
||
// already canonical by the time routing starts. A COMMITTED placement
|
||
// resolves its own destination cell afterwards, which is the case the
|
||
// suppression exists for.
|
||
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;
|
||
}
|
||
|
||
// 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;
|
||
RuntimeEntityKey positionKey = positionRecord.ProjectionKey
|
||
?? throw new InvalidOperationException(
|
||
$"Position owner 0x{update.Guid:X8}/" +
|
||
$"{positionRecord.Generation} has no exact projection key.");
|
||
if (_remoteMovementObservations.TryGetValue(positionKey, out var prev))
|
||
{
|
||
float moveDist = System.Numerics.Vector3.Distance(prev.Pos, worldPos);
|
||
if (moveDist > 0.05f)
|
||
_remoteMovementObservations[positionKey] = (worldPos, now);
|
||
// else: leave old entry so "Time" = last real movement time
|
||
}
|
||
else
|
||
{
|
||
_remoteMovementObservations[positionKey] = (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 (!_liveEntities.TryGetRemoteMotionRuntime(
|
||
update.Guid,
|
||
out IRuntimeRemoteMotion? remoteRuntime)
|
||
|| remoteRuntime is not RemoteMotion rmState)
|
||
{
|
||
rmState =
|
||
_liveEntities.GetOrCreateRemoteMotionRuntime(
|
||
update.Guid);
|
||
// 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;
|
||
// #270: retail spawn placement — establish real ground contact
|
||
// for the fresh body (a UP-created remote that then stands
|
||
// still would otherwise stay airborne-flagged and
|
||
// contact_allows_move would refuse its action animations).
|
||
SeedRemoteSpawnPlacement(
|
||
rmState,
|
||
update.Guid,
|
||
entity,
|
||
worldPos,
|
||
update.Position.LandblockId);
|
||
}
|
||
|
||
// 4a-family correction (2026-08-04, found and reported while
|
||
// pinning C4 route 5's D-P5 no-velocity design): the previous
|
||
// comment here claimed "MoveOrTeleport installs that exact vector
|
||
// with set_velocity". A byte-level disassembly of the PDB-paired
|
||
// binary (0x00516330-0x00516438, every branch) shows
|
||
// MoveOrTeleport never reads its velocity argument's stack slot,
|
||
// and UnpackPositionEvent performs no set_velocity either — the
|
||
// only set_velocity in the whole accepted-Position chain zeroes
|
||
// the LOCAL player (@0x004541B4). This call's actual retail
|
||
// justification is therefore NOT yet established and needs its
|
||
// own audit; what IS still true and unaffected by that finding:
|
||
// the canonical seam below wakes the retained ObjectClock and
|
||
// body in one operation, and the Position-delta velocity further
|
||
// down remains animation diagnostics, 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;
|
||
}
|
||
|
||
// C4 route 4b-3 / D4: retail's single ConstrainTo arming site
|
||
// (@0x00454272) is now entirely post-operation
|
||
// (TryArmConstraintAfterOperation, called once per arm below,
|
||
// after routing). The legacy pre-operation call that used to sit
|
||
// here — unconditional arming ahead of the teleport/cell-less
|
||
// branch this method now dispatches through
|
||
// ApplyRemoteContactRouting — is deleted; retail's own
|
||
// teleport_hook (which the placement runs) UnConstrains before
|
||
// the post-op site re-arms, matching @0x00514F0C then
|
||
// @0x00454272.
|
||
|
||
// Bug B (2026-08-04) — [remote-slide-up]. This is the ONE point
|
||
// both remote arms pass through, and it deliberately sits AHEAD of
|
||
// the two IsAirborneNoOperation early returns below: in the
|
||
// diagnosis's Shape A (ACE reports IsGrounded == false for the
|
||
// whole slide) acdream writes nothing at all, so a line emitted
|
||
// after those returns would leave the entire slide window blank
|
||
// and NOT ESTABLISHED #1 unanswerable. `wireGrounded` is the raw
|
||
// ACE PositionFlags.IsGrounded bit for this packet.
|
||
// docs/research/2026-08-04-bug-b-remote-slide-diagnosis.md §2.
|
||
// Pure reads. TEMPORARY — strip with the probe family.
|
||
if (AcDream.Core.Physics.PhysicsDiagnostics.ShouldLogRemoteSlide(
|
||
update.Guid))
|
||
{
|
||
(int slideQueueDepth, int slideFailCount) =
|
||
rmState.Interp.DiagnosticInterpolationState;
|
||
AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteSlideUp(
|
||
guid: update.Guid,
|
||
wireGrounded: update.IsGrounded,
|
||
wireVelocity: update.Velocity,
|
||
disposition: earlyRemoteRoute is { } slideRoute
|
||
? slideRoute.Disposition.ToString()
|
||
: "unclassified",
|
||
playerDistance: _playerController is { } slideController
|
||
? System.Numerics.Vector3.Distance(
|
||
worldPos,
|
||
slideController.Position)
|
||
: null,
|
||
bodyToTarget: System.Numerics.Vector3.Distance(
|
||
rmState.Body.Position,
|
||
worldPos),
|
||
bodySnapThreshold:
|
||
RuntimeRemoteSteadyStatePosition.DiagnosticBodySnapThreshold,
|
||
willBeDrTicked: WillAdvanceRemoteMotion(update.Guid, rmState),
|
||
firstUp: rmState.LastServerPosTime <= 0.0,
|
||
airborne: rmState.Airborne,
|
||
contact: rmState.Body.InContact,
|
||
onWalkable: rmState.Body.OnWalkable,
|
||
gravity: rmState.Body.HasGravity,
|
||
bodyVelocity: rmState.Body.Velocity,
|
||
contactPlaneValid: rmState.Body.ContactPlaneValid,
|
||
contactPlaneNormalZ: rmState.Body.ContactPlane.Normal.Z,
|
||
wirePosition: worldPos,
|
||
bodyPosition: rmState.Body.Position,
|
||
interpQueueDepth: slideQueueDepth,
|
||
interpFailCount: slideFailCount);
|
||
}
|
||
|
||
// ── UNIFIED REMOTE ROUTING TAIL (OnPosition collapse, 2026-08-04)
|
||
// ────────────────────────────────────────────────────────────
|
||
// Retail CPhysicsObj::MoveOrTeleport (0x00516330) makes NO
|
||
// this==player distinction on any branch of this decision — see
|
||
// ApplyRemoteContactRouting's own doc. This collapses the former
|
||
// player-guid and NPC-guid copies of the routing tail (L.3 M2
|
||
// onward) into one guid-blind path. Two guid-conditionals
|
||
// survive, both named and justified rather than silently kept:
|
||
// • TS-44 sticky suppression (below) — an NPC-only steady-state
|
||
// gate; its own register row already describes it that way.
|
||
// • The AirborneSnap arm's interp-clear and collision-shadow
|
||
// publish (further below) — PRESERVED, not unified, because
|
||
// unifying either way would be an unauthorized behaviour
|
||
// change: #316 (filed 2026-08-04) is a real, UNMEASURED
|
||
// pre-existing player-guid defect (no shadow publish on
|
||
// landing) that this behaviour-preserving collapse must not
|
||
// fix, and the interp-clear's equivalence could not be proven
|
||
// for the steep-non-walkable-landing edge case (see the
|
||
// comment at that arm).
|
||
// nowSec is captured ONCE, shared by both guid ranges (was two
|
||
// independent DateTime.UtcNow reads before this collapse — a
|
||
// microsecond-scale skew in acdream-only bookkeeping/diagnostics).
|
||
double nowSec = (now - System.DateTime.UnixEpoch).TotalSeconds;
|
||
|
||
// Diagnostic (ACDREAM_REMOTE_VEL_DIAG=1), PLAYER-guid scope
|
||
// preserved exactly as before this collapse: 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. Once
|
||
// the grounded-tail synth-velocity install below is unified onto
|
||
// the single NPC formula, Prev/PrevServerPosTime feed nothing but
|
||
// this print — kept as an explicitly-diagnostic step, not
|
||
// extended to NPC guids (which never had it and have no reader
|
||
// for it either).
|
||
if (IsPlayerGuid(update.Guid))
|
||
{
|
||
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1"
|
||
&& rmState.LastServerPosTime > 0.0)
|
||
{
|
||
double dtServer = nowSec - 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;
|
||
}
|
||
|
||
// ── AIRBORNE NO-OP (C4 route 4a / D1) ────────────────────────────
|
||
// Retail CPhysicsObj::MoveOrTeleport (0x00516330): arg4 == 0 (the
|
||
// wire has_contact bit) falls straight to `return 0` @0x0051636D
|
||
// and writes NOTHING — not the body, not the interpolation queue,
|
||
// not the render entity, and (because ConstrainTo sits inside
|
||
// `if (MoveOrTeleport(...) != 0)` at @0x00454254) not the
|
||
// ConstraintManager leash either. AP-135's bookkeeping is
|
||
// acdream-only free-fall-sweep/first-grounded-velocity state, not
|
||
// a retail CPhysicsObj field, so it stays — now written through
|
||
// the shared helper for both guids (previously an inline copy for
|
||
// NPC, an implicit pre-write for the player).
|
||
if (RuntimeRemoteSteadyStatePosition.IsAirborneNoOperation(
|
||
earlyRemoteRoute))
|
||
{
|
||
ApplyWireAirborneLeftoverBookkeeping(
|
||
rmState, p.LandblockId, worldPos, nowSec);
|
||
return;
|
||
}
|
||
|
||
// C4 route 4b-3 (D5): the teleport/cell-less classification is
|
||
// decided AHEAD of every contact carve-out — retail decides
|
||
// @0x00516386 before ever reading arg4 (@0x0051638E). Hoisted
|
||
// here so the D2 check and the synth-velocity gate below can both
|
||
// use it, and so ApplyRemoteContactRouting's own teleport
|
||
// dispatch (inside RunRemoteArmTail, below) is the ONLY teleport
|
||
// arm site for both guids — the former player-only pre-dispatch
|
||
// block is deleted; row 1's outcome-equivalence was verified
|
||
// call-site-by-call-site by the round-2 architecture review.
|
||
bool isTeleportRoute = RuntimeRemoteTeleportPosition
|
||
.OwnsTeleportPlacement(earlyRemoteRoute);
|
||
|
||
// C4 route 4b-3 (D2): retail's return-0 shape, applied to the two
|
||
// acdream-only leftover classifications that remain wire-airborne
|
||
// (null during the login window; RejectedAuthority/RejectedData).
|
||
// Never fires for a teleport-classified route (D5 routes it ahead
|
||
// of every contact carve-out, including this one).
|
||
if (!update.IsGrounded && !isTeleportRoute)
|
||
{
|
||
ApplyWireAirborneLeftoverBookkeeping(
|
||
rmState, p.LandblockId, worldPos, nowSec);
|
||
return;
|
||
}
|
||
|
||
// C4 route 4b-3 (R3/A2 fix round 2026-08-04): retail's teleport
|
||
// branch writes NO velocity at all (contract invariant 6) — the
|
||
// hook's own CancelMoveTo already removes the moveto that would
|
||
// otherwise have suppressed a synthesized run cycle, so this
|
||
// must positively exclude the teleport route rather than rely on
|
||
// an incidental guard. This is now the SINGLE synth-velocity
|
||
// install for both guids (the former player-guid grounded-tail
|
||
// copy, which derived from the Prev pair and fed nothing but its
|
||
// own [VEL_DIAG] print, is deleted): ServerVelocity/
|
||
// HasServerVelocity have exactly two production readers —
|
||
// RemoteServerControlledVelocityCycle.Apply's internal
|
||
// IsPlayerGuid-gated return and RuntimeRemotePhysicsUpdater's
|
||
// !IsPlayerGuid-gated stale-velocity watchdog — so this write is
|
||
// observably inert for player guids (nothing reads it) and
|
||
// load-bearing for NPC guids, exactly as before the collapse.
|
||
// Reads the OLD LastServerPos/Time pair — this runs BEFORE the
|
||
// post-routing sample further below overwrites them.
|
||
if (!isTeleportRoute)
|
||
{
|
||
System.Numerics.Vector3? serverVelocity = update.Velocity;
|
||
if (serverVelocity is null && 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
|
||
{
|
||
rmState.ServerVelocity = System.Numerics.Vector3.Zero;
|
||
rmState.HasServerVelocity = false;
|
||
}
|
||
}
|
||
|
||
// R5-V3 #171 residual / TS-44 — THE ONE named surviving branch of
|
||
// this collapse (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. NOT vacuous for players:
|
||
// LiveEntityMotionRuntimeController.StickToObjectFromWire (retail
|
||
// stick_to_object 0x005127e0, the mt-0 wire sticky trailer) can
|
||
// arm sticky on ANY remote host, player remotes included — but
|
||
// the player arm has never gated on it (only the NPC steady-state
|
||
// path did), and this collapse does not widen that: the TS-44
|
||
// register row already describes exactly "an NPC-only
|
||
// steady-state gate" and stays true. 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).
|
||
bool snapSuppressedByStick = !IsPlayerGuid(update.Guid)
|
||
&& (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})"));
|
||
}
|
||
|
||
// C4 routes 4a + 4b-2 + 4b-3 collapse: the complete near/far/
|
||
// teleport/leftover decision — including the dissolved LANDING
|
||
// TRANSITION block, now the AirborneSnap arm — is the ONE shared
|
||
// entry point every remote guid calls. `arm` defaults to
|
||
// UnroutedCatchUp for the sticky-suppressed case (D4: a stuck
|
||
// NPC's leash still re-arms — see the arming site below).
|
||
RemoteContactArm arm = RemoteContactArm.UnroutedCatchUp;
|
||
if (!snapSuppressedByStick || isTeleportRoute)
|
||
{
|
||
// C4 route 4b-3 (D5): TS-44's sticky suppression does NOT
|
||
// suppress the teleport arm — retail's sticky cannot survive
|
||
// a teleport (`UnStick` is the hook's second action,
|
||
// @0x00514EEE), so a stuck NPC's teleport packet must still
|
||
// run the hook and place.
|
||
//
|
||
// R5 review fix: the currency guard (inside RunRemoteArmTail)
|
||
// sits BEFORE the leash arming — arming is a write (it stamps
|
||
// rmState.Host's PositionManager), and nothing may be written
|
||
// through a superseded owner.
|
||
RemoteContactRouting? routing = RunRemoteArmTail(
|
||
acceptedPositionCanonical,
|
||
positionRecord,
|
||
rmState,
|
||
earlyRemoteRoute,
|
||
update.Guid,
|
||
worldPos,
|
||
rot,
|
||
acceptedPositionAuthorityVersion,
|
||
entity);
|
||
if (routing is null)
|
||
return;
|
||
arm = routing.Value.Arm;
|
||
|
||
if (arm is RemoteContactArm.AirborneSnap)
|
||
{
|
||
// PRESERVED (rows 2a/2b of the collapse contract — NOT
|
||
// unified either way). This is the dissolved LANDING
|
||
// TRANSITION scenario: a grounded wire packet for a body
|
||
// with no contact plane. ApplyRemoteContactRouting's
|
||
// free-flight carve-out already wrote
|
||
// rmState.Body.Position/Orientation to the landing pose;
|
||
// ToConstraintArm(AirborneSnap) => NearInterpolate (the
|
||
// A1 fix) supplies the same arm value the old landing
|
||
// block hard-coded.
|
||
//
|
||
// #316 (filed 2026-08-04, deliberately NOT fixed here):
|
||
// the player-guid copy of this scenario has never
|
||
// published the collision shadow (the tail below does,
|
||
// for every OTHER arm and for this SAME arm on NPC
|
||
// guids) — a real, UNMEASURED pre-existing defect that
|
||
// contradicts the file's own #184 Slice 2b design intent
|
||
// ("player shadows now follow the resolved body ...
|
||
// exactly like NPCs"). Fixing it is a behaviour change
|
||
// this collapse may not make; the skip is reproduced
|
||
// verbatim at the tail below, keyed on this same `arm`
|
||
// value.
|
||
//
|
||
// The interp-queue clear is preserved alongside it rather
|
||
// than unified either way. AdjustOffset's CONTACT_TS gate
|
||
// (InterpolationManager.AdjustOffset: `if (!inContact)
|
||
// return ...;`, before EVER touching the queue) proves a
|
||
// populated queue is inert on an ordinary flat/walkable
|
||
// landing regardless of whether it was cleared here — the
|
||
// per-tick sweep's own `!previousOnWalkable &&
|
||
// finalOnWalkable` edge (AP-139) clears it in the SAME
|
||
// tick that first reopens the CONTACT gate. But that
|
||
// per-tick clear is keyed to WALKABLE, not CONTACT, while
|
||
// AdjustOffset's gate is keyed to CONTACT — so a body
|
||
// that gains CONTACT on a non-walkable steep face (Bug
|
||
// B's own scenario: a remote landing on a roof) would
|
||
// have AdjustOffset's gate reopen one tick before the
|
||
// WALKABLE-keyed clear would ever fire, and a populated
|
||
// pre-arc queue would then be walked with stale
|
||
// waypoints. That edge case's equivalence could not be
|
||
// proven from either direction in the time this slice
|
||
// budgeted; shipping either "always clear" (a behaviour
|
||
// change for NPCs) or "never clear" (a behaviour change
|
||
// for players) on the strength of an incomplete proof is
|
||
// exactly what the contract forbids (§10 stop condition
|
||
// 2). Preserving both guids' exact pre-collapse behaviour
|
||
// is the safe outcome.
|
||
if (IsPlayerGuid(update.Guid))
|
||
{
|
||
rmState.Interp.Clear();
|
||
}
|
||
|
||
// Resolved to UNIFY (idempotent + strictly additive):
|
||
// EnsureRemoteMotionBindings early-returns once rm.Host/
|
||
// rm.Sink are already bound
|
||
// (LiveEntityMotionRuntimeController.
|
||
// EnsureRemoteMotionBindings:73), so "always ensure" is
|
||
// safe for both guids. The motion bindings still have to
|
||
// exist before the next per-tick commit can dispatch
|
||
// this remote's ground edge; an NPC that reaches this arm
|
||
// with no prior UM/OnVector binding had the identical
|
||
// latent gap the player copy already worked around.
|
||
if (_animatedEntities.TryGetValue(entity.Id, out var aeForLand)
|
||
&& aeForLand.Sequencer is not null)
|
||
{
|
||
_motionRuntime.EnsureRemoteMotionBindings(
|
||
rmState, aeForLand, update.Guid);
|
||
}
|
||
|
||
// Bug A investigation (2026-08-04, docs/ISSUES.md #32):
|
||
// the packet-side half of the landing capture, now fired
|
||
// for both guids (diagnostic-only — TEMPORARY, strip with
|
||
// the probe family; not behaviour).
|
||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeRemoteLandingEnabled)
|
||
{
|
||
bool gravitySetForProbe = rmState.Body.HasGravity;
|
||
AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteLanding(
|
||
site: "controller",
|
||
guid: update.Guid,
|
||
airborneBefore: true,
|
||
gravitySet: gravitySetForProbe,
|
||
contact: rmState.Body.InContact,
|
||
onWalkable: rmState.Body.OnWalkable,
|
||
hasDefaultSink: rmState.Motion.DefaultSink is not null,
|
||
resolveIsOnGround: null,
|
||
sequencerStyle: aeForLand?.Sequencer?.CurrentStyle ?? 0,
|
||
sequencerMotion: aeForLand?.Sequencer?.CurrentMotion ?? 0);
|
||
if (!gravitySetForProbe)
|
||
{
|
||
AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteLandingGateNoOp(
|
||
"controller", update.Guid);
|
||
}
|
||
// Zero the sink-dispatch latches before reading them
|
||
// back — nothing at THIS site dispatches (the arming
|
||
// call lives only next to the per-tick HitGround).
|
||
AcDream.Core.Physics.PhysicsDiagnostics
|
||
.BeginRemoteLandingDispatchCapture();
|
||
AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteLandingAfter(
|
||
site: "controller",
|
||
guid: update.Guid,
|
||
hitGroundInvoked: false,
|
||
sequencerStyle: aeForLand?.Sequencer?.CurrentStyle ?? 0,
|
||
sequencerMotion: aeForLand?.Sequencer?.CurrentMotion ?? 0,
|
||
forwardCommand: rmState.Motion.InterpretedState.ForwardCommand);
|
||
}
|
||
}
|
||
}
|
||
|
||
// D2/D4: ConstrainTo arms strictly AFTER the operation, anchored
|
||
// post-move — retail arms it only once MoveOrTeleport returns
|
||
// nonzero (@0x00454254/@0x00454272), which the near, far, AND
|
||
// teleport branches all do (@0x005163BE, @0x005163E8,
|
||
// @0x00516438). This is the ONE arming site for every guid (the
|
||
// legacy pre-operation call and the two duplicated post-operation
|
||
// copies are deleted — D4); TryArmConstraintAfterOperation's own
|
||
// partition decides whether THIS arm arms. Sits OUTSIDE the
|
||
// snapSuppressedByStick gate: retail's ConstraintManager leash is
|
||
// independent of the acdream-only TS-44 suppression, so a stuck
|
||
// NPC's leash still re-arms every accepted Position (AP-138(3)'s
|
||
// one-packet unarmed residual on a superseded incarnation still
|
||
// applies — the currency guard inside RunRemoteArmTail returns
|
||
// above without reaching this call).
|
||
RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation(
|
||
ToConstraintArm(arm), rmState);
|
||
|
||
// C4 route 4b-2/4b-3: NOT after a far snap or a teleport — see
|
||
// TryAdoptWireCellAfterRouting for the suppression rule. The ONE
|
||
// post-routing wire-cell adopt site for every guid (proof
|
||
// obligation re-verified for this collapse: the constraint
|
||
// anchor reads host.Position, never rm.CellId; ApplyInterpolate,
|
||
// the free-flight carve-out, and WillAdvanceRemoteMotion do not
|
||
// read it either; RebucketLiveEntity, above, already committed
|
||
// the wire cell into RuntimeEntityRecord.FullCellId — which
|
||
// RemoteMotion.CellId's getter reads through to — before ANY of
|
||
// this method's guid branching runs, so deleting the former
|
||
// player-guid copy's redundant pre-routing write is a true
|
||
// no-op, exactly as the round-2 architecture review's call-site
|
||
// check already found for the extracted-helper case).
|
||
TryAdoptWireCellAfterRouting(rmState, arm, 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.
|
||
//
|
||
// Resolved to UNIFY: ONE post-routing sample for every guid.
|
||
// Equivalence walk (ApplyInterpolate's AP-87 backstop reads
|
||
// `firstUp = remote.LastServerPosTime <= 0.0`): on a genuine
|
||
// first UP the RemoteMotion was created THIS packet with
|
||
// Body.Position == worldPos (the creation branch above), so
|
||
// bodyToTarget == 0 regardless of which guid's firstUp timing
|
||
// applies. The (until now) NPC-timed firstUp=true path takes
|
||
// ApplyInterpolate's Snapped branch — a no-op body/orientation
|
||
// write at zero distance, an idempotent queue clear. The
|
||
// (until now) player-timed firstUp=false-but-zero-distance path
|
||
// takes InterpolationManager.Enqueue's "already-close" branch
|
||
// (also an idempotent queue clear, and an orientation write
|
||
// derived from the SAME wire orientation via
|
||
// SetHeading(o, GetHeading(o)) — read verbatim, not assumed,
|
||
// and identical for the heading-only quaternions every remote's
|
||
// wire Position carries). Sampling once, after routing, is
|
||
// therefore unobservable versus the former player-guid
|
||
// pre-routing stamp.
|
||
rmState.LastServerPos = worldPos;
|
||
rmState.LastServerPosTime = nowSec;
|
||
|
||
if (!isTeleportRoute
|
||
&& rmState.HasServerVelocity
|
||
&& !snapSuppressedByStick
|
||
&& _animatedEntities.TryGetValue(entity.Id, out var aeForVelocity))
|
||
{
|
||
// The second data-driven-not-branching survivor of this
|
||
// collapse: RemoteServerControlledVelocityCycle.Apply's own
|
||
// internal IsPlayerGuid early return (AP-80/DEV-2) is now the
|
||
// ONLY carrier of the player/NPC animation-cycle distinction
|
||
// — this call site is guid-blind. Player remotes' cycles stay
|
||
// UM-driven only per retail (L.2g S5; DEV-2 deleted); NPC/
|
||
// monster remotes keep PlanFromVelocity cycle selection from
|
||
// UP-derived velocity. Excluded for a teleport-classified
|
||
// route above (isTeleportRoute) — retail's teleport branch
|
||
// writes no velocity at all, so there is nothing here to plan
|
||
// a cycle from.
|
||
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 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 remotes (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. The ONE entity-sync + shadow-publish tail
|
||
// for every guid and every arm — except the #316-preserved
|
||
// exception: a player-guid AirborneSnap arm still commits the
|
||
// render entity from the resolved body but does NOT publish the
|
||
// shadow, matching its pre-collapse behaviour exactly (see the
|
||
// comment at that arm, above).
|
||
entity.SetPosition(rmState.Body.Position);
|
||
entity.ParentCellId = rmState.CellId;
|
||
entity.Rotation = rmState.Body.Orientation;
|
||
if (arm is not RemoteContactArm.AirborneSnap
|
||
|| !IsPlayerGuid(update.Guid))
|
||
{
|
||
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(
|
||
RuntimeTeleportDestinationAdapter.FromAcceptedPosition(
|
||
update),
|
||
timestamps.TeleportAdvanced);
|
||
}
|
||
}
|
||
|
||
internal static bool RequiresSpatialProjectionRecovery(
|
||
LiveEntityRecord record)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(record);
|
||
return record.WorldEntity is null || !record.IsSpatiallyProjected;
|
||
}
|
||
|
||
// 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.
|
||
}
|