feat(physics): port retail complete object frame pipeline

Restore the named-retail object update order across local, remote, static, projectile, animation, shadow, teleport, and effect lifetimes. Separate authoritative root commits from spatial rebucketing, preserve per-owner hook/FIFO ordering, and remove update-path allocations with exact lifecycle and residency gates.

Add deterministic conformance, adversarial lifetime, GUID-reuse, pending-cell, quaternion, timestamp, and allocation coverage. Release build is warning-free and all 6,446 tests pass with five intentional skips; retail, architecture, and adversarial reviews are clean.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-20 09:10:31 +02:00
parent 31a0889f08
commit f961d70023
77 changed files with 12513 additions and 1871 deletions

View file

@ -21,9 +21,7 @@ namespace AcDream.App.Physics;
/// <c>ResolveWithTransition</c> sweep + shadow-follows-resolved, so packed PLAYER
/// remotes de-overlap exactly like NPCs (retail <c>UpdateObjectInternal</c>
/// 0x005156b0 has no player/remote fork). The only surviving player/NPC split is
/// the omega handling (players keep the <c>ObservedOmega||seqOmega</c> world-frame
/// fallback; NPCs + airborne bodies use <c>ObservedOmega</c>-only body-frame) and
/// the <c>!IsPlayerGuid</c>-gated stale-velocity anim-cycle stop. See
/// the <c>!IsPlayerGuid</c>-gated stale-velocity animation-cycle stop. See
/// <c>docs/research/2026-07-07-184-slice2-unify-extract-handoff.md</c>.</para>
///
/// <para>Shared helpers that GameWindow also calls elsewhere are injected:
@ -68,33 +66,87 @@ internal sealed class RemotePhysicsUpdater
AcDream.App.World.LiveEntityRuntime liveEntities,
uint localPlayerServerGuid,
float dt,
System.Action<AcDream.Core.World.WorldEntity> publishRootPose)
System.Action<AcDream.Core.World.WorldEntity> publishRootPose,
System.Action<uint, AcDream.Core.Physics.AnimationSequencer>?
processAnimationHooks = null,
System.Action<uint>? markPartPoseDirty = null)
{
ArgumentNullException.ThrowIfNull(liveEntities);
ArgumentNullException.ThrowIfNull(publishRootPose);
System.Numerics.Vector3? playerPosition = null;
if (localPlayerServerGuid != 0
&& liveEntities.TryGetWorldEntity(
localPlayerServerGuid,
out AcDream.Core.World.WorldEntity playerEntity))
{
playerPosition = playerEntity.Position;
}
liveEntities.CopySpatialRemoteMotionRecordsTo(_spatialRemoteSnapshot);
foreach (AcDream.App.World.LiveEntityRecord record in _spatialRemoteSnapshot)
{
if (record.ServerGuid == localPlayerServerGuid
|| (record.FinalPhysicsState & AcDream.Core.Physics.PhysicsStateFlags.Hidden) == 0
|| !liveEntities.ShouldAdvanceRootRuntime(record.ServerGuid)
|| record.RemoteMotionRuntime is not RemoteMotion remote
|| !liveEntities.IsCurrentSpatialRemoteMotion(record, remote)
|| record.WorldEntity is not { } entity)
continue;
System.Action? handlePartArray = record.AnimationRuntime is AnimatedEntity
{ Sequencer: { } sequencer }
? sequencer.Manager.UseTime
: null;
TickHidden(remote, entity, dt, handlePartArray);
AnimatedEntity? animation = record.AnimationRuntime as AnimatedEntity;
AcDream.Core.Physics.RetailObjectActivityResult activity =
AcDream.Core.Physics.RetailObjectActivityGate.Evaluate(
record.ObjectClock,
remote.Body,
liveEntities.GetRootObjectClockDisposition(record.ServerGuid)
is AcDream.Core.Physics.RetailObjectClockDisposition.Advance,
hasPartArray: record.HasPartArray,
isStatic: (record.FinalPhysicsState
& AcDream.Core.Physics.PhysicsStateFlags.Static) != 0,
entity.Position,
playerPosition,
dt);
if (activity is not AcDream.Core.Physics.RetailObjectActivityResult.Active)
continue;
AcDream.Core.Physics.AnimationSequencer? sequencer = animation?.Sequencer;
ulong objectClockEpoch = record.ObjectClockEpoch;
AcDream.Core.Physics.RetailObjectQuantumBatch batch =
record.ObjectClock.Advance(dt);
for (int qi = 0; qi < batch.Count; qi++)
{
if (!liveEntities.IsCurrentSpatialRemoteMotion(record, remote)
|| !ReferenceEquals(record.WorldEntity, entity)
|| record.ObjectClockEpoch != objectClockEpoch)
break;
if (!TickHidden(
remote,
entity,
batch.GetQuantum(qi),
sequencer?.Manager,
processAnimationHooks,
sequencer,
liveEntities,
record,
objectClockEpoch))
{
break;
}
}
// PositionManager callbacks can rebucket, delete, or replace this
// GUID. Publish only while the captured record/runtime pair still
// owns a loaded spatial projection.
if (liveEntities.IsCurrentSpatialRemoteMotion(record, remote)
&& ReferenceEquals(record.WorldEntity, entity))
if (batch.Count > 0
&& liveEntities.IsCurrentSpatialRemoteMotion(record, remote)
&& ReferenceEquals(record.WorldEntity, entity)
&& record.ObjectClockEpoch == objectClockEpoch)
{
// Hidden suppresses CPartArray::Update, but HandleEnterWorld
// may already have replaced the current sequence pose. Tell
// the presentation pass to sample that retained pose once for
// this admitted object quantum rather than rebuilding it on
// every render frame.
markPartPoseDirty?.Invoke(record.ServerGuid);
publishRootPose(entity);
}
}
@ -106,28 +158,75 @@ internal sealed class RemotePhysicsUpdater
/// body. <c>serverGuid</c> + the entity id derive from
/// <paramref name="ae"/>.Entity; <paramref name="liveCenterX"/>/<paramref name="liveCenterY"/>
/// are passed per-call (they change on streaming recentre — never snapshot
/// them in the constructor). <paramref name="rootMotionLocalDelta"/> is
/// the complete local displacement produced by this frame's preceding
/// them in the constructor). <paramref name="rootMotionLocalFrame"/> is
/// the complete local Frame produced by this frame's preceding
/// <c>CSequence::update</c>, matching retail's
/// <c>CPartArray::Update → PositionManager::adjust_offset</c> order.
/// </summary>
public void Tick(
public bool Tick(
RemoteMotion rm,
AnimatedEntity ae,
float dt,
System.Numerics.Vector3 rootMotionLocalDelta,
AcDream.Core.Physics.Motion.MotionDeltaFrame rootMotionLocalFrame,
int liveCenterX,
int liveCenterY)
int liveCenterY,
System.Action<uint, AcDream.Core.Physics.AnimationSequencer>?
processAnimationHooks = null)
=> Tick(
rm,
ae.Entity,
ae.Scale,
ae.Sequencer,
ae,
dt,
rootMotionLocalFrame,
liveCenterX,
liveCenterY,
processAnimationHooks);
/// <summary>
/// Canonical ordinary-object tick. Render animation is optional: retail
/// walks <c>CPhysics::object_maint</c>, so a live object with a
/// MovementManager or PositionManager must continue even when it has no
/// <c>AnimatedEntity</c> presentation component.
/// </summary>
public bool Tick(
RemoteMotion rm,
AcDream.Core.World.WorldEntity entity,
float objectScale,
AcDream.Core.Physics.AnimationSequencer? sequencer,
AnimatedEntity? animationForVelocityCycle,
float dt,
AcDream.Core.Physics.Motion.MotionDeltaFrame rootMotionLocalFrame,
int liveCenterX,
int liveCenterY,
System.Action<uint, AcDream.Core.Physics.AnimationSequencer>?
processAnimationHooks = null,
AcDream.App.World.LiveEntityRuntime? ownerRuntime = null,
AcDream.App.World.LiveEntityRecord? ownerRecord = null,
ulong ownerClockEpoch = 0)
{
uint serverGuid = ae.Entity.ServerGuid;
ArgumentNullException.ThrowIfNull(rm);
ArgumentNullException.ThrowIfNull(entity);
ArgumentNullException.ThrowIfNull(rootMotionLocalFrame);
if (!IsCurrentOwner(
ownerRuntime,
ownerRecord,
rm,
entity,
ownerClockEpoch))
{
return false;
}
uint serverGuid = entity.ServerGuid;
// #184 Slice 2b — the UNIFIED per-remote tick. The former Path A
// (grounded PLAYER remotes: interp catch-up with the ResolveWithTransition
// sweep OMITTED, per the now-retired issue-#40 "collision is the sender's
// problem" premise) is GONE — every remote now runs the SAME catch-up +
// sweep + shadow-follows-resolved, so packed PLAYER remotes de-overlap
// exactly like NPCs. Retail's UpdateObjectInternal (0x005156b0) has NO
// player/remote fork; the only surviving player/NPC split is the omega
// handling (Step 2 below) and the !IsPlayerGuid-gated anim-cycle stop.
// player/remote fork; only the stale animation-cycle stop below remains
// player/NPC-specific.
//
// Stop detection stays explicit on packet receipt (UpdateMotion
// ForwardCommand cleared -> Ready; UpdatePosition HasVelocity cleared ->
@ -143,8 +242,8 @@ internal sealed class RemotePhysicsUpdater
// remotes below are explicitly OnWalkable and airborne remotes use
// their authoritative velocity/gravity arc, so this is the same
// branch expressed through our retained runtime state.
System.Numerics.Vector3 scaledRootMotionLocalDelta = !rm.Airborne
? rootMotionLocalDelta * ae.Scale
System.Numerics.Vector3 scaledRootMotionLocalOrigin = !rm.Airborne
? rootMotionLocalFrame.Origin * objectScale
: System.Numerics.Vector3.Zero;
// Step 1: re-apply current motion commands → body.Velocity.
@ -199,11 +298,14 @@ internal sealed class RemotePhysicsUpdater
{
rm.ServerVelocity = System.Numerics.Vector3.Zero;
rm.HasServerVelocity = false;
_applyServerControlledVelocityCycle(
serverGuid,
ae,
rm,
System.Numerics.Vector3.Zero);
if (animationForVelocityCycle is not null)
{
_applyServerControlledVelocityCycle(
serverGuid,
animationForVelocityCycle,
rm,
System.Numerics.Vector3.Zero);
}
}
}
@ -223,58 +325,10 @@ internal sealed class RemotePhysicsUpdater
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active;
}
// Step 2: integrate rotation manually per tick. We can't
// rely on PhysicsBody.update_object here — its MinQuantum
// gate (1/30 s) causes it to SKIP integration when our
// 60fps render dt (~0.016s) is below the quantum, meaning
// rotation never advances. Measured snap per UP was ~129°
// = the full expected 1s × 2.24 rad/s, confirming zero
// between-tick rotation.
//
// Manual integration matches retail's FUN_005256b0
// apply_physics (Orientation *= quat(ω × dt)). Use
// ObservedOmega derived from server UP rotation deltas so
// the rate exactly matches server physics — hard-snap on
// next UP becomes invisible by construction.
// #184 Slice 2b: PLAYERS keep the ObservedOmega||seqOmega fallback +
// world-frame (pre-multiply, Concatenate) application inherited from the
// former Path A — a circling player sends RunForward+TurnLeft on ONE UM
// whose RunForward cycle synthesises zero omega, so ObservedOmega (from
// the wire TurnCommand) must carry the turn or the body would not rotate
// between UPs ("rectangle when running circles"). NPCs + AIRBORNE bodies
// keep ObservedOmega-only, body-frame (post-multiply, Multiply) — a
// seqOmega fallback would change NPC turning (handoff 4.1), so the split
// is preserved. For an upright body + a yaw (world-Z) omega the two
// multiplication orders commute, so this fork is faithful, not cosmetic.
// calc_acceleration zeroes Body.Omega for grounded bodies before
// UpdatePhysicsInternal; the explicit zero here covers the airborne case
// (a wire-set Body.Omega would otherwise double-integrate on top of the
// manual rotation).
rm.Body.Omega = System.Numerics.Vector3.Zero;
if (IsPlayerGuid(serverGuid) && !rm.Airborne)
{
System.Numerics.Vector3 seqOmega = ae.Sequencer?.CurrentOmega
?? System.Numerics.Vector3.Zero;
System.Numerics.Vector3 omegaToApply =
rm.ObservedOmega.LengthSquared() > 1e-9f ? rm.ObservedOmega : seqOmega;
if (omegaToApply.LengthSquared() > 1e-9f)
{
float angleDelta = omegaToApply.Length() * (float)dt;
System.Numerics.Vector3 axis = System.Numerics.Vector3.Normalize(omegaToApply);
var rot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angleDelta);
rm.Body.Orientation = System.Numerics.Quaternion.Normalize(
System.Numerics.Quaternion.Concatenate(rm.Body.Orientation, rot));
}
}
else if (rm.ObservedOmega.LengthSquared() > 1e-8f)
{
float omegaMag = rm.ObservedOmega.Length();
var axis = rm.ObservedOmega / omegaMag;
float angle = omegaMag * dt;
var deltaRot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angle);
rm.Body.Orientation = System.Numerics.Quaternion.Normalize(
System.Numerics.Quaternion.Multiply(rm.Body.Orientation, deltaRot));
}
// Step 2: CSequence's complete Frame carries motion-table omega through
// the same compose as root translation. PhysicsBody.Omega remains
// reserved for the object's physical angular velocity and is
// integrated once by UpdatePhysicsInternal below.
// Step 3: integrate physics — retail FUN_005111D0
// UpdatePhysicsInternal. Pure Euler:
@ -289,11 +343,10 @@ internal sealed class RemotePhysicsUpdater
// from the UP hard-snap, producing a visible teleport-stride
// on slopes (the "staircase" the user reported).
//
// PlayerMovementController.cs:358 calls UpdatePhysicsInternal
// directly for the same reason. Remote motion mirrors that.
// Omega is already integrated manually above, so we zero it
// here to prevent UpdatePhysicsInternal's own omega pass from
// double-integrating.
// PlayerMovementController calls UpdatePhysicsInternal directly
// for the same reason. Remote motion mirrors that. CSequence's
// authored orientation has already entered the shared delta Frame;
// Body.Omega remains the separate physical angular-velocity source.
var preIntegratePos = rm.Body.Position;
// R5-V3 (#171) + #184 (2026-07-07): retail chains Interpolation →
// Sticky over ONE shared delta frame (PositionManager::adjust_offset
@ -312,54 +365,73 @@ internal sealed class RemotePhysicsUpdater
// adds no translation on top of the catch-up — no double-move.
if (rm.Host is { } npcHost)
{
AcDream.Core.Physics.Motion.MotionDeltaFrame pmDelta;
if (!rm.Airborne)
{
float maxSpeedNpc = rm.Motion.GetMaxSpeed();
System.Numerics.Vector3? terrainNormalNpc =
_physicsEngine.SampleTerrainNormal(
rm.Body.Position.X, rm.Body.Position.Y);
System.Numerics.Vector3 offsetNpc = rm.Position.ComputeOffset(
dt: (double)dt,
currentBodyPosition: rm.Body.Position,
rootMotionLocalDelta: scaledRootMotionLocalDelta,
ori: rm.Body.Orientation,
interp: rm.Interp,
maxSpeed: maxSpeedNpc,
terrainNormal: terrainNormalNpc);
pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame
{
Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec(
rm.Body.Orientation, offsetNpc),
};
}
else
{
pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame();
}
AcDream.Core.Physics.Motion.MotionDeltaFrame pmDelta =
rm.PositionManagerDeltaScratch;
pmDelta.Origin = scaledRootMotionLocalOrigin;
pmDelta.Orientation = rootMotionLocalFrame.Orientation;
float maxSpeedNpc = rm.Motion.GetMaxSpeed();
System.Numerics.Vector3? terrainNormalNpc = !rm.Airborne
? _physicsEngine.SampleTerrainNormal(
rm.Body.Position.X,
rm.Body.Position.Y)
: null;
rm.Position.ComposeOffset(
dt,
rm.Body.Position,
rm.Body.Orientation,
pmDelta,
rm.Interp,
maxSpeedNpc,
pmDelta,
terrainNormalNpc,
inContact: rm.Body.InContact);
npcHost.PositionManager.AdjustOffset(pmDelta, dt);
ApplyPositionManagerDelta(rm.Body, pmDelta);
}
else if (!rm.Airborne)
else
{
// No PositionManager host yet (pre-binding): apply the catch-up
// directly, matching Path A's fallback (:10202).
// Airborne suppresses only CPartArray Origin. Retail keeps the
// complete root orientation live across the OnWalkable scale
// gate in UpdatePositionInternal (0x00512C30).
AcDream.Core.Physics.Motion.MotionDeltaFrame pmDelta =
rm.PositionManagerDeltaScratch;
pmDelta.Origin = scaledRootMotionLocalOrigin;
pmDelta.Orientation = rootMotionLocalFrame.Orientation;
float maxSpeedNpc = rm.Motion.GetMaxSpeed();
System.Numerics.Vector3? terrainNormalNpc =
_physicsEngine.SampleTerrainNormal(
rm.Body.Position.X, rm.Body.Position.Y);
rm.Body.Position += rm.Position.ComputeOffset(
dt: (double)dt,
currentBodyPosition: rm.Body.Position,
rootMotionLocalDelta: scaledRootMotionLocalDelta,
ori: rm.Body.Orientation,
interp: rm.Interp,
maxSpeed: maxSpeedNpc,
terrainNormal: terrainNormalNpc);
System.Numerics.Vector3? terrainNormalNpc = !rm.Airborne
? _physicsEngine.SampleTerrainNormal(
rm.Body.Position.X,
rm.Body.Position.Y)
: null;
rm.Position.ComposeOffset(
dt,
rm.Body.Position,
rm.Body.Orientation,
pmDelta,
rm.Interp,
maxSpeedNpc,
pmDelta,
terrainNormalNpc,
inContact: rm.Body.InContact);
ApplyPositionManagerDelta(rm.Body, pmDelta);
}
rm.Body.calc_acceleration();
rm.Body.UpdatePhysicsInternal(dt);
if (sequencer is { } hookSequencer)
processAnimationHooks?.Invoke(entity.Id, hookSequencer);
if (!IsCurrentOwner(
ownerRuntime,
ownerRecord,
rm,
entity,
ownerClockEpoch))
{
return false;
}
var postIntegratePos = rm.Body.Position;
uint committedCellId = rm.CellId;
// Step 4: collision sweep — retail FUN_00514B90 →
// FUN_005148A0 → Transition::FindTransitionalPosition.
@ -399,7 +471,7 @@ internal sealed class RemotePhysicsUpdater
// Fallback to the human capsule for a shapeless / unresolvable
// Setup (GetSetupCylinder returns (0,0)); a zero radius would
// degenerate the sweep.
var (deR, deH) = _getSetupCylinder(serverGuid, ae.Entity);
var (deR, deH) = _getSetupCylinder(serverGuid, entity);
if (deR < 0.05f) { deR = 0.48f; deH = 1.835f; }
var resolveResult = _physicsEngine.ResolveWithTransition(
preIntegratePos, postIntegratePos, rm.CellId,
@ -442,11 +514,11 @@ internal sealed class RemotePhysicsUpdater
// the remote's own cylinder and produces ~1m of
// horizontal drift on the first jump frame
// (validated by [SWEEP-OBJ] traces).
movingEntityId: ae.Entity.Id);
movingEntityId: entity.Id);
rm.Body.Position = resolveResult.Position;
if (resolveResult.CellId != 0)
rm.CellId = resolveResult.CellId;
committedCellId = resolveResult.CellId;
// #184 (2026-07-07) — SHADOW-FOLLOWS-RESOLVED (the load-bearing
// de-overlap fix, proven in RemoteDeOverlapMechanismTests). Retail
@ -460,9 +532,9 @@ internal sealed class RemotePhysicsUpdater
// player would collide with a shadow offset from where the monster
// renders (the reverted attempt's "stuck on an invisible monster").
// Syncing the shadow to the resolved body every tick makes the
// de-overlap PERSIST and keeps collision == render. Re-flood is cheap
// MOVEMENT-GATED (#184 review): re-flood only when the resolved
// body moved > ~1 cm since the last shadow registration. This is
// de-overlap PERSIST and keeps collision == render. Re-flood is
// POSE/CELL-GATED (#184 review): re-flood only when the resolved
// body moved > ~1 cm, rotated, or entered another cell. This is
// SAFE now that #184 Slice 2b RETIRED the per-UP raw-pos sync for
// players too — every remote's shadow (player + NPC) is written ONLY
// by this loop + the UP-branch tail, both to the resolved body, so a
@ -473,12 +545,6 @@ internal sealed class RemotePhysicsUpdater
// to actually-moving remotes — the perf risk the review flagged for
// a packed town. (In-place shadow-move + cell-relink-on-change is a
// further optimization if profiling still shows churn.)
if (System.Numerics.Vector3.DistanceSquared(
rm.Body.Position, rm.LastShadowSyncPos) > 1e-4f)
{
SyncRemoteShadowToBody(ae.Entity.Id, rm, liveCenterX, liveCenterY);
}
// #173 (2026-07-05): retail CPhysicsObj::handle_all_collisions
// (pc:282699-282715) runs after EVERY SetPositionInternal —
// remote objects included; a VectorUpdate-launched jump arc
@ -578,21 +644,69 @@ internal sealed class RemotePhysicsUpdater
// airborne UseTime contact gate; without it a
// chasing NPC that lands stalls until ACE's
// ~1 Hz re-emit.
ulong landingStateAuthorityVersion =
ownerRecord?.StateAuthorityVersion ?? 0UL;
rm.Movement.HitGround();
if (!IsCurrentOwner(
ownerRuntime,
ownerRecord,
rm,
entity,
ownerClockEpoch))
{
return false;
}
// DR bookkeeping only (partner of the jump-start
// `State |= Gravity`): stops the per-tick gravity
// integration for the grounded body.
rm.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity;
if (ownerRecord is null
|| ownerRecord.StateAuthorityVersion
== landingStateAuthorityVersion)
{
rm.Body.State &=
~AcDream.Core.Physics.PhysicsStateFlags.Gravity;
}
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
Console.WriteLine($"VU.land guid=0x{serverGuid:X8} Z={rm.Body.Position.Z:F2}");
}
}
ae.Entity.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty
if (rm.CellId != 0)
ae.Entity.ParentCellId = rm.CellId;
ae.Entity.Rotation = rm.Body.Orientation;
// SetPositionInternal commits the resolved body/contact result and
// root frame as one object before changing cell membership. The
// canonical CellId writer can synchronously rebucket loaded to
// pending, delete, or replace this GUID, so it is the transaction's
// final callback boundary before shadow publication.
entity.SetPosition(rm.Body.Position);
entity.ParentCellId = committedCellId;
entity.Rotation = rm.Body.Orientation;
bool cellChanged = committedCellId != 0
&& committedCellId != rm.CellId;
if (cellChanged)
rm.CellId = committedCellId;
if (!IsCurrentOwner(
ownerRuntime,
ownerRecord,
rm,
entity,
ownerClockEpoch))
{
return false;
}
// #184: shadow follows the resolved body, but only after the
// canonical rebucket proves this exact owner is still spatially
// resident. A pending destination keeps its retained registration
// suspended; a callback-created replacement owns another local ID.
if (ShouldSynchronizeShadow(
cellChanged,
rm.Body.Position,
rm.Body.Orientation,
rm.LastShadowSyncPos,
rm.LastShadowSyncOrientation))
{
SyncRemoteShadowToBody(entity.Id, rm, liveCenterX, liveCenterY);
}
}
// R5-V3 (#171): retail UpdateObjectInternal tail —
@ -602,21 +716,17 @@ internal sealed class RemotePhysicsUpdater
// sticky 1 s lease watchdog (StickyManager::UseTime
// 0x00555610 — a stick not re-issued by a fresh server arm
// within 1 s tears itself down). No-op while nothing is stuck.
System.Action? handleTargeting = rm.Host is { } targetHost
? targetHost.HandleTargetting
: null;
System.Action? partArrayHandleMovement = ae.Sequencer is { } sequencer
? sequencer.Manager.UseTime
: null;
System.Action? positionUseTime = rm.Host is { } positionHost
? positionHost.PositionManager.UseTime
: null;
AcDream.Core.Physics.RetailObjectManagerTail.Run(
checkDetection: null,
handleTargeting,
movementUseTime: rm.Movement.UseTime,
partArrayHandleMovement,
positionUseTime);
rm.Host?.TargetManager,
rm.Movement,
sequencer?.Manager,
rm.Host?.PositionManager);
return IsCurrentOwner(
ownerRuntime,
ownerRecord,
rm,
entity,
ownerClockEpoch);
}
/// <summary>
@ -628,37 +738,69 @@ internal sealed class RemotePhysicsUpdater
/// position managers still consume time. Physics-script and particle owners
/// tick later in the shared frame pipeline.
/// </summary>
public void TickHidden(
public bool TickHidden(
RemoteMotion rm,
AcDream.Core.World.WorldEntity entity,
float dt,
System.Action? partArrayHandleMovement = null)
AcDream.Core.Physics.Motion.MotionTableManager?
partArrayHandleMovement = null,
System.Action<uint, AcDream.Core.Physics.AnimationSequencer>?
processAnimationHooks = null,
AcDream.Core.Physics.AnimationSequencer? sequencer = null,
AcDream.App.World.LiveEntityRuntime? ownerRuntime = null,
AcDream.App.World.LiveEntityRecord? ownerRecord = null,
ulong ownerClockEpoch = 0)
{
ArgumentNullException.ThrowIfNull(rm);
ArgumentNullException.ThrowIfNull(entity);
if (!IsCurrentOwner(
ownerRuntime,
ownerRecord,
rm,
entity,
ownerClockEpoch))
{
return false;
}
System.Numerics.Vector3 preComposePosition = rm.Body.Position;
// The part-array contribution is the identity frame while Hidden.
// Interpolation is the first PositionManager stage in retail; acdream
// retains it in RemoteMotionCombiner, ahead of Sticky/Constraint.
System.Numerics.Vector3 interpolationOffset = rm.Position.ComputeOffset(
AcDream.Core.Physics.Motion.MotionDeltaFrame positionDelta =
rm.PositionManagerDeltaScratch;
positionDelta.Reset();
rm.Position.ComposeOffset(
dt,
rm.Body.Position,
System.Numerics.Vector3.Zero,
rm.Body.Orientation,
positionDelta,
rm.Interp,
rm.Motion.GetMaxSpeed());
var positionDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame
{
Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec(
rm.Body.Orientation,
interpolationOffset),
};
rm.Motion.GetMaxSpeed(),
positionDelta,
inContact: rm.Body.InContact);
rm.Host?.PositionManager.AdjustOffset(positionDelta, dt);
ApplyPositionManagerDelta(rm.Body, positionDelta);
// Hidden suppresses CPartArray::Update, but process_hooks remains the
// final UpdatePositionInternal step. Drain any hook already pending on
// the retained sequence before transition/manager time, exactly like
// the visible path above.
if (sequencer is not null)
processAnimationHooks?.Invoke(entity.Id, sequencer);
if (!IsCurrentOwner(
ownerRuntime,
ownerRecord,
rm,
entity,
ownerClockEpoch))
{
return false;
}
System.Numerics.Vector3 composedPosition = rm.Body.Position;
uint committedCellId = rm.CellId;
if (rm.CellId != 0
&& composedPosition != preComposePosition
&& _physicsEngine.LandblockCount > 0)
@ -689,8 +831,8 @@ internal sealed class RemotePhysicsUpdater
movingEntityId: entity.Id);
rm.Body.Position = resolved.Position;
if (resolved.CellId != 0)
rm.CellId = resolved.CellId;
AcDream.Core.Physics.PhysicsObjUpdate.CommitSetPositionTransition(
committedCellId = resolved.CellId;
if (!AcDream.Core.Physics.PhysicsObjUpdate.CommitSetPositionTransition(
rm.Body,
resolved.InContact,
resolved.OnWalkable,
@ -699,39 +841,71 @@ internal sealed class RemotePhysicsUpdater
previousContact,
previousOnWalkable,
rm.Movement.HitGround,
rm.Motion.LeaveGround);
rm.Motion.LeaveGround,
() => IsCurrentOwner(
ownerRuntime,
ownerRecord,
rm,
entity,
ownerClockEpoch)))
{
return false;
}
rm.Airborne = !rm.Body.OnWalkable;
}
// Hidden suppresses mesh/part updates, not SetPositionInternal. Commit
// the resolved root before the canonical cell writer enters the same
// re-entrant rebucket boundary as the visible path.
entity.SetPosition(rm.Body.Position);
if (rm.CellId != 0)
entity.ParentCellId = rm.CellId;
entity.ParentCellId = committedCellId;
entity.Rotation = rm.Body.Orientation;
if (committedCellId != 0 && committedCellId != rm.CellId)
rm.CellId = committedCellId;
if (!IsCurrentOwner(
ownerRuntime,
ownerRecord,
rm,
entity,
ownerClockEpoch))
{
return false;
}
System.Action? handleTargeting = rm.Host is { } targetHost
? targetHost.HandleTargetting
: null;
System.Action? positionUseTime = rm.Host is { } positionHost
? positionHost.PositionManager.UseTime
: null;
AcDream.Core.Physics.RetailObjectManagerTail.Run(
checkDetection: null,
handleTargeting,
movementUseTime: rm.Movement.UseTime,
rm.Host?.TargetManager,
rm.Movement,
partArrayHandleMovement,
positionUseTime);
rm.Host?.PositionManager);
return IsCurrentOwner(
ownerRuntime,
ownerRecord,
rm,
entity,
ownerClockEpoch);
}
private static bool IsCurrentOwner(
AcDream.App.World.LiveEntityRuntime? ownerRuntime,
AcDream.App.World.LiveEntityRecord? ownerRecord,
RemoteMotion remote,
AcDream.Core.World.WorldEntity entity,
ulong ownerClockEpoch) =>
ownerRuntime is null
|| ownerRecord is null
|| (ownerRecord.ObjectClockEpoch == ownerClockEpoch
&& ownerRuntime.IsCurrentSpatialRemoteMotion(ownerRecord, remote)
&& ReferenceEquals(ownerRecord.WorldEntity, entity));
/// <summary>
/// R5-V3 (#171): apply a <see cref="AcDream.Core.Physics.Motion.MotionDeltaFrame"/>
/// written by <c>PositionManager.AdjustOffset</c> onto a body — acdream's
/// stand-in for retail's <c>Frame::combine</c> in
/// <c>CPhysicsObj::UpdatePositionInternal</c> (0x00512c30, combine
/// @0x00512d22). The delta's Origin is mover-LOCAL (sticky writes
/// <c>globaltolocalvec</c> output — 0x00555430), so combining = rotating it
/// out by the body orientation. An untouched (identity) rotation means "no
/// turn"; the P5 pin (identity quaternion = heading 0) makes compass addition
/// the exact frame-combine here. Moved from GameWindow (#184 Slice 2a); the
/// <c>globaltolocalvec</c> output — 0x00555430), so combining rotates it
/// out by the body's current orientation and post-multiplies the complete
/// relative orientation. Moved from GameWindow (#184 Slice 2a); the
/// DR tick is its only caller.
/// </summary>
private static void ApplyPositionManagerDelta(
@ -741,10 +915,10 @@ internal sealed class RemotePhysicsUpdater
if (delta.Origin != System.Numerics.Vector3.Zero)
body.Position += System.Numerics.Vector3.Transform(delta.Origin, body.Orientation);
if (!delta.Orientation.IsIdentity)
body.Orientation = AcDream.Core.Physics.Motion.MoveToMath.SetHeading(
body.Orientation = AcDream.Core.Physics.Motion.FrameOps.SetRotate(
body.Position,
body.Orientation,
AcDream.Core.Physics.Motion.MoveToMath.GetHeading(body.Orientation)
+ delta.GetHeading());
body.Orientation * delta.Orientation);
}
/// <summary>
@ -756,7 +930,10 @@ internal sealed class RemotePhysicsUpdater
/// → remove/add_shadows_to_cells, Ghidra 0x00515330). The streaming centre is
/// passed in (<paramref name="liveCenterX"/>/<paramref name="liveCenterY"/>)
/// rather than snapshotted, since it moves on recentre. Updates
/// <see cref="RemoteMotion.LastShadowSyncPos"/> so callers can movement-gate.
/// <see cref="RemoteMotion.LastShadowSyncPos"/> and
/// <see cref="RemoteMotion.LastShadowSyncOrientation"/> so callers can
/// pose-gate. Rotation matters because Setup collision geometry may be
/// multipart or offset from the root.
/// Moved from GameWindow (#184 Slice 2a); called by the DR tick AND the NPC
/// UP-branch tail.
/// </summary>
@ -774,8 +951,53 @@ internal sealed class RemotePhysicsUpdater
liveCenterY,
authoritativeCellId ?? rm.CellId);
rm.LastShadowSyncPosition = rm.Body.Position;
rm.LastShadowSyncOrientation = rm.Body.Orientation;
}
internal static bool ShouldSynchronizeShadowPose(
System.Numerics.Vector3 currentPosition,
System.Numerics.Quaternion currentOrientation,
System.Numerics.Vector3 lastPosition,
System.Numerics.Quaternion lastOrientation)
{
if (System.Numerics.Vector3.DistanceSquared(
currentPosition,
lastPosition) > 1e-4f)
{
return true;
}
float currentLengthSquared = currentOrientation.LengthSquared();
float lastLengthSquared = lastOrientation.LengthSquared();
if (!float.IsFinite(currentLengthSquared)
|| !float.IsFinite(lastLengthSquared)
|| currentLengthSquared < 1e-12f
|| lastLengthSquared < 1e-12f)
{
return true;
}
float normalizedDot = MathF.Abs(
System.Numerics.Quaternion.Dot(
currentOrientation,
lastOrientation)
/ MathF.Sqrt(currentLengthSquared * lastLengthSquared));
return !float.IsFinite(normalizedDot) || normalizedDot < 0.99999f;
}
internal static bool ShouldSynchronizeShadow(
bool cellChanged,
System.Numerics.Vector3 currentPosition,
System.Numerics.Quaternion currentOrientation,
System.Numerics.Vector3 lastPosition,
System.Numerics.Quaternion lastOrientation) =>
cellChanged
|| ShouldSynchronizeShadowPose(
currentPosition,
currentOrientation,
lastPosition,
lastOrientation);
public void SyncRemoteShadowToBody(
uint entityId,
AcDream.Core.Physics.PhysicsBody body,