From 5cbc58a320ca25f8a0738d670b2ae0eccf91d5ad Mon Sep 17 00:00:00 2001 From: Alex Feldgendler Date: Sun, 18 Oct 2026 18:22:06 +0200 Subject: [PATCH 1/4] fix: Create and destroy worlds under a process-wide lock Box2D keeps every world in one process-wide table, and b2CreateWorld and b2DestroyWorld claim and free its slots without synchronization; Box2D leaves the guard to the caller (erincatto/box2d#904, and the manual's "Multithreading Multiple Worlds"). Dart isolates are threads over one copy of the native library, so isolates creating and destroying worlds at the same time could put two worlds in one slot, which crashed or computed garbage. The native backend now creates and destroys worlds through a small C shim, native/ffi/f2d_world_lock.c, that holds a process-wide mutex around both calls. The WebAssembly backend is single-threaded and unchanged. --- README.md | 9 +++ packages/forge2d/hook/build.dart | 12 ++- packages/forge2d/lib/src/api/world.dart | 6 +- .../lib/src/backend/raw_box2d_ffi.dart | 7 +- .../forge2d/lib/src/ffi/f2d_world_lock.dart | 22 ++++++ packages/forge2d/native/ffi/f2d_world_lock.c | 43 ++++++++++ packages/forge2d/test/api/isolates_test.dart | 78 +++++++++++++++++++ 7 files changed, 173 insertions(+), 4 deletions(-) create mode 100644 packages/forge2d/lib/src/ffi/f2d_world_lock.dart create mode 100644 packages/forge2d/native/ffi/f2d_world_lock.c create mode 100644 packages/forge2d/test/api/isolates_test.dart diff --git a/README.md b/README.md index 5d650b69..71d8afe1 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,15 @@ lengths and gravity together leaves the timing of the simulation unchanged. The length unit is process-wide and cannot change once a `World` exists, which is why it is set through `initializeForge2D`. +## Isolates + +Each isolate can run worlds of its own, for example to simulate ahead in a +background isolate while the main one renders. A world must only be used +from the isolate that created it; creating and destroying worlds from +several isolates at once is safe. Isolates share one copy of the native +library, so the process-wide settings above (the length unit) apply to every +isolate. + ## Performance The standard [bench2d](https://github.com/joelgwebber/bench2d) benchmark diff --git a/packages/forge2d/hook/build.dart b/packages/forge2d/hook/build.dart index bd8eb8a4..7d0d8edb 100644 --- a/packages/forge2d/hook/build.dart +++ b/packages/forge2d/hook/build.dart @@ -45,6 +45,13 @@ const _sources = [ 'third_party/box2d/src/world.c', ]; +/// forge2d's own C sources for the native build, relative to the package root. +const _shimSources = [ + // Creates and destroys worlds under a process-wide lock, as isolates are + // threads over one copy of the library. + 'native/ffi/f2d_world_lock.c', +]; + void main(List args) async { await build(args, (input, output) async { if (!input.config.buildCodeAssets) { @@ -62,7 +69,7 @@ void main(List args) async { final builder = CBuilder.library( name: 'box2d', assetName: 'src/ffi/box2d.g.dart', - sources: _sources, + sources: [..._sources, ..._shimSources], includes: ['third_party/box2d/include', 'third_party/box2d/src'], std: 'c17', optimizationLevel: OptimizationLevel.o2, @@ -77,6 +84,9 @@ void main(List args) async { }, libraries: [ if (os == OS.linux || os == OS.android) 'm', + // pthread_mutex_* live in libpthread before glibc 2.34; bionic has + // them in libc. + if (os == OS.linux) 'pthread', ], ); diff --git a/packages/forge2d/lib/src/api/world.dart b/packages/forge2d/lib/src/api/world.dart index 8594b296..407d298f 100644 --- a/packages/forge2d/lib/src/api/world.dart +++ b/packages/forge2d/lib/src/api/world.dart @@ -24,7 +24,11 @@ import 'package:vector_math/vector_math.dart'; /// explicitly with [destroy]; destroying a world frees every body, shape, /// and joint in it. /// -/// A world must only be used from the isolate that created it. +/// A world must only be used from the isolate that created it. Different +/// isolates can each create, step, and destroy worlds of their own at the +/// same time: creating and destroying a world is synchronized across the +/// process. Settings that are process-wide in Box2D, such as the length unit +/// set through `initializeForge2D`, are shared by every isolate. class World { /// Creates a world. /// diff --git a/packages/forge2d/lib/src/backend/raw_box2d_ffi.dart b/packages/forge2d/lib/src/backend/raw_box2d_ffi.dart index daae1da3..89f74fba 100644 --- a/packages/forge2d/lib/src/backend/raw_box2d_ffi.dart +++ b/packages/forge2d/lib/src/backend/raw_box2d_ffi.dart @@ -3,6 +3,7 @@ import 'dart:ffi'; import 'package:ffi/ffi.dart'; import 'package:forge2d/src/backend/raw_box2d.dart'; import 'package:forge2d/src/ffi/box2d.g.dart' as b2; +import 'package:forge2d/src/ffi/f2d_world_lock.dart' as f2d; /// Prepares the FFI backend. Nothing to do: the native library is bundled by /// the build hook and loaded lazily. [wasmUri] only applies on the web. @@ -98,12 +99,14 @@ final class RawBox2DFfi implements RawBox2D { ..maximumLinearSpeed = maximumLinearSpeed ..enableSleep = enableSleep ..enableContinuous = enableContinuous; - return _packWorld(b2.b2CreateWorld(definition)); + // Isolates are threads over one copy of the library, and Box2D's world + // table is process-wide: create and destroy worlds under a lock. + return _packWorld(f2d.f2dCreateWorldLocked(definition)); }); } @override - void destroyWorld(int worldId) => b2.b2DestroyWorld(_world(worldId)); + void destroyWorld(int worldId) => f2d.f2dDestroyWorldLocked(_world(worldId)); @override bool worldIsValid(int worldId) => b2.b2World_IsValid(_world(worldId)); diff --git a/packages/forge2d/lib/src/ffi/f2d_world_lock.dart b/packages/forge2d/lib/src/ffi/f2d_world_lock.dart new file mode 100644 index 00000000..2552b61b --- /dev/null +++ b/packages/forge2d/lib/src/ffi/f2d_world_lock.dart @@ -0,0 +1,22 @@ +// Bindings to native/ffi/f2d_world_lock.c, which the build hook compiles +// into the same library as Box2D. +@ffi.DefaultAsset('package:forge2d/src/ffi/box2d.g.dart') +library; + +import 'dart:ffi' as ffi; + +import 'package:forge2d/src/ffi/box2d.g.dart'; + +/// [b2CreateWorld] under a process-wide lock. +/// +/// Box2D claims a slot of its process-wide world table without +/// synchronization, and every isolate shares the one copy of the library, so +/// isolates creating worlds at the same time could otherwise share a slot. +@ffi.Native)>( + symbol: 'f2d_create_world_locked', +) +external b2WorldId f2dCreateWorldLocked(ffi.Pointer def); + +/// [b2DestroyWorld] under the same lock as [f2dCreateWorldLocked]. +@ffi.Native(symbol: 'f2d_destroy_world_locked') +external void f2dDestroyWorldLocked(b2WorldId id); diff --git a/packages/forge2d/native/ffi/f2d_world_lock.c b/packages/forge2d/native/ffi/f2d_world_lock.c new file mode 100644 index 00000000..d6cac12a --- /dev/null +++ b/packages/forge2d/native/ffi/f2d_world_lock.c @@ -0,0 +1,43 @@ +// Creating and destroying worlds under a process-wide lock. +// +// Box2D keeps every world in one process-wide table, and b2CreateWorld and +// b2DestroyWorld claim and free its slots without synchronization. Box2D +// leaves the guard to the caller: "You will get a race condition if you +// create or destroy Box2D worlds from multiple threads. You should use a mutex +// to guard this." (manual, Multithreading Multiple Worlds; see also +// https://github.com/erincatto/box2d/issues/904). +// +// Dart isolates run on threads of their own over one copy of this library, so +// the native backend creates and destroys worlds through these two functions +// instead. Everything else a world does only touches that world, which must be +// used from one isolate at a time. +// +// Only the native (FFI) build compiles this file. The WebAssembly module runs +// on a single thread, and each instance has its own memory and world table. + +#include "box2d/box2d.h" + +#if defined(_WIN32) +#include +static SRWLOCK f2d_world_lock = SRWLOCK_INIT; +static void f2d_lock(void) { AcquireSRWLockExclusive(&f2d_world_lock); } +static void f2d_unlock(void) { ReleaseSRWLockExclusive(&f2d_world_lock); } +#else +#include +static pthread_mutex_t f2d_world_lock = PTHREAD_MUTEX_INITIALIZER; +static void f2d_lock(void) { pthread_mutex_lock(&f2d_world_lock); } +static void f2d_unlock(void) { pthread_mutex_unlock(&f2d_world_lock); } +#endif + +BOX2D_EXPORT b2WorldId f2d_create_world_locked(const b2WorldDef* def) { + f2d_lock(); + b2WorldId id = b2CreateWorld(def); + f2d_unlock(); + return id; +} + +BOX2D_EXPORT void f2d_destroy_world_locked(b2WorldId id) { + f2d_lock(); + b2DestroyWorld(id); + f2d_unlock(); +} diff --git a/packages/forge2d/test/api/isolates_test.dart b/packages/forge2d/test/api/isolates_test.dart new file mode 100644 index 00000000..e09ddd01 --- /dev/null +++ b/packages/forge2d/test/api/isolates_test.dart @@ -0,0 +1,78 @@ +@TestOn('vm') +library; + +import 'dart:isolate'; + +import 'package:forge2d/forge2d.dart'; +import 'package:test/test.dart'; + +/// Box2D keeps every world in one process-wide table, and every isolate in +/// the process shares the one copy of the native library. Creating and +/// destroying worlds therefore has to be safe from several isolates at once, +/// as long as each world is only used by the isolate that created it. +void main() { + setUpAll(initializeForge2D); + + test( + 'isolates can create, step and destroy worlds at the same time', + () async { + const isolates = 8; + const worldsPerIsolate = 500; + final expected = _simulate(); + + final results = await Future.wait([ + for (var i = 0; i < isolates; i++) + Isolate.run(() async { + await initializeForge2D(); + for (var j = 0; j < worldsPerIsolate; j++) { + final result = _simulate(); + if (!_sameResult(result, expected)) { + return 'world $j gave $result'; + } + } + return null; + }), + ]); + + expect(results, everyElement(isNull)); + }, + ); +} + +/// Drops a short stack of boxes onto the ground and returns where they end +/// up. Box2D is deterministic, so every run gives exactly the same numbers. +List _simulate() { + final world = World(); + world + .createBody(BodyDef(position: Vector2(0, -1))) + .createShape(Polygon.box(20, 1)); + final boxes = [ + for (var i = 0; i < 3; i++) + world.createBody( + BodyDef( + type: BodyType.dynamic, + position: Vector2(0.1 * i, 1 + 1.1 * i), + ), + )..createShape(Polygon.square(0.5)), + ]; + for (var i = 0; i < 30; i++) { + world.step(1 / 60); + } + final result = [ + for (final box in boxes) ...[box.position.x, box.position.y, box.angle], + ]; + world.destroy(); + return result; +} + +bool _sameResult(List a, List b) { + if (a.length != b.length) { + return false; + } + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) { + return false; + } + } + return true; +} From 3d7595093330c710890397bee0f0d352038ccf1f Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 19 Oct 2026 09:44:50 +0200 Subject: [PATCH 2/4] refactor: Simplify the world lock Use Box2D's own atomics for a spinlock instead of pthread and SRWLOCK, which removes the platform branches and the pthread link. Also trim duplicated documentation and simplify the isolates test. --- README.md | 9 ------- packages/forge2d/hook/build.dart | 11 +------- packages/forge2d/lib/src/api/world.dart | 7 ++--- .../lib/src/backend/raw_box2d_ffi.dart | 2 -- .../forge2d/lib/src/ffi/f2d_world_lock.dart | 4 --- packages/forge2d/native/ffi/f2d_world_lock.c | 20 +++++++------- packages/forge2d/test/api/isolates_test.dart | 26 ++----------------- 7 files changed, 14 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 71d8afe1..5d650b69 100644 --- a/README.md +++ b/README.md @@ -126,15 +126,6 @@ lengths and gravity together leaves the timing of the simulation unchanged. The length unit is process-wide and cannot change once a `World` exists, which is why it is set through `initializeForge2D`. -## Isolates - -Each isolate can run worlds of its own, for example to simulate ahead in a -background isolate while the main one renders. A world must only be used -from the isolate that created it; creating and destroying worlds from -several isolates at once is safe. Isolates share one copy of the native -library, so the process-wide settings above (the length unit) apply to every -isolate. - ## Performance The standard [bench2d](https://github.com/joelgwebber/bench2d) benchmark diff --git a/packages/forge2d/hook/build.dart b/packages/forge2d/hook/build.dart index 7d0d8edb..319e5528 100644 --- a/packages/forge2d/hook/build.dart +++ b/packages/forge2d/hook/build.dart @@ -43,12 +43,6 @@ const _sources = [ 'third_party/box2d/src/weld_joint.c', 'third_party/box2d/src/wheel_joint.c', 'third_party/box2d/src/world.c', -]; - -/// forge2d's own C sources for the native build, relative to the package root. -const _shimSources = [ - // Creates and destroys worlds under a process-wide lock, as isolates are - // threads over one copy of the library. 'native/ffi/f2d_world_lock.c', ]; @@ -69,7 +63,7 @@ void main(List args) async { final builder = CBuilder.library( name: 'box2d', assetName: 'src/ffi/box2d.g.dart', - sources: [..._sources, ..._shimSources], + sources: _sources, includes: ['third_party/box2d/include', 'third_party/box2d/src'], std: 'c17', optimizationLevel: OptimizationLevel.o2, @@ -84,9 +78,6 @@ void main(List args) async { }, libraries: [ if (os == OS.linux || os == OS.android) 'm', - // pthread_mutex_* live in libpthread before glibc 2.34; bionic has - // them in libc. - if (os == OS.linux) 'pthread', ], ); diff --git a/packages/forge2d/lib/src/api/world.dart b/packages/forge2d/lib/src/api/world.dart index 407d298f..d4655a7e 100644 --- a/packages/forge2d/lib/src/api/world.dart +++ b/packages/forge2d/lib/src/api/world.dart @@ -24,11 +24,8 @@ import 'package:vector_math/vector_math.dart'; /// explicitly with [destroy]; destroying a world frees every body, shape, /// and joint in it. /// -/// A world must only be used from the isolate that created it. Different -/// isolates can each create, step, and destroy worlds of their own at the -/// same time: creating and destroying a world is synchronized across the -/// process. Settings that are process-wide in Box2D, such as the length unit -/// set through `initializeForge2D`, are shared by every isolate. +/// A world must only be used from the isolate that created it, but several +/// isolates can each run worlds of their own at the same time. class World { /// Creates a world. /// diff --git a/packages/forge2d/lib/src/backend/raw_box2d_ffi.dart b/packages/forge2d/lib/src/backend/raw_box2d_ffi.dart index 89f74fba..4a3f5c8b 100644 --- a/packages/forge2d/lib/src/backend/raw_box2d_ffi.dart +++ b/packages/forge2d/lib/src/backend/raw_box2d_ffi.dart @@ -99,8 +99,6 @@ final class RawBox2DFfi implements RawBox2D { ..maximumLinearSpeed = maximumLinearSpeed ..enableSleep = enableSleep ..enableContinuous = enableContinuous; - // Isolates are threads over one copy of the library, and Box2D's world - // table is process-wide: create and destroy worlds under a lock. return _packWorld(f2d.f2dCreateWorldLocked(definition)); }); } diff --git a/packages/forge2d/lib/src/ffi/f2d_world_lock.dart b/packages/forge2d/lib/src/ffi/f2d_world_lock.dart index 2552b61b..5b0f584f 100644 --- a/packages/forge2d/lib/src/ffi/f2d_world_lock.dart +++ b/packages/forge2d/lib/src/ffi/f2d_world_lock.dart @@ -8,10 +8,6 @@ import 'dart:ffi' as ffi; import 'package:forge2d/src/ffi/box2d.g.dart'; /// [b2CreateWorld] under a process-wide lock. -/// -/// Box2D claims a slot of its process-wide world table without -/// synchronization, and every isolate shares the one copy of the library, so -/// isolates creating worlds at the same time could otherwise share a slot. @ffi.Native)>( symbol: 'f2d_create_world_locked', ) diff --git a/packages/forge2d/native/ffi/f2d_world_lock.c b/packages/forge2d/native/ffi/f2d_world_lock.c index d6cac12a..b94acac1 100644 --- a/packages/forge2d/native/ffi/f2d_world_lock.c +++ b/packages/forge2d/native/ffi/f2d_world_lock.c @@ -15,19 +15,17 @@ // Only the native (FFI) build compiles this file. The WebAssembly module runs // on a single thread, and each instance has its own memory and world table. +#include "atomic.h" #include "box2d/box2d.h" -#if defined(_WIN32) -#include -static SRWLOCK f2d_world_lock = SRWLOCK_INIT; -static void f2d_lock(void) { AcquireSRWLockExclusive(&f2d_world_lock); } -static void f2d_unlock(void) { ReleaseSRWLockExclusive(&f2d_world_lock); } -#else -#include -static pthread_mutex_t f2d_world_lock = PTHREAD_MUTEX_INITIALIZER; -static void f2d_lock(void) { pthread_mutex_lock(&f2d_world_lock); } -static void f2d_unlock(void) { pthread_mutex_unlock(&f2d_world_lock); } -#endif +static b2AtomicInt f2d_world_lock; + +static void f2d_lock(void) { + while (!b2AtomicCompareExchangeInt(&f2d_world_lock, 0, 1)) { + } +} + +static void f2d_unlock(void) { b2AtomicStoreInt(&f2d_world_lock, 0); } BOX2D_EXPORT b2WorldId f2d_create_world_locked(const b2WorldDef* def) { f2d_lock(); diff --git a/packages/forge2d/test/api/isolates_test.dart b/packages/forge2d/test/api/isolates_test.dart index e09ddd01..32a1742b 100644 --- a/packages/forge2d/test/api/isolates_test.dart +++ b/packages/forge2d/test/api/isolates_test.dart @@ -6,10 +6,6 @@ import 'dart:isolate'; import 'package:forge2d/forge2d.dart'; import 'package:test/test.dart'; -/// Box2D keeps every world in one process-wide table, and every isolate in -/// the process shares the one copy of the native library. Creating and -/// destroying worlds therefore has to be safe from several isolates at once, -/// as long as each world is only used by the isolate that created it. void main() { setUpAll(initializeForge2D); @@ -24,17 +20,11 @@ void main() { for (var i = 0; i < isolates; i++) Isolate.run(() async { await initializeForge2D(); - for (var j = 0; j < worldsPerIsolate; j++) { - final result = _simulate(); - if (!_sameResult(result, expected)) { - return 'world $j gave $result'; - } - } - return null; + return [for (var j = 0; j < worldsPerIsolate; j++) _simulate()]; }), ]); - expect(results, everyElement(isNull)); + expect(results, everyElement(everyElement(expected))); }, ); } @@ -64,15 +54,3 @@ List _simulate() { world.destroy(); return result; } - -bool _sameResult(List a, List b) { - if (a.length != b.length) { - return false; - } - for (var i = 0; i < a.length; i++) { - if (a[i] != b[i]) { - return false; - } - } - return true; -} From 5149dfb7c3129dfad5c4c327f152bccc1acf7d84 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 19 Oct 2026 09:50:33 +0200 Subject: [PATCH 3/4] refactor: Address review findings on the world lock Spin on a plain read before the compare-exchange, keep forge2d's own C sources apart from the vendored Box2D list, shorten the header comment, and avoid abbreviated parameter names. --- packages/forge2d/hook/build.dart | 8 +++--- .../forge2d/lib/src/ffi/f2d_world_lock.dart | 4 ++- packages/forge2d/native/ffi/f2d_world_lock.c | 26 +++++-------------- 3 files changed, 15 insertions(+), 23 deletions(-) diff --git a/packages/forge2d/hook/build.dart b/packages/forge2d/hook/build.dart index 319e5528..35ee2584 100644 --- a/packages/forge2d/hook/build.dart +++ b/packages/forge2d/hook/build.dart @@ -7,7 +7,7 @@ import 'package:native_toolchain_c/native_toolchain_c.dart'; /// /// Listed explicitly so that publishing and hook dependency tracking stay /// deterministic. Regenerate with tool/vendor_box2d.sh when upgrading. -const _sources = [ +const _box2dSources = [ 'third_party/box2d/src/aabb.c', 'third_party/box2d/src/arena_allocator.c', 'third_party/box2d/src/array.c', @@ -43,9 +43,11 @@ const _sources = [ 'third_party/box2d/src/weld_joint.c', 'third_party/box2d/src/wheel_joint.c', 'third_party/box2d/src/world.c', - 'native/ffi/f2d_world_lock.c', ]; +/// forge2d's own C sources, compiled into the same library as Box2D. +const _forge2dSources = ['native/ffi/f2d_world_lock.c']; + void main(List args) async { await build(args, (input, output) async { if (!input.config.buildCodeAssets) { @@ -63,7 +65,7 @@ void main(List args) async { final builder = CBuilder.library( name: 'box2d', assetName: 'src/ffi/box2d.g.dart', - sources: _sources, + sources: [..._box2dSources, ..._forge2dSources], includes: ['third_party/box2d/include', 'third_party/box2d/src'], std: 'c17', optimizationLevel: OptimizationLevel.o2, diff --git a/packages/forge2d/lib/src/ffi/f2d_world_lock.dart b/packages/forge2d/lib/src/ffi/f2d_world_lock.dart index 5b0f584f..ad831520 100644 --- a/packages/forge2d/lib/src/ffi/f2d_world_lock.dart +++ b/packages/forge2d/lib/src/ffi/f2d_world_lock.dart @@ -11,7 +11,9 @@ import 'package:forge2d/src/ffi/box2d.g.dart'; @ffi.Native)>( symbol: 'f2d_create_world_locked', ) -external b2WorldId f2dCreateWorldLocked(ffi.Pointer def); +external b2WorldId f2dCreateWorldLocked( + ffi.Pointer definition, +); /// [b2DestroyWorld] under the same lock as [f2dCreateWorldLocked]. @ffi.Native(symbol: 'f2d_destroy_world_locked') diff --git a/packages/forge2d/native/ffi/f2d_world_lock.c b/packages/forge2d/native/ffi/f2d_world_lock.c index b94acac1..5be3f83c 100644 --- a/packages/forge2d/native/ffi/f2d_world_lock.c +++ b/packages/forge2d/native/ffi/f2d_world_lock.c @@ -1,19 +1,6 @@ -// Creating and destroying worlds under a process-wide lock. -// -// Box2D keeps every world in one process-wide table, and b2CreateWorld and -// b2DestroyWorld claim and free its slots without synchronization. Box2D -// leaves the guard to the caller: "You will get a race condition if you -// create or destroy Box2D worlds from multiple threads. You should use a mutex -// to guard this." (manual, Multithreading Multiple Worlds; see also -// https://github.com/erincatto/box2d/issues/904). -// -// Dart isolates run on threads of their own over one copy of this library, so -// the native backend creates and destroys worlds through these two functions -// instead. Everything else a world does only touches that world, which must be -// used from one isolate at a time. -// -// Only the native (FFI) build compiles this file. The WebAssembly module runs -// on a single thread, and each instance has its own memory and world table. +// Box2D's world table is process-wide and b2CreateWorld and b2DestroyWorld +// are not thread safe (https://github.com/erincatto/box2d/issues/904), but +// Dart isolates are threads over one copy of this library. #include "atomic.h" #include "box2d/box2d.h" @@ -21,15 +8,16 @@ static b2AtomicInt f2d_world_lock; static void f2d_lock(void) { - while (!b2AtomicCompareExchangeInt(&f2d_world_lock, 0, 1)) { + while (b2AtomicLoadInt(&f2d_world_lock) != 0 || + !b2AtomicCompareExchangeInt(&f2d_world_lock, 0, 1)) { } } static void f2d_unlock(void) { b2AtomicStoreInt(&f2d_world_lock, 0); } -BOX2D_EXPORT b2WorldId f2d_create_world_locked(const b2WorldDef* def) { +BOX2D_EXPORT b2WorldId f2d_create_world_locked(const b2WorldDef* definition) { f2d_lock(); - b2WorldId id = b2CreateWorld(def); + b2WorldId id = b2CreateWorld(definition); f2d_unlock(); return id; } From 103fc434bc1b03f5a6372599953b66dca818f5cb Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 19 Oct 2026 10:04:05 +0200 Subject: [PATCH 4/4] fix: Throw when Box2D runs out of worlds and hide the unlocked bindings World now throws a StateError when the world table is full instead of wrapping an invalid id. b2CreateWorld and b2DestroyWorld are excluded from the generated bindings so only the locked wrappers can be called. --- packages/forge2d/lib/src/api/world.dart | 5 +++++ packages/forge2d/lib/src/ffi/box2d.g.dart | 10 ---------- .../forge2d/lib/src/ffi/f2d_world_lock.dart | 4 ++-- packages/forge2d/test/api/world_test.dart | 17 +++++++++++++++++ packages/forge2d/test/ffi/smoke_test.dart | 5 +++-- packages/forge2d/tool/ffigen/ffigen.yaml | 3 +++ 6 files changed, 30 insertions(+), 14 deletions(-) diff --git a/packages/forge2d/lib/src/api/world.dart b/packages/forge2d/lib/src/api/world.dart index d4655a7e..fa9be2f0 100644 --- a/packages/forge2d/lib/src/api/world.dart +++ b/packages/forge2d/lib/src/api/world.dart @@ -48,6 +48,11 @@ class World { enableSleep: definition.enableSleep, enableContinuous: definition.enableContinuous, ) { + if (id == 0) { + throw StateError( + 'Box2D supports at most 128 worlds at a time; destroy one first.', + ); + } // Freezes the length unit: it is baked into this world's tolerances. markWorldCreated(); } diff --git a/packages/forge2d/lib/src/ffi/box2d.g.dart b/packages/forge2d/lib/src/ffi/box2d.g.dart index cb5d5c2c..109491cc 100644 --- a/packages/forge2d/lib/src/ffi/box2d.g.dart +++ b/packages/forge2d/lib/src/ffi/box2d.g.dart @@ -936,16 +936,6 @@ external b2ExplosionDef b2DefaultExplosionDef(); @ffi.Native() external b2DebugDraw b2DefaultDebugDraw(); -/// Create a world for rigid body simulation. A world contains bodies, shapes, and constraints. You make create -/// up to 128 worlds. Each world is completely independent and may be simulated in parallel. -/// @return the world id. -@ffi.Native)>() -external b2WorldId b2CreateWorld(ffi.Pointer def); - -/// Destroy a world -@ffi.Native() -external void b2DestroyWorld(b2WorldId worldId); - /// World id validation. Provides validation for up to 64K allocations. @ffi.Native() external bool b2World_IsValid(b2WorldId id); diff --git a/packages/forge2d/lib/src/ffi/f2d_world_lock.dart b/packages/forge2d/lib/src/ffi/f2d_world_lock.dart index ad831520..a69398c2 100644 --- a/packages/forge2d/lib/src/ffi/f2d_world_lock.dart +++ b/packages/forge2d/lib/src/ffi/f2d_world_lock.dart @@ -7,7 +7,7 @@ import 'dart:ffi' as ffi; import 'package:forge2d/src/ffi/box2d.g.dart'; -/// [b2CreateWorld] under a process-wide lock. +/// `b2CreateWorld` under a process-wide lock. @ffi.Native)>( symbol: 'f2d_create_world_locked', ) @@ -15,6 +15,6 @@ external b2WorldId f2dCreateWorldLocked( ffi.Pointer definition, ); -/// [b2DestroyWorld] under the same lock as [f2dCreateWorldLocked]. +/// `b2DestroyWorld` under the same lock as [f2dCreateWorldLocked]. @ffi.Native(symbol: 'f2d_destroy_world_locked') external void f2dDestroyWorldLocked(b2WorldId id); diff --git a/packages/forge2d/test/api/world_test.dart b/packages/forge2d/test/api/world_test.dart index fe268edd..57934bdd 100644 --- a/packages/forge2d/test/api/world_test.dart +++ b/packages/forge2d/test/api/world_test.dart @@ -5,6 +5,23 @@ void main() { setUpAll(initializeForge2D); group('World', () { + test('throws a StateError when Box2D runs out of worlds', () { + final worlds = []; + addTearDown(() { + for (final world in worlds) { + world.destroy(); + } + }); + expect( + () { + while (worlds.length <= 128) { + worlds.add(World()); + } + }, + throwsStateError, + ); + }); + test('starts valid and becomes invalid on destroy', () { final world = World(); expect(world.isValid, isTrue); diff --git a/packages/forge2d/test/ffi/smoke_test.dart b/packages/forge2d/test/ffi/smoke_test.dart index f0e0d4b6..117d803f 100644 --- a/packages/forge2d/test/ffi/smoke_test.dart +++ b/packages/forge2d/test/ffi/smoke_test.dart @@ -5,6 +5,7 @@ import 'dart:ffi'; import 'package:ffi/ffi.dart'; import 'package:forge2d/src/ffi/box2d.g.dart' as b2; +import 'package:forge2d/src/ffi/f2d_world_lock.dart' as f2d; import 'package:test/test.dart'; /// Exercises the native build hook and the raw generated bindings end-to-end: @@ -13,7 +14,7 @@ void main() { test('a falling box lands on the ground', () { using((arena) { final worldDef = arena()..ref = b2.b2DefaultWorldDef(); - final worldId = b2.b2CreateWorld(worldDef); + final worldId = f2d.f2dCreateWorldLocked(worldDef); expect(b2.b2World_IsValid(worldId), isTrue); final groundDef = arena()..ref = b2.b2DefaultBodyDef(); @@ -45,7 +46,7 @@ void main() { expect(position.y, closeTo(0.5, 0.01)); expect(b2.b2Body_IsAwake(bodyId), isFalse); - b2.b2DestroyWorld(worldId); + f2d.f2dDestroyWorldLocked(worldId); expect(b2.b2World_IsValid(worldId), isFalse); }); }); diff --git a/packages/forge2d/tool/ffigen/ffigen.yaml b/packages/forge2d/tool/ffigen/ffigen.yaml index e33c5345..a178995c 100644 --- a/packages/forge2d/tool/ffigen/ffigen.yaml +++ b/packages/forge2d/tool/ffigen/ffigen.yaml @@ -17,6 +17,9 @@ functions: exclude: - 'b2SetAllocator' - 'b2SetAssertFcn' + # Bound through lib/src/ffi/f2d_world_lock.dart, under a lock. + - 'b2CreateWorld' + - 'b2DestroyWorld' structs: include: - 'b2.*'