Split discontinuous braid ribbons and pack independent collision strips without joining triangles. Restrict static contacts to each racer checkpoint and adjacent checkpoints so rotated older segments cannot block the track. Preserve peer contacts and ghost behavior; export the rebuilt shim API. Regression checks cover disconnected topology, ramp/helix overlaps and both icy-braid lanes. Native browser soaks cleared the recorded failures; two drop-cup recoveries remain documented.
- commit
- 9784c75b23512b64a832ae2b4ec9e7d92619dbd3
- parent
- 6e38293cbab70130b367cac05a0adce902505bd1
- author
- Dami <dami.etoile@gmail.com>
| M | deno.json | 1 | + |
| A | docs/ | 56 | ++++++++++++++++++++ |
| M | src/ | 1 | + |
| M | src/ | 14 | +++++ |
| M | src/ | 42 | ++++++++++++-- |
| M | src/ | 32 | +++++++++++- |
| A | tests/ | 54 | +++++++++++++++++++ |
| M | tests/ | 85 | ++++++++++++++++++++++++++++++ |
| M | tools/ | 2 | +- |
@@ -3,6 +3,7 @@
33 "dev": "deno task build & deno task build:watch & deno run --allow-net --allow-read --allow-env=PORT src/server/main.ts",44 "serve": "deno run --allow-net --allow-read --allow-env=PORT src/server/main.ts",55 "build": "deno bundle --platform browser --sourcemap=linked -o static/dist/app.js src/client/main.ts",6+ "build:physics": "bash tools/build_physics.sh",67 "build:watch": "deno bundle --platform browser --sourcemap=linked --watch -o static/dist/app.js src/client/main.ts",78 "assets:import": "deno run --allow-read --allow-write --allow-env --allow-ffi tools/import_assets.ts",89 "check": "deno check src/ tools/ tests/",@@ -0,0 +1,56 @@
1+# Track stall fixes — September 30, 20262+3+Continues the unfinished diagnostic work in4+[the August recovery sample](rail-recovery-sample-2026-08-26.md).5+6+## Causes and changes7+8+**Independent braid lanes were joined by collision geometry.** A segment can9+contain two independent alternate paths. Their curves were concatenated, then10+extruded as one ribbon. The triangles connecting the end of one lane to the11+start of the other created invisible floors and walls across otherwise empty12+space. In `rail-sample-015` segment 14, that wall blocked the alternate icy lane13+and caused congestion on the main line. The shared proxy builder now breaks14+ribbons at discontinuous authored endpoints. Independent strips are packed into15+one mesh with separate vertex/triangle sets, keeping the native mesh-handle16+budget bounded without connecting the strips.17+18+**An older rotated checkpoint blocked a later ramp.** In `rail-sample-008`,19+segment 9's geometry folds into segment 11 after gravity reorientation. Removing20+only segment 9 from the six-chunk repro made the ramp/helix run clear. Changing21+traction would not fix this obstruction. Static collision categories now22+identify stream checkpoints; each live racer contacts its own checkpoint and its23+two connected neighbors. Racers still collide with one another, and respawn24+ghost filters preserve the checkpoint mask. This prevents unrelated folded25+checkpoints from blocking the active channel while preserving connected26+transitions.27+28+This changes collision behavior, not seeded layouts. Existing generation goldens29+and generator version remain unchanged. World-space visual overlaps and broad30+camera occlusion remain separate generation/presentation concerns.31+32+## Verification33+34+- All 222 Deno tests passed, including measured collision fidelity, generation35+ goldens, jump/reorientation/spur physics, multi-marble contacts and36+ determinism.37+- New regressions assert disconnected collision topology, connected-window38+ continuity, native ghost/respawn mask behavior, and both recorded layouts39+ clearing with real Box3D physics and no respawn or escape-hop fallback.40+- Browser stress test: isolated Chrome runs, 401 racers, 300 deterministic41+ simulated seconds per seed, real streaming client, diagnostics enabled.42+43+| Seed | Hero reached | Fall/stall recovery events | Recorded problem tiles |44+| ----------------- | ---------------------------- | -------------------------- | ---------------------- |45+| `rail-sample-008` | Segment 17, about 1135 units | 0 | Ramp/helix cleared |46+| `rail-sample-015` | Segment 19, about 1166 units | 2 | Icy braid cleared |47+48+Neither hero died; neither browser run produced a page/console error. Both49+remaining recovery events were at `end-hole-rounded` drop cups, outside the50+recorded braid cluster. Drop-cup congestion/recovery remains a follow-up target;51+this result does not claim every possible generated track is stall-free.52+53+The native shim must be rebuilt with `deno task build:physics` when distributing54+these changes. This task requires Emscripten and CMake; `deno task build`55+bundles the browser code separately. The local native module was rebuilt and56+used for all verification above.@@ -2011,6 +2011,7 @@ class Session {
20112011 r.cachedF = null;20122012 return;20132013 }2014+ engine.setTrackSegment(r.idx, r.nearest.seg);20142015 engine.setRestitution(r.idx, stationRestitution(rail, r.nearest.idx));20152016 const nextRail = this.railFor(r, r.nearest.seg + 1) ?? null;20162017 // M16 conjugation: on a reoriented segment the control laws run in@@ -49,6 +49,8 @@ export interface PhysicsModule {
4949 ): void;5050 _ph_destroy_marble(idx: number): void;5151 _ph_marble_ghost(idx: number, ghost: number): void;52+ /** Optional for previously cached native modules. */53+ _ph_marble_segment?(idx: number, key: number): void;5254 _ph_add_box_q(5355 cx: number,5456 cy: number,@@ -198,6 +200,7 @@ export class PhysicsEngine {
198200 private readonly restitutions = new Map<number, number>();199201 /** Per-marble ghost cache (respawn immunity; spawn resets to solid). */200202 private readonly ghosts = new Map<number, boolean>();203+ private readonly trackSegments = new Map<number, number>();201204 private disposed = false;202205 203206 constructor(private readonly m: PhysicsModule) {@@ -219,6 +222,7 @@ export class PhysicsEngine {
219222 this.hitsPtr = 0;220223 this.restitutions.clear();221224 this.ghosts.clear();225+ this.trackSegments.clear();222226 this.m._ph_dispose?.();223227 }224228 @@ -346,6 +350,7 @@ export class PhysicsEngine {
346350 if (idx === 0) this.accumulator = 0;347351 this.restitutions.set(idx, MARBLE_RESTITUTION); // shim recreates the shape348352 this.ghosts.set(idx, false);353+ this.trackSegments.delete(idx);349354 this.m._ph_spawn_marble(350355 idx,351356 pos[0],@@ -362,6 +367,15 @@ export class PhysicsEngine {
362367 this.m._ph_destroy_marble(idx);363368 this.restitutions.delete(idx);364369 this.ghosts.delete(idx);370+ this.trackSegments.delete(idx);371+ }372+373+ /** Limit track contact to this checkpoint and its connected neighbors.374+ * Ghost changes retain this mask; respawns reset the cached ownership. */375+ setTrackSegment(idx: number, key: number): void {376+ if (this.trackSegments.get(idx) === key) return;377+ this.trackSegments.set(idx, key);378+ this.m._ph_marble_segment?.(idx, key);365379 }366380 367381 /** Respawn immunity (cached): a ghost marble keeps colliding with the@@ -18,6 +18,15 @@ static b3WorldId world;
1818 static b3BodyId marbles[MAX_MARBLES];1919 static b3ShapeId marbleShapes[MAX_MARBLES];2020 static int marblesAlive[MAX_MARBLES];21+static uint64_t marbleTrackMasks[MAX_MARBLES];22+static int marbleGhosts[MAX_MARBLES];23+24+25+// 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.27+static uint64_t trackBit(int key) {28+ return 1ull << (2 + ((key % 62 + 62) % 62));29+}2130 2231 2332 @@ -83,6 +92,8 @@ void ph_begin_static(int key) {
8392 refUsed[slot] = 1;8493 refMeshCount[slot] = 0;8594 pendingSlot = slot;95+ shapeDef.filter.categoryBits = trackBit(key);96+ shapeDef.filter.maskBits = CAT_MARBLE;8697 }8798 8899 // Smooth channel ribbon: a triangle mesh extruded along the centerline@@ -170,6 +181,7 @@ void ph_add_bumper(
170181 b3HullData* cyl = b3CreateCylinder(height, radius, 0.0f, 12);171182 if (!cyl) return;172183 b3ShapeDef sd = b3DefaultShapeDef();184+ sd.filter = shapeDef.filter;173185 sd.baseMaterial.restitution = restitution;174186 sd.baseMaterial.friction = 0.4f;175187 b3Transform xf;@@ -191,6 +203,7 @@ void ph_add_bumper_q(
191203 b3HullData* cyl = b3CreateCylinder(height, radius, 0.0f, 12);192204 if (!cyl) return;193205 b3ShapeDef sd = b3DefaultShapeDef();206+ sd.filter = shapeDef.filter;194207 sd.baseMaterial.restitution = restitution;195208 sd.baseMaterial.friction = 0.4f;196209 b3Transform xf;@@ -220,12 +233,10 @@ void ph_remove_static(int key) {
220233 221234 // --- the marbles (M15: an array — peers are real bodies) -------------------222235 223-// Collision categories: statics keep the default category (0x1); marbles236+// Collision categories: each streamed track has its own bit; marbles224237 // are 0x2. A respawn-ghost marble masks out other marbles — it falls onto225238 // the track through the pack, then resolidifies (both directions stop226239 // colliding because Box3D requires the mask test to pass on BOTH shapes).227-228-229240 230241 // Spawn (or respawn) marble `idx`. World gravity stays FIXED at the init231242 // vector — per-marble gravity zones are delta forces applied by the@@ -256,6 +267,8 @@ void ph_spawn_marble(
256267 sd.userData = (void*)(uintptr_t)(idx + 1);257268 marbleShapes[idx] = b3CreateSphereShape(marbles[idx], &sd, &sphere);258269 marblesAlive[idx] = 1;270+ marbleTrackMasks[idx] = ~CAT_MARBLE;271+ marbleGhosts[idx] = 0;259272 }260273 261274 // --- contact hit events -----------------------------------------------------@@ -287,15 +300,30 @@ int ph_hits(float* out, int cap) {
287300 // Respawn-immunity toggle: a ghost marble collides with the track but not288301 // with other marbles (materializing inside the pack must not detonate it).289302 // Spawn resets to solid (the shape is recreated with the default mask).290-EMSCRIPTEN_KEEPALIVE291-void ph_marble_ghost(int idx, int ghost) {292- if (idx < 0 || idx >= MAX_MARBLES || !marblesAlive[idx]) return;303+static void updateMarbleFilter(int idx) {293304 b3Filter f = b3DefaultFilter();294305 f.categoryBits = CAT_MARBLE;295- if (ghost) f.maskBits = ~CAT_MARBLE;306+ f.maskBits = marbleTrackMasks[idx] | (marbleGhosts[idx] ? 0 : CAT_MARBLE);296307 b3Shape_SetFilter(marbleShapes[idx], f, true);297308 }298309 310+EMSCRIPTEN_KEEPALIVE311+void 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+}316+317+// 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.320+EMSCRIPTEN_KEEPALIVE321+void 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+}326+299327 EMSCRIPTEN_KEEPALIVE300328 void ph_destroy_marble(int idx) {301329 if (idx < 0 || idx >= MAX_MARBLES || !marblesAlive[idx]) return;@@ -204,6 +204,9 @@ const BOUNDARY_ERR = 0.008;
204204 /** One sampled station along the path. */205205 interface Station {206206 p: readonly [number, number, number];207+ /** This part starts a separate channel, rather than continuing the208+ * preceding part (a segment can contain two independent braid lanes). */209+ breakBefore?: boolean;207210 yaw: number;208211 pitch: number;209212 halfLen: number;@@ -328,12 +331,25 @@ export function gapSegmentsOf(
328331 * diagnostic paths drew false conclusions (the bench dropped the wide329332 * scales and under-reported five wide/funnel variants). */330333 export function segmentProxies(seg: ProxySource): CollisionProxy[] {331- return [334+ const proxies = [332335 ...channelProxies(seg.path, true, gapSegmentsOf(seg), wideScalesOf(seg)),333336 // Braid alt LANE (M17): a plain channel between the junction334337 // composites — its ribbon meets their open mouths at the ports.335338 ...(seg.altPath ? channelProxies(seg.altPath, true) : []),336339 ];340+ // Pack independent strips into one mesh without joining their rings.341+ // The native shim has a bounded mesh-handle budget per segment; two342+ // braids plus jump/drop strips can otherwise exceed it. Each strip343+ // retains its own vertices and triangles, so the gaps stay open.344+ const meshes = proxies.filter((p): p is MeshProxy => p.kind === "mesh");345+ if (meshes.length <= 1) return proxies;346+ const joined: MeshProxy = { kind: "mesh", vertices: [], indices: [] };347+ for (const mesh of meshes) {348+ const offset = joined.vertices.length / 3;349+ for (const v of mesh.vertices) joined.vertices.push(v);350+ for (const i of mesh.indices) joined.indices.push(i + offset);351+ }352+ return [joined, ...proxies.filter((p) => p.kind !== "mesh")];337353 }338354 339355 /** Channel slabs for one segment's world-space path. Two passes: sample@@ -347,6 +363,7 @@ export function channelProxies(
347363 gapSegments?: ReadonlySet<number>,348364 wideScales?: ReadonlyMap<number, readonly [number, number]>,349365 ): CollisionProxy[] {366+ let previousEnd: readonly [number, number, number] | null = null;350367 const sampleParts = (351368 parts: LoopPath["segments"][number]["curve"],352369 gap: boolean,@@ -360,6 +377,14 @@ export function channelProxies(
360377 : 1;361378 let travelled = 0;362379 for (const part of parts) {380+ const start = segPoint(part, 0);381+ const disconnected = previousEnd !== null &&382+ Math.hypot(383+ start[0] - previousEnd[0],384+ start[1] - previousEnd[1],385+ start[2] - previousEnd[2],386+ ) > 1e-4;387+ previousEnd = segPoint(part, 1);363388 const len = segLength(part);364389 // Curvature-aware sampling: arcs bound the chord-sagitta error at365390 // their OUTER channel boundary (inner boundary chords cut inward@@ -412,6 +437,7 @@ export function channelProxies(
412437 );413438 into.push({414439 p: [p[0], p[1], p[2]],440+ ...(i === 0 && disconnected ? { breakBefore: true } : {}),415441 yaw: vertical ? 0 : Math.atan2(dx, dz),416442 pitch: vertical ? 0 : -Math.atan2(dy, horiz),417443 halfLen,@@ -527,6 +553,10 @@ export function channelProxies(
527553 };528554 for (let i = 0; i < stations.length; i++) {529555 const st = stations[i];556+ // Independent lanes must have independent meshes. Connecting their557+ // last/first rings creates an invisible floor and wall across the558+ // space between them, sometimes cutting through another live track.559+ if (st.breakBefore) flushStrip(true, true);530560 const [px, , pz] = st.p;531561 if (st.diamond) {532562 // Diamond window: the ribbon stops at both ports; the slab@@ -0,0 +1,54 @@
1+import { assert, assertEquals } from "@std/assert";2+import { channelProxies, segmentProxies } from "../src/gen/endless/proxies.ts";3+import type { LoopPath } from "../src/shared/types.ts";4+5+const separated: LoopPath = {6+ segments: [0, 10].map((x) => ({7+ placementIndex: -1,8+ curve: [{9+ kind: "line" as const,10+ a: [x, 0, 0] as const,11+ b: [x, 0, 4] as const,12+ }],13+ length: 4,14+ })),15+ totalLength: 8,16+};17+18+function noBridge(mesh: { vertices: number[]; indices: number[] }): void {19+ for (let i = 0; i < mesh.indices.length; i += 3) {20+ const xs = mesh.indices.slice(i, i + 3).map((v) => mesh.vertices[v * 3]);21+ assert(22+ Math.max(...xs) - Math.min(...xs) < 2,23+ "a triangle bridges independent lanes",24+ );25+ }26+}27+28+Deno.test("disconnected lanes emit separate ribbons with no invisible bridge", () => {29+ const meshes = channelProxies(separated).filter((p) => p.kind === "mesh");30+ assertEquals(meshes.length, 2);31+ meshes.forEach(noBridge);32+});33+34+Deno.test("connected windows retain a continuous ribbon", () => {35+ const path: LoopPath = {36+ ...separated,37+ segments: [0, 4].map((z) => ({38+ placementIndex: -1,39+ curve: [{ kind: "line", a: [0, 0, z], b: [0, 0, z + 4] }],40+ length: 4,41+ })),42+ };43+ assertEquals(channelProxies(path).filter((p) => p.kind === "mesh").length, 1);44+});45+46+Deno.test("packed segment meshes retain disconnected topology", () => {47+ const meshes = segmentProxies({48+ path: separated,49+ pieceFamilies: ["straight", "straight"],50+ windowMods: [null, null],51+ }).filter((p) => p.kind === "mesh");52+ assertEquals(meshes.length, 1);53+ noBridge(meshes[0]);54+});@@ -136,6 +136,7 @@ function driveWith(
136136 const w = new EndlessWorld(seed, catalog);137137 const engine = new PhysicsEngine(module_!);138138 const rails: Rail[] = [];139+ const stationSegments: number[] = [];139140 const fanField = new FanField();140141 let lowest = Infinity;141142 for (let i = 0; i < segments; i++) {@@ -146,6 +147,7 @@ function driveWith(
146147 // corkscrews; downstream statics really rotate).147148 if (seg.frame) attachFrames(segRail, seg.frame);148149 rails.push(segRail);150+ stationSegments.push(...segRail.pts.map(() => i));149151 let proxies = [150152 // The shared channel builder (T7 parity — same call as the client151153 // and the bench), including the braid alt lane when present.@@ -234,6 +236,7 @@ function driveWith(
234236 nearest = i;235237 }236238 }239+ engine.setTrackSegment(0, stationSegments[nearest]);237240 engine.setRestitution(0, stationRestitution(rail, nearest));238241 // Conjugate marble state through the station frame — the control239242 // laws run in plan space (the client's decide() does the same).@@ -292,9 +295,91 @@ function driveWith(
292295 mix(st.pos[2]);293296 }294297 }298+ engine.dispose();295299 return { reachedS, minY, fell, hash };296300 }297301 302+Deno.test("recorded stalls: ramp/helix and both icy braid lanes clear without respawns", async () => {303+ module_ = await factory();304+ for (305+ const [seed, segment, window, alt] of [306+ ["rail-sample-008", 11, 4, false],307+ ["rail-sample-015", 14, 0, false],308+ ["rail-sample-015", 14, 0, true],309+ ] as const310+ ) {311+ const world = new EndlessWorld(seed, catalog);312+ const rails: Rail[] = [];313+ for (let i = 0; i <= segment; i++) {314+ rails.push(railForSegment(world.get(i), alt));315+ }316+ const target = rails[segment];317+ const joined = concatRails(rails);318+ const base = rails.slice(0, segment).reduce(319+ (sum, r) => sum + r.pts.length,320+ 0,321+ );322+ const entry = target.sources!.findIndex((s) => s.window === window);323+ assert(entry >= 0, "recorded approach must still exist");324+ const spawn = joined.s[base + entry];325+ const goal = joined.s[base + target.pts.length - 2];326+ // Build the same three forward chunks as the stream, including the327+ // older rotated geometry that used to block the ramp. No escape hop328+ // or respawn is available in this driver: it must roll through.329+ const result = driveWith(seed, segment + 4, 90, goal, spawn, alt);330+ assert(331+ !result.fell && result.reachedS >= goal,332+ `${seed} ${alt ? "alt" : "main"}: reached ${result.reachedS.toFixed(1)}/${333+ goal.toFixed(1)334+ }, fell=${result.fell}`,335+ );336+ }337+});338+339+Deno.test("track contact masks survive ghost toggles and respawns", async () => {340+ const engine = new PhysicsEngine(await factory());341+ try {342+ engine.addSegment(10, [{343+ kind: "box",344+ center: [0, -0.1, 0],345+ half: [5, 0.1, 2],346+ }]);347+ // An unrelated checkpoint crosses directly through this channel.348+ engine.addSegment(8, [{349+ kind: "box",350+ center: [0, 0.5, 0],351+ half: [0.1, 0.5, 2],352+ }]);353+ for (const ghost of [false, true, false]) {354+ engine.spawnMarble(0, [-1, 0.2, 0], [3, 0, 0]);355+ engine.setTrackSegment(0, 10);356+ engine.setGhost(0, ghost);357+ for (let i = 0; i < 45; i++) engine.step(1 / 60);358+ const state = engine.state();359+ assert(360+ state.pos[0] > 0.2,361+ "unrelated checkpoint must not block the marble",362+ );363+ assert(state.pos[1] > 0, "its own track must stay solid while ghosted");364+ }365+ // Adjacent checkpoint contact must remain solid at a stream seam.366+ engine.addSegment(11, [{367+ kind: "box",368+ center: [0, 0.5, 0],369+ half: [0.1, 0.5, 2],370+ }]);371+ engine.spawnMarble(0, [-1, 0.2, 0], [3, 0, 0]);372+ engine.setTrackSegment(0, 10);373+ for (let i = 0; i < 45; i++) engine.step(1 / 60);374+ assert(375+ engine.state().pos[0] < 0,376+ "connected checkpoint geometry must still collide",377+ );378+ } finally {379+ engine.dispose();380+ }381+});382+298383 Deno.test("physics survivability: assisted marble clears early segments", async () => {299384 module_ = await factory();300385 const SEEDS = [@@ -30,7 +30,7 @@ emcc "$ROOT/src/client/physics/shim.c" \
3030 -O2 \3131 -sMODULARIZE=1 -sEXPORT_ES6=1 -sENVIRONMENT=web,node \3232 -sALLOW_MEMORY_GROWTH=1 \33- -sEXPORTED_FUNCTIONS=_ph_init,_ph_dispose,_ph_begin_static,_ph_add_box,_ph_add_box_q,_ph_add_slab,_ph_add_mesh,_ph_add_bumper,_ph_add_bumper_q,_ph_end_static,_ph_remove_static,_ph_spawn_marble,_ph_destroy_marble,_ph_marble_ghost,_ph_marble_force,_ph_marble_jump,_ph_marble_mass,_ph_marble_restitution,_ph_gravity,_ph_step,_ph_marble_state,_ph_states,_ph_hits,_malloc,_free \33+ -sEXPORTED_FUNCTIONS=_ph_init,_ph_dispose,_ph_begin_static,_ph_add_box,_ph_add_box_q,_ph_add_slab,_ph_add_mesh,_ph_add_bumper,_ph_add_bumper_q,_ph_end_static,_ph_remove_static,_ph_spawn_marble,_ph_destroy_marble,_ph_marble_ghost,_ph_marble_segment,_ph_marble_force,_ph_marble_jump,_ph_marble_mass,_ph_marble_restitution,_ph_gravity,_ph_step,_ph_marble_state,_ph_states,_ph_hits,_malloc,_free \3434 -sEXPORTED_RUNTIME_METHODS=HEAPF64,HEAPF32,HEAP32 \3535 -o "$ROOT/static/dist/box3d.mjs"3636