Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .agents/docs/2026-10-28-add-box2d-mwrap-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Add mcpplibs.mwrap.box2d (box2d-m 3.2.0)

## Form

- Third-party upstream [erincatto/box2d](https://github.com/erincatto/box2d), release v3.2.0 (2026-10-19), MIT.
The tag archive (`ffec747b…180d`) is the one compat.box2d 3.2.0 pins (the parent change).
- Form A external mwrap module repository [mcpplibs/box2d-m](https://github.com/mcpplibs/box2d-m), package
`mcpplibs.mwrap.box2d`, version 3.2.0 (first release of the identity, no revision suffix), tag series
`mwrap-${version}` on the merged main commit (`361aa31`); release asset `box2d-m-3.2.0.tar.gz` (`a7b448ea…7572`),
CN mirror `gitcode.com/mcpp-res/mwrap-box2d` with identical bytes.
- Implementation ownership: the library is compat.box2d's (a static library of every src/*.c, single precision,
`-ffp-contract=off`, upstream's default SIMD selection; its configuration and ABI are sufficient). The wrapper vendors
no upstream code. It retains upstream's README.md and LICENSE under `upstream/box2d/`, with checksums in
`upstream/documents.json`, and deploys them with its own LICENSE/NOTICE (`runtime.deploy`).

## Modules

- `mcpplibs.mwrap.box2d`: by `export using`, generated from the pinned installed headers (`BOX2D_API_FILES`): 110
types, 3 opaque structs, 176 enumerators, 506 `B2_API` functions (`b2InternalAssert` under the header's own
`!defined(NDEBUG) || defined(B2_ENABLE_ASSERT)`), 91 inline functions (`B2_INLINE` / `B2_FORCE_INLINE` /
`B2_ID_INLINE` are `inline` in C++: external linkage, exportable), 8 C++ vector operators and
`b2_lengthUnitsPerMeter`. The 13 `static const` objects (internal linkage: `b2_nullBodyId`, `b2Rot_identity`,
`b2Vec2_zero` …) are `inline constexpr` of the same type and initializer in `mcpplibs::mwrap::box2d`.
- `mcpplibs.mwrap.box2d.macros`: 20 object-like macros as `inline constexpr` of the type of their expansion; the 5 that
read `b2_lengthUnitsPerMeter` (`B2_HUGE`, `B2_LINEAR_SLOP`, `B2_SPECULATIVE_DISTANCE`, `B2_CONTACT_RECYCLE_DISTANCE`,
`B2_MAX_AABB_MARGIN`) as functions of no arguments, since `b2SetLengthUnitsPerMeter` changes them at run time; and
`B2_IS_NULL`, `B2_IS_NON_NULL`, `B2_ID_EQUALS` as constexpr function templates whose body is the macro.
- Stay macros, in `<mwrap/box2d/macros.hpp>` (= `<box2d/box2d.h>`): `BOX2D_EXPORT`, `B2_API`, `B2_INLINE`,
`B2_FORCE_INLINE`, `B2_ID_INLINE`, `B2_ALIGN_AS`, `B2_LITERAL`, `B2_ZERO_INIT`, `B2_NULL_ID`, `B2_ASSERT`,
`B2_VALIDATE`, `B2_BREAKPOINT`.

`tools/check_exports.py` fails when the generated sources are stale, a declaration is not exported, a static constant
not adapted, or a macro neither mapped nor listed; every mapping is checked against the raw header in a header-mixing
test.

## Determinism

Wrapper CI (Linux x86_64 GCC 16.1 and LLVM 23.1.3, Linux arm64 GCC 16.1 and LLVM 23.1.3, macOS LLVM, Windows LLVM,
and wasm32-emscripten under node; all required) runs, in the dev and the release profile, upstream's falling-hinges
scene and a 20-row pyramid struck by a ball (600 steps, sleep off) under 1, 2, 4 and 8 workers of the built-in
scheduler, 4 workers of a user task system and the SSE2 fallback. Every row gives the same values: falling hinges
asleep at step 299, transform hash `0x465F3DF4` (upstream's pin), event hash `0xD019CCFF`, state hash
`0x8BD019D8675ACE67`; pyramid transform hash `0x790D7C28`, event hash `0xE8B80415`, state hash `0xE4E1D89FFCE4C43E`.
Built with `-mfma -ffp-contract=fast` instead, an optimized x86_64 build gives 256 / `0xD513C6E1` and `0x1F3DD87B`:
the flag is what the hashes hold.

## Verification

Here, with the CI-pinned mcpp 2026.10.26.1:

- `bash tests/run_members.sh box2d-physics`: the real release archive through the candidate index; a box that falls,
rests and sleeps, a ray cast, the macros and constants, and the struck pyramid's hash `0x790D7C28` in a consumer
compiled without any floating-point flag; and the side header beside the import: `test result ok`.
- `python3 tests/mwrap/check_installed_template.py --package mcpplibs.mwrap.box2d@3.2.0 --deploy`: the unmodified
default `physics` template from the real archive, then a relocated self-contained bundle whose `licenses/box2d/`
holds the wrapper's LICENSE/NOTICE and Box2D's README and LICENSE byte for byte.

## Motivation

openmui's game physics (mgame.physics) needs a deterministic 2D engine whose results are the same on every row of
its CI, the Web included, with pinned hashes; Box2D v3 is that engine, and this module is how openmui imports it.
5 changes: 5 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1638,6 +1638,11 @@ jobs:
shell: bash
run: python3 tests/mwrap/check_installed_template.py --mcpp "$MCPP" --package mcpplibs.mwrap.sheenbidi@3.0.0 --deploy

- name: Installed Box2D wrapper archive, actual default physics template and its relocated notices
if: contains(format(' {0} ', env.MEMBERS), ' box2d-physics ') && matrix.toolchain == 'default'
shell: bash
run: python3 tests/mwrap/check_installed_template.py --mcpp "$MCPP" --package mcpplibs.mwrap.box2d@3.2.0 --deploy

- name: Installed miniaudio wrapper archive, actual default tone template and its relocated notices
if: contains(format(' {0} ', env.MEMBERS), ' miniaudio-engine ') && matrix.toolchain == 'default'
shell: bash
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@

### Added

- **mcpplibs.mwrap.box2d 3.2.0(新包)。** box2d-m:以 C++23 模块(`import mcpplibs.mwrap.box2d;`)使用
compat.box2d 3.2.0。按上游全局名导出已安装头文件的 110 个类型、3 个不透明结构体、176 个枚举值、506 个 `B2_API` 函数、
91 个内联函数、8 个 C++ 向量运算符与 `b2_lengthUnitsPerMeter`;头文件的 13 个 `static const` 对象(内部链接)以同类型、
同初值的常量置于 `mcpplibs::mwrap::box2d`。`mcpplibs.mwrap.box2d.macros` 把 20 个宏映射为常量、5 个读取长度单位的宏
映射为函数(`B2_LINEAR_SLOP()`)、3 个 id 宏映射为 constexpr 函数;声明说明符、初始化器语法与断言留在
`<mwrap/box2d/macros.hpp>`。包装仓库的 CI(Linux x86_64 GCC/LLVM、Linux arm64 GCC/LLVM、macOS、Windows、
wasm32-emscripten)在 dev 与 release 下运行确定性场景:上游的 falling hinges(第 299 步、哈希 0x465F3DF4)与
被撞倒的 20 层金字塔(600 步,哈希 0x790D7C28),1–8 个 worker、用户任务系统、SSE2 回退,各行逐位一致。
GLOBAL 为 `mwrap-3.2.0` 发布资产,CN 为 GitCode `mcpp-res/mwrap-box2d` 镜像,sha256 一致。新增测试成员
`tests/mwrap/box2d-physics` 与已安装模板及其许可文件的部署检查。
- **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 上
Expand Down
1 change: 1 addition & 0 deletions mcpp.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ members = [
"tests/mwrap/concurrentqueue-mpmc",
"tests/mwrap/fribidi-bidi",
"tests/mwrap/sheenbidi-bidi",
"tests/mwrap/box2d-physics",
"tests/mwrap/miniaudio-engine",
"tests/mwrap/freetype-render",
"tests/mwrap/harfbuzz-shape",
Expand Down
50 changes: 50 additions & 0 deletions pkgs/mwrap/box2d.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
-- Immutable upstream-aligned mwrap source release (box2d-m mwrap-3.2.0);
-- GLOBAL and CN bytes are identical.
--
-- The wrapper is Apache-2.0; the library it links, compat.box2d (Box2D 3.2.0,
-- single precision, -ffp-contract=off), is MIT, linked statically: the package
-- deploys Box2D's README and LICENSE beside programs (runtime.deploy).
package = {
spec = "1",
namespace = "mcpplibs.mwrap",
name = "box2d",
description = "box2d-m: Box2D 3.2.0 (the 2D rigid-body physics engine, cross-platform deterministic) as C++23 modules",
licenses = { "Apache-2.0" },
repo = "https://github.com/mcpplibs/box2d-m",
type = "package",
extensions = {
autoupdate = {
tag = "mwrap-${version}",
url = "https://github.com/mcpplibs/box2d-m/releases/download/${tag}/box2d-m-${version}.tar.gz",
},
},
xpm = {
linux = {
["3.2.0"] = {
url = {
GLOBAL = "https://github.com/mcpplibs/box2d-m/releases/download/mwrap-3.2.0/box2d-m-3.2.0.tar.gz",
CN = "https://gitcode.com/mcpp-res/mwrap-box2d/releases/download/3.2.0/box2d-m-3.2.0.tar.gz",
},
sha256 = "a7b448ea45e29edfb36059f0a6c6acd1f24655deae61742f64287a71bf197572",
},
},
macosx = {
["3.2.0"] = {
url = {
GLOBAL = "https://github.com/mcpplibs/box2d-m/releases/download/mwrap-3.2.0/box2d-m-3.2.0.tar.gz",
CN = "https://gitcode.com/mcpp-res/mwrap-box2d/releases/download/3.2.0/box2d-m-3.2.0.tar.gz",
},
sha256 = "a7b448ea45e29edfb36059f0a6c6acd1f24655deae61742f64287a71bf197572",
},
},
windows = {
["3.2.0"] = {
url = {
GLOBAL = "https://github.com/mcpplibs/box2d-m/releases/download/mwrap-3.2.0/box2d-m-3.2.0.tar.gz",
CN = "https://gitcode.com/mcpp-res/mwrap-box2d/releases/download/3.2.0/box2d-m-3.2.0.tar.gz",
},
sha256 = "a7b448ea45e29edfb36059f0a6c6acd1f24655deae61742f64287a71bf197572",
},
},
},
}
11 changes: 11 additions & 0 deletions tests/mwrap/box2d-physics/mcpp.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[package]
name = "box2d-physics"
version = "3.2.0"
description = "Consume the immutable Box2D wrapper: a world that steps, collides and sleeps, a ray cast, the mapped constants and id macros, the struck-pyramid determinism hash every platform computes, the side header beside the import"

[indices]
# Use the complete candidate built-in index prepared by tests/run_members.sh.
# An explicit empty table overrides the legacy workspace redirect.

[dependencies]
"mcpplibs.mwrap.box2d" = "3.2.0"
93 changes: 93 additions & 0 deletions tests/mwrap/box2d-physics/tests/behavior.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// The installed wrapper, as a consumer uses it: only the module, no header.
// A box falls onto the ground and sleeps; a ray finds it; the mapped
// constants and id macros; and the wrapper's struck-pyramid scene, whose
// final-state hash is the one every platform computes (box2d-m pins it on
// Linux x86_64 and arm64, macOS, Windows and wasm32). Its setup is exact in
// binary, so this consumer needs no floating-point flag of its own.
import std;
import mcpplibs.mwrap.box2d;

using namespace mcpplibs::mwrap::box2d; // b2_nullBodyId, b2Vec2_zero …
namespace m = mcpplibs::mwrap::box2d::macros;

namespace {

b2BodyId ground(b2WorldId world) {
b2BodyDef def = b2DefaultBodyDef();
def.position = b2Vec2{0.0f, -1.0f};
b2BodyId body = b2CreateBody(world, &def);
b2Polygon box = b2MakeBox(40.0f, 1.0f);
b2ShapeDef shape = b2DefaultShapeDef();
b2CreatePolygonShape(body, &shape, &box);
return body;
}

// box2d-m's examples/box2d/src/scenes.hpp, pyramid(): 210 unit boxes, a
// ball at 24 m/s, sleep off, 600 steps.
std::uint32_t pyramid() {
b2WorldDef worldDef = b2DefaultWorldDef();
worldDef.enableSleep = false;
b2WorldId world = b2CreateWorld(&worldDef);
ground(world);
b2Polygon box = b2MakeBox(0.5f, 0.5f);
b2ShapeDef shapeDef = b2DefaultShapeDef();
std::vector<b2BodyId> bodies;
for (int i = 0; i < 20; ++i)
for (int j = 0; j < 20 - i; ++j) {
b2BodyDef def = b2DefaultBodyDef();
def.type = b2_dynamicBody;
def.position = b2Vec2{static_cast<float>(j) + 0.5f * static_cast<float>(i) - 9.5f, static_cast<float>(i) + 0.5f};
bodies.push_back(b2CreateBody(world, &def));
b2CreatePolygonShape(bodies.back(), &shapeDef, &box);
}
b2BodyDef ballDef = b2DefaultBodyDef();
ballDef.type = b2_dynamicBody;
ballDef.position = b2Vec2{-30.0f, 3.0f};
ballDef.linearVelocity = b2Vec2{24.0f, 4.0f};
ballDef.isBullet = true;
bodies.push_back(b2CreateBody(world, &ballDef));
b2Circle circle = {b2Vec2_zero, 1.0f};
b2ShapeDef ballShape = b2DefaultShapeDef();
ballShape.density = 8.0f;
b2CreateCircleShape(bodies.back(), &ballShape, &circle);
for (int step = 0; step < 600; ++step) b2World_Step(world, 1.0f / 60.0f, 4);
std::uint32_t hash = m::B2_HASH_INIT;
for (b2BodyId body : bodies) {
b2Transform t = b2Body_GetTransform(body);
hash = b2Hash(hash, reinterpret_cast<const std::uint8_t*>(&t), static_cast<int>(sizeof t));
}
b2DestroyWorld(world);
return hash;
}

} // namespace

int main() {
b2Version v = b2GetVersion();
if (v.major != m::B2_VERSION_MAJOR || v.minor != 2 || v.revision != 0) return 1;

b2WorldDef worldDef = b2DefaultWorldDef();
b2WorldId world = b2CreateWorld(&worldDef);
ground(world);
b2BodyDef def = b2DefaultBodyDef();
def.type = b2_dynamicBody;
def.position = b2Vec2{0.0f, 4.0f};
b2BodyId body = b2CreateBody(world, &def);
b2Polygon square = b2MakeBox(0.5f, 0.5f);
b2ShapeDef shape = b2DefaultShapeDef();
b2CreatePolygonShape(body, &shape, &square);
for (int i = 0; i < 240; ++i) b2World_Step(world, 1.0f / 60.0f, 4);
if (std::abs(b2Body_GetPosition(body).y - 0.5f) > 0.01f) return 2;
if (b2Body_IsAwake(body)) return 3;
b2RayResult hit = b2World_CastRayClosest(world, b2Vec2{0.0f, 10.0f}, b2Vec2{0.0f, -20.0f}, b2DefaultQueryFilter());
if (!hit.hit || !m::B2_ID_EQUALS(b2Shape_GetBody(hit.shapeId), body)) return 4;
if (!m::B2_IS_NULL(b2_nullBodyId) || m::B2_IS_NULL(body)) return 5;
b2DestroyWorld(world);

static_assert(m::B2_MAX_POLYGON_VERTICES == 8 && m::B2_PI == 3.14159265359f);
if (m::B2_LINEAR_SLOP() != 0.005f) return 6;

const std::uint32_t hash = pyramid();
std::println("struck pyramid, 600 steps: transform hash 0x{:08X}", hash);
return hash == 0x790D7C28u ? 0 : 7;
}
20 changes: 20 additions & 0 deletions tests/mwrap/box2d-physics/tests/side_header.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// The side header from the installed package, beside the import: the raw
// header's macros and static constants with the module's entities.
#include <mwrap/box2d/macros.hpp>

import std;
import mcpplibs.mwrap.box2d;

B2_API int mwrap_box2d_consumer(int value);
int mwrap_box2d_consumer(int value) { return value * 2; }

int main() {
namespace m = mcpplibs::mwrap::box2d::macros;
namespace b = mcpplibs::mwrap::box2d;
b2BodyId none = B2_NULL_ID;
if (!B2_IS_NULL(none) || B2_IS_NULL(none) != (m::B2_IS_NULL)(none)) return 1;
if (std::memcmp(&::b2Rot_identity, &b::b2Rot_identity, sizeof(b2Rot)) != 0) return 2;
if (&::b2World_Step != &b2World_Step) return 3;
static_assert(B2_MAX_POLYGON_VERTICES == 8 && std::is_same_v<decltype(b::b2_nullBodyId), const b2BodyId>);
return mwrap_box2d_consumer(21) == 42 ? 0 : 4;
}
20 changes: 20 additions & 0 deletions tests/mwrap/check_installed_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,26 @@ def main():
notice = (roots[0] / "NOTICE").read_bytes()
assert b"mcpp-community" in notice and b"Muhammad Tayyab Akram" in notice
print(f"Verified {len(declared)} byte-identical wrapper/SheenBidi license files", flush=True)
elif identity == "mcpplibs.mwrap.box2d":
# Box2D is MIT and linked statically: its LICENSE and README
# travel with the program beside the wrapper's LICENSE/NOTICE.
sources = [path for store in (home / "xpkgs", home / "registry/data/xpkgs")
for path in store.glob(f"mcpplibs.mwrap-x-box2d/{version}/*/src/box2d.cppm")]
assert len(sources) == 1, sources
source = sources[0].parent.parent
declared = tomllib.loads((source / "mcpp.toml").read_text(encoding="utf-8"))["runtime"]["deploy"]
roots = list(relocated.rglob("licenses/box2d"))
assert len(roots) == 1, f"Missing Box2D runtime license tree: {roots}"
for entry in declared:
notice = source / entry["from"]
copied = roots[0].parent.parent / entry["to"] / notice.name
assert copied.is_file() and copied.read_bytes() == notice.read_bytes(), copied
assert b"MIT License" in (roots[0] / "upstream/box2d/LICENSE").read_bytes()
assert b"Erin Catto" in (roots[0] / "upstream/box2d/LICENSE").read_bytes()
assert b"Apache License" in (roots[0] / "LICENSE").read_bytes()
notice = (roots[0] / "NOTICE").read_bytes()
assert b"mcpp-community" in notice and b"Erin Catto" in notice
print(f"Verified {len(declared)} byte-identical wrapper/Box2D license files", flush=True)
elif identity == "mcpplibs.mwrap.miniaudio":
# miniaudio (MIT No Attribution or the Unlicense) is linked
# statically: its LICENSE and README travel with the program
Expand Down
Loading