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:
Erik 2026-07-26 13:39:57 +02:00
parent 0dc3bfdeff
commit 7e6033d0ad
39 changed files with 3685 additions and 1722 deletions

View file

@ -0,0 +1,244 @@
using System;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
namespace AcDream.Runtime.Physics;
/// <summary>
/// R5-V2 — the Runtime-owned <see cref="IPhysicsObjHost"/> per entity:
/// acdream's
/// stand-in for retail's <c>CPhysicsObj</c> as the movement managers see it.
/// One is retained per accepted object incarnation on
/// <c>RuntimeEntityRecord</c>, so
/// <see cref="GetObjectA"/> can resolve OTHER entities' hosts — the
/// cross-entity delivery path the <see cref="TargetManager"/> voyeur system
/// needs.
///
/// <para>Owns a <see cref="TargetManager"/> (retail
/// <c>CPhysicsObj::target_manager</c>). Its <c>set_target</c>/<c>clear_target</c>/
/// <c>add_voyeur</c>/<c>remove_voyeur</c>/<c>receive_target_update</c> seams
/// forward to it exactly as retail's CPhysicsObj does; the movement managers'
/// target seams are repointed here, replacing the AP-79 poll adapter. The
/// per-entity accessors (position/velocity/radius/contact/clocks) and the
/// <see cref="HandleUpdateTarget"/> fan-out are injected by the graphical or
/// no-window host while Runtime retains the host and manager identities.</para>
///
/// <para>R5-V3: owns a <see cref="PositionManager"/> too (retail
/// <c>CPhysicsObj::position_manager</c> — retail creates it lazily via
/// <c>get_position_manager</c>; acdream constructs it eagerly, which is
/// behaviorally identical because the empty facade no-ops until its first
/// <c>StickTo</c>/<c>ConstrainTo</c>). <see cref="HandleUpdateTarget"/> fans
/// deliveries to the injected MoveToManager fan FIRST, then the
/// PositionManager — retail <c>CPhysicsObj::HandleUpdateTarget</c> order
/// (0x00512bc0: MovementManager @0x00512bf0, PositionManager
/// @0x00512c1a).</para>
/// </summary>
public sealed class EntityPhysicsHost : IPhysicsObjHost
{
private Func<Position> _getPosition;
private Func<Vector3> _getVelocity;
private Func<float> _getRadius;
private Func<bool> _inContact;
private Func<float?> _minterpMaxSpeed;
private Func<double> _curTime;
private Func<double> _physicsTimerTime;
private Func<uint, IPhysicsObjHost?> _getObjectA;
private Action<TargetInfo> _handleUpdateTarget;
private Action _interruptCurrentMovement;
private readonly TargetManager _targetManager;
public EntityPhysicsHost(
uint id,
Func<Position> getPosition,
Func<Vector3> getVelocity,
Func<float> getRadius,
Func<bool> inContact,
Func<float?> minterpMaxSpeed,
Func<double> curTime,
Func<double> physicsTimerTime,
Func<uint, IPhysicsObjHost?> getObjectA,
Action<TargetInfo> handleUpdateTarget,
Action interruptCurrentMovement)
{
Id = id;
_getPosition = null!;
_getVelocity = null!;
_getRadius = null!;
_inContact = null!;
_minterpMaxSpeed = null!;
_curTime = null!;
_physicsTimerTime = null!;
_getObjectA = null!;
_handleUpdateTarget = null!;
_interruptCurrentMovement = null!;
Rebind(
getPosition,
getVelocity,
getRadius,
inContact,
minterpMaxSpeed,
curTime,
physicsTimerTime,
getObjectA,
handleUpdateTarget,
interruptCurrentMovement);
_targetManager = new TargetManager(this);
PositionManager = new PositionManager(this);
}
/// <summary>
/// Rebinds the changing CPhysicsObj access seams without replacing this
/// object's retail-lifetime managers. A static/minimal live object can gain
/// a MovementManager later, and the local player controller can be rebuilt,
/// but retail keeps the same CPhysicsObj, TargetManager, and PositionManager
/// for the accepted INSTANCE_TS incarnation. Existing voyeur, sticky, and
/// constraint state therefore survives the upgrade.
/// </summary>
private void Rebind(
Func<Position> getPosition,
Func<Vector3> getVelocity,
Func<float> getRadius,
Func<bool> inContact,
Func<float?> minterpMaxSpeed,
Func<double> curTime,
Func<double> physicsTimerTime,
Func<uint, IPhysicsObjHost?> getObjectA,
Action<TargetInfo> handleUpdateTarget,
Action interruptCurrentMovement)
{
ArgumentNullException.ThrowIfNull(getPosition);
ArgumentNullException.ThrowIfNull(getVelocity);
ArgumentNullException.ThrowIfNull(getRadius);
ArgumentNullException.ThrowIfNull(inContact);
ArgumentNullException.ThrowIfNull(minterpMaxSpeed);
ArgumentNullException.ThrowIfNull(curTime);
ArgumentNullException.ThrowIfNull(physicsTimerTime);
ArgumentNullException.ThrowIfNull(getObjectA);
ArgumentNullException.ThrowIfNull(handleUpdateTarget);
ArgumentNullException.ThrowIfNull(interruptCurrentMovement);
_getPosition = getPosition;
_getVelocity = getVelocity;
_getRadius = getRadius;
_inContact = inContact;
_minterpMaxSpeed = minterpMaxSpeed;
_curTime = curTime;
_physicsTimerTime = physicsTimerTime;
_getObjectA = getObjectA;
_handleUpdateTarget = handleUpdateTarget;
_interruptCurrentMovement = interruptCurrentMovement;
}
/// <summary>
/// Applies a freshly composed delegate configuration while retaining this
/// host's exact identity and manager instances.
/// </summary>
internal void RebindFrom(EntityPhysicsHost configuration)
{
ArgumentNullException.ThrowIfNull(configuration);
if (configuration.Id != Id)
throw new ArgumentException(
"A physics-host configuration must match the existing host GUID.",
nameof(configuration));
Rebind(
configuration._getPosition,
configuration._getVelocity,
configuration._getRadius,
configuration._inContact,
configuration._minterpMaxSpeed,
configuration._curTime,
configuration._physicsTimerTime,
configuration._getObjectA,
configuration._handleUpdateTarget,
configuration._interruptCurrentMovement);
}
// ── IPhysicsObjHost accessors ──────────────────────────────────────────
public uint Id { get; }
public Position Position => _getPosition();
public Vector3 Velocity => _getVelocity();
public float Radius => _getRadius();
public bool InContact => _inContact();
public float? MinterpMaxSpeed => _minterpMaxSpeed();
public double CurTime => _curTime();
public double PhysicsTimerTime => _physicsTimerTime();
/// <summary>The owned voyeur manager (retail
/// <c>CPhysicsObj::target_manager</c>).</summary>
public TargetManager TargetManager => _targetManager;
/// <summary>R5-V3 — the owned <see cref="PositionManager"/> facade (retail
/// <c>CPhysicsObj::position_manager</c>): sticky follow + (unarmed)
/// constraint leash. Seam targets: <c>MoveToManager.StickTo/Unstick</c>,
/// <c>MotionInterpreter.UnstickFromObject</c>, the per-tick
/// <c>AdjustOffset</c>/<c>UseTime</c> drivers.</summary>
public PositionManager PositionManager { get; }
// ── IPhysicsObjHost fan-out / target-tracking seams ────────────────────
public IPhysicsObjHost? GetObjectA(uint id) => _getObjectA(id);
public IPhysicsObjHost? GetRelationshipTarget(uint objectId) =>
_targetManager.GetRelationshipTarget(objectId);
public void HandleUpdateTarget(TargetInfo info)
{
// Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan order:
// MovementManager (the injected MoveToManager fan) first, then
// PositionManager (@0x00512c1a — the R5-V3 sticky consumer).
_handleUpdateTarget(info);
PositionManager.HandleUpdateTarget(info);
}
public void InterruptCurrentMovement() => _interruptCurrentMovement();
public void SetTarget(uint contextId, uint objectId, float radius, double quantum)
=> _targetManager.SetTarget(contextId, objectId, radius, quantum);
public void ClearTarget() => _targetManager.ClearTarget();
public void ReceiveTargetUpdate(TargetInfo info, IPhysicsObjHost sender) =>
_targetManager.ReceiveUpdate(info, sender);
public void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum)
=> _targetManager.AddVoyeur(watcher, radius, quantum);
public void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher) =>
_targetManager.RemoveVoyeur(watcherId, expectedWatcher);
// ── per-tick driver + Runtime-owned lifecycle ──────────────────────────
/// <summary>Retail <c>TargetManager::HandleTargetting</c> — the per-tick
/// voyeur sweep (self-throttled to 0.5 s). Retail runs it unconditionally
/// for every entity in <c>UpdateObjectInternal</c>, BEFORE the movement
/// managers' <c>UseTime</c>.</summary>
public void HandleTargetting() => _targetManager.HandleTargetting();
/// <summary>Retail <c>CPhysicsObj::exit_world</c>'s
/// <c>TargetManager::NotifyVoyeurOfEvent(ExitWorld)</c> — tell every
/// watcher of this entity that it left the world (they drop the
/// stick/moveto). Called on despawn before the host is removed from the
/// registry.</summary>
public void NotifyExitWorld() => _targetManager.NotifyVoyeurOfEvent(TargetStatus.ExitWorld);
/// <summary>
/// A Hidden object remains logically alive but is removed from the cell
/// lookup/interaction surface. Retail's DetectionManager sends
/// <c>LeftDetection</c> from <c>CObjCell::hide_object</c>; that manager is
/// not ported yet, so the current host bridges the same unavailability edge
/// through the existing target fan-out. The non-Ok update tears down watcher
/// MoveTo/Sticky consumers, and the watched-role table is cleared so an
/// UnHide re-subscription receives a fresh initial snapshot. The object's
/// own watcher role is deliberately preserved.
/// </summary>
public void NotifyHidden() =>
_targetManager.NotifyVoyeurOfEventAndClear(TargetStatus.ExitWorld);
/// <summary>R5-V3 (#171): retail <c>CPhysicsObj::teleport_hook</c>'s tail
/// (0x00514ed0 @0x00514f1b-0x00514f28) — <c>TargetManager::ClearTarget</c>
/// (drop this entity's OWN subscription) then
/// <c>NotifyVoyeurOfEvent(Teleported)</c> (every entity watching THIS one
/// drops its stick/moveto — <c>StickyManager::HandleUpdateTarget</c>'s
/// non-Ok teardown path). Called after a teleport placement.</summary>
public void NotifyTeleported()
{
_targetManager.ClearTarget();
_targetManager.NotifyVoyeurOfEvent(TargetStatus.Teleported);
}
}

