/** The browser and authoritative server execute the same 20 Hz Rust solver. */ export const PHYSICS_TICK_MS = 50; export const EYE_HEIGHT = { standing: 1.62, crouching: 1.27, swimming: 0.4 }; const copyBody = (body) => ({ ...body, position: [...body.position], velocity: [...body.velocity], }); const validBody = (body) => [body?.position, body?.velocity].every( (v) => Array.isArray(v) && v.length === 3 && v.every(Number.isFinite), ); export async function loadPhysics(url = "/physics.wasm?v=movement-1") { const response = await fetch(url, { cache: "no-cache" }); if (!response.ok) throw Error(`Physics module: HTTP ${response.status}`); let result; try { result = await WebAssembly.instantiateStreaming(response.clone(), {}); } catch { // Some local servers do not supply application/wasm. result = await WebAssembly.instantiate(await response.arrayBuffer(), {}); } const api = result.instance.exports; if ( api.physics_version?.() !== 1 || !api.memory || ![ "physics_input", "physics_run", "physics_output", "physics_output_len", ].every((name) => typeof api[name] === "function") ) throw Error("Incompatible physics module"); const encoder = new TextEncoder(), decoder = new TextDecoder(); return (request) => { const input = encoder.encode(JSON.stringify(request)); if (input.length > 16 * 1024 * 1024) throw Error("Physics input exceeds 16 MiB"); const pointer = api.physics_input(input.length); if (!pointer) throw Error("Physics input allocation failed"); // The allocator/solver can grow memory: never retain an old typed view. new Uint8Array(api.memory.buffer, pointer, input.length).set(input); const status = api.physics_run(); const output = JSON.parse( decoder.decode( new Uint8Array( api.memory.buffer, api.physics_output(), api.physics_output_len(), ), ), ); if (status || !validBody(output)) throw Error(output.error || "Invalid physics output"); return output; }; } /** Build only the swept collision neighbourhood, including non-solid fluids. */ export function samplePhysicsWorld(body, blocks, materials, bounds) { if (!bounds || !validBody(body)) return null; const min = [], max = []; for (let axis = 0; axis < 3; axis++) { const pos = body.position[axis], velocity = body.velocity[axis]; min[axis] = Math.floor( Math.min(pos, pos + velocity) - (axis === 1 ? 3 : 2), ); max[axis] = Math.floor( Math.max(pos, pos + velocity) + (axis === 1 ? 4 : 2), ); // Unknown chunks are not empty air. Keep authority until they arrive. if (!(axis === 1 && bounds.fullHeight) && (min[axis] < bounds.min[axis] || max[axis] > bounds.max[axis])) return null; } if (bounds.fullHeight) { // Above/below the declared world height is known void, not missing chunks. min[1] = Math.max(min[1], bounds.min[1]); max[1] = Math.min(max[1], bounds.max[1]); } if (blocks.hasSection) { for(let x=Math.floor(min[0]/16);x<=Math.floor(max[0]/16);x++) for(let y=Math.floor(min[1]/16);y<=Math.floor(max[1]/16);y++) for(let z=Math.floor(min[2]/16);z<=Math.floor(max[2]/16);z++) if(!blocks.hasSection(`${x},${y},${z}`) || blocks.readySections && !blocks.readySections.has(`${x},${y},${z}`))return null; } const records = []; for (let x = min[0]; x <= max[0]; x++) for (let y = min[1]; y <= max[1]; y++) for (let z = min[2]; z <= max[2]; z++) { const id = blocks.getAt ? blocks.getAt(x,y,z) : blocks.get(`${x},${y},${z}`); if (!id) continue; const material = materials.get(id); if (!material) return null; records.push({ pos: [x, y, z], state: material.state, collision: material.collision, }); } return { blocks: records }; } /** Fixed steps are independent of display FPS; long stalls never create a burst. */ export class PhysicsClock { constructor() { this.reset(); } reset() { this.accumulator = 0; } advance(elapsed, step) { this.accumulator += Math.max(0, Math.min(250, elapsed)); let count = 0; while (this.accumulator + 1e-7 >= PHYSICS_TICK_MS && count < 5) { this.accumulator -= PHYSICS_TICK_MS; count++; step(); } return count; } get alpha() { return Math.max(0, Math.min(1, this.accumulator / PHYSICS_TICK_MS)); } } /** Sequence-based reconciliation; physics state is never replaced by visual lerp. */ export class LocalPrediction { constructor(step, getWorld, historyLimit = 120) { this.step = step; this.getWorld = getWorld; this.historyLimit = historyLimit; this.reset(); } reset() { this.body = null; this.authority = null; this.settings = {}; this.pending = []; this.ack = -1; this.tick = -1; this.epoch = null; this.offset = [0, 0, 0]; this.correction = 0; this.waitingThrough = -1; this.suspended = false; this.preview = null; } simulate(body, input) { const world = this.getWorld(body); return world ? this.step({ body, input, world, settings: this.settings }) : null; } receive(motion, reset = false) { if ( !validBody(motion?.body) || !Number.isSafeInteger(motion.ack) || !Number.isSafeInteger(motion.tick) ) return false; if (!reset && (motion.tick < this.tick || motion.ack < this.ack)) return false; if (!reset && this.epoch !== null && (motion.epoch ?? 0) < this.epoch) return false; const epochChanged = this.epoch !== null && motion.epoch !== this.epoch; const teleport = this.authority && Math.hypot( ...motion.body.position.map((v, i) => v - this.authority.position[i]), ) > 8; const instant = reset || epochChanged || teleport || !this.body; const oldPosition = this.body?.position; if (instant) { this.pending = []; this.offset = [0, 0, 0]; this.waitingThrough = -1; } this.ack = motion.ack; this.tick = motion.tick; this.epoch = motion.epoch ?? 0; this.settings = { ...(motion.settings || {}) }; this.authority = copyBody(motion.body); this.body = copyBody(motion.body); this.pending = this.pending.filter(({ seq }) => seq > this.ack); this.suspended = this.ack < this.waitingThrough; if (!this.suspended) { for (const { input } of this.pending) { const next = this.simulate(this.body, input); if (!next) { this.suspended = true; break; } this.body = next; } } if (this.suspended) this.body = copyBody(this.authority); this.correction = oldPosition ? Math.hypot(...this.body.position.map((v, i) => v - oldPosition[i])) : 0; if (!instant && this.correction < 4) for (let i = 0; i < 3; i++) this.offset[i] += oldPosition[i] - this.body.position[i]; else this.offset = [0, 0, 0]; this.preview = null; return true; } push(seq, input) { if ( !this.body || seq <= this.ack || seq <= (this.pending.at(-1)?.seq ?? -1) ) return false; this.pending.push({ seq, input: { ...input } }); if (this.pending.length > this.historyLimit) { this.waitingThrough = this.pending.shift().seq; this.suspended = true; } if (!this.suspended) { const next = this.simulate(this.body, input); if (next) this.body = next; else this.suspended = true; } this.preview = null; return !this.suspended; } sample(alpha, dt, input) { if (!this.body) return null; const damping = Math.exp(-15 * Math.max(0, dt)); this.offset = this.offset.map((value) => value * damping); let next = this.body; if (!this.suspended) { // This partial render step is disposable and is never sent or acknowledged. const signature = JSON.stringify(input); if (this.preview?.signature !== signature) this.preview = { signature, body: this.simulate(this.body, input) }; next = this.preview?.body || this.body; } return { position: this.body.position.map( (v, i) => v + (next.position[i] - v) * alpha + this.offset[i], ), eyeHeight: EYE_HEIGHT[this.body.pose] ?? 1.62, body: this.body, }; } invalidateWorld() { this.preview = null; } }