acdream/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs
Erik a46c8e65b2 fix #270: fire ReportExhaustion on the stamina-exhaustion edge only, not every stats tick
Retail calls CPhysicsObj::report_exhaustion from exactly one site -
CommandInterpreter::HandleExhaustion (0x006b3c70), a notification handler
for the stamina-exhaustion EVENT. Campaign P P1 wired it to every
movement-stats application instead (every stamina regen/drain tick), and
each call re-dispatches the current movement state through the animation
sink - truncating any in-flight action animation. The diagnostic session
log shows 490 spurious casting-stance re-queues in one short session:
'sometimes stuck in spell animations' was every stamina tick that
collided with a cast gesture's play window.

The re-apply now fires only when the exhausted state (stamina == 0)
transitions, matching retail's event semantics. Stats still reach
PlayerWeenie immediately via RuntimeMovementSkillProjection.ApplyTo.

Also adds the [remote-edge] probe (rides ACDREAM_DUMP_MOTION=1): one
line per remote HitGround/LeaveGround - each such edge drains the
mover's pending action animations (retail HandleEnterWorld), the
working theory for intermittently missing monster attack swings.

Complete Release suite: 10,026 passed / 5 skips / 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 22:11:20 +02:00

955 lines
48 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

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

using AcDream.Runtime.Entities;
namespace AcDream.Runtime.Physics;
internal readonly record struct RuntimeRemotePhysicsSnapshot(
System.Numerics.Vector3 Position,
System.Numerics.Quaternion Orientation,
uint FullCellId);
/// <summary>
/// #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
/// (<c>ae.Sequencer != null &amp;&amp; serverGuid != 0 &amp;&amp; serverGuid != _playerServerGuid
/// &amp;&amp; rm.LastServerPosTime &gt; 0</c>).
/// Hidden remotes use a separate live-entity pass because retail keeps their
/// PositionManager alive even when the object has no render-animation owner.
///
/// <para>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 — <b>every</b> remote now runs the SAME catch-up +
/// <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 <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 policy arrives through focused Physics delegates:
/// DAT-derived shape dimensions and animation-cycle projection arrive through
/// the App adapter in a graphical host. <c>SyncRemoteShadowToBody</c>
/// (remote-physics-specific) moved here and is called back from the UP-branch
/// tail; <c>ApplyPositionManagerDelta</c> / <c>TickRemoteMoveTo</c> had no other
/// callers and moved here outright.</para>
/// </summary>
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;
/// <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
/// render-animation presentation component.
/// </summary>
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<uint, AcDream.Core.Physics.AnimationSequencer>?
processAnimationHooks = null,
System.Action<System.Numerics.Vector3>? applyStaleVelocityCycle = null,
System.Func<RuntimeRemotePhysicsSnapshot, bool>?
acknowledgeProjection = null,
System.Func<bool>? 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<AcDream.Core.Physics.FlatCollisionSphere>
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;
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
// is OnWalkable; otherwise it clears the displacement. Grounded
// 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 scaledRootMotionLocalOrigin = !rm.Airborne
? rootMotionLocalFrame.Origin * objectScale
: System.Numerics.Vector3.Zero;
// Step 1: re-apply current motion commands → body.Velocity.
// Forces OnWalkable + Contact so the gate in apply_current_movement
// always succeeds (remotes are server-authoritative; we don't
// simulate airborne physics for them).
//
// K-fix9 (2026-04-26): SKIP this when the remote is airborne.
// Otherwise the force-OnWalkable + apply_current_movement
// path stomps the +Z velocity we set in OnLiveVectorUpdated,
// and gravity never gets to integrate the arc. The airborne
// body keeps the launch velocity from the VectorUpdate;
// UpdatePhysicsInternal below applies gravity each tick;
// the next UpdatePosition snaps to the new ground location
// and re-grounds.
if (!rm.Airborne)
{
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
| AcDream.Core.Physics.TransientStateFlags.Active;
// #184 (2026-07-07): a grounded remote carries NO translation
// velocity. Its per-tick movement is the interp CATCH-UP toward
// the MoveOrTeleport-queued server waypoint (computed at the
// sticky-compose site below), which the KEPT ResolveWithTransition
// sweep de-overlaps against neighbours — and the resolved position
// is written back into the SHADOW (below) so the de-overlap
// persists and neighbours collide against the resolved body, not
// the raw server pos. This REPLACES the old synth-velocity model
// (get_state_velocity / SERVERVEL Body.Velocity = ServerVelocity):
// retail's UpdateObjectInternal (0x005156b0) has NO synth-velocity
// leg — a remote translates by adjust_offset and the UP is a gentle
// target. As of #184 Slice 2b this grounded model is the SINGLE
// remote path (players + NPCs) — retail has no fork.
rm.Body.Velocity = System.Numerics.Vector3.Zero;
// 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.
}
else
{
// Airborne — keep Active flag (so UpdatePhysicsInternal
// doesn't early-return) but DON'T set Contact / OnWalkable.
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active;
}
// 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).
// Body.Velocity is 0 when grounded (set above), so UpdatePhysicsInternal
// adds no translation on top of the catch-up — no double-move.
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();
System.Numerics.Vector3? terrainNormalNpc = !rm.Airborne
? _physics.Engine.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);
// #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();
System.Numerics.Vector3? terrainNormalNpc = !rm.Airborne
? _physics.Engine.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(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,
// K-fix9 (2026-04-26): mirror the K-fix7 gate —
// airborne remotes must NOT pre-seed the
// ContactPlane, otherwise AdjustOffset's snap-to-plane
// branch zeroes the +Z offset every step (same bug
// we hit on the local jump).
isOnGround: !rm.Airborne,
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.)
// AD-25 (2026-07-30): retail CPhysicsObj::handle_all_collisions
// (0x00514780, pc:282647) runs UNCONDITIONALLY after EVERY
// SetPositionInternal — remote objects included; a
// VectorUpdate-launched jump arc is ordinary object physics in
// retail. #173 (2026-07-05) first mirrored the local player's
// reflect math here by hand, but with a narrower gate than
// retail's: `shouldReflect = !(prevOnWalkable && nowOnWalkable
// && !sledding)` collapses to the two ad-hoc branches this
// block used to hand-roll, and got BOTH wrong — the sledding
// branch suppressed the bounce exactly when retail's
// `!sledding` term forces it UNCONDITIONALLY, and the
// non-sledding branch only reflected airborne→airborne where
// retail reflects on every transition except grounded→grounded.
// PhysicsObjUpdate.HandleAllCollisions is the same verbatim
// port the local player and every ordinary body already use
// (PhysicsObjUpdate.CommitSetPositionTransition); call it
// directly instead of re-deriving the gate. It already
// no-ops the reflect step when collisionNormalValid is false,
// but — unlike the old wrapper this replaces — still runs the
// fsf&gt;1 unconditional velocity-zero "bleed" regardless of
// whether this tick found a collision normal, matching
// retail's own unconditional call site.
AcDream.Core.Physics.PhysicsObjUpdate.HandleAllCollisions(
rm.Body,
resolveResult.CollisionNormalValid,
resolveResult.CollisionNormal,
previousContact,
previousOnWalkable,
resolveResult.IsOnGround);
// K-fix15 (2026-04-26): post-resolve landing
// detection for airborne remotes. Mirrors
// PlayerMovementController's local-player landing
// path: when the resolver says we're on ground AND
// velocity is no longer pointing up, transition
// back to grounded — clear Airborne, restore
// Contact + OnWalkable, remove Gravity, zero any
// residual downward velocity, and trigger
// HitGround so the sequencer can swap from
// Falling → idle/locomotion. Without this, an
// airborne remote falls through the floor (gravity
// keeps building Velocity.Z negative until the
// sphere-sweep clamps each frame, but Airborne
// stays true forever).
if (rm.Airborne
&& resolveResult.IsOnGround
&& rm.Body.Velocity.Z <= 0f)
{
rm.Airborne = false;
// #184 (2026-07-07): clear the interp queue on landing (mirrors
// the player-remote landing). Airborne UPs hard-snap and never
// Enqueue, so any pre-jump waypoints are stale; without this the
// first grounded catch-up after touchdown chases them backward.
rm.Interp.Clear();
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
| AcDream.Core.Physics.TransientStateFlags.OnWalkable;
rm.Body.Velocity = new System.Numerics.Vector3(
rm.Body.Velocity.X, rm.Body.Velocity.Y, 0f);
// #161: HitGround MUST run with the Gravity state
// bit still set — CMotionInterp::HitGround
// (0x00528ac0) gates on state&0x400 (retail never
// clears GRAVITY on landing; it's a persistent
// object property). Clearing it first made this
// re-apply a silent no-op, which is why the
// falling pose never exited. The re-apply
// dispatches the PRESERVED pre-fall forward
// command through the funnel → the motion table
// plays the Falling→X landing link. (The old
// K-fix17 forced SetCycle is deleted: it read the
// then-clobbered InterpretedState.ForwardCommand
// — 0x40000015 — and re-set the very Falling
// cycle it meant to clear.)
// R4-V5 (closes the V4 wiring-contract gap the
// adversarial review caught): retail order —
// minterp first, then moveto (MovementManager::
// HitGround 0x00524300, §2d — the R5-V5 facade
// relay). Re-arms a moveto suspended by the
// airborne UseTime contact gate; without it a
// chasing NPC that lands stalls until ACE's
// ~1 Hz re-emit.
ulong landingStateAuthorityVersion =
record.StateAuthorityVersion;
rm.Movement.HitGround();
if (!IsCurrentOwner(
record,
rm,
objectClockEpoch,
externalOwnerValid))
{
return false;
}
// DR bookkeeping only (partner of the jump-start
// `State |= Gravity`): stops the per-tick gravity
// integration for the grounded body.
if (record.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}");
}
}
// 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);
}
/// <summary>
/// Retail hidden-object slice of <c>CPhysicsObj::UpdatePositionInternal</c>
/// (<c>0x00512C30</c>) plus the manager tail of
/// <c>UpdateObjectInternal</c> (<c>0x005156B0</c>). Hidden skips
/// <c>CPartArray::Update</c> and <c>UpdatePhysicsInternal</c>, 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.
/// </summary>
internal bool TickHidden(
RuntimeEntityRecord record,
RemoteMotion rm,
float dt,
ulong objectClockEpoch,
float radius,
float height,
AcDream.Core.Physics.Motion.MotionTableManager?
partArrayHandleMovement = null,
System.Action<uint, AcDream.Core.Physics.AnimationSequencer>?
processAnimationHooks = null,
AcDream.Core.Physics.AnimationSequencer? sequencer = null,
System.Func<RuntimeRemotePhysicsSnapshot, bool>?
acknowledgeProjection = null,
System.Func<bool>? externalOwnerValid = null,
// TS-46/TS-23 (2026-07-30): see the visible Tick's identical parameters.
System.Collections.Immutable.ImmutableArray<AcDream.Core.Physics.FlatCollisionSphere>
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.");
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;
// [remote-edge] probe (stuck-cast/missing-attack investigation,
// 2026-07-30): each ground edge drains the mover's pending
// action animations (retail HandleEnterWorld) — one line per
// edge correlates eaten attack gestures with contact flickers.
Action hitGround = rm.Movement.HitGround;
Action leaveGround = rm.Motion.LeaveGround;
if (AcDream.Core.Physics.PhysicsDiagnostics.DumpMotionEnabled)
{
uint edgeGuid = record.ServerGuid;
hitGround = () =>
{
Console.WriteLine($"[remote-edge] guid={edgeGuid:X8} HitGround");
rm.Movement.HitGround();
};
leaveGround = () =>
{
Console.WriteLine($"[remote-edge] guid={edgeGuid:X8} LeaveGround");
rm.Motion.LeaveGround();
};
}
if (!AcDream.Core.Physics.PhysicsObjUpdate.CommitSetPositionTransition(
rm.Body,
resolved.InContact,
resolved.OnWalkable,
resolved.CollisionNormalValid,
resolved.CollisionNormal,
previousContact,
previousOnWalkable,
hitGround,
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<bool>? externalOwnerValid) =>
_physics.IsSpatialRemote(record, remote)
&& record.ObjectClockEpoch == objectClockEpoch
&& ReferenceEquals(record.PhysicsBody, remote.Body)
&& (externalOwnerValid?.Invoke() ?? true);
/// <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 rotates it
/// out by the body's current orientation and post-multiplies the complete
/// relative orientation. The remote tick is its only caller.
/// </summary>
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);
}
/// <summary>
/// #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 (<paramref name="liveCenterX"/>/<paramref name="liveCenterY"/>)
/// rather than snapshotted, since it moves on recentre. Updates
/// <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.
/// Called by the remote tick and the authoritative-position tail.
/// </summary>
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);
}
}