Complete 3axis tutorial native and Web/WASM parity tools

This commit is contained in:
2026-08-24 14:01:30 +08:00
parent 938654b19d
commit 7e90142b63
147 changed files with 17639 additions and 557 deletions

View File

@@ -124,18 +124,16 @@ export class AuthoritativeRuntime {
this.state.planner.queue_depth = 0;
return { status: "verified", command: "AUTHORITATIVE_ABORT" };
}
if (this.state.program_status === "completed") {
this.state.status = "completed";
return { status: "verified", command: "AUTHORITATIVE_COMPLETE", queue_depth: this.state.planner.queue_depth };
}
const planner = this.state.planner;
const last = planner.queue.at(-1)?.end ?? (planner.active_id === null ? this.state.kinematics.pose : null);
const last = planner.queue.at(-1)?.end ?? planner.active?.end ?? (planner.active_id === null ? this.state.kinematics.pose : null);
if (!samePose(target, last)) {
const id = planner.next_id++;
planner.queue.push({ id, source_line: program.line ?? null, start: clone(this.state.kinematics.pose), end: target });
}
planner.queue_depth = planner.queue.length + (planner.active_id === null ? 0 : 1);
this.state.status = this.state.program_status === "completed" ? "completed" : "running";
// M2 marks the interpreter complete, but AXIS remains visibly active until
// the final queued motion reaches its endpoint.
this.state.status = this.state.program_status === "completed" && planner.queue_depth === 0 ? "completed" : "running";
return { status: "verified", command: "AUTHORITATIVE_SYNC", queue_depth: planner.queue_depth };
}

View File

@@ -11,8 +11,10 @@ export const LIMITS = Object.freeze({
export const COMMANDS = Object.freeze([
"SESSION_ATTACH", "LOAD_WORKSPACE", "COMMAND", "SIM_STEP", "SNAPSHOT_REQUEST",
"PROGRAM_LOAD", "PROGRAM_RUN", "PROGRAM_PAUSE", "PROGRAM_RESUME", "PROGRAM_ABORT",
"PROGRAM_LOAD", "PROGRAM_RUN", "PROGRAM_PAUSE", "PROGRAM_RESUME", "PROGRAM_ABORT", "PROGRAM_RESTART",
"PROGRAM_STEP", "PROGRAM_SEEK", "MDI_EXECUTE", "CHECKPOINT", "RECOVER", "DESTROY",
"SET_MODE", "SET_OVERRIDE", "JOG_START", "JOG_STOP", "HOME", "ESTOP", "IO_RECOVER", "LIMIT_SET", "FOLLOWING_ERROR",
"SPINDLE_COMMAND", "SPINDLE_FAULT", "SPINDLE_INHIBIT", "SPINDLE_ORIENT", "SPINDLE_ORIENTED", "TOOL_PREPARE", "TOOL_PREPARED", "TOOL_CHANGE", "TOOL_CHANGED", "TOOL_ABORT", "TOOL_FAULT", "TOOL_CLEAR_FAULT", "PROBE_CONFIG",
"ENCODER_SET", "COOLANT_COMMAND", "COOLANT_FAULT", "DOOR_SET", "INTERLOCK_SET", "INTERLOCK_RESET", "DEVICE_ESTOP", "DEVICE_ACK_ISSUE", "DEVICE_ACK", "DEVICE_ACK_ABORT",
"CONFIGURATION_BATCH",
]);

View File

@@ -23,6 +23,7 @@ export class MotionState {
feed_override: 1,
spindle_override: 1,
homed: false,
homed_axes: [false, false, false],
limits: { x: false, y: false, z: false },
following_error: 0,
jog: null,
@@ -42,8 +43,14 @@ export class MotionState {
if (this.state.estop) return this.fail("motion_estop_active");
if (target !== null && !axis(target)) return this.fail("motion_axis_invalid");
const targets = target === null ? AXES : [axis(target)];
for (const name of targets) { this.state.axes[name] = 0; this.state.joints[AXES.indexOf(name)] = 0; this.state.limits[name] = false; }
this.state.homed = true;
for (const name of targets) {
const index = AXES.indexOf(name);
this.state.axes[name] = 0;
this.state.joints[index] = 0;
this.state.homed_axes[index] = true;
this.state.limits[name] = false;
}
this.state.homed = this.state.homed_axes.every(Boolean);
this.state.fault = null;
this.state.following_error = 0;
this.state.tick += 1;
@@ -57,13 +64,14 @@ export class MotionState {
return this.ok("SET_OVERRIDE");
}
startJog({ axis: requestedAxis, direction = 1, speed = 1 } = {}) {
startJog({ axis: requestedAxis, direction = 1, speed = 1, increment = null } = {}) {
const name = axis(requestedAxis);
if (!name) return this.fail("motion_axis_invalid");
if (!this.state.homed) return this.fail("motion_unhomed");
if (this.state.estop || this.state.fault) return this.fail("motion_faulted");
if (!Number.isSafeInteger(direction) || ![-1, 1].includes(direction) || !finite(speed) || speed <= 0 || speed > MAX_SPEED) return this.fail("motion_jog_range");
this.state.jog = { axis: name, direction, speed };
const jogIncrement = finite(increment) && increment > 0 ? Number(increment) : null;
this.state.jog = { axis: name, direction, speed, ...(jogIncrement === null ? {} : { increment: jogIncrement }) };
this.state.velocity = speed * this.state.feed_override;
return this.ok("JOG_START");
}
@@ -91,6 +99,16 @@ export class MotionState {
return this.ok("FOLLOWING_ERROR");
}
resetFault() {
if (this.state.estop) return this.fail("motion_estop_active");
if (Object.values(this.state.limits).some(Boolean)) return this.fail("motion_limit_active");
this.state.fault = null;
this.state.following_error = 0;
this.state.velocity = 0;
this.state.jog = null;
return this.ok("RESET");
}
estop(active) {
if (typeof active !== "boolean") return this.fail("motion_estop_argument");
this.state.estop = active;
@@ -105,12 +123,27 @@ export class MotionState {
if (!this.state.jog || this.state.estop || this.state.fault) { this.state.velocity = 0; return this.export(); }
const { axis: name, direction, speed } = this.state.jog;
if (this.state.limits[name]) { this.state.fault = "limit"; this.state.jog = null; this.state.velocity = 0; return this.export(); }
const delta = direction * speed * this.state.feed_override * CYCLE_DISTANCE;
const requested = speed * this.state.feed_override * CYCLE_DISTANCE;
const increment = this.state.jog.increment == null ? null : this.state.jog.increment;
const delta = direction * (increment === null ? requested : Math.min(requested, increment));
const next = this.state.axes[name] + delta;
if (!finite(next) || Math.abs(next) > MAX_POSITION) { this.state.fault = "position_range"; this.state.jog = null; this.state.velocity = 0; return this.export(); }
this.state.axes[name] = next;
this.state.joints[AXES.indexOf(name)] = next;
this.state.velocity = Math.abs(speed * this.state.feed_override);
if (increment !== null) {
this.state.jog.increment = increment - Math.abs(delta);
if (this.state.jog.increment <= 1e-12) { this.state.jog = null; this.state.velocity = 0; }
}
return this.export();
}
applyWasmPosition(position, velocity = 0, tick = this.state.tick) {
if (!position || ![position.x, position.y, position.z].every(finite)) return this.fail("motion_wasm_position_invalid");
this.state.axes = { x: position.x, y: position.y, z: position.z };
this.state.joints = [position.x, position.y, position.z];
this.state.velocity = finite(velocity) ? velocity : 0;
this.state.tick = Math.max(this.state.tick, Number.isSafeInteger(tick) ? tick : this.state.tick);
return this.export();
}
@@ -119,7 +152,11 @@ export class MotionState {
const unsigned = { ...value }; delete unsigned.state_hash;
if (value.state_hash !== fnv1a64(unsigned)) throw new Error("motion_restore_hash");
if (!finite(value.tick) || !value.axes || !Array.isArray(value.joints) || value.joints.length !== 3) throw new Error("motion_restore_shape");
this.state = clone(unsigned);
const homedAxes = Array.isArray(value.homed_axes)
? value.homed_axes.slice(0, AXES.length).map(Boolean)
: AXES.map(() => value.homed === true);
if (homedAxes.length !== AXES.length) throw new Error("motion_restore_homed_shape");
this.state = { ...clone(unsigned), homed_axes: homedAxes, homed: homedAxes.every(Boolean) };
return this.export();
}
}

View File

@@ -41,6 +41,39 @@ function readInterpreterState(core, pointer) {
};
}
function readInterpreterMotion(core, interpreter, sourceLine = null) {
if (typeof core._sim_interpreter_read_motion !== "function" || typeof core._sim_interpreter_motion_size !== "function") return null;
const size = core._sim_interpreter_motion_size();
if (!Number.isSafeInteger(size) || size < 216 || size > 4096) throw new Error("wasm_motion_size_invalid");
const pointer = core._malloc(size);
if (!pointer) throw new Error("wasm_motion_allocation_failed");
try {
const result = call(core, "sim_interpreter_read_motion", "number", ["number", "number"], [interpreter, pointer]);
if (result !== 0) return null;
const doubles = core.HEAPF64.subarray(pointer / 8, pointer / 8 + 26);
const ints = core.HEAP32.subarray(pointer / 4 + 52, pointer / 4 + 54);
const pose = (offset) => ({
x: doubles[offset], y: doubles[offset + 1], z: doubles[offset + 2],
a: doubles[offset + 3], b: doubles[offset + 4], c: doubles[offset + 5],
u: doubles[offset + 6], v: doubles[offset + 7], w: doubles[offset + 8],
});
return {
source_line: sourceLine,
start: pose(0),
end: pose(9),
center: { x: doubles[18], y: doubles[19], z: doubles[20] },
normal: { x: doubles[21], y: doubles[22], z: doubles[23] },
velocity: doubles[24],
acceleration: doubles[25],
motion_type: ints[0],
turn: ints[1],
source: "wasm_interpreter_canonical_motion",
};
} finally {
core._free(pointer);
}
}
function readCanonical(core, interpreter) {
if (typeof core._sim_interpreter_canonical !== "function") return null;
const required = call(core, "sim_interpreter_canonical", "number", ["number", "number", "number"], [interpreter, 0, 0]);
@@ -328,6 +361,32 @@ export class ProgramRunner {
return this.snapshot();
}
preview() {
if (!this.loaded) return { status: "error", code: "program_not_loaded" };
const source = this.source;
const path = this.path;
const segments = [];
let result = { status: "verified" };
try {
for (let steps = 0; steps < MAX_EXPANDED_LINES && this.programCounter < this.lines.length; steps += 1) {
result = this.executeNext(true);
if (result.status !== "verified") break;
if (result.motion) {
const motion = result.motion;
const linearNoop = motion.motion_type === 1
&& ["x", "y", "z", "a", "b", "c", "u", "v", "w"].every((axis) => motion.start[axis] === motion.end[axis]);
if (!linearNoop) segments.push(motion);
}
}
if (result.status !== "verified") return { ...result, preview: { status: "error", segments } };
return { status: "verified", preview: { status: "verified", segments } };
} catch (error) {
return { status: "error", code: error.message, preview: { status: "error", segments } };
} finally {
this.load(source, path);
}
}
pause() {
if (this.status !== "running") return { status: "error", code: "program_not_running" };
this.status = "paused";
@@ -398,7 +457,7 @@ export class ProgramRunner {
if (stateResult !== 0) return { status: "error", code: "mdi_state_read", result: stateResult };
this.state = readInterpreterState(this.core, this.stateBuffer);
const interpreterState = cloneState(this.state);
return { status: "verified", block, state: interpreterState, interpreter_state: interpreterState, runner: this.snapshot() };
return { status: "verified", block, state: interpreterState, interpreter_state: interpreterState, motion: readInterpreterMotion(this.core, this.interpreter, this.state.line), runner: this.snapshot() };
} catch (error) {
return { status: "error", code: error.message };
}
@@ -588,7 +647,7 @@ export class ProgramRunner {
}
if (this.state.program_end) this.status = "completed";
else if (this.programCounter >= this.lines.length && !seeking) this.status = "completed";
return { status: "verified", line: index + 1, text: sourceLine, state: cloneState(this.state), program_status: this.status };
return { status: "verified", line: index + 1, text: sourceLine, state: cloneState(this.state), program_status: this.status, motion: readInterpreterMotion(this.core, this.interpreter, index + 1) };
}
this.status = "completed";
return { status: "verified", line: this.currentLine, text: null, state: cloneState(this.state), program_status: this.status };
@@ -618,6 +677,11 @@ export class ProgramRunner {
this.lastError = this.lastError ?? { code: error.message };
}
}
const unitsCode = this.state.units === 20 ? "G20" : "G21";
const distanceCode = this.state.distance_mode === 91 ? "G91" : "G90";
const planeCode = this.state.plane === 18 ? "G18" : this.state.plane === 19 ? "G19" : "G17";
const motionCode = `G${this.state.motion_mode}`;
const spindleCode = this.state.spindle_mode === 5 ? "M5" : `M${this.state.spindle_mode}`;
return {
status: "verified",
program_status: this.status,
@@ -632,6 +696,7 @@ export class ProgramRunner {
modal_hash,
motion_control: { ...this.motionControl },
motion_control_hash: fnv1a64(this.motionControl),
active_gcodes: `${unitsCode} ${distanceCode} ${planeCode} ${motionCode} ${spindleCode}`,
error_column,
error_code,
error: this.lastError,

View File

@@ -21,6 +21,9 @@ import { createTrace, validateTrace } from "./trace-export.mjs";
import { ConfigurationBatchRuntime } from "../workspace/configuration-batch.mjs";
import { AuthoritativeRuntime } from "./authoritative-runtime.mjs";
import { axisState } from "./axis-state.mjs";
import { WasmMotionQueue } from "./wasm-motion-queue.mjs";
import { WasmHomeLimit } from "./wasm-home-limit.mjs";
import { axisGate } from "../app/axis-contract.mjs";
const PROTOCOL_SCHEMA = 1;
const CONTEXT_ID = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1";
@@ -40,6 +43,13 @@ const OPERATION_KINDS = new Set([
"REMAP_REGISTER", "REMAP_CALL", "VCP_REGISTER", "VCP_UPDATE", "FILTER_EXEC",
]);
const PANEL_GATE_ACTIONS = Object.freeze({
PROGRAM_RUN: "run", PROGRAM_STEP: "step", PROGRAM_PAUSE: "pause",
PROGRAM_RESUME: "resume", PROGRAM_ABORT: "abort", PROGRAM_RESTART: "restart",
JOG_START: "jogStart", JOG_STOP: "jogStop", HOME: "home", SET_OVERRIDE: "override",
ESTOP: "estop", IO_RECOVER: "recover",
});
function text(value) {
return typeof value === "string" && value.length > 0 ? value : null;
}
@@ -90,10 +100,14 @@ export class SimSession {
this.python = new PythonBridge();
this.program = new ProgramRunner(core);
this.authoritative = new AuthoritativeRuntime();
this.wasmMotion = new WasmMotionQueue(core);
this.wasmHomeLimit = new WasmHomeLimit(core);
this.motionAuthority = "js";
this.task = new SimTask(core, { contextId, sessionId: sessionId || null });
this.scheduler = new SimScheduler(core);
this.configurationState = null;
this.halState = null;
this.previewState = null;
call(core, "sim_protocol_reset");
if (typeof core.ccall === "function") core.ccall("sim_event_reset", null, [], []);
}
@@ -102,6 +116,7 @@ export class SimSession {
const statePayload = payload && typeof payload === "object" ? payload : {};
const program = statePayload.program ?? (this.program.loaded ? this.program.snapshot() : {});
const task = statePayload.task ?? this.task.export();
const panelSnapshot = this.panelSnapshot({ task, program, input, tick });
return {
schema_version: PROTOCOL_SCHEMA,
kind,
@@ -110,10 +125,70 @@ export class SimSession {
seq: input.seq,
...(text(input.operation_id) ? { operation_id: input.operation_id } : {}),
simulation_tick: tick,
payload: { ...statePayload, axis_state: axisState({ task, program, action: input.kind, payload: input.payload ?? {} }) },
payload: {
...statePayload,
panel_snapshot: panelSnapshot,
motion_authority: statePayload.motion_authority ?? this.motionAuthority,
task_authority: statePayload.task_authority ?? this.task.authority(),
wasm_home_limit: statePayload.wasm_home_limit ?? this.wasmHomeLimit.read(),
axis_state: axisState({ task, program, action: input.kind, payload: input.payload ?? {} }),
},
};
}
panelSnapshot({ task = this.task.export(), program = this.program.loaded ? this.program.snapshot() : null, input = {}, tick = this.tick } = {}) {
const motion = this.motion.export();
const wasmMotion = this.wasmMotion.read();
const homeLimit = this.wasmHomeLimit.read();
const devices = this.devices.export();
const deviceIo = this.deviceIo.export();
const halRuntime = this.hal.export();
const authoritative = this.authoritative.export();
const axis = axisState({ task, program: program ?? {}, action: input.kind, payload: input.payload ?? {} });
const gateInput = { task, program: program ?? {}, motion, devices, taskfile: Boolean(this.program.loaded), kinematicsIdentity: this.motionAuthority === "wasm" };
const gateActions = ["load", "run", "step", "pause", "resume", "abort", "restart", "mdi", "jogStart", "jogStop", "home", "estop", "recover", "override", "reset", "spindle", "coolant"];
const gates = Object.fromEntries(gateActions.map((action) => [action, axisGate(action, gateInput)]));
const position = this.motionAuthority === "wasm" && wasmMotion.status === "verified"
? wasmMotion.state.position
: authoritative.kinematics.pose;
const joints = this.motionAuthority === "wasm" && wasmMotion.status === "verified"
? [position.x, position.y, position.z]
: authoritative.kinematics.joints;
const preview = this.previewState?.status === "verified" ? this.previewState : null;
const visual = {
schema: "axis-visual.v1",
pose: { x: position.x, y: position.y, z: position.z },
joints: Array.isArray(joints) ? [...joints] : [],
toolpath: (preview?.segments ?? []).map((segment, index) => ({ id: index + 1, ...segment })),
active_segment_id: wasmMotion.status === "verified" ? wasmMotion.state.active_id : authoritative.planner.active_id,
source: "wasm_canonical_motion",
};
const unsigned = {
schema: "axis-panel-snapshot.v1",
context_id: this.contextId,
session_id: this.sessionId,
simulation_tick: tick,
task,
program,
motion,
coordinates: { machine: [position.x, position.y, position.z], relative: [position.x, position.y, position.z], commanded: [position.x, position.y, position.z] },
overrides: { feed: motion.feed_override, spindle: [motion.spindle_override] },
wasm_motion: wasmMotion,
home_limit: homeLimit,
hal: { configuration: this.halState, runtime: halRuntime },
devices,
device_io: deviceIo,
configuration: this.configurationState,
axis_state: axis,
gates,
visual,
authoritative,
motion_authority: this.motionAuthority,
task_authority: this.task.authority(),
};
return { ...unsigned, state_hash: fnv1a64(unsigned) };
}
error(input, code, messageKey, retryable = false) {
return this.envelope("ERROR", input, {
status: "error",
@@ -135,6 +210,23 @@ export class SimSession {
return this.operations.get(input.operation_id) ?? null;
}
commandGate(input) {
const action = PANEL_GATE_ACTIONS[input.kind];
if (!action) return { action: null, enabled: true, reason: "axis_internal_command" };
const task = this.task.export();
const program = this.program.loaded ? this.program.snapshot() : {};
const motion = this.motion.export();
const devices = this.devices.export();
return axisGate(action, {
task,
program,
motion,
devices,
taskfile: Boolean(this.program.loaded),
kinematicsIdentity: this.motionAuthority === "wasm",
});
}
stateForCheckpoint() {
const state = {
context_id: this.contextId,
@@ -153,6 +245,11 @@ export class SimSession {
configuration: this.configurationState,
hal: this.halState,
python: this.python.exportState(),
motion_authority: this.motionAuthority,
wasm_motion: this.wasmMotion.exportState(),
task_authority: this.task.authority(),
wasm_home_limit: this.wasmHomeLimit.exportState(),
preview: this.previewState,
task: this.task.export(),
scheduler: this.scheduler.export(),
authoritative: this.authoritative.export(),
@@ -182,6 +279,10 @@ export class SimSession {
task: this.task.export(),
scheduler: this.scheduler.export(),
authoritative: this.authoritative.export(),
motion_authority: this.motionAuthority,
wasm_motion: this.wasmMotion.exportState(),
task_authority: this.task.authority(),
wasm_home_limit: this.wasmHomeLimit.exportState(),
};
if (this.program.loaded) value.program = this.program.exportState();
return { ...value, state_hash: fnv1a64(value) };
@@ -221,7 +322,17 @@ export class SimSession {
if (trace.state?.task) this.task.restore(trace.state.task);
if (trace.state?.scheduler) this.scheduler.restore(trace.state.scheduler);
if (trace.state?.authoritative) this.authoritative.restore(trace.state.authoritative);
if (trace.state?.motion_authority) this.motionAuthority = trace.state.motion_authority;
if (trace.state?.wasm_motion) this.wasmMotion.restoreState(trace.state.wasm_motion);
if (trace.state?.wasm_home_limit) this.wasmHomeLimit.restoreState(trace.state.wasm_home_limit);
if (trace.state?.motion) this.motion.restore(trace.state.motion);
if (trace.state?.program) {
this.program = new ProgramRunner(this.core);
this.program.restoreState(trace.state.program);
}
if (trace.state?.configuration !== undefined) this.configurationState = trace.state.configuration;
if (trace.state?.hal !== undefined) this.halState = trace.state.hal;
if (trace.state?.preview !== undefined) this.previewState = trace.state.preview;
if (trace.state?.plc) this.plc.restore(trace.state.plc);
if (trace.state?.hal_runtime) this.hal.restore(trace.state.hal_runtime);
if (trace.state?.device_io) this.deviceIo.restore(trace.state.device_io);
@@ -235,6 +346,10 @@ export class SimSession {
}
restoreCheckpoint(checkpoint, events = null, reconcile = undefined) {
const replayMotionAuthority = checkpoint?.state?.motion_authority ?? this.motionAuthority;
if (replayMotionAuthority === "wasm" && !checkpoint?.state?.wasm_motion) {
return { schema: "simulation-recovery.v1", status: "blocked", context_id: this.contextId, checkpoint_hash: checkpoint?.checkpoint_hash ?? null, blocked: [{ reason: "wasm_motion_checkpoint_missing" }] };
}
const replayDevices = new VirtualDevices({ state: checkpoint?.state?.devices });
const replayMotion = new MotionState();
if (checkpoint?.state?.motion) replayMotion.restore(checkpoint.state.motion);
@@ -256,6 +371,20 @@ export class SimSession {
if (checkpoint?.state?.python) replayPython.restoreState(checkpoint.state.python);
const replayAuthoritative = new AuthoritativeRuntime();
if (checkpoint?.state?.authoritative) replayAuthoritative.restore(checkpoint.state.authoritative);
let replayWasmMotion = null;
let replayHomeLimit = null;
try {
if (replayMotionAuthority === "wasm") {
replayWasmMotion = new WasmMotionQueue(this.core);
replayWasmMotion.restoreState(checkpoint.state.wasm_motion);
if (checkpoint.state.wasm_home_limit) {
replayHomeLimit = new WasmHomeLimit(this.core);
replayHomeLimit.restoreState(checkpoint.state.wasm_home_limit);
}
}
} catch (error) {
return { schema: "simulation-recovery.v1", status: "blocked", context_id: this.contextId, checkpoint_hash: checkpoint?.checkpoint_hash ?? null, blocked: [{ reason: String(error?.message ?? error) }] };
}
const result = recoverSimulation({
checkpoint,
contextId: this.contextId,
@@ -263,7 +392,11 @@ export class SimSession {
reconcile,
apply: (state, event) => {
if (DEVICE_COMMANDS.has(event.kind)) replayDevices.applyEvent(event.kind, event.payload);
if (event.kind === "HOME") replayMotion.home(event.payload?.axis ?? null);
if (event.kind === "HOME") {
replayMotion.home(event.payload?.axis ?? null);
const home = replayHomeLimit?.home(event.seq);
if (home?.status === "error") throw new Error(home.code);
}
if (event.kind === "JOG_START") replayMotion.startJog(event.payload);
if (event.kind === "JOG_STOP") replayMotion.stopJog();
if (event.kind === "SET_OVERRIDE") replayMotion.setOverride(event.payload?.feed ?? event.payload?.feed_override, event.payload?.spindle ?? event.payload?.spindle_override);
@@ -273,23 +406,61 @@ export class SimSession {
if (event.kind === "IO_RECOVER") replayDeviceIo.interlockReset();
if (event.kind === "SIM_STEP") replayMotion.step(event.tick);
if (event.kind === "PROGRAM_LOAD") replayProgram.load(event.payload?.source ?? event.payload?.text ?? event.payload?.program_source, event.payload?.path ?? null);
if (event.kind === "PROGRAM_RUN") replayProgram.start();
if (event.kind === "PROGRAM_PAUSE") replayProgram.pause();
if (event.kind === "PROGRAM_RESUME") replayProgram.resume();
if (event.kind === "PROGRAM_ABORT") replayProgram.abort();
if (event.kind === "PROGRAM_RESTART") replayProgram.restart();
if (event.kind === "PROGRAM_RUN") { replayProgram.start(); replayWasmMotion?.setPaused(false); }
if (event.kind === "PROGRAM_PAUSE") { replayProgram.pause(); replayWasmMotion?.setPaused(true); }
if (event.kind === "PROGRAM_RESUME") { replayProgram.resume(); replayWasmMotion?.setPaused(false); }
if (event.kind === "PROGRAM_ABORT") { replayProgram.abort(); replayWasmMotion?.abort(); }
if (event.kind === "PROGRAM_RESTART") { replayProgram.restart(); replayWasmMotion?.reset(); }
if (event.kind === "SIM_STEP" && replayProgram.loaded) {
if (replayProgram.status === "paused" && state.task?.program_status === "running") replayProgram.resume();
for (let replayTick = replayAuthoritative.state.tick + 1; replayTick <= event.tick; replayTick += 1) {
replayProgram.tick(1);
const synced = replayAuthoritative.syncProgram(replayProgram.snapshot());
if (synced.status === "blocked") throw new Error(synced.code);
const stepped = replayAuthoritative.step(replayTick);
if (stepped.status === "error") throw new Error(stepped.code);
// PROGRAM_LOAD resets the planner clock, while the session clock is
// monotonic. Recovery must start this replay at the checkpoint tick
// or one logical step would consume the whole historical interval.
if (replayMotionAuthority === "wasm") {
if (!replayWasmMotion) throw new Error("wasm_motion_replay_unavailable");
const queueTick = replayWasmMotion.read().state.tick;
if (replayProgram.status === "paused" && state.task?.program_status === "running") replayProgram.resume();
const cycles = Number.isSafeInteger(event.payload?.cycles) && event.payload.cycles >= 0 ? event.payload.cycles : 1;
for (let cycle = 0; cycle < cycles; cycle += 1) {
const replayTick = queueTick + cycle + 1;
const steps = replayProgram.tick(1);
for (const step of steps) {
if (!step.motion) continue;
const linearNoop = step.motion.motion_type === 1 && ["x", "y", "z", "a", "b", "c", "u", "v", "w"].every((axis) => step.motion.start[axis] === step.motion.end[axis]);
if (!linearNoop) {
const enqueue = replayWasmMotion.enqueue(step.motion);
if (enqueue.status === "error") throw new Error(enqueue.code);
}
}
const stepped = replayWasmMotion.step();
if (stepped.status === "error" && stepped.code !== "wasm_motion_step_-605") throw new Error(stepped.code);
if (stepped.status === "verified") {
const projected = replayMotion.applyWasmPosition(stepped.state.position, stepped.state.velocity, replayTick);
if (projected.status === "error") throw new Error(projected.code);
if (replayHomeLimit?.read().state.homed) {
const checked = replayHomeLimit.check([stepped.state.position.x, stepped.state.position.y, stepped.state.position.z]);
if (checked.status === "error") throw new Error(checked.code);
}
}
}
} else {
if (Number.isSafeInteger(state.tick) && replayAuthoritative.state.tick < state.tick) replayAuthoritative.state.tick = state.tick;
if (replayProgram.status === "paused" && state.task?.program_status === "running") replayProgram.resume();
for (let replayTick = replayAuthoritative.state.tick + 1; replayTick <= event.tick; replayTick += 1) {
replayProgram.tick(1);
const synced = replayAuthoritative.syncProgram(replayProgram.snapshot());
if (synced.status === "blocked") throw new Error(synced.code);
const stepped = replayAuthoritative.step(replayTick);
if (stepped.status === "error") throw new Error(stepped.code);
}
}
} else if (event.kind === "SIM_STEP") {
const stepped = replayAuthoritative.step(event.tick);
if (stepped.status === "error") throw new Error(stepped.code);
if (replayMotionAuthority === "wasm") {
const stepped = replayWasmMotion?.step();
if (stepped?.status === "error" && stepped.code !== "wasm_motion_step_-605") throw new Error(stepped.code);
} else {
const stepped = replayAuthoritative.step(event.tick);
if (stepped.status === "error") throw new Error(stepped.code);
}
}
if (event.kind === "PLC_CONFIG") replayPlc.configure(event.payload);
if (event.kind === "PLC_SET") replayPlc.setRegister(event.payload?.register, event.payload?.value);
@@ -352,7 +523,7 @@ export class SimSession {
const programStatus = replayProgram.status === "completed" ? "completed" : replayProgram.status === "running" ? "running" : replayProgram.status === "paused" ? "paused" : replayProgram.status === "aborted" ? "aborted" : "loaded";
task.current_line = replayProgram.currentLine;
task.program_status = programStatus;
if (programStatus === "completed" && task.state === "RUNNING") task.state = "STOPPED";
if (programStatus === "completed" && task.state === "RUNNING" && (replayMotionAuthority !== "wasm" || replayWasmMotion?.read().state.queue_depth === 0)) task.state = "STOPPED";
}
const schedulerUnsigned = {
schema: "sim_scheduler.v1",
@@ -365,11 +536,16 @@ export class SimSession {
last_cycles: event.kind === "SIM_STEP" && Number.isSafeInteger(event.payload?.cycles) ? event.payload.cycles : (state.scheduler?.last_cycles ?? 0),
state_hash: fnv1a64(schedulerUnsigned),
};
return { ...state, tick: event.tick, task: { ...task, state_hash: fnv1a64(task) }, scheduler, devices: replayDevices.export(), motion: replayMotion.export(), authoritative: replayAuthoritative.export(), plc: replayPlc.export(), hal_runtime: replayHal.export(), device_io: replayDeviceIo.export(), configuration_batch: replayConfigurationBatch.export(), io_ack: replayIoAck.export(), callbacks: replayCallbacks.export(), python: replayPython.exportState(), ...(replayProgram.loaded ? { program: replayProgram.exportState() } : {}) };
return { ...state, tick: event.tick, motion_authority: replayMotionAuthority, task_authority: replayMotionAuthority === "wasm" ? "wasm_task_canonical" : "js_compat", task: { ...task, state_hash: fnv1a64(task) }, scheduler, devices: replayDevices.export(), motion: replayMotion.export(), ...(replayWasmMotion ? { wasm_motion: replayWasmMotion.exportState() } : {}), ...(replayHomeLimit ? { wasm_home_limit: replayHomeLimit.exportState() } : {}), authoritative: replayAuthoritative.export(), plc: replayPlc.export(), hal_runtime: replayHal.export(), device_io: replayDeviceIo.export(), configuration_batch: replayConfigurationBatch.export(), io_ack: replayIoAck.export(), callbacks: replayCallbacks.export(), python: replayPython.exportState(), ...(replayProgram.loaded ? { program: replayProgram.exportState() } : {}) };
},
});
replayWasmMotion?.destroy();
replayHomeLimit?.destroy();
if (result.status === "recovered" || result.status === "aborted") {
this.tick = result.simulation_tick;
this.motionAuthority = result.state?.motion_authority ?? checkpoint?.state?.motion_authority ?? this.motionAuthority;
if (this.motionAuthority === "wasm" && result.state?.wasm_motion) this.wasmMotion.restoreState(result.state.wasm_motion);
if (this.motionAuthority === "wasm" && result.state?.wasm_home_limit) this.wasmHomeLimit.restoreState(result.state.wasm_home_limit);
if (result.state?.task) this.task.restore(result.state.task);
if (result.state?.scheduler) this.scheduler.restore(result.state.scheduler);
this.authoritative = new AuthoritativeRuntime();
@@ -377,7 +553,11 @@ export class SimSession {
this.devices = new VirtualDevices({ state: result.state?.devices ?? checkpoint?.state?.devices });
this.motion = new MotionState();
if (result.state?.motion) this.motion.restore(result.state.motion);
this.authoritative.applyMotion(this.motion);
if (this.motionAuthority !== "wasm") this.authoritative.applyMotion(this.motion);
if (this.motionAuthority === "wasm") {
const wasmMotion = this.wasmMotion.read();
if (wasmMotion.status !== "verified" || !result.state?.motion || fnv1a64(result.state.motion.axes) !== fnv1a64(wasmMotion.state.position)) throw new Error("wasm_motion_recovery_projection_mismatch");
}
this.plc = new PlcRuntime();
if (result.state?.plc) this.plc.restore(result.state.plc);
this.hal = new HalRuntime();
@@ -392,6 +572,7 @@ export class SimSession {
if (result.state?.callbacks) this.callbacks.restore(result.state.callbacks);
this.configurationState = result.state?.configuration ?? checkpoint?.state?.configuration ?? null;
this.halState = result.state?.hal ?? checkpoint?.state?.hal ?? null;
this.previewState = result.state?.preview ?? checkpoint?.state?.preview ?? null;
this.python = new PythonBridge();
if (result.state?.python) this.python.restoreState(result.state.python);
this.program = new ProgramRunner(this.core);
@@ -478,6 +659,28 @@ export class SimSession {
if (text(input.operation_id) && OPERATION_KINDS.has(input.kind)) {
this.operations.set(input.operation_id, { operation_id: input.operation_id, status: "executing", seq: input.seq, kind: input.kind, result_hash: null });
}
const gate = this.commandGate(input);
const handlerOwnsFault = (input.kind === "JOG_START" || input.kind === "JOG_STOP") && Boolean(this.motion.state.fault);
const restartAfterCompletion = input.kind === "PROGRAM_RESTART" && this.program.status === "completed";
if (!gate.enabled && !handlerOwnsFault && !restartAfterCompletion) {
if (text(input.operation_id)) this.operations.delete(input.operation_id);
const rejectedPayload = { command: input.kind, gate_reason: gate.reason, payload: input.payload ?? {} };
try {
this.timeline.appendEvent(input.seq, this.tick, "COMMAND_REJECTED", rejectedPayload);
const rejectedEvent = appendCoreEvent(this.core, input.seq, this.tick, "COMMAND_REJECTED", rejectedPayload);
if (rejectedEvent !== 0) return this.error(input, "event_log", "core_event_append_failed");
} catch (error) {
return this.error(input, "event_log", String(error?.message ?? error));
}
return this.envelope("ERROR", input, {
status: "blocked",
code: "axis_gate",
message_key: gate.reason,
gate_reason: gate.reason,
retryable: false,
operation_id: input.operation_id ?? null,
});
}
if (Number.isSafeInteger(input.simulation_tick) && input.simulation_tick >= this.tick) this.tick = input.simulation_tick;
const eventTick = input.kind === "SIM_STEP" && Number.isSafeInteger(input.payload?.cycles)
? this.tick + input.payload.cycles : this.tick;
@@ -560,6 +763,19 @@ export class SimSession {
}
try { this.task.transition("READY"); } catch (error) { return finish(this.error(input, "task_state", String(error?.message ?? error)), "failed"); }
return finish(this.envelope("SESSION_READY", input, { state_hash: this.task.hash(), task: this.task.export(), subscriptions: [] }), "verified");
case "SET_MODE": {
const mode = String(input.payload?.mode ?? "").toUpperCase();
if (!["MANUAL", "MDI", "AUTO"].includes(mode)) return finish(this.error(input, "task_mode", "task_mode_invalid"), "failed");
try { this.task.setMode(mode); } catch (error) { return finish(this.error(input, "task_mode", String(error?.message ?? error)), "failed"); }
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, mode, task: this.task.export(), operation_id: input.operation_id ?? null }));
}
case "RESET": {
if (["RUNNING", "PAUSED"].includes(this.task.state.state)) return finish(this.error(input, "task_state", "reset_while_program_active"), "failed");
const result = this.motion.resetFault();
if (result.status !== "verified") return finish(this.envelope("ERROR", input, { ...result, command: input.kind, task: this.task.export() }), "failed");
if (this.task.state.state === "FAULT" || this.task.state.state === "STOPPED") this.task.recover();
return finish(this.envelope("COMMAND_RESULT", input, { ...result, command: input.kind, task: this.task.export(), motion: this.motion.export(), operation_id: input.operation_id ?? null }));
}
case "LOAD_WORKSPACE": {
const rootPath = input.payload?.root_path;
if (!text(rootPath) || !rootPath.startsWith("/") || rootPath.includes("..")) return finish(this.error(input, "workspace_path", "invalid_workspace_path"), "failed");
@@ -588,7 +804,7 @@ export class SimSession {
startup: startupReport,
}), "failed");
}
this.configurationState = startupReport;
this.configurationState = startupReport;
this.created = true;
startupLoaded = true;
} else {
@@ -610,6 +826,11 @@ export class SimSession {
};
}
}
this.motionAuthority = configuration?.entrypoint?.includes("3axis-tutorial") ? "wasm" : "js";
if (configuration?.home_limits && this.motionAuthority === "wasm") {
this.wasmHomeLimit.destroy();
this.wasmHomeLimit = new WasmHomeLimit(this.core, { limits: configuration.home_limits });
}
if (input.payload?.hal) {
const preflight = preflightHalConfiguration(input.payload.hal);
if (preflight.status !== "ready") {
@@ -636,6 +857,7 @@ export class SimSession {
return loadResult === 0 ? finish(this.envelope("COMMAND_RESULT", input, {
status: "verified",
command: input.kind,
motion_authority: this.motionAuthority,
state_hash: this.task.hash(),
task: this.task.export(),
...(this.configurationState?.schema === "configuration-startup-runtime.v1" ? { configuration: this.configurationState } : {}),
@@ -648,19 +870,30 @@ export class SimSession {
let program_steps = [];
try {
this.scheduler.step(cycles, {
task: () => {
program_steps.push(...this.program.tick(1));
task: () => {
const steps = this.program.tick(1);
program_steps.push(...steps);
for (const step of steps) {
if (!step.motion) continue;
const linearNoop = step.motion.motion_type === 1 && ["x", "y", "z", "a", "b", "c", "u", "v", "w"].every((axis) => step.motion.start[axis] === step.motion.end[axis]);
if (!linearNoop) {
const enqueue = this.wasmMotion.enqueue(step.motion);
if (enqueue.status === "error") throw new Error(enqueue.code);
}
}
},
tp: (nextTick) => {
if (this.program.loaded) {
if (this.motionAuthority !== "wasm" && this.program.loaded) {
const sync = this.authoritative.syncProgram(this.program.snapshot());
if (sync.status === "blocked") throw new Error(sync.code);
}
const planner = this.authoritative.step(nextTick);
if (planner.status === "error") throw new Error(planner.code);
if (this.motionAuthority !== "wasm") {
const planner = this.authoritative.step(nextTick);
if (planner.status === "error") throw new Error(planner.code);
}
},
kinematics: () => {
if (!["trivkins", "corexy", "tripod", "lineardelta", "rotarydelta"].includes(this.authoritative.state.kinematics.type)) throw new Error("authoritative_kinematics_unsupported");
if (this.motionAuthority !== "wasm" && !["trivkins", "corexy", "tripod", "lineardelta", "rotarydelta"].includes(this.authoritative.state.kinematics.type)) throw new Error("authoritative_kinematics_unsupported");
},
motion: (nextTick) => {
const stepResult = call(this.core, "sim_step", 1);
@@ -668,8 +901,19 @@ export class SimSession {
this.tick = nextTick;
this.task.setTick(nextTick);
const motionState = this.motion.step(nextTick);
const wasmMotion = this.wasmMotion.step();
if (wasmMotion.status === "error" && wasmMotion.code !== "wasm_motion_step_-605") throw new Error(wasmMotion.code);
if (motionState.fault && this.task.state.state === "RUNNING") this.task.fault(-1205);
if (this.program.loaded) this.authoritative.applyMotion(this.motion);
if (this.motionAuthority === "wasm") {
if (wasmMotion.status === "verified") {
const position = this.motion.applyWasmPosition(wasmMotion.state.position, wasmMotion.state.velocity, nextTick);
if (position.status === "error") throw new Error(position.code);
}
} else if (this.program.loaded) this.authoritative.applyMotion(this.motion);
if (this.motionAuthority === "wasm" && this.wasmHomeLimit.read().state.homed && !motionState.fault) {
const homeCheck = this.wasmHomeLimit.check(this.motion.state.joints);
if (homeCheck.status === "error") throw new Error(homeCheck.code);
}
const halState = this.hal.step(nextTick);
if (halState.status === "error") throw new Error(halState.code);
const deviceIoState = this.deviceIo.step(nextTick);
@@ -692,12 +936,12 @@ export class SimSession {
if (program) {
const status = program.program_status === "completed" ? "completed" : program.program_status === "running" ? "running" : program.program_status;
this.task.setProgram(status, program.line);
if (status === "completed" && this.task.state.state === "RUNNING") this.task.transition("STOPPED");
if (status === "completed" && this.task.state.state === "RUNNING" && (this.motionAuthority !== "wasm" || this.wasmMotion.read().state.queue_depth === 0)) this.task.transition("STOPPED");
}
const snapshotState = { tick: this.tick, cycles, task: this.task.export(), scheduler: this.scheduler.export(), authoritative: this.authoritative.export(), motion: this.motion.export(), plc: this.plc.export(), hal_runtime: this.hal.export(), device_io: this.deviceIo.export(), configuration_batch: this.configurationBatch.export(), io_ack: this.ioAck.export(), callbacks: this.callbacks.export() };
const snapshotState = { tick: this.tick, cycles, motion_authority: this.motionAuthority, task: this.task.export(), scheduler: this.scheduler.export(), authoritative: this.authoritative.export(), wasm_motion: this.wasmMotion.read(), motion: this.motion.export(), plc: this.plc.export(), hal_runtime: this.hal.export(), device_io: this.deviceIo.export(), configuration_batch: this.configurationBatch.export(), io_ack: this.ioAck.export(), callbacks: this.callbacks.export() };
if (program) snapshotState.program = program;
const snapshot = this.timeline.checkpoint(input.seq, this.tick, snapshotState, this.timeline.snapshots.latest()?.snapshot_id ?? null);
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", cycles, program_steps, task: this.task.export(), scheduler: this.scheduler.export(), authoritative: this.authoritative.export(), motion: this.motion.export(), plc: this.plc.export(), hal_runtime: this.hal.export(), device_io: this.deviceIo.export(), configuration_batch: this.configurationBatch.export(), io_ack: this.ioAck.export(), callbacks: this.callbacks.export(), ...(program ? { program } : {}), state_hash: snapshot.state_hash, snapshot }));
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", cycles, program_steps, motion_authority: this.motionAuthority, task: this.task.export(), scheduler: this.scheduler.export(), authoritative: this.authoritative.export(), wasm_motion: this.wasmMotion.read(), motion: this.motion.export(), plc: this.plc.export(), hal_runtime: this.hal.export(), device_io: this.deviceIo.export(), configuration_batch: this.configurationBatch.export(), io_ack: this.ioAck.export(), callbacks: this.callbacks.export(), ...(program ? { program } : {}), state_hash: snapshot.state_hash, snapshot }));
}
case "PROGRAM_LOAD": {
if (["RUNNING", "PAUSED"].includes(this.task.state.state)) return finish(this.error(input, "task_state", "program_load_while_active"), "failed");
@@ -705,11 +949,20 @@ export class SimSession {
const result = this.program.load(source, input.payload?.path ?? null);
if (result.status === "verified") {
this.authoritative.reset();
this.wasmMotion.reset();
this.wasmMotion.setPaused(false);
this.motion.applyWasmPosition(this.wasmMotion.read().state.position, 0, this.tick);
this.task.setProgram("loaded", 0);
const preview = this.program.preview();
result.preview = preview.preview ?? { status: "error", code: preview.code ?? "program_preview_failed" };
this.previewState = result.preview;
}
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, {
...result,
task: this.task.export(),
motion_authority: this.motionAuthority,
wasm_motion: this.wasmMotion.read(),
motion: this.motion.export(),
command: input.kind,
operation_id: input.operation_id ?? null,
}), result.status === "verified" ? "verified" : "failed");
@@ -719,12 +972,16 @@ export class SimSession {
if (result.status === "verified") {
this.task.setMode("AUTO");
if (this.task.state.state === "STOPPED") this.task.recover();
if (this.motionAuthority === "wasm") this.wasmMotion.setPaused(false);
this.task.transition("RUNNING");
this.task.setProgram("running", this.program.currentLine);
}
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, {
...result,
task: this.task.export(),
motion_authority: this.motionAuthority,
wasm_motion: this.wasmMotion.read(),
motion: this.motion.export(),
command: input.kind,
operation_id: input.operation_id ?? null,
}), result.status === "verified" ? "verified" : "failed");
@@ -733,13 +990,25 @@ export class SimSession {
const action = input.kind === "PROGRAM_PAUSE" ? "pause" : input.kind === "PROGRAM_RESUME" ? "resume" : "abort";
const result = this.program[action]();
if (result.status === "verified") {
if (action === "pause") this.task.transition("PAUSED");
else if (action === "resume") this.task.transition("RUNNING");
else { this.authoritative.abort(); this.task.transition("STOPPED"); this.task.setProgram("aborted", this.program.currentLine); }
if (action === "pause") {
if (this.motionAuthority === "wasm") this.wasmMotion.setPaused(true);
this.task.transition("PAUSED");
} else if (action === "resume") {
if (this.motionAuthority === "wasm") this.wasmMotion.setPaused(false);
this.task.transition("RUNNING");
} else {
if (this.motionAuthority === "wasm") this.wasmMotion.abort();
else this.authoritative.abort();
this.task.transition("STOPPED");
this.task.setProgram("aborted", this.program.currentLine);
}
}
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, {
...result,
task: this.task.export(),
motion_authority: this.motionAuthority,
wasm_motion: this.wasmMotion.read(),
motion: this.motion.export(),
command: input.kind,
operation_id: input.operation_id ?? null,
}), result.status === "verified" ? "verified" : "failed");
@@ -748,9 +1017,13 @@ export class SimSession {
const result = this.program.restart();
if (result.status === "verified") {
if (["ESTOP", "FAULT"].includes(this.task.state.state)) return finish(this.error(input, "task_state", "program_restart_faulted"), "failed");
if (["RUNNING", "PAUSED"].includes(this.task.state.state)) this.task.transition("STOPPED");
if (this.task.state.state === "STOPPED") this.task.recover();
this.task.setMode("AUTO");
this.authoritative.reset();
this.wasmMotion.reset();
this.wasmMotion.setPaused(false);
this.motion.applyWasmPosition(this.wasmMotion.read().state.position, 0, this.tick);
this.task.setProgram("loaded", 0);
}
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, {
@@ -767,14 +1040,26 @@ export class SimSession {
}
const result = this.program.step();
if (result.status === "verified") {
if (this.motionAuthority === "wasm" && result.motion) {
const linearNoop = result.motion.motion_type === 1 && ["x", "y", "z", "a", "b", "c", "u", "v", "w"].every((axis) => result.motion.start[axis] === result.motion.end[axis]);
if (!linearNoop) {
const enqueue = this.wasmMotion.enqueue(result.motion);
if (enqueue.status === "error") return finish(this.error(input, "motion_queue", enqueue.code), "failed");
}
this.wasmMotion.setPaused(true);
}
this.task.setProgram(result.program_status === "completed" ? "completed" : "paused", result.line);
if (result.program_status === "completed") {
if (this.task.state.state === "RUNNING") this.task.transition("STOPPED");
const pending = this.motionAuthority === "wasm" ? this.wasmMotion.read().state.queue_depth > 0 : this.authoritative.state.planner.queue_depth > 0;
if (this.task.state.state === "RUNNING") this.task.transition(pending ? "PAUSED" : "STOPPED");
} else if (this.task.state.state === "RUNNING") this.task.transition("PAUSED");
}
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, {
...result,
task: this.task.export(),
motion_authority: this.motionAuthority,
wasm_motion: this.wasmMotion.read(),
motion: this.motion.export(),
command: input.kind,
operation_id: input.operation_id ?? null,
}), result.status === "verified" ? "verified" : "failed");
@@ -803,16 +1088,21 @@ export class SimSession {
}
case "HOME": {
const result = this.motion.home(input.payload?.axis ?? null);
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, task: this.task.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
if (result.status === "verified" && this.motionAuthority === "wasm") {
const home = this.wasmHomeLimit.home(input.seq);
if (home.status === "error") return finish(this.error(input, "home_limit", home.code), "failed");
}
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, motion: this.motion.export(), task: this.task.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
}
case "JOG_START": case "JOG_STOP": case "SET_OVERRIDE": case "FOLLOWING_ERROR": {
let result;
if (input.kind === "JOG_START" && this.motionAuthority === "wasm" && !this.wasmHomeLimit.read().state.homed) return finish(this.error(input, "motion_state", "motion_unhomed"), "failed");
if (input.kind === "JOG_START") result = this.motion.startJog(input.payload);
else if (input.kind === "JOG_STOP") result = this.motion.stopJog();
else if (input.kind === "SET_OVERRIDE") result = this.motion.setOverride(input.payload?.feed ?? input.payload?.feed_override, input.payload?.spindle ?? input.payload?.spindle_override);
else result = this.motion.setFollowingError(input.payload?.value ?? input.payload?.following_error);
if (result.status === "error" && result.state?.fault && this.task.state.state === "RUNNING") this.task.fault(-1205);
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, task: this.task.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, motion: this.motion.export(), task: this.task.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
}
case "SPINDLE_COMMAND": case "SPINDLE_FAULT": case "SPINDLE_INHIBIT":
case "SPINDLE_ORIENT": case "SPINDLE_ORIENTED": case "IO_STEP":

View File

@@ -74,6 +74,7 @@ export class SimTask {
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) {
@@ -81,6 +82,36 @@ export class SimTask {
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) {
@@ -140,6 +171,7 @@ export class SimTask {
}
export() {
this.syncFromCore();
const unsigned = clone(this.state);
return { ...unsigned, state_hash: fnv1a64(unsigned) };
}

View File

@@ -11,6 +11,7 @@ const EXPECTED_FILES = [
"linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.hal",
"linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial",
"linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial_mm.var",
"linuxcnc/configs/nc_files/3dtest.ngc",
"linuxcnc/configs/sim/axis/vismach/3axis-tutorial/tool.tbl",
"linuxcnc/linuxcnc.gif",
];
@@ -30,6 +31,7 @@ assert.deepEqual(bundle.missing, []);
assert.deepEqual(bundle.optional_missing, []);
assert.equal(bundle.catalog.path, SELECTED_CASE);
assert.equal(bundle.catalog.coordinates, "X Y Z");
assert.ok(bundle.files["linuxcnc/configs/nc_files/3dtest.ngc"].includes("start xy circle"));
for (const path of EXPECTED_FILES) assert.ok(Object.hasOwn(bundle.files, path), `missing exact case file: ${path}`);
assert.equal(bundle.files["linuxcnc/configs/sim/axis/vismach/3axis-tutorial/tool.tbl"].includes("T1"), true);

View File

@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const CASE = "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini";
const fetchImpl = async (url) => {
try {
const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url));
return { ok: true, text: async () => body.toString("utf8") };
} catch {
return { ok: false, text: async () => "" };
}
};
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const session = new SimSession(core, { sessionId: "axis3-visible-completion" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
let sequence = 0;
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++sequence, operation_id: operationId, payload });
send("HELLO", "hello");
send("SESSION_ATTACH", "attach");
send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
const source = "G21 G90\nG0 X0 Y0 Z0\nG1 X10 Y5 F120\nG1 Z-1 F60\nG0 X0 Y0 Z0\nM2\n";
send("PROGRAM_LOAD", "program", { source, path: "visible-completion.ngc" });
send("PROGRAM_RUN", "run");
let finish = null;
let sawWasmMotionAuthority = false;
for (let index = 0; index < 512; index += 1) {
finish = send("SIM_STEP", `step-${index}`, { cycles: 1 });
const wasmPosition = finish.payload.wasm_motion?.state?.position;
const authoritativePosition = finish.payload.authoritative?.kinematics?.pose;
if (wasmPosition && authoritativePosition && (wasmPosition.x !== authoritativePosition.x || wasmPosition.y !== authoritativePosition.y || wasmPosition.z !== authoritativePosition.z)) sawWasmMotionAuthority = true;
if (finish.payload.program?.program_status === "completed" && finish.payload.task?.state === "STOPPED" && finish.payload.wasm_motion?.state?.queue_depth === 0) break;
}
assert.equal(finish.payload.program.program_status, "completed");
assert.equal(finish.payload.task.state, "STOPPED");
assert.equal(finish.payload.authoritative.kinematics.pose.x, 0);
assert.equal(finish.payload.authoritative.kinematics.pose.y, 0);
assert.equal(finish.payload.authoritative.kinematics.pose.z, 0);
assert.equal(finish.payload.motion.axes.x, 0);
assert.equal(finish.payload.motion.axes.y, 0);
assert.equal(finish.payload.motion.axes.z, 0);
assert.equal(finish.payload.wasm_motion.state.source, "wasm_motion_queue");
assert.equal(finish.payload.wasm_motion.state.queue_depth, 0);
assert.equal(sawWasmMotionAuthority, true);
console.log(JSON.stringify({ schema: "axis3-visible-completion.v1", status: "verified", case: CASE, final_pose: finish.payload.authoritative.kinematics.pose, task_state: finish.payload.task.state, program_status: finish.payload.program.program_status }));

View File

@@ -16,6 +16,11 @@ assert.equal(twin.setFollowingError(0.02).state.fault, "following_error");
const checkpoint = twin.export();
const restoredTwin = new MotionState();
assert.deepEqual(restoredTwin.restore(checkpoint), checkpoint);
const partialHome = new MotionState();
assert.deepEqual(partialHome.home("z").state.homed_axes, [false, false, true]);
assert.equal(partialHome.state.homed, false);
assert.deepEqual(partialHome.home("x").state.homed_axes, [true, false, true]);
assert.equal(partialHome.home("y").state.homed, true);
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const session = new SimSession(core, { sessionId: "motion-state" });
@@ -25,6 +30,7 @@ send("HELLO", 1);
send("SESSION_ATTACH", 2, "attach");
send("LOAD_WORKSPACE", 3, "workspace", { root_path: "/workspace" });
assert.equal(send("HOME", 4, "home").payload.state.homed, true);
assert.deepEqual(session.motion.export().homed_axes, [true, true, true]);
send("JOG_START", 5, "jog", { axis: "x", direction: 1, speed: 10 });
const stepped = send("SIM_STEP", 6, "step", { cycles: 2 });
assert.equal(stepped.payload.snapshot.state_hash, stepped.payload.state_hash);

View File

@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const CASE = "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini";
const fetchImpl = async (url) => {
try {
const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url));
return { ok: true, text: async () => body.toString("utf8") };
} catch {
return { ok: false, text: async () => "" };
}
};
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const session = new SimSession(core, { sessionId: "panel-gates" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
let seq = 0;
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: operationId, payload });
send("HELLO", "hello");
send("SESSION_ATTACH", "attach");
send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
const noProgram = send("PROGRAM_RUN", "run-without-program");
assert.equal(noProgram.kind, "ERROR");
assert.equal(noProgram.payload.code, "axis_gate");
assert.equal(noProgram.payload.gate_reason, "axis_taskfile_missing");
send("PROGRAM_LOAD", "load", { path: "gate.ngc", source: "G21 G90\nG1 X1 F10\nM2\n" });
const running = send("PROGRAM_RUN", "run");
assert.equal(running.payload.task.state, "RUNNING");
const homeWhileRunning = send("HOME", "home-while-running", { axis: "x" });
assert.equal(homeWhileRunning.kind, "ERROR");
assert.equal(homeWhileRunning.payload.code, "axis_gate");
assert.equal(homeWhileRunning.payload.gate_reason, "axis_manual_mode_required");
const mdiWhileRunning = send("MDI_EXECUTE", "mdi-while-running", { block: "G0 X2" });
assert.equal(mdiWhileRunning.kind, "ERROR");
assert.equal(mdiWhileRunning.payload.message_key, "mdi_while_program_active");
send("PROGRAM_ABORT", "abort");
send("SET_MODE", "manual", { mode: "MANUAL" });
send("HOME", "home-x", { axis: "x" });
send("HOME", "home-y", { axis: "y" });
send("HOME", "home-z", { axis: "z" });
const jog = send("JOG_START", "jog", { axis: "x", direction: 1, speed: 1 });
assert.equal(jog.payload.status, "verified");
send("JOG_STOP", "jog-stop");
const estop = send("ESTOP", "estop", { active: true });
assert.equal(estop.kind, "ERROR");
const blockedRun = send("PROGRAM_RUN", "run-estop");
assert.equal(blockedRun.kind, "ERROR");
assert.ok(["axis_estop_active", "axis_fault_active"].includes(blockedRun.payload.gate_reason));
send("ESTOP", "estop-reset", { active: false });
send("IO_RECOVER", "recover");
const reset = send("RESET", "reset");
assert.equal(reset.payload.status, "verified");
console.log(JSON.stringify({
schema: "panel-gate-matrix.v1",
status: "verified",
normal: ["PROGRAM_LOAD", "PROGRAM_RUN", "SET_MODE", "HOME", "JOG_START", "JOG_STOP", "RESET"],
blocked: [noProgram.payload.gate_reason, homeWhileRunning.payload.gate_reason, blockedRun.payload.gate_reason, mdiWhileRunning.payload.message_key],
authority: "worker_axis_gate",
}));

View File

@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const CASE = "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini";
const fetchImpl = async (url) => {
try {
const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url));
return { ok: true, text: async () => body.toString("utf8") };
} catch {
return { ok: false, text: async () => "" };
}
};
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const session = new SimSession(core, { sessionId: "panel-snapshot" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
let seq = 0;
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: operationId, payload });
send("HELLO", "hello");
send("SESSION_ATTACH", "attach");
const loaded = send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
const projection = loaded.payload.panel_snapshot;
assert.equal(projection.schema, "axis-panel-snapshot.v1");
assert.equal(projection.context_id, CONTEXT_ID);
assert.equal(projection.session_id, session.sessionId);
assert.equal(projection.motion_authority, "wasm");
assert.equal(projection.task_authority, "wasm_task_canonical");
assert.equal(projection.axis_state.state, "IDLE");
assert.equal(projection.gates.run.enabled, false);
assert.equal(projection.gates.run.reason, "axis_taskfile_missing");
assert.equal(projection.configuration.state, "TASK_READY");
assert.equal(projection.home_limit.state.source, "wasm_home_limit");
assert.equal(projection.visual.source, "wasm_canonical_motion");
assert.deepEqual(projection.visual.pose, { x: 0, y: 0, z: 0 });
const loadedProgram = send("PROGRAM_LOAD", "program", { path: "panel.ngc", source: "G21 G90\nG0 X1 Y2 Z-1\nM2\n" });
assert.equal(loadedProgram.payload.panel_snapshot.program.program_status, "loaded");
assert.ok(loadedProgram.payload.panel_snapshot.visual.toolpath.length > 0);
const beforeHash = loadedProgram.payload.panel_snapshot.state_hash;
send("HOME", "home-z", { axis: "z" });
send("HOME", "home-x", { axis: "x" });
send("HOME", "home-y", { axis: "y" });
const jog = send("JOG_START", "jog", { axis: "x", direction: 1, speed: 2 });
const stepped = send("SIM_STEP", "jog-step", { cycles: 2 });
assert.notEqual(stepped.payload.panel_snapshot.state_hash, beforeHash);
assert.equal(stepped.payload.panel_snapshot.visual.source, "wasm_canonical_motion");
assert.equal(stepped.payload.panel_snapshot.simulation_tick, stepped.simulation_tick);
assert.equal(stepped.payload.panel_snapshot.state_hash, session.panelSnapshot({ input: { kind: "SIM_STEP", payload: { cycles: 2 } }, tick: session.tick }).state_hash);
assert.ok(jog.payload.panel_snapshot.home_limit.state.homed === true);
console.log(JSON.stringify({
schema: "panel-snapshot-projection.v1",
status: "verified",
case: CASE,
fields: ["task", "program", "motion", "wasm_motion", "home_limit", "hal", "devices", "device_io", "configuration", "axis_state", "visual", "state_hash"],
authority: "wasm",
startup: projection.configuration.state,
visual_source: projection.visual.source,
state_hash: stepped.payload.panel_snapshot.state_hash,
}));

View File

@@ -4,6 +4,7 @@ import { SIM_CASE_CATALOG } from "../workspace/sim-case-catalog.mjs";
import { loadSimulationCase } from "../workspace/sim-case-loader.mjs";
import { WorkspaceRuntime } from "../workspace/runtime.mjs";
import { startConfiguration } from "../workspace/configuration-startup.mjs";
import { classifyMissingResources } from "../workspace/linuxcnc-source-semantics.mjs";
const fetchImpl = async (url) => {
try {
@@ -25,7 +26,7 @@ async function matrixRun() {
}
const first = startup?.diagnostics?.[0] ?? null;
const classification = bundle.status !== "ready"
? "BLOCKED_RESOURCE"
? classifyMissingResources(bundle.missing)
: startup.status === "ready"
? "TASK_READY"
: first?.code === "startup_hal_blocked"
@@ -59,7 +60,9 @@ async function matrixRun() {
const first = await matrixRun();
const second = await matrixRun();
assert.equal(first.case_count, 235);
assert.deepEqual(first.counts, { BLOCKED_HAL: 106, BLOCKED_INI: 19, BLOCKED_RESOURCE: 12, BLOCKED_STARTUP: 3, TASK_READY: 95 });
// LinuxCNC treats DISPLAY/OPEN_FILE as an optional AXIS startup hint. Missing
// HALFILEs remain BLOCKED_HAL, while genuine absent resources remain blocked.
assert.deepEqual(first.counts, { BLOCKED_HAL: 106, BLOCKED_INI: 19, BLOCKED_RESOURCE: 11, BLOCKED_STARTUP: 3, TASK_READY: 96 });
assert.deepEqual(first, second);
for (const path of [
"linuxcnc/configs/sim/axis/minimal_xyz.ini",
@@ -67,6 +70,7 @@ for (const path of [
"linuxcnc/configs/sim/axis/corexy/corexy_by_hal.ini",
"linuxcnc/configs/sim/axis/ja_tests/xz/xz.ini",
]) assert.equal(first.cases.find((row) => row.path === path).classification, "TASK_READY");
assert.equal(first.cases.find((row) => row.path.endsWith("anglejog.ini")).classification, "BLOCKED_HAL");
assert.equal(first.cases.find((row) => row.path.endsWith("histogram_demo.ini")).classification, "BLOCKED_INI");
assert.equal(first.cases.find((row) => row.path.endsWith("anglejog.ini")).classification, "BLOCKED_HAL");
assert.equal(first.cases.find((row) => row.path.endsWith("histogram_demo.ini")).classification, "BLOCKED_INI");
assert.equal(first.cases.find((row) => row.path.endsWith("craftsmancnc-axis.ini")).classification, "BLOCKED_HAL");
console.log(JSON.stringify(first));

View File

@@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const CASE = "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini";
const fetchImpl = async (url) => {
try {
const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url));
return { ok: true, text: async () => body.toString("utf8") };
} catch {
return { ok: false, text: async () => "" };
}
};
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const session = new SimSession(core, { sessionId: "wasm-canonical-state" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
let seq = 0;
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: operationId, payload });
send("HELLO", "hello");
send("SESSION_ATTACH", "attach");
const loaded = send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
assert.equal(loaded.payload.motion_authority, "wasm");
assert.equal(loaded.payload.task_authority, "wasm_task_canonical");
assert.equal(loaded.payload.wasm_home_limit.state.source, "wasm_home_limit");
const taskBefore = session.task.export();
session.task.state.state = "FAULT";
const canonicalSnapshot = send("SNAPSHOT_REQUEST", "canonical-snapshot");
assert.equal(canonicalSnapshot.payload.task.state, taskBefore.state);
assert.equal(canonicalSnapshot.payload.task.mode, taskBefore.mode);
assert.equal(canonicalSnapshot.payload.task.program_status, taskBefore.program_status);
assert.equal(canonicalSnapshot.payload.task_authority, "wasm_task_canonical");
const home = send("HOME", "home-z", { axis: "z" });
assert.equal(home.payload.wasm_home_limit.state.source, "wasm_home_limit");
assert.equal(home.payload.wasm_home_limit.state.homed, true);
assert.equal(home.payload.wasm_home_limit.state.home_sequence, 5);
assert.equal(home.payload.wasm_home_limit.state.last_error, 0);
send("HOME", "home-x", { axis: "x" });
send("HOME", "home-y", { axis: "y" });
send("JOG_START", "jog", { axis: "x", direction: 1, speed: 2 });
const stepped = send("SIM_STEP", "jog-step", { cycles: 3 });
assert.equal(stepped.payload.task_authority, "wasm_task_canonical");
assert.equal(stepped.payload.wasm_home_limit.state.source, "wasm_home_limit");
assert.ok(stepped.payload.wasm_home_limit.state.tick > home.payload.wasm_home_limit.state.tick);
assert.deepEqual(stepped.payload.motion.axes, { x: stepped.payload.motion.joints[0], y: stepped.payload.motion.joints[1], z: stepped.payload.motion.joints[2] });
const limit = send("LIMIT_SET", "limit", { axis: "x", active: true });
assert.equal(limit.payload.wasm_home_limit.state.source, "wasm_home_limit");
console.log(JSON.stringify({
schema: "wasm-canonical-state-t4.v1",
status: "verified",
case: CASE,
task_authority: loaded.payload.task_authority,
home_authority: loaded.payload.wasm_home_limit.state.source,
home_sequence: home.payload.wasm_home_limit.state.home_sequence,
home_tick: home.payload.wasm_home_limit.state.tick,
motion_tick: stepped.payload.motion.tick,
home_limit_tick: stepped.payload.wasm_home_limit.state.tick,
js_task_tamper_ignored: canonicalSnapshot.payload.task.state === taskBefore.state,
}));

View File

@@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { fnv1a64 } from "./replay.mjs";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const CASE = "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini";
const fetchImpl = async (url) => {
try {
const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url));
return { ok: true, text: async () => body.toString("utf8") };
} catch {
return { ok: false, text: async () => "" };
}
};
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
async function create(id) {
const core = await createModule({ wasmBinary });
const session = new SimSession(core, { sessionId: id });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: id };
let seq = 0;
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: operationId, payload });
send("HELLO", "hello");
send("SESSION_ATTACH", "attach");
const loaded = send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
assert.equal(loaded.payload.task_authority, "wasm_task_canonical");
send("HOME", "home", { axis: "z" });
send("PROGRAM_LOAD", "program", { path: "canonical-state.ngc", source: "G21 G90\nG0 X0 Y0 Z0\nG1 X20 Y10 F120\nG1 Z-1 F60\nM2\n" });
send("PROGRAM_RUN", "run");
return { session, send };
}
const continuous = await create("wasm-canonical-checkpoint");
continuous.send("SIM_STEP", "step-a", { cycles: 3 });
const checkpoint = continuous.send("CHECKPOINT", "checkpoint").payload.checkpoint;
assert.equal(checkpoint.state.task_authority, "wasm_task_canonical");
assert.equal(checkpoint.state.wasm_home_limit.schema, "sim_home_limit_state.v1");
continuous.send("SIM_STEP", "step-b", { cycles: 4 });
const continuousState = continuous.session.workspacePackageState();
const recovered = await create("wasm-canonical-checkpoint");
const recovery = recovered.session.restoreCheckpoint(checkpoint, continuous.session.timeline.export(), (operation) => operation);
assert.equal(recovery.status, "recovered");
const recoveredState = recovered.session.workspacePackageState();
assert.equal(fnv1a64(continuousState), fnv1a64(recoveredState));
assert.equal(recoveredState.task.state_hash, continuousState.task.state_hash);
assert.equal(recoveredState.wasm_home_limit.state_hash, continuousState.wasm_home_limit.state_hash);
const trace = continuous.session.exportTrace();
const traced = await create("wasm-canonical-checkpoint");
assert.equal(traced.session.importTrace(trace).status, "verified");
assert.equal(traced.session.workspacePackageState().wasm_home_limit.state_hash, continuousState.wasm_home_limit.state_hash);
const damaged = structuredClone(checkpoint);
damaged.state.wasm_home_limit.state_bytes = `${damaged.state.wasm_home_limit.state_bytes.startsWith("00") ? "ff" : "00"}${damaged.state.wasm_home_limit.state_bytes.slice(2)}`;
damaged.state_hash = fnv1a64(damaged.state);
assert.equal(recovered.session.restoreCheckpoint(damaged, null, (operation) => operation).status, "blocked");
console.log(JSON.stringify({
schema: "wasm-canonical-state-checkpoint-t4.v1",
status: "verified",
case: CASE,
checkpoint_hash: checkpoint.checkpoint_hash,
continuous_hash: fnv1a64(continuousState),
recovered_hash: fnv1a64(recoveredState),
endpoint_equal: fnv1a64(continuousState) === fnv1a64(recoveredState),
trace_import: "verified",
damaged_home_state: "blocked",
}));

View File

@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
const report = JSON.parse(await readFile(new URL("../../coverage/wasm-feature-matrix-t5.report.json", import.meta.url), "utf8"));
assert.equal(report.schema, "wasm-feature-matrix-t5.v1");
assert.equal(report.task_id, "T5-FULL-MATRIX-01");
assert.equal(report.context_id, "linuxcnc-2.10.0-pre0-wasm-m1-py-c1");
assert.equal(report.matrix.length, 9);
assert.equal(report.status, "blocked");
assert.equal(report.release_action, "STOP_RELEASE");
assert.equal(report.candidate_eligible, false);
assert.deepEqual(report.counts.statuses, { blocked: 9, queued: 0, verified: 0 });
for (const row of report.matrix) {
assert.ok(row.owner);
assert.ok(row.inputs.length > 0);
assert.ok(row.tests.length > 0);
assert.ok(row.differences.length > 0);
assert.ok(row.stop_reason);
assert.ok(["verified", "blocked", "queued"].includes(row.status));
}
assert.equal(report.checks.no_selected_case_promotion.status, "verified");
const selfHash = report.report_sha256;
const canonical = (value) => {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
if (value && typeof value === "object") return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
return JSON.stringify(value);
};
const copy = structuredClone(report);
delete copy.report_sha256;
assert.equal(selfHash, createHash("sha256").update(canonical(copy)).digest("hex"));
console.log(JSON.stringify({
schema: report.schema,
status: "verified",
task_id: report.task_id,
domains: report.matrix.length,
blocked_domains: report.counts.statuses.blocked,
blocked_tests: report.counts.test_blocked,
release_action: report.release_action,
}));

View File

@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
const report = JSON.parse(await readFile(new URL("../../coverage/wasm-function-closure-t6.report.json", import.meta.url), "utf8"));
assert.equal(report.schema, "wasm-function-closure-t6.v1");
assert.equal(report.task_id, "T6-FUNCTION-CLOSURE-01");
assert.equal(report.status, "blocked");
assert.equal(report.matrix.length, 8);
assert.equal(report.counts.blocked, 8);
assert.equal(report.counts.test_blocked, 0);
assert.equal(report.failure_samples.length, 4);
assert.ok(report.failure_samples.some((sample) => sample.code === "official_kinematics_oracle_missing"));
for (const row of report.matrix) {
assert.ok(row.owner);
assert.ok(row.source_refs.length > 0);
assert.ok(row.verified_slice.length > 0);
assert.ok(row.stop_reason);
assert.ok(row.next_action);
assert.equal(row.status, "blocked");
}
assert.equal(report.checks.no_js_fallback.status, "verified");
assert.equal(report.checks.selected_case_boundary.status, "verified");
const canonical = (value) => {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
if (value && typeof value === "object") return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
return JSON.stringify(value);
};
const copy = structuredClone(report);
const expected = copy.report_sha256;
delete copy.report_sha256;
assert.equal(expected, createHash("sha256").update(canonical(copy)).digest("hex"));
console.log(JSON.stringify({ schema: report.schema, status: "verified", rows: report.matrix.length, blocked: report.counts.blocked, failure_samples: report.failure_samples.length, release_action: report.release_action }));

View File

@@ -0,0 +1,74 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const CASE = "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini";
const fetchImpl = async (url) => {
try {
const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url));
return { ok: true, text: async () => body.toString("utf8") };
} catch {
return { ok: false, text: async () => "" };
}
};
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const session = new SimSession(core, { sessionId: "wasm-motion-authority" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
let seq = 0;
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: operationId, payload });
send("HELLO", "hello");
send("SESSION_ATTACH", "attach");
const loaded = send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
assert.equal(loaded.payload.motion_authority, "wasm");
let jsAuthorityCalls = 0;
for (const method of ["syncProgram", "step", "applyMotion"]) {
session.authoritative[method] = () => {
jsAuthorityCalls += 1;
throw new Error(`js_motion_authority_called:${method}`);
};
}
send("PROGRAM_LOAD", "program", { path: "authority.ngc", source: "G21 G90\nG0 X0 Y0 Z0\nG1 X40 Y20 F120\nG1 Z-2 F60\nM2\n" });
assert.equal(send("PROGRAM_RUN", "run").payload.task.state, "RUNNING");
const moving = send("SIM_STEP", "moving", { cycles: 3 });
assert.equal(moving.payload.wasm_motion.state.source, "wasm_motion_queue");
assert.deepEqual(moving.payload.motion.axes, moving.payload.wasm_motion.state.position);
assert.ok(moving.payload.wasm_motion.state.queue_depth > 0);
const pausedPosition = { ...moving.payload.wasm_motion.state.position };
const paused = send("PROGRAM_PAUSE", "pause");
assert.equal(paused.payload.task.state, "PAUSED");
assert.deepEqual(paused.payload.wasm_motion.state.position, pausedPosition);
assert.deepEqual(paused.payload.motion.axes, pausedPosition);
assert.equal(paused.payload.wasm_motion.state.velocity, 0);
assert.equal(send("PROGRAM_RESUME", "resume").payload.task.state, "RUNNING");
const resumed = send("SIM_STEP", "resumed-step", { cycles: 2 });
assert.notDeepEqual(resumed.payload.wasm_motion.state.position, pausedPosition);
send("PROGRAM_RESTART", "restart");
const stepped = send("PROGRAM_STEP", "single-step");
assert.equal(stepped.payload.task.state, "PAUSED");
assert.ok(stepped.payload.wasm_motion.state.queue_depth >= 0);
const aborted = send("PROGRAM_ABORT", "abort");
assert.equal(aborted.payload.task.program_status, "aborted");
assert.equal(aborted.payload.wasm_motion.state.queue_depth, 0);
assert.deepEqual(aborted.payload.motion.axes, aborted.payload.wasm_motion.state.position);
assert.equal(jsAuthorityCalls, 0);
console.log(JSON.stringify({
schema: "wasm-motion-authority-t3.v1",
status: "verified",
case: CASE,
motion_authority: loaded.payload.motion_authority,
paused_position: pausedPosition,
resumed_position: resumed.payload.wasm_motion.state.position,
abort_queue_depth: aborted.payload.wasm_motion.state.queue_depth,
js_authority_calls: jsAuthorityCalls,
}));

View File

@@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { fnv1a64 } from "./replay.mjs";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const CASE = "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini";
const fetchImpl = async (url) => {
try {
const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url));
return { ok: true, text: async () => body.toString("utf8") };
} catch {
return { ok: false, text: async () => "" };
}
};
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
const source = "G21 G90\nG0 X0 Y0 Z0\nG1 X40 Y20 F120\nG1 Z-2 F60\nG0 X0 Y0 Z0\nM2\n";
async function create(id) {
const core = await createModule({ wasmBinary });
const session = new SimSession(core, { sessionId: id });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: id };
let seq = 0;
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: operationId, payload });
send("HELLO", "hello");
send("SESSION_ATTACH", "attach");
const loaded = send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
assert.equal(loaded.payload.motion_authority, "wasm");
send("PROGRAM_LOAD", "program", { path: "checkpoint.ngc", source });
send("PROGRAM_RUN", "run");
return { session, send };
}
const continuous = await create("wasm-motion-checkpoint");
const before = continuous.send("SIM_STEP", "before", { cycles: 3 });
assert.ok(before.payload.wasm_motion.state.queue_depth > 0);
const checkpoint = continuous.send("CHECKPOINT", "checkpoint").payload.checkpoint;
assert.equal(checkpoint.state.wasm_motion.schema, "sim_motion_queue_state.v1");
assert.equal(checkpoint.state.motion_authority, "wasm");
assert.equal(checkpoint.state_hash, fnv1a64(checkpoint.state));
continuous.send("SIM_STEP", "after", { cycles: 4 });
const continuousState = continuous.session.workspacePackageState();
const recovered = await create("wasm-motion-checkpoint");
for (const method of ["syncProgram", "step", "applyMotion"]) {
recovered.session.authoritative[method] = () => { throw new Error(`js_motion_authority_called:${method}`); };
}
const recovery = recovered.session.restoreCheckpoint(checkpoint, continuous.session.timeline.export(), (operation) => operation);
assert.equal(recovery.status, "recovered");
const recoveredState = recovered.session.workspacePackageState();
assert.equal(fnv1a64(continuousState), fnv1a64(recoveredState));
assert.equal(recoveredState.wasm_motion.state.queue_depth, continuousState.wasm_motion.state.queue_depth);
assert.deepEqual(recoveredState.wasm_motion.state.position, continuousState.wasm_motion.state.position);
const trace = continuous.session.exportTrace();
const traced = await create("wasm-motion-checkpoint");
const traceImport = traced.session.importTrace(trace);
assert.equal(traceImport.status, "verified");
assert.equal(traced.session.workspacePackageState().wasm_motion.state_hash, continuousState.wasm_motion.state_hash);
assert.deepEqual(traced.session.workspacePackageState().wasm_motion.state.position, continuousState.wasm_motion.state.position);
const damaged = structuredClone(checkpoint);
damaged.state.wasm_motion.bytes = `${damaged.state.wasm_motion.bytes.startsWith("00") ? "ff" : "00"}${damaged.state.wasm_motion.bytes.slice(2)}`;
damaged.state_hash = fnv1a64(damaged.state);
const blocked = recovered.session.restoreCheckpoint(damaged, null, (operation) => operation);
assert.equal(blocked.status, "blocked");
console.log(JSON.stringify({
schema: "wasm-motion-checkpoint-t3.v1",
status: "verified",
case: CASE,
checkpoint_hash: checkpoint.checkpoint_hash,
continuous_hash: fnv1a64(continuousState),
recovered_hash: fnv1a64(recoveredState),
endpoint_equal: fnv1a64(continuousState) === fnv1a64(recoveredState),
trace_import: traceImport.status,
checkpoint_queue_depth: checkpoint.state.wasm_motion.state.queue_depth,
blocked_damaged_checkpoint: blocked.status,
}));

View File

@@ -0,0 +1,24 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const session = new SimSession(core, { sessionId: "wasm-motion-event" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
let sequence = 0;
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++sequence, operation_id: operationId, payload });
send("HELLO", "hello");
send("SESSION_ATTACH", "attach");
send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace" });
send("PROGRAM_LOAD", "program", { source: "G21 G90\nG0 X0 Y0 Z0\nG1 X10 Y5 F120\nG17 G02 I1 J1\nM2\n", path: "wasm-motion-event.ngc" });
send("PROGRAM_RUN", "run");
const first = send("SIM_STEP", "step-1", { cycles: 4 });
const events = first.payload.program_steps.filter((step) => step.motion?.source === "wasm_interpreter_canonical_motion");
assert.ok(events.length >= 1);
assert.equal(events[0].motion.start.x, 0);
assert.equal(events[0].motion.end.x, 0);
assert.equal(events.at(-1).motion.motion_type, 2);
assert.equal(events.at(-1).motion.source_line, 4);
console.log(JSON.stringify({ schema: "wasm-motion-event.v1", status: "verified", events: events.length, last_motion: events.at(-1).motion }));

View File

@@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const sourcePath = "linuxcnc/nc_files/3dtest.ngc";
const source = await readFile(new URL(`../../${sourcePath}`, import.meta.url), "utf8");
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const session = new SimSession(core, { sessionId: "wasm-preview" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
let sequence = 0;
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++sequence, operation_id: operationId, payload });
send("HELLO", "hello");
send("SESSION_ATTACH", "attach");
send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace" });
const loaded = send("PROGRAM_LOAD", "program", { source, path: sourcePath });
assert.equal(loaded.payload.preview?.status, "verified");
const segments = loaded.payload.preview.segments;
assert.equal(segments.length, 49);
const arcs = segments.filter((segment) => [6, 23, 39].includes(segment.source_line));
assert.equal(arcs.length, 3);
assert.ok(arcs.every((segment) => segment.motion_type === 2));
assert.deepEqual(arcs.map((segment) => segment.normal), [{ x: 0, y: 0, z: 1 }, { x: 0, y: 1, z: 0 }, { x: 1, y: 0, z: 0 }]);
assert.ok(arcs.every((segment) => segment.source === "wasm_interpreter_canonical_motion"));
console.log(JSON.stringify({ schema: "wasm-preview.v1", status: "verified", source_path: sourcePath, source_lines: source.split(/\r?\n/).length, segment_count: segments.length, arc_lines: [6, 23, 39], source: "wasm_interpreter_canonical_motion" }));

View File

@@ -0,0 +1,136 @@
import { fnv1a64 } from "./replay.mjs";
const HOME_LIMIT_SCHEMA = "sim_home_limit_state.v1";
const KIN_TYPE_TRIVKINS = 1;
const KIN_CONFIG_SIZE = 1608;
const KIN_SIZE = 1760;
const HOME_LIMIT_SIZE = 184;
const HOME_STATE_SIZE = 176;
const HOME_STATE_OFFSET = 8;
const CONFIG_OFFSETS = Object.freeze({ type: 0, mode: 4, joint_count: 8, kinematics_joint_count: 12, extra_joint_count: 16, coordinates: 20, limit_enabled: 1448, joint_min: 1464, joint_max: 1536 });
const HOME_STATE_OFFSETS = Object.freeze({ pose: 0, joints: 72, joint_count: 144, homed: 148, home_sequence: 152, tick: 156, last_error: 160, limit_error: 164, kinematics_error: 168 });
function call(core, name, returnType, argTypes, args) {
const direct = core?.[`_${name}`] ?? core?.[name];
if (typeof direct === "function") return direct(...args);
if (typeof core?.ccall === "function") return core.ccall(name, returnType, argTypes, args);
throw new Error(`missing core export: ${name}`);
}
function bytesToHex(bytes) {
let output = "";
for (const byte of bytes) output += byte.toString(16).padStart(2, "0");
return output;
}
function hexToBytes(value) {
if (typeof value !== "string" || value.length !== HOME_STATE_SIZE * 2 || !/^[0-9a-f]+$/i.test(value)) throw new Error("wasm_home_state_bytes_invalid");
const bytes = new Uint8Array(HOME_STATE_SIZE);
for (let index = 0; index < bytes.length; index += 1) bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
return bytes;
}
function pose(heap, offset) {
const doubles = heap.HEAPF64.subarray(offset / 8, offset / 8 + 9);
return { x: doubles[0], y: doubles[1], z: doubles[2], a: doubles[3], b: doubles[4], c: doubles[5], u: doubles[6], v: doubles[7], w: doubles[8] };
}
function finitePose(value) {
return value && [value.x, value.y, value.z, value.a, value.b, value.c, value.u, value.v, value.w].every((entry) => typeof entry === "number" && Number.isFinite(entry));
}
export class WasmHomeLimit {
constructor(core, { jointCount = 3, limits = {} } = {}) {
this.core = core;
this.available = ["sim_kinematics_init", "sim_kinematics_home_limit_init", "sim_kinematics_home_limit_reset", "sim_kinematics_home_limit_home", "sim_kinematics_home_limit_check", "sim_kinematics_home_limit_read"].every((name) => typeof core?.[`_${name}`] === "function" || typeof core?.[name] === "function");
this.jointCount = jointCount;
this.config = this.kinematics = this.homeLimit = this.state = this.joints = 0;
this.stateBuffer = 0;
this.limits = { x: [-500, 500], y: [-100, 100], z: [-240, 0.001], ...limits };
if (!this.available || typeof core?._malloc !== "function") return;
this.config = core._malloc(KIN_CONFIG_SIZE);
this.kinematics = core._malloc(KIN_SIZE);
this.homeLimit = core._malloc(HOME_LIMIT_SIZE);
this.stateBuffer = core._malloc(HOME_STATE_SIZE);
this.joints = core._malloc(9 * 8);
if (![this.config, this.kinematics, this.homeLimit, this.stateBuffer, this.joints].every(Boolean)) throw new Error("wasm_home_limit_allocation_failed");
this.core.HEAPU8.fill(0, this.config, this.config + KIN_CONFIG_SIZE);
this.core.HEAPU8.fill(0, this.kinematics, this.kinematics + KIN_SIZE);
this.core.HEAPU8.fill(0, this.homeLimit, this.homeLimit + HOME_LIMIT_SIZE);
this.core.HEAPU8.fill(0, this.stateBuffer, this.stateBuffer + HOME_STATE_SIZE);
this.core.HEAPU8.fill(0, this.joints, this.joints + 9 * 8);
const configView = new DataView(this.core.HEAPU8.buffer, this.config, KIN_CONFIG_SIZE);
configView.setInt32(CONFIG_OFFSETS.type, KIN_TYPE_TRIVKINS, true);
configView.setInt32(CONFIG_OFFSETS.mode, 0, true);
configView.setUint32(CONFIG_OFFSETS.joint_count, jointCount, true);
configView.setUint32(CONFIG_OFFSETS.kinematics_joint_count, jointCount, true);
configView.setUint32(CONFIG_OFFSETS.extra_joint_count, 0, true);
for (const [index, axis] of ["X", "Y", "Z"].entries()) this.core.HEAPU8[this.config + CONFIG_OFFSETS.coordinates + index] = axis.charCodeAt(0);
for (const [index, axis] of ["x", "y", "z"].entries()) {
const limit = this.limits[axis] ?? [-1e9, 1e9];
this.core.HEAPU8[this.config + CONFIG_OFFSETS.limit_enabled + index] = 1;
configView.setFloat64(CONFIG_OFFSETS.joint_min + index * 8, limit[0], true);
configView.setFloat64(CONFIG_OFFSETS.joint_max + index * 8, limit[1], true);
}
const initialized = call(core, "sim_kinematics_init", "number", ["number", "number"], [this.kinematics, this.config]);
if (initialized !== 0) throw new Error(`wasm_home_kinematics_init_${initialized}`);
const homeInitialized = call(core, "sim_kinematics_home_limit_init", "number", ["number", "number", "number"], [this.homeLimit, this.kinematics, jointCount]);
if (homeInitialized !== 0) throw new Error(`wasm_home_limit_init_${homeInitialized}`);
}
read() {
if (!this.available) return { status: "blocked", state: { schema: HOME_LIMIT_SCHEMA, source: "wasm_home_limit_unavailable", homed: false, home_sequence: 0, tick: 0, joints: [0, 0, 0], pose: { x: 0, y: 0, z: 0 }, last_error: -840, limit_error: 0, kinematics_error: 0 } };
const result = call(this.core, "sim_kinematics_home_limit_read", "number", ["number", "number"], [this.homeLimit, this.stateBuffer]);
if (result !== 0) return { status: "error", code: `wasm_home_limit_read_${result}` };
const ints = this.core.HEAPU32;
const signed = this.core.HEAP32;
const joints = [...this.core.HEAPF64.subarray(this.stateBuffer / 8 + 9, this.stateBuffer / 8 + 12)];
return { status: "verified", state: { schema: HOME_LIMIT_SCHEMA, source: "wasm_home_limit", pose: pose(this.core, this.stateBuffer), joints, joint_count: ints[this.stateBuffer / 4 + 36], homed: Boolean(ints[this.stateBuffer / 4 + 37]), home_sequence: ints[this.stateBuffer / 4 + 38], tick: ints[this.stateBuffer / 4 + 39], last_error: signed[this.stateBuffer / 4 + 40], limit_error: signed[this.stateBuffer / 4 + 41], kinematics_error: signed[this.stateBuffer / 4 + 42] } };
}
home(sequence = 0) {
if (!this.available) return this.read();
const result = call(this.core, "sim_kinematics_home_limit_home", "number", ["number", "number"], [this.homeLimit, sequence]);
if (result !== 0) return { status: "error", code: `wasm_home_${result}`, state: this.read().state };
return this.read();
}
check(joints) {
if (!this.available) return this.read();
if (!Array.isArray(joints) || joints.length < this.jointCount || joints.some((value) => typeof value !== "number" || !Number.isFinite(value))) return { status: "error", code: "wasm_home_joints_invalid" };
this.core.HEAPF64.set(joints.slice(0, this.jointCount), this.joints / 8);
const result = call(this.core, "sim_kinematics_home_limit_check", "number", ["number", "number", "number"], [this.homeLimit, this.joints, this.jointCount]);
return result === 0 ? this.read() : { status: "error", code: `wasm_home_check_${result}`, state: this.read().state };
}
reset() {
if (!this.available) return this.read();
const result = call(this.core, "sim_kinematics_home_limit_reset", "number", ["number"], [this.homeLimit]);
if (result !== 0) throw new Error(`wasm_home_limit_reset_${result}`);
return this.read();
}
exportState() {
const current = this.read();
const stateBytes = this.available ? bytesToHex(this.core.HEAPU8.slice(this.homeLimit + HOME_STATE_OFFSET, this.homeLimit + HOME_STATE_OFFSET + HOME_STATE_SIZE)) : null;
const unsigned = { schema: HOME_LIMIT_SCHEMA, available: this.available, joint_count: this.jointCount, state: current.state, state_bytes: stateBytes };
return { ...unsigned, state_hash: fnv1a64(unsigned) };
}
restoreState(value) {
if (!value || value.schema !== HOME_LIMIT_SCHEMA) throw new Error("wasm_home_restore_schema");
const unsigned = structuredClone(value);
delete unsigned.state_hash;
if (value.state_hash !== fnv1a64(unsigned)) throw new Error("wasm_home_restore_hash");
if (!this.available || value.available !== true || value.joint_count !== this.jointCount) throw new Error("wasm_home_restore_unavailable");
const bytes = hexToBytes(value.state_bytes);
this.core.HEAPU8.set(bytes, this.homeLimit + HOME_STATE_OFFSET);
const actual = this.read();
if (actual.status !== "verified" || fnv1a64(actual.state) !== fnv1a64(value.state)) throw new Error("wasm_home_restore_state_mismatch");
return this.exportState();
}
destroy() { for (const pointer of [this.config, this.kinematics, this.homeLimit, this.stateBuffer, this.joints]) if (pointer && typeof this.core?._free === "function") this.core._free(pointer); this.config = this.kinematics = this.homeLimit = this.stateBuffer = this.joints = 0; }
}
export { HOME_LIMIT_SCHEMA };

View File

@@ -0,0 +1,240 @@
import { fnv1a64 } from "./replay.mjs";
const POSE_DOUBLES = 9;
const SEGMENT_DOUBLES = POSE_DOUBLES * 2 + 3 + 3 + 2;
const SEGMENT_INT_OFFSET = SEGMENT_DOUBLES * 2;
const QUEUE_HEADER_BYTES = 32;
const STATE_POSITION_BYTES = 72;
const MOTION_QUEUE_SCHEMA = "sim_motion_queue_state.v1";
const DEFAULT_CONTROLS = Object.freeze({ profile: 0, lookahead: 1, acceleration_limit: 1e9, jerk_limit: 1e12, paused: false });
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") {
try { return core.ccall(name, returnType, argTypes, args); } catch (error) { throw new Error(`missing core export: ${name}`, { cause: error }); }
}
throw new Error(`missing core export: ${name}`);
}
function optionalCall(core, name, returnType, argTypes, args) {
const fn = core[`_${name}`] ?? core[name];
if (typeof fn === "function") return fn(...args);
if (typeof core.ccall === "function") {
try { return core.ccall(name, returnType, argTypes, args); } catch { return null; }
}
return null;
}
function poseToArray(pose = {}) {
return ["x", "y", "z", "a", "b", "c", "u", "v", "w"].map((axis) => Number(pose[axis] ?? 0));
}
function bytesToHex(bytes) {
let output = "";
for (const byte of bytes) output += byte.toString(16).padStart(2, "0");
return output;
}
function hexToBytes(value) {
if (typeof value !== "string" || value.length % 2 !== 0 || !/^[0-9a-f]*$/i.test(value)) throw new Error("wasm_motion_state_bytes_invalid");
const bytes = new Uint8Array(value.length / 2);
for (let index = 0; index < bytes.length; index += 1) bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
return bytes;
}
function controls(value = {}) {
const result = {
profile: value.profile ?? DEFAULT_CONTROLS.profile,
lookahead: value.lookahead ?? DEFAULT_CONTROLS.lookahead,
acceleration_limit: value.acceleration_limit ?? DEFAULT_CONTROLS.acceleration_limit,
jerk_limit: value.jerk_limit ?? DEFAULT_CONTROLS.jerk_limit,
paused: value.paused ?? DEFAULT_CONTROLS.paused,
};
if (![0, 1].includes(result.profile) || !Number.isSafeInteger(result.lookahead) || result.lookahead < 1 || !Number.isFinite(result.acceleration_limit) || result.acceleration_limit <= 0 || !Number.isFinite(result.jerk_limit) || result.jerk_limit <= 0 || typeof result.paused !== "boolean") throw new Error("wasm_motion_controls_invalid");
return result;
}
export class WasmMotionQueue {
constructor(core, { cycleTime = 0.01, velocityLimit = 1e9 } = {}) {
this.core = core;
this.available = ["sim_motion_size", "sim_motion_command_size", "sim_motion_state_size", "sim_motion_init", "sim_motion_enqueue", "sim_motion_step", "sim_motion_read_state"].every((name) => typeof core?.[`_${name}`] === "function" || typeof core?.[name] === "function");
this.queue = 0;
this.command = 0;
this.state = 0;
this.commandCapacity = 0;
this.stateCapacity = 0;
this.queueSize = 0;
this.queueCapacity = 0;
this.cycleTime = cycleTime;
this.velocityLimit = velocityLimit;
this.controls = controls();
if (!this.available) return;
const queueSize = call(core, "sim_motion_size", "number", [], []);
this.queueSize = queueSize;
this.commandCapacity = call(core, "sim_motion_command_size", "number", [], []);
this.stateCapacity = call(core, "sim_motion_state_size", "number", [], []);
if (![queueSize, this.commandCapacity, this.stateCapacity].every((value) => Number.isSafeInteger(value) && value > 0)) throw new Error("wasm_motion_queue_size_invalid");
this.queueCapacity = (queueSize - QUEUE_HEADER_BYTES - this.stateCapacity) / this.commandCapacity;
if (!Number.isSafeInteger(this.queueCapacity) || this.queueCapacity < 1) throw new Error("wasm_motion_queue_capacity_invalid");
this.queue = core._malloc(queueSize);
this.command = core._malloc(this.commandCapacity);
this.state = core._malloc(this.stateCapacity);
if (!this.queue || !this.command || !this.state) throw new Error("wasm_motion_queue_allocation_failed");
this.core.HEAPU8.fill(0, this.queue, this.queue + queueSize);
const result = call(core, "sim_motion_init", "number", ["number", "number", "number"], [this.queue, cycleTime, velocityLimit]);
if (result !== 0) throw new Error(`wasm_motion_init_${result}`);
}
enqueue(segment) {
if (!this.available) return { status: "skipped", code: "wasm_motion_unavailable" };
if (!segment) return { status: "skipped" };
const heap = this.core.HEAPU8;
heap.fill(0, this.command, this.command + this.commandCapacity);
const doubles = this.core.HEAPF64;
const ints = this.core.HEAP32;
const base = this.command / 8;
[...poseToArray(segment.start), ...poseToArray(segment.end), Number(segment.center?.x ?? 0), Number(segment.center?.y ?? 0), Number(segment.center?.z ?? 0), Number(segment.normal?.x ?? 0), Number(segment.normal?.y ?? 0), Number(segment.normal?.z ?? 0), Number(segment.velocity ?? 0), Number(segment.acceleration ?? 0)].forEach((value, index) => { doubles[base + index] = value; });
ints[this.command / 4 + SEGMENT_INT_OFFSET] = Number(segment.motion_type ?? 1);
ints[this.command / 4 + SEGMENT_INT_OFFSET + 1] = Number(segment.turn ?? 0);
const result = call(this.core, "sim_motion_enqueue", "number", ["number", "number", "number"], [this.queue, this.command, 0]);
return result === 0 ? { status: "verified", source_line: segment.source_line ?? null } : { status: "error", code: `wasm_motion_enqueue_${result}` };
}
reset() {
if (!this.available) return this.read();
this.core.HEAPU8.fill(0, this.queue, this.queue + this.queueSize);
const result = call(this.core, "sim_motion_init", "number", ["number", "number", "number"], [this.queue, this.cycleTime, this.velocityLimit]);
if (result !== 0) throw new Error(`wasm_motion_reset_${result}`);
this.controls = controls();
return this.read();
}
setProfile(profile) {
const next = controls({ ...this.controls, profile });
if (!this.available) { this.controls = next; return this.read(); }
const result = optionalCall(this.core, "sim_motion_set_profile", "number", ["number", "number"], [this.queue, next.profile]);
if (result !== null && result !== 0) throw new Error(`wasm_motion_profile_${result}`);
this.controls = next;
return this.read();
}
setLookahead(lookahead) {
const next = controls({ ...this.controls, lookahead });
if (!this.available) { this.controls = next; return this.read(); }
const result = optionalCall(this.core, "sim_motion_set_lookahead", "number", ["number", "number"], [this.queue, next.lookahead]);
if (result !== null && result !== 0) throw new Error(`wasm_motion_lookahead_${result}`);
this.controls = next;
return this.read();
}
setDynamics(accelerationLimit, jerkLimit) {
const next = controls({ ...this.controls, acceleration_limit: accelerationLimit, jerk_limit: jerkLimit });
if (!this.available) { this.controls = next; return this.read(); }
const result = optionalCall(this.core, "sim_motion_set_dynamics", "number", ["number", "number", "number"], [this.queue, next.acceleration_limit, next.jerk_limit]);
if (result !== null && result !== 0) throw new Error(`wasm_motion_dynamics_${result}`);
this.controls = next;
return this.read();
}
setPaused(paused) {
const next = controls({ ...this.controls, paused: Boolean(paused) });
if (!this.available) { this.controls = next; return this.read(); }
const result = optionalCall(this.core, "sim_motion_set_paused", "number", ["number", "number"], [this.queue, next.paused ? 1 : 0]);
if (result !== null && result !== 0) throw new Error(`wasm_motion_pause_${result}`);
this.controls = next;
return this.read();
}
abort() {
if (!this.available) return this.read();
const current = this.read();
if (current.status !== "verified") throw new Error("wasm_motion_abort_state");
const previous = this.core.HEAPU8.slice(this.queue, this.queue + this.queueSize);
const headerOffset = this.queueCapacity * this.commandCapacity;
const stateOffset = headerOffset + QUEUE_HEADER_BYTES;
const nextId = new DataView(previous.buffer, previous.byteOffset, previous.byteLength).getUint32(8, true);
this.reset();
const bytes = new Uint8Array(this.queueSize);
const view = new DataView(bytes.buffer);
bytes.set(previous.subarray(16, 32), 16);
view.setUint32(8, nextId, true);
view.setUint32(12, current.state.tick, true);
bytes.set(previous.subarray(stateOffset, stateOffset + STATE_POSITION_BYTES), stateOffset);
view.setFloat64(stateOffset + 72, 0, true);
view.setFloat64(stateOffset + 80, 0, true);
view.setUint32(stateOffset + 88, current.state.tick, true);
view.setUint32(stateOffset + 92, 0, true);
view.setUint32(stateOffset + 96, 0, true);
view.setUint32(stateOffset + 100, 0, true);
view.setInt32(stateOffset + 104, 0, true);
view.setInt32(stateOffset + 108, 0, true);
this.core.HEAPU8.set(bytes, this.queue);
return this.read();
}
step() {
if (!this.available) return { status: "error", code: "wasm_motion_step_-605" };
if (this.controls.paused) return { status: "error", code: "wasm_motion_step_-605" };
const result = call(this.core, "sim_motion_step", "number", ["number"], [this.queue]);
if (result !== 0) return { status: "error", code: `wasm_motion_step_${result}` };
return this.read();
}
read() {
if (!this.available) return { status: "blocked", state: { position: { x: 0, y: 0, z: 0 }, velocity: 0, progress: 0, tick: 0, queue_depth: 0, active_id: 0, active_tag: 0, motion_type: 0, status: -605, source: "wasm_motion_queue_unavailable" } };
const result = call(this.core, "sim_motion_read_state", "number", ["number", "number"], [this.queue, this.state]);
if (result !== 0) return { status: "error", code: `wasm_motion_read_${result}` };
const doubles = this.core.HEAPF64.subarray(this.state / 8, this.state / 8 + 11);
const ints = this.core.HEAPU32.subarray(this.state / 4 + 22, this.state / 4 + 26);
return { status: "verified", state: { position: { x: doubles[0], y: doubles[1], z: doubles[2] }, velocity: this.controls.paused ? 0 : doubles[9], progress: doubles[10], tick: ints[0], queue_depth: ints[1], active_id: ints[2], active_tag: ints[3], motion_type: this.core.HEAP32[this.state / 4 + 26], status: this.core.HEAP32[this.state / 4 + 27], source: "wasm_motion_queue" } };
}
exportState() {
if (!this.available) {
const unsigned = { schema: MOTION_QUEUE_SCHEMA, available: false, controls: { ...this.controls }, state: this.read().state };
return { ...unsigned, state_hash: fnv1a64(unsigned) };
}
const current = this.read();
if (current.status !== "verified") throw new Error("wasm_motion_state_unavailable");
const unsigned = {
schema: MOTION_QUEUE_SCHEMA,
queue_size: this.queueSize,
command_size: this.commandCapacity,
state_size: this.stateCapacity,
queue_capacity: this.queueCapacity,
cycle_time: this.cycleTime,
velocity_limit: this.velocityLimit,
controls: { ...this.controls },
state: current.state,
bytes: bytesToHex(this.core.HEAPU8.slice(this.queue, this.queue + this.queueSize)),
};
return { ...unsigned, state_hash: fnv1a64(unsigned) };
}
restoreState(value) {
if (!this.available) throw new Error("wasm_motion_restore_unavailable");
if (!value || value.schema !== MOTION_QUEUE_SCHEMA) throw new Error("wasm_motion_restore_schema");
const unsigned = structuredClone(value);
delete unsigned.state_hash;
if (value.state_hash !== fnv1a64(unsigned)) throw new Error("wasm_motion_restore_hash");
if (value.queue_size !== this.queueSize || value.command_size !== this.commandCapacity || value.state_size !== this.stateCapacity || value.queue_capacity !== this.queueCapacity || value.cycle_time !== this.cycleTime || value.velocity_limit !== this.velocityLimit) throw new Error("wasm_motion_restore_layout");
const bytes = hexToBytes(value.bytes);
if (bytes.length !== this.queueSize) throw new Error("wasm_motion_restore_bytes");
const nextControls = controls(value.controls);
this.core.HEAPU8.fill(0, this.queue, this.queue + this.queueSize);
const initialized = call(this.core, "sim_motion_init", "number", ["number", "number", "number"], [this.queue, this.cycleTime, this.velocityLimit]);
if (initialized !== 0) throw new Error(`wasm_motion_restore_init_${initialized}`);
this.controls = controls();
this.setProfile(nextControls.profile);
this.setLookahead(nextControls.lookahead);
this.setDynamics(nextControls.acceleration_limit, nextControls.jerk_limit);
this.setPaused(nextControls.paused);
this.core.HEAPU8.set(bytes, this.queue);
const actual = this.read();
if (actual.status !== "verified" || fnv1a64(actual.state) !== fnv1a64(value.state)) throw new Error("wasm_motion_restore_state_mismatch");
return this.exportState();
}
destroy() { for (const pointer of [this.queue, this.command, this.state]) if (pointer && typeof this.core._free === "function") this.core._free(pointer); this.queue = this.command = this.state = 0; }
}