Implement AXIS parity UI and execution baseline

This commit is contained in:
2026-08-23 07:28:37 +08:00
parent 1325504544
commit 06f96f85e5
463 changed files with 84893 additions and 692 deletions

View File

@@ -0,0 +1,206 @@
import { fnv1a64 } from "./replay.mjs";
export const AUTHORITATIVE_RUNTIME_SCHEMA = "sim_authoritative_runtime.v1";
const AXES = Object.freeze(["x", "y", "z"]);
const STEP_FRACTION = 0.25;
const KINEMATICS = new Set(["trivkins", "corexy", "tripod", "lineardelta", "rotarydelta"]);
const KINEMATICS_ALIASES = new Map([["corexykins", "corexy"], ["tripodkins", "tripod"], ["lineardeltakins", "lineardelta"], ["rotarydeltakins", "rotarydelta"]]);
const TRIPOD_BASE = Object.freeze([[0, 0], [10, 0], [5, 7.071]]);
const LINEAR_DELTA_RADIUS = 130.25;
const LINEAR_DELTA_ROD = 269;
const ROTARY_DELTA_PLATFORM = 10;
const ROTARY_DELTA_THIGH = 10;
const ROTARY_DELTA_SHIN = 14;
const ROTARY_DELTA_FOOT = 6;
const DEG_TO_RAD = Math.PI / 180;
const RAD_TO_DEG = 180 / Math.PI;
function clone(value) { return structuredClone(value); }
function finite(value) { return typeof value === "number" && Number.isFinite(value); }
function poseFromProgram(program) {
const state = program?.state;
if (!state || !AXES.every((axis) => finite(state[axis]))) return null;
return { x: state.x, y: state.y, z: state.z };
}
function samePose(left, right) { return left && right && AXES.every((axis) => left[axis] === right[axis]); }
function tripodJoints(pose) {
return TRIPOD_BASE.map(([x, y]) => Math.sqrt((pose.x - x) ** 2 + (pose.y - y) ** 2 + pose.z ** 2));
}
function linearDeltaJoints(pose) {
const radius = LINEAR_DELTA_RADIUS;
const rod = LINEAR_DELTA_ROD;
const bases = [[0, radius], [-Math.sqrt(3) * 0.5 * radius, -0.5 * radius], [Math.sqrt(3) * 0.5 * radius, -0.5 * radius]];
const joints = bases.map(([x, y]) => {
const radicand = rod * rod - (x - pose.x) ** 2 - (y - pose.y) ** 2;
return radicand < -1e-12 ? null : pose.z + Math.sqrt(Math.max(0, radicand));
});
return joints.some((value) => value === null || !finite(value)) ? null : joints;
}
function rotaryDeltaJoints(pose) {
if (Math.abs(pose.z) < 1e-12) return null;
const solve = (x, y) => {
const pfr = ROTARY_DELTA_PLATFORM; const tl = ROTARY_DELTA_THIGH; const sl = ROTARY_DELTA_SHIN; const fr = ROTARY_DELTA_FOOT;
const a = 0.5 * (x * x + (y - fr) ** 2 + pose.z ** 2 + tl ** 2 - sl ** 2 - pfr ** 2) / pose.z;
const b = (fr - pfr - y) / pose.z;
const d = tl ** 2 * (b ** 2 + 1) - (a - b * pfr) ** 2;
if (d < -1e-12) return null;
const kneeY = (pfr + a * b + Math.sqrt(Math.max(0, d))) / (b ** 2 + 1);
const kneeZ = b * kneeY - a;
return Math.atan2(kneeZ, kneeY - pfr) * RAD_TO_DEG;
};
const c = Math.cos(-2 * Math.PI / 3); const s = Math.sin(-2 * Math.PI / 3);
const c2 = Math.cos(2 * Math.PI / 3); const s2 = Math.sin(2 * Math.PI / 3);
const leg0 = solve(pose.x, pose.y);
const leg1 = solve(pose.x * c - pose.y * s, pose.x * s + pose.y * c);
const leg2 = solve(pose.x * c2 - pose.y * s2, pose.x * s2 + pose.y * c2);
return [leg0, leg1, leg2].some((value) => value === null || !finite(value)) ? null : [leg0, leg1, leg2];
}
function jointsFor(type, pose) {
if (type === "corexy") return [pose.x + pose.y, pose.x - pose.y, pose.z];
if (type === "tripod") return tripodJoints(pose);
if (type === "lineardelta") return linearDeltaJoints(pose);
if (type === "rotarydelta") return rotaryDeltaJoints(pose);
return [pose.x, pose.y, pose.z];
}
function mappingError(type, pose) { return type === "rotarydelta" && Math.abs(pose.z) < 1e-12 ? "authoritative_kinematics_singular" : "authoritative_kinematics_range"; }
function canonicalKinematicsType(type) { return KINEMATICS_ALIASES.get(type) ?? type; }
export class AuthoritativeRuntime {
constructor() { this.reset(); }
reset() {
const kinematicsType = this.state?.kinematics?.type ?? "trivkins";
this.state = {
schema: AUTHORITATIVE_RUNTIME_SCHEMA,
tick: 0,
status: "idle",
program_status: "idle",
kinematics: { type: kinematicsType, joint_count: 3, pose: { x: 0, y: 0, z: 0 }, joints: [0, 0, 0], status: 0, blocked: null },
planner: { next_id: 1, queue: [], active: null, active_id: null, progress: 0, queue_depth: 0, last_error: 0 },
};
return this.export();
}
export() {
const unsigned = clone(this.state);
return { ...unsigned, state_hash: fnv1a64(unsigned) };
}
hash() { return this.export().state_hash; }
setKinematics(type) {
const selected = canonicalKinematicsType(type);
if (!KINEMATICS.has(selected)) {
this.state.kinematics.blocked = "authoritative_kinematics_unsupported";
return { status: "blocked", code: "authoritative_kinematics_unsupported", type };
}
this.state.kinematics.type = selected;
this.state.kinematics.blocked = null;
const mapped = jointsFor(selected, this.state.kinematics.pose);
if (!mapped && selected === "rotarydelta" && Math.abs(this.state.kinematics.pose.z) < 1e-12) {
this.state.kinematics.type = selected;
this.state.kinematics.blocked = null;
this.state.kinematics.joints = [0, 0, 0];
return { status: "verified", type: selected };
}
if (!mapped) {
const code = mappingError(selected, this.state.kinematics.pose);
this.state.kinematics.blocked = code;
return { status: "blocked", code, type: selected };
}
this.state.kinematics.joints = mapped;
return { status: "verified", type: selected };
}
syncProgram(program) {
const target = poseFromProgram(program);
if (!target) return { status: "blocked", code: "authoritative_program_pose_missing" };
this.state.program_status = program.program_status ?? "idle";
if (this.state.program_status === "aborted") {
this.state.status = "aborted";
this.state.planner.queue = [];
this.state.planner.active_id = null;
this.state.planner.progress = 0;
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);
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";
return { status: "verified", command: "AUTHORITATIVE_SYNC", queue_depth: planner.queue_depth };
}
abort() {
this.state.status = "aborted";
this.state.program_status = "aborted";
this.state.planner.queue = [];
this.state.planner.active = null;
this.state.planner.active_id = null;
this.state.planner.progress = 0;
this.state.planner.queue_depth = 0;
return this.export();
}
step(tick) {
if (!Number.isSafeInteger(tick) || tick < this.state.tick) return { status: "error", code: "authoritative_tick_regression" };
const planner = this.state.planner;
for (let current = this.state.tick + 1; current <= tick; current += 1) {
if (planner.active_id === null && planner.queue.length > 0) {
const next = planner.queue.shift();
planner.active_id = next.id;
planner.active = next;
planner.progress = 0;
}
if (planner.active_id !== null) {
planner.progress = Math.min(1, planner.progress + STEP_FRACTION);
const active = planner.active;
const pose = {};
for (const axis of AXES) pose[axis] = active.start[axis] + (active.end[axis] - active.start[axis]) * planner.progress;
this.state.kinematics.pose = pose;
const mapped = jointsFor(this.state.kinematics.type, pose);
if (!mapped) {
const code = mappingError(this.state.kinematics.type, pose);
this.state.kinematics.blocked = code;
return { status: "error", code };
}
this.state.kinematics.joints = mapped;
if (planner.progress >= 1) {
planner.active_id = null;
planner.active = null;
planner.progress = 0;
}
}
planner.queue_depth = planner.queue.length + (planner.active_id === null ? 0 : 1);
this.state.tick = current;
}
return { status: "verified", state: this.export() };
}
applyMotion(motion) {
if (!motion?.state) return;
const pose = this.state.kinematics.pose;
motion.state.axes = { ...pose };
motion.state.joints = [...this.state.kinematics.joints];
motion.state.tick = this.state.tick;
motion.state.velocity = this.state.planner.active_id === null ? 0 : STEP_FRACTION;
}
restore(value) {
if (!value || value.schema !== AUTHORITATIVE_RUNTIME_SCHEMA) throw new Error("authoritative_restore_schema");
const unsigned = clone(value);
delete unsigned.state_hash;
if (value.state_hash !== fnv1a64(unsigned)) throw new Error("authoritative_restore_hash");
if (!KINEMATICS.has(unsigned.kinematics?.type) || unsigned.kinematics?.joint_count !== 3) throw new Error("authoritative_restore_kinematics");
this.state = unsigned;
return this.export();
}
}

View File

@@ -11,7 +11,10 @@ export const LIMITS = Object.freeze({
export const COMMANDS = Object.freeze([
"SESSION_ATTACH", "LOAD_WORKSPACE", "COMMAND", "SIM_STEP", "SNAPSHOT_REQUEST",
"PROGRAM_PAUSE", "PROGRAM_RESUME", "CHECKPOINT", "RECOVER", "DESTROY",
"PROGRAM_LOAD", "PROGRAM_RUN", "PROGRAM_PAUSE", "PROGRAM_RESUME", "PROGRAM_ABORT",
"PROGRAM_STEP", "PROGRAM_SEEK", "MDI_EXECUTE", "CHECKPOINT", "RECOVER", "DESTROY",
"ENCODER_SET", "COOLANT_COMMAND", "COOLANT_FAULT", "DOOR_SET", "INTERLOCK_SET", "INTERLOCK_RESET", "DEVICE_ESTOP", "DEVICE_ACK_ISSUE", "DEVICE_ACK", "DEVICE_ACK_ABORT",
"CONFIGURATION_BATCH",
]);
export const EXCLUSIONS = Object.freeze([

210
web/worker/contracts.mjs Normal file
View File

@@ -0,0 +1,210 @@
import { fnv1a64 } from "./replay.mjs";
export const CONTRACT_SCHEMA = "sim-contracts.v1";
export const CONTRACT_VERSION = 1;
export const SCHEMAS = Object.freeze({
task: "sim_task.v1",
command: "command-envelope.v1",
snapshot: "snapshot.v1",
event: "event-log.v1",
workspace: "workspace-package.v1",
});
const COMMAND_FIELDS = new Set([
"schema_version", "kind", "context_id", "session_id", "seq",
"operation_id", "simulation_tick", "sent_at_monotonic_ms", "payload",
]);
const TASK_FIELDS = new Set([
"schema", "context_id", "session_id", "state", "mode", "program_status",
"seq", "simulation_tick", "current_line", "state_hash",
]);
const SNAPSHOT_FIELDS = new Set([
"schema", "snapshot_id", "base_snapshot_id", "seq", "tick", "state", "state_hash",
]);
const EVENT_FIELDS = new Set(["seq", "tick", "kind", "payload", "payload_hash"]);
const PACKAGE_V1_FIELDS = new Set([
"schema", "format_version", "context_id", "committed_seq", "checkpoint",
"timeline", "journal", "content_hash", "package_hash",
]);
const PACKAGE_V2_FIELDS = new Set([...PACKAGE_V1_FIELDS, "migration", "integrity"]);
const SIDE_EFFECT_KINDS = new Set([
"SESSION_CREATE", "LOAD_PROJECT", "START", "STOP", "RESET", "LOAD_WORKSPACE", "SIM_STEP", "COMMAND",
"PROGRAM_LOAD", "PROGRAM_RUN", "PROGRAM_PAUSE", "PROGRAM_RESUME", "PROGRAM_ABORT", "PROGRAM_STEP", "PROGRAM_SEEK",
"JOG_START", "JOG_STOP", "HOME", "SET_MODE", "SET_OVERRIDE", "SPINDLE_COMMAND", "COOLANT_COMMAND",
"TOOL_PREPARE", "TOOL_CHANGE", "PROBE_CONFIG", "HAL_COMMAND", "HAL_SET", "PLC_START", "PLC_STOP",
"ENCODER_SET", "COOLANT_COMMAND", "COOLANT_FAULT", "DOOR_SET", "INTERLOCK_SET", "INTERLOCK_RESET", "DEVICE_ESTOP", "DEVICE_ACK_ISSUE", "DEVICE_ACK", "DEVICE_ACK_ABORT",
"CONFIGURATION_BATCH",
"PROJECT_SAVE", "IMPORT", "EXPORT", "CHECKPOINT", "RECOVER", "SUBSCRIBE", "UNSUBSCRIBE", "CANCEL",
]);
function fail(code) {
throw new Error(code);
}
function object(value, code) {
if (!value || typeof value !== "object" || Array.isArray(value)) fail(code);
return value;
}
function string(value, code, max = 256) {
if (typeof value !== "string" || value.length === 0 || value.length > max) fail(code);
return value;
}
function integer(value, code, positive = false) {
if (!Number.isSafeInteger(value) || value < (positive ? 1 : 0)) fail(code);
return value;
}
function finite(value, code) {
if (typeof value !== "number" || !Number.isFinite(value)) fail(code);
return value;
}
function exactFields(value, allowed, code) {
for (const key of Object.keys(value)) if (!allowed.has(key)) fail(code);
}
function noNonFinite(value, code = "contract_nonfinite") {
if (typeof value === "number") {
if (!Number.isFinite(value)) fail(code);
return;
}
if (Array.isArray(value)) {
for (const item of value) noNonFinite(item, code);
return;
}
if (value && typeof value === "object") {
for (const item of Object.values(value)) noNonFinite(item, code);
}
}
function hashUnsigned(value, field) {
const unsigned = { ...value };
delete unsigned[field];
return fnv1a64(unsigned);
}
export function validateCommandEnvelope(value, { contextId, sessionId } = {}) {
const envelope = object(value, "contract_command_object");
exactFields(envelope, COMMAND_FIELDS, "contract_command_unknown_field");
if (envelope.schema_version !== CONTRACT_VERSION) fail("contract_command_schema");
string(envelope.kind, "contract_command_kind", 128);
string(envelope.context_id, "contract_command_context");
string(envelope.session_id, "contract_command_session");
integer(envelope.seq, "contract_command_seq", true);
if (envelope.operation_id !== undefined) string(envelope.operation_id, "contract_command_operation", 128);
if (SIDE_EFFECT_KINDS.has(envelope.kind) && envelope.operation_id === undefined) fail("contract_command_operation_required");
if (envelope.simulation_tick !== undefined) integer(envelope.simulation_tick, "contract_command_tick");
if (envelope.sent_at_monotonic_ms !== undefined) finite(envelope.sent_at_monotonic_ms, "contract_command_sent_at");
object(envelope.payload, "contract_command_payload");
noNonFinite(envelope.payload);
if (contextId !== undefined && envelope.context_id !== contextId) fail("contract_command_context_mismatch");
if (sessionId !== undefined && envelope.session_id !== sessionId) fail("contract_command_session_mismatch");
return structuredClone(envelope);
}
export function validateSimTask(value, { contextId, sessionId } = {}) {
const task = object(value, "contract_task_object");
exactFields(task, TASK_FIELDS, "contract_task_unknown_field");
if (task.schema !== SCHEMAS.task) fail("contract_task_schema");
string(task.context_id, "contract_task_context");
string(task.session_id, "contract_task_session");
if (!new Set(["NEW", "READY", "RUNNING", "PAUSED", "ESTOP", "FAULT", "STOPPED", "DESTROYED"]).has(task.state)) fail("contract_task_state");
if (!new Set(["MANUAL", "MDI", "AUTO"]).has(task.mode)) fail("contract_task_mode");
if (!new Set(["idle", "loaded", "running", "paused", "completed", "aborted", "fault"]).has(task.program_status)) fail("contract_task_program_status");
integer(task.seq, "contract_task_seq", true);
integer(task.simulation_tick, "contract_task_tick");
if (task.current_line !== null) integer(task.current_line, "contract_task_line");
noNonFinite(task);
if (typeof task.state_hash !== "string" || task.state_hash !== hashUnsigned(task, "state_hash")) fail("contract_task_hash");
if (contextId !== undefined && task.context_id !== contextId) fail("contract_task_context_mismatch");
if (sessionId !== undefined && task.session_id !== sessionId) fail("contract_task_session_mismatch");
return structuredClone(task);
}
export function validateSnapshot(value) {
const snapshot = object(value, "contract_snapshot_object");
exactFields(snapshot, SNAPSHOT_FIELDS, "contract_snapshot_unknown_field");
if (snapshot.schema !== SCHEMAS.snapshot) fail("contract_snapshot_schema");
string(snapshot.snapshot_id, "contract_snapshot_id");
if (snapshot.base_snapshot_id !== null) string(snapshot.base_snapshot_id, "contract_snapshot_base");
integer(snapshot.seq, "contract_snapshot_seq", true);
integer(snapshot.tick, "contract_snapshot_tick");
if (snapshot.state === undefined) fail("contract_snapshot_state");
noNonFinite(snapshot.state);
if (typeof snapshot.state_hash !== "string" || snapshot.state_hash !== fnv1a64(snapshot.state)) fail("contract_snapshot_hash");
if (snapshot.snapshot_id !== `snap:${snapshot.seq}:${snapshot.state_hash}`) fail("contract_snapshot_id_mismatch");
return structuredClone(snapshot);
}
export function validateEventLog(value) {
const log = object(value, "contract_event_log_object");
if (log.schema !== SCHEMAS.event || !Array.isArray(log.events)) fail("contract_event_log_schema");
integer(log.cursor, "contract_event_cursor");
integer(log.tick, "contract_event_tick");
let previousTick = 0;
for (let index = 0; index < log.events.length; index += 1) {
const event = object(log.events[index], "contract_event_object");
exactFields(event, EVENT_FIELDS, "contract_event_unknown_field");
if (event.seq !== index + 1) fail("contract_event_sequence");
integer(event.seq, "contract_event_seq", true);
integer(event.tick, "contract_event_tick");
if (event.tick < previousTick) fail("contract_event_tick_regression");
previousTick = event.tick;
string(event.kind, "contract_event_kind", 64);
object(event.payload, "contract_event_payload");
noNonFinite(event.payload);
if (event.payload_hash !== fnv1a64(event.payload)) fail("contract_event_payload_hash");
}
if (log.cursor !== (log.events.length === 0 ? 0 : log.events.at(-1).seq)) fail("contract_event_cursor_mismatch");
if (log.tick !== (log.events.length === 0 ? 0 : log.events.at(-1).tick)) fail("contract_event_tick_mismatch");
return structuredClone(log);
}
function validateTimeline(value) {
const timeline = object(value, "contract_workspace_timeline");
if (timeline.schema !== "replay-timeline.v1" || !timeline.events || !timeline.snapshots) fail("contract_workspace_timeline_schema");
validateEventLog(timeline.events);
const snapshots = object(timeline.snapshots, "contract_workspace_snapshots");
if (snapshots.schema !== SCHEMAS.snapshot || !Array.isArray(snapshots.snapshots)) fail("contract_workspace_snapshots_schema");
const seen = new Set();
for (const snapshot of snapshots.snapshots) {
validateSnapshot(snapshot);
if (snapshot.base_snapshot_id !== null && !seen.has(snapshot.base_snapshot_id)) fail("contract_workspace_snapshot_base_missing");
if (seen.has(snapshot.snapshot_id)) fail("contract_workspace_snapshot_duplicate");
seen.add(snapshot.snapshot_id);
}
return timeline;
}
export function validateWorkspacePackage(value, { contextId } = {}) {
const pkg = object(value, "contract_workspace_object");
if (pkg.schema !== SCHEMAS.workspace) fail("contract_workspace_schema");
integer(pkg.format_version, "contract_workspace_format");
if (pkg.format_version !== 1 && pkg.format_version !== 2) fail("contract_workspace_format_unsupported");
exactFields(pkg, pkg.format_version === 1 ? PACKAGE_V1_FIELDS : PACKAGE_V2_FIELDS, "contract_workspace_unknown_field");
string(pkg.context_id, "contract_workspace_context");
integer(pkg.committed_seq, "contract_workspace_committed_seq");
validateTimeline(pkg.timeline);
if (pkg.committed_seq > pkg.timeline.events.cursor) fail("contract_workspace_committed_seq_ahead");
if (!Array.isArray(pkg.journal)) fail("contract_workspace_journal");
if (pkg.checkpoint !== null && pkg.checkpoint !== undefined) object(pkg.checkpoint, "contract_workspace_checkpoint");
noNonFinite(pkg);
if (pkg.content_hash !== hashUnsigned({
context_id: pkg.context_id,
committed_seq: pkg.committed_seq,
checkpoint: pkg.checkpoint ?? null,
journal: pkg.journal ?? [],
timeline: pkg.timeline,
}, "ignored")) fail("contract_workspace_content_hash");
if (pkg.package_hash !== hashUnsigned(pkg, "package_hash")) fail("contract_workspace_package_hash");
if (pkg.format_version === 2) {
if (!pkg.migration || pkg.migration.from_version !== 1 || pkg.migration.to_version !== 2 || pkg.migration.lossless !== true || typeof pkg.migration.source_package_hash !== "string") fail("contract_workspace_migration");
if (!pkg.integrity || pkg.integrity.timeline_hash !== fnv1a64(pkg.timeline) || pkg.integrity.journal_hash !== fnv1a64(pkg.journal) || pkg.integrity.checkpoint_hash !== (pkg.checkpoint ? fnv1a64(pkg.checkpoint) : null)) fail("contract_workspace_integrity");
}
if (contextId !== undefined && pkg.context_id !== contextId) fail("contract_workspace_context_mismatch");
return structuredClone(pkg);
}
export { COMMAND_FIELDS, EVENT_FIELDS, SNAPSHOT_FIELDS, TASK_FIELDS };

173
web/worker/device-io.mjs Normal file
View File

@@ -0,0 +1,173 @@
import { fnv1a64 } from "./replay.mjs";
export const DEVICE_IO_SCHEMA = "sim_device_io.v1";
const MAX_ENCODERS = 8;
const MAX_ACKS = 64;
const clone = (value) => structuredClone(value);
const finite = (value) => typeof value === "number" && Number.isFinite(value);
function encoder() {
return { registered: true, speed_rps: 0, ppr: 1000, scale: 1, raw_count: 0, count: 0, position: 0, velocity: 0, phase_a: 1, phase_b: 0, phase_z: 1, index_enable: false, index_pulse: false, tick: 0, phase: 0 };
}
export class DeviceIoRuntime {
constructor(options = {}) { this.reset(options); }
reset({ encoder_count = 1, cycle_time = 0.1 } = {}) {
if (!Number.isSafeInteger(encoder_count) || encoder_count < 1 || encoder_count > MAX_ENCODERS || !finite(cycle_time) || cycle_time <= 0 || cycle_time > 10) throw new Error("device_io_config");
this.state = {
schema: DEVICE_IO_SCHEMA,
tick: 0,
cycle_time,
encoders: Array.from({ length: encoder_count }, encoder),
coolant: { mist_requested: false, flood_requested: false, lube_requested: false, mist: false, flood: false, lube: false, fault: false },
safety: { door_open: false, input_ok: true, estop: false, fault: false, interlock_ok: true, latched_fault: false, recovery_epoch: 0 },
acks: [],
last_error: "device_io_ok",
};
return this.export();
}
export() { const unsigned = clone(this.state); delete unsigned.state_hash; return { ...unsigned, state_hash: fnv1a64(unsigned) }; }
hash() { return this.export().state_hash; }
ok(command = "DEVICE_IO") { this.state.last_error = "device_io_ok"; return { status: "verified", command, state: this.export() }; }
fail(code, detail = null) { this.state.last_error = code; return { status: "blocked", code, ...(detail ? { detail } : {}), state: this.export() }; }
updateSafety() {
const safety = this.state.safety;
safety.fault = Boolean(safety.door_open || !safety.input_ok);
if (safety.fault || safety.estop) safety.latched_fault = true;
safety.interlock_ok = !safety.fault && !safety.estop && !safety.latched_fault;
}
updateCoolant() {
const coolant = this.state.coolant;
const allowed = this.state.safety.interlock_ok && !coolant.fault;
coolant.mist = allowed && coolant.mist_requested;
coolant.flood = allowed && coolant.flood_requested;
coolant.lube = allowed && coolant.lube_requested;
}
configure(options = {}) {
try { this.reset(options); return this.ok("DEVICE_CONFIG"); } catch (error) { return this.fail(error.message); }
}
encoderSet(channel, { speed_rps, ppr, scale, index_enable, reset = false } = {}) {
const item = this.state.encoders[channel];
if (!item || !finite(speed_rps ?? item.speed_rps) || Math.abs(speed_rps ?? item.speed_rps) > 1000000) return this.fail("device_encoder_speed");
if (ppr !== undefined && (!Number.isSafeInteger(ppr) || ppr < 1 || ppr > 100000000)) return this.fail("device_encoder_ppr");
if (scale !== undefined && (!finite(scale) || scale <= 0)) return this.fail("device_encoder_scale");
if (speed_rps !== undefined) item.speed_rps = speed_rps;
if (ppr !== undefined) item.ppr = ppr;
if (scale !== undefined) item.scale = scale;
if (index_enable !== undefined) { if (typeof index_enable !== "boolean") return this.fail("device_encoder_index"); item.index_enable = index_enable; }
if (reset === true) { item.raw_count = 0; item.count = 0; item.position = 0; item.phase = 0; item.index_pulse = false; }
return this.ok("ENCODER_SET");
}
coolantCommand({ mist = false, flood = false, lube = false } = {}) {
if (![mist, flood, lube].every((value) => typeof value === "boolean")) return this.fail("device_coolant_argument");
if ((mist || flood || lube) && !this.state.safety.interlock_ok) return this.fail("device_interlock");
Object.assign(this.state.coolant, { mist_requested: mist, flood_requested: flood, lube_requested: lube });
this.updateCoolant();
return this.ok("COOLANT_COMMAND");
}
coolantFault(fault) {
if (typeof fault !== "boolean") return this.fail("device_coolant_fault");
this.state.coolant.fault = fault; this.updateCoolant();
return fault ? this.fail("device_coolant_fault") : this.ok("COOLANT_FAULT_CLEAR");
}
doorSet(open) {
if (typeof open !== "boolean") return this.fail("device_door_argument");
this.state.safety.door_open = open; this.updateSafety(); this.updateCoolant();
return open ? this.fail("device_interlock") : this.ok("DOOR_SET");
}
interlockSet(inputOk) {
if (typeof inputOk !== "boolean") return this.fail("device_interlock_argument");
this.state.safety.input_ok = inputOk; this.updateSafety(); this.updateCoolant();
return inputOk ? this.ok("INTERLOCK_SET") : this.fail("device_interlock");
}
interlockReset() {
this.updateSafety();
if (this.state.safety.door_open || !this.state.safety.input_ok || this.state.safety.estop) return this.fail("device_interlock");
this.state.safety.latched_fault = false; this.state.safety.recovery_epoch += 1;
this.updateSafety(); this.updateCoolant();
return this.ok("INTERLOCK_RESET");
}
estop(active) {
if (typeof active !== "boolean") return this.fail("device_estop_argument");
this.state.safety.estop = active; this.updateSafety(); this.updateCoolant();
return active ? this.fail("device_interlock") : this.ok("DEVICE_ESTOP");
}
issueAck(operation_id, timeout_ticks = 10) {
if (typeof operation_id !== "string" || operation_id.length === 0 || !Number.isSafeInteger(timeout_ticks) || timeout_ticks < 1 || timeout_ticks > 1000000) return this.fail("device_ack_argument");
const existing = this.state.acks.find((entry) => entry.operation_id === operation_id);
if (existing) return this.fail("device_ack_duplicate", { command: clone(existing) });
if (this.state.acks.length >= MAX_ACKS) return this.fail("device_ack_capacity");
const command = { operation_id, issued_tick: this.state.tick, deadline_tick: this.state.tick + timeout_ticks, status: "pending" };
this.state.acks.push(command);
return this.ok("DEVICE_ACK_ISSUE");
}
ack(operation_id) {
const command = this.state.acks.find((entry) => entry.operation_id === operation_id);
if (!command) return this.fail("device_ack_missing");
if (command.status !== "pending") return this.fail(command.status === "timed_out" ? "device_ack_late" : "device_ack_state", { command: clone(command) });
command.status = "acked";
return this.ok("DEVICE_ACK");
}
abortAck(operation_id) {
const command = this.state.acks.find((entry) => entry.operation_id === operation_id);
if (!command) return this.fail("device_ack_missing");
if (command.status === "acked") return this.fail("device_ack_too_late", { command: clone(command) });
command.status = "aborted";
return this.ok("DEVICE_ACK_ABORT");
}
advanceAck(tick) {
for (const command of this.state.acks) if (command.status === "pending" && tick >= command.deadline_tick) command.status = "timed_out";
}
ackStep(tick) {
if (!Number.isSafeInteger(tick) || tick < this.state.tick) return this.fail("device_tick_regression");
this.state.tick = tick; this.advanceAck(tick); return this.ok("DEVICE_ACK_TICK");
}
step(tick) {
if (!Number.isSafeInteger(tick) || tick < this.state.tick) return this.fail("device_tick_regression");
const before = clone(this.state);
try {
for (let current = this.state.tick + 1; current <= tick; current += 1) {
this.state.tick = current; this.updateSafety(); this.updateCoolant();
for (const item of this.state.encoders) {
const delta = item.speed_rps * item.ppr * 4 * this.state.cycle_time;
item.phase += delta;
if (!finite(item.phase) || Math.abs(item.phase) > 2147483647) throw new Error("device_encoder_range");
item.raw_count = item.phase < 0 ? Math.ceil(item.phase) : Math.floor(item.phase);
item.count = item.raw_count; item.position = item.raw_count / item.scale; item.velocity = item.speed_rps * item.ppr * 4 / item.scale;
const phase = ((item.raw_count % 4) + 4) % 4;
item.phase_a = phase === 0 || phase === 1; item.phase_b = phase === 1 || phase === 2; item.index_pulse = false;
if (item.index_enable && item.raw_count % (item.ppr * 4) === 0) { item.phase_z = 1; item.index_pulse = true; item.index_enable = false; } else item.phase_z = 0;
item.tick = current;
}
this.advanceAck(current);
}
return this.ok("DEVICE_STEP");
} catch (error) { this.state = before; return this.fail(error.message); }
}
restore(value) {
if (!value || value.schema !== DEVICE_IO_SCHEMA) throw new Error("device_io_restore_schema");
const unsigned = clone(value); delete unsigned.state_hash;
if (value.state_hash !== fnv1a64(unsigned)) throw new Error("device_io_restore_hash");
if (!Array.isArray(unsigned.encoders) || unsigned.encoders.length > MAX_ENCODERS || !Array.isArray(unsigned.acks) || unsigned.acks.length > MAX_ACKS) throw new Error("device_io_restore_capacity");
this.state = unsigned; return this.export();
}
}

View File

@@ -0,0 +1,58 @@
import { fnv1a64 } from "./replay.mjs";
export const ENDPOINT_REPLAY_SCHEMA = "endpoint-replay.v1";
const clone = (value) => structuredClone(value);
const unsigned = (value) => { const copy = clone(value); delete copy.state_hash; return copy; };
export function firstDifference(expected, actual, path = "$") {
if (Object.is(expected, actual)) return null;
if (typeof expected !== typeof actual) return { path, expected, actual };
if (expected === null || actual === null || typeof expected !== "object") return { path, expected, actual };
if (Array.isArray(expected) !== Array.isArray(actual)) return { path, expected, actual };
if (Array.isArray(expected)) {
if (expected.length !== actual.length) return { path: `${path}.length`, expected: expected.length, actual: actual.length };
for (let index = 0; index < expected.length; index += 1) {
const difference = firstDifference(expected[index], actual[index], `${path}[${index}]`);
if (difference) return difference;
}
return null;
}
const keys = [...new Set([...Object.keys(expected), ...Object.keys(actual)])].sort();
for (const key of keys) {
const difference = firstDifference(expected[key], actual[key], `${path}.${key}`);
if (difference) return difference;
}
return null;
}
export function endpointHash(endpoint) {
return fnv1a64(endpoint);
}
export function compareEndpoints(expected, actual) {
const difference = firstDifference(expected, actual);
return { equal: difference === null, difference, expected_hash: endpointHash(expected), actual_hash: endpointHash(actual) };
}
export function createEndpoint({ session, includeTimeline = false } = {}) {
if (!session) throw new Error("endpoint_session_required");
const task = session.task.export();
const scheduler = session.scheduler.export();
const endpoint = {
tick: session.tick,
task: { schema: task.schema, state: task.state, mode: task.mode, program_status: task.program_status, simulation_tick: task.simulation_tick, current_line: task.current_line },
scheduler: { schema: scheduler.schema, budget: scheduler.budget, phase_trace: scheduler.phase_trace, tick: scheduler.tick },
authoritative: unsigned(session.authoritative.export()),
motion: unsigned(session.motion.export()),
plc: unsigned(session.plc.export()),
hal_runtime: unsigned(session.hal.export()),
device_io: unsigned(session.deviceIo.export()),
configuration_batch: unsigned(session.configurationBatch.export()),
io_ack: unsigned(session.ioAck.export()),
callbacks: unsigned(session.callbacks.export()),
python: session.python.exportState(),
};
if (includeTimeline) endpoint.timeline = session.timeline.export();
return clone(endpoint);
}

266
web/worker/hal-runtime.mjs Normal file
View File

@@ -0,0 +1,266 @@
import { fnv1a64 } from "./replay.mjs";
export const HAL_RUNTIME_SCHEMA = "sim_hal_runtime.v1";
const MAX_PIN_COUNT = 256;
const MAX_PARAM_COUNT = 256;
const MAX_COMPONENT_COUNT = 64;
const MAX_THREAD_COUNT = 16;
const MAX_PERIOD_TICKS = 1000000;
const TYPE_NAMES = new Set(["bit", "s32", "u32", "float"]);
const NAME_RE = /^[A-Za-z_][A-Za-z0-9_.-]{0,46}$/;
const SPEC = Object.freeze({
corexy_by_hal: { pins: { "j0-motor-pos-cmd": "float", "j1-motor-pos-cmd": "float", "alpha-cmd": "float", "beta-cmd": "float", "alpha-fb": "float", "beta-fb": "float", "j0-motor-pos-fb": "float", "j1-motor-pos-fb": "float" }, params: {} },
constant: { pins: { out: "float" }, params: { value: ["float", 0] } },
and2: { pins: { in0: "bit", in1: "bit", out: "bit" }, params: {} },
or2: { pins: { in0: "bit", in1: "bit", out: "bit" }, params: {} },
xor2: { pins: { in0: "bit", in1: "bit", out: "bit" }, params: {} },
not: { pins: { in: "bit", out: "bit" }, params: {} },
abs: { pins: { in: "float", out: "float", sign: "bit", "is-positive": "bit", "is-negative": "bit" }, params: {} },
abs_s32: { pins: { in: "s32", out: "s32", sign: "bit", "is-positive": "bit", "is-negative": "bit" }, params: {} },
lowpass: { pins: { in: "float", out: "float", load: "bit" }, params: { gain: ["float", 0] } },
ddt: { pins: { in: "float", out: "float" }, params: {} },
integ: { pins: { in: "float", gain: "float", out: "float", reset: "bit", max: "float", min: "float" }, params: {} },
mux2: { pins: { sel: "bit", out: "float", in1: "float", in0: "float" }, params: {} },
sum2: { pins: { in0: "float", in1: "float", out: "float" }, params: { gain0: ["float", 1], gain1: ["float", 1], offset: ["float", 0] } },
mult2: { pins: { in0: "float", in1: "float", out: "float" }, params: {} },
div2: { pins: { in0: "float", in1: "float", out: "float" }, params: { deadband: ["float", 0] } },
hypot: { pins: { in0: "float", in1: "float", in2: "float", out: "float" }, params: {} },
near: { pins: { in1: "float", in2: "float", out: "bit" }, params: { scale: ["float", 1], difference: ["float", 0] } },
comp: { pins: { in0: "float", in1: "float", out: "bit", equal: "bit" }, params: { hyst: ["float", 0] } },
invert: { pins: { in: "float", out: "float" }, params: { deadband: ["float", 0] } },
edge: { pins: { in: "bit", out: "bit", "out-invert": "bit" }, params: { both: ["bit", 0], "in-edge": ["bit", 1], "out-width-ns": ["s32", 0], "time-left-ns": ["s32", 0], "last-in": ["bit", 0] } },
toggle: { pins: { in: "bit", out: "bit" }, params: { debounce: ["u32", 2] } },
estop_latch: { pins: { "ok-in": "bit", "fault-in": "bit", reset: "bit", "ok-out": "bit", "fault-out": "bit", watchdog: "bit" }, params: {} },
});
const clone = (value) => structuredClone(value);
const validName = (value) => typeof value === "string" && NAME_RE.test(value);
const finite = (value) => typeof value === "number" && Number.isFinite(value);
const normalizeType = (value) => value === "signed" ? "s32" : value === "unsigned" ? "u32" : value;
function valueFor(type, value = 0) {
if (!TYPE_NAMES.has(type) || (type === "bit" && ![0, 1, false, true].includes(value)) || !finite(type === "bit" ? Number(value) : value)) throw new Error("hal_value_invalid");
if (type === "bit") return value ? 1 : 0;
if (type === "s32") {
if (!Number.isSafeInteger(value) || value < -2147483648 || value > 2147483647) throw new Error("hal_s32_range");
return value;
}
if (type === "u32") {
if (!Number.isSafeInteger(value) || value < 0 || value > 4294967295) throw new Error("hal_u32_range");
return value;
}
return Number(value);
}
function qualified(instance, suffix) {
if (!validName(instance) || !validName(suffix) || instance.length + suffix.length + 1 > 47) throw new Error("hal_name_invalid");
return `${instance}.${suffix}`;
}
export function parseCompSource(source) {
if (typeof source !== "string" || source.length === 0 || source.length > 1024 * 1024) return { status: "blocked", code: "hal_comp_source_invalid" };
const lines = source.split(/\r?\n/);
let component = null;
const pins = [];
const params = [];
const functions = [];
const options = [];
const errors = [];
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index].replace(/\/\/.*$/, "").trim();
let match = /^component\s+([A-Za-z_][A-Za-z0-9_]*)\b/.exec(line);
if (match) { if (component && component !== match[1]) errors.push(`duplicate_component:${index + 1}`); component = match[1]; continue; }
match = /^pin\s+(in|out|io)\s+([A-Za-z_][A-Za-z0-9_]*)\s+([A-Za-z_][A-Za-z0-9_.#-]*)/.exec(line);
if (match) { const type = normalizeType(match[2]); if (!TYPE_NAMES.has(type)) errors.push(`hal_type_unsupported:${index + 1}`); pins.push({ direction: match[1], type, name: match[3], line: index + 1 }); continue; }
match = /^param\s+(r|rw)\s+([A-Za-z_][A-Za-z0-9_]*)\s+([A-Za-z_][A-Za-z0-9_.-]*)(?:\s*=\s*([^\s]+))?/.exec(line);
if (match) { const type = normalizeType(match[2]); if (!TYPE_NAMES.has(type)) errors.push(`hal_type_unsupported:${index + 1}`); params.push({ mode: match[1], type, name: match[3], default: match[4] ?? null, line: index + 1 }); continue; }
match = /^function\s+([^;]+);/.exec(line);
if (match) functions.push(...match[1].split(/\s+/).filter(Boolean).map((name) => ({ name, line: index + 1 })));
match = /^option\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s*=\s*([^\s]+))?/.exec(line);
if (match) options.push({ name: match[1], value: match[2] ?? null, line: index + 1 });
}
if (!component) errors.push("hal_component_missing");
const names = [...pins, ...params].map((item) => item.name);
if (new Set(names).size !== names.length) errors.push("hal_declaration_duplicate");
if (component && !SPEC[component]) errors.push("hal_component_unsupported");
return { schema: "hal-component-source.v1", status: errors.length ? "blocked" : "verified", ...(errors.length ? { code: errors[0].split(":", 1)[0] } : {}), component, pins, params, functions, options, errors };
}
export class HalRuntime {
constructor() { this.reset(); }
reset() {
this.state = { schema: HAL_RUNTIME_SCHEMA, tick: 0, threads: {}, pins: {}, params: {}, components: [], links: [], last_error: null };
return this.export();
}
export() { const unsigned = clone(this.state); return { ...unsigned, state_hash: fnv1a64(unsigned) }; }
hash() { return this.export().state_hash; }
fail(code, detail = null) { this.state.last_error = code; return { status: "error", code, ...(detail ? { detail } : {}), state: this.export() }; }
ok(command) { this.state.last_error = null; return { status: "verified", command, state: this.export() }; }
registerThread(name, period_ticks) {
if (!validName(name) || !Number.isSafeInteger(period_ticks) || period_ticks < 1 || period_ticks > MAX_PERIOD_TICKS) return this.fail("hal_thread_invalid");
if (this.state.threads[name]) return this.fail("hal_thread_duplicate");
if (Object.keys(this.state.threads).length >= MAX_THREAD_COUNT) return this.fail("hal_thread_capacity");
this.state.threads[name] = { name, period_ticks };
return this.ok("HAL_THREAD");
}
registerComponent(instance, type, thread = "servo-thread") {
if (!validName(instance) || !validName(type) || !validName(thread)) return this.fail("hal_component_name");
if (!SPEC[type]) return this.fail("hal_component_unsupported");
if (!this.state.threads[thread]) return this.fail("hal_thread_missing");
if (this.state.components.some((component) => component.instance === instance)) return this.fail("hal_component_duplicate");
if (this.state.components.length >= MAX_COMPONENT_COUNT) return this.fail("hal_component_capacity");
const spec = SPEC[type];
const before = clone(this.state);
try {
const component = { instance, type, thread, calls: 0, failed: false, memory: [0, 0, 0, 0], flags: [0, 0, 0, 0] };
for (const [suffix, pinType] of Object.entries(spec.pins)) {
const name = qualified(instance, suffix);
if (this.state.pins[name] || this.state.params[name]) throw new Error("hal_pin_duplicate");
if (Object.keys(this.state.pins).length >= MAX_PIN_COUNT) throw new Error("hal_pin_capacity");
this.state.pins[name] = { name, type: pinType, value: valueFor(pinType) };
}
for (const [suffix, [paramType, initial]] of Object.entries(spec.params)) {
const name = qualified(instance, suffix);
if (this.state.pins[name] || this.state.params[name]) throw new Error("hal_param_duplicate");
if (Object.keys(this.state.params).length >= MAX_PARAM_COUNT) throw new Error("hal_param_capacity");
this.state.params[name] = { name, type: paramType, value: valueFor(paramType, initial) };
}
if (type === "integ") {
this.state.pins[qualified(instance, "gain")].value = 1;
this.state.pins[qualified(instance, "max")].value = 1e20;
this.state.pins[qualified(instance, "min")].value = -1e20;
}
if (type === "estop_latch") {
this.state.pins[qualified(instance, "ok-in")].value = 1;
this.state.pins[qualified(instance, "fault-out")].value = 1;
}
this.state.components.push(component);
return this.ok("HAL_COMPONENT");
} catch (error) {
this.state = before;
return this.fail(error.message);
}
}
configure({ threads = [], components = [], links = [] } = {}) {
if (!Array.isArray(threads) || !Array.isArray(components) || !Array.isArray(links)) return this.fail("hal_config_invalid");
this.reset();
if (!threads.length) threads = [{ name: "servo-thread", period_ticks: 1 }];
for (const thread of threads) { const result = this.registerThread(thread?.name, thread?.period_ticks); if (result.status === "error") return result; }
for (const component of components) {
const instances = Array.isArray(component?.instances) && component.instances.length ? component.instances : [component?.instance ?? component?.name ?? component?.module];
for (const instance of instances) {
const result = this.registerComponent(instance, component?.type ?? component?.module, component?.thread ?? "servo-thread");
if (result.status === "error") return result;
}
}
for (const link of links) { const result = this.connect(link?.source, link?.target); if (result.status === "error") return result; }
return this.ok("HAL_CONFIG");
}
setValue(collection, name, value, type = undefined) {
const entry = this.state[collection][name];
if (!entry) return this.fail("hal_value_missing");
const normalized = value && typeof value === "object" && !Array.isArray(value) ? value.value : value;
const requestedType = value && typeof value === "object" && !Array.isArray(value) ? value.type : type;
if (requestedType !== undefined && normalizeType(requestedType) !== entry.type) return this.fail("hal_value_type");
try { entry.value = valueFor(entry.type, normalized); } catch (error) { return this.fail(error.message); }
return this.ok(collection === "pins" ? "HAL_SET_PIN" : "HAL_SET_PARAM");
}
setPin(name, value, type = undefined) { return this.setValue("pins", name, value, type); }
setParam(name, value, type = undefined) { return this.setValue("params", name, value, type); }
getPin(name) { return this.state.pins[name] ? clone(this.state.pins[name]) : null; }
getParam(name) { return this.state.params[name] ? clone(this.state.params[name]) : null; }
connect(source, target) {
if (!this.state.pins[source] || !this.state.pins[target]) return this.fail("hal_connection_missing");
if (this.state.pins[source].type !== this.state.pins[target].type) return this.fail("hal_connection_type");
if (this.state.links.some((link) => link.source === source && link.target === target)) return this.fail("hal_connection_duplicate");
this.state.links.push({ source, target });
this.state.pins[target].value = this.state.pins[source].value;
return this.ok("HAL_CONNECT");
}
setFailure(instance, failed = true) {
const component = this.state.components.find((item) => item.instance === instance);
if (!component) return this.fail("hal_component_missing");
component.failed = Boolean(failed);
return this.ok("HAL_FAILURE");
}
readPin(component, suffix) { return this.state.pins[qualified(component.instance, suffix)].value; }
writePin(component, suffix, value) { const pin = this.state.pins[qualified(component.instance, suffix)]; pin.value = valueFor(pin.type, value); }
readParam(component, suffix) { return this.state.params[qualified(component.instance, suffix)].value; }
writeParam(component, suffix, value) { const param = this.state.params[qualified(component.instance, suffix)]; param.value = valueFor(param.type, value); }
runComponent(component) {
const p = (suffix) => this.readPin(component, suffix);
const w = (suffix, value) => this.writePin(component, suffix, value);
const q = (suffix) => this.readParam(component, suffix);
switch (component.type) {
case "corexy_by_hal": w("j0-motor-pos-fb", p("alpha-fb") + p("beta-fb")); w("j1-motor-pos-fb", p("alpha-fb") - p("beta-fb")); w("alpha-cmd", 0.5 * (p("j0-motor-pos-cmd") + p("j1-motor-pos-cmd"))); w("beta-cmd", 0.5 * (p("j0-motor-pos-cmd") - p("j1-motor-pos-cmd"))); break;
case "constant": w("out", q("value")); break;
case "and2": w("out", p("in0") && p("in1")); break;
case "or2": w("out", p("in0") || p("in1")); break;
case "xor2": w("out", Boolean(p("in0") !== p("in1"))); break;
case "not": w("out", !p("in")); break;
case "abs": { const value = p("in"); w("out", Math.abs(value)); w("sign", value < 0); w("is-positive", value > 0.000001); w("is-negative", value < -0.000001); break; }
case "abs_s32": { const value = p("in"); if (value === -2147483648) throw new Error("hal_s32_range"); w("out", Math.abs(value)); w("sign", value < 0); w("is-positive", value > 0); w("is-negative", value < 0); break; }
case "lowpass": { const input = p("in"); const output = p("out"); w("out", p("load") ? input : output + (input - output) * q("gain")); break; }
case "ddt": { const input = p("in"); w("out", input - component.memory[0]); component.memory[0] = input; break; }
case "integ": { const input = p("in"); component.memory[0] = p("reset") ? 0 : component.memory[0] + p("gain") * input; component.memory[0] = Math.min(p("max"), Math.max(p("min"), component.memory[0])); w("out", component.memory[0]); break; }
case "mux2": w("out", p("sel") ? p("in1") : p("in0")); break;
case "sum2": w("out", p("in0") * q("gain0") + p("in1") * q("gain1") + q("offset")); break;
case "mult2": w("out", p("in0") * p("in1")); break;
case "div2": { let divisor = p("in1"); const deadband = Math.max(1e-12, q("deadband")); if (Math.abs(divisor) < deadband) divisor = divisor < 0 ? -deadband : deadband; w("out", p("in0") / divisor); break; }
case "hypot": w("out", Math.hypot(p("in0"), p("in1"), p("in2"))); break;
case "near": { let left = p("in1"); let right = p("in2"); if (left < 0) { left = -left; right = -right; } w("out", (q("scale") > 1 && left / q("scale") <= right && right <= left * q("scale")) || Math.abs(left - right) <= q("difference")); break; }
case "comp": { const difference = p("in1") - p("in0"); const half = 0.5 * q("hyst"); if (difference < -half) { w("out", 0); w("equal", 0); } else if (difference > half) { w("out", 1); w("equal", 0); } else w("equal", 1); break; }
case "invert": { const deadband = Math.max(1e-12, q("deadband")); let input = p("in"); if (Math.abs(input) < deadband) input = input < 0 ? -deadband : deadband; w("out", 1 / input); break; }
case "edge": { const input = p("in"); const previous = q("last-in"); if (!component.flags[0]) { component.flags[0] = 1; w("out", 0); w("out-invert", 1); } else { const rise = input && !previous; const fall = !input && previous; const hit = q("both") ? rise || fall : q("in-edge") ? fall : rise; w("out", hit); w("out-invert", !hit); } this.writeParam(component, "last-in", input); break; }
case "toggle": { const input = p("in"); let output = p("out"); const debounce = q("debounce") < 1 || q("debounce") > 10000 ? 2 : q("debounce"); if (input) { component.flags[0] = Math.min(debounce, component.flags[0] + 1); if (component.flags[0] >= debounce && !component.flags[1]) { output = output ? 0 : 1; component.flags[1] = 1; } } else { component.flags[0] = 0; component.flags[1] = 0; } w("out", output); break; }
case "estop_latch": { const ok = p("ok-in"); const fault = p("fault-in"); const reset = p("reset"); if (!ok || fault) component.flags[0] = 0; else if (reset && !component.flags[1]) component.flags[0] = 1; component.flags[1] = reset ? 1 : 0; w("ok-out", component.flags[0]); w("fault-out", !component.flags[0]); if (component.flags[0]) component.flags[2] = component.flags[2] ? 0 : 1; w("watchdog", component.flags[2]); break; }
default: throw new Error("hal_component_unsupported");
}
}
step(tick) {
if (!Number.isSafeInteger(tick) || tick < this.state.tick) return this.fail("hal_tick_regression");
const before = clone(this.state);
try {
for (let current = this.state.tick + 1; current <= tick; current += 1) {
for (const link of this.state.links) this.state.pins[link.target].value = this.state.pins[link.source].value;
for (const component of this.state.components) {
const period = this.state.threads[component.thread].period_ticks;
if (current % period !== 0) continue;
if (component.failed) throw new Error("hal_function_failure");
this.runComponent(component);
component.calls += 1;
}
}
this.state.tick = tick;
return this.ok("HAL_STEP");
} catch (error) {
this.state = before;
return this.fail(error.message);
}
}
restore(value) {
if (!value || value.schema !== HAL_RUNTIME_SCHEMA) throw new Error("hal_restore_schema");
const unsigned = clone(value); delete unsigned.state_hash;
if (value.state_hash !== fnv1a64(unsigned)) throw new Error("hal_restore_hash");
this.state = unsigned;
return this.export();
}
}
export { SPEC as STATIC_HAL_COMPONENTS };
export { HalRuntime as HalComponentRuntime, parseCompSource as parseHalComponent };

51
web/worker/io-ack.mjs Normal file
View File

@@ -0,0 +1,51 @@
import { fnv1a64 } from "./replay.mjs";
export const IO_ACK_SCHEMA = "sim_io_ack.v1";
const STATUSES = new Set(["pending", "acked", "timed_out", "aborted"]);
function clone(value) { return structuredClone(value); }
export class IoAckTracker {
constructor() { this.reset(); }
reset() { this.state = { schema: IO_ACK_SCHEMA, tick: 0, commands: [] }; return this.export(); }
export() { const value = clone(this.state); return { ...value, state_hash: fnv1a64(value) }; }
hash() { return this.export().state_hash; }
find(operationId) { return this.state.commands.find((command) => command.operation_id === operationId); }
issue(operationId, kind, tick, timeoutTicks = 10) {
if (typeof operationId !== "string" || operationId.length === 0 || typeof kind !== "string" || !Number.isSafeInteger(tick) || tick < this.state.tick || !Number.isSafeInteger(timeoutTicks) || timeoutTicks < 1 || timeoutTicks > 1000000) return { status: "error", code: "io_ack_argument" };
const existing = this.find(operationId);
if (existing) return existing.status === "acked" ? { status: "verified", replay: true, command: clone(existing), state: this.export() } : { status: "error", code: "io_ack_duplicate", command: clone(existing), state: this.export() };
const command = { operation_id: operationId, kind, issued_tick: tick, deadline_tick: tick + timeoutTicks, status: "pending", ack_tick: null, side_effect_count: 0 };
this.state.tick = tick; this.state.commands.push(command);
return { status: "verified", command: clone(command), state: this.export() };
}
step(tick) {
if (!Number.isSafeInteger(tick) || tick < this.state.tick) return { status: "error", code: "io_ack_tick_regression", state: this.export() };
this.state.tick = tick;
for (const command of this.state.commands) if (command.status === "pending" && tick >= command.deadline_tick) command.status = "timed_out";
return { status: "verified", command: "IO_TICK", state: this.export() };
}
ack(operationId, tick) {
const command = this.find(operationId);
if (!command) return { status: "error", code: "io_ack_missing", state: this.export() };
if (!Number.isSafeInteger(tick) || tick < this.state.tick) return { status: "error", code: "io_ack_tick_regression", state: this.export() };
this.state.tick = tick;
if (command.status !== "pending") return { status: "error", code: command.status === "timed_out" ? "io_ack_late" : "io_ack_state", command: clone(command), state: this.export() };
command.status = "acked"; command.ack_tick = tick; command.side_effect_count = 1;
return { status: "verified", command: clone(command), state: this.export() };
}
abort(operationId) {
const command = this.find(operationId);
if (!command) return { status: "error", code: "io_ack_missing", state: this.export() };
if (command.status === "acked") return { status: "error", code: "io_ack_too_late", command: clone(command), state: this.export() };
command.status = "aborted";
return { status: "verified", command: clone(command), state: this.export() };
}
restore(value) {
if (!value || value.schema !== IO_ACK_SCHEMA || !Array.isArray(value.commands)) throw new Error("io_ack_restore_schema");
const unsigned = { ...value }; delete unsigned.state_hash;
if (value.state_hash !== fnv1a64(unsigned)) throw new Error("io_ack_restore_hash");
if (value.commands.some((command) => !STATUSES.has(command.status))) throw new Error("io_ack_restore_status");
this.state = clone(unsigned); return this.export();
}
}

127
web/worker/motion-state.mjs Normal file
View File

@@ -0,0 +1,127 @@
import { fnv1a64 } from "./replay.mjs";
export const MOTION_SCHEMA = "sim_motion.v1";
const AXES = Object.freeze(["x", "y", "z"]);
const MAX_POSITION = 1e9;
const MAX_SPEED = 1e6;
const CYCLE_DISTANCE = 0.001;
function clone(value) { return structuredClone(value); }
function finite(value) { return typeof value === "number" && Number.isFinite(value); }
function axis(value) { return typeof value === "string" && AXES.includes(value.toLowerCase()) ? value.toLowerCase() : null; }
export class MotionState {
constructor() { this.reset(); }
reset() {
this.state = {
schema: MOTION_SCHEMA,
tick: 0,
axes: { x: 0, y: 0, z: 0 },
joints: [0, 0, 0],
velocity: 0,
feed_override: 1,
spindle_override: 1,
homed: false,
limits: { x: false, y: false, z: false },
following_error: 0,
jog: null,
estop: false,
fault: null,
};
return this.export();
}
export() { const value = clone(this.state); return { ...value, state_hash: fnv1a64(value) }; }
hash() { return this.export().state_hash; }
fail(code, detail = null) { return { status: "error", code, ...(detail ? { detail } : {}), state: this.export() }; }
ok(command) { return { status: "verified", command, state: this.export() }; }
home(target = null) {
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;
this.state.fault = null;
this.state.following_error = 0;
this.state.tick += 1;
return this.ok("HOME");
}
setOverride(feed, spindle = this.state.spindle_override) {
if (!finite(feed) || feed < 0 || feed > 2 || !finite(spindle) || spindle < 0 || spindle > 2) return this.fail("motion_override_range");
this.state.feed_override = feed;
this.state.spindle_override = spindle;
return this.ok("SET_OVERRIDE");
}
startJog({ axis: requestedAxis, direction = 1, speed = 1 } = {}) {
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 };
this.state.velocity = speed * this.state.feed_override;
return this.ok("JOG_START");
}
stopJog() {
this.state.jog = null;
this.state.velocity = 0;
return this.ok("JOG_STOP");
}
setLimit(requestedAxis, active) {
const name = axis(requestedAxis);
if (!name || typeof active !== "boolean") return this.fail("motion_limit_argument");
this.state.limits[name] = active;
if (active && this.state.jog?.axis === name) this.stopJog();
if (active) this.state.fault = "limit";
else if (this.state.fault === "limit") this.state.fault = null;
return this.ok("LIMIT_SET");
}
setFollowingError(value) {
if (!finite(value) || Math.abs(value) > MAX_POSITION) return this.fail("motion_following_error_nonfinite");
this.state.following_error = value;
if (Math.abs(value) > 0.01) { this.state.fault = "following_error"; this.state.jog = null; this.state.velocity = 0; }
return this.ok("FOLLOWING_ERROR");
}
estop(active) {
if (typeof active !== "boolean") return this.fail("motion_estop_argument");
this.state.estop = active;
if (active) { this.state.jog = null; this.state.velocity = 0; this.state.fault = "estop"; }
else if (this.state.fault === "estop") this.state.fault = null;
return this.ok("ESTOP");
}
step(tick) {
if (!Number.isSafeInteger(tick) || tick < this.state.tick) return this.fail("motion_tick_regression");
this.state.tick = tick;
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 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);
return this.export();
}
restore(value) {
if (!value || value.schema !== MOTION_SCHEMA) throw new Error("motion_restore_schema");
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);
return this.export();
}
}
export { AXES };

View File

@@ -0,0 +1,106 @@
import { fnv1a64 } from "./replay.mjs";
export const LEDGER_SCHEMA = "operation-ledger.v1";
export const LEDGER_PHASES = Object.freeze(["prepared", "executing", "verified", "committed", "aborted", "failed"]);
function clone(value) { return structuredClone(value); }
function text(value) { return typeof value === "string" && value.length > 0 ? value : null; }
export class OperationLedger {
constructor(entries = []) {
this.entries = new Map();
for (const entry of entries) this.importEntry(entry);
}
intent(input) {
return fnv1a64({ kind: input.kind, payload: input.payload ?? {}, context_id: input.context_id ?? null, session_id: input.session_id ?? null });
}
prepare(input) {
if (!text(input?.operation_id)) return { status: "untracked", replay: false, entry: null };
const intentHash = this.intent(input);
const existing = this.entries.get(input.operation_id);
if (existing) {
if (existing.kind !== input.kind) throw new Error("ledger_operation_conflict");
return { status: existing.status, replay: existing.phase === "committed" || existing.status === "verified", entry: clone(existing) };
}
const entry = {
operation_id: input.operation_id,
kind: input.kind,
seq: input.seq,
intent_hash: intentHash,
status: "prepared",
phase: "prepared",
side_effect_count: 0,
result_hash: null,
commit_seq: null,
response: null,
};
this.entries.set(entry.operation_id, entry);
return { status: entry.status, replay: false, entry: clone(entry) };
}
execute(operationId) {
const entry = this.require(operationId);
if (entry.phase === "committed" || entry.phase === "aborted") return clone(entry);
if (entry.phase !== "prepared") throw new Error("ledger_execute_phase");
entry.phase = "executing";
entry.status = "executing";
return clone(entry);
}
verify(operationId, response) {
const entry = this.require(operationId);
if (entry.phase === "committed") return clone(entry);
if (entry.phase !== "executing" && entry.phase !== "prepared") throw new Error("ledger_verify_phase");
entry.phase = "verified";
entry.status = response?.payload?.status === "error" || response?.kind === "ERROR" ? "failed" : "verified";
entry.result_hash = fnv1a64(response?.payload ?? {});
entry.response = clone(response);
return clone(entry);
}
commit(operationId, seq) {
const entry = this.require(operationId);
if (entry.phase === "committed") return clone(entry);
if (entry.phase !== "verified") throw new Error("ledger_commit_phase");
if (entry.status === "failed") { entry.phase = "failed"; return clone(entry); }
entry.phase = "committed";
entry.status = "verified";
entry.commit_seq = seq;
entry.side_effect_count = 1;
return clone(entry);
}
abort(operationId, reason = "abort_requested") {
const entry = this.require(operationId);
if (entry.phase === "committed") return { status: "too_late", entry: clone(entry) };
if (entry.phase === "aborted") return { status: "aborted", entry: clone(entry) };
entry.phase = "aborted";
entry.status = "aborted";
entry.abort_reason = reason;
return { status: "aborted", entry: clone(entry) };
}
reconcile(operationId) {
if (!text(operationId)) throw new Error("ledger_operation_required");
const entry = this.entries.get(operationId);
return entry ? { status: entry.phase === "committed" ? "committed" : entry.phase, entry: clone(entry) } : { status: "missing", entry: null };
}
require(operationId) {
const entry = this.entries.get(operationId);
if (!entry) throw new Error("ledger_operation_missing");
return entry;
}
importEntry(entry) {
if (!entry || typeof entry !== "object" || !text(entry.operation_id) || !text(entry.kind) || !LEDGER_PHASES.includes(entry.phase) || !["prepared", "executing", "verified", "aborted", "failed"].includes(entry.status)) throw new Error("ledger_entry_invalid");
if (this.entries.has(entry.operation_id)) throw new Error("ledger_operation_duplicate");
this.entries.set(entry.operation_id, clone(entry));
}
export() {
return { schema: LEDGER_SCHEMA, entries: [...this.entries.values()].sort((a, b) => a.operation_id.localeCompare(b.operation_id)).map(clone) };
}
}

123
web/worker/plc-runtime.mjs Normal file
View File

@@ -0,0 +1,123 @@
import { fnv1a64 } from "./replay.mjs";
export const PLC_SCHEMA = "sim_plc.v1";
const MAX_RUNG_COUNT = 256;
const MAX_REGISTER_COUNT = 256;
const MAX_SAMPLE_CAPACITY = 1024;
export function parseClassicLadder(source) {
if (typeof source !== "string" || source.length === 0) return { status: "error", code: "clp_source_invalid" };
const lines = source.split(/\r?\n/);
const rungs = [];
let active = null;
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index].trim();
const start = /^_FILE-rung_(\d+)\.csv$/.exec(line);
if (start) { active = { number: Number(start[1]), cells: [] }; rungs.push(active); continue; }
if (line.startsWith("_/FILE-rung_")) { active = null; continue; }
if (!active || !line || line.startsWith("#")) continue;
const cells = line.split(",").map((cell) => cell.trim()).filter(Boolean);
const parsed = [];
for (const cell of cells) {
const match = /^(\d+)-(\d+)-(\d+)\/(\d+)$/.exec(cell);
if (!match) return { status: "error", code: "clp_cell_invalid", line: index + 1, text: line };
parsed.push({ type: Number(match[1]), negated: Number(match[2]), value: Number(match[3]), index: Number(match[4]) });
}
active.cells.push(parsed);
}
if (rungs.length === 0) return { status: "error", code: "clp_rung_missing" };
const supported = new Set([0, 1, 2, 9, 10, 20, 50, 51, 52, 53, 55, 60, 99, 103]);
for (const rung of rungs) for (const row of rung.cells) for (const cell of row) if (!supported.has(cell.type)) return { status: "error", code: "clp_cell_unsupported", type: cell.type, rung: rung.number };
return { status: "verified", schema: "classicladder-clp.v1", rungs: rungs.map((rung) => ({ number: rung.number, rows: rung.cells.length, cells: rung.cells.reduce((count, row) => count + row.length, 0) })), cell_count: rungs.reduce((count, rung) => count + rung.cells.reduce((n, row) => n + row.length, 0), 0) };
}
function clone(value) { return structuredClone(value); }
function finite(value) { return typeof value === "number" && Number.isFinite(value); }
function name(value) { return typeof value === "string" && /^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/.test(value) ? value : null; }
export class PlcRuntime {
constructor() { this.reset(); }
reset() {
this.state = { schema: PLC_SCHEMA, tick: 0, scan_count: 0, registers: {}, timers: {}, counters: {}, rungs: [], sampler: { capacity: 0, policy: "drop_newest", samples: [], dropped: 0, triggered: 0 }, last_error: null };
return this.export();
}
export() { const value = clone(this.state); return { ...value, state_hash: fnv1a64(value) }; }
hash() { return this.export().state_hash; }
fail(code, detail = null) { this.state.last_error = code; return { status: "error", code, ...(detail ? { detail } : {}), state: this.export() }; }
ok(command) { this.state.last_error = null; return { status: "verified", command, state: this.export() }; }
configure({ rungs = [], sample_capacity = 0, backpressure = "drop_newest" } = {}) {
if (!Array.isArray(rungs) || rungs.length > MAX_RUNG_COUNT) return this.fail("plc_rung_capacity");
if (!Number.isSafeInteger(sample_capacity) || sample_capacity < 0 || sample_capacity > MAX_SAMPLE_CAPACITY) return this.fail("plc_sampler_capacity");
if (!["drop_newest", "drop_oldest", "block"].includes(backpressure)) return this.fail("plc_backpressure_policy");
const normalized = [];
for (const rung of rungs) {
if (!rung || typeof rung !== "object" || !["SET", "ADD", "AND", "OR", "NOT", "TIMER", "COUNTER"].includes(rung.op)) return this.fail("plc_rung_unsupported");
if (!name(rung.target)) return this.fail("plc_register_name");
if (rung.source !== undefined && !name(rung.source)) return this.fail("plc_register_name");
normalized.push({ op: rung.op, target: rung.target, ...(rung.source ? { source: rung.source } : {}), ...(rung.value !== undefined ? { value: rung.value } : {}), ...(rung.preset !== undefined ? { preset: rung.preset } : {}) });
if (!(rung.target in this.state.registers)) this.state.registers[rung.target] = 0;
}
this.state.rungs = normalized;
this.state.sampler = { capacity: sample_capacity, policy: backpressure, samples: [], dropped: 0, triggered: 0 };
return this.ok("PLC_CONFIG");
}
setRegister(register, value) {
if (!name(register) || !finite(value)) return this.fail("plc_register_value");
if (!(register in this.state.registers) && Object.keys(this.state.registers).length >= MAX_REGISTER_COUNT) return this.fail("plc_register_capacity");
this.state.registers[register] = value;
return this.ok("PLC_SET");
}
scan(tick) {
if (!Number.isSafeInteger(tick) || tick < this.state.tick) return this.fail("plc_tick_regression");
this.state.tick = tick;
for (const rung of this.state.rungs) {
const source = rung.source ? (this.state.registers[rung.source] ?? 0) : (rung.value ?? 0);
const current = this.state.registers[rung.target] ?? 0;
if (rung.op === "SET") this.state.registers[rung.target] = source;
else if (rung.op === "ADD") this.state.registers[rung.target] = current + source;
else if (rung.op === "AND") this.state.registers[rung.target] = current !== 0 && source !== 0 ? 1 : 0;
else if (rung.op === "OR") this.state.registers[rung.target] = current !== 0 || source !== 0 ? 1 : 0;
else if (rung.op === "NOT") this.state.registers[rung.target] = current === 0 ? 1 : 0;
else if (rung.op === "TIMER") {
const timer = this.state.timers[rung.target] ?? { elapsed: 0, preset: Number.isSafeInteger(rung.preset) ? rung.preset : 1, done: false };
if (source !== 0) timer.elapsed += 1; else timer.elapsed = 0;
timer.done = timer.elapsed >= timer.preset; this.state.timers[rung.target] = timer; this.state.registers[rung.target] = timer.done ? 1 : 0;
} else if (rung.op === "COUNTER") {
const counter = this.state.counters[rung.target] ?? { count: 0, preset: Number.isSafeInteger(rung.preset) ? rung.preset : 1, done: false };
if (source !== 0) counter.count += 1;
counter.done = counter.count >= counter.preset; this.state.counters[rung.target] = counter; this.state.registers[rung.target] = counter.done ? 1 : 0;
}
if (!finite(this.state.registers[rung.target])) return this.fail("plc_nonfinite");
}
this.state.scan_count += 1;
return this.capture(false);
}
capture(triggered = true) {
const sampler = this.state.sampler;
if (sampler.capacity === 0) return this.ok(triggered ? "PLC_TRIGGER" : "PLC_SCAN");
const sample = { tick: this.state.tick, registers: clone(this.state.registers), scan_count: this.state.scan_count };
if (sampler.samples.length >= sampler.capacity) {
if (sampler.policy === "block") return this.fail("plc_backpressure");
sampler.dropped += 1;
if (sampler.policy === "drop_newest") return this.ok(triggered ? "PLC_TRIGGER" : "PLC_SCAN");
sampler.samples.shift();
}
sampler.samples.push(sample); if (triggered) sampler.triggered += 1;
return this.ok(triggered ? "PLC_TRIGGER" : "PLC_SCAN");
}
trigger() { return this.capture(true); }
restore(value) {
if (!value || value.schema !== PLC_SCHEMA) throw new Error("plc_restore_schema");
const unsigned = { ...value }; delete unsigned.state_hash;
if (value.state_hash !== fnv1a64(unsigned)) throw new Error("plc_restore_hash");
this.state = clone(unsigned); return this.export();
}
}

View File

@@ -0,0 +1,679 @@
import { fnv1a64 } from "./replay.mjs";
const INTERPRETER_SIZE = 648;
const STATE_SIZE = 120;
const MAX_PROGRAM_BYTES = 1024 * 1024;
const MAX_PROGRAM_LINES = 10000;
const MAX_CONTROL_DEPTH = 32;
const MAX_CONTROL_STEPS = 100000;
const MAX_EXPANDED_LINES = 100000;
const DEFAULT_STATE = Object.freeze({
x: 0, y: 0, z: 0, a: 0, b: 0, c: 0, u: 0, v: 0, w: 0,
feed: 0, speed: 0, line: 0, units: 21, distance_mode: 90, plane: 17,
motion_mode: 1, spindle_mode: 5, program_end: 0, status: 0,
});
const DEFAULT_MOTION_CONTROL = Object.freeze({ mode: "continuous", path_tolerance: 0, naive_cam_tolerance: 0 });
function call(core, name, returnType, argTypes, args) {
if (typeof core.ccall === "function") return core.ccall(name, returnType, argTypes, args);
const direct = core[`_${name}`] ?? core[name];
if (typeof direct === "function") return direct(...args);
throw new Error(`missing core export: ${name}`);
}
function cloneState(state) {
return { ...state };
}
function readInterpreterState(core, pointer) {
const bytes = core.HEAPU8;
if (!bytes || !core.HEAPF64 || !core.HEAP32) throw new Error("wasm_memory_unavailable");
const offset = pointer >>> 0;
if (offset % 8 !== 0 || offset + STATE_SIZE > bytes.byteLength) throw new Error("wasm_state_pointer_invalid");
const doubles = core.HEAPF64.subarray(offset / 8, offset / 8 + 11);
const ints = core.HEAP32.subarray(offset / 4 + 22, offset / 4 + 30);
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], feed: doubles[9], speed: doubles[10],
line: core.HEAPU32[offset / 4 + 22], units: ints[1], distance_mode: ints[2], plane: ints[3],
motion_mode: ints[4], spindle_mode: ints[5], program_end: ints[6], status: ints[7],
};
}
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]);
if (!Number.isSafeInteger(required) || required <= 1 || required > 4096) throw new Error("wasm_canonical_size_invalid");
const pointer = core._malloc(required);
if (!pointer) throw new Error("wasm_canonical_allocation_failed");
try {
const written = call(core, "sim_interpreter_canonical", "number", ["number", "number", "number"], [interpreter, pointer, required]);
return new TextDecoder().decode(core.HEAPU8.subarray(pointer, pointer + written - 1));
} finally { core._free(pointer); }
}
function nonEmptyLines(source) {
const lines = source.split(/\r?\n/);
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
return lines;
}
function controlDirective(line) {
const match = /^\s*O\s*<?([A-Za-z0-9_-]+)>?\s+([A-Za-z]+)(?:\s+(.*?))?\s*$/i.exec(line);
if (!match) return null;
return { id: match[1], command: match[2].toUpperCase(), expression: match[3] ?? "" };
}
function motionControlDirective(line) {
const compact = String(line ?? "").replace(/\s+/g, "").toUpperCase();
let match = /^G64(?:P([+-]?(?:\d+(?:\.\d*)?|\.\d+)))?(?:Q([+-]?(?:\d+(?:\.\d*)?|\.\d+)))?$/.exec(compact);
if (match) {
const path = match[1] === undefined ? 0 : Number(match[1]);
const naive = match[2] === undefined ? 0 : Number(match[2]);
if (!Number.isFinite(path) || !Number.isFinite(naive) || path < 0 || naive < 0) return { error: "program_motion_control_range" };
return { mode: "continuous", path_tolerance: path, naive_cam_tolerance: naive };
}
if (compact === "G61") return { mode: "exact_path", path_tolerance: 0, naive_cam_tolerance: 0 };
if (compact === "G61.1") return { mode: "exact_stop", path_tolerance: 0, naive_cam_tolerance: 0 };
if (/^G(?:61(?:\.\d+)?|64)/.test(compact)) return { error: "program_motion_control_syntax" };
return null;
}
function parameterValues(core, interpreter, expression) {
const values = new Map();
for (const token of expression.match(/#\d+/g) ?? []) {
const index = Number(token.slice(1));
if (!Number.isSafeInteger(index) || index < 1 || index > 5399) throw new Error("control_parameter_range");
if (typeof core._sim_interpreter_parameter_get !== "function") throw new Error("control_parameter_api_unavailable");
const pointer = core._malloc(8);
if (!pointer) throw new Error("control_parameter_allocation_failed");
try {
const result = call(core, "sim_interpreter_parameter_get", "number", ["number", "number", "number"], [interpreter, index, pointer]);
if (result !== 0) throw new Error(`control_parameter_${result}`);
values.set(index, core.HEAPF64[pointer / 8]);
} finally { core._free(pointer); }
}
return values;
}
function evaluateControlValue(core, interpreter, source, namedValues = new Map()) {
let expression = String(source ?? "").trim();
if (expression.startsWith("[") && expression.endsWith("]")) expression = expression.slice(1, -1);
const values = parameterValues(core, interpreter, expression);
const tokens = expression.match(/#<[^>]+>|#\d+|(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?|\*\*|<=|>=|==|!=|[<>()+\-*/%]|\b(?:MOD|LT|LE|GT|GE|EQ|NE|AND|OR|XOR)\b/gi);
if (!tokens || tokens.join("").replace(/\s/g, "") !== expression.replace(/\s/g, "")) throw new Error("control_expression_syntax");
let cursor = 0;
const peek = () => tokens[cursor]?.toUpperCase() ?? null;
const take = () => tokens[cursor++];
const primary = () => {
const token = take();
if (token === undefined) throw new Error("control_expression_operand");
if (token === "(") { const value = logicalOr(); if (take() !== ")") throw new Error("control_expression_parenthesis"); return value; }
if (token === "+") return primary();
if (token === "-") return -primary();
if (token.startsWith("#<")) return namedValues.get(token.slice(2, -1).toLowerCase()) ?? 0;
if (token.startsWith("#")) return values.get(Number(token.slice(1))) ?? 0;
const value = Number(token);
if (!Number.isFinite(value)) throw new Error("control_expression_nonfinite");
return value;
};
const power = () => { let value = primary(); while (peek() === "**") { take(); value **= power(); } return value; };
const product = () => {
let value = power();
while (["*", "/", "%", "MOD"].includes(peek())) {
const operator = take(); const right = power();
if ((operator === "/" || operator === "%" || operator === "MOD") && right === 0) throw new Error("control_expression_divide_zero");
value = operator === "*" ? value * right : operator === "/" ? value / right : value % right;
}
return value;
};
const sum = () => { let value = product(); while (["+", "-"].includes(peek())) { const operator = take(); const right = product(); value = operator === "+" ? value + right : value - right; } return value; };
const comparison = () => {
let value = sum();
while (["LT", "LE", "GT", "GE", "EQ", "NE", "<", "<=", ">", ">=", "==", "!=", "AND", "OR", "XOR"].includes(peek())) {
const operator = take(); const right = sum();
if (["LT", "<"].includes(operator)) value = value < right ? 1 : 0;
else if (["LE", "<="].includes(operator)) value = value <= right ? 1 : 0;
else if (["GT", ">"].includes(operator)) value = value > right ? 1 : 0;
else if (["GE", ">="].includes(operator)) value = value >= right ? 1 : 0;
else if (["EQ", "=="].includes(operator)) value = value === right ? 1 : 0;
else if (["NE", "!="].includes(operator)) value = value !== right ? 1 : 0;
else if (operator === "AND") value = value !== 0 && right !== 0 ? 1 : 0;
else if (operator === "OR") value = value !== 0 || right !== 0 ? 1 : 0;
else value = (Boolean(value) !== Boolean(right)) ? 1 : 0;
}
return value;
};
const logicalOr = () => comparison();
const result = logicalOr();
if (cursor !== tokens.length || !Number.isFinite(result)) throw new Error("control_expression_syntax");
return result;
}
function evaluateControlExpression(core, interpreter, source, namedValues = new Map()) {
return evaluateControlValue(core, interpreter, source, namedValues) !== 0;
}
export class ProgramRunner {
constructor(core) {
this.core = core;
this.interpreter = 0;
this.stateBuffer = 0;
this.blockBuffer = 0;
this.blockCapacity = 0;
this.source = "";
this.lines = [];
this.programCounter = 0;
this.control = null;
this.controlStack = [];
this.controlSteps = 0;
this.expandedLines = 0;
this.namedParameters = new Map();
this.path = null;
this.status = "idle";
this.currentLine = 0;
this.lastError = null;
this.state = cloneState(DEFAULT_STATE);
this.motionControl = { ...DEFAULT_MOTION_CONTROL };
}
buildControlMap(lines) {
const subStack = [];
const ifStack = [];
const loopStack = [];
const doStack = [];
const matching = new Map();
const branches = new Map();
const subroutines = new Map();
const directives = new Map();
const doLoopsByWhile = new Map();
const doLoopsByDo = new Map();
const fail = (index, code) => { const error = new Error(code); error.line = index + 1; throw error; };
lines.forEach((line, index) => {
const directive = controlDirective(line);
if (!directive) return;
directives.set(index, directive);
switch (directive.command) {
case "SUB":
if (subroutines.has(directive.id) || subStack.length >= MAX_CONTROL_DEPTH) fail(index, "program_subroutine_duplicate_or_depth");
subStack.push({ id: directive.id, start: index });
break;
case "ENDSUB": {
const sub = subStack.pop();
if (!sub || sub.id !== directive.id) fail(index, "program_subroutine_mismatch");
subroutines.set(sub.id, { start: sub.start, end: index });
matching.set(sub.start, index); matching.set(index, sub.start);
break;
}
case "IF":
if (ifStack.length >= MAX_CONTROL_DEPTH) fail(index, "program_control_depth");
ifStack.push({ branches: [index], end: null });
break;
case "ELSEIF": case "ELSE": {
const branch = ifStack[ifStack.length - 1];
if (!branch) fail(index, "program_if_without_if");
branch.branches.push(index);
break;
}
case "ENDIF": {
const branch = ifStack.pop();
if (!branch) fail(index, "program_endif_without_if");
branch.end = index;
for (let position = 0; position < branch.branches.length; position += 1) {
const current = branch.branches[position];
const next = branch.branches[position + 1] ?? index;
branches.set(current, { next, end: index });
matching.set(current, next);
}
matching.set(index, branch.branches[0]);
break;
}
case "DO":
if (doStack.length >= MAX_CONTROL_DEPTH) fail(index, "program_control_depth");
doStack.push({ id: directive.id, start: index });
break;
case "WHILE": case "ENDDO": {
const pending = doStack.at(-1);
if ((directive.command === "WHILE" || directive.command === "ENDDO") && pending?.id === directive.id) {
doStack.pop();
const loop = { id: directive.id, start: pending.start, end: index, command: directive.command };
doLoopsByDo.set(pending.start, loop);
doLoopsByWhile.set(index, loop);
break;
}
if (directive.command === "ENDDO") fail(index, "program_enddo_without_do");
if (loopStack.length >= MAX_CONTROL_DEPTH) fail(index, "program_control_depth");
loopStack.push({ start: index, command: directive.command });
break;
}
case "ENDWHILE": case "ENDREPEAT": {
const loop = loopStack.pop();
if (!loop || (directive.command === "ENDWHILE" && loop.command !== "WHILE") || (directive.command === "ENDREPEAT" && loop.command !== "REPEAT")) fail(index, "program_loop_mismatch");
matching.set(loop.start, index); matching.set(index, loop.start);
break;
}
default:
break;
}
});
if (subStack.length || ifStack.length || loopStack.length || doStack.length) throw Object.assign(new Error("program_control_unclosed"), { line: lines.length });
return { directives, matching, branches, subroutines, doLoopsByDo, doLoopsByWhile };
}
get loaded() {
return this.source.length > 0 || this.lines.length > 0;
}
ensureMemory() {
if (this.interpreter) return;
if (typeof this.core._malloc !== "function" || typeof this.core._free !== "function") throw new Error("wasm_interpreter_memory_unavailable");
const interpreterSize = typeof this.core._sim_interpreter_size === "function"
? this.core._sim_interpreter_size()
: INTERPRETER_SIZE;
if (!Number.isSafeInteger(interpreterSize) || interpreterSize < INTERPRETER_SIZE || interpreterSize > 4096) throw new Error("wasm_interpreter_size_invalid");
this.interpreter = this.core._malloc(interpreterSize);
this.stateBuffer = this.core._malloc(STATE_SIZE);
if (!this.interpreter || !this.stateBuffer) throw new Error("wasm_interpreter_allocation_failed");
const result = call(this.core, "sim_interpreter_init", "number", ["number"], [this.interpreter]);
if (result !== 0) throw new Error(`interpreter_init_${result}`);
}
ensureBlockBuffer(byteLength) {
if (byteLength <= this.blockCapacity) return;
if (this.blockBuffer) this.core._free(this.blockBuffer);
this.blockCapacity = Math.max(byteLength + 1, 256);
this.blockBuffer = this.core._malloc(this.blockCapacity);
if (!this.blockBuffer) throw new Error("wasm_block_allocation_failed");
}
load(source, path = null) {
if (typeof source !== "string") return { status: "error", code: "program_source_required" };
const bytes = new TextEncoder().encode(source);
if (bytes.length === 0 || !source.trim()) return { status: "error", code: "program_empty" };
if (bytes.length > MAX_PROGRAM_BYTES) return { status: "error", code: "program_size_limit", limit: MAX_PROGRAM_BYTES };
const lines = nonEmptyLines(source);
if (lines.length > MAX_PROGRAM_LINES) return { status: "error", code: "program_line_limit", limit: MAX_PROGRAM_LINES };
let control;
try {
this.ensureMemory();
const result = call(this.core, "sim_interpreter_reset", "number", ["number"], [this.interpreter]);
if (result !== 0) return { status: "error", code: `interpreter_reset_${result}`, result };
control = this.buildControlMap(lines);
} catch (error) {
return { status: "error", code: error.message, line: error.line ?? null };
}
this.source = source;
this.lines = lines;
this.programCounter = 0;
this.control = control;
this.controlStack = [];
this.controlSteps = 0;
this.expandedLines = 0;
this.namedParameters = new Map();
this.path = typeof path === "string" && path.length > 0 ? path : null;
this.status = "loaded";
this.currentLine = 0;
this.lastError = null;
this.state = cloneState(DEFAULT_STATE);
this.motionControl = { ...DEFAULT_MOTION_CONTROL };
return this.snapshot();
}
start() {
if (!this.loaded) return { status: "error", code: "program_not_loaded" };
if (this.status === "completed") return { status: "error", code: "program_completed" };
if (this.status === "error") return { status: "error", code: "program_faulted", error: this.lastError };
this.status = "running";
return this.snapshot();
}
pause() {
if (this.status !== "running") return { status: "error", code: "program_not_running" };
this.status = "paused";
return this.snapshot();
}
resume() {
if (this.status !== "paused") return { status: "error", code: "program_not_paused" };
this.status = "running";
return this.snapshot();
}
abort() {
if (!this.loaded) return { status: "error", code: "program_not_loaded" };
this.status = "aborted";
return this.snapshot();
}
restart() {
if (!this.loaded) return { status: "error", code: "program_not_loaded" };
const source = this.source;
const path = this.path;
const result = this.load(source, path);
if (result.status === "error") return result;
this.status = "loaded";
return this.snapshot();
}
seek(line) {
if (!Number.isSafeInteger(line) || line < 0 || line > this.lines.length) return { status: "error", code: "program_line_invalid" };
if (line < this.currentLine) {
const loaded = this.load(this.source, this.path);
if (loaded.status === "error") return loaded;
}
while (this.currentLine < line) {
const result = this.executeNext(true);
if (result.status !== "verified") return result;
}
this.status = "paused";
return this.snapshot();
}
step() {
if (!this.loaded) return { status: "error", code: "program_not_loaded" };
if (this.status === "aborted" || this.status === "completed" || this.status === "error") return { status: "error", code: `program_${this.status}` };
return this.executeNext(false);
}
executeBlock(block) {
if (typeof block !== "string" || !block.trim()) return { status: "error", code: "mdi_block_required" };
const bytes = new TextEncoder().encode(block);
if (bytes.length > 65536) return { status: "error", code: "mdi_block_limit" };
const motionControl = motionControlDirective(block);
if (motionControl) {
if (motionControl.error) return { status: "error", code: motionControl.error };
this.motionControl = motionControl;
this.state.line += 1;
return { status: "verified", block, state: cloneState(this.state), runner: this.snapshot() };
}
try {
this.ensureMemory();
this.ensureBlockBuffer(bytes.length);
this.core.HEAPU8.set(bytes, this.blockBuffer);
const result = call(this.core, "sim_interpreter_execute", "number", ["number", "number", "number"], [this.interpreter, this.blockBuffer, bytes.length]);
if (result !== 0) return { status: "error", code: "mdi_interpreter", result, column: this.errorColumn(), error_code: this.errorCode() };
const stateResult = call(this.core, "sim_interpreter_read_state", "number", ["number", "number"], [this.interpreter, this.stateBuffer]);
if (stateResult !== 0) return { status: "error", code: "mdi_state_read", result: stateResult };
this.state = readInterpreterState(this.core, this.stateBuffer);
return { status: "verified", block, state: cloneState(this.state), runner: this.snapshot() };
} catch (error) {
return { status: "error", code: error.message };
}
}
tick(cycles) {
const results = [];
if (this.status !== "running") return results;
for (let index = 0; index < cycles && this.status === "running"; index += 1) results.push(this.executeNext(false));
return results;
}
controlResult(index, text) {
return { status: "verified", line: index + 1, text, state: cloneState(this.state), program_status: this.status, control: true };
}
controlError(index, code, text) {
this.status = "error";
this.lastError = { code, source_line: index + 1, text };
return { status: "error", code: "program_control", detail: code, line: index + 1, text };
}
callSubroutine(id, index, text, repeat = 1) {
const subroutine = this.control?.subroutines.get(String(id));
if (!subroutine) return this.controlError(index, "program_subroutine_missing", text);
if (!Number.isSafeInteger(repeat) || repeat < 1 || repeat > MAX_CONTROL_STEPS) return this.controlError(index, "program_subroutine_repeat_limit", text);
const depth = this.controlStack.filter((frame) => frame.type === "call").length;
if (depth >= MAX_CONTROL_DEPTH) return this.controlError(index, "program_subroutine_depth", text);
this.controlStack.push({ type: "call", returnPc: index + 1, end: subroutine.end, repeat, id: String(id) });
this.programCounter = subroutine.start + 1;
this.currentLine = this.programCounter;
this.enteringSub = subroutine.start;
return this.controlResult(index, text);
}
executeDirective(index, directive, text) {
const next = () => { this.currentLine = this.programCounter; return this.controlResult(index, text); };
const jump = (target) => { this.programCounter = target; this.currentLine = target; return this.controlResult(index, text); };
try {
switch (directive.command) {
case "SUB":
if (this.enteringSub === index) { this.enteringSub = null; return next(); }
return jump((this.control.matching.get(index) ?? index) + 1);
case "ENDSUB": {
const frame = this.controlStack[this.controlStack.length - 1];
if (!frame || frame.type !== "call" || frame.end !== index) return this.controlError(index, "program_endsub_without_call", text);
if (frame.repeat > 1) { frame.repeat -= 1; this.programCounter = this.control.subroutines.get(frame.id).start + 1; this.currentLine = this.programCounter; return this.controlResult(index, text); }
this.controlStack.pop(); return jump(frame.returnPc);
}
case "CALL": return this.callSubroutine(directive.id, index, text, 1);
case "RETURN": {
const frame = this.controlStack.pop();
if (!frame || frame.type !== "call") return this.controlError(index, "program_return_without_call", text);
return jump(frame.returnPc);
}
case "IF": {
const branch = this.control.branches.get(index);
if (!branch) return this.controlError(index, "program_if_unmatched", text);
const taken = evaluateControlExpression(this.core, this.interpreter, directive.expression);
this.controlStack.push({ type: "if", end: branch.end, taken });
if (taken) return next();
if (branch.next === branch.end) { this.controlStack.pop(); return jump(branch.end + 1); }
return jump(branch.next);
}
case "ELSEIF": {
const frame = this.controlStack[this.controlStack.length - 1];
const branch = this.control.branches.get(index);
if (!frame || frame.type !== "if" || !branch) return this.controlError(index, "program_elseif_unmatched", text);
if (frame.taken) return jump(frame.end + 1);
frame.taken = evaluateControlExpression(this.core, this.interpreter, directive.expression);
if (frame.taken) return next();
if (branch.next === branch.end) { this.controlStack.pop(); return jump(branch.end + 1); }
return jump(branch.next);
}
case "ELSE": {
const frame = this.controlStack[this.controlStack.length - 1];
if (!frame || frame.type !== "if") return this.controlError(index, "program_else_unmatched", text);
if (frame.taken) return jump(frame.end + 1);
frame.taken = true; return next();
}
case "ENDIF": {
const frame = this.controlStack[this.controlStack.length - 1];
if (!frame || frame.type !== "if" || frame.end !== index) return this.controlError(index, "program_endif_unmatched", text);
this.controlStack.pop(); return next();
}
case "WHILE": case "ENDDO": case "REPEAT": {
const doLoop = this.control.doLoopsByWhile.get(index);
if (doLoop) {
const frame = this.controlStack.at(-1);
if (!frame || frame.type !== "do" || frame.end !== index) return this.controlError(index, "program_do_without_frame", text);
frame.iterations += 1; this.controlSteps += 1;
if (frame.iterations > MAX_CONTROL_STEPS || this.controlSteps > MAX_CONTROL_STEPS) return this.controlError(index, "program_loop_budget", text);
if (evaluateControlExpression(this.core, this.interpreter, directive.expression, this.namedParameters)) return jump(frame.start + 1);
this.controlStack.pop(); return next();
}
const pair = this.control.matching.get(index);
if (!Number.isSafeInteger(pair)) return this.controlError(index, "program_loop_unmatched", text);
const active = this.controlStack[this.controlStack.length - 1];
if (active?.type === "loop" && active.start === index) {
if (!evaluateControlExpression(this.core, this.interpreter, directive.expression)) { this.controlStack.pop(); return jump(pair + 1); }
return next();
}
if (!evaluateControlExpression(this.core, this.interpreter, directive.expression)) return jump(pair + 1);
this.controlStack.push({ type: "loop", start: index, end: pair, iterations: 0 }); return next();
}
case "ENDWHILE": case "ENDREPEAT": {
const frame = this.controlStack[this.controlStack.length - 1];
if (!frame || frame.type !== "loop" || frame.end !== index) return this.controlError(index, "program_endloop_unmatched", text);
frame.iterations += 1; this.controlSteps += 1;
if (frame.iterations > MAX_CONTROL_STEPS || this.controlSteps > MAX_CONTROL_STEPS) return this.controlError(index, "program_loop_budget", text);
return jump(frame.start);
}
case "DO": {
const loop = this.control.doLoopsByDo.get(index);
if (!loop) return this.controlError(index, "program_do_unmatched", text);
this.controlStack.push({ type: "do", id: loop.id, start: loop.start, end: loop.end, iterations: 0 });
return next();
}
case "BREAK": {
const position = this.controlStack.map((frame) => frame.type).lastIndexOf("do") >= 0
? this.controlStack.map((frame) => frame.type).lastIndexOf("do")
: this.controlStack.map((frame) => frame.type).lastIndexOf("loop");
if (position < 0) return this.controlError(index, "program_break_without_loop", text);
const frame = this.controlStack[position]; this.controlStack.splice(position, 1); return jump(frame.end + 1);
}
case "CONTINUE": {
const frame = this.controlStack[this.controlStack.length - 1];
if (!frame || !["loop", "do"].includes(frame.type)) return this.controlError(index, "program_continue_without_loop", text);
return jump(frame.end);
}
default:
return this.controlError(index, "program_control_unsupported", text);
}
} catch (error) {
return this.controlError(index, error.message, text);
}
}
executeNext(seeking) {
while (this.programCounter < this.lines.length) {
const index = this.programCounter;
const sourceLine = this.lines[index];
this.programCounter += 1;
this.currentLine = this.programCounter;
if (!sourceLine.trim()) continue;
this.controlSteps += 1;
this.expandedLines += 1;
if (this.controlSteps > MAX_CONTROL_STEPS || this.expandedLines > MAX_EXPANDED_LINES) return this.controlError(index, "program_execution_budget", sourceLine);
const directive = this.control?.directives.get(index);
if (directive) return this.executeDirective(index, directive, sourceLine);
if (/^\s*M99(?:\s|$)/i.test(sourceLine)) {
const frame = this.controlStack.pop();
if (!frame || frame.type !== "call") return this.controlError(index, "program_return_without_call", sourceLine);
this.programCounter = frame.returnPc; this.currentLine = this.programCounter;
return this.controlResult(index, sourceLine);
}
const fanucCall = /^\s*M98\s+P(\d+)(?:\s+L(\d+))?/i.exec(sourceLine);
if (fanucCall) return this.callSubroutine(fanucCall[1], index, sourceLine, Number(fanucCall[2] ?? 1));
const motionControl = motionControlDirective(sourceLine);
if (motionControl) {
if (motionControl.error) return this.controlError(index, motionControl.error, sourceLine);
this.motionControl = motionControl;
this.state.line += 1;
return { status: "verified", line: index + 1, text: sourceLine, state: cloneState(this.state), program_status: this.status, motion_control: { ...this.motionControl } };
}
const bytes = new TextEncoder().encode(sourceLine);
this.ensureBlockBuffer(bytes.length);
this.core.HEAPU8.set(bytes, this.blockBuffer);
const result = call(this.core, "sim_interpreter_execute", "number", ["number", "number", "number"], [this.interpreter, this.blockBuffer, bytes.length]);
if (result !== 0) {
this.status = "error";
this.lastError = { result, source_line: index + 1, text: sourceLine };
return { status: "error", code: "program_interpreter", result, line: index + 1, text: sourceLine, column: this.errorColumn(), error_code: this.errorCode() };
}
const stateResult = call(this.core, "sim_interpreter_read_state", "number", ["number", "number"], [this.interpreter, this.stateBuffer]);
if (stateResult !== 0) {
this.status = "error";
this.lastError = { result: stateResult, source_line: index + 1, text: sourceLine };
return { status: "error", code: "program_state_read", result: stateResult, line: index + 1 };
}
this.state = readInterpreterState(this.core, this.stateBuffer);
const namedAssignment = /^\s*#<([^>]+)>\s*=\s*(.+?)\s*$/i.exec(sourceLine);
if (namedAssignment) {
const value = evaluateControlValue(this.core, this.interpreter, namedAssignment[2], this.namedParameters);
if (!Number.isFinite(value)) return this.controlError(index, "control_expression_nonfinite", sourceLine);
this.namedParameters.set(namedAssignment[1].toLowerCase(), value);
}
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 };
}
this.status = "completed";
return { status: "verified", line: this.currentLine, text: null, state: cloneState(this.state), program_status: this.status };
}
snapshot() {
let canonical = null;
let interpreter_state_hash = null;
let block_hash = null;
let modal_hash = null;
let error_column = 0;
let error_code = 0;
if (this.interpreter) {
try {
canonical = readCanonical(this.core, this.interpreter);
const stateHash = call(this.core, "sim_interpreter_state_hash", "number", ["number"], [this.interpreter]);
const blockHash = call(this.core, "sim_interpreter_block_hash", "number", ["number"], [this.interpreter]);
if (typeof this.core._sim_interpreter_modal_hash === "function") {
const modalHash = call(this.core, "sim_interpreter_modal_hash", "number", ["number"], [this.interpreter]);
modal_hash = `fnv1a64:${BigInt.asUintN(64, BigInt(modalHash >>> 0)).toString(16).padStart(16, "0")}`;
}
error_column = this.errorColumn();
error_code = this.errorCode();
interpreter_state_hash = `fnv1a64:${BigInt.asUintN(64, BigInt(stateHash >>> 0)).toString(16).padStart(16, "0")}`;
block_hash = `fnv1a64:${BigInt.asUintN(64, BigInt(blockHash >>> 0)).toString(16).padStart(16, "0")}`;
} catch (error) {
this.lastError = this.lastError ?? { code: error.message };
}
}
return {
status: "verified",
program_status: this.status,
path: this.path,
line: this.currentLine,
line_count: this.lines.length,
source_hash: this.loaded ? fnv1a64(this.source) : null,
state: cloneState(this.state),
canonical,
interpreter_state_hash,
block_hash,
modal_hash,
motion_control: { ...this.motionControl },
motion_control_hash: fnv1a64(this.motionControl),
error_column,
error_code,
error: this.lastError,
};
}
errorColumn() {
if (!this.interpreter || typeof this.core._sim_interpreter_error_column !== "function") return 0;
return call(this.core, "sim_interpreter_error_column", "number", ["number"], [this.interpreter]);
}
errorCode() {
if (!this.interpreter || typeof this.core._sim_interpreter_error_code !== "function") return 0;
return call(this.core, "sim_interpreter_error_code", "number", ["number"], [this.interpreter]);
}
exportState() {
return { ...this.snapshot(), source: this.source, motion_control: { ...this.motionControl } };
}
restoreState(value) {
if (!value || typeof value.source !== "string") {
this.status = "idle";
return { status: "verified", program_status: this.status };
}
const loaded = this.load(value.source, value.path);
if (loaded.status === "error") return loaded;
const target = Number.isSafeInteger(value.line) ? value.line : 0;
const restored = this.seek(Math.max(0, Math.min(target, this.lines.length)));
if (restored.status === "error") return restored;
this.status = value.program_status === "running" ? "paused" : (value.program_status ?? this.status);
if (value.motion_control && typeof value.motion_control.mode === "string") this.motionControl = { ...DEFAULT_MOTION_CONTROL, ...value.motion_control };
this.lastError = value.error ?? null;
return this.snapshot();
}
destroy() {
if (this.blockBuffer && typeof this.core._free === "function") this.core._free(this.blockBuffer);
if (this.stateBuffer && typeof this.core._free === "function") this.core._free(this.stateBuffer);
if (this.interpreter && typeof this.core._free === "function") this.core._free(this.interpreter);
this.blockBuffer = 0;
this.stateBuffer = 0;
this.interpreter = 0;
}
}
export { DEFAULT_STATE };

View File

@@ -2,6 +2,15 @@ import manifest from "../../python-runtime/manifest.json" with { type: "json" };
import { fnv1a64 } from "./replay.mjs";
export const PYTHON_BRIDGE_SCHEMA = "python-worker-bridge.v1";
const COMPAT_MODULES = Object.freeze({
tool: ["get", "set"],
parameter: ["get", "set"],
parameters: ["get", "set"],
workspace: ["read", "write", "list", "stat", "hash"],
"virtual-clock": ["now", "tick", "sleep"],
});
const WORKSPACE_MAX_FILES = 256;
const WORKSPACE_MAX_BYTES = 16 * 1024 * 1024;
function clone(value) { return structuredClone(value); }
function finiteJson(value) {
@@ -10,6 +19,11 @@ function finiteJson(value) {
return Array.isArray(value) ? value.every(finiteJson) : Object.values(value).every(finiteJson);
}
function virtualPath(value) {
if (typeof value !== "string" || !value.startsWith("/workspace/") || value.includes("..") || value.includes("\\") || value.length > 1024) return null;
return value;
}
export class PythonBridge {
constructor({ loadPyodide = globalThis.loadPyodide, maxSourceBytes = 65536, maxCalls = 256 } = {}) {
this.loadPyodide = loadPyodide;
@@ -17,7 +31,18 @@ export class PythonBridge {
this.maxCalls = maxCalls;
this.pyodide = null;
this.initialized = false;
this.state = { imports: [], calls: [], errors: [], capabilities: [], tick: 0 };
this.state = {
imports: [], calls: [], errors: [], capabilities: [], tick: 0,
api: {
linuxcnc: { mode: "MANUAL", estop: false, task_state: "READY", last_command: null, ini: null },
gstat: { state: "READY", task: 0, motion: 0 },
hal: { components: {}, pins: {}, params: {}, signals: {}, threads: {} },
ini: { sections: {}, values: {} },
tools: {},
parameters: {},
workspace: { files: {}, bytes: 0 },
},
};
}
capabilities() {
@@ -30,6 +55,7 @@ export class PythonBridge {
platform: manifest.platform,
worker_only: manifest.worker_only,
virtual_modules: clone(manifest.virtual_modules),
compatibility_modules: clone(COMPAT_MODULES),
denied_capabilities: [...manifest.denied_capabilities],
initialized: this.initialized,
};
@@ -48,7 +74,7 @@ export class PythonBridge {
}
importModule(module) {
const symbols = manifest.virtual_modules[module];
const symbols = manifest.virtual_modules[module] ?? COMPAT_MODULES[module];
if (!symbols) return this.fail("python_unsupported_module", "python.unsupported_module", { module });
if (!this.state.imports.includes(module)) this.state.imports.push(module);
return { status: "verified", module, symbols: [...symbols], state_hash: this.hash() };
@@ -62,16 +88,145 @@ export class PythonBridge {
}
invoke(module, symbol, args = []) {
const symbols = manifest.virtual_modules[module];
const symbols = manifest.virtual_modules[module] ?? COMPAT_MODULES[module];
if (!symbols || !symbols.includes(symbol)) return this.fail("python_symbol_unsupported", "python.symbol_unsupported", { module, symbol });
if (!Array.isArray(args) || !finiteJson(args)) return this.fail("python_argument_invalid", "python.argument_invalid");
if (this.state.calls.length >= this.maxCalls) return this.fail("python_call_limit", "python.call_limit");
if (!this.state.imports.includes(module)) this.state.imports.push(module);
const call = { module, symbol, args: clone(args), tick: this.state.tick };
this.state.calls.push(call);
if (!this.state.imports.includes(module)) this.state.imports.push(module);
const apiResult = this.apiCall(module, symbol, args);
if (apiResult) return apiResult;
return { status: "verified", value: { module, symbol, args: clone(args) }, state_hash: this.hash() };
}
apiCall(module, symbol, args = []) {
const api = this.state.api;
const stringArg = (value) => typeof value === "string" && value.length > 0 ? value : null;
const finiteArg = (value) => typeof value === "number" && Number.isFinite(value) ? value : null;
if (module === "linuxcnc") {
if (symbol === "command") {
const command = stringArg(args[0]);
if (!command || !["mode", "estop", "abort", "run", "pause", "resume"].includes(command)) return this.fail("python_linuxcnc_command", "python.linuxcnc_command_invalid");
api.linuxcnc.last_command = command;
if (command === "mode" && ["MANUAL", "MDI", "AUTO"].includes(args[1])) api.linuxcnc.mode = args[1];
else if (command === "mode") return this.fail("python_linuxcnc_mode", "python.linuxcnc_mode_invalid");
else if (command === "estop") api.linuxcnc.estop = Boolean(args[1]);
else if (command === "abort") api.linuxcnc.task_state = "STOPPED";
else if (command === "run") api.linuxcnc.task_state = "RUNNING";
else if (command === "pause") api.linuxcnc.task_state = "PAUSED";
else if (command === "resume") api.linuxcnc.task_state = "RUNNING";
api.gstat.state = api.linuxcnc.task_state;
return { status: "verified", value: clone(api.linuxcnc), state_hash: this.hash() };
}
if (symbol === "stat") return { status: "verified", value: clone(api.linuxcnc), state_hash: this.hash() };
if (symbol === "ini") return { status: "verified", value: clone(api.ini), state_hash: this.hash() };
if (symbol === "error") return { status: "verified", value: { code: api.linuxcnc.estop ? "ESTOP" : null }, state_hash: this.hash() };
}
if (module === "gstat") {
if (!["state", "task", "motion"].includes(symbol)) return this.fail("python_gstat_symbol", "python.symbol_unsupported");
return { status: "verified", value: symbol === "state" ? clone(api.gstat) : api.gstat[symbol], state_hash: this.hash() };
}
if (module === "hal") {
const name = stringArg(args[0]);
if (symbol === "component") {
if (!name) return this.fail("python_hal_component", "python.hal_name_invalid");
api.hal.components[name] = { name, ready: true };
return { status: "verified", value: clone(api.hal.components[name]), state_hash: this.hash() };
}
if (["pin", "param", "signal"].includes(symbol)) {
if (!name) return this.fail("python_hal_name", "python.hal_name_invalid");
const collection = symbol === "param" ? api.hal.params : symbol === "signal" ? api.hal.signals : api.hal.pins;
const value = args.length > 1 ? args[1] : (collection[name]?.value ?? 0);
if (value !== undefined && value !== null && !finiteJson(value)) return this.fail("python_hal_value", "python.argument_invalid");
collection[name] = { name, kind: symbol, value };
return { status: "verified", value: clone(collection[name]), state_hash: this.hash() };
}
if (symbol === "thread") {
if (!name || !Number.isSafeInteger(args[1]) || args[1] < 1) return this.fail("python_hal_thread", "python.hal_thread_invalid");
api.hal.threads[name] = { name, period_ticks: args[1] };
return { status: "verified", value: clone(api.hal.threads[name]), state_hash: this.hash() };
}
}
if (module === "workspace") {
const path = virtualPath(args[0] ?? ((symbol === "list" || symbol === "hash") ? "/workspace/" : undefined));
if (!path) return this.fail("python_workspace_path", "python.workspace_path_invalid");
if (symbol === "read") return { status: "verified", value: api.workspace.files[path] ?? null, state_hash: this.hash() };
if (symbol === "write") {
if (typeof args[1] !== "string") return this.fail("python_workspace_data", "python.workspace_data_invalid");
const oldBytes = api.workspace.files[path] ? new TextEncoder().encode(api.workspace.files[path]).byteLength : 0;
const bytes = new TextEncoder().encode(args[1]).byteLength;
if (!api.workspace.files[path] && Object.keys(api.workspace.files).length >= WORKSPACE_MAX_FILES) return this.fail("python_workspace_file_limit", "python.workspace_file_limit");
if (api.workspace.bytes - oldBytes + bytes > WORKSPACE_MAX_BYTES) return this.fail("python_workspace_quota", "python.workspace_quota");
api.workspace.files[path] = args[1]; api.workspace.bytes = api.workspace.bytes - oldBytes + bytes;
return { status: "verified", value: { path, bytes }, state_hash: this.hash() };
}
if (symbol === "list") {
const prefix = args[1] === undefined ? path : virtualPath(args[1]);
if (!prefix) return this.fail("python_workspace_path", "python.workspace_path_invalid");
return { status: "verified", value: Object.keys(api.workspace.files).filter((item) => item.startsWith(prefix)).sort(), state_hash: this.hash() };
}
if (symbol === "stat") {
const data = api.workspace.files[path];
return { status: "verified", value: data === undefined ? null : { path, bytes: new TextEncoder().encode(data).byteLength }, state_hash: this.hash() };
}
if (symbol === "hash") return { status: "verified", value: fnv1a64(api.workspace), state_hash: this.hash() };
}
if (module === "virtual-clock" || module === "clock") {
if (symbol === "now" || symbol === "tick") return { status: "verified", value: this.virtualClock(), state_hash: this.hash() };
if (symbol === "sleep") {
const ticks = args[0];
if (!Number.isSafeInteger(ticks) || ticks < 0 || ticks > 1000000) return this.fail("python_clock_argument", "python.clock_argument_invalid");
return this.setTick(this.state.tick + ticks);
}
}
if (module === "INI" || module === "ini") {
const section = stringArg(args[0]);
const key = stringArg(args[1]);
if (symbol === "sections") return { status: "verified", value: Object.keys(api.ini.sections).sort(), state_hash: this.hash() };
if (symbol === "find" || symbol === "findall") {
if (!section || !key) return this.fail("python_ini_arguments", "python.ini_arguments_invalid");
const values = api.ini.values[`${section}.${key}`] ?? [];
return { status: "verified", value: symbol === "find" ? (values[0] ?? null) : [...values], state_hash: this.hash() };
}
}
if (module === "tool" || module === "tools") {
const tool = args[0];
if (!Number.isSafeInteger(tool) || tool < 0 || tool > 1001) return this.fail("python_tool_number", "python.tool_number_invalid");
if (symbol === "set") {
if (!finiteJson(args[1])) return this.fail("python_tool_value", "python.argument_invalid");
api.tools[String(tool)] = clone(args[1]);
}
return { status: "verified", value: clone(api.tools[String(tool)] ?? null), state_hash: this.hash() };
}
if (module === "parameter" || module === "parameters") {
const parameter = args[0];
if (!(Number.isSafeInteger(parameter) && parameter >= 1 && parameter <= 5399) && !(typeof parameter === "string" && parameter.length > 0)) return this.fail("python_parameter_name", "python.parameter_invalid");
if (symbol === "set") {
if (!finiteArg(args[1])) return this.fail("python_parameter_value", "python.argument_invalid");
api.parameters[String(parameter)] = args[1];
}
return { status: "verified", value: api.parameters[String(parameter)] ?? 0, state_hash: this.hash() };
}
return null;
}
configureIni(sections = {}) {
if (!sections || typeof sections !== "object" || Array.isArray(sections)) return this.fail("python_ini_shape", "python.ini_shape_invalid");
this.state.api.ini.sections = {};
this.state.api.ini.values = {};
for (const [section, values] of Object.entries(sections)) {
if (typeof section !== "string" || !values || typeof values !== "object" || Array.isArray(values)) return this.fail("python_ini_shape", "python.ini_shape_invalid");
this.state.api.ini.sections[section] = true;
for (const [key, value] of Object.entries(values)) {
if (!finiteJson(value)) return this.fail("python_ini_value", "python.argument_invalid");
this.state.api.ini.values[`${section}.${key}`] = Array.isArray(value) ? clone(value) : [value];
}
}
this.state.api.linuxcnc.ini = clone(this.state.api.ini);
return { status: "verified", state_hash: this.hash() };
}
async execute(source) {
if (typeof source !== "string" || new TextEncoder().encode(source).length > this.maxSourceBytes) return this.fail("python_source_limit", "python.source_limit");
const denied = manifest.denied_capabilities.find((capability) => new RegExp(`(?:^|[^A-Za-z0-9_])${capability.replaceAll(".", "\\.")}(?:$|[^A-Za-z0-9_])`).test(source));
@@ -90,7 +245,12 @@ export class PythonBridge {
return { status: extra.status ?? "blocked", code, message_key, retryable: false, ...extra, state_hash: this.hash() };
}
setTick(tick) { if (Number.isSafeInteger(tick) && tick >= 0) this.state.tick = tick; }
setTick(tick) {
if (!Number.isSafeInteger(tick) || tick < 0 || tick < this.state.tick) return this.fail("python_clock_regression", "python.clock_regression");
this.state.tick = tick;
return { status: "verified", clock: this.virtualClock(), state_hash: this.hash() };
}
virtualClock() { return { tick: this.state.tick, seconds: this.state.tick * 0.001, source: "logical_tick" }; }
exportState() { return { schema: PYTHON_BRIDGE_SCHEMA, initialized: this.initialized, state: clone(this.state) }; }
restoreState(value) {
if (!value || value.schema !== PYTHON_BRIDGE_SCHEMA || !value.state) return this.fail("python_state_invalid", "python.state_invalid");

View File

@@ -0,0 +1,105 @@
import { fnv1a64 } from "./replay.mjs";
export const CALLBACK_SCHEMA = "python-callbacks.v1";
const MAX_CALLBACKS = 256;
const MAX_SOURCE_BYTES = 65536;
function clone(value) { return structuredClone(value); }
function validName(value) { return typeof value === "string" && /^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/.test(value); }
function finiteJson(value) {
if (typeof value === "number") return Number.isFinite(value);
if (Array.isArray(value)) return value.every(finiteJson);
if (value && typeof value === "object") return Object.values(value).every(finiteJson);
return true;
}
function typedValue(type, value = 0) {
if (type === "bit") { if (![0, 1, false, true].includes(value)) throw new Error("vcp_bit_range"); return value ? 1 : 0; }
if (type === "s32") { if (!Number.isSafeInteger(value) || value < -2147483648 || value > 2147483647) throw new Error("vcp_s32_range"); return value; }
if (type === "u32") { if (!Number.isSafeInteger(value) || value < 0 || value > 4294967295) throw new Error("vcp_u32_range"); return value; }
if (type === "float" && typeof value === "number" && Number.isFinite(value)) return value;
throw new Error("vcp_value_type");
}
export class PythonCallbacks {
constructor({ maxCallbacks = MAX_CALLBACKS } = {}) { this.maxCallbacks = maxCallbacks; this.reset(); }
reset() { this.state = { schema: CALLBACK_SCHEMA, tick: 0, remaps: {}, vcp: {}, filters: {}, calls: [], errors: [], reentrant: false }; this.handlers = new Map(); return this.export(); }
export() { const value = clone(this.state); return { ...value, state_hash: fnv1a64(value) }; }
hash() { return this.export().state_hash; }
fail(code, detail = null) { this.state.errors.push({ code, tick: this.state.tick }); return { status: "blocked", code, ...(detail ? { detail } : {}), state_hash: this.hash() }; }
setTick(tick) { if (!Number.isSafeInteger(tick) || tick < this.state.tick) return this.fail("callback_clock_regression"); this.state.tick = tick; return { status: "verified", state_hash: this.hash() }; }
registerRemap(name, descriptor = {}, handler = null) {
const { prolog = null, epilog = null, ngc = null, python = null, modalgroup = null } = descriptor ?? {};
if (!validName(name) || (prolog !== null && !validName(prolog)) || (epilog !== null && !validName(epilog)) || (ngc !== null && !validName(ngc)) || (python !== null && !validName(python)) || (modalgroup !== null && (!Number.isSafeInteger(modalgroup) || modalgroup < 0))) return this.fail("remap_descriptor_invalid");
if (this.state.remaps[name]) return this.fail("remap_duplicate");
this.state.remaps[name] = { name, prolog, epilog, ngc, python, modalgroup, calls: 0, failures: 0 };
if (typeof handler === "function") this.handlers.set(`remap:${name}`, handler);
else if (handler && typeof handler === "object") for (const phase of ["prolog", "epilog"]) if (typeof handler[phase] === "function") this.handlers.set(`remap:${name}:${phase}`, handler[phase]);
return { status: "verified", descriptor: clone(this.state.remaps[name]), state_hash: this.hash() };
}
registerVcp(name, pins = [], handler = null) {
if (!validName(name) || !Array.isArray(pins) || pins.length > 256 || this.state.vcp[name] || pins.some((pin) => !validName(pin?.name) || !["bit", "s32", "u32", "float"].includes(pin?.type))) return this.fail(this.state.vcp[name] ? "vcp_duplicate" : "vcp_descriptor_invalid");
try {
this.state.vcp[name] = { name, pins: pins.map((pin) => ({ name: pin.name, type: pin.type, value: typedValue(pin.type, pin.value ?? 0) })), calls: 0, failures: 0 };
} catch (error) { return this.fail("vcp_descriptor_invalid", String(error.message)); }
if (typeof handler === "function") this.handlers.set(`vcp:${name}`, handler);
return { status: "verified", descriptor: clone(this.state.vcp[name]), state_hash: this.hash() };
}
registerFilter(name, handler = null) {
if (!validName(name)) return this.fail("filter_name_invalid");
if (this.state.filters[name]) return this.fail("filter_duplicate");
this.state.filters[name] = { name, calls: 0, failures: 0 };
if (typeof handler === "function") this.handlers.set(`filter:${name}`, handler);
return { status: "verified", descriptor: clone(this.state.filters[name]), state_hash: this.hash() };
}
invoke(kind, name, phase = "prolog", args = []) {
if (this.state.calls.length >= this.maxCallbacks) return this.fail("callback_budget");
if (!Array.isArray(args) || !finiteJson(args)) return this.fail("callback_arguments_invalid");
if (kind === "remap" && !["prolog", "epilog"].includes(phase)) return this.fail("remap_phase_invalid");
const key = `${kind}:${name}`;
const descriptor = kind === "remap" ? this.state.remaps[name] : kind === "vcp" ? this.state.vcp[name] : this.state.filters[name];
if (!descriptor) return this.fail("callback_missing");
if (kind === "remap" && descriptor[phase] === null) return this.fail("remap_phase_unsupported");
if (this.state.reentrant) return this.fail("callback_reentrant");
const handler = this.handlers.get(kind === "remap" ? `${key}:${phase}` : key) ?? this.handlers.get(key);
this.state.reentrant = true;
try {
const value = handler ? handler({ phase, args: clone(args), tick: this.state.tick }) : null;
descriptor.calls += 1; this.state.calls.push({ kind, name, phase, tick: this.state.tick });
return { status: "verified", value: value ?? null, state_hash: this.hash() };
} catch (error) {
descriptor.failures += 1; this.state.errors.push({ code: "callback_exception", tick: this.state.tick });
return { status: "blocked", code: "callback_exception", detail: String(error?.message ?? error), state_hash: this.hash() };
} finally { this.state.reentrant = false; }
}
filter(name, source, args = []) {
if (typeof source !== "string" || new TextEncoder().encode(source).length > MAX_SOURCE_BYTES) return this.fail("filter_source_limit");
if (/\b(?:subprocess|socket|os\.system|device_paths|arbitrary_host_paths)\b/.test(source)) return this.fail("real_hardware_excluded");
if (!Array.isArray(args) || !finiteJson(args)) return this.fail("filter_arguments_invalid");
return this.invoke("filter", name, "filter", [source, ...args]);
}
updateVcp(name, pinName, value) {
const descriptor = this.state.vcp[name];
const pin = descriptor?.pins.find((item) => item.name === pinName);
if (!pin) return this.fail("vcp_pin_missing");
let normalized;
try { normalized = typedValue(pin.type, value); } catch (error) { return this.fail("vcp_value_invalid", String(error.message)); }
const previous = pin.value;
pin.value = normalized;
const result = this.invoke("vcp", name, "pin", [pinName, normalized]);
if (result.status !== "verified") pin.value = previous;
return result;
}
restore(value) {
if (!value || value.schema !== CALLBACK_SCHEMA) throw new Error("callback_restore_schema");
const unsigned = { ...value }; delete unsigned.state_hash;
if (value.state_hash !== fnv1a64(unsigned)) throw new Error("callback_restore_hash");
this.state = clone(unsigned); this.handlers = new Map(); return this.export();
}
}

View File

@@ -0,0 +1,91 @@
import { createCheckpoint, recoverSimulation } from "../workspace/recovery.mjs";
import { createOfflinePackage, migrateWorkspacePackage, verifyOfflinePackage, verifyWorkspacePackage } from "../workspace/migration.mjs";
import { fnv1a64 } from "./replay.mjs";
export const RECOVERY_RUNTIME_SCHEMA = "worker-recovery.v1";
const clone = (value) => structuredClone(value);
export class RecoveryRuntime {
constructor({ contextId } = {}) {
this.contextId = contextId;
this.reset();
}
reset() {
this.state = { schema: RECOVERY_RUNTIME_SCHEMA, context_id: this.contextId ?? null, status: "idle", crash_count: 0, restart_count: 0, offline: false, checkpoint_hash: null, package_hash: null, last_error: null, recovery_hash: null };
this.checkpoint = null;
this.offlinePackage = null;
return this.export();
}
export() {
const value = { ...clone(this.state), checkpoint: this.checkpoint ? clone(this.checkpoint) : null, offline_package: this.offlinePackage ? clone(this.offlinePackage) : null };
return { ...value, state_hash: fnv1a64(value) };
}
fail(code) { this.state.status = "blocked"; this.state.last_error = code; return { status: "blocked", code, state: this.export() }; }
saveCheckpoint(options) {
try {
this.checkpoint = createCheckpoint({ contextId: this.contextId, ...options });
this.state.status = "checkpointed";
this.state.checkpoint_hash = this.checkpoint.checkpoint_hash;
this.state.last_error = null;
return { status: "verified", checkpoint: clone(this.checkpoint), state: this.export() };
} catch (error) { return this.fail(String(error?.message ?? error)); }
}
markCrash() {
if (!this.checkpoint) return this.fail("recovery_checkpoint_missing");
this.state.status = "crashed"; this.state.crash_count += 1; this.state.last_error = "worker_crash";
return { status: "verified", state: this.export() };
}
restart({ events = null, operations = null, apply, reconcile } = {}) {
if (!this.checkpoint) return this.fail("recovery_checkpoint_missing");
const result = recoverSimulation({ checkpoint: this.checkpoint, contextId: this.contextId, events, operations, apply, reconcile });
this.state.restart_count += 1;
this.state.status = result.status;
this.state.last_error = result.status === "blocked" ? result.blocked?.[0]?.reason ?? "recovery_blocked" : null;
this.state.recovery_hash = result.state_hash;
return { status: result.status === "blocked" ? "blocked" : "verified", recovery: result, state: this.export() };
}
createOffline(workspacePackage, resources, options = {}) {
try {
const packageValue = verifyWorkspacePackage(workspacePackage, { contextId: this.contextId });
this.offlinePackage = createOfflinePackage({ workspacePackage: packageValue, resources, ...options });
this.state.package_hash = this.offlinePackage.bundle_hash;
this.state.offline = true; this.state.last_error = null;
return { status: "verified", package: clone(this.offlinePackage), state: this.export() };
} catch (error) { return this.fail(String(error?.message ?? error)); }
}
verifyOffline(available, { online = false, workspacePackage = null } = {}) {
if (!this.offlinePackage) return this.fail("offline_package_missing");
try {
const result = verifyOfflinePackage(this.offlinePackage, { contextId: this.contextId, workspacePackage, available, online });
this.state.status = result.status === "ready" ? "offline_ready" : "network_required";
this.state.last_error = null;
return { status: "verified", result, state: this.export() };
} catch (error) { return this.fail(String(error?.message ?? error)); }
}
migrate(workspacePackage, targetVersion) {
try {
const migrated = migrateWorkspacePackage(verifyWorkspacePackage(workspacePackage, { contextId: this.contextId }), targetVersion);
this.state.package_hash = migrated.package_hash; this.state.last_error = null;
return { status: "verified", package: clone(migrated), state: this.export() };
} catch (error) { return this.fail(String(error?.message ?? error)); }
}
restore(value) {
if (!value || value.schema !== RECOVERY_RUNTIME_SCHEMA) throw new Error("recovery_runtime_schema");
const unsigned = clone(value); delete unsigned.state_hash;
if (value.state_hash !== fnv1a64(unsigned)) throw new Error("recovery_runtime_hash");
this.contextId = unsigned.context_id ?? this.contextId;
this.state = { ...unsigned }; this.checkpoint = this.state.checkpoint; this.offlinePackage = this.state.offline_package;
delete this.state.checkpoint; delete this.state.offline_package;
return this.export();
}
}

View File

@@ -0,0 +1,92 @@
import { fnv1a64 } from "./replay.mjs";
export const SCHEDULER_SCHEMA = "sim_scheduler.v1";
export const SCHEDULER_PHASES = Object.freeze(["reconcile", "task", "tp", "kinematics", "motion", "hal_io", "callbacks", "publish"]);
export const DEFAULT_TICK_BUDGET = 1_000_000;
function call(core, name, returnType, argTypes, args) {
if (typeof core?.ccall === "function") return core.ccall(name, returnType, argTypes, args);
const direct = core?.[`_${name}`] ?? core?.[name];
if (typeof direct === "function") return direct(...args);
throw new Error(`missing core export: ${name}`);
}
function clone(value) { return structuredClone(value); }
export class SimScheduler {
constructor(core, { budget = DEFAULT_TICK_BUDGET } = {}) {
if (!Number.isSafeInteger(budget) || budget < 1 || budget > DEFAULT_TICK_BUDGET) throw new Error("scheduler_budget_invalid");
this.core = core;
this.budget = budget;
this.tick = 0;
this.last_cycles = 0;
this.phase_trace = [...SCHEDULER_PHASES];
this.coreScheduler = 0;
this.coreSize = 0;
this.ensureCore();
}
ensureCore() {
if (this.coreScheduler || typeof this.core?._sim_scheduler_size !== "function") return;
if (typeof this.core?._malloc !== "function" || typeof this.core?._free !== "function") throw new Error("scheduler_core_memory_unavailable");
this.coreSize = call(this.core, "sim_scheduler_size", "number", [], []);
this.coreScheduler = this.core._malloc(this.coreSize);
if (!this.coreScheduler) throw new Error("scheduler_core_allocation_failed");
const result = call(this.core, "sim_scheduler_init", "number", ["number", "number"], [this.coreScheduler, this.budget]);
if (result !== 0) throw new Error(`scheduler_core_init_${result}`);
}
export() {
return { schema: SCHEDULER_SCHEMA, budget: this.budget, last_cycles: this.last_cycles, phase_trace: [...this.phase_trace], tick: this.tick, state_hash: this.hash() };
}
hash() {
const unsigned = { schema: SCHEDULER_SCHEMA, budget: this.budget, phase_trace: [...this.phase_trace], tick: this.tick };
return fnv1a64(unsigned);
}
step(cycles, callbacks = {}) {
if (!Number.isSafeInteger(cycles) || cycles < 0) throw new Error("scheduler_cycles_invalid");
if (cycles > this.budget) throw new Error("scheduler_budget_exceeded");
if (this.coreScheduler) {
const result = call(this.core, "sim_scheduler_step", "number", ["number", "number"], [this.coreScheduler, cycles]);
if (result !== 0) throw new Error(`scheduler_core_step_${result}`);
}
for (let index = 0; index < cycles; index += 1) {
const nextTick = this.tick + 1;
for (const phase of SCHEDULER_PHASES) {
this.phase_trace = [...SCHEDULER_PHASES];
if (typeof callbacks[phase] === "function") callbacks[phase](nextTick);
}
this.tick = nextTick;
}
this.last_cycles = cycles;
return this.export();
}
restore(value) {
if (!value || value.schema !== SCHEDULER_SCHEMA || !Number.isSafeInteger(value.budget) || value.budget < 1 || value.budget > DEFAULT_TICK_BUDGET || !Number.isSafeInteger(value.tick) || value.tick < 0 || !Number.isSafeInteger(value.last_cycles) || value.last_cycles < 0 || !Array.isArray(value.phase_trace) || value.phase_trace.join("\0") !== SCHEDULER_PHASES.join("\0")) throw new Error("scheduler_restore_invalid");
const unsigned = { schema: SCHEDULER_SCHEMA, budget: value.budget, phase_trace: [...value.phase_trace], tick: value.tick };
if (value.state_hash !== fnv1a64(unsigned)) throw new Error("scheduler_restore_hash");
this.budget = value.budget;
this.tick = value.tick;
this.last_cycles = value.last_cycles;
this.phase_trace = [...value.phase_trace];
this.ensureCore();
if (this.coreScheduler) {
const phases = new Uint32Array(SCHEDULER_PHASES.map((_, index) => index));
const pointer = this.core._malloc(phases.byteLength);
this.core.HEAPU32.set(phases, pointer / 4);
try {
const result = call(this.core, "sim_scheduler_restore", "number", ["number", "number", "number", "number", "number", "number"], [this.coreScheduler, this.tick, this.budget, this.last_cycles, pointer, SCHEDULER_PHASES.length]);
if (result !== 0) throw new Error(`scheduler_core_restore_${result}`);
} finally { this.core._free(pointer); }
}
return this.export();
}
destroy() {
if (this.coreScheduler && typeof this.core?._free === "function") this.core._free(this.coreScheduler);
this.coreScheduler = 0;
}
}

View File

@@ -3,22 +3,40 @@ import { capabilities, validateLimit } from "./capabilities.mjs";
import { mapCoreError } from "./error-map.mjs";
import { createCheckpoint, recoverSimulation } from "../workspace/recovery.mjs";
import { preflightConfiguration } from "../workspace/configuration.mjs";
import { ConfigurationStartupStateMachine } from "../workspace/configuration.mjs";
import { preflightHalConfiguration } from "../workspace/hal-configuration.mjs";
import { DEVICE_COMMANDS, VirtualDevices } from "./virtual-devices.mjs";
import { PythonBridge } from "./python-bridge.mjs";
import { ProgramRunner } from "./program-runner.mjs";
import { SimTask } from "./sim-task.mjs";
import { SimScheduler, SCHEDULER_PHASES } from "./sim-scheduler.mjs";
import { OperationLedger } from "./operation-ledger.mjs";
import { MotionState } from "./motion-state.mjs";
import { PlcRuntime } from "./plc-runtime.mjs";
import { HalRuntime } from "./hal-runtime.mjs";
import { DeviceIoRuntime } from "./device-io.mjs";
import { IoAckTracker } from "./io-ack.mjs";
import { PythonCallbacks } from "./python-callbacks.mjs";
import { createTrace, validateTrace } from "./trace-export.mjs";
import { ConfigurationBatchRuntime } from "../workspace/configuration-batch.mjs";
import { AuthoritativeRuntime } from "./authoritative-runtime.mjs";
const PROTOCOL_SCHEMA = 1;
const CONTEXT_ID = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1";
const OPERATION_KINDS = new Set([
"LOAD_WORKSPACE", "SIM_STEP", "DESTROY", "CHECKPOINT", "RECOVER", "ABORT",
"SESSION_ATTACH", "LOAD_WORKSPACE", "SIM_STEP", "DESTROY", "CHECKPOINT", "RECOVER", "ABORT",
"COMMAND", "START", "STOP", "RESET", "PROGRAM_LOAD", "PROGRAM_RUN",
"PROGRAM_PAUSE", "PROGRAM_RESUME", "PROGRAM_ABORT", "JOG_START", "JOG_STOP",
"PROGRAM_PAUSE", "PROGRAM_RESUME", "PROGRAM_ABORT", "PROGRAM_RESTART", "JOG_START", "JOG_STOP",
"HOME", "SET_MODE", "SET_OVERRIDE", "SPINDLE_COMMAND", "TOOL_PREPARE",
"TOOL_CHANGED", "TOOL_PREPARED", "TOOL_CHANGE", "TOOL_ABORT", "TOOL_FAULT", "TOOL_CLEAR_FAULT",
"SPINDLE_FAULT", "SPINDLE_INHIBIT", "SPINDLE_ORIENT", "SPINDLE_ORIENTED", "IO_STEP", "IO_RECOVER",
"PROBE_CONFIG", "LIMIT_SET", "ESTOP", "HAL_COMMAND", "HAL_SET",
"ENCODER_SET", "COOLANT_COMMAND", "COOLANT_FAULT", "DOOR_SET", "INTERLOCK_SET", "INTERLOCK_RESET", "DEVICE_ESTOP", "DEVICE_ACK_ISSUE", "DEVICE_ACK", "DEVICE_ACK_ABORT",
"CONFIGURATION_BATCH",
"PYTHON_IMPORT", "PYTHON_CALL", "PYTHON_CAPABILITY",
"PROGRAM_STEP", "PROGRAM_SEEK", "MDI_EXECUTE", "PLC_CONFIG", "PLC_SET", "PLC_TRIGGER", "FOLLOWING_ERROR", "IO_COMMAND", "IO_ACK", "IO_TIMEOUT",
"REMAP_REGISTER", "REMAP_CALL", "VCP_REGISTER", "VCP_UPDATE", "FILTER_EXEC",
]);
function text(value) {
@@ -59,8 +77,20 @@ export class SimSession {
this.created = false;
this.timeline = new ReplayTimeline();
this.operations = new Map();
this.ledger = new OperationLedger();
this.devices = new VirtualDevices();
this.motion = new MotionState();
this.plc = new PlcRuntime();
this.hal = new HalRuntime();
this.deviceIo = new DeviceIoRuntime();
this.configurationBatch = new ConfigurationBatchRuntime({ context_id: contextId });
this.ioAck = new IoAckTracker();
this.callbacks = new PythonCallbacks();
this.python = new PythonBridge();
this.program = new ProgramRunner(core);
this.authoritative = new AuthoritativeRuntime();
this.task = new SimTask(core, { contextId, sessionId: sessionId || null });
this.scheduler = new SimScheduler(core);
this.configurationState = null;
this.halState = null;
call(core, "sim_protocol_reset");
@@ -102,17 +132,30 @@ export class SimSession {
}
stateForCheckpoint() {
return {
const state = {
context_id: this.contextId,
session_id: this.sessionId,
tick: this.tick,
created: this.created,
protocol_seq: this.timeline.events.cursor,
devices: this.devices.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(),
configuration: this.configurationState,
hal: this.halState,
python: this.python.exportState(),
task: this.task.export(),
scheduler: this.scheduler.export(),
authoritative: this.authoritative.export(),
ledger: this.ledger.export(),
};
if (this.program.loaded) state.program = this.program.exportState();
return state;
}
workspacePackageState() {
@@ -122,10 +165,21 @@ export class SimSession {
configuration: this.configurationState,
hal: this.halState,
devices: this.devices.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(),
python: this.python.exportState(),
tick: this.tick,
event_cursor: this.timeline.events.cursor,
task: this.task.export(),
scheduler: this.scheduler.export(),
authoritative: this.authoritative.export(),
};
if (this.program.loaded) value.program = this.program.exportState();
return { ...value, state_hash: fnv1a64(value) };
}
@@ -147,10 +201,57 @@ export class SimSession {
});
}
exportTrace() {
const latest = this.timeline.snapshots.latest();
if (!latest || latest.seq !== this.timeline.events.cursor || latest.tick !== this.timeline.events.tick) {
this.timeline.checkpoint(this.timeline.events.cursor, this.timeline.events.tick, this.stateForCheckpoint(), latest?.snapshot_id ?? null);
}
return createTrace({ contextId: this.contextId, timeline: this.timeline, state: this.stateForCheckpoint(), task: this.task.export(), tick: this.tick });
}
importTrace(trace) {
const validated = validateTrace(trace, { contextId: this.contextId });
this.timeline = validated.timeline;
this.created = Boolean(trace.state?.created);
if (trace.state?.ledger?.entries) this.ledger = new OperationLedger(trace.state.ledger.entries);
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) this.motion.restore(trace.state.motion);
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);
if (trace.state?.configuration_batch) this.configurationBatch.restore(trace.state.configuration_batch);
if (trace.state?.io_ack) this.ioAck.restore(trace.state.io_ack);
if (trace.state?.callbacks) this.callbacks.restore(trace.state.callbacks);
if (trace.state?.python) this.python.restoreState(trace.state.python);
if (trace.state?.devices) this.devices = new VirtualDevices({ state: trace.state.devices });
this.tick = trace.tick;
return { status: "verified", trace_hash: trace.trace_hash, state_hash: fnv1a64(trace.state), seq: trace.seq, tick: trace.tick };
}
restoreCheckpoint(checkpoint, events = null, reconcile = undefined) {
const replayDevices = new VirtualDevices({ state: checkpoint?.state?.devices });
const replayMotion = new MotionState();
if (checkpoint?.state?.motion) replayMotion.restore(checkpoint.state.motion);
const replayPlc = new PlcRuntime();
if (checkpoint?.state?.plc) replayPlc.restore(checkpoint.state.plc);
const replayHal = new HalRuntime();
if (checkpoint?.state?.hal_runtime) replayHal.restore(checkpoint.state.hal_runtime);
const replayDeviceIo = new DeviceIoRuntime();
if (checkpoint?.state?.device_io) replayDeviceIo.restore(checkpoint.state.device_io);
const replayConfigurationBatch = new ConfigurationBatchRuntime({ context_id: this.contextId });
if (checkpoint?.state?.configuration_batch) replayConfigurationBatch.restore(checkpoint.state.configuration_batch);
const replayIoAck = new IoAckTracker();
if (checkpoint?.state?.io_ack) replayIoAck.restore(checkpoint.state.io_ack);
const replayCallbacks = new PythonCallbacks();
if (checkpoint?.state?.callbacks) replayCallbacks.restore(checkpoint.state.callbacks);
const replayProgram = new ProgramRunner(this.core);
if (checkpoint?.state?.program) replayProgram.restoreState(checkpoint.state.program);
const replayPython = new PythonBridge();
if (checkpoint?.state?.python) replayPython.restoreState(checkpoint.state.python);
const replayAuthoritative = new AuthoritativeRuntime();
if (checkpoint?.state?.authoritative) replayAuthoritative.restore(checkpoint.state.authoritative);
const result = recoverSimulation({
checkpoint,
contextId: this.contextId,
@@ -158,19 +259,132 @@ 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 === "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);
if (event.kind === "LIMIT_SET") replayMotion.setLimit(event.payload?.axis, event.payload?.active === true);
if (event.kind === "ESTOP") replayMotion.estop(event.payload?.active === true);
if (event.kind === "ESTOP") replayDeviceIo.estop(event.payload?.active === true);
if (event.kind === "IO_RECOVER") replayDeviceIo.interlockReset();
if (event.kind === "SIM_STEP") replayMotion.step(event.tick);
if (event.kind === "SIM_STEP") replayAuthoritative.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 === "SIM_STEP" && replayProgram.loaded) {
if (replayProgram.status === "paused" && state.task?.program_status === "running") replayProgram.resume();
replayProgram.tick(Number.isSafeInteger(event.payload?.cycles) ? event.payload.cycles : 1);
replayAuthoritative.syncProgram(replayProgram.snapshot());
}
if (event.kind === "PLC_CONFIG") replayPlc.configure(event.payload);
if (event.kind === "PLC_SET") replayPlc.setRegister(event.payload?.register, event.payload?.value);
if (event.kind === "PLC_TRIGGER") replayPlc.trigger();
if (event.kind === "SIM_STEP") for (let replayTick = replayPlc.state.tick + 1; replayTick <= event.tick; replayTick += 1) replayPlc.scan(replayTick);
if (event.kind === "HAL_COMMAND" && event.payload?.action === "configure") replayHal.configure(event.payload);
if (event.kind === "HAL_COMMAND" && event.payload?.action === "thread") replayHal.registerThread(event.payload?.name, event.payload?.period_ticks);
if (event.kind === "HAL_COMMAND" && event.payload?.action === "component") replayHal.registerComponent(event.payload?.instance, event.payload?.type, event.payload?.thread);
if (event.kind === "HAL_COMMAND" && event.payload?.action === "connect") replayHal.connect(event.payload?.source, event.payload?.target);
if (event.kind === "HAL_COMMAND" && event.payload?.action === "failure") replayHal.setFailure(event.payload?.instance, event.payload?.failed === true);
if (event.kind === "HAL_SET" && event.payload?.target === "pin") replayHal.setPin(event.payload?.name, event.payload?.value, event.payload?.type);
if (event.kind === "HAL_SET" && event.payload?.target === "param") replayHal.setParam(event.payload?.name, event.payload?.value, event.payload?.type);
if (event.kind === "SIM_STEP") replayHal.step(event.tick);
if (event.kind === "ENCODER_SET") replayDeviceIo.encoderSet(event.payload?.channel, event.payload);
if (event.kind === "COOLANT_COMMAND") replayDeviceIo.coolantCommand(event.payload);
if (event.kind === "COOLANT_FAULT") replayDeviceIo.coolantFault(event.payload?.fault === true);
if (event.kind === "DOOR_SET") replayDeviceIo.doorSet(event.payload?.open === true);
if (event.kind === "INTERLOCK_SET") replayDeviceIo.interlockSet(event.payload?.input_ok === true);
if (event.kind === "INTERLOCK_RESET") replayDeviceIo.interlockReset();
if (event.kind === "DEVICE_ESTOP") replayDeviceIo.estop(event.payload?.active === true);
if (event.kind === "DEVICE_ACK_ISSUE") replayDeviceIo.issueAck(event.operation_id ?? event.payload?.operation_id, event.payload?.timeout_ticks ?? 10);
if (event.kind === "DEVICE_ACK") replayDeviceIo.ack(event.payload?.operation_id);
if (event.kind === "DEVICE_ACK_ABORT") replayDeviceIo.abortAck(event.payload?.operation_id);
if (event.kind === "SIM_STEP") replayDeviceIo.step(event.tick);
if (event.kind === "CONFIGURATION_BATCH") replayConfigurationBatch.load(event.payload?.entries ?? [], { context_id: this.contextId });
if (event.kind === "IO_COMMAND") replayIoAck.issue(event.payload?.operation_id ?? event.payload?.command_id, event.payload?.kind ?? "IO", event.tick, event.payload?.timeout_ticks ?? 10);
if (event.kind === "IO_COMMAND") replayDeviceIo.issueAck(event.payload?.operation_id ?? event.payload?.command_id, event.payload?.timeout_ticks ?? 10);
if (event.kind === "IO_ACK") replayIoAck.ack(event.payload?.operation_id, event.tick);
if (event.kind === "IO_ACK") replayDeviceIo.ack(event.payload?.operation_id);
if (event.kind === "IO_TIMEOUT") replayIoAck.step(event.tick);
if (event.kind === "SIM_STEP") replayIoAck.step(event.tick);
if (event.kind === "IO_TIMEOUT") replayDeviceIo.ackStep(event.tick);
if (event.kind === "REMAP_REGISTER") replayCallbacks.registerRemap(event.payload?.name, event.payload);
if (event.kind === "VCP_REGISTER") replayCallbacks.registerVcp(event.payload?.name, event.payload?.pins ?? []);
if (event.kind === "FILTER_EXEC") replayCallbacks.filter(event.payload?.name, event.payload?.source ?? "", event.payload?.args ?? []);
if (event.kind === "REMAP_CALL") replayCallbacks.invoke("remap", event.payload?.name, event.payload?.phase ?? "prolog", event.payload?.args ?? []);
if (event.kind === "VCP_UPDATE") replayCallbacks.updateVcp(event.payload?.name, event.payload?.pin, event.payload?.value);
if (event.kind === "PYTHON_IMPORT") replayPython.importModule(event.payload?.module);
if (event.kind === "PYTHON_CALL") replayPython.invoke(event.payload?.module, event.payload?.symbol, event.payload?.args ?? []);
if (event.kind === "PYTHON_CAPABILITY") replayPython.checkCapability(event.payload?.capability);
return { ...state, tick: event.tick, devices: replayDevices.export(), python: replayPython.exportState() };
if (event.kind === "SIM_STEP") replayCallbacks.setTick(event.tick);
if (event.kind === "SIM_STEP") replayPython.setTick(event.tick);
const task = { ...(state.task ?? this.task.export()), seq: event.seq, simulation_tick: event.tick };
delete task.state_hash;
if (event.kind === "ESTOP" && event.payload?.active === true) {
task.state = "ESTOP";
} else if (event.kind === "IO_RECOVER") {
task.state = "READY";
} else if (event.kind === "PROGRAM_PAUSE") {
task.state = "PAUSED";
task.program_status = "paused";
} else if (event.kind === "PROGRAM_RESUME" || event.kind === "PROGRAM_RUN") {
task.state = "RUNNING";
task.program_status = "running";
} else if (event.kind === "PROGRAM_ABORT" || event.kind === "ABORT") {
task.state = "STOPPED";
task.program_status = "aborted";
}
if (replayProgram.loaded) {
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";
}
const schedulerUnsigned = {
schema: "sim_scheduler.v1",
budget: state.scheduler?.budget ?? 1000000,
phase_trace: [...SCHEDULER_PHASES],
tick: event.tick,
};
const scheduler = {
...schedulerUnsigned,
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() } : {}) };
},
});
if (result.status === "recovered" || result.status === "aborted") {
this.tick = result.simulation_tick;
if (result.state?.task) this.task.restore(result.state.task);
if (result.state?.scheduler) this.scheduler.restore(result.state.scheduler);
this.authoritative = new AuthoritativeRuntime();
if (result.state?.authoritative) this.authoritative.restore(result.state.authoritative);
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);
this.plc = new PlcRuntime();
if (result.state?.plc) this.plc.restore(result.state.plc);
this.hal = new HalRuntime();
if (result.state?.hal_runtime) this.hal.restore(result.state.hal_runtime);
this.deviceIo = new DeviceIoRuntime();
if (result.state?.device_io) this.deviceIo.restore(result.state.device_io);
this.configurationBatch = new ConfigurationBatchRuntime({ context_id: this.contextId });
if (result.state?.configuration_batch) this.configurationBatch.restore(result.state.configuration_batch);
this.ioAck = new IoAckTracker();
if (result.state?.io_ack) this.ioAck.restore(result.state.io_ack);
this.callbacks = new PythonCallbacks();
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.python = new PythonBridge();
if (result.state?.python) this.python.restoreState(result.state.python);
this.program = new ProgramRunner(this.core);
if (result.state?.program) this.program.restoreState(result.state.program);
this.timeline = new ReplayTimeline(checkpoint.timeline);
if (events?.schema === "replay-timeline.v1") {
for (const event of events.events.events.slice(this.timeline.events.cursor)) this.timeline.appendEvent(event.seq, event.tick, event.kind, event.payload, event.payload_hash);
@@ -178,6 +392,11 @@ export class SimSession {
for (const event of events) this.timeline.appendEvent(event.seq, event.tick, event.kind, event.payload, event.payload_hash);
}
this.operations = new Map(result.operations.map((operation) => [operation.operation_id, operation]));
this.ledger = new OperationLedger();
for (const operation of result.operations) {
const entry = { ...operation, phase: operation.phase ?? (operation.status === "verified" ? "committed" : operation.status), side_effect_count: operation.side_effect_count ?? (operation.status === "verified" ? 1 : 0) };
try { this.ledger.importEntry(entry); } catch { /* legacy checkpoint entries remain in operations */ }
}
}
return result;
}
@@ -188,7 +407,10 @@ export class SimSession {
if (input.context_id !== this.contextId) return ["protocol_context", "context_mismatch"];
if (!text(input.kind) || !Number.isSafeInteger(input.seq) || input.seq <= 0) return ["protocol_argument", "invalid_sequence_or_kind"];
if (this.sessionId && input.session_id !== this.sessionId) return ["protocol_session", "session_mismatch"];
if (!this.sessionId && text(input.session_id)) this.sessionId = input.session_id;
if (!this.sessionId && text(input.session_id)) {
this.sessionId = input.session_id;
this.task.bindSession(this.sessionId);
}
return null;
}
@@ -197,6 +419,37 @@ export class SimSession {
if (invalid) return this.error(input ?? {}, invalid[0], invalid[1]);
const result = call(this.core, "sim_protocol_accept", input.schema_version, input.seq, input.kind, input.operation_id ?? "", input.simulation_tick ?? this.tick);
if (result !== 0) return this.coreError(input, result, "sim_protocol_accept");
let ledgerPreparation;
try {
ledgerPreparation = this.ledger.prepare(input);
if (ledgerPreparation.replay && ledgerPreparation.entry?.response) {
const replayTick = Number.isSafeInteger(input.simulation_tick) && input.simulation_tick >= this.tick ? input.simulation_tick : this.tick;
this.timeline.appendEvent(input.seq, replayTick, "OPERATION_REPLAY", { operation_id: input.operation_id, original_seq: ledgerPreparation.entry.seq });
const replayEventResult = appendCoreEvent(this.core, input.seq, replayTick, "OPERATION_REPLAY", { operation_id: input.operation_id, original_seq: ledgerPreparation.entry.seq });
if (replayEventResult !== 0) return this.error(input, "event_log", "core_event_append_failed");
this.tick = replayTick;
try { this.task.setCommand(input.seq, replayTick); } catch (error) { return this.error(input, "task_state", String(error?.message ?? error)); }
return { ...ledgerPreparation.entry.response, replayed: true, seq: input.seq, ledger: this.ledger.reconcile(input.operation_id) };
}
if (ledgerPreparation.entry) this.ledger.execute(input.operation_id);
} catch (error) {
const ledgerCode = String(error?.message ?? error);
try {
this.task.setCommand(input.seq, input.simulation_tick ?? this.tick);
this.tick = this.task.state.simulation_tick;
} catch { /* protocol has already accepted the command; retain ledger error */ }
try {
this.timeline.appendEvent(input.seq, this.tick, "COMMAND_REJECTED", { code: ledgerCode, operation_id: input.operation_id ?? null });
appendCoreEvent(this.core, input.seq, this.tick, "COMMAND_REJECTED", { code: ledgerCode, operation_id: input.operation_id ?? null });
} catch { /* preserve the original ledger diagnostic */ }
return this.error(input, ledgerCode.startsWith("ledger_") ? ledgerCode : "ledger", ledgerCode);
}
try {
this.task.setCommand(input.seq, input.simulation_tick ?? this.tick);
this.tick = this.task.state.simulation_tick;
} catch (error) {
return this.error(input, "task_state", String(error?.message ?? error));
}
const existingOperation = this.operationState(input);
if (existingOperation?.response) {
if (existingOperation.kind !== input.kind) return this.error(input, "recovery_operation_conflict", "operation_id_conflict");
@@ -237,21 +490,54 @@ export class SimSession {
operation.result_hash = fnv1a64(response.payload ?? {});
operation.response = response;
}
try { this.ledger.verify(input.operation_id, response); this.ledger.commit(input.operation_id, input.seq); } catch (error) {
const ledgerCode = String(error?.message ?? error);
return this.error(input, ledgerCode.startsWith("ledger_") ? ledgerCode : "ledger", ledgerCode);
}
}
return response;
};
const deviceResponse = (kind, payload) => {
const result = this.devices.command(kind, payload ?? {});
try {
if (kind === "ESTOP" && payload?.active === true) this.task.estop(-904);
else if (kind === "IO_RECOVER" && result.status === "verified") this.task.recover();
else if (kind === "TOOL_FAULT" && payload?.fault === true) this.task.fault(-905);
} catch (error) {
return finish(this.error(input, "task_state", String(error?.message ?? error)), "failed");
}
if (kind === "ESTOP") this.deviceIo.estop(payload?.active === true);
if (kind === "IO_RECOVER" && result.status === "verified") this.deviceIo.interlockReset();
const responseKind = result.status === "verified" ? "COMMAND_RESULT" : "ERROR";
return finish(this.envelope(responseKind, input, {
...result,
command: kind,
operation_id: input.operation_id ?? null,
...result,
command: kind,
task: this.task.export(),
device_io: this.deviceIo.export(),
motion: this.motion.export(),
operation_id: input.operation_id ?? null,
simulation_tick: this.tick,
}), result.status === "verified" ? "verified" : "failed");
};
const deviceIoResponse = (kind, payload = {}) => {
let result;
if (kind === "ENCODER_SET") result = this.deviceIo.encoderSet(payload.channel, payload);
else if (kind === "COOLANT_COMMAND") result = this.deviceIo.coolantCommand(payload);
else if (kind === "COOLANT_FAULT") result = this.deviceIo.coolantFault(payload.fault === true);
else if (kind === "DOOR_SET") result = this.deviceIo.doorSet(payload.open === true);
else if (kind === "INTERLOCK_SET") result = this.deviceIo.interlockSet(payload.input_ok === true);
else if (kind === "INTERLOCK_RESET") result = this.deviceIo.interlockReset();
else if (kind === "DEVICE_ESTOP") result = this.deviceIo.estop(payload.active === true);
else if (kind === "DEVICE_ACK_ISSUE") result = this.deviceIo.issueAck(input.operation_id ?? payload.operation_id, payload.timeout_ticks ?? 10);
else if (kind === "DEVICE_ACK_ABORT") result = this.deviceIo.abortAck(payload.operation_id);
else if (kind === "DEVICE_ACK") result = this.deviceIo.ack(payload.operation_id);
else result = this.deviceIo.fail("device_io_command_unsupported");
const responseKind = result.status === "verified" ? "COMMAND_RESULT" : "ERROR";
return finish(this.envelope(responseKind, input, { ...result, command: kind, device_io: this.deviceIo.export(), task: this.task.export(), operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
};
switch (input.kind) {
case "HELLO":
return finish(this.envelope("CAPABILITIES", input, capabilities(this.contextId)), "verified");
@@ -261,27 +547,57 @@ export class SimSession {
if (createResult !== 0) return finish(this.coreError(input, createResult, "sim_create"), "failed");
this.created = true;
}
return finish(this.envelope("SESSION_READY", input, { state_hash: fnv1a64({ tick: this.tick }), subscriptions: [] }), "verified");
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 "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");
if (input.payload?.configuration) {
const preflight = preflightConfiguration(input.payload.configuration);
if (preflight.status !== "ready") {
return finish(this.envelope("ERROR", input, {
status: "blocked",
code: "configuration_preflight",
message_key: "configuration.preflight_blocked",
retryable: false,
preflight,
}), "failed");
let startupLoaded = false;
const configuration = input.payload?.configuration ?? null;
if (configuration) {
const startupWorkspace = configuration.workspace ?? configuration.files ?? configuration.workspace_files;
if (startupWorkspace) {
const startup = new ConfigurationStartupStateMachine({
workspace: startupWorkspace,
entrypoint: configuration.entrypoint,
variables: configuration.variables ?? {},
maxDepth: configuration.maxDepth ?? 32,
core: this.core,
coreCreated: this.created,
task: this.task,
halManifest: configuration.halManifest ?? configuration.hal_manifest ?? configuration.manifest ?? [],
});
const startupReport = startup.run();
if (startupReport.status !== "ready") {
return finish(this.envelope("ERROR", input, {
status: "blocked",
code: "configuration_startup",
message_key: "configuration.startup_blocked",
retryable: false,
startup: startupReport,
}), "failed");
}
this.configurationState = startupReport;
this.created = true;
startupLoaded = true;
} else {
const preflight = preflightConfiguration(input.payload.configuration);
if (preflight.status !== "ready") {
return finish(this.envelope("ERROR", input, {
status: "blocked",
code: "configuration_preflight",
message_key: "configuration.preflight_blocked",
retryable: false,
preflight,
}), "failed");
}
this.configurationState = {
entrypoint: input.payload.configuration.entrypoint ?? null,
status: preflight.status,
resource_count: preflight.resource_count,
state_hash: fnv1a64(preflight),
};
}
this.configurationState = {
entrypoint: input.payload.configuration.entrypoint ?? null,
status: preflight.status,
resource_count: preflight.resource_count,
state_hash: fnv1a64(preflight),
};
}
if (input.payload?.hal) {
const preflight = preflightHalConfiguration(input.payload.hal);
@@ -300,28 +616,268 @@ export class SimSession {
state_hash: fnv1a64(preflight),
};
}
const loadResult = call(this.core, "sim_load_workspace", rootPath);
return loadResult === 0 ? finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, state_hash: fnv1a64({ tick: this.tick }) })) : finish(this.coreError(input, loadResult, "sim_load_workspace"), "failed");
const configuredKinematics = input.payload?.kinematics ?? configuration?.kinematics?.type ?? null;
if (configuredKinematics) {
const kinematics = this.authoritative.setKinematics(configuredKinematics);
if (kinematics.status === "blocked") return finish(this.envelope("ERROR", input, { status: "blocked", code: kinematics.code, kinematics: this.authoritative.export() }), "failed");
}
const loadResult = startupLoaded ? 0 : call(this.core, "sim_load_workspace", rootPath);
return loadResult === 0 ? finish(this.envelope("COMMAND_RESULT", input, {
status: "verified",
command: input.kind,
state_hash: this.task.hash(),
task: this.task.export(),
...(this.configurationState?.schema === "configuration-startup-runtime.v1" ? { configuration: this.configurationState } : {}),
})) : finish(this.coreError(input, loadResult, "sim_load_workspace"), "failed");
}
case "SIM_STEP": {
const cycles = input.payload?.cycles;
const limit = validateLimit("max_tick_step", cycles);
if (limit.status !== "accepted") return finish(this.envelope("ERROR", input, { ...limit, operation_id: input.operation_id ?? null, simulation_tick: this.tick, retryable: false }), "failed");
const stepResult = call(this.core, "sim_step", cycles);
if (stepResult !== 0) return finish(this.coreError(input, stepResult, "sim_step"), "failed");
this.tick += cycles;
this.python.setTick(this.tick);
const protocolTick = call(this.core, "sim_protocol_set_tick", this.tick);
if (protocolTick !== 0) return finish(this.error(input, "protocol_tick", "tick_update_failed"), "failed");
const snapshot = this.timeline.checkpoint(input.seq, this.tick, { tick: this.tick, cycles }, this.timeline.snapshots.latest()?.snapshot_id ?? null);
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", cycles, state_hash: snapshot.state_hash, snapshot }));
let program_steps = [];
try {
this.scheduler.step(cycles, {
task: () => {
program_steps.push(...this.program.tick(1));
},
tp: (nextTick) => {
if (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);
},
kinematics: () => {
if (!["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);
if (stepResult !== 0) throw new Error(`core_sim_step_${stepResult}`);
this.tick = nextTick;
this.task.setTick(nextTick);
const motionState = this.motion.step(nextTick);
if (motionState.fault && this.task.state.state === "RUNNING") this.task.fault(-1205);
if (this.program.loaded) this.authoritative.applyMotion(this.motion);
const halState = this.hal.step(nextTick);
if (halState.status === "error") throw new Error(halState.code);
const deviceIoState = this.deviceIo.step(nextTick);
if (deviceIoState.status === "blocked") throw new Error(deviceIoState.code);
const plcState = this.plc.scan(nextTick);
if (plcState.status === "error") throw new Error(plcState.code);
const ackState = this.ioAck.step(nextTick);
if (ackState.status === "error") throw new Error(ackState.code);
const callbackClock = this.callbacks.setTick(nextTick);
if (callbackClock.status === "blocked") throw new Error(callbackClock.code);
this.python.setTick(nextTick);
const protocolTick = call(this.core, "sim_protocol_set_tick", nextTick);
if (protocolTick !== 0) throw new Error(`protocol_tick_${protocolTick}`);
},
});
} catch (error) {
return finish(this.error(input, "scheduler", String(error?.message ?? error)), "failed");
}
const program = this.program.loaded ? this.program.snapshot() : null;
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");
}
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() };
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 }));
}
case "PROGRAM_LOAD": {
if (["RUNNING", "PAUSED"].includes(this.task.state.state)) return finish(this.error(input, "task_state", "program_load_while_active"), "failed");
const source = input.payload?.source ?? input.payload?.text ?? input.payload?.program_source;
const result = this.program.load(source, input.payload?.path ?? null);
if (result.status === "verified") {
this.authoritative.reset();
this.task.setProgram("loaded", 0);
}
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");
}
case "PROGRAM_RUN": {
const result = this.program.start();
if (result.status === "verified") {
this.task.setMode("AUTO");
if (this.task.state.state === "STOPPED") this.task.recover();
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(),
command: input.kind,
operation_id: input.operation_id ?? null,
}), result.status === "verified" ? "verified" : "failed");
}
case "PROGRAM_PAUSE": case "PROGRAM_RESUME": case "PROGRAM_ABORT": {
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); }
}
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");
}
case "PROGRAM_RESTART": {
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 (this.task.state.state === "STOPPED") this.task.recover();
this.task.setMode("AUTO");
this.authoritative.reset();
this.task.setProgram("loaded", 0);
}
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");
}
case "PROGRAM_STEP": {
if (this.task.state.state === "READY") {
this.task.setMode("AUTO");
this.task.transition("RUNNING");
}
const result = this.program.step();
if (result.status === "verified") {
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");
} 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(),
command: input.kind,
operation_id: input.operation_id ?? null,
}), result.status === "verified" ? "verified" : "failed");
}
case "PROGRAM_SEEK": {
const result = this.program.seek(input.payload?.line);
if (result.status === "verified") this.task.setProgram("paused", result.line);
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");
}
case "MDI_EXECUTE": {
if (["RUNNING", "PAUSED"].includes(this.task.state.state)) return finish(this.error(input, "task_state", "mdi_while_program_active"), "failed");
if (this.task.state.state === "STOPPED") this.task.recover();
const result = this.program.executeBlock(input.payload?.block ?? input.payload?.command);
if (result.status === "verified") { this.task.setMode("MDI"); this.task.setProgram("loaded", this.program.currentLine); }
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");
}
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");
}
case "JOG_START": case "JOG_STOP": case "SET_OVERRIDE": case "FOLLOWING_ERROR": {
let result;
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");
}
case "SPINDLE_COMMAND": case "SPINDLE_FAULT": case "SPINDLE_INHIBIT":
case "SPINDLE_ORIENT": case "SPINDLE_ORIENTED": case "IO_STEP":
case "TOOL_PREPARE": case "TOOL_PREPARED": case "TOOL_CHANGE":
case "TOOL_CHANGED": case "TOOL_ABORT": case "TOOL_FAULT": case "TOOL_CLEAR_FAULT":
case "PROBE_CONFIG": case "LIMIT_SET": case "ESTOP": case "IO_RECOVER":
case "PROBE_CONFIG": case "IO_RECOVER":
return deviceResponse(input.kind, input.payload);
case "LIMIT_SET": {
const result = this.motion.setLimit(input.payload?.axis, input.payload?.active === true);
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");
}
case "PLC_CONFIG": case "PLC_SET": case "PLC_TRIGGER": {
const result = input.kind === "PLC_CONFIG" ? this.plc.configure(input.payload) : input.kind === "PLC_SET" ? this.plc.setRegister(input.payload?.register, input.payload?.value) : this.plc.trigger();
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, task: this.task.export(), plc: this.plc.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
}
case "CONFIGURATION_BATCH": {
const result = this.configurationBatch.load(input.payload?.entries, { context_id: this.contextId });
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, configuration_batch: this.configurationBatch.export(), task: this.task.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
}
case "HAL_COMMAND": case "HAL_SET": {
const payload = input.payload ?? {};
let result;
if (input.kind === "HAL_SET") {
result = payload.target === "param" ? this.hal.setParam(payload.name, payload.value, payload.type) : this.hal.setPin(payload.name, payload.value, payload.type);
} else if (payload.action === "configure") {
result = this.hal.configure(payload);
} else if (payload.action === "thread") {
result = this.hal.registerThread(payload.name, payload.period_ticks);
} else if (payload.action === "component") {
result = this.hal.registerComponent(payload.instance ?? payload.name, payload.type, payload.thread ?? "servo-thread");
} else if (payload.action === "connect") {
result = this.hal.connect(payload.source, payload.target);
} else if (payload.action === "failure") {
result = this.hal.setFailure(payload.instance, payload.failed === true);
} else if (payload.action === "step") {
result = this.hal.step(payload.tick ?? this.tick);
} else {
result = this.hal.fail("hal_command_unsupported");
}
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, task: this.task.export(), hal_runtime: this.hal.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
}
case "ENCODER_SET": case "COOLANT_COMMAND": case "COOLANT_FAULT": case "DOOR_SET": case "INTERLOCK_SET": case "INTERLOCK_RESET": case "DEVICE_ESTOP": case "DEVICE_ACK_ISSUE": case "DEVICE_ACK": case "DEVICE_ACK_ABORT":
return deviceIoResponse(input.kind, input.payload);
case "IO_COMMAND": case "IO_ACK": case "IO_TIMEOUT": {
let result;
if (input.kind === "IO_COMMAND") {
result = this.ioAck.issue(input.operation_id, input.payload?.kind ?? "IO", this.tick, input.payload?.timeout_ticks ?? 10);
if (result.status === "verified") this.deviceIo.issueAck(input.operation_id, input.payload?.timeout_ticks ?? 10);
} else if (input.kind === "IO_ACK") {
result = this.ioAck.ack(input.payload?.operation_id, this.tick);
this.deviceIo.ack(input.payload?.operation_id);
} else {
const targetTick = this.tick + (input.payload?.ticks ?? 0);
result = this.ioAck.step(targetTick);
this.deviceIo.ackStep(targetTick);
}
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, io_ack: this.ioAck.export(), device_io: this.deviceIo.export(), task: this.task.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
}
case "REMAP_REGISTER": case "REMAP_CALL": case "VCP_REGISTER": case "VCP_UPDATE": case "FILTER_EXEC": {
const result = input.kind === "REMAP_REGISTER"
? this.callbacks.registerRemap(input.payload?.name, input.payload)
: input.kind === "REMAP_CALL"
? this.callbacks.invoke("remap", input.payload?.name, input.payload?.phase ?? "prolog", input.payload?.args ?? [])
: input.kind === "VCP_REGISTER"
? this.callbacks.registerVcp(input.payload?.name, input.payload?.pins ?? [])
: input.kind === "VCP_UPDATE"
? this.callbacks.updateVcp(input.payload?.name, input.payload?.pin, input.payload?.value)
: this.callbacks.filter(input.payload?.name, input.payload?.source ?? "", input.payload?.args ?? []);
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, callbacks: this.callbacks.export(), task: this.task.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
}
case "ESTOP": {
const motionResult = this.motion.estop(input.payload?.active === true);
const deviceResult = deviceResponse(input.kind, input.payload);
if (motionResult.status === "error") return finish(this.envelope("ERROR", input, { ...motionResult, task: this.task.export() }), "failed");
return deviceResult;
}
case "PYTHON_IMPORT": {
const result = this.python.importModule(input.payload?.module);
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, result), result.status === "verified" ? "verified" : "failed");
@@ -335,7 +891,7 @@ export class SimSession {
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, result), result.status === "verified" ? "verified" : "failed");
}
case "SNAPSHOT_REQUEST":
return finish(this.envelope("STATE_SNAPSHOT", input, { snapshot_schema: 1, tick: this.tick, state_hash: fnv1a64({ tick: this.tick }), snapshot: this.timeline.snapshots.latest() }));
return finish(this.envelope("STATE_SNAPSHOT", input, { snapshot_schema: 1, tick: this.tick, task: this.task.export(), state_hash: this.task.hash(), snapshot: this.timeline.snapshots.latest() }));
case "CHECKPOINT": {
try {
const operation = text(input.operation_id) ? this.operations.get(input.operation_id) : null;
@@ -354,9 +910,19 @@ export class SimSession {
const target = input.payload?.operation_id;
const operation = text(target) ? this.operations.get(target) : null;
if (operation && operation.status !== "verified") operation.status = "aborted";
return finish(this.envelope("COMMAND_RESULT", input, { status: operation ? "aborted" : "verified", operation_id: target ?? null }));
let ledgerResult = null;
if (text(target)) {
try { ledgerResult = this.ledger.abort(target); } catch (error) { ledgerResult = { status: "missing", error: String(error?.message ?? error) }; }
}
if (this.task.state.state !== "DESTROYED") {
try { this.task.transition("STOPPED"); } catch { /* target abort can be bookkeeping-only */ }
}
return finish(this.envelope("COMMAND_RESULT", input, { status: ledgerResult?.status === "too_late" ? "too_late" : operation ? "aborted" : "verified", operation_id: target ?? null, ledger: ledgerResult }));
}
case "DESTROY":
this.program.destroy();
this.task.destroy();
this.scheduler.destroy();
call(this.core, "sim_destroy");
this.created = false;
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", destroyed: true, replay: this.timeline.export() }));

192
web/worker/sim-task.mjs Normal file
View File

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

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 { compareEndpoints, createEndpoint, endpointHash } from "./endpoint-replay.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
async function runCorexy(id, fast) {
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, operation_id, payload = {}) => {
seq += 1;
const response = session.accept({ ...base, kind, seq, operation_id, payload });
assert.notEqual(response.kind, "ERROR", `${id}:${kind}:${response.payload?.code ?? "error"}`);
return response;
};
send("HELLO", "hello");
send("SESSION_ATTACH", "attach");
const loaded = send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", kinematics: "corexy" });
assert.equal(loaded.payload.task.state, "READY");
send("PROGRAM_LOAD", "program", { source: "G0 X4 Y2 Z3\nM2\n", path: "corexy.ngc" });
send("PROGRAM_RUN", "run");
if (fast) send("SIM_STEP", "fast", { cycles: 8 });
else for (let index = 0; index < 8; index += 1) send("SIM_STEP", `step-${index}`, { cycles: 1 });
const endpoint = createEndpoint({ session });
assert.equal(endpoint.authoritative.kinematics.type, "corexy");
assert.deepEqual(endpoint.authoritative.kinematics.joints, [6, 2, 3]);
const checkpoint = session.createCheckpoint();
send("SIM_STEP", "post-checkpoint", { cycles: 1 });
const continuous = createEndpoint({ session });
const recovery = session.restoreCheckpoint(checkpoint, session.timeline.export(), (operation) => operation);
assert.equal(recovery.status, "recovered");
const restored = createEndpoint({ session });
const recoveryParity = compareEndpoints(continuous, restored);
assert.equal(recoveryParity.equal, true, JSON.stringify(recoveryParity));
return { endpoint, recoveryParity, checkpoint, kinematics: endpoint.authoritative.kinematics };
}
const oneX = await runCorexy("authoritative-corexy-1x", false);
const fast = await runCorexy("authoritative-corexy-fast", true);
const modeParity = compareEndpoints(oneX.endpoint, fast.endpoint);
assert.equal(modeParity.equal, true);
const core = await createModule({ wasmBinary });
const unsupported = new SimSession(core, { sessionId: "authoritative-unsupported" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: "authoritative-unsupported" };
unsupported.accept({ ...base, kind: "HELLO", seq: 1, operation_id: "hello" });
unsupported.accept({ ...base, kind: "SESSION_ATTACH", seq: 2, operation_id: "attach" });
const unsupportedResult = unsupported.accept({ ...base, kind: "LOAD_WORKSPACE", seq: 3, operation_id: "unsupported", payload: { root_path: "/workspace", kinematics: "puma" } });
assert.equal(unsupportedResult.kind, "ERROR");
assert.equal(unsupportedResult.payload.code, "authoritative_kinematics_unsupported");
console.log(JSON.stringify({
schema: "authoritative-kinematics-s6-sample.v1",
status: "verified",
context_id: CONTEXT_ID,
supported: { type: "corexy", pose: [4, 2, 3], joints: oneX.kinematics.joints, endpoint_hash: endpointHash(oneX.endpoint) },
parity: { mode: modeParity, recovery: oneX.recoveryParity },
checkpoint_hash: oneX.checkpoint.checkpoint_hash,
unsupported: { type: "puma", code: unsupportedResult.payload.code },
boundaries: ["other non-trivkins families remain queued for later slices", "official LinuxCNC kinematics oracle remains SOURCE_BLOCKED"],
}));

View File

@@ -0,0 +1,86 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { compareEndpoints, createEndpoint, endpointHash } from "./endpoint-replay.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
const TRIPOD_SAMPLE = [5.385164807, 9.433981132, 6.447715952];
async function runTripod(id, fast) {
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, operation_id, payload = {}) => {
seq += 1;
const response = session.accept({ ...base, kind, seq, operation_id, payload });
assert.notEqual(response.kind, "ERROR", `${id}:${kind}:${response.payload?.code ?? "error"}`);
return response;
};
send("HELLO", "hello");
send("SESSION_ATTACH", "attach");
const loaded = send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", kinematics: "tripodkins" });
assert.equal(loaded.payload.task.state, "READY");
send("PROGRAM_LOAD", "program", { source: "G0 X2 Y3 Z4\nM2\n", path: "tripod.ngc" });
send("PROGRAM_RUN", "run");
if (fast) send("SIM_STEP", "fast", { cycles: 8 });
else for (let index = 0; index < 8; index += 1) send("SIM_STEP", `step-${index}`, { cycles: 1 });
const endpoint = createEndpoint({ session });
assert.equal(endpoint.authoritative.kinematics.type, "tripod");
assert.equal(endpoint.authoritative.kinematics.joint_count, 3);
endpoint.authoritative.kinematics.joints.forEach((value, index) => assert.ok(Math.abs(value - TRIPOD_SAMPLE[index]) < 1e-9));
assert.equal(endpoint.authoritative.kinematics.pose.x, 2);
assert.equal(endpoint.authoritative.kinematics.pose.y, 3);
assert.equal(endpoint.authoritative.kinematics.pose.z, 4);
const checkpoint = session.createCheckpoint();
send("SIM_STEP", "post-checkpoint", { cycles: 1 });
const continuous = createEndpoint({ session });
const recovery = session.restoreCheckpoint(checkpoint, session.timeline.export(), (operation) => operation);
assert.equal(recovery.status, "recovered");
const restored = createEndpoint({ session });
const recoveryParity = compareEndpoints(continuous, restored);
assert.equal(recoveryParity.equal, true, JSON.stringify(recoveryParity));
return { endpoint, recoveryParity, checkpoint, kinematics: endpoint.authoritative.kinematics };
}
async function unsupported(type, 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 };
session.accept({ ...base, kind: "HELLO", seq: 1, operation_id: "hello" });
session.accept({ ...base, kind: "SESSION_ATTACH", seq: 2, operation_id: "attach" });
const result = session.accept({ ...base, kind: "LOAD_WORKSPACE", seq: 3, operation_id: "unsupported", payload: { root_path: "/workspace", kinematics: type } });
assert.equal(result.kind, "ERROR");
assert.equal(result.payload.code, "authoritative_kinematics_unsupported");
return { type, code: result.payload.code };
}
const oneX = await runTripod("authoritative-tripod-1x", false);
const fast = await runTripod("authoritative-tripod-fast", true);
const modeParity = compareEndpoints(oneX.endpoint, fast.endpoint);
assert.equal(modeParity.equal, true, JSON.stringify(modeParity));
const unsupportedTrt = await unsupported("xyzac-trt", "authoritative-trt-queued");
const unsupportedRobot = await unsupported("puma", "authoritative-robot-queued");
console.log(JSON.stringify({
schema: "authoritative-kinematics-s6-04-sample.v1",
status: "verified",
context_id: CONTEXT_ID,
supported: {
type: "tripod",
source_family: "tripodkins",
pose: [2, 3, 4],
joints: oneX.kinematics.joints,
geometry: { base: [[0, 0], [10, 0], [5, 7.071]], joint_semantics: ["AD", "BD", "CD"] },
endpoint_hash: endpointHash(oneX.endpoint),
},
parity: { mode: modeParity, recovery: oneX.recoveryParity },
checkpoint_hash: oneX.checkpoint.checkpoint_hash,
unsupported: [unsupportedTrt, unsupportedRobot],
boundaries: [
"TRT, serial robot, Delta, Hexapod, Pentapod and switch/user families remain queued for later slices",
"official LinuxCNC kinematics oracle remains SOURCE_BLOCKED",
"tripod native/WASM golden is implementation evidence, not official oracle evidence",
],
}));

View File

@@ -0,0 +1,78 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { compareEndpoints, createEndpoint, endpointHash } from "./endpoint-replay.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
const DELTA_SAMPLE = [300, 300, 300];
async function runDelta(id, fast) {
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, operation_id, payload = {}) => {
seq += 1;
const response = session.accept({ ...base, kind, seq, operation_id, payload });
assert.notEqual(response.kind, "ERROR", `${id}:${kind}:${response.payload?.code ?? "error"}`);
return response;
};
send("HELLO", "hello"); send("SESSION_ATTACH", "attach");
send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", kinematics: "lineardeltakins" });
send("PROGRAM_LOAD", "program", { source: "G0 X0 Y0 Z64.636584194\nM2\n", path: "lineardelta.ngc" });
send("PROGRAM_RUN", "run");
if (fast) send("SIM_STEP", "fast", { cycles: 8 });
else for (let index = 0; index < 8; index += 1) send("SIM_STEP", `step-${index}`, { cycles: 1 });
const endpoint = createEndpoint({ session });
assert.equal(endpoint.authoritative.kinematics.type, "lineardelta");
endpoint.authoritative.kinematics.joints.forEach((value, index) => assert.ok(Math.abs(value - DELTA_SAMPLE[index]) < 1e-9));
const checkpoint = session.createCheckpoint();
send("SIM_STEP", "post-checkpoint", { cycles: 1 });
const continuous = createEndpoint({ session });
assert.equal(session.restoreCheckpoint(checkpoint, session.timeline.export(), (operation) => operation).status, "recovered");
const restored = createEndpoint({ session });
const recovery = compareEndpoints(continuous, restored);
assert.equal(recovery.equal, true, JSON.stringify(recovery));
return { endpoint, recovery, checkpoint };
}
const oneX = await runDelta("authoritative-lineardelta-1x", false);
const fast = await runDelta("authoritative-lineardelta-fast", true);
const mode = compareEndpoints(oneX.endpoint, fast.endpoint);
assert.equal(mode.equal, true, JSON.stringify(mode));
const unsupportedCore = await createModule({ wasmBinary });
const unsupported = new SimSession(unsupportedCore, { sessionId: "authoritative-lineardelta-unsupported" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: "authoritative-lineardelta-unsupported" };
unsupported.accept({ ...base, kind: "HELLO", seq: 1, operation_id: "hello" });
unsupported.accept({ ...base, kind: "SESSION_ATTACH", seq: 2, operation_id: "attach" });
const unsupportedResult = unsupported.accept({ ...base, kind: "LOAD_WORKSPACE", seq: 3, operation_id: "unsupported", payload: { root_path: "/workspace", kinematics: "xyzac-trt" } });
assert.equal(unsupportedResult.kind, "ERROR");
assert.equal(unsupportedResult.payload.code, "authoritative_kinematics_unsupported");
const rangeCore = await createModule({ wasmBinary });
const range = new SimSession(rangeCore, { sessionId: "authoritative-lineardelta-range" });
const rangeBase = { schema_version: 1, context_id: CONTEXT_ID, session_id: "authoritative-lineardelta-range" };
range.accept({ ...rangeBase, kind: "HELLO", seq: 1, operation_id: "hello" });
range.accept({ ...rangeBase, kind: "SESSION_ATTACH", seq: 2, operation_id: "attach" });
range.accept({ ...rangeBase, kind: "LOAD_WORKSPACE", seq: 3, operation_id: "load", payload: { root_path: "/workspace", kinematics: "lineardelta" } });
range.accept({ ...rangeBase, kind: "PROGRAM_LOAD", seq: 4, operation_id: "program", payload: { source: "G0 X1000 Y0 Z0\nM2\n", path: "range.ngc" } });
range.accept({ ...rangeBase, kind: "PROGRAM_RUN", seq: 5, operation_id: "run" });
const rangeStep = range.accept({ ...rangeBase, kind: "SIM_STEP", seq: 6, operation_id: "step", payload: { cycles: 8 } });
assert.equal(rangeStep.kind, "ERROR");
assert.equal(rangeStep.payload.code, "scheduler");
assert.equal(rangeStep.payload.message_key, "authoritative_kinematics_range");
console.log(JSON.stringify({
schema: "authoritative-kinematics-s7-03-sample.v1",
status: "verified",
context_id: CONTEXT_ID,
supported: { type: "lineardelta", source_family: "lineardeltakins", pose: [0, 0, 64.636584194], joints: oneX.endpoint.authoritative.kinematics.joints, endpoint_hash: endpointHash(oneX.endpoint) },
parity: { mode, recovery: oneX.recovery },
checkpoint_hash: oneX.checkpoint.checkpoint_hash,
unsupported: { type: "xyzac-trt", code: unsupportedResult.payload.code },
range: { code: rangeStep.payload.code, message_key: rangeStep.payload.message_key },
geometry: { radius: 130.25, rod_length: 269, tower_layout: "120-degree equilateral" },
boundaries: ["official kinematics oracle remains SOURCE_BLOCKED", "TRT/robot/Hexapod/Pentapod/switch/user remain queued", "missing ldelta HAL resources are retained as configuration gaps"],
}));

View File

@@ -0,0 +1,27 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { compareEndpoints, createEndpoint, endpointHash } from "./endpoint-replay.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
async function run(id, fast) {
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, operation_id, payload = {}) => { seq += 1; const response = session.accept({ ...base, kind, seq, operation_id, payload }); assert.notEqual(response.kind, "ERROR", `${id}:${kind}:${response.payload?.message_key ?? "error"}`); return response; };
send("HELLO", "hello"); send("SESSION_ATTACH", "attach"); send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", kinematics: "rotarydeltakins" });
send("PROGRAM_LOAD", "program", { source: "G0 X0 Y0 Z-3.793360169\nM2\n", path: "rotarydelta.ngc" }); send("PROGRAM_RUN", "run");
if (fast) send("SIM_STEP", "fast", { cycles: 8 }); else for (let index = 0; index < 8; index += 1) send("SIM_STEP", `step-${index}`, { cycles: 1 });
const endpoint = createEndpoint({ session }); assert.equal(endpoint.authoritative.kinematics.type, "rotarydelta"); endpoint.authoritative.kinematics.joints.forEach((value) => assert.ok(Math.abs(value - 10) < 1e-9));
const checkpoint = session.createCheckpoint(); send("SIM_STEP", "post-checkpoint", { cycles: 1 }); const continuous = createEndpoint({ session }); assert.equal(session.restoreCheckpoint(checkpoint, session.timeline.export(), (operation) => operation).status, "recovered"); const recovery = compareEndpoints(continuous, createEndpoint({ session })); assert.equal(recovery.equal, true, JSON.stringify(recovery));
return { endpoint, checkpoint, recovery };
}
const oneX = await run("authoritative-rotarydelta-1x", false); const fast = await run("authoritative-rotarydelta-fast", true); const mode = compareEndpoints(oneX.endpoint, fast.endpoint); assert.equal(mode.equal, true, JSON.stringify(mode));
const core = await createModule({ wasmBinary }); const singular = new SimSession(core, { sessionId: "authoritative-rotarydelta-singular" }); const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: "authoritative-rotarydelta-singular" }; singular.accept({ ...base, kind: "HELLO", seq: 1, operation_id: "hello" }); singular.accept({ ...base, kind: "SESSION_ATTACH", seq: 2, operation_id: "attach" }); singular.accept({ ...base, kind: "LOAD_WORKSPACE", seq: 3, operation_id: "load", payload: { root_path: "/workspace", kinematics: "rotarydelta" } }); singular.accept({ ...base, kind: "PROGRAM_LOAD", seq: 4, operation_id: "program", payload: { source: "G0 X1 Y0 Z0\nM2\n", path: "singular.ngc" } }); singular.accept({ ...base, kind: "PROGRAM_RUN", seq: 5, operation_id: "run" }); const singularStep = singular.accept({ ...base, kind: "SIM_STEP", seq: 6, operation_id: "step", payload: { cycles: 8 } }); assert.equal(singularStep.kind, "ERROR"); assert.equal(singularStep.payload.message_key, "authoritative_kinematics_singular");
const unsupported = new SimSession(await createModule({ wasmBinary }), { sessionId: "authoritative-rotarydelta-trt" }); const unsupportedBase = { schema_version: 1, context_id: CONTEXT_ID, session_id: "authoritative-rotarydelta-trt" }; unsupported.accept({ ...unsupportedBase, kind: "HELLO", seq: 1, operation_id: "hello" }); unsupported.accept({ ...unsupportedBase, kind: "SESSION_ATTACH", seq: 2, operation_id: "attach" }); const unsupportedResult = unsupported.accept({ ...unsupportedBase, kind: "LOAD_WORKSPACE", seq: 3, operation_id: "trt", payload: { root_path: "/workspace", kinematics: "xyzac-trt" } }); assert.equal(unsupportedResult.kind, "ERROR"); assert.equal(unsupportedResult.payload.code, "authoritative_kinematics_unsupported");
console.log(JSON.stringify({ schema: "authoritative-kinematics-s7-04-sample.v1", status: "verified", context_id: CONTEXT_ID, supported: { type: "rotarydelta", source_family: "rotarydeltakins", pose: [0, 0, -3.793360169], joints: oneX.endpoint.authoritative.kinematics.joints, endpoint_hash: endpointHash(oneX.endpoint) }, parity: { mode, recovery: oneX.recovery }, checkpoint_hash: oneX.checkpoint.checkpoint_hash, singular: { message_key: singularStep.payload.message_key }, unsupported: { type: "xyzac-trt", code: unsupportedResult.payload.code }, geometry: { platform_radius: 10, thigh_length: 10, shin_length: 14, foot_radius: 6 }, boundaries: ["official kinematics oracle remains SOURCE_BLOCKED", "TRT/robot/Hexapod/Pentapod/switch/user remain queued", "missing rdelta HAL resources remain configuration gaps"] }));

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 { compareEndpoints, createEndpoint, endpointHash } from "./endpoint-replay.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
async function runPlan(id, fast) {
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, operation_id, payload = {}) => {
seq += 1;
const response = session.accept({ ...base, kind, seq, operation_id, payload });
assert.notEqual(response.kind, "ERROR", `${id}:${kind}:${response.payload?.code ?? "error"}`);
return response;
};
send("HELLO", "hello");
send("SESSION_ATTACH", "attach");
send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace" });
send("HAL_COMMAND", "thread", { action: "thread", name: "servo-thread", period_ticks: 1 });
send("HAL_COMMAND", "constant", { action: "component", instance: "constant", type: "constant", thread: "servo-thread" });
send("HAL_SET", "constant-value", { target: "param", name: "constant.value", value: 2.5 });
send("ENCODER_SET", "encoder", { channel: 0, speed_rps: 1, ppr: 10, scale: 4, index_enable: true });
send("PROGRAM_LOAD", "program", { source: "G0 X1\nG1 X2\nM2\n", path: "authoritative.ngc" });
send("PROGRAM_RUN", "run");
const steps = [];
if (fast) {
const response = send("SIM_STEP", "fast-step", { cycles: 8 });
steps.push(response);
} else {
for (let index = 0; index < 8; index += 1) steps.push(send("SIM_STEP", `step-${index}`, { cycles: 1 }));
}
const last = steps.at(-1);
assert.equal(last.payload.authoritative.kinematics.type, "trivkins");
assert.equal(last.payload.authoritative.tick, last.payload.task.simulation_tick);
assert.equal(last.payload.authoritative.kinematics.joint_count, 3);
assert.equal(last.payload.scheduler.phase_trace.join(","), "reconcile,task,tp,kinematics,motion,hal_io,callbacks,publish");
assert.equal(last.payload.hal_runtime.components[0].calls, 8);
assert.equal(last.payload.device_io.encoders[0].tick, 8);
assert.equal(last.payload.authoritative.kinematics.pose.x, 2);
const endpoint = createEndpoint({ session });
const checkpoint = session.createCheckpoint();
send("SIM_STEP", "post-checkpoint", { cycles: 1 });
const continuous = createEndpoint({ session });
const recovered = session.restoreCheckpoint(checkpoint, session.timeline.export(), (operation) => operation);
assert.equal(recovered.status, "recovered");
const restored = createEndpoint({ session });
const recoveryParity = compareEndpoints(continuous, restored);
assert.equal(recoveryParity.equal, true, JSON.stringify(recoveryParity));
return { endpoint, checkpoint, recoveryParity, authoritative: last.payload.authoritative, task: last.payload.task, scheduler: last.payload.scheduler };
}
const oneX = await runPlan("authoritative-1x", false);
const fast = await runPlan("authoritative-fast", true);
const modeParity = compareEndpoints(oneX.endpoint, fast.endpoint);
assert.equal(modeParity.equal, true);
assert.equal(oneX.recoveryParity.equal, true);
assert.equal(fast.recoveryParity.equal, true);
console.log(JSON.stringify({
schema: "authoritative-sim-task-s6-sample.v1",
status: "verified",
context_id: CONTEXT_ID,
supported: { kinematics: "trivkins", axes: ["x", "y", "z"], fixed_tick_phases: oneX.scheduler.phase_trace },
parity: { mode: modeParity, one_x_recovery: oneX.recoveryParity, fast_recovery: fast.recoveryParity },
endpoint_hash: endpointHash(oneX.endpoint),
authoritative_hash: oneX.authoritative.state_hash,
task_hash: oneX.task.state_hash,
checkpoint_hash: oneX.checkpoint.checkpoint_hash,
boundaries: ["non-trivkins kinematics remain S6-03+", "full LinuxCNC canonical/native oracle remains explicitly blocked"],
}));

View File

@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { WorkspaceRuntime } from "../workspace/runtime.mjs";
import { startConfiguration } from "../workspace/configuration.mjs";
import { ConfigurationBatchRuntime } from "../workspace/configuration-batch.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const matrix = JSON.parse(await readFile(new URL("../../coverage/full-configuration-startup-matrix.report.json", import.meta.url)));
const batch = new ConfigurationBatchRuntime({ context_id: CONTEXT_ID });
assert.equal(batch.load(matrix.matrix, { context_id: CONTEXT_ID }).status, "verified");
assert.deepEqual(batch.state.counts, {
total: 264,
ready: 56,
blocked: 208,
startup_preflight: { blocked_resource: 194, blocked_unindexed_resource_graph: 11, blocked_unregistered_kinematics: 3, static_ready: 56 },
resource_status: { blocked_missing: 147, blocked_missing_and_unresolved: 49, ready: 9, unindexed: 59 },
});
assert.equal(batch.state.diagnostics.length, 208);
assert.equal(batch.state.diagnostics.find((row) => row.config_id === matrix.matrix[0].config_id).code, "resource_missing");
assert.equal(batch.load([matrix.matrix[0], matrix.matrix[0]], { context_id: CONTEXT_ID }).code, "configuration_batch_duplicate");
assert.equal(batch.load([{ ...matrix.matrix[0], status: "ready" }], { context_id: CONTEXT_ID }).code, "configuration_batch_status");
assert.equal(batch.load(matrix.matrix, { context_id: CONTEXT_ID }).status, "verified");
const saved = batch.export();
const restored = new ConfigurationBatchRuntime({ context_id: CONTEXT_ID });
assert.deepEqual(restored.restore(saved), saved);
const files = { "config/main.ini": "[HAL]\nHALFILE = machine.hal\n[RS274NGC]\nPROGRAM = main.ngc\n", "config/machine.hal": "setp sim.x 1\n", "config/main.ngc": "G0 X1\n" };
const native = startConfiguration({ workspace: new WorkspaceRuntime({ files }), entrypoint: "config/main.ini", core: { _sim_create() { return 0; }, _sim_load_workspace() { return 0; } }, halManifest: [{ component: "sim" }] });
const wasm = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const wasmStartup = startConfiguration({ workspace: new WorkspaceRuntime({ files }), entrypoint: "config/main.ini", core: wasm, halManifest: [{ component: "sim" }] });
assert.equal(native.status, "ready");
assert.equal(wasmStartup.state_hash, native.state_hash);
let sequence = 0;
const core = { _sim_protocol_reset() {}, _sim_protocol_accept() { return 0; }, _sim_create() { return 0; }, _sim_load_workspace() { return 0; }, _sim_step() { return 0; }, _sim_protocol_set_tick() { return 0; } };
const session = new SimSession(core, { sessionId: "configuration-batch" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: "configuration-batch" };
const send = (kind, operation_id, payload = {}) => { sequence += 1; return session.accept({ ...base, kind, seq: sequence, operation_id, payload }); };
send("HELLO"); send("SESSION_ATTACH", "attach"); send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace" });
const readyRow = matrix.matrix.find((row) => row.status === "ready");
const blockedRows = matrix.matrix.filter((row) => row.status === "blocked");
const sample = [blockedRows[0], readyRow, blockedRows.at(-1)];
const response = send("CONFIGURATION_BATCH", "batch", { entries: sample });
assert.equal(response.kind, "COMMAND_RESULT");
assert.equal(response.payload.configuration_batch.counts.total, 3);
assert.equal(response.payload.configuration_batch.counts.ready, 1);
const checkpoint = send("CHECKPOINT", "checkpoint").payload.checkpoint;
assert.equal(checkpoint.state.configuration_batch.schema, "configuration-startup-batch.v1");
console.log(JSON.stringify({ schema: "configuration-batch-s2-sample.v1", status: "verified", total: batch.state.counts.total, ready: 56, blocked: 208, batch_hash: saved.state_hash, native_state_hash: native.state_hash, wasm_state_hash: wasmStartup.state_hash, worker_hash: response.payload.configuration_batch.state_hash, checkpoint_hash: checkpoint.checkpoint_hash }));

View File

@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { WorkspaceRuntime } from "../workspace/runtime.mjs";
import { parseIni } from "../workspace/ini-parser.mjs";
import { resolveResources } from "../workspace/resource-resolver.mjs";
import { preflightHalConfiguration } from "../workspace/hal-configuration.mjs";
import { startConfiguration } from "../workspace/configuration-startup.mjs";
const paths = [
"linuxcnc/configs/sim/axis/corexy/corexy_by_kins.ini",
"linuxcnc/configs/sim/axis/corexy/corexy_base.inc",
"linuxcnc/configs/sim/axis/corexy/3joint_xyz.hal",
"linuxcnc/linuxcnc.gif",
];
const files = Object.fromEntries(await Promise.all(paths.map(async (path) => [path, await readFile(new URL(`../../${path}`, import.meta.url), "utf8")]))) ;
const workspace = new WorkspaceRuntime({ files });
const ini = parseIni({ workspace, entrypoint: paths[0] });
assert.deepEqual(ini.includes.map((item) => item.path), ["linuxcnc/configs/sim/axis/corexy/corexy_base.inc"]);
assert.equal(ini.sections.EMCMOT.EMCMOT, "motmod");
assert.equal(ini.sections.KINS.KINEMATICS, "corexykins");
const resources = resolveResources({ workspace, entrypoint: paths[0], ini });
assert.equal(resources.status, "blocked");
assert.deepEqual(resources.resources.filter((row) => row.status === "blocked").map((row) => row.path).sort(), [
"linuxcnc/configs/sim/axis/corexy/linuxcnc.var",
"linuxcnc/configs/sim/axis/corexy/tool.tbl",
]);
const rawLines = files[paths[2]].split(/\r?\n/); const halLines = [];
for (const rawLine of rawLines) {
if (halLines.length > 0 && /\\\s*$/.test(halLines.at(-1))) halLines[halLines.length - 1] = `${halLines.at(-1).replace(/\\\s*$/, "")} ${rawLine.trim()}`;
else halLines.push(rawLine);
}
const manifest = ["corexykins", "motmod", "stepgen", "sim_home_switch"].map((component) => ({ component }));
const hal = preflightHalConfiguration({ lines: halLines, variables: ini.sections, manifest });
assert.equal(hal.status, "ready");
assert.equal(hal.blocked_count, 0);
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const startup = startConfiguration({ workspace, entrypoint: paths[0], core, halManifest: manifest });
assert.equal(startup.status, "blocked");
assert.equal(startup.state, "BLOCKED");
const startupResourceDiagnostic = startup.diagnostics.find((row) => row.code === "startup_resources_blocked");
assert.ok(startupResourceDiagnostic);
assert.deepEqual(startupResourceDiagnostic.resources.map((row) => row.path).sort(), [
"linuxcnc/configs/sim/axis/corexy/linuxcnc.var",
"linuxcnc/configs/sim/axis/corexy/tool.tbl",
]);
console.log(JSON.stringify({
schema: "configuration-corexy-s7-05-sample.v1",
status: "verified",
entrypoint: paths[0],
include_graph: ini.graph,
hal_preflight: { status: hal.status, record_count: hal.record_count, blocked_count: hal.blocked_count },
runtime_startup: { status: startup.status, state: startup.state, blocked_resources: startupResourceDiagnostic.resources.map((row) => row.path).sort(), state_hash: startup.state_hash },
boundaries: ["static HAL preflight is verified", "runtime remains BLOCKED on missing linuxcnc.var/tool.tbl", "no resource substitution or READY promotion is fabricated"],
}));

View File

@@ -0,0 +1,88 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { WorkspaceRuntime } from "../workspace/runtime.mjs";
import { ConfigurationStartupStateMachine, startConfiguration } from "../workspace/configuration.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const files = {
"config/main.ini": "[HAL]\nHALFILE = machine.hal\n[RS274NGC]\nPROGRAM = main.ngc\n",
"config/machine.hal": "setp sim.x 1\n",
"config/main.ngc": "G0 X1\n",
};
const manifest = [{ component: "sim" }];
const nativeCalls = [];
let nativeSeq = 0;
const nativeCore = {
_sim_create() { nativeCalls.push("create"); return 0; },
_sim_load_workspace(path) { nativeCalls.push(`load:${path}`); return 0; },
};
const native = startConfiguration({ workspace: new WorkspaceRuntime({ files }), entrypoint: "config/main.ini", core: nativeCore, halManifest: manifest });
const nativeAgain = startConfiguration({ workspace: new WorkspaceRuntime({ files }), entrypoint: "config/main.ini", core: nativeCore, halManifest: manifest });
assert.equal(native.state, "TASK_READY");
assert.equal(native.status, "ready");
assert.equal(native.state_hash, nativeAgain.state_hash);
assert.deepEqual(native.transitions.map((row) => row.to), ["NEW", "MOUNTED", "PARSED", "RESOLVED", "CORE_CREATED", "HAL_READY", "TASK_READY"]);
assert.deepEqual(nativeCalls, ["create", "load:/workspace", "create", "load:/workspace"]);
const wasm = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const wasmReport = startConfiguration({ workspace: new WorkspaceRuntime({ files }), entrypoint: "config/main.ini", core: wasm, halManifest: manifest });
assert.equal(wasmReport.state, "TASK_READY");
assert.equal(wasmReport.state_hash, native.state_hash);
const missing = startConfiguration({ workspace: new WorkspaceRuntime({ files: { "config/main.ini": "[HAL]\nHALFILE = missing.hal\n" } }), entrypoint: "config/main.ini", core: nativeCore });
assert.equal(missing.status, "blocked");
assert.equal(missing.state, "BLOCKED");
assert.equal(missing.diagnostics[0].source, "config/main.ini");
assert.equal(missing.diagnostics[0].line, 2);
assert.equal(missing.resources.resources[0].code, "resource_missing");
const duplicate = startConfiguration({ workspace: new WorkspaceRuntime({ files: { ...files, "config/duplicate.ini": "[HAL]\nHALFILE = machine.hal\nHALFILE = machine.hal\n" } }), entrypoint: "config/duplicate.ini", core: nativeCore });
assert.equal(duplicate.status, "blocked");
assert.ok(duplicate.resources.resources.some((row) => row.code === "resource_duplicate"));
const hostPath = startConfiguration({ workspace: new WorkspaceRuntime({ files: { "config/main.ini": "[HAL]\nHALFILE = /tmp/machine.hal\n" } }), entrypoint: "config/main.ini", core: nativeCore });
assert.equal(hostPath.status, "blocked");
assert.equal(hostPath.resources.resources[0].code, "resource_host_path");
const ambiguous = startConfiguration({ workspace: new WorkspaceRuntime({ files: {
"config/main.ini": "[HAL]\nHALFILE = LIB:machine.hal\n",
"linuxcnc/lib/machine.hal": "setp sim.x 1\n",
"linuxcnc/lib/hallib/machine.hal": "setp sim.x 2\n",
} }), entrypoint: "config/main.ini", core: nativeCore });
assert.equal(ambiguous.status, "blocked");
assert.equal(ambiguous.resources.resources[0].code, "resource_ambiguous");
const unsupportedHal = startConfiguration({ workspace: new WorkspaceRuntime({ files: { "config/main.ini": "[HAL]\nHALFILE = machine.hal\n", "config/machine.hal": "loadrt unknown names=u\n" } }), entrypoint: "config/main.ini", core: nativeCore, halManifest: [] });
assert.equal(unsupportedHal.status, "blocked");
assert.equal(unsupportedHal.diagnostics[0].source, "config/machine.hal");
assert.equal(unsupportedHal.diagnostics[0].line, 1);
assert.equal(unsupportedHal.diagnostics[0].hal.records[0].code, "hal_component_undeclared");
let workerSeq = 0;
const workerCore = {
_sim_protocol_reset() {},
_sim_protocol_accept(...args) { if (args[1] !== workerSeq + 1) return -101; workerSeq = args[1]; return 0; },
_sim_create() { return 0; },
_sim_load_workspace() { return 0; },
};
const session = new SimSession(workerCore, { sessionId: "startup-session" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: "startup-session" };
assert.equal(session.accept({ ...base, kind: "HELLO", seq: 1, payload: {} }).kind, "CAPABILITIES");
assert.equal(session.accept({ ...base, kind: "SESSION_ATTACH", seq: 2, operation_id: "attach", payload: {} }).kind, "SESSION_READY");
const workerResult = session.accept({ ...base, kind: "LOAD_WORKSPACE", seq: 3, operation_id: "startup", payload: {
root_path: "/workspace",
configuration: { entrypoint: "config/main.ini", files, halManifest: manifest },
} });
assert.equal(workerResult.kind, "COMMAND_RESULT");
assert.equal(workerResult.payload.configuration.state, "TASK_READY");
assert.equal(workerResult.payload.configuration.state_hash, native.state_hash);
const workerBlocked = session.accept({ ...base, kind: "LOAD_WORKSPACE", seq: 4, operation_id: "blocked", payload: {
root_path: "/workspace",
configuration: { entrypoint: "config/main.ini", files: { "config/main.ini": "[HAL]\nHALFILE = missing.hal\n" } },
} });
assert.equal(workerBlocked.kind, "ERROR");
assert.equal(workerBlocked.payload.code, "configuration_startup");
assert.equal(workerBlocked.payload.startup.state, "BLOCKED");
console.log(`configuration_startup_runtime=passed state_hash=${native.state_hash} resource_hash=${native.resources.state_hash}`);

View File

@@ -0,0 +1,78 @@
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";
import { ReplayTimeline, fnv1a64 } from "./replay.mjs";
import { createWorkspacePackage, migrateWorkspacePackage } from "../workspace/migration.mjs";
import {
SCHEMAS,
validateCommandEnvelope,
validateEventLog,
validateSimTask,
validateSnapshot,
validateWorkspacePackage,
} from "./contracts.mjs";
const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
const core = await createModule({ wasmBinary });
const sessionId = "contract-session";
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: sessionId };
function command(kind, seq, payload = {}, operation_id = undefined) {
return { ...base, kind, seq, ...(operation_id ? { operation_id } : {}), payload };
}
validateCommandEnvelope(command("HELLO", 1), { contextId: CONTEXT_ID, sessionId });
validateCommandEnvelope({ ...command("PING", 2), sent_at_monotonic_ms: 12.5 });
validateCommandEnvelope(command("X".repeat(128), 2));
for (const invalid of [
{ ...command("PING", 2), schema_version: 2 },
{ ...command("PING", 2), seq: 0 },
{ ...command("PING", 2), payload: [] },
{ ...command("PING", 2), payload: { value: Number.NaN } },
{ ...command("PING", 2), unknown: true },
command("SIM_STEP", 2, { cycles: 1 }),
command("X".repeat(129), 2),
]) {
assert.throws(() => validateCommandEnvelope(invalid), /^Error: contract_/);
}
assert.throws(() => validateCommandEnvelope(command("PING", 2), { contextId: "other" }), /contract_command_context_mismatch/);
const taskUnsigned = {
schema: SCHEMAS.task,
context_id: CONTEXT_ID,
session_id: sessionId,
state: "READY",
mode: "MANUAL",
program_status: "idle",
seq: 2,
simulation_tick: 0,
current_line: null,
};
validateSimTask({ ...taskUnsigned, state_hash: fnv1a64(taskUnsigned) }, { contextId: CONTEXT_ID, sessionId });
assert.throws(() => validateSimTask({ ...taskUnsigned, state_hash: "fnv1a64:0000000000000000" }), /contract_task_hash/);
assert.throws(() => validateSimTask({ ...taskUnsigned, state: "UNKNOWN", state_hash: fnv1a64(taskUnsigned) }), /contract_task_state/);
const session = new SimSession(core, { sessionId });
assert.equal(session.accept(command("HELLO", 1)).kind, "CAPABILITIES");
assert.equal(session.accept(command("SESSION_ATTACH", 2, {}, "attach")).kind, "SESSION_READY");
assert.equal(session.accept(command("LOAD_WORKSPACE", 3, { root_path: "/workspace" }, "load")).payload.status, "verified");
const stepped = session.accept(command("SIM_STEP", 4, { cycles: 5 }, "step"));
assert.equal(stepped.simulation_tick, 5);
validateSnapshot(stepped.payload.snapshot);
const eventLog = session.timeline.events.export();
validateEventLog(eventLog);
assert.equal(core._sim_protocol_last_seq(), eventLog.cursor);
assert.equal(core._sim_event_count(), eventLog.events.length);
assert.equal(core._sim_protocol_accept(2, 5, "PING", "", 5), -100);
const timeline = new ReplayTimeline(session.timeline.export());
const workspace = createWorkspacePackage({ contextId: CONTEXT_ID, timeline, committedSeq: eventLog.cursor });
validateWorkspacePackage(workspace, { contextId: CONTEXT_ID });
const migrated = migrateWorkspacePackage(workspace, 2);
validateWorkspacePackage(migrated, { contextId: CONTEXT_ID });
assert.throws(() => validateWorkspacePackage({ ...workspace, package_hash: "fnv1a64:0000000000000000" }), /contract_workspace_package_hash/);
assert.throws(() => validateWorkspacePackage({ ...workspace, committed_seq: Number.POSITIVE_INFINITY }), /contract_workspace_committed_seq/);
assert.equal(migrateWorkspacePackage(migrated, 1).package_hash, workspace.package_hash);
console.log(`contracts=passed schemas=${Object.values(SCHEMAS).join(",")} tick=${stepped.simulation_tick} cursor=${eventLog.cursor}`);

View File

@@ -0,0 +1,69 @@
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 { compareEndpoints, createEndpoint, endpointHash } from "./endpoint-replay.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
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 CASES = [
{ path: "linuxcnc/configs/sim/axis/corexy/corexy_by_kins.ini", mode: "kins" },
{ path: "linuxcnc/configs/sim/axis/corexy/corexy_by_hal.ini", mode: "hal" },
];
const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
async function runCase({ path, mode }, fast) {
const bundle = await loadSimulationCase({ path, fetchImpl });
assert.equal(bundle.status, "ready");
assert.equal(bundle.missing.length, 0);
const configuration = toWorkerConfiguration(bundle);
assert.equal(configuration.kinematics?.path_mode, mode);
const core = await createModule({ wasmBinary });
const session = new SimSession(core, { sessionId: `${mode}-${fast ? "fast" : "1x"}` });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
let seq = 0;
const send = (kind, operationId, payload = {}) => {
const response = session.accept({ ...base, kind, seq: ++seq, operation_id: operationId, payload });
assert.notEqual(response.kind, "ERROR", `${path}:${kind}:${response.payload?.code ?? "error"}`);
return response;
};
send("HELLO", "hello");
send("SESSION_ATTACH", "attach");
const loaded = send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration });
assert.equal(loaded.payload.configuration.state, "TASK_READY");
assert.equal(loaded.payload.configuration.hal.blocked_count, 0);
send("HOME", "home");
send("PROGRAM_LOAD", "program", { source: "G21 G90\nG0 X4 Y2 Z3\nM2\n", path: `${mode}.ngc` });
send("PROGRAM_RUN", "run");
if (fast) send("SIM_STEP", "fast", { cycles: 8 });
else for (let index = 0; index < 8; index += 1) send("SIM_STEP", `step-${index}`, { cycles: 1 });
const endpoint = createEndpoint({ session });
assert.equal(endpoint.authoritative.kinematics.type, "corexy");
assert.deepEqual(endpoint.authoritative.kinematics.joints, [6, 2, 3]);
const checkpoint = session.createCheckpoint();
send("SIM_STEP", "post-checkpoint", { cycles: 1 });
const continuous = createEndpoint({ session });
const recovery = session.restoreCheckpoint(checkpoint, session.timeline.export(), (operation) => operation);
assert.equal(recovery.status, "recovered");
const recoveryParity = compareEndpoints(continuous, createEndpoint({ session }));
assert.equal(recoveryParity.equal, true, JSON.stringify(recoveryParity));
return { endpoint, recoveryParity, checkpoint, path, mode, hal_records: loaded.payload.configuration.hal.record_count };
}
const results = [];
for (const entry of CASES) {
const oneX = await runCase(entry, false);
const fast = await runCase(entry, true);
const modeParity = compareEndpoints(oneX.endpoint, fast.endpoint);
assert.equal(modeParity.equal, true, JSON.stringify(modeParity));
results.push({ path: entry.path, mode: entry.mode, joints: oneX.endpoint.authoritative.kinematics.joints, endpoint_hash: endpointHash(oneX.endpoint), hal_records: oneX.hal_records, mode_parity: modeParity, recovery: oneX.recoveryParity, checkpoint_hash: oneX.checkpoint.checkpoint_hash });
}
console.log(JSON.stringify({ schema: "corexy-dual-path-s8-05-sample.v1", status: "verified", cases: results, distinction: { kins: "corexykins homes motor joints directly", hal: "trivkins + corexy_by_hal routes Cartesian commands through alpha/beta HAL transform" }, boundary: "native/WASM corexy HAL formula oracle remains separately source-verified; other case families remain queued" }));

View File

@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import { DeviceIoRuntime } from "./device-io.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const device = new DeviceIoRuntime({ encoder_count: 2, cycle_time: 0.1 });
assert.equal(device.encoderSet(0, { speed_rps: 1, ppr: 10, scale: 4, index_enable: true }).status, "verified");
assert.equal(device.step(1).status, "verified");
assert.equal(device.state.encoders[0].count, 4);
assert.equal(device.step(10).status, "verified");
assert.equal(device.state.encoders[0].index_pulse, true);
assert.equal(device.encoderSet(0, { speed_rps: Number.NaN }).code, "device_encoder_speed");
assert.equal(device.coolantCommand({ mist: true, flood: false, lube: true }).status, "verified");
assert.equal(device.doorSet(true).code, "device_interlock");
assert.equal(device.state.coolant.mist, false);
assert.equal(device.doorSet(false).status, "verified");
assert.equal(device.interlockReset().status, "verified");
assert.equal(device.coolantCommand({ mist: true }).status, "verified");
assert.equal(device.estop(true).code, "device_interlock");
assert.equal(device.estop(false).status, "verified");
assert.equal(device.interlockReset().status, "verified");
assert.equal(device.issueAck("ack-1", 2).status, "verified");
assert.equal(device.issueAck("ack-1", 2).code, "device_ack_duplicate");
assert.equal(device.ackStep(12).status, "verified");
assert.equal(device.ack("ack-1").code, "device_ack_late");
const saved = device.export();
const restored = new DeviceIoRuntime();
assert.deepEqual(restored.restore(saved), saved);
const core = {
_sim_protocol_reset() {}, _sim_protocol_accept() { return 0; }, _sim_create() { return 0; },
_sim_load_workspace() { return 0; }, _sim_step() { return 0; }, _sim_protocol_set_tick() { return 0; },
};
const session = new SimSession(core, { sessionId: "device-io" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: "device-io" };
const send = (kind, seq, operation_id, payload = {}) => session.accept({ ...base, kind, seq, operation_id, payload });
send("HELLO", 1); send("SESSION_ATTACH", 2, "attach"); send("LOAD_WORKSPACE", 3, "workspace", { root_path: "/workspace" });
assert.equal(send("ENCODER_SET", 4, "encoder", { channel: 0, speed_rps: 1, ppr: 10, scale: 4, index_enable: true }).payload.device_io.encoders[0].speed_rps, 1);
assert.equal(send("COOLANT_COMMAND", 5, "coolant", { mist: true }).payload.device_io.coolant.mist, true);
assert.equal(send("DOOR_SET", 6, "door", { open: true }).kind, "ERROR");
assert.equal(send("INTERLOCK_RESET", 7, "interlock", {}).kind, "ERROR");
assert.equal(send("DOOR_SET", 8, "door-close", { open: false }).kind, "COMMAND_RESULT");
assert.equal(send("INTERLOCK_RESET", 9, "interlock-reset", {}).kind, "COMMAND_RESULT");
assert.equal(send("DEVICE_ACK_ISSUE", 10, "ack-op", { timeout_ticks: 2 }).payload.device_io.acks[0].status, "pending");
assert.equal(send("SIM_STEP", 11, "device-step", { cycles: 2 }).payload.device_io.acks[0].status, "timed_out");
assert.equal(send("DEVICE_ACK", 12, "late", { operation_id: "ack-op" }).payload.code, "device_ack_late");
const checkpoint = send("CHECKPOINT", 13, "device-checkpoint").payload.checkpoint;
assert.equal(checkpoint.state.device_io.schema, "sim_device_io.v1");
console.log(JSON.stringify({ schema: "device-io-worker-sample.v1", status: "verified", tick: device.state.tick, count: device.state.encoders[0].count, state_hash: device.hash(), worker_tick: session.tick, worker_state_hash: session.deviceIo.hash(), checkpoint_hash: checkpoint.checkpoint_hash }));

View File

@@ -0,0 +1,95 @@
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";
import { compareEndpoints, createEndpoint, endpointHash, firstDifference } from "./endpoint-replay.mjs";
const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
async function sessionWithPlan(id, mode) {
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, operation_id, payload = {}) => {
seq += 1;
const response = session.accept({ ...base, kind, seq, operation_id, payload });
assert.notEqual(response.kind, "ERROR", `${id}:${kind}:${response.payload?.code ?? "error"}`);
return response;
};
send("HELLO", "hello");
send("SESSION_ATTACH", "attach");
send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace" });
send("HAL_COMMAND", "thread", { action: "thread", name: "servo-thread", period_ticks: 1 });
send("HAL_COMMAND", "component", { action: "component", instance: "gate", type: "and2", thread: "servo-thread" });
send("HAL_SET", "in0", { target: "pin", name: "gate.in0", value: 1 });
send("HAL_SET", "in1", { target: "pin", name: "gate.in1", value: 1 });
send("PLC_CONFIG", "plc", { rungs: [{ op: "SET", target: "ready", value: 1 }, { op: "ADD", target: "count", source: "ready" }] });
send("PROGRAM_LOAD", "program", { source: "G0 X1\nG1 X2\n", path: "replay.ngc" });
send("PROGRAM_RUN", "run");
if (mode === "single_step") {
send("PROGRAM_STEP", "program-step");
} else if (mode === "1x") {
for (let index = 0; index < 4; index += 1) send("SIM_STEP", `step-${index}`, { cycles: 1 });
} else {
send("SIM_STEP", "step-fast", { cycles: 4 });
}
return { session, send };
}
const oneX = await sessionWithPlan("replay-1x", "1x");
const fast = await sessionWithPlan("replay-fast", "fast");
const oneEndpoint = createEndpoint({ session: oneX.session });
const fastEndpoint = createEndpoint({ session: fast.session });
const modeParity = compareEndpoints(oneEndpoint, fastEndpoint);
assert.equal(modeParity.equal, true);
assert.equal(modeParity.difference, null);
const single = await sessionWithPlan("replay-single", "single_step");
const singleEndpoint = createEndpoint({ session: single.session });
assert.equal(singleEndpoint.task.program_status, "paused");
const replay = await sessionWithPlan("replay-duplicate", "1x");
const beforeDuplicate = createEndpoint({ session: replay.session });
const duplicateSeq = replay.session.timeline.events.cursor + 1;
const duplicate = replay.session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: "replay-duplicate", seq: duplicateSeq, kind: "SIM_STEP", operation_id: "step-3", payload: { cycles: 1 } });
assert.equal(duplicate.replayed, true);
const afterDuplicate = createEndpoint({ session: replay.session });
const duplicateParity = compareEndpoints(beforeDuplicate, afterDuplicate);
assert.equal(duplicateParity.equal, true);
const recoveredSource = await sessionWithPlan("replay-recover", "1x");
const checkpointResponse = recoveredSource.send("CHECKPOINT", "checkpoint");
assert.equal(checkpointResponse.kind, "CHECKPOINT_READY");
const checkpoint = checkpointResponse.payload.checkpoint;
recoveredSource.send("SIM_STEP", "post-checkpoint", { cycles: 2 });
const continuousAfterCheckpoint = createEndpoint({ session: recoveredSource.session });
const recovery = recoveredSource.session.restoreCheckpoint(checkpoint, recoveredSource.session.timeline.export(), (operation) => operation);
assert.equal(recovery.status, "recovered");
const recoveredEndpoint = createEndpoint({ session: recoveredSource.session });
const recoveryParity = compareEndpoints(continuousAfterCheckpoint, recoveredEndpoint);
assert.equal(recoveryParity.equal, true);
const trace = recoveredSource.session.exportTrace();
const traceCore = await createModule({ wasmBinary });
const traceSession = new SimSession(traceCore, { sessionId: "replay-recover" });
const imported = traceSession.importTrace(trace);
assert.equal(imported.trace_hash, trace.trace_hash);
const traceParity = compareEndpoints(recoveredEndpoint, createEndpoint({ session: traceSession }));
assert.equal(traceParity.equal, true);
const expected = { nested: [{ value: 1 }, { value: 2 }] };
const actual = { nested: [{ value: 1 }, { value: 3 }] };
assert.deepEqual(firstDifference(expected, actual), { path: "$.nested[1].value", expected: 2, actual: 3 });
const report = {
schema: "endpoint-replay-s4-sample.v1",
status: "verified",
context_id: CONTEXT_ID,
modes: { one_x: endpointHash(oneEndpoint), fast: endpointHash(fastEndpoint), single_step: endpointHash(singleEndpoint) },
parity: { mode_equal: modeParity, duplicate_operation_equal: duplicateParity, recovery_equal: recoveryParity, trace_equal: traceParity },
first_divergence: null,
checkpoint_hash: checkpoint.checkpoint_hash,
trace_hash: trace.trace_hash,
};
console.log(JSON.stringify(report));

View File

@@ -0,0 +1,85 @@
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";
import { fnv1a64 } from "./replay.mjs";
const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
async function createSession(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, operation_id, payload = {}) => {
seq += 1;
const response = session.accept({ ...base, kind, seq, operation_id, payload });
assert.notEqual(response.kind, "ERROR", `${id}:${kind}:${response.payload?.code ?? "error"}`);
return response;
};
send("HELLO", "hello");
send("SESSION_ATTACH", "attach");
send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace" });
send("HAL_COMMAND", "hal-thread", { action: "thread", name: "servo-thread", period_ticks: 1 });
send("HAL_COMMAND", "hal-component", { action: "component", instance: "gate", type: "and2", thread: "servo-thread" });
send("HAL_SET", "hal-in0", { target: "pin", name: "gate.in0", value: 1 });
send("HAL_SET", "hal-in1", { target: "pin", name: "gate.in1", value: 1 });
send("PLC_CONFIG", "plc-config", { rungs: [{ op: "SET", target: "ready", value: 1 }, { op: "ADD", target: "count", source: "ready" }], sample_capacity: 4, backpressure: "drop_newest" });
send("ENCODER_SET", "encoder", { channel: 0, speed_rps: 1, ppr: 10, scale: 4, index_enable: true });
send("COOLANT_COMMAND", "coolant", { mist: true });
send("PYTHON_IMPORT", "python-import", { module: "workspace" });
send("PYTHON_CALL", "python-write", { module: "workspace", symbol: "write", args: ["/workspace/full.ngc", "G0 X1\n"] });
send("REMAP_REGISTER", "remap", { name: "M6", prolog: "m6_prolog", epilog: "m6_epilog" });
send("VCP_REGISTER", "vcp", { name: "panel", pins: [{ name: "enabled", type: "bit" }] });
send("PROGRAM_LOAD", "program-load", { source: "G0 X1\nG1 X2\n", path: "full.ngc" });
return { session, send };
}
function endpoint(session) {
return {
tick: session.tick,
task: { state: session.task.state.state, mode: session.task.state.mode, program_status: session.task.state.program_status, current_line: session.task.state.current_line },
motion: session.motion.hash(),
plc: session.plc.hash(),
hal: session.hal.hash(),
device_io: session.deviceIo.hash(),
python: session.python.hash(),
callbacks: session.callbacks.hash(),
};
}
async function runMode(mode) {
const { session, send } = await createSession(`full-${mode}`);
send("PROGRAM_RUN", "program-run");
if (mode === "1x") for (let index = 0; index < 4; index += 1) send("SIM_STEP", `step-${index}`, { cycles: 1 });
else send("SIM_STEP", "step-fast", { cycles: 4 });
return { session, endpoint: endpoint(session), endpoint_hash: fnv1a64(endpoint(session)) };
}
const oneX = await runMode("1x");
const fast = await runMode("fast");
assert.deepEqual(oneX.endpoint, fast.endpoint);
assert.equal(oneX.endpoint_hash, fast.endpoint_hash);
const single = await createSession("full-single");
const singleResult = single.send("PROGRAM_STEP", "single-step");
assert.equal(singleResult.payload.status, "verified");
const singleEndpoint = endpoint(single.session);
const recoverSource = await createSession("full-recover");
recoverSource.send("PROGRAM_RUN", "recover-run");
recoverSource.send("SIM_STEP", "recover-step-1", { cycles: 1 });
const checkpoint = recoverSource.send("CHECKPOINT", "recover-checkpoint").payload.checkpoint;
const recovery = recoverSource.session.restoreCheckpoint(checkpoint, recoverSource.session.timeline.export(), (operation) => operation);
assert.equal(recovery.status, "recovered");
assert.equal(recoverSource.session.tick, checkpoint.simulation_tick);
const recoveredEndpoint = endpoint(recoverSource.session);
const report = {
schema: "full-simulation-s4-sample.v1",
status: "verified",
context_id: CONTEXT_ID,
modes: { one_x: oneX.endpoint, fast: fast.endpoint, single_step: singleEndpoint, recover: recoveredEndpoint },
parity: { one_x_fast_equal: oneX.endpoint_hash === fast.endpoint_hash, recovery_status: recovery.status, recovery_tick: recoverSource.session.tick === checkpoint.simulation_tick },
hashes: { one_x: oneX.endpoint_hash, fast: fast.endpoint_hash, single_step: fnv1a64(singleEndpoint), recover: fnv1a64(recoveredEndpoint) },
};
console.log(JSON.stringify(report));

View File

@@ -0,0 +1,63 @@
import assert from "node:assert/strict";
import { HalRuntime, parseCompSource } from "./hal-runtime.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const parsed = parseCompSource("component and2;\npin in bit in0;\npin in bit in1;\npin out bit out;\nfunction _ nofp;\n");
assert.equal(parsed.status, "verified");
assert.equal(parseCompSource("pin in bit missing;").code, "hal_component_missing");
assert.equal(parseCompSource("component unknown;\n").code, "hal_component_unsupported");
assert.equal(parseCompSource("component abs_s64;\npin in s64 in;\n").code, "hal_type_unsupported");
const hal = new HalRuntime();
assert.equal(hal.configure({ threads: [{ name: "servo-thread", period_ticks: 1 }], components: [
{ instance: "gate", type: "and2", thread: "servo-thread" },
{ instance: "constant", type: "constant", thread: "servo-thread" },
{ instance: "xy", type: "corexy_by_hal", thread: "servo-thread" },
] }).status, "verified");
assert.equal(hal.setPin("gate.in0", 1).status, "verified");
assert.equal(hal.setPin("gate.in1", 1).status, "verified");
assert.equal(hal.setParam("constant.value", 3.5).status, "verified");
assert.equal(hal.setPin("xy.j0-motor-pos-cmd", 6).status, "verified");
assert.equal(hal.setPin("xy.j1-motor-pos-cmd", 2).status, "verified");
assert.equal(hal.setPin("xy.alpha-fb", 4).status, "verified");
assert.equal(hal.setPin("xy.beta-fb", 1).status, "verified");
assert.equal(hal.step(1).status, "verified");
assert.equal(hal.getPin("gate.out").value, 1);
assert.equal(hal.getPin("constant.out").value, 3.5);
assert.equal(hal.getPin("xy.alpha-cmd").value, 4);
assert.equal(hal.getPin("xy.j0-motor-pos-fb").value, 5);
assert.equal(hal.state.components.find((item) => item.instance === "xy").calls, 1);
assert.equal(hal.setFailure("gate", true).status, "verified");
assert.equal(hal.step(2).code, "hal_function_failure");
assert.equal(hal.state.tick, 1);
const saved = hal.export();
const restored = new HalRuntime();
assert.deepEqual(restored.restore(saved), saved);
assert.equal(hal.setPin("constant.out", Number.NaN).code, "hal_value_invalid");
const batch = new HalRuntime();
assert.equal(batch.configure({ components: [{ module: "constant", instances: ["c0", "c1"] }] }).status, "verified");
assert.equal(batch.state.components.length, 2);
const core = {
_sim_protocol_reset() {},
_sim_protocol_accept() { return 0; },
_sim_create() { return 0; },
_sim_load_workspace() { return 0; },
_sim_step() { return 0; },
_sim_protocol_set_tick() { return 0; },
};
const session = new SimSession(core, { sessionId: "hal-state" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: "hal-state" };
const send = (kind, seq, operation_id, payload = {}) => session.accept({ ...base, kind, seq, operation_id, payload });
send("HELLO", 1);
send("SESSION_ATTACH", 2, "attach");
send("LOAD_WORKSPACE", 3, "workspace", { root_path: "/workspace" });
assert.equal(send("HAL_COMMAND", 4, "hal-thread", { action: "thread", name: "servo-thread", period_ticks: 1 }).payload.hal_runtime.threads["servo-thread"].period_ticks, 1);
assert.equal(send("HAL_COMMAND", 5, "hal-component", { action: "component", instance: "gate", type: "and2", thread: "servo-thread" }).payload.hal_runtime.components.length, 1);
send("HAL_SET", 6, "hal-in0", { target: "pin", name: "gate.in0", value: 1 });
send("HAL_SET", 7, "hal-in1", { target: "pin", name: "gate.in1", value: 1 });
const stepped = send("SIM_STEP", 8, "hal-step", { cycles: 1 });
assert.equal(stepped.payload.hal_runtime.pins["gate.out"].value, 1);
const checkpoint = send("CHECKPOINT", 9, "hal-checkpoint").payload.checkpoint;
assert.equal(checkpoint.state.hal_runtime.schema, "sim_hal_runtime.v1");
console.log(`hal_runtime=passed tick=${hal.state.tick} components=${hal.state.components.length} worker_tick=${stepped.payload.hal_runtime.tick}`);

View File

@@ -0,0 +1,23 @@
import assert from "node:assert/strict";
import { WorkspaceRuntime } from "../workspace/runtime.mjs";
import { parseIni } from "../workspace/ini-parser.mjs";
const workspace = new WorkspaceRuntime({ files: {
"config/main.ini": "[EMC]\nMACHINE = ${MACHINE}\n[VAR]\nSCALE = 2\ninclude sub.ini\n",
"config/sub.ini": "[TRAJ]\nLINEAR_UNITS = mm\nCOUNT = $SCALE\n",
} });
const parsed = parseIni({ workspace, entrypoint: "config/main.ini", variables: { MACHINE: "web-sim" } });
assert.equal(parsed.schema, "ini-resource-graph.v1");
assert.equal(parsed.sections.EMC.MACHINE, "web-sim");
assert.equal(parsed.sections.TRAJ.COUNT, "2");
assert.deepEqual(parsed.graph, ["config/main.ini", "config/sub.ini"]);
assert.equal(parsed.includes[0].line, 5);
assert.match(parsed.state_hash, /^fnv1a64:/);
assert.throws(() => parseIni({ workspace, entrypoint: "config/missing.ini" }), /ini_source_missing/);
assert.throws(() => parseIni({ workspace: new WorkspaceRuntime({ files: { "a.ini": "include b.ini\n", "b.ini": "include a.ini\n" } }), entrypoint: "a.ini" }), /ini_include_cycle/);
assert.throws(() => parseIni({ workspace: new WorkspaceRuntime({ files: { "a.ini": "[A]\nX=${MISSING}\n" } }), entrypoint: "a.ini" }), /ini_variable_missing/);
assert.throws(() => parseIni({ workspace: new WorkspaceRuntime({ files: { "a.ini": "include /etc/passwd\n" } }), entrypoint: "a.ini" }), /ini_include_path_invalid/);
assert.throws(() => parseIni({ workspace: new WorkspaceRuntime({ files: { "a.ini": "X=1\n" } }), entrypoint: "a.ini" }), /ini_section_invalid/);
const second = parseIni({ workspace, entrypoint: "config/main.ini", variables: { MACHINE: "web-sim" } });
assert.equal(second.state_hash, parsed.state_hash);
console.log(`ini_parser=passed sections=${Object.keys(parsed.sections).length} graph=${parsed.graph.length} state_hash=${parsed.state_hash}`);

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 { IoAckTracker } from "./io-ack.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const tracker = new IoAckTracker();
assert.equal(tracker.issue("op-1", "TOOL_CHANGE", 0, 2).status, "verified");
assert.equal(tracker.step(1).status, "verified");
assert.equal(tracker.ack("op-1", 1).command.status, "acked");
assert.equal(tracker.issue("op-2", "SPINDLE", 1, 1).status, "verified");
assert.equal(tracker.step(2).state.commands.find((command) => command.operation_id === "op-2").status, "timed_out");
assert.equal(tracker.ack("op-2", 2).code, "io_ack_late");
const saved = tracker.export(); const restored = new IoAckTracker(); assert.deepEqual(restored.restore(saved), saved);
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const session = new SimSession(core, { sessionId: "io-ack" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: "io-ack" };
const send = (kind, seq, operation_id, payload = {}) => session.accept({ ...base, kind, seq, operation_id, payload });
send("HELLO", 1); send("SESSION_ATTACH", 2, "attach"); send("LOAD_WORKSPACE", 3, "workspace", { root_path: "/workspace" });
assert.equal(send("IO_COMMAND", 4, "io-command", { command_id: "io-command", kind: "TOOL_CHANGE", timeout_ticks: 2 }).payload.io_ack.commands[0].status, "pending");
assert.equal(send("SIM_STEP", 5, "step", { cycles: 2 }).payload.io_ack.commands[0].status, "timed_out");
const late = send("IO_ACK", 6, "io-ack-late", { operation_id: "io-command" });
assert.equal(late.kind, "ERROR");
assert.equal(late.payload.code, "io_ack_late");
console.log(`io_ack=passed timeout=${tracker.state.commands[1].status} worker=${late.payload.code} hash=${tracker.hash()}`);

View File

@@ -0,0 +1,62 @@
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 { compareEndpoints, createEndpoint, endpointHash } from "./endpoint-replay.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
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 CASES = [
{ path: "linuxcnc/configs/sim/axis/ldelta.ini", type: "lineardelta", pose: [0, 0, 64.636584194], joints: [300, 300, 300] },
{ path: "linuxcnc/configs/sim/axis/rdelta.ini", type: "rotarydelta", pose: [0, 0, -3.793360169], joints: [10, 10, 10] },
];
const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
async function runCase(entry, fast) {
const bundle = await loadSimulationCase({ path: entry.path, fetchImpl });
assert.equal(bundle.status, "ready");
const configuration = toWorkerConfiguration(bundle);
assert.equal(configuration.kinematics.type, entry.type);
const core = await createModule({ wasmBinary });
const session = new SimSession(core, { sessionId: `${entry.type}-${fast ? "fast" : "1x"}` });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
let seq = 0;
const send = (kind, operationId, payload = {}) => {
const response = session.accept({ ...base, kind, seq: ++seq, operation_id: operationId, payload });
assert.notEqual(response.kind, "ERROR", `${entry.path}:${kind}:${response.payload?.code ?? "error"}`);
return response;
};
send("HELLO", "hello"); send("SESSION_ATTACH", "attach");
const loaded = send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration });
assert.equal(loaded.payload.configuration.state, "TASK_READY");
assert.equal(loaded.payload.configuration.hal.blocked_count, 0);
send("PROGRAM_LOAD", "program", { source: `G21 G90\nG0 X${entry.pose[0]} Y${entry.pose[1]} Z${entry.pose[2]}\nM2\n`, path: `${entry.type}.ngc` });
send("PROGRAM_RUN", "run");
if (fast) send("SIM_STEP", "fast", { cycles: 8 }); else for (let index = 0; index < 8; index += 1) send("SIM_STEP", `step-${index}`, { cycles: 1 });
const endpoint = createEndpoint({ session });
assert.equal(endpoint.authoritative.kinematics.type, entry.type);
entry.joints.forEach((expected, index) => assert.ok(Math.abs(endpoint.authoritative.kinematics.joints[index] - expected) < 1e-6));
const checkpoint = session.createCheckpoint();
send("SIM_STEP", "post-checkpoint", { cycles: 1 });
const continuous = createEndpoint({ session });
const recovery = session.restoreCheckpoint(checkpoint, session.timeline.export(), (operation) => operation);
assert.equal(recovery.status, "recovered");
const recoveryParity = compareEndpoints(continuous, createEndpoint({ session }));
assert.equal(recoveryParity.equal, true, JSON.stringify(recoveryParity));
return { path: entry.path, type: entry.type, startup: loaded.payload.configuration.state, joints: endpoint.authoritative.kinematics.joints, endpoint_hash: endpointHash(endpoint), mode_parity: null, recovery: recoveryParity, checkpoint_hash: checkpoint.checkpoint_hash };
}
const cases = [];
for (const entry of CASES) {
const oneX = await runCase(entry, false);
const fast = await runCase(entry, true);
// The endpoint is deterministic across 1x/fast; rerun captures the same nominal family golden.
const modeParity = { equal: oneX.endpoint_hash === fast.endpoint_hash, expected_hash: oneX.endpoint_hash, actual_hash: fast.endpoint_hash };
assert.equal(modeParity.equal, true);
cases.push({ ...oneX, mode_parity: modeParity, fast_endpoint_hash: fast.endpoint_hash });
}
console.log(JSON.stringify({ schema: "machine-families-s8-08-sample.v1", status: "verified", cases, boundary: "Delta lineardelta/rotarydelta only; official kinematics oracle remains separately blocked; other machine families queued" }));

View File

@@ -0,0 +1,85 @@
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 { compareEndpoints, createEndpoint, endpointHash } from "./endpoint-replay.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
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: "linuxcnc/configs/sim/axis/minimal_xyz.ini", fetchImpl });
assert.equal(bundle.status, "ready");
const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
function createSession(id, core) {
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 });
return { session, send };
}
const core = await createModule({ wasmBinary });
const run = createSession("minimal-s8-04", core);
assert.equal(run.send("HELLO", "hello").kind, "CAPABILITIES");
assert.equal(run.send("SESSION_ATTACH", "attach").kind, "SESSION_READY");
const loaded = run.send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
assert.equal(loaded.kind, "COMMAND_RESULT");
assert.equal(loaded.payload.configuration.state, "TASK_READY");
assert.equal(run.send("HOME", "home").payload.status, "verified");
assert.equal(run.send("JOG_START", "jog-start", { axis: "x", direction: 1, speed: 2 }).payload.status, "verified");
const jogStep = run.send("SIM_STEP", "jog-step", { cycles: 10 });
assert.equal(jogStep.payload.motion.homed, true);
assert.ok(jogStep.payload.motion.axes.x > 0);
assert.equal(run.send("JOG_STOP", "jog-stop").payload.status, "verified");
const override = run.send("SET_OVERRIDE", "override", { feed: 0.5, spindle: 1.2 });
assert.equal(override.payload.state.feed_override, 0.5);
const program = run.send("PROGRAM_LOAD", "program", { path: "minimal-s8-04.ngc", source: "G21 G90\nG0 X0 Y0 Z0\nG1 X2 F60\nG1 Y1\nM2\n" });
assert.equal(program.payload.status, "verified");
assert.equal(run.send("PROGRAM_RUN", "run").payload.task.state, "RUNNING");
const checkpoint = run.send("CHECKPOINT", "checkpoint").payload.checkpoint;
run.send("SIM_STEP", "program-step-1", { cycles: 1 });
const continuous = createEndpoint({ session: run.session });
const recovered = run.session.restoreCheckpoint(checkpoint, run.session.timeline.export(), (operation) => operation);
assert.equal(recovered.status, "recovered");
const recoveryParity = compareEndpoints(continuous, createEndpoint({ session: run.session }));
assert.equal(recoveryParity.equal, true, JSON.stringify(recoveryParity));
const controlCore = await createModule({ wasmBinary });
const controls = createSession("minimal-s8-04-controls", controlCore);
controls.send("HELLO", "hello"); controls.send("SESSION_ATTACH", "attach");
controls.send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
controls.send("PROGRAM_LOAD", "program", { path: "minimal-s8-04-controls.ngc", source: "G21 G90\nG0 X0 Y0 Z0\nG1 X2 F60\nG1 Y1\nM2\n" });
assert.equal(controls.send("PROGRAM_RUN", "run").payload.task.state, "RUNNING");
controls.send("SIM_STEP", "program-step-1", { cycles: 1 });
assert.equal(controls.send("PROGRAM_PAUSE", "pause").payload.task.state, "PAUSED");
assert.equal(controls.send("PROGRAM_RESUME", "resume").payload.task.state, "RUNNING");
const finished = controls.send("SIM_STEP", "program-step-2", { cycles: 4 });
assert.equal(finished.payload.program.program_status, "completed");
assert.equal(finished.payload.task.state, "STOPPED");
assert.equal(controls.send("PROGRAM_ABORT", "abort").payload.task.program_status, "aborted");
assert.equal(controls.send("PROGRAM_RESTART", "restart").payload.task.program_status, "loaded");
const mdi = controls.send("MDI_EXECUTE", "mdi", { block: "G0 X1" });
assert.equal(mdi.payload.status, "verified");
const estopCore = await createModule({ wasmBinary });
const estop = createSession("minimal-s8-04-estop", estopCore);
estop.send("HELLO", "hello"); estop.send("SESSION_ATTACH", "attach");
estop.send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
estop.send("HOME", "home");
const estopResult = estop.send("ESTOP", "estop", { active: true });
assert.equal(estopResult.kind, "ERROR");
assert.equal(estopResult.payload.code, "sim_io_estop");
assert.equal(estopResult.payload.task.state, "ESTOP");
assert.equal(estop.send("JOG_START", "blocked-jog", { axis: "x", direction: 1, speed: 1 }).kind, "ERROR");
assert.equal(estop.send("ESTOP", "clear-estop", { active: false }).payload.status, "verified");
assert.equal(estop.send("IO_RECOVER", "recover").payload.status, "verified");
assert.equal(estop.send("JOG_START", "jog-after-recover", { axis: "x", direction: 1, speed: 1 }).payload.status, "verified");
console.log(JSON.stringify({ schema: "minimal-xyz-vertical-s8-04-sample.v1", status: "verified", case: bundle.entrypoint, startup_state: loaded.payload.configuration.state, jog: { tick: jogStep.payload.motion.tick, x: jogStep.payload.motion.axes.x }, override: { feed: override.payload.state.feed_override, spindle: override.payload.state.spindle_override }, program: { status: finished.payload.program.program_status, line: finished.payload.program.line, mdi: mdi.payload.status }, recovery: { status: recovered.status, parity: recoveryParity }, estop: { blocked_code: estopResult.payload.code, recovered: estop.send("SNAPSHOT_REQUEST", "snapshot").payload.task.state }, endpoint_hash: endpointHash(continuous), checkpoint_hash: checkpoint.checkpoint_hash }));

View File

@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { MotionState } from "./motion-state.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const twin = new MotionState();
assert.equal(twin.startJog({ axis: "x", direction: 1, speed: 10 }).code, "motion_unhomed");
assert.equal(twin.home().status, "verified");
assert.equal(twin.setOverride(0.5, 1).status, "verified");
assert.equal(twin.startJog({ axis: "x", direction: 1, speed: 10 }).status, "verified");
twin.step(1);
assert.equal(twin.state.axes.x, 0.005);
assert.equal(twin.setLimit("x", true).state.fault, "limit");
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 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" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: "motion-state" };
const send = (kind, seq, operation_id, payload = {}) => session.accept({ ...base, kind, seq, operation_id, payload });
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);
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);
assert.equal(stepped.payload.motion.axes.x, 0.02);
const limit = send("LIMIT_SET", 7, "limit", { axis: "x", active: true });
assert.equal(limit.payload.state.fault, "limit");
const blocked = send("JOG_START", 8, "jog-limit", { axis: "x", direction: 1, speed: 10 });
assert.equal(blocked.kind, "ERROR");
assert.equal(blocked.payload.code, "motion_faulted");
console.log(`motion_state=passed jog_x=${stepped.payload.motion.axes.x} fault=${limit.payload.state.fault} hash=${limit.payload.state.state_hash}`);

View File

@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { PlcRuntime, parseClassicLadder } from "./plc-runtime.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const plc = new PlcRuntime();
const clp = parseClassicLadder("_FILES_CLASSICLADDER\n_FILE-rung_0.csv\n#VER=2.0\n1-0-50/0 , 9-0-0/0 , 50-0-60/1\n_/FILE-rung_0.csv\n");
assert.equal(clp.status, "verified");
assert.equal(clp.rungs[0].cells, 3);
assert.equal(parseClassicLadder("_FILES_CLASSICLADDER\n_FILE-rung_0.csv\ninvalid\n_/FILE-rung_0.csv\n").code, "clp_cell_invalid");
assert.equal(plc.configure({ rungs: [{ op: "SET", target: "input", value: 1 }, { op: "ADD", target: "count", source: "input" }, { op: "TIMER", target: "done", source: "input", preset: 2 }], sample_capacity: 2, backpressure: "drop_oldest" }).status, "verified");
assert.equal(plc.scan(1).state.registers.count, 1);
assert.equal(plc.scan(2).state.registers.count, 2);
assert.equal(plc.trigger().status, "verified");
assert.equal(plc.state.sampler.samples.length, 2);
assert.equal(plc.configure({ rungs: [], sample_capacity: 1, backpressure: "block" }).status, "verified");
plc.scan(3);
assert.equal(plc.trigger().code, "plc_backpressure");
const saved = plc.export();
const restored = new PlcRuntime();
assert.deepEqual(restored.restore(saved), saved);
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const session = new SimSession(core, { sessionId: "plc-state" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: "plc-state" };
const send = (kind, seq, operation_id, payload = {}) => session.accept({ ...base, kind, seq, operation_id, payload });
send("HELLO", 1); send("SESSION_ATTACH", 2, "attach"); send("LOAD_WORKSPACE", 3, "workspace", { root_path: "/workspace" });
assert.equal(send("PLC_CONFIG", 4, "plc-config", { rungs: [{ op: "SET", target: "ready", value: 1 }], sample_capacity: 4, backpressure: "drop_newest" }).payload.plc.state_hash.startsWith("fnv1a64:"), true);
const stepped = send("SIM_STEP", 5, "plc-step", { cycles: 1 });
assert.equal(stepped.payload.plc.registers.ready, 1);
const malformed = send("PLC_CONFIG", 6, "plc-bad", { rungs: [{ op: "UNKNOWN", target: "bad" }] });
assert.equal(malformed.kind, "ERROR");
assert.equal(malformed.payload.code, "plc_rung_unsupported");
console.log(`plc_runtime=passed count=${plc.state.registers.count} dropped=${plc.state.sampler.dropped} worker_tick=${stepped.payload.plc.tick}`);

View File

@@ -0,0 +1,41 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { ProgramRunner } from "./program-runner.mjs";
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
async function run(source, expectedX) {
const runner = new ProgramRunner(core);
const loaded = runner.load(source, "control.ngc");
assert.equal(loaded.status, "verified");
assert.equal(runner.start().status, "verified");
let guard = 0;
while (runner.status === "running" && guard < 200) { runner.tick(1); guard += 1; }
assert.ok(guard < 200, "control program exceeded deterministic budget");
assert.equal(runner.status, "completed");
assert.equal(runner.state.x, expectedX);
runner.destroy();
}
await run(`#1=0\nO10 WHILE [#1 LT 3]\n#1=[#1+1]\nG1 X[#1]\nO10 ENDWHILE\nM2\n`, 3);
await run(`#1=0\nO100 SUB\n#1=[#1+1]\nG1 X[#1]\nO100 ENDSUB\nO100 CALL\nO100 CALL\nM2\n`, 2);
await run(`#1=0\nO1 IF [#1 EQ 1]\nG1 X9\nO1 ELSE\nG1 X4\nO1 ENDIF\nM2\n`, 4);
const malformed = new ProgramRunner(core);
const malformedResult = malformed.load("O10 WHILE [1]\nG1 X1\n", "bad-control.ngc");
assert.equal(malformedResult.status, "error");
assert.equal(malformedResult.code, "program_control_unclosed");
assert.equal(malformedResult.line, 2);
malformed.destroy();
const missing = new ProgramRunner(core);
assert.equal(missing.load("O999 CALL\n", "missing.ngc").status, "verified");
missing.start();
const missingStep = missing.step();
assert.equal(missingStep.status, "error");
assert.equal(missingStep.code, "program_control");
assert.equal(missingStep.detail, "program_subroutine_missing");
missing.destroy();
console.log("program_control=passed while=3 call=2 if=else depth_budget=verified");

View File

@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import { PythonBridge, manifest } from "./python-bridge.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const bridge = new PythonBridge({ loadPyodide: async () => ({ runPythonAsync: async (source) => source.length }) });
assert.equal((await bridge.initialize()).status, "verified");
for (const module of ["linuxcnc", "hal", "gstat", "INI", "tool", "parameter", "workspace", "virtual-clock"]) assert.equal(bridge.importModule(module).status, "verified");
assert.equal(bridge.invoke("hal", "component", ["demo"]).status, "verified");
assert.equal(bridge.invoke("hal", "param", ["demo.gain", 2.5]).value.value, 2.5);
assert.equal(bridge.invoke("hal", "signal", ["ready", 1]).value.value, 1);
assert.equal(bridge.invoke("linuxcnc", "command", ["run"]).value.task_state, "RUNNING");
assert.equal(bridge.invoke("gstat", "state", []).value.state, "RUNNING");
assert.equal(bridge.configureIni({ RS274NGC: { PROGRAM: "main.ngc" }, HAL: { HALFILE: ["machine.hal", "extra.hal"] } }).status, "verified");
assert.equal(bridge.invoke("linuxcnc", "ini", []).value.sections.RS274NGC, true);
assert.deepEqual(bridge.invoke("gstat", "state", []).value, { state: "RUNNING", task: 0, motion: 0 });
assert.equal(bridge.invoke("workspace", "write", ["/workspace/main.ngc", "G0 X1\n"]).status, "verified");
assert.equal(bridge.invoke("workspace", "read", ["/workspace/main.ngc"]).value, "G0 X1\n");
assert.deepEqual(bridge.invoke("workspace", "list", ["/workspace/"]).value, ["/workspace/main.ngc"]);
assert.deepEqual(bridge.invoke("workspace", "list", []).value, ["/workspace/main.ngc"]);
assert.equal(bridge.invoke("workspace", "stat", ["/workspace/main.ngc"]).value.bytes, 6);
assert.match(bridge.invoke("workspace", "hash", ["/workspace/"]).value, /^fnv1a64:/);
assert.equal(bridge.invoke("workspace", "read", ["/etc/passwd"]).code, "python_workspace_path");
assert.equal(bridge.invoke("virtual-clock", "sleep", [4]).clock.seconds, 0.004);
assert.equal(bridge.invoke("virtual-clock", "now", []).value.tick, 4);
assert.equal(bridge.invoke("virtual-clock", "sleep", [-1]).code, "python_clock_argument");
assert.equal(bridge.checkCapability("subprocess").status, "REAL-EXCLUDED");
assert.equal((await bridge.execute("1 + 1")).value, 5);
const saved = bridge.exportState();
const restored = new PythonBridge();
assert.equal(restored.restoreState(saved).status, "verified");
assert.equal(restored.hash(), bridge.hash());
const limited = new PythonBridge({ maxCalls: 1 });
assert.equal(limited.invoke("parameter", "get", [1]).status, "verified");
assert.equal(limited.invoke("parameter", "get", [2]).code, "python_call_limit");
assert.equal(manifest.worker_only, true);
let sequence = 0;
const core = { _sim_protocol_reset() {}, _sim_protocol_accept() { return 0; }, _sim_create() { return 0; }, _sim_load_workspace() { return 0; }, _sim_step() { return 0; }, _sim_protocol_set_tick() { return 0; } };
const session = new SimSession(core, { sessionId: "python-api" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: "python-api" };
const send = (kind, operation_id, payload = {}) => { sequence += 1; return session.accept({ ...base, kind, seq: sequence, operation_id, payload }); };
send("HELLO"); send("SESSION_ATTACH", "attach"); send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace" });
assert.equal(send("PYTHON_IMPORT", "import-workspace", { module: "workspace" }).payload.status, "verified");
assert.equal(send("PYTHON_CALL", "write", { module: "workspace", symbol: "write", args: ["/workspace/session.ngc", "G1 X2\n"] }).payload.status, "verified");
assert.equal(send("PYTHON_CALL", "clock", { module: "virtual-clock", symbol: "sleep", args: [2] }).payload.status, "verified");
const checkpoint = send("CHECKPOINT", "checkpoint").payload.checkpoint;
assert.equal(checkpoint.state.python.schema, "python-worker-bridge.v1");
assert.equal(checkpoint.state.python.state.api.workspace.files["/workspace/session.ngc"], "G1 X2\n");
console.log(JSON.stringify({ schema: "python-api-s3-sample.v1", status: "verified", manifest_version: manifest.version, bridge_hash: bridge.hash(), restored_hash: restored.hash(), session_tick: session.tick, checkpoint_hash: checkpoint.checkpoint_hash }));

View File

@@ -9,7 +9,18 @@ assert.equal(bridge.importModule("linuxcnc").status, "verified");
assert.equal(bridge.importModule("os").code, "python_unsupported_module");
assert.equal(bridge.checkCapability("subprocess").status, "REAL-EXCLUDED");
assert.equal(bridge.invoke("hal", "pin", ["demo.pin"]).status, "verified");
assert.equal(bridge.invoke("linuxcnc", "command", ["mode", "MDI"]).value.mode, "MDI");
assert.equal(bridge.invoke("hal", "component", ["demo"]).status, "verified");
assert.equal(bridge.invoke("hal", "pin", ["demo.pin", 1.5]).value.value, 1.5);
assert.equal(bridge.importModule("tool").status, "verified");
assert.equal(bridge.invoke("tool", "set", [7, { length: 12 }]).value.length, 12);
assert.equal(bridge.invoke("parameter", "set", [100, 2.5]).value, 2.5);
assert.equal(bridge.configureIni({ RS274NGC: { PROGRAM: "main.ngc" } }).status, "verified");
assert.equal(bridge.invoke("INI", "find", ["RS274NGC", "PROGRAM"]).value, "main.ngc");
assert.equal((await bridge.execute("1 + 1")).value, 5);
assert.equal((await bridge.execute("import subprocess")).status, "REAL-EXCLUDED");
assert.equal(bridge.virtualClock().source, "logical_tick");
assert.equal(bridge.setTick(4).clock.seconds, 0.004);
assert.equal(bridge.setTick(3).code, "python_clock_regression");
assert.equal(bridge.restoreState(bridge.exportState()).status, "verified");
console.log(`python_bridge=passed calls=${bridge.state.calls.length} errors=${bridge.state.errors.length} hash=${bridge.hash()}`);

View File

@@ -0,0 +1,27 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { PythonCallbacks } from "./python-callbacks.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const callbacks = new PythonCallbacks();
assert.equal(callbacks.registerRemap("M6", { prolog: "m6_prolog", epilog: "m6_epilog" }, ({ phase }) => phase).status, "verified");
assert.equal(callbacks.invoke("remap", "M6", "prolog", [1]).value, "prolog");
assert.equal(callbacks.registerFilter("pure", () => "G1 X1").status, "verified");
assert.equal(callbacks.filter("pure", "G1 X1").status, "verified");
assert.equal(callbacks.filter("pure", "import subprocess").code, "real_hardware_excluded");
assert.equal(callbacks.registerVcp("panel", [{ name: "enabled", type: "bit" }], ({ args }) => args[1]).status, "verified");
assert.equal(callbacks.updateVcp("panel", "enabled", 1).status, "verified");
const saved = callbacks.export(); const restored = new PythonCallbacks(); assert.deepEqual(restored.restore(saved), saved);
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const session = new SimSession(core, { sessionId: "callbacks" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: "callbacks" };
const send = (kind, seq, operation_id, payload = {}) => session.accept({ ...base, kind, seq, operation_id, payload });
send("HELLO", 1); send("SESSION_ATTACH", 2, "attach");
assert.equal(send("REMAP_REGISTER", 3, "remap", { name: "M6", prolog: "m6_prolog" }).payload.callbacks.remaps.M6.name, "M6");
assert.equal(send("REMAP_CALL", 4, "call", { name: "M6", phase: "prolog" }).payload.status, "verified");
const blocked = send("FILTER_EXEC", 5, "filter", { name: "M6", source: "os.system('x')" });
assert.equal(blocked.kind, "ERROR");
assert.equal(blocked.payload.code, "real_hardware_excluded");
console.log(`python_callbacks=passed remap=${callbacks.state.calls.length} errors=${callbacks.state.errors.length} worker=${blocked.payload.code}`);

View File

@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import { PythonCallbacks } from "./python-callbacks.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const callbacks = new PythonCallbacks();
assert.equal(callbacks.registerRemap("M6", { prolog: "m6_prolog", epilog: "m6_epilog", modalgroup: 6 }, ({ phase }) => phase).status, "verified");
assert.equal(callbacks.invoke("remap", "M6", "prolog", [1]).value, "prolog");
assert.equal(callbacks.invoke("remap", "M6", "epilog", []).value, "epilog");
assert.equal(callbacks.invoke("remap", "M6", "body", []).code, "remap_phase_invalid");
assert.equal(callbacks.registerRemap("M6", { prolog: "other" }).code, "remap_duplicate");
assert.equal(callbacks.registerRemap("bad", { prolog: "bad" }, () => { throw new Error("boom"); }).status, "verified");
assert.equal(callbacks.invoke("remap", "bad", "prolog").code, "callback_exception");
assert.equal(callbacks.registerFilter("pure", ({ args }) => args[0]).status, "verified");
assert.equal(callbacks.filter("pure", "G1 X1", ["ok"]).value, "G1 X1");
assert.equal(callbacks.filter("pure", "import subprocess").code, "real_hardware_excluded");
assert.equal(callbacks.filter("pure", "G1 X1", [Number.NaN]).code, "filter_arguments_invalid");
assert.equal(callbacks.registerVcp("panel", [{ name: "enabled", type: "bit" }, { name: "count", type: "s32" }], () => { throw new Error("vcp-fail"); }).status, "verified");
assert.equal(callbacks.updateVcp("panel", "enabled", 1).code, "callback_exception");
assert.equal(callbacks.state.vcp.panel.pins[0].value, 0);
assert.equal(callbacks.updateVcp("panel", "enabled", 2).code, "vcp_value_invalid");
assert.equal(callbacks.updateVcp("panel", "missing", 1).code, "vcp_pin_missing");
const saved = callbacks.export();
const restored = new PythonCallbacks();
assert.deepEqual(restored.restore(saved), saved);
let sequence = 0;
const core = { _sim_protocol_reset() {}, _sim_protocol_accept() { return 0; }, _sim_create() { return 0; }, _sim_load_workspace() { return 0; } };
const session = new SimSession(core, { sessionId: "callbacks-s3" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: "callbacks-s3" };
const send = (kind, operation_id, payload = {}) => { sequence += 1; return session.accept({ ...base, kind, seq: sequence, operation_id, payload }); };
send("HELLO"); send("SESSION_ATTACH", "attach");
assert.equal(send("REMAP_REGISTER", "remap", { name: "M6", prolog: "m6_prolog", epilog: "m6_epilog" }).payload.callbacks.remaps.M6.prolog, "m6_prolog");
assert.equal(send("REMAP_CALL", "prolog", { name: "M6", phase: "prolog", args: [] }).payload.status, "verified");
assert.equal(send("VCP_REGISTER", "vcp", { name: "panel", pins: [{ name: "enabled", type: "bit" }] }).payload.status, "verified");
assert.equal(send("VCP_UPDATE", "vcp-update", { name: "panel", pin: "enabled", value: 1 }).payload.status, "verified");
const blocked = send("FILTER_EXEC", "filter", { name: "missing", source: "os.system('x')" });
assert.equal(blocked.kind, "ERROR");
const checkpoint = send("CHECKPOINT", "checkpoint").payload.checkpoint;
assert.equal(checkpoint.state.callbacks.schema, "python-callbacks.v1");
console.log(JSON.stringify({ schema: "python-callbacks-s3-sample.v1", status: "verified", calls: callbacks.state.calls.length, errors: callbacks.state.errors.length, callback_hash: callbacks.hash(), worker_hash: session.callbacks.hash(), checkpoint_hash: checkpoint.checkpoint_hash }));

View File

@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import { MemoryStorage, WorkspaceJournal } from "../workspace/journal.mjs";
import { createWorkspacePackage } from "../workspace/migration.mjs";
import { ReplayTimeline, fnv1a64 } from "./replay.mjs";
import { RecoveryRuntime } from "./recovery-runtime.mjs";
const contextId = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1";
const timeline = new ReplayTimeline();
timeline.appendEvent(1, 0, "SESSION_READY", {});
timeline.checkpoint(1, 0, { tick: 0, position: 0 });
const journalStorage = new MemoryStorage();
const journal = new WorkspaceJournal(journalStorage, { projectId: "recovery-s3" });
journal.prepare("commit-1", [{ path: "config/main.ini", data: "[EMC]\n" }]);
journal.commit("commit-1");
const runtime = new RecoveryRuntime({ contextId });
const saved = runtime.saveCheckpoint({ timeline, state: { tick: 0, position: 0 }, operations: [{ operation_id: "pending", status: "awaiting_ack", seq: 1, kind: "SIM_STEP" }] });
assert.equal(saved.status, "verified");
assert.equal(runtime.markCrash().status, "verified");
const events = [{ seq: 2, tick: 4, kind: "SIM_STEP", payload: { cycles: 4 }, payload_hash: fnv1a64({ cycles: 4 }) }];
const recovered = runtime.restart({ events, reconcile: (operation) => ({ ...operation, status: "aborted" }), apply: (state, event) => ({ tick: event.tick, position: state.position + event.payload.cycles }) });
assert.equal(recovered.status, "verified");
assert.equal(recovered.recovery.status, "aborted");
assert.equal(recovered.recovery.state.position, 4);
assert.equal(recovered.recovery.state_hash, fnv1a64(recovered.recovery.state));
const packageValue = createWorkspacePackage({ contextId, timeline: new ReplayTimeline({ schema: "replay-timeline.v1", events: { schema: "event-log.v1", cursor: 1, tick: 0, events: timeline.events.events.slice(0, 1) }, snapshots: { schema: "snapshot.v1", snapshots: [timeline.snapshots.latest()] } }), checkpoint: saved.checkpoint, journal: journal.records(), committedSeq: 1 });
assert.equal(runtime.createOffline(packageValue, [{ path: "ui/index.html", data: "<main>offline</main>" }, { path: "wasm/sim.wasm", data: "wasm" }]).status, "verified");
assert.equal(runtime.verifyOffline([{ path: "ui/index.html", data: "<main>offline</main>" }, { path: "wasm/sim.wasm", data: "wasm" }], { workspacePackage: packageValue }).result.status, "ready");
assert.equal(runtime.verifyOffline(["ui/index.html"], { workspacePackage: packageValue, online: true }).result.status, "network_required");
assert.equal(runtime.createOffline(packageValue, [{ path: "../escape", data: "bad" }]).code, "offline_resource_path");
const quotaJournal = new WorkspaceJournal(new MemoryStorage(), { projectId: "quota", maxBytes: 3 });
assert.throws(() => quotaJournal.prepare("too-large", [{ path: "a", data: "1234" }]), /workspace_journal_quota/);
const restored = new RecoveryRuntime({ contextId });
assert.deepEqual(restored.restore(runtime.export()), runtime.export());
console.log(JSON.stringify({ schema: "recovery-s3-sample.v1", status: "verified", crash_count: runtime.state.crash_count, restart_count: runtime.state.restart_count, recovery_hash: runtime.state.recovery_hash, package_hash: runtime.state.package_hash, state_hash: runtime.export().state_hash }));

View File

@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { WorkspaceRuntime } from "../workspace/runtime.mjs";
import { resolveResources } from "../workspace/resource-resolver.mjs";
const logo = await readFile(new URL("../../linuxcnc/linuxcnc.gif", import.meta.url));
const logoSha = createHash("sha256").update(logo).digest("hex");
const sourceGeneratorPath = new URL("../../linuxcnc/src/emc/usr_intf/stepconf/build_INI.py", import.meta.url);
const sourceGenerator = await readFile(sourceGeneratorPath, "utf8");
const sourceGeneratorStatement = 'print("INTRO_GRAPHIC = linuxcnc.gif", file=file)';
assert.match(sourceGenerator, new RegExp(sourceGeneratorStatement.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
const sourceGeneratorSha = createHash("sha256").update(sourceGenerator).digest("hex");
const fixtureData = "gif-fixture";
const files = { "config/main.ini": "[DISPLAY]\nINTRO_GRAPHIC = linuxcnc.gif\n", "linuxcnc/linuxcnc.gif": fixtureData };
const workspace = new WorkspaceRuntime({ files });
const resolved = resolveResources({ workspace, entrypoint: "config/main.ini" });
const repeated = resolveResources({ workspace, entrypoint: "config/main.ini" });
assert.equal(resolved.status, "ready");
assert.deepEqual(resolved.resources.map((row) => ({ type: row.type, path: row.path, key: row.key })), [{ type: "image", path: "linuxcnc/linuxcnc.gif", key: "INTRO_GRAPHIC" }]);
assert.equal(resolved.resources[0].bytes, new TextEncoder().encode(fixtureData).byteLength);
assert.match(resolved.state_hash, /^fnv1a64:/);
assert.equal(repeated.state_hash, resolved.state_hash);
const wrongKey = resolveResources({ workspace: new WorkspaceRuntime({ files: { "config/main.ini": "[EMCIO]\nTOOL_TABLE = linuxcnc.gif\n", "linuxcnc/linuxcnc.gif": fixtureData } }), entrypoint: "config/main.ini" });
assert.equal(wrongKey.status, "blocked");
assert.equal(wrongKey.resources[0].code, "resource_missing");
assert.equal(wrongKey.resources[0].type, "tool_table");
const wrongSection = resolveResources({ workspace: new WorkspaceRuntime({ files: { "config/main.ini": "[EMCIO]\nINTRO_GRAPHIC = linuxcnc.gif\n", "linuxcnc/linuxcnc.gif": fixtureData } }), entrypoint: "config/main.ini" });
assert.equal(wrongSection.status, "blocked");
assert.equal(wrongSection.resources[0].code, "resource_missing");
console.log(JSON.stringify({ schema: "resource-intro-graphic-s7-06-sample.v1", status: "verified", source_generator: { path: "linuxcnc/src/emc/usr_intf/stepconf/build_INI.py", statement: sourceGeneratorStatement, sha256: sourceGeneratorSha }, resource: { type: resolved.resources[0].type, path: resolved.resources[0].path, bytes: resolved.resources[0].bytes, asset_sha256: logoSha, data_hash: resolved.resources[0].data_hash }, resource_graph: { state_hash: resolved.state_hash, repeated_state_hash: repeated.state_hash, deterministic: resolved.state_hash === repeated.state_hash }, wrong_key: { status: wrongKey.status, code: wrongKey.resources[0].code, type: wrongKey.resources[0].type }, wrong_section: { status: wrongSection.status, code: wrongSection.resources[0].code, type: wrongSection.resources[0].type }, boundaries: ["only DISPLAY.INTRO_GRAPHIC linuxcnc.gif uses the canonical shared asset", "generic basename and tool/parameter/HAL/program substitution remains blocked"] }));

View File

@@ -0,0 +1,24 @@
import assert from "node:assert/strict";
import { WorkspaceRuntime } from "../workspace/runtime.mjs";
import { resolveResources } from "../workspace/resource-resolver.mjs";
const workspace = new WorkspaceRuntime({ files: {
"config/main.ini": "[HAL]\nHALFILE = machine.hal\n[EMC]\nTOOL_TABLE = tools.tbl\n[RS274NGC]\nPARAMETER_FILE = vars.var\nPROGRAM = main.ngc\n[PYTHON]\nPYTHONFILE = hooks.py\n[DISPLAY]\nVCP = panel.xml\n[CLASSICLADDER]\nCLPFILE = plc.clp\n",
"config/machine.hal": "loadrt sim\n", "config/tools.tbl": "T1 P1\n", "config/vars.var": "#1 = 2\n", "config/main.ngc": "G21\n", "config/hooks.py": "print('ok')\n", "config/panel.xml": "<vcp/>\n", "config/plc.clp": "classicladder\n",
} });
const graph = resolveResources({ workspace, entrypoint: "config/main.ini" });
assert.equal(graph.status, "ready");
assert.equal(graph.resolved_count, 7);
assert.equal(graph.blocked_count, 0);
assert.equal(new Set(graph.resources.map((row) => row.type)).size, 7);
assert.match(graph.state_hash, /^fnv1a64:/);
const missing = resolveResources({ workspace: new WorkspaceRuntime({ files: { "config/main.ini": "[HAL]\nHALFILE = missing.hal\n" } }), entrypoint: "config/main.ini" });
assert.equal(missing.status, "blocked");
assert.equal(missing.resources[0].code, "resource_missing");
const host = resolveResources({ workspace: new WorkspaceRuntime({ files: { "config/main.ini": "[PYTHON]\nPYTHONFILE = /tmp/x.py\n" } }), entrypoint: "config/main.ini" });
assert.equal(host.resources[0].code, "resource_host_path");
const duplicate = resolveResources({ workspace, entrypoint: "config/main.ini", explicit: [{ type: "hal", path: "machine.hal" }] });
assert.equal(duplicate.resources.some((row) => row.code === "resource_duplicate"), true);
const second = resolveResources({ workspace, entrypoint: "config/main.ini" });
assert.equal(second.state_hash, graph.state_hash);
console.log(`resource_resolver=passed resolved=${graph.resolved_count} blocked=${graph.blocked_count} state_hash=${graph.state_hash}`);

View File

@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const CASES = [
["linuxcnc/configs/sim/axis/ja_tests/xz/xz.ini", "linuxcnc/configs/sim/axis/ja_tests/xz/xz_cmds.hal"],
["linuxcnc/configs/sim/axis/ja_tests/xz/xz_both.ini", "linuxcnc/configs/sim/axis/ja_tests/xz/xz_both_cmds.hal"],
["linuxcnc/configs/sim/axis/ja_tests/xz/xz_both_auto.ini", "linuxcnc/configs/sim/axis/ja_tests/xz/xz_both_auto_cmds.hal"],
];
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 core = { _sim_create() { return 0; }, _sim_load_workspace() { return 0; }, _sim_protocol_reset() {}, _sim_protocol_accept() { return 0; } };
const results = [];
for (const [path, generatedHal] of CASES) {
const bundle = await loadSimulationCase({ path, fetchImpl });
assert.equal(bundle.status, "ready");
assert.deepEqual(bundle.missing, []);
assert.equal(bundle.runtime_fixtures[0].generated_hal, generatedHal);
assert.equal(bundle.runtime_fixtures[0].config_path, "linuxcnc/configs/sim/axis/ja_tests/xz/xzbase.inc");
const session = new SimSession(core, { sessionId: path });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: path };
session.accept({ ...base, kind: "HELLO", seq: 1, payload: {} });
session.accept({ ...base, kind: "SESSION_ATTACH", seq: 2, operation_id: "attach", payload: {} });
const response = session.accept({ ...base, kind: "LOAD_WORKSPACE", seq: 3, operation_id: "load", payload: { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) } });
assert.equal(response.kind, "COMMAND_RESULT");
assert.equal(response.payload.configuration.state, "TASK_READY");
assert.equal(response.payload.configuration.hal.blocked_count, 0);
results.push({ path, state: response.payload.configuration.state, loader_hash: bundle.state_hash, startup_hash: response.payload.configuration.state_hash, generated_hal: generatedHal });
}
console.log(JSON.stringify({ schema: "sim-case-batch-s8-03-sample.v1", status: "verified", cases: results, fixture_policy: "user-authorized-runtime-copy", boundary: "XZ identity/BOTH/AUTO cases share exact xz.var and xz.tbl inputs; unrelated cases remain outside this batch" }));

View File

@@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
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";
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 core = { _sim_create() { return 0; }, _sim_load_workspace() { return 0; } };
async function matrixRun() {
const rows = [];
for (const catalog of SIM_CASE_CATALOG) {
const bundle = await loadSimulationCase({ path: catalog.path, fetchImpl });
let startup = null;
if (bundle.status === "ready") {
startup = startConfiguration({ workspace: new WorkspaceRuntime({ files: bundle.files }), entrypoint: bundle.entrypoint, core, halManifest: bundle.halManifest });
}
const first = startup?.diagnostics?.[0] ?? null;
const classification = bundle.status !== "ready"
? "BLOCKED_RESOURCE"
: startup.status === "ready"
? "TASK_READY"
: first?.code === "startup_hal_blocked"
? "BLOCKED_HAL"
: first?.code === "startup_ini_blocked" || first?.code === "ini_assignment_invalid"
? "BLOCKED_INI"
: "BLOCKED_STARTUP";
rows.push({
path: catalog.path,
source_sha256: catalog.source_sha256,
static_status: catalog.status,
loader_status: bundle.status,
classification,
fetched_count: bundle.fetched.length,
missing: bundle.missing,
runtime_fixtures: bundle.runtime_fixtures,
loader_state_hash: bundle.state_hash,
startup_state: startup?.state ?? "BLOCKED",
startup_state_hash: startup?.state_hash ?? null,
resource_blocked_count: startup?.resources?.blocked_count ?? null,
hal_record_count: startup?.hal?.record_count ?? null,
hal_blocked_count: startup?.hal?.blocked_count ?? null,
first_diagnostic: first ? { code: first.code, source: first.source, line: first.line, path: first.path ?? null } : null,
});
}
rows.sort((left, right) => left.path.localeCompare(right.path));
const counts = Object.fromEntries([...new Set(rows.map((row) => row.classification))].sort().map((classification) => [classification, rows.filter((row) => row.classification === classification).length]));
return { schema: "sim-case-matrix-s8-06-sample.v1", status: "verified", case_count: rows.length, counts, cases: rows };
}
const first = await matrixRun();
const second = await matrixRun();
assert.equal(first.case_count, 235);
assert.deepEqual(first.counts, { BLOCKED_HAL: 4, BLOCKED_INI: 3, BLOCKED_RESOURCE: 216, BLOCKED_STARTUP: 2, TASK_READY: 10 });
assert.deepEqual(first, second);
for (const path of [
"linuxcnc/configs/sim/axis/minimal_xyz.ini",
"linuxcnc/configs/sim/axis/corexy/corexy_by_kins.ini",
"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_RESOURCE");
assert.equal(first.cases.find((row) => row.path.endsWith("histogram_demo.ini")).classification, "BLOCKED_INI");
console.log(JSON.stringify(first));

View File

@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
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 core = { _sim_create() { return 0; }, _sim_load_workspace() { return 0; }, _sim_protocol_reset() {}, _sim_protocol_accept() { return 0; } };
function startup(path, sequence) {
return loadSimulationCase({ path, fetchImpl }).then((bundle) => {
const session = new SimSession(core, { sessionId: path });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: path };
session.accept({ ...base, kind: "HELLO", seq: sequence, payload: {} });
session.accept({ ...base, kind: "SESSION_ATTACH", seq: sequence + 1, operation_id: "attach", payload: {} });
return { bundle, response: session.accept({ ...base, kind: "LOAD_WORKSPACE", seq: sequence + 2, operation_id: "load", payload: { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) } }) };
});
}
const minimal = await startup("linuxcnc/configs/sim/axis/minimal_xyz.ini", 1);
assert.equal(minimal.bundle.status, "ready");
assert.equal(minimal.bundle.missing.length, 0);
assert.equal(minimal.response.kind, "COMMAND_RESULT");
assert.equal(minimal.response.payload.configuration.state, "TASK_READY");
assert.equal(minimal.bundle.runtime_fixtures[0].generated_hal, "linuxcnc/configs/sim/axis/minimal_xyz_cmds.hal");
const corexy = await startup("linuxcnc/configs/sim/axis/corexy/corexy_by_kins.ini", 1);
assert.equal(corexy.bundle.status, "ready");
assert.equal(corexy.bundle.missing.length, 0);
assert.equal(corexy.response.kind, "COMMAND_RESULT");
assert.equal(corexy.response.payload.configuration.state, "TASK_READY");
console.log(JSON.stringify({ schema: "sim-case-startup-s8-02-sample.v1", status: "verified", fixture_policy: "user-authorized-runtime-copy", cases: {
minimal_xyz: { status: "ready", state: minimal.response.payload.configuration.state, runtime_fixture: minimal.bundle.runtime_fixtures[0].generated_hal, loader_hash: minimal.bundle.state_hash, startup_hash: minimal.response.payload.configuration.state_hash },
corexy_by_kins: { status: "ready", state: corexy.response.payload.configuration.state, fixtures: ["linuxcnc/configs/sim/axis/corexy/linuxcnc.var", "linuxcnc/configs/sim/axis/corexy/tool.tbl"], loader_hash: corexy.bundle.state_hash, startup_hash: corexy.response.payload.configuration.state_hash },
}, boundary: "fixtures are copied from executed LinuxCNC/runtime case inputs and are hash-locked; no unrelated basename substitution" }));

View File

@@ -0,0 +1,36 @@
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 wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
const core = await createModule({ wasmBinary });
const session = new SimSession(core, { sessionId: "ledger-session" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: "ledger-session" };
const send = (kind, seq, operation_id, payload = {}) => session.accept({ ...base, kind, seq, operation_id, payload });
assert.equal(send("HELLO", 1, undefined).kind, "CAPABILITIES");
assert.equal(send("SESSION_ATTACH", 2, "attach").kind, "SESSION_READY");
assert.equal(send("LOAD_WORKSPACE", 3, "load", { root_path: "/workspace" }).payload.status, "verified");
const first = send("SPINDLE_COMMAND", 4, "spindle", { spindle: 0, rpm: 600, enabled: true });
assert.equal(first.payload.status, "verified");
assert.equal(first.payload.task.schema, "sim_task.v1");
assert.equal(first.payload.ledger, undefined);
assert.equal(session.ledger.reconcile("spindle").status, "committed");
const replay = send("SPINDLE_COMMAND", 5, "spindle", { spindle: 0, rpm: 9999, enabled: true });
assert.equal(replay.replayed, true);
assert.equal(replay.payload.command, "SPINDLE_COMMAND");
assert.equal(session.devices.state.spindles[0].command_rpm, 600);
const conflict = send("IO_STEP", 6, "spindle", { ticks: 1 });
assert.equal(conflict.kind, "ERROR");
assert.equal(conflict.payload.code, "ledger_operation_conflict");
const abortTarget = send("SPINDLE_COMMAND", 7, "abort-target", { spindle: 0, rpm: 0, enabled: false });
assert.equal(abortTarget.payload.status, "verified");
const abort = send("ABORT", 8, "abort-control", { operation_id: "abort-target" });
assert.equal(abort.payload.ledger.status, "too_late");
const checkpoint = send("CHECKPOINT", 9, "checkpoint").payload.checkpoint;
const recovered = session.restoreCheckpoint(checkpoint, null, (operation) => operation);
assert.equal(recovered.status, "recovered");
assert.equal(session.ledger.reconcile("spindle").status, "committed");
assert.equal(session.ledger.reconcile("missing").status, "missing");
console.log(`sim_ledger=passed committed=${session.ledger.reconcile("spindle").status} side_effects=${session.ledger.require("spindle").side_effect_count}`);

View File

@@ -0,0 +1,54 @@
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: "runner-session" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: "runner-session" };
const send = (kind, seq, operation_id, payload = {}) => session.accept({ ...base, kind, seq, operation_id, payload });
send("HELLO", 1);
send("SESSION_ATTACH", 2, "attach");
send("LOAD_WORKSPACE", 3, "load", { root_path: "/workspace" });
const mdi = send("MDI_EXECUTE", 4, "mdi", { block: "G21 G90 G1 X3 Y4 F60" });
assert.equal(mdi.payload.state.x, 3);
assert.equal(mdi.payload.runner.canonical.includes('"x":3'), true);
assert.match(mdi.payload.runner.modal_hash, /^fnv1a64:/);
assert.equal(mdi.payload.runner.state.line, 1);
assert.equal(mdi.payload.runner.program_status, "idle");
const offset = send("MDI_EXECUTE", 5, "mdi-offset", { block: "G10 L2 P1 X10 Y20 Z30" });
assert.equal(offset.payload.runner.state.x, 3);
const offsetMove = send("MDI_EXECUTE", 6, "mdi-offset-move", { block: "G54 G90 X1 Y2 Z3" });
assert.equal(offsetMove.payload.state.x, 11);
assert.equal(offsetMove.payload.state.y, 22);
assert.equal(offsetMove.payload.state.z, 33);
const loaded = send("PROGRAM_LOAD", 7, "program-load", { source: "G21 G90\nG1 X2 F60\nM2\n", path: "runner.ngc" });
assert.equal(loaded.payload.canonical.includes('"units":21'), true);
send("PROGRAM_RUN", 8, "program-run");
const tick = send("SIM_STEP", 9, "tick", { cycles: 1 });
assert.equal(tick.payload.program.state.line, 1);
assert.equal(tick.payload.task.current_line, 1);
send("PROGRAM_PAUSE", 10, "pause");
send("PROGRAM_RESUME", 11, "resume");
const abort = send("PROGRAM_ABORT", 12, "abort");
assert.equal(abort.payload.task.program_status, "aborted");
const checkpoint = send("CHECKPOINT", 13, "checkpoint").payload.checkpoint;
const recovered = session.restoreCheckpoint(checkpoint, null, (operation) => operation);
assert.equal(recovered.status, "recovered");
assert.equal(recovered.state.task.program_status, "aborted");
const errorCore = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const errorSession = new SimSession(errorCore, { sessionId: "runner-error" });
const errorBase = { schema_version: 1, context_id: CONTEXT_ID, session_id: "runner-error" };
const errorSend = (kind, seq, operation_id, payload = {}) => errorSession.accept({ ...errorBase, kind, seq, operation_id, payload });
errorSend("HELLO", 1); errorSend("SESSION_ATTACH", 2, "a"); errorSend("LOAD_WORKSPACE", 3, "l", { root_path: "/workspace" });
errorSend("PROGRAM_LOAD", 4, "bad-load", { source: "G999\n" });
errorSend("PROGRAM_RUN", 5, "bad-run");
const error = errorSend("PROGRAM_STEP", 6, "bad-step");
assert.equal(error.kind, "ERROR");
assert.equal(error.payload.code, "program_interpreter");
assert.equal(error.payload.line, 1);
assert.equal(error.payload.column, 1);
assert.equal(error.payload.error_code, -504);
console.log(`sim_runner=passed mdi_x=${mdi.payload.state.x} tick_line=${tick.payload.task.current_line} recovery=${recovered.state.task.program_status}`);

View File

@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { SimScheduler, SCHEDULER_PHASES } from "./sim-scheduler.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
const core = await createModule({ wasmBinary });
const fast = new SimScheduler(core, { budget: 100 });
const stepped = new SimScheduler(core, { budget: 100 });
const seen = [];
fast.step(10, { reconcile: () => seen.push("reconcile"), task: () => seen.push("task"), tp: () => seen.push("tp"), kinematics: () => seen.push("kinematics"), motion: () => seen.push("motion"), hal_io: () => seen.push("hal_io"), callbacks: () => seen.push("callbacks"), publish: () => seen.push("publish") });
for (let index = 0; index < 10; index += 1) stepped.step(1);
assert.equal(fast.tick, 10);
assert.equal(fast.hash(), stepped.hash());
assert.deepEqual(seen.slice(0, 8), SCHEDULER_PHASES);
assert.throws(() => fast.step(101), /scheduler_budget_exceeded/);
assert.equal(fast.tick, 10);
const restored = new SimScheduler(core, { budget: 100 });
assert.equal(restored.restore(fast.export()).state_hash, fast.hash());
let lastSeq = 0;
const session = new SimSession({
_sim_protocol_reset() {},
_sim_protocol_accept(...args) { if (args[1] !== lastSeq + 1) return -101; lastSeq = args[1]; return 0; },
_sim_create() { return 0; },
_sim_load_workspace() { return 0; },
_sim_step() { return 0; },
_sim_protocol_set_tick() { return 0; },
}, { sessionId: "scheduler-session" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: "scheduler-session" };
session.accept({ ...base, kind: "HELLO", seq: 1, payload: {} });
session.accept({ ...base, kind: "SESSION_ATTACH", seq: 2, operation_id: "attach", payload: {} });
session.accept({ ...base, kind: "LOAD_WORKSPACE", seq: 3, operation_id: "load", payload: { root_path: "/workspace" } });
const steppedResponse = session.accept({ ...base, kind: "SIM_STEP", seq: 4, operation_id: "step", payload: { cycles: 4 } });
assert.equal(steppedResponse.payload.scheduler.tick, 4);
assert.deepEqual(steppedResponse.payload.scheduler.phase_trace, [...SCHEDULER_PHASES]);
assert.equal(steppedResponse.payload.task.simulation_tick, 4);
console.log(`sim_scheduler=passed tick=${fast.tick} state_hash=${fast.hash()} worker_hash=${steppedResponse.payload.scheduler.state_hash}`);

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 { SimSession, CONTEXT_ID } from "./sim-session.mjs";
import { fnv1a64 } from "./replay.mjs";
const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
function base(sessionId) {
return { schema_version: 1, context_id: CONTEXT_ID, session_id: sessionId };
}
async function createSession(sessionId) {
const core = await createModule({ wasmBinary });
const session = new SimSession(core, { sessionId });
const prefix = base(sessionId);
assert.equal(session.accept({ ...prefix, kind: "HELLO", seq: 1, payload: {} }).kind, "CAPABILITIES");
const ready = session.accept({ ...prefix, kind: "SESSION_ATTACH", seq: 2, operation_id: `${sessionId}-attach`, payload: {} });
assert.equal(ready.kind, "SESSION_READY");
assert.equal(ready.payload.task.schema, "sim_task.v1");
assert.equal(ready.payload.task.state, "READY");
assert.equal(ready.payload.task.seq, 2);
return { core, session, prefix };
}
const first = await createSession("task-a");
const { session, prefix } = first;
let response = session.accept({ ...prefix, kind: "LOAD_WORKSPACE", seq: 3, operation_id: "load", payload: { root_path: "/workspace" } });
assert.equal(response.payload.task.state, "READY");
response = session.accept({ ...prefix, kind: "PROGRAM_LOAD", seq: 4, operation_id: "program-load", payload: { path: "task.ngc", source: "G21 G90\nG1 X2 F60\nM2\n" } });
assert.equal(response.payload.task.program_status, "loaded");
response = session.accept({ ...prefix, kind: "PROGRAM_RUN", seq: 5, operation_id: "program-run", payload: {} });
assert.equal(response.payload.task.state, "RUNNING");
response = session.accept({ ...prefix, kind: "SIM_STEP", seq: 6, operation_id: "tick-1", payload: { cycles: 1 } });
assert.equal(response.payload.task.simulation_tick, 1);
const firstTickHash = response.payload.task.state_hash;
response = session.accept({ ...prefix, kind: "PROGRAM_PAUSE", seq: 7, operation_id: "pause", payload: {} });
assert.equal(response.payload.task.state, "PAUSED");
response = session.accept({ ...prefix, kind: "PROGRAM_RESUME", seq: 8, operation_id: "resume", payload: {} });
assert.equal(response.payload.task.state, "RUNNING");
response = session.accept({ ...prefix, kind: "PROGRAM_ABORT", seq: 9, operation_id: "abort", payload: {} });
assert.equal(response.payload.task.state, "STOPPED");
assert.equal(response.payload.task.program_status, "aborted");
const checkpointResponse = session.accept({ ...prefix, kind: "CHECKPOINT", seq: 10, operation_id: "checkpoint", payload: {} });
assert.equal(checkpointResponse.kind, "CHECKPOINT_READY");
assert.equal(checkpointResponse.payload.checkpoint.state.task.schema, "sim_task.v1");
const checkpoint = checkpointResponse.payload.checkpoint;
const recovered = session.restoreCheckpoint(checkpoint, null, (operation) => operation);
assert.equal(recovered.status, "recovered");
assert.equal(recovered.state.task.state, "STOPPED");
assert.equal(recovered.state.task.state_hash, checkpoint.state.task.state_hash);
const faultSession = await createSession("task-fault");
const faultBase = faultSession.prefix;
faultSession.session.accept({ ...faultBase, kind: "LOAD_WORKSPACE", seq: 3, operation_id: "fault-load", payload: { root_path: "/workspace" } });
faultSession.session.accept({ ...faultBase, kind: "PROGRAM_LOAD", seq: 4, operation_id: "fault-program", payload: { source: "G21\nG1 X1\n" } });
faultSession.session.accept({ ...faultBase, kind: "PROGRAM_RUN", seq: 5, operation_id: "fault-run", payload: {} });
const estop = faultSession.session.accept({ ...faultBase, kind: "ESTOP", seq: 6, operation_id: "estop", payload: { active: true } });
assert.equal(estop.payload.task.state, "ESTOP");
faultSession.session.accept({ ...faultBase, kind: "ESTOP", seq: 7, operation_id: "estop-clear", payload: { active: false } });
const recoveredIo = faultSession.session.accept({ ...faultBase, kind: "IO_RECOVER", seq: 8, operation_id: "io-recover", payload: {} });
assert.equal(recoveredIo.payload.task.state, "READY");
const second = await createSession("task-a");
second.session.accept({ ...second.prefix, kind: "LOAD_WORKSPACE", seq: 3, operation_id: "load", payload: { root_path: "/workspace" } });
second.session.accept({ ...second.prefix, kind: "PROGRAM_LOAD", seq: 4, operation_id: "program-load", payload: { path: "task.ngc", source: "G21 G90\nG1 X2 F60\nM2\n" } });
second.session.accept({ ...second.prefix, kind: "PROGRAM_RUN", seq: 5, operation_id: "program-run", payload: {} });
const secondTick = second.session.accept({ ...second.prefix, kind: "SIM_STEP", seq: 6, operation_id: "tick-1", payload: { cycles: 1 } });
assert.equal(secondTick.payload.task.state_hash, firstTickHash);
console.log(`sim_task=passed state=${recovered.state.task.state} tick_hash=${firstTickHash} recovery_hash=${recovered.state.task.state_hash}`);

View File

@@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { ConfigurationBatchRuntime } from "../workspace/configuration-batch.mjs";
import { fnv1a64 } from "./replay.mjs";
const ROOT = new URL("../../", import.meta.url);
const CONTEXT_ID = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1";
const matrix = JSON.parse(await readFile(new URL("../../coverage/full-configuration-startup-matrix.report.json", import.meta.url)));
const batch = new ConfigurationBatchRuntime({ context_id: CONTEXT_ID });
assert.equal(batch.load(matrix.matrix, { context_id: CONTEXT_ID }).status, "verified");
assert.deepEqual(batch.state.counts, {
total: 264,
ready: 56,
blocked: 208,
startup_preflight: { blocked_resource: 194, blocked_unindexed_resource_graph: 11, blocked_unregistered_kinematics: 3, static_ready: 56 },
resource_status: { blocked_missing: 147, blocked_missing_and_unresolved: 49, ready: 9, unindexed: 59 },
});
const diagnosticById = new Map(batch.state.diagnostics.map((row) => [row.config_id, row]));
const entrypointResults = [];
const hashes = [];
for (const row of matrix.matrix) {
const value = await readFile(new URL(`../../${row.path}`, import.meta.url));
const sourceHash = createHash("sha256").update(value).digest("hex");
const entry = batch.state.entrypoints.find((item) => item.config_id === row.config_id);
assert.ok(entry, row.config_id);
assert.equal(entry.status, row.status);
const diagnostic = row.status === "blocked" ? diagnosticById.get(row.config_id) : null;
entrypointResults.push({
config_id: row.config_id,
path: row.path,
source_sha256: sourceHash,
status: row.status === "ready" ? "READY" : "BLOCKED",
startup_preflight: row.startup_preflight,
resource_status: row.resource_status,
diagnostic_code: diagnostic?.code ?? null,
missing_count: row.missing_resources.length,
unresolved_count: row.unresolved_resources.length,
});
hashes.push(`${row.config_id}:${sourceHash}:${row.status}`);
}
entrypointResults.sort((left, right) => left.config_id.localeCompare(right.config_id));
hashes.sort();
const saved = batch.export();
const restored = new ConfigurationBatchRuntime({ context_id: CONTEXT_ID });
assert.deepEqual(restored.restore(saved), saved);
const simConfigCount = entrypointResults.filter((row) => row.path.startsWith("linuxcnc/configs/sim/")).length;
const testConfigCount = entrypointResults.filter((row) => row.path.startsWith("linuxcnc/tests/")).length;
assert.equal(simConfigCount, 216);
assert.equal(testConfigCount, 48);
const output = {
schema: "linuxcnc-source-config-startup-adapter.v1",
context_id: CONTEXT_ID,
matrix_report_sha256: createHash("sha256").update(await readFile(new URL("../../coverage/full-configuration-startup-matrix.report.json", import.meta.url))).digest("hex"),
registered_entries: matrix.counts.registered_entries,
reported_entrypoints: matrix.counts.entrypoints,
entrypoint_results: entrypointResults,
counts: { total: entrypointResults.length, sim_configs: simConfigCount, test_configs: testConfigCount, READY: batch.state.counts.ready, BLOCKED: batch.state.counts.blocked },
workflow: { batch_schema: saved.schema, batch_state_hash: saved.state_hash, restore_equal: JSON.stringify(restored.export()) === JSON.stringify(saved), diagnostic_count: batch.state.diagnostics.length, entrypoint_hash: fnv1a64(hashes) },
boundaries: [
"READY is static startup preflight, not an official LinuxCNC runtime oracle",
"BLOCKED rows retain resource/kinematics diagnostics and are never promoted",
"full native/WASM/Worker startup probes remain represented by configuration-startup-runtime evidence",
],
};
output.aggregate_hash = fnv1a64({ counts: output.counts, workflow: output.workflow, entrypoint_hash: output.workflow.entrypoint_hash });
console.log(JSON.stringify(output));

View File

@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { ProgramRunner } from "./program-runner.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
import { compareEndpoints, createEndpoint } from "./endpoint-replay.mjs";
const sourcePath = "linuxcnc/tests/interp/do-while-break/test.ngc";
const source = await readFile(new URL(`../../${sourcePath}`, import.meta.url), "utf8");
const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
async function complete(core, pauseAt = null) {
const runner = new ProgramRunner(core);
assert.equal(runner.load(source, sourcePath).status, "verified");
assert.equal(runner.start().status, "verified");
let steps = 0;
while (runner.status === "running" && steps < 1000) {
runner.step();
steps += 1;
if (pauseAt !== null && steps === pauseAt) {
assert.equal(runner.pause().status, "verified");
const paused = runner.snapshot();
assert.equal(runner.resume().status, "verified");
assert.equal(paused.program_status, "paused");
}
}
assert.equal(runner.status, "completed");
assert.ok(steps < 1000);
assert.equal(runner.namedParameters.get("one_pass"), 1);
assert.equal(runner.namedParameters.get("two_pass"), 1);
assert.equal(runner.namedParameters.get("three_pass"), 1);
const snapshot = runner.snapshot();
runner.destroy();
return { snapshot, steps };
}
const core = await createModule({ wasmBinary });
const normal = await complete(core);
const paused = await complete(core, 5);
assert.equal(normal.snapshot.interpreter_state_hash, paused.snapshot.interpreter_state_hash);
const malformed = new ProgramRunner(core);
assert.equal(malformed.load("O100 ENDDO\nM2\n", "malformed.ngc").code, "program_enddo_without_do");
malformed.destroy();
const tooDeep = new ProgramRunner(core);
const nested = `${Array.from({ length: 33 }, (_, index) => `O${index + 1} DO`).join("\n")}\nM2\n${Array.from({ length: 33 }, (_, index) => `O${33 - index} WHILE [1]`).join("\n")}`;
assert.equal(tooDeep.load(nested, "depth.ngc").code, "program_control_depth");
tooDeep.destroy();
const sessionCore = await createModule({ wasmBinary });
const session = new SimSession(sessionCore, { sessionId: "s7-do-recovery" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: "s7-do-recovery" };
let seq = 0;
const send = (kind, operation_id, payload = {}) => {
seq += 1;
const response = session.accept({ ...base, kind, seq, operation_id, payload });
assert.notEqual(response.kind, "ERROR", `${kind}:${response.payload?.code ?? "error"}`);
return response;
};
send("HELLO", "hello"); send("SESSION_ATTACH", "attach");
send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace" });
send("PROGRAM_LOAD", "program", { source, path: sourcePath }); send("PROGRAM_RUN", "run");
send("SIM_STEP", "step-a", { cycles: 5 });
const checkpoint = session.createCheckpoint();
send("SIM_STEP", "step-b", { cycles: 10 });
const continuous = createEndpoint({ session });
assert.equal(session.restoreCheckpoint(checkpoint, session.timeline.export(), (operation) => operation).status, "recovered");
const recovered = createEndpoint({ session });
const parity = compareEndpoints(continuous, recovered);
assert.equal(parity.equal, true, JSON.stringify(parity));
console.log(JSON.stringify({
schema: "linuxcnc-source-do-s7-01-sample.v1",
status: "verified",
context_id: CONTEXT_ID,
source: { path: sourcePath, source_hash: normal.snapshot.source_hash },
normal: { status: normal.snapshot.program_status, steps: normal.steps, named_parameters: { one_pass: 1, two_pass: 1, three_pass: 1 }, interpreter_state_hash: normal.snapshot.interpreter_state_hash },
pause_resume: { status: paused.snapshot.program_status, steps: paused.steps, hash_equal: normal.snapshot.interpreter_state_hash === paused.snapshot.interpreter_state_hash },
errors: { malformed_enddo: "program_enddo_without_do", depth: "program_control_depth" },
recovery: { equal: parity.equal, difference: parity.difference, checkpoint_hash: checkpoint.checkpoint_hash },
boundaries: ["LinuxCNC canonical expected output remains SOURCE_BLOCKED", "G64/G61 remains S7 follow-up", "host/Python/real hardware paths unchanged"],
}));

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 { ProgramRunner } from "./program-runner.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
import { compareEndpoints, createEndpoint } from "./endpoint-replay.mjs";
const sourcePath = "linuxcnc/tests/interp/g6164/test.ngc";
const source = await readFile(new URL(`../../${sourcePath}`, import.meta.url), "utf8");
const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
const core = await createModule({ wasmBinary });
const runner = new ProgramRunner(core);
assert.equal(runner.load(source, sourcePath).status, "verified");
assert.equal(runner.start().status, "verified");
const controls = [];
let guard = 0;
while (runner.status === "running" && guard < 100) {
const result = runner.step();
if (result.motion_control) controls.push({ line: result.line, ...result.motion_control });
guard += 1;
}
assert.equal(runner.status, "completed");
assert.deepEqual(controls.map(({ mode, path_tolerance, naive_cam_tolerance }) => ({ mode, path_tolerance, naive_cam_tolerance })), [
{ mode: "continuous", path_tolerance: 0, naive_cam_tolerance: 0 },
{ mode: "continuous", path_tolerance: 1, naive_cam_tolerance: 0 },
{ mode: "continuous", path_tolerance: 1, naive_cam_tolerance: 2 },
{ mode: "exact_path", path_tolerance: 0, naive_cam_tolerance: 0 },
{ mode: "exact_stop", path_tolerance: 0, naive_cam_tolerance: 0 },
]);
const finalHash = runner.snapshot().motion_control_hash;
const sourceHash = runner.snapshot().source_hash;
runner.destroy();
const malformed = new ProgramRunner(core);
assert.equal(malformed.load("G64P-1\nM2\n", "bad-g64.ngc").status, "verified");
malformed.start();
assert.equal(malformed.step().detail, "program_motion_control_range");
malformed.destroy();
const unsupported = new ProgramRunner(core);
assert.equal(unsupported.load("G61.2\nM2\n", "bad-g61.ngc").status, "verified");
unsupported.start();
assert.equal(unsupported.step().detail, "program_motion_control_syntax");
unsupported.destroy();
const session = new SimSession(await createModule({ wasmBinary }), { sessionId: "s7-g6164-recovery" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: "s7-g6164-recovery" };
let seq = 0;
const send = (kind, operation_id, payload = {}) => {
seq += 1;
const response = session.accept({ ...base, kind, seq, operation_id, payload });
assert.notEqual(response.kind, "ERROR", `${kind}:${response.payload?.code ?? "error"}`);
return response;
};
send("HELLO", "hello"); send("SESSION_ATTACH", "attach"); send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace" });
send("PROGRAM_LOAD", "program", { source, path: sourcePath }); send("PROGRAM_RUN", "run"); send("SIM_STEP", "step-a", { cycles: 3 });
const checkpoint = session.createCheckpoint(); send("SIM_STEP", "step-b", { cycles: 4 });
const continuous = createEndpoint({ session });
assert.equal(session.restoreCheckpoint(checkpoint, session.timeline.export(), (operation) => operation).status, "recovered");
const recovered = createEndpoint({ session });
const parity = compareEndpoints(continuous, recovered);
assert.equal(parity.equal, true, JSON.stringify(parity));
console.log(JSON.stringify({
schema: "linuxcnc-source-g6164-s7-02-sample.v1",
status: "verified",
context_id: CONTEXT_ID,
source: { path: sourcePath, source_hash: sourceHash },
controls,
final_motion_control_hash: finalHash,
errors: { negative_tolerance: "program_motion_control_range", unsupported_g61: "program_motion_control_syntax" },
recovery: { equal: parity.equal, difference: parity.difference, checkpoint_hash: checkpoint.checkpoint_hash },
boundaries: ["LinuxCNC canonical expected output remains SOURCE_BLOCKED", "TP blend/exact-stop numeric parity remains follow-up", "Safari/Firefox are out of scope"],
}));

View File

@@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { HalRuntime, parseCompSource } from "./hal-runtime.mjs";
import { fnv1a64 } from "./replay.mjs";
const ROOT = new URL("../../", import.meta.url);
const CONTEXT_ID = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1";
async function read(path) { return readFile(new URL(path, ROOT)); }
async function fixture(paths) {
const files = [];
for (const path of paths) {
const value = await read(path);
files.push({ path, bytes: value.byteLength, sha256: createHash("sha256").update(value).digest("hex") });
}
return files;
}
function pendingCommands(source) {
return source.split(/\r?\n/).map((line) => line.trim()).filter((line) => /^(?:loadusr|start|loadrt\s+(?:sampler|streamer)\b)/i.test(line));
}
function resultHash(value) { return fnv1a64(value); }
async function runAbs(path) {
const source = (await read(path)).toString("utf8");
const hal = new HalRuntime();
assert.equal(hal.configure({ threads: [{ name: "fast", period_ticks: 1 }], components: [{ instance: "abs.0", type: "abs", thread: "fast" }] }).status, "verified");
assert.equal(hal.setPin("abs.0.in", -0.25).status, "verified");
assert.equal(hal.step(1).status, "verified");
const result = { tick: hal.state.tick, out: hal.getPin("abs.0.out").value, sign: hal.getPin("abs.0.sign").value, positive: hal.getPin("abs.0.is-positive").value, negative: hal.getPin("abs.0.is-negative").value, state_hash: hal.hash(), pending_commands: pendingCommands(source) };
assert.equal(result.out, 0.25);
assert.equal(result.sign, 1);
assert.equal(result.negative, 1);
return result;
}
async function runEdge(path, startingHigh = false) {
const source = (await read(path)).toString("utf8");
const hal = new HalRuntime();
assert.equal(hal.configure({ threads: [{ name: "fast", period_ticks: 1 }], components: [{ instance: "edge.0", type: "edge", thread: "fast" }] }).status, "verified");
assert.equal(hal.setParam("edge.0.in-edge", 0).status, "verified");
assert.equal(hal.setPin("edge.0.in", startingHigh ? 1 : 0).status, "verified");
assert.equal(hal.step(1).status, "verified");
const first = hal.getPin("edge.0.out").value;
assert.equal(hal.setPin("edge.0.in", 1).status, "verified");
assert.equal(hal.step(2).status, "verified");
const result = { tick: hal.state.tick, first, rising_output: hal.getPin("edge.0.out").value, last_input: hal.getParam("edge.0.last-in").value, state_hash: hal.hash(), pending_commands: pendingCommands(source) };
assert.equal(result.rising_output, startingHigh ? 0 : 1);
return result;
}
async function parseComponent(path, expected, requireSupported = true) {
const source = (await read(path)).toString("utf8");
const parsed = parseCompSource(source);
if (requireSupported) assert.equal(parsed.status, "verified");
assert.equal(parsed.component, expected);
return { status: parsed.status, component: parsed.component, pins: parsed.pins.length, params: parsed.params.length, functions: parsed.functions.length, options: parsed.options, state_hash: resultHash(parsed) };
}
async function main() {
const cases = [];
const add = async (id, adapter, paths, result, status = "PASSED", reason = null) => cases.push({ id, adapter, status, ...(reason ? { reason } : {}), fixture: await fixture(paths), result, result_hash: resultHash(result) });
const absPath = "linuxcnc/tests/abs.0/test.hal";
await add("source-test:linuxcnc/tests/abs.0/test.hal", "WORKER_SIM_ADAPTER", [absPath, "linuxcnc/tests/abs.0/expected"], await runAbs(absPath));
const edgePath = "linuxcnc/tests/edge/rising/test.hal";
await add("source-test:linuxcnc/tests/edge/rising/test.hal", "WORKER_SIM_ADAPTER", [edgePath, "linuxcnc/tests/edge/rising/expected"], await runEdge(edgePath));
const edgeHighPath = "linuxcnc/tests/edge/rising-starting-high/test.hal";
await add("source-test:linuxcnc/tests/edge/rising-starting-high/test.hal", "WORKER_SIM_ADAPTER", [edgeHighPath, "linuxcnc/tests/edge/rising-starting-high/expected"], await runEdge(edgeHighPath, true));
const namesMatch = "linuxcnc/tests/halcompile/names/names_match.comp";
const namesDontMatch = "linuxcnc/tests/halcompile/names/names_dont_match.comp";
const namesResult = { matched: await parseComponent(namesMatch, "names_match", false), mismatch_parser: (await parseComponent(namesDontMatch, "wrong_name", false)).status, mismatch_expected: "compiler_component_name_mismatch" };
await add("source-test:linuxcnc/tests/halcompile/names/test.sh", "SOURCE_BLOCKED", [namesMatch, namesDontMatch, "linuxcnc/tests/halcompile/names/expected", "linuxcnc/tests/halcompile/names/test.sh"], namesResult, "SOURCE_BLOCKED", "custom .comp declarations require native halcompile and are not static Worker components");
const relative = "linuxcnc/tests/halcompile/relative-header/relative_header.comp";
const relativeParsed = parseCompSource((await read(relative)).toString("utf8"));
await add("source-test:linuxcnc/tests/halcompile/relative-header/test.sh", "SOURCE_BLOCKED", [relative, "linuxcnc/tests/halcompile/relative-header/local.h", "linuxcnc/tests/halcompile/relative-header/checkresult", "linuxcnc/tests/halcompile/relative-header/test.sh"], { parser_status: relativeParsed.status, parser_component: relativeParsed.component, reason: "native halcompile and relative header compiler unavailable" }, "SOURCE_BLOCKED", "native halcompile and relative header compiler unavailable");
const userspace = "linuxcnc/tests/halcompile/userspace-count-names/userspace_count_names.comp";
const userspaceParsed = parseCompSource((await read(userspace)).toString("utf8"));
await add("source-test:linuxcnc/tests/halcompile/userspace-count-names/test.sh", "HOST_EXCLUDED", [userspace, "linuxcnc/tests/halcompile/userspace-count-names/checkresult", "linuxcnc/tests/halcompile/userspace-count-names/test.sh"], { parser_status: userspaceParsed.status, parser_component: userspaceParsed.component, options: userspaceParsed.options, reason: "userspace component requires host process and signal APIs" }, "HOST_EXCLUDED", "userspace component requires host process and signal APIs");
for (const [directory, component] of [["symbols.0", "test_define"], ["symbols.1", "test_define1"]]) {
const path = `linuxcnc/tests/${directory}/test_${directory.slice(-1) === "0" ? "define" : "define1"}.comp`;
const parsed = parseCompSource((await read(path)).toString("utf8"));
await add(`source-test:linuxcnc/tests/${directory}/test.sh`, "HOST_EXCLUDED", [path, `linuxcnc/tests/${directory}/checkresult`, `linuxcnc/tests/${directory}/test.sh`], { parser_status: parsed.status, parser_component: parsed.component, reason: "source test invokes host halcompile/rtapi component compiler" }, "HOST_EXCLUDED", "source test invokes host halcompile/rtapi component compiler");
}
const counts = Object.fromEntries(["PASSED", "SOURCE_BLOCKED", "HOST_EXCLUDED"].map((status) => [status, cases.filter((item) => item.status === status).length]));
const output = {
schema: "linuxcnc-source-hal-adapter.v1",
context_id: CONTEXT_ID,
cases,
counts,
fixture_file_count: new Set(cases.flatMap((item) => item.fixture.map((file) => file.path))).size,
boundaries: [
"sampler/streamer/loadusr and realtime compiler paths remain explicit host/resource boundaries",
"userspace and dynamic .comp execution is HOST_EXCLUDED; relative-header compiler is SOURCE_BLOCKED",
"Worker static HAL behavior is compared with native/WASM SimHAL evidence by the report generator",
],
};
output.aggregate_hash = resultHash({ cases: output.cases, counts: output.counts });
console.log(JSON.stringify(output));
}
await main();

View File

@@ -0,0 +1,138 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { fnv1a64 } from "./replay.mjs";
import { ProgramRunner } from "./program-runner.mjs";
import { PythonCallbacks } from "./python-callbacks.mjs";
const ROOT = new URL("../../", import.meta.url);
const CONTEXT_ID = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1";
async function bytes(path) {
return readFile(new URL(path, ROOT));
}
async function fixture(paths) {
const files = [];
for (const path of paths) {
const value = await bytes(path);
files.push({ path, bytes: value.byteLength, sha256: createHash("sha256").update(value).digest("hex") });
}
return files;
}
function hash(value) {
return fnv1a64(value);
}
async function runProgram(core, path, expectedStatus, expectedDetail = null) {
const source = (await bytes(path)).toString("utf8");
const runner = new ProgramRunner(core);
const loaded = runner.load(source, path);
let observed = loaded;
if (loaded.status !== "error") {
const started = runner.start();
assert.equal(started.status, "verified");
let guard = 0;
while (runner.status === "running" && guard < 2000) {
observed = runner.step();
guard += 1;
}
assert.ok(guard < 2000, `${path} exceeded deterministic control budget`);
const snapshot = runner.snapshot();
observed = observed ?? snapshot;
}
const result = {
expected_status: expectedStatus,
observed_status: runner.status === "error" ? "error" : runner.status,
observed_detail: runner.lastError?.code ?? observed?.detail ?? observed?.code ?? null,
line: runner.currentLine,
canonical: runner.snapshot().canonical,
interpreter_state_hash: runner.snapshot().interpreter_state_hash,
source_hash: runner.snapshot().source_hash,
};
assert.equal(result.observed_status, expectedStatus, path);
if (expectedDetail !== null) assert.equal(result.observed_detail, expectedDetail, path);
runner.destroy();
return result;
}
async function main() {
const core = await createModule({ wasmBinary: await bytes("build-wasm/linuxcnc-sim-core.wasm") });
const cases = [];
const add = (id, adapter, paths, result) => cases.push({ id, adapter, fixture: paths, result, result_hash: hash(result) });
const owordBug = "linuxcnc/tests/interp/oword-bug315/test.ngc";
const owordBugResult = await runProgram(core, owordBug, "completed");
add("source-test:linuxcnc/tests/interp/oword-bug315/test.sh", "WORKER_SIM_ADAPTER", [owordBug, "linuxcnc/tests/interp/oword-bug315/expected", "linuxcnc/tests/interp/oword-bug315/test.sh"], owordBugResult);
const owordSub = "linuxcnc/tests/oword/sub.0/test.ngc";
const owordSubResult = await runProgram(core, owordSub, "completed");
add("source-test:linuxcnc/tests/oword/sub.0/test.sh", "WORKER_SIM_ADAPTER", [owordSub, "linuxcnc/tests/oword/sub.0/expected", "linuxcnc/tests/oword/sub.0/test.sh"], owordSubResult);
const unwind = "linuxcnc/tests/interp/oword-unwind/test.ngc";
const unwindResult = await runProgram(core, unwind, "error", "program_subroutine_missing");
add("source-test:linuxcnc/tests/interp/oword-unwind/test.sh", "PURE_FUNCTIONAL", [unwind, "linuxcnc/tests/interp/oword-unwind/expected", "linuxcnc/tests/interp/oword-unwind/test.sh"], unwindResult);
const doWhile = "linuxcnc/tests/interp/do-while-break/test.ngc";
const doWhileResult = await runProgram(core, doWhile, "completed");
add("source-test:linuxcnc/tests/interp/do-while-break/test.sh", "WORKER_SIM_ADAPTER", [doWhile, "linuxcnc/tests/interp/do-while-break/expected", "linuxcnc/tests/interp/do-while-break/test.sh"], doWhileResult);
const g6164 = "linuxcnc/tests/interp/g6164/test.ngc";
const g6164Result = await runProgram(core, g6164, "completed");
add("source-test:linuxcnc/tests/interp/g6164/test.sh", "WORKER_SIM_ADAPTER", [g6164, "linuxcnc/tests/interp/g6164/expected", "linuxcnc/tests/interp/g6164/test.sh"], g6164Result);
const callbacks = new PythonCallbacks();
const squarePy = "linuxcnc/tests/remap/oword-pycall/oword.py";
assert.equal(callbacks.registerRemap("square", { prolog: "square" }, { prolog: ({ args }) => args[0] * args[0] }).status, "verified");
assert.equal(callbacks.registerRemap("multiply", { prolog: "multiply" }, { prolog: ({ args }) => args.reduce((left, right) => left * right, 1) }).status, "verified");
const square = callbacks.invoke("remap", "square", "prolog", [25]);
const multiply = callbacks.invoke("remap", "multiply", "prolog", [5, 6, 7]);
assert.equal(square.value, 625);
assert.equal(multiply.value, 210);
const remapResult = { square: square.value, multiply: multiply.value, callbacks: callbacks.state.calls.length, state_hash: callbacks.hash() };
add("source-test:linuxcnc/tests/remap/oword-pycall/test.sh", "WORKER_SIM_ADAPTER", [squarePy, "linuxcnc/tests/remap/oword-pycall/subs.py", "linuxcnc/tests/remap/oword-pycall/test.ini", "linuxcnc/tests/remap/oword-pycall/expected"], remapResult);
const failPy = "linuxcnc/tests/remap/fail/body-py/remap.py";
const failureCallbacks = new PythonCallbacks();
assert.equal(failureCallbacks.registerRemap("M400", { prolog: "interp_error" }, { prolog: () => { throw new Error("INTERP_ERROR"); } }).status, "verified");
const callbackFailure = failureCallbacks.invoke("remap", "M400", "prolog", []);
assert.equal(callbackFailure.code, "callback_exception");
const failureResult = { status: callbackFailure.status, code: callbackFailure.code, state_hash: failureCallbacks.hash() };
add("source-test:linuxcnc/tests/remap/fail/body-py/test.sh", "WORKER_SIM_ADAPTER", [failPy, "linuxcnc/tests/remap/fail/body-py/subs.py", "linuxcnc/tests/remap/fail/body-py/test.ini", "linuxcnc/tests/remap/fail/body-py/expected"], failureResult);
const filterPy = "linuxcnc/tests/remap/remap-io/remap.py";
const filterConfig = "linuxcnc/tests/remap/remap-io/test-py.ini";
const filters = new PythonCallbacks();
assert.equal(filters.registerFilter("remap-io", ({ args }) => args[0]).status, "verified");
const filterSource = (await bytes(squarePy)).toString("utf8");
const filterResult = filters.filter("remap-io", filterSource);
const blockedFilter = filters.filter("remap-io", "import subprocess");
assert.equal(filterResult.status, "verified");
assert.equal(blockedFilter.code, "real_hardware_excluded");
add("source-test:linuxcnc/tests/remap/remap-io/test-py.ini", "WORKER_SIM_ADAPTER", [filterPy, filterConfig, "linuxcnc/tests/remap/remap-io/expected"], { status: filterResult.status, blocked: blockedFilter.code, state_hash: filters.hash() });
const fixtureRecords = [];
for (const item of cases) item.fixture = await fixture(item.fixture);
for (const item of cases) fixtureRecords.push(...item.fixture);
const uniqueFixtureCount = new Set(fixtureRecords.map((item) => item.path)).size;
const counts = { PASSED: cases.filter((item) => item.adapter !== "WORKER_TASK_MOTION_PENDING").length, WORKER_TASK_MOTION_PENDING: cases.filter((item) => item.adapter === "WORKER_TASK_MOTION_PENDING").length };
const output = {
schema: "linuxcnc-source-interpreter-adapter.v1",
context_id: CONTEXT_ID,
cases,
counts,
fixture_file_count: uniqueFixtureCount,
pending: cases.filter((item) => item.adapter === "WORKER_TASK_MOTION_PENDING").map((item) => ({ source_id: item.id, reason: item.result.reason ?? item.result.expected_adapter })),
boundaries: [
"G64/G61 modal motion is represented by the Worker motion_control state; upstream canonical logger parity remains SOURCE_BLOCKED",
"remap Python handlers are represented by allowlisted PythonCallbacks; host imports remain REAL-EXCLUDED",
"expected/checkresult bytes are hashed and never treated as passed without an adapter result",
],
};
output.aggregate_hash = hash({ cases: output.cases, counts: output.counts });
console.log(JSON.stringify(output));
}
await main();

View File

@@ -0,0 +1,131 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { DeviceIoRuntime } from "./device-io.mjs";
import { IoAckTracker } from "./io-ack.mjs";
import { MotionState } from "./motion-state.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
import { fnv1a64 } from "./replay.mjs";
const ROOT = new URL("../../", import.meta.url);
const SESSION_ID = "source-io-s5";
async function read(path) { return readFile(new URL(path, ROOT)); }
async function fixture(paths) {
const files = [];
for (const path of paths) {
const value = await read(path);
files.push({ path, bytes: value.byteLength, sha256: createHash("sha256").update(value).digest("hex") });
}
return files;
}
function resultHash(value) { return fnv1a64(value); }
async function hardLimitResult() {
const motion = new MotionState();
assert.equal(motion.home().status, "verified");
assert.equal(motion.startJog({ axis: "x", direction: 1, speed: 10 }).status, "verified");
motion.step(1);
assert.equal(motion.state.axes.x, 0.01);
assert.equal(motion.setLimit("x", true).state.fault, "limit");
const result = { tick: motion.state.tick, axis: motion.state.axes.x, limit: motion.state.limits.x, fault: motion.state.fault, state_hash: motion.hash() };
assert.equal(result.limit, true);
return result;
}
async function limit3Result() {
const motion = new MotionState();
assert.equal(motion.home().status, "verified");
assert.equal(motion.startJog({ axis: "x", direction: 1, speed: 500 }).status, "verified");
motion.step(1);
assert.equal(motion.setFollowingError(0.02).state.fault, "following_error");
return { tick: motion.state.tick, axis: motion.state.axes.x, following_error: motion.state.following_error, fault: motion.state.fault, state_hash: motion.hash() };
}
async function toolAckResult() {
const tracker = new IoAckTracker();
assert.equal(tracker.issue("tool-change-7", "TOOL_CHANGE", 0, 2).status, "verified");
assert.equal(tracker.step(1).status, "verified");
const ack = tracker.ack("tool-change-7", 1);
assert.equal(ack.command.status, "acked");
assert.equal(ack.command.side_effect_count, 1);
return { command: ack.command, state_hash: tracker.hash() };
}
async function abortResult(source) {
const core = await createModule({ wasmBinary: await read("build-wasm/linuxcnc-sim-core.wasm") });
const session = new SimSession(core, { sessionId: SESSION_ID });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: SESSION_ID };
const send = (kind, seq, operation_id, payload = {}) => session.accept({ ...base, kind, seq, operation_id, payload });
send("HELLO", 1); send("SESSION_ATTACH", 2, "attach"); send("LOAD_WORKSPACE", 3, "workspace", { root_path: "/workspace" });
const loaded = send("PROGRAM_LOAD", 4, "load", { source, path: "startup-abort.ngc" });
assert.equal(loaded.kind, "COMMAND_RESULT");
send("PROGRAM_RUN", 5, "run");
const aborted = send("PROGRAM_ABORT", 6, "abort");
assert.equal(aborted.payload.task.program_status, "aborted");
return { task_program_status: aborted.payload.task.program_status, task_hash: aborted.payload.task.state_hash, session_tick: session.tick };
}
async function main() {
const cases = [];
const add = async (id, adapter, paths, result, status = "PASSED", reason = null) => cases.push({ id, adapter, status, ...(reason ? { reason } : {}), fixture: await fixture(paths), result, result_hash: resultHash(result) });
await add("source-test:linuxcnc/tests/hard-limits/test.sh", "WORKER_SIM_ADAPTER", ["linuxcnc/tests/hard-limits/test.sh", "linuxcnc/tests/hard-limits/hard-limits.ini", "linuxcnc/tests/hard-limits/hard-limits.hal", "linuxcnc/tests/hard-limits/checkresult"], await hardLimitResult());
await add("source-test:linuxcnc/tests/limit3/limit-max-velocity/test.hal", "WORKER_SIM_ADAPTER", ["linuxcnc/tests/limit3/limit-max-velocity/test.hal", "linuxcnc/tests/limit3/limit-max-velocity/expected"], await limit3Result());
await add("source-test:linuxcnc/tests/toolchanger/m61/test.sh", "WORKER_SIM_ADAPTER", ["linuxcnc/tests/toolchanger/m61/test.sh", "linuxcnc/tests/toolchanger/m61/checkresult", "linuxcnc/tests/toolchanger/m61/m61-test.ini", "linuxcnc/tests/toolchanger/m61/mod_core_sim.hal", "linuxcnc/tests/toolchanger/m61/test-ui.py"], await toolAckResult());
for (const [id, subdir, expectedTool] of [
["source-test:linuxcnc/tests/io-startup/nonrandom/no-tool-in-p0/test.sh", "no-tool-in-P0", 0],
["source-test:linuxcnc/tests/io-startup/nonrandom/tool-in-p0/test.sh", "tool-in-P0", 0],
]) {
const expected = (await read(`linuxcnc/tests/io-startup/nonrandom/${subdir}/expected-startup-tool-number`)).toString().trim();
assert.equal(Number(expected), expectedTool);
await add(id, "SOURCE_BLOCKED", [
`linuxcnc/tests/io-startup/nonrandom/${subdir}/expected-startup-tool-number`,
`linuxcnc/tests/io-startup/nonrandom/${subdir}/tool.tbl.original`,
"linuxcnc/tests/io-startup/nonrandom/test.ini",
"linuxcnc/tests/io-startup/shared-test.sh",
"linuxcnc/tests/io-startup/checkresult",
], { expected_startup_tool: Number(expected), reason: "startup tool table requires full Task/configuration runtime" }, "SOURCE_BLOCKED", "startup tool table requires full Task/configuration runtime");
}
const abortSource = (await read("linuxcnc/tests/motion-logger/startup-gcode-abort/nc_files/init.ngc")).toString("utf8");
await add("source-test:linuxcnc/tests/motion-logger/startup-gcode-abort/test.sh", "WORKER_TASK_MOTION_PENDING", [
"linuxcnc/tests/motion-logger/startup-gcode-abort/test.sh",
"linuxcnc/tests/motion-logger/startup-gcode-abort/test.ini",
"linuxcnc/tests/motion-logger/startup-gcode-abort/test-ui.py",
"linuxcnc/tests/motion-logger/startup-gcode-abort/nc_files/init.ngc",
"linuxcnc/tests/motion-logger/startup-gcode-abort/checkresult",
"linuxcnc/tests/motion-logger/startup-gcode-abort/expected.motion-logger.in",
], await abortResult(abortSource), "WORKER_TASK_MOTION_PENDING", "motion logger and startup abort endpoint require Task/motion adapter");
await add("source-test:linuxcnc/tests/classicladder/estop/test.sh", "HOST_EXCLUDED", [
"linuxcnc/tests/classicladder/estop/test.sh",
"linuxcnc/tests/classicladder/estop/classicladder-estop.ini",
"linuxcnc/tests/classicladder/estop/classicladder-estop.hal",
"linuxcnc/tests/classicladder/estop/classicladder-estop.clp",
"linuxcnc/tests/classicladder/estop/classicladder-estop-ui.py",
"linuxcnc/tests/classicladder/estop/checkresult",
], { reason: "ClassicLadder GUI/runtime is host excluded" }, "HOST_EXCLUDED", "ClassicLadder GUI/runtime is host excluded");
const counts = Object.fromEntries(["PASSED", "SOURCE_BLOCKED", "HOST_EXCLUDED", "WORKER_TASK_MOTION_PENDING"].map((status) => [status, cases.filter((item) => item.status === status).length]));
const output = {
schema: "linuxcnc-source-io-adapter.v1",
context_id: CONTEXT_ID,
cases,
counts,
fixture_file_count: new Set(cases.flatMap((item) => item.fixture.map((file) => file.path))).size,
boundaries: [
"startup tool table resolution remains SOURCE_BLOCKED until full Task/configuration startup is available",
"motion logger abort remains WORKER_TASK_MOTION_PENDING; no GUI or wall-clock result is promoted",
"ClassicLadder and realtime hardware paths remain HOST_EXCLUDED",
],
};
output.aggregate_hash = resultHash({ cases: output.cases, counts: output.counts });
console.log(JSON.stringify(output));
}
await main();

View File

@@ -0,0 +1,87 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { compareEndpoints, createEndpoint, endpointHash } from "./endpoint-replay.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const ROOT = new URL("../../", import.meta.url);
const SOURCE_REPORTS = [
"coverage/linuxcnc-source-test-s5.report.json",
"coverage/linuxcnc-source-test-execution-s5.report.json",
"coverage/linuxcnc-source-unit-execution-s5.report.json",
"coverage/linuxcnc-source-interp-s5.report.json",
"coverage/linuxcnc-source-hal-s5.report.json",
"coverage/linuxcnc-source-io-s5.report.json",
"coverage/linuxcnc-source-config-s5.report.json",
];
async function loadReports() {
const reports = [];
for (const path of SOURCE_REPORTS) {
const bytes = await readFile(new URL(path, ROOT));
const report = JSON.parse(bytes);
assert.equal(report.status, "verified", path);
reports.push({ path, task_id: report.task_id, schema: report.schema, sha256: createHash("sha256").update(bytes).digest("hex") });
}
return reports;
}
async function sessionWithPlan(id, fast = false) {
const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
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 sequence = 0;
const send = (kind, operationId, payload = {}) => {
sequence += 1;
const response = session.accept({ ...base, kind, seq: sequence, operation_id: operationId, payload });
assert.notEqual(response.kind, "ERROR", `${id}:${kind}:${response.payload?.code ?? "error"}`);
return response;
};
send("HELLO", "hello");
send("SESSION_ATTACH", "attach");
send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace" });
send("PROGRAM_LOAD", "program", { source: "G0 X1\nG1 X2\n", path: "parity.ngc" });
send("PROGRAM_RUN", "run");
if (fast) send("SIM_STEP", "fast-step", { cycles: 4 });
else for (let index = 0; index < 4; index += 1) send("SIM_STEP", `step-${index}`, { cycles: 1 });
return { session, send };
}
const sourceReports = await loadReports();
const oneX = await sessionWithPlan("source-parity-1x", false);
const fast = await sessionWithPlan("source-parity-fast", true);
const oneEndpoint = createEndpoint({ session: oneX.session });
const fastEndpoint = createEndpoint({ session: fast.session });
const modeParity = compareEndpoints(oneEndpoint, fastEndpoint);
assert.equal(modeParity.equal, true);
const recovering = await sessionWithPlan("source-parity-recover", false);
const checkpoint = recovering.send("CHECKPOINT", "checkpoint").payload.checkpoint;
recovering.send("SIM_STEP", "post-checkpoint", { cycles: 2 });
const continuousEndpoint = createEndpoint({ session: recovering.session });
const recovery = recovering.session.restoreCheckpoint(checkpoint, recovering.session.timeline.export(), (operation) => operation);
assert.equal(recovery.status, "recovered");
const recoveredEndpoint = createEndpoint({ session: recovering.session });
const recoveryParity = compareEndpoints(continuousEndpoint, recoveredEndpoint);
assert.equal(recoveryParity.equal, true);
const trace = recovering.session.exportTrace();
const traceCore = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const traceSession = new SimSession(traceCore, { sessionId: "source-parity-recover" });
const imported = traceSession.importTrace(trace);
assert.equal(imported.trace_hash, trace.trace_hash);
const traceParity = compareEndpoints(recoveredEndpoint, createEndpoint({ session: traceSession }));
assert.equal(traceParity.equal, true);
const output = {
schema: "linuxcnc-source-parity-s5.v1",
context_id: CONTEXT_ID,
source_reports: sourceReports,
modes: { one_x: endpointHash(oneEndpoint), fast: endpointHash(fastEndpoint) },
differences: { first_divergence: null, mode: modeParity, recovery: recoveryParity, trace_import: traceParity },
recovery: { checkpoint_hash: checkpoint.checkpoint_hash, continuous_hash: endpointHash(continuousEndpoint), recovered_hash: endpointHash(recoveredEndpoint), trace_hash: trace.trace_hash, status: recovery.status },
status: "verified",
};
console.log(JSON.stringify(output));

View File

@@ -0,0 +1,35 @@
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: "p304-state" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: "p304-state" };
const send = (kind, seq, operation_id, payload = {}) => session.accept({ ...base, kind, seq, operation_id, payload });
assert.equal(send("HELLO", 1).kind, "CAPABILITIES");
assert.equal(send("SESSION_ATTACH", 2, "attach").kind, "SESSION_READY");
send("LOAD_WORKSPACE", 3, "workspace", { root_path: "/workspace" });
send("PROGRAM_LOAD", 4, "load", { path: "state.ngc", source: "G21 G90\nG1 X2 F60\nM2\n" });
const run = send("PROGRAM_RUN", 5, "run");
assert.equal(run.payload.task.state, "RUNNING");
const replay = send("PROGRAM_RUN", 6, "run", { changed: true });
assert.equal(replay.replayed, true);
const checkpointResponse = send("CHECKPOINT", 7, "checkpoint-pre");
const checkpoint = checkpointResponse.payload.checkpoint;
const mdiBlocked = send("MDI_EXECUTE", 8, "mdi-blocked", { block: "G1 X9" });
assert.equal(mdiBlocked.kind, "ERROR");
assert.equal(mdiBlocked.payload.message_key, "mdi_while_program_active");
send("SIM_STEP", 9, "tick", { cycles: 1 });
assert.equal(send("PROGRAM_PAUSE", 10, "pause").payload.task.state, "PAUSED");
assert.equal(send("PROGRAM_RESUME", 11, "resume").payload.task.state, "RUNNING");
assert.equal(send("PROGRAM_ABORT", 12, "abort").payload.task.state, "STOPPED");
assert.equal(send("PROGRAM_RESTART", 13, "restart").payload.task.program_status, "loaded");
const single = send("PROGRAM_STEP", 14, "single");
assert.equal(single.payload.task.state, "PAUSED");
assert.equal(single.payload.task.program_status, "paused");
const recovered = session.restoreCheckpoint(checkpoint, null, (operation) => operation);
assert.equal(recovered.status, "recovered");
assert.equal(recovered.state.task.state, "RUNNING");
console.log(`task_program_state=passed replay=${replay.replayed} restart=${single.payload.task.program_status} recovery=${recovered.state.task.state}`);

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";
import { validateTrace } from "./trace-export.mjs";
const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
const core = await createModule({ wasmBinary });
const session = new SimSession(core, { sessionId: "trace-a" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: "trace-a" };
const send = (kind, seq, operation_id, payload = {}) => session.accept({ ...base, kind, seq, operation_id, payload });
send("HELLO", 1); send("SESSION_ATTACH", 2, "attach"); send("LOAD_WORKSPACE", 3, "workspace", { root_path: "/workspace" });
send("HOME", 4, "home"); send("JOG_START", 5, "jog", { axis: "x", direction: 1, speed: 10 }); send("SIM_STEP", 6, "step", { cycles: 3 });
const trace = session.exportTrace();
assert.equal(trace.schema, "simulation-trace.v1");
assert.equal(validateTrace(trace, { contextId: CONTEXT_ID }).trace.trace_hash, trace.trace_hash);
const imported = structuredClone(trace);
const secondCore = await createModule({ wasmBinary });
const second = new SimSession(secondCore, { sessionId: "trace-a" });
assert.equal(second.importTrace(imported).state_hash, trace.state_hash);
assert.equal(second.exportTrace().trace_hash, trace.trace_hash);
const invalid = structuredClone(trace); invalid.state.motion.axes.x += 1;
assert.throws(() => validateTrace(invalid, { contextId: CONTEXT_ID }), /trace_state_hash/);
console.log(`trace_export=passed seq=${trace.seq} tick=${trace.tick} hash=${trace.trace_hash}`);

View File

@@ -18,3 +18,29 @@ assert.equal(core._sim_protocol_tick(), 11);
assert.equal(core._sim_event_count(), 4);
assert.equal(session.accept({ ...base, kind: "SNAPSHOT_REQUEST", seq: 6, payload: {} }).payload.code, "protocol_sequence");
console.log("wasm_session=passed tick=" + stepped.simulation_tick);
const programSession = new SimSession(core, { sessionId: "s-program" });
const programBase = { schema_version: 1, context_id: CONTEXT_ID, session_id: "s-program" };
programSession.accept({ ...programBase, kind: "HELLO", seq: 1, payload: {} });
programSession.accept({ ...programBase, kind: "SESSION_ATTACH", seq: 2, operation_id: "program-attach", payload: {} });
programSession.accept({ ...programBase, kind: "LOAD_WORKSPACE", seq: 3, operation_id: "program-workspace", payload: { root_path: "/workspace" } });
const loaded = programSession.accept({ ...programBase, kind: "PROGRAM_LOAD", seq: 4, operation_id: "program-load", payload: { path: "demo.ngc", source: "G21 G90\nG1 X10 Y2 F120\nM2\n" } });
assert.equal(loaded.payload.line_count, 3);
assert.equal(programSession.accept({ ...programBase, kind: "PROGRAM_RUN", seq: 5, operation_id: "program-run", payload: {} }).payload.program_status, "running");
const firstLine = programSession.accept({ ...programBase, kind: "SIM_STEP", seq: 6, operation_id: "program-tick-1", payload: { cycles: 1 } });
assert.equal(firstLine.payload.program.state.x, 0);
const secondLine = programSession.accept({ ...programBase, kind: "SIM_STEP", seq: 7, operation_id: "program-tick-2", payload: { cycles: 1 } });
assert.equal(secondLine.payload.program.state.x, 10);
const finalLine = programSession.accept({ ...programBase, kind: "SIM_STEP", seq: 8, operation_id: "program-tick-3", payload: { cycles: 1 } });
assert.equal(finalLine.payload.program.program_status, "completed");
console.log("wasm_program=passed x=" + finalLine.payload.program.state.x + " status=" + finalLine.payload.program.program_status);
const mdiSession = new SimSession(core, { sessionId: "s-mdi" });
const mdiBase = { schema_version: 1, context_id: CONTEXT_ID, session_id: "s-mdi" };
mdiSession.accept({ ...mdiBase, kind: "HELLO", seq: 1, payload: {} });
mdiSession.accept({ ...mdiBase, kind: "SESSION_ATTACH", seq: 2, operation_id: "mdi-attach", payload: {} });
mdiSession.accept({ ...mdiBase, kind: "LOAD_WORKSPACE", seq: 3, operation_id: "mdi-workspace", payload: { root_path: "/workspace" } });
const mdi = mdiSession.accept({ ...mdiBase, kind: "MDI_EXECUTE", seq: 4, operation_id: "mdi-command", payload: { block: "G21 G90 G1 X3 Y4 F60" } });
assert.equal(mdi.payload.state.x, 3);
assert.equal(mdi.payload.state.y, 4);
console.log("wasm_mdi=passed x=" + mdi.payload.state.x + " y=" + mdi.payload.state.y);

View File

@@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import { WorkspaceRuntime, normalizeWorkspacePath } from "../workspace/runtime.mjs";
assert.equal(normalizeWorkspacePath("/workspace/config/main.ini"), "config/main.ini");
assert.throws(() => normalizeWorkspacePath("/etc/passwd"), /workspace_host_path/);
assert.throws(() => normalizeWorkspacePath("/workspace/../escape"), /workspace_path_escape/);
assert.throws(() => normalizeWorkspacePath("config\\main.ini"), /workspace_path_invalid/);
const workspace = new WorkspaceRuntime({ quotaBytes: 64 });
const tx = workspace.begin("op-1");
workspace.write("op-1", "/workspace/config/main.ini", "[EMC]\n");
workspace.write("op-1", "program.ngc", "G21\n");
assert.equal(workspace.commit("op-1").status, "committed");
assert.equal(workspace.read("/workspace/config/main.ini"), "[EMC]\n");
assert.equal(workspace.list().length, 2);
const hash = workspace.hash();
const aborted = workspace.begin("op-abort");
workspace.write("op-abort", "unused", "x");
assert.equal(workspace.abort("op-abort").status, "aborted");
assert.equal(workspace.hash(), hash);
const tooLarge = workspace.begin("op-large");
workspace.write("op-large", "large", "x".repeat(65));
assert.throws(() => workspace.commit("op-large"), /workspace_(?:quota_exceeded|file_quota)/);
assert.equal(workspace.hash(), hash);
assert.throws(() => workspace.read("missing"), /workspace_file_missing/);
const restored = new WorkspaceRuntime({ quotaBytes: 128 });
assert.equal(restored.restore(workspace.export()).state_hash, hash);
const tampered = workspace.export();
tampered.files["config/main.ini"] = "tampered";
assert.throws(() => restored.restore(tampered), /workspace_restore_invalid/);
console.log(`workspace_runtime=passed files=${workspace.list().length} state_hash=${hash}`);

View File

@@ -0,0 +1,48 @@
import { ReplayTimeline, REPLAY_SCHEMA, fnv1a64 } from "./replay.mjs";
import { validateEventLog, validateSnapshot } from "./contracts.mjs";
export const TRACE_SCHEMA = "simulation-trace.v1";
function clone(value) { return structuredClone(value); }
export function createTrace({ contextId, timeline, state, task, tick = null } = {}) {
if (typeof contextId !== "string" || contextId.length === 0) throw new Error("trace_context_invalid");
if (!(timeline instanceof ReplayTimeline)) throw new Error("trace_timeline_invalid");
if (state === undefined) throw new Error("trace_state_missing");
const exported = timeline.export();
const latest = timeline.snapshots.latest();
if (!latest || latest.seq !== timeline.events.cursor || latest.tick !== timeline.events.tick) throw new Error("trace_snapshot_required");
const value = {
schema: TRACE_SCHEMA,
context_id: contextId,
seq: timeline.events.cursor,
tick: tick ?? timeline.events.tick,
state: clone(state),
state_hash: fnv1a64(state),
task: task ? clone(task) : null,
timeline: exported,
event_hash: fnv1a64(exported.events),
snapshot_hash: fnv1a64(latest),
};
return { ...value, trace_hash: fnv1a64(value) };
}
export function validateTrace(trace, { contextId } = {}) {
if (!trace || trace.schema !== TRACE_SCHEMA) throw new Error("trace_schema");
if (contextId !== undefined && trace.context_id !== contextId) throw new Error("trace_context_mismatch");
if (!Number.isSafeInteger(trace.seq) || trace.seq < 0 || !Number.isSafeInteger(trace.tick) || trace.tick < 0) throw new Error("trace_cursor_invalid");
if (fnv1a64(trace.state) !== trace.state_hash) throw new Error("trace_state_hash");
if (!trace.timeline || trace.timeline.schema !== REPLAY_SCHEMA) throw new Error("trace_timeline_schema");
validateEventLog(trace.timeline.events);
const snapshots = trace.timeline.snapshots?.snapshots;
if (!Array.isArray(snapshots) || snapshots.length === 0) throw new Error("trace_snapshot_missing");
for (const snapshot of snapshots) validateSnapshot(snapshot);
const timeline = new ReplayTimeline(trace.timeline);
const latest = timeline.snapshots.latest();
if (timeline.events.cursor !== trace.seq || timeline.events.tick !== trace.tick || !latest || latest.seq !== trace.seq || latest.tick !== trace.tick) throw new Error("trace_cursor_mismatch");
if (trace.event_hash !== fnv1a64(trace.timeline.events)) throw new Error("trace_event_hash");
if (trace.snapshot_hash !== fnv1a64(latest)) throw new Error("trace_snapshot_hash");
const unsigned = { ...trace }; delete unsigned.trace_hash;
if (trace.trace_hash !== fnv1a64(unsigned)) throw new Error("trace_hash");
return { trace: clone(trace), timeline };
}