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

@ -39,6 +39,14 @@ public enum EntityCollisionFlags : byte
IsPKLite = 0x08,
/// <summary>Set when <c>BF_FREE_PKSTATUS (0x200000)</c> is set (a.k.a. "Free" PK status — cannot be PKed).</summary>
IsImpenetrable = 0x10,
/// <summary>
/// Runtime collision metadata: the target has a retail
/// <c>CWeenieObject</c>. This is deliberately distinct from
/// <see cref="IsCreature"/> because doors, portals, and items also have a
/// weenie. <c>OBJECTINFO::missile_ignore</c> at <c>0x0050CEB0</c>
/// requires this distinction for ethereal targets.
/// </summary>
HasWeenie = 0x20,
}
/// <summary>Helpers to convert raw retail bitfields into <see cref="EntityCollisionFlags"/>.</summary>

View file

@ -106,7 +106,9 @@ public sealed class PhysicsBody
public const float SmallVelocitySquared = SmallVelocity * SmallVelocity;
public const float DefaultFriction = 0.95f;
public const float MinQuantum = 1.0f / 30.0f; // ~0.0333 s
public const float MaxQuantum = 0.1f; // 10 fps lower bound
// Matching-client disassembly resolves the named lift's stripped global to
// 0.2 seconds. update_object consumes larger gaps as repeated 0.2 s quanta.
public const float MaxQuantum = 0.2f;
public const float HugeQuantum = 2.0f; // discard stale dt
// ── struct fields ──────────────────────────────────────────────────────
@ -177,6 +179,27 @@ public sealed class PhysicsBody
InWorld = true; // retail: enter_world / set_cell assigns physics_obj->cell
}
/// <summary>
/// Commits only the frame while retaining the current cell identity. This
/// is retail <c>CPhysicsObj::set_frame</c> at <c>0x00514090</c>, used by
/// <c>UpdateObjectInternal</c> when a transition fails: the integrated
/// candidate frame is kept, but <c>Position.objcell_id</c> is not changed
/// and no outdoor canonicalization is performed.
/// </summary>
public void SetFrameInCurrentCell(Vector3 worldPosition, Quaternion orientation)
{
Vector3 delta = worldPosition - _position;
_position = worldPosition;
Orientation = orientation;
if (CellPosition.ObjCellId != 0)
{
CellPosition = new Position(
CellPosition.ObjCellId,
new CellFrame(CellPosition.Frame.Origin + delta, orientation));
}
}
// Mirror a world-position translation into the cell-relative frame. Velocity is
// frame-invariant under translation, so the same delta applies to the local origin.
// AdjustToOutside then recomputes the cell index from the local (intra-landblock 24 m
@ -552,32 +575,35 @@ public sealed class PhysicsBody
// velocity += acceleration * dt (done unconditionally in decompile)
Velocity += Acceleration * dt;
// Angular integration: apply omega rotation.
// omega * dt gives the angle-axis delta rotation.
float omegaLen = Omega.Length();
if (omegaLen > 1e-6f)
// Angular integration: Frame::grotate receives the WORLD-space
// rotation vector omega*dt and premultiplies its quaternion onto the
// frame (0x005357A0; called at UpdatePhysicsInternal 0x00510935).
// Its F_EPSILON gate is applied to the rotation vector, not omega.
Vector3 rotation = Omega * dt;
float rotationLenSq = rotation.LengthSquared();
if (rotationLenSq >= PhysicsGlobals.EpsilonSq)
{
float angle = omegaLen * dt;
Quaternion deltaRot = Quaternion.CreateFromAxisAngle(Omega / omegaLen, angle);
Orientation = Quaternion.Normalize(Quaternion.Multiply(Orientation, deltaRot));
float angle = MathF.Sqrt(rotationLenSq);
Quaternion deltaRot = Quaternion.CreateFromAxisAngle(rotation / angle, angle);
Orientation = Quaternion.Normalize(Quaternion.Multiply(deltaRot, Orientation));
}
}
// ── FUN_00515020 ───────────────────────────────────────────────────────
// ── CPhysicsObj::update_object 0x00515D10 ───────────────────────────────
/// <summary>
/// Per-frame top-level driver. Computes dt from the wall clock versus
/// LastUpdateTime, clamping to [MinQuantum, HugeQuantum], then calls
/// calc_acceleration and UpdatePhysicsInternal.
/// LastUpdateTime, discards invalid gaps, and advances the consumed
/// PhysicsTimer time through fixed maximum quanta plus one remainder.
///
/// Decompiled logic (FUN_00515020):
/// Decompiled logic (<c>CPhysicsObj::update_object</c> 0x00515D10):
/// if parent-attached (offset +0x40 != 0) → return
/// dVar1 = currentTime - LastUpdateTime
/// if dVar1 &lt; MinQuantum → return (too short — skip)
/// if dVar1 &lt;= EPSILON → update timestamp and return
/// if dVar1 > HugeQuantum → update timestamp and return (stale — discard)
/// while dVar1 > MaxQuantum: simulate MaxQuantum step, subtract
/// if dVar1 > MinQuantum: simulate remainder
/// LastUpdateTime = currentTime
/// LastUpdateTime = consumed PhysicsTimer time
///
/// The caller passes currentTime; the object does not read a global clock
/// directly in this port so tests can drive the clock explicitly.
@ -586,9 +612,13 @@ public sealed class PhysicsBody
{
double deltaTime = currentTime - LastUpdateTime;
// dt too small — nothing to simulate yet
if (deltaTime < MinQuantum)
// Retail drops only a true micro-fragment. A larger remainder at or
// below MinQuantum is retained by leaving LastUpdateTime unchanged.
if (deltaTime <= PhysicsGlobals.EPSILON)
{
LastUpdateTime = currentTime;
return;
}
// Stale / first frame — just consume the time without simulating
if (deltaTime > HugeQuantum)
@ -597,9 +627,12 @@ public sealed class PhysicsBody
return;
}
// Sub-step: break large dt into MaxQuantum chunks
double physicsTime = LastUpdateTime;
// Sub-step: break large dt into MaxQuantum chunks.
while (deltaTime > MaxQuantum)
{
physicsTime += MaxQuantum;
calc_acceleration();
UpdatePhysicsInternal(MaxQuantum);
deltaTime -= MaxQuantum;
@ -608,10 +641,11 @@ public sealed class PhysicsBody
// Simulate the remainder
if (deltaTime > MinQuantum)
{
physicsTime += deltaTime;
calc_acceleration();
UpdatePhysicsInternal((float)deltaTime);
}
LastUpdateTime = currentTime;
LastUpdateTime = physicsTime;
}
}

View file

@ -937,7 +937,11 @@ public sealed class PhysicsEngine
bool isOnGround,
PhysicsBody? body = null,
ObjectInfoState moverFlags = ObjectInfoState.None,
uint movingEntityId = 0)
uint movingEntityId = 0,
Vector3? localSphereOrigin = null,
Quaternion? beginOrientation = null,
Quaternion? endOrientation = null,
uint designatedTargetId = 0)
{
// A6.P3 #98 (2026-05-23) live capture. Filtered to IsPlayer so NPC /
// remote ResolveWithTransition calls don't pollute the capture. Snapshot
@ -963,6 +967,8 @@ public sealed class PhysicsEngine
// this->object = arg2). The skip itself is at
// CObjCell::find_obj_collisions line 308931.
transition.ObjectInfo.SelfEntityId = movingEntityId;
transition.ObjectInfo.MoverPhysicsState = body?.State ?? PhysicsStateFlags.None;
transition.ObjectInfo.TargetId = designatedTargetId;
// Commit C 2026-04-29 — caller-supplied mover flags drive the
// retail PvP exemption block in FindObjCollisions. The local
@ -971,6 +977,11 @@ public sealed class PhysicsEngine
// (matches non-player movement, all targets collide).
transition.ObjectInfo.State |= moverFlags;
// CPhysicsObj::get_object_info 0x00511CC0: Missile contributes
// PathClipped only. PerfectClip is deliberately not inferred.
if (transition.ObjectInfo.MoverPhysicsState.HasFlag(PhysicsStateFlags.Missile))
transition.ObjectInfo.State |= ObjectInfoState.PathClipped;
// frames_stationary_fall gate input: retail reads the mover's GRAVITY state bit
// (object_info.object->state & 0x400, pc:272625). Seed it from the body so the ladder
// in ValidateTransition runs for gravity movers (the player) and not floating props.
@ -1020,7 +1031,15 @@ public sealed class PhysicsEngine
transition.CollisionInfo.SetSlidingNormal(body.SlidingNormal);
}
transition.SpherePath.InitPath(currentPos, targetPos, cellId, sphereRadius, sphereHeight);
transition.SpherePath.InitPath(
currentPos,
targetPos,
cellId,
sphereRadius,
sphereHeight,
localSphereOrigin,
beginOrientation,
endOrientation);
// #145: supply the carried cell-relative frame anchor to the outdoor
// membership pick. body.Position - body.CellPosition.Frame.Origin is the TRUE
@ -1071,65 +1090,69 @@ public sealed class PhysicsEngine
// when current is invalid (e.g., airborne this frame), matching retail.
if (body is not null)
{
if (ci.ContactPlaneValid)
{
body.ContactPlaneValid = true;
body.ContactPlane = ci.ContactPlane;
body.ContactPlaneCellId = ci.ContactPlaneCellId;
body.ContactPlaneIsWater = ci.ContactPlaneIsWater;
}
else if (ci.LastKnownContactPlaneValid)
{
body.ContactPlaneValid = true;
body.ContactPlane = ci.LastKnownContactPlane;
body.ContactPlaneCellId = ci.LastKnownContactPlaneCellId;
body.ContactPlaneIsWater = ci.LastKnownContactPlaneIsWater;
}
else
{
body.ContactPlaneValid = false;
}
// Publish frames_stationary_fall + carry it to the next frame via the Stationary*
// transient bits. Retail encodes these bits in handle_all_collisions (pc:282737-758);
// acdream co-locates the encode with the fsf writeback here (STRUCTURAL ADAPTATION,
// register) so the round-trip (seed→ladder→writeback→seed) is self-contained in Core.
// handle_all_collisions (PhysicsObjUpdate) then only READS body.FramesStationaryFall.
body.FramesStationaryFall = ci.FramesStationaryFall;
body.TransientState &= ~(TransientStateFlags.StationaryFall
| TransientStateFlags.StationaryStop
| TransientStateFlags.StationaryStuck);
body.TransientState |= ci.FramesStationaryFall switch
{
1 => TransientStateFlags.StationaryFall,
2 => TransientStateFlags.StationaryStop,
3 => TransientStateFlags.StationaryStuck,
_ => TransientStateFlags.None,
};
if (sp.HasLastWalkablePolygon && sp.LastWalkableVertices is not null)
{
body.WalkablePolygonValid = true;
body.WalkablePlane = sp.LastWalkablePlane;
body.WalkableVertices = (Vector3[])sp.LastWalkableVertices.Clone();
body.WalkableUp = sp.LastWalkableUp;
}
else if (!isOnGround && !ci.ContactPlaneValid && !ci.LastKnownContactPlaneValid)
{
body.WalkablePolygonValid = false;
body.WalkableVertices = null;
}
// Retail persists sliding state to the body ONLY on transition
// SUCCESS: CPhysicsObj::SetPositionInternal copies the normal at
// 0x005154c2 and syncs SLIDING_TS (bit 4) from the transition's
// final sliding_normal_valid at 0x005154e1 — and SetPositionInternal
// is unreachable when find_valid_position fails (the transition is
// discarded whole; the body keeps its prior state). #137 mechanism
// 2: an unconditional writeback here could persist a normal retail
// would discard.
// CPhysicsObj::transition 0x00512DC0 discards its CTransition when
// find_valid_position fails. Only SetPositionInternal 0x00515330
// publishes contact/fsf/walkable/sliding state, and that function
// is unreachable on the UpdateObjectInternal failure branch.
if (ok)
{
if (ci.ContactPlaneValid)
{
body.ContactPlaneValid = true;
body.ContactPlane = ci.ContactPlane;
body.ContactPlaneCellId = ci.ContactPlaneCellId;
body.ContactPlaneIsWater = ci.ContactPlaneIsWater;
}
else if (ci.LastKnownContactPlaneValid)
{
body.ContactPlaneValid = true;
body.ContactPlane = ci.LastKnownContactPlane;
body.ContactPlaneCellId = ci.LastKnownContactPlaneCellId;
body.ContactPlaneIsWater = ci.LastKnownContactPlaneIsWater;
}
else
{
body.ContactPlaneValid = false;
}
// Publish frames_stationary_fall + carry it to the next frame via the Stationary*
// transient bits. Retail encodes these bits in handle_all_collisions (pc:282737-758);
// acdream co-locates the encode with the fsf writeback here (STRUCTURAL ADAPTATION,
// register) so the round-trip (seed→ladder→writeback→seed) is self-contained in Core.
// handle_all_collisions (PhysicsObjUpdate) then only READS body.FramesStationaryFall.
body.FramesStationaryFall = ci.FramesStationaryFall;
body.TransientState &= ~(TransientStateFlags.StationaryFall
| TransientStateFlags.StationaryStop
| TransientStateFlags.StationaryStuck);
body.TransientState |= ci.FramesStationaryFall switch
{
1 => TransientStateFlags.StationaryFall,
2 => TransientStateFlags.StationaryStop,
3 => TransientStateFlags.StationaryStuck,
_ => TransientStateFlags.None,
};
if (sp.HasLastWalkablePolygon && sp.LastWalkableVertices is not null)
{
body.WalkablePolygonValid = true;
body.WalkablePlane = sp.LastWalkablePlane;
body.WalkableVertices = (Vector3[])sp.LastWalkableVertices.Clone();
body.WalkableUp = sp.LastWalkableUp;
}
else if (!isOnGround && !ci.ContactPlaneValid && !ci.LastKnownContactPlaneValid)
{
body.WalkablePolygonValid = false;
body.WalkableVertices = null;
}
// Retail persists sliding state to the body ONLY on transition
// SUCCESS: CPhysicsObj::SetPositionInternal copies the normal at
// 0x005154c2 and syncs SLIDING_TS (bit 4) from the transition's
// final sliding_normal_valid at 0x005154e1 — and SetPositionInternal
// is unreachable when find_valid_position fails (the transition is
// discarded whole; the body keeps its prior state). #137 mechanism
// 2: an unconditional writeback here could persist a normal retail
// would discard.
if (ci.SlidingNormalValid
&& ci.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
{
@ -1254,7 +1277,11 @@ public sealed class PhysicsEngine
ResolveResult resolveResult;
if (ok)
{
bool onGround = ci.ContactPlaneValid
bool inContact = ci.ContactPlaneValid;
bool onWalkable = PhysicsObjUpdate.IsWalkableContact(
inContact,
ci.ContactPlane.Normal);
bool onGround = inContact
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable);
resolveResult = new ResolveResult(
@ -1268,7 +1295,10 @@ public sealed class PhysicsEngine
sp.CurCellId,
onGround,
collisionNormalValid,
collisionNormal);
collisionNormal,
Orientation: sp.CurOrientation,
InContact: inContact,
OnWalkable: onWalkable);
}
else
{
@ -1291,7 +1321,8 @@ public sealed class PhysicsEngine
partialOnGround,
collisionNormalValid,
collisionNormal,
Ok: false); // Render Residual A — the sweep failed (find_valid_position == 0)
Ok: false,
Orientation: sp.CurOrientation); // Render Residual A — the sweep failed (find_valid_position == 0)
}
// A6.P3 #98 capture: emit one JSON Lines record per player call,

View file

@ -13,6 +13,38 @@ namespace AcDream.Core.Physics;
/// </summary>
public static class PhysicsObjUpdate
{
/// <summary>
/// Retail <c>SetPositionInternal</c> walkability comparison at
/// <c>0x00515465-0x0051548E</c>. Equality with FloorZ is walkable.
/// </summary>
public static bool IsWalkableContact(bool inContact, Vector3 contactNormal)
=> inContact && contactNormal.Z >= PhysicsGlobals.FloorZ;
/// <summary>
/// Applies the two independent transient facts written by retail
/// <c>CPhysicsObj::SetPositionInternal</c> at <c>0x00515430-0x0051549F</c>.
/// Contact comes from contact-plane validity; OnWalkable additionally
/// requires a walkable plane normal. A steep contact therefore remains in
/// contact without becoming grounded.
/// </summary>
public static void ApplySetPositionContact(
PhysicsBody body,
bool inContact,
bool onWalkable)
{
if (inContact)
body.TransientState |= TransientStateFlags.Contact;
else
body.TransientState &= ~TransientStateFlags.Contact;
if (inContact && onWalkable)
body.TransientState |= TransientStateFlags.OnWalkable;
else
body.TransientState &= ~TransientStateFlags.OnWalkable;
body.calc_acceleration();
}
/// <summary>
/// retail <c>handle_all_collisions</c> (0x00514780). Reflects or zeros the body's
/// <see cref="PhysicsBody.Velocity"/> (retail m_velocityVector) based on

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;
}
}
}

View file

@ -34,4 +34,21 @@ public readonly record struct ResolveResult(
/// start cell or was immediately stuck. The camera <c>SweepEye</c> reads this
/// to trigger <c>SmartBox::update_viewer</c>'s fallbacks. Default <c>true</c>
/// so existing callers are unaffected.</summary>
bool Ok = true);
bool Ok = true,
/// <summary>
/// Final transition orientation. Projectile callers consume the
/// interpolated frame committed before a PathClipped collision; legacy
/// point/capsule callers leave this at identity and ignore it.
/// </summary>
Quaternion Orientation = default,
/// <summary>
/// Exact retail SetPositionInternal contact-plane-valid result. This is
/// distinct from <see cref="OnWalkable"/>: a steep surface may be contact
/// without being ground.
/// </summary>
bool InContact = false,
/// <summary>
/// Exact retail walkability result after testing the contact-plane normal
/// against <see cref="PhysicsGlobals.FloorZ"/>.
/// </summary>
bool OnWalkable = false);

View file

@ -0,0 +1,63 @@
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Retail frame-orientation helpers used by physics presentation.
/// </summary>
public static class RetailFrameMath
{
/// <summary>
/// Points the frame's local +Y axis along a world-space direction with
/// zero roll. This is the observable contract of retail
/// <c>Frame::set_vector_heading</c> at <c>0x00535DB0</c>: normalize the
/// full three-dimensional vector, derive compass yaw plus elevation, and
/// replace the frame rotation. A zero-length vector leaves the prior
/// orientation unchanged.
/// </summary>
public static Quaternion SetVectorHeading(
Quaternion currentOrientation,
Vector3 direction)
{
float lengthSquared = direction.LengthSquared();
if (lengthSquared < PhysicsGlobals.EpsilonSq || !float.IsFinite(lengthSquared))
return currentOrientation;
Vector3 forward = direction / MathF.Sqrt(lengthSquared);
// Retail's zero-roll Euler construction is equivalent to choosing a
// horizontal right axis from the compass heading, then deriving the
// remaining up axis. At a perfectly vertical heading atan2(0, 0)
// resolves to zero, so retain +X as the deterministic right axis.
Vector3 right;
if (forward.X == 0f && forward.Y == 0f)
{
right = Vector3.UnitX;
}
else
{
// Normalize the horizontal pair without squaring the original
// components. That preserves every non-zero compass component,
// including a nearly vertical vector below PhysicsGlobals.EPSILON,
// just as retail's atan2-based construction does.
float scale = MathF.Max(MathF.Abs(forward.X), MathF.Abs(forward.Y));
float x = forward.X / scale;
float y = forward.Y / scale;
float horizontalLength = MathF.Sqrt((x * x) + (y * y));
right = new Vector3(y / horizontalLength, -x / horizontalLength, 0f);
}
Vector3 up = Vector3.Normalize(Vector3.Cross(right, forward));
// System.Numerics uses row-vector transforms. Each row below is the
// world direction of one local basis axis: +X right, +Y heading,
// +Z up.
var rotation = new Matrix4x4(
right.X, right.Y, right.Z, 0f,
forward.X, forward.Y, forward.Z, 0f,
up.X, up.Y, up.Z, 0f,
0f, 0f, 0f, 1f);
return Quaternion.Normalize(Quaternion.CreateFromRotationMatrix(rotation));
}
}

View file

@ -56,6 +56,15 @@ public sealed class ObjectInfo
public bool StepDown = true;
public float Scale = 1.0f;
/// <summary>The moving object's retail PhysicsState.</summary>
public PhysicsStateFlags MoverPhysicsState;
/// <summary>
/// Authoritative designated projectile target. Zero means unknown/no
/// target; it is never inferred from the player's selected UI target.
/// </summary>
public uint TargetId;
/// <summary>
/// EntityId of the moving entity, used by <c>FindObjCollisions</c> to
/// skip the moving object's own ShadowEntry. Mirrors retail
@ -88,6 +97,32 @@ public sealed class ObjectInfo
public bool PathClipped => State.HasFlag(ObjectInfoState.PathClipped);
public bool FreeRotate => State.HasFlag(ObjectInfoState.FreeRotate);
/// <summary>
/// Verbatim decision tree from retail <c>OBJECTINFO::missile_ignore</c>
/// at <c>0x0050CEB0</c>. A true result skips all target shapes.
/// </summary>
public bool MissileIgnore(
uint targetEntityId,
uint targetPhysicsState,
EntityCollisionFlags targetFlags)
{
var targetState = (PhysicsStateFlags)targetPhysicsState;
if (targetState.HasFlag(PhysicsStateFlags.Missile))
return true;
if (!MoverPhysicsState.HasFlag(PhysicsStateFlags.Missile))
return false;
if (targetEntityId == TargetId)
return false;
bool hasWeenie = targetFlags.HasFlag(EntityCollisionFlags.HasWeenie);
if (targetState.HasFlag(PhysicsStateFlags.Ethereal) && hasWeenie)
return true;
return TargetId != 0
&& hasWeenie
&& targetFlags.HasFlag(EntityCollisionFlags.IsCreature);
}
/// <summary>
/// Return the Z threshold for a walkable surface appropriate to the
/// current movement context.
@ -446,7 +481,8 @@ public sealed class SpherePath
// Update global spheres to match new check position
for (int i = 0; i < NumSphere; i++)
{
GlobalSphere[i].Origin = LocalSphere[i].Origin + pos;
GlobalSphere[i].Origin =
Vector3.Transform(LocalSphere[i].Origin, CheckOrientation) + pos;
GlobalSphere[i].Radius = LocalSphere[i].Radius;
}
}
@ -580,15 +616,27 @@ public sealed class SpherePath
/// <summary>
/// Initialize the path for a simple point-to-point movement.
/// </summary>
public void InitPath(Vector3 begin, Vector3 end, uint cellId,
float sphereRadius, float sphereHeight = 0f)
public void InitPath(
Vector3 begin,
Vector3 end,
uint cellId,
float sphereRadius,
float sphereHeight = 0f,
Vector3? localSphereOrigin = null,
Quaternion? beginOrientation = null,
Quaternion? endOrientation = null)
{
BeginPos = begin;
EndPos = end;
CurPos = begin;
CurCellId = cellId;
LocalSphere[0].Origin = new Vector3(0, 0, sphereRadius);
BeginOrientation = beginOrientation ?? Quaternion.Identity;
EndOrientation = endOrientation ?? BeginOrientation;
CurOrientation = BeginOrientation;
CheckOrientation = BeginOrientation;
LocalSphere[0].Origin = localSphereOrigin ?? new Vector3(0, 0, sphereRadius);
LocalSphere[0].Radius = sphereRadius;
if (sphereHeight > 0)
@ -607,7 +655,8 @@ public sealed class SpherePath
// Also init CurCenter
for (int i = 0; i < NumSphere; i++)
{
GlobalCurrCenter[i].Origin = LocalSphere[i].Origin + begin;
GlobalCurrCenter[i].Origin =
Vector3.Transform(LocalSphere[i].Origin, CurOrientation) + begin;
GlobalCurrCenter[i].Radius = LocalSphere[i].Radius;
}
}
@ -627,7 +676,6 @@ public static class PhysicsGlobals
public const float Gravity = -9.8f;
public const float MaxVelocity = 50.0f;
public const float DummySphereRadius = 0.1f;
public const int MaxTransitionSteps = 30; // retail uses 30, ACE uses 1000
}
/// <summary>
@ -653,54 +701,9 @@ public sealed class Transition
Environment.GetEnvironmentVariable("ACDREAM_DUMP_EDGE_SLIDE") == "1";
// -----------------------------------------------------------------------
// A6.P7 (2026-05-25) + W1 (2026-06-24) — retail-binary dispatch rule
// A6.P7 (2026-05-25) — retail-binary shape dispatch rule
// -----------------------------------------------------------------------
/// <summary>
/// W1: Named predicate for the PvP-exemption term in retail's
/// <c>CPhysicsObj::FindObjCollisions</c> dispatch (pc:276861,
/// <c>acclient_2013_pseudo_c.txt:276808276841</c>).
///
/// <para>
/// In retail <c>ebp_1</c> is non-null when the mover is a player AND
/// the target is not impenetrable AND PK/PKLite flags indicate a
/// PvP-exempt pairing — i.e. the mover should pass through rather than
/// collide. While this is non-null the dispatch takes the cyl+sphere
/// path regardless of <c>HAS_PHYSICS_BSP_PS</c>, effectively making
/// PvP-exempt players transparent to BSP-only objects.
/// </para>
///
/// <para>
/// M1.5 scope has no PK. Returns <c>false</c> always.
/// Wire through mover + target state when PK ships (M2+ / phase TBD).
/// Retail oracle: pc:276808276841.
/// </para>
/// </summary>
internal static bool PvpExempt() => false; // wire when PK ships (M2+) — pc:276808276841
/// <summary>
/// W1: Named predicate for the <c>OBJECTINFO::missile_ignore</c> term
/// in retail's <c>CPhysicsObj::FindObjCollisions</c> dispatch
/// (pc:276861, <c>acclient_2013_pseudo_c.txt:274385 + :276858276861</c>).
///
/// <para>
/// In retail <c>eax_12 = OBJECTINFO::missile_ignore(ebx, this)</c>
/// returns non-zero when either the TARGET has <c>MISSILE_PS (0x40)</c>
/// set, or the mover has <c>MISSILE_PS</c> set and the target is a
/// creature that is not the mover's designated target. When non-zero the
/// dispatch takes the cyl+sphere path regardless of
/// <c>HAS_PHYSICS_BSP_PS</c> — missiles pass through BSP-only objects.
/// </para>
///
/// <para>
/// M1.5 scope has no missiles. Returns <c>false</c> always.
/// Wire through mover OBJECTINFO + target state when missiles ship (F.3).
/// Retail oracle: <c>OBJECTINFO::missile_ignore</c> pc:274385;
/// dispatch pc:276858276861.
/// </para>
/// </summary>
internal static bool MissileIgnore() => false; // wire when missiles ship (F.3) — pc:274385
/// <summary>
/// A6.P7 retail-binary dispatch predicate. Returns true when an
/// entity's collision queries should go to its BSP exclusively,
@ -716,24 +719,19 @@ public sealed class Transition
/// else
/// // BSP-only via CPartArray::FindObjCollisions
/// </code>
/// where <c>ebp_1</c> is the PvP-target-player flag (lines 276808
/// 276841, see <see cref="PvpExempt"/>) and <c>eax_12</c> is the
/// <c>OBJECTINFO::missile_ignore</c> result (line 274385, see
/// <see cref="MissileIgnore"/>). The flag is named
/// where <c>ebp_1</c> is the PvP-target-player exemption and
/// <c>eax_12</c> is <c>OBJECTINFO::missile_ignore</c>. Both are handled
/// as whole-object early exits before this shape-dispatch predicate. The
/// flag is named
/// <c>HAS_PHYSICS_BSP_PS = 0x10000</c> in acclient.h:2833 and
/// <c>PhysicsState.HasPhysicsBSP</c> in ACE
/// (references/ACE/Source/ACE.Entity/Enum/PhysicsState.cs:24).
/// </para>
///
/// <para>
/// BSP-only iff:
/// <c>HAS_PHYSICS_BSP_PS</c> is set on the entity
/// AND the mover is not PvP-exempt for this target (<see cref="PvpExempt"/> = false)
/// AND the entity is not missile-ignore exempt (<see cref="MissileIgnore"/> = false).
/// In M1.5 <see cref="PvpExempt"/> and <see cref="MissileIgnore"/> both
/// return false, so the predicate reduces to
/// <c>(state &amp; HAS_PHYSICS_BSP_PS) != 0</c> — no behavior change
/// from before W1.
/// Once an object reaches shape dispatch, it is BSP-only exactly when
/// <c>HAS_PHYSICS_BSP_PS</c> is set. The exemption gates have already
/// returned <c>OK_TS</c> for the complete target object.
/// </para>
///
/// <para>
@ -744,14 +742,9 @@ public sealed class Transition
/// <param name="entityState">The collision target entity's raw
/// <c>PhysicsState</c> value (as stored on
/// <c>ShadowEntry.State</c>).</param>
/// <returns>True when retail would dispatch BSP-only — i.e. when
/// the entity has <c>HAS_PHYSICS_BSP_PS</c> set AND neither the PvP
/// exemption nor the missile-ignore exemption applies; cyl/sphere
/// shapes must be skipped at the per-entry dispatch site.</returns>
/// <returns>True when retail dispatches exclusively to the target BSP.</returns>
public static bool BspOnlyDispatch(uint entityState)
=> (entityState & (uint)PhysicsStateFlags.HasPhysicsBsp) != 0
&& !PvpExempt()
&& !MissileIgnore();
=> (entityState & (uint)PhysicsStateFlags.HasPhysicsBsp) != 0;
// -----------------------------------------------------------------------
// Public entry point
@ -840,15 +833,10 @@ public sealed class Transition
}
}
// Retail safety cap (30 steps). Viewer/sight objects bypass it, matching
// retail: CTransition::find_transitional_position (acclient_2013_pseudo_c.txt
// :273613) has no cap, and calc_num_steps (:272149) has a dedicated viewer
// branch `if ((state & 4) != 0)` at :272181. The A8.F camera spring-arm
// (IsViewer) sweeps the eye far past 30 steps; the zoom clamp (≤40 m / 0.3 m
// radius ≈ 134 steps) bounds it. Non-viewers keep the safety net (players
// never exceed it: 30 × 0.48 m ≈ 14 m/tick).
if (numSteps > PhysicsGlobals.MaxTransitionSteps && !ObjectInfo.IsViewer)
return false;
// No arbitrary step cap: retail find_transitional_position 0x0050BDF0
// iterates every step returned by calc_num_steps 0x0050A0B0. The old
// acdream-only 30-step bail truncated valid 0.2-second missile sweeps
// (50 world units/s with a 0.1-unit sphere requires 100 steps).
// Apply free rotation if requested.
if (ObjectInfo.FreeRotate)
@ -1090,7 +1078,8 @@ public sealed class Transition
sp.CurOrientation = sp.CheckOrientation;
for (int i = 0; i < sp.NumSphere; i++)
{
sp.GlobalCurrCenter[i].Origin = sp.LocalSphere[i].Origin + sp.CurPos;
sp.GlobalCurrCenter[i].Origin =
Vector3.Transform(sp.LocalSphere[i].Origin, sp.CurOrientation) + sp.CurPos;
sp.GlobalCurrCenter[i].Radius = sp.LocalSphere[i].Radius;
}
}
@ -2586,10 +2575,29 @@ public sealed class Transition
return otherCellsState;
// Retarget the carried cell to the ordered-pick containing cell (retail check_cell =
// var_4c, then adjust_check_pos). SetCheckPos mirrors that id swap + global-sphere
// refresh without moving the sphere.
// var_4c, then adjust_check_pos). Our positions are world-space, so a carried
// outdoor frame must also rebase by 192 metres when the containing landblock
// changes. Retail gets this automatically because Position.Frame is relative to
// its current ObjCell. Keeping the old origin would make every remaining sweep
// substep advance the cell id by another entire landblock.
if (containingCellId != sp.CheckCellId)
{
if (sp.CarriedBlockOrigin is { } carriedOrigin
&& (sp.CheckCellId & 0xFFFFu) is >= 1u and <= 0x40u
&& (containingCellId & 0xFFFFu) is >= 1u and <= 0x40u)
{
int oldBlockX = (int)((sp.CheckCellId >> 24) & 0xFFu);
int oldBlockY = (int)((sp.CheckCellId >> 16) & 0xFFu);
int newBlockX = (int)((containingCellId >> 24) & 0xFFu);
int newBlockY = (int)((containingCellId >> 16) & 0xFFu);
sp.CarriedBlockOrigin = carriedOrigin + new Vector3(
(newBlockX - oldBlockX) * LandDefs.BlockLength,
(newBlockY - oldBlockY) * LandDefs.BlockLength,
0f);
}
sp.SetCheckPos(sp.CheckPos, containingCellId);
}
return TransitionState.OK;
}
@ -2763,6 +2771,12 @@ public sealed class Transition
if (oi.SelfEntityId != 0 && obj.EntityId == oi.SelfEntityId)
continue;
// OBJECTINFO::missile_ignore 0x0050CEB0 is an all-shapes
// exemption. Retail computes it before the BSP-vs-cyl/sphere
// dispatch; a true result reaches neither branch.
if (oi.MissileIgnore(obj.EntityId, obj.State, obj.Flags))
continue;
// Broad-phase: can the moving sphere reach this object?
Vector3 deltaToCurr = currPos - obj.Position;
float distToCurr;
@ -2954,10 +2968,9 @@ public sealed class Transition
// door-a6p6-v2.utf8.log. See investigation at
// docs/research/2026-05-25-a6-door-cyl-retail-dispatch-investigation.md.
//
// M1.5 scope: PvP exemption (ebp_1) and missile_ignore
// (eax_12) are treated as false. Wire them through when
// PK / missiles ship — matches retail's full predicate
// at line 276861.
// The PvP-target exemption and OBJECTINFO::missile_ignore are
// evaluated as whole-object early exits above this per-shape
// dispatch, matching retail's full predicate at line 276861.
if (BspOnlyDispatch(obj.State))
{
if (PhysicsDiagnostics.ProbeBuildingEnabled)
@ -4725,7 +4738,8 @@ public sealed class Transition
// Cache the current-center spheres at the new position.
for (int i = 0; i < sp.NumSphere; i++)
{
sp.GlobalCurrCenter[i].Origin = sp.LocalSphere[i].Origin + sp.CurPos;
sp.GlobalCurrCenter[i].Origin =
Vector3.Transform(sp.LocalSphere[i].Origin, sp.CurOrientation) + sp.CurPos;
sp.GlobalCurrCenter[i].Radius = sp.LocalSphere[i].Radius;
}