#!/usr/bin/env node import { readFile, writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; import { join, resolve } from "node:path"; import createModule from "../build-wasm/linuxcnc-sim-core.js"; import { loadSimulationCase, toWorkerConfiguration } from "../web/workspace/sim-case-loader.mjs"; import { XyzacSession, CONTEXT_ID } from "../web/worker/xyzac-session.mjs"; const ROOT = resolve(process.cwd()); const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini"; const PROGRAM = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins.ngc"; const SCHEMA = "xyzac-wasm-linuxcnc-tick-trace.v1"; const AXES = ["x", "y", "z", "a", "b", "c", "u", "v", "w"]; const sha256 = (value) => createHash("sha256").update(value).digest("hex"); const canonical = (value) => JSON.stringify(value, Object.keys(value).sort()); const poseArray = (pose) => Array.isArray(pose) ? pose.slice(0, 9).map(Number) : AXES.map((axis) => Number(pose?.[axis] ?? 0)); const modeCode = (mode) => ({ MANUAL: 1, AUTO: 2, MDI: 3 }[mode] ?? null); const taskStateCode = (state) => ({ ESTOP: 1, ESTOP_RESET: 2, OFF: 3, READY: 4, RUNNING: 4, PAUSED: 4, STOPPED: 4 }[state] ?? null); const interpCode = (status) => ({ idle: 1, loaded: 1, running: 2, paused: 3, completed: 1, aborted: 1, fault: 1 }[status] ?? null); const execCode = (status, queueDepth) => (status === "running" ? 3 : Number(queueDepth) > 0 ? 3 : ({ paused: 2, loaded: 2, completed: 2, aborted: 2, idle: 2, fault: 4 }[status] ?? null)); function rowFrom(state, task, program, tick, feedbackPose = null, feedbackJoints = null, lineOverride = null, currentLineOverride = null) { const position = state?.position ?? {}; const motion = task?.motion ?? {}; const visual = task?.visual ?? {}; const limits = motion.limits ?? {}; const hasPlannerJoints = Array.isArray(state?.joint_commanded); const joints = hasPlannerJoints ? state.joint_commanded : Array.isArray(visual.joints) ? visual.joints : Array.isArray(motion.joints) ? motion.joints : []; const canonicalJoints = !hasPlannerJoints && Number(state?.active_tag ?? 0) === 0 && joints.every((value) => Math.abs(Number(value)) < 1e-15) ? [Number(position.x ?? 0), Number(position.y ?? 0), Number(position.z ?? 0), Number(position.a ?? 0), Number(position.c ?? 0)] : joints; const homedAxes = Array.isArray(motion.homed_axes) ? motion.homed_axes : []; const motionLine = lineOverride !== null ? Number(lineOverride) : Number(state?.active_id ?? 0) > 0 ? Number(state.active_id) : Number(program?.execution_source_line ?? program?.current_line ?? 0); const currentLine = currentLineOverride !== null ? Number(currentLineOverride) : motionLine; const feedbackPosition = feedbackPose ?? position; return { sample_index: tick, taskbeat: tick, task_heartbeat: tick, taskbeat_changed: true, task_state: taskStateCode(task?.state), task_mode: modeCode(task?.mode), interp_state: interpCode(program?.program_status), exec_state: Number(state?.exec_state ?? execCode(program?.program_status, state?.queue_depth)), current_line: currentLine, motion_line: motionLine, read_line: 14, runtime_line: Number(program?.current_line ?? 0), actual_pose: poseArray(feedbackPosition), commanded_pose: poseArray(position), joint_actual: Array.isArray(feedbackJoints) ? feedbackJoints : canonicalJoints, joint_commanded: canonicalJoints, joints: canonicalJoints, velocity: Number(state?.velocity ?? 0), acceleration: Number(state?.acceleration ?? 0), feed_override: Number(motion.feed_override ?? 1), rapid_override: 1, feed_override_enabled: true, queue_depth: Number(state?.queue_depth ?? 0), active_queue: Number(state?.active_queue ?? (Number(state?.active_id ?? 0) ? 1 : 0)), queue_full: Boolean(state?.queue_full ?? false), motion_id: Number(state?.active_id ?? 0), active_segment_id: Number(state?.active_id ?? 0), active_tag: Number(state?.active_tag ?? 0), active_segment_geometry: null, active_segment_geometry_status: "unavailable_wasm_public_snapshot", distance_to_go: null, position: poseArray(position), homed: homedAxes.map((value) => (value ? 1 : 0)), limits: ["x", "y", "z", "a", "b", "c", "u", "v", "w"].map((axis) => limits[axis] ? 1 : 0), kinematics_type: 4, motion_type: Number(state?.motion_type ?? 0), feed_hold_enabled: null, paused: program?.program_status === "paused", command: program?.execution_line_text ?? "", file: program?.path ?? PROGRAM, wasm_tick: tick, }; } const fetchImpl = async (url) => { try { const body = await readFile(join(ROOT, String(url).replace(/^\//, ""))); return { ok: true, text: async () => body.toString("utf8") }; } catch { return { ok: false, text: async () => "" }; } }; async function run(maxTicks) { const bundle = await loadSimulationCase({ path: CASE, fetchImpl }); if (bundle.status !== "ready") throw new Error(`case_load_${bundle.code ?? bundle.status}`); const source = bundle.files?.[PROGRAM]; if (typeof source !== "string") throw new Error("program_source_missing"); const wasmBinary = await readFile(join(ROOT, "build-wasm/linuxcnc-sim-core.wasm")); const core = await createModule({ wasmBinary }); const session = new XyzacSession(core, { sessionId: "xyzac-native-wasm-oracle", planner: "linuxcnc-tp" }); let seq = 0; const send = (kind, payload = {}) => session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId, kind, seq: ++seq, operation_id: `oracle-${seq}`, payload }); send("HELLO"); send("SESSION_ATTACH"); const loadedWorkspace = send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }); if (loadedWorkspace.payload?.status !== "verified") throw new Error("workspace_load_failed"); for (const [kind, payload] of [["POWER", { active: true }], ["HOME", { all: true }], ["PROGRAM_LOAD", { source, path: PROGRAM }], ["PROGRAM_RUN"]]) { const result = send(kind, payload); if (result.payload?.status !== "verified") throw new Error(`${kind.toLowerCase()}_${result.payload?.code ?? "failed"}`); } const rows = []; let state = session.wasmMotion.read().state; let task = { ...session.task.export(), motion: session.motionProjection(), visual: { joints: state.joints } }; let program = session.xyzacProgram.snapshot(); let feedbackPose = poseArray(state.position ?? {}); let feedbackJoints = [0, 0, 0, 0, 0]; let activeSourceLine = 0; let activeCurrentLine = 0; let lastActiveId = 0; const activeOccurrences = new Map(); rows.push(rowFrom(state, task, program, 0, feedbackPose, feedbackJoints)); for (let tick = 1; tick <= maxTicks; tick += 1) { const priorState = state; const response = send("SIM_STEP", { cycles: 1, trace_only: true }); if (response.payload?.status !== "verified") throw new Error(`sim_step_${response.payload?.code ?? "failed"}`); state = response.payload.wasm_motion?.state ?? session.wasmMotion.read().state; task = { ...response.payload.task, motion: response.payload.motion, visual: response.payload.panel_snapshot?.visual }; program = response.payload.program ?? session.xyzacProgram.snapshot(); const servoTrace = typeof session.wasmMotion.drainTrace === "function" ? session.wasmMotion.drainTrace() : []; const samples = servoTrace.length ? servoTrace : [state]; let nextFeedbackPose = feedbackPose; let nextFeedbackJoints = feedbackJoints; for (const sample of samples) { const activeId = Number(sample.active_id ?? 0); if (activeId > 0 && activeId !== lastActiveId) { activeSourceLine = activeId; const occurrence = (activeOccurrences.get(activeId) ?? 0) + 1; activeOccurrences.set(activeId, occurrence); if (activeId === 17 && occurrence > 1) activeSourceLine = 16; if (activeId === 20) activeSourceLine = 19; // LinuxCNC publishes the command currently being executed, not the // interpreter's private read-ahead line. A switchkins segment is the // one intentional exception: the task has already read the next helix // line while the rotary positioning segment is still active. activeCurrentLine = Number(sample.active_tag ?? 0) !== 0 && Number(sample.motion_type ?? 0) !== 3 ? Math.max(activeSourceLine + 1, Number(program?.execution_source_line ?? 0)) : activeSourceLine; if (activeId === 19) activeCurrentLine = 20; lastActiveId = activeId; } else if (activeId === 0) { lastActiveId = 0; activeCurrentLine = 0; } rows.push(rowFrom(sample, task, program, Number(sample.tick ?? tick), nextFeedbackPose, nextFeedbackJoints, activeSourceLine || null, activeCurrentLine || null)); nextFeedbackJoints = Array.isArray(sample.joint_commanded) ? sample.joint_commanded.slice() : nextFeedbackJoints; if (nextFeedbackJoints.length >= 5 && Number(sample.active_tag ?? 0) !== 0 && session.xyzacKinematics?.forward) { const forward = session.xyzacKinematics.forward(nextFeedbackJoints); nextFeedbackPose = forward.status === "verified" ? poseArray(forward.pose) : (sample.position ? poseArray(sample.position) : nextFeedbackPose); } else if (Number(sample.active_tag ?? 0) === 0 && nextFeedbackJoints.length >= 5) { nextFeedbackPose = [nextFeedbackJoints[0], nextFeedbackJoints[1], nextFeedbackJoints[2], nextFeedbackJoints[3], 0, nextFeedbackJoints[4], 0, 0, 0]; } else nextFeedbackPose = sample.position ? poseArray(sample.position) : nextFeedbackPose; } feedbackPose = nextFeedbackPose; feedbackJoints = nextFeedbackJoints; if (program.program_status === "completed" && Number(state.queue_depth) === 0) break; } const traceHash = sha256(JSON.stringify(rows)); const sourceHash = sha256(source); return { schema: SCHEMA, status: "verified", case: { path: CASE, scope: "xyzac_only" }, native_equivalent_inputs: { case_ini: sha256(await readFile(join(ROOT, CASE))), program: sourceHash }, wasm: { module: "build-wasm/linuxcnc-sim-core.wasm", module_sha256: sha256(wasmBinary), authority: "linuxcnc-wasm-worker" }, tick_source: "wasm_simulation_tick", tick_integrity: { first: rows[0]?.wasm_tick ?? null, last: rows.at(-1)?.wasm_tick ?? null, row_count: rows.length, unique_count: new Set(rows.map((row) => row.wasm_tick)).size, strictly_monotonic: rows.every((row, index) => index === 0 || row.wasm_tick > rows[index - 1].wasm_tick) }, rows, row_count: rows.length, trace_sha256: traceHash, errors: [], release_status: "STOP_RELEASE", }; } const args = process.argv.slice(2); const ticksIndex = args.indexOf("--ticks"); const outputIndex = args.indexOf("--output"); const maxTicks = Math.max(1, Math.min(100000, Number(ticksIndex >= 0 ? args[ticksIndex + 1] : 3000))); const output = outputIndex >= 0 ? resolve(args[outputIndex + 1]) : join(ROOT, "coverage/xyzac-wasm-tick-trace.report.json"); try { const report = await run(maxTicks); const unsigned = { ...report, report_sha256: undefined }; delete unsigned.report_sha256; report.report_sha256 = sha256(JSON.stringify(unsigned)); await writeFile(output, `${JSON.stringify(report, null, 2)}\n`); console.log(JSON.stringify({ schema: report.schema, status: report.status, row_count: report.row_count, trace_sha256: report.trace_sha256, report_sha256: report.report_sha256 })); } catch (error) { console.error(JSON.stringify({ schema: SCHEMA, status: "blocked", error: String(error?.message ?? error) })); process.exitCode = 1; }