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