acdream/src/AcDream.Core/Physics/ProjectilePhysicsStepper.cs
Erik f961d70023 feat(physics): port retail complete object frame pipeline
Restore the named-retail object update order across local, remote, static, projectile, animation, shadow, teleport, and effect lifetimes. Separate authoritative root commits from spatial rebucketing, preserve per-owner hook/FIFO ordering, and remove update-path allocations with exact lifecycle and residency gates.

Add deterministic conformance, adversarial lifetime, GUID-reuse, pending-cell, quaternion, timestamp, and allocation coverage. Release build is warning-free and all 6,446 tests pass with five intentional skips; retail, architecture, and adversarial reviews are clean.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-07-20 09:10:31 +02:00

491 lines
17 KiB
C#

using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// One Setup-local collision sphere used by a retail projectile.
/// Installed-DAT audit 2026-07-13 pins the required arrow, bolt, and spell
/// projectile set to exactly one ordinary sphere, while preserving Force
/// Bolt's non-centered origin. Both origin and radius scale with the object's
/// live scale, matching <c>CTransition::init_sphere</c> from
/// <c>CPhysicsObj::transition</c> at <c>0x00512DC0</c>.
/// </summary>
public readonly record struct ProjectileCollisionSphere(
Vector3 SetupLocalOrigin,
float SetupRadius,
float ObjectScale = 1f)
{
public Vector3 LocalOrigin => SetupLocalOrigin * ObjectScale;
public float Radius => SetupRadius * ObjectScale;
public bool IsValid
{
get
{
Vector3 origin = LocalOrigin;
float radius = Radius;
return float.IsFinite(SetupRadius) && SetupRadius > 0f
&& float.IsFinite(ObjectScale) && ObjectScale > 0f
&& float.IsFinite(radius) && radius > PhysicsGlobals.EPSILON
&& float.IsFinite(origin.X)
&& float.IsFinite(origin.Y)
&& float.IsFinite(origin.Z);
}
}
}
/// <summary>Outcome of one retail projectile clock advance.</summary>
public readonly record struct ProjectileAdvanceResult(
uint CellId,
int QuantumCount,
bool Simulated,
bool CollisionNormalValid,
Vector3 CollisionNormal,
bool TransitionOk);
/// <summary>
/// Candidate frame produced by UpdatePhysicsInternal and held across retail's
/// process_hooks slot before the outer transition/collision commit.
/// </summary>
public readonly record struct ProjectileQuantumPreparation(
uint CellId,
float Quantum,
bool Simulated,
bool RequiresTransition,
Vector3 BeginPosition,
Quaternion BeginOrientation,
Position BeginCellPosition,
bool BeginInWorld,
ProjectileQuantumDynamics BeginDynamics,
Vector3 CandidatePosition,
Quaternion CandidateOrientation,
ProjectileQuantumDynamics CandidateDynamics,
bool PreviousContact,
bool PreviousOnWalkable);
/// <summary>
/// Mutable kinematic fields produced by the candidate integration. The split
/// App scheduler holds these transactionally across <c>process_hooks</c> so a
/// re-entrant authoritative correction never inherits an abandoned impulse.
/// </summary>
public readonly record struct ProjectileQuantumDynamics(
Vector3 Velocity,
Vector3 CachedVelocity,
Vector3 Acceleration,
Vector3 Omega,
TransientStateFlags TransientState,
double LastUpdateTime);
/// <summary>
/// Core-only port of the retail live-projectile physics driver. It owns no
/// renderer, network, or world-lifetime state: the App controller supplies a
/// live <see cref="PhysicsBody"/>, cell, Setup sphere, and identities, then
/// commits the returned cell through the live-entity runtime.
///
/// <para>Retail anchors:</para>
/// <list type="bullet">
/// <item><c>CPhysicsObj::update_object</c> <c>0x00515D10</c> (clock gate and catch-up).</item>
/// <item><c>CPhysicsObj::UpdateObjectInternal</c> <c>0x005156B0</c> (candidate, AlignPath, transition, cached velocity).</item>
/// <item><c>CPhysicsObj::UpdatePhysicsInternal</c> <c>0x00510700</c> (linear/angular integration).</item>
/// <item><c>CPhysicsObj::get_object_info</c> <c>0x00511CC0</c> (Missile adds PathClipped, never PerfectClip).</item>
/// </list>
/// </summary>
public sealed class ProjectilePhysicsStepper
{
private readonly PhysicsEngine _physics;
public ProjectilePhysicsStepper(PhysicsEngine physics)
=> _physics = physics ?? throw new ArgumentNullException(nameof(physics));
/// <summary>
/// Advances a projectile to the supplied absolute game time. ACE remains
/// authoritative: this predicts only motion/collision presentation and
/// never invents impact effects, damage, deletion, or a target id.
/// </summary>
public ProjectileAdvanceResult Advance(
PhysicsBody body,
double currentTime,
uint cellId,
ProjectileCollisionSphere sphere,
uint movingEntityId,
uint designatedTargetId = 0,
bool isParented = false)
{
ArgumentNullException.ThrowIfNull(body);
if (!double.IsFinite(currentTime))
throw new ArgumentOutOfRangeException(
nameof(currentTime), currentTime, "The game clock must be finite.");
if (!double.IsFinite(body.LastUpdateTime))
throw new InvalidOperationException(
"The projectile's prior update timestamp must be finite.");
if (!sphere.IsValid)
return new ProjectileAdvanceResult(cellId, 0, false, false, default, false);
// update_object 0x00515D10 excludes parented, cell-less, and Frozen
// objects and clears Active. Static projectiles are likewise not live
// update candidates; the App selector should never submit one.
if (isParented || !body.InWorld
|| body.State.HasFlag(PhysicsStateFlags.Frozen)
|| body.State.HasFlag(PhysicsStateFlags.Static)
|| cellId == 0)
{
body.TransientState &= ~TransientStateFlags.Active;
return new ProjectileAdvanceResult(cellId, 0, false, false, default, true);
}
if (!body.IsActive)
return new ProjectileAdvanceResult(cellId, 0, false, false, default, true);
double elapsed = currentTime - body.LastUpdateTime;
if (elapsed <= PhysicsGlobals.EPSILON || elapsed > PhysicsBody.HugeQuantum)
{
body.LastUpdateTime = currentTime;
return new ProjectileAdvanceResult(cellId, 0, false, false, default, true);
}
int quantumCount = 0;
bool collisionNormalValid = false;
Vector3 collisionNormal = default;
bool transitionOk = true;
double physicsTime = body.LastUpdateTime;
while (elapsed > PhysicsBody.MaxQuantum)
{
physicsTime += PhysicsBody.MaxQuantum;
StepQuantum(
body, PhysicsBody.MaxQuantum, ref cellId, sphere,
movingEntityId, designatedTargetId,
ref collisionNormalValid, ref collisionNormal, ref transitionOk);
quantumCount++;
elapsed -= PhysicsBody.MaxQuantum;
}
if (elapsed > PhysicsBody.MinQuantum)
{
float quantum = (float)elapsed;
physicsTime += elapsed;
StepQuantum(
body, quantum, ref cellId, sphere,
movingEntityId, designatedTargetId,
ref collisionNormalValid, ref collisionNormal, ref transitionOk);
quantumCount++;
}
// A remainder at or below 1/30 second stays accumulated: retail writes
// update_time from PhysicsTimer, not unconditionally from Timer.
body.LastUpdateTime = physicsTime;
return new ProjectileAdvanceResult(
cellId,
quantumCount,
quantumCount != 0,
collisionNormalValid,
collisionNormal,
transitionOk);
}
/// <summary>
/// Advances exactly one quantum already admitted by the owning
/// <see cref="RetailObjectQuantumClock"/>. This is the live-object path:
/// one CPhysicsObj clock admits PartArray, projectile physics, hooks, and
/// the manager tail together. The absolute-time overload remains for the
/// pure stepper conformance surface.
/// </summary>
public ProjectileAdvanceResult AdvanceQuantum(
PhysicsBody body,
float quantum,
uint cellId,
ProjectileCollisionSphere sphere,
uint movingEntityId,
uint designatedTargetId = 0,
bool isParented = false)
{
ProjectileQuantumPreparation preparation = BeginQuantum(
body,
quantum,
cellId,
sphere,
isParented);
return CompleteQuantum(
body,
preparation,
sphere,
movingEntityId,
designatedTargetId);
}
public ProjectileQuantumPreparation BeginQuantum(
PhysicsBody body,
float quantum,
uint cellId,
ProjectileCollisionSphere sphere,
bool isParented = false)
{
ArgumentNullException.ThrowIfNull(body);
if (!float.IsFinite(quantum)
|| quantum <= PhysicsGlobals.EPSILON
|| quantum > PhysicsBody.MaxQuantum)
{
throw new ArgumentOutOfRangeException(
nameof(quantum),
quantum,
"An admitted object quantum must be finite and no larger than MaxQuantum.");
}
if (!sphere.IsValid)
return NotSimulated(cellId, quantum);
if (isParented || !body.InWorld
|| body.State.HasFlag(PhysicsStateFlags.Frozen)
|| body.State.HasFlag(PhysicsStateFlags.Static)
|| cellId == 0)
{
body.TransientState &= ~TransientStateFlags.Active;
return NotSimulated(cellId, quantum);
}
if (!body.IsActive)
return NotSimulated(cellId, quantum);
Vector3 beginPosition = body.Position;
Quaternion beginOrientation = body.Orientation;
Position beginCellPosition = body.CellPosition;
bool beginInWorld = body.InWorld;
ProjectileQuantumDynamics beginDynamics = CaptureDynamics(body);
bool previousContact = body.InContact;
bool previousOnWalkable = body.OnWalkable;
body.calc_acceleration();
body.UpdatePhysicsInternal(quantum);
Vector3 candidatePosition = body.Position;
Quaternion candidateOrientation = body.Orientation;
Vector3 displacement = candidatePosition - beginPosition;
bool candidateMoved = displacement != Vector3.Zero;
if (candidateMoved && body.State.HasFlag(PhysicsStateFlags.AlignPath))
{
candidateOrientation = RetailFrameMath.SetVectorHeading(
candidateOrientation,
displacement);
}
if (!candidateMoved)
{
body.CachedVelocity = Vector3.Zero;
body.Orientation = candidateOrientation;
body.LastUpdateTime += quantum;
}
else
{
// process_hooks runs against the post-physics candidate while the
// authoritative root remains at the begin frame until transition.
body.Position = beginPosition;
body.Orientation = beginOrientation;
}
ProjectileQuantumDynamics candidateDynamics = CaptureDynamics(body);
RestoreBeginFrame(
body,
beginPosition,
beginOrientation,
beginCellPosition,
beginInWorld);
ApplyDynamics(body, beginDynamics);
return new ProjectileQuantumPreparation(
cellId,
quantum,
Simulated: true,
RequiresTransition: candidateMoved,
beginPosition,
beginOrientation,
beginCellPosition,
beginInWorld,
beginDynamics,
candidatePosition,
candidateOrientation,
candidateDynamics,
previousContact,
previousOnWalkable);
}
public ProjectileAdvanceResult CompleteQuantum(
PhysicsBody body,
in ProjectileQuantumPreparation preparation,
ProjectileCollisionSphere sphere,
uint movingEntityId,
uint designatedTargetId = 0)
{
ArgumentNullException.ThrowIfNull(body);
if (!preparation.Simulated)
return new ProjectileAdvanceResult(preparation.CellId, 0, false, false, default, true);
ApplyDynamics(body, preparation.CandidateDynamics);
if (!preparation.RequiresTransition)
{
body.SetFrameInCurrentCell(
preparation.CandidatePosition,
preparation.CandidateOrientation);
return new ProjectileAdvanceResult(preparation.CellId, 1, true, false, default, true);
}
uint cellId = preparation.CellId;
ObjectInfoState moverFlags = body.State.HasFlag(PhysicsStateFlags.PathClipped)
? ObjectInfoState.PathClipped
: ObjectInfoState.None;
var resolved = _physics.ResolveWithTransition(
preparation.BeginPosition,
preparation.CandidatePosition,
cellId,
sphereRadius: sphere.Radius,
sphereHeight: 0f,
stepUpHeight: PhysicsGlobals.DefaultStepHeight,
stepDownHeight: 0f,
isOnGround: preparation.PreviousOnWalkable,
body: body,
moverFlags: moverFlags,
movingEntityId: movingEntityId,
localSphereOrigin: sphere.LocalOrigin,
beginOrientation: preparation.BeginOrientation,
endOrientation: preparation.CandidateOrientation,
designatedTargetId: designatedTargetId);
bool collisionNormalValid = false;
Vector3 collisionNormal = default;
bool transitionOk = resolved.Ok;
if (!resolved.Ok)
{
body.SetFrameInCurrentCell(
preparation.CandidatePosition,
preparation.CandidateOrientation);
body.CachedVelocity = Vector3.Zero;
}
else
{
body.CachedVelocity = (resolved.Position - preparation.BeginPosition)
/ preparation.Quantum;
body.Position = resolved.Position;
body.Orientation = resolved.Orientation == default
? preparation.CandidateOrientation
: resolved.Orientation;
if (resolved.CellId != 0)
cellId = resolved.CellId;
PhysicsObjUpdate.ApplySetPositionContact(
body,
resolved.InContact,
resolved.OnWalkable);
PhysicsObjUpdate.HandleAllCollisions(
body,
resolved.CollisionNormalValid,
resolved.CollisionNormal,
preparation.PreviousContact,
preparation.PreviousOnWalkable,
nowOnWalkable: body.OnWalkable);
if (resolved.CollisionNormalValid)
{
collisionNormalValid = true;
collisionNormal = resolved.CollisionNormal;
}
}
body.LastUpdateTime += preparation.Quantum;
return new ProjectileAdvanceResult(
cellId,
1,
true,
collisionNormalValid,
collisionNormal,
transitionOk);
}
private static ProjectileQuantumPreparation NotSimulated(
uint cellId,
float quantum) => new(
CellId: cellId,
Quantum: quantum,
Simulated: false,
RequiresTransition: false,
BeginPosition: default,
BeginOrientation: default,
BeginCellPosition: default,
BeginInWorld: false,
BeginDynamics: default,
CandidatePosition: default,
CandidateOrientation: default,
CandidateDynamics: default,
PreviousContact: false,
PreviousOnWalkable: false);
private static ProjectileQuantumDynamics CaptureDynamics(PhysicsBody body) => new(
body.Velocity,
body.CachedVelocity,
body.Acceleration,
body.Omega,
body.TransientState,
body.LastUpdateTime);
private static void ApplyDynamics(
PhysicsBody body,
in ProjectileQuantumDynamics dynamics)
{
body.Velocity = dynamics.Velocity;
body.CachedVelocity = dynamics.CachedVelocity;
body.Acceleration = dynamics.Acceleration;
body.Omega = dynamics.Omega;
body.TransientState = dynamics.TransientState;
body.LastUpdateTime = dynamics.LastUpdateTime;
}
private static void RestoreBeginFrame(
PhysicsBody body,
Vector3 beginPosition,
Quaternion beginOrientation,
in Position beginCellPosition,
bool beginInWorld)
{
body.Orientation = beginOrientation;
if (beginCellPosition.ObjCellId != 0)
{
body.SnapToCell(
beginCellPosition.ObjCellId,
beginPosition,
beginCellPosition.Frame.Origin);
}
else
{
body.SetFrameInCurrentCell(beginPosition, beginOrientation);
}
body.InWorld = beginInWorld;
}
private void StepQuantum(
PhysicsBody body,
float dt,
ref uint cellId,
ProjectileCollisionSphere sphere,
uint movingEntityId,
uint designatedTargetId,
ref bool collisionNormalValid,
ref Vector3 collisionNormal,
ref bool transitionOk)
{
ProjectileQuantumPreparation preparation = BeginQuantum(
body,
dt,
cellId,
sphere);
ProjectileAdvanceResult result = CompleteQuantum(
body,
preparation,
sphere,
movingEntityId,
designatedTargetId);
if (result.CellId != 0)
cellId = result.CellId;
collisionNormalValid |= result.CollisionNormalValid;
if (result.CollisionNormalValid)
collisionNormal = result.CollisionNormal;
transitionOk &= result.TransitionOk;
}
}