diff --git a/CHANGELOG.md b/CHANGELOG.md index ef4ff177..aadd443b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ ### Added +- **compat.box2d 3.2.0(新包)。** Box2D v3:游戏用 2D 刚体物理引擎(C17,MIT,无依赖)。按上游 CMake 的做法从标签归档 + 构建 `src/*.c` 为静态库:`-std=gnu17`、`-ffp-contract=off`(上游为跨平台确定性对每个 GCC/Clang 设置,禁止乘加收缩为 + FMA)、上游默认的 SIMD 选择(x86_64 为 SSE2,并在运行时按 cpuid 分派 AVX2 求解器;arm64 为 Neon)、unix 上 + `-fPIC -fvisibility=hidden`,Linux 链接 `-lm -lpthread`;Emscripten 目标加上游同样的 `-msimd128 -msse2`(`target_cfg`)。 + 不定义 BOX2D_VALIDATE / BOX2D_DOUBLE_PRECISION / BOX2D_PROFILE(与上游作为依赖使用时相同)。使用者 `#include `。 + GLOBAL 为 GitHub 标签归档,CN 为 GitCode `mcpp-res` 镜像,sha256 两处一致;声明 `extensions.autoupdate`(`v${version}` 标签)。 + 新增测试成员 `tests/examples/box2d`(三平台):版本、下落与休眠、接触与移动事件、射线与凸包,以及上游的确定性场景 + falling hinges(第 299 步全部休眠、变换哈希 0x465F3DF4,1 个与 4 个 worker、SSE2 回退路径)。 - **mcpplibs.mwrap.miniaudio 0.11.25(新包)。** miniaudio-m:以 C++23 模块(`import mcpplibs.mwrap.miniaudio;`)使用 compat.miniaudio 0.11.25(MIT-0 或 Unlicense)。导出 `miniaudio.h` API 部分的全部 266 个类型与结构体标签、368 个枚举值与 931 个 `MA_API` 函数(上游全局名不变),每个声明位于头文件声明它的同一预处理条件下;三个 `static MA_INLINE` 辅助函数在 diff --git a/mcpp.toml b/mcpp.toml index 178776e0..559afd7c 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -133,6 +133,7 @@ members = [ "tests/examples/safeint-c-api", "tests/examples/sdl2", "tests/examples/sdl3", + "tests/examples/box2d", "tests/examples/sheenbidi", "tests/examples/spdlog", "tests/examples/fontconfig", diff --git a/pkgs/c/compat.box2d.lua b/pkgs/c/compat.box2d.lua new file mode 100644 index 00000000..542f3d32 --- /dev/null +++ b/pkgs/c/compat.box2d.lua @@ -0,0 +1,140 @@ +-- compat.box2d — Box2D v3, the 2D rigid-body physics engine for games, MIT. +-- +-- Box2D 3.x is a C17 rewrite of the engine (bodies, shapes, joints, contact +-- and sensor events, ray and shape casts, continuous collision, a built-in +-- task scheduler) with no dependencies beyond the C library. Upstream +-- documents it as cross-platform deterministic: the same inputs give the +-- same bits on x86_64 (SSE2 or AVX2) and arm64 (Neon), with any worker +-- count, as long as the floating point is built as upstream builds it. Its +-- own test suite pins that as a hash (test/test_determinism.c, the "falling +-- hinges" scene: sleep step 299, hash 0x465F3DF4 in single precision). +-- +-- ───────────────────────────────────────────────────────────────────────── +-- THE BUILD UPSTREAM'S CMAKE MAKES, AND WHY EACH FLAG +-- +-- sources every src/*.c (src/CMakeLists.txt's BOX2D_SOURCE_FILES is +-- exactly the .c files of src/; the .inl files are included +-- by contact_solver_w4.c / _w8.c and recording.c). +-- C17, GNU `C_STANDARD 17`, `C_EXTENSIONS YES`: c_standard "gnu17" +-- (-std=gnu17, the last -std on the line). +-- -ffp-contract=off +-- CMakeLists.txt "Deterministic math": contraction of a*b+c +-- into an FMA is the one value-changing transform compilers +-- enable by default, and whether it fires depends on the +-- target (arm64 always has FMA; x86_64 only under AVX2/FMA, +-- which the AVX2 solver file is). Upstream sets it for every +-- GCC and Clang, and for clang-cl as /clang:-ffp-contract=off; +-- this index's Windows toolchain is clang's GNU-style driver +-- targeting MSVC, which takes the plain flag. Without it the +-- determinism hash differs between rows. +-- SIMD upstream's default (BOX2D_DISABLE_SIMD off): src/core.h +-- picks SSE2 on x86_64 and Neon on arm64 from the compiler's +-- predefined macros, and on x86_64 additionally compiles +-- contact_solver_w8.c under `#pragma GCC target("avx2")` / +-- `clang attribute push(target("avx2"))`, chosen at run time +-- by cpuid (src/simd.c) — no -mavx2 for the build. The 4-wide +-- and 8-wide solvers give the same bits (upstream's +-- SSE2-fallback determinism test). +-- -fvisibility=hidden +-- `CMAKE_C_VISIBILITY_PRESET hidden`: of a shared object a +-- consumer links this into, only its own exports are visible; +-- the B2_API declarations need no attribute in a static +-- library (BOX2D_EXPORT is empty without box2d_EXPORTS). +-- -fPIC unix: linked into shared objects by its consumers. Not on +-- Windows (PE/COFF; clang rejects it for the MSVC target). +-- -lm -lpthread linux: libm for sqrtf/atan2f, pthreads for the built-in +-- scheduler's workers (src/timer.c). macOS has both in +-- libSystem; Windows uses CreateThread (kernel32). +-- +-- NOT DEFINED, as a FetchContent / installed consumer of upstream gets it: +-- BOX2D_VALIDATE (a top-level-only option; only effective without NDEBUG), +-- B2_ENABLE_ASSERT (RelWithDebInfo only), BOX2D_PROFILE (Tracy), +-- BOX2D_DOUBLE_PRECISION (large worlds; it is PUBLIC upstream because it +-- changes the headers' types, so it would be a separate package, not a +-- consumer define). B2_ASSERT is on when NDEBUG is not defined, upstream's +-- rule (include/box2d/base.h). +-- +-- kind = "lib": a static library; consumers `#include `. +-- src/ is not on the consumer's include path: the sources include their +-- internal headers in quote form beside themselves. +package = { + spec = "1", + namespace = "compat", + name = "box2d", + description = "Box2D 3.2.0 — the 2D rigid-body physics engine for games (C17, cross-platform deterministic), MIT", + licenses = {"MIT"}, + repo = "https://github.com/erincatto/box2d", + type = "package", + + extensions = { + autoupdate = { + tag = "v${version}", + url = "https://github.com/erincatto/box2d/archive/refs/tags/${tag}.tar.gz", + }, + }, + + -- One source tarball for every platform. + xpm = { + linux = { + ["3.2.0"] = { + url = { + GLOBAL = "https://github.com/erincatto/box2d/archive/refs/tags/v3.2.0.tar.gz", + CN = "https://gitcode.com/mcpp-res/box2d/releases/download/3.2.0/box2d-3.2.0.tar.gz", + }, + sha256 = "ffec747b27a2267016f8d0240d658c82d187fb2271fa0f69fcbe53fbc067180d", + }, + }, + macosx = { + ["3.2.0"] = { + url = { + GLOBAL = "https://github.com/erincatto/box2d/archive/refs/tags/v3.2.0.tar.gz", + CN = "https://gitcode.com/mcpp-res/box2d/releases/download/3.2.0/box2d-3.2.0.tar.gz", + }, + sha256 = "ffec747b27a2267016f8d0240d658c82d187fb2271fa0f69fcbe53fbc067180d", + }, + }, + windows = { + ["3.2.0"] = { + url = { + GLOBAL = "https://github.com/erincatto/box2d/archive/refs/tags/v3.2.0.tar.gz", + CN = "https://gitcode.com/mcpp-res/box2d/releases/download/3.2.0/box2d-3.2.0.tar.gz", + }, + sha256 = "ffec747b27a2267016f8d0240d658c82d187fb2271fa0f69fcbe53fbc067180d", + }, + }, + }, + + mcpp = { + language = "c++23", + import_std = false, + c_standard = "gnu17", + + -- The public headers, included as . They declare the + -- API as `extern "C"` under C++ themselves (B2_API). + include_dirs = { "*/include" }, + + sources = { "*/src/*.c" }, + cflags = { "-ffp-contract=off" }, + + -- The Web: src/core.h maps wasm onto the SSE2 path, whose intrinsics + -- compile only with wasm SIMD enabled (upstream's CMakeLists.txt sets + -- exactly these two for EMSCRIPTEN). A program running it needs a + -- browser or node with wasm SIMD (every current one). + target_cfg = { + ["cfg(os = \"emscripten\")"] = { + cflags = { "-msimd128", "-msse2" }, + }, + }, + + linux = { + cflags = { "-fPIC", "-fvisibility=hidden" }, + ldflags = { "-lm", "-lpthread" }, + }, + macosx = { + cflags = { "-fPIC", "-fvisibility=hidden" }, + }, + + targets = { box2d = { kind = "lib" } }, + deps = {}, + }, +} diff --git a/tests/examples/box2d/mcpp.toml b/tests/examples/box2d/mcpp.toml new file mode 100644 index 00000000..bb022940 --- /dev/null +++ b/tests/examples/box2d/mcpp.toml @@ -0,0 +1,16 @@ +# compat.box2d — the 2D physics engine (MIT), on every platform the descriptor +# declares: a world that steps, collides, casts and sleeps, and upstream's +# cross-platform determinism scene with the hash upstream pins. +[package] +name = "box2d-tests" +version = "0.1.0" +standard = "c++23" + +# The test's own scene setup (positions such as h + 2*h*i) is floating point +# too: compiled as upstream compiles its tests, without contraction into FMA, +# or the arm64 rows would start from different bits. +[build] +cxxflags = ["-ffp-contract=off"] + +[dependencies] +compat.box2d = "3.2.0" diff --git a/tests/examples/box2d/tests/box2d.cpp b/tests/examples/box2d/tests/box2d.cpp new file mode 100644 index 00000000..3f88ea3c --- /dev/null +++ b/tests/examples/box2d/tests/box2d.cpp @@ -0,0 +1,208 @@ +// compat.box2d — Box2D 3.2.0, on a world that needs it. +// +// WHAT THIS ASSERTS AND WHY +// +// the version b2GetVersion, the linked library's own numbers +// a falling box gravity, a contact with the ground, a body at rest +// on it, and its sleep — the solver end to end +// events contact begin events, body move events +// queries a ray cast and an AABB overlap against the world +// geometry b2MakeBox's mass, b2ComputeHull of a point cloud +// determinism upstream's "falling hinges" scene (shared/ +// determinism.c, test/test_determinism.c): 80 boxes +// in hinged pairs fall and settle; the step on which +// the world falls asleep and the djb2 hash of every +// body's final transform are upstream's pinned values +// (299, 0x465F3DF4) — with one worker, with four +// workers of the built-in scheduler, and on the 4-wide +// SSE2 solver path where the CPU would take AVX2. +// +// A pass means the library was built as upstream builds it: the hash moves +// with any value-changing floating-point transform (FMA contraction above all), +// on any row. + +#include +#include + +#include +#include +#include + +namespace { + +int failures = 0; + +void check(bool ok, const char *what) +{ + std::printf("%-62s %s\n", what, ok ? "ok" : "FAILED"); + if (!ok) ++failures; +} + +struct Hinges { + std::vector bodies; + int sleepStep = -1; + std::uint32_t hash = 0; +}; + +// shared/determinism.c, CreateFallingHinges, in C++. +Hinges createFallingHinges(b2WorldId world) +{ + { + b2BodyDef bodyDef = b2DefaultBodyDef(); + bodyDef.position = b2Pos{0.0f, -1.0f}; + b2BodyId ground = b2CreateBody(world, &bodyDef); + b2Polygon box = b2MakeBox(40.0f, 1.0f); + b2ShapeDef shapeDef = b2DefaultShapeDef(); + b2CreatePolygonShape(ground, &shapeDef, &box); + } + const int columnCount = 4, rowCount = 20; + Hinges data; + const float h = 0.25f; + b2Polygon box = b2MakeSquare(h); + b2ShapeDef shapeDef = b2DefaultShapeDef(); + + b2RevoluteJointDef jointDef = b2DefaultRevoluteJointDef(); + jointDef.enableLimit = true; + jointDef.lowerAngle = -0.1f * B2_PI; + jointDef.upperAngle = 0.2f * B2_PI; + jointDef.enableSpring = true; + jointDef.hertz = 1.0f; + jointDef.dampingRatio = 1.0f; + jointDef.enableMotor = true; + jointDef.maxMotorTorque = 0.25f; + jointDef.base.localFrameA.p = b2Vec2{-h, h}; + jointDef.base.localFrameB.p = b2Vec2{-h, -h}; + jointDef.base.constraintHertz = 60.0f; + jointDef.base.constraintDampingRatio = 0.0f; + jointDef.base.drawScale = 0.5f; + + const float offset = 0.4f * h, dx = 10.0f * h; + const float xBase = -0.5f * dx * (columnCount - 1.0f); + for (int j = 0; j < columnCount; ++j) { + const float x = xBase + j * dx; + b2BodyId prev = b2_nullBodyId; + for (int i = 0; i < rowCount; ++i) { + b2BodyDef bodyDef = b2DefaultBodyDef(); + bodyDef.type = b2_dynamicBody; + bodyDef.position.x = x + offset * i; + bodyDef.position.y = h + 2.0f * h * i; + bodyDef.rotation = b2MakeRot((i & 1) == 0 ? -0.1f : 0.1f); + b2BodyId body = b2CreateBody(world, &bodyDef); + if ((i & 1) == 0) { + prev = body; + } else { + jointDef.base.bodyIdA = prev; + jointDef.base.bodyIdB = body; + b2CreateRevoluteJoint(world, &jointDef); + prev = b2_nullBodyId; + } + b2CreatePolygonShape(body, &shapeDef, &box); + data.bodies.push_back(body); + } + } + return data; +} + +// Steps until every body sleeps; upstream's UpdateFallingHinges. +Hinges runFallingHinges(int workerCount, bool sse2Fallback) +{ + b2WorldDef worldDef = b2DefaultWorldDef(); + worldDef.workerCount = workerCount; + b2WorldId world = b2CreateWorld(&worldDef); + b2World_EnableSSE2Fallback(world, sse2Fallback); + Hinges data = createFallingHinges(world); + for (int step = 0; step < 1000 && data.hash == 0; ++step) { + b2World_Step(world, 1.0f / 60.0f, 4); + if (b2World_GetBodyEvents(world).moveCount == 0) { + data.hash = B2_HASH_INIT; + for (b2BodyId body : data.bodies) { + b2WorldTransform t = b2Body_GetTransform(body); + data.hash = b2Hash(data.hash, reinterpret_cast(&t), sizeof t); + } + data.sleepStep = step; + } + } + b2DestroyWorld(world); + return data; +} + +} // namespace + +int main() +{ + b2Version v = b2GetVersion(); + check(v.major == 3 && v.minor == 2 && v.revision == 0, "b2GetVersion is 3.2.0"); + check(!b2IsDoublePrecision(), "single precision (BOX2D_DOUBLE_PRECISION is off)"); + std::printf("AVX2 available: %s\n", b2IsAVX2Available() ? "yes" : "no"); + + // A box falls onto the ground, touches it, comes to rest and sleeps. + { + b2WorldDef worldDef = b2DefaultWorldDef(); + b2WorldId world = b2CreateWorld(&worldDef); + check(b2World_IsValid(world), "b2CreateWorld"); + + b2BodyDef groundDef = b2DefaultBodyDef(); + b2BodyId ground = b2CreateBody(world, &groundDef); + b2Segment segment = {{-20.0f, 0.0f}, {20.0f, 0.0f}}; + b2ShapeDef groundShape = b2DefaultShapeDef(); + b2CreateSegmentShape(ground, &groundShape, &segment); + + b2BodyDef bodyDef = b2DefaultBodyDef(); + bodyDef.type = b2_dynamicBody; + bodyDef.position = b2Pos{0.0f, 4.0f}; + b2BodyId body = b2CreateBody(world, &bodyDef); + b2Polygon box = b2MakeBox(0.5f, 0.5f); + b2ShapeDef shapeDef = b2DefaultShapeDef(); + shapeDef.density = 1.0f; + shapeDef.enableContactEvents = true; + b2CreatePolygonShape(body, &shapeDef, &box); + + b2MassData mass = b2Body_GetMassData(body); + check(mass.mass > 0.999f && mass.mass < 1.001f, "a 1x1 box of density 1 weighs 1"); + + int begins = 0, moves = 0; + for (int i = 0; i < 240; ++i) { + b2World_Step(world, 1.0f / 60.0f, 4); + begins += b2World_GetContactEvents(world).beginCount; + moves += b2World_GetBodyEvents(world).moveCount; + } + b2Pos p = b2Body_GetPosition(body); + check(p.y > 0.49f && p.y < 0.51f, "the box rests on the ground (y = 0.5)"); + check(begins >= 1, "a contact begin event"); + check(moves > 0, "body move events"); + check(!b2Body_IsAwake(body), "the box falls asleep"); + + b2RayResult hit = b2World_CastRayClosest(world, b2Pos{0.0f, 10.0f}, b2Vec2{0.0f, -20.0f}, b2DefaultQueryFilter()); + check(hit.hit && B2_ID_EQUALS(b2Shape_GetBody(hit.shapeId), body), "a ray cast down hits the box first"); + check(hit.point.y > 0.99f && hit.point.y < 1.01f, "at its top face (y = 1)"); + + b2DestroyWorld(world); + check(!b2World_IsValid(world), "b2DestroyWorld"); + } + + // Geometry: the convex hull of a square with an interior point. + { + b2Vec2 points[] = {{0, 0}, {1, 0}, {1, 1}, {0, 1}, {0.5f, 0.5f}}; + b2Hull hull = b2ComputeHull(points, 5); + check(hull.count == 4 && b2ValidateHull(&hull), "b2ComputeHull drops the interior point"); + b2Polygon polygon = b2MakePolygon(&hull, 0.0f); + b2MassData mass = b2ComputePolygonMass(&polygon, 2.0f); + check(mass.mass > 1.999f && mass.mass < 2.001f, "a unit square of density 2 weighs 2"); + } + + // Upstream's determinism scene and hash. + const struct { int workers; bool sse2; const char *what; } runs[] = { + {1, false, "falling hinges, 1 worker: sleep step 299, hash 0x465F3DF4"}, + {4, false, "falling hinges, 4 workers: sleep step 299, hash 0x465F3DF4"}, + {1, true, "falling hinges, SSE2 fallback: sleep step 299, hash 0x465F3DF4"}, + }; + for (const auto &run : runs) { + Hinges data = runFallingHinges(run.workers, run.sse2); + std::printf(" workers=%d sse2Fallback=%d sleepStep=%d hash=0x%08X\n", run.workers, run.sse2 ? 1 : 0, + data.sleepStep, data.hash); + check(data.sleepStep == 299 && data.hash == 0x465F3DF4u, run.what); + } + + std::printf("%s\n", failures == 0 ? "all checks passed" : "FAILURES"); + return failures == 0 ? 0 : 1; +}