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
|
|
@ -8,6 +8,8 @@ namespace AcDream.Core.Tests.Input;
|
|||
|
||||
public class PlayerMovementControllerTests
|
||||
{
|
||||
private static float ObjectTick => PhysicsBody.MinQuantum + 0.001f;
|
||||
|
||||
private static PhysicsEngine MakeFlatEngine()
|
||||
{
|
||||
var engine = new PhysicsEngine();
|
||||
|
|
@ -21,6 +23,29 @@ public class PlayerMovementControllerTests
|
|||
return engine;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerformMovement_ReactivatesCanonicalClock_ExceptForStaticObject()
|
||||
{
|
||||
var clock = new RetailObjectQuantumClock();
|
||||
var controller = new PlayerMovementController(MakeFlatEngine(), clock);
|
||||
clock.Deactivate();
|
||||
controller.ApplyPhysicsState(PhysicsStateFlags.None);
|
||||
|
||||
controller.Movement.PerformMovement(new MovementStruct
|
||||
{
|
||||
Type = (MovementType)99,
|
||||
});
|
||||
Assert.True(clock.IsActive);
|
||||
|
||||
clock.Deactivate();
|
||||
controller.ApplyPhysicsState(PhysicsStateFlags.Static);
|
||||
controller.Movement.PerformMovement(new MovementStruct
|
||||
{
|
||||
Type = (MovementType)99,
|
||||
});
|
||||
Assert.False(clock.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_NoInput_PositionUnchanged()
|
||||
{
|
||||
|
|
@ -156,10 +181,10 @@ public class PlayerMovementControllerTests
|
|||
var start = new Vector3(96f, 96f, 50f);
|
||||
controller.SetPosition(start, 0x0001);
|
||||
controller.Yaw = 0f;
|
||||
controller.AttachAnimationRootMotionSource(_ => Vector3.Zero);
|
||||
controller.AttachAnimationRootMotionSource((_, _) => { });
|
||||
|
||||
MovementResult result = controller.Update(
|
||||
PhysicsBody.MinQuantum,
|
||||
ObjectTick,
|
||||
new MovementInput(Forward: true));
|
||||
|
||||
Assert.Equal(start, result.Position);
|
||||
|
|
@ -175,10 +200,11 @@ public class PlayerMovementControllerTests
|
|||
controller.SetPosition(start, 0x0001);
|
||||
controller.Yaw = 0f;
|
||||
controller.ObjectScale = 2f;
|
||||
controller.AttachAnimationRootMotionSource(_ => new Vector3(0f, 0.1f, 0f));
|
||||
controller.AttachAnimationRootMotionSource((_, frame) =>
|
||||
frame.Origin = new Vector3(0f, 0.1f, 0f));
|
||||
|
||||
MovementResult result = controller.Update(
|
||||
PhysicsBody.MinQuantum,
|
||||
ObjectTick,
|
||||
new MovementInput(Forward: true));
|
||||
|
||||
// Local +Y is forward. Yaw 0 maps it to world +X; m_scale doubles
|
||||
|
|
@ -188,24 +214,313 @@ public class PlayerMovementControllerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_SubQuantumAnimationRootDeltas_AccumulateIntoOnePhysicsStep()
|
||||
public void Update_SubQuantumFrames_AdvanceAnimationOnceAtObjectThreshold()
|
||||
{
|
||||
var controller = new PlayerMovementController(MakeFlatEngine());
|
||||
var start = new Vector3(96f, 96f, 50f);
|
||||
controller.SetPosition(start, 0x0001);
|
||||
controller.Yaw = 0f;
|
||||
controller.AttachAnimationRootMotionSource(_ => new Vector3(0f, 0.05f, 0f));
|
||||
int advances = 0;
|
||||
controller.AttachAnimationRootMotionSource((_, frame) =>
|
||||
{
|
||||
advances++;
|
||||
frame.Origin = new Vector3(0f, 0.1f, 0f);
|
||||
});
|
||||
|
||||
MovementResult first = controller.Update(
|
||||
PhysicsBody.MinQuantum * 0.5f,
|
||||
ObjectTick * 0.5f,
|
||||
new MovementInput(Forward: true));
|
||||
MovementResult second = controller.Update(
|
||||
PhysicsBody.MinQuantum * 0.5f,
|
||||
ObjectTick * 0.5f,
|
||||
new MovementInput(Forward: true));
|
||||
|
||||
Assert.Equal(start, first.Position);
|
||||
Assert.Equal(start.X + 0.1f, second.Position.X, precision: 3);
|
||||
Assert.Equal(start.Y, second.Position.Y, precision: 3);
|
||||
Assert.Equal(1, advances);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_AttachedAnimationFrame_ComposesTranslationBeforeTurn()
|
||||
{
|
||||
var controller = new PlayerMovementController(MakeFlatEngine());
|
||||
var start = new Vector3(96f, 96f, 50f);
|
||||
controller.SetPosition(start, 0x0001);
|
||||
controller.Yaw = 0f; // body local +Y faces world +X
|
||||
controller.AttachAnimationRootMotionSource((_, frame) =>
|
||||
{
|
||||
frame.Origin = new Vector3(0f, 0.1f, 0f);
|
||||
frame.Orientation = Quaternion.CreateFromAxisAngle(
|
||||
Vector3.UnitZ,
|
||||
MathF.PI / 2f);
|
||||
});
|
||||
|
||||
MovementResult result = controller.Update(
|
||||
ObjectTick,
|
||||
new MovementInput());
|
||||
|
||||
// Retail Frame::combine transforms the delta origin by the OLD body
|
||||
// orientation, then composes the delta orientation. The body therefore
|
||||
// moves east before finishing the quarter-turn to north.
|
||||
Assert.Equal(start.X + 0.1f, result.Position.X, precision: 3);
|
||||
Assert.Equal(start.Y, result.Position.Y, precision: 3);
|
||||
Assert.Equal(MathF.PI / 2f, controller.Yaw, precision: 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_AttachedAnimationFrame_PreservesCompleteNonCommutingOrientation()
|
||||
{
|
||||
var controller = new PlayerMovementController(MakeFlatEngine());
|
||||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
||||
Quaternion initial = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 1.1f);
|
||||
Quaternion delta = Quaternion.CreateFromAxisAngle(Vector3.UnitY, -0.9f);
|
||||
controller.SetBodyOrientation(initial);
|
||||
controller.AttachAnimationRootMotionSource((_, frame) =>
|
||||
frame.Orientation = delta);
|
||||
|
||||
controller.Update(ObjectTick, new MovementInput());
|
||||
|
||||
Quaternion expected = Quaternion.Normalize(initial * delta);
|
||||
float alignment = MathF.Abs(Quaternion.Dot(
|
||||
expected,
|
||||
Quaternion.Normalize(controller.BodyOrientation)));
|
||||
Assert.InRange(alignment, 0.99999f, 1.00001f);
|
||||
Assert.True(MathF.Abs(Quaternion.Dot(
|
||||
Quaternion.Normalize(delta * initial),
|
||||
Quaternion.Normalize(controller.BodyOrientation))) < 0.999f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_MultiQuantumAnimationFrames_ComposeInTemporalOrder()
|
||||
{
|
||||
var controller = new PlayerMovementController(MakeFlatEngine());
|
||||
var start = new Vector3(96f, 96f, 50f);
|
||||
controller.SetPosition(start, 0x0001);
|
||||
controller.Yaw = 0f;
|
||||
int sample = 0;
|
||||
controller.AttachAnimationRootMotionSource((_, frame) =>
|
||||
{
|
||||
if (sample++ == 0)
|
||||
{
|
||||
frame.Orientation = Quaternion.CreateFromAxisAngle(
|
||||
Vector3.UnitZ,
|
||||
MathF.PI / 2f);
|
||||
}
|
||||
else
|
||||
{
|
||||
frame.Origin = new Vector3(0f, 0.1f, 0f);
|
||||
}
|
||||
});
|
||||
|
||||
MovementResult result = controller.Update(
|
||||
PhysicsBody.MaxQuantum * 2f,
|
||||
new MovementInput());
|
||||
|
||||
// The second local-forward displacement occurs after the first
|
||||
// quarter-turn, so it moves north. Adding Origins as bare vectors
|
||||
// would incorrectly move east.
|
||||
Assert.Equal(start.X, result.Position.X, precision: 3);
|
||||
Assert.Equal(start.Y + 0.1f, result.Position.Y, precision: 3);
|
||||
Assert.Equal(MathF.PI / 2f, controller.Yaw, precision: 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_ExactMinQuantum_RetainsTimeWithoutAdvancingObject()
|
||||
{
|
||||
var controller = new PlayerMovementController(MakeFlatEngine());
|
||||
var start = new Vector3(96f, 96f, 50f);
|
||||
controller.SetPosition(start, 0x0001);
|
||||
int advances = 0;
|
||||
controller.AttachAnimationRootMotionSource((_, frame) =>
|
||||
{
|
||||
advances++;
|
||||
frame.Origin = new Vector3(0f, 1f, 0f);
|
||||
});
|
||||
|
||||
MovementResult atThreshold = controller.Update(
|
||||
PhysicsBody.MinQuantum,
|
||||
new MovementInput());
|
||||
|
||||
Assert.Equal(start, atThreshold.Position);
|
||||
Assert.Equal(0, advances);
|
||||
|
||||
controller.Update(0.001f, new MovementInput());
|
||||
Assert.Equal(1, advances);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_LargeFrame_MatchesSeparateRetailObjectQuanta()
|
||||
{
|
||||
var combined = new PlayerMovementController(MakeFlatEngine());
|
||||
var split = new PlayerMovementController(MakeFlatEngine());
|
||||
var start = new Vector3(96f, 96f, 50f);
|
||||
combined.SetPosition(start, 0x0001);
|
||||
split.SetPosition(start, 0x0001);
|
||||
int combinedHooks = 0;
|
||||
int splitHooks = 0;
|
||||
|
||||
static void Advance(float dt, AcDream.Core.Physics.Motion.MotionDeltaFrame frame)
|
||||
{
|
||||
frame.Origin = new Vector3(0f, dt * 2f, 0f);
|
||||
frame.Orientation = Quaternion.CreateFromAxisAngle(
|
||||
Vector3.UnitZ,
|
||||
dt * 0.7f);
|
||||
}
|
||||
|
||||
combined.AttachAnimationRootMotionSource(Advance, () => combinedHooks++);
|
||||
split.AttachAnimationRootMotionSource(Advance, () => splitHooks++);
|
||||
|
||||
combined.Update(PhysicsBody.MaxQuantum * 2f, new MovementInput());
|
||||
split.Update(PhysicsBody.MaxQuantum, new MovementInput());
|
||||
split.Update(PhysicsBody.MaxQuantum, new MovementInput());
|
||||
|
||||
Assert.Equal(split.Position.X, combined.Position.X, precision: 5);
|
||||
Assert.Equal(split.Position.Y, combined.Position.Y, precision: 5);
|
||||
Assert.Equal(split.Position.Z, combined.Position.Z, precision: 5);
|
||||
float alignment = MathF.Abs(Quaternion.Dot(
|
||||
Quaternion.Normalize(split.BodyOrientation),
|
||||
Quaternion.Normalize(combined.BodyOrientation)));
|
||||
Assert.InRange(alignment, 0.99999f, 1.00001f);
|
||||
Assert.Equal(2, combinedHooks);
|
||||
Assert.Equal(2, splitHooks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TickHidden_DoesNotAdvancePartArrayButStillProcessesHooks()
|
||||
{
|
||||
var controller = new PlayerMovementController(MakeFlatEngine());
|
||||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
||||
int advances = 0;
|
||||
int hookPasses = 0;
|
||||
controller.AttachAnimationRootMotionSource(
|
||||
(_, frame) =>
|
||||
{
|
||||
advances++;
|
||||
frame.Origin = new Vector3(0f, 1f, 0f);
|
||||
},
|
||||
() => hookPasses++);
|
||||
|
||||
controller.TickHidden(ObjectTick);
|
||||
|
||||
Assert.Equal(0, advances);
|
||||
Assert.Equal(1, hookPasses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidElapsed_VisibleFrameIsPurePresentationRead()
|
||||
{
|
||||
var controller = new PlayerMovementController(MakeFlatEngine());
|
||||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
||||
float initialTime = controller.SimTimeSeconds;
|
||||
float initialYaw = controller.Yaw;
|
||||
Vector3 initialPosition = controller.Position;
|
||||
RawMotionState initialMotion = controller.Motion.RawState;
|
||||
var hostileInput = new MovementInput(
|
||||
Forward: true,
|
||||
TurnLeft: true,
|
||||
Jump: true,
|
||||
Run: true);
|
||||
|
||||
foreach (float elapsed in new[]
|
||||
{
|
||||
float.NaN,
|
||||
float.PositiveInfinity,
|
||||
float.NegativeInfinity,
|
||||
-0.1f,
|
||||
0f,
|
||||
})
|
||||
{
|
||||
MovementResult result = controller.Update(elapsed, hostileInput);
|
||||
Assert.False(result.ShouldSendMovementEvent);
|
||||
}
|
||||
|
||||
Assert.Equal(initialTime, controller.SimTimeSeconds);
|
||||
Assert.Equal(initialYaw, controller.Yaw);
|
||||
Assert.Equal(initialPosition, controller.Position);
|
||||
Assert.Equal(initialMotion, controller.Motion.RawState);
|
||||
|
||||
controller.Update(ObjectTick, new MovementInput());
|
||||
controller.Update(ObjectTick, new MovementInput());
|
||||
Assert.True(controller.AdvancedObjectQuantumLastTick);
|
||||
controller.Update(float.NaN, hostileInput);
|
||||
Assert.False(controller.AdvancedObjectQuantumLastTick);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidElapsed_HiddenFrameDoesNotAdvanceClockOrManagerTail()
|
||||
{
|
||||
var controller = new PlayerMovementController(MakeFlatEngine());
|
||||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
||||
float initialTime = controller.SimTimeSeconds;
|
||||
int targetPasses = 0;
|
||||
|
||||
controller.TickHidden(float.NaN, () => targetPasses++);
|
||||
controller.TickHidden(float.PositiveInfinity, () => targetPasses++);
|
||||
controller.TickHidden(-0.1f, () => targetPasses++);
|
||||
|
||||
Assert.Equal(initialTime, controller.SimTimeSeconds);
|
||||
Assert.Equal(0, targetPasses);
|
||||
Assert.False(controller.AdvancedObjectQuantumLastTick);
|
||||
|
||||
controller.TickHidden(ObjectTick);
|
||||
controller.TickHidden(ObjectTick);
|
||||
Assert.True(controller.AdvancedObjectQuantumLastTick);
|
||||
controller.TickHidden(float.PositiveInfinity);
|
||||
Assert.False(controller.AdvancedObjectQuantumLastTick);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_AirbornePartArrayFrame_SuppressesOriginButPreservesOrientation()
|
||||
{
|
||||
var controller = new PlayerMovementController(MakeFlatEngine());
|
||||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
||||
controller.Update(1f, new MovementInput(Jump: true));
|
||||
|
||||
Vector3 beforeRelease = controller.Position;
|
||||
Quaternion beforeOrientation = controller.BodyOrientation;
|
||||
Quaternion delta = Quaternion.CreateFromAxisAngle(Vector3.UnitY, 0.4f);
|
||||
int advances = 0;
|
||||
controller.AttachAnimationRootMotionSource((_, frame) =>
|
||||
{
|
||||
advances++;
|
||||
frame.Origin = new Vector3(0f, 10f, 0f);
|
||||
frame.Orientation = delta;
|
||||
});
|
||||
|
||||
controller.Update(ObjectTick, new MovementInput(Jump: false));
|
||||
|
||||
Assert.True(controller.IsAirborne);
|
||||
Assert.Equal(beforeRelease.X, controller.Position.X, precision: 5);
|
||||
Assert.Equal(beforeRelease.Y, controller.Position.Y, precision: 5);
|
||||
Quaternion expected = Quaternion.Normalize(beforeOrientation * delta);
|
||||
float alignment = MathF.Abs(Quaternion.Dot(
|
||||
expected,
|
||||
Quaternion.Normalize(controller.BodyOrientation)));
|
||||
Assert.InRange(alignment, 0.99999f, 1.00001f);
|
||||
Assert.Equal(1, advances);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_AttachedAnimationTurn_IsNotAppliedByASecondYawIntegrator()
|
||||
{
|
||||
var controller = new PlayerMovementController(MakeFlatEngine());
|
||||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
||||
controller.Yaw = 0f;
|
||||
controller.AttachAnimationRootMotionSource((dt, frame) =>
|
||||
{
|
||||
frame.Orientation = Quaternion.CreateFromAxisAngle(
|
||||
Vector3.UnitZ,
|
||||
-(MathF.PI / 2f) * dt);
|
||||
});
|
||||
|
||||
controller.Update(
|
||||
ObjectTick,
|
||||
new MovementInput(TurnRight: true));
|
||||
|
||||
Assert.Equal(
|
||||
-(MathF.PI / 2f) * ObjectTick,
|
||||
controller.Yaw,
|
||||
precision: 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -217,7 +532,7 @@ public class PlayerMovementControllerTests
|
|||
controller.SetPosition(start, 0x0001);
|
||||
controller.Yaw = 0f;
|
||||
|
||||
var firstTick = controller.Update(PhysicsBody.MinQuantum, new MovementInput(Forward: true));
|
||||
var firstTick = controller.Update(ObjectTick, new MovementInput(Forward: true));
|
||||
Assert.True(firstTick.Position.X > start.X, "Physics tick should advance the authoritative body position");
|
||||
Assert.Equal(start.X, firstTick.RenderPosition.X, precision: 4);
|
||||
|
||||
|
|
@ -240,7 +555,7 @@ public class PlayerMovementControllerTests
|
|||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
||||
controller.Yaw = 0f;
|
||||
|
||||
controller.Update(PhysicsBody.MinQuantum, new MovementInput(Forward: true));
|
||||
controller.Update(ObjectTick, new MovementInput(Forward: true));
|
||||
controller.Update(PhysicsBody.MinQuantum * 0.5f, new MovementInput(Forward: true));
|
||||
|
||||
var snapped = new Vector3(120f, 80f, 50f);
|
||||
|
|
@ -257,7 +572,7 @@ public class PlayerMovementControllerTests
|
|||
var controller = new PlayerMovementController(MakeFlatEngine());
|
||||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
||||
controller.Yaw = 0f;
|
||||
controller.Update(PhysicsBody.MinQuantum, new MovementInput(Forward: true));
|
||||
controller.Update(ObjectTick, new MovementInput(Forward: true));
|
||||
Vector3 velocity = controller.BodyVelocity;
|
||||
Assert.True(velocity.LengthSquared() > 0f);
|
||||
|
||||
|
|
@ -305,12 +620,25 @@ public class PlayerMovementControllerTests
|
|||
var controller = new PlayerMovementController(engine);
|
||||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
||||
controller.Yaw = 0f;
|
||||
int animationAdvances = 0;
|
||||
int hookPasses = 0;
|
||||
controller.AttachAnimationRootMotionSource(
|
||||
(_, frame) =>
|
||||
{
|
||||
animationAdvances++;
|
||||
frame.Origin = new Vector3(0f, 0.1f, 0f);
|
||||
},
|
||||
() => hookPasses++);
|
||||
|
||||
var moved = controller.Update(PhysicsBody.MinQuantum, new MovementInput(Forward: true));
|
||||
var moved = controller.Update(ObjectTick, new MovementInput(Forward: true));
|
||||
int advancesBeforeDiscard = animationAdvances;
|
||||
int hooksBeforeDiscard = hookPasses;
|
||||
var stale = controller.Update(PhysicsBody.HugeQuantum + 0.1f, new MovementInput(Forward: true));
|
||||
|
||||
Assert.Equal(moved.Position.X, stale.Position.X, precision: 4);
|
||||
Assert.Equal(stale.Position, stale.RenderPosition);
|
||||
Assert.Equal(advancesBeforeDiscard, animationAdvances);
|
||||
Assert.Equal(hooksBeforeDiscard, hookPasses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -283,6 +283,27 @@ public sealed class AnimationSequencerTests
|
|||
$"Expected orientation near {rot.Z} but got {transforms[0].Orientation.Z}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InstallsSetupDefaultAnimationForEveryPartArray()
|
||||
{
|
||||
const uint AnimId = 0x0300AA01u;
|
||||
var setup = Fixtures.MakeSetup(1);
|
||||
setup.DefaultAnimation = (QualifiedDataId<Animation>)AnimId;
|
||||
var loader = new FakeLoader();
|
||||
loader.Register(AnimId, Fixtures.MakeTwoFrameAnim(
|
||||
1,
|
||||
Vector3.Zero,
|
||||
Quaternion.Identity,
|
||||
new Vector3(2f, 0f, 0f),
|
||||
Quaternion.Identity));
|
||||
var sequencer = new AnimationSequencer(setup, new MotionTable(), loader);
|
||||
|
||||
Assert.True(sequencer.HasCurrentNode);
|
||||
Assert.Equal(30f, sequencer.CurrentNodeDiag.Framerate);
|
||||
Assert.Equal(0, sequencer.CurrentNodeDiag.StartFrame);
|
||||
Assert.Equal(1, sequencer.CurrentNodeDiag.EndFrame);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Advance_FrameWrapsAtHighFrame()
|
||||
{
|
||||
|
|
@ -1883,6 +1904,36 @@ public sealed class AnimationSequencerTests
|
|||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void InitializeSetupDefaultAnimation_PreservesPhysicsAndPlacementState()
|
||||
{
|
||||
// CPartArray::InitDefaults (0x00518980) replaces only the animation
|
||||
// list. It does not call CSequence::clear, so the PartArray's existing
|
||||
// velocity, omega, and placement frame survive this operation.
|
||||
const uint animationId = 0x03000531u;
|
||||
var loader = new FakeLoader();
|
||||
loader.Register(
|
||||
animationId,
|
||||
Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity));
|
||||
var seq = new AnimationSequencer(
|
||||
Fixtures.MakeSetup(1),
|
||||
new MotionTable(),
|
||||
loader);
|
||||
var placement = new AnimationFrame(1);
|
||||
Vector3 velocity = new(1f, 2f, 3f);
|
||||
Vector3 omega = new(0.1f, 0.2f, 0.3f);
|
||||
seq.Core.SetVelocity(velocity);
|
||||
seq.Core.SetOmega(omega);
|
||||
seq.Core.SetPlacementFrame(placement, 0x1234u);
|
||||
|
||||
Assert.True(seq.InitializeSetupDefaultAnimation(animationId));
|
||||
|
||||
Assert.Equal(velocity, seq.CurrentVelocity);
|
||||
Assert.Equal(omega, seq.CurrentOmega);
|
||||
Assert.Same(placement, seq.Core.PlacementFrame);
|
||||
Assert.Equal(0x1234u, seq.Core.PlacementFrameId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expose the core CSequence's FrameNumber via reflection (test-only).
|
||||
/// R1-P5 rehost (2026-07-02): _framePosition lived directly on
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using AcDream.Content.Vfx;
|
||||
using AcDream.Content;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using AcDream.Core.Tests.Conformance;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
|
@ -23,6 +24,7 @@ public sealed class HumanoidMotionTableRootMotionTests
|
|||
private const uint RunForward = 0x40000007u;
|
||||
private const uint Ready = 0x41000003u;
|
||||
private const uint InterpretedWalkForward = 0x45000005u;
|
||||
private const uint TurnRight = 0x6500000Du;
|
||||
|
||||
[Theory]
|
||||
[InlineData(WalkForward)]
|
||||
|
|
@ -76,4 +78,32 @@ public sealed class HumanoidMotionTableRootMotionTests
|
|||
distance > 1f,
|
||||
$"Installed retail WalkForward should author root travel; got {distance:F3} m over 2 s.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetailTurnRightModifier_ExposesLiteralSequenceOmega()
|
||||
{
|
||||
string? datDir = ConformanceDats.ResolveDatDir();
|
||||
if (datDir is null)
|
||||
return;
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
MotionTable table = Assert.IsType<MotionTable>(dats.Get<MotionTable>(HumanoidMotionTable));
|
||||
int styledKey = unchecked((int)((NonCombatStyle << 16) | (TurnRight & 0x00FF_FFFFu)));
|
||||
int unstyledKey = unchecked((int)(TurnRight & 0x00FF_FFFFu));
|
||||
bool found = table.Modifiers.TryGetValue(styledKey, out MotionData? modifier)
|
||||
|| table.Modifiers.TryGetValue(unstyledKey, out modifier);
|
||||
|
||||
Assert.True(
|
||||
found,
|
||||
$"Retail Humanoid TurnRight modifier was absent at styled key 0x{styledKey:X8} "
|
||||
+ $"and fallback key 0x{unstyledKey:X8}.");
|
||||
Assert.NotNull(modifier);
|
||||
|
||||
Assert.True(
|
||||
modifier!.Flags.HasFlag(DatReaderWriter.Enums.MotionDataFlags.HasOmega),
|
||||
$"Retail Humanoid TurnRight must mark its literal omega present; "
|
||||
+ $"flags={modifier.Flags}.");
|
||||
Assert.Equal(System.Numerics.Vector3.Zero, modifier.Omega with { Z = 0f });
|
||||
Assert.Equal(-1.5f, modifier.Omega.Z, 5);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
|
@ -27,6 +28,164 @@ public sealed class InterpolationManagerTests
|
|||
|
||||
private static InterpolationManager Make() => new InterpolationManager();
|
||||
|
||||
[Fact]
|
||||
public void AdjustOffset_CompleteFrame_ReplacesOriginAndNonCommutingOrientation()
|
||||
{
|
||||
var manager = Make();
|
||||
Quaternion current = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 1.1f);
|
||||
Quaternion target = Quaternion.CreateFromAxisAngle(Vector3.UnitY, -0.9f);
|
||||
manager.Enqueue(
|
||||
new Vector3(10f, 0f, 0f),
|
||||
target,
|
||||
isMovingTo: false,
|
||||
currentBodyPosition: Vector3.Zero);
|
||||
var offset = new MotionDeltaFrame
|
||||
{
|
||||
Origin = new Vector3(7f, 8f, 9f),
|
||||
Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.4f),
|
||||
};
|
||||
|
||||
bool overwritten = manager.AdjustOffset(
|
||||
dt: 0.1,
|
||||
currentBodyPosition: Vector3.Zero,
|
||||
currentBodyOrientation: current,
|
||||
maxSpeedFromMinterp: 4f,
|
||||
offset);
|
||||
|
||||
Assert.True(overwritten);
|
||||
Vector3 expectedLocalOrigin = MoveToMath.GlobalToLocalVec(
|
||||
current,
|
||||
new Vector3(0.8f, 0f, 0f));
|
||||
Assert.Equal(expectedLocalOrigin.X, offset.Origin.X, 5);
|
||||
Assert.Equal(expectedLocalOrigin.Y, offset.Origin.Y, 5);
|
||||
Assert.Equal(expectedLocalOrigin.Z, offset.Origin.Z, 5);
|
||||
Quaternion expectedOrientation = Quaternion.Normalize(
|
||||
Quaternion.Inverse(current) * target);
|
||||
Assert.InRange(
|
||||
MathF.Abs(Quaternion.Dot(
|
||||
expectedOrientation,
|
||||
Quaternion.Normalize(offset.Orientation))),
|
||||
0.99999f,
|
||||
1.00001f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdjustOffset_MoveToNode_KeepsCurrentHeading()
|
||||
{
|
||||
var manager = Make();
|
||||
manager.Enqueue(
|
||||
new Vector3(10f, 0f, 0f),
|
||||
Quaternion.CreateFromAxisAngle(Vector3.UnitY, -0.9f),
|
||||
isMovingTo: true,
|
||||
currentBodyPosition: Vector3.Zero);
|
||||
var offset = new MotionDeltaFrame
|
||||
{
|
||||
Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 0.6f),
|
||||
};
|
||||
|
||||
Assert.True(manager.AdjustOffset(
|
||||
0.1,
|
||||
Vector3.Zero,
|
||||
Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.2f),
|
||||
4f,
|
||||
offset));
|
||||
|
||||
Assert.Equal(Quaternion.Identity, offset.Orientation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdjustOffset_UsesManagerWideLatestKeepHeadingFlag()
|
||||
{
|
||||
var manager = Make();
|
||||
Quaternion current = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.2f);
|
||||
manager.Enqueue(
|
||||
new Vector3(10f, 0f, 0f),
|
||||
Quaternion.CreateFromAxisAngle(Vector3.UnitY, 0.7f),
|
||||
isMovingTo: false,
|
||||
currentBodyPosition: Vector3.Zero,
|
||||
currentBodyOrientation: current);
|
||||
manager.Enqueue(
|
||||
new Vector3(20f, 0f, 0f),
|
||||
Quaternion.CreateFromAxisAngle(Vector3.UnitX, -0.6f),
|
||||
isMovingTo: true,
|
||||
currentBodyPosition: Vector3.Zero,
|
||||
currentBodyOrientation: current);
|
||||
var offset = new MotionDeltaFrame();
|
||||
|
||||
Assert.True(manager.AdjustOffset(
|
||||
0.1,
|
||||
Vector3.Zero,
|
||||
current,
|
||||
4f,
|
||||
offset));
|
||||
|
||||
// keep_heading is an InterpolationManager field in retail, not a
|
||||
// property of each queued node. The latest near enqueue therefore
|
||||
// controls the current head as well.
|
||||
Assert.Equal(Quaternion.Identity, offset.Orientation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdjustOffset_WithoutContact_LeavesFrameAndQueueUntouched()
|
||||
{
|
||||
var manager = Make();
|
||||
manager.Enqueue(
|
||||
FarTarget,
|
||||
Quaternion.CreateFromAxisAngle(Vector3.UnitY, 0.7f),
|
||||
isMovingTo: false,
|
||||
currentBodyPosition: BodyOrigin);
|
||||
var offset = new MotionDeltaFrame
|
||||
{
|
||||
Origin = new Vector3(1f, 2f, 3f),
|
||||
Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 0.4f),
|
||||
};
|
||||
Vector3 originalOrigin = offset.Origin;
|
||||
Quaternion originalOrientation = offset.Orientation;
|
||||
|
||||
bool overwritten = manager.AdjustOffset(
|
||||
0.1,
|
||||
BodyOrigin,
|
||||
Quaternion.Identity,
|
||||
4f,
|
||||
offset,
|
||||
inContact: false);
|
||||
|
||||
Assert.False(overwritten);
|
||||
Assert.Equal(originalOrigin, offset.Origin);
|
||||
Assert.Equal(originalOrientation, offset.Orientation);
|
||||
Assert.True(manager.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enqueue_AlreadyClose_InstallsOnlyTargetHeading()
|
||||
{
|
||||
var manager = Make();
|
||||
Quaternion target = Quaternion.Normalize(
|
||||
Quaternion.CreateFromAxisAngle(Vector3.UnitX, 0.8f)
|
||||
* Quaternion.CreateFromAxisAngle(Vector3.UnitZ, -0.6f));
|
||||
|
||||
Quaternion? immediate = manager.Enqueue(
|
||||
new Vector3(0.01f, 0f, 0f),
|
||||
target,
|
||||
isMovingTo: false,
|
||||
currentBodyPosition: Vector3.Zero);
|
||||
|
||||
Assert.NotNull(immediate);
|
||||
Quaternion expected = MoveToMath.SetHeading(
|
||||
target,
|
||||
MoveToMath.GetHeading(target));
|
||||
Assert.InRange(
|
||||
MathF.Abs(Quaternion.Dot(
|
||||
Quaternion.Normalize(expected),
|
||||
Quaternion.Normalize(immediate!.Value))),
|
||||
0.99999f,
|
||||
1.00001f);
|
||||
Assert.True(MathF.Abs(Quaternion.Dot(
|
||||
Quaternion.Normalize(target),
|
||||
Quaternion.Normalize(immediate.Value))) < 0.999f);
|
||||
Assert.False(manager.IsActive);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Queue mechanics
|
||||
// =========================================================================
|
||||
|
|
|
|||
163
tests/AcDream.Core.Tests/Physics/Motion/MotionDeltaFrameTests.cs
Normal file
163
tests/AcDream.Core.Tests/Physics/Motion/MotionDeltaFrameTests.cs
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// Conformance coverage for retail <c>Frame::combine</c> (0x005122E0).
|
||||
/// The production object tick accumulates one or more CSequence frames before
|
||||
/// composing that delta onto the live body, so translation and orientation
|
||||
/// must remain one indivisible transform.
|
||||
/// </summary>
|
||||
public sealed class MotionDeltaFrameTests
|
||||
{
|
||||
[Fact]
|
||||
public void Combine_MatchesRetailFrameOps_ForTranslationAndOrientation()
|
||||
{
|
||||
Quaternion baseRotation = Quaternion.CreateFromAxisAngle(
|
||||
Vector3.UnitZ,
|
||||
MathF.PI / 2f);
|
||||
Quaternion deltaRotation = Quaternion.CreateFromAxisAngle(
|
||||
Vector3.UnitZ,
|
||||
MathF.PI / 4f);
|
||||
|
||||
var expected = new Frame
|
||||
{
|
||||
Origin = new Vector3(10f, 20f, 30f),
|
||||
Orientation = baseRotation,
|
||||
};
|
||||
FrameOps.Combine(expected, new Frame
|
||||
{
|
||||
Origin = new Vector3(2f, 0f, 1f),
|
||||
Orientation = deltaRotation,
|
||||
});
|
||||
|
||||
var actual = new MotionDeltaFrame
|
||||
{
|
||||
Origin = new Vector3(10f, 20f, 30f),
|
||||
Orientation = baseRotation,
|
||||
};
|
||||
actual.Combine(
|
||||
new Vector3(2f, 0f, 1f),
|
||||
deltaRotation);
|
||||
|
||||
AssertVectorEqual(expected.Origin, actual.Origin);
|
||||
AssertQuaternionEquivalent(expected.Orientation, actual.Orientation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Combine_SequentialFrames_RotatesLaterTranslationByEarlierTurn()
|
||||
{
|
||||
var accumulated = new MotionDeltaFrame();
|
||||
accumulated.Combine(
|
||||
Vector3.Zero,
|
||||
Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f));
|
||||
accumulated.Combine(
|
||||
new Vector3(0f, 2f, 0f),
|
||||
Quaternion.Identity);
|
||||
|
||||
AssertVectorEqual(new Vector3(-2f, 0f, 0f), accumulated.Origin);
|
||||
AssertQuaternionEquivalent(
|
||||
Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f),
|
||||
accumulated.Orientation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Combine_NonCommutingRotations_UsesRetailPostMultiplyOrder()
|
||||
{
|
||||
Quaternion aroundX = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 0.7f);
|
||||
Quaternion aroundY = Quaternion.CreateFromAxisAngle(Vector3.UnitY, -0.4f);
|
||||
var accumulated = new MotionDeltaFrame
|
||||
{
|
||||
Orientation = aroundX,
|
||||
};
|
||||
|
||||
accumulated.Combine(Vector3.Zero, aroundY);
|
||||
|
||||
AssertQuaternionEquivalent(
|
||||
FrameOps.SetRotate(Vector3.Zero, aroundX, aroundX * aroundY),
|
||||
accumulated.Orientation);
|
||||
Assert.True(MathF.Abs(Quaternion.Dot(
|
||||
accumulated.Orientation,
|
||||
Quaternion.Normalize(aroundY * aroundX))) < 0.999f);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(InvalidRotations))]
|
||||
public void Combine_InvalidCandidateRotation_RestoresPreviousQuaternion(
|
||||
Quaternion invalid)
|
||||
{
|
||||
Quaternion previous = Quaternion.CreateFromAxisAngle(Vector3.UnitY, 0.35f);
|
||||
var accumulated = new MotionDeltaFrame
|
||||
{
|
||||
Orientation = previous,
|
||||
};
|
||||
|
||||
accumulated.Combine(Vector3.Zero, invalid);
|
||||
|
||||
AssertQuaternionEquivalent(previous, accumulated.Orientation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Combine_NearZeroNonzeroQuaternion_NormalizesLikeRetail()
|
||||
{
|
||||
var accumulated = new MotionDeltaFrame();
|
||||
var tiny = new Quaternion(1e-30f, 0f, 0f, 0f);
|
||||
|
||||
accumulated.Combine(Vector3.Zero, tiny);
|
||||
|
||||
AssertQuaternionEquivalent(
|
||||
Quaternion.CreateFromAxisAngle(Vector3.UnitX, MathF.PI),
|
||||
accumulated.Orientation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Combine_OriginScale_DoesNotScaleRotation()
|
||||
{
|
||||
var accumulated = new MotionDeltaFrame();
|
||||
Quaternion turn = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 3f);
|
||||
|
||||
accumulated.Combine(new Vector3(0f, 0.5f, 0f), turn, originScale: 2f);
|
||||
|
||||
AssertVectorEqual(new Vector3(0f, 1f, 0f), accumulated.Origin);
|
||||
AssertQuaternionEquivalent(turn, accumulated.Orientation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_RestoresIdentityFrame()
|
||||
{
|
||||
var frame = new MotionDeltaFrame
|
||||
{
|
||||
Origin = Vector3.One,
|
||||
Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 0.5f),
|
||||
};
|
||||
|
||||
frame.Reset();
|
||||
|
||||
Assert.Equal(Vector3.Zero, frame.Origin);
|
||||
Assert.Equal(Quaternion.Identity, frame.Orientation);
|
||||
}
|
||||
|
||||
private static void AssertVectorEqual(Vector3 expected, Vector3 actual)
|
||||
{
|
||||
Assert.Equal(expected.X, actual.X, 5);
|
||||
Assert.Equal(expected.Y, actual.Y, 5);
|
||||
Assert.Equal(expected.Z, actual.Z, 5);
|
||||
}
|
||||
|
||||
private static void AssertQuaternionEquivalent(Quaternion expected, Quaternion actual)
|
||||
{
|
||||
float alignment = MathF.Abs(Quaternion.Dot(
|
||||
Quaternion.Normalize(expected),
|
||||
Quaternion.Normalize(actual)));
|
||||
Assert.InRange(alignment, 0.99999f, 1.00001f);
|
||||
}
|
||||
|
||||
public static TheoryData<Quaternion> InvalidRotations => new()
|
||||
{
|
||||
Quaternion.Zero,
|
||||
new Quaternion(float.NaN, 0f, 0f, 1f),
|
||||
new Quaternion(float.PositiveInfinity, 0f, 0f, 1f),
|
||||
};
|
||||
}
|
||||
|
|
@ -13,8 +13,7 @@ namespace AcDream.Core.Tests.Physics.Motion;
|
|||
/// R2-Q5 — <see cref="MotionTableDispatchSink"/>: the funnel's dispatches go
|
||||
/// straight into <see cref="AnimationSequencer.PerformMovement"/> (no axis
|
||||
/// collection, no priority pick, no fallback chain — GetObjectSequence
|
||||
/// 0x00522860 + is_allowed decide) with the TurnApplied/TurnStopped
|
||||
/// ObservedOmega seam.
|
||||
/// 0x00522860 + is_allowed decide).
|
||||
/// </summary>
|
||||
public class MotionTableDispatchSinkTests
|
||||
{
|
||||
|
|
@ -100,15 +99,10 @@ public class MotionTableDispatchSinkTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyMotion_Turn_Branch4Modifier_FiresTurnApplied_NoCycleChange()
|
||||
public void ApplyMotion_Turn_Branch4Modifier_PreservesCycleAndAppliesDatOmega()
|
||||
{
|
||||
var seq = MakeSequencer();
|
||||
uint? turnMotion = null;
|
||||
float turnSpeed = 0f;
|
||||
var sink = new MotionTableDispatchSink(seq)
|
||||
{
|
||||
TurnApplied = (m, s) => { turnMotion = m; turnSpeed = s; },
|
||||
};
|
||||
var sink = new MotionTableDispatchSink(seq);
|
||||
|
||||
sink.ApplyMotion(Walk, 1.0f);
|
||||
sink.ApplyMotion(TurnRight, 1.5f);
|
||||
|
|
@ -116,22 +110,19 @@ public class MotionTableDispatchSinkTests
|
|||
// The AP-73 mechanism for real: substate cycle untouched, the turn
|
||||
// is a MotionState modifier with its dat omega combined.
|
||||
Assert.Equal(Walk, seq.CurrentMotion);
|
||||
Assert.Equal((TurnRight, 1.5f), (turnMotion!.Value, turnSpeed));
|
||||
Assert.Equal(1.2f * 1.5f, seq.CurrentOmega.Z, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StopMotion_Turn_FiresTurnStopped_UnwindsModifierPhysics()
|
||||
public void StopMotion_Turn_UnwindsModifierPhysics()
|
||||
{
|
||||
var seq = MakeSequencer();
|
||||
bool stopped = false;
|
||||
var sink = new MotionTableDispatchSink(seq) { TurnStopped = () => stopped = true };
|
||||
var sink = new MotionTableDispatchSink(seq);
|
||||
|
||||
sink.ApplyMotion(Walk, 1.0f);
|
||||
sink.ApplyMotion(TurnRight, 1.5f);
|
||||
sink.StopMotion(TurnRight);
|
||||
|
||||
Assert.True(stopped);
|
||||
// StopSequenceMotion Case B (0x00522fc0): subtract_motion of the dat
|
||||
// omega + modifier unlinked.
|
||||
Assert.Equal(0f, seq.CurrentOmega.Z, 3);
|
||||
|
|
|
|||
|
|
@ -16,12 +16,9 @@ namespace AcDream.Core.Tests.Physics.Motion;
|
|||
/// Golden cardinals: N(0,+1)→0, E(+1,0)→90, S(0,-1)→180, W(-1,0)→270.
|
||||
///
|
||||
/// <para>
|
||||
/// <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 wire convention. <see cref="MoveToMath.GetHeading"/> must
|
||||
/// use the CORRECT scalar bridge from acdream yaw (yaw=0 faces +X, per
|
||||
/// <b>The scalar-convention trap (V0-pins §P5 correction):</b>
|
||||
/// <see cref="MoveToMath.GetHeading"/> must use the correct scalar bridge
|
||||
/// from acdream yaw (yaw=0 faces +X, per
|
||||
/// <c>PlayerMovementController.cs:1022-1025</c>): <c>heading = (90 -
|
||||
/// yawDeg) mod 360</c> — NOT <c>180 - yawDeg</c>.
|
||||
/// </para>
|
||||
|
|
|
|||
|
|
@ -164,6 +164,27 @@ public sealed class MovementManagerTests
|
|||
Assert.Equal(0, calls[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerformMovement_ActivatesBeforeDispatch_EvenWhenTypeIsInvalid()
|
||||
{
|
||||
var (mm, _, _) = MakeFacade();
|
||||
int activations = 0;
|
||||
mm.ActivatePhysicsObject = () => activations++;
|
||||
|
||||
Assert.Equal(WeenieError.None,
|
||||
mm.PerformMovement(new MovementStruct
|
||||
{
|
||||
Type = MovementType.StopCompletely,
|
||||
}));
|
||||
Assert.Equal(WeenieError.GeneralMovementFailure,
|
||||
mm.PerformMovement(new MovementStruct
|
||||
{
|
||||
Type = (MovementType)99,
|
||||
}));
|
||||
|
||||
Assert.Equal(2, activations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerformMovement_MoveToType_WithoutFactory_Fails0x47()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@ namespace AcDream.Core.Tests.Physics.Motion;
|
|||
// the scalar) and drains pending_motions synthetically, so the two legs where
|
||||
// the live #170 residual actually lives have ZERO coverage:
|
||||
//
|
||||
// 1. the PHYSICAL turn: _DoMotion(TurnRight) → MotionTableDispatchSink.
|
||||
// TurnApplied → ObservedOmega → per-tick quaternion integration →
|
||||
// MoveToMath.GetHeading → HandleTurnToHeading's HeadingGreater test;
|
||||
// 1. the PHYSICAL turn: _DoMotion(TurnRight) → MotionTableDispatchSink →
|
||||
// DAT MotionData.Omega → CSequence's complete root Frame →
|
||||
// Frame::combine → HandleTurnToHeading's HeadingGreater test;
|
||||
// 2. the REAL drain: MotionTableManager pending_animations countdown fed by
|
||||
// CSequence AnimDone hooks (link-anim completions), popping
|
||||
// CMotionInterp.pending_motions via the MotionDoneTarget seam.
|
||||
|
|
@ -31,11 +31,9 @@ namespace AcDream.Core.Tests.Physics.Motion;
|
|||
// This harness wires a real MotionInterpreter + AnimationSequencer +
|
||||
// MotionTableDispatchSink + MoveToManager EXACTLY like GameWindow's
|
||||
// EnsureRemoteMotionBindings (GameWindow.cs:4251) and ticks them in
|
||||
// GameWindow.TickAnimations' per-entity order for the grounded branch=MOVETO
|
||||
// path (GameWindow.cs:9697 HandleTargetting → 9994 TickRemoteMoveTo →
|
||||
// 10024 get_state_velocity refresh → 10050 manual omega integration →
|
||||
// 10247 Sequencer.Advance → 10306 AnimationDone per AnimDoneHook →
|
||||
// 10309 Manager.UseTime). Wire events (aggro stance UM, mt-6 arms, attack
|
||||
// the retail per-object order: CSequence update → PositionManager adjust →
|
||||
// complete Frame combine → hook processing → Target/Movement/PartArray/
|
||||
// Position manager tail. Wire events (aggro stance UM, mt-6 arms, attack
|
||||
// UMs) replay the exact live sequence captured in launch-drainq.log
|
||||
// (2026-07-04, guid 0x80000BE5, Mite Scamp chasing the fleeing player).
|
||||
//
|
||||
|
|
@ -118,9 +116,6 @@ internal sealed class RemoteChaseHarness
|
|||
private readonly CreatureHost _creatureHost;
|
||||
private readonly TargetHost _playerHost;
|
||||
|
||||
/// <summary>GameWindow's RemoteMotion.ObservedOmega twin.</summary>
|
||||
public Vector3 ObservedOmega;
|
||||
|
||||
/// <summary>Scripted player (chase target) world position.</summary>
|
||||
public Vector3 PlayerPos;
|
||||
public Vector3 PlayerVelocity;
|
||||
|
|
@ -162,17 +157,7 @@ internal sealed class RemoteChaseHarness
|
|||
};
|
||||
|
||||
// ── EnsureRemoteMotionBindings (GameWindow.cs:4251) verbatim ──
|
||||
Sink = new MotionTableDispatchSink(Seq)
|
||||
{
|
||||
TurnApplied = (turnMotion, turnSpeed) =>
|
||||
{
|
||||
float signed = (turnMotion & 0xFFu) == 0x0E
|
||||
? -MathF.Abs(turnSpeed)
|
||||
: turnSpeed;
|
||||
ObservedOmega = new Vector3(0f, 0f, -(MathF.PI / 2f) * signed);
|
||||
},
|
||||
TurnStopped = () => ObservedOmega = Vector3.Zero,
|
||||
};
|
||||
Sink = new MotionTableDispatchSink(Seq);
|
||||
Interp.DefaultSink = Sink;
|
||||
// #174: production binds the seam to Manager.HandleEnterWorld
|
||||
// (strip + full queue drain, retail 0x0050fe20 → 0x0051bdd0) —
|
||||
|
|
@ -425,64 +410,52 @@ internal sealed class RemoteChaseHarness
|
|||
// Player (chase target) moves per its scripted velocity.
|
||||
PlayerPos += PlayerVelocity * Dt;
|
||||
|
||||
// 1. Voyeur delivery (player host HandleTargetting → this entity's
|
||||
// HandleUpdateTarget; GameWindow.cs:8094 runs before the per-remote
|
||||
// loop, 9697 in-loop).
|
||||
if (_targetArmed && Now - _lastDeliveryTime >= _quantum)
|
||||
DeliverTargetInfo();
|
||||
|
||||
// 2. MovementManager drive (TickRemoteMoveTo — the R5-V5 facade
|
||||
// relay, MovementManager::UseTime 0x005242f0).
|
||||
Movement.UseTime();
|
||||
|
||||
// 3. get_state_velocity → body velocity (the d2ccc80e refresh,
|
||||
// GameWindow.cs:10024).
|
||||
if (Body.OnWalkable)
|
||||
Body.set_local_velocity(Interp.get_state_velocity(), false);
|
||||
|
||||
// 4. Manual omega integration (GameWindow.cs:10050-10058 verbatim).
|
||||
if (ObservedOmega.LengthSquared() > 1e-8f)
|
||||
// CPartArray::Update emits one complete local Frame: authored PosFrames
|
||||
// plus MotionData velocity/omega (CSequence::apply_physics 0x00524AB0).
|
||||
var rootFrame = new Frame
|
||||
{
|
||||
float omegaMag = ObservedOmega.Length();
|
||||
var axis = ObservedOmega / omegaMag;
|
||||
float angle = omegaMag * Dt;
|
||||
var deltaRot = Quaternion.CreateFromAxisAngle(axis, angle);
|
||||
Body.Orientation = Quaternion.Normalize(
|
||||
Quaternion.Multiply(Body.Orientation, deltaRot));
|
||||
}
|
||||
Origin = Vector3.Zero,
|
||||
Orientation = Quaternion.Identity,
|
||||
};
|
||||
Seq.Advance(Dt, rootFrame);
|
||||
|
||||
// 5. R5-V3 (#171): the sticky steer — GameWindow's legacy-branch slot
|
||||
// (retail UpdatePositionInternal PositionManager::adjust_offset
|
||||
// @0x00512d0e, composed BEFORE the velocity integration). Origin is
|
||||
// mover-local; the rotation carries a RELATIVE heading (identity =
|
||||
// untouched = no turn).
|
||||
var pmDelta = new MotionDeltaFrame();
|
||||
// PositionManager receives that same Frame and may replace its motion
|
||||
// (sticky/interpolation) before Frame::combine applies translation with
|
||||
// the OLD orientation, then post-multiplies the relative orientation.
|
||||
var pmDelta = new MotionDeltaFrame
|
||||
{
|
||||
Origin = rootFrame.Origin,
|
||||
Orientation = rootFrame.Orientation,
|
||||
};
|
||||
Pm.AdjustOffset(pmDelta, Dt);
|
||||
if (pmDelta.Origin != Vector3.Zero)
|
||||
Body.Position += Vector3.Transform(pmDelta.Origin, Body.Orientation);
|
||||
if (!pmDelta.Orientation.IsIdentity)
|
||||
Body.Orientation = MoveToMath.SetHeading(
|
||||
Body.Orientation,
|
||||
MoveToMath.GetHeading(Body.Orientation) + pmDelta.GetHeading());
|
||||
Body.Orientation = Quaternion.Normalize(
|
||||
Body.Orientation * pmDelta.Orientation);
|
||||
|
||||
// 5b. Position integration (UpdatePhysicsInternal, simplified: grounded,
|
||||
// no gravity participation for this scenario).
|
||||
Body.Position += Body.Velocity * Dt;
|
||||
// The fixture has no gravity or independent physical velocity, but run
|
||||
// the same physics primitive so a future fixture cannot accidentally
|
||||
// bypass the retail slot.
|
||||
Body.calc_acceleration();
|
||||
Body.UpdatePhysicsInternal(Dt);
|
||||
|
||||
// 5c. R5-V3: PositionManager::UseTime (retail UpdateObjectInternal
|
||||
// tail @0x005159b3) — the sticky 1 s lease watchdog.
|
||||
Pm.UseTime();
|
||||
|
||||
// 6. Anim loop (GameWindow.cs:10247-10309): advance, drain AnimDone
|
||||
// hooks into the manager countdown, zero-tick sweep.
|
||||
Seq.Advance(Dt);
|
||||
// process_hooks precedes the manager tail. AnimationDone decrements the
|
||||
// exact pending-animation node that owns the transition.
|
||||
var hooks = Seq.ConsumePendingHooks();
|
||||
for (int i = 0; i < hooks.Count; i++)
|
||||
{
|
||||
if (hooks[i] is DatReaderWriter.Types.AnimationDoneHook)
|
||||
Seq.Manager.AnimationDone(success: true);
|
||||
}
|
||||
|
||||
// UpdateObjectInternal manager tail: Target → Movement → PartArray →
|
||||
// Position. A movement selected here contributes to next tick's Frame.
|
||||
if (_targetArmed && Now - _lastDeliveryTime >= _quantum)
|
||||
DeliverTargetInfo();
|
||||
Movement.UseTime();
|
||||
Seq.Manager.UseTime();
|
||||
Pm.UseTime();
|
||||
|
||||
// ── Observables ──
|
||||
uint substate = Seq.Manager.State.Substate;
|
||||
|
|
@ -509,7 +482,7 @@ internal sealed class RemoteChaseHarness
|
|||
public void Snapshot(string tag)
|
||||
{
|
||||
string line = FormattableString.Invariant(
|
||||
$"t={Now,6:F2} {tag,-14} mt={Mgr.MovementTypeState,-14} substate=0x{Seq.Manager.State.Substate:X8} heading={Heading,6:F1} dist={DistToPlayer,6:F2} pending={Interp.MotionsPending()} omega.Z={ObservedOmega.Z,6:F3}");
|
||||
$"t={Now,6:F2} {tag,-14} mt={Mgr.MovementTypeState,-14} substate=0x{Seq.Manager.State.Substate:X8} heading={Heading,6:F1} dist={DistToPlayer,6:F2} pending={Interp.MotionsPending()} omega.Z={Seq.CurrentOmega.Z,6:F3}");
|
||||
Trace.Add(line);
|
||||
_log?.WriteLine(line);
|
||||
}
|
||||
|
|
@ -627,7 +600,11 @@ internal sealed class RemoteChaseHarness
|
|||
AddLink(mt, Combat, Ready, AttackAction, MakeMd(AttackLink));
|
||||
|
||||
// Global (unstyled) TurnRight modifier — physics-only in Branch 4.
|
||||
mt.Modifiers[(int)(TurnRight & 0xFFFFFFu)] = MakeMd(TurnAnim);
|
||||
// The signed DAT omega is the rotation source, matching retail's
|
||||
// Humanoid motion table; no command-derived side channel exists.
|
||||
mt.Modifiers[(int)(TurnRight & 0xFFFFFFu)] = MakeMd(
|
||||
TurnAnim,
|
||||
omega: new Vector3(0f, 0f, -1.5f));
|
||||
|
||||
return (setup, mt, loader);
|
||||
}
|
||||
|
|
@ -646,7 +623,7 @@ public sealed class RemoteChaseEndToEndHarnessTests
|
|||
/// The core chase cycle: aggro stance change, one mt-6 arm at a target
|
||||
/// 90° off the creature's facing, 15 m away, stationary. Retail bar: the
|
||||
/// stance links play out (~1 s), the chase turn starts, completes at the
|
||||
/// turn rate (90° at π/2·2.08 rad/s ≈ 0.5 s), and BeginMoveForward
|
||||
/// authored turn rate (90° at 1.5·2.08 rad/s ≈ 0.5 s), and BeginMoveForward
|
||||
/// installs the forward cycle. Total budget: 4 s of ticks is generous.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
|
|
@ -692,7 +669,7 @@ public sealed class RemoteChaseEndToEndHarnessTests
|
|||
Assert.True(installTick >= 0,
|
||||
$"forward cycle never installed within 6 s of the arm (bearing {bearingDeg}°); " +
|
||||
$"final: mt={h.Mgr.MovementTypeState} substate=0x{h.Seq.Manager.State.Substate:X8} " +
|
||||
$"heading={h.Heading:F1} pending={h.Interp.MotionsPending()} omega.Z={h.ObservedOmega.Z:F3}");
|
||||
$"heading={h.Heading:F1} pending={h.Interp.MotionsPending()} omega.Z={h.Seq.CurrentOmega.Z:F3}");
|
||||
Assert.True(installTick <= Seconds(4f),
|
||||
$"forward cycle took {installTick * Dt:F2} s to install (bearing {bearingDeg}°) — " +
|
||||
"retail installs within the turn duration (~1-2 s)");
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ public sealed class MotionVelocityPipelineTests
|
|||
Assert.True(v.X < 0f, "strafe-left velocity must be negative");
|
||||
}
|
||||
|
||||
// ── turn (drives the local Yaw omega: base π/2 × TurnSpeed) ───────────────
|
||||
// ── turn normalization (the DAT MotionData supplies production omega) ────
|
||||
|
||||
[Fact]
|
||||
public void RunTurnRight_InterpretedTurnSpeedIsPositiveRunTurnFactor()
|
||||
|
|
|
|||
|
|
@ -104,6 +104,40 @@ public sealed class ProjectilePhysicsStepperTests
|
|||
Assert.Equal(50f, body.CachedVelocity.Y, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SplitQuantum_HoldsBeginFrameAcrossHookSlotThenCommitsCandidate()
|
||||
{
|
||||
var (engine, _) = BuildEmptyEngine();
|
||||
var body = MakeBody(
|
||||
new Vector3(12f, 10f, 2f),
|
||||
new Vector3(10f, 0f, 0f));
|
||||
var stepper = new ProjectilePhysicsStepper(engine);
|
||||
Vector3 begin = body.Position;
|
||||
|
||||
ProjectileQuantumPreparation preparation = stepper.BeginQuantum(
|
||||
body,
|
||||
0.1f,
|
||||
Cell,
|
||||
ArrowSphere);
|
||||
|
||||
Assert.True(preparation.Simulated);
|
||||
Assert.True(preparation.RequiresTransition);
|
||||
Assert.Equal(begin, body.Position);
|
||||
Assert.Equal(begin + Vector3.UnitX, preparation.CandidatePosition);
|
||||
|
||||
ProjectileAdvanceResult result = stepper.CompleteQuantum(
|
||||
body,
|
||||
preparation,
|
||||
ArrowSphere,
|
||||
ProjectileId);
|
||||
|
||||
Assert.True(result.Simulated);
|
||||
Assert.InRange(
|
||||
Vector3.Distance(begin + Vector3.UnitX, body.Position),
|
||||
0f,
|
||||
0.00001f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Advance_GravityUsesFinalPhysicsState_NotParsedAcceleration()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,150 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
public sealed class RetailObjectActivityGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void FarPartArrayObject_ConsumesInactiveClockAndRebasesWhenNearAgain()
|
||||
{
|
||||
var clock = new RetailObjectQuantumClock();
|
||||
var body = new PhysicsBody
|
||||
{
|
||||
TransientState = TransientStateFlags.Active,
|
||||
};
|
||||
Assert.Equal(0, clock.Advance(0.02).Count);
|
||||
|
||||
Assert.Equal(
|
||||
RetailObjectActivityResult.Inactive,
|
||||
RetailObjectActivityGate.Evaluate(
|
||||
clock,
|
||||
body,
|
||||
lifecycleEligible: true,
|
||||
hasPartArray: true,
|
||||
isStatic: false,
|
||||
objectPosition: new Vector3(96.01f, 0f, 0f),
|
||||
playerPosition: Vector3.Zero,
|
||||
elapsedSeconds: 0.02));
|
||||
Assert.False(clock.IsActive);
|
||||
Assert.Equal(0.0, clock.PendingSeconds, 8);
|
||||
Assert.False(body.TransientState.HasFlag(TransientStateFlags.Active));
|
||||
|
||||
Assert.Equal(
|
||||
RetailObjectActivityResult.Reactivated,
|
||||
RetailObjectActivityGate.Evaluate(
|
||||
clock,
|
||||
body,
|
||||
lifecycleEligible: true,
|
||||
hasPartArray: true,
|
||||
isStatic: false,
|
||||
objectPosition: new Vector3(95.99f, 0f, 0f),
|
||||
playerPosition: Vector3.Zero,
|
||||
elapsedSeconds: 0.02));
|
||||
Assert.True(clock.IsActive);
|
||||
Assert.Equal(0.0, clock.PendingSeconds, 8);
|
||||
Assert.True(body.TransientState.HasFlag(TransientStateFlags.Active));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FrozenInterval_IsDiscardedByInactiveToActiveRebase()
|
||||
{
|
||||
var clock = new RetailObjectQuantumClock();
|
||||
Assert.Equal(0, clock.Advance(0.02).Count);
|
||||
|
||||
Assert.Equal(
|
||||
RetailObjectActivityResult.Suspended,
|
||||
RetailObjectActivityGate.Evaluate(
|
||||
clock,
|
||||
body: null,
|
||||
lifecycleEligible: false,
|
||||
hasPartArray: true,
|
||||
isStatic: false,
|
||||
objectPosition: Vector3.Zero,
|
||||
playerPosition: Vector3.Zero,
|
||||
elapsedSeconds: 0.5));
|
||||
Assert.Equal(
|
||||
RetailObjectActivityResult.Reactivated,
|
||||
RetailObjectActivityGate.Evaluate(
|
||||
clock,
|
||||
body: null,
|
||||
lifecycleEligible: true,
|
||||
hasPartArray: true,
|
||||
isStatic: false,
|
||||
objectPosition: Vector3.Zero,
|
||||
playerPosition: Vector3.Zero,
|
||||
elapsedSeconds: 0.01));
|
||||
|
||||
Assert.Equal(0.0, clock.PendingSeconds, 8);
|
||||
Assert.Equal(0, clock.Advance(0.02).Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PartArrayLessObject_RemainsActiveBeyondDistanceGate()
|
||||
{
|
||||
var clock = new RetailObjectQuantumClock();
|
||||
|
||||
Assert.Equal(
|
||||
RetailObjectActivityResult.Active,
|
||||
RetailObjectActivityGate.Evaluate(
|
||||
clock,
|
||||
body: null,
|
||||
lifecycleEligible: true,
|
||||
hasPartArray: false,
|
||||
isStatic: false,
|
||||
objectPosition: new Vector3(500f, 0f, 0f),
|
||||
playerPosition: Vector3.Zero,
|
||||
elapsedSeconds: 0.02));
|
||||
Assert.True(clock.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingPlayer_PreservesPriorInactiveStateWithoutReactivation()
|
||||
{
|
||||
var clock = new RetailObjectQuantumClock();
|
||||
clock.Deactivate();
|
||||
|
||||
Assert.Equal(
|
||||
RetailObjectActivityResult.Inactive,
|
||||
RetailObjectActivityGate.Evaluate(
|
||||
clock,
|
||||
body: null,
|
||||
lifecycleEligible: true,
|
||||
hasPartArray: true,
|
||||
isStatic: false,
|
||||
objectPosition: Vector3.Zero,
|
||||
playerPosition: null,
|
||||
elapsedSeconds: 0.04));
|
||||
|
||||
Assert.False(clock.IsActive);
|
||||
Assert.Equal(0.0, clock.PendingSeconds, 8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NearStaticObject_DoesNotReactivateOrdinaryObjectPath()
|
||||
{
|
||||
var clock = new RetailObjectQuantumClock();
|
||||
clock.Deactivate();
|
||||
var body = new PhysicsBody
|
||||
{
|
||||
State = PhysicsStateFlags.Static,
|
||||
TransientState = TransientStateFlags.None,
|
||||
};
|
||||
|
||||
Assert.Equal(
|
||||
RetailObjectActivityResult.Inactive,
|
||||
RetailObjectActivityGate.Evaluate(
|
||||
clock,
|
||||
body,
|
||||
lifecycleEligible: true,
|
||||
hasPartArray: true,
|
||||
isStatic: true,
|
||||
objectPosition: Vector3.Zero,
|
||||
playerPosition: Vector3.Zero,
|
||||
elapsedSeconds: 0.04));
|
||||
|
||||
Assert.False(clock.IsActive);
|
||||
Assert.Equal(0.0, clock.PendingSeconds, 8);
|
||||
Assert.False(body.TransientState.HasFlag(TransientStateFlags.Active));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
public sealed class RetailObjectQuantumClockTests
|
||||
{
|
||||
[Fact]
|
||||
public void Advance_SubMinimumTime_IsRetainedUntilStrictThresholdIsCrossed()
|
||||
{
|
||||
var clock = new RetailObjectQuantumClock();
|
||||
|
||||
Assert.Equal(0, clock.Advance(PhysicsBody.MinQuantum * 0.5).Count);
|
||||
Assert.Equal(0, clock.Advance(PhysicsBody.MinQuantum * 0.5).Count);
|
||||
RetailObjectQuantumBatch batch = clock.Advance(0.00001);
|
||||
|
||||
Assert.Equal(1, batch.Count);
|
||||
Assert.True(batch.GetQuantum(0) > PhysicsBody.MinQuantum);
|
||||
Assert.Equal(0.0, clock.PendingSeconds, 8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Advance_ExactFourTenths_EqualsTwoRetailMaxQuanta()
|
||||
{
|
||||
var clock = new RetailObjectQuantumClock();
|
||||
|
||||
RetailObjectQuantumBatch batch = clock.Advance(0.4);
|
||||
|
||||
Assert.Equal(2, batch.Count);
|
||||
Assert.Equal(PhysicsBody.MaxQuantum, batch.GetQuantum(0));
|
||||
Assert.Equal(PhysicsBody.MaxQuantum, batch.GetQuantum(1), 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Advance_HugePlusEpsilon_DiscardsWithoutObjectTick()
|
||||
{
|
||||
var clock = new RetailObjectQuantumClock();
|
||||
|
||||
RetailObjectQuantumBatch batch = clock.Advance(
|
||||
PhysicsBody.HugeQuantum + 0.00001);
|
||||
|
||||
Assert.True(batch.Discarded);
|
||||
Assert.Equal(0, batch.Count);
|
||||
Assert.Equal(0.0, clock.PendingSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Advance_ExactHugeQuantum_IsSubdividedNotDiscarded()
|
||||
{
|
||||
var clock = new RetailObjectQuantumClock();
|
||||
|
||||
RetailObjectQuantumBatch batch = clock.Advance(PhysicsBody.HugeQuantum);
|
||||
|
||||
Assert.False(batch.Discarded);
|
||||
Assert.Equal(10, batch.Count);
|
||||
for (int i = 0; i < batch.Count; i++)
|
||||
Assert.Equal(PhysicsBody.MaxQuantum, batch.GetQuantum(i), 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Advance_MicroQuantum_IsConsumedRatherThanAccumulated()
|
||||
{
|
||||
var clock = new RetailObjectQuantumClock();
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
Assert.Equal(0, clock.Advance(0.0001).Count);
|
||||
|
||||
Assert.Equal(0.0, clock.PendingSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeactivateThenActivate_RebasesOnceAndDoesNotCatchUp()
|
||||
{
|
||||
var clock = new RetailObjectQuantumClock();
|
||||
Assert.Equal(0, clock.Advance(0.02).Count);
|
||||
|
||||
clock.Deactivate();
|
||||
Assert.False(clock.IsActive);
|
||||
Assert.Equal(0.02, clock.PendingSeconds, 8);
|
||||
Assert.True(clock.Activate());
|
||||
Assert.True(clock.IsActive);
|
||||
Assert.Equal(0.0, clock.PendingSeconds, 8);
|
||||
Assert.False(clock.Activate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetForEnterWorld_DiscardsFragmentAndActivatesImmediately()
|
||||
{
|
||||
var clock = new RetailObjectQuantumClock();
|
||||
Assert.Equal(0, clock.Advance(0.02).Count);
|
||||
|
||||
clock.ResetForEnterWorld();
|
||||
|
||||
Assert.True(clock.IsActive);
|
||||
Assert.Equal(0.0, clock.PendingSeconds, 8);
|
||||
Assert.False(clock.Activate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetForEnterWorld_StaticObjectRebasesButRetainsInactiveState()
|
||||
{
|
||||
var clock = new RetailObjectQuantumClock();
|
||||
Assert.Equal(0, clock.Advance(0.02).Count);
|
||||
clock.Deactivate();
|
||||
|
||||
clock.ResetForEnterWorld(isStatic: true);
|
||||
|
||||
Assert.False(clock.IsActive);
|
||||
Assert.Equal(0.0, clock.PendingSeconds, 8);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue