Expose Box3D collision profiles through Ymir
This commit is contained in:
parent
dedbc4e429
commit
e49fe42ef9
21 changed files with 540 additions and 51 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
<AssemblyName>Ymir.Box3D</AssemblyName>
|
||||
<Description>Native ABI boundary between Ymir sessions and pinned Box3D.</Description>
|
||||
<PackageId>GameCult.Ymir.Box3D</PackageId>
|
||||
<Version>0.2.0</Version>
|
||||
<Version>0.3.0</Version>
|
||||
<Authors>GameCult</Authors>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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<YmirWorldStateDocumentV1>(new CultRecordKey($"ymir:world:{worldId}")))
|
||||
YmirWorldStateDocumentV2.FromSoA(worldId, world),
|
||||
new CultRecordHandle<YmirWorldStateDocumentV2>(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<YmirWorldStateDocumentV1>(key)?.ToSoA()
|
||||
return cache.Get<YmirWorldStateDocumentV2>(key)?.ToSoA()
|
||||
?? cache.Get<YmirWorldStateDocumentV1>(key)?.ToSoA()
|
||||
?? cache.Get<YmirWorldStateDocumentV0>(key)?.ToSoA();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<ulong> { value.StableIdA, value.StableIdB })
|
||||
.ToArray();
|
||||
|
||||
Assert.Single(pairs);
|
||||
Assert.Equal(new HashSet<ulong> { 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<ulong> { 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string> { "source", "other-payload" }, new HashSet<string> { 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) =>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue