clone

an endless marble-race generator in the browser; the url is the seed, so every track can be shared

CONTACT EVENTS (shim round): the pack's knocks are real now

Dami committed 11ae0ef
New wasm surface, no audio (user rule — audio stays parked):
- shim.c: hit events enabled on every marble shape (userData = idx+1;
  statics stay silent and resolve as −1), world hitEventThreshold 1.5
  u/s (real knocks, not rolling contact); ph_hits flattens the step's
  b3ContactHitEvents to [idxA, idxB, approachSpeed, px, py, pz] × 6
  floats — pull-based and deterministic. Rebuilt via build_physics.sh.
- engine.ts hits(): reusable heap-view readback (HITS_CAP 256).
- endless.ts consumes per step INSIDE the step callback (events clear
  next step): marble-marble hits arm the TEMPER trigger (real contacts
  now; the 2.5-radii proximity proxy stays as the sub-threshold
  fallback), and hard marble-static knocks (≥4.5 u/s) queue pendingScars
  — the M25 world-memory feed.
- Headless test: head-on pair reports exactly the expected pair above
  threshold; hard drop reports a static knock. 210 green, check clean,
  goldens untouched (client+shim only).
commit
11ae0ef5c39a001d0a8f858fc6050b1687f6b006
parent
6c1fa38aedfe3b3ef1a0cfb861598040fcd2aa9c
author
Dami <dami.etoile@gmail.com>
6 files changed, +167 -11
Mprogress.md4+-
Msrc/client/endless.ts58++++++++++++++++++++++++++----
Msrc/client/physics/engine.ts26+++++++++++++
Msrc/client/physics/shim.c33+++++++++++++++++
Mtests/physics_test.ts55++++++++++++++++++++++++++++
Mtools/build_physics.sh2+-
M progress.md +2 -2
M src/client/endless.ts +50 -8
@@ -356,6 +356,8 @@ const BLOCK_RANGE = 4;
356356 const BLOCK_OFF = 0.25;357357 /** Braid lane mirroring engages when the rival is this close (world u). */358358 const RIVAL_MIRROR_S = 10;359+/** A marble-static knock this fast leaves a mark (M25 world memory). */360+const SCAR_SPEED = 4.5;359361 360362 // --- physics LOD (pack-scale realtime) -------------------------------------361363 //
@@ -563,6 +565,17 @@ class Session {
563565 private windT = 0;564566 /** Respawn timestamps inside the trailing 2s (M20a pile-up telemetry). */565567 private readonly respawnTimes: number[] = [];568+ /** Racers hit (marble-marble, above threshold) since their last decide569+ * — the temper trigger's REAL contact source (contact-events round). */570+ private readonly hitFlags = new Set<number>();571+ /** Hard static knocks awaiting world-memory marks (M25). */572+ private readonly pendingScars: {573+ x: number;574+ y: number;575+ z: number;576+ speed: number;577+ idx: number;578+ }[] = [];566579 /** Grudge-gated boost ignitions (M20b declustering telemetry). */567580 private rivalIgnitions = 0;568581 /** Rival HUD token flag (M20b, ?rival=1) — default off until review. */
@@ -1922,14 +1935,18 @@ class Session {
19221935 // Temper (M20b): no contact events in the shim — the deterministic19231936 // proxy is a peer inside contact distance this decision (plan19241937 // space, the same slots separation sees).1925- let contactNear = false;1926- for (const slot of this.othersScratch) {1927- const dx = slot.pos[0] - cpos[0];1928- const dy = slot.pos[1] - cpos[1];1929- const dz = slot.pos[2] - cpos[2];1930- if (dx * dx + dy * dy + dz * dz < CONTACT_NEAR * CONTACT_NEAR) {1931- contactNear = true;1932- break;1938+ // Real solver hits first (contact-events round), the proximity1939+ // proxy as the sub-threshold fallback.1940+ let contactNear = this.hitFlags.delete(r.idx);1941+ if (!contactNear) {1942+ for (const slot of this.othersScratch) {1943+ const dx = slot.pos[0] - cpos[0];1944+ const dy = slot.pos[1] - cpos[1];1945+ const dz = slot.pos[2] - cpos[2];1946+ if (dx * dx + dy * dy + dz * dz < CONTACT_NEAR * CONTACT_NEAR) {1947+ contactNear = true;1948+ break;1949+ }19331950 }19341951 }19351952 r.temperClock = temperStep(r.temperClock, FIXED_DT * K, contactNear);
@@ -2084,6 +2101,31 @@ class Session {
20842101 const liveN = this.liveCount || n;20852102 const K = liveN <= 64 ? 1 : liveN <= 256 ? 2 : liveN <= 1024 ? 4 : 8;20862103 engine.step(dt, () => {2104+ // Contact hit events (contact-events round): real knocks from the2105+ // solver, per step (events clear on the next step — read them2106+ // inside the callback). Marble-marble hits arm the TEMPER trigger2107+ // (upgrading the proximity proxy); marble-static hits above the2108+ // scar threshold queue world-memory marks (M25).2109+ const hits = engine.hits();2110+ for (let h = 0; h < hits.count; h++) {2111+ const a = hits.data[h * 6];2112+ const b = hits.data[h * 6 + 1];2113+ const speed = hits.data[h * 6 + 2];2114+ if (a >= 0 && b >= 0) {2115+ this.hitFlags.add(a);2116+ this.hitFlags.add(b);2117+ } else if (speed >= SCAR_SPEED) {2118+ const solo = a >= 0 ? a : b;2119+ this.pendingScars.push({2120+ x: hits.data[h * 6 + 3],2121+ y: hits.data[h * 6 + 4],2122+ z: hits.data[h * 6 + 5],2123+ speed,2124+ idx: solo,2125+ });2126+ if (this.pendingScars.length > 64) this.pendingScars.shift();2127+ }2128+ }20872129 // ONE wasm call for the whole pack, copied into persistent20882130 // per-racer scratch (no per-step allocation). Riders keep their20892131 // kinematic state — their slots read back zeros.
M src/client/physics/engine.ts +26 -0
@@ -6,6 +6,10 @@
66 import type { CollisionProxy } from "../../gen/endless/proxies.ts";77 88 /** The emscripten module surface the shim exports. */9+/** Hit-event readback capacity per frame (plenty: a 240-body pack rarely10+ * lands 20 threshold-beating knocks in one step). */11+const HITS_CAP = 256;12+913 export interface PhysicsModule {1014 _ph_init(gravityY: number): void;1115 _ph_begin_static(key: number): void;
@@ -94,6 +98,7 @@ export interface PhysicsModule {
9498 _ph_step(dt: number, substeps: number): void;9599 _ph_marble_state(idx: number, outPtr: number): void;96100 _ph_states(outPtr: number, n: number): void;101+ _ph_hits(outPtr: number, cap: number): number;97102 _malloc(bytes: number): number;98103 _free(ptr: number): void;99104 HEAPF64: Float64Array;
@@ -383,6 +388,8 @@ export class PhysicsEngine {
383388 * [0, n); returns a dense HEAP VIEW [i*10 .. i*10+9] = px py pz vx vy384389 * vz qx qy qz qw. The view is invalidated by the next call and by any385390 * heap growth — consume it within the step. */391+ private hitsPtr = 0;392+386393 statesAll(n: number): Float64Array {387394 if (n > this.batchCap) {388395 if (this.batchPtr !== 0) this.m._free(this.batchPtr);
@@ -396,6 +403,25 @@ export class PhysicsEngine {
396403 );397404 }398405 406+ /** Contact hit events since the last step (contact-events round):407+ * a dense HEAP VIEW of `count` × 6 floats [idxA, idxB (−1 = static),408+ * approachSpeed, px, py, pz]. Hits report only above the world's409+ * threshold (~1.5 u/s — real knocks, not rolling contact). Consume410+ * within the frame; the view dies on the next call or heap growth. */411+ hits(): { count: number; data: Float32Array } {412+ if (this.hitsPtr === 0) {413+ this.hitsPtr = this.m._malloc(HITS_CAP * 6 * 4);414+ }415+ const count = this.m._ph_hits(this.hitsPtr, HITS_CAP);416+ return {417+ count,418+ data: this.m.HEAPF32.subarray(419+ this.hitsPtr / 4,420+ this.hitsPtr / 4 + count * 6,421+ ),422+ };423+ }424+399425 state(idx = 0): MarbleState {400426 this.m._ph_marble_state(idx, this.out);401427 const h = this.m.HEAPF64;
M src/client/physics/shim.c +33 -0
@@ -35,6 +35,9 @@ void ph_init(float gravityY) {
3535 def.workerCount = 1; // determinism-friendly; perf headroom is huge3636 def.gravity = (b3Vec3){0, gravityY, 0};3737 def.enableSleep = false; // an assisted marble must never doze mid-climb38+ // Hit events fire above this approach speed (u/s): real knocks only —39+ // rolling contact and gentle rubbing stay silent.40+ def.hitEventThreshold = 1.5f;3841 world = b3CreateWorld(&def);3942 shapeDef = b3DefaultShapeDef();4043 for (int i = 0; i < MAX_REFS; i++) refUsed[i] = 0;
@@ -224,10 +227,40 @@ void ph_spawn_marble(
224227 sd.baseMaterial.restitution = 0.35f;225228 sd.baseMaterial.friction = 0.7f;226229 sd.filter.categoryBits = CAT_MARBLE;230+ // Contact events round: hit events on every marble shape (statics stay231+ // silent — a hit's partner resolves via shape userData, 0 = static).232+ sd.enableHitEvents = true;233+ sd.userData = (void*)(uintptr_t)(idx + 1);227234 marbleShapes[idx] = b3CreateSphereShape(marbles[idx], &sd, &sphere);228235 marblesAlive[idx] = 1;229236 }230237 238+// --- contact hit events -----------------------------------------------------239+//240+// After each step the world reports shape pairs that collided faster than241+// the hit threshold. The shim flattens them to 6 floats per hit:242+// [idxA, idxB (−1 = static), approachSpeed, px, py, pz]. Reading is pull-243+// based and deterministic (same sim, same hits, same order).244+EMSCRIPTEN_KEEPALIVE245+int ph_hits(float* out, int cap) {246+ b3ContactEvents ev = b3World_GetContactEvents(world);247+ int n = 0;248+ for (int i = 0; i < ev.hitCount && n < cap; i++) {249+ const b3ContactHitEvent* h = &ev.hitEvents[i];250+ int a = (int)(uintptr_t)b3Shape_GetUserData(h->shapeIdA) - 1;251+ int b = (int)(uintptr_t)b3Shape_GetUserData(h->shapeIdB) - 1;252+ if (a < 0 && b < 0) continue; // static-static noise253+ out[n * 6 + 0] = (float)a;254+ out[n * 6 + 1] = (float)b;255+ out[n * 6 + 2] = h->approachSpeed;256+ out[n * 6 + 3] = (float)h->point.x;257+ out[n * 6 + 4] = (float)h->point.y;258+ out[n * 6 + 5] = (float)h->point.z;259+ n++;260+ }261+ return n;262+}263+231264 // Respawn-immunity toggle: a ghost marble collides with the track but not232265 // with other marbles (materializing inside the pack must not detonate it).233266 // Spawn resets to solid (the shape is recreated with the default mask).
M tests/physics_test.ts +55 -0
@@ -365,6 +365,61 @@ Deno.test("scenario open fork (grav-scan-32): both lanes cross the boundary clea
365365 }366366 });367367 368+Deno.test("contact events: the shim reports real knocks, pull-based", async () => {369+ module_ = await factory();370+ const engine = new PhysicsEngine(module_!);371+ engine.addSegment(0, [372+ { kind: "slab", center: [0, -0.1, 0], half: [8, 0.1, 8], yaw: 0, pitch: 0 },373+ ]);374+ // Head-on pair well above the 1.5 u/s hit threshold.375+ engine.spawnMarble(0, [-1, 0.15, 0], [3, 0, 0]);376+ engine.spawnMarble(1, [1, 0.15, 0], [-3, 0, 0]);377+ let pair = false;378+ let pairSpeed = 0;379+ let statik = false;380+ for (let s = 0; s < 180; s++) {381+ engine.step(1 / 60, () => {382+ const h = engine.hits();383+ for (let i = 0; i < h.count; i++) {384+ const a = h.data[i * 6];385+ const b = h.data[i * 6 + 1];386+ const speed = h.data[i * 6 + 2];387+ assert(speed > 0, "hit speed must be positive");388+ if (a >= 0 && b >= 0) {389+ pair = true;390+ pairSpeed = Math.max(pairSpeed, speed);391+ // The pair is exactly our two marbles.392+ assert(393+ (a === 0 && b === 1) || (a === 1 && b === 0),394+ `unexpected pair ${a}/${b}`,395+ );396+ }397+ if (a >= 0 !== b >= 0) statik = true;398+ }399+ });400+ if (pair && statik) break;401+ }402+ if (!statik) {403+ // A hard drop guarantees a static knock above threshold.404+ engine.spawnMarble(2, [0, 2.5, 3], [0, 0, 0]);405+ for (let s = 0; s < 120 && !statik; s++) {406+ engine.step(1 / 60, () => {407+ const h = engine.hits();408+ for (let i = 0; i < h.count; i++) {409+ if (h.data[i * 6] >= 0 !== h.data[i * 6 + 1] >= 0) statik = true;410+ }411+ });412+ }413+ engine.destroyMarble(2);414+ }415+ assert(pair, "head-on marble pair never reported a hit");416+ assert(pairSpeed > 1.5, `pair hit under the threshold (${pairSpeed})`);417+ assert(statik, "no marble-static knock reported");418+ engine.destroyMarble(0);419+ engine.destroyMarble(1);420+ engine.removeSegment(0);421+});422+368423 Deno.test("scenario braid chain (grav-scan-32): 3-segment fork rides home on both lanes", async () => {369424 module_ = await factory();370425 // grav-scan-32: open fork @0, STAY-OPEN middle spanning all of @1,
M tools/build_physics.sh +1 -1