feat(physics): port retail projectile integration

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

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

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

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

View file

@ -15,7 +15,7 @@ namespace AcDream.Core.Tests.Physics;
/// as <c>HasPhysicsBSP = 0x00010000</c>.
///
/// <para>
/// For non-PvP, non-missile movers (M1.5 scope — walking-vs-static), an
/// After the whole-object PvP and missile exemption gates, an
/// entity with HAS_PHYSICS_BSP_PS in its state tests its BSP exclusively
/// — the foot cyl is NEVER tested. The closed cottage door (state
/// <c>0x10008</c> = <c>STATIC | REPORT_COLLISIONS | HAS_PHYSICS_BSP</c>)
@ -59,8 +59,8 @@ public class A6P7DispatchRulesTests
/// else
/// → BSP-only path
/// </code>
/// For M1.5 scope <c>ebp_1</c> (PvP-target-player) and <c>eax_12</c>
/// (missile_ignore) are treated as false. The predicate reduces to:
/// The PvP and missile terms are whole-object early exits in the port.
/// Once shape dispatch is reached, the predicate is therefore:
/// "BSP-only iff <c>HAS_PHYSICS_BSP_PS</c> is set".
/// </summary>
[Theory]
@ -76,44 +76,28 @@ public class A6P7DispatchRulesTests
Assert.Equal(expected, Transition.BspOnlyDispatch(entityState));
}
// -----------------------------------------------------------------------
// W1 (2026-06-24) — named PvP/missile terms are false in M1.5
// -----------------------------------------------------------------------
/// <summary>
/// W1: <see cref="Transition.PvpExempt"/> must return false in M1.5.
/// When PK ships (M2+) this stub will accept mover + target state;
/// the test pins the current named-false value as a guard.
/// Retail oracle: <c>CPhysicsObj::FindObjCollisions</c> pc:276808276841.
/// </summary>
[Fact]
public void W1_PvpExempt_ReturnsFalseInM15Scope()
{
// PvP hasn't shipped (M1.5); the stub always returns false.
Assert.False(Transition.PvpExempt());
}
/// <summary>
/// W1: <see cref="Transition.MissileIgnore"/> must return false in M1.5.
/// When missiles ship (F.3) this stub will accept mover OBJECTINFO +
/// target state; the test pins the current named-false value as a guard.
/// A non-missile mover does not trigger the retail missile exemption.
/// Retail oracle: <c>OBJECTINFO::missile_ignore</c> pc:274385;
/// dispatch use pc:276858276861.
/// </summary>
[Fact]
public void W1_MissileIgnore_ReturnsFalseInM15Scope()
public void NonMissileMover_DoesNotIgnoreOrdinaryTarget()
{
// Missiles haven't shipped (M1.5); the stub always returns false.
Assert.False(Transition.MissileIgnore());
var mover = new ObjectInfo();
Assert.False(mover.MissileIgnore(
targetEntityId: 1u,
targetPhysicsState: 0u,
targetFlags: EntityCollisionFlags.HasWeenie));
}
/// <summary>
/// Guard: with both W1 terms false, a BSP-flagged entity still
/// dispatches BSP-only — A6.P7 door behavior is unchanged after W1.
/// Guard: a BSP-flagged entity that reaches shape dispatch still uses
/// only its BSP — A6.P7 door behavior remains unchanged.
/// This protects the A6.P7 cottage-door / dungeon-wall fix.
/// </summary>
[Fact]
public void W1_BspOnlyDispatch_DoorStateStillDispatchesBspOnly()
public void BspOnlyDispatch_DoorStateStillDispatchesBspOnly()
{
// Cottage door state from A6.P7 investigation: 0x10008 = STATIC | REPORT | HAS_BSP
const uint doorState = 0x00010008u;

View file

@ -19,6 +19,43 @@ public class HandleAllCollisionsTests
FramesStationaryFall = fsf,
};
[Fact]
public void SetPositionContact_SteepSurfaceIsContactButNotWalkable()
{
var body = Airborne(new Vector3(0f, 0f, -1f));
Vector3 steepNormal = Vector3.Normalize(new Vector3(1f, 0f, 0.5f));
bool onWalkable = PhysicsObjUpdate.IsWalkableContact(
inContact: true,
contactNormal: steepNormal);
PhysicsObjUpdate.ApplySetPositionContact(
body,
inContact: true,
onWalkable: onWalkable);
Assert.True(body.InContact);
Assert.False(body.OnWalkable);
Assert.Equal(PhysicsBody.Gravity, body.Acceleration.Z);
}
[Fact]
public void SetPositionContact_WalkableSurfaceSetsBothFacts()
{
var body = Airborne(new Vector3(0f, 0f, -1f));
bool onWalkable = PhysicsObjUpdate.IsWalkableContact(
inContact: true,
contactNormal: new Vector3(0f, 0f, PhysicsGlobals.FloorZ));
PhysicsObjUpdate.ApplySetPositionContact(
body,
inContact: true,
onWalkable: onWalkable);
Assert.True(body.InContact);
Assert.True(body.OnWalkable);
Assert.Equal(Vector3.Zero, body.Acceleration);
}
[Fact]
public void Fsf0_AirborneWallHit_ReflectsIntoWallComponent()
{

View file

@ -0,0 +1,94 @@
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Conformance table for retail <c>OBJECTINFO::missile_ignore</c>
/// (<c>0x0050CEB0</c>). These tests deliberately separate "has a weenie"
/// from "is a creature" because the retail branches do.
/// </summary>
public sealed class MissileCollisionRulesTests
{
private const uint Target = 0x1111u;
private const uint Other = 0x2222u;
private static ObjectInfo Missile(uint targetId = 0) => new()
{
MoverPhysicsState = PhysicsStateFlags.Missile,
TargetId = targetId,
};
[Fact]
public void OtherMissile_IsAlwaysIgnored_EvenWhenDesignated()
{
var mover = Missile(Target);
Assert.True(mover.MissileIgnore(
Target,
(uint)PhysicsStateFlags.Missile,
EntityCollisionFlags.HasWeenie));
}
[Fact]
public void NonMissileMover_DoesNotUseMissileExemptions()
{
var mover = new ObjectInfo();
Assert.False(mover.MissileIgnore(
Other,
(uint)PhysicsStateFlags.Ethereal,
EntityCollisionFlags.HasWeenie | EntityCollisionFlags.IsCreature));
}
[Fact]
public void DesignatedTarget_IsNeverIgnoredByLaterBranches()
{
var mover = Missile(Target);
Assert.False(mover.MissileIgnore(
Target,
(uint)PhysicsStateFlags.Ethereal,
EntityCollisionFlags.HasWeenie | EntityCollisionFlags.IsCreature));
}
[Theory]
[InlineData(true, true)]
[InlineData(false, false)]
public void EtherealTarget_RequiresWeenieMetadata(bool hasWeenie, bool ignored)
{
var flags = hasWeenie
? EntityCollisionFlags.HasWeenie
: EntityCollisionFlags.None;
Assert.Equal(ignored, Missile().MissileIgnore(
Other,
(uint)PhysicsStateFlags.Ethereal,
flags));
}
[Fact]
public void KnownTarget_IgnoresOtherCreatures()
{
var mover = Missile(Target);
Assert.True(mover.MissileIgnore(
Other,
0u,
EntityCollisionFlags.HasWeenie | EntityCollisionFlags.IsCreature));
}
[Fact]
public void UnknownTarget_DoesNotIgnoreCreatures()
{
Assert.False(Missile().MissileIgnore(
Other,
0u,
EntityCollisionFlags.HasWeenie | EntityCollisionFlags.IsCreature));
}
[Fact]
public void KnownTarget_DoesNotIgnoreNonCreatureWeenie()
{
Assert.False(Missile(Target).MissileIgnore(
Other,
0u,
EntityCollisionFlags.HasWeenie));
}
}

View file

@ -459,7 +459,7 @@ public sealed class PhysicsBodyTests
// ════════════════════════════════════════════════════════════════════
[Fact]
public void update_object_dt_below_min_quantum_does_not_advance()
public void update_object_dt_below_min_quantum_accumulates_without_advancing()
{
var body = MakeAirborne();
body.Velocity = new Vector3(1f, 0f, 0f);
@ -470,6 +470,7 @@ public sealed class PhysicsBodyTests
body.update_object(PhysicsBody.MinQuantum * 0.5);
Assert.Equal(Vector3.Zero, body.Position);
Assert.Equal(0d, body.LastUpdateTime);
}
[Fact]
@ -516,6 +517,17 @@ public sealed class PhysicsBodyTests
Assert.Equal(t, body.LastUpdateTime, precision: 10);
}
[Fact]
public void update_object_micro_fragment_is_consumed()
{
var body = MakeAirborne();
body.update_object(PhysicsGlobals.EPSILON * 0.5);
Assert.Equal(PhysicsGlobals.EPSILON * 0.5, body.LastUpdateTime, precision: 10);
Assert.Equal(Vector3.Zero, body.Position);
}
[Fact]
public void update_object_gravity_free_fall_accumulates_downward_velocity()
{

View file

@ -0,0 +1,750 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Pure-Core conformance and adversarial coverage for the retail projectile
/// step (<c>CPhysicsObj::update_object</c> 0x00515D10 through
/// <c>UpdateObjectInternal</c> 0x005156B0).
/// </summary>
public sealed class ProjectilePhysicsStepperTests
{
private const uint Landblock = 0xA9B40000u;
private const uint Cell = 0xA9B40001u;
private const uint ProjectileId = 0x7000u;
private static readonly ProjectileCollisionSphere ArrowSphere =
new(Vector3.Zero, 0.10f);
[Fact]
public void CollisionSphere_ScalesOffsetAndRadius_WithoutCenteringForceBolt()
{
var sphere = new ProjectileCollisionSphere(
new Vector3(0f, -0.165f, 0.1045f),
0.102f,
2f);
Assert.Equal(new Vector3(0f, -0.33f, 0.209f), sphere.LocalOrigin);
Assert.Equal(0.204f, sphere.Radius, 5);
Assert.True(sphere.IsValid);
}
[Fact]
public void CollisionSphere_RejectsInvalidComputedShape()
{
Assert.False(new ProjectileCollisionSphere(
Vector3.Zero, float.MaxValue, 2f).IsValid);
Assert.False(new ProjectileCollisionSphere(
new Vector3(float.MaxValue, 0f, 0f), 0.1f, 2f).IsValid);
Assert.False(new ProjectileCollisionSphere(
Vector3.Zero, 0.0003f, 0.5f).IsValid);
Assert.False(new ProjectileCollisionSphere(
Vector3.Zero, 0.1f, -1f).IsValid);
Assert.False(new ProjectileCollisionSphere(
Vector3.One, -0.1f, -1f).IsValid);
}
[Theory]
[InlineData(0f, 1f, 0f)]
[InlineData(1f, 0f, 0f)]
[InlineData(0f, 0f, 1f)]
[InlineData(0f, 0f, -1f)]
[InlineData(1f, 2f, 3f)]
public void SetVectorHeading_MapsLocalForwardToFull3dDirection(float x, float y, float z)
{
Vector3 direction = Vector3.Normalize(new Vector3(x, y, z));
Quaternion orientation = RetailFrameMath.SetVectorHeading(
Quaternion.Identity,
direction);
Vector3 actual = Vector3.Transform(Vector3.UnitY, orientation);
AssertVectorNear(direction, actual, 0.0001f);
}
[Fact]
public void SetVectorHeading_ZeroDirection_PreservesOrientation()
{
Quaternion original = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.7f);
Assert.Equal(original, RetailFrameMath.SetVectorHeading(original, Vector3.Zero));
}
[Theory]
[InlineData(0.0001f, 0f, 1f)]
[InlineData(0.000001f, -0.000002f, -1f)]
public void SetVectorHeading_NearlyVertical_PreservesNonzeroCompass(
float x, float y, float z)
{
Vector3 expected = Vector3.Normalize(new Vector3(x, y, z));
Quaternion orientation = RetailFrameMath.SetVectorHeading(
Quaternion.Identity,
expected);
AssertVectorNear(
expected,
Vector3.Transform(Vector3.UnitY, orientation),
0.000001f);
}
[Fact]
public void Advance_ClampsVelocityToFiftyBeforeIntegrating()
{
var (engine, _) = BuildEmptyEngine();
var body = MakeBody(new Vector3(12f, 10f, 2f), new Vector3(0f, 100f, 0f));
var result = new ProjectilePhysicsStepper(engine).Advance(
body, 0.04, Cell, ArrowSphere, ProjectileId);
Assert.True(result.Simulated);
Assert.Equal(50f, body.Velocity.Length(), 4);
Assert.Equal(12f, body.Position.Y, 3); // 10 + 50 * 0.04
Assert.Equal(50f, body.CachedVelocity.Y, 3);
}
[Fact]
public void Advance_GravityUsesFinalPhysicsState_NotParsedAcceleration()
{
var (engine, _) = BuildEmptyEngine();
var body = MakeBody(
new Vector3(12f, 10f, 10f),
new Vector3(0f, 10f, 0f),
PhysicsStateFlags.Missile | PhysicsStateFlags.Gravity);
body.Acceleration = new Vector3(100f, 200f, 300f); // wire value must not drive runtime
new ProjectilePhysicsStepper(engine).Advance(
body, 0.04, Cell, ArrowSphere, ProjectileId);
Assert.Equal(12f, body.Position.X, 4);
Assert.Equal(10.4f, body.Position.Y, 4);
Assert.Equal(10f + 0.5f * PhysicsBody.Gravity * 0.04f * 0.04f,
body.Position.Z, 4);
Assert.Equal(PhysicsBody.Gravity * 0.04f, body.Velocity.Z, 4);
}
[Fact]
public void Advance_NoGravity_ClearsStaleAcceleration()
{
var (engine, _) = BuildEmptyEngine();
var body = MakeBody(new Vector3(12f, 10f, 10f), new Vector3(0f, 10f, 0f));
body.Acceleration = new Vector3(100f, 200f, 300f);
new ProjectilePhysicsStepper(engine).Advance(
body, 0.04, Cell, ArrowSphere, ProjectileId);
Assert.Equal(Vector3.Zero, body.Acceleration);
Assert.Equal(10f, body.Position.Z, 4);
}
[Theory]
[InlineData(0f, 10f, 0f)]
[InlineData(10f, 0f, 0f)]
[InlineData(0f, 0f, 10f)]
[InlineData(3f, 4f, 5f)]
public void Advance_AlignPathUsesActualThreeDimensionalDisplacement(float x, float y, float z)
{
var (engine, _) = BuildEmptyEngine();
Vector3 velocity = new(x, y, z);
var body = MakeBody(
new Vector3(12f, 10f, 10f),
velocity,
PhysicsStateFlags.Missile | PhysicsStateFlags.AlignPath);
new ProjectilePhysicsStepper(engine).Advance(
body, 0.04, Cell, ArrowSphere, ProjectileId);
Vector3 heading = Vector3.Transform(Vector3.UnitY, body.Orientation);
AssertVectorNear(Vector3.Normalize(velocity), heading, 0.0002f);
}
[Fact]
public void UpdatePhysicsInternal_AngularVelocityPremultipliesInWorldSpace()
{
var body = new PhysicsBody
{
Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 0.5f),
Omega = new Vector3(0f, 0f, 2f),
};
Quaternion worldDelta = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.2f);
Quaternion expected = Quaternion.Normalize(worldDelta * body.Orientation);
body.UpdatePhysicsInternal(0.1f);
AssertQuaternionEquivalent(expected, body.Orientation, 0.0001f);
}
[Fact]
public void Advance_RotatingProjectileWithoutAlignPathKeepsOmegaSpin()
{
var (engine, _) = BuildEmptyEngine();
var body = MakeBody(new Vector3(12f, 10f, 10f), new Vector3(0f, 2f, 0f));
body.Omega = new Vector3(0f, 0f, 4f);
new ProjectilePhysicsStepper(engine).Advance(
body, 0.04, Cell, ArrowSphere, ProjectileId);
Quaternion expected = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.16f);
AssertQuaternionEquivalent(expected, body.Orientation, 0.0001f);
}
[Fact]
public void Advance_ExclusionsClearActiveAndDoNotMove()
{
var (engine, _) = BuildEmptyEngine();
var stepper = new ProjectilePhysicsStepper(engine);
var cases = new[]
{
(body: MakeBody(new(12f, 10f, 2f), Vector3.UnitY), parented: true),
(body: MakeBody(new(12f, 10f, 2f), Vector3.UnitY,
PhysicsStateFlags.Missile | PhysicsStateFlags.Frozen), parented: false),
(body: MakeBody(new(12f, 10f, 2f), Vector3.UnitY,
PhysicsStateFlags.Missile | PhysicsStateFlags.Static), parented: false),
};
cases[2].body.InWorld = true;
foreach (var item in cases)
{
Vector3 before = item.body.Position;
var result = stepper.Advance(
item.body, 0.04, Cell, ArrowSphere, ProjectileId,
isParented: item.parented);
Assert.False(result.Simulated);
Assert.False(item.body.IsActive);
Assert.Equal(before, item.body.Position);
}
var outOfWorld = MakeBody(new(12f, 10f, 2f), Vector3.UnitY);
outOfWorld.InWorld = false;
Assert.False(stepper.Advance(
outOfWorld, 0.04, Cell, ArrowSphere, ProjectileId).Simulated);
Assert.False(outOfWorld.IsActive);
}
[Fact]
public void Advance_AccumulatesSmallRemainderUntilRetailMinimumQuantum()
{
var (engine, _) = BuildEmptyEngine();
var body = MakeBody(new Vector3(12f, 10f, 2f), Vector3.UnitY);
var stepper = new ProjectilePhysicsStepper(engine);
var first = stepper.Advance(body, 0.02, Cell, ArrowSphere, ProjectileId);
Assert.False(first.Simulated);
Assert.Equal(0d, body.LastUpdateTime, 8);
var second = stepper.Advance(body, 0.04, Cell, ArrowSphere, ProjectileId);
Assert.True(second.Simulated);
Assert.Equal(10.04f, body.Position.Y, 4);
Assert.Equal(0.04d, body.LastUpdateTime, 8);
}
[Fact]
public void Advance_ExactMinimumQuantum_RemainsAccumulated()
{
var (engine, _) = BuildEmptyEngine();
var body = MakeBody(new Vector3(12f, 10f, 2f), Vector3.UnitY);
var result = new ProjectilePhysicsStepper(engine).Advance(
body, PhysicsBody.MinQuantum, Cell, ArrowSphere, ProjectileId);
Assert.False(result.Simulated);
Assert.Equal(0d, body.LastUpdateTime, 8);
Assert.Equal(10f, body.Position.Y);
}
[Fact]
public void Advance_ExactMaximumQuantum_UsesOneQuantum()
{
var (engine, _) = BuildEmptyEngine();
var body = MakeBody(new Vector3(12f, 10f, 2f), Vector3.UnitY);
var result = new ProjectilePhysicsStepper(engine).Advance(
body, PhysicsBody.MaxQuantum, Cell, ArrowSphere, ProjectileId);
Assert.Equal(1, result.QuantumCount);
Assert.Equal(10.2f, body.Position.Y, 4);
Assert.Equal((double)PhysicsBody.MaxQuantum, body.LastUpdateTime, 7);
}
[Fact]
public void Advance_ExactHugeQuantum_IsStillSimulated()
{
var (engine, _) = BuildEmptyEngine();
var body = MakeBody(new Vector3(12f, 10f, 2f), Vector3.UnitY);
var result = new ProjectilePhysicsStepper(engine).Advance(
body, PhysicsBody.HugeQuantum, Cell, ArrowSphere, ProjectileId);
Assert.Equal(10, result.QuantumCount);
Assert.Equal(12f, body.Position.Y, 3);
Assert.Equal((double)PhysicsBody.HugeQuantum, body.LastUpdateTime, 7);
}
[Theory]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
[InlineData(double.NegativeInfinity)]
public void Advance_NonFiniteClock_IsRejectedWithoutMutation(double currentTime)
{
var (engine, _) = BuildEmptyEngine();
var body = MakeBody(new Vector3(12f, 10f, 2f), Vector3.UnitY);
Vector3 before = body.Position;
Assert.Throws<ArgumentOutOfRangeException>(() =>
new ProjectilePhysicsStepper(engine).Advance(
body, currentTime, Cell, ArrowSphere, ProjectileId));
Assert.Equal(before, body.Position);
Assert.Equal(0d, body.LastUpdateTime);
}
[Theory]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
[InlineData(double.NegativeInfinity)]
public void Advance_NonFinitePriorClock_IsRejectedWithoutMutation(double priorTime)
{
var (engine, _) = BuildEmptyEngine();
var body = MakeBody(new Vector3(12f, 10f, 2f), Vector3.UnitY);
body.LastUpdateTime = priorTime;
Vector3 before = body.Position;
Assert.Throws<InvalidOperationException>(() =>
new ProjectilePhysicsStepper(engine).Advance(
body, 1d, Cell, ArrowSphere, ProjectileId));
Assert.Equal(before, body.Position);
Assert.Equal(priorTime, body.LastUpdateTime);
}
[Fact]
public void Advance_CatchupUsesPointTwoSecondQuantaAndRetainsNoLargeRemainder()
{
var (engine, _) = BuildEmptyEngine();
var body = MakeBody(new Vector3(12f, 10f, 2f), new Vector3(0f, 50f, 0f));
var result = new ProjectilePhysicsStepper(engine).Advance(
body, 0.45, Cell, ArrowSphere, ProjectileId);
Assert.Equal(3, result.QuantumCount); // 0.2 + 0.2 + 0.05
Assert.Equal(32.5f, body.Position.Y, 3);
Assert.Equal(0.45d, body.LastUpdateTime, 8);
}
[Fact]
public void Advance_GapAboveTwoSecondsIsDiscarded()
{
var (engine, _) = BuildEmptyEngine();
var body = MakeBody(new Vector3(12f, 10f, 2f), Vector3.UnitY);
var result = new ProjectilePhysicsStepper(engine).Advance(
body, 2.01, Cell, ArrowSphere, ProjectileId);
Assert.False(result.Simulated);
Assert.Equal(new Vector3(12f, 10f, 2f), body.Position);
Assert.Equal(2.01d, body.LastUpdateTime, 8);
}
[Fact]
public void Sweep_AtMaximumSpeed_DoesNotTunnelThroughThinSphere()
{
var (engine, _) = BuildEmptyEngine();
RegisterSphere(engine, 0x8100u, new Vector3(12f, 11f, 2f), 0.01f);
var body = MakeBody(
new Vector3(12f, 10f, 2f),
new Vector3(0f, 50f, 0f),
PhysicsStateFlags.Missile | PhysicsStateFlags.Inelastic);
var result = new ProjectilePhysicsStepper(engine).Advance(
body, 0.04, Cell, ArrowSphere, ProjectileId);
Assert.True(result.CollisionNormalValid);
Assert.True(body.Position.Y < 11.1f, $"Projectile tunneled to Y={body.Position.Y}");
Assert.Equal(Vector3.Zero, body.Velocity);
}
[Fact]
public void Sweep_ElasticProjectileReflectsFromObjectSurface()
{
var (engine, _) = BuildEmptyEngine();
RegisterSphere(engine, 0x8101u, new Vector3(12f, 11f, 2f), 0.01f);
var body = MakeBody(
new Vector3(12f, 10f, 2f),
new Vector3(0f, 50f, 0f));
body.Elasticity = 0.5f;
var result = new ProjectilePhysicsStepper(engine).Advance(
body, 0.04, Cell, ArrowSphere, ProjectileId);
Assert.True(result.CollisionNormalValid);
Assert.Equal(-25f, body.Velocity.Y, 2);
}
[Fact]
public void Sweep_DownwardProjectileCollidesWithTerrainFloor()
{
var engine = BuildTerrainEngine(0f);
var body = MakeBody(
new Vector3(12f, 10f, 0.3f),
new Vector3(0f, 0f, -10f),
PhysicsStateFlags.Missile | PhysicsStateFlags.Inelastic);
var result = new ProjectilePhysicsStepper(engine).Advance(
body, 0.04, Cell, ArrowSphere, ProjectileId);
Assert.True(result.CollisionNormalValid);
Assert.True(body.Position.Z >= 0.09f);
Assert.Equal(Vector3.Zero, body.Velocity);
Assert.True(body.InContact);
Assert.True(body.OnWalkable);
Vector3 settled = body.Position;
var settleTick = new ProjectilePhysicsStepper(engine).Advance(
body, 0.08, Cell, ArrowSphere, ProjectileId);
Assert.True(settleTick.Simulated);
Assert.Equal(settled, body.Position);
Assert.False(body.IsActive);
var inactiveTick = new ProjectilePhysicsStepper(engine).Advance(
body, 0.12, Cell, ArrowSphere, ProjectileId);
Assert.False(inactiveTick.Simulated);
Assert.Equal(settled, body.Position);
}
[Fact]
public void Sweep_FailedTransition_KeepsCandidateFrameAndCurrentCell()
{
var engine = BuildBoundaryEngine();
var body = MakeBody(
new Vector3(10f, 191f, 50f), new Vector3(0f, 50f, 0f),
PhysicsStateFlags.Missile | PhysicsStateFlags.Inelastic,
cellId: 0xA9B40008u,
cellLocal: new Vector3(10f, 191f, 50f));
body.SlidingNormal = -Vector3.UnitY;
body.TransientState |=
TransientStateFlags.Sliding | TransientStateFlags.StationaryStop;
body.FramesStationaryFall = 2;
body.ContactPlaneValid = true;
body.ContactPlane = new Plane(Vector3.UnitZ, -50f);
body.ContactPlaneCellId = 0xA9B40008u;
body.ContactPlaneIsWater = true;
body.WalkablePolygonValid = true;
body.WalkablePlane = new Plane(Vector3.UnitZ, -50f);
body.WalkableVertices =
[
new Vector3(0f, 0f, 50f),
new Vector3(1f, 0f, 50f),
new Vector3(0f, 1f, 50f),
];
body.WalkableUp = Vector3.UnitZ;
Plane priorContactPlane = body.ContactPlane;
Vector3[] priorWalkableVertices = (Vector3[])body.WalkableVertices.Clone();
TransientStateFlags priorTransient = body.TransientState;
var result = new ProjectilePhysicsStepper(engine).Advance(
body, 0.04, 0xA9B40008u, ArrowSphere, ProjectileId);
Assert.False(result.TransitionOk);
Assert.Equal(0xA9B40008u, result.CellId);
Assert.Equal(193f, body.Position.Y, 3);
Assert.Equal(0xA9B40008u, body.CellPosition.ObjCellId);
Assert.Equal(193f, body.CellPosition.Frame.Origin.Y, 3);
Assert.Equal(Vector3.Zero, body.CachedVelocity);
Assert.Equal(50f, body.Velocity.Y);
Assert.True(body.ContactPlaneValid);
Assert.Equal(priorContactPlane, body.ContactPlane);
Assert.Equal(0xA9B40008u, body.ContactPlaneCellId);
Assert.True(body.ContactPlaneIsWater);
Assert.True(body.WalkablePolygonValid);
Assert.Equal(priorWalkableVertices, body.WalkableVertices);
Assert.Equal(Vector3.UnitZ, body.WalkableUp);
Assert.Equal(2, body.FramesStationaryFall);
Assert.Equal(priorTransient, body.TransientState);
}
[Fact]
public void Sweep_SelfShadowIsExcluded()
{
var (engine, _) = BuildEmptyEngine();
RegisterSphere(engine, ProjectileId, new Vector3(12f, 11f, 2f), 0.01f);
var body = MakeBody(new(12f, 10f, 2f), new(0f, 50f, 0f));
new ProjectilePhysicsStepper(engine).Advance(
body, 0.04, Cell, ArrowSphere, ProjectileId);
Assert.Equal(12f, body.Position.Y, 3);
}
[Fact]
public void Sweep_OtherMissileIsIgnored()
{
var (engine, _) = BuildEmptyEngine();
RegisterSphere(
engine, 0x8200u, new Vector3(12f, 11f, 2f), 0.01f,
(uint)PhysicsStateFlags.Missile,
EntityCollisionFlags.HasWeenie);
var body = MakeBody(new(12f, 10f, 2f), new(0f, 50f, 0f));
new ProjectilePhysicsStepper(engine).Advance(
body, 0.04, Cell, ArrowSphere, ProjectileId);
Assert.Equal(12f, body.Position.Y, 3);
}
[Fact]
public void Sweep_EtherealWeenieIsIgnored()
{
var (ignoreEngine, _) = BuildEmptyEngine();
RegisterSphere(
ignoreEngine, 0x8300u, new Vector3(12f, 11f, 2f), 0.01f,
(uint)PhysicsStateFlags.Ethereal,
EntityCollisionFlags.HasWeenie);
var ignoredBody = MakeBody(new(12f, 10f, 2f), new(0f, 50f, 0f));
new ProjectilePhysicsStepper(ignoreEngine).Advance(
ignoredBody, 0.04, Cell, ArrowSphere, ProjectileId);
Assert.Equal(12f, ignoredBody.Position.Y, 3);
}
[Fact]
public void Sweep_WithKnownTarget_IgnoresOtherCreatureAndHitsDesignatedCreature()
{
var (engine, _) = BuildEmptyEngine();
const uint targetId = 0x8401u;
var creature = EntityCollisionFlags.HasWeenie | EntityCollisionFlags.IsCreature;
RegisterSphere(engine, 0x8400u, new(12f, 10.8f, 2f), 0.05f, 0u, creature);
RegisterSphere(engine, targetId, new(12f, 11.5f, 2f), 0.05f, 0u, creature);
var body = MakeBody(
new(12f, 10f, 2f), new(0f, 50f, 0f),
PhysicsStateFlags.Missile | PhysicsStateFlags.Inelastic);
var result = new ProjectilePhysicsStepper(engine).Advance(
body, 0.04, Cell, ArrowSphere, ProjectileId, targetId);
Assert.True(result.CollisionNormalValid);
Assert.True(body.Position.Y > 10.8f, "The non-designated creature blocked the missile");
Assert.True(body.Position.Y < 11.6f, "The designated creature was skipped");
}
[Fact]
public void Sweep_UnknownTargetCollidesWithCreature()
{
var (engine, _) = BuildEmptyEngine();
RegisterSphere(
engine, 0x8500u, new(12f, 11f, 2f), 0.05f, 0u,
EntityCollisionFlags.HasWeenie | EntityCollisionFlags.IsCreature);
var body = MakeBody(
new(12f, 10f, 2f), new(0f, 50f, 0f),
PhysicsStateFlags.Missile | PhysicsStateFlags.Inelastic);
var result = new ProjectilePhysicsStepper(engine).Advance(
body, 0.04, Cell, ArrowSphere, ProjectileId);
Assert.True(result.CollisionNormalValid);
}
[Fact]
public void Sweep_ThinBspWallStopsInelasticProjectile()
{
var (engine, cache) = BuildEmptyEngine();
RegisterThinBspWall(engine, cache, y: 11f);
var body = MakeBody(
new(12f, 10f, 2f), new(0f, 50f, 0f),
PhysicsStateFlags.Missile | PhysicsStateFlags.Inelastic);
var result = new ProjectilePhysicsStepper(engine).Advance(
body, 0.04, Cell, ArrowSphere, ProjectileId);
Assert.True(result.CollisionNormalValid);
Assert.True(body.Position.Y < 11.1f);
Assert.Equal(Vector3.Zero, body.Velocity);
}
[Theory]
[InlineData(191f, 10f, 40f, 0f, 0xA9B40039u, 0xAAB40001u)]
[InlineData( 1f, 10f, -40f, 0f, 0xA9B40001u, 0xA8B40039u)]
[InlineData( 10f,191f, 0f, 40f, 0xA9B40008u, 0xA9B50001u)]
[InlineData( 10f, 1f, 0f, -40f, 0xA9B40001u, 0xA9B30008u)]
public void Sweep_CrossesLoadedLandblockBoundaryInEveryDirection(
float startX, float startY,
float velocityX, float velocityY,
uint startCell, uint expectedCell)
{
var engine = BuildBoundaryEngine();
var body = MakeBody(
new(startX, startY, 50f), new(velocityX, velocityY, 0f),
PhysicsStateFlags.Missile,
cellId: startCell,
cellLocal: new(startX, startY, 50f));
var result = new ProjectilePhysicsStepper(engine).Advance(
body, 0.05, startCell,
new ProjectileCollisionSphere(Vector3.Zero, 0.5f),
ProjectileId);
Assert.Equal(expectedCell, result.CellId);
Assert.Equal(startX + (velocityX * 0.05f), body.Position.X, 3);
Assert.Equal(startY + (velocityY * 0.05f), body.Position.Y, 3);
}
private static PhysicsBody MakeBody(
Vector3 position,
Vector3 velocity,
PhysicsStateFlags state = PhysicsStateFlags.Missile,
uint cellId = Cell,
Vector3? cellLocal = null)
{
var body = new PhysicsBody
{
State = state,
Position = position,
Orientation = Quaternion.Identity,
LastUpdateTime = 0d,
};
body.SnapToCell(cellId, position, cellLocal ?? position);
body.Velocity = velocity; // allow UpdatePhysicsInternal itself to prove the 50-unit clamp
body.TransientState = TransientStateFlags.Active;
return body;
}
private static (PhysicsEngine Engine, PhysicsDataCache Cache) BuildEmptyEngine()
{
var cache = new PhysicsDataCache();
var engine = new PhysicsEngine { DataCache = cache };
RegisterTerrain(engine, -1000f);
return (engine, cache);
}
private static PhysicsEngine BuildTerrainEngine(float height)
{
var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() };
RegisterTerrain(engine, height);
return engine;
}
private static void RegisterTerrain(PhysicsEngine engine, float height)
{
var heights = new byte[81];
var heightTable = new float[256];
for (int i = 0; i < heightTable.Length; i++)
heightTable[i] = height;
engine.AddLandblock(
Landblock,
new TerrainSurface(heights, heightTable),
System.Array.Empty<CellSurface>(),
System.Array.Empty<PortalPlane>(),
0f,
0f);
}
private static PhysicsEngine BuildBoundaryEngine()
{
static TerrainSurface EmptyTerrain()
{
var heights = new byte[81];
var table = new float[256];
for (int i = 0; i < table.Length; i++) table[i] = -1000f;
return new TerrainSurface(heights, table);
}
var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() };
engine.AddLandblock(0xA9B4FFFFu, EmptyTerrain(), System.Array.Empty<CellSurface>(),
System.Array.Empty<PortalPlane>(), 0f, 0f);
engine.AddLandblock(0xA8B4FFFFu, EmptyTerrain(), System.Array.Empty<CellSurface>(),
System.Array.Empty<PortalPlane>(), -192f, 0f);
engine.AddLandblock(0xAAB4FFFFu, EmptyTerrain(), System.Array.Empty<CellSurface>(),
System.Array.Empty<PortalPlane>(), 192f, 0f);
engine.AddLandblock(0xA9B3FFFFu, EmptyTerrain(), System.Array.Empty<CellSurface>(),
System.Array.Empty<PortalPlane>(), 0f, -192f);
engine.AddLandblock(0xA9B5FFFFu, EmptyTerrain(), System.Array.Empty<CellSurface>(),
System.Array.Empty<PortalPlane>(), 0f, 192f);
return engine;
}
private static void RegisterSphere(
PhysicsEngine engine,
uint id,
Vector3 position,
float radius,
uint state = (uint)PhysicsStateFlags.Static,
EntityCollisionFlags flags = EntityCollisionFlags.None)
{
engine.ShadowObjects.Register(
id,
0u,
position,
Quaternion.Identity,
radius,
0f,
0f,
Landblock,
ShadowCollisionType.Sphere,
state: state,
flags: flags,
isStatic: (state & (uint)PhysicsStateFlags.Static) != 0);
}
private static void RegisterThinBspWall(
PhysicsEngine engine,
PhysicsDataCache cache,
float y)
{
const uint gfxId = 0x0100F001u;
const uint entityId = 0x8600u;
var vertices = new[]
{
new Vector3(-2f, 0f, -2f),
new Vector3( 2f, 0f, -2f),
new Vector3( 2f, 0f, 2f),
new Vector3(-2f, 0f, 2f),
};
var polygon = new ResolvedPolygon
{
Vertices = vertices,
Plane = new Plane(new Vector3(0f, -1f, 0f), 0f),
NumPoints = 4,
SidesType = CullMode.None,
};
var leaf = new PhysicsBSPNode
{
Type = BSPNodeType.Leaf,
BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 4f },
};
leaf.Polygons.Add(0);
cache.RegisterGfxObjForTest(gfxId, new GfxObjPhysics
{
BSP = new PhysicsBSPTree { Root = leaf },
PhysicsPolygons = new Dictionary<ushort, Polygon>(),
Vertices = new VertexArray(),
Resolved = new Dictionary<ushort, ResolvedPolygon> { [0] = polygon },
BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 4f },
});
engine.ShadowObjects.Register(
entityId,
gfxId,
new Vector3(12f, y, 2f),
Quaternion.Identity,
4f,
0f,
0f,
Landblock,
ShadowCollisionType.BSP,
state: (uint)(PhysicsStateFlags.Static | PhysicsStateFlags.HasPhysicsBsp),
isStatic: true);
}
private static void AssertVectorNear(Vector3 expected, Vector3 actual, float epsilon)
{
Assert.InRange(Vector3.Distance(expected, actual), 0f, epsilon);
}
private static void AssertQuaternionEquivalent(
Quaternion expected,
Quaternion actual,
float epsilon)
{
float dot = MathF.Abs(Quaternion.Dot(expected, actual));
Assert.InRange(dot, 1f - epsilon, 1f + epsilon);
}
}

View file

@ -217,8 +217,7 @@ public class TransitionTests
// Arrange: flat terrain at Z=10. The sphere starts in contact with the
// surface and moves horizontally. Because the terrain stays flat the
// Contact flag should persist and no step-down is needed.
// Movement distance is kept < MaxTransitionSteps * radius to avoid the
// retail 30-step safety cap. With radius=1.0 and 15 units: 15 steps < 30.
// A 15-unit move at radius 1.0 requires 15 continuous-sweep steps.
const float groundZ = 10f;
var terrain = FlatTerrain(groundZ);
var engine = MakeEngine(terrain);
@ -284,31 +283,28 @@ public class TransitionTests
}
[Fact]
public void FindTransitionalPosition_LongSweep_ViewerBypassesStepCap()
public void FindTransitionalPosition_LongSweep_HasNoInventedStepCap()
{
// A8.F: the 30-step safety cap bailed long sweeps. Retail's
// find_transitional_position has no cap and handles viewers specially
// (calc_num_steps `state & 4` branch, acclient :272181). The camera
// spring arm (IsViewer) must not bail. radius 0.3, dist 12 → 40 steps > 30.
// Retail find_transitional_position 0x0050BDF0 iterates every step
// returned by calc_num_steps. radius 0.3, dist 12 => 40 steps.
const float groundZ = 10f;
var engine = MakeEngine(FlatTerrain(groundZ));
Vector3 from = new(50f, 50f, groundZ);
Vector3 to = new(62f, 50f, groundZ); // 12 units → 40 steps at r=0.3 (>30 cap)
// Normal mover: hits the 30-step cap, bails without moving.
var normal = MakeTransition(from, to, sphereRadius: 0.3f);
bool normalOk = normal.FindTransitionalPosition(engine);
Assert.False(normalOk);
Assert.True(normalOk);
Assert.True(normal.SpherePath.CurPos.X > from.X);
// Viewer: cap bypassed → sweep proceeds the full distance over flat ground.
// Viewer uses calc_num_steps' dedicated path and likewise completes.
var viewer = MakeTransition(from, to, sphereRadius: 0.3f);
viewer.ObjectInfo.State |= ObjectInfoState.IsViewer;
bool viewerOk = viewer.FindTransitionalPosition(engine);
Assert.True(viewerOk);
// Position must have advanced toward `to` (key invariant: viewer proceeds).
Assert.True(viewer.SpherePath.CurPos.X > from.X,
"Viewer sphere should have advanced in +X past the step cap");
"Viewer sphere should have advanced in +X through the full sweep");
}
[Fact]