feat(physics): port retail complete object frame pipeline
Restore the named-retail object update order across local, remote, static, projectile, animation, shadow, teleport, and effect lifetimes. Separate authoritative root commits from spatial rebucketing, preserve per-owner hook/FIFO ordering, and remove update-path allocations with exact lifecycle and residency gates. Add deterministic conformance, adversarial lifetime, GUID-reuse, pending-cell, quaternion, timestamp, and allocation coverage. Release build is warning-free and all 6,446 tests pass with five intentional skips; retail, architecture, and adversarial reviews are clean. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
31a0889f08
commit
f961d70023
77 changed files with 12513 additions and 1871 deletions
|
|
@ -142,15 +142,16 @@ public sealed class AnimationSequencer
|
|||
/// <para>
|
||||
/// Crucially this is **not** per-node: while a link animation plays, the
|
||||
/// surfaced velocity is still the cycle's velocity (the cycle was added
|
||||
/// last, so SetVelocity's latest call wins). Remote entity dead-reckoning
|
||||
/// reads this to integrate position without gapping during stance
|
||||
/// transitions.
|
||||
/// last, so SetVelocity's latest call wins). <c>CSequence::apply_physics</c>
|
||||
/// contributes it directly to the complete root Frame; no separate body-
|
||||
/// velocity reconstruction is performed from this accessor.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public Vector3 CurrentVelocity => _core.Velocity;
|
||||
|
||||
/// <summary>
|
||||
/// Sequence-wide omega, matching <see cref="CurrentVelocity"/>'s semantics.
|
||||
/// Sequence-wide omega. <c>CSequence::apply_physics</c> rotates the same
|
||||
/// complete root Frame that carries PosFrames and sequence velocity.
|
||||
/// </summary>
|
||||
public Vector3 CurrentOmega => _core.Omega;
|
||||
|
||||
|
|
@ -283,6 +284,12 @@ public sealed class AnimationSequencer
|
|||
_table = new CMotionTable(motionTable);
|
||||
_state = new MotionState();
|
||||
_manager = new MotionTableManager(_table, _state, _core, new ForwardingMotionDoneSink(this));
|
||||
|
||||
// CPartArray::InitDefaults (0x00518980) runs for every setup-backed
|
||||
// PartArray, before CPhysicsObj::InitDefaults installs its motion
|
||||
// table. Static is only a later workset-membership decision; it does
|
||||
// not gate installation of Setup.DefaultAnimation itself.
|
||||
InitializeSetupDefaultAnimation((uint)setup.DefaultAnimation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -399,38 +406,9 @@ public sealed class AnimationSequencer
|
|||
if (dispatchResult != MotionTableManagerError.Success)
|
||||
return;
|
||||
|
||||
// ── Synthesize CurrentOmega for turn cycles ───────────────────────
|
||||
// Humanoid turn MotionData often ships without HasOmega. Until the
|
||||
// remaining R6 rotation path consumes CSequence's complete Frame
|
||||
// orientation, the remote ObservedOmega seam needs the retail
|
||||
// turn-rate fallback. Decompile references:
|
||||
// FUN_00529210 apply_current_movement (writes Omega)
|
||||
// chunk_00520000.c TurnRate globals (~π/2 rad/s for speed=1)
|
||||
// The ACE port uses `omega.z = ±(π/2) × turnSpeed` for right/left
|
||||
// turns (holtburger confirms the same via motion_resolution.rs).
|
||||
if (_core.Omega.LengthSquared() < 1e-9f)
|
||||
{
|
||||
float zomega = 0f;
|
||||
uint low = motion & 0xFFu;
|
||||
switch (low)
|
||||
{
|
||||
case 0x0D: // TurnRight — clockwise from above = -Z in right-handed.
|
||||
zomega = -(MathF.PI / 2f) * adjustedSpeed;
|
||||
break;
|
||||
case 0x0E: // TurnLeft — counter-clockwise = +Z.
|
||||
// adjust_motion above ALREADY remapped 0x0E → 0x0D
|
||||
// with adjustedSpeed = -speedMod, so the same
|
||||
// formula as 0x0D applied to the negated speed
|
||||
// produces the correct +Z (CCW) result. Using a
|
||||
// different sign here would double-negate and
|
||||
// animate a left turn as a right turn — that was
|
||||
// the bug observed before this fix (commit follows).
|
||||
zomega = -(MathF.PI / 2f) * adjustedSpeed;
|
||||
break;
|
||||
}
|
||||
if (zomega != 0f)
|
||||
_core.SetOmega(new Vector3(0f, 0f, zomega));
|
||||
}
|
||||
// Rotation stays DAT-authored. Retail MotionData::add_motion
|
||||
// (0x005224B0) contributes the entry's literal omega to CSequence;
|
||||
// CSequence::apply_physics emits it through the complete Frame.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -453,6 +431,36 @@ public sealed class AnimationSequencer
|
|||
/// </summary>
|
||||
public void InitializeState() => EnsureInitialized();
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CPartArray::InitDefaults</c> (0x00518980): a Setup
|
||||
/// <c>DefaultAnimation</c> bypasses the MotionTable, clears the sequence,
|
||||
/// and appends one direct animation over frames <c>0..-1</c> at 30 fps.
|
||||
/// Installation is unconditional for every setup-backed PartArray.
|
||||
/// <c>CPhysicsObj::InitDefaults</c> separately decides whether a Static
|
||||
/// owner enters <c>CPhysics::static_animating_objects</c>; ordinary motion
|
||||
/// table initialization may subsequently replace this direct sequence.
|
||||
/// </summary>
|
||||
public bool InitializeSetupDefaultAnimation(uint animationId)
|
||||
{
|
||||
if (animationId == 0)
|
||||
return false;
|
||||
|
||||
// CPartArray::InitDefaults (0x00518980) calls only
|
||||
// CSequence::clear_animations (0x00524DC0). Sequence velocity,
|
||||
// omega, and placement state belong to the surrounding PartArray
|
||||
// lifetime and survive replacement of the animation list.
|
||||
_core.ClearAnimations();
|
||||
_pendingHooks.Clear();
|
||||
_core.AppendAnimation(new AnimData
|
||||
{
|
||||
AnimId = (QualifiedDataId<Animation>)animationId,
|
||||
LowFrame = 0,
|
||||
HighFrame = -1,
|
||||
Framerate = 30f,
|
||||
});
|
||||
return _core.CurrAnim is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R2-Q5: the single dispatch entry — lazy initialize_state, then
|
||||
/// <see cref="MotionTableManager.PerformMovement"/>. The resulting
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
|
|
@ -15,15 +16,9 @@ namespace AcDream.Core.Physics;
|
|||
// InterpolationManager::NodeCompleted acclient @ 0x005559A0
|
||||
// InterpolationManager::StopInterpolating acclient @ 0x00555950
|
||||
//
|
||||
// FIFO position-waypoint queue (cap 20). Each physics tick the caller passes
|
||||
// current body position + max-speed from the motion table; we return the
|
||||
// world-space delta vector to apply to the body for this frame.
|
||||
//
|
||||
// Public C# API kept Vector3-based for compatibility with PositionManager and
|
||||
// GameWindow callsites; retail-spec method names are documented inline. The
|
||||
// retail Frame mutation pattern collapses to "return a Vector3 delta" because
|
||||
// adjust_offset's offset Frame is rotation-zero (translation-only) for this
|
||||
// queue's purposes — see audit 04-interp-manager.md § 4.
|
||||
// FIFO Position-waypoint queue (cap 20). The compatibility overload returns
|
||||
// only its world-space origin, while the production overload carries retail's
|
||||
// complete relative Frame from Position::subtract2, including orientation.
|
||||
//
|
||||
// Bug fixes applied vs prior port (audit § 7):
|
||||
// #1: progress_quantum accumulates dt (not step magnitude).
|
||||
|
|
@ -38,8 +33,7 @@ namespace AcDream.Core.Physics;
|
|||
internal sealed class InterpolationNode
|
||||
{
|
||||
public Vector3 TargetPosition;
|
||||
public float Heading;
|
||||
public bool IsHeadingValid;
|
||||
public Quaternion TargetOrientation = Quaternion.Identity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -120,6 +114,7 @@ public sealed class InterpolationManager
|
|||
private float _progressQuantum = 0f; // progress_quantum (sum of dt)
|
||||
private float _originalDistance = OriginalDistanceSentinel; // original_distance
|
||||
private int _failCount = 0; // node_fail_counter
|
||||
private bool _keepHeading; // keep_heading
|
||||
|
||||
// ── public API ────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -167,11 +162,31 @@ public sealed class InterpolationManager
|
|||
/// Pass <c>null</c> if not available — far/near classification falls back
|
||||
/// to "near" (no pre-armed blip).
|
||||
/// </param>
|
||||
public void Enqueue(
|
||||
public Quaternion? Enqueue(
|
||||
Vector3 targetPosition,
|
||||
float heading,
|
||||
bool isMovingTo,
|
||||
Vector3? currentBodyPosition = null)
|
||||
=> Enqueue(
|
||||
targetPosition,
|
||||
Quaternion.CreateFromAxisAngle(Vector3.UnitZ, heading),
|
||||
isMovingTo,
|
||||
currentBodyPosition,
|
||||
currentBodyOrientation: null);
|
||||
|
||||
/// <summary>
|
||||
/// Complete-frame overload of retail <c>InterpolateTo</c>. The node keeps
|
||||
/// the target quaternion; a near enqueue assigns
|
||||
/// <paramref name="isMovingTo"/> to retail's manager-wide
|
||||
/// <c>keep_heading</c> flag. The far branch deliberately retains the
|
||||
/// manager's prior flag, matching the retail early return.
|
||||
/// </summary>
|
||||
public Quaternion? Enqueue(
|
||||
Vector3 targetPosition,
|
||||
Quaternion targetOrientation,
|
||||
bool isMovingTo,
|
||||
Vector3? currentBodyPosition = null,
|
||||
Quaternion? currentBodyOrientation = null)
|
||||
{
|
||||
// Retail compares dist against either the tail's stored position
|
||||
// (if tail exists AND tail->type == 1) or the body's m_position.
|
||||
|
|
@ -195,10 +210,17 @@ public sealed class InterpolationManager
|
|||
// Far branch (retail line 352918, dist > GetAutonomyBlipDistance):
|
||||
if (dist > AutonomyBlipDistance)
|
||||
{
|
||||
EnqueueRaw(targetPosition, heading, isMovingTo);
|
||||
// The far branch does not assign keep_heading from arg3. It uses
|
||||
// the manager's existing flag when storing this Position.
|
||||
EnqueueRaw(
|
||||
targetPosition,
|
||||
StoreTargetOrientation(
|
||||
targetOrientation,
|
||||
currentBodyOrientation,
|
||||
_keepHeading));
|
||||
// Pre-arm immediate blip on next AdjustOffset (audit § 7 #3).
|
||||
_failCount = StallFailCountThreshold + 1;
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Near & already-close branch (retail line 352962):
|
||||
|
|
@ -209,7 +231,14 @@ public sealed class InterpolationManager
|
|||
if (bodyDist <= DesiredDistance)
|
||||
{
|
||||
Clear();
|
||||
return;
|
||||
// InterpolateTo 0x00555C08 calls CPhysicsObj::set_heading
|
||||
// with the target Frame's heading. It does not install the
|
||||
// target's pitch/roll at this already-close seam.
|
||||
return isMovingTo
|
||||
? null
|
||||
: MoveToMath.SetHeading(
|
||||
targetOrientation,
|
||||
MoveToMath.GetHeading(targetOrientation));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -227,19 +256,43 @@ public sealed class InterpolationManager
|
|||
_queue.RemoveFirst();
|
||||
|
||||
// 3. Append.
|
||||
EnqueueRaw(targetPosition, heading, isMovingTo);
|
||||
_keepHeading = isMovingTo;
|
||||
EnqueueRaw(
|
||||
targetPosition,
|
||||
StoreTargetOrientation(
|
||||
targetOrientation,
|
||||
currentBodyOrientation,
|
||||
_keepHeading));
|
||||
return null;
|
||||
}
|
||||
|
||||
private void EnqueueRaw(Vector3 target, float heading, bool isMovingTo)
|
||||
private void EnqueueRaw(
|
||||
Vector3 target,
|
||||
Quaternion targetOrientation)
|
||||
{
|
||||
_queue.AddLast(new InterpolationNode
|
||||
{
|
||||
TargetPosition = target,
|
||||
Heading = heading,
|
||||
IsHeadingValid = isMovingTo,
|
||||
TargetOrientation = targetOrientation,
|
||||
});
|
||||
}
|
||||
|
||||
private static Quaternion StoreTargetOrientation(
|
||||
Quaternion targetOrientation,
|
||||
Quaternion? currentBodyOrientation,
|
||||
bool keepHeading)
|
||||
{
|
||||
if (!keepHeading || currentBodyOrientation is not { } current)
|
||||
return targetOrientation;
|
||||
|
||||
// InterpolateTo stores the object's current heading into the node
|
||||
// when keep_heading is active. Frame::set_heading intentionally
|
||||
// discards the target Position's pitch/roll at this seam.
|
||||
return MoveToMath.SetHeading(
|
||||
targetOrientation,
|
||||
MoveToMath.GetHeading(current));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compute the per-frame world-space correction delta. Combines the retail
|
||||
/// <c>UseTime</c> blip-check (fail_count > 3 → snap to tail, clear queue)
|
||||
|
|
@ -259,14 +312,74 @@ public sealed class InterpolationManager
|
|||
/// Max motion-table speed for this entity's current cycle (m/s).
|
||||
/// Pass 0 to use the <see cref="MaxInterpolatedVelocity"/> fallback.
|
||||
/// </param>
|
||||
public Vector3 AdjustOffset(double dt, Vector3 currentBodyPosition, float maxSpeedFromMinterp)
|
||||
public Vector3 AdjustOffset(
|
||||
double dt,
|
||||
Vector3 currentBodyPosition,
|
||||
float maxSpeedFromMinterp,
|
||||
bool inContact = true)
|
||||
{
|
||||
if (!inContact)
|
||||
return Vector3.Zero;
|
||||
|
||||
InterpolationStep step = ComputeStep(
|
||||
dt,
|
||||
currentBodyPosition,
|
||||
maxSpeedFromMinterp);
|
||||
return step.Overwrites ? step.WorldOrigin : Vector3.Zero;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>InterpolationManager::adjust_offset</c> complete-Frame path
|
||||
/// (0x00555D30). When interpolation is active it replaces both components
|
||||
/// of <paramref name="offset"/> with <c>Position::subtract2</c>'s relative
|
||||
/// target frame, then scales only Origin to the catch-up step. A MoveTo
|
||||
/// node keeps heading by replacing the relative rotation with identity.
|
||||
/// When the queue is empty or a node completes, the incoming PartArray
|
||||
/// frame remains untouched.
|
||||
/// </summary>
|
||||
public bool AdjustOffset(
|
||||
double dt,
|
||||
Vector3 currentBodyPosition,
|
||||
Quaternion currentBodyOrientation,
|
||||
float maxSpeedFromMinterp,
|
||||
MotionDeltaFrame offset,
|
||||
bool inContact = true)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(offset);
|
||||
if (!inContact)
|
||||
return false;
|
||||
|
||||
InterpolationStep step = ComputeStep(
|
||||
dt,
|
||||
currentBodyPosition,
|
||||
maxSpeedFromMinterp);
|
||||
if (!step.Overwrites)
|
||||
return false;
|
||||
|
||||
offset.Origin = MoveToMath.GlobalToLocalVec(
|
||||
currentBodyOrientation,
|
||||
step.WorldOrigin);
|
||||
offset.Orientation = _keepHeading
|
||||
? Quaternion.Identity
|
||||
: FrameOps.SetRotate(
|
||||
offset.Origin,
|
||||
Quaternion.Identity,
|
||||
Quaternion.Inverse(currentBodyOrientation)
|
||||
* step.TargetOrientation);
|
||||
return true;
|
||||
}
|
||||
|
||||
private InterpolationStep ComputeStep(
|
||||
double dt,
|
||||
Vector3 currentBodyPosition,
|
||||
float maxSpeedFromMinterp)
|
||||
{
|
||||
// dt sanity guard — protects PhysicsBody.Position from NaN poisoning.
|
||||
if (dt <= 0 || double.IsNaN(dt))
|
||||
return Vector3.Zero;
|
||||
return default;
|
||||
|
||||
if (_queue.First is null)
|
||||
return Vector3.Zero;
|
||||
return default;
|
||||
|
||||
// Distance to head node (retail line 353083).
|
||||
var head = _queue.First.Value;
|
||||
|
|
@ -278,7 +391,7 @@ public sealed class InterpolationManager
|
|||
if (dist <= DesiredDistance)
|
||||
{
|
||||
NodeCompleted(popHead: true, currentBodyPosition);
|
||||
return Vector3.Zero;
|
||||
return default;
|
||||
}
|
||||
|
||||
// Catch-up speed (retail line 353122 + 353128 fallback).
|
||||
|
|
@ -344,9 +457,13 @@ public sealed class InterpolationManager
|
|||
// Retail splits this into a separate UseTime call; we collapse it.
|
||||
if (_failCount > StallFailCountThreshold)
|
||||
{
|
||||
Vector3 tailPos = _queue.Last!.Value.TargetPosition;
|
||||
InterpolationNode tail = _queue.Last!.Value;
|
||||
Vector3 tailDelta = tail.TargetPosition - currentBodyPosition;
|
||||
Clear();
|
||||
return tailPos - currentBodyPosition;
|
||||
return new InterpolationStep(
|
||||
true,
|
||||
tailDelta,
|
||||
tail.TargetOrientation);
|
||||
}
|
||||
|
||||
// Per-frame step magnitude (retail line 353218).
|
||||
|
|
@ -358,9 +475,17 @@ public sealed class InterpolationManager
|
|||
|
||||
// Direction × step.
|
||||
Vector3 delta = ((head.TargetPosition - currentBodyPosition) / dist) * step;
|
||||
return delta;
|
||||
return new InterpolationStep(
|
||||
true,
|
||||
delta,
|
||||
head.TargetOrientation);
|
||||
}
|
||||
|
||||
private readonly record struct InterpolationStep(
|
||||
bool Overwrites,
|
||||
Vector3 WorldOrigin,
|
||||
Quaternion TargetOrientation);
|
||||
|
||||
/// <summary>
|
||||
/// Retail NodeCompleted (@ 0x005559A0). popHead=true after head reached;
|
||||
/// popHead=false during stall fail (re-baseline only). For our collapsed
|
||||
|
|
|
|||
|
|
@ -25,6 +25,48 @@ public static class FrameOps
|
|||
/// square against |v|².</summary>
|
||||
public const float FEpsilon = 0.000199999995f;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>Frame::set_rotate</c> (0x00535080). The candidate is
|
||||
/// normalized in extended precision, then the complete frame is checked
|
||||
/// by <c>Frame::IsValid</c> (0x00534ED0). If that check fails, retail
|
||||
/// restores the previous quaternion instead of allowing NaNs to poison
|
||||
/// the live object transform.
|
||||
/// </summary>
|
||||
public static Quaternion SetRotate(
|
||||
Vector3 frameOrigin,
|
||||
Quaternion previous,
|
||||
Quaternion candidate)
|
||||
{
|
||||
double lengthSquared =
|
||||
((double)candidate.W * candidate.W)
|
||||
+ ((double)candidate.X * candidate.X)
|
||||
+ ((double)candidate.Y * candidate.Y)
|
||||
+ ((double)candidate.Z * candidate.Z);
|
||||
double inverseLength = 1.0 / Math.Sqrt(lengthSquared);
|
||||
var normalized = new Quaternion(
|
||||
(float)(candidate.X * inverseLength),
|
||||
(float)(candidate.Y * inverseLength),
|
||||
(float)(candidate.Z * inverseLength),
|
||||
(float)(candidate.W * inverseLength));
|
||||
|
||||
if (float.IsNaN(frameOrigin.X)
|
||||
|| float.IsNaN(frameOrigin.Y)
|
||||
|| float.IsNaN(frameOrigin.Z)
|
||||
|| float.IsNaN(normalized.W)
|
||||
|| float.IsNaN(normalized.X)
|
||||
|| float.IsNaN(normalized.Y)
|
||||
|| float.IsNaN(normalized.Z))
|
||||
{
|
||||
return previous;
|
||||
}
|
||||
|
||||
float normalizedLengthSquared = normalized.LengthSquared();
|
||||
return !float.IsNaN(normalizedLengthSquared)
|
||||
&& MathF.Abs(normalizedLengthSquared - 1f) < FEpsilon * 5f
|
||||
? normalized
|
||||
: previous;
|
||||
}
|
||||
|
||||
/// <summary><c>Frame::grotate</c> — incremental WORLD-space rotation.</summary>
|
||||
public static void GRotate(Frame frame, Vector3 rotationGlobal)
|
||||
{
|
||||
|
|
@ -48,7 +90,10 @@ public static class FrameOps
|
|||
rotationGlobal.Y * s * invMag,
|
||||
rotationGlobal.Z * s * invMag,
|
||||
c);
|
||||
frame.Orientation = Quaternion.Normalize(Quaternion.Multiply(r, frame.Orientation));
|
||||
frame.Orientation = SetRotate(
|
||||
frame.Origin,
|
||||
frame.Orientation,
|
||||
Quaternion.Multiply(r, frame.Orientation));
|
||||
}
|
||||
|
||||
/// <summary><c>Frame::rotate</c> — LOCAL rotation vector, mapped to
|
||||
|
|
@ -65,7 +110,10 @@ public static class FrameOps
|
|||
public static void Combine(Frame frame, Frame pos)
|
||||
{
|
||||
frame.Origin += Vector3.Transform(pos.Origin, frame.Orientation);
|
||||
frame.Orientation = Quaternion.Normalize(frame.Orientation * pos.Orientation);
|
||||
frame.Orientation = SetRotate(
|
||||
frame.Origin,
|
||||
frame.Orientation,
|
||||
frame.Orientation * pos.Orientation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -75,7 +123,9 @@ public static class FrameOps
|
|||
/// </summary>
|
||||
public static void Subtract1(Frame frame, Frame pos)
|
||||
{
|
||||
frame.Orientation = Quaternion.Normalize(
|
||||
frame.Orientation = SetRotate(
|
||||
frame.Origin,
|
||||
frame.Orientation,
|
||||
frame.Orientation * Quaternion.Conjugate(pos.Orientation));
|
||||
frame.Origin -= Vector3.Transform(pos.Origin, frame.Orientation);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@ namespace AcDream.Core.Physics.Motion;
|
|||
///
|
||||
/// <para><see cref="Origin"/> = retail <c>m_fOrigin</c> (the accumulated
|
||||
/// position delta, in the mover's LOCAL frame after
|
||||
/// <c>Position::globaltolocalvec</c>). <see cref="Orientation"/> carries the
|
||||
/// heading retail's <c>Frame::set_heading</c> writes; read/write it as a
|
||||
/// compass heading via <see cref="GetHeading"/> / <see cref="SetHeading"/>
|
||||
/// (P5 convention, degrees).</para>
|
||||
/// <c>Position::globaltolocalvec</c>). <see cref="Orientation"/> is the full
|
||||
/// relative quaternion carried by retail <c>Frame</c>; the heading accessors
|
||||
/// are narrow helpers for managers that explicitly call
|
||||
/// <c>Frame::set_heading</c>.</para>
|
||||
/// </summary>
|
||||
public sealed class MotionDeltaFrame
|
||||
{
|
||||
|
|
@ -26,10 +26,50 @@ public sealed class MotionDeltaFrame
|
|||
/// delta (mover-local frame).</summary>
|
||||
public Vector3 Origin;
|
||||
|
||||
/// <summary>Retail <c>Frame</c> rotation — carries the
|
||||
/// <c>Frame::set_heading</c> output.</summary>
|
||||
/// <summary>Retail <c>Frame</c>'s complete relative rotation.</summary>
|
||||
public Quaternion Orientation = Quaternion.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// Restore retail's identity <c>Frame</c>: zero translation and identity
|
||||
/// orientation. Object ticks reuse these accumulators to avoid allocating
|
||||
/// one transform per entity per render frame.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
Origin = Vector3.Zero;
|
||||
Orientation = Quaternion.Identity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>Frame::combine</c> (0x005122E0), specialized for the mutable
|
||||
/// per-tick delta frame. The incoming translation is expressed in this
|
||||
/// frame's local coordinates, so the current orientation rotates it before
|
||||
/// addition; the incoming orientation is then post-multiplied. Translation
|
||||
/// scaling is the separate <c>m_scale</c> operation performed by
|
||||
/// <c>CPhysicsObj::UpdatePositionInternal</c>.
|
||||
/// </summary>
|
||||
public void Combine(
|
||||
Vector3 localOrigin,
|
||||
Quaternion localOrientation,
|
||||
float originScale = 1f)
|
||||
{
|
||||
Origin += Vector3.Transform(localOrigin * originScale, Orientation);
|
||||
Orientation = FrameOps.SetRotate(
|
||||
Origin,
|
||||
Orientation,
|
||||
Orientation * localOrientation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compose another complete delta frame using retail
|
||||
/// <c>Frame::combine</c> semantics.
|
||||
/// </summary>
|
||||
public void Combine(MotionDeltaFrame delta, float originScale = 1f)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(delta);
|
||||
Combine(delta.Origin, delta.Orientation, originScale);
|
||||
}
|
||||
|
||||
/// <summary>Retail <c>Frame::get_heading</c> (P5 compass degrees).</summary>
|
||||
public float GetHeading() => MoveToMath.GetHeading(Orientation);
|
||||
|
||||
|
|
|
|||
|
|
@ -20,52 +20,25 @@ namespace AcDream.Core.Physics.Motion;
|
|||
/// <c>combine_motion</c>/<c>re_modify</c> — the composition the retired
|
||||
/// AP-73 row approximated with one cycle.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The <see cref="TurnApplied"/>/<see cref="TurnStopped"/> callbacks are the
|
||||
/// interim ObservedOmega seam: the App seeds the remote body's angular
|
||||
/// velocity from the wire turn so rotation starts the same tick (retail
|
||||
/// rotates the body from the sequence omega inside the per-tick
|
||||
/// <c>apply_physics</c> chain — R6 scope; register row). They fire BEFORE
|
||||
/// the dispatch so a consumer sees the seed even if the dat lacks the
|
||||
/// modifier entry.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class MotionTableDispatchSink : IInterpretedMotionSink
|
||||
{
|
||||
private readonly AnimationSequencer _sequencer;
|
||||
|
||||
/// <summary>Turn-class dispatch observed: (motion, signedSpeed) —
|
||||
/// TurnLeft arrives either as the explicit 0x0E command or as
|
||||
/// TurnRight + negative speed (adjust_motion wire convention).</summary>
|
||||
public Action<uint, float>? TurnApplied { get; set; }
|
||||
|
||||
/// <summary>Turn-class stop observed.</summary>
|
||||
public Action? TurnStopped { get; set; }
|
||||
|
||||
public MotionTableDispatchSink(AnimationSequencer sequencer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sequencer);
|
||||
_sequencer = sequencer;
|
||||
}
|
||||
|
||||
private static bool IsTurn(uint motion)
|
||||
=> (motion & 0xFF000000u) == 0x65000000u && (motion & 0xFFu) is 0x0D or 0x0E;
|
||||
|
||||
public bool ApplyMotion(uint motion, float speed)
|
||||
{
|
||||
if (IsTurn(motion))
|
||||
TurnApplied?.Invoke(motion, speed);
|
||||
|
||||
uint result = _sequencer.PerformMovement(MotionTableMovement.Interpreted(motion, speed));
|
||||
return result == MotionTableManagerError.Success;
|
||||
}
|
||||
|
||||
public bool StopMotion(uint motion)
|
||||
{
|
||||
if (IsTurn(motion))
|
||||
TurnStopped?.Invoke();
|
||||
|
||||
uint result = _sequencer.PerformMovement(MotionTableMovement.StopInterpreted(motion, 1f));
|
||||
return result == MotionTableManagerError.Success;
|
||||
}
|
||||
|
|
@ -75,14 +48,10 @@ public sealed class MotionTableDispatchSink : IInterpretedMotionSink
|
|||
/// (0x00518890): <c>MotionTableManager::PerformMovement(type 5)</c>.
|
||||
/// The manager stops everything and queues its Ready-sentinel
|
||||
/// pending_animations entry UNCONDITIONALLY — the completable partner
|
||||
/// of the interp's A9 pending_motions node. A full stop ends any turn
|
||||
/// cycle, so the ObservedOmega seam is notified like
|
||||
/// <see cref="StopMotion"/>'s turn-class branch.
|
||||
/// of the interp's A9 pending_motions node.
|
||||
/// </summary>
|
||||
public bool StopCompletely()
|
||||
{
|
||||
TurnStopped?.Invoke();
|
||||
|
||||
uint result = _sequencer.PerformMovement(MotionTableMovement.StopCompletely());
|
||||
return result == MotionTableManagerError.Success;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,12 +114,8 @@ public static class MoveToMath
|
|||
/// <summary>
|
||||
/// Retail <c>Frame::get_heading</c> (<c>0x00535760</c>, raw 319781) —
|
||||
/// extracts the compass heading (P5 convention) from a body orientation
|
||||
/// quaternion. <b>The packer-reuse trap (V0-pins §P5 correction):</b>
|
||||
/// acdream's outbound packer (<c>GameWindow.YawToAcQuaternion</c>) is
|
||||
/// wire-correct at the QUATERNION level but its internal scalar
|
||||
/// intermediate (<c>headingDeg = 180 - yawDeg</c>) is holtburger's
|
||||
/// SHIFTED convention, not retail's. This method uses the CORRECT
|
||||
/// scalar bridge derived from acdream's own body convention
|
||||
/// quaternion. This method uses the scalar bridge derived from acdream's
|
||||
/// body convention
|
||||
/// (<c>PlayerMovementController.cs:1022-1025</c>: <c>Orientation =
|
||||
/// AxisAngle(Z, Yaw - PI/2)</c>, local-forward = +Y, Yaw=0 faces +X):
|
||||
/// world-forward = <c>(cos Yaw, sin Yaw)</c>, so
|
||||
|
|
@ -166,11 +162,10 @@ public static class MoveToMath
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// R4-V5: the scalar leg of <see cref="GetHeading"/> for bodies whose
|
||||
/// authoritative facing is a yaw ANGLE rather than a quaternion (the
|
||||
/// local player: <c>PlayerMovementController.Yaw</c>, radians, Yaw=0
|
||||
/// faces +X, re-synced into the body quaternion every Update). Same P5
|
||||
/// bridge: <c>heading = (90 - yawDeg) mod 360</c>.
|
||||
/// R4-V5: scalar projection of <see cref="GetHeading"/>. The local
|
||||
/// player's <c>Yaw</c> property exposes this horizontal projection while
|
||||
/// its complete body quaternion remains authoritative. Same P5 bridge:
|
||||
/// <c>heading = (90 - yawDeg) mod 360</c>.
|
||||
/// </summary>
|
||||
public static float HeadingFromYaw(float yawRad)
|
||||
{
|
||||
|
|
@ -181,10 +176,10 @@ public static class MoveToMath
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// R4-V5: exact inverse of <see cref="HeadingFromYaw"/> — the
|
||||
/// <c>set_heading</c> seam for yaw-authoritative bodies (the local
|
||||
/// player's heading snap must write <c>Yaw</c>, NOT the body
|
||||
/// quaternion, which the controller re-derives from Yaw every frame).
|
||||
/// R4-V5: exact inverse of <see cref="HeadingFromYaw"/> — the scalar
|
||||
/// bridge used by an explicit <c>Frame::set_heading</c> operation. This
|
||||
/// operation deliberately replaces pitch/roll; ordinary frame composition
|
||||
/// does not pass through this projection.
|
||||
/// Returns radians wrapped to [-π, π] matching the controller's own
|
||||
/// wrap discipline.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -46,11 +46,11 @@ namespace AcDream.Core.Physics.Motion;
|
|||
/// <c>SetWeenieObject</c>/<c>Destroy</c> have no acdream caller yet —
|
||||
/// <c>get_minterp</c> 0x005242a0 ≡ the <see cref="Minterp"/> property.</para>
|
||||
///
|
||||
/// <para><b>PerformMovement's <c>set_active(1)</c> head</b>
|
||||
/// (0x005240d9) is not re-asserted here: acdream bodies assert the Active
|
||||
/// transient bit at spawn (<c>RemoteMotion</c> ctor) and the pre-facade
|
||||
/// route never re-asserted it — status quo preserved (zero-behavior-change
|
||||
/// slice), not a new deviation.</para>
|
||||
/// <para><b>Activation.</b> Retail begins
|
||||
/// <c>MovementManager::PerformMovement</c> (0x005240D0, call at 0x005240D9)
|
||||
/// with an unconditional <c>CPhysicsObj::set_active(1)</c>, before validating
|
||||
/// the movement type. <see cref="ActivatePhysicsObject"/> is the host seam for
|
||||
/// that call. Static objects keep retail's no-op behavior in the host.</para>
|
||||
/// </summary>
|
||||
public sealed class MovementManager
|
||||
{
|
||||
|
|
@ -73,6 +73,14 @@ public sealed class MovementManager
|
|||
/// <c>CPhysicsObj</c>.</summary>
|
||||
public Func<MoveToManager>? MoveToFactory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Host seam for retail's unconditional
|
||||
/// <c>CPhysicsObj::set_active(1)</c> at the head of
|
||||
/// <see cref="PerformMovement"/>. It is deliberately invoked even for an
|
||||
/// invalid movement type.
|
||||
/// </summary>
|
||||
public Action? ActivatePhysicsObject { get; set; }
|
||||
|
||||
public MovementManager(MotionInterpreter minterp)
|
||||
{
|
||||
Minterp = minterp ?? throw new ArgumentNullException(nameof(minterp));
|
||||
|
|
@ -102,6 +110,8 @@ public sealed class MovementManager
|
|||
/// </summary>
|
||||
public WeenieError PerformMovement(MovementStruct mvs)
|
||||
{
|
||||
ActivatePhysicsObject?.Invoke();
|
||||
|
||||
switch (mvs.Type)
|
||||
{
|
||||
case MovementType.RawCommand:
|
||||
|
|
|
|||
|
|
@ -774,7 +774,7 @@ public sealed class MotionInterpreter : IMotionDoneSink
|
|||
/// is set). Register row: releases a "stuck to object" sticky-manager
|
||||
/// attachment — R5 wires the real StickyManager; until then this is an
|
||||
/// optional callback the App layer may bind, matching the existing
|
||||
/// <c>Action?</c> seam convention (see <c>MotionTableDispatchSink.TurnStopped</c>).
|
||||
/// <c>Action?</c> seams on this runtime owner.
|
||||
/// </summary>
|
||||
public Action? UnstickFromObject { get; set; }
|
||||
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ public static class PhysicsObjUpdate
|
|||
/// must therefore observe any velocity change made by the movement
|
||||
/// callback; moving that callback after reflection changes the result.
|
||||
/// </remarks>
|
||||
public static void CommitSetPositionTransition(
|
||||
public static bool CommitSetPositionTransition(
|
||||
PhysicsBody body,
|
||||
bool inContact,
|
||||
bool onWalkable,
|
||||
|
|
@ -68,7 +68,9 @@ public static class PhysicsObjUpdate
|
|||
bool previousContact,
|
||||
bool previousOnWalkable,
|
||||
Action? hitGround = null,
|
||||
Action? leaveGround = null)
|
||||
Action? leaveGround = null,
|
||||
Func<bool>? isCurrent = null,
|
||||
Func<bool>? isVelocityCurrent = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(body);
|
||||
|
||||
|
|
@ -95,11 +97,26 @@ public static class PhysicsObjUpdate
|
|||
body.TransientState &= ~TransientStateFlags.OnWalkable;
|
||||
|
||||
if (!previousOnWalkable && finalOnWalkable)
|
||||
{
|
||||
hitGround?.Invoke();
|
||||
if (isCurrent?.Invoke() == false)
|
||||
return false;
|
||||
}
|
||||
else if (previousOnWalkable && !finalOnWalkable)
|
||||
{
|
||||
leaveGround?.Invoke();
|
||||
if (isCurrent?.Invoke() == false)
|
||||
return false;
|
||||
}
|
||||
body.calc_acceleration();
|
||||
|
||||
// Position, Vector, and Movement are independently timestamped but
|
||||
// can all install m_velocityVector. If a later one arrived from a
|
||||
// callback above, retain its vector and finish the non-overlapping
|
||||
// contact/pose commit without applying this older collision response.
|
||||
if (isVelocityCurrent?.Invoke() == false)
|
||||
return isCurrent?.Invoke() ?? true;
|
||||
|
||||
HandleAllCollisions(
|
||||
body,
|
||||
collisionNormalValid,
|
||||
|
|
@ -107,6 +124,7 @@ public static class PhysicsObjUpdate
|
|||
previousContact,
|
||||
previousOnWalkable,
|
||||
finalOnWalkable);
|
||||
return isCurrent?.Invoke() ?? true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -42,6 +42,39 @@ public readonly record struct ProjectileAdvanceResult(
|
|||
Vector3 CollisionNormal,
|
||||
bool TransitionOk);
|
||||
|
||||
/// <summary>
|
||||
/// Candidate frame produced by UpdatePhysicsInternal and held across retail's
|
||||
/// process_hooks slot before the outer transition/collision commit.
|
||||
/// </summary>
|
||||
public readonly record struct ProjectileQuantumPreparation(
|
||||
uint CellId,
|
||||
float Quantum,
|
||||
bool Simulated,
|
||||
bool RequiresTransition,
|
||||
Vector3 BeginPosition,
|
||||
Quaternion BeginOrientation,
|
||||
Position BeginCellPosition,
|
||||
bool BeginInWorld,
|
||||
ProjectileQuantumDynamics BeginDynamics,
|
||||
Vector3 CandidatePosition,
|
||||
Quaternion CandidateOrientation,
|
||||
ProjectileQuantumDynamics CandidateDynamics,
|
||||
bool PreviousContact,
|
||||
bool PreviousOnWalkable);
|
||||
|
||||
/// <summary>
|
||||
/// Mutable kinematic fields produced by the candidate integration. The split
|
||||
/// App scheduler holds these transactionally across <c>process_hooks</c> so a
|
||||
/// re-entrant authoritative correction never inherits an abandoned impulse.
|
||||
/// </summary>
|
||||
public readonly record struct ProjectileQuantumDynamics(
|
||||
Vector3 Velocity,
|
||||
Vector3 CachedVelocity,
|
||||
Vector3 Acceleration,
|
||||
Vector3 Omega,
|
||||
TransientStateFlags TransientState,
|
||||
double LastUpdateTime);
|
||||
|
||||
/// <summary>
|
||||
/// Core-only port of the retail live-projectile physics driver. It owns no
|
||||
/// renderer, network, or world-lifetime state: the App controller supplies a
|
||||
|
|
@ -152,32 +185,84 @@ public sealed class ProjectilePhysicsStepper
|
|||
transitionOk);
|
||||
}
|
||||
|
||||
private void StepQuantum(
|
||||
/// <summary>
|
||||
/// Advances exactly one quantum already admitted by the owning
|
||||
/// <see cref="RetailObjectQuantumClock"/>. This is the live-object path:
|
||||
/// one CPhysicsObj clock admits PartArray, projectile physics, hooks, and
|
||||
/// the manager tail together. The absolute-time overload remains for the
|
||||
/// pure stepper conformance surface.
|
||||
/// </summary>
|
||||
public ProjectileAdvanceResult AdvanceQuantum(
|
||||
PhysicsBody body,
|
||||
float dt,
|
||||
ref uint cellId,
|
||||
float quantum,
|
||||
uint cellId,
|
||||
ProjectileCollisionSphere sphere,
|
||||
uint movingEntityId,
|
||||
uint designatedTargetId,
|
||||
ref bool collisionNormalValid,
|
||||
ref Vector3 collisionNormal,
|
||||
ref bool transitionOk)
|
||||
uint designatedTargetId = 0,
|
||||
bool isParented = false)
|
||||
{
|
||||
ProjectileQuantumPreparation preparation = BeginQuantum(
|
||||
body,
|
||||
quantum,
|
||||
cellId,
|
||||
sphere,
|
||||
isParented);
|
||||
return CompleteQuantum(
|
||||
body,
|
||||
preparation,
|
||||
sphere,
|
||||
movingEntityId,
|
||||
designatedTargetId);
|
||||
}
|
||||
|
||||
public ProjectileQuantumPreparation BeginQuantum(
|
||||
PhysicsBody body,
|
||||
float quantum,
|
||||
uint cellId,
|
||||
ProjectileCollisionSphere sphere,
|
||||
bool isParented = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(body);
|
||||
if (!float.IsFinite(quantum)
|
||||
|| quantum <= PhysicsGlobals.EPSILON
|
||||
|| quantum > PhysicsBody.MaxQuantum)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(quantum),
|
||||
quantum,
|
||||
"An admitted object quantum must be finite and no larger than MaxQuantum.");
|
||||
}
|
||||
|
||||
if (!sphere.IsValid)
|
||||
return NotSimulated(cellId, quantum);
|
||||
|
||||
if (isParented || !body.InWorld
|
||||
|| body.State.HasFlag(PhysicsStateFlags.Frozen)
|
||||
|| body.State.HasFlag(PhysicsStateFlags.Static)
|
||||
|| cellId == 0)
|
||||
{
|
||||
body.TransientState &= ~TransientStateFlags.Active;
|
||||
return NotSimulated(cellId, quantum);
|
||||
}
|
||||
|
||||
if (!body.IsActive)
|
||||
return NotSimulated(cellId, quantum);
|
||||
|
||||
Vector3 beginPosition = body.Position;
|
||||
Quaternion beginOrientation = body.Orientation;
|
||||
Position beginCellPosition = body.CellPosition;
|
||||
bool beginInWorld = body.InWorld;
|
||||
ProjectileQuantumDynamics beginDynamics = CaptureDynamics(body);
|
||||
bool previousContact = body.InContact;
|
||||
bool previousOnWalkable = body.OnWalkable;
|
||||
|
||||
// Final PhysicsState drives acceleration on every quantum. Network
|
||||
// acceleration is retained by the protocol parser but not installed.
|
||||
body.calc_acceleration();
|
||||
body.UpdatePhysicsInternal(dt);
|
||||
body.UpdatePhysicsInternal(quantum);
|
||||
|
||||
Vector3 candidatePosition = body.Position;
|
||||
Quaternion candidateOrientation = body.Orientation;
|
||||
Vector3 displacement = candidatePosition - beginPosition;
|
||||
bool candidateMoved = displacement != Vector3.Zero;
|
||||
|
||||
if (candidateMoved && body.State.HasFlag(PhysicsStateFlags.AlignPath))
|
||||
{
|
||||
candidateOrientation = RetailFrameMath.SetVectorHeading(
|
||||
|
|
@ -189,76 +274,218 @@ public sealed class ProjectilePhysicsStepper
|
|||
{
|
||||
body.CachedVelocity = Vector3.Zero;
|
||||
body.Orientation = candidateOrientation;
|
||||
return;
|
||||
body.LastUpdateTime += quantum;
|
||||
}
|
||||
else
|
||||
{
|
||||
// process_hooks runs against the post-physics candidate while the
|
||||
// authoritative root remains at the begin frame until transition.
|
||||
body.Position = beginPosition;
|
||||
body.Orientation = beginOrientation;
|
||||
}
|
||||
|
||||
// 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;
|
||||
ProjectileQuantumDynamics candidateDynamics = CaptureDynamics(body);
|
||||
RestoreBeginFrame(
|
||||
body,
|
||||
beginPosition,
|
||||
beginOrientation,
|
||||
beginCellPosition,
|
||||
beginInWorld);
|
||||
ApplyDynamics(body, beginDynamics);
|
||||
|
||||
return new ProjectileQuantumPreparation(
|
||||
cellId,
|
||||
quantum,
|
||||
Simulated: true,
|
||||
RequiresTransition: candidateMoved,
|
||||
beginPosition,
|
||||
beginOrientation,
|
||||
beginCellPosition,
|
||||
beginInWorld,
|
||||
beginDynamics,
|
||||
candidatePosition,
|
||||
candidateOrientation,
|
||||
candidateDynamics,
|
||||
previousContact,
|
||||
previousOnWalkable);
|
||||
}
|
||||
|
||||
public ProjectileAdvanceResult CompleteQuantum(
|
||||
PhysicsBody body,
|
||||
in ProjectileQuantumPreparation preparation,
|
||||
ProjectileCollisionSphere sphere,
|
||||
uint movingEntityId,
|
||||
uint designatedTargetId = 0)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(body);
|
||||
if (!preparation.Simulated)
|
||||
return new ProjectileAdvanceResult(preparation.CellId, 0, false, false, default, true);
|
||||
ApplyDynamics(body, preparation.CandidateDynamics);
|
||||
if (!preparation.RequiresTransition)
|
||||
{
|
||||
body.SetFrameInCurrentCell(
|
||||
preparation.CandidatePosition,
|
||||
preparation.CandidateOrientation);
|
||||
return new ProjectileAdvanceResult(preparation.CellId, 1, true, false, default, true);
|
||||
}
|
||||
|
||||
uint cellId = preparation.CellId;
|
||||
ObjectInfoState moverFlags = body.State.HasFlag(PhysicsStateFlags.PathClipped)
|
||||
? ObjectInfoState.PathClipped
|
||||
: ObjectInfoState.None;
|
||||
var resolved = _physics.ResolveWithTransition(
|
||||
beginPosition,
|
||||
candidatePosition,
|
||||
preparation.BeginPosition,
|
||||
preparation.CandidatePosition,
|
||||
cellId,
|
||||
sphereRadius: sphere.Radius,
|
||||
sphereHeight: 0f,
|
||||
stepUpHeight: PhysicsGlobals.DefaultStepHeight,
|
||||
stepDownHeight: 0f,
|
||||
isOnGround: previousOnWalkable,
|
||||
isOnGround: preparation.PreviousOnWalkable,
|
||||
body: body,
|
||||
moverFlags: moverFlags,
|
||||
movingEntityId: movingEntityId,
|
||||
localSphereOrigin: sphere.LocalOrigin,
|
||||
beginOrientation: beginOrientation,
|
||||
endOrientation: candidateOrientation,
|
||||
beginOrientation: preparation.BeginOrientation,
|
||||
endOrientation: preparation.CandidateOrientation,
|
||||
designatedTargetId: designatedTargetId);
|
||||
|
||||
bool collisionNormalValid = false;
|
||||
Vector3 collisionNormal = default;
|
||||
bool transitionOk = resolved.Ok;
|
||||
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.SetFrameInCurrentCell(
|
||||
preparation.CandidatePosition,
|
||||
preparation.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)
|
||||
else
|
||||
{
|
||||
collisionNormalValid = true;
|
||||
collisionNormal = resolved.CollisionNormal;
|
||||
body.CachedVelocity = (resolved.Position - preparation.BeginPosition)
|
||||
/ preparation.Quantum;
|
||||
body.Position = resolved.Position;
|
||||
body.Orientation = resolved.Orientation == default
|
||||
? preparation.CandidateOrientation
|
||||
: resolved.Orientation;
|
||||
if (resolved.CellId != 0)
|
||||
cellId = resolved.CellId;
|
||||
|
||||
PhysicsObjUpdate.ApplySetPositionContact(
|
||||
body,
|
||||
resolved.InContact,
|
||||
resolved.OnWalkable);
|
||||
PhysicsObjUpdate.HandleAllCollisions(
|
||||
body,
|
||||
resolved.CollisionNormalValid,
|
||||
resolved.CollisionNormal,
|
||||
preparation.PreviousContact,
|
||||
preparation.PreviousOnWalkable,
|
||||
nowOnWalkable: body.OnWalkable);
|
||||
if (resolved.CollisionNormalValid)
|
||||
{
|
||||
collisionNormalValid = true;
|
||||
collisionNormal = resolved.CollisionNormal;
|
||||
}
|
||||
}
|
||||
|
||||
body.LastUpdateTime += preparation.Quantum;
|
||||
return new ProjectileAdvanceResult(
|
||||
cellId,
|
||||
1,
|
||||
true,
|
||||
collisionNormalValid,
|
||||
collisionNormal,
|
||||
transitionOk);
|
||||
}
|
||||
|
||||
private static ProjectileQuantumPreparation NotSimulated(
|
||||
uint cellId,
|
||||
float quantum) => new(
|
||||
CellId: cellId,
|
||||
Quantum: quantum,
|
||||
Simulated: false,
|
||||
RequiresTransition: false,
|
||||
BeginPosition: default,
|
||||
BeginOrientation: default,
|
||||
BeginCellPosition: default,
|
||||
BeginInWorld: false,
|
||||
BeginDynamics: default,
|
||||
CandidatePosition: default,
|
||||
CandidateOrientation: default,
|
||||
CandidateDynamics: default,
|
||||
PreviousContact: false,
|
||||
PreviousOnWalkable: false);
|
||||
|
||||
private static ProjectileQuantumDynamics CaptureDynamics(PhysicsBody body) => new(
|
||||
body.Velocity,
|
||||
body.CachedVelocity,
|
||||
body.Acceleration,
|
||||
body.Omega,
|
||||
body.TransientState,
|
||||
body.LastUpdateTime);
|
||||
|
||||
private static void ApplyDynamics(
|
||||
PhysicsBody body,
|
||||
in ProjectileQuantumDynamics dynamics)
|
||||
{
|
||||
body.Velocity = dynamics.Velocity;
|
||||
body.CachedVelocity = dynamics.CachedVelocity;
|
||||
body.Acceleration = dynamics.Acceleration;
|
||||
body.Omega = dynamics.Omega;
|
||||
body.TransientState = dynamics.TransientState;
|
||||
body.LastUpdateTime = dynamics.LastUpdateTime;
|
||||
}
|
||||
|
||||
private static void RestoreBeginFrame(
|
||||
PhysicsBody body,
|
||||
Vector3 beginPosition,
|
||||
Quaternion beginOrientation,
|
||||
in Position beginCellPosition,
|
||||
bool beginInWorld)
|
||||
{
|
||||
body.Orientation = beginOrientation;
|
||||
if (beginCellPosition.ObjCellId != 0)
|
||||
{
|
||||
body.SnapToCell(
|
||||
beginCellPosition.ObjCellId,
|
||||
beginPosition,
|
||||
beginCellPosition.Frame.Origin);
|
||||
}
|
||||
else
|
||||
{
|
||||
body.SetFrameInCurrentCell(beginPosition, beginOrientation);
|
||||
}
|
||||
body.InWorld = beginInWorld;
|
||||
}
|
||||
|
||||
private void StepQuantum(
|
||||
PhysicsBody body,
|
||||
float dt,
|
||||
ref uint cellId,
|
||||
ProjectileCollisionSphere sphere,
|
||||
uint movingEntityId,
|
||||
uint designatedTargetId,
|
||||
ref bool collisionNormalValid,
|
||||
ref Vector3 collisionNormal,
|
||||
ref bool transitionOk)
|
||||
{
|
||||
ProjectileQuantumPreparation preparation = BeginQuantum(
|
||||
body,
|
||||
dt,
|
||||
cellId,
|
||||
sphere);
|
||||
ProjectileAdvanceResult result = CompleteQuantum(
|
||||
body,
|
||||
preparation,
|
||||
sphere,
|
||||
movingEntityId,
|
||||
designatedTargetId);
|
||||
if (result.CellId != 0)
|
||||
cellId = result.CellId;
|
||||
collisionNormalValid |= result.CollisionNormalValid;
|
||||
if (result.CollisionNormalValid)
|
||||
collisionNormal = result.CollisionNormal;
|
||||
transitionOk &= result.TransitionOk;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
|
|
@ -7,10 +8,10 @@ namespace AcDream.Core.Physics;
|
|||
/// by <c>CSequence::update</c> + InterpolationManager catch-up correction.
|
||||
/// Pure function — no side effects or hidden state.
|
||||
///
|
||||
/// Mirrors retail CPhysicsObj::UpdateObjectInternal (acclient @ 0x00513730):
|
||||
/// rootOffset = CPartArray::Update(dt) // animation
|
||||
/// PositionManager::adjust_offset(rootOffset) // adds correction
|
||||
/// frame.origin += rootOffset
|
||||
/// Mirrors retail <c>CPhysicsObj::UpdatePositionInternal</c> (0x00512C30):
|
||||
/// CPartArray writes one complete local Frame, then PositionManager mutates
|
||||
/// that same Frame. Active interpolation replaces it with
|
||||
/// <c>Position::subtract2</c>; later managers receive the result.
|
||||
///
|
||||
/// The animation root motion is the complete body-local <c>Frame.Origin</c>
|
||||
/// accumulated by <c>CPartArray::Update</c>: authored PosFrames plus the
|
||||
|
|
@ -30,6 +31,50 @@ namespace AcDream.Core.Physics;
|
|||
/// </summary>
|
||||
public sealed class RemoteMotionCombiner
|
||||
{
|
||||
/// <summary>
|
||||
/// Compose retail's complete per-object delta frame. Interpolation, when
|
||||
/// active, replaces the PartArray frame via
|
||||
/// <c>Position::subtract2</c>; otherwise the authored root frame remains.
|
||||
/// </summary>
|
||||
/// <returns><c>true</c> when interpolation replaced the root frame.</returns>
|
||||
public bool ComposeOffset(
|
||||
double dt,
|
||||
Vector3 currentBodyPosition,
|
||||
Quaternion ori,
|
||||
MotionDeltaFrame rootMotionLocalFrame,
|
||||
InterpolationManager interp,
|
||||
float maxSpeed,
|
||||
MotionDeltaFrame output,
|
||||
Vector3? terrainNormal = null,
|
||||
bool inContact = true)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rootMotionLocalFrame);
|
||||
ArgumentNullException.ThrowIfNull(interp);
|
||||
ArgumentNullException.ThrowIfNull(output);
|
||||
|
||||
output.Origin = rootMotionLocalFrame.Origin;
|
||||
output.Orientation = rootMotionLocalFrame.Orientation;
|
||||
bool interpolationOverwrote = interp.AdjustOffset(
|
||||
dt,
|
||||
currentBodyPosition,
|
||||
ori,
|
||||
maxSpeed,
|
||||
output,
|
||||
inContact);
|
||||
|
||||
if (!interpolationOverwrote
|
||||
&& terrainNormal.HasValue
|
||||
&& terrainNormal.Value.Z > 0.01f)
|
||||
{
|
||||
Vector3 rootMotionWorld = Vector3.Transform(output.Origin, ori);
|
||||
Vector3 normal = terrainNormal.Value;
|
||||
rootMotionWorld -= normal * Vector3.Dot(rootMotionWorld, normal);
|
||||
output.Origin = MoveToMath.GlobalToLocalVec(ori, rootMotionWorld);
|
||||
}
|
||||
|
||||
return interpolationOverwrote;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compute the per-frame world-space delta to add to body.Position.
|
||||
/// </summary>
|
||||
|
|
@ -90,11 +135,21 @@ public sealed class RemoteMotionCombiner
|
|||
// AdjustOffset returns Vector3.Zero in two cases mapped to retail's
|
||||
// early-return: empty queue OR distance < DesiredDistance (0.05m).
|
||||
// In both, body falls back to animation root motion.
|
||||
Vector3 correction = interp.AdjustOffset(dt, currentBodyPosition, maxSpeed);
|
||||
if (correction.LengthSquared() > 0f)
|
||||
return correction;
|
||||
|
||||
Vector3 rootMotionWorld = Vector3.Transform(rootMotionLocalDelta, ori);
|
||||
var root = new MotionDeltaFrame
|
||||
{
|
||||
Origin = rootMotionLocalDelta,
|
||||
};
|
||||
var output = new MotionDeltaFrame();
|
||||
ComposeOffset(
|
||||
dt,
|
||||
currentBodyPosition,
|
||||
ori,
|
||||
root,
|
||||
interp,
|
||||
maxSpeed,
|
||||
output,
|
||||
terrainNormal: null);
|
||||
Vector3 rootMotionWorld = Vector3.Transform(output.Origin, ori);
|
||||
|
||||
// Slope projection (queue-empty fallback only). Locomotion cycles
|
||||
// bake Z=0 in body-local, so without projection the body's Z stays
|
||||
|
|
|
|||
101
src/AcDream.Core/Physics/RetailObjectActivityGate.cs
Normal file
101
src/AcDream.Core/Physics/RetailObjectActivityGate.cs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Ports the lifecycle and 96-world-unit Active gate at the head of retail
|
||||
/// <c>CPhysicsObj::update_object</c> (0x00515D10) plus the inactive branch of
|
||||
/// <c>UpdateObjectInternal</c> (0x005156B0).
|
||||
/// </summary>
|
||||
public static class RetailObjectActivityGate
|
||||
{
|
||||
public const float MaxPhysicsDistance = 96f;
|
||||
|
||||
public static RetailObjectActivityResult Evaluate(
|
||||
RetailObjectQuantumClock clock,
|
||||
PhysicsBody? body,
|
||||
bool lifecycleEligible,
|
||||
bool hasPartArray,
|
||||
bool isStatic,
|
||||
Vector3 objectPosition,
|
||||
Vector3? playerPosition,
|
||||
double elapsedSeconds)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(clock);
|
||||
|
||||
if (!lifecycleEligible)
|
||||
{
|
||||
SetActive(clock, body, active: false);
|
||||
return RetailObjectActivityResult.Suspended;
|
||||
}
|
||||
|
||||
// Retail performs both the 96-unit decision and set_active(1) only
|
||||
// while player_object exists. During login/session transitions a
|
||||
// missing player preserves the prior Active state; it must not wake a
|
||||
// previously distant object.
|
||||
if (playerPosition is null)
|
||||
{
|
||||
if (clock.IsActive)
|
||||
return RetailObjectActivityResult.Active;
|
||||
clock.Advance(elapsedSeconds);
|
||||
return RetailObjectActivityResult.Inactive;
|
||||
}
|
||||
|
||||
bool withinActiveBubble = !hasPartArray
|
||||
|| Vector3.Distance(objectPosition, playerPosition.Value)
|
||||
<= MaxPhysicsDistance;
|
||||
if (!withinActiveBubble)
|
||||
{
|
||||
SetActive(clock, body, active: false);
|
||||
// Inactive ordinary objects still consume update_time and run
|
||||
// their particle/script tail. Those owners tick elsewhere in
|
||||
// acdream; consuming the batch here prevents later catch-up.
|
||||
clock.Advance(elapsedSeconds);
|
||||
return RetailObjectActivityResult.Inactive;
|
||||
}
|
||||
|
||||
// CPhysicsObj::set_active(1) (0x0050FC40) is a no-op for Static.
|
||||
// Static objects that were initialized or removed from visibility
|
||||
// inactive therefore stay on UpdateObjectInternal's particle/script
|
||||
// tail instead of entering root physics.
|
||||
bool reactivated = false;
|
||||
if (!isStatic)
|
||||
{
|
||||
reactivated = clock.Activate();
|
||||
if (body is not null)
|
||||
body.TransientState |= TransientStateFlags.Active;
|
||||
}
|
||||
|
||||
if (!clock.IsActive)
|
||||
{
|
||||
clock.Advance(elapsedSeconds);
|
||||
return RetailObjectActivityResult.Inactive;
|
||||
}
|
||||
|
||||
return reactivated
|
||||
? RetailObjectActivityResult.Reactivated
|
||||
: RetailObjectActivityResult.Active;
|
||||
}
|
||||
|
||||
private static void SetActive(
|
||||
RetailObjectQuantumClock clock,
|
||||
PhysicsBody? body,
|
||||
bool active)
|
||||
{
|
||||
if (active)
|
||||
clock.Activate();
|
||||
else
|
||||
clock.Deactivate();
|
||||
|
||||
if (body is not null && !active)
|
||||
body.TransientState &= ~TransientStateFlags.Active;
|
||||
}
|
||||
}
|
||||
|
||||
public enum RetailObjectActivityResult
|
||||
{
|
||||
Suspended,
|
||||
Inactive,
|
||||
Reactivated,
|
||||
Active,
|
||||
}
|
||||
|
|
@ -12,6 +12,40 @@ namespace AcDream.Core.Physics;
|
|||
/// </remarks>
|
||||
public static class RetailObjectManagerTail
|
||||
{
|
||||
/// <summary>
|
||||
/// Allocation-free production overload for the concrete retail manager
|
||||
/// owners. DetectionManager is not ported, so its ordered slot is empty.
|
||||
/// </summary>
|
||||
public static void Run(
|
||||
Motion.TargetManager? target,
|
||||
Motion.MovementManager? movement,
|
||||
Motion.MotionTableManager? partArray,
|
||||
Motion.PositionManager? position)
|
||||
{
|
||||
target?.HandleTargetting();
|
||||
movement?.UseTime();
|
||||
partArray?.UseTime();
|
||||
position?.UseTime();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Allocation-free local-player overload. Its target action and PartArray
|
||||
/// completion action are cached ownership seams; the other managers are
|
||||
/// passed as concrete objects rather than allocating bound delegates per
|
||||
/// quantum.
|
||||
/// </summary>
|
||||
public static void Run(
|
||||
Action? handleTargeting,
|
||||
Motion.MovementManager? movement,
|
||||
Action? partArrayHandleMovement,
|
||||
Motion.PositionManager? position)
|
||||
{
|
||||
handleTargeting?.Invoke();
|
||||
movement?.UseTime();
|
||||
partArrayHandleMovement?.Invoke();
|
||||
position?.UseTime();
|
||||
}
|
||||
|
||||
public static void Run(
|
||||
Action? checkDetection,
|
||||
Action? handleTargeting,
|
||||
|
|
|
|||
122
src/AcDream.Core/Physics/RetailObjectQuantumClock.cs
Normal file
122
src/AcDream.Core/Physics/RetailObjectQuantumClock.cs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
using System;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Allocation-free port of <c>CPhysicsObj::update_object</c>
|
||||
/// (0x00515D10). One clock belongs to one logical physics object. It retains
|
||||
/// sub-minimum elapsed time, subdivides long frames into complete object
|
||||
/// updates, and discards stale gaps greater than retail's huge quantum.
|
||||
/// </summary>
|
||||
public sealed class RetailObjectQuantumClock
|
||||
{
|
||||
private double _pending;
|
||||
|
||||
public double PendingSeconds => _pending;
|
||||
public bool IsActive { get; private set; } = true;
|
||||
|
||||
public RetailObjectQuantumBatch Advance(double elapsedSeconds)
|
||||
{
|
||||
if (double.IsNaN(elapsedSeconds) || elapsedSeconds < 0.0)
|
||||
{
|
||||
_pending = 0.0;
|
||||
return new RetailObjectQuantumBatch(0, 0f, Discarded: true);
|
||||
}
|
||||
|
||||
double elapsed = _pending + elapsedSeconds;
|
||||
if (elapsed <= FrameEpsilon)
|
||||
{
|
||||
_pending = 0.0;
|
||||
return default;
|
||||
}
|
||||
|
||||
if (elapsed > PhysicsBody.HugeQuantum)
|
||||
{
|
||||
_pending = 0.0;
|
||||
return new RetailObjectQuantumBatch(0, 0f, Discarded: true);
|
||||
}
|
||||
|
||||
int fullSteps = 0;
|
||||
while (elapsed > PhysicsBody.MaxQuantum)
|
||||
{
|
||||
fullSteps++;
|
||||
elapsed -= PhysicsBody.MaxQuantum;
|
||||
}
|
||||
|
||||
float remainder = 0f;
|
||||
if (elapsed > PhysicsBody.MinQuantum)
|
||||
{
|
||||
remainder = (float)elapsed;
|
||||
elapsed = 0.0;
|
||||
}
|
||||
|
||||
_pending = elapsed;
|
||||
return new RetailObjectQuantumBatch(fullSteps, remainder, Discarded: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>set_active(0)</c>: suppress the full object path without
|
||||
/// advancing or rebasing <c>update_time</c>.
|
||||
/// </summary>
|
||||
public void Deactivate() => IsActive = false;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>set_active(1)</c>. An inactive-to-active edge rebases
|
||||
/// <c>update_time</c> to the current timer, so the reactivation frame does
|
||||
/// not catch up suppressed time.
|
||||
/// </summary>
|
||||
/// <returns>True only when this call performed the reactivation edge.</returns>
|
||||
public bool Activate()
|
||||
{
|
||||
if (IsActive)
|
||||
return false;
|
||||
IsActive = true;
|
||||
_pending = 0.0;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Reset() => _pending = 0.0;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CPhysicsObj::prepare_to_enter_world</c> (0x00511FA0): rebase
|
||||
/// <c>update_time</c> to the current timer and immediately set Active when
|
||||
/// the object is not Static. A Static object retains its prior Active bit;
|
||||
/// normal initial entry and re-entry arrive here inactive. The next elapsed
|
||||
/// frame of a non-Static object is therefore eligible for ordinary object-
|
||||
/// quantum admission; there is no second activation frame to discard.
|
||||
/// </summary>
|
||||
public void ResetForEnterWorld(bool isStatic = false)
|
||||
{
|
||||
_pending = 0.0;
|
||||
if (!isStatic)
|
||||
IsActive = true;
|
||||
}
|
||||
|
||||
private const double FrameEpsilon = 0.000199999995;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How the owning live-object lifecycle treats this render frame before
|
||||
/// entering retail <c>CPhysicsObj::update_object</c>.
|
||||
/// </summary>
|
||||
public enum RetailObjectClockDisposition
|
||||
{
|
||||
Advance,
|
||||
Suspend,
|
||||
}
|
||||
|
||||
/// <summary>Compact result of one retail object-clock admission pass.</summary>
|
||||
public readonly record struct RetailObjectQuantumBatch(
|
||||
int FullSteps,
|
||||
float Remainder,
|
||||
bool Discarded)
|
||||
{
|
||||
public int Count => FullSteps + (Remainder > 0f ? 1 : 0);
|
||||
|
||||
public float GetQuantum(int index)
|
||||
{
|
||||
if ((uint)index >= (uint)Count)
|
||||
throw new ArgumentOutOfRangeException(nameof(index));
|
||||
return index < FullSteps ? PhysicsBody.MaxQuantum : Remainder;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue