Replace managed physics with Box3D runtime boundary

This commit is contained in:
Meta Vens 2026-07-14 20:33:05 +01:00
commit 721eee0656
26 changed files with 2167 additions and 200 deletions

2
.gitignore vendored
View file

@ -6,4 +6,4 @@ obj/
*.user
TestResults/
BenchmarkDotNet.Artifacts/
artifacts/box3d-native/
artifacts/

View file

@ -1,8 +1,8 @@
# Ymir
Ymir is the GameCult Box3D wrapper and authoritative physics daemon for
Aetheria and future world-bearing projects. The name means **Not Invented
Here**. We are taking the hint.
Ymir is the GameCult Box3D wrapper and physics-daemon boundary for Aetheria and
future world-bearing projects. The name means **Not Invented Here**. We are
taking the hint.
Box3D owns physics algorithms. Ymir owns the service boundary that makes those
algorithms usable as durable, typed GameCult world truth. Unity, renderers, and
@ -15,10 +15,13 @@ authorities.
behavior, continuous collision, overlap and cast semantics, contact
lifecycle, force and torque lifetime, tolerances, and deterministic step
behavior for identical recorded inputs.
- Ymir owns the pinned Box3D version, stable GameCult entity ids, native ABI
isolation, long-lived world sessions, typed commands and facts,
deterministic result ordering, checkpoint reconstruction, CultCache state,
CultMesh publication, and daemon lifecycle.
- Ymir currently owns the pinned Box3D version, stable GameCult entity ids,
native ABI isolation, snapshot lowering, deterministic result ordering,
versioned CultCache snapshots, and a diagnostic Eve publication.
- The Ymir daemon will own long-lived world sessions, typed commands and
receipts, contact lifecycle facts, checkpoint reconstruction, CultMesh
publication, and process supervision. Those are the cutover contract, not a
claim that the current CLI already exposes them.
- Aetheria owns gameplay policy and supplies typed physical intent: bodies,
fields, tractor targets, forces, projectiles, filters, and the gameplay
consequences of Ymir contact facts.
@ -44,11 +47,19 @@ parity suite already witness released Box3D behavior for:
- restitution mixing
- transient torque lifetime
The legacy managed Ymir solver remains temporarily live while retained Box3D
world sessions, end-contact lifecycle, continuous collision, replay, and
checkpoint reconstruction are connected. It is compatibility scaffolding, not
the target architecture. It will be deleted, along with Ymir's custom geometry
and spatial indexes, once the Box3D-backed path proves the public contract.
The production `Ymir.Box3D` boundary owns the native session ABI. The legacy
snapshot `YmirSimulator.Step` contract now creates an isolated Box3D session,
projects the result, and disposes it. It contains no managed integrator,
collision loop, or impulse solver.
That snapshot facade deliberately does not pretend to own long-lived world
identity. The retained native session exists behind an internal managed port;
there is not yet a public session registry or command contract. Aetheria will
name one Ymir session per run and zone when that contract is exposed. Its old
process-wide simulator objects are not session identifiers. Planar heading,
angular velocity, and transient torque already pass through Box3D. Typed
begin/end/hit lifecycle facts, checkpoint reconstruction, and non-Windows RID
artifacts remain cutover work.
See [the architecture map](docs/architecture.md) and
[the executable parity contract](docs/box3d-parity.md).
@ -71,9 +82,17 @@ dotnet test tests\Ymir.Box3D.Parity\Ymir.Box3D.Parity.csproj
Box3D is MIT licensed. Its license is retained in `extern/box3d`.
`GameCult.Ymir.Box3D` packages the managed boundary, Box3D license, and native
facade under `runtimes/<rid>/native`. A local Windows x64 package has loaded in
an isolated empty consumer without CMake or the submodule. A committed release
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.
## Cutover Rule
A Ymir cutover is complete only when all runtime stepping, collision queries,
and contact facts come from a retained Box3D world through one Ymir session
primitive. Manual Unity callbacks, Aetheria geometry tests, and the managed
Ymir solver must be structurally unable to decide the result.
and contact facts come from Box3D through an explicitly owned Ymir session.
Manual Unity callbacks and Aetheria geometry tests must be structurally unable
to decide the result. Snapshot compatibility calls may use isolated sessions;
they are not allowed to infer or share retained-world identity.

View file

@ -1,6 +1,7 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/ymir-daemon/ymir-daemon.csproj" />
<Project Path="src/Ymir.Box3D/Ymir.Box3D.csproj" />
<Project Path="src/Ymir.Core/Ymir.Core.csproj" />
</Folder>
<Folder Name="/tests/">

View file

@ -9,7 +9,7 @@ Ymir is a wrapper and daemon, not an independent physics-engine research
project. Box3D supplies the physics mind. Ymir supplies the memory, nerves,
names, and service boundary.
## Owner
## Target Owner
A long-lived Box3D `b3World` owns the physical decision: given the current
world plus commands for a fixed step, what transforms, velocities, overlaps,
@ -27,7 +27,23 @@ Ymir owns the enclosing session and contract:
CultMesh;
- supervise world lifetime, failure, replay, and observability.
## Inputs
## Current Body
The runtime solver cut is complete: Box3D is the only integrator, collision
engine, broadphase, query-geometry implementation, and contact source in the
committed Ymir path. The public `YmirSimulator.Step` compatibility API creates
an isolated internal session for one complete snapshot, projects its output,
and disposes it. Public circle overlap and cast APIs invoke Box3D geometry over
caller-supplied bodies.
The retained native session and its managed wrapper are internal. The current
CLI has no named session registry, public mutation commands, command receipts,
retained-world queries, or begin/end/hit lifecycle contract. Its CultCache and
Eve output is a regenerated diagnostic projection. The sections marked as
target contract describe the cut still to build; they are not advertisements
of current daemon capability.
## Target Inputs
- stable body and shape definitions
- fixed time step and explicit substep count
@ -39,7 +55,7 @@ Ymir owns the enclosing session and contract:
Aetheria chooses physical intent and gameplay eligibility. Box3D decides
geometry membership and contact. Ymir carries the typed handoff.
## Outputs
## Target Outputs
- body transforms and linear/angular velocities
- begin, end, and hit contact facts with stable Ymir ids
@ -87,12 +103,21 @@ solver.
- transport endpoints or dashboards owning world state;
- Box3D opaque ids stored as durable GameCult identity.
## Shared Paths
## Target Shared Paths
Direct player commands, agent commands, programmatic spawns, server ticks,
imports, reconnects, replay, and checkpoint recovery all enter one Ymir world
session primitive. DTO compatibility methods may lower into that primitive;
they may not run an alternate step.
imports, reconnects, replay, and checkpoint recovery will enter the same Ymir
session primitive for their named world. Today, DTO compatibility methods
lower into an isolated Box3D session; they may not run an alternate step or
infer retained identity from a body list.
Session identity must be explicit. A process-wide simulator object, zone index,
or repeated stable body id is not enough to join two calls to the same world.
Aetheria owns run and zone lifetime and will create, retain, and dispose the
corresponding Ymir session after the public port exists. Current projectile
sweep and stationary mine proximity integration can use Ymir's static
Box3D-backed circle-cast and overlap queries, but those calls do not join a
retained world or own contact lifecycle.
Radial gravity and tractor attraction are typed force-generation inputs. They
are applied to Box3D before stepping. Pickup collection remains an Aetheria
@ -115,6 +140,13 @@ 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
checkpoint: it does not preserve full shape, material, filter, session tuning,
provenance, or warm contact state.
The canonical checkpoint schema must contain enough typed definitions and
state to reconstruct the accepted Box3D world: stable ids, 3D transforms,
linear and angular velocity, body/shape/material/filter definitions, session
@ -127,8 +159,10 @@ under a reassigned MessagePack layout.
## Cut Line
The managed Ymir solver, `YmirQueryPrimitives`, and `YmirSpatialIndex` are
deleted when retained Box3D sessions prove:
The managed integrator and circle impulse solver have been deleted from the
runtime path. Any uncommitted `YmirQueryPrimitives` or `YmirSpatialIndex` work
is obsolete and must not be annexed. The retained-session cut continues until
Box3D-backed sessions prove:
1. fixed-step body state projection;
2. begin/end/hit contact lifecycle;
@ -148,7 +182,9 @@ failure fails closed and reports the missing capability.
session facts.
- Negative source checks prove custom solver and spatial-index authorities are
absent after cutover.
- Aetheria smokes prove gravity, contact-gated destruction loot, tractor
attraction, pickup collection, and fast payload hits.
- Aetheria smokes must prove gravity, contact-gated destruction loot, tractor
attraction, pickup collection, and fast payload hits through the retained
contract. Current gravity, pickup-contact, cast, and overlap witnesses cover
only the snapshot/query compatibility path.
- Released-client witnesses prove generic Eve clients only observe Ymir and
Aetheria provider contracts, never native implementation details.

View file

@ -3,7 +3,8 @@
## Authority
Box3D v0.1.0 is Ymir's physics implementation and executable semantics oracle.
The production session cutover is still under construction. The exact upstream source is pinned as the
The production native session ABI and snapshot lowering are live; explicit
retained-world ownership is still being connected. The exact upstream source is pinned as the
`extern/box3d` submodule at commit
`8441b4a06d6d09dcfb0b0f704df4d847d1437b92`.
@ -15,11 +16,17 @@ torque lifetime, and numeric tolerances.
## Body
`native/Ymir.Box3D` statically links the pinned C17 library into one small
shared-library facade. The facade exports only blittable sphere overlap,
sphere cast, capsule-versus-sphere membership, planar pair stepping, torque
lifetime, and version operations. This keeps Box3D's alpha C ABI out of Ymir's
shared-library facade. The facade exports retained sphere-world sessions plus
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 2 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. Planar direction maps
to Box3D rotation about Y; angular velocity and transient torque remain Box3D
state and commands rather than managed integration.
`tests/Ymir.Box3D.Parity` builds that facade through CMake and invokes it with
source-generated .NET interop. Run it with:
@ -50,9 +57,8 @@ is retained in the pinned submodule.
## Cut Line
The current managed collision formulas are transitional. They may exist only
while differential tests prove compatibility. The remaining parity slice
covers retained world sessions, end-contact lifecycle, continuous collision,
and replay. Once those facts are projected through a stable Ymir native port,
the managed solver, query geometry, and spatial indexes are deleted rather
than retained as fallback authority.
The managed integrator and circle collision solver are no longer runtime
authorities. The remaining parity slice covers explicit retained-world
ownership, end-contact lifecycle, continuous collision, and replay. Custom
query geometry and spatial indexes are not fallback authorities; Box3D owns
those edge cases.

View file

