refactor(runtime): own per-session physics simulation
Move the sole PhysicsEngine, production cache, collision admissions, canonical bodies and hosts, remote components, ordinary/remote worksets, simulation, cell commits, and shadow synchronization under RuntimeEntityObjectLifetime. Keep App as the prepared-asset, animation-input, and render-projection adapter while preserving the named-retail update and collision order. Add exact-incarnation, object-clock, callback-reentrancy, GUID-reuse, two-runtime isolation, source ownership, collision publication, and graphical projection coverage. Release build and the complete 8,588-test solution pass. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
parent
0dc3bfdeff
commit
7e6033d0ad
39 changed files with 3685 additions and 1722 deletions
900
src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs
Normal file
900
src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs
Normal file
|
|
@ -0,0 +1,900 @@
|
|||
using AcDream.Runtime.Entities;
|
||||
|
||||
namespace AcDream.Runtime.Physics;
|
||||
|
||||
internal readonly record struct RuntimeRemotePhysicsSnapshot(
|
||||
System.Numerics.Vector3 Position,
|
||||
System.Numerics.Quaternion Orientation,
|
||||
uint FullCellId);
|
||||
|
||||
/// <summary>
|
||||
/// #184 Slice 2a extracted the per-remote dead-reckoning physics tick
|
||||
/// verbatim from the former graphical frame owner. Slice J5.5 moved that
|
||||
/// presentation-free simulation under the per-session Runtime physics owner.
|
||||
/// It is called once per eligible remote entity per retail object quantum,
|
||||
/// preserving the same guard
|
||||
/// (<c>ae.Sequencer != null && serverGuid != 0 && serverGuid != _playerServerGuid
|
||||
/// && rm.LastServerPosTime > 0</c>).
|
||||
/// Hidden remotes use a separate live-entity pass because retail keeps their
|
||||
/// PositionManager alive even when the object has no render-animation owner.
|
||||
///
|
||||
/// <para>Slice 2a extracted this verbatim (fork intact). Slice 2b then COLLAPSED
|
||||
/// the player/NPC fork: the former Path A (grounded PLAYER remotes advanced by the
|
||||
/// interp catch-up with the sweep deliberately OMITTED, per the now-retired issue
|
||||
/// #40 premise) is gone — <b>every</b> remote now runs the SAME catch-up +
|
||||
/// <c>ResolveWithTransition</c> sweep + shadow-follows-resolved, so packed PLAYER
|
||||
/// remotes de-overlap exactly like NPCs (retail <c>UpdateObjectInternal</c>
|
||||
/// 0x005156b0 has no player/remote fork). The only surviving player/NPC split is
|
||||
/// the <c>!IsPlayerGuid</c>-gated stale-velocity animation-cycle stop. See
|
||||
/// <c>docs/research/2026-07-07-184-slice2-unify-extract-handoff.md</c>.</para>
|
||||
///
|
||||
/// <para>Shared policy arrives through focused Physics delegates:
|
||||
/// DAT-derived shape dimensions and animation-cycle projection arrive through
|
||||
/// the App adapter in a graphical host. <c>SyncRemoteShadowToBody</c>
|
||||
/// (remote-physics-specific) moved here and is called back from the UP-branch
|
||||
/// tail; <c>ApplyPositionManagerDelta</c> / <c>TickRemoteMoveTo</c> had no other
|
||||
/// callers and moved here outright.</para>
|
||||
/// </summary>
|
||||
internal sealed class RuntimeRemotePhysicsUpdater
|
||||
{
|
||||
// Preserved from #184 Slice 2a; the remote tick remains its only caller.
|
||||
private const double ServerControlledVelocityStaleSeconds = 0.60;
|
||||
|
||||
private readonly RuntimePhysicsState _physics;
|
||||
|
||||
internal RuntimeRemotePhysicsUpdater(
|
||||
RuntimePhysicsState physics)
|
||||
{
|
||||
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
|
||||
}
|
||||
|
||||
// Retail GUID classification used only for collision flags.
|
||||
private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Canonical ordinary-object tick. Render animation is optional: retail
|
||||
/// walks <c>CPhysics::object_maint</c>, so a live object with a
|
||||
/// MovementManager or PositionManager must continue even when it has no
|
||||
/// render-animation presentation component.
|
||||
/// </summary>
|
||||
internal bool Tick(
|
||||
RuntimeEntityRecord record,
|
||||
RemoteMotion rm,
|
||||
float objectScale,
|
||||
AcDream.Core.Physics.AnimationSequencer? sequencer,
|
||||
float dt,
|
||||
ulong objectClockEpoch,
|
||||
AcDream.Core.Physics.Motion.MotionDeltaFrame rootMotionLocalFrame,
|
||||
float radius,
|
||||
float height,
|
||||
int liveCenterX,
|
||||
int liveCenterY,
|
||||
System.Action<uint, AcDream.Core.Physics.AnimationSequencer>?
|
||||
processAnimationHooks = null,
|
||||
System.Action<System.Numerics.Vector3>? applyStaleVelocityCycle = null,
|
||||
System.Func<RuntimeRemotePhysicsSnapshot, bool>?
|
||||
acknowledgeProjection = null,
|
||||
System.Func<bool>? externalOwnerValid = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
ArgumentNullException.ThrowIfNull(rm);
|
||||
ArgumentNullException.ThrowIfNull(rootMotionLocalFrame);
|
||||
if (!IsCurrentOwner(
|
||||
record,
|
||||
rm,
|
||||
objectClockEpoch,
|
||||
externalOwnerValid))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
uint serverGuid = record.ServerGuid;
|
||||
uint localEntityId = record.LocalEntityId
|
||||
?? throw new InvalidOperationException(
|
||||
$"Runtime entity 0x{serverGuid:X8}/{record.Incarnation} has no local identity.");
|
||||
// #184 Slice 2b — the UNIFIED per-remote tick. The former Path A
|
||||
// (grounded PLAYER remotes: interp catch-up with the ResolveWithTransition
|
||||
// sweep OMITTED, per the now-retired issue-#40 "collision is the sender's
|
||||
// problem" premise) is GONE — every remote now runs the SAME catch-up +
|
||||
// sweep + shadow-follows-resolved, so packed PLAYER remotes de-overlap
|
||||
// exactly like NPCs. Retail's UpdateObjectInternal (0x005156b0) has NO
|
||||
// player/remote fork; only the stale animation-cycle stop below remains
|
||||
// player/NPC-specific.
|
||||
//
|
||||
// Stop detection stays explicit on packet receipt (UpdateMotion
|
||||
// ForwardCommand cleared -> Ready; UpdatePosition HasVelocity cleared ->
|
||||
// StopCompletely). Mirrors retail update_object -> UpdatePositionInternal
|
||||
// -> UpdatePhysicsInternal (FUN_00515020 / FUN_00513730 / FUN_005111D0).
|
||||
// The bare block scopes this update's locals (formerly the else body).
|
||||
{
|
||||
double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
||||
|
||||
// Retail CPhysicsObj::UpdatePositionInternal @ 0x00512C30 scales
|
||||
// the CSequence root displacement by m_scale only while the body
|
||||
// is OnWalkable; otherwise it clears the displacement. Grounded
|
||||
// remotes below are explicitly OnWalkable and airborne remotes use
|
||||
// their authoritative velocity/gravity arc, so this is the same
|
||||
// branch expressed through our retained runtime state.
|
||||
System.Numerics.Vector3 scaledRootMotionLocalOrigin = !rm.Airborne
|
||||
? rootMotionLocalFrame.Origin * objectScale
|
||||
: System.Numerics.Vector3.Zero;
|
||||
|
||||
// Step 1: re-apply current motion commands → body.Velocity.
|
||||
// Forces OnWalkable + Contact so the gate in apply_current_movement
|
||||
// always succeeds (remotes are server-authoritative; we don't
|
||||
// simulate airborne physics for them).
|
||||
//
|
||||
// K-fix9 (2026-04-26): SKIP this when the remote is airborne.
|
||||
// Otherwise the force-OnWalkable + apply_current_movement
|
||||
// path stomps the +Z velocity we set in OnLiveVectorUpdated,
|
||||
// and gravity never gets to integrate the arc. The airborne
|
||||
// body keeps the launch velocity from the VectorUpdate;
|
||||
// UpdatePhysicsInternal below applies gravity each tick;
|
||||
// the next UpdatePosition snaps to the new ground location
|
||||
// and re-grounds.
|
||||
if (!rm.Airborne)
|
||||
{
|
||||
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
|
||||
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
|
||||
| AcDream.Core.Physics.TransientStateFlags.Active;
|
||||
|
||||
// #184 (2026-07-07): a grounded remote carries NO translation
|
||||
// velocity. Its per-tick movement is the interp CATCH-UP toward
|
||||
// the MoveOrTeleport-queued server waypoint (computed at the
|
||||
// sticky-compose site below), which the KEPT ResolveWithTransition
|
||||
// sweep de-overlaps against neighbours — and the resolved position
|
||||
// is written back into the SHADOW (below) so the de-overlap
|
||||
// persists and neighbours collide against the resolved body, not
|
||||
// the raw server pos. This REPLACES the old synth-velocity model
|
||||
// (get_state_velocity / SERVERVEL Body.Velocity = ServerVelocity):
|
||||
// retail's UpdateObjectInternal (0x005156b0) has NO synth-velocity
|
||||
// leg — a remote translates by adjust_offset and the UP is a gentle
|
||||
// target. As of #184 Slice 2b this grounded model is the SINGLE
|
||||
// remote path (players + NPCs) — retail has no fork.
|
||||
rm.Body.Velocity = System.Numerics.Vector3.Zero;
|
||||
|
||||
// Stale server-velocity → stop the locomotion CYCLE (the legs).
|
||||
// ANIM ONLY — translation is the catch-up. Kept verbatim (same
|
||||
// !moveToArmed && !stickyArmed gate) from the old SERVERVEL branch
|
||||
// so a scripted-path NPC that stops server-side drops out of its
|
||||
// walk/run cycle; ApplyServerControlledVelocityCycle selects the
|
||||
// anim from ServerVelocity, independent of Body.Velocity.
|
||||
bool moveToArmed = rm.MoveTo is
|
||||
{ MovementTypeState: not AcDream.Core.Physics.MovementType.Invalid };
|
||||
bool stickyArmed =
|
||||
(rm.Host?.PositionManager.GetStickyObjectId() ?? 0u) != 0u;
|
||||
if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity
|
||||
&& !moveToArmed && !stickyArmed)
|
||||
{
|
||||
double velocityAge = nowSec - rm.LastServerPosTime;
|
||||
if (velocityAge > ServerControlledVelocityStaleSeconds)
|
||||
{
|
||||
rm.ServerVelocity = System.Numerics.Vector3.Zero;
|
||||
rm.HasServerVelocity = false;
|
||||
applyStaleVelocityCycle?.Invoke(
|
||||
System.Numerics.Vector3.Zero);
|
||||
}
|
||||
}
|
||||
|
||||
// R4-V4: tick the MoveToManager UNCONDITIONALLY (retail
|
||||
// MovementManager::UseTime per tick, UpdateObjectInternal call
|
||||
// @0x00515998) — UseTime runs HandleMoveToPosition /
|
||||
// HandleTurnToHeading (steering + arrival + fail-distance),
|
||||
// dispatching its per-node locomotion (turn / RunForward) through
|
||||
// the sink (the LEGS). Position comes from the catch-up; legs from
|
||||
// this per-node dispatch + the funnel. The #170-deleted per-frame
|
||||
// apply_current_movement is NOT reintroduced.
|
||||
}
|
||||
else
|
||||
{
|
||||
// Airborne — keep Active flag (so UpdatePhysicsInternal
|
||||
// doesn't early-return) but DON'T set Contact / OnWalkable.
|
||||
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active;
|
||||
}
|
||||
|
||||
// Step 2: CSequence's complete Frame carries motion-table omega through
|
||||
// the same compose as root translation. PhysicsBody.Omega remains
|
||||
// reserved for the object's physical angular velocity and is
|
||||
// integrated once by UpdatePhysicsInternal below.
|
||||
|
||||
// Step 3: integrate physics — retail FUN_005111D0
|
||||
// UpdatePhysicsInternal. Pure Euler:
|
||||
// position += velocity × dt + 0.5 × accel × dt²
|
||||
//
|
||||
// Call UpdatePhysicsInternal DIRECTLY rather than via
|
||||
// PhysicsBody.update_object (FUN_00515020). update_object gates
|
||||
// on MinQuantum = 1/30s: at our 60fps render tick (~16ms),
|
||||
// deltaTime < MinQuantum → early return AND LastUpdateTime is
|
||||
// NOT advanced. Net effect: position never integrates between
|
||||
// UpdatePositions and the only Body.Position changes come
|
||||
// from the UP hard-snap, producing a visible teleport-stride
|
||||
// on slopes (the "staircase" the user reported).
|
||||
//
|
||||
// PlayerMovementController calls UpdatePhysicsInternal directly
|
||||
// for the same reason. Remote motion mirrors that. CSequence's
|
||||
// authored orientation has already entered the shared delta Frame;
|
||||
// Body.Omega remains the separate physical angular-velocity source.
|
||||
var preIntegratePos = rm.Body.Position;
|
||||
// R5-V3 (#171) + #184 (2026-07-07): retail chains Interpolation →
|
||||
// Sticky over ONE shared delta frame (PositionManager::adjust_offset
|
||||
// 0x00555190), composed BEFORE UpdatePhysicsInternal + the transition
|
||||
// sweep so collision resolves whichever movement won (preIntegratePos
|
||||
// captured first — the sweep covers it).
|
||||
// • GROUNDED: the interp CATCH-UP SEEDS the frame (world→local) —
|
||||
// the movement source is the adjust_offset walk toward the
|
||||
// MoveOrTeleport-queued server waypoint, exactly like Path A
|
||||
// (:10173). StickyManager::adjust_offset then OVERWRITES the
|
||||
// Origin when armed (0x00555430 ASSIGNS m_fOrigin — the REPLACE
|
||||
// dichotomy), so a stuck monster still steers via #171.
|
||||
// • AIRBORNE: seed an EMPTY frame (no catch-up — the arc integrates
|
||||
// from velocity + gravity, unchanged).
|
||||
// Body.Velocity is 0 when grounded (set above), so UpdatePhysicsInternal
|
||||
// adds no translation on top of the catch-up — no double-move.
|
||||
if (rm.Host is { } npcHost)
|
||||
{
|
||||
AcDream.Core.Physics.Motion.MotionDeltaFrame pmDelta =
|
||||
rm.PositionManagerDeltaScratch;
|
||||
pmDelta.Origin = scaledRootMotionLocalOrigin;
|
||||
pmDelta.Orientation = rootMotionLocalFrame.Orientation;
|
||||
float maxSpeedNpc = rm.Motion.GetMaxSpeed();
|
||||
System.Numerics.Vector3? terrainNormalNpc = !rm.Airborne
|
||||
? _physics.Engine.SampleTerrainNormal(
|
||||
rm.Body.Position.X,
|
||||
rm.Body.Position.Y)
|
||||
: null;
|
||||
rm.Position.ComposeOffset(
|
||||
dt,
|
||||
rm.Body.Position,
|
||||
rm.Body.Orientation,
|
||||
pmDelta,
|
||||
rm.Interp,
|
||||
maxSpeedNpc,
|
||||
pmDelta,
|
||||
terrainNormalNpc,
|
||||
inContact: rm.Body.InContact);
|
||||
npcHost.PositionManager.AdjustOffset(pmDelta, dt);
|
||||
ApplyPositionManagerDelta(rm.Body, pmDelta);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No PositionManager host yet (pre-binding): apply the catch-up
|
||||
// directly, matching Path A's fallback (:10202).
|
||||
// Airborne suppresses only CPartArray Origin. Retail keeps the
|
||||
// complete root orientation live across the OnWalkable scale
|
||||
// gate in UpdatePositionInternal (0x00512C30).
|
||||
AcDream.Core.Physics.Motion.MotionDeltaFrame pmDelta =
|
||||
rm.PositionManagerDeltaScratch;
|
||||
pmDelta.Origin = scaledRootMotionLocalOrigin;
|
||||
pmDelta.Orientation = rootMotionLocalFrame.Orientation;
|
||||
float maxSpeedNpc = rm.Motion.GetMaxSpeed();
|
||||
System.Numerics.Vector3? terrainNormalNpc = !rm.Airborne
|
||||
? _physics.Engine.SampleTerrainNormal(
|
||||
rm.Body.Position.X,
|
||||
rm.Body.Position.Y)
|
||||
: null;
|
||||
rm.Position.ComposeOffset(
|
||||
dt,
|
||||
rm.Body.Position,
|
||||
rm.Body.Orientation,
|
||||
pmDelta,
|
||||
rm.Interp,
|
||||
maxSpeedNpc,
|
||||
pmDelta,
|
||||
terrainNormalNpc,
|
||||
inContact: rm.Body.InContact);
|
||||
ApplyPositionManagerDelta(rm.Body, pmDelta);
|
||||
}
|
||||
rm.Body.calc_acceleration();
|
||||
rm.Body.UpdatePhysicsInternal(dt);
|
||||
if (sequencer is { } hookSequencer)
|
||||
processAnimationHooks?.Invoke(localEntityId, hookSequencer);
|
||||
if (!IsCurrentOwner(
|
||||
record,
|
||||
rm,
|
||||
objectClockEpoch,
|
||||
externalOwnerValid))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var postIntegratePos = rm.Body.Position;
|
||||
uint committedCellId = rm.CellId;
|
||||
|
||||
// Step 4: collision sweep — retail FUN_00514B90 →
|
||||
// FUN_005148A0 → Transition::FindTransitionalPosition.
|
||||
// Projects the sphere from preIntegratePos to postIntegratePos
|
||||
// through the BSP + terrain, resolving:
|
||||
// - terrain Z snap along the slope (fixes the "staircase" where
|
||||
// horizontal Euler motion up a slope sinks into rising ground
|
||||
// until the next UP pops it up)
|
||||
// - indoor BSP walls (via the 6-path dispatcher in BSPQuery)
|
||||
// - object collisions via ShadowObjectRegistry
|
||||
// - step-up / step-down against walkable ledges
|
||||
// ResolveWithTransition is the same call PlayerMovementController
|
||||
// uses for the local player; remotes now get the full retail
|
||||
// treatment between UpdatePositions instead of pure kinematics.
|
||||
//
|
||||
// Skipped when rm.CellId == 0 (no UP landed yet — can't build
|
||||
// a SpherePath without a starting cell). One-frame grace until
|
||||
// the first UP arrives; harmless because the entity is
|
||||
// server-freshly-spawned at a valid Z anyway.
|
||||
if (rm.CellId != 0 && _physics.Engine.LandblockCount > 0)
|
||||
{
|
||||
// #184 Slice 3 (2026-07-07): Setup-DERIVED mover sphere so
|
||||
// creatures de-overlap at their TRUE radii (a big monster
|
||||
// spreads wider, a small one tighter), not the hardcoded
|
||||
// human 0.48/1.835. GetSetupCylinder returns (setup.Radius,
|
||||
// setup.Height) × ObjScale — the creature's own dat Setup
|
||||
// scaled by its wire ObjScale, the same source the local
|
||||
// player + moveto/sticky use, and consistent with the
|
||||
// spawn-time shadow registration's entScale. 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.
|
||||
float deR = radius;
|
||||
float deH = height;
|
||||
if (deR < 0.05f) { deR = 0.48f; deH = 1.835f; }
|
||||
var resolveResult = _physics.Engine.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 DR
|
||||
// should exercise the same edge/cliff branch as local movement.
|
||||
// #184 Slice 2b: a remote PLAYER mover ALSO carries IsPlayer, so
|
||||
// CollisionExemption's PvP block fires exactly as it does for the
|
||||
// LOCAL player (PlayerMovementController :920) — two non-PK players
|
||||
// WALK THROUGH each other (retail sets IsPlayer on every object's
|
||||
// own transition via OBJECTINFO::init 0x0050cf30 `state |= 0x100`
|
||||
// from its weenie IsPlayer(); FindObjCollisions pc:276812 exempts a
|
||||
// non-PK player pair). Without IsPlayer the mover would de-overlap
|
||||
// two players — MORE solid than retail (you can stand inside another
|
||||
// non-PK player in AC). Players still COLLIDE with monsters (target
|
||||
// not IsPlayer → no exemption) + terrain + walls. PK/PKLite/
|
||||
// Impenetrable are NOT plumbed onto the remote mover yet, so a PK
|
||||
// pair walks through where retail collides — the SAME M1.5 gap the
|
||||
// local player carries (see TS-23; PlayerDescription PK status
|
||||
// unparsed).
|
||||
moverFlags: IsPlayerGuid(serverGuid)
|
||||
? AcDream.Core.Physics.ObjectInfoState.IsPlayer
|
||||
| AcDream.Core.Physics.ObjectInfoState.EdgeSlide
|
||||
: 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: localEntityId);
|
||||
|
||||
rm.Body.Position = resolveResult.Position;
|
||||
if (resolveResult.CellId != 0)
|
||||
committedCellId = resolveResult.CellId;
|
||||
|
||||
// #184 (2026-07-07) — SHADOW-FOLLOWS-RESOLVED (the load-bearing
|
||||
// de-overlap fix, proven in RemoteDeOverlapMechanismTests). Retail
|
||||
// re-registers a moved object's shadow every transition step
|
||||
// (SetPositionInternal → remove/add_shadows_to_cells, Ghidra
|
||||
// 0x00515330) so its m_position — the RESOLVED position — is what
|
||||
// OTHER creatures collide against. acdream's shadow otherwise only
|
||||
// syncs to the RAW server pos on UpdatePosition, so neighbours would
|
||||
// de-overlap against each other's OVERLAPPING shadows and any spread
|
||||
// would be discarded on the next UP (never accumulating), AND the
|
||||
// player would collide with a shadow offset from where the monster
|
||||
// renders (the reverted attempt's "stuck on an invisible monster").
|
||||
// Syncing the shadow to the resolved body every tick makes the
|
||||
// de-overlap PERSIST and keeps collision == render. Re-flood is
|
||||
// POSE/CELL-GATED (#184 review): re-flood only when the resolved
|
||||
// body moved > ~1 cm, rotated, or entered another cell. This is
|
||||
// SAFE now that #184 Slice 2b RETIRED the per-UP raw-pos sync for
|
||||
// players too — every remote's shadow (player + NPC) is written ONLY
|
||||
// by this loop + the UP-branch tail, both to the resolved body, so a
|
||||
// net-stationary (de-overlapped, sweep-
|
||||
// blocked) creature keeps its correct shadow and never re-floods,
|
||||
// while a moving/de-overlapping crowd (which moves every tick) still
|
||||
// syncs every tick. Bounds the per-tick RegisterMultiPart flood cost
|
||||
// to actually-moving remotes — the perf risk the review flagged for
|
||||
// a packed town. (In-place shadow-move + cell-relink-on-change is a
|
||||
// further optimization if profiling still shows churn.)
|
||||
// #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.
|
||||
ulong landingStateAuthorityVersion =
|
||||
record.StateAuthorityVersion;
|
||||
rm.Movement.HitGround();
|
||||
if (!IsCurrentOwner(
|
||||
record,
|
||||
rm,
|
||||
objectClockEpoch,
|
||||
externalOwnerValid))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// DR bookkeeping only (partner of the jump-start
|
||||
// `State |= Gravity`): stops the per-tick gravity
|
||||
// integration for the grounded body.
|
||||
if (record.StateAuthorityVersion
|
||||
== landingStateAuthorityVersion)
|
||||
{
|
||||
rm.Body.State &=
|
||||
~AcDream.Core.Physics.PhysicsStateFlags.Gravity;
|
||||
}
|
||||
|
||||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
||||
Console.WriteLine($"VU.land guid=0x{serverGuid:X8} Z={rm.Body.Position.Z:F2}");
|
||||
}
|
||||
}
|
||||
|
||||
// SetPositionInternal commits the resolved body/contact result and
|
||||
// root frame as one object before changing cell membership. The
|
||||
// canonical CellId writer can synchronously rebucket loaded to
|
||||
// pending, delete, or replace this GUID, so it is the transaction's
|
||||
// final callback boundary before shadow publication.
|
||||
if (!(acknowledgeProjection?.Invoke(
|
||||
new RuntimeRemotePhysicsSnapshot(
|
||||
rm.Body.Position,
|
||||
rm.Body.Orientation,
|
||||
committedCellId)) ?? true)
|
||||
|| !IsCurrentOwner(
|
||||
record,
|
||||
rm,
|
||||
objectClockEpoch,
|
||||
externalOwnerValid))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
bool cellChanged = committedCellId != 0
|
||||
&& committedCellId != rm.CellId;
|
||||
if (cellChanged)
|
||||
rm.CellId = committedCellId;
|
||||
if (!IsCurrentOwner(
|
||||
record,
|
||||
rm,
|
||||
objectClockEpoch,
|
||||
externalOwnerValid))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// #184: shadow follows the resolved body, but only after the
|
||||
// canonical rebucket proves this exact owner is still spatially
|
||||
// resident. A pending destination keeps its retained registration
|
||||
// suspended; a callback-created replacement owns another local ID.
|
||||
if (ShouldSynchronizeShadow(
|
||||
cellChanged,
|
||||
rm.Body.Position,
|
||||
rm.Body.Orientation,
|
||||
rm.LastShadowSyncPos,
|
||||
rm.LastShadowSyncOrientation))
|
||||
{
|
||||
SyncRemoteShadowToBody(
|
||||
localEntityId,
|
||||
rm,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
}
|
||||
}
|
||||
|
||||
// R5-V3 (#171): retail UpdateObjectInternal tail —
|
||||
// PositionManager::UseTime (0x005156b0, call @0x005159b3,
|
||||
// right after CPartArray::HandleMovement, UNCONDITIONAL for
|
||||
// every entity in both grounded and airborne branches): the
|
||||
// sticky 1 s lease watchdog (StickyManager::UseTime
|
||||
// 0x00555610 — a stick not re-issued by a fresh server arm
|
||||
// within 1 s tears itself down). No-op while nothing is stuck.
|
||||
AcDream.Core.Physics.RetailObjectManagerTail.Run(
|
||||
rm.Host?.TargetManager,
|
||||
rm.Movement,
|
||||
sequencer?.Manager,
|
||||
rm.Host?.PositionManager);
|
||||
return IsCurrentOwner(
|
||||
record,
|
||||
rm,
|
||||
objectClockEpoch,
|
||||
externalOwnerValid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail hidden-object slice of <c>CPhysicsObj::UpdatePositionInternal</c>
|
||||
/// (<c>0x00512C30</c>) plus the manager tail of
|
||||
/// <c>UpdateObjectInternal</c> (<c>0x005156B0</c>). Hidden skips
|
||||
/// <c>CPartArray::Update</c> and <c>UpdatePhysicsInternal</c>, but the
|
||||
/// PositionManager offset is still composed and the target, movement, and
|
||||
/// position managers still consume time. Physics-script and particle owners
|
||||
/// tick later in the shared frame pipeline.
|
||||
/// </summary>
|
||||
internal bool TickHidden(
|
||||
RuntimeEntityRecord record,
|
||||
RemoteMotion rm,
|
||||
float dt,
|
||||
ulong objectClockEpoch,
|
||||
float radius,
|
||||
float height,
|
||||
AcDream.Core.Physics.Motion.MotionTableManager?
|
||||
partArrayHandleMovement = null,
|
||||
System.Action<uint, AcDream.Core.Physics.AnimationSequencer>?
|
||||
processAnimationHooks = null,
|
||||
AcDream.Core.Physics.AnimationSequencer? sequencer = null,
|
||||
System.Func<RuntimeRemotePhysicsSnapshot, bool>?
|
||||
acknowledgeProjection = null,
|
||||
System.Func<bool>? externalOwnerValid = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
ArgumentNullException.ThrowIfNull(rm);
|
||||
if (!IsCurrentOwner(
|
||||
record,
|
||||
rm,
|
||||
objectClockEpoch,
|
||||
externalOwnerValid))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
uint localEntityId = record.LocalEntityId
|
||||
?? throw new InvalidOperationException(
|
||||
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} has no local identity.");
|
||||
|
||||
System.Numerics.Vector3 preComposePosition = rm.Body.Position;
|
||||
|
||||
// The part-array contribution is the identity frame while Hidden.
|
||||
// Interpolation is the first PositionManager stage in retail; acdream
|
||||
// retains it in RemoteMotionCombiner, ahead of Sticky/Constraint.
|
||||
AcDream.Core.Physics.Motion.MotionDeltaFrame positionDelta =
|
||||
rm.PositionManagerDeltaScratch;
|
||||
positionDelta.Reset();
|
||||
rm.Position.ComposeOffset(
|
||||
dt,
|
||||
rm.Body.Position,
|
||||
rm.Body.Orientation,
|
||||
positionDelta,
|
||||
rm.Interp,
|
||||
rm.Motion.GetMaxSpeed(),
|
||||
positionDelta,
|
||||
inContact: rm.Body.InContact);
|
||||
rm.Host?.PositionManager.AdjustOffset(positionDelta, dt);
|
||||
ApplyPositionManagerDelta(rm.Body, positionDelta);
|
||||
|
||||
// Hidden suppresses CPartArray::Update, but process_hooks remains the
|
||||
// final UpdatePositionInternal step. Drain any hook already pending on
|
||||
// the retained sequence before transition/manager time, exactly like
|
||||
// the visible path above.
|
||||
if (sequencer is not null)
|
||||
processAnimationHooks?.Invoke(localEntityId, sequencer);
|
||||
if (!IsCurrentOwner(
|
||||
record,
|
||||
rm,
|
||||
objectClockEpoch,
|
||||
externalOwnerValid))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
System.Numerics.Vector3 composedPosition = rm.Body.Position;
|
||||
uint committedCellId = rm.CellId;
|
||||
if (rm.CellId != 0
|
||||
&& composedPosition != preComposePosition
|
||||
&& _physics.Engine.LandblockCount > 0)
|
||||
{
|
||||
if (radius < 0.05f)
|
||||
{
|
||||
radius = 0.48f;
|
||||
height = 1.835f;
|
||||
}
|
||||
|
||||
bool previousContact = rm.Body.InContact;
|
||||
bool previousOnWalkable = rm.Body.OnWalkable;
|
||||
var resolved = _physics.Engine.ResolveWithTransition(
|
||||
preComposePosition,
|
||||
composedPosition,
|
||||
rm.CellId,
|
||||
radius,
|
||||
height,
|
||||
stepUpHeight: 0.4f,
|
||||
stepDownHeight: 0.4f,
|
||||
isOnGround: previousOnWalkable,
|
||||
body: rm.Body,
|
||||
moverFlags: IsPlayerGuid(record.ServerGuid)
|
||||
? AcDream.Core.Physics.ObjectInfoState.IsPlayer
|
||||
| AcDream.Core.Physics.ObjectInfoState.EdgeSlide
|
||||
: AcDream.Core.Physics.ObjectInfoState.EdgeSlide,
|
||||
movingEntityId: localEntityId);
|
||||
rm.Body.Position = resolved.Position;
|
||||
if (resolved.CellId != 0)
|
||||
committedCellId = resolved.CellId;
|
||||
if (!AcDream.Core.Physics.PhysicsObjUpdate.CommitSetPositionTransition(
|
||||
rm.Body,
|
||||
resolved.InContact,
|
||||
resolved.OnWalkable,
|
||||
resolved.CollisionNormalValid,
|
||||
resolved.CollisionNormal,
|
||||
previousContact,
|
||||
previousOnWalkable,
|
||||
rm.Movement.HitGround,
|
||||
rm.Motion.LeaveGround,
|
||||
() => IsCurrentOwner(
|
||||
record,
|
||||
rm,
|
||||
objectClockEpoch,
|
||||
externalOwnerValid)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
rm.Airborne = !rm.Body.OnWalkable;
|
||||
}
|
||||
|
||||
// Hidden suppresses mesh/part updates, not SetPositionInternal. Commit
|
||||
// the resolved root before the canonical cell writer enters the same
|
||||
// re-entrant rebucket boundary as the visible path.
|
||||
if (!(acknowledgeProjection?.Invoke(
|
||||
new RuntimeRemotePhysicsSnapshot(
|
||||
rm.Body.Position,
|
||||
rm.Body.Orientation,
|
||||
committedCellId)) ?? true)
|
||||
|| !IsCurrentOwner(
|
||||
record,
|
||||
rm,
|
||||
objectClockEpoch,
|
||||
externalOwnerValid))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (committedCellId != 0 && committedCellId != rm.CellId)
|
||||
rm.CellId = committedCellId;
|
||||
if (!IsCurrentOwner(
|
||||
record,
|
||||
rm,
|
||||
objectClockEpoch,
|
||||
externalOwnerValid))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
AcDream.Core.Physics.RetailObjectManagerTail.Run(
|
||||
rm.Host?.TargetManager,
|
||||
rm.Movement,
|
||||
partArrayHandleMovement,
|
||||
rm.Host?.PositionManager);
|
||||
return IsCurrentOwner(
|
||||
record,
|
||||
rm,
|
||||
objectClockEpoch,
|
||||
externalOwnerValid);
|
||||
}
|
||||
|
||||
private bool IsCurrentOwner(
|
||||
RuntimeEntityRecord record,
|
||||
RemoteMotion remote,
|
||||
ulong objectClockEpoch,
|
||||
System.Func<bool>? externalOwnerValid) =>
|
||||
_physics.IsSpatialRemote(record, remote)
|
||||
&& record.ObjectClockEpoch == objectClockEpoch
|
||||
&& ReferenceEquals(record.PhysicsBody, remote.Body)
|
||||
&& (externalOwnerValid?.Invoke() ?? true);
|
||||
|
||||
/// <summary>
|
||||
/// R5-V3 (#171): apply a <see cref="AcDream.Core.Physics.Motion.MotionDeltaFrame"/>
|
||||
/// written by <c>PositionManager.AdjustOffset</c> onto a body — acdream's
|
||||
/// stand-in for retail's <c>Frame::combine</c> in
|
||||
/// <c>CPhysicsObj::UpdatePositionInternal</c> (0x00512c30, combine
|
||||
/// @0x00512d22). The delta's Origin is mover-LOCAL (sticky writes
|
||||
/// <c>globaltolocalvec</c> output — 0x00555430), so combining rotates it
|
||||
/// out by the body's current orientation and post-multiplies the complete
|
||||
/// relative orientation. The remote tick is its only caller.
|
||||
/// </summary>
|
||||
private static void ApplyPositionManagerDelta(
|
||||
AcDream.Core.Physics.PhysicsBody body,
|
||||
AcDream.Core.Physics.Motion.MotionDeltaFrame delta)
|
||||
{
|
||||
if (delta.Origin != System.Numerics.Vector3.Zero)
|
||||
body.Position += System.Numerics.Vector3.Transform(delta.Origin, body.Orientation);
|
||||
if (!delta.Orientation.IsIdentity)
|
||||
body.Orientation = AcDream.Core.Physics.Motion.FrameOps.SetRotate(
|
||||
body.Position,
|
||||
body.Orientation,
|
||||
body.Orientation * delta.Orientation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #184 — shadow-follows-resolved. Re-register a remote creature's collision
|
||||
/// SHADOW at its RESOLVED body position, so OTHER creatures (and the player)
|
||||
/// de-overlap / collide against where the monster actually IS (== where it
|
||||
/// renders), not the raw overlapping server position. Retail re-registers a
|
||||
/// moved object's shadow every accepted transition step (SetPositionInternal
|
||||
/// → remove/add_shadows_to_cells, Ghidra 0x00515330). The streaming centre is
|
||||
/// passed in (<paramref name="liveCenterX"/>/<paramref name="liveCenterY"/>)
|
||||
/// rather than snapshotted, since it moves on recentre. Updates
|
||||
/// <see cref="RemoteMotion.LastShadowSyncPos"/> and
|
||||
/// <see cref="RemoteMotion.LastShadowSyncOrientation"/> so callers can
|
||||
/// pose-gate. Rotation matters because Setup collision geometry may be
|
||||
/// multipart or offset from the root.
|
||||
/// Called by the remote tick and the authoritative-position tail.
|
||||
/// </summary>
|
||||
internal void SyncRemoteShadowToBody(
|
||||
uint entityId,
|
||||
AcDream.Runtime.Physics.IRuntimeRemotePlacement rm,
|
||||
int liveCenterX,
|
||||
int liveCenterY,
|
||||
uint? authoritativeCellId = null)
|
||||
{
|
||||
SyncRemoteShadowToBody(
|
||||
entityId,
|
||||
rm.Body,
|
||||
liveCenterX,
|
||||
liveCenterY,
|
||||
authoritativeCellId ?? rm.CellId);
|
||||
rm.LastShadowSyncPosition = rm.Body.Position;
|
||||
rm.LastShadowSyncOrientation = rm.Body.Orientation;
|
||||
}
|
||||
|
||||
internal static bool ShouldSynchronizeShadowPose(
|
||||
System.Numerics.Vector3 currentPosition,
|
||||
System.Numerics.Quaternion currentOrientation,
|
||||
System.Numerics.Vector3 lastPosition,
|
||||
System.Numerics.Quaternion lastOrientation)
|
||||
{
|
||||
if (System.Numerics.Vector3.DistanceSquared(
|
||||
currentPosition,
|
||||
lastPosition) > 1e-4f)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
float currentLengthSquared = currentOrientation.LengthSquared();
|
||||
float lastLengthSquared = lastOrientation.LengthSquared();
|
||||
if (!float.IsFinite(currentLengthSquared)
|
||||
|| !float.IsFinite(lastLengthSquared)
|
||||
|| currentLengthSquared < 1e-12f
|
||||
|| lastLengthSquared < 1e-12f)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
float normalizedDot = MathF.Abs(
|
||||
System.Numerics.Quaternion.Dot(
|
||||
currentOrientation,
|
||||
lastOrientation)
|
||||
/ MathF.Sqrt(currentLengthSquared * lastLengthSquared));
|
||||
return !float.IsFinite(normalizedDot) || normalizedDot < 0.99999f;
|
||||
}
|
||||
|
||||
internal static bool ShouldSynchronizeShadow(
|
||||
bool cellChanged,
|
||||
System.Numerics.Vector3 currentPosition,
|
||||
System.Numerics.Quaternion currentOrientation,
|
||||
System.Numerics.Vector3 lastPosition,
|
||||
System.Numerics.Quaternion lastOrientation) =>
|
||||
cellChanged
|
||||
|| ShouldSynchronizeShadowPose(
|
||||
currentPosition,
|
||||
currentOrientation,
|
||||
lastPosition,
|
||||
lastOrientation);
|
||||
|
||||
internal void SyncRemoteShadowToBody(
|
||||
uint entityId,
|
||||
AcDream.Core.Physics.PhysicsBody body,
|
||||
int liveCenterX,
|
||||
int liveCenterY,
|
||||
uint authoritativeCellId)
|
||||
{
|
||||
ShadowPositionSynchronizer.Sync(
|
||||
_physics.Engine.ShadowObjects,
|
||||
entityId,
|
||||
body.Position,
|
||||
body.Orientation,
|
||||
authoritativeCellId,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue