diff --git a/.gitignore b/.gitignore index 07d6ce7..c3dbd8a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,4 @@ obj/ *.user TestResults/ BenchmarkDotNet.Artifacts/ -artifacts/box3d-native/ +artifacts/ diff --git a/README.md b/README.md index d7c1434..9afa0d5 100644 --- a/README.md +++ b/README.md @@ -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//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. diff --git a/Ymir.slnx b/Ymir.slnx index 53b0fac..63de4d7 100644 --- a/Ymir.slnx +++ b/Ymir.slnx @@ -1,6 +1,7 @@ + diff --git a/docs/architecture.md b/docs/architecture.md index a4e021b..ce93991 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/box3d-parity.md b/docs/box3d-parity.md index d9e7790..e03f84f 100644 --- a/docs/box3d-parity.md +++ b/docs/box3d-parity.md @@ -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. diff --git a/native/Ymir.Box3D/ymir_box3d.c b/native/Ymir.Box3D/ymir_box3d.c index 0756e3c..0126aa8 100644 --- a/native/Ymir.Box3D/ymir_box3d.c +++ b/native/Ymir.Box3D/ymir_box3d.c @@ -5,10 +5,17 @@ #include #include +#include +#include +#include +#include + #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); +} diff --git a/src/Ymir.Box3D/Box3DNative.cs b/src/Ymir.Box3D/Box3DNative.cs new file mode 100644 index 0000000..86b3615 --- /dev/null +++ b/src/Ymir.Box3D/Box3DNative.cs @@ -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); diff --git a/src/Ymir.Box3D/Box3DQueries.cs b/src/Ymir.Box3D/Box3DQueries.cs new file mode 100644 index 0000000..a2f480f --- /dev/null +++ b/src/Ymir.Box3D/Box3DQueries.cs @@ -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); + } +} diff --git a/src/Ymir.Box3D/Box3DSession.cs b/src/Ymir.Box3D/Box3DSession.cs new file mode 100644 index 0000000..dc409a9 --- /dev/null +++ b/src/Ymir.Box3D/Box3DSession.cs @@ -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 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 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(bodyInputSize); + EnsureSize(fieldInputSize); + EnsureSize(bodyOutputSize); + EnsureSize(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(uint nativeSize) where T : struct + { + var managedSize = checked((uint)Marshal.SizeOf()); + 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); +} diff --git a/src/Ymir.Box3D/Properties/AssemblyInfo.cs b/src/Ymir.Box3D/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..65a30aa --- /dev/null +++ b/src/Ymir.Box3D/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Ymir.Core")] diff --git a/src/Ymir.Box3D/README.md b/src/Ymir.Box3D/README.md new file mode 100644 index 0000000..3e797c4 --- /dev/null +++ b/src/Ymir.Box3D/README.md @@ -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//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. diff --git a/src/Ymir.Box3D/Ymir.Box3D.csproj b/src/Ymir.Box3D/Ymir.Box3D.csproj new file mode 100644 index 0000000..8664858 --- /dev/null +++ b/src/Ymir.Box3D/Ymir.Box3D.csproj @@ -0,0 +1,58 @@ + + + + net8.0 + enable + enable + true + Ymir.Box3D + Ymir.Box3D + Native ABI boundary between Ymir sessions and pinned Box3D. + GameCult.Ymir.Box3D + 0.1.0 + GameCult + true + README.md + $(MSBuildThisFileDirectory)..\..\native\Ymir.Box3D + $(MSBuildThisFileDirectory)..\..\artifacts\box3d-native\production\$(Configuration) + C:\Program Files\CMake\bin\cmake.exe + cmake + + + + + + + + + + + + + + + + diff --git a/src/Ymir.Core/PhysicsModel.cs b/src/Ymir.Core/PhysicsModel.cs index 00ecd1b..188215c 100644 --- a/src/Ymir.Core/PhysicsModel.cs +++ b/src/Ymir.Core/PhysicsModel.cs @@ -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 Contacts); + +public sealed record CircleCastQueryRequest( + Vec2 Origin, + Vec2 Direction, + float Distance, + float Radius, + IReadOnlyList Bodies); + +public sealed record CircleCastHit( + string BodyId, + Vec2 Point, + Vec2 Normal, + float Distance); + +public sealed record CircleCastQueryResult(IReadOnlyList Hits); + +public sealed record CircleOverlapQueryRequest( + Vec2 Center, + float Radius, + IReadOnlyList Bodies); + +public sealed record CircleOverlapHit( + string BodyId, + Vec2 Point, + Vec2 Normal, + float Distance); + +public sealed record CircleOverlapQueryResult(IReadOnlyList Hits); diff --git a/src/Ymir.Core/Properties/AssemblyInfo.cs b/src/Ymir.Core/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..53b0ddf --- /dev/null +++ b/src/Ymir.Core/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Ymir.Tests")] diff --git a/src/Ymir.Core/Ymir.Core.csproj b/src/Ymir.Core/Ymir.Core.csproj index 241aa28..00ea67f 100644 --- a/src/Ymir.Core/Ymir.Core.csproj +++ b/src/Ymir.Core/Ymir.Core.csproj @@ -6,6 +6,7 @@ + @@ -18,7 +19,7 @@ enable Ymir.Core Ymir.Core - GameCult physics/world substrate engine built on CultMath. + GameCult physics session daemon and contracts backed by pinned Box3D. GameCult diff --git a/src/Ymir.Core/YmirQueries.cs b/src/Ymir.Core/YmirQueries.cs new file mode 100644 index 0000000..19cf5a1 --- /dev/null +++ b/src/Ymir.Core/YmirQueries.cs @@ -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(); + 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(); + 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)); + } + } +} diff --git a/src/Ymir.Core/YmirServicePublicationDocument.cs b/src/Ymir.Core/YmirServicePublicationDocument.cs index 0573799..4b492ea 100644 --- a/src/Ymir.Core/YmirServicePublicationDocument.cs +++ b/src/Ymir.Core/YmirServicePublicationDocument.cs @@ -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) }; diff --git a/src/Ymir.Core/YmirSimulator.cs b/src/Ymir.Core/YmirSimulator.cs index df4c6a4..d22c4de 100644 --- a/src/Ymir.Core/YmirSimulator.cs +++ b/src/Ymir.Core/YmirSimulator.cs @@ -1,144 +1,20 @@ -using CultMath; -using static CultMath.math; - namespace Ymir.Core; +/// +/// Compatibility façade for the snapshot-in/snapshot-out Ymir contract. +/// Calls are isolated by design; retained world ownership is not inferred from snapshots. +/// 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(); - ResolveCircleContacts(world, contacts); - - world.Time += request.DeltaTime; - return new SoASimulationStepResult( - world, - contacts); - } - - private static void ResolveCircleContacts(YmirSoAWorld world, List 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); } } diff --git a/src/Ymir.Core/YmirSoAWorld.cs b/src/Ymir.Core/YmirSoAWorld.cs index c312dc9..4117af7 100644 --- a/src/Ymir.Core/YmirSoAWorld.cs +++ b/src/Ymir.Core/YmirSoAWorld.cs @@ -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; diff --git a/src/Ymir.Core/YmirWorldSession.cs b/src/Ymir.Core/YmirWorldSession.cs new file mode 100644 index 0000000..fa033ac --- /dev/null +++ b/src/Ymir.Core/YmirWorldSession.cs @@ -0,0 +1,200 @@ +using Ymir.Box3D; + +namespace Ymir.Core; + +/// +/// Owns one retained Box3D world and its stable GameCult identity projection. +/// Callers own the session lifetime; body snapshots do not imply session identity. +/// +internal sealed class YmirWorldSession : IDisposable +{ + private readonly object _gate = new(); + private readonly Box3DSession _session = new(); + private readonly Dictionary _keysById = new(StringComparer.Ordinal); + private readonly Dictionary _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(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 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 bodyIds) + { + var unique = new HashSet(StringComparer.Ordinal); + foreach (var id in bodyIds) + { + if (!unique.Add(id)) + { + throw new ArgumentException($"Ymir body id '{id}' occurs more than once.", nameof(bodyIds)); + } + } + } +} diff --git a/src/Ymir.Core/YmirWorldStateDocument.cs b/src/Ymir.Core/YmirWorldStateDocument.cs index 4a34c06..3b9b2cf 100644 --- a/src/Ymir.Core/YmirWorldStateDocument.cs +++ b/src/Ymir.Core/YmirWorldStateDocument.cs @@ -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(new CultRecordKey($"ymir:world:{worldId}"))) + YmirWorldStateDocumentV1.FromSoA(worldId, world), + new CultRecordHandle(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(new CultRecordKey($"ymir:world:{worldId}"))?.ToSoA(); + var key = new CultRecordKey($"ymir:world:{worldId}"); + return cache.Get(key)?.ToSoA() + ?? cache.Get(key)?.ToSoA(); } } diff --git a/src/ymir-daemon/Program.cs b/src/ymir-daemon/Program.cs index 88b4b49..54f1089 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.v0", + schema = "gamecult.ymir.world_state.v1", 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 5e501b0..beb1d58 100644 --- a/tests/Ymir.Box3D.Parity/Box3DOracle.cs +++ b/tests/Ymir.Box3D.Parity/Box3DOracle.cs @@ -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); } diff --git a/tests/Ymir.Box3D.Parity/Ymir.Box3D.Parity.csproj b/tests/Ymir.Box3D.Parity/Ymir.Box3D.Parity.csproj index 79466d8..0987218 100644 --- a/tests/Ymir.Box3D.Parity/Ymir.Box3D.Parity.csproj +++ b/tests/Ymir.Box3D.Parity/Ymir.Box3D.Parity.csproj @@ -7,7 +7,7 @@ true false $(MSBuildThisFileDirectory)..\..\native\Ymir.Box3D - $(MSBuildThisFileDirectory)..\..\artifacts\box3d-native\$(Configuration) + $(MSBuildThisFileDirectory)..\..\artifacts\box3d-native\parity\$(Configuration) C:\Program Files\CMake\bin\cmake.exe cmake diff --git a/tests/Ymir.Tests/YmirServicePublicationTests.cs b/tests/Ymir.Tests/YmirServicePublicationTests.cs index a6a4d93..d06be58 100644 --- a/tests/Ymir.Tests/YmirServicePublicationTests.cs +++ b/tests/Ymir.Tests/YmirServicePublicationTests.cs @@ -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] diff --git a/tests/Ymir.Tests/YmirSimulatorTests.cs b/tests/Ymir.Tests/YmirSimulatorTests.cs index cfb01fe..8eb4f05 100644 --- a/tests/Ymir.Tests/YmirSimulatorTests.cs +++ b/tests/Ymir.Tests/YmirSimulatorTests.cs @@ -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(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();