feat(physics): port retail projectile integration
Add a pure Core projectile driver for retail clock quanta, final-state acceleration, 50-unit velocity clamping, world-space angular integration, full-3D AlignPath, and oriented Setup-local sphere sweeps. Preserve exact success versus set_frame-only failure semantics, independent Contact/OnWalkable state, and full-cell identity across all landblock directions. Port OBJECTINFO::missile_ignore with explicit weenie/creature metadata, remove the invented transition-step cap, retain PathClipped without arming PerfectClip, and keep authoritative target identity optional. Add malformed-shape/clock, thin-wall, terrain, target-exclusion, frame-spike, failure-state, and boundary conformance coverage. Retire TS-2 and synchronize the architecture, milestones, roadmap, research oracle, and durable physics memory. Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
parent
542dcfc384
commit
f02b995e4f
21 changed files with 1669 additions and 263 deletions
|
|
@ -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:276808–276841.
|
||||
/// </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:276858–276861.
|
||||
/// </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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue