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:
parent
542dcfc384
commit
f02b995e4f
21 changed files with 1669 additions and 263 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 < MinQuantum → return (too short — skip)
|
||||
/// if dVar1 <= 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
261
src/AcDream.Core/Physics/ProjectilePhysicsStepper.cs
Normal file
261
src/AcDream.Core/Physics/ProjectilePhysicsStepper.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
63
src/AcDream.Core/Physics/RetailFrameMath.cs
Normal file
63
src/AcDream.Core/Physics/RetailFrameMath.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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:276808–276841</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:276808–276841.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static bool PvpExempt() => false; // wire when PK ships (M2+) — pc:276808–276841
|
||||
|
||||
/// <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 + :276858–276861</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:276858–276861.
|
||||
/// </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 & 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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue