import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import test from "node:test"; import ts from "typescript"; const root = path.resolve(import.meta.dirname, "../../.."); const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "physics-cache-family-unit-")); function transpile(sourceName, outputName, replacements = []) { const sourcePath = path.join(root, "web/protocol", sourceName); const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true, }); assert.deepEqual(transpiled.diagnostics, []); const output = replacements.reduce((source, [from, to]) => source.replaceAll(from, to), transpiled.outputText); fs.writeFileSync(path.join(temporary, outputName), output); } transpile("capability-gates.ts", "capability-gates.mjs"); transpile("physics-simulation.ts", "physics-simulation.mjs", [ ['from "./capability-gates"', 'from "./capability-gates.mjs"'], ]); const physics = await import(pathToFileURL(path.join(temporary, "physics-simulation.mjs"))); const digest = async (value) => Array.from( new Uint8Array(await crypto.subtle.digest("SHA-256", value)), (byte) => byte.toString(16).padStart(2, "0"), ).join(""); async function cachedSystem(family, index = 0, cachePatch = {}) { const source = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, index]).buffer; const payload = Uint8Array.from([index + 1, 2, index + 3, 4]).buffer; const first = payload.slice(0, 2); const second = payload.slice(2, 4); const settingsHash = await digest(Uint8Array.from([index + 11]).buffer); const cache = { schemaVersion: 1, cacheKey: `physics-${family.toLowerCase()}-1-2`, family, source: index % 2 === 0 ? "BLENDER_DESKTOP_BAKE" : "BLENDER_SERVER_BAKE", blenderVersion: "5.2.0", sourceBlendSha256: await digest(source), settingsHash, inputHash: await digest(Uint8Array.from([index + 21]).buffer), cacheSha256: await digest(payload), frameStart: 1, frameEnd: 2, byteLength: payload.byteLength, frames: [ { frame: 1, byteOffset: 0, byteLength: 2, sha256: await digest(first) }, { frame: 2, byteOffset: 2, byteLength: 2, sha256: await digest(second) }, ], status: "COMPLETE", ...cachePatch, }; const manifest = { schemaVersion: 1, systems: [{ id: `physics:${family.toLowerCase()}`, family, ownerObjectId: `object:${family}`, settingsHash, settings: { enabled: true }, dependencyIds: [], cache, }], }; return { manifest, source, payload }; } test("M10-14 verifies source, payload and frame hashes for every Physics family", async () => { for (const [index, family] of physics.PHYSICS_FAMILIES.entries()) { const value = await cachedSystem(family, index); const parsed = physics.parsePhysicsSimulationManifest(value.manifest); const cache = await physics.verifyPhysicsCachePayload(parsed.systems[0], value.source, value.payload); assert.equal(cache.family, family); assert.equal(cache.source, index % 2 === 0 ? "BLENDER_DESKTOP_BAKE" : "BLENDER_SERVER_BAKE"); assert.equal(cache.frames.length, 2); assert.deepEqual(physics.selectPhysicsCacheFrame(parsed.systems[0], 2), { cacheKey: cache.cacheKey, frame: 2 }); } }); test("M10-14 rejects source and cache byte drift before playback", async () => { const value = await cachedSystem("CLOTH"); const parsed = physics.parsePhysicsSimulationManifest(value.manifest); await assert.rejects( physics.verifyPhysicsCachePayload(parsed.systems[0], Uint8Array.from([1]).buffer, value.payload), { code: "PHYSICS_CACHE_SOURCE_MISMATCH" }, ); await assert.rejects( physics.verifyPhysicsCachePayload(parsed.systems[0], value.source, Uint8Array.from([9, 9, 9, 9]).buffer), { code: "PHYSICS_CACHE_HASH_MISMATCH" }, ); }); test("M10-14 rejects family, version, range, byte budget and undeclared cache fields", async () => { const value = await cachedSystem("FLUID"); const cache = value.manifest.systems[0].cache; const invalid = [ [{ ...cache, schemaVersion: 2 }, "PROTOCOL_MISMATCH"], [{ ...cache, blenderVersion: "5.3.0" }, "PROTOCOL_MISMATCH"], [{ ...cache, family: "CLOTH" }, "PHYSICS_MANIFEST_INVALID"], [{ ...cache, byteLength: physics.PHYSICS_SIMULATION_BUDGET.maxCacheBytes + 1 }, "PHYSICS_BUDGET_EXCEEDED"], [{ ...cache, frames: [{ ...cache.frames[0], byteOffset: 1 }, cache.frames[1]] }, "PHYSICS_CACHE_FRAME_MISMATCH"], [{ ...cache, frames: [cache.frames[0]] }, "PHYSICS_CACHE_FRAME_MISMATCH"], [{ ...cache, proxySuccess: true }, "PHYSICS_MANIFEST_INVALID"], ]; for (const [candidate, code] of invalid) { assert.throws(() => physics.parsePhysicsSimulationManifest({ ...value.manifest, systems: [{ ...value.manifest.systems[0], cache: candidate }], }), { code }); } }); test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));