Add pinned Box3D parity oracle

This commit is contained in:
Meta Vens 2026-07-14 19:20:15 +01:00
commit 6d13a2cb97
9 changed files with 395 additions and 0 deletions

2
.gitignore vendored
View file

@ -5,3 +5,5 @@ obj/
.ymir/
*.user
TestResults/
BenchmarkDotNet.Artifacts/
artifacts/box3d-native/

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "extern/box3d"]
path = extern/box3d
url = https://github.com/erincatto/box3d.git

53
docs/box3d-parity.md Normal file
View file

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

1
extern/box3d vendored Submodule

@ -0,0 +1 @@
Subproject commit 8441b4a06d6d09dcfb0b0f704df4d847d1437b92

View file

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

View file

@ -0,0 +1,134 @@
#include <box3d/base.h>
#include <box3d/collision.h>
#include <box3d/constants.h>
#include <box3d/math_functions.h>
#include <box3d/types.h>
#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,
};
}

View file

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

View file

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

View file

@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<IsPackable>false</IsPackable>
<NativeSourceDir>$(MSBuildThisFileDirectory)..\..\native\Ymir.Box3D</NativeSourceDir>
<NativeBuildDir>$(MSBuildThisFileDirectory)..\..\artifacts\box3d-native\$(Configuration)</NativeBuildDir>
<CMakeExe Condition="'$(OS)' == 'Windows_NT'">C:\Program Files\CMake\bin\cmake.exe</CMakeExe>
<CMakeExe Condition="'$(OS)' != 'Windows_NT'">cmake</CMakeExe>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<Target Name="BuildBox3DParityOracle" BeforeTargets="Build" Condition="'$(DesignTimeBuild)' != 'true'">
<Exec Command="&quot;$(CMakeExe)&quot; -S &quot;$(NativeSourceDir)&quot; -B &quot;$(NativeBuildDir)&quot; -DBUILD_SHARED_LIBS=OFF" />
<Exec Command="&quot;$(CMakeExe)&quot; --build &quot;$(NativeBuildDir)&quot; --config $(Configuration) --target ymir_box3d" />
<Copy SourceFiles="$(NativeBuildDir)\bin\$(Configuration)\ymir_box3d.dll" DestinationFolder="$(OutDir)" Condition="'$(OS)' == 'Windows_NT'" />
<Copy SourceFiles="$(NativeBuildDir)\bin\libymir_box3d.so" DestinationFolder="$(OutDir)" Condition="'$(OS)' != 'Windows_NT' and !$([MSBuild]::IsOSPlatform('OSX'))" />
<Copy SourceFiles="$(NativeBuildDir)\bin\libymir_box3d.dylib" DestinationFolder="$(OutDir)" Condition="$([MSBuild]::IsOSPlatform('OSX'))" />
</Target>
</Project>