534 lines
25 KiB
TypeScript
534 lines
25 KiB
TypeScript
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
|
|
import type { ErrorCode } from "./error";
|
|
|
|
export const PHYSICS_SIMULATION_SCHEMA = 1 as const;
|
|
export const PHYSICS_CACHE_SCHEMA = 1 as const;
|
|
export const PHYSICS_CACHE_BLENDER_VERSION_PREFIX = "5.2." as const;
|
|
export const PHYSICS_SIMULATION_BUDGET = {
|
|
maxSystems: 4_096,
|
|
maxDependenciesPerSystem: 1_024,
|
|
maxSettings: 256,
|
|
maxSettingsBytes: 64 * 1024,
|
|
maxFrames: 100_000,
|
|
maxFrameBytes: 512 * 1024 * 1024,
|
|
maxCacheBytes: 16 * 1024 * 1024 * 1024,
|
|
} as const;
|
|
|
|
export const PHYSICS_FAMILIES = [
|
|
"RIGID_BODY",
|
|
"SOFT_BODY",
|
|
"CLOTH",
|
|
"FLUID",
|
|
"DYNAMIC_PAINT",
|
|
"PARTICLE",
|
|
"HAIR",
|
|
] as const;
|
|
|
|
export type PhysicsFamily = typeof PHYSICS_FAMILIES[number];
|
|
export type PhysicsExecutionRequest = "METADATA" | "CACHE_MANIFEST" | "CACHE_PLAYBACK" | "LOCAL_SOLVER" | "SERVER_JOB";
|
|
export type PhysicsSettingValue = boolean | number | string | null;
|
|
|
|
export interface PhysicsCacheFrameIR {
|
|
frame: number;
|
|
byteOffset: number;
|
|
byteLength: number;
|
|
sha256: string;
|
|
}
|
|
|
|
export interface PhysicsCacheBindingIR {
|
|
schemaVersion: typeof PHYSICS_CACHE_SCHEMA;
|
|
cacheKey: string;
|
|
family: PhysicsFamily;
|
|
source: "BLENDER_DESKTOP_BAKE" | "BLENDER_SERVER_BAKE";
|
|
blenderVersion: string;
|
|
sourceBlendSha256: string;
|
|
settingsHash: string;
|
|
inputHash: string;
|
|
cacheSha256: string;
|
|
frameStart: number;
|
|
frameEnd: number;
|
|
byteLength: number;
|
|
frames: PhysicsCacheFrameIR[];
|
|
status: "COMPLETE" | "PARTIAL";
|
|
}
|
|
|
|
export interface PhysicsSystemIR {
|
|
id: string;
|
|
family: PhysicsFamily;
|
|
ownerObjectId: string;
|
|
settingsHash: string;
|
|
settings: Record<string, PhysicsSettingValue>;
|
|
dependencyIds: string[];
|
|
cache?: PhysicsCacheBindingIR;
|
|
}
|
|
|
|
export interface PhysicsSimulationManifestIR {
|
|
schemaVersion: typeof PHYSICS_SIMULATION_SCHEMA;
|
|
systems: PhysicsSystemIR[];
|
|
}
|
|
|
|
export interface PhysicsFamilyCapabilityIR {
|
|
family: PhysicsFamily;
|
|
metadata: "LOCAL_BOUNDED";
|
|
cacheManifest: "LOCAL_BOUNDED";
|
|
cachePlayback: "BLOCKED";
|
|
localSolver: "READY" | "BLOCKED";
|
|
solverProbe: PhysicsSolverProbeStatus;
|
|
unsupportedRoute: "DESKTOP_SERVER_BAKE";
|
|
serverJob: "BLOCKED";
|
|
}
|
|
|
|
export type PhysicsSolverProbeStatus =
|
|
| "READY"
|
|
| "EXPORT_UNAVAILABLE"
|
|
| "INITIALIZATION_FAILED"
|
|
| "THREADS_UNAVAILABLE"
|
|
| "MEMORY_UNAVAILABLE"
|
|
| "INVALID_RESULT";
|
|
|
|
export interface PhysicsSolverProbeEnvironmentIR {
|
|
threadMode: "SINGLE" | "PTHREAD";
|
|
memoryLimitBytes: number;
|
|
}
|
|
|
|
export interface PhysicsSolverInitializationIR {
|
|
initialized: boolean;
|
|
requiredThreadMode: "SINGLE" | "PTHREAD";
|
|
requiredMemoryBytes: number;
|
|
}
|
|
|
|
export interface PhysicsSolverRuntimeProbe {
|
|
hasFamilyExport(family: PhysicsFamily): boolean;
|
|
initializeFamily(family: PhysicsFamily): PhysicsSolverInitializationIR | Promise<PhysicsSolverInitializationIR>;
|
|
}
|
|
|
|
export interface PhysicsExecutionRouteIR {
|
|
family: PhysicsFamily;
|
|
mode: "LOCAL_SOLVER" | "DESKTOP_SERVER_BAKE";
|
|
probe: PhysicsSolverProbeStatus;
|
|
}
|
|
|
|
export interface BrowserTransformCacheObjectIR {
|
|
objectId: string;
|
|
translation: [number, number, number];
|
|
rotationQuaternion: [number, number, number, number];
|
|
scale: [number, number, number];
|
|
}
|
|
|
|
export interface BrowserTransformCacheFrameIR {
|
|
schemaVersion: 1;
|
|
frame: number;
|
|
objects: BrowserTransformCacheObjectIR[];
|
|
}
|
|
|
|
export class PhysicsSimulationValidationError extends Error {
|
|
readonly code: ErrorCode;
|
|
|
|
constructor(code: ErrorCode, message: string) {
|
|
super(`${code}: ${message}`);
|
|
this.name = "PhysicsSimulationValidationError";
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
const SHA256 = /^[a-f0-9]{64}$/;
|
|
const CACHE_KEY = /^[A-Za-z0-9._:-]{1,256}$/;
|
|
|
|
function record(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function text(value: unknown, name: string, prefix?: string): string {
|
|
if (typeof value !== "string" || value.length === 0 || value.length > 256 || (prefix && !value.startsWith(prefix))) {
|
|
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `${name} is invalid`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function digest(value: unknown, name: string): string {
|
|
if (typeof value !== "string" || !SHA256.test(value)) {
|
|
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `${name} must be a lowercase SHA-256 digest`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function integer(value: unknown, name: string, minimum = 0, maximum = Number.MAX_SAFE_INTEGER): number {
|
|
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
|
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `${name} is outside its integer range`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function exactKeys(value: Record<string, unknown>, allowed: readonly string[], name: string): void {
|
|
const allowedSet = new Set(allowed);
|
|
if (Object.keys(value).some((key) => !allowedSet.has(key))) {
|
|
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `${name} contains undeclared fields`);
|
|
}
|
|
}
|
|
|
|
function frame(value: unknown, name: string): number {
|
|
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < -1_000_000 || value > 1_000_000) {
|
|
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `${name} is outside the supported frame range`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function parseSettings(value: unknown, systemIndex: number): Record<string, PhysicsSettingValue> {
|
|
if (!record(value)) throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `systems[${systemIndex}].settings must be an object`);
|
|
const entries = Object.entries(value);
|
|
if (entries.length > PHYSICS_SIMULATION_BUDGET.maxSettings) {
|
|
throw new PhysicsSimulationValidationError("PHYSICS_BUDGET_EXCEEDED", `systems[${systemIndex}].settings exceeds the entry budget`);
|
|
}
|
|
const settings: Record<string, PhysicsSettingValue> = {};
|
|
for (const [name, item] of entries) {
|
|
if (name.length === 0 || name.length > 128 || !/^[A-Za-z0-9_.:-]+$/.test(name) ||
|
|
!(item === null || typeof item === "boolean" || typeof item === "string" || (typeof item === "number" && Number.isFinite(item)))) {
|
|
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `systems[${systemIndex}].settings.${name} is invalid`);
|
|
}
|
|
if (typeof item === "string" && item.length > 1_024) {
|
|
throw new PhysicsSimulationValidationError("PHYSICS_BUDGET_EXCEEDED", `systems[${systemIndex}].settings.${name} exceeds the string budget`);
|
|
}
|
|
settings[name] = item;
|
|
}
|
|
if (new TextEncoder().encode(JSON.stringify(settings)).byteLength > PHYSICS_SIMULATION_BUDGET.maxSettingsBytes) {
|
|
throw new PhysicsSimulationValidationError("PHYSICS_BUDGET_EXCEEDED", `systems[${systemIndex}].settings exceeds the byte budget`);
|
|
}
|
|
return settings;
|
|
}
|
|
|
|
function parseCache(
|
|
value: unknown,
|
|
settingsHash: string,
|
|
family: PhysicsFamily,
|
|
systemIndex: number,
|
|
): PhysicsCacheBindingIR | undefined {
|
|
if (value === undefined) return undefined;
|
|
if (!record(value)) {
|
|
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `systems[${systemIndex}].cache is invalid`);
|
|
}
|
|
exactKeys(value, [
|
|
"schemaVersion", "cacheKey", "family", "source", "blenderVersion", "sourceBlendSha256",
|
|
"settingsHash", "inputHash", "cacheSha256", "frameStart", "frameEnd", "byteLength",
|
|
"frames", "status",
|
|
], `systems[${systemIndex}].cache`);
|
|
if (value.schemaVersion !== PHYSICS_CACHE_SCHEMA) {
|
|
throw new PhysicsSimulationValidationError("PROTOCOL_MISMATCH", `systems[${systemIndex}].cache has an unsupported schema`);
|
|
}
|
|
if (value.family !== family ||
|
|
(value.source !== "BLENDER_DESKTOP_BAKE" && value.source !== "BLENDER_SERVER_BAKE") ||
|
|
!CACHE_KEY.test(String(value.cacheKey ?? "")) ||
|
|
(value.status !== "COMPLETE" && value.status !== "PARTIAL")) {
|
|
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `systems[${systemIndex}].cache is invalid`);
|
|
}
|
|
const blenderVersion = text(value.blenderVersion, `systems[${systemIndex}].cache.blenderVersion`);
|
|
if (!blenderVersion.startsWith(PHYSICS_CACHE_BLENDER_VERSION_PREFIX)) {
|
|
throw new PhysicsSimulationValidationError("PROTOCOL_MISMATCH", `Physics cache requires Blender ${PHYSICS_CACHE_BLENDER_VERSION_PREFIX}x`);
|
|
}
|
|
const frameStart = frame(value.frameStart, `systems[${systemIndex}].cache.frameStart`);
|
|
const frameEnd = frame(value.frameEnd, `systems[${systemIndex}].cache.frameEnd`);
|
|
const byteLength = integer(value.byteLength, `systems[${systemIndex}].cache.byteLength`, 1);
|
|
if (byteLength > PHYSICS_SIMULATION_BUDGET.maxCacheBytes) {
|
|
throw new PhysicsSimulationValidationError("PHYSICS_BUDGET_EXCEEDED", `systems[${systemIndex}].cache exceeds the byte budget`);
|
|
}
|
|
if (frameEnd < frameStart || frameEnd - frameStart + 1 > PHYSICS_SIMULATION_BUDGET.maxFrames || !Array.isArray(value.frames)) {
|
|
throw new PhysicsSimulationValidationError("PHYSICS_BUDGET_EXCEEDED", `systems[${systemIndex}].cache frame range exceeds the budget`);
|
|
}
|
|
if (value.frames.length === 0 || value.frames.length > PHYSICS_SIMULATION_BUDGET.maxFrames) {
|
|
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `systems[${systemIndex}].cache frames are invalid`);
|
|
}
|
|
let nextOffset = 0;
|
|
const frames = value.frames.map((item, frameIndex): PhysicsCacheFrameIR => {
|
|
if (!record(item)) {
|
|
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `systems[${systemIndex}].cache.frames[${frameIndex}] is invalid`);
|
|
}
|
|
exactKeys(item, ["frame", "byteOffset", "byteLength", "sha256"], `systems[${systemIndex}].cache.frames[${frameIndex}]`);
|
|
const frameNumber = frame(item.frame, `systems[${systemIndex}].cache.frames[${frameIndex}].frame`);
|
|
const byteOffset = integer(item.byteOffset, `systems[${systemIndex}].cache.frames[${frameIndex}].byteOffset`);
|
|
const frameByteLength = integer(item.byteLength, `systems[${systemIndex}].cache.frames[${frameIndex}].byteLength`, 1);
|
|
if (frameByteLength > PHYSICS_SIMULATION_BUDGET.maxFrameBytes) {
|
|
throw new PhysicsSimulationValidationError("PHYSICS_BUDGET_EXCEEDED", `systems[${systemIndex}].cache.frames[${frameIndex}] exceeds the byte budget`);
|
|
}
|
|
if (frameNumber < frameStart || frameNumber > frameEnd ||
|
|
byteOffset !== nextOffset || byteOffset > byteLength - frameByteLength) {
|
|
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `systems[${systemIndex}].cache.frames[${frameIndex}] is not ordered or contiguous`);
|
|
}
|
|
nextOffset += frameByteLength;
|
|
return {
|
|
frame: frameNumber,
|
|
byteOffset,
|
|
byteLength: frameByteLength,
|
|
sha256: digest(item.sha256, `systems[${systemIndex}].cache.frames[${frameIndex}].sha256`),
|
|
};
|
|
});
|
|
if (nextOffset !== byteLength) {
|
|
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `systems[${systemIndex}].cache frame ranges do not cover the payload`);
|
|
}
|
|
if (frames.some((item, index) => index > 0 && item.frame <= frames[index - 1].frame)) {
|
|
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `systems[${systemIndex}].cache frames must be unique, ordered and in range`);
|
|
}
|
|
if (value.status === "COMPLETE" && (frames.length !== frameEnd - frameStart + 1 || frames.some((item, index) => item.frame !== frameStart + index))) {
|
|
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `systems[${systemIndex}] declares an incomplete cache as COMPLETE`);
|
|
}
|
|
const cacheSettingsHash = digest(value.settingsHash, `systems[${systemIndex}].cache.settingsHash`);
|
|
if (cacheSettingsHash !== settingsHash) {
|
|
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `systems[${systemIndex}] cache settings do not match the current system`);
|
|
}
|
|
return {
|
|
schemaVersion: PHYSICS_CACHE_SCHEMA,
|
|
cacheKey: value.cacheKey as string,
|
|
family,
|
|
source: value.source,
|
|
blenderVersion,
|
|
sourceBlendSha256: digest(value.sourceBlendSha256, `systems[${systemIndex}].cache.sourceBlendSha256`),
|
|
settingsHash: cacheSettingsHash,
|
|
inputHash: digest(value.inputHash, `systems[${systemIndex}].cache.inputHash`),
|
|
cacheSha256: digest(value.cacheSha256, `systems[${systemIndex}].cache.cacheSha256`),
|
|
frameStart,
|
|
frameEnd,
|
|
byteLength,
|
|
frames,
|
|
status: value.status,
|
|
};
|
|
}
|
|
|
|
export function parsePhysicsSimulationManifest(value: unknown): PhysicsSimulationManifestIR {
|
|
if (!record(value) || value.schemaVersion !== PHYSICS_SIMULATION_SCHEMA || !Array.isArray(value.systems)) {
|
|
throw new PhysicsSimulationValidationError("PROTOCOL_MISMATCH", "Unsupported PhysicsSimulation manifest schema");
|
|
}
|
|
if (value.systems.length > PHYSICS_SIMULATION_BUDGET.maxSystems) {
|
|
throw new PhysicsSimulationValidationError("PHYSICS_BUDGET_EXCEEDED", "Physics system count exceeds the budget");
|
|
}
|
|
const ids = new Set<string>();
|
|
const systems = value.systems.map((item, index): PhysicsSystemIR => {
|
|
if (!record(item) || !PHYSICS_FAMILIES.includes(item.family as PhysicsFamily) || !Array.isArray(item.dependencyIds)) {
|
|
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `systems[${index}] is invalid`);
|
|
}
|
|
const id = text(item.id, `systems[${index}].id`, "physics:");
|
|
if (ids.has(id)) throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `Duplicate physics system ${id}`);
|
|
ids.add(id);
|
|
if (item.dependencyIds.length > PHYSICS_SIMULATION_BUDGET.maxDependenciesPerSystem) {
|
|
throw new PhysicsSimulationValidationError("PHYSICS_BUDGET_EXCEEDED", `systems[${index}].dependencyIds exceeds the budget`);
|
|
}
|
|
const dependencyIds = item.dependencyIds.map((dependency, dependencyIndex) =>
|
|
text(dependency, `systems[${index}].dependencyIds[${dependencyIndex}]`));
|
|
if (new Set(dependencyIds).size !== dependencyIds.length) {
|
|
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `systems[${index}] contains duplicate dependencies`);
|
|
}
|
|
const family = item.family as PhysicsFamily;
|
|
const settingsHash = digest(item.settingsHash, `systems[${index}].settingsHash`);
|
|
return {
|
|
id,
|
|
family,
|
|
ownerObjectId: text(item.ownerObjectId, `systems[${index}].ownerObjectId`, "object:"),
|
|
settingsHash,
|
|
settings: parseSettings(item.settings, index),
|
|
dependencyIds,
|
|
cache: parseCache(item.cache, settingsHash, family, index),
|
|
};
|
|
});
|
|
|
|
const byId = new Map(systems.map((system) => [system.id, system]));
|
|
const active = new Set<string>();
|
|
const complete = new Set<string>();
|
|
const visit = (id: string): void => {
|
|
if (active.has(id)) throw new PhysicsSimulationValidationError("PHYSICS_DEPENDENCY_CYCLE", `Physics dependency cycle includes ${id}`);
|
|
if (complete.has(id)) return;
|
|
active.add(id);
|
|
for (const dependency of byId.get(id)?.dependencyIds ?? []) if (byId.has(dependency)) visit(dependency);
|
|
active.delete(id);
|
|
complete.add(id);
|
|
};
|
|
for (const system of systems) visit(system.id);
|
|
return { schemaVersion: PHYSICS_SIMULATION_SCHEMA, systems };
|
|
}
|
|
|
|
export function physicsCapabilityInventory(): PhysicsFamilyCapabilityIR[] {
|
|
return PHYSICS_FAMILIES.map((family) => ({
|
|
family,
|
|
metadata: "LOCAL_BOUNDED",
|
|
cacheManifest: "LOCAL_BOUNDED",
|
|
cachePlayback: "BLOCKED",
|
|
localSolver: "BLOCKED",
|
|
solverProbe: "EXPORT_UNAVAILABLE",
|
|
unsupportedRoute: "DESKTOP_SERVER_BAKE",
|
|
serverJob: "BLOCKED",
|
|
}));
|
|
}
|
|
|
|
function solverCapability(family: PhysicsFamily, status: PhysicsSolverProbeStatus): PhysicsFamilyCapabilityIR {
|
|
return {
|
|
family,
|
|
metadata: "LOCAL_BOUNDED",
|
|
cacheManifest: "LOCAL_BOUNDED",
|
|
cachePlayback: "BLOCKED",
|
|
localSolver: status === "READY" ? "READY" : "BLOCKED",
|
|
solverProbe: status,
|
|
unsupportedRoute: "DESKTOP_SERVER_BAKE",
|
|
serverJob: "BLOCKED",
|
|
};
|
|
}
|
|
|
|
function validProbeEnvironment(value: PhysicsSolverProbeEnvironmentIR): boolean {
|
|
return (value.threadMode === "SINGLE" || value.threadMode === "PTHREAD") &&
|
|
Number.isSafeInteger(value.memoryLimitBytes) && value.memoryLimitBytes > 0 && value.memoryLimitBytes <= 2_147_483_648;
|
|
}
|
|
|
|
function validInitialization(value: unknown): value is PhysicsSolverInitializationIR {
|
|
return record(value) && typeof value.initialized === "boolean" &&
|
|
(value.requiredThreadMode === "SINGLE" || value.requiredThreadMode === "PTHREAD") &&
|
|
typeof value.requiredMemoryBytes === "number" && Number.isSafeInteger(value.requiredMemoryBytes) &&
|
|
value.requiredMemoryBytes > 0 && value.requiredMemoryBytes <= 2_147_483_648;
|
|
}
|
|
|
|
export async function probePhysicsSolverCapabilities(
|
|
runtime: PhysicsSolverRuntimeProbe | undefined,
|
|
environment: PhysicsSolverProbeEnvironmentIR,
|
|
): Promise<PhysicsFamilyCapabilityIR[]> {
|
|
if (!validProbeEnvironment(environment)) {
|
|
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", "Physics solver probe environment is invalid");
|
|
}
|
|
const capabilities: PhysicsFamilyCapabilityIR[] = [];
|
|
for (const family of PHYSICS_FAMILIES) {
|
|
if (!runtime) {
|
|
capabilities.push(solverCapability(family, "EXPORT_UNAVAILABLE"));
|
|
continue;
|
|
}
|
|
let hasExport = false;
|
|
try { hasExport = runtime.hasFamilyExport(family) === true; }
|
|
catch { /* A failed symbol lookup is unavailable, not implicit support. */ }
|
|
if (!hasExport) {
|
|
capabilities.push(solverCapability(family, "EXPORT_UNAVAILABLE"));
|
|
continue;
|
|
}
|
|
let initialized: unknown;
|
|
try { initialized = await runtime.initializeFamily(family); }
|
|
catch {
|
|
capabilities.push(solverCapability(family, "INITIALIZATION_FAILED"));
|
|
continue;
|
|
}
|
|
if (!validInitialization(initialized)) {
|
|
capabilities.push(solverCapability(family, "INVALID_RESULT"));
|
|
continue;
|
|
}
|
|
if (!initialized.initialized) {
|
|
capabilities.push(solverCapability(family, "INITIALIZATION_FAILED"));
|
|
continue;
|
|
}
|
|
if (initialized.requiredThreadMode === "PTHREAD" && environment.threadMode !== "PTHREAD") {
|
|
capabilities.push(solverCapability(family, "THREADS_UNAVAILABLE"));
|
|
continue;
|
|
}
|
|
if (initialized.requiredMemoryBytes > environment.memoryLimitBytes) {
|
|
capabilities.push(solverCapability(family, "MEMORY_UNAVAILABLE"));
|
|
continue;
|
|
}
|
|
capabilities.push(solverCapability(family, "READY"));
|
|
}
|
|
return capabilities;
|
|
}
|
|
|
|
export function selectPhysicsExecutionRoute(
|
|
family: PhysicsFamily,
|
|
capabilities: readonly PhysicsFamilyCapabilityIR[],
|
|
): PhysicsExecutionRouteIR {
|
|
const capability = capabilities.find((entry) => entry.family === family);
|
|
if (!capability || capability.localSolver !== "READY" || capability.solverProbe !== "READY") {
|
|
return { family, mode: "DESKTOP_SERVER_BAKE", probe: capability?.solverProbe ?? "EXPORT_UNAVAILABLE" };
|
|
}
|
|
return { family, mode: "LOCAL_SOLVER", probe: "READY" };
|
|
}
|
|
|
|
export function gatePhysicsExecution(
|
|
family: PhysicsFamily,
|
|
request: PhysicsExecutionRequest,
|
|
capabilities: readonly PhysicsFamilyCapabilityIR[] = physicsCapabilityInventory(),
|
|
): CapabilityGateResult {
|
|
if (request === "METADATA" || request === "CACHE_MANIFEST") return readyGate("N-018", `${family}_${request}`);
|
|
const route = selectPhysicsExecutionRoute(family, capabilities);
|
|
if (request === "LOCAL_SOLVER" && route.mode === "LOCAL_SOLVER") return readyGate("N-018", `${family}_${request}`);
|
|
const issue = request === "CACHE_PLAYBACK" ?
|
|
capabilityIssue("PHYSICS_CACHE_PLAYBACK_UNAVAILABLE", `${family} cache playback is not connected to frame evaluation`) :
|
|
request === "LOCAL_SOLVER" ?
|
|
capabilityIssue("PHYSICS_SOLVER_UNAVAILABLE", `${family} local WASM solver probe is ${route.probe}; use a verified desktop/server bake`) :
|
|
capabilityIssue("PHYSICS_SERVER_UNAVAILABLE", `${family} server job execution is not configured`);
|
|
return blockedGate("N-018", `${family}_${request}`, [issue]);
|
|
}
|
|
|
|
export function selectPhysicsCacheFrame(system: PhysicsSystemIR, requestedFrame: number): { cacheKey: string; frame: number } {
|
|
const cache = system.cache;
|
|
if (!Number.isSafeInteger(requestedFrame) || !cache || !cache.frames.some((item) => item.frame === requestedFrame)) {
|
|
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Physics cache has no verified frame ${requestedFrame}`);
|
|
}
|
|
return { cacheKey: cache.cacheKey, frame: requestedFrame };
|
|
}
|
|
|
|
async function sha256(value: ArrayBuffer): Promise<string> {
|
|
const hash = await crypto.subtle.digest("SHA-256", value);
|
|
return Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
}
|
|
|
|
export async function verifyPhysicsCachePayload(
|
|
system: PhysicsSystemIR,
|
|
sourceBlend: ArrayBuffer,
|
|
payload: ArrayBuffer,
|
|
): Promise<PhysicsCacheBindingIR> {
|
|
const cache = parseCache(system.cache, system.settingsHash, system.family, 0);
|
|
if (!cache) throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `Physics system ${system.id} has no cache`);
|
|
if (!(sourceBlend instanceof ArrayBuffer) || await sha256(sourceBlend) !== cache.sourceBlendSha256) {
|
|
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_SOURCE_MISMATCH", `Physics ${cache.family} cache source does not match the current blend`);
|
|
}
|
|
if (!(payload instanceof ArrayBuffer) || payload.byteLength !== cache.byteLength || await sha256(payload) !== cache.cacheSha256) {
|
|
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_HASH_MISMATCH", `Physics ${cache.family} cache payload failed SHA-256 verification`);
|
|
}
|
|
for (const cacheFrame of cache.frames) {
|
|
const bytes = payload.slice(cacheFrame.byteOffset, cacheFrame.byteOffset + cacheFrame.byteLength);
|
|
if (await sha256(bytes) !== cacheFrame.sha256) {
|
|
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_HASH_MISMATCH", `Physics ${cache.family} frame ${cacheFrame.frame} failed SHA-256 verification`);
|
|
}
|
|
}
|
|
return cache;
|
|
}
|
|
|
|
const BROWSER_TRANSFORM_CACHE_MAGIC = 0x31465442; // BTF1
|
|
const BROWSER_TRANSFORM_CACHE_HEADER_BYTES = 16;
|
|
const BROWSER_TRANSFORM_CACHE_OBJECT_BYTES = 72;
|
|
|
|
export function decodeBrowserTransformCacheFrame(value: ArrayBuffer): BrowserTransformCacheFrameIR {
|
|
if (!(value instanceof ArrayBuffer) || value.byteLength < BROWSER_TRANSFORM_CACHE_HEADER_BYTES) {
|
|
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", "Browser transform cache frame is truncated");
|
|
}
|
|
const view = new DataView(value);
|
|
if (view.getUint32(0, true) !== BROWSER_TRANSFORM_CACHE_MAGIC || view.getUint16(4, true) !== 1 || view.getUint16(6, true) !== BROWSER_TRANSFORM_CACHE_HEADER_BYTES) {
|
|
throw new PhysicsSimulationValidationError("PROTOCOL_MISMATCH", "Unsupported browser transform cache frame schema");
|
|
}
|
|
const frameNumber = view.getInt32(8, true);
|
|
const count = view.getUint32(12, true);
|
|
if (frameNumber < -1_000_000 || frameNumber > 1_000_000 || count > PHYSICS_SIMULATION_BUDGET.maxSystems || value.byteLength !== BROWSER_TRANSFORM_CACHE_HEADER_BYTES + count * BROWSER_TRANSFORM_CACHE_OBJECT_BYTES) {
|
|
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", "Browser transform cache frame length or count is invalid");
|
|
}
|
|
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
const objects: BrowserTransformCacheObjectIR[] = [];
|
|
const ids = new Set<string>();
|
|
for (let index = 0; index < count; index += 1) {
|
|
const offset = BROWSER_TRANSFORM_CACHE_HEADER_BYTES + index * BROWSER_TRANSFORM_CACHE_OBJECT_BYTES;
|
|
const idLength = view.getUint8(offset);
|
|
if (idLength === 0 || idLength > 31) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache object ${index} has an invalid ID length`);
|
|
let objectId: string;
|
|
try { objectId = decoder.decode(new Uint8Array(value, offset + 1, idLength)); }
|
|
catch { throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache object ${index} has invalid UTF-8`); }
|
|
if (!objectId.startsWith("object:") || ids.has(objectId)) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache object ${index} has an invalid or duplicate ID`);
|
|
ids.add(objectId);
|
|
const numbers = Array.from({ length: 10 }, (_, component) => view.getFloat32(offset + 32 + component * 4, true));
|
|
if (numbers.some((component) => !Number.isFinite(component))) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache object ${index} has non-finite transforms`);
|
|
const quaternionLength = Math.hypot(numbers[3], numbers[4], numbers[5], numbers[6]);
|
|
if (Math.abs(quaternionLength - 1) > 1e-3 || numbers.slice(7).some((component) => component === 0)) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache object ${index} has an invalid rotation or scale`);
|
|
objects.push({
|
|
objectId,
|
|
translation: numbers.slice(0, 3) as [number, number, number],
|
|
rotationQuaternion: numbers.slice(3, 7) as [number, number, number, number],
|
|
scale: numbers.slice(7, 10) as [number, number, number],
|
|
});
|
|
}
|
|
return { schemaVersion: 1, frame: frameNumber, objects };
|
|
}
|