Headless 3D SoA world over Box3D, state read in place #1

Merged
nibu merged 1 commit from nibu/soa-world into main 2026-11-10 17:13:58 +00:00
Member

Smallest vertical slice for Lodur then Bad Day Simulator: a headless 3D Box3D world stepped deterministically, with a structure-of-arrays state view read in place. No solver of ours; every physical decision is Box3D's (pinned v0.1.0). Base: main (8d02b1e), no other open PRs.

Survey (what existed, and what it changed)

  • Ymir is C# (Ymir.Box3D net8.0, Ymir.Core) over a C shim (ymir_box3d.c, CMake builds the pinned extern/box3d submodule at 8441b4a). Native ABI v5, P/Invoke with LibraryImport. Tests: Ymir.Box3D.Parity (20, native oracle) and Ymir.Tests (34, needs the CultLib 2.0.0 worktree).
  • The retained session is Aetheria's planar one: gravity = 0, motionLocks.linearY/angularX/angularZ, yaw only, spheres only, and state leaves by copy_bodies (array of structs). YmirSoAWorld in Ymir.Core is a managed 2D snapshot with clones, not a native view.
  • That changed the plan. A body cannot fall or tumble through the existing session and every read is a copy, so the slice is a second native object (ymir_box3d_soa.c, ABI 1), not an extension of the session; the Aetheria session contract is untouched. The cost is a second creation path for a Box3D world; it replaces nothing and is the liability to review.

What it adds

  • ymir_box3d_soa_create(capacity, gravity) / add_sphere / add_static_box / step / get_view / destroy. Capacity is fixed, so lane pointers are stable for the world's life. Lanes: stable id, kind, position xyz, rotation quaternion xyzw, linear and angular velocity xyz, one contiguous array per component. One Box3D worker, insertion order.
  • Box3DSoaWorld/Box3DSoaView (internal) expose each lane as ReadOnlySpan<T> over native memory. Also docs/soa-world.md, a README entry, and the new test project in Ymir.slnx.

Tests (tests/Ymir.Box3D.Soa.Tests, 6): a sphere falls under gravity and rests on the slab, read through one held view; the span address equals the native lane and is unchanged after stepping, and a view taken before stepping shows later state (not a snapshot); two runs of 25 colliding spheres on a slab are byte-equal at six checkpoints over 300 steps; a one-ulp input difference diverges (the equality can fail); bad input, duplicate id, full world, disposed world.

Verified here (Linux x64, .NET 10.0.403, GCC 14.2): Soa 6/6, Parity 20/20, Ymir.Tests 34/34 (with -p:CultLibRoot at the pinned tag). Hand mutants: no refresh in step, gravity ignored, quaternion lane swapped, all killed. workerCount = 4 survives: Box3D gave identical bytes anyway, so the single-worker setting is not shown to matter here; kept as a safe default.

  • A finding during the build: a held view's step count and body count are by-value copies, not live. Only the lanes are in place; step count moved to the world, and Count is documented as taken at view time.

Could not run: Windows (the repo's primary target) and macOS; any other CPU or compiler, so cross-platform bit-identity is not claimed; the Unity/Aetheria consumers; Lodur and Bad Day Simulator themselves; no performance measurement (24 spheres only); dotnet test Ymir.slnx as a whole (the slnx lists ../CultLib, which this workspace lacks; I ran the three projects separately).

Not done and recorded as a follow-up: joints, body layout beyond spheres/static boxes, torque/force write path, contact facts, and a public Ymir.Core surface. Merge nothing.

🤖 Generated with Claude Code

Smallest vertical slice for Lodur then Bad Day Simulator: a headless 3D Box3D world stepped deterministically, with a structure-of-arrays state view read in place. No solver of ours; every physical decision is Box3D's (pinned v0.1.0). Base: `main` (8d02b1e), no other open PRs. **Survey (what existed, and what it changed)** - Ymir is C# (`Ymir.Box3D` net8.0, `Ymir.Core`) over a C shim (`ymir_box3d.c`, CMake builds the pinned `extern/box3d` submodule at 8441b4a). Native ABI v5, P/Invoke with `LibraryImport`. Tests: `Ymir.Box3D.Parity` (20, native oracle) and `Ymir.Tests` (34, needs the CultLib 2.0.0 worktree). - The retained session is Aetheria's planar one: `gravity = 0`, `motionLocks.linearY/angularX/angularZ`, yaw only, spheres only, and state leaves by `copy_bodies` (array of structs). `YmirSoAWorld` in `Ymir.Core` is a managed 2D snapshot with clones, not a native view. - **That changed the plan.** A body cannot fall or tumble through the existing session and every read is a copy, so the slice is a second native object (`ymir_box3d_soa.c`, ABI 1), not an extension of the session; the Aetheria session contract is untouched. The cost is a second creation path for a Box3D world; it replaces nothing and is the liability to review. **What it adds** - `ymir_box3d_soa_create(capacity, gravity) / add_sphere / add_static_box / step / get_view / destroy`. Capacity is fixed, so lane pointers are stable for the world's life. Lanes: stable id, kind, position xyz, rotation quaternion xyzw, linear and angular velocity xyz, one contiguous array per component. One Box3D worker, insertion order. - `Box3DSoaWorld`/`Box3DSoaView` (internal) expose each lane as `ReadOnlySpan<T>` over native memory. Also `docs/soa-world.md`, a README entry, and the new test project in `Ymir.slnx`. **Tests (`tests/Ymir.Box3D.Soa.Tests`, 6)**: a sphere falls under gravity and rests on the slab, read through one held view; the span address equals the native lane and is unchanged after stepping, and a view taken before stepping shows later state (not a snapshot); two runs of 25 colliding spheres on a slab are byte-equal at six checkpoints over 300 steps; a one-ulp input difference diverges (the equality can fail); bad input, duplicate id, full world, disposed world. **Verified here (Linux x64, .NET 10.0.403, GCC 14.2)**: Soa 6/6, Parity 20/20, Ymir.Tests 34/34 (with `-p:CultLibRoot` at the pinned tag). Hand mutants: no refresh in step, gravity ignored, quaternion lane swapped, all killed. `workerCount = 4` survives: Box3D gave identical bytes anyway, so the single-worker setting is not shown to matter here; kept as a safe default. - A finding during the build: a held view's step count and body count are by-value copies, not live. Only the lanes are in place; step count moved to the world, and `Count` is documented as taken at view time. **Could not run**: Windows (the repo's primary target) and macOS; any other CPU or compiler, so cross-platform bit-identity is not claimed; the Unity/Aetheria consumers; Lodur and Bad Day Simulator themselves; no performance measurement (24 spheres only); `dotnet test Ymir.slnx` as a whole (the slnx lists `../CultLib`, which this workspace lacks; I ran the three projects separately). Not done and recorded as a follow-up: joints, body layout beyond spheres/static boxes, torque/force write path, contact facts, and a public `Ymir.Core` surface. Merge nothing. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
New native object ymir_box3d_soa (ABI 1): gravity, free rotation, spheres and static boxes,
one worker, state copied once per step into contiguous lanes that a caller reads in place.
Managed Box3DSoaWorld/Box3DSoaView expose the lanes as spans. The planar retained session is
unchanged. Tests: fall and rest, in-place reads, bit-for-bit repeat runs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Author
Member

Self-review of #1 (read the C and the C#, ran the 6 tests on Linux x64: pass). Defects, most important first. Fixed ones are in the stacked draft #2.

  1. Wrong reason for determinism. ymir_box3d_soa.c header and docs/soa-world.md say "bodies stepped in insertion order". Box3D orders work by its own constraint graph; insertion order is not what the solver follows. The guarantee we rely on is one worker plus Box3D's own deterministic design, on one build. Reword; do not claim an ordering we do not control. Open.
  2. Determinism evidence is narrow. Two runs in one process, Debug native build, Linux x64. No second process, no Release build, no Windows. The docs say "not claimed across platforms", which is right; the test names read stronger than the evidence. Open.
  3. ABI check is weaker than it looks. get_view_size plus the version number catch a size change, not two pointer lanes swapped. No per-lane offset check. Open (#2 adds descriptor size checks, same limit).
  4. Concurrent world creation aborts the host. Found in #2: two test classes created worlds on two threads and b3CreateWorld trapped (SIGILL). #1's one-class suite does not hit it; any second test class will. #2 serialises the test assembly and documents it. Constraint for any consumer that builds worlds on several threads.
  5. Spans outlive the world. Box3DSoaView holds raw pointers; a view taken before Dispose reads freed memory afterwards and nothing guards it. Documented, not enforced. Open, acceptable while the type is internal.
  6. Negative friction and restitution were accepted. Fixed in #2 (descriptor validation).
  7. stable_id goes into shape userData through (uintptr_t). Truncates on a 32-bit target (Unity 32-bit Android would). Open, minor.
  8. Duplicate-id check is linear per add, so building a world is O(n^2). Fine at hundreds of bodies. Open.
  9. Known limits already named in the PR: fixed capacity, no body removal, no contact facts, internal type.

Not measured here: Windows build and Unity consumption (unrun), cross-platform bit-identity (unclaimed).

🤖 Generated with Claude Code

Self-review of #1 (read the C and the C#, ran the 6 tests on Linux x64: pass). Defects, most important first. Fixed ones are in the stacked draft #2. 1. **Wrong reason for determinism.** `ymir_box3d_soa.c` header and `docs/soa-world.md` say "bodies stepped in insertion order". Box3D orders work by its own constraint graph; insertion order is not what the solver follows. The guarantee we rely on is one worker plus Box3D's own deterministic design, on one build. Reword; do not claim an ordering we do not control. Open. 2. **Determinism evidence is narrow.** Two runs in one process, Debug native build, Linux x64. No second process, no Release build, no Windows. The docs say "not claimed across platforms", which is right; the test names read stronger than the evidence. Open. 3. **ABI check is weaker than it looks.** `get_view_size` plus the version number catch a size change, not two pointer lanes swapped. No per-lane offset check. Open (#2 adds descriptor size checks, same limit). 4. **Concurrent world creation aborts the host.** Found in #2: two test classes created worlds on two threads and `b3CreateWorld` trapped (SIGILL). #1's one-class suite does not hit it; any second test class will. #2 serialises the test assembly and documents it. Constraint for any consumer that builds worlds on several threads. 5. **Spans outlive the world.** `Box3DSoaView` holds raw pointers; a view taken before `Dispose` reads freed memory afterwards and nothing guards it. Documented, not enforced. Open, acceptable while the type is internal. 6. **Negative friction and restitution were accepted.** Fixed in #2 (descriptor validation). 7. **`stable_id` goes into shape `userData` through `(uintptr_t)`.** Truncates on a 32-bit target (Unity 32-bit Android would). Open, minor. 8. **Duplicate-id check is linear per add**, so building a world is O(n^2). Fine at hundreds of bodies. Open. 9. Known limits already named in the PR: fixed capacity, no body removal, no contact facts, internal type. Not measured here: Windows build and Unity consumption (unrun), cross-platform bit-identity (unclaimed). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Member

Steward check before merge (nibu, 2026-11-10).

  • Applies to main: 0 commits behind, mergeable.
  • dotnet test tests/Ymir.Box3D.Soa.Tests at the head: 6/6 pass (native shim built with cmake). tests/Ymir.Box3D.Parity (the existing suite, same native CMakeLists touched by this PR): 20/20 pass. Ymir.Tests and the full .slnx need a pinned CultLib sibling that this host lacks; this PR does not touch Ymir.Core.
  • Falsification: swapping gravity y and z in the native def.gravity fails 2 of 6 tests, so gravity direction and read-in-place rest height are pinned.
  • Merged.
Steward check before merge (nibu, 2026-11-10). - Applies to main: 0 commits behind, mergeable. - `dotnet test tests/Ymir.Box3D.Soa.Tests` at the head: 6/6 pass (native shim built with cmake). `tests/Ymir.Box3D.Parity` (the existing suite, same native CMakeLists touched by this PR): 20/20 pass. `Ymir.Tests` and the full `.slnx` need a pinned CultLib sibling that this host lacks; this PR does not touch `Ymir.Core`. - Falsification: swapping gravity y and z in the native `def.gravity` fails 2 of 6 tests, so gravity direction and read-in-place rest height are pinned. - Merged.
nibu merged commit 78786670eb into main 2026-11-10 17:13:58 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
GameCult/Ymir!1
No description provided.