diff --git a/Ymir.slnx b/Ymir.slnx
index 2db5400..53b0fac 100644
--- a/Ymir.slnx
+++ b/Ymir.slnx
@@ -4,6 +4,7 @@
+
diff --git a/docs/box3d-parity.md b/docs/box3d-parity.md
index d33f2b7..d6f129f 100644
--- a/docs/box3d-parity.md
+++ b/docs/box3d-parity.md
@@ -16,8 +16,9 @@ torque lifetime, and numeric tolerances.
`native/Ymir.Box3D` statically links the pinned C17 library into one small
shared-library facade. The facade exports only blittable sphere overlap,
-sphere cast, capsule-versus-sphere membership, and version operations. This
-keeps Box3D's alpha C ABI out of Ymir's public managed contracts.
+sphere cast, capsule-versus-sphere membership, planar pair stepping, torque
+lifetime, and version operations. This keeps Box3D's alpha C ABI out of Ymir's
+public managed contracts.
`tests/Ymir.Box3D.Parity` builds that facade through CMake and invokes it with
source-generated .NET interop. Run it with:
@@ -42,12 +43,16 @@ is retained in the pinned submodule.
includes rounded start and end caps and the same overlap slop.
- Box3D query callback order is not a contract. Ymir sorts projected results by
distance and stable entity id.
+- Contact begin facts are emitted after the world step.
+- Restitution uses the maximum of the two shape materials.
+- Applied torque is transient and cleared by each world step; a continuous
+ torque must be submitted again for the next step.
## Cut Line
The current managed collision formulas are transitional. They may exist only
-while differential tests prove compatibility. The next parity slice covers a
-planar sphere-world step, transient forces and torques, restitution mixing,
-and begin/end contact events. Once those facts are projected through a stable
-Ymir native port, the managed solver, query geometry, and spatial indexes are
-deleted rather than retained as fallback authority.
+while differential tests prove compatibility. The remaining parity slice
+covers retained world sessions, end-contact lifecycle, continuous collision,
+and replay. Once those facts are projected through a stable Ymir native port,
+the managed solver, query geometry, and spatial indexes are deleted rather
+than retained as fallback authority.
diff --git a/native/Ymir.Box3D/ymir_box3d.c b/native/Ymir.Box3D/ymir_box3d.c
index f1ed29e..0756e3c 100644
--- a/native/Ymir.Box3D/ymir_box3d.c
+++ b/native/Ymir.Box3D/ymir_box3d.c
@@ -1,4 +1,5 @@
#include
+#include
#include
#include
#include
@@ -28,11 +29,88 @@ typedef struct ymir_box3d_cast_result
float normal_y;
} ymir_box3d_cast_result;
+typedef struct ymir_box3d_body_input
+{
+ float position_x;
+ float position_y;
+ float velocity_x;
+ float velocity_y;
+ float radius;
+ float mass;
+ float restitution;
+ int is_static;
+} ymir_box3d_body_input;
+
+typedef struct ymir_box3d_body_output
+{
+ float position_x;
+ float position_y;
+ float velocity_x;
+ float velocity_y;
+ float angular_velocity;
+} ymir_box3d_body_output;
+
+typedef struct ymir_box3d_pair_step_result
+{
+ ymir_box3d_body_output body_a;
+ ymir_box3d_body_output body_b;
+ int begin_contact_count;
+ int end_contact_count;
+ int hit_contact_count;
+} ymir_box3d_pair_step_result;
+
+typedef struct ymir_box3d_torque_result
+{
+ float angular_velocity_after_applied_step;
+ float angular_velocity_after_unforced_step;
+} ymir_box3d_torque_result;
+
static b3Vec3 ymir_vec(float x, float y)
{
return (b3Vec3){x, 0.0f, y};
}
+static b3BodyId ymir_create_body(b3WorldId world_id, const ymir_box3d_body_input* input)
+{
+ b3BodyDef body_def = b3DefaultBodyDef();
+ body_def.type = input->is_static != 0 ? b3_staticBody : b3_dynamicBody;
+ body_def.position = ymir_vec(input->position_x, input->position_y);
+ body_def.linearVelocity = ymir_vec(input->velocity_x, input->velocity_y);
+ body_def.motionLocks.linearY = true;
+ body_def.motionLocks.angularX = true;
+ body_def.motionLocks.angularZ = true;
+ body_def.enableSleep = false;
+ b3BodyId body_id = b3CreateBody(world_id, &body_def);
+
+ b3ShapeDef shape_def = b3DefaultShapeDef();
+ if (input->is_static == 0)
+ {
+ float volume = 4.0f / 3.0f * B3_PI * input->radius * input->radius * input->radius;
+ shape_def.density = input->mass / volume;
+ }
+ shape_def.baseMaterial.friction = 0.0f;
+ shape_def.baseMaterial.restitution = input->restitution;
+ shape_def.enableContactEvents = true;
+ shape_def.enableHitEvents = true;
+ b3Sphere sphere = {{0.0f, 0.0f, 0.0f}, input->radius};
+ b3CreateSphereShape(body_id, &shape_def, &sphere);
+ return body_id;
+}
+
+static ymir_box3d_body_output ymir_read_body(b3BodyId body_id)
+{
+ b3Pos position = b3Body_GetPosition(body_id);
+ b3Vec3 velocity = b3Body_GetLinearVelocity(body_id);
+ b3Vec3 angular_velocity = b3Body_GetAngularVelocity(body_id);
+ return (ymir_box3d_body_output){
+ .position_x = position.x,
+ .position_y = position.z,
+ .velocity_x = velocity.x,
+ .velocity_y = velocity.z,
+ .angular_velocity = angular_velocity.y,
+ };
+}
+
YMIR_EXPORT int ymir_box3d_get_version(int* major, int* minor, int* revision)
{
b3Version version = b3GetVersion();
@@ -132,3 +210,55 @@ YMIR_EXPORT ymir_box3d_cast_result ymir_box3d_cast_sphere(
.normal_y = output.normal.z,
};
}
+
+YMIR_EXPORT void ymir_box3d_step_pair(
+ const ymir_box3d_body_input* body_a_input,
+ const ymir_box3d_body_input* body_b_input,
+ float time_step,
+ int sub_step_count,
+ float restitution_threshold,
+ ymir_box3d_pair_step_result* result)
+{
+ b3WorldDef world_def = b3DefaultWorldDef();
+ world_def.gravity = b3Vec3_zero;
+ world_def.enableSleep = false;
+ world_def.restitutionThreshold = restitution_threshold;
+ b3WorldId world_id = b3CreateWorld(&world_def);
+
+ b3BodyId body_a = ymir_create_body(world_id, body_a_input);
+ b3BodyId body_b = ymir_create_body(world_id, body_b_input);
+ b3World_Step(world_id, time_step, sub_step_count);
+
+ b3ContactEvents events = b3World_GetContactEvents(world_id);
+ result->body_a = ymir_read_body(body_a);
+ result->body_b = ymir_read_body(body_b);
+ result->begin_contact_count = events.beginCount;
+ result->end_contact_count = events.endCount;
+ result->hit_contact_count = events.hitCount;
+
+ b3DestroyWorld(world_id);
+}
+
+YMIR_EXPORT ymir_box3d_torque_result ymir_box3d_torque_lifetime(float torque, float time_step, int sub_step_count)
+{
+ b3WorldDef world_def = b3DefaultWorldDef();
+ world_def.gravity = b3Vec3_zero;
+ world_def.enableSleep = false;
+ b3WorldId world_id = b3CreateWorld(&world_def);
+
+ ymir_box3d_body_input input = {
+ .radius = 1.0f,
+ .mass = 1.0f,
+ .is_static = 0,
+ };
+ b3BodyId body_id = ymir_create_body(world_id, &input);
+ b3Body_ApplyTorque(body_id, (b3Vec3){0.0f, torque, 0.0f}, true);
+ b3World_Step(world_id, time_step, sub_step_count);
+ float after_applied_step = b3Body_GetAngularVelocity(body_id).y;
+
+ b3World_Step(world_id, time_step, sub_step_count);
+ float after_unforced_step = b3Body_GetAngularVelocity(body_id).y;
+ b3DestroyWorld(world_id);
+
+ return (ymir_box3d_torque_result){after_applied_step, after_unforced_step};
+}
diff --git a/tests/Ymir.Box3D.Parity/Box3DOracle.cs b/tests/Ymir.Box3D.Parity/Box3DOracle.cs
index ca9345c..5e501b0 100644
--- a/tests/Ymir.Box3D.Parity/Box3DOracle.cs
+++ b/tests/Ymir.Box3D.Parity/Box3DOracle.cs
@@ -40,6 +40,18 @@ internal static partial class Box3DOracle
float bodyY,
float bodyRadius,
int canEncroach);
+
+ [LibraryImport(LibraryName)]
+ internal static partial void ymir_box3d_step_pair(
+ in Box3DBodyInput bodyA,
+ in Box3DBodyInput bodyB,
+ float timeStep,
+ int subStepCount,
+ float restitutionThreshold,
+ out Box3DPairStepResult result);
+
+ [LibraryImport(LibraryName)]
+ internal static partial Box3DTorqueResult ymir_box3d_torque_lifetime(float torque, float timeStep, int subStepCount);
}
[StructLayout(LayoutKind.Sequential)]
@@ -58,3 +70,35 @@ internal readonly record struct Box3DCastResult(
{
public bool IsHit => Hit != 0;
}
+
+[StructLayout(LayoutKind.Sequential)]
+internal readonly record struct Box3DBodyInput(
+ float PositionX,
+ float PositionY,
+ float VelocityX,
+ float VelocityY,
+ float Radius,
+ float Mass,
+ float Restitution,
+ int IsStatic);
+
+[StructLayout(LayoutKind.Sequential)]
+internal readonly record struct Box3DBodyOutput(
+ float PositionX,
+ float PositionY,
+ float VelocityX,
+ float VelocityY,
+ float AngularVelocity);
+
+[StructLayout(LayoutKind.Sequential)]
+internal readonly record struct Box3DPairStepResult(
+ Box3DBodyOutput BodyA,
+ Box3DBodyOutput BodyB,
+ int BeginContactCount,
+ int EndContactCount,
+ int HitContactCount);
+
+[StructLayout(LayoutKind.Sequential)]
+internal readonly record struct Box3DTorqueResult(
+ float AngularVelocityAfterAppliedStep,
+ float AngularVelocityAfterUnforcedStep);
diff --git a/tests/Ymir.Box3D.Parity/Box3DParityTests.cs b/tests/Ymir.Box3D.Parity/Box3DParityTests.cs
index 2c740cd..828c501 100644
--- a/tests/Ymir.Box3D.Parity/Box3DParityTests.cs
+++ b/tests/Ymir.Box3D.Parity/Box3DParityTests.cs
@@ -83,4 +83,41 @@ public sealed class Box3DParityTests
Assert.True(result.IsHit);
}
+
+ [Fact]
+ public void PairStepEmitsBeginContactAndSeparatesOverlappingSpheres()
+ {
+ var bodyA = new Box3DBodyInput(-0.9f, 0.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0);
+ var bodyB = new Box3DBodyInput(0.9f, 0.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0);
+
+ Box3DOracle.ymir_box3d_step_pair(in bodyA, in bodyB, 1.0f / 60.0f, 4, 0.0f, out var result);
+
+ Assert.Equal(1, result.BeginContactCount);
+ Assert.True(result.BodyA.PositionX < -0.9f);
+ Assert.True(result.BodyB.PositionX > 0.9f);
+ }
+
+ [Fact]
+ public void PairStepUsesMaximumRestitution()
+ {
+ var bodyA = new Box3DBodyInput(-1.0f, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f, 0.1f, 0);
+ var bodyB = new Box3DBodyInput(1.0f, 0.0f, -1.0f, 0.0f, 1.0f, 1.0f, 0.8f, 0);
+
+ Box3DOracle.ymir_box3d_step_pair(in bodyA, in bodyB, 1.0f / 60.0f, 4, 0.0f, out var result);
+
+ Assert.True(result.BodyA.VelocityX < -0.7f);
+ Assert.True(result.BodyB.VelocityX > 0.7f);
+ }
+
+ [Fact]
+ public void TorqueIsClearedAfterEachWorldStep()
+ {
+ var result = Box3DOracle.ymir_box3d_torque_lifetime(0.4f, 0.1f, 4);
+
+ Assert.True(result.AngularVelocityAfterAppliedStep > 0.0f);
+ Assert.Equal(
+ result.AngularVelocityAfterAppliedStep,
+ result.AngularVelocityAfterUnforcedStep,
+ precision: 5);
+ }
}
diff --git a/tests/Ymir.Box3D.Parity/Ymir.Box3D.Parity.csproj b/tests/Ymir.Box3D.Parity/Ymir.Box3D.Parity.csproj
index 78d766d..79466d8 100644
--- a/tests/Ymir.Box3D.Parity/Ymir.Box3D.Parity.csproj
+++ b/tests/Ymir.Box3D.Parity/Ymir.Box3D.Parity.csproj
@@ -8,8 +8,8 @@
false
$(MSBuildThisFileDirectory)..\..\native\Ymir.Box3D
$(MSBuildThisFileDirectory)..\..\artifacts\box3d-native\$(Configuration)
- C:\Program Files\CMake\bin\cmake.exe
- cmake
+ C:\Program Files\CMake\bin\cmake.exe
+ cmake