import { fnv1a64 } from "./replay.mjs"; export const TASK_SCHEMA = "sim_task.v1"; export const TASK_STATES = Object.freeze(["NEW", "READY", "RUNNING", "PAUSED", "ESTOP", "FAULT", "STOPPED", "DESTROYED"]); export const TASK_MODES = Object.freeze(["MANUAL", "MDI", "AUTO"]); export const PROGRAM_STATES = Object.freeze(["idle", "loaded", "running", "paused", "completed", "aborted", "fault"]); const CORE_STATES = Object.freeze({ NEW: 0, READY: 1, RUNNING: 2, PAUSED: 3, ESTOP: 4, FAULT: 5, STOPPED: 6, DESTROYED: 7 }); const CORE_MODES = Object.freeze({ MANUAL: 0, MDI: 1, AUTO: 2 }); const CORE_PROGRAMS = Object.freeze({ idle: 0, loaded: 1, running: 2, paused: 3, completed: 4, aborted: 5, fault: 6 }); function clone(value) { return structuredClone(value); } function safeString(value) { return typeof value === "string" && value.length > 0 ? value : null; } function validInteger(value, minimum = 0) { return Number.isSafeInteger(value) && value >= minimum; } function coreCall(core, name, returnType, argTypes, args) { if (typeof core?.ccall === "function") return core.ccall(name, returnType, argTypes, args); const direct = core?.[`_${name}`] ?? core?.[name]; if (typeof direct === "function") return direct(...args); throw new Error(`missing core export: ${name}`); } function readCoreString(core, pointer, length) { const bytes = core?.HEAPU8; if (!bytes || !pointer || !length) throw new Error("task_core_memory_unavailable"); const view = bytes.subarray(pointer, pointer + length); const end = view.indexOf(0); return new TextDecoder().decode(view.subarray(0, end < 0 ? view.length : end)); } export class SimTask { constructor(core, { contextId, sessionId = null } = {}) { if (!safeString(contextId)) throw new Error("task_context_required"); this.core = core; this.contextId = contextId; this.sessionId = safeString(sessionId); this.coreTask = 0; this.coreSize = 0; this.lastError = 0; this.state = { schema: TASK_SCHEMA, context_id: contextId, session_id: this.sessionId, state: "NEW", mode: "MANUAL", program_status: "idle", seq: 0, simulation_tick: 0, current_line: null, }; } bindSession(sessionId) { if (!safeString(sessionId)) throw new Error("task_session_required"); if (this.state.seq !== 0 && this.sessionId !== sessionId) throw new Error("task_session_immutable"); this.sessionId = sessionId; this.state.session_id = sessionId; this.ensureCore(); } ensureCore() { if (this.coreTask || typeof this.core?._sim_task_size !== "function") return; if (typeof this.core?._malloc !== "function" || typeof this.core?._free !== "function") throw new Error("task_core_memory_unavailable"); if (!this.sessionId) return; this.coreSize = coreCall(this.core, "sim_task_size", "number", [], []); this.coreTask = this.core._malloc(this.coreSize); if (!this.coreTask) throw new Error("task_core_allocation_failed"); const result = coreCall(this.core, "sim_task_init", "number", ["number", "string", "string"], [this.coreTask, this.contextId, this.sessionId]); if (result !== 0) throw new Error(`task_core_init_${result}`); } mirror(name, args = [], code = "task_core_error") { this.ensureCore(); if (!this.coreTask) return; const result = coreCall(this.core, name, "number", ["number", ...args.map(() => "number")], [this.coreTask, ...args]); if (result !== 0) throw new Error(`${code}_${result}`); this.syncFromCore(); } mirrorCommand(seq, tick) { this.ensureCore(); if (!this.coreTask) return; const result = coreCall(this.core, "sim_task_set_command", "number", ["number", "number", "number"], [this.coreTask, seq, tick]); if (result !== 0) throw new Error(`task_command_${result}`); this.syncFromCore(); } syncFromCore() { if (!this.coreTask || typeof this.core?._sim_task_canonical !== "function") return false; const required = coreCall(this.core, "sim_task_canonical", "number", ["number", "number", "number"], [this.coreTask, 0, 0]); if (!Number.isSafeInteger(required) || required <= 1 || required > 4096) throw new Error("task_core_canonical_invalid"); const pointer = this.core._malloc(required); if (!pointer) throw new Error("task_core_canonical_allocation_failed"); try { const written = coreCall(this.core, "sim_task_canonical", "number", ["number", "number", "number"], [this.coreTask, pointer, required]); const canonical = JSON.parse(readCoreString(this.core, pointer, written)); if (canonical.schema !== TASK_SCHEMA || canonical.context_id !== this.contextId || canonical.session_id !== this.sessionId) throw new Error("task_core_canonical_context"); this.state = { ...this.state, state: canonical.state, mode: canonical.mode, program_status: canonical.program_status, seq: canonical.seq, simulation_tick: canonical.simulation_tick, current_line: canonical.current_line, }; return true; } finally { this.core._free(pointer); } } authority() { return this.coreTask && typeof this.core?._sim_task_canonical === "function" ? "wasm_task_canonical" : "js_compat"; } setCommand(seq, tick) { if (!validInteger(seq, 1)) throw new Error("task_sequence_invalid"); if (!validInteger(tick, 0)) throw new Error("task_tick_invalid"); if (seq !== this.state.seq + 1) throw new Error("task_sequence_gap"); if (tick < this.state.simulation_tick) throw new Error("task_tick_regression"); this.mirrorCommand(seq, tick); this.state.seq = seq; this.state.simulation_tick = tick; this.lastError = 0; } setTick(tick) { if (!validInteger(tick, 0)) throw new Error("task_tick_invalid"); if (tick < this.state.simulation_tick) throw new Error("task_tick_regression"); this.mirror("sim_task_set_tick", [tick], "task_tick"); this.state.simulation_tick = tick; } transition(next) { if (!TASK_STATES.includes(next)) throw new Error("task_state_invalid"); this.mirror("sim_task_transition", [CORE_STATES[next]], "task_transition"); this.state.state = next; } setMode(mode) { if (!TASK_MODES.includes(mode)) throw new Error("task_mode_invalid"); this.mirror("sim_task_set_mode", [CORE_MODES[mode]], "task_mode"); this.state.mode = mode; } setProgram(programStatus, currentLine = null) { if (!PROGRAM_STATES.includes(programStatus)) throw new Error("task_program_invalid"); if (currentLine !== null && !validInteger(currentLine, 0)) throw new Error("task_line_invalid"); this.mirror("sim_task_set_program", [CORE_PROGRAMS[programStatus], currentLine ?? 0xffffffff], "task_program"); this.state.program_status = programStatus; this.state.current_line = currentLine; } fault(errorCode) { this.mirror("sim_task_fault", [errorCode], "task_fault"); this.state.state = "FAULT"; this.lastError = errorCode; } estop(errorCode) { this.mirror("sim_task_estop", [errorCode], "task_estop"); this.state.state = "ESTOP"; this.lastError = errorCode; } recover() { this.mirror("sim_task_recover", [], "task_recover"); this.state.state = "READY"; this.lastError = 0; } export() { this.syncFromCore(); const unsigned = clone(this.state); return { ...unsigned, state_hash: fnv1a64(unsigned) }; } hash() { return this.export().state_hash; } assertCoreCanonical() { if (!this.coreTask) return true; const required = coreCall(this.core, "sim_task_canonical", "number", ["number", "number", "number"], [this.coreTask, 0, 0]); if (!required || required > 4096) throw new Error("task_core_canonical_invalid"); const pointer = this.core._malloc(required); if (!pointer) throw new Error("task_core_canonical_allocation_failed"); try { const written = coreCall(this.core, "sim_task_canonical", "number", ["number", "number", "number"], [this.coreTask, pointer, required]); const actual = readCoreString(this.core, pointer, written); const expected = canonicalTask(this.state); if (actual !== expected) throw new Error("task_core_canonical_mismatch"); return true; } finally { this.core._free(pointer); } } restore(value) { if (!value || value.schema !== TASK_SCHEMA || value.context_id !== this.contextId || value.session_id !== this.sessionId) throw new Error("task_restore_schema"); const unsigned = { ...value }; delete unsigned.state_hash; if (value.state_hash !== fnv1a64(unsigned)) throw new Error("task_restore_hash"); if (!TASK_STATES.includes(value.state) || !TASK_MODES.includes(value.mode) || !PROGRAM_STATES.includes(value.program_status)) throw new Error("task_restore_enum"); if (!validInteger(value.seq, 1) || !validInteger(value.simulation_tick, 0) || (value.current_line !== null && !validInteger(value.current_line, 0))) throw new Error("task_restore_range"); this.ensureCore(); if (this.coreTask) { const result = coreCall(this.core, "sim_task_restore", "number", ["number", "number", "number", "number", "number", "number", "number", "number"], [this.coreTask, CORE_STATES[value.state], CORE_MODES[value.mode], CORE_PROGRAMS[value.program_status], value.seq, value.simulation_tick, value.current_line ?? 0xffffffff, this.lastError]); if (result !== 0) throw new Error(`task_restore_core_${result}`); } this.state = { schema: TASK_SCHEMA, context_id: value.context_id, session_id: value.session_id, state: value.state, mode: value.mode, program_status: value.program_status, seq: value.seq, simulation_tick: value.simulation_tick, current_line: value.current_line }; this.assertCoreCanonical(); return this.export(); } destroy() { if (this.coreTask && typeof this.core?._free === "function") this.core._free(this.coreTask); this.coreTask = 0; this.state.state = "DESTROYED"; } } export function canonicalTask(state) { return `{"context_id":${JSON.stringify(state.context_id)},"current_line":${state.current_line === null ? "null" : state.current_line},"mode":${JSON.stringify(state.mode)},"program_status":${JSON.stringify(state.program_status)},"schema":"sim_task.v1","seq":${state.seq},"session_id":${JSON.stringify(state.session_id)},"simulation_tick":${state.simulation_tick},"state":${JSON.stringify(state.state)}}`; }