import { fnv1a64 } from "./replay.mjs"; const SCHEMA = "wasm_program_runtime.v1"; const SEGMENT_BYTES = 216; const POSE_BYTES = 72; const AXES = ["x", "y", "z", "a", "b", "c", "u", "v", "w"]; const DEFAULT_RAPID_VELOCITY = 1000; const PROGRAM_ERROR_REASONS = Object.freeze({ [-1100]: "wasm_program_invalid_argument", [-1101]: "wasm_program_range_error", [-1102]: "wasm_program_invalid_state", [-1103]: "wasm_program_control_error", [-1104]: "wasm_program_interpreter_error", [-1105]: "wasm_program_motion_error", }); function programError(errorPrefix, result) { const reason = PROGRAM_ERROR_REASONS[result] ?? `wasm_${errorPrefix}_${result}`; return { status: "error", code: `wasm_${errorPrefix}_${result}`, result, reason, message_key: reason, source: "linuxcnc-wasm-sim_program" }; } function modalCode(prefix, value, fallback) { return `${prefix}${Number.isFinite(Number(value)) ? Number(value) : fallback}`; } function activeGcodes(state) { const motion = { 0: "G0", 1: "G1", 2: "G2", 3: "G3" }[state.motion_mode] ?? "G1"; const spindle = { 3: "M3", 4: "M4", 5: "M5" }[state.spindle_mode] ?? `M${state.spindle_mode ?? 5}`; const feed = Number(state.feed ?? 0); const speed = Number(state.speed ?? 0); return [state.units === 20 ? "G20" : "G21", state.distance_mode === 91 ? "G91" : "G90", modalCode("G", state.plane, 17), motion, spindle, `F${Number.isFinite(feed) ? feed : 0}`, `S${Number.isFinite(speed) ? speed : 0}`].join(" "); } function call(core, name, returnType, argTypes, args) { const fn = core?.[`_${name}`] ?? core?.[name]; if (typeof fn === "function") return fn(...args); if (typeof core?.ccall === "function") return core.ccall(name, returnType, argTypes, args); throw new Error(`missing core export: ${name}`); } function pose(core, pointer) { const values = [...core.HEAPF64.subarray(pointer / 8, pointer / 8 + 9)]; return Object.fromEntries(AXES.map((axis, index) => [axis, values[index]])); } function segment(core, pointer) { const start = pose(core, pointer); const end = pose(core, pointer + 72); const cart = (offset) => ({ x: core.HEAPF64[pointer / 8 + offset / 8], y: core.HEAPF64[pointer / 8 + offset / 8 + 1], z: core.HEAPF64[pointer / 8 + offset / 8 + 2] }); const ints = new DataView(core.HEAPU8.buffer, pointer, SEGMENT_BYTES); return { start, end, center: cart(144), normal: cart(168), velocity: core.HEAPF64[pointer / 8 + 24], acceleration: core.HEAPF64[pointer / 8 + 25], motion_type: ints.getInt32(208, true), turn: ints.getInt32(212, true) }; } function sampleSegment(core, segmentPointer, posePointer, value) { const turn = Math.max(1, Math.abs(Number(value.turn ?? 1))); const count = value.motion_type === 2 ? Math.max(32, Math.ceil(turn * 32)) : 1; const samples = []; for (let index = 0; index <= count; index += 1) { const result = call(core, "sim_posemath_segment_point", "number", ["number", "number", "number"], [segmentPointer, index / count, posePointer]); if (result !== 0) throw new Error(`wasm_program_segment_sample_${result}`); samples.push(pose(core, posePointer)); } return samples; } function readString(core, pointer, capacity) { const bytes = core.HEAPU8.subarray(pointer, pointer + capacity); const end = bytes.indexOf(0); return new TextDecoder().decode(bytes.subarray(0, end < 0 ? bytes.length : end)); } export class WasmProgramRuntime { constructor(core) { this.core = core; this.available = ["sim_program_size", "sim_program_init", "sim_program_step", "sim_program_canonical", "sim_program_add_subroutine", "sim_program_set_rapid_velocity"].every((name) => typeof core?.[`_${name}`] === "function" || typeof core?.[name] === "function"); this.pointer = 0; this.motionPointer = 0; this.samplePointer = 0; this.samplePosePointer = 0; this.source = ""; this.path = null; this.subroutines = []; this.executionSource = ""; this.executionLines = []; this.rapidVelocity = 35; if (!this.available || typeof core?._malloc !== "function") return; this.pointer = core._malloc(call(core, "sim_program_size", "number", [], [])); this.motionPointer = core._malloc(SEGMENT_BYTES); this.samplePointer = core._malloc(SEGMENT_BYTES); this.samplePosePointer = core._malloc(POSE_BYTES); if (!this.pointer || !this.motionPointer || !this.samplePointer || !this.samplePosePointer) throw new Error("wasm_program_allocation_failed"); const result = call(core, "sim_program_init", "number", ["number"], [this.pointer]); if (result !== 0) throw new Error(`wasm_program_init_${result}`); } invoke(name, args = [], errorPrefix = name) { if (!this.available) return { status: "blocked", code: "wasm_program_unavailable" }; const result = call(this.core, name, "number", ["number", ...args.map(() => "number")], [this.pointer, ...args]); return result === 0 ? { status: "verified", command: name } : { status: "error", code: `wasm_${errorPrefix}_${result}`, result, reason: PROGRAM_ERROR_REASONS[result] ?? `wasm_${errorPrefix}_${result}`, message_key: PROGRAM_ERROR_REASONS[result] ?? `wasm_${errorPrefix}_${result}`, source: "linuxcnc-wasm-sim_program" }; } load(source, path = null) { if (typeof source !== "string" || source.length === 0) return { status: "error", code: "program_source_invalid" }; const bytes = new TextEncoder().encode(source); const pointer = this.core._malloc(bytes.byteLength); this.core.HEAPU8.set(bytes, pointer); try { const result = call(this.core, "sim_program_load", "number", ["number", "number", "number"], [this.pointer, pointer, bytes.byteLength]); if (result !== 0) return programError("program_load", result); const velocityResult = call(this.core, "sim_program_set_rapid_velocity", "number", ["number", "number"], [this.pointer, this.rapidVelocity]); if (velocityResult !== 0) return { status: "error", code: `wasm_program_rapid_velocity_${velocityResult}` }; this.source = source; this.path = path; this.subroutines = []; this.executionSource = source; this.executionLines = source.split(/\r?\n/); return { status: "verified", program: this.snapshot() }; } finally { this.core._free(pointer); } } requiresSubroutines(source = this.source) { if (typeof source !== "string" || source.length === 0) return { status: "error", code: "program_source_invalid" }; const bytes = new TextEncoder().encode(source); const pointer = this.core._malloc(bytes.byteLength); this.core.HEAPU8.set(bytes, pointer); try { const result = call(this.core, "sim_program_requires_subroutines", "number", ["number", "number"], [pointer, bytes.byteLength]); if (result < 0) return { status: "error", code: `wasm_program_requires_subroutines_${result}` }; return { status: "verified", required: result === 1 }; } finally { this.core._free(pointer); } } preview() { if (!this.source) return { status: "error", code: "program_preview_source_missing" }; const temporary = new WasmProgramRuntime(this.core); const segments = []; try { const loaded = temporary.load(this.source, this.path); if (loaded.status !== "verified") return loaded; temporary.setRapidVelocity(this.rapidVelocity); for (const subroutine of this.subroutines) { const added = temporary.addSubroutine(subroutine.name, subroutine.source); if (added.status !== "verified") return added; } const started = temporary.start(); if (started.status !== "verified") return started; const limit = Math.min(100000, Math.max(1, temporary.snapshot().line_count * 8 + 1)); let kinematicsMode = 0; for (let index = 0; index < limit; index += 1) { const step = temporary.step(); if (step.status !== "verified") return { status: "error", code: step.code, line: temporary.snapshot().current_line, segments }; if (step.has_control && [428, 429, 430].includes(step.control_code)) kinematicsMode = { 428: 1, 429: 0, 430: 2 }[step.control_code]; if (step.motion) segments.push({ id: segments.length + 1, kinematics_mode: kinematicsMode, ...step.motion }); if ([4, 5, 6].includes(step.program.status)) break; } return { status: "verified", schema: "wasm-canonical-preview.v1", source: "linuxcnc-wasm-sim_program", path: this.path, source_hash: fnv1a64(this.source), line_count: this.snapshot().line_count, segments, preview_hash: fnv1a64(segments) }; } finally { temporary.destroy(); } } start() { return this.invoke("sim_program_start", [], "program_start"); } setRapidVelocity(velocity) { if (!Number.isFinite(Number(velocity)) || Number(velocity) <= 0) return { status: "error", code: "program_rapid_velocity_invalid" }; this.rapidVelocity = Number(velocity); const result = call(this.core, "sim_program_set_rapid_velocity", "number", ["number", "number"], [this.pointer, this.rapidVelocity]); if (result !== 0) throw new Error(`wasm_program_set_rapid_velocity_${result}`); return { status: "verified", command: "sim_program_set_rapid_velocity" }; } addSubroutine(name, source) { if (typeof source !== "string" || source.length === 0) return { status: "error", code: "program_subroutine_invalid" }; const bytes = new TextEncoder().encode(source); const pointer = this.core._malloc(bytes.byteLength); this.core.HEAPU8.set(bytes, pointer); try { const code = call(this.core, "sim_program_add_subroutine", "number", ["number", "number", "number"], [this.pointer, pointer, bytes.byteLength]); if (code !== 0) return { status: "error", code: `wasm_program_add_subroutine_${code}`, result: code }; this.subroutines.push({ name: String(name ?? "subroutine"), source }); if (this.executionSource && !this.executionSource.endsWith("\n")) this.executionSource += "\n"; if (this.executionLines.length && this.executionLines.at(-1) !== "") this.executionLines.push(""); this.executionSource += source; if (this.executionLines.length && this.executionLines.at(-1) === "") this.executionLines.pop(); this.executionLines.push(...source.split(/\r?\n/)); return { status: "verified", command: "sim_program_add_subroutine", name: String(name ?? "subroutine") }; } finally { this.core._free(pointer); } } pause() { return this.invoke("sim_program_pause", [], "program_pause"); } resume() { return this.invoke("sim_program_resume", [], "program_resume"); } abort() { return this.invoke("sim_program_abort", [], "program_abort"); } seek(line) { return this.invoke("sim_program_seek", [line], "program_seek"); } restoreState(value) { if (!value || ![SCHEMA, "sim_program.v1"].includes(value.schema) || !Number.isSafeInteger(value.current_line) || value.current_line < 0 || !this.source) throw new Error("wasm_program_restore_schema"); if (value.source_hash && value.source_hash !== fnv1a64(this.source)) throw new Error("wasm_program_restore_source_hash"); const subroutines = [...this.subroutines]; const loaded = this.load(this.source, this.path); if (loaded.status !== "verified") throw new Error(loaded.code); for (const subroutine of subroutines) { const added = this.addSubroutine(subroutine.name, subroutine.source); if (added.status !== "verified") throw new Error(added.code); } if (value.current_line > 0) { const seek = this.seek(value.current_line); if (seek.status !== "verified") throw new Error(seek.code); } const status = value.program_status ?? ({ 0: "idle", 1: "loaded", 2: "running", 3: "paused", 4: "completed", 5: "aborted", 6: "fault" }[value.status] ?? "loaded"); if (status === "running") { const resumed = this.resume(); if (resumed.status !== "verified") { const started = this.start(); if (started.status !== "verified") throw new Error(started.code); } } else if (status === "completed") { this.start(); for (let index = 0; index < this.snapshot().line_count + 1 && this.snapshot().status === 2; index += 1) this.step(); } else if (status === "aborted") { const aborted = this.abort(); if (aborted.status !== "verified") throw new Error(aborted.code); } if (value.has_motion === 0) this.clearMotion(); return this.snapshot(); } executeBlock(block) { const bytes = new TextEncoder().encode(String(block ?? "")); const pointer = this.core._malloc(bytes.byteLength); this.core.HEAPU8.set(bytes, pointer); try { const result = call(this.core, "sim_program_execute_block", "number", ["number", "number", "number"], [this.pointer, pointer, bytes.byteLength]); if (result !== 0) return programError("program_execute", result); const program = this.snapshot(); return { status: "verified", program, motion: this.readMotion(program) }; } finally { this.core._free(pointer); } } readMotion(state = this.snapshot()) { if (!state.has_motion) return null; const result = call(this.core, "sim_program_read_motion", "number", ["number", "number"], [this.pointer, this.motionPointer]); if (result !== 0) return null; const value = segment(this.core, this.motionPointer); // LinuxCNC rapid blocks do not carry an F word. The trajectory queue // requires a positive velocity, so use the simulation's configured rapid // rate for that WASM-produced linear segment. if (state.motion_mode === 0 && value.motion_type === 1 && value.velocity <= 0) value.velocity = DEFAULT_RAPID_VELOCITY; value.samples = sampleSegment(this.core, this.motionPointer, this.samplePosePointer, value); value.samples_source = "linuxcnc-wasm-sim_posemath_segment_point"; value.canonical_motion_mode = state.motion_mode; value.kind = value.motion_type === 2 && Math.abs(Number(value.end?.z ?? 0) - Number(value.start?.z ?? 0)) > 1e-12 ? "helix" : value.motion_type === 2 ? "arc" : state.motion_mode === 0 ? "rapid" : value.motion_type === 1 ? "feed" : "traverse"; if (value.kind === "helix") { value.helix = true; value.radius = Math.hypot(Number(value.start?.x ?? 0) - Number(value.center?.x ?? 0), Number(value.start?.y ?? 0) - Number(value.center?.y ?? 0)); value.turns = Math.abs(Number(value.turn ?? 1)); value.axial_delta = Number(value.end?.z ?? 0) - Number(value.start?.z ?? 0); } call(this.core, "sim_program_clear_motion", "number", ["number"], [this.pointer]); return { ...value, source_line: state.current_line }; } clearMotion() { return this.invoke("sim_program_clear_motion", [], "program_clear_motion"); } step() { const result = this.invoke("sim_program_step", [], "program_step"); if (result.status !== "verified") return result; const state = this.snapshot(); return { status: "verified", program_status: state.status, line: state.current_line, control_code: state.control_code ?? 0, has_control: state.has_control === 1, program: state, motion: this.readMotion(state) }; } snapshot() { if (!this.available) return { schema: SCHEMA, status: "blocked", code: "wasm_program_unavailable" }; const required = call(this.core, "sim_program_canonical", "number", ["number", "number", "number"], [this.pointer, 0, 0]); const buffer = this.core._malloc(required); try { call(this.core, "sim_program_canonical", "number", ["number", "number", "number"], [this.pointer, buffer, required]); const state = JSON.parse(readString(this.core, buffer, required)); const hash = call(this.core, "sim_program_state_hash", "number", ["number"], [this.pointer]); const programStatus = { 0: "idle", 1: "loaded", 2: "running", 3: "paused", 4: "completed", 5: "aborted", 6: "fault" }[state.status] ?? "idle"; const executionLineText = state.current_line > 0 ? (this.executionLines[state.current_line - 1] ?? "").trim() : ""; return { schema: SCHEMA, source: "linuxcnc-wasm-sim_program", ...state, active_gcodes: activeGcodes(state), loaded: state.status !== 0, line: state.current_line, currentLine: state.current_line, execution_line: state.current_line, execution_line_text: executionLineText, program_status: programStatus, path: this.path, subroutines: this.subroutines.map((subroutine) => subroutine.name), state_hash: `fnv1a64:${BigInt.asUintN(64, BigInt(hash >>> 0)).toString(16).padStart(16, "0")}`, source_hash: this.source ? fnv1a64(this.source) : null }; } finally { this.core._free(buffer); } } destroy() { for (const pointer of [this.pointer, this.motionPointer, this.samplePointer, this.samplePosePointer]) if (pointer && typeof this.core?._free === "function") this.core._free(pointer); this.pointer = this.motionPointer = this.samplePointer = this.samplePosePointer = 0; this.executionSource = ""; this.executionLines = []; } }