View file

@ -0,0 +1,343 @@
namespace AcDream.Runtime.Physics;
/// <summary>
/// Per-remote-entity physics + motion stack — verbatim application of
/// retail's client-side motion pipeline to every remote. Mirrors retail
/// <c>CPhysicsObj::update_object</c> (0x00515D10) →
/// <c>CPhysicsObj::UpdateObjectInternal</c> (0x005156B0) →
/// <c>CPhysicsObj::UpdatePositionInternal</c> (0x00512C30) →
/// <c>CPhysicsObj::UpdatePhysicsInternal</c> (0x00510700), and ACE's
/// <c>PhysicsObj.cs</c> port.
///
/// <para>
/// Retail has NO special "interpolator" for remote entities — it runs
/// the full motion state machine on every entity, local or remote,
/// and reconciles via hard-snap on UpdatePosition. This class simply
/// pairs a <see cref="AcDream.Core.Physics.PhysicsBody"/> with its
/// <see cref="AcDream.Core.Physics.MotionInterpreter"/> so each
/// remote gets the same treatment as the local player.
/// </para>
/// </summary>
internal sealed class RemoteMotion :
IRuntimeRemotePlacement,
IRuntimeCanonicalPhysicsConsumer
{
public AcDream.Core.Physics.PhysicsBody Body { get; }
AcDream.Core.Physics.PhysicsBody IRuntimeRemoteMotion.Body => Body;
/// <summary>R5-V5: retail <c>CPhysicsObj::movement_manager</c> — the
/// ONE per-entity owner of the interp + moveto pair (acclient.h
/// <c>/* 3463 */</c>). Constructed here with the interp; the
/// MoveToManager side arrives via <c>MoveToFactory</c> +
/// <c>MakeMoveToManager()</c> in EnsureRemoteMotionBindings.
/// <see cref="Motion"/>/<see cref="MoveTo"/> below are views of its
/// children (retail <c>get_minterp</c>-style access), kept so the
/// dozens of existing call sites read unchanged.</summary>
public AcDream.Core.Physics.Motion.MovementManager Movement;
public AcDream.Core.Physics.MotionInterpreter Motion => Movement.Minterp;
/// <summary>R3-W4: the persistent per-remote dispatch sink (created
/// once by EnsureRemoteMotionBindings; also bound as
/// Motion.DefaultSink so LeaveGround/HitGround re-applies dispatch
/// cycles through the motion-table backend).</summary>
public AcDream.Core.Physics.Motion.MotionTableDispatchSink? Sink;
/// <summary>Last UpdatePosition timestamp — drives body.update_object sub-stepping.</summary>
public double LastServerPosTime;
/// <summary>Last known server position — kept for diagnostics / HUD.</summary>
public System.Numerics.Vector3 LastServerPos;
/// <summary>
/// #184: the body position at which this remote's collision SHADOW was
/// last (re-)registered. The per-tick shadow-follows-resolved sync
/// (<c>SyncRemoteShadowToBody</c>) skips re-flooding when the body has
/// not moved &gt; ε since this — a resting/idle-town crowd doesn't churn
/// the registry, while a moving/de-overlapping crowd re-registers every
/// tick. Sentinel <c>Zero</c> forces the first sync.
/// </summary>
public System.Numerics.Vector3 LastShadowSyncPos;
/// <summary>
/// Complete root orientation paired with <see cref="LastShadowSyncPos"/>.
/// Multipart and off-centre Setup collision geometry must be re-flooded
/// when the object turns in place, not only when its origin translates.
/// The default zero quaternion is the first-sync sentinel.
/// </summary>
public System.Numerics.Quaternion LastShadowSyncOrientation;
/// <summary>
/// Latest server-authoritative velocity for NPC/monster smoothing.
/// Prefer the HasVelocity vector from UpdatePosition; when ACE omits
/// it for a server-controlled creature, derive it from consecutive
/// authoritative positions instead of guessing from player RUM state.
/// </summary>
public System.Numerics.Vector3 ServerVelocity;
public bool HasServerVelocity;
/// <summary>R4-V4: the entity's verbatim retail MoveToManager
/// (server-directed movement), constructed + seam-bound by
/// EnsureRemoteMotionBindings beside the R2-Q5/R3 binds. R5-V5:
/// owned by <see cref="Movement"/>; this is the child view.</summary>
public AcDream.Core.Physics.Motion.MoveToManager? MoveTo => Movement.MoveTo;
// R5-V2: the entity's CPhysicsObj stand-in — owns the TargetManager
// voyeur system (retail CPhysicsObj::target_manager). Replaces the
// AP-79 TrackedTarget* poll fields: the MoveToManager's set_target/
// clear_target/quantum seams route here, HandleTargetting ticks per
// frame, and OTHER entities resolve this one through RuntimeEntityRecord's
// exact-incarnation PhysicsHost slot.
private Func<AcDream.Core.Physics.Motion.IPhysicsObjHost?>? _physicsHostReader;
private bool _fullPhysicsHostBound;
public EntityPhysicsHost? Host => _fullPhysicsHostBound
? _physicsHostReader?.Invoke() as EntityPhysicsHost
: null;
/// <summary>
/// True while a server MoveToObject/MoveToPosition packet is the
/// active locomotion source. Retail runs these through MoveToManager
/// and CMotionInterp; the per-tick remote driver consults this to
/// decide whether to feed body steering through
/// <see cref="AcDream.Core.Physics.RemoteMoveToDriver"/> instead of
/// the InterpretedMotionState path.
/// </summary>
public bool ServerMoveToActive;
/// <summary>
/// True once a MoveTo packet's full path payload (Origin + thresholds)
/// has been parsed and the world-converted destination is stored on
/// <see cref="MoveToDestinationWorld"/>. Cleared on arrival or when
/// the next non-MoveTo UpdateMotion replaces the locomotion source.
/// Phase L.1c (2026-04-28).
/// </summary>
public bool HasMoveToDestination;
/// <summary>
/// World-space destination from the most recent MoveTo packet's
/// <c>Origin</c> field, converted via the same landblock-grid
/// arithmetic <c>OnLivePositionUpdated</c> uses.
/// </summary>
public System.Numerics.Vector3 MoveToDestinationWorld;
/// <summary>
/// <c>min_distance</c> from the MoveTo packet's MovementParameters.
/// Used by <see cref="AcDream.Core.Physics.RemoteMoveToDriver"/> as
/// the chase-arrival threshold per retail
/// <c>MoveToManager::HandleMoveToPosition</c>.
/// </summary>
public float MoveToMinDistance;
/// <summary>
/// <c>distance_to_object</c> from the MoveTo packet. Reserved for
/// the flee branch (<c>move_away</c>); chase uses
/// <see cref="MoveToMinDistance"/>.
/// </summary>
public float MoveToDistanceToObject;
/// <summary>
/// True if MovementParameters bit 9 (<c>move_towards</c>, mask
/// <c>0x200</c>) is set on the active packet — i.e. this is a
/// chase. False = flee (<c>move_away</c>) or static target.
/// </summary>
public bool MoveToMoveTowards;
/// <summary>
/// Seconds-since-epoch timestamp of the most recent MoveTo packet
/// for this entity. Used by the per-tick driver to give up
/// steering when no refresh has arrived for
/// <see cref="AcDream.Core.Physics.RemoteMoveToDriver.StaleDestinationSeconds"/>
/// — typically because the entity left our streaming view and
/// the server stopped broadcasting its MoveTo updates.
/// </summary>
public double LastMoveToPacketTime;
/// <summary>
/// Full 32-bit cell ID from the latest UpdatePosition. High 16 bits
/// = landblock (LBx,LBy), low 16 bits = cell index (outdoor 0x0001-
/// 0x0040, indoor 0x0100+). Fed into
/// <see cref="AcDream.Core.Physics.PhysicsEngine.ResolveWithTransition"/>
/// so the retail sphere-sweep can look up the right terrain/EnvCell
/// polygons for each remote's per-frame motion. Zero until the first
/// UP lands, which disables the transition step for that frame
/// (Euler alone, matching pre-wire behavior).
/// </summary>
private uint _cellId;
private Func<uint>? _canonicalCellReader;
private Action<uint>? _canonicalCellWriter;
/// <summary>
/// Canonical full cell for this one live CPhysicsObj. Ordinary remotes
/// retain the local backing value. A MovementManager attached to an
/// existing projectile delegates reads and writes to
/// <see cref="RuntimePhysicsState"/>, so projectile prediction,
/// Movement packets, and spatial rebucketing cannot develop competing
/// cell identities.
/// </summary>
public uint CellId
{
get => _canonicalCellReader?.Invoke() ?? _cellId;
set
{
_cellId = value;
_canonicalCellWriter?.Invoke(value);
}
}
System.Numerics.Vector3 IRuntimeRemotePlacement.LastServerPosition
{
get => LastServerPos;
set => LastServerPos = value;
}
double IRuntimeRemotePlacement.LastServerPositionTime
{
get => LastServerPosTime;
set => LastServerPosTime = value;
}
System.Numerics.Vector3 IRuntimeRemotePlacement.LastShadowSyncPosition
{
get => LastShadowSyncPos;
set => LastShadowSyncPos = value;
}
System.Numerics.Quaternion IRuntimeRemotePlacement.LastShadowSyncOrientation
{
get => LastShadowSyncOrientation;
set => LastShadowSyncOrientation = value;
}
bool IRuntimeRemotePlacement.Airborne
{
get => Airborne;
set => Airborne = value;
}
void IRuntimeRemotePlacement.HitGround() =>
Movement.HitGround();
void IRuntimeRemotePlacement.LeaveGround() =>
Motion.LeaveGround();
/// <summary>
/// K-fix9 (2026-04-26): true while the remote is airborne (jump
/// arc in flight). Set when a 0xF74E VectorUpdate arrives with
/// non-trivial +Z velocity; cleared when the next UpdatePosition
/// snaps to a new ground location. While true, the per-tick
/// remote update SKIPS the "force OnWalkable + apply_current_movement"
/// step that would otherwise stomp the body's Z velocity each
/// frame, AND enables gravity so the parabolic arc actually plays
/// out between server snaps.
/// </summary>
public bool Airborne;
/// <summary>
/// Per-remote position-waypoint queue + catch-up math (retail's
/// CPhysicsObj::InterpolateTo + InterpolationManager::adjust_offset).
/// Drives per-tick body translation for grounded player remotes
/// via <see cref="Position"/>.
/// </summary>
public AcDream.Core.Physics.InterpolationManager Interp { get; } =
new AcDream.Core.Physics.InterpolationManager();
/// <summary>
/// Per-frame combiner for animation root motion + InterpolationManager
/// correction. Mirrors retail UpdatePositionInternal @ 0x00512c30:
/// queue catch-up REPLACES anim when active; anim stands when queue
/// is idle.
/// </summary>
public AcDream.Core.Physics.RemoteMotionCombiner Position { get; } =
new AcDream.Core.Physics.RemoteMotionCombiner();
/// <summary>Reusable complete delta frame shared by interpolation,
/// Sticky/Constraint, and the final body composition.</summary>
public AcDream.Core.Physics.Motion.MotionDeltaFrame PositionManagerDeltaScratch { get; } =
new AcDream.Core.Physics.Motion.MotionDeltaFrame();
/// <summary>
/// Diagnostic-only (gated on <c>ACDREAM_REMOTE_VEL_DIAG=1</c>): the
/// previous UpdatePosition's world position + timestamp. The per-tick
/// path compares the server's broadcast pace with the literal root
/// displacement emitted by <c>CSequence::update</c>. The old inferred
/// walk/run velocity was the source of the periodic overshoot this
/// diagnostic was introduced to find.
/// </summary>
public System.Numerics.Vector3 PrevServerPos;
public double PrevServerPosTime;
public double LastOmegaDiagLogTime;
/// <summary>
/// Diagnostic-only: maximum scaled CSequence root-motion speed
/// observed since the last UpdatePosition arrival. The next UP
/// compares it with the server's observed pace. Reset on each UP.
/// </summary>
public float MaxRootMotionSpeedSinceLastUP;
public RemoteMotion(AcDream.Core.Physics.PhysicsBody? sharedBody = null)
{
Body = sharedBody ?? new AcDream.Core.Physics.PhysicsBody
{
// Remotes don't simulate gravity — server owns Z. Force
// Contact + OnWalkable + Active so apply_current_movement
// writes velocity through every tick (the gate in
// MotionInterpreter.apply_current_movement is
// PhysicsObj.OnWalkable).
State = AcDream.Core.Physics.PhysicsStateFlags.ReportCollisions,
TransientState = AcDream.Core.Physics.TransientStateFlags.Contact
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
| AcDream.Core.Physics.TransientStateFlags.Active,
// R4-V5 door-swing fix: a RemoteMotion exists only for a
// WORLD entity — retail's physics_obj->cell is non-null the
// moment the object is placed. Without this, the interp's
// detached-object guard stripped every dispatched
// transition link (see PhysicsBody.InWorld).
InWorld = true,
};
// R5-V5: the interp is owned by the MovementManager facade
// (retail CPhysicsObj::movement_manager -> motion_interpreter).
// acdream constructs it eagerly here — retail's lazy-create
// window is never observable (see MovementManager's class doc).
Movement = new AcDream.Core.Physics.Motion.MovementManager(
new AcDream.Core.Physics.MotionInterpreter(Body)
{
// R4-V5 #160 fix: retail remotes carry a weenie whose
// InqRunRate FAILS, landing apply_run_to_command on
// my_run_rate (the M13 wire feed). A null weenie took the
// degenerate 1.0 branch — observer-side run movetos played
// and moved in slow motion. See RemoteWeenie.
WeenieObj = new AcDream.Core.Physics.RemoteWeenie(),
});
}
public void BindCanonicalRuntime(
Func<AcDream.Core.Physics.Motion.IPhysicsObjHost?> readPhysicsHost,
Func<uint> readCell,
Action<uint> writeCell)
{
ArgumentNullException.ThrowIfNull(readPhysicsHost);
ArgumentNullException.ThrowIfNull(readCell);
ArgumentNullException.ThrowIfNull(writeCell);
if (_physicsHostReader is not null
|| _canonicalCellReader is not null
|| _canonicalCellWriter is not null)
{
throw new InvalidOperationException("The remote canonical runtime context is already bound.");
}
// All validation precedes publication. Nothing below can throw, so a
// rejected context never leaves a half-bound reusable component.
_physicsHostReader = readPhysicsHost;
_canonicalCellReader = readCell;
_canonicalCellWriter = writeCell;
}
public void BindCanonicalCell(Func<uint> read, Action<uint> write)
{
ArgumentNullException.ThrowIfNull(read);
ArgumentNullException.ThrowIfNull(write);
if (_canonicalCellReader is not null || _canonicalCellWriter is not null)
throw new InvalidOperationException("The remote canonical cell source is already bound.");
_canonicalCellReader = read;
_canonicalCellWriter = write;
}
public void MarkFullPhysicsHostBound()
{
if (_physicsHostReader is null)
throw new InvalidOperationException("The canonical physics-host source is not bound.");
_fullPhysicsHostBound = true;
}
}

View file

@ -0,0 +1,272 @@
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.Runtime.Entities;
using DatReaderWriter.Types;
namespace AcDream.Runtime.Physics;
internal readonly record struct RuntimePhysicsFrameSnapshot(
Vector3 Position,
Quaternion Orientation,
uint FullCellId);
internal sealed class RuntimeOrdinaryPhysicsCommit
{
internal required RuntimeOrdinaryPhysicsUpdater Owner { get; init; }
internal required RuntimeEntityRecord Record { get; init; }
internal required PhysicsBody Body { get; init; }
internal required ulong ObjectClockEpoch { get; init; }
internal required bool FrameChanged { get; init; }
internal required Func<bool>? ExternalOwnerValid { get; init; }
internal bool Completed { get; set; }
internal RuntimePhysicsFrameSnapshot Snapshot { get; init; }
}
/// <summary>
/// Presentation-free, body-backed branch of retail
/// <c>CPhysicsObj::UpdateObjectInternal</c> (`0x005156B0`). App supplies the
/// completed animation root frame and acknowledges the resulting projection;
/// Runtime owns integration, transition, canonical-body state, and shadows.
/// </summary>
internal sealed class RuntimeOrdinaryPhysicsUpdater
{
private readonly RuntimePhysicsState _physics;
internal RuntimeOrdinaryPhysicsUpdater(RuntimePhysicsState physics)
{
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
}
internal bool TryBegin(
RuntimeEntityRecord record,
Frame rootFrame,
float objectScale,
float quantum,
float radius,
float height,
ulong objectClockEpoch,
AnimationSequencer? sequencer,
Action<uint, AnimationSequencer> captureAnimationHooks,
Func<bool>? externalOwnerValid,
out RuntimeOrdinaryPhysicsCommit commit)
{
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(rootFrame);
ArgumentNullException.ThrowIfNull(captureAnimationHooks);
if (record.PhysicsBody is not { } body
|| !IsCurrent(
record,
body,
objectClockEpoch,
externalOwnerValid))
{
commit = null!;
return false;
}
body.State = record.FinalPhysicsState;
Vector3 priorPosition = body.Position;
Quaternion priorOrientation = body.Orientation;
bool previousContact = body.InContact;
bool previousOnWalkable = body.OnWalkable;
// UpdatePositionInternal 0x00512CA1: scale root translation only while
// OnWalkable. Complete root orientation composes regardless.
Vector3 candidatePosition = priorPosition;
if (body.OnWalkable && rootFrame.Origin != Vector3.Zero)
{
candidatePosition += Vector3.Transform(
rootFrame.Origin * objectScale,
priorOrientation);
}
Quaternion candidateOrientation = priorOrientation;
if (!rootFrame.Orientation.IsIdentity)
{
candidateOrientation = FrameOps.SetRotate(
candidatePosition,
priorOrientation,
priorOrientation * rootFrame.Orientation);
}
body.SetFrameInCurrentCell(candidatePosition, candidateOrientation);
body.calc_acceleration();
body.UpdatePhysicsInternal(quantum);
body.SetFrameInCurrentCell(body.Position, body.Orientation);
if (sequencer is not null)
{
uint localId = record.LocalEntityId
?? throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} has no local identity.");
captureAnimationHooks(localId, sequencer);
}
if (!IsCurrent(
record,
body,
objectClockEpoch,
externalOwnerValid))
{
commit = null!;
return false;
}
Vector3 integratedPosition = body.Position;
uint sourceCellId = record.FullCellId;
uint resolvedCellId = sourceCellId;
bool frameChanged = integratedPosition != priorPosition
|| body.Orientation != priorOrientation;
uint movingEntityId = record.LocalEntityId ?? 0u;
if (integratedPosition != priorPosition
&& sourceCellId != 0
&& radius >= 0.05f
&& _physics.Engine.LandblockCount > 0)
{
ResolveResult resolved = _physics.Engine.ResolveWithTransition(
priorPosition,
integratedPosition,
sourceCellId,
radius,
height,
stepUpHeight: 0.4f,
stepDownHeight: 0.4f,
isOnGround: previousOnWalkable,
body: body,
moverFlags: IsPlayerGuid(record.ServerGuid)
? ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide
: ObjectInfoState.EdgeSlide,
movingEntityId: movingEntityId);
if (resolved.Ok)
{
resolvedCellId = resolved.CellId != 0
? resolved.CellId
: sourceCellId;
body.CommitTransitionPosition(
resolvedCellId,
resolved.Position);
PhysicsObjUpdate.CommitSetPositionTransition(
body,
resolved.InContact,
resolved.OnWalkable,
resolved.CollisionNormalValid,
resolved.CollisionNormal,
previousContact,
previousOnWalkable);
body.CachedVelocity = quantum > 0f
? (body.Position - priorPosition) / quantum
: Vector3.Zero;
}
else
{
body.CachedVelocity = Vector3.Zero;
}
}
else
{
body.CachedVelocity = Vector3.Zero;
}
if (!IsCurrent(
record,
body,
objectClockEpoch,
externalOwnerValid))
{
commit = null!;
return false;
}
commit = new RuntimeOrdinaryPhysicsCommit
{
Owner = this,
Record = record,
Body = body,
ObjectClockEpoch = objectClockEpoch,
FrameChanged = frameChanged,
ExternalOwnerValid = externalOwnerValid,
Snapshot = new RuntimePhysicsFrameSnapshot(
body.Position,
body.Orientation,
resolvedCellId),
};
return true;
}
internal bool Complete(
RuntimeOrdinaryPhysicsCommit commit,
int liveCenterX,
int liveCenterY,
Func<RuntimePhysicsFrameSnapshot, bool> acknowledgeProjection)
{
ArgumentNullException.ThrowIfNull(commit);
ArgumentNullException.ThrowIfNull(acknowledgeProjection);
if (!ReferenceEquals(commit.Owner, this))
{
throw new InvalidOperationException(
"An ordinary-physics commit belongs to another Runtime owner.");
}
if (commit.Completed)
{
throw new InvalidOperationException(
"An ordinary-physics commit has already completed.");
}
commit.Completed = true;
if (!IsCurrent(
commit.Record,
commit.Body,
commit.ObjectClockEpoch,
commit.ExternalOwnerValid)
|| !_physics.CommitOrdinaryCell(
commit.Record,
commit.Body,
commit.ObjectClockEpoch,
commit.Snapshot.FullCellId,
commit.ExternalOwnerValid)
|| !acknowledgeProjection(commit.Snapshot)
|| !IsCurrent(
commit.Record,
commit.Body,
commit.ObjectClockEpoch,
commit.ExternalOwnerValid))
{
return false;
}
if (commit.FrameChanged
&& commit.Record.FullCellId != 0)
{
ShadowPositionSynchronizer.Sync(
_physics.Engine.ShadowObjects,
commit.Record.LocalEntityId ?? 0u,
commit.Body.Position,
commit.Body.Orientation,
commit.Record.FullCellId,
liveCenterX,
liveCenterY);
}
return IsCurrent(
commit.Record,
commit.Body,
commit.ObjectClockEpoch,
commit.ExternalOwnerValid);
}
private bool IsCurrent(
RuntimeEntityRecord record,
PhysicsBody body,
ulong objectClockEpoch,
Func<bool>? externalOwnerValid) =>
_physics.IsSpatialRoot(record)
&& record.ObjectClockEpoch == objectClockEpoch
&& ReferenceEquals(record.PhysicsBody, body)
&& record.RemoteMotion is null
&& (externalOwnerValid?.Invoke() ?? true);
private static bool IsPlayerGuid(uint guid) =>
(guid & 0xFF000000u) == 0x50000000u;
}

