diff --git a/README.md b/README.md
index 138a991..7452b06 100644
--- a/README.md
+++ b/README.md
@@ -47,6 +47,8 @@ parity suite already witness released Box3D behavior for:
- contact-begin facts
- restitution mixing
- transient torque lifetime
+- category/mask/group collision filtering
+- bullet CCD against kinematic bodies, including Box3D's next-step contact-event timing
The production `Ymir.Box3D` boundary owns the native session ABI. The legacy
snapshot `YmirSimulator.Step` contract now creates an isolated Box3D session,
@@ -93,6 +95,11 @@ smoke is still required before that is a repeatable packaging guarantee. Linux
and macOS packages must be produced and smoked on their own release workers
before those RIDs are advertised.
+The current package version is `0.3.0`. ABI v4 exposes Box3D body type,
+bullet, field-participation, and collision-filter configuration without
+exposing Box3D handles. These are Ymir contract fields, but their behavior is
+Box3D behavior.
+
## Cutover Rule
A Ymir cutover is complete only when all runtime stepping, collision queries,
diff --git a/docs/architecture.md b/docs/architecture.md
index 4f8a989..c377e64 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -145,10 +145,11 @@ The native facade must remain boring:
## Persistence
-`gamecult.ymir.world_state.v1` appends planar direction and angular velocity to
-the v0 layout. The v0 reader is an explicit migration path; keys are not
-reassigned. Torque is transient command state and is intentionally absent.
-Version 1 is a compatibility world snapshot, not a complete retained-session
+`gamecult.ymir.world_state.v2` appends Box3D body type, bullet,
+field-participation, and collision filter arrays to the v1 layout. The v1 and
+v0 readers are explicit migration paths; keys are not reassigned. Torque is
+transient command state and is intentionally absent. Version 2 is a
+compatibility world snapshot, not a complete retained-session
checkpoint: it does not preserve full shape, material, filter, session tuning,
provenance, or warm contact state.
@@ -172,7 +173,7 @@ Box3D-backed sessions prove:
1. fixed-step body state projection;
2. begin/end/hit contact lifecycle;
3. overlap and shape-cast lowering;
-4. continuous collision and projectile witnesses;
+4. continuous collision event timing and same-step Box3D cast witnesses;
5. transient force and torque application;
6. checkpoint reconstruction and replay;
7. Aetheria gravity, tractor, pickup, and payload smokes.
diff --git a/docs/box3d-parity.md b/docs/box3d-parity.md
index 1cb7f92..cae67ca 100644
--- a/docs/box3d-parity.md
+++ b/docs/box3d-parity.md
@@ -21,7 +21,7 @@ the overlap, cast, pair-step, torque-lifetime, version, and ABI-layout
operations used by the harness. This keeps Box3D's alpha C ABI out of Ymir's
public managed contracts.
-Production ABI version 3 uses caller-owned blittable buffers, explicit cdecl,
+Production ABI version 4 uses caller-owned blittable buffers, explicit cdecl,
fixed-width status/count values, compile-time C layout assertions, and a
managed/native layout sentinel before opening a session. It adds explicit
retained body mutations, one-shot force and torque operations, and a
@@ -30,6 +30,8 @@ stored contact key only long enough to correlate its separate event arrays;
public Ymir facts never expose native contact identity. Planar direction maps to Box3D
rotation about Y; angular velocity and transient torque remain Box3D state and
commands rather than managed integration.
+ABI v4 additionally carries Box3D static/kinematic/dynamic selection, bullet
+CCD, category/mask/group filtering, and per-body radial-field participation.
`tests/Ymir.Box3D.Parity` builds that facade through CMake and invokes it with
source-generated .NET interop. Run it with:
@@ -58,6 +60,12 @@ is retained in the pinned submodule.
- 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.
+- A negative Box3D group index suppresses collision within that group before
+ category/mask evaluation. Category and mask bits then decide eligible pairs.
+- Bullet CCD against a kinematic target prevents traversal during the fast
+ step, but Box3D v0.1.0 emits the typed contact event on the following step.
+ Same-step gameplay triggering therefore requires an explicit Box3D shape
+ cast; Ymir must not invent an earlier Begin event.
## Cut Line
diff --git a/docs/retained-session-contract.md b/docs/retained-session-contract.md
index a5920dd..cc770bb 100644
--- a/docs/retained-session-contract.md
+++ b/docs/retained-session-contract.md
@@ -38,7 +38,7 @@ The retained port accepts narrow operations with explicit meaning:
- remove a body, rejecting an unknown id;
- teleport a body;
- set linear and angular velocity;
-- configure body shape, mass, type, and restitution;
+- configure body shape, mass, static/kinematic/dynamic type, and restitution;
- apply one-shot force or torque;
- step with a fixed delta, substep count, and typed fields;
- dispose the session.
@@ -46,6 +46,10 @@ The retained port accepts narrow operations with explicit meaning:
The current public slice implements those mutation and step operations.
Retained-world overlap and cast queries at a named revision are the next
library cut; the static compatibility queries do not satisfy that contract.
+Bullet, collision-filter, and field-participation profiles are currently
+spawn/restore definitions. Retained `Configure` changes radius, mass,
+static/kinematic/dynamic type, and restitution only. Changing the profile
+requires explicit remove-and-spawn until a typed profile command exists.
Restore/import is the only path allowed to accept a complete world snapshot.
There is no `Upsert`, generic patch bag, or omission-based removal.
@@ -73,7 +77,7 @@ chronology.
- Box3D handles are process-local and never cross the contract.
- DTO and SoA body arrays are projections.
-- `gamecult.ymir.world_state.v1` is an inspection/migration snapshot, not a
+- `gamecult.ymir.world_state.v2` is an inspection/migration snapshot, not a
retained-session checkpoint.
- Receipt and contact-episode ledgers are session metadata, not physics law.
- Aetheria cargo, damage, loot, feedback, and scoring remain gameplay state
diff --git a/native/Ymir.Box3D/ymir_box3d.c b/native/Ymir.Box3D/ymir_box3d.c
index 9120c2c..6d3d0f8 100644
--- a/native/Ymir.Box3D/ymir_box3d.c
+++ b/native/Ymir.Box3D/ymir_box3d.c
@@ -85,6 +85,8 @@ typedef enum ymir_box3d_status
typedef struct ymir_box3d_session_body_input
{
uint64_t stable_id;
+ uint64_t collision_category_bits;
+ uint64_t collision_mask_bits;
float position_x;
float position_y;
float velocity_x;
@@ -97,6 +99,10 @@ typedef struct ymir_box3d_session_body_input
float mass;
float restitution;
uint32_t is_static;
+ uint32_t is_kinematic;
+ uint32_t is_bullet;
+ uint32_t participates_in_fields;
+ int32_t collision_group_index;
} ymir_box3d_session_body_input;
typedef struct ymir_box3d_radial_field_input
@@ -182,6 +188,12 @@ typedef struct ymir_box3d_session_body
float mass;
float restitution;
uint32_t is_static;
+ uint32_t is_kinematic;
+ uint32_t is_bullet;
+ uint32_t participates_in_fields;
+ uint64_t collision_category_bits;
+ uint64_t collision_mask_bits;
+ int32_t collision_group_index;
int seen;
} ymir_box3d_session_body;
@@ -206,7 +218,7 @@ typedef struct ymir_box3d_session
} ymir_box3d_session;
_Static_assert(sizeof(ymir_box3d_status) == sizeof(int32_t), "Ymir Box3D status ABI must be 32-bit");
-_Static_assert(sizeof(ymir_box3d_session_body_input) == 56, "Unexpected Ymir Box3D body input layout");
+_Static_assert(sizeof(ymir_box3d_session_body_input) == 88, "Unexpected Ymir Box3D body input layout");
_Static_assert(sizeof(ymir_box3d_radial_field_input) == 16, "Unexpected Ymir Box3D field layout");
_Static_assert(sizeof(ymir_box3d_session_body_output) == 40, "Unexpected Ymir Box3D body output layout");
_Static_assert(sizeof(ymir_box3d_session_contact_output) == 40, "Unexpected Ymir Box3D contact layout");
@@ -235,7 +247,17 @@ static int ymir_session_body_input_is_valid(const ymir_box3d_session_body_input*
ymir_is_finite(input->torque) &&
ymir_is_finite(input->radius) && input->radius > 0.0f && ymir_is_finite(input->mass) &&
(input->is_static != 0 || input->mass > 0.0f) && ymir_is_finite(input->restitution) &&
- input->restitution >= 0.0f;
+ input->restitution >= 0.0f && !(input->is_static != 0 && input->is_kinematic != 0) &&
+ input->collision_category_bits != 0;
+}
+
+static b3BodyType ymir_session_body_type(const ymir_box3d_session_body_input* input)
+{
+ if (input->is_static != 0)
+ {
+ return b3_staticBody;
+ }
+ return input->is_kinematic != 0 ? b3_kinematicBody : b3_dynamicBody;
}
static b3Quat ymir_session_rotation(float direction_x, float direction_y)
@@ -374,7 +396,7 @@ static ymir_box3d_status ymir_session_create_body(
ymir_box3d_session_body* output)
{
b3BodyDef body_def = b3DefaultBodyDef();
- body_def.type = input->is_static != 0 ? b3_staticBody : b3_dynamicBody;
+ body_def.type = ymir_session_body_type(input);
body_def.position = ymir_vec(input->position_x, input->position_y);
body_def.rotation = ymir_session_rotation(input->direction_x, input->direction_y);
body_def.linearVelocity = ymir_vec(input->velocity_x, input->velocity_y);
@@ -383,6 +405,7 @@ static ymir_box3d_status ymir_session_create_body(
body_def.motionLocks.angularX = true;
body_def.motionLocks.angularZ = true;
body_def.enableSleep = false;
+ body_def.isBullet = input->is_bullet != 0;
b3BodyId body_id = b3CreateBody(session->world_id, &body_def);
if (B3_IS_NULL(body_id))
{
@@ -394,6 +417,9 @@ static ymir_box3d_status ymir_session_create_body(
shape_def.density = input->is_static != 0 ? 0.0f : ymir_session_density(input->mass, input->radius);
shape_def.baseMaterial.friction = 0.0f;
shape_def.baseMaterial.restitution = input->restitution;
+ shape_def.filter.categoryBits = input->collision_category_bits;
+ shape_def.filter.maskBits = input->collision_mask_bits;
+ shape_def.filter.groupIndex = input->collision_group_index;
shape_def.enableContactEvents = true;
shape_def.enableHitEvents = true;
b3Sphere sphere = {{0.0f, 0.0f, 0.0f}, input->radius};
@@ -420,6 +446,12 @@ static ymir_box3d_status ymir_session_create_body(
.mass = input->mass,
.restitution = input->restitution,
.is_static = input->is_static != 0,
+ .is_kinematic = input->is_kinematic != 0,
+ .is_bullet = input->is_bullet != 0,
+ .participates_in_fields = input->participates_in_fields != 0,
+ .collision_category_bits = input->collision_category_bits,
+ .collision_mask_bits = input->collision_mask_bits,
+ .collision_group_index = input->collision_group_index,
.seen = 1,
};
return YMIR_BOX3D_OK;
@@ -429,13 +461,31 @@ static void ymir_session_update_body(
ymir_box3d_session_body* body,
const ymir_box3d_session_body_input* input)
{
- int type_changed = body->is_static != (input->is_static != 0);
+ int type_changed = body->is_static != (input->is_static != 0) ||
+ body->is_kinematic != (input->is_kinematic != 0);
int radius_changed = body->radius != input->radius;
int mass_changed = body->mass != input->mass;
if (type_changed)
{
- b3Body_SetType(body->body_id, input->is_static != 0 ? b3_staticBody : b3_dynamicBody);
+ b3Body_SetType(body->body_id, ymir_session_body_type(input));
+ }
+
+ if (body->is_bullet != (input->is_bullet != 0))
+ {
+ b3Body_SetBullet(body->body_id, input->is_bullet != 0);
+ }
+
+ if (body->collision_category_bits != input->collision_category_bits ||
+ body->collision_mask_bits != input->collision_mask_bits ||
+ body->collision_group_index != input->collision_group_index)
+ {
+ b3Filter filter = {
+ .categoryBits = input->collision_category_bits,
+ .maskBits = input->collision_mask_bits,
+ .groupIndex = input->collision_group_index,
+ };
+ b3Shape_SetFilter(body->shape_id, filter, true);
}
if (radius_changed)
@@ -487,6 +537,12 @@ static void ymir_session_update_body(
body->mass = input->mass;
body->restitution = input->restitution;
body->is_static = input->is_static != 0;
+ body->is_kinematic = input->is_kinematic != 0;
+ body->is_bullet = input->is_bullet != 0;
+ body->participates_in_fields = input->participates_in_fields != 0;
+ body->collision_category_bits = input->collision_category_bits;
+ body->collision_mask_bits = input->collision_mask_bits;
+ body->collision_group_index = input->collision_group_index;
body->seen = 1;
}
@@ -1150,7 +1206,7 @@ YMIR_EXPORT ymir_box3d_torque_result YMIR_CALL ymir_box3d_torque_lifetime(float
YMIR_EXPORT uint32_t YMIR_CALL ymir_box3d_get_abi_version(void)
{
- return 3;
+ return 4;
}
YMIR_EXPORT int32_t YMIR_CALL ymir_box3d_get_abi_layout(
@@ -1403,10 +1459,12 @@ YMIR_EXPORT int32_t YMIR_CALL ymir_box3d_session_configure(
float radius,
float mass,
float restitution,
- uint32_t is_static)
+ uint32_t is_static,
+ uint32_t is_kinematic)
{
if (session == NULL || !ymir_is_finite(radius) || radius <= 0.0f || !ymir_is_finite(mass) ||
- (is_static == 0 && mass <= 0.0f) || !ymir_is_finite(restitution) || restitution < 0.0f)
+ (is_static == 0 && mass <= 0.0f) || (is_static != 0 && is_kinematic != 0) ||
+ !ymir_is_finite(restitution) || restitution < 0.0f)
{
return YMIR_BOX3D_INVALID_ARGUMENT;
}
@@ -1416,12 +1474,14 @@ YMIR_EXPORT int32_t YMIR_CALL ymir_box3d_session_configure(
return YMIR_BOX3D_INVALID_ARGUMENT;
}
ymir_box3d_session_body* body = session->bodies + index;
- int type_changed = body->is_static != (is_static != 0);
+ int type_changed = body->is_static != (is_static != 0) || body->is_kinematic != (is_kinematic != 0);
int radius_changed = body->radius != radius;
int mass_changed = body->mass != mass;
if (type_changed)
{
- b3Body_SetType(body->body_id, is_static != 0 ? b3_staticBody : b3_dynamicBody);
+ b3Body_SetType(
+ body->body_id,
+ is_static != 0 ? b3_staticBody : (is_kinematic != 0 ? b3_kinematicBody : b3_dynamicBody));
}
if (radius_changed)
{
@@ -1442,6 +1502,7 @@ YMIR_EXPORT int32_t YMIR_CALL ymir_box3d_session_configure(
body->mass = mass;
body->restitution = restitution;
body->is_static = is_static != 0;
+ body->is_kinematic = is_kinematic != 0;
ymir_box3d_status status = ymir_session_drain_contact_events(session, YMIR_DRAIN_END, 1);
return status;
}
@@ -1526,7 +1587,7 @@ YMIR_EXPORT int32_t YMIR_CALL ymir_box3d_session_step(
for (int body_index = 0; body_index < session->body_count; ++body_index)
{
ymir_box3d_session_body* body = session->bodies + body_index;
- if (body->is_static)
+ if (body->is_static || body->is_kinematic)
{
body->pending_torque = 0.0f;
continue;
@@ -1538,6 +1599,11 @@ YMIR_EXPORT int32_t YMIR_CALL ymir_box3d_session_step(
}
body->pending_torque = 0.0f;
+ if (!body->participates_in_fields)
+ {
+ continue;
+ }
+
b3Pos position = b3Body_GetPosition(body->body_id);
float acceleration_x = 0.0f;
float acceleration_y = 0.0f;
diff --git a/src/Ymir.Box3D/Box3DNative.cs b/src/Ymir.Box3D/Box3DNative.cs
index f250b7b..fb7ff83 100644
--- a/src/Ymir.Box3D/Box3DNative.cs
+++ b/src/Ymir.Box3D/Box3DNative.cs
@@ -110,7 +110,8 @@ internal static partial class Box3DNative
float radius,
float mass,
float restitution,
- uint isStatic);
+ uint isStatic,
+ uint isKinematic);
[LibraryImport(LibraryName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
@@ -191,6 +192,8 @@ internal sealed class Box3DSessionHandle : SafeHandleZeroOrMinusOneIsInvalid
[StructLayout(LayoutKind.Sequential)]
internal readonly record struct Box3DBodyInput(
ulong StableId,
+ ulong CollisionCategoryBits,
+ ulong CollisionMaskBits,
float PositionX,
float PositionZ,
float VelocityX,
@@ -202,7 +205,11 @@ internal readonly record struct Box3DBodyInput(
float Radius,
float Mass,
float Restitution,
- uint IsStatic);
+ uint IsStatic,
+ uint IsKinematic,
+ uint IsBullet,
+ uint ParticipatesInFields,
+ int CollisionGroupIndex);
[StructLayout(LayoutKind.Sequential)]
internal readonly record struct Box3DRadialField(
diff --git a/src/Ymir.Box3D/Box3DSession.cs b/src/Ymir.Box3D/Box3DSession.cs
index b6a9035..a395470 100644
--- a/src/Ymir.Box3D/Box3DSession.cs
+++ b/src/Ymir.Box3D/Box3DSession.cs
@@ -4,6 +4,8 @@ namespace Ymir.Box3D;
internal readonly record struct Box3DBody(
ulong StableId,
+ ulong CollisionCategoryBits,
+ ulong CollisionMaskBits,
float PositionX,
float PositionZ,
float VelocityX,
@@ -15,7 +17,11 @@ internal readonly record struct Box3DBody(
float Radius,
float Mass,
float Restitution,
- bool IsStatic);
+ bool IsStatic,
+ bool IsKinematic,
+ bool IsBullet,
+ bool ParticipatesInFields,
+ int CollisionGroupIndex);
internal readonly record struct Box3DField(float PositionX, float PositionZ, float Strength, float Radius);
@@ -63,7 +69,7 @@ internal readonly record struct Box3DContactFact(
internal sealed class Box3DSession : IDisposable
{
- internal const uint AbiVersion = 3;
+ internal const uint AbiVersion = 4;
internal const int DefaultSubstepCount = 4;
private readonly Box3DSessionHandle _handle;
@@ -90,6 +96,8 @@ internal sealed class Box3DSession : IDisposable
var body = bodies[i];
native[i] = new Box3DBodyInput(
body.StableId,
+ body.CollisionCategoryBits,
+ body.CollisionMaskBits,
body.PositionX,
body.PositionZ,
body.VelocityX,
@@ -101,7 +109,11 @@ internal sealed class Box3DSession : IDisposable
body.Radius,
body.Mass,
body.Restitution,
- body.IsStatic ? 1u : 0u);
+ body.IsStatic ? 1u : 0u,
+ body.IsKinematic ? 1u : 0u,
+ body.IsBullet ? 1u : 0u,
+ body.ParticipatesInFields ? 1u : 0u,
+ body.CollisionGroupIndex);
}
fixed (Box3DBodyInput* pointer = native)
@@ -155,12 +167,13 @@ internal sealed class Box3DSession : IDisposable
float radius,
float mass,
float restitution,
- bool isStatic)
+ bool isStatic,
+ bool isKinematic)
{
ThrowIfDisposed();
ThrowIfMutationFailed(
Box3DNative.ymir_box3d_session_configure(
- _handle, stableId, radius, mass, restitution, isStatic ? 1u : 0u),
+ _handle, stableId, radius, mass, restitution, isStatic ? 1u : 0u, isKinematic ? 1u : 0u),
"configure body");
}
@@ -368,6 +381,8 @@ internal sealed class Box3DSession : IDisposable
private static Box3DBodyInput ToNative(Box3DBody body) => new(
body.StableId,
+ body.CollisionCategoryBits,
+ body.CollisionMaskBits,
body.PositionX,
body.PositionZ,
body.VelocityX,
@@ -379,7 +394,11 @@ internal sealed class Box3DSession : IDisposable
body.Radius,
body.Mass,
body.Restitution,
- body.IsStatic ? 1u : 0u);
+ body.IsStatic ? 1u : 0u,
+ body.IsKinematic ? 1u : 0u,
+ body.IsBullet ? 1u : 0u,
+ body.ParticipatesInFields ? 1u : 0u,
+ body.CollisionGroupIndex);
private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this);
}
diff --git a/src/Ymir.Box3D/Ymir.Box3D.csproj b/src/Ymir.Box3D/Ymir.Box3D.csproj
index 8d5c0f2..58b2df9 100644
--- a/src/Ymir.Box3D/Ymir.Box3D.csproj
+++ b/src/Ymir.Box3D/Ymir.Box3D.csproj
@@ -9,7 +9,7 @@
Ymir.Box3D
Native ABI boundary between Ymir sessions and pinned Box3D.
GameCult.Ymir.Box3D
- 0.2.0
+ 0.3.0
GameCult
true
README.md
diff --git a/src/Ymir.Core/PhysicsModel.cs b/src/Ymir.Core/PhysicsModel.cs
index 188215c..9ed51cc 100644
--- a/src/Ymir.Core/PhysicsModel.cs
+++ b/src/Ymir.Core/PhysicsModel.cs
@@ -21,7 +21,13 @@ public sealed record PhysicsBody(
float Restitution = 0.2f,
Vec2? Direction = null,
float AngularVelocity = 0.0f,
- float Torque = 0.0f);
+ float Torque = 0.0f,
+ bool IsKinematic = false,
+ bool IsBullet = false,
+ bool ParticipatesInFields = true,
+ ulong CollisionCategoryBits = 1UL,
+ ulong CollisionMaskBits = ulong.MaxValue,
+ int CollisionGroupIndex = 0);
public sealed record RadialField(
string Id,
diff --git a/src/Ymir.Core/RetainedSessionModel.cs b/src/Ymir.Core/RetainedSessionModel.cs
index a30ea72..7f51f20 100644
--- a/src/Ymir.Core/RetainedSessionModel.cs
+++ b/src/Ymir.Core/RetainedSessionModel.cs
@@ -29,7 +29,8 @@ public sealed record YmirConfigureBodyCommand(
float Radius,
float Mass,
bool IsStatic,
- float Restitution);
+ float Restitution,
+ bool IsKinematic = false);
public sealed record YmirApplyForceCommand(
YmirCommandHeader Header,
diff --git a/src/Ymir.Core/YmirServicePublicationDocument.cs b/src/Ymir.Core/YmirServicePublicationDocument.cs
index 615692b..4770757 100644
--- a/src/Ymir.Core/YmirServicePublicationDocument.cs
+++ b/src/Ymir.Core/YmirServicePublicationDocument.cs
@@ -53,7 +53,7 @@ public sealed class YmirOperatorStateDocument
public string BatchKernel { get; set; } = "";
[Key(5)]
- public string Persistence { get; set; } = "CultCache gamecult.ymir.world_state.v1; explicit v0 read migration";
+ public string Persistence { get; set; } = "CultCache gamecult.ymir.world_state.v2; explicit v1/v0 read migration";
[Key(6)]
public string UpdatedAtUtc { get; set; } = "";
@@ -90,7 +90,7 @@ public static class YmirServicePublication
"stable GameCult id to transient Box3D handle projection",
"Box3D-backed circle overlap and cast queries over submitted bodies",
"deterministic result ordering",
- "CultCache world snapshot v1 with explicit v0 read migration",
+ "CultCache world snapshot v2 with explicit v1/v0 read migration",
"diagnostic Eve publication"
],
DoesNotOwn =
@@ -115,7 +115,7 @@ public static class YmirServicePublication
StateOwner = "In-process retained Box3D sessions, isolated snapshot steps, and diagnostic projections",
NumericSubstrate = "Box3D v0.1.0 (C17)",
BatchKernel = "Box3D native solver; Ymir does not own physics algorithms",
- Persistence = "CultCache gamecult.ymir.world_state.v1; explicit v0 read migration",
+ Persistence = "CultCache gamecult.ymir.world_state.v2; explicit v1/v0 read migration",
UpdatedAtUtc = FormatUtc(now)
};
diff --git a/src/Ymir.Core/YmirSession.cs b/src/Ymir.Core/YmirSession.cs
index e963e6c..71f5dd3 100644
--- a/src/Ymir.Core/YmirSession.cs
+++ b/src/Ymir.Core/YmirSession.cs
@@ -217,6 +217,7 @@ public sealed class YmirSession : IDisposable
writer.Write(command.Mass);
writer.Write(command.IsStatic);
writer.Write(command.Restitution);
+ writer.Write(command.IsKinematic);
});
if (Preflight(command.Header, fingerprint, "body.configure") is { } previous)
{
@@ -228,6 +229,7 @@ public sealed class YmirSession : IDisposable
}
if (!float.IsFinite(command.Radius) || command.Radius <= 0.0f ||
!float.IsFinite(command.Mass) || (!command.IsStatic && command.Mass <= 0.0f) ||
+ (command.IsStatic && command.IsKinematic) ||
!float.IsFinite(command.Restitution) || command.Restitution < 0.0f)
{
return RejectAndStore(command.Header, fingerprint, "body.configure", YmirCommandError.InvalidCommand);
@@ -235,12 +237,19 @@ public sealed class YmirSession : IDisposable
RunNative(() =>
{
- _native.Configure(key, command.Radius, command.Mass, command.Restitution, command.IsStatic);
+ _native.Configure(
+ key,
+ command.Radius,
+ command.Mass,
+ command.Restitution,
+ command.IsStatic,
+ command.IsKinematic);
_bodies[command.BodyId] = body with
{
Radius = command.Radius,
Mass = command.Mass,
IsStatic = command.IsStatic,
+ IsKinematic = command.IsKinematic,
Restitution = command.Restitution
};
UpdateBodyProjection(_native.CopyBodies());
@@ -267,7 +276,7 @@ public sealed class YmirSession : IDisposable
{
return RejectAndStore(command.Header, fingerprint, "body.apply_force", YmirCommandError.UnknownBody);
}
- if (body.IsStatic)
+ if (body.IsStatic || body.IsKinematic)
{
return RejectAndStore(command.Header, fingerprint, "body.apply_force", YmirCommandError.StaticBody);
}
@@ -299,7 +308,7 @@ public sealed class YmirSession : IDisposable
{
return RejectAndStore(command.Header, fingerprint, "body.apply_torque", YmirCommandError.UnknownBody);
}
- if (body.IsStatic)
+ if (body.IsStatic || body.IsKinematic)
{
return RejectAndStore(command.Header, fingerprint, "body.apply_torque", YmirCommandError.StaticBody);
}
@@ -791,6 +800,8 @@ public sealed class YmirSession : IDisposable
var direction = DirectionOrDefault(body.Direction);
return new Box3DBody(
key,
+ body.CollisionCategoryBits,
+ body.CollisionMaskBits,
body.Position.X,
body.Position.Y,
body.Velocity.X,
@@ -802,7 +813,11 @@ public sealed class YmirSession : IDisposable
body.Radius,
body.Mass,
body.Restitution,
- body.IsStatic);
+ body.IsStatic,
+ body.IsKinematic,
+ body.IsBullet,
+ body.ParticipatesInFields,
+ body.CollisionGroupIndex);
}
private static Vec2 DirectionOrDefault(Vec2? direction) =>
@@ -858,7 +873,9 @@ public sealed class YmirSession : IDisposable
if (string.IsNullOrWhiteSpace(body.Id) || !Finite(body.Position) || !Finite(body.Velocity) ||
!float.IsFinite(body.Radius) || body.Radius <= 0.0f ||
!float.IsFinite(body.Mass) || (!body.IsStatic && body.Mass <= 0.0f) ||
+ (body.IsStatic && body.IsKinematic) ||
!float.IsFinite(body.Restitution) || body.Restitution < 0.0f ||
+ body.CollisionCategoryBits == 0 ||
(body.Direction is { } direction && !ValidDirection(direction)) ||
!float.IsFinite(body.AngularVelocity) || !float.IsFinite(body.Torque) ||
(!allowTorque && body.Torque != 0.0f))
@@ -918,6 +935,12 @@ public sealed class YmirSession : IDisposable
}
writer.Write(body.AngularVelocity);
writer.Write(body.Torque);
+ writer.Write(body.IsKinematic);
+ writer.Write(body.IsBullet);
+ writer.Write(body.ParticipatesInFields);
+ writer.Write(body.CollisionCategoryBits);
+ writer.Write(body.CollisionMaskBits);
+ writer.Write(body.CollisionGroupIndex);
}
private static void WriteField(BinaryWriter writer, RadialField? field)
diff --git a/src/Ymir.Core/YmirSoAWorld.cs b/src/Ymir.Core/YmirSoAWorld.cs
index 4117af7..3985ca1 100644
--- a/src/Ymir.Core/YmirSoAWorld.cs
+++ b/src/Ymir.Core/YmirSoAWorld.cs
@@ -18,6 +18,12 @@ public sealed class YmirSoAWorld
public float[] Mass { get; set; } = [];
public float[] DynamicMask { get; set; } = [];
public float[] Restitution { get; set; } = [];
+ public float[] KinematicMask { get; set; } = [];
+ public float[] BulletMask { get; set; } = [];
+ public float[] FieldParticipationMask { get; set; } = [];
+ public ulong[] CollisionCategoryBits { get; set; } = [];
+ public ulong[] CollisionMaskBits { get; set; } = [];
+ public int[] CollisionGroupIndex { get; set; } = [];
public string[] FieldIds { get; set; } = [];
public float[] FieldPositionX { get; set; } = [];
public float[] FieldPositionY { get; set; } = [];
@@ -49,6 +55,12 @@ public sealed class YmirSoAWorld
Mass = bodies.Select(body => body.Mass).ToArray(),
DynamicMask = bodies.Select(body => body.IsStatic ? 0.0f : 1.0f).ToArray(),
Restitution = bodies.Select(body => body.Restitution).ToArray(),
+ KinematicMask = bodies.Select(body => body.IsKinematic ? 1.0f : 0.0f).ToArray(),
+ BulletMask = bodies.Select(body => body.IsBullet ? 1.0f : 0.0f).ToArray(),
+ FieldParticipationMask = bodies.Select(body => body.ParticipatesInFields ? 1.0f : 0.0f).ToArray(),
+ CollisionCategoryBits = bodies.Select(body => body.CollisionCategoryBits).ToArray(),
+ CollisionMaskBits = bodies.Select(body => body.CollisionMaskBits).ToArray(),
+ CollisionGroupIndex = bodies.Select(body => body.CollisionGroupIndex).ToArray(),
FieldIds = fields.Select(field => field.Id).ToArray(),
FieldPositionX = fields.Select(field => field.Position.X).ToArray(),
FieldPositionY = fields.Select(field => field.Position.Y).ToArray(),
@@ -70,7 +82,13 @@ public sealed class YmirSoAWorld
Restitution[i],
new Vec2(DirectionX[i], DirectionY[i]),
AngularVelocity[i],
- Torque[i]))
+ Torque[i],
+ KinematicMask[i] > 0.0f,
+ BulletMask[i] > 0.0f,
+ FieldParticipationMask[i] > 0.0f,
+ CollisionCategoryBits[i],
+ CollisionMaskBits[i],
+ CollisionGroupIndex[i]))
.ToArray(),
Enumerable.Range(0, FieldCount)
.Select(i => new RadialField(
@@ -96,6 +114,12 @@ public sealed class YmirSoAWorld
Mass = (float[])Mass.Clone(),
DynamicMask = (float[])DynamicMask.Clone(),
Restitution = (float[])Restitution.Clone(),
+ KinematicMask = (float[])KinematicMask.Clone(),
+ BulletMask = (float[])BulletMask.Clone(),
+ FieldParticipationMask = (float[])FieldParticipationMask.Clone(),
+ CollisionCategoryBits = (ulong[])CollisionCategoryBits.Clone(),
+ CollisionMaskBits = (ulong[])CollisionMaskBits.Clone(),
+ CollisionGroupIndex = (int[])CollisionGroupIndex.Clone(),
FieldIds = (string[])FieldIds.Clone(),
FieldPositionX = (float[])FieldPositionX.Clone(),
FieldPositionY = (float[])FieldPositionY.Clone(),
@@ -125,6 +149,16 @@ public sealed class YmirSoAWorld
throw new ArgumentOutOfRangeException(nameof(Mass), "Dynamic body mass must be positive.");
}
+ if (DynamicMask[i] <= 0.0f && KinematicMask[i] > 0.0f)
+ {
+ throw new ArgumentException("A body cannot be both static and kinematic.");
+ }
+
+ if (CollisionCategoryBits[i] == 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(CollisionCategoryBits), "Collision category bits must be non-zero.");
+ }
+
var directionLengthSquared = DirectionX[i] * DirectionX[i] + DirectionY[i] * DirectionY[i];
if (!float.IsFinite(directionLengthSquared) || directionLengthSquared <= 1.0e-8f)
{
@@ -158,6 +192,7 @@ public sealed class YmirSoAWorld
{
var expected = BodyIds.Length;
FillMissingAngularState(expected);
+ FillMissingCollisionState(expected);
foreach (var length in new[]
{
PositionX.Length,
@@ -171,7 +206,13 @@ public sealed class YmirSoAWorld
Radius.Length,
Mass.Length,
DynamicMask.Length,
- Restitution.Length
+ Restitution.Length,
+ KinematicMask.Length,
+ BulletMask.Length,
+ FieldParticipationMask.Length,
+ CollisionCategoryBits.Length,
+ CollisionMaskBits.Length,
+ CollisionGroupIndex.Length
})
{
if (length != expected)
@@ -181,6 +222,19 @@ public sealed class YmirSoAWorld
}
}
+ private void FillMissingCollisionState(int expected)
+ {
+ if (KinematicMask.Length == 0 && expected > 0) KinematicMask = new float[expected];
+ if (BulletMask.Length == 0 && expected > 0) BulletMask = new float[expected];
+ if (FieldParticipationMask.Length == 0 && expected > 0)
+ FieldParticipationMask = Enumerable.Repeat(1.0f, expected).ToArray();
+ if (CollisionCategoryBits.Length == 0 && expected > 0)
+ CollisionCategoryBits = Enumerable.Repeat(1UL, expected).ToArray();
+ if (CollisionMaskBits.Length == 0 && expected > 0)
+ CollisionMaskBits = Enumerable.Repeat(ulong.MaxValue, expected).ToArray();
+ if (CollisionGroupIndex.Length == 0 && expected > 0) CollisionGroupIndex = new int[expected];
+ }
+
private void FillMissingAngularState(int expected)
{
if (DirectionX.Length == 0 && expected > 0)
diff --git a/src/Ymir.Core/YmirWorldSession.cs b/src/Ymir.Core/YmirWorldSession.cs
index fa033ac..ad8f01f 100644
--- a/src/Ymir.Core/YmirWorldSession.cs
+++ b/src/Ymir.Core/YmirWorldSession.cs
@@ -53,6 +53,8 @@ internal sealed class YmirWorldSession : IDisposable
indicesByKey.Add(key, i);
bodies[i] = new Box3DBody(
key,
+ world.CollisionCategoryBits[i],
+ world.CollisionMaskBits[i],
world.PositionX[i],
world.PositionY[i],
world.VelocityX[i],
@@ -64,7 +66,11 @@ internal sealed class YmirWorldSession : IDisposable
world.Radius[i],
world.Mass[i],
world.Restitution[i],
- world.DynamicMask[i] <= 0.0f);
+ world.DynamicMask[i] <= 0.0f,
+ world.KinematicMask[i] > 0.0f,
+ world.BulletMask[i] > 0.0f,
+ world.FieldParticipationMask[i] > 0.0f,
+ world.CollisionGroupIndex[i]);
}
Array.Sort(bodies, static (left, right) => left.StableId.CompareTo(right.StableId));
diff --git a/src/Ymir.Core/YmirWorldStateDocument.cs b/src/Ymir.Core/YmirWorldStateDocument.cs
index 3b9b2cf..8878029 100644
--- a/src/Ymir.Core/YmirWorldStateDocument.cs
+++ b/src/Ymir.Core/YmirWorldStateDocument.cs
@@ -167,6 +167,76 @@ public sealed class YmirWorldStateDocumentV1
};
}
+[CultDocument("gamecult.ymir.world_state", "gamecult.ymir.world_state.v2")]
+[MessagePackObject]
+public sealed class YmirWorldStateDocumentV2
+{
+ [Key(0), CultName] public string WorldId { get; set; } = "default";
+ [Key(1)] public float Time { get; set; }
+ [Key(2)] public string[] BodyIds { get; set; } = [];
+ [Key(3)] public float[] PositionX { get; set; } = [];
+ [Key(4)] public float[] PositionY { get; set; } = [];
+ [Key(5)] public float[] VelocityX { get; set; } = [];
+ [Key(6)] public float[] VelocityY { get; set; } = [];
+ [Key(7)] public float[] Radius { get; set; } = [];
+ [Key(8)] public float[] Mass { get; set; } = [];
+ [Key(9)] public float[] DynamicMask { get; set; } = [];
+ [Key(10)] public float[] Restitution { get; set; } = [];
+ [Key(11)] public string[] FieldIds { get; set; } = [];
+ [Key(12)] public float[] FieldPositionX { get; set; } = [];
+ [Key(13)] public float[] FieldPositionY { get; set; } = [];
+ [Key(14)] public float[] FieldStrength { get; set; } = [];
+ [Key(15)] public float[] FieldRadius { get; set; } = [];
+ [Key(16)] public float[] DirectionX { get; set; } = [];
+ [Key(17)] public float[] DirectionY { get; set; } = [];
+ [Key(18)] public float[] AngularVelocity { get; set; } = [];
+ [Key(19)] public float[] KinematicMask { get; set; } = [];
+ [Key(20)] public float[] BulletMask { get; set; } = [];
+ [Key(21)] public float[] FieldParticipationMask { get; set; } = [];
+ [Key(22)] public ulong[] CollisionCategoryBits { get; set; } = [];
+ [Key(23)] public ulong[] CollisionMaskBits { get; set; } = [];
+ [Key(24)] public int[] CollisionGroupIndex { get; set; } = [];
+
+ public static YmirWorldStateDocumentV2 FromSoA(string worldId, YmirSoAWorld world) => new()
+ {
+ WorldId = worldId, Time = world.Time,
+ BodyIds = (string[])world.BodyIds.Clone(),
+ PositionX = (float[])world.PositionX.Clone(), PositionY = (float[])world.PositionY.Clone(),
+ VelocityX = (float[])world.VelocityX.Clone(), VelocityY = (float[])world.VelocityY.Clone(),
+ Radius = (float[])world.Radius.Clone(), Mass = (float[])world.Mass.Clone(),
+ DynamicMask = (float[])world.DynamicMask.Clone(), Restitution = (float[])world.Restitution.Clone(),
+ FieldIds = (string[])world.FieldIds.Clone(),
+ FieldPositionX = (float[])world.FieldPositionX.Clone(), FieldPositionY = (float[])world.FieldPositionY.Clone(),
+ FieldStrength = (float[])world.FieldStrength.Clone(), FieldRadius = (float[])world.FieldRadius.Clone(),
+ DirectionX = (float[])world.DirectionX.Clone(), DirectionY = (float[])world.DirectionY.Clone(),
+ AngularVelocity = (float[])world.AngularVelocity.Clone(),
+ KinematicMask = (float[])world.KinematicMask.Clone(), BulletMask = (float[])world.BulletMask.Clone(),
+ FieldParticipationMask = (float[])world.FieldParticipationMask.Clone(),
+ CollisionCategoryBits = (ulong[])world.CollisionCategoryBits.Clone(),
+ CollisionMaskBits = (ulong[])world.CollisionMaskBits.Clone(),
+ CollisionGroupIndex = (int[])world.CollisionGroupIndex.Clone()
+ };
+
+ public YmirSoAWorld ToSoA() => new()
+ {
+ Time = Time, BodyIds = (string[])BodyIds.Clone(),
+ PositionX = (float[])PositionX.Clone(), PositionY = (float[])PositionY.Clone(),
+ VelocityX = (float[])VelocityX.Clone(), VelocityY = (float[])VelocityY.Clone(),
+ Radius = (float[])Radius.Clone(), Mass = (float[])Mass.Clone(),
+ DynamicMask = (float[])DynamicMask.Clone(), Restitution = (float[])Restitution.Clone(),
+ FieldIds = (string[])FieldIds.Clone(),
+ FieldPositionX = (float[])FieldPositionX.Clone(), FieldPositionY = (float[])FieldPositionY.Clone(),
+ FieldStrength = (float[])FieldStrength.Clone(), FieldRadius = (float[])FieldRadius.Clone(),
+ DirectionX = (float[])DirectionX.Clone(), DirectionY = (float[])DirectionY.Clone(),
+ AngularVelocity = (float[])AngularVelocity.Clone(),
+ KinematicMask = (float[])KinematicMask.Clone(), BulletMask = (float[])BulletMask.Clone(),
+ FieldParticipationMask = (float[])FieldParticipationMask.Clone(),
+ CollisionCategoryBits = (ulong[])CollisionCategoryBits.Clone(),
+ CollisionMaskBits = (ulong[])CollisionMaskBits.Clone(),
+ CollisionGroupIndex = (int[])CollisionGroupIndex.Clone()
+ };
+}
+
public static class YmirCultCacheStore
{
public static async Task SaveWorldAsync(string path, string worldId, YmirSoAWorld world)
@@ -183,8 +253,8 @@ public static class YmirCultCacheStore
using (cache)
{
await cache.UpsertAsync(
- YmirWorldStateDocumentV1.FromSoA(worldId, world),
- new CultRecordHandle(new CultRecordKey($"ymir:world:{worldId}")))
+ YmirWorldStateDocumentV2.FromSoA(worldId, world),
+ new CultRecordHandle(new CultRecordKey($"ymir:world:{worldId}")))
.ConfigureAwait(false);
await cache.FlushAsync().ConfigureAwait(false);
}
@@ -196,7 +266,8 @@ public static class YmirCultCacheStore
path,
new CultCacheOpenOptions { UseDirectoryStore = true }).ConfigureAwait(false);
var key = new CultRecordKey($"ymir:world:{worldId}");
- return cache.Get(key)?.ToSoA()
+ return cache.Get(key)?.ToSoA()
+ ?? cache.Get(key)?.ToSoA()
?? cache.Get(key)?.ToSoA();
}
}
diff --git a/src/ymir-daemon/Program.cs b/src/ymir-daemon/Program.cs
index 54f1089..96c9e6e 100644
--- a/src/ymir-daemon/Program.cs
+++ b/src/ymir-daemon/Program.cs
@@ -82,7 +82,7 @@ internal static class Program
ok = true,
path,
record = "ymir:world:sample",
- schema = "gamecult.ymir.world_state.v1",
+ schema = "gamecult.ymir.world_state.v2",
bodyCount = result.World.BodyCount,
fieldCount = result.World.FieldCount
});
diff --git a/tests/Ymir.Box3D.Parity/Box3DOracle.cs b/tests/Ymir.Box3D.Parity/Box3DOracle.cs
index 43a98be..4d42ca9 100644
--- a/tests/Ymir.Box3D.Parity/Box3DOracle.cs
+++ b/tests/Ymir.Box3D.Parity/Box3DOracle.cs
@@ -93,6 +93,18 @@ internal static partial class Box3DOracle
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial int ymir_box3d_session_get_contact_event_count(nint session);
+ [LibraryImport(LibraryName)]
+ [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
+ internal static partial int ymir_box3d_session_get_body_count(nint session);
+
+ [LibraryImport(LibraryName)]
+ [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
+ internal static unsafe partial int ymir_box3d_session_copy_bodies(
+ nint session,
+ Box3DSessionBodyOutput* bodies,
+ int capacity,
+ out int written);
+
[LibraryImport(LibraryName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static unsafe partial int ymir_box3d_session_copy_contact_events(
@@ -154,6 +166,8 @@ internal readonly record struct Box3DTorqueResult(
[StructLayout(LayoutKind.Sequential)]
internal readonly record struct Box3DSessionBodyInput(
ulong StableId,
+ ulong CollisionCategoryBits,
+ ulong CollisionMaskBits,
float PositionX,
float PositionY,
float VelocityX,
@@ -165,7 +179,11 @@ internal readonly record struct Box3DSessionBodyInput(
float Radius,
float Mass,
float Restitution,
- uint IsStatic);
+ uint IsStatic,
+ uint IsKinematic,
+ uint IsBullet,
+ uint ParticipatesInFields,
+ int CollisionGroupIndex);
[StructLayout(LayoutKind.Sequential)]
internal readonly record struct Box3DSessionContactEvent(
@@ -182,3 +200,14 @@ internal readonly record struct Box3DSessionContactEvent(
float NormalY,
float Penetration,
float RelativeSpeed);
+
+[StructLayout(LayoutKind.Sequential)]
+internal readonly record struct Box3DSessionBodyOutput(
+ ulong StableId,
+ float PositionX,
+ float PositionY,
+ float VelocityX,
+ float VelocityY,
+ float DirectionX,
+ float DirectionY,
+ float AngularVelocity);
diff --git a/tests/Ymir.Box3D.Parity/Box3DParityTests.cs b/tests/Ymir.Box3D.Parity/Box3DParityTests.cs
index 7d68ca3..6dd9475 100644
--- a/tests/Ymir.Box3D.Parity/Box3DParityTests.cs
+++ b/tests/Ymir.Box3D.Parity/Box3DParityTests.cs
@@ -124,14 +124,12 @@ public sealed class Box3DParityTests
[Fact]
public unsafe void RetainedSessionCommandsPreserveTypedContactLifecycle()
{
- Assert.Equal(3u, Box3DOracle.ymir_box3d_get_abi_version());
+ Assert.Equal(4u, Box3DOracle.ymir_box3d_get_abi_version());
Assert.Equal(0, Box3DOracle.ymir_box3d_session_create(out var session));
try
{
- var bodyA = new Box3DSessionBodyInput(
- 1, -0.9f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0);
- var bodyB = new Box3DSessionBodyInput(
- 2, 0.9f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0);
+ var bodyA = SessionBody(1, -0.9f);
+ var bodyB = SessionBody(2, 0.9f);
Assert.Equal(0, Box3DOracle.ymir_box3d_session_spawn(session, &bodyA));
Assert.Equal(0, Box3DOracle.ymir_box3d_session_spawn(session, &bodyB));
Assert.Equal(2, Box3DOracle.ymir_box3d_session_spawn(session, &bodyA));
@@ -155,6 +153,74 @@ public sealed class Box3DParityTests
}
}
+ [Fact]
+ public unsafe void NegativeGroupAndCategoryMasksAreBox3DCollisionAuthority()
+ {
+ Assert.Equal(0, Box3DOracle.ymir_box3d_session_create(out var session));
+ try
+ {
+ var source = SessionBody(1, 0.0f, category: 1, mask: 2, group: -7);
+ var ownPayload = SessionBody(2, 0.0f, category: 2, mask: 1, group: -7);
+ var otherPayload = SessionBody(3, 0.0f, category: 2, mask: 1, group: -8);
+ Assert.Equal(0, Box3DOracle.ymir_box3d_session_spawn(session, &source));
+ Assert.Equal(0, Box3DOracle.ymir_box3d_session_spawn(session, &ownPayload));
+ Assert.Equal(0, Box3DOracle.ymir_box3d_session_spawn(session, &otherPayload));
+
+ Assert.Equal(0, Box3DOracle.ymir_box3d_session_step(session, 1.0f / 60.0f, 4, null, 0));
+ var pairs = CopyEvents(session)
+ .Where(value => value.Kind == 1)
+ .Select(value => new HashSet { value.StableIdA, value.StableIdB })
+ .ToArray();
+
+ Assert.Single(pairs);
+ Assert.Equal(new HashSet { 1, 3 }, pairs[0]);
+ }
+ finally
+ {
+ Box3DOracle.ymir_box3d_session_destroy(session);
+ }
+ }
+
+ [Fact]
+ public unsafe void BulletUsesBox3DContinuousCollisionAgainstKinematicTarget()
+ {
+ Assert.Equal(0, Box3DOracle.ymir_box3d_session_create(out var session));
+ try
+ {
+ var bullet = SessionBody(1, -10.0f, velocityX: 100.0f, isBullet: true);
+ var target = SessionBody(2, 0.0f, isKinematic: true);
+ Assert.Equal(0, Box3DOracle.ymir_box3d_session_spawn(session, &bullet));
+ Assert.Equal(0, Box3DOracle.ymir_box3d_session_spawn(session, &target));
+
+ Assert.Equal(0, Box3DOracle.ymir_box3d_session_step(session, 0.2f, 4, null, 0));
+ Assert.Empty(CopyEvents(session));
+ var ccdBodies = CopyBodies(session);
+ Assert.True(ccdBodies.Single(value => value.StableId == 1).PositionX < 0.0f);
+ Assert.Equal(0.0f, ccdBodies.Single(value => value.StableId == 2).PositionX, precision: 5);
+ Assert.Equal(0, Box3DOracle.ymir_box3d_session_step(session, 1.0f / 60.0f, 4, null, 0));
+ Assert.Contains(CopyEvents(session), value =>
+ (value.Kind == 1 || value.Kind == 2) &&
+ new HashSet { value.StableIdA, value.StableIdB }.SetEquals([1UL, 2UL]));
+ }
+ finally
+ {
+ Box3DOracle.ymir_box3d_session_destroy(session);
+ }
+ }
+
+ private static Box3DSessionBodyInput SessionBody(
+ ulong id,
+ float positionX,
+ float velocityX = 0.0f,
+ ulong category = 1,
+ ulong mask = ulong.MaxValue,
+ int group = 0,
+ bool isKinematic = false,
+ bool isBullet = false) => new(
+ id, category, mask, positionX, 0.0f, velocityX, 0.0f, 0.0f, 1.0f,
+ 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0,
+ isKinematic ? 1u : 0u, isBullet ? 1u : 0u, 1u, group);
+
private static unsafe Box3DSessionContactEvent[] CopyEvents(nint session)
{
var count = Box3DOracle.ymir_box3d_session_get_contact_event_count(session);
@@ -167,4 +233,17 @@ public sealed class Box3DParityTests
}
return events;
}
+
+ private static unsafe Box3DSessionBodyOutput[] CopyBodies(nint session)
+ {
+ var count = Box3DOracle.ymir_box3d_session_get_body_count(session);
+ Assert.True(count >= 0);
+ var bodies = new Box3DSessionBodyOutput[count];
+ fixed (Box3DSessionBodyOutput* pointer = bodies)
+ {
+ Assert.Equal(0, Box3DOracle.ymir_box3d_session_copy_bodies(session, pointer, count, out var written));
+ Assert.Equal(count, written);
+ }
+ return bodies;
+ }
}
diff --git a/tests/Ymir.Tests/YmirServicePublicationTests.cs b/tests/Ymir.Tests/YmirServicePublicationTests.cs
index 3668aed..b3aee04 100644
--- a/tests/Ymir.Tests/YmirServicePublicationTests.cs
+++ b/tests/Ymir.Tests/YmirServicePublicationTests.cs
@@ -24,7 +24,7 @@ public sealed class YmirServicePublicationTests
Assert.Contains("Box3D solver and collision semantics", advertisement.DoesNotOwn);
Assert.Equal("Box3D v0.1.0 (C17)", diagnostics.NumericSubstrate);
Assert.Contains("does not own physics algorithms", diagnostics.BatchKernel, StringComparison.Ordinal);
- Assert.Contains("world_state.v1", diagnostics.Persistence, StringComparison.Ordinal);
+ Assert.Contains("world_state.v2", diagnostics.Persistence, StringComparison.Ordinal);
Assert.Contains("v0 read migration", diagnostics.Persistence, StringComparison.Ordinal);
}
diff --git a/tests/Ymir.Tests/YmirSessionTests.cs b/tests/Ymir.Tests/YmirSessionTests.cs
index 662a250..ce7ad41 100644
--- a/tests/Ymir.Tests/YmirSessionTests.cs
+++ b/tests/Ymir.Tests/YmirSessionTests.cs
@@ -190,6 +190,74 @@ public sealed class YmirSessionTests
Assert.Equal(Vec2.Zero, session.Snapshot().Bodies.Single().Velocity);
}
+ [Fact]
+ public void CollisionProfilesExcludeOwnGroupAndPayloadPeers()
+ {
+ using var session = YmirSession.Create(new YmirSessionCreateRequest(
+ "collision-profile",
+ [
+ Body("source") with
+ {
+ CollisionCategoryBits = 1,
+ CollisionMaskBits = 2,
+ CollisionGroupIndex = -7
+ },
+ Body("own-payload") with
+ {
+ CollisionCategoryBits = 2,
+ CollisionMaskBits = 1,
+ CollisionGroupIndex = -7
+ },
+ Body("other-payload") with
+ {
+ CollisionCategoryBits = 2,
+ CollisionMaskBits = 1,
+ CollisionGroupIndex = -8
+ }
+ ]));
+
+ var result = session.Step(Step("profile-step", 0, 1.0f / 60.0f));
+ var begin = Assert.Single(result.ContactFacts, fact => fact.Kind == YmirContactFactKind.Begin);
+
+ Assert.Equal(new HashSet { "source", "other-payload" }, new HashSet { begin.BodyA, begin.BodyB });
+ }
+
+ [Fact]
+ public void BodyCanOptOutOfRadialFields()
+ {
+ using var session = YmirSession.Create(new YmirSessionCreateRequest(
+ "field-profile",
+ [Body("affected", positionX: 1.0f), Body("ignored", positionX: -5.0f) with { ParticipatesInFields = false }]));
+
+ var result = session.Step(new YmirStepSessionCommand(
+ Header("field-step", 0),
+ 0.1f,
+ [new RadialField("gravity", Vec2.Zero, 10.0f, 10.0f)]));
+
+ Assert.NotEqual(0.0f, result.World.Bodies.Single(body => body.Id == "affected").Velocity.X);
+ Assert.Equal(0.0f, result.World.Bodies.Single(body => body.Id == "ignored").Velocity.X, 5);
+ }
+
+ [Fact]
+ public void KinematicTargetDoesNotAcceptBulletImpulse()
+ {
+ using var session = YmirSession.Create(new YmirSessionCreateRequest(
+ "bullet-kinematic",
+ [
+ Body("bullet", positionX: -10.0f, velocityX: 100.0f) with { IsBullet = true },
+ Body("target") with { IsKinematic = true }
+ ]));
+
+ var ccdStep = session.Step(Step("ccd", 0, 0.2f));
+ var eventStep = session.Step(Step("event", 1, 1.0f / 60.0f));
+
+ Assert.Empty(ccdStep.ContactFacts);
+ Assert.Contains(eventStep.ContactFacts, fact => fact.Kind is YmirContactFactKind.Begin or YmirContactFactKind.Hit);
+ var target = eventStep.World.Bodies.Single(body => body.Id == "target");
+ Assert.Equal(Vec2.Zero, target.Velocity);
+ Assert.Equal(Vec2.Zero, target.Position);
+ }
+
private static YmirCommandHeader Header(string id, long revision) => new(id, revision);
private static long BodyGeneration(YmirContactFact fact, string bodyId) =>
diff --git a/tests/Ymir.Tests/YmirSimulatorTests.cs b/tests/Ymir.Tests/YmirSimulatorTests.cs
index 8eb4f05..19e2f1a 100644
--- a/tests/Ymir.Tests/YmirSimulatorTests.cs
+++ b/tests/Ymir.Tests/YmirSimulatorTests.cs
@@ -250,6 +250,46 @@ public sealed class YmirSimulatorTests
}
}
+ [Fact]
+ public async Task CultCacheV2PreservesBox3DBodyProfile()
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"ymir-v2-tests-{Guid.NewGuid():N}.cc");
+ var recordsPath = $"{path}.records";
+ try
+ {
+ var world = YmirSoAWorld.FromWorld(new YmirWorld(
+ 3.0f,
+ [new PhysicsBody("payload", Vec2.Zero, Vec2.Zero, 0.5f, 1.0f) with
+ {
+ IsKinematic = true,
+ IsBullet = true,
+ ParticipatesInFields = false,
+ CollisionCategoryBits = 4,
+ CollisionMaskBits = 2,
+ CollisionGroupIndex = -11
+ }],
+ []));
+
+ await YmirCultCacheStore.SaveWorldAsync(path, "test", world);
+ var loaded = await YmirCultCacheStore.LoadWorldAsync(path, "test");
+
+ Assert.NotNull(loaded);
+ loaded!.Validate();
+ var body = Assert.Single(loaded.ToWorld().Bodies);
+ Assert.True(body.IsKinematic);
+ Assert.True(body.IsBullet);
+ Assert.False(body.ParticipatesInFields);
+ Assert.Equal(4UL, body.CollisionCategoryBits);
+ Assert.Equal(2UL, body.CollisionMaskBits);
+ Assert.Equal(-11, body.CollisionGroupIndex);
+ }
+ finally
+ {
+ if (File.Exists(path)) File.Delete(path);
+ if (Directory.Exists(recordsPath)) Directory.Delete(recordsPath, recursive: true);
+ }
+ }
+
private static SimulationStepResult Step(params PhysicsBody[] bodies)
{
var simulator = new YmirSimulator();