@ -5,10 +5,17 @@
#include <box3d/math_functions.h>
#include <box3d/types.h>
#include <math.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#if defined(_WIN32)
#define YMIR_EXPORT __declspec(dllexport)
#define YMIR_CALL __cdecl
#else
#define YMIR_EXPORT __attribute__((visibility("default")))
#define YMIR_CALL
#endif
typedef struct ymir_box3d_overlap_result
@ -65,11 +72,561 @@ typedef struct ymir_box3d_torque_result
float angular_velocity_after_unforced_step;
} ymir_box3d_torque_result;
typedef enum ymir_box3d_status
{
YMIR_BOX3D_OK = 0,
YMIR_BOX3D_INVALID_ARGUMENT = 1,
YMIR_BOX3D_DUPLICATE_ID = 2,
YMIR_BOX3D_BUFFER_TOO_SMALL = 3,
YMIR_BOX3D_OUT_OF_MEMORY = 4,
YMIR_BOX3D_INTERNAL_ERROR = 5,
} ymir_box3d_status;
typedef struct ymir_box3d_session_body_input
{
uint64_t stable_id;
float position_x;
float position_y;
float velocity_x;
float velocity_y;
float direction_x;
float direction_y;
float angular_velocity;
float torque;
float radius;
float mass;
float restitution;
uint32_t is_static;
} ymir_box3d_session_body_input;
typedef struct ymir_box3d_radial_field_input
{
float position_x;
float position_y;
float strength;
float radius;
} ymir_box3d_radial_field_input;
typedef struct ymir_box3d_session_body_output
{
uint64_t stable_id;
float position_x;
float position_y;
float velocity_x;
float velocity_y;
float direction_x;
float direction_y;
float angular_velocity;
} ymir_box3d_session_body_output;
typedef struct ymir_box3d_session_contact_output
{
uint64_t stable_id_a;
uint64_t stable_id_b;
float point_x;
float point_y;
float normal_x;
float normal_y;
float penetration;
float relative_speed;
} ymir_box3d_session_contact_output;
typedef struct ymir_box3d_session_body
{
uint64_t stable_id;
b3BodyId body_id;
b3ShapeId shape_id;
float last_position_x;
float last_position_y;
float last_velocity_x;
float last_velocity_y;
float last_direction_x;
float last_direction_y;
float last_angular_velocity;
float pending_torque;
float radius;
float mass;
float restitution;
uint32_t is_static;
int seen;
} ymir_box3d_session_body;
typedef struct ymir_box3d_session
{
b3WorldId world_id;
ymir_box3d_session_body* bodies;
int body_count;
int body_capacity;
} 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_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");
static b3Vec3 ymir_vec(float x, float y)
{
return (b3Vec3){x, 0.0f, y};
}
static int ymir_is_finite(float value)
{
return isfinite(value) != 0;
}
static int ymir_session_body_input_is_valid(const ymir_box3d_session_body_input* input)
{
float direction_length_squared = input != NULL
? input->direction_x * input->direction_x + input->direction_y * input->direction_y
: 0.0f;
return input != NULL && input->stable_id != 0 && input->stable_id <= (uint64_t)UINTPTR_MAX &&
ymir_is_finite(input->position_x) && ymir_is_finite(input->position_y) &&
ymir_is_finite(input->velocity_x) && ymir_is_finite(input->velocity_y) &&
ymir_is_finite(input->direction_x) && ymir_is_finite(input->direction_y) &&
direction_length_squared > 1.0e-8f && ymir_is_finite(input->angular_velocity) &&
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;
}
static b3Quat ymir_session_rotation(float direction_x, float direction_y)
{
float angle = atan2f(direction_x, direction_y);
return b3MakeQuatFromAxisAngle((b3Vec3){0.0f, 1.0f, 0.0f}, angle);
}
static b3Vec3 ymir_session_direction(b3BodyId body_id)
{
return b3RotateVector(b3Body_GetRotation(body_id), (b3Vec3){0.0f, 0.0f, 1.0f});
}
static int ymir_session_find_body(const ymir_box3d_session* session, uint64_t stable_id)
{
for (int i = 0; i < session->body_count; ++i)
{
if (session->bodies[i].stable_id == stable_id)
{
return i;
}
}
return -1;
}
static ymir_box3d_status ymir_session_reserve_bodies(ymir_box3d_session* session, int capacity)
{
if (capacity <= session->body_capacity)
{
return YMIR_BOX3D_OK;
}
int new_capacity = session->body_capacity > 0 ? session->body_capacity : 16;
while (new_capacity < capacity)
{
if (new_capacity > INT32_MAX / 2)
{
new_capacity = capacity;
break;
}
new_capacity *= 2;
}
ymir_box3d_session_body* bodies =
(ymir_box3d_session_body*)realloc(session->bodies, (size_t)new_capacity * sizeof(ymir_box3d_session_body));
if (bodies == NULL)
{
return YMIR_BOX3D_OUT_OF_MEMORY;
}
session->bodies = bodies;
session->body_capacity = new_capacity;
return YMIR_BOX3D_OK;
}
static float ymir_session_density(float mass, float radius)
{
float volume = 4.0f / 3.0f * B3_PI * radius * radius * radius;
return mass / volume;
}
static ymir_box3d_status ymir_session_create_body(
ymir_box3d_session* session,
const ymir_box3d_session_body_input* input,
ymir_box3d_session_body* output)
{
b3BodyDef body_def = b3DefaultBodyDef();
body_def.type = input->is_static != 0 ? b3_staticBody : b3_dynamicBody;
body_def.position = ymir_vec(input->position_x, input->position_y);
body_def.rotation = ymir_session_rotation(input->direction_x, input->direction_y);
body_def.linearVelocity = ymir_vec(input->velocity_x, input->velocity_y);
body_def.angularVelocity = (b3Vec3){0.0f, input->angular_velocity, 0.0f};
body_def.motionLocks.linearY = true;
body_def.motionLocks.angularX = true;
body_def.motionLocks.angularZ = true;
body_def.enableSleep = false;
b3BodyId body_id = b3CreateBody(session->world_id, &body_def);
if (B3_IS_NULL(body_id))
{
return YMIR_BOX3D_INTERNAL_ERROR;
}
b3ShapeDef shape_def = b3DefaultShapeDef();
shape_def.userData = (void*)(uintptr_t)input->stable_id;
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.enableContactEvents = true;
shape_def.enableHitEvents = true;
b3Sphere sphere = {{0.0f, 0.0f, 0.0f}, input->radius};
b3ShapeId shape_id = b3CreateSphereShape(body_id, &shape_def, &sphere);
if (B3_IS_NULL(shape_id))
{
b3DestroyBody(body_id);
return YMIR_BOX3D_INTERNAL_ERROR;
}
*output = (ymir_box3d_session_body){
.stable_id = input->stable_id,
.body_id = body_id,
.shape_id = shape_id,
.last_position_x = input->position_x,
.last_position_y = input->position_y,
.last_velocity_x = input->velocity_x,
.last_velocity_y = input->velocity_y,
.last_direction_x = input->direction_x,
.last_direction_y = input->direction_y,
.last_angular_velocity = input->angular_velocity,
.pending_torque = input->torque,
.radius = input->radius,
.mass = input->mass,
.restitution = input->restitution,
.is_static = input->is_static != 0,
.seen = 1,
};
return YMIR_BOX3D_OK;
}
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 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);
}
if (radius_changed)
{
b3Sphere sphere = {{0.0f, 0.0f, 0.0f}, input->radius};
b3Shape_SetSphere(body->shape_id, &sphere);
}
if (type_changed || radius_changed || mass_changed)
{
float density = input->is_static != 0 ? 0.0f : ymir_session_density(input->mass, input->radius);
b3Shape_SetDensity(body->shape_id, density, false);
b3Body_ApplyMassFromShapes(body->body_id);
}
if (body->restitution != input->restitution)
{
b3Shape_SetRestitution(body->shape_id, input->restitution);
}
if (body->last_position_x != input->position_x || body->last_position_y != input->position_y ||
body->last_direction_x != input->direction_x || body->last_direction_y != input->direction_y)
{
b3Body_SetTransform(
body->body_id,
ymir_vec(input->position_x, input->position_y),
ymir_session_rotation(input->direction_x, input->direction_y));
}
if (type_changed || body->last_velocity_x != input->velocity_x || body->last_velocity_y != input->velocity_y)
{
b3Body_SetLinearVelocity(body->body_id, ymir_vec(input->velocity_x, input->velocity_y));
}
if (type_changed || body->last_angular_velocity != input->angular_velocity)
{
b3Body_SetAngularVelocity(body->body_id, (b3Vec3){0.0f, input->angular_velocity, 0.0f});
}
body->last_position_x = input->position_x;
body->last_position_y = input->position_y;
body->last_velocity_x = input->velocity_x;
body->last_velocity_y = input->velocity_y;
body->last_direction_x = input->direction_x;
body->last_direction_y = input->direction_y;
body->last_angular_velocity = input->angular_velocity;
body->pending_torque = input->torque;
body->radius = input->radius;
body->mass = input->mass;
body->restitution = input->restitution;
body->is_static = input->is_static != 0;
body->seen = 1;
}
static uint64_t ymir_session_shape_stable_id(b3ShapeId shape_id)
{
return (uint64_t)(uintptr_t)b3Shape_GetUserData(shape_id);
}
typedef struct ymir_box3d_stable_pair
{
uint64_t first;
uint64_t second;
} ymir_box3d_stable_pair;
typedef struct ymir_box3d_stable_pair_set
{
ymir_box3d_stable_pair* pairs;
int count;
int capacity;
} ymir_box3d_stable_pair_set;
static ymir_box3d_status ymir_session_add_stable_pair(
ymir_box3d_stable_pair_set* set,
uint64_t stable_id_a,
uint64_t stable_id_b,
int* added)
{
uint64_t first = stable_id_a < stable_id_b ? stable_id_a : stable_id_b;
uint64_t second = stable_id_a < stable_id_b ? stable_id_b : stable_id_a;
for (int i = 0; i < set->count; ++i)
{
if (set->pairs[i].first == first && set->pairs[i].second == second)
{
*added = 0;
return YMIR_BOX3D_OK;
}
}
if (set->count == set->capacity)
{
int new_capacity = set->capacity > 0 ? set->capacity * 2 : 16;
ymir_box3d_stable_pair* pairs =
(ymir_box3d_stable_pair*)realloc(set->pairs, (size_t)new_capacity * sizeof(ymir_box3d_stable_pair));
if (pairs == NULL)
{
return YMIR_BOX3D_OUT_OF_MEMORY;
}
set->pairs = pairs;
set->capacity = new_capacity;
}
set->pairs[set->count++] = (ymir_box3d_stable_pair){first, second};
*added = 1;
return YMIR_BOX3D_OK;
}
static int ymir_session_contact_from_manifolds(
const b3ContactData* contact,
ymir_box3d_session_contact_output* result)
{
const b3Manifold* selected_manifold = NULL;
const b3ManifoldPoint* selected_point = NULL;
for (int manifold_index = 0; manifold_index < contact->manifoldCount; ++manifold_index)
{
const b3Manifold* manifold = contact->manifolds + manifold_index;
for (int point_index = 0; point_index < manifold->pointCount; ++point_index)
{
const b3ManifoldPoint* point = manifold->points + point_index;
if (selected_point == NULL || point->separation < selected_point->separation)
{
selected_manifold = manifold;
selected_point = point;
}
}
}
if (selected_point == NULL)
{
return 0;
}
uint64_t stable_id_a = ymir_session_shape_stable_id(contact->shapeIdA);
uint64_t stable_id_b = ymir_session_shape_stable_id(contact->shapeIdB);
if (stable_id_a == 0 || stable_id_b == 0 || stable_id_a == stable_id_b)
{
return 0;
}
b3BodyId body_a = b3Shape_GetBody(contact->shapeIdA);
b3BodyId body_b = b3Shape_GetBody(contact->shapeIdB);
b3Pos center_a = b3Body_GetWorldCenterOfMass(body_a);
b3Pos center_b = b3Body_GetWorldCenterOfMass(body_b);
b3Pos point_a = {
center_a.x + selected_point->anchorA.x,
center_a.y + selected_point->anchorA.y,
center_a.z + selected_point->anchorA.z,
};
b3Pos point_b = {
center_b.x + selected_point->anchorB.x,
center_b.y + selected_point->anchorB.y,
center_b.z + selected_point->anchorB.z,
};
*result = (ymir_box3d_session_contact_output){
.stable_id_a = stable_id_a,
.stable_id_b = stable_id_b,
.point_x = 0.5f * (point_a.x + point_b.x),
.point_y = 0.5f * (point_a.z + point_b.z),
.normal_x = selected_manifold->normal.x,
.normal_y = selected_manifold->normal.z,
.penetration = selected_point->separation < 0.0f ? -selected_point->separation : 0.0f,
.relative_speed = selected_point->normalVelocity,
};
return 1;
}
static ymir_box3d_status ymir_session_emit_contact(
ymir_box3d_stable_pair_set* seen,
const ymir_box3d_session_contact_output* contact,
ymir_box3d_session_contact_output* output,
int capacity,
int* total)
{
int added = 0;
ymir_box3d_status status =
ymir_session_add_stable_pair(seen, contact->stable_id_a, contact->stable_id_b, &added);
if (status != YMIR_BOX3D_OK || !added)
{
return status;
}
if (output != NULL)
{
if (*total >= capacity)
{
return YMIR_BOX3D_BUFFER_TOO_SMALL;
}
output[*total] = *contact;
}
*total += 1;
return YMIR_BOX3D_OK;
}
static ymir_box3d_status ymir_session_visit_contacts(
const ymir_box3d_session* session,
ymir_box3d_session_contact_output* output,
int capacity,
int* count)
{
int total = 0;
ymir_box3d_stable_pair_set seen = {0};
for (int body_index = 0; body_index < session->body_count; ++body_index)
{
b3ShapeId source_shape = session->bodies[body_index].shape_id;
int contact_capacity = b3Shape_GetContactCapacity(source_shape);
if (contact_capacity <= 0)
{
continue;
}
b3ContactData* contacts = (b3ContactData*)malloc((size_t)contact_capacity * sizeof(b3ContactData));
if (contacts == NULL)
{
free(seen.pairs);
return YMIR_BOX3D_OUT_OF_MEMORY;
}
int contact_count = b3Shape_GetContactData(source_shape, contacts, contact_capacity);
for (int contact_index = 0; contact_index < contact_count; ++contact_index)
{
const b3ContactData* contact = contacts + contact_index;
uint64_t stable_id_a = ymir_session_shape_stable_id(contact->shapeIdA);
uint64_t stable_id_b = ymir_session_shape_stable_id(contact->shapeIdB);
uint64_t source_stable_id = session->bodies[body_index].stable_id;
uint64_t first_stable_id = stable_id_a < stable_id_b ? stable_id_a : stable_id_b;
if (stable_id_a == 0 || stable_id_b == 0 || source_stable_id != first_stable_id)
{
continue;
}
ymir_box3d_session_contact_output candidate;
if (ymir_session_contact_from_manifolds(contact, &candidate))
{
ymir_box3d_status status =
ymir_session_emit_contact(&seen, &candidate, output, capacity, &total);
if (status != YMIR_BOX3D_OK)
{
free(contacts);
free(seen.pairs);
return status;
}
}
}
free(contacts);
}
b3ContactEvents events = b3World_GetContactEvents(session->world_id);
for (int event_index = 0; event_index < events.hitCount; ++event_index)
{
const b3ContactHitEvent* event = events.hitEvents + event_index;
if (!b3Shape_IsValid(event->shapeIdA) || !b3Shape_IsValid(event->shapeIdB))
{
continue;
}
uint64_t stable_id_a = ymir_session_shape_stable_id(event->shapeIdA);
uint64_t stable_id_b = ymir_session_shape_stable_id(event->shapeIdB);
if (stable_id_a == 0 || stable_id_b == 0 || stable_id_a == stable_id_b)
{
continue;
}
ymir_box3d_session_contact_output candidate = {
.stable_id_a = stable_id_a,
.stable_id_b = stable_id_b,
.point_x = event->point.x,
.point_y = event->point.z,
.normal_x = event->normal.x,
.normal_y = event->normal.z,
.penetration = 0.0f,
.relative_speed = -event->approachSpeed,
};
ymir_box3d_status status = ymir_session_emit_contact(&seen, &candidate, output, capacity, &total);
if (status != YMIR_BOX3D_OK)
{
free(seen.pairs);
return status;
}
}
for (int event_index = 0; event_index < events.beginCount; ++event_index)
{
const b3ContactBeginTouchEvent* event = events.beginEvents + event_index;
if (!b3Contact_IsValid(event->contactId))
{
continue;
}
b3ContactData contact = b3Contact_GetData(event->contactId);
ymir_box3d_session_contact_output candidate;
if (!ymir_session_contact_from_manifolds(&contact, &candidate))
{
continue;
}
ymir_box3d_status status = ymir_session_emit_contact(&seen, &candidate, output, capacity, &total);
if (status != YMIR_BOX3D_OK)
{
free(seen.pairs);
return status;
}
}
free(seen.pairs);
*count = total;
return YMIR_BOX3D_OK;
}
static b3BodyId ymir_create_body(b3WorldId world_id, const ymir_box3d_body_input* input)
{
b3BodyDef body_def = b3DefaultBodyDef();
@ -111,7 +668,7 @@ static ymir_box3d_body_output ymir_read_body(b3BodyId body_id)
};
}
YMIR_EXPORT int ymir_box3d_get_version(int* major, int* minor, int* revision)
YMIR_EXPORT int32_t YMIR_CALL ymir_box3d_get_version(int32_t* major, int32_t* minor, int32_t* revision)
{
b3Version version = b3GetVersion();
*major = version.major;
@ -120,7 +677,7 @@ YMIR_EXPORT int ymir_box3d_get_version(int* major, int* minor, int* revision)
return b3IsDoublePrecision() ? 1 : 0;
}
YMIR_EXPORT ymir_box3d_overlap_result ymir_box3d_overlap_spheres(
YMIR_EXPORT ymir_box3d_overlap_result YMIR_CALL ymir_box3d_overlap_spheres(
float query_x,
float query_y,
float query_radius,
@ -149,7 +706,7 @@ YMIR_EXPORT ymir_box3d_overlap_result ymir_box3d_overlap_spheres(
};
}
YMIR_EXPORT ymir_box3d_overlap_result ymir_box3d_overlap_capsule_sphere(
YMIR_EXPORT ymir_box3d_overlap_result YMIR_CALL ymir_box3d_overlap_capsule_sphere(
float start_x,
float start_y,
float end_x,
@ -180,7 +737,7 @@ YMIR_EXPORT ymir_box3d_overlap_result ymir_box3d_overlap_capsule_sphere(
};
}
YMIR_EXPORT ymir_box3d_cast_result ymir_box3d_cast_sphere(
YMIR_EXPORT ymir_box3d_cast_result YMIR_CALL ymir_box3d_cast_sphere(
float origin_x,
float origin_y,
float translation_x,
@ -211,7 +768,7 @@ YMIR_EXPORT ymir_box3d_cast_result ymir_box3d_cast_sphere(
};
}
YMIR_EXPORT void ymir_box3d_step_pair(
YMIR_EXPORT void YMIR_CALL ymir_box3d_step_pair(
const ymir_box3d_body_input* body_a_input,
const ymir_box3d_body_input* body_b_input,
float time_step,
@ -239,7 +796,7 @@ YMIR_EXPORT void ymir_box3d_step_pair(
b3DestroyWorld(world_id);
}
YMIR_EXPORT ymir_box3d_torque_result ymir_box3d_torque_lifetime(float torque, float time_step, int sub_step_count)
YMIR_EXPORT ymir_box3d_torque_result YMIR_CALL ymir_box3d_torque_lifetime(float torque, float time_step, int32_t sub_step_count)
{
b3WorldDef world_def = b3DefaultWorldDef();
world_def.gravity = b3Vec3_zero;
@ -262,3 +819,301 @@ YMIR_EXPORT ymir_box3d_torque_result ymir_box3d_torque_lifetime(float torque, fl
return (ymir_box3d_torque_result){after_applied_step, after_unforced_step};
}
YMIR_EXPORT uint32_t YMIR_CALL ymir_box3d_get_abi_version(void)
{
return 2;
}
YMIR_EXPORT int32_t YMIR_CALL ymir_box3d_get_abi_layout(
uint32_t* body_input_size,
uint32_t* field_input_size,
uint32_t* body_output_size,
uint32_t* contact_output_size)
{
if (body_input_size == NULL || field_input_size == NULL || body_output_size == NULL || contact_output_size == NULL)
{
return YMIR_BOX3D_INVALID_ARGUMENT;
}
*body_input_size = (uint32_t)sizeof(ymir_box3d_session_body_input);
*field_input_size = (uint32_t)sizeof(ymir_box3d_radial_field_input);
*body_output_size = (uint32_t)sizeof(ymir_box3d_session_body_output);
*contact_output_size = (uint32_t)sizeof(ymir_box3d_session_contact_output);
return YMIR_BOX3D_OK;
}
YMIR_EXPORT int32_t YMIR_CALL ymir_box3d_session_create(ymir_box3d_session** output)
{
if (output == NULL)
{
return YMIR_BOX3D_INVALID_ARGUMENT;
}
*output = NULL;
ymir_box3d_session* session = (ymir_box3d_session*)calloc(1, sizeof(ymir_box3d_session));
if (session == NULL)
{
return YMIR_BOX3D_OUT_OF_MEMORY;
}
b3WorldDef world_def = b3DefaultWorldDef();
world_def.gravity = b3Vec3_zero;
world_def.enableSleep = false;
world_def.workerCount = 1;
session->world_id = b3CreateWorld(&world_def);
if (B3_IS_NULL(session->world_id))
{
free(session);
return YMIR_BOX3D_INTERNAL_ERROR;
}
*output = session;
return YMIR_BOX3D_OK;
}
YMIR_EXPORT void YMIR_CALL ymir_box3d_session_destroy(ymir_box3d_session* session)
{
if (session == NULL)
{
return;
}
if (B3_IS_NON_NULL(session->world_id) && b3World_IsValid(session->world_id))
{
b3DestroyWorld(session->world_id);
}
free(session->bodies);
free(session);
}
YMIR_EXPORT int32_t YMIR_CALL ymir_box3d_session_sync_bodies(
ymir_box3d_session* session,
const ymir_box3d_session_body_input* bodies,
int32_t body_count)
{
if (session == NULL || body_count < 0 || (body_count > 0 && bodies == NULL))
{
return YMIR_BOX3D_INVALID_ARGUMENT;
}
for (int i = 0; i < body_count; ++i)
{
if (!ymir_session_body_input_is_valid(bodies + i))
{
return YMIR_BOX3D_INVALID_ARGUMENT;
}
for (int j = 0; j < i; ++j)
{
if (bodies[i].stable_id == bodies[j].stable_id)
{
return YMIR_BOX3D_DUPLICATE_ID;
}
}
}
ymir_box3d_status status = ymir_session_reserve_bodies(session, session->body_count + body_count);
if (status != YMIR_BOX3D_OK)
{
return status;
}
for (int i = 0; i < session->body_count; ++i)
{
session->bodies[i].seen = 0;
}
for (int i = 0; i < body_count; ++i)
{
int body_index = ymir_session_find_body(session, bodies[i].stable_id);
if (body_index >= 0)
{
ymir_session_update_body(session->bodies + body_index, bodies + i);
continue;
}
ymir_box3d_session_body body;
status = ymir_session_create_body(session, bodies + i, &body);
if (status != YMIR_BOX3D_OK)
{
return status;
}
session->bodies[session->body_count++] = body;
}
for (int i = session->body_count - 1; i >= 0; --i)
{
if (session->bodies[i].seen)
{
continue;
}
b3DestroyBody(session->bodies[i].body_id);
session->bodies[i] = session->bodies[session->body_count - 1];
session->body_count -= 1;
}
return YMIR_BOX3D_OK;
}
YMIR_EXPORT int32_t YMIR_CALL ymir_box3d_session_step(
ymir_box3d_session* session,
float time_step,
int32_t sub_step_count,
const ymir_box3d_radial_field_input* fields,
int32_t field_count)
{
if (session == NULL || !ymir_is_finite(time_step) || time_step <= 0.0f || sub_step_count <= 0 ||
field_count < 0 || (field_count > 0 && fields == NULL))
{
return YMIR_BOX3D_INVALID_ARGUMENT;
}
for (int field_index = 0; field_index < field_count; ++field_index)
{
const ymir_box3d_radial_field_input* field = fields + field_index;
if (!ymir_is_finite(field->position_x) || !ymir_is_finite(field->position_y) ||
!ymir_is_finite(field->strength) || !ymir_is_finite(field->radius))
{
return YMIR_BOX3D_INVALID_ARGUMENT;
}
}
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)
{
body->pending_torque = 0.0f;
continue;
}
if (body->pending_torque != 0.0f)
{
b3Body_ApplyTorque(body->body_id, (b3Vec3){0.0f, body->pending_torque, 0.0f}, true);
}
body->pending_torque = 0.0f;
b3Pos position = b3Body_GetPosition(body->body_id);
float acceleration_x = 0.0f;
float acceleration_y = 0.0f;
for (int field_index = 0; field_index < field_count; ++field_index)
{
const ymir_box3d_radial_field_input* field = fields + field_index;
if (field->radius <= 0.0f || field->strength == 0.0f)
{
continue;
}
float delta_x = field->position_x - position.x;
float delta_y = field->position_y - position.z;
float distance_squared = delta_x * delta_x + delta_y * delta_y;
if (distance_squared <= 0.0f)
{
continue;
}
float distance = sqrtf(distance_squared);
if (distance > field->radius)
{
continue;
}
float falloff = 1.0f - distance / field->radius;
float magnitude = field->strength * falloff / distance;
acceleration_x += delta_x * magnitude;
acceleration_y += delta_y * magnitude;
}
if (acceleration_x != 0.0f || acceleration_y != 0.0f)
{
float mass = b3Body_GetMass(body->body_id);
b3Body_ApplyForceToCenter(body->body_id, ymir_vec(mass * acceleration_x, mass * acceleration_y), true);
}
}
b3World_Step(session->world_id, time_step, sub_step_count);
for (int i = 0; i < session->body_count; ++i)
{
b3Pos position = b3Body_GetPosition(session->bodies[i].body_id);
b3Vec3 velocity = b3Body_GetLinearVelocity(session->bodies[i].body_id);
b3Vec3 direction = ymir_session_direction(session->bodies[i].body_id);
b3Vec3 angular_velocity = b3Body_GetAngularVelocity(session->bodies[i].body_id);
session->bodies[i].last_position_x = position.x;
session->bodies[i].last_position_y = position.z;
session->bodies[i].last_velocity_x = velocity.x;
session->bodies[i].last_velocity_y = velocity.z;
session->bodies[i].last_direction_x = direction.x;
session->bodies[i].last_direction_y = direction.z;
session->bodies[i].last_angular_velocity = angular_velocity.y;
}
return YMIR_BOX3D_OK;
}
YMIR_EXPORT int32_t YMIR_CALL ymir_box3d_session_get_body_count(const ymir_box3d_session* session)
{
return session != NULL ? session->body_count : -YMIR_BOX3D_INVALID_ARGUMENT;
}
YMIR_EXPORT int32_t YMIR_CALL ymir_box3d_session_copy_bodies(
const ymir_box3d_session* session,
ymir_box3d_session_body_output* output,
int32_t capacity,
int32_t* written)
{
if (session == NULL || written == NULL || capacity < 0 || (capacity > 0 && output == NULL))
{
return YMIR_BOX3D_INVALID_ARGUMENT;
}
*written = session->body_count;
if (capacity < session->body_count)
{
return YMIR_BOX3D_BUFFER_TOO_SMALL;
}
for (int i = 0; i < session->body_count; ++i)
{
b3Pos position = b3Body_GetPosition(session->bodies[i].body_id);
b3Vec3 velocity = b3Body_GetLinearVelocity(session->bodies[i].body_id);
b3Vec3 direction = ymir_session_direction(session->bodies[i].body_id);
b3Vec3 angular_velocity = b3Body_GetAngularVelocity(session->bodies[i].body_id);
output[i] = (ymir_box3d_session_body_output){
.stable_id = session->bodies[i].stable_id,
.position_x = position.x,
.position_y = position.z,
.velocity_x = velocity.x,
.velocity_y = velocity.z,
.direction_x = direction.x,
.direction_y = direction.z,
.angular_velocity = angular_velocity.y,
};
}
return YMIR_BOX3D_OK;
}
YMIR_EXPORT int32_t YMIR_CALL ymir_box3d_session_get_contact_count(const ymir_box3d_session* session)
{
if (session == NULL)
{
return -YMIR_BOX3D_INVALID_ARGUMENT;
}
int count = 0;
ymir_box3d_status status = ymir_session_visit_contacts(session, NULL, 0, &count);
return status == YMIR_BOX3D_OK ? count : -(int)status;
}
YMIR_EXPORT int32_t YMIR_CALL ymir_box3d_session_copy_contacts(
const ymir_box3d_session* session,
ymir_box3d_session_contact_output* output,
int32_t capacity,
int32_t* written)
{
if (session == NULL || written == NULL || capacity < 0 || (capacity > 0 && output == NULL))
{
return YMIR_BOX3D_INVALID_ARGUMENT;
}
*written = 0;
return ymir_session_visit_contacts(session, output, capacity, written);
}

View file

@ -0,0 +1,183 @@
using System.Runtime.InteropServices;
using System.Runtime.CompilerServices;
using Microsoft.Win32.SafeHandles;
namespace Ymir.Box3D;
internal enum Box3DStatus
{
Ok = 0,
InvalidArgument = 1,
DuplicateId = 2,
BufferTooSmall = 3,
OutOfMemory = 4,
InternalError = 5
}
internal static partial class Box3DNative
{
internal const string LibraryName = "ymir_box3d";
[LibraryImport(LibraryName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial uint ymir_box3d_get_abi_version();
[LibraryImport(LibraryName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial Box3DStatus ymir_box3d_get_abi_layout(
out uint bodyInputSize,
out uint fieldInputSize,
out uint bodyOutputSize,
out uint contactOutputSize);
[LibraryImport(LibraryName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial int ymir_box3d_get_version(out int major, out int minor, out int revision);
[LibraryImport(LibraryName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial Box3DCastOutput ymir_box3d_cast_sphere(
float originX,
float originZ,
float translationX,
float translationZ,
float queryRadius,
float bodyX,
float bodyZ,
float bodyRadius,
int canEncroach);
[LibraryImport(LibraryName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial Box3DOverlapOutput ymir_box3d_overlap_spheres(
float queryX,
float queryZ,
float queryRadius,
float bodyX,
float bodyZ,
float bodyRadius);
[LibraryImport(LibraryName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial Box3DStatus ymir_box3d_session_create(out nint session);
[LibraryImport(LibraryName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial void ymir_box3d_session_destroy(nint session);
[LibraryImport(LibraryName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static unsafe partial Box3DStatus ymir_box3d_session_sync_bodies(
Box3DSessionHandle session,
Box3DBodyInput* bodies,
int bodyCount);
[LibraryImport(LibraryName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static unsafe partial Box3DStatus ymir_box3d_session_step(
Box3DSessionHandle session,
float deltaTime,
int substepCount,
Box3DRadialField* fields,
int fieldCount);
[LibraryImport(LibraryName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial int ymir_box3d_session_get_body_count(Box3DSessionHandle session);
[LibraryImport(LibraryName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static unsafe partial Box3DStatus ymir_box3d_session_copy_bodies(
Box3DSessionHandle session,
Box3DBodyOutput* output,
int capacity,
out int written);
[LibraryImport(LibraryName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial int ymir_box3d_session_get_contact_count(Box3DSessionHandle session);
[LibraryImport(LibraryName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static unsafe partial Box3DStatus ymir_box3d_session_copy_contacts(
Box3DSessionHandle session,
Box3DContactOutput* output,
int capacity,
out int written);
}
internal sealed class Box3DSessionHandle : SafeHandleZeroOrMinusOneIsInvalid
{
private Box3DSessionHandle() : base(true)
{
}
internal Box3DSessionHandle(nint value) : base(true) => SetHandle(value);
protected override bool ReleaseHandle()
{
Box3DNative.ymir_box3d_session_destroy(handle);
return true;
}
}
[StructLayout(LayoutKind.Sequential)]
internal readonly record struct Box3DBodyInput(
ulong StableId,
float PositionX,
float PositionZ,
float VelocityX,
float VelocityZ,
float DirectionX,
float DirectionZ,
float AngularVelocity,
float Torque,
float Radius,
float Mass,
float Restitution,
uint IsStatic);
[StructLayout(LayoutKind.Sequential)]
internal readonly record struct Box3DRadialField(
float PositionX,
float PositionZ,
float Strength,
float Radius);
[StructLayout(LayoutKind.Sequential)]
internal readonly record struct Box3DBodyOutput(
ulong StableId,
float PositionX,
float PositionZ,
float VelocityX,
float VelocityZ,
float DirectionX,
float DirectionZ,
float AngularVelocity);
[StructLayout(LayoutKind.Sequential)]
internal readonly record struct Box3DContactOutput(
ulong StableIdA,
ulong StableIdB,
float PointX,
float PointZ,
float NormalX,
float NormalZ,
float Penetration,
float RelativeSpeed);
[StructLayout(LayoutKind.Sequential)]
internal readonly record struct Box3DCastOutput(
int Hit,
float Fraction,
float PointX,
float PointZ,
float NormalX,
float NormalZ);
[StructLayout(LayoutKind.Sequential)]
internal readonly record struct Box3DOverlapOutput(
int Hit,
float Distance,
float NormalX,
float NormalZ);

View file

@ -0,0 +1,65 @@
namespace Ymir.Box3D;
internal readonly record struct Box3DSphereCastHit(
float Fraction,
float PointX,
float PointZ,
float NormalX,
float NormalZ);
internal readonly record struct Box3DSphereOverlapHit(float Distance, float NormalX, float NormalZ);
internal static class Box3DQueries
{
internal static Box3DSphereOverlapHit? OverlapSpheres(
float queryX,
float queryZ,
float queryRadius,
float bodyX,
float bodyZ,
float bodyRadius)
{
Box3DSession.EnsureCompatibleNativeLibrary();
var output = Box3DNative.ymir_box3d_overlap_spheres(
queryX,
queryZ,
queryRadius,
bodyX,
bodyZ,
bodyRadius);
return output.Hit == 0
? null
: new Box3DSphereOverlapHit(output.Distance, output.NormalX, output.NormalZ);
}
internal static Box3DSphereCastHit? CastSphere(
float originX,
float originZ,
float translationX,
float translationZ,
float queryRadius,
float bodyX,
float bodyZ,
float bodyRadius)
{
Box3DSession.EnsureCompatibleNativeLibrary();
var output = Box3DNative.ymir_box3d_cast_sphere(
originX,
originZ,
translationX,
translationZ,
queryRadius,
bodyX,
bodyZ,
bodyRadius,
canEncroach: 0);
return output.Hit == 0
? null
: new Box3DSphereCastHit(
output.Fraction,
output.PointX,
output.PointZ,
output.NormalX,
output.NormalZ);
}
}

View file

@ -0,0 +1,240 @@
using System.Runtime.InteropServices;
namespace Ymir.Box3D;
internal readonly record struct Box3DBody(
ulong StableId,
float PositionX,
float PositionZ,
float VelocityX,
float VelocityZ,
float DirectionX,
float DirectionZ,
float AngularVelocity,
float Torque,
float Radius,
float Mass,
float Restitution,
bool IsStatic);
internal readonly record struct Box3DField(float PositionX, float PositionZ, float Strength, float Radius);
internal readonly record struct Box3DBodyState(
ulong StableId,
float PositionX,
float PositionZ,
float VelocityX,
float VelocityZ,
float DirectionX,
float DirectionZ,
float AngularVelocity);
internal readonly record struct Box3DContact(
ulong StableIdA,
ulong StableIdB,
float PointX,
float PointZ,
float NormalX,
float NormalZ,
float Penetration,
float RelativeSpeed);
internal sealed class Box3DSession : IDisposable
{
internal const uint AbiVersion = 2;
internal const int DefaultSubstepCount = 4;
private readonly Box3DSessionHandle _handle;
private bool _disposed;
internal Box3DSession()
{
EnsureCompatibleNativeLibrary();
ThrowIfFailed(Box3DNative.ymir_box3d_session_create(out var value), "create session");
if (value == 0)
{
throw new InvalidOperationException("Box3D returned a null Ymir session.");
}
_handle = new Box3DSessionHandle(value);
}
internal unsafe void SyncBodies(ReadOnlySpan<Box3DBody> bodies)
{
ThrowIfDisposed();
var native = new Box3DBodyInput[bodies.Length];
for (var i = 0; i < bodies.Length; i++)
{
var body = bodies[i];
native[i] = new Box3DBodyInput(
body.StableId,
body.PositionX,
body.PositionZ,
body.VelocityX,
body.VelocityZ,
body.DirectionX,
body.DirectionZ,
body.AngularVelocity,
body.Torque,
body.Radius,
body.Mass,
body.Restitution,
body.IsStatic ? 1u : 0u);
}
fixed (Box3DBodyInput* pointer = native)
{
var status = Box3DNative.ymir_box3d_session_sync_bodies(_handle, pointer, native.Length);
if (status is Box3DStatus.OutOfMemory or Box3DStatus.InternalError)
{
Dispose();
}
ThrowIfFailed(status, "synchronize bodies");
}
}
internal unsafe void Step(float deltaTime, ReadOnlySpan<Box3DField> fields, int substepCount = DefaultSubstepCount)
{
ThrowIfDisposed();
if (!float.IsFinite(deltaTime) || deltaTime <= 0.0f)
{
throw new ArgumentOutOfRangeException(nameof(deltaTime), "Delta time must be finite and positive.");
}
if (substepCount <= 0)
{
throw new ArgumentOutOfRangeException(nameof(substepCount), "Substep count must be positive.");
}
var native = new Box3DRadialField[fields.Length];
for (var i = 0; i < fields.Length; i++)
{
native[i] = new Box3DRadialField(fields[i].PositionX, fields[i].PositionZ, fields[i].Strength, fields[i].Radius);
}
fixed (Box3DRadialField* pointer = native)
{
ThrowIfFailed(
Box3DNative.ymir_box3d_session_step(_handle, deltaTime, substepCount, pointer, native.Length),
"step session");
}
}
internal unsafe Box3DBodyState[] CopyBodies()
{
ThrowIfDisposed();
var count = Box3DNative.ymir_box3d_session_get_body_count(_handle);
if (count < 0)
{
throw new InvalidOperationException("Box3D returned an invalid body count.");
}
var native = new Box3DBodyOutput[count];
fixed (Box3DBodyOutput* pointer = native)
{
ThrowIfFailed(Box3DNative.ymir_box3d_session_copy_bodies(_handle, pointer, count, out var written), "copy bodies");
if (written != count)
{
throw new InvalidOperationException($"Box3D body snapshot changed during copy: expected {count}, received {written}.");
}
}
return native.Select(body => new Box3DBodyState(
body.StableId,
body.PositionX,
body.PositionZ,
body.VelocityX,
body.VelocityZ,
body.DirectionX,
body.DirectionZ,
body.AngularVelocity)).ToArray();
}
internal unsafe Box3DContact[] CopyContacts()
{
ThrowIfDisposed();
var count = Box3DNative.ymir_box3d_session_get_contact_count(_handle);
if (count < 0)
{
throw new InvalidOperationException("Box3D returned an invalid contact count.");
}
var native = new Box3DContactOutput[count];
fixed (Box3DContactOutput* pointer = native)
{
ThrowIfFailed(Box3DNative.ymir_box3d_session_copy_contacts(_handle, pointer, count, out var written), "copy contacts");
if (written != count)
{
throw new InvalidOperationException($"Box3D contact snapshot changed during copy: expected {count}, received {written}.");
}
}
return native.Select(contact => new Box3DContact(
contact.StableIdA,
contact.StableIdB,
contact.PointX,
contact.PointZ,
contact.NormalX,
contact.NormalZ,
contact.Penetration,
contact.RelativeSpeed)).ToArray();
}
public void Dispose()
{
if (_disposed)
{
return;
}
_handle.Dispose();
_disposed = true;
}
internal static void EnsureCompatibleNativeLibrary()
{
var abi = Box3DNative.ymir_box3d_get_abi_version();
if (abi != AbiVersion)
{
throw new InvalidOperationException($"Ymir Box3D ABI mismatch. Managed expects {AbiVersion}, native provides {abi}.");
}
ThrowIfFailed(
Box3DNative.ymir_box3d_get_abi_layout(
out var bodyInputSize,
out var fieldInputSize,
out var bodyOutputSize,
out var contactOutputSize),
"read native ABI layout");
EnsureSize<Box3DBodyInput>(bodyInputSize);
EnsureSize<Box3DRadialField>(fieldInputSize);
EnsureSize<Box3DBodyOutput>(bodyOutputSize);
EnsureSize<Box3DContactOutput>(contactOutputSize);
Box3DNative.ymir_box3d_get_version(out var major, out var minor, out var revision);
if (major != 0 || minor != 1 || revision != 0)
{
throw new InvalidOperationException($"Ymir requires Box3D 0.1.0, but loaded {major}.{minor}.{revision}.");
}
}
private static void EnsureSize<T>(uint nativeSize) where T : struct
{
var managedSize = checked((uint)Marshal.SizeOf<T>());
if (managedSize != nativeSize)
{
throw new InvalidOperationException(
$"Ymir Box3D ABI layout mismatch for {typeof(T).Name}. Managed expects {managedSize} bytes, native provides {nativeSize}.");
}
}
private static void ThrowIfFailed(Box3DStatus status, string operation)
{
if (status != Box3DStatus.Ok)
{
throw new InvalidOperationException($"Ymir could not {operation}: native Box3D status {status}.");
}
}
private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this);
}

View file

@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Ymir.Core")]

13
src/Ymir.Box3D/README.md Normal file
View file

@ -0,0 +1,13 @@
# GameCult.Ymir.Box3D
This is Ymir's private managed/native boundary for pinned Box3D v0.1.0. It is
installed transitively by Ymir; products and clients should reference Ymir's
typed session contracts instead of this package.
The package includes the compiled native facade under `runtimes/<rid>/native`.
Package consumers do not need CMake, a C compiler, or the Box3D submodule.
Missing or ABI-incompatible native assets fail closed when Ymir opens a physics
session.
Source builds of Ymir use CMake to compile the pinned submodule. Box3D is MIT
licensed; its license is included in the package.

View file

@ -0,0 +1,58 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<RootNamespace>Ymir.Box3D</RootNamespace>
<AssemblyName>Ymir.Box3D</AssemblyName>
<Description>Native ABI boundary between Ymir sessions and pinned Box3D.</Description>
<PackageId>GameCult.Ymir.Box3D</PackageId>
<Version>0.1.0</Version>
<Authors>GameCult</Authors>
<IsPackable>true</IsPackable>
<PackageReadmeFile>README.md</PackageReadmeFile>
<NativeSourceDir>$(MSBuildThisFileDirectory)..\..\native\Ymir.Box3D</NativeSourceDir>
<NativeBuildDir>$(MSBuildThisFileDirectory)..\..\artifacts\box3d-native\production\$(Configuration)</NativeBuildDir>
<CMakeExe Condition="'$(CMakeExe)' == '' and '$(OS)' == 'Windows_NT' and Exists('C:\Program Files\CMake\bin\cmake.exe')">C:\Program Files\CMake\bin\cmake.exe</CMakeExe>
<CMakeExe Condition="'$(CMakeExe)' == ''">cmake</CMakeExe>
</PropertyGroup>
<ItemGroup>
<None Include="$(NativeBuildDir)\bin\$(Configuration)\ymir_box3d.dll"
Link="ymir_box3d.dll"
CopyToOutputDirectory="PreserveNewest"
CopyToPublishDirectory="PreserveNewest"
Pack="true"
PackagePath="runtimes/win-x64/native/ymir_box3d.dll"
Condition="'$(OS)' == 'Windows_NT'" />
<None Include="$(NativeBuildDir)\bin\libymir_box3d.so"
Link="libymir_box3d.so"
CopyToOutputDirectory="PreserveNewest"
CopyToPublishDirectory="PreserveNewest"
Pack="true"
PackagePath="runtimes/linux-x64/native/libymir_box3d.so"
Condition="'$(OS)' != 'Windows_NT' and !$([MSBuild]::IsOSPlatform('OSX'))" />
<None Include="$(NativeBuildDir)\bin\libymir_box3d.dylib"
Link="libymir_box3d.dylib"
CopyToOutputDirectory="PreserveNewest"
CopyToPublishDirectory="PreserveNewest"
Pack="true"
PackagePath="runtimes/osx-x64/native/libymir_box3d.dylib"
Condition="$([MSBuild]::IsOSPlatform('OSX'))" />
<None Include="..\..\extern\box3d\LICENSE"
Link="box3d-MIT.txt"
Pack="true"
PackagePath="licenses" />
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>
<Target Name="BuildYmirBox3DNative"
BeforeTargets="PrepareForBuild;GetCopyToOutputDirectoryItems;GetCopyToPublishDirectoryItems"
Condition="'$(DesignTimeBuild)' != 'true'">
<Exec Command="&quot;$(CMakeExe)&quot; -S &quot;$(NativeSourceDir)&quot; -B &quot;$(NativeBuildDir)&quot; -DBUILD_SHARED_LIBS=OFF" />
<Exec Command="&quot;$(CMakeExe)&quot; --build &quot;$(NativeBuildDir)&quot; --config $(Configuration) --target ymir_box3d" />
</Target>
</Project>

View file

@ -18,7 +18,10 @@ public sealed record PhysicsBody(
float Radius,
float Mass,
bool IsStatic = false,
float Restitution = 0.2f);
float Restitution = 0.2f,
Vec2? Direction = null,
float AngularVelocity = 0.0f,
float Torque = 0.0f);
public sealed record RadialField(
string Id,
@ -46,3 +49,31 @@ public sealed record ContactEvent(
public sealed record SimulationStepResult(
YmirWorld World,
IReadOnlyList<ContactEvent> Contacts);
public sealed record CircleCastQueryRequest(
Vec2 Origin,
Vec2 Direction,
float Distance,
float Radius,
IReadOnlyList<PhysicsBody> Bodies);
public sealed record CircleCastHit(
string BodyId,
Vec2 Point,
Vec2 Normal,
float Distance);
public sealed record CircleCastQueryResult(IReadOnlyList<CircleCastHit> Hits);
public sealed record CircleOverlapQueryRequest(
Vec2 Center,
float Radius,
IReadOnlyList<PhysicsBody> Bodies);
public sealed record CircleOverlapHit(
string BodyId,
Vec2 Point,
Vec2 Normal,
float Distance);
public sealed record CircleOverlapQueryResult(IReadOnlyList<CircleOverlapHit> Hits);

View file

@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Ymir.Tests")]

View file

@ -6,6 +6,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Ymir.Box3D\Ymir.Box3D.csproj" />
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Caching\GameCult.Caching.csproj" />
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" />
<ProjectReference Include="$(EveUnityRoot)\packages\org.gamecult.eve.surface\GameCult.Eve.Surface.csproj" />
@ -18,7 +19,7 @@
<Nullable>enable</Nullable>
<RootNamespace>Ymir.Core</RootNamespace>
<AssemblyName>Ymir.Core</AssemblyName>
<Description>GameCult physics/world substrate engine built on CultMath.</Description>
<Description>GameCult physics session daemon and contracts backed by pinned Box3D.</Description>
<Authors>GameCult</Authors>
</PropertyGroup>

View file

@ -0,0 +1,116 @@
using Ymir.Box3D;
namespace Ymir.Core;
public static class YmirQueries
{
public static CircleOverlapQueryResult OverlapCircle(CircleOverlapQueryRequest request)
{
ArgumentNullException.ThrowIfNull(request);
if (!float.IsFinite(request.Radius) || request.Radius <= 0.0f)
{
throw new ArgumentOutOfRangeException(nameof(request), "Overlap radius must be finite and positive.");
}
if (!float.IsFinite(request.Center.X) || !float.IsFinite(request.Center.Y))
{
throw new ArgumentOutOfRangeException(nameof(request), "Overlap center must be finite.");
}
var hits = new List<CircleOverlapHit>();
foreach (var body in request.Bodies)
{
ValidateQueryBody(body, request);
var hit = Box3DQueries.OverlapSpheres(
request.Center.X,
request.Center.Y,
request.Radius,
body.Position.X,
body.Position.Y,
body.Radius);
if (hit is not { } value)
{
continue;
}
hits.Add(new CircleOverlapHit(
body.Id,
request.Center,
new Vec2(value.NormalX, value.NormalZ),
value.Distance));
}
return new CircleOverlapQueryResult(hits
.OrderBy(hit => hit.Distance)
.ThenBy(hit => hit.BodyId, StringComparer.Ordinal)
.ToArray());
}
public static CircleCastQueryResult CastCircle(CircleCastQueryRequest request)
{
ArgumentNullException.ThrowIfNull(request);
if (!float.IsFinite(request.Distance) || request.Distance < 0.0f)
{
throw new ArgumentOutOfRangeException(nameof(request), "Cast distance must be finite and non-negative.");
}
if (!float.IsFinite(request.Radius) || request.Radius <= 0.0f)
{
throw new ArgumentOutOfRangeException(nameof(request), "Cast radius must be finite and positive.");
}
if (!float.IsFinite(request.Origin.X) || !float.IsFinite(request.Origin.Y))
{
throw new ArgumentOutOfRangeException(nameof(request), "Cast origin must be finite.");
}
var directionLengthSquared = request.Direction.X * request.Direction.X + request.Direction.Y * request.Direction.Y;
if (!float.IsFinite(directionLengthSquared) || directionLengthSquared <= 1.0e-8f)
{
throw new ArgumentOutOfRangeException(nameof(request), "Cast direction must be finite and non-zero.");
}
var inverseLength = 1.0f / MathF.Sqrt(directionLengthSquared);
var translationX = request.Direction.X * inverseLength * request.Distance;
var translationY = request.Direction.Y * inverseLength * request.Distance;
var hits = new List<CircleCastHit>();
foreach (var body in request.Bodies)
{
ValidateQueryBody(body, request);
var hit = Box3DQueries.CastSphere(
request.Origin.X,
request.Origin.Y,
translationX,
translationY,
request.Radius,
body.Position.X,
body.Position.Y,
body.Radius);
if (hit is not { } value)
{
continue;
}
hits.Add(new CircleCastHit(
body.Id,
new Vec2(value.PointX, value.PointZ),
new Vec2(value.NormalX, value.NormalZ),
value.Fraction * request.Distance));
}
return new CircleCastQueryResult(hits
.OrderBy(hit => hit.Distance)
.ThenBy(hit => hit.BodyId, StringComparer.Ordinal)
.ToArray());
}
private static void ValidateQueryBody(PhysicsBody body, object request)
{
if (string.IsNullOrWhiteSpace(body.Id) ||
!float.IsFinite(body.Position.X) ||
!float.IsFinite(body.Position.Y) ||
!float.IsFinite(body.Radius) ||
body.Radius <= 0.0f)
{
throw new ArgumentException("Query bodies require stable ids and finite positive radii.", nameof(request));
}
}
}

View file

@ -17,7 +17,7 @@ public sealed class YmirProviderAdvertisementDocument
public string Title { get; set; } = "Ymir Physics";
[Key(2)]
public string Description { get; set; } = "GameCult authoritative Box3D session wrapper and physics daemon.";
public string Description { get; set; } = "GameCult Box3D snapshot wrapper and physics-daemon cutover surface.";
[Key(3)]
public string[] Owns { get; set; } = [];
@ -44,7 +44,7 @@ public sealed class YmirOperatorStateDocument
public string Status { get; set; } = "mvp";
[Key(2)]
public string StateOwner { get; set; } = "Ymir world sessions and typed GameCult projections";
public string StateOwner { get; set; } = "Isolated Box3D snapshot steps and typed diagnostic projections";
[Key(3)]
public string NumericSubstrate { get; set; } = "Box3D v0.1.0 (C17)";
@ -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.v0 legacy checkpoint during Box3D cutover";
public string Persistence { get; set; } = "CultCache gamecult.ymir.world_state.v1; explicit v0 read migration";
[Key(6)]
public string UpdatedAtUtc { get; set; } = "";
@ -81,18 +81,21 @@ public static class YmirServicePublication
{
ProviderId = "ymir.physics",
Title = "Ymir Physics",
Description = "GameCult authoritative Box3D session wrapper and physics daemon.",
Description = "GameCult Box3D snapshot wrapper and physics-daemon cutover surface.",
Owns =
[
"authoritative physics session lifecycle",
"stable GameCult id to Box3D handle projection",
"typed physics commands and facts",
"Box3D-only isolated snapshot stepping",
"stable GameCult id to transient Box3D handle projection",
"Box3D-backed circle overlap and cast queries over submitted bodies",
"deterministic result ordering",
"CultCache checkpoint reconstruction",
"CultMesh and Eve service publication"
"CultCache world snapshot v1 with explicit v0 read migration",
"diagnostic Eve publication"
],
DoesNotOwn =
[
"public retained session lifecycle yet",
"typed command receipts or begin/end/hit lifecycle facts yet",
"complete checkpoint reconstruction yet",
"physics algorithm invention",
"Box3D solver and collision semantics",
"Unity scene truth",
@ -108,10 +111,10 @@ public static class YmirServicePublication
{
ProviderId = "ymir.physics",
Status = "mvp",
StateOwner = "Ymir world sessions and typed GameCult projections",
StateOwner = "Isolated Box3D snapshot steps and typed diagnostic projections",
NumericSubstrate = "Box3D v0.1.0 (C17)",
BatchKernel = "Box3D native solver; Ymir does not own physics algorithms",
Persistence = "CultCache gamecult.ymir.world_state.v0 legacy checkpoint during Box3D cutover",
Persistence = "CultCache gamecult.ymir.world_state.v1; explicit v0 read migration",
UpdatedAtUtc = FormatUtc(now)
};

View file

@ -1,144 +1,20 @@
using CultMath;
using static CultMath.math;
namespace Ymir.Core;
/// <summary>
/// Compatibility façade for the snapshot-in/snapshot-out Ymir contract.
/// Calls are isolated by design; retained world ownership is not inferred from snapshots.
/// </summary>
public sealed class YmirSimulator
{
private const float MinimumMass = 0.0001f;
private const float MinimumDistance = 0.0001f;
public SimulationStepResult Step(SimulationStepRequest request)
{
var result = Step(new SoASimulationStepRequest(
request.DeltaTime,
YmirSoAWorld.FromWorld(request.World)));
return new SimulationStepResult(result.World.ToWorld(), result.Contacts);
using var session = new YmirWorldSession();
return session.Step(request);
}
public SoASimulationStepResult Step(SoASimulationStepRequest request)
{
if (request.DeltaTime <= 0.0f)
{
throw new ArgumentOutOfRangeException(nameof(request), "DeltaTime must be positive.");
}
var world = request.World.Clone();
world.Validate();
var accelerationX = new float[world.BodyCount];
var accelerationY = new float[world.BodyCount];
for (var i = 0; i < world.FieldCount; i++)
{
BatchMath.AddRadialFalloffAcceleration2D(
world.PositionX,
world.PositionY,
world.FieldPositionX[i],
world.FieldPositionY[i],
world.FieldStrength[i],
world.FieldRadius[i],
accelerationX,
accelerationY);
}
BatchMath.IntegrateSemiImplicitEuler2D(
request.DeltaTime,
world.DynamicMask,
world.PositionX,
world.PositionY,
world.VelocityX,
world.VelocityY,
accelerationX,
accelerationY);
var contacts = new List<ContactEvent>();
ResolveCircleContacts(world, contacts);
world.Time += request.DeltaTime;
return new SoASimulationStepResult(
world,
contacts);
}
private static void ResolveCircleContacts(YmirSoAWorld world, List<ContactEvent> contacts)
{
for (var i = 0; i < world.BodyCount; i++)
{
for (var j = i + 1; j < world.BodyCount; j++)
{
var delta = world.PositionAt(j) - world.PositionAt(i);
var distance = length(delta);
var combinedRadius = world.Radius[i] + world.Radius[j];
var penetration = combinedRadius - distance;
if (penetration <= 0.0f)
{
continue;
}
var normal = distance <= MinimumDistance ? float2(1.0f, 0.0f) : delta / distance;
var point = world.PositionAt(i) + normal * (world.Radius[i] - penetration * 0.5f);
var relativeVelocity = world.VelocityAt(j) - world.VelocityAt(i);
var relativeSpeed = dot(relativeVelocity, normal);
contacts.Add(new ContactEvent(
world.BodyIds[i],
world.BodyIds[j],
Vec2.FromCultMath(point),
Vec2.FromCultMath(normal),
penetration,
relativeSpeed));
ResolveImpulse(world, i, j, normal, penetration, relativeSpeed);
}
}
}
private static void ResolveImpulse(
YmirSoAWorld world,
int left,
int right,
float2 normal,
float penetration,
float relativeSpeed)
{
var leftInvMass = world.DynamicMask[left] <= 0.0f ? 0.0f : 1.0f / max(world.Mass[left], MinimumMass);
var rightInvMass = world.DynamicMask[right] <= 0.0f ? 0.0f : 1.0f / max(world.Mass[right], MinimumMass);
var invMassSum = leftInvMass + rightInvMass;
if (invMassSum <= 0.0f)
{
return;
}
var correction = normal * (penetration / invMassSum);
if (leftInvMass > 0.0f)
{
world.SetPosition(left, world.PositionAt(left) - correction * leftInvMass);
}
if (rightInvMass > 0.0f)
{
world.SetPosition(right, world.PositionAt(right) + correction * rightInvMass);
}
if (relativeSpeed >= 0.0f)
{
return;
}
var restitution = min(world.Restitution[left], world.Restitution[right]);
var impulseMagnitude = -(1.0f + restitution) * relativeSpeed / invMassSum;
var impulse = normal * impulseMagnitude;
if (leftInvMass > 0.0f)
{
world.SetVelocity(left, world.VelocityAt(left) - impulse * leftInvMass);
}
if (rightInvMass > 0.0f)
{
world.SetVelocity(right, world.VelocityAt(right) + impulse * rightInvMass);
}
using var session = new YmirWorldSession();
return session.Step(request);
}
}

View file

@ -10,6 +10,10 @@ public sealed class YmirSoAWorld
public float[] PositionY { get; set; } = [];
public float[] VelocityX { get; set; } = [];
public float[] VelocityY { get; set; } = [];
public float[] DirectionX { get; set; } = [];
public float[] DirectionY { get; set; } = [];
public float[] AngularVelocity { get; set; } = [];
public float[] Torque { get; set; } = [];
public float[] Radius { get; set; } = [];
public float[] Mass { get; set; } = [];
public float[] DynamicMask { get; set; } = [];
@ -37,6 +41,10 @@ public sealed class YmirSoAWorld
PositionY = bodies.Select(body => body.Position.Y).ToArray(),
VelocityX = bodies.Select(body => body.Velocity.X).ToArray(),
VelocityY = bodies.Select(body => body.Velocity.Y).ToArray(),
DirectionX = bodies.Select(body => DirectionOrDefault(body.Direction).X).ToArray(),
DirectionY = bodies.Select(body => DirectionOrDefault(body.Direction).Y).ToArray(),
AngularVelocity = bodies.Select(body => body.AngularVelocity).ToArray(),
Torque = bodies.Select(body => body.Torque).ToArray(),
Radius = bodies.Select(body => body.Radius).ToArray(),
Mass = bodies.Select(body => body.Mass).ToArray(),
DynamicMask = bodies.Select(body => body.IsStatic ? 0.0f : 1.0f).ToArray(),
@ -59,7 +67,10 @@ public sealed class YmirSoAWorld
Radius[i],
Mass[i],
DynamicMask[i] <= 0.0f,
Restitution[i]))
Restitution[i],
new Vec2(DirectionX[i], DirectionY[i]),
AngularVelocity[i],
Torque[i]))
.ToArray(),
Enumerable.Range(0, FieldCount)
.Select(i => new RadialField(
@ -77,6 +88,10 @@ public sealed class YmirSoAWorld
PositionY = (float[])PositionY.Clone(),
VelocityX = (float[])VelocityX.Clone(),
VelocityY = (float[])VelocityY.Clone(),
DirectionX = (float[])DirectionX.Clone(),
DirectionY = (float[])DirectionY.Clone(),
AngularVelocity = (float[])AngularVelocity.Clone(),
Torque = (float[])Torque.Clone(),
Radius = (float[])Radius.Clone(),
Mass = (float[])Mass.Clone(),
DynamicMask = (float[])DynamicMask.Clone(),
@ -109,6 +124,17 @@ public sealed class YmirSoAWorld
{
throw new ArgumentOutOfRangeException(nameof(Mass), "Dynamic body mass must be positive.");
}
var directionLengthSquared = DirectionX[i] * DirectionX[i] + DirectionY[i] * DirectionY[i];
if (!float.IsFinite(directionLengthSquared) || directionLengthSquared <= 1.0e-8f)
{
throw new ArgumentOutOfRangeException(nameof(DirectionX), "Body direction must be finite and non-zero.");
}
if (!float.IsFinite(AngularVelocity[i]) || !float.IsFinite(Torque[i]))
{
throw new ArgumentOutOfRangeException(nameof(AngularVelocity), "Body angular state must be finite.");
}
}
}
@ -131,12 +157,17 @@ public sealed class YmirSoAWorld
private void ValidateBodyLengths()
{
var expected = BodyIds.Length;
FillMissingAngularState(expected);
foreach (var length in new[]
{
PositionX.Length,
PositionY.Length,
VelocityX.Length,
VelocityY.Length,
DirectionX.Length,
DirectionY.Length,
AngularVelocity.Length,
Torque.Length,
Radius.Length,
Mass.Length,
DynamicMask.Length,
@ -150,6 +181,40 @@ public sealed class YmirSoAWorld
}
}
private void FillMissingAngularState(int expected)
{
if (DirectionX.Length == 0 && expected > 0)
{
DirectionX = new float[expected];
}
if (DirectionY.Length == 0 && expected > 0)
{
DirectionY = Enumerable.Repeat(1.0f, expected).ToArray();
}
if (AngularVelocity.Length == 0 && expected > 0)
{
AngularVelocity = new float[expected];
}
if (Torque.Length == 0 && expected > 0)
{
Torque = new float[expected];
}
}
private static Vec2 DirectionOrDefault(Vec2? direction)
{
if (direction is not { } value ||
value.X * value.X + value.Y * value.Y <= 1.0e-8f)
{
return new Vec2(0.0f, 1.0f);
}
return value;
}
private void ValidateFieldLengths()
{
var expected = FieldIds.Length;

View file

@ -0,0 +1,200 @@
using Ymir.Box3D;
namespace Ymir.Core;
/// <summary>
/// Owns one retained Box3D world and its stable GameCult identity projection.
/// Callers own the session lifetime; body snapshots do not imply session identity.
/// </summary>
internal sealed class YmirWorldSession : IDisposable
{
private readonly object _gate = new();
private readonly Box3DSession _session = new();
private readonly Dictionary<string, ulong> _keysById = new(StringComparer.Ordinal);
private readonly Dictionary<ulong, string> _idsByKey = new();
private ulong _nextKey = 1;
private bool _disposed;
public SimulationStepResult Step(SimulationStepRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var result = Step(new SoASimulationStepRequest(
request.DeltaTime,
YmirSoAWorld.FromWorld(request.World)));
return new SimulationStepResult(result.World.ToWorld(), result.Contacts);
}
public SoASimulationStepResult Step(SoASimulationStepRequest request)
{
ArgumentNullException.ThrowIfNull(request);
if (!float.IsFinite(request.DeltaTime) || request.DeltaTime <= 0.0f)
{
throw new ArgumentOutOfRangeException(nameof(request), "DeltaTime must be finite and positive.");
}
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
var world = request.World.Clone();
world.Validate();
ValidateStableIds(world.BodyIds);
var indicesByKey = new Dictionary<ulong, int>(world.BodyCount);
foreach (var id in world.BodyIds.OrderBy(id => id, StringComparer.Ordinal))
{
GetOrCreateKey(id);
}
var bodies = new Box3DBody[world.BodyCount];
for (var i = 0; i < world.BodyCount; i++)
{
var key = GetOrCreateKey(world.BodyIds[i]);
indicesByKey.Add(key, i);
bodies[i] = new Box3DBody(
key,
world.PositionX[i],
world.PositionY[i],
world.VelocityX[i],
world.VelocityY[i],
world.DirectionX[i],
world.DirectionY[i],
world.AngularVelocity[i],
world.Torque[i],
world.Radius[i],
world.Mass[i],
world.Restitution[i],
world.DynamicMask[i] <= 0.0f);
}
Array.Sort(bodies, static (left, right) => left.StableId.CompareTo(right.StableId));
var fields = Enumerable.Range(0, world.FieldCount)
.OrderBy(index => world.FieldIds[index], StringComparer.Ordinal)
.Select(index => new Box3DField(
world.FieldPositionX[index],
world.FieldPositionY[index],
world.FieldStrength[index],
world.FieldRadius[index]))
.ToArray();
_session.SyncBodies(bodies);
_session.Step(request.DeltaTime, fields);
foreach (var body in _session.CopyBodies())
{
if (!indicesByKey.TryGetValue(body.StableId, out var index))
{
throw new InvalidOperationException($"Box3D returned unknown stable body key {body.StableId}.");
}
world.PositionX[index] = body.PositionX;
world.PositionY[index] = body.PositionZ;
world.VelocityX[index] = body.VelocityX;
world.VelocityY[index] = body.VelocityZ;
world.DirectionX[index] = body.DirectionX;
world.DirectionY[index] = body.DirectionZ;
world.AngularVelocity[index] = body.AngularVelocity;
world.Torque[index] = 0.0f;
}
var contacts = _session.CopyContacts()
.Select(ProjectContact)
.OrderBy(contact => contact.BodyA, StringComparer.Ordinal)
.ThenBy(contact => contact.BodyB, StringComparer.Ordinal)
.ThenBy(contact => contact.Point.X)
.ThenBy(contact => contact.Point.Y)
.ToArray();
PruneInactiveIds(world.BodyIds);
world.Time += request.DeltaTime;
return new SoASimulationStepResult(world, contacts);
}
}
public void Dispose()
{
lock (_gate)
{
if (_disposed)
{
return;
}
_session.Dispose();
_disposed = true;
}
}
private ulong GetOrCreateKey(string id)
{
if (_keysById.TryGetValue(id, out var existing))
{
return existing;
}
if (_nextKey == 0)
{
throw new InvalidOperationException("Ymir exhausted its stable native body key space.");
}
var key = _nextKey++;
_keysById.Add(id, key);
_idsByKey.Add(key, id);
return key;
}
private ContactEvent ProjectContact(Box3DContact contact)
{
var bodyA = ResolveId(contact.StableIdA);
var bodyB = ResolveId(contact.StableIdB);
var normal = new Vec2(contact.NormalX, contact.NormalZ);
if (StringComparer.Ordinal.Compare(bodyA, bodyB) > 0)
{
(bodyA, bodyB) = (bodyB, bodyA);
normal = new Vec2(-normal.X, -normal.Y);
}
return new ContactEvent(
bodyA,
bodyB,
new Vec2(contact.PointX, contact.PointZ),
normal,
contact.Penetration,
contact.RelativeSpeed);
}
private string ResolveId(ulong key)
{
if (!_idsByKey.TryGetValue(key, out var id))
{
throw new InvalidOperationException($"Box3D returned unknown stable body key {key}.");
}
return id;
}
private void PruneInactiveIds(IReadOnlyList<string> activeIds)
{
var active = activeIds.ToHashSet(StringComparer.Ordinal);
foreach (var id in _keysById.Keys.Where(id => !active.Contains(id)).ToArray())
{
var key = _keysById[id];
_keysById.Remove(id);
_idsByKey.Remove(key);
}
}
private static void ValidateStableIds(IReadOnlyList<string> bodyIds)
{
var unique = new HashSet<string>(StringComparer.Ordinal);
foreach (var id in bodyIds)
{
if (!unique.Add(id))
{
throw new ArgumentException($"Ymir body id '{id}' occurs more than once.", nameof(bodyIds));
}
}
}
}

View file

@ -6,7 +6,7 @@ namespace Ymir.Core;
[CultDocument("gamecult.ymir.world_state", "gamecult.ymir.world_state.v0")]
[MessagePackObject]
public sealed class YmirWorldStateDocument
public sealed class YmirWorldStateDocumentV0
{
[Key(0)]
[CultName]
@ -57,7 +57,7 @@ public sealed class YmirWorldStateDocument
[Key(15)]
public float[] FieldRadius { get; set; } = [];
public static YmirWorldStateDocument FromSoA(string worldId, YmirSoAWorld world) => new()
public static YmirWorldStateDocumentV0 FromSoA(string worldId, YmirSoAWorld world) => new()
{
WorldId = worldId,
Time = world.Time,
@ -97,6 +97,76 @@ public sealed class YmirWorldStateDocument
};
}
[CultDocument("gamecult.ymir.world_state", "gamecult.ymir.world_state.v1")]
[MessagePackObject]
public sealed class YmirWorldStateDocumentV1
{
[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; } = [];
public static YmirWorldStateDocumentV1 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()
};
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()
};
}
public static class YmirCultCacheStore
{
public static async Task SaveWorldAsync(string path, string worldId, YmirSoAWorld world)
@ -113,8 +183,8 @@ public static class YmirCultCacheStore
using (cache)
{
await cache.UpsertAsync(
YmirWorldStateDocument.FromSoA(worldId, world),
new CultRecordHandle<YmirWorldStateDocument>(new CultRecordKey($"ymir:world:{worldId}")))
YmirWorldStateDocumentV1.FromSoA(worldId, world),
new CultRecordHandle<YmirWorldStateDocumentV1>(new CultRecordKey($"ymir:world:{worldId}")))
.ConfigureAwait(false);
await cache.FlushAsync().ConfigureAwait(false);
}
@ -125,6 +195,8 @@ public static class YmirCultCacheStore
using var cache = await CultCacheMessagePack.OpenAsync(
path,
new CultCacheOpenOptions { UseDirectoryStore = true }).ConfigureAwait(false);
return cache.Get<YmirWorldStateDocument>(new CultRecordKey($"ymir:world:{worldId}"))?.ToSoA();
var key = new CultRecordKey($"ymir:world:{worldId}");
return 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.v0",
schema = "gamecult.ymir.world_state.v1",
bodyCount = result.World.BodyCount,
fieldCount = result.World.FieldCount
});

View file

@ -1,4 +1,5 @@
using System.Runtime.InteropServices;
using System.Runtime.CompilerServices;
namespace Ymir.Box3D.Parity;
@ -7,9 +8,11 @@ internal static partial class Box3DOracle
private const string LibraryName = "ymir_box3d";
[LibraryImport(LibraryName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial int ymir_box3d_get_version(out int major, out int minor, out int revision);
[LibraryImport(LibraryName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial Box3DOverlapResult ymir_box3d_overlap_spheres(
float queryX,
float queryY,
@ -19,6 +22,7 @@ internal static partial class Box3DOracle
float bodyRadius);
[LibraryImport(LibraryName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial Box3DOverlapResult ymir_box3d_overlap_capsule_sphere(
float startX,
float startY,
@ -30,6 +34,7 @@ internal static partial class Box3DOracle
float bodyRadius);
[LibraryImport(LibraryName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial Box3DCastResult ymir_box3d_cast_sphere(
float originX,
float originY,
@ -42,6 +47,7 @@ internal static partial class Box3DOracle
int canEncroach);
[LibraryImport(LibraryName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial void ymir_box3d_step_pair(
in Box3DBodyInput bodyA,
in Box3DBodyInput bodyB,
@ -51,6 +57,7 @@ internal static partial class Box3DOracle
out Box3DPairStepResult result);
[LibraryImport(LibraryName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial Box3DTorqueResult ymir_box3d_torque_lifetime(float torque, float timeStep, int subStepCount);
}

View file

@ -7,7 +7,7 @@
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<IsPackable>false</IsPackable>
<NativeSourceDir>$(MSBuildThisFileDirectory)..\..\native\Ymir.Box3D</NativeSourceDir>
<NativeBuildDir>$(MSBuildThisFileDirectory)..\..\artifacts\box3d-native\$(Configuration)</NativeBuildDir>
<NativeBuildDir>$(MSBuildThisFileDirectory)..\..\artifacts\box3d-native\parity\$(Configuration)</NativeBuildDir>
<CMakeExe Condition="'$(CMakeExe)' == '' and '$(OS)' == 'Windows_NT' and Exists('C:\Program Files\CMake\bin\cmake.exe')">C:\Program Files\CMake\bin\cmake.exe</CMakeExe>
<CMakeExe Condition="'$(CMakeExe)' == ''">cmake</CMakeExe>
</PropertyGroup>

View file

@ -16,11 +16,14 @@ public sealed class YmirServicePublicationTests
var diagnostics = YmirServicePublication.OperatorState(now);
Assert.Contains("Box3D", advertisement.Description, StringComparison.Ordinal);
Assert.Contains("authoritative physics session lifecycle", advertisement.Owns);
Assert.Contains("Box3D-only isolated snapshot stepping", advertisement.Owns);
Assert.Contains("public retained session lifecycle yet", advertisement.DoesNotOwn);
Assert.Empty(advertisement.CommandLowerings);
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.v0", diagnostics.Persistence, StringComparison.Ordinal);
Assert.Contains("world_state.v1", diagnostics.Persistence, StringComparison.Ordinal);
Assert.Contains("v0 read migration", diagnostics.Persistence, StringComparison.Ordinal);
}
[Fact]

View file

@ -1,4 +1,6 @@
using Ymir.Core;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
namespace Ymir.Tests;
@ -76,7 +78,78 @@ public sealed class YmirSimulatorTests
}
[Fact]
public void SoAStepUsesBufferAsSimulationAuthority()
public void ExplicitWorldSessionRetainsBox3DStateAcrossSteps()
{
using var session = new YmirWorldSession();
var first = session.Step(new SimulationStepRequest(
0.1f,
new YmirWorld(
0.0f,
[new PhysicsBody("ship", Vec2.Zero, new Vec2(10.0f, 0.0f), 0.5f, 1.0f)],
[])));
var second = session.Step(new SimulationStepRequest(0.1f, first.World));
Assert.Equal(2.0f, second.World.Bodies[0].Position.X, precision: 5);
Assert.Equal(0.2f, second.World.Time, precision: 5);
}
[Fact]
public void Box3DOwnsPlanarOrientationAndConsumesTorqueOnce()
{
using var session = new YmirWorldSession();
var first = session.Step(new SimulationStepRequest(
0.1f,
new YmirWorld(
0.0f,
[new PhysicsBody(
"ship",
Vec2.Zero,
Vec2.Zero,
0.5f,
1.0f,
Direction: new Vec2(0.0f, 1.0f),
Torque: 1.0f)],
[])));
var firstBody = first.World.Bodies[0];
Assert.NotNull(firstBody.Direction);
Assert.NotEqual(0.0f, firstBody.AngularVelocity);
Assert.Equal(0.0f, firstBody.Torque);
var second = session.Step(new SimulationStepRequest(0.1f, first.World));
Assert.Equal(firstBody.AngularVelocity, second.World.Bodies[0].AngularVelocity, precision: 4);
Assert.NotEqual(firstBody.Direction, second.World.Bodies[0].Direction);
}
[Fact]
public void CircleCastUsesPinnedBox3DEdgeSemantics()
{
var result = YmirQueries.CastCircle(new CircleCastQueryRequest(
Vec2.Zero,
new Vec2(1.0f, 0.0f),
10.0f,
1.0f,
[new PhysicsBody("target", new Vec2(30.0f, 0.0f), Vec2.Zero, 20.0f, 1.0f, IsStatic: true)]));
var hit = Assert.Single(result.Hits);
Assert.Equal("target", hit.BodyId);
Assert.InRange(hit.Distance, 8.99f, 9.01f);
}
[Fact]
public void CircleOverlapUsesPinnedBox3DTangentSemantics()
{
var result = YmirQueries.OverlapCircle(new CircleOverlapQueryRequest(
Vec2.Zero,
1.0f,
[new PhysicsBody("target", new Vec2(2.0f, 0.0f), Vec2.Zero, 1.0f, 1.0f, IsStatic: true)]));
Assert.Equal("target", Assert.Single(result.Hits).BodyId);
}
[Fact]
public void SoAStepProjectsBox3DStateIntoContractBuffers()
{
var simulator = new YmirSimulator();
var world = new YmirSoAWorld
@ -114,7 +187,7 @@ public sealed class YmirSimulatorTests
{
var world = YmirSoAWorld.FromWorld(new YmirWorld(
2.0f,
new[] { new PhysicsBody("ship", new Vec2(1.0f, 2.0f), new Vec2(3.0f, 4.0f), 0.5f, 1.0f) },
new[] { new PhysicsBody("ship", new Vec2(1.0f, 2.0f), new Vec2(3.0f, 4.0f), 0.5f, 1.0f, Direction: new Vec2(1.0f, 0.0f), AngularVelocity: 2.0f) },
new[] { new RadialField("well", new Vec2(5.0f, 6.0f), 7.0f, 8.0f) }));
await YmirCultCacheStore.SaveWorldAsync(path, "test", world);
@ -124,6 +197,8 @@ public sealed class YmirSimulatorTests
Assert.Equal(["ship"], loaded!.BodyIds);
Assert.Equal(1.0f, loaded.PositionX[0], precision: 5);
Assert.Equal(7.0f, loaded.FieldStrength[0], precision: 5);
Assert.Equal(1.0f, loaded.DirectionX[0], precision: 5);
Assert.Equal(2.0f, loaded.AngularVelocity[0], precision: 5);
}
finally
{
@ -139,6 +214,42 @@ public sealed class YmirSimulatorTests
}
}
[Fact]
public async Task CultCacheStoreReadsV0WithDefaultAngularState()
{
var path = Path.Combine(Path.GetTempPath(), $"ymir-v0-tests-{Guid.NewGuid():N}.cc");
var recordsPath = $"{path}.records";
try
{
var world = YmirSoAWorld.FromWorld(new YmirWorld(
2.0f,
[new PhysicsBody("ship", Vec2.Zero, Vec2.Zero, 0.5f, 1.0f)],
[]));
using (var cache = await CultCacheMessagePack.OpenAsync(
path,
new CultCacheOpenOptions { UseDirectoryStore = true, FlushOnDispose = true, StoreFlushOnDispose = true }))
{
await cache.UpsertAsync(
YmirWorldStateDocumentV0.FromSoA("test", world),
new CultRecordHandle<YmirWorldStateDocumentV0>(new CultRecordKey("ymir:world:test")));
await cache.FlushAsync();
}
var loaded = await YmirCultCacheStore.LoadWorldAsync(path, "test");
Assert.NotNull(loaded);
loaded!.Validate();
Assert.Equal(0.0f, loaded.DirectionX[0]);
Assert.Equal(1.0f, loaded.DirectionY[0]);
Assert.Equal(0.0f, loaded.AngularVelocity[0]);
}
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();