using AcDream.Runtime.Entities;
namespace AcDream.Runtime.Physics;
internal readonly record struct RuntimeRemotePhysicsSnapshot(
System.Numerics.Vector3 Position,
System.Numerics.Quaternion Orientation,
uint FullCellId);
///
/// #184 Slice 2a extracted the per-remote dead-reckoning physics tick
/// verbatim from the former graphical frame owner. Slice J5.5 moved that
/// presentation-free simulation under the per-session Runtime physics owner.
/// It is called once per eligible remote entity per retail object quantum,
/// preserving the same guard
/// (ae.Sequencer != null && serverGuid != 0 && serverGuid != _playerServerGuid
/// && rm.LastServerPosTime > 0).
/// Hidden remotes use a separate live-entity pass because retail keeps their
/// PositionManager alive even when the object has no render-animation owner.
///
/// Slice 2a extracted this verbatim (fork intact). Slice 2b then COLLAPSED
/// the player/NPC fork: the former Path A (grounded PLAYER remotes advanced by the
/// interp catch-up with the sweep deliberately OMITTED, per the now-retired issue
/// #40 premise) is gone — every remote now runs the SAME catch-up +
/// ResolveWithTransition sweep + shadow-follows-resolved, so packed PLAYER
/// remotes de-overlap exactly like NPCs (retail UpdateObjectInternal
/// 0x005156b0 has no player/remote fork). The only surviving player/NPC split is
/// the !IsPlayerGuid-gated stale-velocity animation-cycle stop. See
/// docs/research/2026-07-07-184-slice2-unify-extract-handoff.md.
///
/// Shared policy arrives through focused Physics delegates:
/// DAT-derived shape dimensions and animation-cycle projection arrive through
/// the App adapter in a graphical host. SyncRemoteShadowToBody
/// (remote-physics-specific) moved here and is called back from the UP-branch
/// tail; ApplyPositionManagerDelta / TickRemoteMoveTo had no other
/// callers and moved here outright.
///
internal sealed class RuntimeRemotePhysicsUpdater
{
// Preserved from #184 Slice 2a; the remote tick remains its only caller.
private const double ServerControlledVelocityStaleSeconds = 0.60;
private readonly RuntimePhysicsState _physics;
internal RuntimeRemotePhysicsUpdater(
RuntimePhysicsState physics)
{
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
}
// Retail GUID classification used only for collision flags.
private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u;
///
/// Canonical ordinary-object tick. Render animation is optional: retail
/// walks CPhysics::object_maint, so a live object with a
/// MovementManager or PositionManager must continue even when it has no
/// render-animation presentation component.
///
internal bool Tick(
RuntimeEntityRecord record,
RemoteMotion rm,
float objectScale,
AcDream.Core.Physics.AnimationSequencer? sequencer,
float dt,
ulong objectClockEpoch,
AcDream.Core.Physics.Motion.MotionDeltaFrame rootMotionLocalFrame,
float radius,
float height,
int liveCenterX,
int liveCenterY,
System.Action?
processAnimationHooks = null,
System.Action? applyStaleVelocityCycle = null,
System.Func?
acknowledgeProjection = null,
System.Func? externalOwnerValid = null,
// TS-46 (2026-07-30): the Setup's own ≤2-sphere list + Setup-derived
// step heights (LiveEntityMotionRuntimeController.GetSetupMoverShape).
// Default/empty preserves the pre-TS-46 human-capsule fallback below.
System.Collections.Immutable.ImmutableArray
sphereList = default,
float sphereScale = 1f,
float stepUpHeight = 0.4f,
float stepDownHeight = 0.4f,
// TS-23 (2026-07-30): the remote's own PK/PKLite/Impenetrable
// ObjectInfoState bits (LiveEntityMotionRuntimeController's
// ClientObjectTable-backed lookup, translated via
// EntityCollisionFlagsExt.ToMoverState). Default None is a no-op OR
// for every non-PK remote — bit-identical to the pre-P3 value.
AcDream.Core.Physics.ObjectInfoState moverPvpState =
AcDream.Core.Physics.ObjectInfoState.None)
{
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(rm);
ArgumentNullException.ThrowIfNull(rootMotionLocalFrame);
if (!IsCurrentOwner(
record,
rm,
objectClockEpoch,
externalOwnerValid))
{
return false;
}
uint serverGuid = record.ServerGuid;
// Bug B (2026-08-04): stamp the GUID for any [remote-slide-*] line
// emitted from inside this remote's synchronous tick — in particular
// blip producer Candidate 2, which fires deep inside
// InterpolationManager and has no GUID of its own. TEMPORARY — strip
// with the ACDREAM_PROBE_REMOTE_SLIDE family.
AcDream.Core.Physics.PhysicsDiagnostics.BeginRemoteSlideAttribution(
serverGuid);
uint localEntityId = record.LocalEntityId
?? throw new InvalidOperationException(
$"Runtime entity 0x{serverGuid:X8}/{record.Incarnation} has no local identity.");
// #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; 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 ->
// StopCompletely). Mirrors retail update_object -> UpdatePositionInternal
// -> UpdatePhysicsInternal (FUN_00515020 / FUN_00513730 / FUN_005111D0).
// The bare block scopes this update's locals (formerly the else body).
{
double nowSec = _physics.UtcNowSeconds;
// Retail CPhysicsObj::UpdatePositionInternal @ 0x00512C30 scales
// the CSequence root displacement by m_scale only while the body
// carries ON_WALKABLE_TS (`if ((transient_state & 2) == 0)` at
// 0x00512CA1 multiplies the accumulated root frame by 0f, the else
// arm by m_scale). Bug B (2026-08-04): read the transient the
// sweep committed, never a separately tracked client bool — the
// two disagree exactly on a steep contact, which is the surface
// this whole fix is about.
bool bodyOnWalkableAtTickStart = rm.Body.OnWalkable;
System.Numerics.Vector3 scaledRootMotionLocalOrigin =
bodyOnWalkableAtTickStart
? rootMotionLocalFrame.Origin * objectScale
: System.Numerics.Vector3.Zero;
// Bug B (2026-08-04) capture for the [remote-slide-tick] line
// below. These USED to record whether the deleted per-tick
// `TransientState |= Contact | OnWalkable` force actually flipped a
// clear bit. The force is gone (see the block comment below), so
// they now simply report the transient state the body ENTERED this
// tick with — the INVERSE of what "forced" implied. The C# names
// are kept because the diagnosis doc quotes them, but the LOG keys
// were renamed to `entryNoContact=`/`entryNoWalkable=` so a
// post-fix capture cannot be read against a pre-fix one; grep the
// new keys.
// TEMPORARY — strip with the ACDREAM_PROBE_REMOTE_SLIDE family.
bool slideForcedContact = !rm.Body.InContact;
bool slideForcedWalkable = !rm.Body.OnWalkable;
System.Numerics.Vector3 slideVelocityBeforeZero = rm.Body.Velocity;
// retail CPhysicsObj::update_object 0x00515D10 -> set_active(1)
// @0x00515DC2. ACTIVE is the only transient this tick may assert
// on its own; CONTACT and ON_WALKABLE belong to
// SetPositionInternal (0x00515330) and are committed from the
// sweep's contact plane below.
//
// Bug B (2026-08-04): the deleted lines were
// if (!rm.Airborne)
// rm.Body.TransientState |= Contact | OnWalkable | Active;
// rm.Body.Velocity = Vector3.Zero;
// — a per-tick FORGE of both retail transients plus a discard of
// the authoritative velocity ACE delivered. On a 52.4-degree roof
// the sweep correctly reported "contact, not walkable" and this
// overruled it every tick, so `calc_acceleration` saw
// Contact && OnWalkable and returned zero acceleration, friction
// never engaged, and the body could not move at all. Retail has no
// such write: CONTACT comes from `contact_plane_valid`
// (0x00515430) and ON_WALKABLE from `contact_plane.N.z >= floor_z`
// (0x00515465-0x0051548E), and `MoveOrTeleport` 0x00516330 never
// touches the wire velocity vector for a remote at all.
rm.Body.TransientState |=
AcDream.Core.Physics.TransientStateFlags.Active;
if (!rm.Airborne)
{
// Stale server-velocity → stop the locomotion CYCLE (the legs).
// ANIM ONLY — translation is the catch-up. Kept verbatim (same
// !moveToArmed && !stickyArmed gate) from the old SERVERVEL branch
// so a scripted-path NPC that stops server-side drops out of its
// walk/run cycle; ApplyServerControlledVelocityCycle selects the
// anim from ServerVelocity, independent of Body.Velocity.
bool moveToArmed = rm.MoveTo is
{ MovementTypeState: not AcDream.Core.Physics.MovementType.Invalid };
bool stickyArmed =
(rm.Host?.PositionManager.GetStickyObjectId() ?? 0u) != 0u;
if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity
&& !moveToArmed && !stickyArmed)
{
double velocityAge = nowSec - rm.LastServerPosTime;
if (velocityAge > ServerControlledVelocityStaleSeconds)
{
rm.ServerVelocity = System.Numerics.Vector3.Zero;
rm.HasServerVelocity = false;
applyStaleVelocityCycle?.Invoke(
System.Numerics.Vector3.Zero);
}
}
// R4-V4: tick the MoveToManager UNCONDITIONALLY (retail
// MovementManager::UseTime per tick, UpdateObjectInternal call
// @0x00515998) — UseTime runs HandleMoveToPosition /
// HandleTurnToHeading (steering + arrival + fail-distance),
// dispatching its per-node locomotion (turn / RunForward) through
// the sink (the LEGS). Position comes from the catch-up; legs from
// this per-node dispatch + the funnel. The #170-deleted per-frame
// apply_current_movement is NOT reintroduced.
}
// 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:
// position += velocity × dt + 0.5 × accel × dt²
//
// Call UpdatePhysicsInternal DIRECTLY rather than via
// PhysicsBody.update_object (FUN_00515020). update_object gates
// on MinQuantum = 1/30s: at our 60fps render tick (~16ms),
// deltaTime < MinQuantum → early return AND LastUpdateTime is
// NOT advanced. Net effect: position never integrates between
// UpdatePositions and the only Body.Position changes come
// from the UP hard-snap, producing a visible teleport-stride
// on slopes (the "staircase" the user reported).
//
// 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
// 0x00555190), composed BEFORE UpdatePhysicsInternal + the transition
// sweep so collision resolves whichever movement won (preIntegratePos
// captured first — the sweep covers it).
// • GROUNDED: the interp CATCH-UP SEEDS the frame (world→local) —
// the movement source is the adjust_offset walk toward the
// MoveOrTeleport-queued server waypoint, exactly like Path A
// (:10173). StickyManager::adjust_offset then OVERWRITES the
// Origin when armed (0x00555430 ASSIGNS m_fOrigin — the REPLACE
// dichotomy), so a stuck monster still steers via #171.
// • AIRBORNE: seed an EMPTY frame (no catch-up — the arc integrates
// from velocity + gravity, unchanged). Retail expresses that
// gate as `transient_state & 1` (CONTACT_TS) inside
// InterpolationManager::adjust_offset @0x00555D52, which is the
// `inContact:` argument below; before Bug B's fix the deleted
// per-tick force made that argument permanently true.
// Bug B (2026-08-04): the body's own velocity is no longer discarded
// each tick, so UpdatePhysicsInternal genuinely integrates whatever
// the sweep, gravity, and the authoritative wire vector left on it —
// that integration is what a steep-contact slide IS.
if (rm.Host is { } npcHost)
{
AcDream.Core.Physics.Motion.MotionDeltaFrame pmDelta =
rm.PositionManagerDeltaScratch;
pmDelta.Origin = scaledRootMotionLocalOrigin;
pmDelta.Orientation = rootMotionLocalFrame.Orientation;
float maxSpeedNpc = rm.Motion.GetAdjustedMaxSpeed();
// AD-10 (retired 2026-08-06): a terrain-only slope projection
// used to run here, ahead of the sweep. It was an EXTRA copy of
// retail's own per-sub-step projection
// (CTransition::adjust_offset 0x0050a370, pc:272271-272393,
// ported verbatim in Transition.AdjustOffset and reached by the
// ResolveWithTransition call below), taken against a
// single-point SampleTerrainNormal(x, y) lookup blind to the
// body's Z, its cell, buildings, EnvCells and statics. Retail
// has no such pre-sweep step. Measured redundant 2026-08-06:
// with it removed the production trajectory down a 31-degree
// ramp is bit-identical and the whole Runtime suite is
// unchanged. Both fork branches carried this block verbatim
// (the AP-22 shape); removing the parameter from ComposeOffset
// makes a one-site-only regression fail to compile.
rm.Position.ComposeOffset(
dt,
rm.Body.Position,
rm.Body.Orientation,
pmDelta,
rm.Interp,
maxSpeedNpc,
pmDelta,
inContact: rm.Body.InContact);
npcHost.PositionManager.AdjustOffset(pmDelta, dt);
// #167 (Campaign P P5): push the read side of TS-35's
// jump_is_allowed gate. Retail reads IsFullyConstrained through
// CPhysicsObj/PositionManager/ConstraintManager directly; acdream's
// MotionInterpreter only has a PhysicsBody, so the per-tick pump
// (the single owner of this write, matching the taper call just
// above) is the seam that keeps the stub property current.
rm.Body.IsFullyConstrained = npcHost.PositionManager.IsFullyConstrained();
ApplyPositionManagerDelta(rm.Body, pmDelta);
}
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.GetAdjustedMaxSpeed();
// AD-10 (retired 2026-08-06): a terrain-only slope projection
// used to run here, ahead of the sweep. It was an EXTRA copy of
// retail's own per-sub-step projection
// (CTransition::adjust_offset 0x0050a370, pc:272271-272393,
// ported verbatim in Transition.AdjustOffset and reached by the
// ResolveWithTransition call below), taken against a
// single-point SampleTerrainNormal(x, y) lookup blind to the
// body's Z, its cell, buildings, EnvCells and statics. Retail
// has no such pre-sweep step. Measured redundant 2026-08-06:
// with it removed the production trajectory down a 31-degree
// ramp is bit-identical and the whole Runtime suite is
// unchanged. Both fork branches carried this block verbatim
// (the AP-22 shape); removing the parameter from ComposeOffset
// makes a one-site-only regression fail to compile.
rm.Position.ComposeOffset(
dt,
rm.Body.Position,
rm.Body.Orientation,
pmDelta,
rm.Interp,
maxSpeedNpc,
pmDelta,
inContact: rm.Body.InContact);
ApplyPositionManagerDelta(rm.Body, pmDelta);
}
rm.Body.calc_acceleration();
rm.Body.UpdatePhysicsInternal(dt);
if (sequencer is { } hookSequencer)
processAnimationHooks?.Invoke(localEntityId, hookSequencer);
if (!IsCurrentOwner(
record,
rm,
objectClockEpoch,
externalOwnerValid))
{
return false;
}
var postIntegratePos = rm.Body.Position;
uint committedCellId = rm.CellId;
// Step 4: collision sweep — retail FUN_00514B90 →
// FUN_005148A0 → Transition::FindTransitionalPosition.
// Projects the sphere from preIntegratePos to postIntegratePos
// through the BSP + terrain, resolving:
// - terrain Z snap along the slope (fixes the "staircase" where
// horizontal Euler motion up a slope sinks into rising ground
// until the next UP pops it up)
// - indoor BSP walls (via the 6-path dispatcher in BSPQuery)
// - object collisions via ShadowObjectRegistry
// - step-up / step-down against walkable ledges
// ResolveWithTransition is the same call PlayerMovementController
// uses for the local player; remotes now get the full retail
// treatment between UpdatePositions instead of pure kinematics.
//
// Skipped when rm.CellId == 0 (no UP landed yet — can't build
// a SpherePath without a starting cell). One-frame grace until
// the first UP arrives; harmless because the entity is
// server-freshly-spawned at a valid Z anyway.
if (rm.CellId != 0 && _physics.Engine.LandblockCount > 0)
{
// #184 Slice 3 (2026-07-07): Setup-DERIVED mover sphere so
// creatures de-overlap at their TRUE radii (a big monster
// spreads wider, a small one tighter), not the hardcoded
// human 0.48/1.835. GetSetupCylinder returns (setup.Radius,
// setup.Height) × ObjScale — the creature's own dat Setup
// scaled by its wire ObjScale, the same source the local
// player + moveto/sticky use, and consistent with the
// spawn-time shadow registration's entScale. TS-46 (2026-07-30)
// closed the remaining residual: sphereList/sphereScale below
// now carry the Setup's own verbatim sphere list (falling back
// to this deR/deH reconstruction only when the Setup has no
// sphere rows), and stepUpHeight/stepDownHeight are
// Setup-derived rather than a hardcoded 0.4f.
// Fallback to the human capsule for a shapeless / unresolvable
// Setup (GetSetupCylinder returns (0,0)); a zero radius would
// degenerate the sweep.
float deR = radius;
float deH = height;
if (deR < 0.05f) { deR = 0.48f; deH = 1.835f; }
// AD-25 (2026-07-30): retail handle_all_collisions (pc:282647)
// reads the mover's own transient_state CONTACT_TS/
// ON_WALKABLE_TS bits BEFORE this SetPositionInternal-equivalent
// resolve — capture them here, matching TickHidden's identical
// pre-resolve capture below.
bool previousContact = rm.Body.InContact;
bool previousOnWalkable = rm.Body.OnWalkable;
var resolveResult = _physics.Engine.ResolveWithTransition(
preIntegratePos, postIntegratePos, rm.CellId,
sphereRadius: deR,
sphereHeight: deH,
stepUpHeight: stepUpHeight, // TS-46: Setup-derived, was a 0.4f literal
stepDownHeight: stepDownHeight, // TS-46: Setup-derived, was a 0.4f literal
// TS-46: the Setup's own sphere list, scaled by the
// creature's own ObjScale. Empty falls back to the
// deR/deH two-scalar reconstruction above.
sphereList: sphereList,
sphereScale: sphereScale,
// With a body present this argument no longer seeds
// transition contact at all (retail check_contact
// 0x0050F5B0 owns that, see PhysicsEngine); it only decides
// whether the retained walkable polygon is handed to the
// SpherePath. Bug B (2026-08-04): read the committed
// ON_WALKABLE transient, exactly like the local player
// (`isOnGround: _body.OnWalkable`) and TickHidden
// (`isOnGround: previousOnWalkable`), instead of the client
// Airborne bool.
isOnGround: previousOnWalkable,
body: rm.Body, // persist ContactPlane across frames for slope tracking
// Retail default physics state includes EdgeSlide; remote DR
// should exercise the same edge/cliff branch as local movement.
// #184 Slice 2b: a remote PLAYER mover ALSO carries IsPlayer, so
// CollisionExemption's PvP block fires exactly as it does for the
// LOCAL player (PlayerMovementController :920) — two non-PK players
// WALK THROUGH each other (retail sets IsPlayer on every object's
// own transition via OBJECTINFO::init 0x0050cf30 `state |= 0x100`
// from its weenie IsPlayer(); FindObjCollisions pc:276812 exempts a
// non-PK player pair). Without IsPlayer the mover would de-overlap
// two players — MORE solid than retail (you can stand inside another
// non-PK player in AC). Players still COLLIDE with monsters (target
// not IsPlayer → no exemption) + terrain + walls. TS-23
// (2026-07-30): moverPvpState carries the remote's real
// PK/PKLite/Impenetrable bits (default None — a no-op OR
// for every non-PK remote, bit-identical to the pre-P3
// value); a PK-vs-PK pair now collides exactly like
// retail instead of always walking through.
moverFlags: (IsPlayerGuid(serverGuid)
? AcDream.Core.Physics.ObjectInfoState.IsPlayer
| AcDream.Core.Physics.ObjectInfoState.EdgeSlide
: AcDream.Core.Physics.ObjectInfoState.EdgeSlide)
| moverPvpState,
// Fix #42 (2026-05-05): skip the moving remote's
// own ShadowEntry. _animatedEntities is keyed by
// entity.Id so kv.Key matches the EntityId the
// ShadowObjectRegistry has for this remote.
// Without this, the airborne sweep collides with
// the remote's own cylinder and produces ~1m of
// horizontal drift on the first jump frame
// (validated by [SWEEP-OBJ] traces).
movingEntityId: localEntityId);
rm.Body.Position = resolveResult.Position;
if (resolveResult.CellId != 0)
committedCellId = resolveResult.CellId;
// #184 (2026-07-07) — SHADOW-FOLLOWS-RESOLVED (the load-bearing
// de-overlap fix, proven in RemoteDeOverlapMechanismTests). Retail
// re-registers a moved object's shadow every transition step
// (SetPositionInternal → remove/add_shadows_to_cells, Ghidra
// 0x00515330) so its m_position — the RESOLVED position — is what
// OTHER creatures collide against. acdream's shadow otherwise only
// syncs to the RAW server pos on UpdatePosition, so neighbours would
// de-overlap against each other's OVERLAPPING shadows and any spread
// would be discarded on the next UP (never accumulating), AND the
// 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
// 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
// net-stationary (de-overlapped, sweep-
// blocked) creature keeps its correct shadow and never re-floods,
// while a moving/de-overlapping crowd (which moves every tick) still
// syncs every tick. Bounds the per-tick RegisterMultiPart flood cost
// 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.)
// ── SetPositionInternal commit (Bug B, 2026-08-04) ───────────
// This block REPLACES a bare `HandleAllCollisions(...,
// resolveResult.IsOnGround)` call. That call was the TAIL of
// retail SetPositionInternal (0x00515330) without its PREFIX:
// the sweep's own `InContact` / `OnWalkable` — the exact retail
// classification the engine already computed — were never
// committed to the body, and the ground edge was decided from
// `resolveResult.IsOnGround`, which is `inContact || …`
// (PhysicsEngine) and is therefore TRUE on a steep contact.
// A remote that touched a 52.4-degree roof was consequently
// declared landed, forced walkable, and stripped of gravity.
//
// Retail order (0x00515430 → 0x0051548E → 0x005154FE):
// CONTACT_TS <- collision_info.contact_plane_valid
// calc_acceleration
// ON_WALKABLE_TS <- contact_plane.N.z >= floor_z, via
// set_on_walkable @0x00511310, which is
// the SOLE source of
// MovementManager::HitGround /
// ::LeaveGround — no ownership, player, or
// creature gate anywhere in it
// calc_acceleration
// handle_all_collisions
//
// That is PhysicsObjUpdate.CommitSetPositionTransition's
// sequence MINUS its velocity-authority check: the helper also
// honours an `isVelocityCurrent` delegate and skips
// handle_all_collisions when a newer Vector/Movement packet
// installed a velocity from inside the ground-edge callback
// (PhysicsObjUpdate.cs:101-102). That check is inert here —
// this site would pass it as null (the helper's default), the
// same as the TickHidden call below, because a per-quantum
// simulation step is not a packet apply and opens no window in
// which a competing velocity authority could land: the only
// callbacks between the contact prefix and
// handle_all_collisions are HitGround/LeaveGround and the
// ownership re-check. The packet-driven placement paths are
// the ones that need it: `RuntimeSetPositionState`'s
// canonical commit (which now backs every remote packet-
// driven placement, teleport included — C4 route 4b-3
// deleted the `RemoteTeleportPlacement.Apply` caller this
// note used to name) makes the same check inline around its
// own `HandleAllCollisions`.
//
// It is spelled out through its own public sub-steps
// (CommitSetPositionContactPrefix / the ground edge /
// CommitSetPositionPostGround / HandleAllCollisions — the seam
// whose doc comment exists for precisely this) for two reasons:
// the Bug A landing probes must bracket the exact HitGround
// call, and this per-remote per-quantum path must not allocate
// an `isCurrent` closure.
//
// The whole commit is gated on `Ok && candidateMoved`, matching
// PlayerMovementController and retail UpdateObjectInternal
// (pc:283657): a failed transition is discarded whole and a
// zero-move frame never re-derives contact.
bool candidateMoved = postIntegratePos != preIntegratePos;
if (resolveResult.Ok && candidateMoved)
{
bool finalOnWalkable = AcDream.Core.Physics.PhysicsObjUpdate
.CommitSetPositionContactPrefix(
rm.Body,
resolveResult.InContact,
resolveResult.OnWalkable,
previousOnWalkable);
if (!previousOnWalkable && finalOnWalkable)
{
// #161: HitGround MUST run with the Gravity state bit
// still set — CMotionInterp::HitGround (0x00528AC0)
// gates on state & 0x400. Bug B deleted the clear that
// used to follow this call: retail NEVER toggles
// GRAVITY_PS on a ground edge (`set_state` @0x00514DD0
// post-processes only lighting/nodraw/hidden), it gates
// gravity ACCELERATION on the CONTACT/ON_WALKABLE
// transients inside calc_acceleration @0x00510950.
// R4-V5: retail order is minterp then moveto
// (MovementManager::HitGround 0x00524300).
rm.Movement.HitGround();
if (!IsCurrentOwner(
record,
rm,
objectClockEpoch,
externalOwnerValid))
{
return false;
}
// #184 (2026-07-07): clear the interp queue on the
// LANDING edge. An airborne remote's Positions hard-snap
// and never Enqueue, so any pre-arc waypoints are stale;
// without this the first grounded catch-up after
// touchdown chases them backward. Bug B kept the
// behaviour and only re-derived the edge — it now hangs
// off the same `set_on_walkable(1)` transition retail
// fires HitGround from, instead of the hand-rolled
// `IsOnGround && Velocity.Z <= 0` test that fired on a
// steep contact too. Register row AP-139.
rm.Interp.Clear();
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
Console.WriteLine($"VU.land guid=0x{serverGuid:X8} Z={rm.Body.Position.Z:F2}");
}
else if (previousOnWalkable && !finalOnWalkable)
{
// set_on_walkable(0) @0x0051133C —
// MovementManager::LeaveGround. A remote that walks off
// a ledge or slides off a walkable lip onto a steep face
// now takes retail's ground-departure edge instead of
// staying nominally grounded forever.
rm.Motion.LeaveGround();
if (!IsCurrentOwner(
record,
rm,
objectClockEpoch,
externalOwnerValid))
{
return false;
}
}
AcDream.Core.Physics.PhysicsObjUpdate
.CommitSetPositionPostGround(rm.Body);
// retail CPhysicsObj::handle_all_collisions (0x00514780,
// pc:282647) @0x005154FE — the same verbatim port the local
// player and every ordinary body use. `nowOnWalkable` is the
// COMMITTED transient, not the contact-derived
// `resolveResult.IsOnGround` the old call passed: on a steep
// contact those disagree, and passing IsOnGround suppressed
// the landing reflect exactly where retail forces it.
AcDream.Core.Physics.PhysicsObjUpdate.HandleAllCollisions(
rm.Body,
resolveResult.CollisionNormalValid,
resolveResult.CollisionNormal,
previousContact,
previousOnWalkable,
rm.Body.OnWalkable);
// Bug B (2026-08-04): Airborne is DERIVED from the committed
// ON_WALKABLE transient, never latched by a landing test.
// This is the project's ONE definition of the flag — every
// writer spells `!Body.OnWalkable`, and there are FOUR of
// them: `SettleSpawnedRemoteContact` (the spawn-settle
// tail), `RuntimeSetPositionState`'s canonical placement
// commit (C4 route 4b-3 retired the fifth writer this
// note used to name, `RemoteTeleportPlacement.Apply` in
// App — the teleport path's `Airborne` derivation is now
// this same canonical commit's), and this file's two
// (here and the `TickHidden` resolve).
// `PlayerMovementController.IsAirborne` computes the same
// predicate for the local player. It stays unchanged here;
// only the fact it is derived FROM has moved, from a
// hand-rolled `IsOnGround` test to the sweep's own
// contact-plane result.
//
// What this flag then GATES is a separate, still-open
// divergence: retail's free-flight predicate for the
// interpolate-vs-snap decision is CONTACT, not walkability
// (`InterpolationManager::adjust_offset` @0x00555D30 gates
// its whole body on `transient_state & 1` @0x00555D52).
// Register row AP-140.
rm.Airborne = !rm.Body.OnWalkable;
}
// Bug B (2026-08-04) — [remote-slide-tick]. Emitted here, after
// the SetPositionInternal commit above, so it reports the
// sweep's own retail classification (rsInContact / rsOnWalkable
// / rsCpNz) next to what the body now carries. Before the fix
// those two columns disagreed on a steep roof — that
// disagreement WAS the bug — and they must now agree on every
// committed frame. bodyCpNz vs floorZ settles NOT ESTABLISHED
// #2 ("is that roof steep in OUR collision data").
//
// Rate limit: ShouldEmitRemoteSlideTick emits immediately on
// any change to the signature below (every contact / walkable /
// grounded / airborne / gravity / steep / moved transition is
// captured at full 30 Hz fidelity) and otherwise throttles to
// one line per GUID per 200 ms, so a RESTING remote — the whole
// point of the capture — stays at ~5 lines/s instead of 30.
// TEMPORARY — strip with the ACDREAM_PROBE_REMOTE_SLIDE family.
{
bool slideBodyCpValid = rm.Body.ContactPlaneValid;
float slideBodyCpNz = rm.Body.ContactPlane.Normal.Z;
int slideSignature =
(rm.Airborne ? 1 << 0 : 0)
| (slideForcedContact ? 1 << 1 : 0)
| (slideForcedWalkable ? 1 << 2 : 0)
| (resolveResult.InContact ? 1 << 3 : 0)
| (resolveResult.OnWalkable ? 1 << 4 : 0)
| (resolveResult.IsOnGround ? 1 << 5 : 0)
| (rm.Body.InContact ? 1 << 6 : 0)
| (rm.Body.OnWalkable ? 1 << 7 : 0)
| (rm.Body.HasGravity ? 1 << 8 : 0)
| (slideBodyCpValid ? 1 << 9 : 0)
| (slideBodyCpValid
&& slideBodyCpNz
< AcDream.Core.Physics.PhysicsGlobals.FloorZ
? 1 << 10 : 0)
| (System.Numerics.Vector3.Distance(
preIntegratePos, resolveResult.Position) > 0.01f
? 1 << 11 : 0);
if (AcDream.Core.Physics.PhysicsDiagnostics
.ShouldEmitRemoteSlideTick(serverGuid, slideSignature))
{
AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteSlideTick(
guid: serverGuid,
airborne: rm.Airborne,
forcedContact: slideForcedContact,
forcedWalkable: slideForcedWalkable,
velocityBeforeZero: slideVelocityBeforeZero,
resolved: true,
resolveInContact: resolveResult.InContact,
resolveOnWalkable: resolveResult.OnWalkable,
resolveIsOnGround: resolveResult.IsOnGround,
resolveContactPlaneValid: resolveResult.InContact,
resolveContactPlaneNormalZ:
resolveResult.ContactPlane.Normal.Z,
bodyContactPlaneValid: slideBodyCpValid,
bodyContactPlaneNormalZ: slideBodyCpNz,
contact: rm.Body.InContact,
onWalkable: rm.Body.OnWalkable,
gravity: rm.Body.HasGravity,
velocity: rm.Body.Velocity,
acceleration: rm.Body.Acceleration,
preIntegratePosition: preIntegratePos,
postIntegratePosition: postIntegratePos,
resolvedPosition: resolveResult.Position);
}
}
}
else
{
// Bug B (2026-08-04): the sweep was SKIPPED this tick (no
// starting cell, or no landblocks resident). Reported with
// resolved=false and every rs* field default so a silent
// stretch in the log cannot be misread as "the probe is not
// firing". Same edge-or-throttle admission. TEMPORARY — strip
// with the ACDREAM_PROBE_REMOTE_SLIDE family.
bool skipBodyCpValid = rm.Body.ContactPlaneValid;
float skipBodyCpNz = rm.Body.ContactPlane.Normal.Z;
int skipSignature =
(rm.Airborne ? 1 << 0 : 0)
| (slideForcedContact ? 1 << 1 : 0)
| (slideForcedWalkable ? 1 << 2 : 0)
| (rm.Body.InContact ? 1 << 6 : 0)
| (rm.Body.OnWalkable ? 1 << 7 : 0)
| (rm.Body.HasGravity ? 1 << 8 : 0)
| (skipBodyCpValid ? 1 << 9 : 0)
| (1 << 12);
if (AcDream.Core.Physics.PhysicsDiagnostics
.ShouldEmitRemoteSlideTick(serverGuid, skipSignature))
{
AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteSlideTick(
guid: serverGuid,
airborne: rm.Airborne,
forcedContact: slideForcedContact,
forcedWalkable: slideForcedWalkable,
velocityBeforeZero: slideVelocityBeforeZero,
resolved: false,
resolveInContact: false,
resolveOnWalkable: false,
resolveIsOnGround: false,
resolveContactPlaneValid: false,
resolveContactPlaneNormalZ: 0f,
bodyContactPlaneValid: skipBodyCpValid,
bodyContactPlaneNormalZ: skipBodyCpNz,
contact: rm.Body.InContact,
onWalkable: rm.Body.OnWalkable,
gravity: rm.Body.HasGravity,
velocity: rm.Body.Velocity,
acceleration: rm.Body.Acceleration,
preIntegratePosition: preIntegratePos,
postIntegratePosition: postIntegratePos,
resolvedPosition: rm.Body.Position);
}
}
// 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.
if (!(acknowledgeProjection?.Invoke(
new RuntimeRemotePhysicsSnapshot(
rm.Body.Position,
rm.Body.Orientation,
committedCellId)) ?? true)
|| !IsCurrentOwner(
record,
rm,
objectClockEpoch,
externalOwnerValid))
{
return false;
}
bool cellChanged = committedCellId != 0
&& committedCellId != rm.CellId;
if (cellChanged)
rm.CellId = committedCellId;
if (!IsCurrentOwner(
record,
rm,
objectClockEpoch,
externalOwnerValid))
{
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(
localEntityId,
rm,
liveCenterX,
liveCenterY);
}
}
// R5-V3 (#171): retail UpdateObjectInternal tail —
// PositionManager::UseTime (0x005156b0, call @0x005159b3,
// right after CPartArray::HandleMovement, UNCONDITIONAL for
// every entity in both grounded and airborne branches): the
// 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.
AcDream.Core.Physics.RetailObjectManagerTail.Run(
rm.Host?.TargetManager,
rm.Movement,
sequencer?.Manager,
rm.Host?.PositionManager);
return IsCurrentOwner(
record,
rm,
objectClockEpoch,
externalOwnerValid);
}
///
/// Retail hidden-object slice of CPhysicsObj::UpdatePositionInternal
/// (0x00512C30) plus the manager tail of
/// UpdateObjectInternal (0x005156B0). Hidden skips
/// CPartArray::Update and UpdatePhysicsInternal, but the
/// PositionManager offset is still composed and the target, movement, and
/// position managers still consume time. Physics-script and particle owners
/// tick later in the shared frame pipeline.
///
internal bool TickHidden(
RuntimeEntityRecord record,
RemoteMotion rm,
float dt,
ulong objectClockEpoch,
float radius,
float height,
AcDream.Core.Physics.Motion.MotionTableManager?
partArrayHandleMovement = null,
System.Action?
processAnimationHooks = null,
AcDream.Core.Physics.AnimationSequencer? sequencer = null,
System.Func?
acknowledgeProjection = null,
System.Func? externalOwnerValid = null,
// TS-46/TS-23 (2026-07-30): see the visible Tick's identical parameters.
System.Collections.Immutable.ImmutableArray
sphereList = default,
float sphereScale = 1f,
float stepUpHeight = 0.4f,
float stepDownHeight = 0.4f,
AcDream.Core.Physics.ObjectInfoState moverPvpState =
AcDream.Core.Physics.ObjectInfoState.None)
{
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(rm);
if (!IsCurrentOwner(
record,
rm,
objectClockEpoch,
externalOwnerValid))
{
return false;
}
uint localEntityId = record.LocalEntityId
?? throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} has no local identity.");
// Bug B (2026-08-04): hidden remotes run the same ComposeOffset chain,
// so the InterpolationManager stall snap can fire from here too and
// needs the same GUID attribution. TEMPORARY — strip with the
// ACDREAM_PROBE_REMOTE_SLIDE family.
AcDream.Core.Physics.PhysicsDiagnostics.BeginRemoteSlideAttribution(
record.ServerGuid);
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.
AcDream.Core.Physics.Motion.MotionDeltaFrame positionDelta =
rm.PositionManagerDeltaScratch;
positionDelta.Reset();
rm.Position.ComposeOffset(
dt,
rm.Body.Position,
rm.Body.Orientation,
positionDelta,
rm.Interp,
rm.Motion.GetAdjustedMaxSpeed(),
positionDelta,
inContact: rm.Body.InContact);
rm.Host?.PositionManager.AdjustOffset(positionDelta, dt);
// #167 (Campaign P P5): see the identical push in Tick's grounded
// npcHost branch — Hidden objects still keep their PositionManager
// (and therefore their leash) alive per retail.
if (rm.Host is { } hiddenHost)
rm.Body.IsFullyConstrained = hiddenHost.PositionManager.IsFullyConstrained();
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(localEntityId, sequencer);
if (!IsCurrentOwner(
record,
rm,
objectClockEpoch,
externalOwnerValid))
{
return false;
}
System.Numerics.Vector3 composedPosition = rm.Body.Position;
uint committedCellId = rm.CellId;
if (rm.CellId != 0
&& composedPosition != preComposePosition
&& _physics.Engine.LandblockCount > 0)
{
if (radius < 0.05f)
{
radius = 0.48f;
height = 1.835f;
}
bool previousContact = rm.Body.InContact;
bool previousOnWalkable = rm.Body.OnWalkable;
var resolved = _physics.Engine.ResolveWithTransition(
preComposePosition,
composedPosition,
rm.CellId,
radius,
height,
stepUpHeight: stepUpHeight, // TS-46: Setup-derived, was a 0.4f literal
stepDownHeight: stepDownHeight, // TS-46: Setup-derived, was a 0.4f literal
isOnGround: previousOnWalkable,
body: rm.Body,
// TS-23: moverPvpState is a no-op OR (None) for every
// non-PK remote.
moverFlags: (IsPlayerGuid(record.ServerGuid)
? AcDream.Core.Physics.ObjectInfoState.IsPlayer
| AcDream.Core.Physics.ObjectInfoState.EdgeSlide
: AcDream.Core.Physics.ObjectInfoState.EdgeSlide)
| moverPvpState,
movingEntityId: localEntityId,
// TS-46: the Setup's own sphere list, scaled by ObjScale.
// Empty falls back to the radius/height reconstruction above.
sphereList: sphereList,
sphereScale: sphereScale);
rm.Body.Position = resolved.Position;
if (resolved.CellId != 0)
committedCellId = resolved.CellId;
if (!AcDream.Core.Physics.PhysicsObjUpdate.CommitSetPositionTransition(
rm.Body,
resolved.InContact,
resolved.OnWalkable,
resolved.CollisionNormalValid,
resolved.CollisionNormal,
previousContact,
previousOnWalkable,
rm.Movement.HitGround,
rm.Motion.LeaveGround,
() => IsCurrentOwner(
record,
rm,
objectClockEpoch,
externalOwnerValid)))
{
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.
if (!(acknowledgeProjection?.Invoke(
new RuntimeRemotePhysicsSnapshot(
rm.Body.Position,
rm.Body.Orientation,
committedCellId)) ?? true)
|| !IsCurrentOwner(
record,
rm,
objectClockEpoch,
externalOwnerValid))
{
return false;
}
if (committedCellId != 0 && committedCellId != rm.CellId)
rm.CellId = committedCellId;
if (!IsCurrentOwner(
record,
rm,
objectClockEpoch,
externalOwnerValid))
{
return false;
}
AcDream.Core.Physics.RetailObjectManagerTail.Run(
rm.Host?.TargetManager,
rm.Movement,
partArrayHandleMovement,
rm.Host?.PositionManager);
return IsCurrentOwner(
record,
rm,
objectClockEpoch,
externalOwnerValid);
}
private bool IsCurrentOwner(
RuntimeEntityRecord record,
RemoteMotion remote,
ulong objectClockEpoch,
System.Func? externalOwnerValid) =>
_physics.IsSpatialRemote(record, remote)
&& record.ObjectClockEpoch == objectClockEpoch
&& ReferenceEquals(record.PhysicsBody, remote.Body)
&& (externalOwnerValid?.Invoke() ?? true);
///
/// R5-V3 (#171): apply a
/// written by PositionManager.AdjustOffset onto a body — acdream's
/// stand-in for retail's Frame::combine in
/// CPhysicsObj::UpdatePositionInternal (0x00512c30, combine
/// @0x00512d22). The delta's Origin is mover-LOCAL (sticky writes
/// globaltolocalvec output — 0x00555430), so combining rotates it
/// out by the body's current orientation and post-multiplies the complete
/// relative orientation. The remote tick is its only caller.
///
private static void ApplyPositionManagerDelta(
AcDream.Core.Physics.PhysicsBody body,
AcDream.Core.Physics.Motion.MotionDeltaFrame delta)
{
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.FrameOps.SetRotate(
body.Position,
body.Orientation,
body.Orientation * delta.Orientation);
}
///
/// #184 — shadow-follows-resolved. Re-register a remote creature's collision
/// SHADOW at its RESOLVED body position, so OTHER creatures (and the player)
/// de-overlap / collide against where the monster actually IS (== where it
/// renders), not the raw overlapping server position. Retail re-registers a
/// moved object's shadow every accepted transition step (SetPositionInternal
/// → remove/add_shadows_to_cells, Ghidra 0x00515330). The streaming centre is
/// passed in (/)
/// rather than snapshotted, since it moves on recentre. Updates
/// and
/// so callers can
/// pose-gate. Rotation matters because Setup collision geometry may be
/// multipart or offset from the root.
/// Called by the remote tick and the authoritative-position tail.
///
internal void SyncRemoteShadowToBody(
uint entityId,
AcDream.Runtime.Physics.IRuntimeRemotePlacement rm,
int liveCenterX,
int liveCenterY,
uint? authoritativeCellId = null)
{
SyncRemoteShadowToBody(
entityId,
rm.Body,
liveCenterX,
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);
internal void SyncRemoteShadowToBody(
uint entityId,
AcDream.Core.Physics.PhysicsBody body,
int liveCenterX,
int liveCenterY,
uint authoritativeCellId)
{
ShadowPositionSynchronizer.Sync(
_physics.Engine.ShadowObjects,
entityId,
body.Position,
body.Orientation,
authoritativeCellId,
liveCenterX,
liveCenterY);
}
}