1// The marbles ↔ Box3D shim (M10). Keep the JS surface TINY: the game2// uploads static collision hulls per stream segment (boxes are enough —3// channel floors/walls/island blocks; curved floors are short box runs4// sampled along the centerline), owns one dynamic marble sphere, steps a5// fixed clock, and reads the marble transform back. Segment lifetime6// mirrors the render window: one static body per ref, destroyed on cull.7//8// Built by tools/build_physics.sh against the pinned box3d commit (see9// tools/physics-spike/README.md). Exported via EXPORTED_FUNCTIONS.101112131415static b3WorldId world;161718static b3BodyId marbles[MAX_MARBLES];19static b3ShapeId marbleShapes[MAX_MARBLES];20static int marblesAlive[MAX_MARBLES];21static uint64_t marbleTrackMasks[MAX_MARBLES];22static int marbleGhosts[MAX_MARBLES];232425// The six-segment streaming window fits comfortably in 62 track bits.26// Reusing a bit after 62 checkpoints is safe once the old chunk is culled.27static uint64_t trackBit(int key) {28 return 1ull << (2 + ((key % 62 + 62) % 62));29}30313233static int refKeys[MAX_REFS];34static b3BodyId refBodies[MAX_REFS];35static int refUsed[MAX_REFS];36static b3MeshData* refMeshes[MAX_REFS][MAX_MESHES];37static int refMeshCount[MAX_REFS];38static b3ShapeDef shapeDef;39static int pendingSlot = -1;4041EMSCRIPTEN_KEEPALIVE42void ph_dispose(void) {43 if (B3_IS_NULL(world)) return;44 // World teardown releases bodies/shapes first; mesh-data handles are45 // caller-owned and are destroyed after no shape references remain.46 b3DestroyWorld(world);47 for (int i = 0; i < MAX_REFS; i++) {48 if (refUsed[i]) {49 for (int j = 0; j < refMeshCount[i]; j++) {50 b3DestroyMesh(refMeshes[i][j]);51 }52 }53 refMeshCount[i] = 0;54 refUsed[i] = 0;55 }56 for (int i = 0; i < MAX_MARBLES; i++) marblesAlive[i] = 0;57 pendingSlot = -1;58 world = b3_nullWorldId;59}6061EMSCRIPTEN_KEEPALIVE62void ph_init(float gravityY) {63 // The shim owns one global world. Reinitialization without teardown leaks64 // every body, mesh and allocator from the previous same-page seed.65 ph_dispose();66 b3WorldDef def = b3DefaultWorldDef();67 def.workerCount = 1; // determinism-friendly; perf headroom is huge68 def.gravity = (b3Vec3){0, gravityY, 0};69 def.enableSleep = false; // an assisted marble must never doze mid-climb70 // Hit events fire above this approach speed (u/s): real knocks only —71 // rolling contact and gentle rubbing stay silent.72 def.hitEventThreshold = 1.5f;73 world = b3CreateWorld(&def);74 shapeDef = b3DefaultShapeDef();75 for (int i = 0; i < MAX_REFS; i++) refUsed[i] = 0;76 for (int i = 0; i < MAX_MARBLES; i++) marblesAlive[i] = 0;77}7879// --- static geometry, one body per stream ref ------------------------------8081EMSCRIPTEN_KEEPALIVE82void ph_begin_static(int key) {83 int slot = -1;84 for (int i = 0; i < MAX_REFS && slot < 0; i++) {85 if (!refUsed[i]) slot = i;86 }87 if (slot < 0) return; // caller leaked refs; bounded by render window88 b3BodyDef def = b3DefaultBodyDef();89 def.type = b3_staticBody;90 refBodies[slot] = b3CreateBody(world, &def);91 refKeys[slot] = key;92 refUsed[slot] = 1;93 refMeshCount[slot] = 0;94 pendingSlot = slot;95 shapeDef.filter.categoryBits = trackBit(key);96 shapeDef.filter.maskBits = CAT_MARBLE;97}9899// Smooth channel ribbon: a triangle mesh extruded along the centerline100// (verts = xyz float triples, indices = CCW int32 triples). Box3D copies101// vertex data into its own BVH storage; the b3MeshData handle must live102// as long as the body, so it's tracked per slot.103EMSCRIPTEN_KEEPALIVE104int ph_add_mesh(float* verts, int vertCount, int* tris, int triCount) {105 if (pendingSlot < 0 || refMeshCount[pendingSlot] >= MAX_MESHES) return -1;106 b3MeshDef md = {0};107 md.vertices = (b3Vec3*)verts;108 md.indices = (int32_t*)tris;109 md.vertexCount = vertCount;110 md.triangleCount = triCount;111 md.weldVertices = true;112 md.weldTolerance = 0.001f;113 md.useMedianSplit = true; // ribbon rings are grid-like114 md.identifyEdges = true; // internal-edge smoothing: no seam catching115 b3MeshData* mesh = b3CreateMesh(&md, 0, 0);116 if (!mesh) return 0; // caller must treat this as a build error117 refMeshes[pendingSlot][refMeshCount[pendingSlot]++] = mesh;118 b3CreateMeshShape(refBodies[pendingSlot], &shapeDef, mesh,119 (b3Vec3){1, 1, 1});120 return 1;121}122123// Axis-aligned box in world space (channel slabs, island blocks); rotated124// slabs pass a quarter-turn yaw. Attached to the pending static body.125EMSCRIPTEN_KEEPALIVE126void ph_add_box(127 double cx, double cy, double cz,128 float hx, float hy, float hz,129 float yawQuarter) {130 if (pendingSlot < 0) return;131 b3Transform xf;132 xf.p = (b3Vec3){(float)cx, (float)cy, (float)cz};133 xf.q = b3MakeQuatFromAxisAngle((b3Vec3){0, 1, 0}, yawQuarter * 1.5707963f);134 b3BoxHull box = b3MakeTransformedBoxHull(hx, hy, hz, xf);135 b3CreateHullShape(refBodies[pendingSlot], &shapeDef, &box.base);136}137138// Fully-oriented box: center + local half-extents + a world quaternion —139// gravity-reoriented chunks rotate their proxies rigidly (yaw/pitch slabs140// can't express a roll).141EMSCRIPTEN_KEEPALIVE142void ph_add_box_q(143 double cx, double cy, double cz,144 float hx, float hy, float hz,145 float qx, float qy, float qz, float qw) {146 if (pendingSlot < 0) return;147 b3Transform xf;148 xf.p = (b3Vec3){(float)cx, (float)cy, (float)cz};149 xf.q = (b3Quat){{qx, qy, qz}, qw};150 b3BoxHull box = b3MakeTransformedBoxHull(hx, hy, hz, xf);151 b3CreateHullShape(refBodies[pendingSlot], &shapeDef, &box.base);152}153154// Arbitrarily-oriented slab: center + local half-extents + a basis given as155// tangent (x-ish) and up handled via yaw+pitch — used for sloped floors.156EMSCRIPTEN_KEEPALIVE157void ph_add_slab(158 double cx, double cy, double cz,159 float hx, float hy, float hz,160 float yawRad, float pitchRad) {161 if (pendingSlot < 0) return;162 b3Quat yaw = b3MakeQuatFromAxisAngle((b3Vec3){0, 1, 0}, yawRad);163 b3Quat pitch = b3MakeQuatFromAxisAngle((b3Vec3){1, 0, 0}, pitchRad);164 b3Transform xf;165 xf.p = (b3Vec3){(float)cx, (float)cy, (float)cz};166 xf.q = b3MulQuat(yaw, pitch);167 b3BoxHull box = b3MakeTransformedBoxHull(hx, hy, hz, xf);168 b3CreateHullShape(refBodies[pendingSlot], &shapeDef, &box.base);169}170171// Bouncer pin (M13): a high-restitution cylinder prop (the mushroom172// bumper). Contact restitution combines via max, so the pin bounces the173// marble regardless of its own restitution. The cylinder hull is interned174// into the world's shape database by b3CreateTransformedHullShape (the175// geometry is fully cloned), so the local hull is destroyed immediately.176EMSCRIPTEN_KEEPALIVE177void ph_add_bumper(178 double cx, double cy, double cz,179 float radius, float height, float restitution) {180 if (pendingSlot < 0) return;181 b3HullData* cyl = b3CreateCylinder(height, radius, 0.0f, 12);182 if (!cyl) return;183 b3ShapeDef sd = b3DefaultShapeDef();184 sd.filter = shapeDef.filter;185 sd.baseMaterial.restitution = restitution;186 sd.baseMaterial.friction = 0.4f;187 b3Transform xf;188 xf.p = (b3Vec3){(float)cx, (float)cy, (float)cz};189 xf.q = b3MakeQuatFromAxisAngle((b3Vec3){0, 1, 0}, 0.0f);190 b3CreateTransformedHullShape(refBodies[pendingSlot], &sd, cyl, xf,191 (b3Vec3){1, 1, 1});192 b3DestroyHull(cyl);193}194195// Bumper pin with a full world orientation (reoriented island decks stand196// their pins sideways along the local gravity).197EMSCRIPTEN_KEEPALIVE198void ph_add_bumper_q(199 double cx, double cy, double cz,200 float radius, float height, float restitution,201 float qx, float qy, float qz, float qw) {202 if (pendingSlot < 0) return;203 b3HullData* cyl = b3CreateCylinder(height, radius, 0.0f, 12);204 if (!cyl) return;205 b3ShapeDef sd = b3DefaultShapeDef();206 sd.filter = shapeDef.filter;207 sd.baseMaterial.restitution = restitution;208 sd.baseMaterial.friction = 0.4f;209 b3Transform xf;210 xf.p = (b3Vec3){(float)cx, (float)cy, (float)cz};211 xf.q = (b3Quat){{qx, qy, qz}, qw};212 b3CreateTransformedHullShape(refBodies[pendingSlot], &sd, cyl, xf,213 (b3Vec3){1, 1, 1});214 b3DestroyHull(cyl);215}216217EMSCRIPTEN_KEEPALIVE218void ph_end_static(void) {219 pendingSlot = -1;220}221222EMSCRIPTEN_KEEPALIVE223void ph_remove_static(int key) {224 for (int i = 0; i < MAX_REFS; i++) {225 if (refUsed[i] && refKeys[i] == key) {226 b3DestroyBody(refBodies[i]);227 for (int j = 0; j < refMeshCount[i]; j++) b3DestroyMesh(refMeshes[i][j]);228 refMeshCount[i] = 0;229 refUsed[i] = 0;230 }231 }232}233234// --- the marbles (M15: an array — peers are real bodies) -------------------235236// Collision categories: each streamed track has its own bit; marbles237// are 0x2. A respawn-ghost marble masks out other marbles — it falls onto238// the track through the pack, then resolidifies (both directions stop239// colliding because Box3D requires the mask test to pass on BOTH shapes).240241// Spawn (or respawn) marble `idx`. World gravity stays FIXED at the init242// vector — per-marble gravity zones are delta forces applied by the243// driver (one global vector cannot serve N marbles in different zones).244EMSCRIPTEN_KEEPALIVE245void ph_spawn_marble(246 int idx,247 double x, double y, double z,248 float vx, float vy, float vz, float radius) {249 if (idx < 0 || idx >= MAX_MARBLES) return;250 if (marblesAlive[idx]) b3DestroyBody(marbles[idx]);251 b3BodyDef def = b3DefaultBodyDef();252 def.type = b3_dynamicBody;253 def.position = (b3Pos){x, y, z};254 def.linearVelocity = (b3Vec3){vx, vy, vz};255 marbles[idx] = b3CreateBody(world, &def);256 b3Sphere sphere = {{0, 0, 0}, radius};257 b3ShapeDef sd = b3DefaultShapeDef();258 // Marble Blast: bounceRestitution 0.5 (we run a touch lower — the void259 // has no shock-absorber powerup yet), friction 0.7. Marble-marble260 // restitution combines via MAX -> 0.35, the pack-friendly range.261 sd.baseMaterial.restitution = 0.35f;262 sd.baseMaterial.friction = 0.7f;263 sd.filter.categoryBits = CAT_MARBLE;264 // Contact events round: hit events on every marble shape (statics stay265 // silent — a hit's partner resolves via shape userData, 0 = static).266 sd.enableHitEvents = true;267 sd.userData = (void*)(uintptr_t)(idx + 1);268 marbleShapes[idx] = b3CreateSphereShape(marbles[idx], &sd, &sphere);269 marblesAlive[idx] = 1;270 marbleTrackMasks[idx] = ~CAT_MARBLE;271 marbleGhosts[idx] = 0;272}273274// --- contact hit events -----------------------------------------------------275//276// After each step the world reports shape pairs that collided faster than277// the hit threshold. The shim flattens them to 6 floats per hit:278// [idxA, idxB (−1 = static), approachSpeed, px, py, pz]. Reading is pull-279// based and deterministic (same sim, same hits, same order).280EMSCRIPTEN_KEEPALIVE281int ph_hits(float* out, int cap) {282 b3ContactEvents ev = b3World_GetContactEvents(world);283 int n = 0;284 for (int i = 0; i < ev.hitCount && n < cap; i++) {285 const b3ContactHitEvent* h = &ev.hitEvents[i];286 int a = (int)(uintptr_t)b3Shape_GetUserData(h->shapeIdA) - 1;287 int b = (int)(uintptr_t)b3Shape_GetUserData(h->shapeIdB) - 1;288 if (a < 0 && b < 0) continue; // static-static noise289 out[n * 6 + 0] = (float)a;290 out[n * 6 + 1] = (float)b;291 out[n * 6 + 2] = h->approachSpeed;292 out[n * 6 + 3] = (float)h->point.x;293 out[n * 6 + 4] = (float)h->point.y;294 out[n * 6 + 5] = (float)h->point.z;295 n++;296 }297 return n;298}299300// Respawn-immunity toggle: a ghost marble collides with the track but not301// with other marbles (materializing inside the pack must not detonate it).302// Spawn resets to solid (the shape is recreated with the default mask).303static void updateMarbleFilter(int idx) {304 b3Filter f = b3DefaultFilter();305 f.categoryBits = CAT_MARBLE;306 f.maskBits = marbleTrackMasks[idx] | (marbleGhosts[idx] ? 0 : CAT_MARBLE);307 b3Shape_SetFilter(marbleShapes[idx], f, true);308}309310EMSCRIPTEN_KEEPALIVE311void ph_marble_ghost(int idx, int ghost) {312 if (idx < 0 || idx >= MAX_MARBLES || !marblesAlive[idx]) return;313 marbleGhosts[idx] = ghost;314 updateMarbleFilter(idx);315}316317// Gravity rotations can fold unrelated checkpoints into the same world318// space. Contact the racer's own track and both connected neighbors;319// distant chunks must not become walls across its current channel.320EMSCRIPTEN_KEEPALIVE321void ph_marble_segment(int idx, int key) {322 if (idx < 0 || idx >= MAX_MARBLES || !marblesAlive[idx]) return;323 marbleTrackMasks[idx] = trackBit(key - 1) | trackBit(key) | trackBit(key + 1);324 updateMarbleFilter(idx);325}326327EMSCRIPTEN_KEEPALIVE328void ph_destroy_marble(int idx) {329 if (idx < 0 || idx >= MAX_MARBLES || !marblesAlive[idx]) return;330 b3DestroyBody(marbles[idx]);331 marblesAlive[idx] = 0;332}333334// Shock-absorber zones (M11): the shared rail law overrides a marble's335// restitution near hard landings and restores it after. Spawn resets to336// the 0.35 default.337EMSCRIPTEN_KEEPALIVE338void ph_marble_restitution(int idx, float restitution) {339 if (idx < 0 || idx >= MAX_MARBLES || !marblesAlive[idx]) return;340 b3Shape_SetRestitution(marbleShapes[idx], restitution);341}342343// Kept for compatibility (spike-era tools); drivers no longer tilt the344// world — gravity zones are per-marble delta forces now.345EMSCRIPTEN_KEEPALIVE346void ph_gravity(float gx, float gy, float gz) {347 b3World_SetGravity(world, (b3Vec3){gx, gy, gz});348}349350// Jump (M14b): Marble Blast's top-up — velocity along `n` is SET to351// `target`, never added (MBHaxe: jumping while already rising does352// nothing; deterministic leave-speed is what lets an autopilot PLAN a353// jump arc before committing).354EMSCRIPTEN_KEEPALIVE355void ph_marble_jump(int idx, float nx, float ny, float nz, float target) {356 if (idx < 0 || idx >= MAX_MARBLES || !marblesAlive[idx]) return;357 b3Vec3 v = b3Body_GetLinearVelocity(marbles[idx]);358 float sv = v.x * nx + v.y * ny + v.z * nz;359 if (sv >= target) return;360 float add = target - sv;361 v.x += nx * add;362 v.y += ny * add;363 v.z += nz * add;364 b3Body_SetLinearVelocity(marbles[idx], v);365}366367EMSCRIPTEN_KEEPALIVE368float ph_marble_mass(int idx) {369 if (idx < 0 || idx >= MAX_MARBLES || !marblesAlive[idx]) return 0.0f;370 return b3Body_GetMass(marbles[idx]);371}372373EMSCRIPTEN_KEEPALIVE374void ph_marble_force(int idx, float fx, float fy, float fz) {375 if (idx < 0 || idx >= MAX_MARBLES || !marblesAlive[idx]) return;376 b3Body_ApplyForceToCenter(marbles[idx], (b3Vec3){fx, fy, fz}, true);377}378379EMSCRIPTEN_KEEPALIVE380void ph_step(float dt, int substeps) {381 b3World_Step(world, dt, substeps);382}383384// Batched readback (large packs): marbles [0, n) written densely to385// `out` (n x 10 doubles; dead slots zeroed). One wasm call per step386// instead of N.387EMSCRIPTEN_KEEPALIVE388void ph_states(double* out, int n) {389 if (n > MAX_MARBLES) n = MAX_MARBLES;390 for (int i = 0; i < n; i++) {391 double* o = out + i * 10;392 if (!marblesAlive[i]) {393 for (int k = 0; k < 10; k++) o[k] = 0;394 continue;395 }396 b3Pos p = b3Body_GetPosition(marbles[i]);397 b3Vec3 v = b3Body_GetLinearVelocity(marbles[i]);398 b3WorldTransform t = b3Body_GetTransform(marbles[i]);399 o[0] = p.x;400 o[1] = p.y;401 o[2] = p.z;402 o[3] = v.x;403 o[4] = v.y;404 o[5] = v.z;405 o[6] = t.q.v.x;406 o[7] = t.q.v.y;407 o[8] = t.q.v.z;408 o[9] = t.q.s;409 }410}411412// [px py pz vx vy vz qx qy qz qw] written to `out` (10 doubles).413EMSCRIPTEN_KEEPALIVE414void ph_marble_state(int idx, double* out) {415 if (idx < 0 || idx >= MAX_MARBLES || !marblesAlive[idx]) return;416 b3Pos p = b3Body_GetPosition(marbles[idx]);417 b3Vec3 v = b3Body_GetLinearVelocity(marbles[idx]);418 b3WorldTransform t = b3Body_GetTransform(marbles[idx]);419 out[0] = p.x;420 out[1] = p.y;421 out[2] = p.z;422 out[3] = v.x;423 out[4] = v.y;424 out[5] = v.z;425 out[6] = t.q.v.x;426 out[7] = t.q.v.y;427 out[8] = t.q.v.z;428 out[9] = t.q.s;429}