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>
955 lines
48 KiB
C#
955 lines
48 KiB
C#
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 && serverGuid != 0 && serverGuid != _playerServerGuid
|
||
/// && rm.LastServerPosTime > 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>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);
|
||
}
|
||
}
|