View file

@ -0,0 +1,787 @@
using AcDream.Core.Physics;
using AcDream.Runtime.Entities;
namespace AcDream.Runtime.Physics;
public readonly record struct RuntimePhysicsOwnershipSnapshot(
int LandblockCount,
int RetainedShadowRegistrationCount,
int SpatialRootCount,
int SpatialRemoteCount,
int CollisionAdmissionCount,
int CollisionGenerationCount,
bool OwnsProductionDataCache,
bool IsDisposed);
public readonly record struct RuntimePhysicsCellCommit(
RuntimeEntityRecord Record,
uint PreviousFullCellId,
uint FullCellId,
ulong SpatialAuthorityVersion);
public sealed record RuntimeLandblockCollisionAssets(
uint LandblockId,
TerrainSurface Terrain,
IReadOnlyList<CellSurface> CellSurfaces,
IReadOnlyList<PortalPlane> PortalPlanes,
float WorldOffsetX,
float WorldOffsetY,
uint CurrentCellId);
public sealed class RuntimeCollisionAdmission
{
internal RuntimeCollisionAdmission(
RuntimePhysicsState owner,
uint landblockId,
ulong generation)
{
Owner = owner;
LandblockId = landblockId;
Generation = generation;
}
internal RuntimePhysicsState Owner { get; }
internal bool AssetsAdmitted { get; set; }
internal bool Completed { get; set; }
public uint LandblockId { get; }
public ulong Generation { get; }
}
public readonly record struct RuntimeCollisionAcknowledgement(
uint LandblockId,
ulong Generation,
bool WasResident);
/// <summary>
/// Presentation-free mutable physics world for one Runtime/session owner.
/// Immutable prepared collision inputs may be supplied by a graphical or
/// no-window host, but the engine, transition scratch, cell graph, and shadow
/// registry are never shared between Runtime instances.
/// </summary>
public sealed class RuntimePhysicsState : IDisposable
{
private readonly Dictionary<RuntimeEntityKey, RuntimeEntityRecord>
_spatialRoots = new();
private readonly Dictionary<RuntimeEntityKey, IRuntimeRemoteMotion>
_spatialRemotes = new();
private readonly Dictionary<uint, ulong> _collisionGenerations = new();
private readonly Dictionary<uint, RuntimeCollisionAdmission>
_collisionAdmissions = new();
private bool _disposed;
public event Action<RuntimePhysicsCellCommit>? CellCommitted;
internal RuntimePhysicsState(
RuntimeEntityDirectory entities,
PhysicsDataCache? dataCache = null)
{
Entities = entities ?? throw new ArgumentNullException(nameof(entities));
DataCache = dataCache ?? PhysicsDataCache.CreateProduction();
Engine = new PhysicsEngine
{
DataCache = DataCache,
};
}
internal RuntimeEntityDirectory Entities { get; }
public PhysicsEngine Engine { get; }
public PhysicsDataCache DataCache { get; }
public int SpatialRootCount => _spatialRoots.Count;
public int SpatialRemoteCount => _spatialRemotes.Count;
public RuntimePhysicsOwnershipSnapshot CaptureOwnership() =>
new(
Engine.LandblockCount,
Engine.ShadowObjects.RetainedRegistrationCount,
_spatialRoots.Count,
_spatialRemotes.Count,
_collisionAdmissions.Count,
_collisionGenerations.Count,
ReferenceEquals(Engine.DataCache, DataCache),
_disposed);
public void AcknowledgeSpatialProjection(
RuntimeEntityRecord record,
bool spatial)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(record);
if (record.Key is not { } key)
{
if (spatial)
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} cannot enter the physics workset without a local identity.");
}
return;
}
if (spatial && Entities.IsCurrent(record))
{
_spatialRoots[key] = record;
if (record.RemoteMotion is { } remote)
_spatialRemotes[key] = remote;
else
_spatialRemotes.Remove(key);
return;
}
RemoveSpatialProjection(record);
}
public void RefreshRemoteComponent(RuntimeEntityRecord record)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(record);
if (record.Key is not { } key
|| !_spatialRoots.TryGetValue(key, out RuntimeEntityRecord? root)
|| !ReferenceEquals(root, record)
|| !Entities.IsCurrent(record))
{
RemoveSpatialRemote(record);
return;
}
if (record.RemoteMotion is { } remote)
_spatialRemotes[key] = remote;
else
_spatialRemotes.Remove(key);
}
public void SetRemoteMotion(
RuntimeEntityRecord record,
IRuntimeRemoteMotion runtime,
Func<bool>? externalOwnerValid = null)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(runtime);
EnsureCurrent(record);
if (!(externalOwnerValid?.Invoke() ?? true))
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} changed external ownership before remote-motion binding.");
}
if (record.RemoteMotionBindingInProgress)
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} remote-motion binding is already in progress.");
}
PhysicsBody candidateBody = runtime.Body
?? throw new InvalidOperationException(
"A remote-motion runtime returned no physics body.");
if (ReferenceEquals(record.RemoteMotion, runtime))
{
if (!ReferenceEquals(record.PhysicsBody, candidateBody))
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} remote motion changed its canonical physics body.");
}
candidateBody.State = record.FinalPhysicsState;
SynchronizeBodyActiveState(record);
RefreshRemoteComponent(record);
return;
}
if (record.PhysicsBodyAcquisitionInProgress
&& record.PhysicsBody is null)
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} cannot bind remote motion during physics-body acquisition.");
}
if (record.PhysicsBody is { } canonicalBody
&& !ReferenceEquals(canonicalBody, candidateBody))
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} cannot replace its canonical physics body.");
}
if (record.RequiresRemotePlacementRuntime
&& runtime is not IRuntimeRemotePlacement)
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} cannot discard its remote-placement contract.");
}
ulong sessionVersion = Entities.SessionLifetimeVersion;
PhysicsBody? expectedBody = record.PhysicsBody;
IRuntimeRemoteMotion? expectedRuntime = record.RemoteMotion;
bool expectedPlacementContract =
record.RequiresRemotePlacementRuntime;
Func<AcDream.Core.Physics.Motion.IPhysicsObjHost?> readPhysicsHost =
() => Entities.IsCurrent(record)
&& ReferenceEquals(record.RemoteMotion, runtime)
? record.PhysicsHost
: null;
Func<uint> readCell = () => record.FullCellId;
Action<uint> writeCell = cellId =>
CommitCanonicalCell(record, runtime, cellId);
Entities.SetRemoteMotionBindingInProgress(record, true);
try
{
if (runtime is IRuntimeCanonicalPhysicsConsumer canonicalConsumer)
{
canonicalConsumer.BindCanonicalRuntime(
readPhysicsHost,
readCell,
writeCell);
}
else
{
bool consumesHost = runtime is IRuntimePhysicsHostConsumer;
bool consumesCell = runtime is IRuntimeCanonicalCellConsumer;
if (consumesHost && consumesCell)
{
throw new InvalidOperationException(
"A remote runtime that consumes both canonical host and cell identity must bind them atomically.");
}
if (runtime is IRuntimePhysicsHostConsumer hostConsumer)
hostConsumer.BindPhysicsHost(readPhysicsHost);
if (runtime is IRuntimeCanonicalCellConsumer cellConsumer)
cellConsumer.BindCanonicalCell(readCell, writeCell);
}
if (Entities.SessionLifetimeVersion != sessionVersion
|| !Entities.IsCurrent(record)
|| !ReferenceEquals(record.PhysicsBody, expectedBody)
|| !ReferenceEquals(record.RemoteMotion, expectedRuntime)
|| record.RequiresRemotePlacementRuntime
!= expectedPlacementContract
|| !(externalOwnerValid?.Invoke() ?? true))
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} changed ownership during remote-motion binding.");
}
Entities.SetRequiresRemotePlacementRuntime(
record,
expectedPlacementContract
|| runtime is IRuntimeRemotePlacement);
Entities.SetRemoteMotion(record, runtime);
Entities.SetPhysicsBody(record, candidateBody);
candidateBody.State = record.FinalPhysicsState;
SynchronizeBodyActiveState(record);
RefreshRemoteComponent(record);
}
finally
{
if (Entities.IsCurrent(record))
{
Entities.SetRemoteMotionBindingInProgress(record, false);
}
}
}
/// <summary>
/// Acquires the one retail <c>CPhysicsObj</c> body for an exact accepted
/// object incarnation. Factories may cross a host boundary, so ownership
/// is revalidated after the callback before the body becomes canonical.
/// </summary>
public PhysicsBody GetOrCreatePhysicsBody(
RuntimeEntityRecord record,
Func<RuntimeEntityRecord, PhysicsBody> factory,
Func<bool>? externalOwnerValid = null)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(factory);
EnsureCurrent(record);
if (!(externalOwnerValid?.Invoke() ?? true))
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} changed external ownership before physics-body acquisition.");
}
if (record.PhysicsBody is { } retained)
return retained;
if (record.PhysicsBodyAcquisitionInProgress)
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} physics-body acquisition is already in progress.");
}
Entities.SetPhysicsBodyAcquisitionInProgress(record, true);
try
{
PhysicsBody candidate = factory(record)
?? throw new InvalidOperationException(
"Physics-body factory returned null.");
if (!Entities.IsCurrent(record)
|| !(externalOwnerValid?.Invoke() ?? true))
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} changed ownership during physics-body acquisition.");
}
if (record.PhysicsBody is { } concurrentlyBound)
{
if (ReferenceEquals(concurrentlyBound, candidate))
return concurrentlyBound;
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} acquired two physics bodies within one incarnation.");
}
Entities.SetPhysicsBody(record, candidate);
candidate.State = record.FinalPhysicsState;
SynchronizeBodyActiveState(record);
return candidate;
}
finally
{
// A callback can retire this incarnation. The in-progress bit
// belongs to the record itself and must not strand its teardown.
if (Entities.IsCurrent(record))
Entities.SetPhysicsBodyAcquisitionInProgress(record, false);
else
record.PhysicsBodyAcquisitionInProgress = false;
}
}
public void InstallPhysicsHost(
RuntimeEntityRecord record,
AcDream.Core.Physics.Motion.IPhysicsObjHost host,
Func<bool>? externalOwnerValid = null)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(host);
EnsureCurrent(record);
if (host.Id != record.ServerGuid)
{
throw new ArgumentException(
"A physics host must match its runtime entity GUID.",
nameof(host));
}
if (!(externalOwnerValid?.Invoke() ?? true))
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} changed external ownership before physics-host installation.");
}
if (record.PhysicsHost is not null)
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} already owns its incarnation-stable physics host.");
}
Entities.SetPhysicsHost(record, host);
if (!Entities.IsCurrent(record)
|| !(externalOwnerValid?.Invoke() ?? true))
{
if (ReferenceEquals(record.PhysicsHost, host))
record.PhysicsHost = null;
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} changed ownership during physics-host installation.");
}
}
public EntityPhysicsHost InstallOrRebindPhysicsHost(
RuntimeEntityRecord record,
EntityPhysicsHost configuration,
Func<bool>? externalOwnerValid = null)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(configuration);
EnsureCurrent(record);
if (!(externalOwnerValid?.Invoke() ?? true))
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} changed external ownership before physics-host composition.");
}
if (record.PhysicsHost is null)
{
InstallPhysicsHost(record, configuration, externalOwnerValid);
return configuration;
}
if (record.PhysicsHost is not EntityPhysicsHost existing)
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} owns an incompatible physics-host implementation.");
}
existing.RebindFrom(configuration);
if (!Entities.IsCurrent(record)
|| !(externalOwnerValid?.Invoke() ?? true))
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} changed ownership during physics-host composition.");
}
return existing;
}
public EntityPhysicsHost SelectStablePhysicsHostWithoutRebind(
RuntimeEntityRecord record,
EntityPhysicsHost configuration,
Func<bool>? externalOwnerValid = null)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(configuration);
EnsureCurrent(record);
if (!(externalOwnerValid?.Invoke() ?? true))
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} changed external ownership during physics-host preparation.");
}
return record.PhysicsHost switch
{
null => configuration,
EntityPhysicsHost existing => existing,
_ => throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} owns an incompatible physics-host implementation."),
};
}
public bool TryGetPhysicsHost(
uint serverGuid,
out AcDream.Core.Physics.Motion.IPhysicsObjHost host)
{
EnsureNotDisposed();
if (Entities.TryGetActive(serverGuid, out RuntimeEntityRecord record)
&& record.PhysicsHost is { } existing)
{
host = existing;
return true;
}
host = null!;
return false;
}
public bool ClearRemoteMotion(RuntimeEntityRecord record)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(record);
if (!Entities.IsCurrent(record)
|| record.RemoteMotion is null)
{
return false;
}
Entities.SetRemoteMotion(record, null);
RefreshRemoteComponent(record);
return true;
}
public void RemoveSpatialProjection(RuntimeEntityRecord record)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(record);
if (record.Key is not { } key)
return;
if (_spatialRoots.TryGetValue(key, out RuntimeEntityRecord? root)
&& ReferenceEquals(root, record))
{
_spatialRoots.Remove(key);
}
RemoveSpatialRemote(record);
}
public bool IsSpatialRoot(RuntimeEntityRecord record) =>
record.Key is { } key
&& Entities.IsCurrent(record)
&& _spatialRoots.TryGetValue(key, out RuntimeEntityRecord? indexed)
&& ReferenceEquals(indexed, record);
public bool IsSpatialRemote(
RuntimeEntityRecord record,
IRuntimeRemoteMotion remote) =>
IsSpatialRoot(record)
&& ReferenceEquals(record.RemoteMotion, remote)
&& record.Key is { } key
&& _spatialRemotes.TryGetValue(key, out IRuntimeRemoteMotion? indexed)
&& ReferenceEquals(indexed, remote);
public void CopySpatialRootsTo(List<RuntimeEntityRecord> destination)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(destination);
destination.Clear();
foreach ((RuntimeEntityKey key, RuntimeEntityRecord record)
in _spatialRoots)
{
if (record.Key == key
&& Entities.IsCurrent(record))
{
destination.Add(record);
}
}
}
public void CopySpatialRemotesTo(List<RuntimeEntityRecord> destination)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(destination);
destination.Clear();
foreach ((RuntimeEntityKey key, IRuntimeRemoteMotion remote)
in _spatialRemotes)
{
if (Entities.TryGetByLocalId(
key.LocalEntityId,
out RuntimeEntityRecord record)
&& record.Key == key
&& ReferenceEquals(record.RemoteMotion, remote)
&& IsSpatialRoot(record))
{
destination.Add(record);
}
}
}
internal bool CommitOrdinaryCell(
RuntimeEntityRecord record,
PhysicsBody body,
ulong objectClockEpoch,
uint fullCellId,
Func<bool>? externalOwnerValid)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(body);
bool IsExactOwner() =>
IsSpatialRoot(record)
&& record.ObjectClockEpoch == objectClockEpoch
&& ReferenceEquals(record.PhysicsBody, body)
&& record.RemoteMotion is null
&& (externalOwnerValid?.Invoke() ?? true);
return CommitCanonicalCell(
record,
fullCellId,
IsExactOwner);
}
public void ClearSpatialWorksets()
{
EnsureNotDisposed();
_spatialRemotes.Clear();
_spatialRoots.Clear();
}
public RuntimeCollisionAdmission BeginCollisionAdmission(
uint landblockId)
{
EnsureNotDisposed();
uint canonical = CanonicalLandblock(landblockId);
ulong generation = _collisionGenerations.TryGetValue(
canonical,
out ulong current)
? checked(current + 1UL)
: 1UL;
_collisionGenerations[canonical] = generation;
var admission = new RuntimeCollisionAdmission(
this,
canonical,
generation);
_collisionAdmissions[canonical] = admission;
return admission;
}
public void AdmitCollisionAssets(
RuntimeCollisionAdmission admission,
RuntimeLandblockCollisionAssets assets)
{
ValidateAdmission(admission);
ArgumentNullException.ThrowIfNull(assets);
if (CanonicalLandblock(assets.LandblockId)
!= admission.LandblockId)
{
throw new ArgumentException(
"Collision assets do not match their admission landblock.",
nameof(assets));
}
if (admission.Completed)
{
throw new InvalidOperationException(
"A completed collision admission cannot publish more assets.");
}
if (admission.AssetsAdmitted)
{
throw new InvalidOperationException(
"Collision assets were already admitted by this receipt.");
}
Engine.AddLandblock(
admission.LandblockId,
assets.Terrain,
assets.CellSurfaces,
assets.PortalPlanes,
assets.WorldOffsetX,
assets.WorldOffsetY);
if ((assets.CurrentCellId & 0xFFFF0000u)
== (admission.LandblockId & 0xFFFF0000u))
{
Engine.UpdatePlayerCurrCell(assets.CurrentCellId);
}
admission.AssetsAdmitted = true;
}
public RuntimeCollisionAcknowledgement CompleteCollisionAdmission(
RuntimeCollisionAdmission admission)
{
ValidateAdmission(admission);
if (!admission.AssetsAdmitted)
{
throw new InvalidOperationException(
"Collision admission cannot complete before its assets publish.");
}
if (admission.Completed)
{
throw new InvalidOperationException(
"Collision admission has already completed.");
}
admission.Completed = true;
_collisionAdmissions.Remove(admission.LandblockId);
return new RuntimeCollisionAcknowledgement(
admission.LandblockId,
admission.Generation,
Engine.IsLandblockTerrainResident(admission.LandblockId));
}
public RuntimeCollisionAcknowledgement DemoteCollisionToTerrain(
uint landblockId)
{
EnsureNotDisposed();
uint canonical = CanonicalLandblock(landblockId);
bool resident = Engine.IsLandblockTerrainResident(canonical);
InvalidateCollisionAdmission(canonical);
Engine.DemoteLandblockToTerrain(canonical);
return new RuntimeCollisionAcknowledgement(
canonical,
_collisionGenerations[canonical],
resident);
}
public RuntimeCollisionAcknowledgement WithdrawCollision(
uint landblockId)
{
EnsureNotDisposed();
uint canonical = CanonicalLandblock(landblockId);
bool resident = Engine.IsLandblockTerrainResident(canonical);
InvalidateCollisionAdmission(canonical);
Engine.RemoveLandblock(canonical);
return new RuntimeCollisionAcknowledgement(
canonical,
_collisionGenerations[canonical],
resident);
}
public void Dispose()
{
if (_disposed)
return;
_spatialRemotes.Clear();
_spatialRoots.Clear();
_collisionAdmissions.Clear();
_collisionGenerations.Clear();
CellCommitted = null;
_disposed = true;
}
private void CommitCanonicalCell(
RuntimeEntityRecord record,
IRuntimeRemoteMotion expectedRuntime,
uint fullCellId)
{
_ = CommitCanonicalCell(
record,
fullCellId,
() => Entities.IsCurrent(record)
&& ReferenceEquals(record.RemoteMotion, expectedRuntime));
}
private bool CommitCanonicalCell(
RuntimeEntityRecord record,
uint fullCellId,
Func<bool> exactOwnerValid)
{
if (fullCellId == 0 || !exactOwnerValid())
return false;
if (fullCellId == record.FullCellId)
return true;
uint previousCellId = record.FullCellId;
Entities.SetFullCell(
record,
fullCellId,
(fullCellId & 0xFFFF0000u) | 0xFFFFu);
CellCommitted?.Invoke(
new RuntimePhysicsCellCommit(
record,
previousCellId,
fullCellId,
record.SpatialAuthorityVersion));
return exactOwnerValid()
&& record.FullCellId == fullCellId;
}
private void RemoveSpatialRemote(RuntimeEntityRecord record)
{
if (record.Key is not { } key)
return;
if (_spatialRemotes.TryGetValue(
key,
out IRuntimeRemoteMotion? remote)
&& (record.RemoteMotion is null
|| ReferenceEquals(remote, record.RemoteMotion)
|| !Entities.IsCurrent(record)))
{
_spatialRemotes.Remove(key);
}
}
private void EnsureNotDisposed() =>
ObjectDisposedException.ThrowIf(_disposed, this);
private void EnsureCurrent(RuntimeEntityRecord record)
{
if (!Entities.IsCurrent(record))
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} is not current.");
}
}
private void ValidateAdmission(RuntimeCollisionAdmission admission)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(admission);
if (!ReferenceEquals(admission.Owner, this)
|| !_collisionAdmissions.TryGetValue(
admission.LandblockId,
out RuntimeCollisionAdmission? current)
|| !ReferenceEquals(current, admission)
|| !_collisionGenerations.TryGetValue(
admission.LandblockId,
out ulong generation)
|| generation != admission.Generation)
{
throw new InvalidOperationException(
"Collision admission is stale or belongs to another Runtime.");
}
}
private void InvalidateCollisionAdmission(uint landblockId)
{
ulong generation = _collisionGenerations.TryGetValue(
landblockId,
out ulong current)
? checked(current + 1UL)
: 1UL;
_collisionGenerations[landblockId] = generation;
_collisionAdmissions.Remove(landblockId);
}
private static uint CanonicalLandblock(uint value) =>
(value & 0xFFFF0000u) | 0xFFFFu;
private static void SynchronizeBodyActiveState(RuntimeEntityRecord record)
{
if (record.PhysicsBody is not { } body)
return;
if (record.ObjectClock.IsActive)
body.TransientState |= TransientStateFlags.Active;
else
body.TransientState &= ~TransientStateFlags.Active;
}
}

