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_SIMULATION_BUDGET = { maxSystems: 4_096, maxDependenciesPerSystem: 1_024, maxSettings: 256, maxSettingsBytes: 64 * 1024, maxFrames: 100_000, } 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 PhysicsCacheBindingIR { cacheKey: string; source: "BLENDER_DESKTOP_BAKE"; sourceBlendSha256: string; settingsHash: string; inputHash: string; cacheSha256: string; frameStart: number; frameEnd: number; cachedFrames: number[]; status: "COMPLETE" | "PARTIAL"; } export interface PhysicsSystemIR { id: string; family: PhysicsFamily; ownerObjectId: string; settingsHash: string; settings: Record; 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: "BLOCKED"; serverJob: "BLOCKED"; } 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 { 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 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 { 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 = {}; 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, systemIndex: number): PhysicsCacheBindingIR | undefined { if (value === undefined) return undefined; if (!record(value) || value.source !== "BLENDER_DESKTOP_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 frameStart = frame(value.frameStart, `systems[${systemIndex}].cache.frameStart`); const frameEnd = frame(value.frameEnd, `systems[${systemIndex}].cache.frameEnd`); if (frameEnd < frameStart || frameEnd - frameStart + 1 > PHYSICS_SIMULATION_BUDGET.maxFrames || !Array.isArray(value.cachedFrames)) { throw new PhysicsSimulationValidationError("PHYSICS_BUDGET_EXCEEDED", `systems[${systemIndex}].cache frame range exceeds the budget`); } const cachedFrames = value.cachedFrames.map((item, frameIndex) => frame(item, `systems[${systemIndex}].cache.cachedFrames[${frameIndex}]`)); if (cachedFrames.length === 0 || cachedFrames.length > PHYSICS_SIMULATION_BUDGET.maxFrames || cachedFrames.some((item, index) => item < frameStart || item > frameEnd || (index > 0 && item <= cachedFrames[index - 1]))) { throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `systems[${systemIndex}].cache frames must be unique, ordered and in range`); } if (value.status === "COMPLETE" && (cachedFrames.length !== frameEnd - frameStart + 1 || cachedFrames.some((item, index) => item !== 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 { cacheKey: value.cacheKey as string, source: "BLENDER_DESKTOP_BAKE", 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, cachedFrames, 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(); 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 settingsHash = digest(item.settingsHash, `systems[${index}].settingsHash`); return { id, family: item.family as PhysicsFamily, ownerObjectId: text(item.ownerObjectId, `systems[${index}].ownerObjectId`, "object:"), settingsHash, settings: parseSettings(item.settings, index), dependencyIds, cache: parseCache(item.cache, settingsHash, index), }; }); const byId = new Map(systems.map((system) => [system.id, system])); const active = new Set(); const complete = new Set(); 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", serverJob: "BLOCKED", })); } export function gatePhysicsExecution(family: PhysicsFamily, request: PhysicsExecutionRequest): CapabilityGateResult { if (request === "METADATA" || request === "CACHE_MANIFEST") 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} has no verified local WASM solver`) : 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.cachedFrames.includes(requestedFrame)) { throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Physics cache has no verified frame ${requestedFrame}`); } return { cacheKey: cache.cacheKey, frame: requestedFrame }; }