From 6d13a2cb979425b6eb4f84ab58e15e59e7b633e5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 14 Jul 2026 19:20:15 +0100 Subject: [PATCH] Add pinned Box3D parity oracle --- .gitignore | 2 + .gitmodules | 3 + docs/box3d-parity.md | 53 +++++++ extern/box3d | 1 + native/Ymir.Box3D/CMakeLists.txt | 23 +++ native/Ymir.Box3D/ymir_box3d.c | 134 ++++++++++++++++++ tests/Ymir.Box3D.Parity/Box3DOracle.cs | 60 ++++++++ tests/Ymir.Box3D.Parity/Box3DParityTests.cs | 86 +++++++++++ .../Ymir.Box3D.Parity.csproj | 33 +++++ 9 files changed, 395 insertions(+) create mode 100644 .gitmodules create mode 100644 docs/box3d-parity.md create mode 160000 extern/box3d create mode 100644 native/Ymir.Box3D/CMakeLists.txt create mode 100644 native/Ymir.Box3D/ymir_box3d.c create mode 100644 tests/Ymir.Box3D.Parity/Box3DOracle.cs create mode 100644 tests/Ymir.Box3D.Parity/Box3DParityTests.cs create mode 100644 tests/Ymir.Box3D.Parity/Ymir.Box3D.Parity.csproj diff --git a/.gitignore b/.gitignore index b8ce64a..07d6ce7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ obj/ .ymir/ *.user TestResults/ +BenchmarkDotNet.Artifacts/ +artifacts/box3d-native/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..0773f0a --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "extern/box3d"] + path = extern/box3d + url = https://github.com/erincatto/box3d.git diff --git a/docs/box3d-parity.md b/docs/box3d-parity.md new file mode 100644 index 0000000..d33f2b7 --- /dev/null +++ b/docs/box3d-parity.md @@ -0,0 +1,53 @@ +# Box3D Parity Harness + +## Authority + +Box3D v0.1.0 is Ymir's collision-semantics oracle while the native cutover is +under construction. The exact upstream source is pinned as the +`extern/box3d` submodule at commit +`8441b4a06d6d09dcfb0b0f704df4d847d1437b92`. + +The oracle does not own GameCult entity ids, gameplay policy, persistence, or +result ordering. Ymir projects Box3D facts into those contracts. Box3D owns +shape overlap, shape cast, solver, broadphase, contact lifecycle, force and +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, and version operations. This +keeps Box3D's alpha C ABI out of Ymir's public managed contracts. + +`tests/Ymir.Box3D.Parity` builds that facade through CMake and invokes it with +source-generated .NET interop. Run it with: + +```powershell +dotnet test tests\Ymir.Box3D.Parity\Ymir.Box3D.Parity.csproj +``` + +The build requires CMake and a C17 compiler. Box3D is MIT licensed; its license +is retained in the pinned submodule. + +## Released v0.1.0 Law + +- Sphere overlap accepts separation strictly below the `0.0005 m` overlap + slop. Exact tangency therefore overlaps. +- Sphere shape casts use Box3D's `0.005 m` linear slop. The hit fraction is not + the zero-tolerance analytic sphere intersection. +- The low-level v0.1.0 sphere shape cast reports initially touching and + initially overlapping shapes as hits. The harness records the released + binary's behavior rather than relying on prose or recollection. +- A two-point shape proxy plus radius is the tractor capsule. Membership + includes rounded start and end caps and the same overlap slop. +- Box3D query callback order is not a contract. Ymir sorts projected results by + distance and stable entity id. + +## Cut Line + +The current managed collision formulas are transitional. They may exist only +while differential tests prove compatibility. The next parity slice covers a +planar sphere-world step, transient forces and torques, restitution mixing, +and begin/end contact events. 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. diff --git a/extern/box3d b/extern/box3d new file mode 160000 index 0000000..8441b4a --- /dev/null +++ b/extern/box3d @@ -0,0 +1 @@ +Subproject commit 8441b4a06d6d09dcfb0b0f704df4d847d1437b92 diff --git a/native/Ymir.Box3D/CMakeLists.txt b/native/Ymir.Box3D/CMakeLists.txt new file mode 100644 index 0000000..fc7dfc7 --- /dev/null +++ b/native/Ymir.Box3D/CMakeLists.txt @@ -0,0 +1,23 @@ +cmake_minimum_required(VERSION 3.22) + +project(ymir_box3d VERSION 0.1.0 LANGUAGES C) + +set(CMAKE_POSITION_INDEPENDENT_CODE ON) +set(BOX3D_SAMPLES OFF CACHE BOOL "" FORCE) +set(BOX3D_BENCHMARKS OFF CACHE BOOL "" FORCE) +set(BOX3D_DOCS OFF CACHE BOOL "" FORCE) +set(BOX3D_UNIT_TESTS OFF CACHE BOOL "" FORCE) +set(BOX3D_VALIDATE ON CACHE BOOL "" FORCE) + +add_subdirectory("${CMAKE_CURRENT_SOURCE_DIR}/../../extern/box3d" "${CMAKE_CURRENT_BINARY_DIR}/box3d") + +add_library(ymir_box3d SHARED ymir_box3d.c) +target_link_libraries(ymir_box3d PRIVATE box3d::box3d) +target_include_directories(ymir_box3d PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/../../extern/box3d/include") +target_compile_features(ymir_box3d PRIVATE c_std_17) + +set_target_properties(ymir_box3d PROPERTIES + C_VISIBILITY_PRESET hidden + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" + LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" + ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib") diff --git a/native/Ymir.Box3D/ymir_box3d.c b/native/Ymir.Box3D/ymir_box3d.c new file mode 100644 index 0000000..f1ed29e --- /dev/null +++ b/native/Ymir.Box3D/ymir_box3d.c @@ -0,0 +1,134 @@ +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#define YMIR_EXPORT __declspec(dllexport) +#else +#define YMIR_EXPORT __attribute__((visibility("default"))) +#endif + +typedef struct ymir_box3d_overlap_result +{ + int hit; + float distance; + float normal_x; + float normal_y; +} ymir_box3d_overlap_result; + +typedef struct ymir_box3d_cast_result +{ + int hit; + float fraction; + float point_x; + float point_y; + float normal_x; + float normal_y; +} ymir_box3d_cast_result; + +static b3Vec3 ymir_vec(float x, float y) +{ + return (b3Vec3){x, 0.0f, y}; +} + +YMIR_EXPORT int ymir_box3d_get_version(int* major, int* minor, int* revision) +{ + b3Version version = b3GetVersion(); + *major = version.major; + *minor = version.minor; + *revision = version.revision; + return b3IsDoublePrecision() ? 1 : 0; +} + +YMIR_EXPORT ymir_box3d_overlap_result ymir_box3d_overlap_spheres( + float query_x, + float query_y, + float query_radius, + float body_x, + float body_y, + float body_radius) +{ + b3Sphere body = {ymir_vec(body_x, body_y), body_radius}; + b3Vec3 query_point = ymir_vec(query_x, query_y); + b3ShapeProxy query = {&query_point, 1, query_radius}; + + b3DistanceInput input = { + .proxyA = (b3ShapeProxy){&body.center, 1, body.radius}, + .proxyB = query, + .transform = b3Transform_identity, + .useRadii = true, + }; + b3SimplexCache cache = {0}; + b3DistanceOutput distance = b3ShapeDistance(&input, &cache, NULL, 0); + + return (ymir_box3d_overlap_result){ + .hit = b3OverlapSphere(&body, b3Transform_identity, &query) ? 1 : 0, + .distance = distance.distance, + .normal_x = distance.normal.x, + .normal_y = distance.normal.z, + }; +} + +YMIR_EXPORT ymir_box3d_overlap_result ymir_box3d_overlap_capsule_sphere( + float start_x, + float start_y, + float end_x, + float end_y, + float capsule_radius, + float body_x, + float body_y, + float body_radius) +{ + b3Sphere body = {ymir_vec(body_x, body_y), body_radius}; + b3Vec3 capsule_points[2] = {ymir_vec(start_x, start_y), ymir_vec(end_x, end_y)}; + b3ShapeProxy capsule = {capsule_points, 2, capsule_radius}; + + b3DistanceInput input = { + .proxyA = (b3ShapeProxy){&body.center, 1, body.radius}, + .proxyB = capsule, + .transform = b3Transform_identity, + .useRadii = true, + }; + b3SimplexCache cache = {0}; + b3DistanceOutput distance = b3ShapeDistance(&input, &cache, NULL, 0); + + return (ymir_box3d_overlap_result){ + .hit = b3OverlapSphere(&body, b3Transform_identity, &capsule) ? 1 : 0, + .distance = distance.distance, + .normal_x = distance.normal.x, + .normal_y = distance.normal.z, + }; +} + +YMIR_EXPORT ymir_box3d_cast_result ymir_box3d_cast_sphere( + float origin_x, + float origin_y, + float translation_x, + float translation_y, + float query_radius, + float body_x, + float body_y, + float body_radius, + int can_encroach) +{ + b3Sphere body = {ymir_vec(body_x, body_y), body_radius}; + b3Vec3 query_point = ymir_vec(origin_x, origin_y); + b3ShapeCastInput input = { + .proxy = (b3ShapeProxy){&query_point, 1, query_radius}, + .translation = ymir_vec(translation_x, translation_y), + .maxFraction = 1.0f, + .canEncroach = can_encroach != 0, + }; + b3CastOutput output = b3ShapeCastSphere(&body, &input); + + return (ymir_box3d_cast_result){ + .hit = output.hit ? 1 : 0, + .fraction = output.fraction, + .point_x = output.point.x, + .point_y = output.point.z, + .normal_x = output.normal.x, + .normal_y = output.normal.z, + }; +} diff --git a/tests/Ymir.Box3D.Parity/Box3DOracle.cs b/tests/Ymir.Box3D.Parity/Box3DOracle.cs new file mode 100644 index 0000000..ca9345c --- /dev/null +++ b/tests/Ymir.Box3D.Parity/Box3DOracle.cs @@ -0,0 +1,60 @@ +using System.Runtime.InteropServices; + +namespace Ymir.Box3D.Parity; + +internal static partial class Box3DOracle +{ + private const string LibraryName = "ymir_box3d"; + + [LibraryImport(LibraryName)] + internal static partial int ymir_box3d_get_version(out int major, out int minor, out int revision); + + [LibraryImport(LibraryName)] + internal static partial Box3DOverlapResult ymir_box3d_overlap_spheres( + float queryX, + float queryY, + float queryRadius, + float bodyX, + float bodyY, + float bodyRadius); + + [LibraryImport(LibraryName)] + internal static partial Box3DOverlapResult ymir_box3d_overlap_capsule_sphere( + float startX, + float startY, + float endX, + float endY, + float capsuleRadius, + float bodyX, + float bodyY, + float bodyRadius); + + [LibraryImport(LibraryName)] + internal static partial Box3DCastResult ymir_box3d_cast_sphere( + float originX, + float originY, + float translationX, + float translationY, + float queryRadius, + float bodyX, + float bodyY, + float bodyRadius, + int canEncroach); +} + +[StructLayout(LayoutKind.Sequential)] +internal readonly record struct Box3DOverlapResult(int Hit, float Distance, float NormalX, float NormalY) +{ + public bool IsHit => Hit != 0; +} +[StructLayout(LayoutKind.Sequential)] +internal readonly record struct Box3DCastResult( + int Hit, + float Fraction, + float PointX, + float PointY, + float NormalX, + float NormalY) +{ + public bool IsHit => Hit != 0; +} diff --git a/tests/Ymir.Box3D.Parity/Box3DParityTests.cs b/tests/Ymir.Box3D.Parity/Box3DParityTests.cs new file mode 100644 index 0000000..2c740cd --- /dev/null +++ b/tests/Ymir.Box3D.Parity/Box3DParityTests.cs @@ -0,0 +1,86 @@ +namespace Ymir.Box3D.Parity; + +public sealed class Box3DParityTests +{ + [Fact] + public void OracleIsPinnedBox3DRelease() + { + var doublePrecision = Box3DOracle.ymir_box3d_get_version(out var major, out var minor, out var revision); + + Assert.Equal((0, 1, 0), (major, minor, revision)); + Assert.Equal(0, doublePrecision); + } + + [Theory] + [InlineData(1.9f, true)] + [InlineData(2.0f, true)] + [InlineData(2.00049f, true)] + [InlineData(2.00051f, false)] + public void SphereOverlapClassificationMatchesBox3DSlop(float centerDistance, bool expected) + { + var result = Box3DOracle.ymir_box3d_overlap_spheres(0.0f, 0.0f, 1.0f, centerDistance, 0.0f, 1.0f); + + Assert.Equal(expected, result.IsHit); + } + + [Theory] + [InlineData(-1.00049f, true)] + [InlineData(-1.00051f, false)] + [InlineData(5.0f, true)] + [InlineData(11.00049f, true)] + [InlineData(11.00051f, false)] + public void TractorCapsuleMembershipUsesBox3DRoundedCapsAndSlop(float pickupX, bool expected) + { + var result = Box3DOracle.ymir_box3d_overlap_capsule_sphere( + 0.0f, + 0.0f, + 10.0f, + 0.0f, + 0.5f, + pickupX, + 0.0f, + 0.5f); + + Assert.Equal(expected, result.IsHit); + } + + [Fact] + public void SphereCastReturnsBox3DFractionAndNormal() + { + var result = Box3DOracle.ymir_box3d_cast_sphere( + -5.0f, + 0.0f, + 10.0f, + 0.0f, + 0.5f, + 0.0f, + 0.0f, + 1.0f, + 0); + + Assert.True(result.IsHit); + Assert.Equal(0.3505f, result.Fraction, precision: 4); + Assert.Equal(-1.0f, result.NormalX, precision: 4); + Assert.Equal(0.0f, result.NormalY, precision: 4); + } + + [Theory] + [InlineData(-1.5f)] + [InlineData(-1.4f)] + [InlineData(0.0f)] + public void SphereCastInitiallyTouchingOrOverlappingIsAHit(float originX) + { + var result = Box3DOracle.ymir_box3d_cast_sphere( + originX, + 0.0f, + 5.0f, + 0.0f, + 0.5f, + 0.0f, + 0.0f, + 1.0f, + 0); + + Assert.True(result.IsHit); + } +} diff --git a/tests/Ymir.Box3D.Parity/Ymir.Box3D.Parity.csproj b/tests/Ymir.Box3D.Parity/Ymir.Box3D.Parity.csproj new file mode 100644 index 0000000..78d766d --- /dev/null +++ b/tests/Ymir.Box3D.Parity/Ymir.Box3D.Parity.csproj @@ -0,0 +1,33 @@ + + + + net10.0 + enable + enable + true + false + $(MSBuildThisFileDirectory)..\..\native\Ymir.Box3D + $(MSBuildThisFileDirectory)..\..\artifacts\box3d-native\$(Configuration) + C:\Program Files\CMake\bin\cmake.exe + cmake + + + + + + + + + + + + + + + + + + + + +