refactor(#184): Slice 2a — extract RemotePhysicsUpdater (byte-exact, fork preserved)
Extract the ~690-line per-remote dead-reckoning tick out of the >10k-line GameWindow.TickAnimations into a testable AcDream.App.Physics.RemotePhysicsUpdater (Code Structure Rule 1). The guard in TickAnimations now calls _remotePhysicsUpdater.Tick(rm, ae, dt, _liveCenterX, _liveCenterY); the animation/render half stays in GameWindow. Pure, behaviour-neutral refactor — the Path A (grounded player remotes skip the sweep) / Path B (NPCs + airborne players run it) FORK is preserved verbatim inside the new class. Slice 2b collapses it. Mechanics: - Moved into RemotePhysicsUpdater: the Tick body (verbatim), SyncRemoteShadowToBody (now called back from the NPC UP-branch tail), ApplyPositionManagerDelta, TickRemoteMoveTo, ServerControlledVelocityStaleSeconds, and the diagnostics. - Injected as delegates (kept on GameWindow — they have callers outside the DR loop): GetSetupCylinder (player cylinder + moveto/sticky radii) and ApplyServerControlledVelocityCycle (also called from the UP handler). - AnimatedEntity: private -> internal (matches RemoteMotion) so the extracted class can take it by type. Verified byte-exact: the extracted Tick body reverse-transforms (re-indent +8, undo the 4 delegate/id substitutions) to diff-identical against the original block. Behaviour-neutral: Core 2621 / App 741 green (unchanged), 0 warnings. No visual gate (2a is structure only). Handoff: docs/research/2026-07-07-184-slice2-unify-extract-handoff.md section 3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
4b07b0f541
commit
e1ac56cce9
2 changed files with 847 additions and 760 deletions
817
src/AcDream.App/Physics/RemotePhysicsUpdater.cs
Normal file
817
src/AcDream.App/Physics/RemotePhysicsUpdater.cs
Normal file
|
|
@ -0,0 +1,817 @@
|
||||||
|
using RemoteMotion = AcDream.App.Rendering.GameWindow.RemoteMotion;
|
||||||
|
using AnimatedEntity = AcDream.App.Rendering.GameWindow.AnimatedEntity;
|
||||||
|
|
||||||
|
namespace AcDream.App.Physics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #184 Slice 2a — the per-remote dead-reckoning physics tick, extracted
|
||||||
|
/// verbatim from <c>GameWindow.TickAnimations</c> (Code Structure Rule 1: no
|
||||||
|
/// new feature bodies in the >10k-line <c>GameWindow</c>). One instance is
|
||||||
|
/// owned by <c>GameWindow</c> and called once per animated remote entity per
|
||||||
|
/// frame, from inside the same guard the body used to live under
|
||||||
|
/// (<c>ae.Sequencer != null && serverGuid != 0 && serverGuid != _playerServerGuid
|
||||||
|
/// && rm.LastServerPosTime > 0</c>).
|
||||||
|
///
|
||||||
|
/// <para>Behaviour is byte-for-byte the pre-extraction body: the player/NPC
|
||||||
|
/// FORK is PRESERVED here unchanged — Path A (grounded PLAYER remotes,
|
||||||
|
/// <c>IsPlayerGuid(serverGuid) && !rm.Airborne</c>) advances by the interp
|
||||||
|
/// catch-up and deliberately OMITS the sweep; Path B (NPCs + airborne player
|
||||||
|
/// remotes) runs <c>ResolveWithTransition</c> + shadow-follows-resolved. Slice
|
||||||
|
/// 2b collapses the fork inside this class. See
|
||||||
|
/// <c>docs/research/2026-07-07-184-slice2-unify-extract-handoff.md</c>.</para>
|
||||||
|
///
|
||||||
|
/// <para>Shared helpers that GameWindow also calls elsewhere are injected:
|
||||||
|
/// <c>GetSetupCylinder</c> (a general Setup-dimension helper with ~9 callers,
|
||||||
|
/// incl. the local player's own cylinder — kept on GameWindow) and
|
||||||
|
/// <c>ApplyServerControlledVelocityCycle</c> (anim-cycle selection, also called
|
||||||
|
/// from the UP handler) arrive as delegates. <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 RemotePhysicsUpdater
|
||||||
|
{
|
||||||
|
// Moved from GameWindow (#184 Slice 2a — the DR tick was its only caller).
|
||||||
|
private const double ServerControlledVelocityStaleSeconds = 0.60;
|
||||||
|
|
||||||
|
private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine;
|
||||||
|
private readonly System.Func<uint, AcDream.Core.World.WorldEntity, (float Radius, float Height)> _getSetupCylinder;
|
||||||
|
private readonly System.Action<uint, AnimatedEntity, RemoteMotion, System.Numerics.Vector3> _applyServerControlledVelocityCycle;
|
||||||
|
|
||||||
|
internal RemotePhysicsUpdater(
|
||||||
|
AcDream.Core.Physics.PhysicsEngine physicsEngine,
|
||||||
|
System.Func<uint, AcDream.Core.World.WorldEntity, (float Radius, float Height)> getSetupCylinder,
|
||||||
|
System.Action<uint, AnimatedEntity, RemoteMotion, System.Numerics.Vector3> applyServerControlledVelocityCycle)
|
||||||
|
{
|
||||||
|
_physicsEngine = physicsEngine;
|
||||||
|
_getSetupCylinder = getSetupCylinder;
|
||||||
|
_applyServerControlledVelocityCycle = applyServerControlledVelocityCycle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicated one-liner (GameWindow keeps its own copy — many callers there).
|
||||||
|
private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #184 Slice 2a — the per-remote DR tick (retail <c>UpdateObjectInternal</c>
|
||||||
|
/// shape), verbatim from the former <c>GameWindow.TickAnimations</c> guard
|
||||||
|
/// body. <c>serverGuid</c> + the entity id derive from
|
||||||
|
/// <paramref name="ae"/>.Entity; <paramref name="liveCenterX"/>/<paramref name="liveCenterY"/>
|
||||||
|
/// are passed per-call (they change on streaming recentre — never snapshot
|
||||||
|
/// them in the constructor).
|
||||||
|
/// </summary>
|
||||||
|
public void Tick(RemoteMotion rm, AnimatedEntity ae, float dt, int liveCenterX, int liveCenterY)
|
||||||
|
{
|
||||||
|
uint serverGuid = ae.Entity.ServerGuid;
|
||||||
|
// R5-V2: retail UpdateObjectInternal ticks TargetManager::
|
||||||
|
// HandleTargetting UNCONDITIONALLY per entity, BEFORE the
|
||||||
|
// movement managers' UseTime. This is where this entity, as a
|
||||||
|
// watched target, pushes its position to its voyeurs (any entity
|
||||||
|
// moving-to it), and where its own target-info staleness times
|
||||||
|
// out. Runs for every remote regardless of the grounded/airborne
|
||||||
|
// branch below (which drive MoveToManager.UseTime via
|
||||||
|
// TickRemoteMoveTo). No-op for entities with no target + no voyeurs.
|
||||||
|
rm.Host?.HandleTargetting();
|
||||||
|
|
||||||
|
if (IsPlayerGuid(serverGuid) && !rm.Airborne)
|
||||||
|
{
|
||||||
|
// ── L.3 M2/M3 (2026-05-05): queue + anim chase for grounded player remotes ──
|
||||||
|
//
|
||||||
|
// Per retail spec (docs/research/2026-05-04-l3-port/01-per-tick.md +
|
||||||
|
// 04-interp-manager.md +
|
||||||
|
// 05-position-manager-and-partarray.md):
|
||||||
|
//
|
||||||
|
// - For a grounded REMOTE player, m_velocityVector stays at 0.
|
||||||
|
// - apply_current_movement is NEVER called per tick on remotes
|
||||||
|
// (it's the local-player-only velocity feed).
|
||||||
|
// - UpdatePhysicsInternal's translation step is gated on
|
||||||
|
// velocity² > 0, so it's a no-op when body.Velocity = 0.
|
||||||
|
// - ResolveWithTransition is NOT called — the server already
|
||||||
|
// collision-resolved the broadcast position.
|
||||||
|
// - Per-tick body translation per retail UpdatePositionInternal:
|
||||||
|
// 1. CPartArray::Update writes anim root motion (body-local
|
||||||
|
// seqVel × dt) into the local frame.
|
||||||
|
// 2. PositionManager::adjust_offset OVERWRITES the local
|
||||||
|
// frame's origin with the queue catch-up vector when
|
||||||
|
// the queue is active and the head is not yet reached
|
||||||
|
// — REPLACE, not additive.
|
||||||
|
// 3. Frame::combine composes the local frame with the
|
||||||
|
// body's world pose.
|
||||||
|
// Net: catch-up replaces anim during the chase phase, anim
|
||||||
|
// stands when the queue is empty / head reached. PositionManager.
|
||||||
|
// ComputeOffset implements this exact REPLACE dichotomy.
|
||||||
|
//
|
||||||
|
// Airborne player remotes (rm.Airborne) and NPCs fall through to
|
||||||
|
// the legacy path below — unchanged from main per the M2 plan.
|
||||||
|
System.Numerics.Vector3 seqVel = ae.Sequencer?.CurrentVelocity
|
||||||
|
?? System.Numerics.Vector3.Zero;
|
||||||
|
System.Numerics.Vector3 seqOmega = ae.Sequencer?.CurrentOmega
|
||||||
|
?? System.Numerics.Vector3.Zero;
|
||||||
|
|
||||||
|
// Step 1: transient flags (Contact + OnWalkable for grounded;
|
||||||
|
// Active always so UpdatePhysicsInternal doesn't early-return).
|
||||||
|
if (!rm.Airborne)
|
||||||
|
{
|
||||||
|
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
|
||||||
|
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
|
||||||
|
| AcDream.Core.Physics.TransientStateFlags.Active;
|
||||||
|
|
||||||
|
// For grounded remotes the body should not be carrying
|
||||||
|
// velocity — retail's m_velocityVector for a remote is
|
||||||
|
// 0 unless the server explicitly pushed one. Clear any
|
||||||
|
// stale velocity from a prior airborne arc so
|
||||||
|
// UpdatePhysicsInternal doesn't double-apply it on top
|
||||||
|
// of the seqVel-driven ComputeOffset translation below.
|
||||||
|
rm.Body.Velocity = System.Numerics.Vector3.Zero;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active;
|
||||||
|
}
|
||||||
|
|
||||||
|
// R4-V5 glide fix (2026-07-03 user report: a retail
|
||||||
|
// player using an object GLIDES to it — position moves
|
||||||
|
// via the UP queue but no walk/run legs): remote
|
||||||
|
// PLAYERS' MoveToManagers were armed by mt-6 but never
|
||||||
|
// TICKED — the V4 UseTime slot lived only in the
|
||||||
|
// NPC/legacy branch below, gated !IsPlayerGuid
|
||||||
|
// (inherited from the deleted RemoteMoveToDriver's
|
||||||
|
// NPC-only scope). Retail ticks every entity's
|
||||||
|
// MovementManager (UpdateObjectInternal has no entity-
|
||||||
|
// class fork). The manager's dispatches produce the
|
||||||
|
// locomotion cycle through the funnel sink (the LEGS);
|
||||||
|
// position stays queue-chased per the L.3 M2 spec, so
|
||||||
|
// the two compose exactly like an NPC's tick.
|
||||||
|
TickRemoteMoveTo(rm);
|
||||||
|
|
||||||
|
// Step 2 (M3): queue + anim translation via PositionManager.
|
||||||
|
// ComputeOffset returns:
|
||||||
|
// - Vector3.Zero when queue is empty AND seqVel is zero
|
||||||
|
// (idle remote between UPs after head reached) — body
|
||||||
|
// stays still.
|
||||||
|
// - Direction × min(catchUpSpeed × dt, dist) when the
|
||||||
|
// queue is active and head is not reached — body chases
|
||||||
|
// the head waypoint at up to 2× motion-table max speed
|
||||||
|
// (REPLACES anim for this frame).
|
||||||
|
// - Anim root motion (seqVel × dt rotated into world) when
|
||||||
|
// the queue is empty OR head is within DesiredDistance —
|
||||||
|
// body advances with the locomotion cycle's baked
|
||||||
|
// velocity, keeping legs and body pace synchronized.
|
||||||
|
// - Blip-to-tail (tail − body) when fail_count > 3.
|
||||||
|
float maxSpeed = rm.Motion.GetMaxSpeed();
|
||||||
|
// Slope-staircase fix (2026-05-05): sample terrain normal
|
||||||
|
// at the body's current XY so PositionManager can project
|
||||||
|
// the seqVel-only fallback onto the local slope. Without
|
||||||
|
// this, the queue-empty interval between UPs left Z flat
|
||||||
|
// (anim cycles bake Z=0 body-local) — visible ~5 Hz
|
||||||
|
// staircase when a remote runs up/down hills. The
|
||||||
|
// projection is a no-op on flat ground.
|
||||||
|
System.Numerics.Vector3? terrainNormal = _physicsEngine.SampleTerrainNormal(
|
||||||
|
rm.Body.Position.X, rm.Body.Position.Y);
|
||||||
|
|
||||||
|
System.Numerics.Vector3 bodyPosBefore = rm.Body.Position;
|
||||||
|
System.Numerics.Vector3 offset = rm.Position.ComputeOffset(
|
||||||
|
dt: (double)dt,
|
||||||
|
currentBodyPosition: rm.Body.Position,
|
||||||
|
seqVel: seqVel,
|
||||||
|
ori: rm.Body.Orientation,
|
||||||
|
interp: rm.Interp,
|
||||||
|
maxSpeed: maxSpeed,
|
||||||
|
terrainNormal: terrainNormal);
|
||||||
|
// R5-V3 (#171): retail chains Interpolation → Sticky over
|
||||||
|
// ONE shared delta frame (PositionManager::adjust_offset
|
||||||
|
// 0x00555190). The combiner's catch-up IS acdream's
|
||||||
|
// interpolation stage, so its offset SEEDS the frame
|
||||||
|
// (converted to mover-local) and StickyManager::
|
||||||
|
// adjust_offset OVERWRITES it when armed+initialized
|
||||||
|
// (0x00555430 assigns m_fOrigin rather than accumulating).
|
||||||
|
// With no stick armed the frame comes back untouched and
|
||||||
|
// this reduces to the pre-V3 `Position += offset`.
|
||||||
|
if (rm.Host is { } plHost)
|
||||||
|
{
|
||||||
|
var pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame
|
||||||
|
{
|
||||||
|
Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec(
|
||||||
|
rm.Body.Orientation, offset),
|
||||||
|
};
|
||||||
|
plHost.PositionManager.AdjustOffset(pmDelta, dt);
|
||||||
|
ApplyPositionManagerDelta(rm.Body, pmDelta);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
rm.Body.Position += offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slope-staircase diagnostic — gated on ACDREAM_SLOPE_DIAG=1.
|
||||||
|
// Prints per-tick body Z trajectory + queue state + projected
|
||||||
|
// offset.Z so we can grep before/after the fix and confirm Z
|
||||||
|
// changes continuously between UPs on slopes (no flat
|
||||||
|
// intervals followed by snaps).
|
||||||
|
if (System.Environment.GetEnvironmentVariable("ACDREAM_SLOPE_DIAG") == "1")
|
||||||
|
{
|
||||||
|
bool queueActive = rm.Interp.IsActive;
|
||||||
|
float nz = terrainNormal?.Z ?? 1.0f;
|
||||||
|
System.Console.WriteLine(
|
||||||
|
$"[SLOPE] guid={serverGuid:X8} bodyZ={bodyPosBefore.Z:F3}->{rm.Body.Position.Z:F3} "
|
||||||
|
+ $"offset=({offset.X:F3},{offset.Y:F3},{offset.Z:F3}) "
|
||||||
|
+ $"queue={queueActive} cpN.Z={nz:F3}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2.5: angular velocity → body orientation. Prefer
|
||||||
|
// ObservedOmega (set explicitly in OnLiveMotionUpdated from
|
||||||
|
// the wire's TurnCommand + signed TurnSpeed) over the
|
||||||
|
// sequencer's synthesized omega: when the player runs in
|
||||||
|
// a circle ACE broadcasts ForwardCommand=RunForward AND
|
||||||
|
// TurnCommand=TurnLeft on the same UpdateMotion. The
|
||||||
|
// sequencer's animCycle picker chooses RunForward (legs
|
||||||
|
// running), whose synthesized CurrentOmega is zero. Body
|
||||||
|
// would not rotate between UPs and body.Velocity stays in
|
||||||
|
// an out-of-date world direction, producing the
|
||||||
|
// user-reported "rectangle when running circles" effect.
|
||||||
|
// ObservedOmega has the correct turn rate even when the
|
||||||
|
// visible cycle is RunForward.
|
||||||
|
System.Numerics.Vector3 omegaToApply =
|
||||||
|
rm.ObservedOmega.LengthSquared() > 1e-9f
|
||||||
|
? rm.ObservedOmega
|
||||||
|
: seqOmega;
|
||||||
|
if (omegaToApply.LengthSquared() > 1e-9f)
|
||||||
|
{
|
||||||
|
float angleDelta = omegaToApply.Length() * (float)dt;
|
||||||
|
System.Numerics.Vector3 axis = System.Numerics.Vector3.Normalize(omegaToApply);
|
||||||
|
var rot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angleDelta);
|
||||||
|
rm.Body.Orientation = System.Numerics.Quaternion.Normalize(
|
||||||
|
System.Numerics.Quaternion.Concatenate(rm.Body.Orientation, rot));
|
||||||
|
|
||||||
|
// Diagnostic (ACDREAM_REMOTE_VEL_DIAG=1): print seqOmega direction
|
||||||
|
// once per remote per ~1 second so we can confirm whether the omega
|
||||||
|
// sign actually being applied matches the retail-observed turn
|
||||||
|
// direction. Z>0 = CCW (TurnLeft); Z<0 = CW (TurnRight).
|
||||||
|
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
|
||||||
|
{
|
||||||
|
double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
||||||
|
if (nowSec - rm.LastOmegaDiagLogTime > 0.5)
|
||||||
|
{
|
||||||
|
uint seqMotion = ae.Sequencer?.CurrentMotion ?? 0;
|
||||||
|
System.Console.WriteLine(
|
||||||
|
$"[OMEGA_DIAG] guid={serverGuid:X8} motion=0x{seqMotion:X8} "
|
||||||
|
+ $"omegaApplied.Z={omegaToApply.Z:F3} "
|
||||||
|
+ $"(seq.Z={seqOmega.Z:F3} obs.Z={rm.ObservedOmega.Z:F3}) "
|
||||||
|
+ $"(Z>0=CCW=TurnLeft, Z<0=CW=TurnRight)");
|
||||||
|
rm.LastOmegaDiagLogTime = nowSec;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: calc_acceleration sets body.Acceleration from the Gravity flag
|
||||||
|
// (mirrors retail CPhysicsObj::calc_acceleration @ FUN_00511420, called
|
||||||
|
// per-frame in update_object). Without this, body.Acceleration stays stale
|
||||||
|
// or zero → gravity never decays jump velocity → endless rise on jumps.
|
||||||
|
rm.Body.calc_acceleration();
|
||||||
|
|
||||||
|
// Step 4: physics integration (Euler: pos += vel*dt + 0.5*accel*dt²).
|
||||||
|
rm.Body.UpdatePhysicsInternal(dt);
|
||||||
|
|
||||||
|
// Step 4b INTENTIONALLY OMITTED in M2:
|
||||||
|
// ResolveWithTransition is NOT called — the server has
|
||||||
|
// already collision-resolved the broadcast position, and
|
||||||
|
// running our sweep on tiny per-frame queue catch-up deltas
|
||||||
|
// amplifies micro-bounces into visible position blips
|
||||||
|
// (issue #40 staircase + flat-ground blips). Per retail
|
||||||
|
// spec the per-tick body advance for a remote is purely
|
||||||
|
// the queue catch-up; collision is the sender's problem.
|
||||||
|
//
|
||||||
|
// Step 5 (landing fallback) is unreachable in this branch —
|
||||||
|
// we're gated on !rm.Airborne. Airborne player remotes fall
|
||||||
|
// through to the legacy path below where K-fix15 still fires.
|
||||||
|
|
||||||
|
// Step 6: speed-overshoot diagnostic (ACDREAM_REMOTE_VEL_DIAG=1).
|
||||||
|
// Track the maximum sequencer velocity magnitude seen since
|
||||||
|
// the last UpdatePosition arrival (carried on the
|
||||||
|
// RemoteMotion struct), then on each UP arrival the
|
||||||
|
// OnLivePositionUpdated path prints the comparison against
|
||||||
|
// the server's actual broadcast pace
|
||||||
|
// ((LastServerPos - PrevServerPos) / Δt). This guarantees
|
||||||
|
// both sides are sampled during the same window and the
|
||||||
|
// ratio reflects the real overshoot.
|
||||||
|
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
|
||||||
|
{
|
||||||
|
// body.Velocity is now the source of bulk translation
|
||||||
|
// (set above by apply_current_movement). Track its
|
||||||
|
// magnitude so VEL_DIAG can compare against the actual
|
||||||
|
// server broadcast pace.
|
||||||
|
float seqSpeedNow = rm.Body.Velocity.Length();
|
||||||
|
if (seqSpeedNow > rm.MaxSeqSpeedSinceLastUP)
|
||||||
|
rm.MaxSeqSpeedSinceLastUP = seqSpeedNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
ae.Entity.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty
|
||||||
|
if (rm.CellId != 0)
|
||||||
|
ae.Entity.ParentCellId = rm.CellId;
|
||||||
|
ae.Entity.Rotation = rm.Body.Orientation;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// ── LEGACY PATH (UNCHANGED — kept until Task 8 cleanup) ──
|
||||||
|
//
|
||||||
|
// Stop detection is handled explicitly on packet receipt:
|
||||||
|
// - UpdateMotion with ForwardCommand flag CLEARED → Ready.
|
||||||
|
// - UpdatePosition with HasVelocity flag CLEARED → StopCompletely.
|
||||||
|
// Both map to retail's "flag-absent = Invalid = reset to
|
||||||
|
// default" semantics (FUN_0051F260 bulk-copy). No timer-based
|
||||||
|
// inference needed — the server sends the right signal every
|
||||||
|
// time a remote stops.
|
||||||
|
|
||||||
|
// Retail per-tick motion pipeline applied to every remote.
|
||||||
|
// Mirrors retail FUN_00515020 update_object → FUN_00513730
|
||||||
|
// UpdatePositionInternal → FUN_005111D0 UpdatePhysicsInternal:
|
||||||
|
//
|
||||||
|
// 1. apply_current_movement (FUN_00529210) — recomputes
|
||||||
|
// body.Velocity from InterpretedState via get_state_velocity.
|
||||||
|
// 2. Pull omega from the sequencer (baked MotionData.Omega
|
||||||
|
// for TurnRight / TurnLeft cycles, scaled by speedMod).
|
||||||
|
// 3. body.update_object(now) — Euler-integrates
|
||||||
|
// position += Velocity × dt + 0.5 × Accel × dt² AND
|
||||||
|
// orientation += omega × dt.
|
||||||
|
//
|
||||||
|
// On UpdatePosition receipt we hard-snap body.Position and
|
||||||
|
// body.Orientation — if integration matched server physics,
|
||||||
|
// each snap is small/invisible.
|
||||||
|
double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
||||||
|
|
||||||
|
// 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. Matches Path A's grounded model (:10113); clearing
|
||||||
|
// velocity mirrors Path A (:10125).
|
||||||
|
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;
|
||||||
|
_applyServerControlledVelocityCycle(
|
||||||
|
serverGuid,
|
||||||
|
ae,
|
||||||
|
rm,
|
||||||
|
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.
|
||||||
|
TickRemoteMoveTo(rm);
|
||||||
|
}
|
||||||
|
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: integrate rotation manually per tick. We can't
|
||||||
|
// rely on PhysicsBody.update_object here — its MinQuantum
|
||||||
|
// gate (1/30 s) causes it to SKIP integration when our
|
||||||
|
// 60fps render dt (~0.016s) is below the quantum, meaning
|
||||||
|
// rotation never advances. Measured snap per UP was ~129°
|
||||||
|
// = the full expected 1s × 2.24 rad/s, confirming zero
|
||||||
|
// between-tick rotation.
|
||||||
|
//
|
||||||
|
// Manual integration matches retail's FUN_005256b0
|
||||||
|
// apply_physics (Orientation *= quat(ω × dt)). Use
|
||||||
|
// ObservedOmega derived from server UP rotation deltas so
|
||||||
|
// the rate exactly matches server physics — hard-snap on
|
||||||
|
// next UP becomes invisible by construction.
|
||||||
|
rm.Body.Omega = System.Numerics.Vector3.Zero; // don't double-integrate in update_object
|
||||||
|
if (rm.ObservedOmega.LengthSquared() > 1e-8f)
|
||||||
|
{
|
||||||
|
float omegaMag = rm.ObservedOmega.Length();
|
||||||
|
var axis = rm.ObservedOmega / omegaMag;
|
||||||
|
float angle = omegaMag * dt;
|
||||||
|
var deltaRot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angle);
|
||||||
|
rm.Body.Orientation = System.Numerics.Quaternion.Normalize(
|
||||||
|
System.Numerics.Quaternion.Multiply(rm.Body.Orientation, deltaRot));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 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.cs:358 calls UpdatePhysicsInternal
|
||||||
|
// directly for the same reason. Remote motion mirrors that.
|
||||||
|
// Omega is already integrated manually above, so we zero it
|
||||||
|
// here to prevent UpdatePhysicsInternal's own omega pass from
|
||||||
|
// double-integrating.
|
||||||
|
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;
|
||||||
|
if (!rm.Airborne)
|
||||||
|
{
|
||||||
|
System.Numerics.Vector3 seqVelNpc = ae.Sequencer?.CurrentVelocity
|
||||||
|
?? System.Numerics.Vector3.Zero;
|
||||||
|
float maxSpeedNpc = rm.Motion.GetMaxSpeed();
|
||||||
|
System.Numerics.Vector3? terrainNormalNpc =
|
||||||
|
_physicsEngine.SampleTerrainNormal(
|
||||||
|
rm.Body.Position.X, rm.Body.Position.Y);
|
||||||
|
System.Numerics.Vector3 offsetNpc = rm.Position.ComputeOffset(
|
||||||
|
dt: (double)dt,
|
||||||
|
currentBodyPosition: rm.Body.Position,
|
||||||
|
seqVel: seqVelNpc,
|
||||||
|
ori: rm.Body.Orientation,
|
||||||
|
interp: rm.Interp,
|
||||||
|
maxSpeed: maxSpeedNpc,
|
||||||
|
terrainNormal: terrainNormalNpc);
|
||||||
|
pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame
|
||||||
|
{
|
||||||
|
Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec(
|
||||||
|
rm.Body.Orientation, offsetNpc),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame();
|
||||||
|
}
|
||||||
|
npcHost.PositionManager.AdjustOffset(pmDelta, dt);
|
||||||
|
ApplyPositionManagerDelta(rm.Body, pmDelta);
|
||||||
|
}
|
||||||
|
else if (!rm.Airborne)
|
||||||
|
{
|
||||||
|
// No PositionManager host yet (pre-binding): apply the catch-up
|
||||||
|
// directly, matching Path A's fallback (:10202).
|
||||||
|
System.Numerics.Vector3 seqVelNpc = ae.Sequencer?.CurrentVelocity
|
||||||
|
?? System.Numerics.Vector3.Zero;
|
||||||
|
float maxSpeedNpc = rm.Motion.GetMaxSpeed();
|
||||||
|
System.Numerics.Vector3? terrainNormalNpc =
|
||||||
|
_physicsEngine.SampleTerrainNormal(
|
||||||
|
rm.Body.Position.X, rm.Body.Position.Y);
|
||||||
|
rm.Body.Position += rm.Position.ComputeOffset(
|
||||||
|
dt: (double)dt,
|
||||||
|
currentBodyPosition: rm.Body.Position,
|
||||||
|
seqVel: seqVelNpc,
|
||||||
|
ori: rm.Body.Orientation,
|
||||||
|
interp: rm.Interp,
|
||||||
|
maxSpeed: maxSpeedNpc,
|
||||||
|
terrainNormal: terrainNormalNpc);
|
||||||
|
}
|
||||||
|
rm.Body.calc_acceleration();
|
||||||
|
rm.Body.UpdatePhysicsInternal(dt);
|
||||||
|
var postIntegratePos = rm.Body.Position;
|
||||||
|
|
||||||
|
// 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 && _physicsEngine.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. Retail seeds
|
||||||
|
// the transition from the object's own Setup sphere list ×
|
||||||
|
// m_scale (CPhysicsObj::transition 0x00512dc0 → init_sphere;
|
||||||
|
// ObjScale from set_description 0x00514f40). This narrows
|
||||||
|
// TS-46 (remotes no longer use human dims); the two-scalar
|
||||||
|
// API is still a lossy stand-in for retail's full (≤2)
|
||||||
|
// sphere list, and stepUp/stepDown stay 0.4 (retail derives
|
||||||
|
// those from the Setup too — an adjacent divergence left as-is).
|
||||||
|
// Fallback to the human capsule for a shapeless / unresolvable
|
||||||
|
// Setup (GetSetupCylinder returns (0,0)); a zero radius would
|
||||||
|
// degenerate the sweep.
|
||||||
|
var (deR, deH) = _getSetupCylinder(serverGuid, ae.Entity);
|
||||||
|
if (deR < 0.05f) { deR = 0.48f; deH = 1.835f; }
|
||||||
|
var resolveResult = _physicsEngine.ResolveWithTransition(
|
||||||
|
preIntegratePos, postIntegratePos, rm.CellId,
|
||||||
|
sphereRadius: deR,
|
||||||
|
sphereHeight: deH,
|
||||||
|
stepUpHeight: 0.4f, // L.2.3a: retail human-scale, was 2.0f
|
||||||
|
stepDownHeight: 0.4f, // L.2.3a: retail human-scale, was 0.04f
|
||||||
|
// 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 dead-reckoning should exercise the same
|
||||||
|
// edge/cliff branch as local movement.
|
||||||
|
moverFlags: AcDream.Core.Physics.ObjectInfoState.EdgeSlide,
|
||||||
|
// 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: ae.Entity.Id);
|
||||||
|
|
||||||
|
rm.Body.Position = resolveResult.Position;
|
||||||
|
if (resolveResult.CellId != 0)
|
||||||
|
rm.CellId = 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 cheap
|
||||||
|
// MOVEMENT-GATED (#184 review): re-flood only when the resolved
|
||||||
|
// body moved > ~1 cm since the last shadow registration. This is
|
||||||
|
// SAFE now that the per-UP :5669 sync is players-only — the NPC
|
||||||
|
// shadow's only writers are 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.)
|
||||||
|
if (System.Numerics.Vector3.DistanceSquared(
|
||||||
|
rm.Body.Position, rm.LastShadowSyncPos) > 1e-4f)
|
||||||
|
{
|
||||||
|
SyncRemoteShadowToBody(ae.Entity.Id, rm, liveCenterX, liveCenterY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// #173 (2026-07-05): retail CPhysicsObj::handle_all_collisions
|
||||||
|
// (pc:282699-282715) runs after EVERY SetPositionInternal —
|
||||||
|
// remote objects included; a VectorUpdate-launched jump arc
|
||||||
|
// is ordinary object physics in retail. acdream ported the
|
||||||
|
// velocity reflection for the LOCAL player only (L.3a,
|
||||||
|
// PlayerMovementController ~:940), so a remote jumping into
|
||||||
|
// a dungeon ceiling had its POSITION pinned by the sweep
|
||||||
|
// while its +Z velocity kept integrating — the char hovered
|
||||||
|
// at the roof until gravity burned the arc off, landing
|
||||||
|
// late (user report, 0x0007 dungeon). Mirror the local
|
||||||
|
// site exactly:
|
||||||
|
// v_new = v − (1 + elasticity)·dot(v, n)·n
|
||||||
|
// with the AD-25 suppression (bounce only when airborne
|
||||||
|
// before AND after — corridor slides and landings don't
|
||||||
|
// reflect; the landing snap below keeps its
|
||||||
|
// `Velocity.Z <= 0` gate intact). Inelastic movers
|
||||||
|
// (missiles, later) zero out instead.
|
||||||
|
if (resolveResult.CollisionNormalValid)
|
||||||
|
{
|
||||||
|
bool prevOnWalkable = rm.Body.OnWalkable;
|
||||||
|
bool nowOnWalkable = resolveResult.IsOnGround;
|
||||||
|
bool applyBounce = rm.Body.State.HasFlag(
|
||||||
|
AcDream.Core.Physics.PhysicsStateFlags.Sledding)
|
||||||
|
? !(prevOnWalkable && nowOnWalkable)
|
||||||
|
: (!prevOnWalkable && !nowOnWalkable);
|
||||||
|
if (applyBounce)
|
||||||
|
{
|
||||||
|
if (rm.Body.State.HasFlag(
|
||||||
|
AcDream.Core.Physics.PhysicsStateFlags.Inelastic))
|
||||||
|
{
|
||||||
|
rm.Body.Velocity = System.Numerics.Vector3.Zero;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var vRem = rm.Body.Velocity;
|
||||||
|
var nRem = resolveResult.CollisionNormal;
|
||||||
|
float dotVN = System.Numerics.Vector3.Dot(vRem, nRem);
|
||||||
|
if (dotVN < 0f)
|
||||||
|
{
|
||||||
|
rm.Body.Velocity =
|
||||||
|
vRem + nRem * (-(dotVN * (rm.Body.Elasticity + 1f)));
|
||||||
|
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
||||||
|
Console.WriteLine(
|
||||||
|
$"VU.bounce guid=0x{serverGuid:X8} n=({nRem.X:F2},{nRem.Y:F2},{nRem.Z:F2}) vZ {vRem.Z:F2}->{rm.Body.Velocity.Z:F2}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
rm.Movement.HitGround();
|
||||||
|
// DR bookkeeping only (partner of the jump-start
|
||||||
|
// `State |= Gravity`): stops the per-tick gravity
|
||||||
|
// integration for the grounded body.
|
||||||
|
rm.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity;
|
||||||
|
|
||||||
|
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
||||||
|
Console.WriteLine($"VU.land guid=0x{serverGuid:X8} Z={rm.Body.Position.Z:F2}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ae.Entity.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty
|
||||||
|
if (rm.CellId != 0)
|
||||||
|
ae.Entity.ParentCellId = rm.CellId;
|
||||||
|
ae.Entity.Rotation = rm.Body.Orientation;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
rm.Host?.PositionManager.UseTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R4-V5 / R5-V2: the per-tick <see cref="AcDream.Core.Physics.Motion.MovementManager"/>
|
||||||
|
/// drive (retail <c>MovementManager::UseTime</c> 0x005242f0 — the moveto
|
||||||
|
/// side's steering, arrival, fail-distance; R5-V5 facade relay). Moved from
|
||||||
|
/// GameWindow (#184 Slice 2a); the DR tick is its only caller.
|
||||||
|
/// </summary>
|
||||||
|
private static void TickRemoteMoveTo(RemoteMotion rm)
|
||||||
|
{
|
||||||
|
rm.Movement.UseTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R5-V3 (#171): apply a <see cref="AcDream.Core.Physics.Motion.MotionDeltaFrame"/>
|
||||||
|
/// written by <c>PositionManager.AdjustOffset</c> onto a body — acdream's
|
||||||
|
/// stand-in for retail's <c>Frame::combine</c> in
|
||||||
|
/// <c>CPhysicsObj::UpdatePositionInternal</c> (0x00512c30, combine
|
||||||
|
/// @0x00512d22). The delta's Origin is mover-LOCAL (sticky writes
|
||||||
|
/// <c>globaltolocalvec</c> output — 0x00555430), so combining = rotating it
|
||||||
|
/// out by the body orientation. An untouched (identity) rotation means "no
|
||||||
|
/// turn"; the P5 pin (identity quaternion = heading 0) makes compass addition
|
||||||
|
/// the exact frame-combine here. Moved from GameWindow (#184 Slice 2a); the
|
||||||
|
/// DR 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.MoveToMath.SetHeading(
|
||||||
|
body.Orientation,
|
||||||
|
AcDream.Core.Physics.Motion.MoveToMath.GetHeading(body.Orientation)
|
||||||
|
+ delta.GetHeading());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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"/> so callers can movement-gate.
|
||||||
|
/// Moved from GameWindow (#184 Slice 2a); called by the DR tick AND the NPC
|
||||||
|
/// UP-branch tail.
|
||||||
|
/// </summary>
|
||||||
|
public void SyncRemoteShadowToBody(uint entityId, RemoteMotion rm, int liveCenterX, int liveCenterY)
|
||||||
|
{
|
||||||
|
int shLbX = (int)((rm.CellId >> 24) & 0xFFu);
|
||||||
|
int shLbY = (int)((rm.CellId >> 16) & 0xFFu);
|
||||||
|
float shOffX = (shLbX - liveCenterX) * 192f;
|
||||||
|
float shOffY = (shLbY - liveCenterY) * 192f;
|
||||||
|
_physicsEngine.ShadowObjects.UpdatePosition(
|
||||||
|
entityId, rm.Body.Position, rm.Body.Orientation,
|
||||||
|
shOffX, shOffY, rm.CellId, seedCellId: rm.CellId);
|
||||||
|
rm.LastShadowSyncPos = rm.Body.Position;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -151,6 +151,12 @@ public sealed class GameWindow : IDisposable
|
||||||
// ConcurrentDictionary inside makes cross-thread access safe.
|
// ConcurrentDictionary inside makes cross-thread access safe.
|
||||||
private readonly AcDream.Core.Physics.PhysicsDataCache _physicsDataCache = new();
|
private readonly AcDream.Core.Physics.PhysicsDataCache _physicsDataCache = new();
|
||||||
|
|
||||||
|
// #184 Slice 2a: the per-remote dead-reckoning tick, extracted out of the
|
||||||
|
// >10k-line TickAnimations (Code Structure Rule 1). Assigned in the ctor
|
||||||
|
// once the injected shared helpers exist as method groups (GetSetupCylinder,
|
||||||
|
// ApplyServerControlledVelocityCycle). See RemotePhysicsUpdater.
|
||||||
|
private readonly AcDream.App.Physics.RemotePhysicsUpdater _remotePhysicsUpdater;
|
||||||
|
|
||||||
// Step 4: portal-based interior cell visibility.
|
// Step 4: portal-based interior cell visibility.
|
||||||
private readonly CellVisibility _cellVisibility = new();
|
private readonly CellVisibility _cellVisibility = new();
|
||||||
|
|
||||||
|
|
@ -270,7 +276,10 @@ public sealed class GameWindow : IDisposable
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly AcDream.App.Rendering.Wb.EntityClassificationCache _classificationCache = new();
|
private readonly AcDream.App.Rendering.Wb.EntityClassificationCache _classificationCache = new();
|
||||||
|
|
||||||
private sealed class AnimatedEntity
|
// #184 Slice 2a: internal (was private) so the extracted
|
||||||
|
// RemotePhysicsUpdater in AcDream.App.Physics can take it by type. Matches
|
||||||
|
// RemoteMotion's existing internal visibility.
|
||||||
|
internal sealed class AnimatedEntity
|
||||||
{
|
{
|
||||||
public required AcDream.Core.World.WorldEntity Entity;
|
public required AcDream.Core.World.WorldEntity Entity;
|
||||||
public required DatReaderWriter.DBObjs.Setup Setup;
|
public required DatReaderWriter.DBObjs.Setup Setup;
|
||||||
|
|
@ -1030,7 +1039,8 @@ public sealed class GameWindow : IDisposable
|
||||||
private readonly Dictionary<uint, AcDream.Core.Physics.Motion.IPhysicsObjHost> _physicsHosts = new();
|
private readonly Dictionary<uint, AcDream.Core.Physics.Motion.IPhysicsObjHost> _physicsHosts = new();
|
||||||
|
|
||||||
private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u;
|
private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u;
|
||||||
private const double ServerControlledVelocityStaleSeconds = 0.60;
|
// ServerControlledVelocityStaleSeconds moved to RemotePhysicsUpdater (#184
|
||||||
|
// Slice 2a — the DR tick's stale-velocity anim-stop was its only user).
|
||||||
private int _liveSpawnReceived; // diagnostics
|
private int _liveSpawnReceived; // diagnostics
|
||||||
private int _liveSpawnHydrated;
|
private int _liveSpawnHydrated;
|
||||||
private int _liveDropReasonNoPos;
|
private int _liveDropReasonNoPos;
|
||||||
|
|
@ -1053,6 +1063,13 @@ public sealed class GameWindow : IDisposable
|
||||||
_uiRegistry = uiRegistry;
|
_uiRegistry = uiRegistry;
|
||||||
SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable);
|
SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable);
|
||||||
LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook);
|
LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook);
|
||||||
|
// #184 Slice 2a: the extracted per-remote DR tick. Shares GameWindow's
|
||||||
|
// PhysicsEngine; the two helpers it also needs but that have callers
|
||||||
|
// outside the DR loop (GetSetupCylinder — the player's own cylinder +
|
||||||
|
// moveto/sticky radii; ApplyServerControlledVelocityCycle — the UP
|
||||||
|
// handler) stay on GameWindow and are injected as delegates.
|
||||||
|
_remotePhysicsUpdater = new AcDream.App.Physics.RemotePhysicsUpdater(
|
||||||
|
_physicsEngine, GetSetupCylinder, ApplyServerControlledVelocityCycle);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -4591,56 +4608,10 @@ public sealed class GameWindow : IDisposable
|
||||||
return (setup.Radius * scale, setup.Height * scale);
|
return (setup.Radius * scale, setup.Height * scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
// #184 Slice 2a: ApplyPositionManagerDelta + SyncRemoteShadowToBody moved to
|
||||||
/// R5-V3 (#171): apply a <see cref="AcDream.Core.Physics.Motion.MotionDeltaFrame"/>
|
// AcDream.App.Physics.RemotePhysicsUpdater. ApplyPositionManagerDelta had no
|
||||||
/// written by <c>PositionManager.AdjustOffset</c> onto a body — acdream's
|
// caller outside the DR tick; SyncRemoteShadowToBody is now called back via
|
||||||
/// stand-in for retail's <c>Frame::combine</c> in
|
// _remotePhysicsUpdater from the NPC UP-branch tail (below).
|
||||||
/// <c>CPhysicsObj::UpdatePositionInternal</c> (0x00512c30, combine
|
|
||||||
/// @0x00512d22). The delta's Origin is mover-LOCAL (sticky writes
|
|
||||||
/// <c>globaltolocalvec</c> output — 0x00555430), so combining = rotating it
|
|
||||||
/// out by the body orientation. The rotation carries a RELATIVE heading;
|
|
||||||
/// an untouched (identity) rotation means "no turn" — retail distinguishes
|
|
||||||
/// an unwritten offset rotation from <c>set_heading(0)</c> by the identity
|
|
||||||
/// VALUE, not the angle, and the P5 pin (identity quaternion = heading 0)
|
|
||||||
/// makes compass addition the exact frame-combine here.
|
|
||||||
/// </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.MoveToMath.SetHeading(
|
|
||||||
body.Orientation,
|
|
||||||
AcDream.Core.Physics.Motion.MoveToMath.GetHeading(body.Orientation)
|
|
||||||
+ delta.GetHeading());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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); this is the acdream
|
|
||||||
/// equivalent driven from the remote DR loop. worldOffset is recomputed from
|
|
||||||
/// <paramref name="rm"/>.CellId + the streaming centre, exactly as the UP
|
|
||||||
/// handler derives <c>origin</c> (:5618) — and is DEAD anyway when seedCellId
|
|
||||||
/// is supplied (it only feeds the short-circuited DeriveOutdoorSeed). Updates
|
|
||||||
/// <see cref="RemoteMotion.LastShadowSyncPos"/> so callers can movement-gate.
|
|
||||||
/// </summary>
|
|
||||||
private void SyncRemoteShadowToBody(uint entityId, RemoteMotion rm)
|
|
||||||
{
|
|
||||||
int shLbX = (int)((rm.CellId >> 24) & 0xFFu);
|
|
||||||
int shLbY = (int)((rm.CellId >> 16) & 0xFFu);
|
|
||||||
float shOffX = (shLbX - _liveCenterX) * 192f;
|
|
||||||
float shOffY = (shLbY - _liveCenterY) * 192f;
|
|
||||||
_physicsEngine.ShadowObjects.UpdatePosition(
|
|
||||||
entityId, rm.Body.Position, rm.Body.Orientation,
|
|
||||||
shOffX, shOffY, rm.CellId, seedCellId: rm.CellId);
|
|
||||||
rm.LastShadowSyncPos = rm.Body.Position;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// R5-V4: retail <c>CPhysicsObj::stick_to_object</c> (0x005127e0) — the
|
/// R5-V4: retail <c>CPhysicsObj::stick_to_object</c> (0x005127e0) — the
|
||||||
|
|
@ -4848,23 +4819,10 @@ public sealed class GameWindow : IDisposable
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
// #184 Slice 2a: TickRemoteMoveTo (retail MovementManager::UseTime
|
||||||
/// R4-V5 / R5-V2: the per-tick <see cref="AcDream.Core.Physics.Motion.MovementManager"/>
|
// 0x005242f0 — moveto steering / arrival / fail-distance; the P4 poll is
|
||||||
/// drive (retail <c>MovementManager::UseTime</c> 0x005242f0 — the moveto
|
// gone, TargetManager voyeur delivers positions) moved to
|
||||||
/// side's steering, arrival, fail-distance; R5-V5 facade relay). The P4
|
// RemotePhysicsUpdater. The DR tick was its only caller.
|
||||||
/// TargetTracker POLL is gone (R5-V2): target-position delivery now flows
|
|
||||||
/// through the <see cref="AcDream.Core.Physics.Motion.TargetManager"/>
|
|
||||||
/// voyeur system — the target's own <c>HandleTargetting</c> pushes updates
|
|
||||||
/// into this entity's <c>HandleUpdateTarget</c>, which is called
|
|
||||||
/// unconditionally per remote in the per-tick loop BEFORE this drive
|
|
||||||
/// (retail <c>UpdateObjectInternal</c> order). Safe for any remote: a
|
|
||||||
/// manager with no armed moveto no-ops, airborne bodies held by
|
|
||||||
/// <c>UseTime</c>'s contact gate.
|
|
||||||
/// </summary>
|
|
||||||
private void TickRemoteMoveTo(RemoteMotion rm)
|
|
||||||
{
|
|
||||||
rm.Movement.UseTime();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Phase 6.6: the server says an entity's motion has changed. Look up
|
/// Phase 6.6: the server says an entity's motion has changed. Look up
|
||||||
|
|
@ -6153,7 +6111,8 @@ public sealed class GameWindow : IDisposable
|
||||||
// the per-tick loop skips). The per-tick loop keeps it current between
|
// the per-tick loop skips). The per-tick loop keeps it current between
|
||||||
// UPs, movement-gated. rmState.CellId is the server cell adopted above.
|
// UPs, movement-gated. rmState.CellId is the server cell adopted above.
|
||||||
if (rmState.CellId != 0)
|
if (rmState.CellId != 0)
|
||||||
SyncRemoteShadowToBody(entity.Id, rmState);
|
_remotePhysicsUpdater.SyncRemoteShadowToBody(
|
||||||
|
entity.Id, rmState, _liveCenterX, _liveCenterY);
|
||||||
|
|
||||||
entity.SetPosition(rmState.Body.Position);
|
entity.SetPosition(rmState.Body.Position);
|
||||||
entity.Rotation = rmState.Body.Orientation;
|
entity.Rotation = rmState.Body.Orientation;
|
||||||
|
|
@ -10181,696 +10140,7 @@ public sealed class GameWindow : IDisposable
|
||||||
// TickAnimations advance.
|
// TickAnimations advance.
|
||||||
&& rm.LastServerPosTime > 0)
|
&& rm.LastServerPosTime > 0)
|
||||||
{
|
{
|
||||||
// R5-V2: retail UpdateObjectInternal ticks TargetManager::
|
_remotePhysicsUpdater.Tick(rm, ae, dt, _liveCenterX, _liveCenterY);
|
||||||
// HandleTargetting UNCONDITIONALLY per entity, BEFORE the
|
|
||||||
// movement managers' UseTime. This is where this entity, as a
|
|
||||||
// watched target, pushes its position to its voyeurs (any entity
|
|
||||||
// moving-to it), and where its own target-info staleness times
|
|
||||||
// out. Runs for every remote regardless of the grounded/airborne
|
|
||||||
// branch below (which drive MoveToManager.UseTime via
|
|
||||||
// TickRemoteMoveTo). No-op for entities with no target + no voyeurs.
|
|
||||||
rm.Host?.HandleTargetting();
|
|
||||||
|
|
||||||
if (IsPlayerGuid(serverGuid) && !rm.Airborne)
|
|
||||||
{
|
|
||||||
// ── L.3 M2/M3 (2026-05-05): queue + anim chase for grounded player remotes ──
|
|
||||||
//
|
|
||||||
// Per retail spec (docs/research/2026-05-04-l3-port/01-per-tick.md +
|
|
||||||
// 04-interp-manager.md +
|
|
||||||
// 05-position-manager-and-partarray.md):
|
|
||||||
//
|
|
||||||
// - For a grounded REMOTE player, m_velocityVector stays at 0.
|
|
||||||
// - apply_current_movement is NEVER called per tick on remotes
|
|
||||||
// (it's the local-player-only velocity feed).
|
|
||||||
// - UpdatePhysicsInternal's translation step is gated on
|
|
||||||
// velocity² > 0, so it's a no-op when body.Velocity = 0.
|
|
||||||
// - ResolveWithTransition is NOT called — the server already
|
|
||||||
// collision-resolved the broadcast position.
|
|
||||||
// - Per-tick body translation per retail UpdatePositionInternal:
|
|
||||||
// 1. CPartArray::Update writes anim root motion (body-local
|
|
||||||
// seqVel × dt) into the local frame.
|
|
||||||
// 2. PositionManager::adjust_offset OVERWRITES the local
|
|
||||||
// frame's origin with the queue catch-up vector when
|
|
||||||
// the queue is active and the head is not yet reached
|
|
||||||
// — REPLACE, not additive.
|
|
||||||
// 3. Frame::combine composes the local frame with the
|
|
||||||
// body's world pose.
|
|
||||||
// Net: catch-up replaces anim during the chase phase, anim
|
|
||||||
// stands when the queue is empty / head reached. PositionManager.
|
|
||||||
// ComputeOffset implements this exact REPLACE dichotomy.
|
|
||||||
//
|
|
||||||
// Airborne player remotes (rm.Airborne) and NPCs fall through to
|
|
||||||
// the legacy path below — unchanged from main per the M2 plan.
|
|
||||||
System.Numerics.Vector3 seqVel = ae.Sequencer?.CurrentVelocity
|
|
||||||
?? System.Numerics.Vector3.Zero;
|
|
||||||
System.Numerics.Vector3 seqOmega = ae.Sequencer?.CurrentOmega
|
|
||||||
?? System.Numerics.Vector3.Zero;
|
|
||||||
|
|
||||||
// Step 1: transient flags (Contact + OnWalkable for grounded;
|
|
||||||
// Active always so UpdatePhysicsInternal doesn't early-return).
|
|
||||||
if (!rm.Airborne)
|
|
||||||
{
|
|
||||||
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
|
|
||||||
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
|
|
||||||
| AcDream.Core.Physics.TransientStateFlags.Active;
|
|
||||||
|
|
||||||
// For grounded remotes the body should not be carrying
|
|
||||||
// velocity — retail's m_velocityVector for a remote is
|
|
||||||
// 0 unless the server explicitly pushed one. Clear any
|
|
||||||
// stale velocity from a prior airborne arc so
|
|
||||||
// UpdatePhysicsInternal doesn't double-apply it on top
|
|
||||||
// of the seqVel-driven ComputeOffset translation below.
|
|
||||||
rm.Body.Velocity = System.Numerics.Vector3.Zero;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active;
|
|
||||||
}
|
|
||||||
|
|
||||||
// R4-V5 glide fix (2026-07-03 user report: a retail
|
|
||||||
// player using an object GLIDES to it — position moves
|
|
||||||
// via the UP queue but no walk/run legs): remote
|
|
||||||
// PLAYERS' MoveToManagers were armed by mt-6 but never
|
|
||||||
// TICKED — the V4 UseTime slot lived only in the
|
|
||||||
// NPC/legacy branch below, gated !IsPlayerGuid
|
|
||||||
// (inherited from the deleted RemoteMoveToDriver's
|
|
||||||
// NPC-only scope). Retail ticks every entity's
|
|
||||||
// MovementManager (UpdateObjectInternal has no entity-
|
|
||||||
// class fork). The manager's dispatches produce the
|
|
||||||
// locomotion cycle through the funnel sink (the LEGS);
|
|
||||||
// position stays queue-chased per the L.3 M2 spec, so
|
|
||||||
// the two compose exactly like an NPC's tick.
|
|
||||||
TickRemoteMoveTo(rm);
|
|
||||||
|
|
||||||
// Step 2 (M3): queue + anim translation via PositionManager.
|
|
||||||
// ComputeOffset returns:
|
|
||||||
// - Vector3.Zero when queue is empty AND seqVel is zero
|
|
||||||
// (idle remote between UPs after head reached) — body
|
|
||||||
// stays still.
|
|
||||||
// - Direction × min(catchUpSpeed × dt, dist) when the
|
|
||||||
// queue is active and head is not reached — body chases
|
|
||||||
// the head waypoint at up to 2× motion-table max speed
|
|
||||||
// (REPLACES anim for this frame).
|
|
||||||
// - Anim root motion (seqVel × dt rotated into world) when
|
|
||||||
// the queue is empty OR head is within DesiredDistance —
|
|
||||||
// body advances with the locomotion cycle's baked
|
|
||||||
// velocity, keeping legs and body pace synchronized.
|
|
||||||
// - Blip-to-tail (tail − body) when fail_count > 3.
|
|
||||||
float maxSpeed = rm.Motion.GetMaxSpeed();
|
|
||||||
// Slope-staircase fix (2026-05-05): sample terrain normal
|
|
||||||
// at the body's current XY so PositionManager can project
|
|
||||||
// the seqVel-only fallback onto the local slope. Without
|
|
||||||
// this, the queue-empty interval between UPs left Z flat
|
|
||||||
// (anim cycles bake Z=0 body-local) — visible ~5 Hz
|
|
||||||
// staircase when a remote runs up/down hills. The
|
|
||||||
// projection is a no-op on flat ground.
|
|
||||||
System.Numerics.Vector3? terrainNormal = _physicsEngine.SampleTerrainNormal(
|
|
||||||
rm.Body.Position.X, rm.Body.Position.Y);
|
|
||||||
|
|
||||||
System.Numerics.Vector3 bodyPosBefore = rm.Body.Position;
|
|
||||||
System.Numerics.Vector3 offset = rm.Position.ComputeOffset(
|
|
||||||
dt: (double)dt,
|
|
||||||
currentBodyPosition: rm.Body.Position,
|
|
||||||
seqVel: seqVel,
|
|
||||||
ori: rm.Body.Orientation,
|
|
||||||
interp: rm.Interp,
|
|
||||||
maxSpeed: maxSpeed,
|
|
||||||
terrainNormal: terrainNormal);
|
|
||||||
// R5-V3 (#171): retail chains Interpolation → Sticky over
|
|
||||||
// ONE shared delta frame (PositionManager::adjust_offset
|
|
||||||
// 0x00555190). The combiner's catch-up IS acdream's
|
|
||||||
// interpolation stage, so its offset SEEDS the frame
|
|
||||||
// (converted to mover-local) and StickyManager::
|
|
||||||
// adjust_offset OVERWRITES it when armed+initialized
|
|
||||||
// (0x00555430 assigns m_fOrigin rather than accumulating).
|
|
||||||
// With no stick armed the frame comes back untouched and
|
|
||||||
// this reduces to the pre-V3 `Position += offset`.
|
|
||||||
if (rm.Host is { } plHost)
|
|
||||||
{
|
|
||||||
var pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame
|
|
||||||
{
|
|
||||||
Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec(
|
|
||||||
rm.Body.Orientation, offset),
|
|
||||||
};
|
|
||||||
plHost.PositionManager.AdjustOffset(pmDelta, dt);
|
|
||||||
ApplyPositionManagerDelta(rm.Body, pmDelta);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
rm.Body.Position += offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Slope-staircase diagnostic — gated on ACDREAM_SLOPE_DIAG=1.
|
|
||||||
// Prints per-tick body Z trajectory + queue state + projected
|
|
||||||
// offset.Z so we can grep before/after the fix and confirm Z
|
|
||||||
// changes continuously between UPs on slopes (no flat
|
|
||||||
// intervals followed by snaps).
|
|
||||||
if (System.Environment.GetEnvironmentVariable("ACDREAM_SLOPE_DIAG") == "1")
|
|
||||||
{
|
|
||||||
bool queueActive = rm.Interp.IsActive;
|
|
||||||
float nz = terrainNormal?.Z ?? 1.0f;
|
|
||||||
System.Console.WriteLine(
|
|
||||||
$"[SLOPE] guid={serverGuid:X8} bodyZ={bodyPosBefore.Z:F3}->{rm.Body.Position.Z:F3} "
|
|
||||||
+ $"offset=({offset.X:F3},{offset.Y:F3},{offset.Z:F3}) "
|
|
||||||
+ $"queue={queueActive} cpN.Z={nz:F3}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2.5: angular velocity → body orientation. Prefer
|
|
||||||
// ObservedOmega (set explicitly in OnLiveMotionUpdated from
|
|
||||||
// the wire's TurnCommand + signed TurnSpeed) over the
|
|
||||||
// sequencer's synthesized omega: when the player runs in
|
|
||||||
// a circle ACE broadcasts ForwardCommand=RunForward AND
|
|
||||||
// TurnCommand=TurnLeft on the same UpdateMotion. The
|
|
||||||
// sequencer's animCycle picker chooses RunForward (legs
|
|
||||||
// running), whose synthesized CurrentOmega is zero. Body
|
|
||||||
// would not rotate between UPs and body.Velocity stays in
|
|
||||||
// an out-of-date world direction, producing the
|
|
||||||
// user-reported "rectangle when running circles" effect.
|
|
||||||
// ObservedOmega has the correct turn rate even when the
|
|
||||||
// visible cycle is RunForward.
|
|
||||||
System.Numerics.Vector3 omegaToApply =
|
|
||||||
rm.ObservedOmega.LengthSquared() > 1e-9f
|
|
||||||
? rm.ObservedOmega
|
|
||||||
: seqOmega;
|
|
||||||
if (omegaToApply.LengthSquared() > 1e-9f)
|
|
||||||
{
|
|
||||||
float angleDelta = omegaToApply.Length() * (float)dt;
|
|
||||||
System.Numerics.Vector3 axis = System.Numerics.Vector3.Normalize(omegaToApply);
|
|
||||||
var rot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angleDelta);
|
|
||||||
rm.Body.Orientation = System.Numerics.Quaternion.Normalize(
|
|
||||||
System.Numerics.Quaternion.Concatenate(rm.Body.Orientation, rot));
|
|
||||||
|
|
||||||
// Diagnostic (ACDREAM_REMOTE_VEL_DIAG=1): print seqOmega direction
|
|
||||||
// once per remote per ~1 second so we can confirm whether the omega
|
|
||||||
// sign actually being applied matches the retail-observed turn
|
|
||||||
// direction. Z>0 = CCW (TurnLeft); Z<0 = CW (TurnRight).
|
|
||||||
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
|
|
||||||
{
|
|
||||||
double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
|
||||||
if (nowSec - rm.LastOmegaDiagLogTime > 0.5)
|
|
||||||
{
|
|
||||||
uint seqMotion = ae.Sequencer?.CurrentMotion ?? 0;
|
|
||||||
System.Console.WriteLine(
|
|
||||||
$"[OMEGA_DIAG] guid={serverGuid:X8} motion=0x{seqMotion:X8} "
|
|
||||||
+ $"omegaApplied.Z={omegaToApply.Z:F3} "
|
|
||||||
+ $"(seq.Z={seqOmega.Z:F3} obs.Z={rm.ObservedOmega.Z:F3}) "
|
|
||||||
+ $"(Z>0=CCW=TurnLeft, Z<0=CW=TurnRight)");
|
|
||||||
rm.LastOmegaDiagLogTime = nowSec;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: calc_acceleration sets body.Acceleration from the Gravity flag
|
|
||||||
// (mirrors retail CPhysicsObj::calc_acceleration @ FUN_00511420, called
|
|
||||||
// per-frame in update_object). Without this, body.Acceleration stays stale
|
|
||||||
// or zero → gravity never decays jump velocity → endless rise on jumps.
|
|
||||||
rm.Body.calc_acceleration();
|
|
||||||
|
|
||||||
// Step 4: physics integration (Euler: pos += vel*dt + 0.5*accel*dt²).
|
|
||||||
rm.Body.UpdatePhysicsInternal(dt);
|
|
||||||
|
|
||||||
// Step 4b INTENTIONALLY OMITTED in M2:
|
|
||||||
// ResolveWithTransition is NOT called — the server has
|
|
||||||
// already collision-resolved the broadcast position, and
|
|
||||||
// running our sweep on tiny per-frame queue catch-up deltas
|
|
||||||
// amplifies micro-bounces into visible position blips
|
|
||||||
// (issue #40 staircase + flat-ground blips). Per retail
|
|
||||||
// spec the per-tick body advance for a remote is purely
|
|
||||||
// the queue catch-up; collision is the sender's problem.
|
|
||||||
//
|
|
||||||
// Step 5 (landing fallback) is unreachable in this branch —
|
|
||||||
// we're gated on !rm.Airborne. Airborne player remotes fall
|
|
||||||
// through to the legacy path below where K-fix15 still fires.
|
|
||||||
|
|
||||||
// Step 6: speed-overshoot diagnostic (ACDREAM_REMOTE_VEL_DIAG=1).
|
|
||||||
// Track the maximum sequencer velocity magnitude seen since
|
|
||||||
// the last UpdatePosition arrival (carried on the
|
|
||||||
// RemoteMotion struct), then on each UP arrival the
|
|
||||||
// OnLivePositionUpdated path prints the comparison against
|
|
||||||
// the server's actual broadcast pace
|
|
||||||
// ((LastServerPos - PrevServerPos) / Δt). This guarantees
|
|
||||||
// both sides are sampled during the same window and the
|
|
||||||
// ratio reflects the real overshoot.
|
|
||||||
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
|
|
||||||
{
|
|
||||||
// body.Velocity is now the source of bulk translation
|
|
||||||
// (set above by apply_current_movement). Track its
|
|
||||||
// magnitude so VEL_DIAG can compare against the actual
|
|
||||||
// server broadcast pace.
|
|
||||||
float seqSpeedNow = rm.Body.Velocity.Length();
|
|
||||||
if (seqSpeedNow > rm.MaxSeqSpeedSinceLastUP)
|
|
||||||
rm.MaxSeqSpeedSinceLastUP = seqSpeedNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
ae.Entity.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty
|
|
||||||
if (rm.CellId != 0)
|
|
||||||
ae.Entity.ParentCellId = rm.CellId;
|
|
||||||
ae.Entity.Rotation = rm.Body.Orientation;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// ── LEGACY PATH (UNCHANGED — kept until Task 8 cleanup) ──
|
|
||||||
//
|
|
||||||
// Stop detection is handled explicitly on packet receipt:
|
|
||||||
// - UpdateMotion with ForwardCommand flag CLEARED → Ready.
|
|
||||||
// - UpdatePosition with HasVelocity flag CLEARED → StopCompletely.
|
|
||||||
// Both map to retail's "flag-absent = Invalid = reset to
|
|
||||||
// default" semantics (FUN_0051F260 bulk-copy). No timer-based
|
|
||||||
// inference needed — the server sends the right signal every
|
|
||||||
// time a remote stops.
|
|
||||||
|
|
||||||
// Retail per-tick motion pipeline applied to every remote.
|
|
||||||
// Mirrors retail FUN_00515020 update_object → FUN_00513730
|
|
||||||
// UpdatePositionInternal → FUN_005111D0 UpdatePhysicsInternal:
|
|
||||||
//
|
|
||||||
// 1. apply_current_movement (FUN_00529210) — recomputes
|
|
||||||
// body.Velocity from InterpretedState via get_state_velocity.
|
|
||||||
// 2. Pull omega from the sequencer (baked MotionData.Omega
|
|
||||||
// for TurnRight / TurnLeft cycles, scaled by speedMod).
|
|
||||||
// 3. body.update_object(now) — Euler-integrates
|
|
||||||
// position += Velocity × dt + 0.5 × Accel × dt² AND
|
|
||||||
// orientation += omega × dt.
|
|
||||||
//
|
|
||||||
// On UpdatePosition receipt we hard-snap body.Position and
|
|
||||||
// body.Orientation — if integration matched server physics,
|
|
||||||
// each snap is small/invisible.
|
|
||||||
double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
|
||||||
|
|
||||||
// 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. Matches Path A's grounded model (:10113); clearing
|
|
||||||
// velocity mirrors Path A (:10125).
|
|
||||||
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;
|
|
||||||
ApplyServerControlledVelocityCycle(
|
|
||||||
serverGuid,
|
|
||||||
ae,
|
|
||||||
rm,
|
|
||||||
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.
|
|
||||||
TickRemoteMoveTo(rm);
|
|
||||||
}
|
|
||||||
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: integrate rotation manually per tick. We can't
|
|
||||||
// rely on PhysicsBody.update_object here — its MinQuantum
|
|
||||||
// gate (1/30 s) causes it to SKIP integration when our
|
|
||||||
// 60fps render dt (~0.016s) is below the quantum, meaning
|
|
||||||
// rotation never advances. Measured snap per UP was ~129°
|
|
||||||
// = the full expected 1s × 2.24 rad/s, confirming zero
|
|
||||||
// between-tick rotation.
|
|
||||||
//
|
|
||||||
// Manual integration matches retail's FUN_005256b0
|
|
||||||
// apply_physics (Orientation *= quat(ω × dt)). Use
|
|
||||||
// ObservedOmega derived from server UP rotation deltas so
|
|
||||||
// the rate exactly matches server physics — hard-snap on
|
|
||||||
// next UP becomes invisible by construction.
|
|
||||||
rm.Body.Omega = System.Numerics.Vector3.Zero; // don't double-integrate in update_object
|
|
||||||
if (rm.ObservedOmega.LengthSquared() > 1e-8f)
|
|
||||||
{
|
|
||||||
float omegaMag = rm.ObservedOmega.Length();
|
|
||||||
var axis = rm.ObservedOmega / omegaMag;
|
|
||||||
float angle = omegaMag * dt;
|
|
||||||
var deltaRot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angle);
|
|
||||||
rm.Body.Orientation = System.Numerics.Quaternion.Normalize(
|
|
||||||
System.Numerics.Quaternion.Multiply(rm.Body.Orientation, deltaRot));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 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.cs:358 calls UpdatePhysicsInternal
|
|
||||||
// directly for the same reason. Remote motion mirrors that.
|
|
||||||
// Omega is already integrated manually above, so we zero it
|
|
||||||
// here to prevent UpdatePhysicsInternal's own omega pass from
|
|
||||||
// double-integrating.
|
|
||||||
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;
|
|
||||||
if (!rm.Airborne)
|
|
||||||
{
|
|
||||||
System.Numerics.Vector3 seqVelNpc = ae.Sequencer?.CurrentVelocity
|
|
||||||
?? System.Numerics.Vector3.Zero;
|
|
||||||
float maxSpeedNpc = rm.Motion.GetMaxSpeed();
|
|
||||||
System.Numerics.Vector3? terrainNormalNpc =
|
|
||||||
_physicsEngine.SampleTerrainNormal(
|
|
||||||
rm.Body.Position.X, rm.Body.Position.Y);
|
|
||||||
System.Numerics.Vector3 offsetNpc = rm.Position.ComputeOffset(
|
|
||||||
dt: (double)dt,
|
|
||||||
currentBodyPosition: rm.Body.Position,
|
|
||||||
seqVel: seqVelNpc,
|
|
||||||
ori: rm.Body.Orientation,
|
|
||||||
interp: rm.Interp,
|
|
||||||
maxSpeed: maxSpeedNpc,
|
|
||||||
terrainNormal: terrainNormalNpc);
|
|
||||||
pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame
|
|
||||||
{
|
|
||||||
Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec(
|
|
||||||
rm.Body.Orientation, offsetNpc),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame();
|
|
||||||
}
|
|
||||||
npcHost.PositionManager.AdjustOffset(pmDelta, dt);
|
|
||||||
ApplyPositionManagerDelta(rm.Body, pmDelta);
|
|
||||||
}
|
|
||||||
else if (!rm.Airborne)
|
|
||||||
{
|
|
||||||
// No PositionManager host yet (pre-binding): apply the catch-up
|
|
||||||
// directly, matching Path A's fallback (:10202).
|
|
||||||
System.Numerics.Vector3 seqVelNpc = ae.Sequencer?.CurrentVelocity
|
|
||||||
?? System.Numerics.Vector3.Zero;
|
|
||||||
float maxSpeedNpc = rm.Motion.GetMaxSpeed();
|
|
||||||
System.Numerics.Vector3? terrainNormalNpc =
|
|
||||||
_physicsEngine.SampleTerrainNormal(
|
|
||||||
rm.Body.Position.X, rm.Body.Position.Y);
|
|
||||||
rm.Body.Position += rm.Position.ComputeOffset(
|
|
||||||
dt: (double)dt,
|
|
||||||
currentBodyPosition: rm.Body.Position,
|
|
||||||
seqVel: seqVelNpc,
|
|
||||||
ori: rm.Body.Orientation,
|
|
||||||
interp: rm.Interp,
|
|
||||||
maxSpeed: maxSpeedNpc,
|
|
||||||
terrainNormal: terrainNormalNpc);
|
|
||||||
}
|
|
||||||
rm.Body.calc_acceleration();
|
|
||||||
rm.Body.UpdatePhysicsInternal(dt);
|
|
||||||
var postIntegratePos = rm.Body.Position;
|
|
||||||
|
|
||||||
// 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 && _physicsEngine.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. Retail seeds
|
|
||||||
// the transition from the object's own Setup sphere list ×
|
|
||||||
// m_scale (CPhysicsObj::transition 0x00512dc0 → init_sphere;
|
|
||||||
// ObjScale from set_description 0x00514f40). This narrows
|
|
||||||
// TS-46 (remotes no longer use human dims); the two-scalar
|
|
||||||
// API is still a lossy stand-in for retail's full (≤2)
|
|
||||||
// sphere list, and stepUp/stepDown stay 0.4 (retail derives
|
|
||||||
// those from the Setup too — an adjacent divergence left as-is).
|
|
||||||
// Fallback to the human capsule for a shapeless / unresolvable
|
|
||||||
// Setup (GetSetupCylinder returns (0,0)); a zero radius would
|
|
||||||
// degenerate the sweep.
|
|
||||||
var (deR, deH) = GetSetupCylinder(serverGuid, ae.Entity);
|
|
||||||
if (deR < 0.05f) { deR = 0.48f; deH = 1.835f; }
|
|
||||||
var resolveResult = _physicsEngine.ResolveWithTransition(
|
|
||||||
preIntegratePos, postIntegratePos, rm.CellId,
|
|
||||||
sphereRadius: deR,
|
|
||||||
sphereHeight: deH,
|
|
||||||
stepUpHeight: 0.4f, // L.2.3a: retail human-scale, was 2.0f
|
|
||||||
stepDownHeight: 0.4f, // L.2.3a: retail human-scale, was 0.04f
|
|
||||||
// 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 dead-reckoning should exercise the same
|
|
||||||
// edge/cliff branch as local movement.
|
|
||||||
moverFlags: AcDream.Core.Physics.ObjectInfoState.EdgeSlide,
|
|
||||||
// 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: kv.Key);
|
|
||||||
|
|
||||||
rm.Body.Position = resolveResult.Position;
|
|
||||||
if (resolveResult.CellId != 0)
|
|
||||||
rm.CellId = 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 cheap
|
|
||||||
// MOVEMENT-GATED (#184 review): re-flood only when the resolved
|
|
||||||
// body moved > ~1 cm since the last shadow registration. This is
|
|
||||||
// SAFE now that the per-UP :5669 sync is players-only — the NPC
|
|
||||||
// shadow's only writers are 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.)
|
|
||||||
if (System.Numerics.Vector3.DistanceSquared(
|
|
||||||
rm.Body.Position, rm.LastShadowSyncPos) > 1e-4f)
|
|
||||||
{
|
|
||||||
SyncRemoteShadowToBody(kv.Key, rm);
|
|
||||||
}
|
|
||||||
|
|
||||||
// #173 (2026-07-05): retail CPhysicsObj::handle_all_collisions
|
|
||||||
// (pc:282699-282715) runs after EVERY SetPositionInternal —
|
|
||||||
// remote objects included; a VectorUpdate-launched jump arc
|
|
||||||
// is ordinary object physics in retail. acdream ported the
|
|
||||||
// velocity reflection for the LOCAL player only (L.3a,
|
|
||||||
// PlayerMovementController ~:940), so a remote jumping into
|
|
||||||
// a dungeon ceiling had its POSITION pinned by the sweep
|
|
||||||
// while its +Z velocity kept integrating — the char hovered
|
|
||||||
// at the roof until gravity burned the arc off, landing
|
|
||||||
// late (user report, 0x0007 dungeon). Mirror the local
|
|
||||||
// site exactly:
|
|
||||||
// v_new = v − (1 + elasticity)·dot(v, n)·n
|
|
||||||
// with the AD-25 suppression (bounce only when airborne
|
|
||||||
// before AND after — corridor slides and landings don't
|
|
||||||
// reflect; the landing snap below keeps its
|
|
||||||
// `Velocity.Z <= 0` gate intact). Inelastic movers
|
|
||||||
// (missiles, later) zero out instead.
|
|
||||||
if (resolveResult.CollisionNormalValid)
|
|
||||||
{
|
|
||||||
bool prevOnWalkable = rm.Body.OnWalkable;
|
|
||||||
bool nowOnWalkable = resolveResult.IsOnGround;
|
|
||||||
bool applyBounce = rm.Body.State.HasFlag(
|
|
||||||
AcDream.Core.Physics.PhysicsStateFlags.Sledding)
|
|
||||||
? !(prevOnWalkable && nowOnWalkable)
|
|
||||||
: (!prevOnWalkable && !nowOnWalkable);
|
|
||||||
if (applyBounce)
|
|
||||||
{
|
|
||||||
if (rm.Body.State.HasFlag(
|
|
||||||
AcDream.Core.Physics.PhysicsStateFlags.Inelastic))
|
|
||||||
{
|
|
||||||
rm.Body.Velocity = System.Numerics.Vector3.Zero;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var vRem = rm.Body.Velocity;
|
|
||||||
var nRem = resolveResult.CollisionNormal;
|
|
||||||
float dotVN = System.Numerics.Vector3.Dot(vRem, nRem);
|
|
||||||
if (dotVN < 0f)
|
|
||||||
{
|
|
||||||
rm.Body.Velocity =
|
|
||||||
vRem + nRem * (-(dotVN * (rm.Body.Elasticity + 1f)));
|
|
||||||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
|
||||||
Console.WriteLine(
|
|
||||||
$"VU.bounce guid=0x{serverGuid:X8} n=({nRem.X:F2},{nRem.Y:F2},{nRem.Z:F2}) vZ {vRem.Z:F2}->{rm.Body.Velocity.Z:F2}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
rm.Movement.HitGround();
|
|
||||||
// DR bookkeeping only (partner of the jump-start
|
|
||||||
// `State |= Gravity`): stops the per-tick gravity
|
|
||||||
// integration for the grounded body.
|
|
||||||
rm.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity;
|
|
||||||
|
|
||||||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
|
||||||
Console.WriteLine($"VU.land guid=0x{serverGuid:X8} Z={rm.Body.Position.Z:F2}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ae.Entity.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty
|
|
||||||
if (rm.CellId != 0)
|
|
||||||
ae.Entity.ParentCellId = rm.CellId;
|
|
||||||
ae.Entity.Rotation = rm.Body.Orientation;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
rm.Host?.PositionManager.UseTime();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Get per-part (origin, orientation) from either sequencer or legacy ──
|
// ── Get per-part (origin, orientation) from either sequencer or legacy ──
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue