acdream/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs
Erik 0c5057c9ff fix #435 (part 1): delete 17 probes that outlived their closed investigations
Each of these was temporary apparatus added to chase one bug, and each was
supposed to be deleted in the commit that fixed it. Fourteen closed issues
later they were still here: #337's support/wire-mesh trio, #171's sticky
timeline, #119's viewer and entity dumps, #113's phantom probe, and a dozen
more. 3,493 lines removed; the client now reads 144 environment variables
instead of 161, and 47 temporary probes remain instead of 64.

This is not only tidying. Every probe leaves a branch on its hot path when
unset, several re-read the environment per call rather than caching, and
the volume buries the diagnostics that are actually load-bearing. It is
also a headless correctness matter: HeadlessStaticStateAudit reflects over
PhysicsDiagnostics' flags to refuse a multi-session host when any is set,
and cannot see probes that live outside that owner.

Four files went entirely — WalkMissDiagnostic.cs, CollisionMeshWireframe.cs
and two test files whose only subject was a deleted probe.
TransitionTypes.SetContactPlane also sheds its CallerMemberName /
CallerLineNumber parameters, which existed solely for #337's cpSrc=
attribution and carried the instruction to strip them with the probe
family; no call site passed them, so no behavior changes. F2's collision
overlay survives and reverts to its proxy-cylinder form, which is what
removing the ACDREAM_WIRE_MESH upgrade means.

LaunchOptionsDocumentationTests earned its keep here: it refused the
deletion until docs/launch-options.md moved the 17 rows into Retired and
the frozen direct-read counts came down (PhysicsEngine.cs to zero,
TransitionTypes.cs 3 to 2). The documentation could not drift during a
cleanup this wide.

The 14 probes that name no owning issue are deliberately NOT deleted.
Nothing records when they became safe to remove, and guessing is how a
future investigation loses apparatus it needed; #435 stays open for their
attribution.

Full hermetic suite 15,321 passed / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 11:41:20 +02:00

1152 lines
61 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;
// 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);
}
/// <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.");
// 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<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);
}
}