View file

@ -0,0 +1,42 @@
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
namespace AcDream.Runtime.Physics;
public interface IRuntimeRemoteMotion
{
PhysicsBody Body { get; }
}
public interface IRuntimePhysicsHostConsumer
{
void BindPhysicsHost(Func<IPhysicsObjHost?> read);
}
public interface IRuntimeCanonicalPhysicsConsumer
{
void BindCanonicalRuntime(
Func<IPhysicsObjHost?> readPhysicsHost,
Func<uint> readCell,
Action<uint> writeCell);
}
public interface IRuntimeCanonicalCellConsumer
{
void BindCanonicalCell(Func<uint> read, Action<uint> write);
}
public interface IRuntimeRemotePlacement :
IRuntimeRemoteMotion,
IRuntimeCanonicalCellConsumer
{
uint CellId { get; set; }
bool Airborne { get; set; }
Vector3 LastServerPosition { get; set; }
double LastServerPositionTime { get; set; }
Vector3 LastShadowSyncPosition { get; set; }
Quaternion LastShadowSyncOrientation { get; set; }
void HitGround();
void LeaveGround();
}

View 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 &amp;&amp; serverGuid != 0 &amp;&amp; serverGuid != _playerServerGuid
/// &amp;&amp; rm.LastServerPosTime &gt; 0</c>).
/// Hidden remotes use a separate live-entity pass because retail keeps their
/// PositionManager alive even when the object has no render-animation owner.
///
/// <para>Slice 2a extracted this verbatim (fork intact). Slice 2b then COLLAPSED
/// the player/NPC fork: the former Path A (grounded PLAYER remotes advanced by the
/// interp catch-up with the sweep deliberately OMITTED, per the now-retired issue
/// #40 premise) is gone — <b>every</b> remote now runs the SAME catch-up +
/// <c>ResolveWithTransition</c> sweep + shadow-follows-resolved, so packed PLAYER
/// remotes de-overlap exactly like NPCs (retail <c>UpdateObjectInternal</c>
/// 0x005156b0 has no player/remote fork). The only surviving player/NPC split is
/// the <c>!IsPlayerGuid</c>-gated stale-velocity animation-cycle stop. See
/// <c>docs/research/2026-07-07-184-slice2-unify-extract-handoff.md</c>.</para>
///
/// <para>Shared policy arrives through focused Physics delegates:
/// DAT-derived shape dimensions and animation-cycle projection arrive through
/// the App adapter in a graphical host. <c>SyncRemoteShadowToBody</c>
/// (remote-physics-specific) moved here and is called back from the UP-branch
/// tail; <c>ApplyPositionManagerDelta</c> / <c>TickRemoteMoveTo</c> had no other
/// callers and moved here outright.</para>
/// </summary>
internal sealed class RuntimeRemotePhysicsUpdater
{
// Preserved from #184 Slice 2a; the remote tick remains its only caller.
private const double ServerControlledVelocityStaleSeconds = 0.60;
private readonly RuntimePhysicsState _physics;
internal RuntimeRemotePhysicsUpdater(
RuntimePhysicsState physics)
{
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
}
// Retail GUID classification used only for collision flags.
private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u;
/// <summary>
/// Canonical ordinary-object tick. Render animation is optional: retail
/// walks <c>CPhysics::object_maint</c>, so a live object with a
/// MovementManager or PositionManager must continue even when it has no
/// render-animation presentation component.
/// </summary>
internal bool Tick(
RuntimeEntityRecord record,
RemoteMotion rm,
float objectScale,
AcDream.Core.Physics.AnimationSequencer? sequencer,
float dt,
ulong objectClockEpoch,
AcDream.Core.Physics.Motion.MotionDeltaFrame rootMotionLocalFrame,
float radius,
float height,
int liveCenterX,
int liveCenterY,
System.Action<uint, AcDream.Core.Physics.AnimationSequencer>?
processAnimationHooks = null,
System.Action<System.Numerics.Vector3>? applyStaleVelocityCycle = null,
System.Func<RuntimeRemotePhysicsSnapshot, bool>?
acknowledgeProjection = null,
System.Func<bool>? externalOwnerValid = null)
{
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);
}
}

View file

@ -0,0 +1,39 @@
using System.Numerics;
using AcDream.Core.Physics;
namespace AcDream.Runtime.Physics;
/// <summary>
/// Shared host adapter for retail's moved-object shadow re-registration
/// (<c>CPhysicsObj::SetPositionInternal</c> 0x00515330). Both the local body
/// and remote dead-reckoning bodies publish their resolved position through
/// this one cell-offset calculation.
/// </summary>
internal static class ShadowPositionSynchronizer
{
public static void Sync(
ShadowObjectRegistry registry,
uint entityId,
Vector3 position,
Quaternion orientation,
uint cellId,
int liveCenterX,
int liveCenterY)
{
if (cellId == 0)
return;
int landblockX = (int)((cellId >> 24) & 0xFFu);
int landblockY = (int)((cellId >> 16) & 0xFFu);
float worldOffsetX = (landblockX - liveCenterX) * 192f;
float worldOffsetY = (landblockY - liveCenterY) * 192f;
registry.UpdatePosition(
entityId,
position,
orientation,
worldOffsetX,
worldOffsetY,
cellId,
seedCellId: cellId);
}
}