Expose Box3D collision profiles through Ymir

This commit is contained in:
Meta Vens 2026-07-14 22:01:19 +01:00
commit e49fe42ef9
21 changed files with 540 additions and 51 deletions

View file

@ -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,

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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;

View file

@ -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(

View file

@ -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);
}

View file

@ -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>

View file

@ -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,

View file

@ -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,

View file

@ -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)
};

View file

@ -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)

View file

@ -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)

View file

@ -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));

View file

@ -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();
}
}

View file

@ -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
});

View file

@ -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);

View file

@ -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;
}
}

View file

@ -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);
}

View file

@ -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) =>

View file

@ -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();