feat(physics): port retail projectile integration

Add a pure Core projectile driver for retail clock quanta, final-state acceleration, 50-unit velocity clamping, world-space angular integration, full-3D AlignPath, and oriented Setup-local sphere sweeps. Preserve exact success versus set_frame-only failure semantics, independent Contact/OnWalkable state, and full-cell identity across all landblock directions.

Port OBJECTINFO::missile_ignore with explicit weenie/creature metadata, remove the invented transition-step cap, retain PathClipped without arming PerfectClip, and keep authoritative target identity optional. Add malformed-shape/clock, thin-wall, terrain, target-exclusion, frame-spike, failure-state, and boundary conformance coverage.

Retire TS-2 and synchronize the architecture, milestones, roadmap, research oracle, and durable physics memory.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-14 11:43:11 +02:00
parent 542dcfc384
commit f02b995e4f
21 changed files with 1669 additions and 263 deletions

View file

@ -0,0 +1,261 @@
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>
/// 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);
}
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)
{
Vector3 beginPosition = body.Position;
Quaternion beginOrientation = body.Orientation;
bool previousContact = body.InContact;
bool previousOnWalkable = body.OnWalkable;
// Final PhysicsState drives acceleration on every quantum. Network
// acceleration is retained by the protocol parser but not installed.
body.calc_acceleration();
body.UpdatePhysicsInternal(dt);
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;
return;
}
// The integrator produces a candidate without committing the root.
// Restore the authoritative start frame before transition setup so
// carried cell-relative position and self-shadow identity remain exact.
body.Position = beginPosition;
body.Orientation = beginOrientation;
var resolved = _physics.ResolveWithTransition(
beginPosition,
candidatePosition,
cellId,
sphereRadius: sphere.Radius,
sphereHeight: 0f,
stepUpHeight: PhysicsGlobals.DefaultStepHeight,
stepDownHeight: 0f,
isOnGround: previousOnWalkable,
body: body,
moverFlags: ObjectInfoState.PathClipped,
movingEntityId: movingEntityId,
localSphereOrigin: sphere.LocalOrigin,
beginOrientation: beginOrientation,
endOrientation: candidateOrientation,
designatedTargetId: designatedTargetId);
if (!resolved.Ok)
{
// UpdateObjectInternal 0x005156B0: when transition() returns null,
// retail calls set_frame(candidate), zeros cached_velocity, and
// deliberately skips SetPositionInternal/handle_all_collisions.
// set_frame retains Position.objcell_id even when the candidate
// frame lies beyond the current outdoor cell's canonical range.
body.SetFrameInCurrentCell(candidatePosition, candidateOrientation);
body.CachedVelocity = Vector3.Zero;
transitionOk = false;
return;
}
body.CachedVelocity = (resolved.Position - beginPosition) / dt;
body.Position = resolved.Position;
body.Orientation = resolved.Orientation == default
? candidateOrientation
: resolved.Orientation;
if (resolved.CellId != 0)
cellId = resolved.CellId;
// SetPositionInternal 0x00515330 commits Contact and OnWalkable as
// distinct facts before handle_all_collisions. A steep surface can be
// contact without becoming walkable ground.
PhysicsObjUpdate.ApplySetPositionContact(
body,
resolved.InContact,
resolved.OnWalkable);
PhysicsObjUpdate.HandleAllCollisions(
body,
resolved.CollisionNormalValid,
resolved.CollisionNormal,
previousContact,
previousOnWalkable,
nowOnWalkable: body.OnWalkable);
if (resolved.CollisionNormalValid)
{
collisionNormalValid = true;
collisionNormal = resolved.CollisionNormal;
}
}
}