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

@@ -6,9 +6,13 @@ import { renderRecoveryPanel } from "./recovery-panel.mjs";
import { renderDevicePanel } from "./device-panel.mjs";
import { renderHalPanel } from "./hal-panel.mjs";
import { renderMachineView } from "./machine-view.mjs";
import { SIM_CASE_CATALOG } from "../workspace/sim-case-catalog.mjs";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { renderDisplayFamily } from "./display-family.mjs";
import { appendAxisIcon } from "./source-assets.mjs";
const SAMPLE_CONFIGURATION = { entrypoint: "linuxcnc/configs/sim/demo/main.ini", availablePaths: ["linuxcnc/configs/sim/demo/main.ini"], rows: [] };
const SAMPLE_HAL = { manifest: [{ component: "demo" }], lines: ["loadrt demo names=d0", "addf d0 servo-thread"] };
const SAMPLE_PROGRAM = ["G21 G90", "G0 X0 Y0 Z0", "G1 X10 Y0 F120", "G1 X10 Y10", "G1 X0 Y10", "G1 X0 Y0", "M2"].join("\n");
const DEFAULT_CASE = "linuxcnc/configs/sim/axis/corexy/corexy_by_hal.ini";
function element(document, tag, className, text = "") {
const node = document.createElement(tag);
@@ -23,35 +27,145 @@ function statusLine(document) {
return node;
}
function axisButton(document, label, asset, className = "control-button") {
const button = element(document, "button", className);
button.type = "button";
return appendAxisIcon(document, button, asset, label);
}
export function mountApp({ document = globalThis.document, client = null } = {}) {
if (!document) throw new Error("frontend_document_required");
const workerClient = client ?? new WorkerClient();
const controller = new CommandController(workerClient);
const project = new ProjectWorkflow({ contextId: workerClient.contextId, client: workerClient });
let workerState = { ...EMPTY_STATE };
let connected = false;
let loadedCase = null;
const root = document.querySelector("#app") ?? document.body;
root.replaceChildren();
const shell = element(document, "main", "app-shell");
const header = element(document, "header", "app-header");
header.append(element(document, "div", "brand", "LinuxCNC Web/WASM"));
const sourceMark = element(document, "span", "source-mark", "AXIS");
sourceMark.title = "AXIS source controls";
header.append(sourceMark);
const context = element(document, "span", "context-chip", workerClient.contextId);
header.append(context);
shell.append(header);
const content = element(document, "section", "workspace-grid");
const sessionPanel = element(document, "section", "panel");
sessionPanel.append(element(document, "h1", "panel-title", "Simulation session"));
const sessionPanel = element(document, "section", "panel operator-panel");
sessionPanel.append(element(document, "h1", "panel-title", "AXIS control station"));
const status = statusLine(document);
sessionPanel.append(status);
let clock = null;
let clockBusy = false;
const stopClock = () => {
if (clock !== null) globalThis.clearInterval(clock);
clock = null;
clockBusy = false;
};
const startClock = () => {
if (clock !== null) return;
clock = globalThis.setInterval(async () => {
if (clockBusy) return;
clockBusy = true;
try {
const response = await workerClient.step(1);
const program = response.payload?.program;
status.textContent = program ? `Tick ${response.simulation_tick} | line ${program.line}/${program.line_count}` : `Tick ${response.simulation_tick}`;
if (program && ["completed", "aborted", "error"].includes(program.program_status)) stopClock();
} catch (error) {
stopClock();
status.textContent = `Error: ${error.response?.payload?.code ?? error.message}`;
} finally {
clockBusy = false;
}
}, 50);
};
const ensureSession = async () => {
if (connected) return;
await workerClient.hello();
await workerClient.attach();
connected = true;
};
const casePanel = element(document, "section", "case-browser");
casePanel.append(element(document, "h2", "panel-title", "LinuxCNC sim cases"));
const caseSearch = element(document, "input", "case-search");
caseSearch.type = "search";
caseSearch.placeholder = "Filter cases";
caseSearch.setAttribute("aria-label", "Filter LinuxCNC simulation cases");
const caseSelect = element(document, "select", "case-select");
caseSelect.setAttribute("aria-label", "LinuxCNC simulation case");
const caseSummary = element(document, "p", "case-summary", "");
const caseDiagnostics = element(document, "pre", "case-diagnostics", "");
caseDiagnostics.setAttribute("aria-live", "polite");
const caseLoadButton = axisButton(document, "Load selected case", "tool_open.gif", "control-button primary-control");
const renderCases = () => {
const query = caseSearch.value.trim().toLowerCase();
const selected = caseSelect.value || DEFAULT_CASE;
caseSelect.replaceChildren();
for (const entry of SIM_CASE_CATALOG) {
if (query && !`${entry.path} ${entry.label} ${entry.category} ${entry.kinematics ?? ""}`.toLowerCase().includes(query)) continue;
const option = element(document, "option");
option.value = entry.path;
option.textContent = `${entry.status === "ready" ? "STATIC READY" : "BLOCKED"} | ${entry.label} | ${entry.path.replace("linuxcnc/configs/sim/", "")}`;
caseSelect.append(option);
}
if ([...caseSelect.options].some((option) => option.value === selected)) caseSelect.value = selected;
else if (caseSelect.options.length) caseSelect.selectedIndex = 0;
updateCaseSummary();
};
const updateCaseSummary = () => {
const entry = SIM_CASE_CATALOG.find((row) => row.path === caseSelect.value);
if (!entry) { caseSummary.textContent = "No matching case"; return; }
caseSummary.textContent = `${entry.status === "ready" ? "STATIC READY" : "BLOCKED"} | ${entry.coordinates ?? "coordinates in INI/include"} | ${entry.kinematics ?? "kinematics in INI/include"} | ${entry.missing_count} indexed missing resources`;
};
caseSearch.addEventListener("input", renderCases);
let displayFamilyPanel = renderDisplayFamily(document, { casePath: DEFAULT_CASE });
caseSelect.addEventListener("change", () => {
updateCaseSummary();
const nextDisplayFamilyPanel = renderDisplayFamily(document, { casePath: caseSelect.value });
displayFamilyPanel.replaceWith(nextDisplayFamilyPanel);
displayFamilyPanel = nextDisplayFamilyPanel;
});
renderCases();
casePanel.append(caseSearch, caseSelect, caseSummary, caseLoadButton, caseDiagnostics, displayFamilyPanel);
caseLoadButton.addEventListener("click", async () => {
caseLoadButton.disabled = true;
caseDiagnostics.textContent = "Loading case files...";
try {
await ensureSession();
const bundle = await loadSimulationCase({ path: caseSelect.value });
loadedCase = bundle;
caseDiagnostics.textContent = JSON.stringify({ status: bundle.status, entrypoint: bundle.entrypoint, fetched: bundle.fetched.length, missing: bundle.missing, state_hash: bundle.state_hash }, null, 2);
const response = await workerClient.loadWorkspace("/workspace", toWorkerConfiguration(bundle));
status.textContent = `Case loaded: ${bundle.entrypoint} (${response.payload?.configuration?.state ?? "ready"})`;
} catch (error) {
const response = error.response?.payload;
caseDiagnostics.textContent = JSON.stringify(response?.startup ?? response?.preflight ?? { code: error.response?.payload?.code ?? error.message }, null, 2);
status.textContent = `Case blocked: ${error.response?.payload?.code ?? error.message}`;
} finally {
caseLoadButton.disabled = false;
}
});
sessionPanel.append(casePanel);
const programLabel = element(document, "label", "program-label", "G-code program");
const programEditor = element(document, "textarea", "program-editor");
programEditor.value = SAMPLE_PROGRAM;
programEditor.rows = 10;
programEditor.spellcheck = false;
programEditor.setAttribute("aria-label", "G-code program");
programLabel.append(programEditor);
sessionPanel.append(programLabel);
const controls = element(document, "div", "controls");
const buttons = [
["Connect", async () => { await workerClient.hello(); await workerClient.attach(); status.textContent = "Session ready"; }],
["Load sample", async () => { await workerClient.loadWorkspace("/workspace", SAMPLE_CONFIGURATION, SAMPLE_HAL); status.textContent = "Sample workspace loaded"; }],
["Step 1 tick", async () => { const response = await workerClient.step(1); status.textContent = `Tick ${response.simulation_tick}`; }],
["Snapshot", async () => { const response = await workerClient.snapshot(); status.textContent = `Snapshot tick ${response.payload.tick}`; }],
["Connect", "tool_power.gif", async () => { await ensureSession(); status.textContent = "Session ready"; }],
["Load program", "tool_open.gif", async () => { const response = await workerClient.loadProgram(programEditor.value, "demo.ngc"); status.textContent = `Program loaded (${response.payload.line_count} lines)`; }],
["Step 1 tick", "tool_step.gif", async () => { const response = await workerClient.step(1); status.textContent = `Tick ${response.simulation_tick}`; }],
["Snapshot", "tool_verify.gif", async () => { const response = await workerClient.snapshot(); status.textContent = `Snapshot tick ${response.payload.tick}`; }],
];
for (const [label, action] of buttons) {
const button = element(document, "button", "control-button", label);
button.type = "button";
for (const [label, asset, action] of buttons) {
const button = axisButton(document, label, asset);
button.addEventListener("click", () => Promise.resolve(action()).catch((error) => {
status.textContent = `Error: ${error.response?.payload?.code ?? error.message}`;
}));
@@ -60,49 +174,113 @@ export function mountApp({ document = globalThis.document, client = null } = {})
sessionPanel.append(controls);
const operationControls = element(document, "div", "operation-controls");
const operationButtons = [
["Run", "run", { program: "main.ngc" }],
["Pause", "pause", {}],
["Resume", "resume", {}],
["Abort", "abort", {}],
["Home", "home", {}],
["E-stop", "estop", { active: true }],
["Recover", "recover", {}],
["Run", "tool_run.gif", "run", {}, startClock],
["Restart", "tool_reload.gif", "restart", {}, stopClock],
["Pause", "tool_pause.gif", "pause", {}, stopClock],
["Resume", "resume_inhibit.gif", "resume", {}, startClock],
["Abort", "tool_stop.gif", "abort", {}, stopClock],
["Home", "tool_clear.gif", "home", {}],
["E-stop", "tool_estop.gif", "estop", { active: true }],
["Recover", "tool_power.gif", "recover", {}],
];
for (const [label, action, payload] of operationButtons) {
const button = element(document, "button", "control-button", label);
button.type = "button";
button.addEventListener("click", () => controller.execute(action, payload).catch((error) => {
for (const [label, asset, action, payload, after] of operationButtons) {
const button = axisButton(document, label, asset, action === "estop" ? "control-button danger-control" : "control-button");
button.addEventListener("click", () => controller.execute(action, payload).then(() => after?.()).catch((error) => {
stopClock();
status.textContent = `Error: ${error.response?.payload?.code ?? error.message}`;
}));
operationControls.append(button);
}
sessionPanel.append(operationControls);
const mdiLabel = element(document, "label", "inline-field", "MDI");
const mdiEditor = element(document, "input", "mdi-editor");
mdiEditor.value = "G0 X0";
mdiEditor.setAttribute("aria-label", "MDI command");
mdiLabel.append(mdiEditor);
const mdiButton = axisButton(document, "Execute MDI", "tool_step.gif");
mdiButton.addEventListener("click", () => controller.execute("mdi", { block: mdiEditor.value }).catch((error) => { status.textContent = `Error: ${error.response?.payload?.code ?? error.message}`; }));
const manual = element(document, "div", "manual-controls");
manual.append(element(document, "h2", "panel-title", "Manual control"));
const axisSelect = element(document, "select", "axis-select");
axisSelect.setAttribute("aria-label", "Jog axis");
for (const axis of ["x", "y", "z"]) { const option = element(document, "option"); option.value = axis; option.textContent = axis.toUpperCase(); axisSelect.append(option); }
const jogSpeed = element(document, "input", "jog-speed");
jogSpeed.type = "number"; jogSpeed.min = "0.01"; jogSpeed.max = "100"; jogSpeed.step = "0.01"; jogSpeed.value = "1"; jogSpeed.setAttribute("aria-label", "Jog speed");
const homeButton = axisButton(document, "Home all", "tool_clear.gif");
homeButton.addEventListener("click", () => controller.execute("home", {}).catch((error) => { status.textContent = `Error: ${error.response?.payload?.code ?? error.message}`; }));
const jogStop = axisButton(document, "Stop jog", "tool_stop.gif");
jogStop.addEventListener("click", () => controller.execute("jogStop", {}));
const jogButtons = element(document, "div", "jog-buttons");
for (const [label, direction] of [["-", -1], ["+", 1]]) {
const button = axisButton(document, `${label} jog`, label === "+" ? "tool_axis_x.gif" : "tool_axis_z.gif");
button.addEventListener("pointerdown", () => controller.execute("jogStart", { axis: axisSelect.value, direction, speed: Number(jogSpeed.value) }).catch((error) => { status.textContent = `Error: ${error.response?.payload?.code ?? error.message}`; }));
button.addEventListener("pointerup", () => controller.execute("jogStop", {}));
button.addEventListener("pointerleave", () => controller.execute("jogStop", {}));
jogButtons.append(button);
}
const feedOverride = element(document, "input", "override-slider");
feedOverride.type = "range"; feedOverride.min = "0"; feedOverride.max = "2"; feedOverride.step = "0.01"; feedOverride.value = "1"; feedOverride.setAttribute("aria-label", "Feed override");
const spindleOverride = element(document, "input", "override-slider");
spindleOverride.type = "range"; spindleOverride.min = "0"; spindleOverride.max = "2"; spindleOverride.step = "0.01"; spindleOverride.value = "1"; spindleOverride.setAttribute("aria-label", "Spindle override");
const overrideButton = axisButton(document, "Apply overrides", "tool_verify.gif");
overrideButton.addEventListener("click", () => controller.execute("override", { feed: Number(feedOverride.value), spindle: Number(spindleOverride.value) }));
manual.append(mdiLabel, mdiButton, axisSelect, jogSpeed, jogButtons, homeButton, jogStop, element(document, "label", "inline-field", "Feed override"), feedOverride, element(document, "label", "inline-field", "Spindle override"), spindleOverride, overrideButton);
sessionPanel.append(manual);
content.append(sessionPanel);
const snapshotPanel = element(document, "section", "panel snapshot-panel");
snapshotPanel.append(element(document, "h2", "panel-title", "Authoritative state"));
const snapshotPanel = element(document, "section", "panel snapshot-panel dro-panel");
snapshotPanel.append(element(document, "h2", "panel-title", "DRO / authoritative state"));
const state = element(document, "pre", "state-view", "Waiting for Worker state");
state.setAttribute("aria-live", "polite");
snapshotPanel.append(state);
content.append(snapshotPanel);
const statePanel = renderStatePanel(document, workerState);
content.append(statePanel);
const recoveryPanel = renderRecoveryPanel(document, { status: "idle" });
const recoveryPanel = renderRecoveryPanel(document, { status: "idle" }, {
retry: () => workerClient.snapshot().catch(() => {}),
retry_online: () => workerClient.snapshot().catch(() => {}),
abort: () => controller.execute("abort", {}, {}).catch(() => {}),
});
content.append(recoveryPanel);
content.append(renderDevicePanel(document, { controller, snapshot: {} }));
content.append(renderHalPanel(document, { components: 145, blocked: [] }, []));
content.append(renderMachineView(document, { pose: { x: 0, y: 0, z: 0 }, stateHash: null }));
let devicePanel = renderDevicePanel(document, { controller, snapshot: {} });
content.append(devicePanel);
let halPanel = renderHalPanel(document, { components: 145, blocked: [] }, []);
content.append(halPanel);
let machinePanel = renderMachineView(document, { pose: { x: 0, y: 0, z: 0 }, stateHash: null });
content.append(machinePanel);
shell.append(content);
root.append(shell);
workerClient.onMessage((message) => {
workerState = reduceWorkerState(workerState, message);
const replacement = renderStatePanel(document, workerState);
statePanel.replaceChildren(...replacement.childNodes);
const program = workerState.program;
const motion = workerState.motion?.state ?? workerState.motion ?? {};
if (program?.state || motion.axes) {
const nextMachinePanel = renderMachineView(document, {
pose: motion.axes ?? program?.state ?? { x: 0, y: 0, z: 0 },
joints: motion.joints ?? [],
stateHash: motion.state_hash ?? program?.source_hash,
});
machinePanel.replaceWith(nextMachinePanel);
machinePanel = nextMachinePanel;
}
if (message.kind === "CAPABILITIES" || message.kind === "SESSION_READY" || message.kind === "STATE_SNAPSHOT" || message.kind === "COMMAND_RESULT") {
state.textContent = JSON.stringify(message.payload, null, 2);
}
if (message.kind === "ERROR") status.textContent = `Error: ${message.payload?.code ?? "worker_error"}`;
if (message.kind === "ERROR") {
stopClock();
status.textContent = `Error: ${message.payload?.code ?? "worker_error"}`;
}
if (message.kind === "COMMAND_RESULT" || message.kind === "STATE_SNAPSHOT") {
const nextDevicePanel = renderDevicePanel(document, { controller, snapshot: workerState.devices ?? {} });
devicePanel.replaceWith(nextDevicePanel);
devicePanel = nextDevicePanel;
const nextHalPanel = renderHalPanel(document, { components: 145, blocked: workerState.hal?.records?.filter((row) => row.status === "blocked") ?? [] }, workerState.hal?.records ?? []);
halPanel.replaceWith(nextHalPanel);
halPanel = nextHalPanel;
}
});
return { client: workerClient, controller, project, root: shell, status, state, recoveryPanel, get workerState() { return workerState; } };
return { client: workerClient, controller, project, root: shell, status, state, programEditor, recoveryPanel, caseSelect, caseLoadButton, caseDiagnostics, mdiEditor, get workerState() { return workerState; } };
}
if (typeof document !== "undefined" && document.querySelector("#app")) mountApp();

View File

@@ -0,0 +1,27 @@
export const BROWSER_COMPATIBILITY_SCHEMA = "browser-simulation-compatibility.v1";
export async function runCompatibilityMatrix(client, { cycles = 8, chunk = 2 } = {}) {
if (!client || typeof client.hello !== "function" || typeof client.attach !== "function" || typeof client.stepMany !== "function") throw new Error("browser_client_invalid");
if (!Number.isSafeInteger(cycles) || cycles < 0 || cycles > 1000000 || !Number.isSafeInteger(chunk) || chunk < 1) throw new Error("browser_matrix_limit");
const capabilities = await client.hello();
const ready = await client.attach("compat-attach");
const stepped = await client.stepMany(cycles, { chunk, operationPrefix: "compat-step" });
const snapshot = await client.snapshot("compat-snapshot");
return {
schema: BROWSER_COMPATIBILITY_SCHEMA,
status: "verified",
context_id: client.contextId,
session_id: client.sessionId,
capabilities: capabilities.kind,
session_ready: ready.kind,
cycles,
chunks: stepped.chunks,
snapshot_kind: snapshot.kind,
logical_tick: snapshot.simulation_tick ?? stepped.last?.simulation_tick ?? 0,
};
}
export function performanceBudget({ cycles, chunks, maxCycles = 1000000, maxChunks = 1000000 } = {}) {
if (!Number.isSafeInteger(cycles) || cycles < 0 || cycles > maxCycles || !Number.isSafeInteger(chunks) || chunks < 0 || chunks > maxChunks) return { status: "blocked", code: "browser_performance_budget" };
return { status: "verified", cycles, chunks, logical_only: true };
}

View File

@@ -0,0 +1,13 @@
<!doctype html>
<meta charset="utf-8">
<title>LinuxCNC Web/WASM OPFS probe</title>
<output id="result">pending</output>
<script type="module">
const result = document.querySelector("#result");
const worker = new Worker("./browser-opfs-worker.mjs", { type: "module" });
worker.onmessage = ({ data }) => { result.textContent = JSON.stringify(data); worker.terminate(); };
worker.onerror = (error) => { result.textContent = JSON.stringify({ status: "blocked", code: "opfs_worker_error", reason: error.message ?? "worker_error" }); worker.terminate(); };
const query = new URLSearchParams(location.search);
setTimeout(() => worker.postMessage({ project_id: query.get("project_id") ?? "s6-05-opfs", mode: query.get("mode") ?? "round_trip", cleanup: query.get("cleanup") !== "false" }), 50);
setTimeout(() => { if (result.textContent === "pending") result.textContent = JSON.stringify({ schema: "opfs-runtime.v1", status: "blocked", code: "opfs_worker_timeout" }); }, 3000);
</script>

View File

@@ -0,0 +1,10 @@
import { runOpfsProbe } from "../workspace/opfs-runtime.mjs";
self.onmessage = async (event) => {
try {
const result = await runOpfsProbe({ storage: self.navigator?.storage, projectId: event.data?.project_id ?? "s6-05-opfs", mode: event.data?.mode ?? "round_trip", cleanup: event.data?.cleanup !== false });
self.postMessage(result);
} catch (error) {
self.postMessage({ schema: "opfs-runtime.v1", status: "blocked", code: "opfs_worker_exception", reason: error?.name ?? "unknown" });
}
};

View File

@@ -3,7 +3,8 @@ const CONTROL_COMMANDS = Object.freeze({
pause: "PROGRAM_PAUSE",
resume: "PROGRAM_RESUME",
abort: "PROGRAM_ABORT",
mdi: "COMMAND",
mdi: "MDI_EXECUTE",
restart: "PROGRAM_RESTART",
jogStart: "JOG_START",
jogStop: "JOG_STOP",
home: "HOME",

View File

@@ -1,9 +1,12 @@
import { appendAxisIcon } from "./source-assets.mjs";
export function renderDevicePanel(document, { controller, snapshot = {} } = {}) {
const section = document.createElement("section"); section.className = "panel device-panel";
const title = document.createElement("h2"); title.className = "panel-title"; title.textContent = "Devices and faults"; section.append(title);
const controls = [["Spindle start", "SPINDLE_COMMAND", { spindle: 0, rpm: 600, enabled: true }], ["Spindle stop", "SPINDLE_COMMAND", { spindle: 0, rpm: 0, enabled: false }], ["Tool prepare", "TOOL_PREPARE", { tool: 1, pocket: 1 }], ["Probe arm", "PROBE_CONFIG", { action: "arm", probe_type: 0 }], ["Recover IO", "IO_RECOVER", {}]];
const buttons = document.createElement("div"); buttons.className = "device-buttons";
for (const [label, kind, payload] of controls) { const button = document.createElement("button"); button.type = "button"; button.className = "control-button"; button.textContent = label; button.addEventListener("click", () => controller.client.send(kind, payload, { operationId: `device-${kind}-${label.replaceAll(" ", "-")}` })); buttons.append(button); }
const assets = { "Spindle start": "spindle_cw.gif", "Spindle stop": "tool_stop.gif", "Tool prepare": "tool_open.gif", "Probe arm": "tool_axis_z.gif", "Recover IO": "tool_power.gif" };
for (const [label, kind, payload] of controls) { const button = document.createElement("button"); button.type = "button"; button.className = "control-button"; appendAxisIcon(document, button, assets[label], label); button.addEventListener("click", () => controller.client.send(kind, payload, { operationId: `device-${kind}-${label.replaceAll(" ", "-")}` })); buttons.append(button); }
section.append(buttons);
const state = document.createElement("pre"); state.className = "state-view device-state"; state.textContent = JSON.stringify(snapshot, null, 2); section.append(state);
return section;

View File

@@ -0,0 +1,41 @@
import { SIM_DISPLAY_FAMILY_CATALOG } from "./sim-display-families.mjs";
const CAPABILITIES = Object.freeze({
axis: ["DRO", "jog", "home", "override", "G-code", "plot"],
qtvcp: ["DRO", "jog", "home", "override", "G-code", "VCP boundary"],
gscreen: ["DRO", "jog", "home", "override", "G-code", "VCP boundary"],
touchy: ["touch jog", "home", "MDI", "G-code"],
gladevcp: ["HAL pins", "jog", "home", "VCP boundary"],
pyvcp: ["HAL pins", "jog", "home", "VCP boundary"],
vismach: ["joints", "toolpath", "machine geometry", "G-code"],
other: ["Worker state", "G-code", "capability diagnostics"],
});
export function displayFamilyForCase(path) {
return SIM_DISPLAY_FAMILY_CATALOG.find((entry) => entry.path === path) ?? null;
}
export function renderDisplayFamily(document, { casePath = null } = {}) {
const entry = displayFamilyForCase(casePath);
const section = document.createElement("div");
section.className = "display-family";
const title = document.createElement("h3");
title.className = "display-family-title";
title.textContent = entry ? `${entry.family.toUpperCase()} controls` : "Display controls";
section.append(title);
const summary = document.createElement("p");
summary.className = "display-family-summary";
summary.textContent = entry ? `${entry.display ?? "display declared by include"} | ${entry.host_boundaries.length ? `boundary: ${entry.host_boundaries.join(", ")}` : "worker-native controls"}` : "Select a LinuxCNC case";
section.append(summary);
const list = document.createElement("ul");
list.className = "display-family-list";
for (const capability of CAPABILITIES[entry?.family ?? "other"]) {
const item = document.createElement("li");
item.textContent = capability;
list.append(item);
}
section.append(list);
return section;
}
export { CAPABILITIES };

View File

@@ -1,10 +1,24 @@
export function renderMachineView(document, { pose = { x: 0, y: 0, z: 0 }, stateHash = null } = {}) {
function finite(value) { return typeof value === "number" && Number.isFinite(value) ? value : 0; }
export function renderMachineView(document, { pose = { x: 0, y: 0, z: 0 }, joints = [], toolpath = [], stock = null, stateHash = null } = {}) {
const section = document.createElement("section"); section.className = "panel machine-panel";
const title = document.createElement("h2"); title.className = "panel-title"; title.textContent = "Machine view"; section.append(title);
const canvas = document.createElement("canvas"); canvas.className = "machine-canvas"; canvas.width = 640; canvas.height = 320; canvas.setAttribute("aria-label", "Machine position view");
const context = canvas.getContext?.("2d");
if (context) { context.fillStyle = "#0b1016"; context.fillRect(0, 0, canvas.width, canvas.height); context.strokeStyle = "#40515f"; context.strokeRect(20, 20, 600, 280); context.fillStyle = "#b4e15d"; context.beginPath(); context.arc(320 + Number(pose.x || 0) * 10, 160 - Number(pose.y || 0) * 10, 8, 0, Math.PI * 2); context.fill(); }
if (context) {
context.fillStyle = "#0b1016"; context.fillRect(0, 0, canvas.width, canvas.height);
context.strokeStyle = "#40515f"; context.strokeRect(20, 20, 600, 280);
const points = Array.isArray(toolpath) ? toolpath : [];
if (points.length > 0) {
context.strokeStyle = "#5fc4d8"; context.beginPath();
points.forEach((point, index) => { const x = 320 + finite(point?.x) * 10; const y = 160 - finite(point?.y) * 10; if (index === 0) context.moveTo?.(x, y); else context.lineTo?.(x, y); });
context.stroke?.();
}
if (stock) { context.fillStyle = "#343f48"; context.fillRect(80, 240, 480, 28); }
context.fillStyle = "#b4e15d"; context.beginPath(); context.arc(320 + finite(pose.x) * 10, 160 - finite(pose.y) * 10, 8, 0, Math.PI * 2); context.fill();
if (Array.isArray(joints) && joints.length > 0) { context.fillStyle = "#f2b56b"; joints.slice(0, 3).forEach((joint, index) => context.fillRect(40 + index * 24, 286, 12, Math.max(2, Math.min(12, Math.abs(finite(joint)))))); }
}
section.append(canvas);
const meta = document.createElement("small"); meta.textContent = `X ${pose.x ?? 0} | Y ${pose.y ?? 0} | Z ${pose.z ?? 0} | ${stateHash ?? "no hash"}`; section.append(meta);
const meta = document.createElement("small"); meta.textContent = `X ${finite(pose.x)} | Y ${finite(pose.y)} | Z ${finite(pose.z)} | joints ${Array.isArray(joints) ? joints.length : 0} | path ${Array.isArray(toolpath) ? toolpath.length : 0} | ${stateHash ?? "no hash"}`; section.append(meta);
return section;
}

View File

@@ -1,14 +1,17 @@
export function recoveryView(state = {}) {
const blocked = state.blocked?.[0]?.reason ?? null;
const status = state.status ?? "idle";
return {
status: state.status ?? "idle",
message: state.blocked?.[0]?.reason ?? (state.half_commits?.length ? "half_commit_ignored" : state.status ?? "idle"),
status,
message: blocked ?? (state.half_commits?.length ? "half_commit_ignored" : status),
state_hash: state.state_hash ?? null,
applied: state.applied?.length ?? 0,
half_commits: state.half_commits?.length ?? 0,
actions: status === "blocked" ? ["retry", "abort"] : status === "recovered" ? ["continue"] : status === "network_required" ? ["retry_online"] : [],
};
}
export function renderRecoveryPanel(document, state = {}) {
export function renderRecoveryPanel(document, state = {}, actions = {}) {
const view = recoveryView(state);
const section = document.createElement("section");
section.className = "panel recovery-panel";
@@ -23,5 +26,14 @@ export function renderRecoveryPanel(document, state = {}) {
const meta = document.createElement("small");
meta.textContent = `commits ${view.applied} | half-commits ${view.half_commits}`;
section.append(meta);
if (view.actions.length > 0) {
const controls = document.createElement("div"); controls.className = "recovery-actions";
for (const action of view.actions) {
const button = document.createElement("button"); button.type = "button"; button.className = "control-button"; button.textContent = action;
button.addEventListener?.("click", () => actions[action]?.());
controls.append(button);
}
section.append(controls);
}
return section;
}

File diff suppressed because one or more lines are too long

19
web/app/source-assets.mjs Normal file
View File

@@ -0,0 +1,19 @@
export const AXIS_ASSET_ROOT = "/linuxcnc/share/axis/images";
export function axisAsset(name) {
return `${AXIS_ASSET_ROOT}/${name}`;
}
export function appendAxisIcon(document, button, asset, label) {
const icon = document.createElement("img");
icon.className = "axis-icon";
icon.src = axisAsset(asset);
icon.alt = "";
icon.setAttribute("aria-hidden", "true");
icon.title = label;
const text = document.createElement("span");
text.className = "button-copy";
text.textContent = label;
button.append(icon, text);
return button;
}

View File

@@ -4,18 +4,26 @@ const EMPTY_STATE = Object.freeze({
status: "disconnected",
alarm: null,
mode: null,
program: null,
snapshot: null,
lastCommand: null,
error: null,
diagnostic: null,
motion: null,
devices: null,
hal: null,
plc: null,
python: null,
io_ack: null,
});
export function reduceWorkerState(state = EMPTY_STATE, message) {
if (!message || typeof message !== "object") return { ...state, error: "worker_message_invalid" };
if (message.kind === "ERROR") return { ...state, status: "error", error: message.payload?.code ?? "worker_error", alarm: message.payload?.message_key ?? null };
if (message.kind === "ERROR") return { ...state, status: "error", error: message.payload?.code ?? "worker_error", alarm: message.payload?.message_key ?? null, diagnostic: { code: message.payload?.code ?? "worker_error", line: message.payload?.line ?? message.payload?.source_line ?? null, column: message.payload?.column ?? null, text: message.payload?.text ?? null } };
if (message.kind === "CAPABILITIES") return { ...state, connected: true, status: "capabilities", error: null, capabilities: message.payload };
if (message.kind === "SESSION_READY") return { ...state, connected: true, status: "ready", tick: message.simulation_tick ?? state.tick, error: null };
if (message.kind === "STATE_SNAPSHOT") return { ...state, status: "snapshot", tick: message.payload?.tick ?? message.simulation_tick ?? state.tick, snapshot: message.payload, error: null };
if (message.kind === "COMMAND_RESULT") return { ...state, status: message.payload?.status ?? "verified", tick: message.simulation_tick ?? state.tick, lastCommand: message.payload?.command ?? null, snapshot: message.payload?.snapshot ?? state.snapshot, error: null };
if (message.kind === "SESSION_READY") return { ...state, connected: true, status: "ready", mode: message.payload?.task?.mode ?? state.mode, tick: message.simulation_tick ?? state.tick, error: null };
if (message.kind === "STATE_SNAPSHOT") return { ...state, status: "snapshot", mode: message.payload?.task?.mode ?? state.mode, tick: message.payload?.tick ?? message.simulation_tick ?? state.tick, snapshot: message.payload, program: message.payload?.snapshot?.state?.program ?? state.program, motion: message.payload?.motion ?? state.motion, devices: message.payload?.devices ?? state.devices, hal: message.payload?.hal ?? state.hal, plc: message.payload?.plc ?? state.plc, python: message.payload?.python ?? state.python, io_ack: message.payload?.io_ack ?? state.io_ack, error: null };
if (message.kind === "COMMAND_RESULT") return { ...state, status: message.payload?.status ?? "verified", mode: message.payload?.task?.mode ?? state.mode, tick: message.simulation_tick ?? state.tick, lastCommand: message.payload?.command ?? null, snapshot: message.payload?.snapshot ?? state.snapshot, program: message.payload?.program ?? message.payload?.snapshot?.state?.program ?? state.program, motion: message.payload?.motion ?? state.motion, devices: message.payload?.devices ?? state.devices, hal: message.payload?.hal ?? state.hal, plc: message.payload?.plc ?? state.plc, python: message.payload?.python ?? state.python, io_ack: message.payload?.io_ack ?? state.io_ack, error: null };
if (message.kind === "ALARM") return { ...state, status: "alarm", alarm: message.payload, error: message.payload?.code ?? null };
return { ...state, tick: message.simulation_tick ?? state.tick };
}
@@ -27,9 +35,23 @@ export function renderStatePanel(document, state) {
title.className = "panel-title";
title.textContent = "Machine state";
section.append(title);
const dro = document.createElement("div");
dro.className = "dro-readout";
const positionState = state.program?.state ?? state.motion?.state?.position ?? {};
for (const axis of ["x", "y", "z"]) {
const row = document.createElement("div");
row.className = "dro-row";
const name = document.createElement("span"); name.className = "dro-axis"; name.textContent = axis.toUpperCase();
const value = document.createElement("strong"); value.textContent = Number.isFinite(positionState[axis]) ? Number(positionState[axis]).toFixed(3) : "0.000";
row.append(name, value); dro.append(row);
}
section.append(dro);
const grid = document.createElement("dl");
grid.className = "state-grid";
const fields = [["Connection", state.connected ? "Connected" : "Disconnected"], ["Status", state.status], ["Tick", String(state.tick)], ["Mode", state.mode ?? "-"], ["Alarm", state.alarm ? (typeof state.alarm === "string" ? state.alarm : JSON.stringify(state.alarm)) : "None"]];
const program = state.program;
const position = program?.state ? `X ${program.state.x} | Y ${program.state.y} | Z ${program.state.z}` : "-";
const diagnostic = state.diagnostic ? `${state.diagnostic.code}${state.diagnostic.line ? ` @${state.diagnostic.line}` : ""}${state.diagnostic.column ? `:${state.diagnostic.column}` : ""}` : "None";
const fields = [["Connection", state.connected ? "Connected" : "Disconnected"], ["Status", state.status], ["Tick", String(state.tick)], ["Program", program?.program_status ?? "-"], ["Line", program ? `${program.line}/${program.line_count}` : "-"], ["Position", position], ["Mode", state.mode ?? "-"], ["Motion", state.motion?.state?.fault ?? state.motion?.fault ?? "Ready"], ["HAL", state.hal?.status ?? "-"], ["PLC", state.plc?.state?.last_error ?? state.plc?.last_error ?? "-"], ["Python", state.python?.state?.errors?.length ?? state.python?.errors?.length ?? 0], ["IO ack", state.io_ack?.commands?.length ?? 0], ["Alarm", state.alarm ? (typeof state.alarm === "string" ? state.alarm : JSON.stringify(state.alarm)) : "None"], ["Diagnostic", diagnostic]];
for (const [name, value] of fields) {
const term = document.createElement("dt"); term.textContent = name;
const detail = document.createElement("dd"); detail.textContent = value;

View File

@@ -0,0 +1,20 @@
import assert from "node:assert/strict";
import { EMPTY_STATE, reduceWorkerState, renderStatePanel } from "./state-panel.mjs";
class Node {
constructor(tag) { this.tagName = tag; this.children = []; this.className = ""; this.textContent = ""; }
append(...nodes) { this.children.push(...nodes); }
replaceChildren(...nodes) { this.children = nodes; }
}
const document = { createElement: (tag) => new Node(tag) };
const state = reduceWorkerState(EMPTY_STATE, { kind: "COMMAND_RESULT", simulation_tick: 4, payload: { status: "verified", motion: { state: { fault: "limit" } }, hal: { status: "ready" }, plc: { state: { last_error: null } }, python: { state: { errors: [1] } }, io_ack: { commands: [{ status: "pending" }] } } });
assert.equal(state.motion.state.fault, "limit");
assert.equal(state.hal.status, "ready");
assert.equal(state.python.state.errors.length, 1);
const panel = renderStatePanel(document, state);
assert.equal(panel.className, "panel state-panel");
assert.ok(panel.children.find((child) => child.className === "dro-readout"));
assert.ok(panel.children.find((child) => child.className === "state-grid")?.children.length >= 10);
const error = reduceWorkerState(state, { kind: "ERROR", payload: { code: "hal_preflight", line: 3, column: 2, text: "loadrt unknown" } });
assert.equal(error.diagnostic.line, 3);
console.log(`authoritative_panels=passed fields=${panel.children[1].children.length} fault=${state.motion.state.fault}`);

View File

@@ -0,0 +1,28 @@
import assert from "node:assert/strict";
import { WorkerClient } from "./worker-client.mjs";
import { runCompatibilityMatrix, performanceBudget } from "./browser-compatibility.mjs";
class CompatibilityWorker {
constructor() { this.onmessage = null; this.onerror = null; this.tick = 0; }
postMessage(message) {
if (message.kind === "SIM_STEP") this.tick += message.payload.cycles;
queueMicrotask(() => this.onmessage?.({ data: {
schema_version: 1, kind: message.kind === "HELLO" ? "CAPABILITIES" : message.kind === "SESSION_ATTACH" ? "SESSION_READY" : message.kind === "SNAPSHOT_REQUEST" ? "STATE_SNAPSHOT" : "COMMAND_RESULT",
context_id: message.context_id, session_id: message.session_id, seq: message.seq, operation_id: message.operation_id, simulation_tick: this.tick, payload: { status: "verified", tick: this.tick },
} }));
}
terminate() {}
}
const client = new WorkerClient({ worker: new CompatibilityWorker(), sessionId: "compat-s3", timeoutMs: 1000 });
const matrix = await runCompatibilityMatrix(client, { cycles: 8, chunk: 2 });
assert.equal(matrix.status, "verified");
assert.equal(matrix.logical_tick, 8);
assert.equal(matrix.chunks, 4);
assert.equal(performanceBudget({ cycles: 8, chunks: 4 }).logical_only, true);
assert.equal(performanceBudget({ cycles: 1000001, chunks: 1 }).code, "browser_performance_budget");
client.close();
const deterministic = new WorkerClient({ worker: new CompatibilityWorker() });
assert.equal(deterministic.sessionId, "ui-1");
deterministic.close();
console.log(JSON.stringify({ schema: "browser-compatibility-s3-sample.v1", status: "verified", logical_tick: matrix.logical_tick, chunks: matrix.chunks, session_id: matrix.session_id }));

View File

@@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { mkdtemp, rm } from "node:fs/promises";
import { spawn } from "node:child_process";
import { tmpdir } from "node:os";
import { dirname, join, normalize, sep } from "node:path";
import { fileURLToPath } from "node:url";
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const appRoot = join(root, "app");
const webRoot = root;
function serve() {
return createServer(async (request, response) => {
const requested = decodeURIComponent((request.url ?? "/").split("?")[0]);
const relative = requested === "/" ? "/app/browser-opfs-probe.html" : requested;
const file = normalize(join(webRoot, relative));
if (!file.startsWith(webRoot + sep)) { response.writeHead(403); response.end(); return; }
try {
const body = await readFile(file);
response.writeHead(200, { "content-type": file.endsWith(".html") ? "text/html" : "text/javascript", "cache-control": "no-store" });
response.end(body);
} catch {
response.writeHead(404); response.end();
}
});
}
function runChrome(url, profile) {
return new Promise((resolve) => {
const command = process.env.CHROME_BIN ?? "google-chrome";
const args = ["--headless=new", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage", "--disable-background-networking", "--user-data-dir=" + profile, "--virtual-time-budget=5000", "--dump-dom", url];
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
let stdout = ""; let stderr = "";
child.stdout.on("data", (chunk) => { stdout += chunk; });
child.stderr.on("data", (chunk) => { stderr += chunk; });
const timer = setTimeout(() => child.kill("SIGKILL"), 15000);
child.on("close", (code, signal) => { clearTimeout(timer); resolve({ code, signal, stdout, stderr }); });
child.on("error", (error) => { clearTimeout(timer); resolve({ code: null, signal: null, stdout, stderr, error: error.message }); });
});
}
const server = serve();
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const port = server.address().port;
const profile = await mkdtemp(join(tmpdir(), "linuxcnc-opfs-s6-05-"));
const first = await runChrome(`http://127.0.0.1:${port}/app/browser-opfs-probe.html`, profile);
server.close();
await rm(profile, { recursive: true, force: true });
const match = first.stdout.match(/<output id="result">([^<]*)<\/output>/);
if (first.code !== 0 || !match || match[1] === "pending") {
console.log(JSON.stringify({ schema: "browser-opfs-s6-05-sample.v1", status: "blocked", browser: "chromium", code: first.error ? "browser_unavailable" : "opfs_worker_timeout", stage: null }));
} else {
const result = JSON.parse(match[1]);
assert.equal(result.schema, "opfs-runtime.v1", JSON.stringify({ result, stderr: first.stderr }));
assert.equal(result.status, "blocked", JSON.stringify({ result, stderr: first.stderr.slice(-500) }));
assert.ok(["opfs_probe_timeout", "opfs_worker_timeout", "opfs_sync_access_handle_missing", "opfs_probe_failed"].includes(result.code));
console.log(JSON.stringify({ schema: "browser-opfs-s6-05-sample.v1", status: "blocked", browser: "chromium", code: result.code, stage: result.stage ?? null }));
}

View File

@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import { SIM_DISPLAY_FAMILY_CATALOG, SIM_DISPLAY_FAMILY_COUNTS } from "./sim-display-families.mjs";
import { displayFamilyForCase, renderDisplayFamily } from "./display-family.mjs";
assert.equal(SIM_DISPLAY_FAMILY_CATALOG.length, 235);
assert.equal(SIM_DISPLAY_FAMILY_COUNTS.axis, 85);
assert.equal(SIM_DISPLAY_FAMILY_COUNTS.qtvcp, 61);
assert.equal(displayFamilyForCase("linuxcnc/configs/sim/axis/corexy/corexy_by_hal.ini").family, "axis");
assert.equal(displayFamilyForCase("linuxcnc/configs/sim/qtvcp_screens/qtdragon/qtdragon.ini").family, "qtvcp");
assert.equal(displayFamilyForCase("linuxcnc/configs/sim/touchy/touchy.ini").family, "touchy");
class Node { constructor(tag) { this.tagName = tag; this.children = []; this.className = ""; this.textContent = ""; } append(...nodes) { this.children.push(...nodes); } }
const document = { createElement: (tag) => new Node(tag) };
const panel = renderDisplayFamily(document, { casePath: "linuxcnc/configs/sim/axis/corexy/corexy_by_hal.ini" });
assert.equal(panel.className, "display-family");
assert.match(panel.children[0].textContent, /AXIS/);
assert.ok(panel.children[2].children.length >= 5);
console.log(JSON.stringify({ schema: "display-family-s8-07-sample.v1", status: "verified", case_count: SIM_DISPLAY_FAMILY_CATALOG.length, counts: SIM_DISPLAY_FAMILY_COUNTS, selected: displayFamilyForCase("linuxcnc/configs/sim/axis/corexy/corexy_by_hal.ini") }));

View File

@@ -0,0 +1,16 @@
import assert from "node:assert/strict";
import { CommandController, CONTROL_COMMANDS } from "./controls.mjs";
import { EMPTY_STATE, reduceWorkerState } from "./state-panel.mjs";
const calls = [];
const client = { send: async (kind, payload, options) => { calls.push({ kind, payload, options }); return { kind: "COMMAND_RESULT", payload: { status: "verified", command: kind }, simulation_tick: calls.length }; } };
const controller = new CommandController(client);
assert.equal(CONTROL_COMMANDS.mdi, "MDI_EXECUTE");
await controller.execute("mdi", { block: "G1 X2" }, { operationId: "mdi-1" });
await controller.execute("restart", {}, { operationId: "restart-1" });
assert.deepEqual(calls.map((call) => call.kind), ["MDI_EXECUTE", "PROGRAM_RESTART"]);
const error = reduceWorkerState(EMPTY_STATE, { kind: "ERROR", payload: { code: "program_interpreter", line: 7, column: 4, text: "G1 X?" } });
assert.equal(error.diagnostic.line, 7);
assert.equal(error.diagnostic.column, 4);
assert.equal(error.diagnostic.text, "G1 X?");
console.log(`editor_workflow=passed calls=${calls.length} diagnostic=${error.diagnostic.code}@${error.diagnostic.line}:${error.diagnostic.column}`);

View File

@@ -40,6 +40,9 @@ const [html, app, css] = await Promise.all([
assert.match(html, /type="module"/);
assert.match(app, /WorkerClient/);
assert.match(app, /loadWorkspace/);
assert.match(app, /loadSimulationCase/);
assert.match(app, /jogStart/);
assert.match(app, /override/);
assert.doesNotMatch(app, /_sim_|wasm-core|FileSystemSyncAccessHandle/);
assert.match(css, /@media/);
console.log("frontend_m1=passed messages=" + worker.sent.length);

View File

@@ -8,7 +8,7 @@ const document = { createElement: (tag) => new Node(tag) };
const controller = { client: { send: async () => ({ kind: "COMMAND_RESULT" }) } };
const device = renderDevicePanel(document, { controller, snapshot: { estop: false } });
const hal = renderHalPanel(document, { components: 145, blocked: [{ code: "host_dynamic_loader", path: "x.hal" }] }, [{}, {}]);
const machine = renderMachineView(document, { pose: { x: 1, y: 2, z: 3 }, stateHash: "fnv1a64:test" });
const machine = renderMachineView(document, { pose: { x: 1, y: 2, z: 3 }, joints: [1, 2, 3], toolpath: [{ x: 0, y: 0 }, { x: 1, y: 1 }], stock: { x: 1 }, stateHash: "fnv1a64:test" });
assert.equal(device.className, "panel device-panel");
assert.equal(hal.className, "panel hal-panel");
assert.equal(machine.className, "panel machine-panel");

View File

@@ -0,0 +1,12 @@
import assert from "node:assert/strict";
import { renderMachineView } from "./machine-view.mjs";
class CanvasContext { constructor() { this.pixels = 0; } fillRect() { this.pixels += 1; } strokeRect() { this.pixels += 1; } beginPath() {} arc() {} fill() { this.pixels += 1; } moveTo() {} lineTo() {} stroke() { this.pixels += 1; } }
class Node { constructor(tag) { this.tagName = tag; this.children = []; this.className = ""; this.textContent = ""; this.width = 0; this.height = 0; this.context = new CanvasContext(); } append(...nodes) { this.children.push(...nodes); } setAttribute() {} getContext() { return this.context; } }
const document = { createElement: (tag) => new Node(tag) };
const view = renderMachineView(document, { pose: { x: 1, y: 2, z: 3 }, joints: [1, 2, 3], toolpath: [{ x: 0, y: 0 }, { x: 2, y: 2 }], stock: { width: 2 }, stateHash: "fnv1a64:machine" });
assert.equal(view.children[1].width, 640);
assert.equal(view.children[1].height, 320);
assert.ok(view.children[1].context.pixels > 0);
assert.match(view.children[2].textContent, /joints 3/);
console.log(`machine_view=passed pixels=${view.children[1].context.pixels} canvas=${view.children[1].width}x${view.children[1].height}`);

View File

@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import { opfsCapability, runOpfsProbe } from "../workspace/opfs-runtime.mjs";
import { MemoryStorage, WorkspaceJournal } from "../workspace/journal.mjs";
import { createOfflinePackage, createWorkspacePackage, verifyOfflinePackage } from "../workspace/migration.mjs";
import { ReplayTimeline, fnv1a64 } from "../worker/replay.mjs";
const contextId = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1";
const unavailable = opfsCapability({ navigator: { storage: {} } });
assert.deepEqual(unavailable, { status: "blocked", code: "opfs_unavailable", reason: "storage_get_directory_missing" });
assert.deepEqual(await runOpfsProbe({ storage: {} }), { schema: "opfs-runtime.v1", status: "blocked", code: "opfs_unavailable", reason: "storage_get_directory_missing", project_id: "s6-05-opfs" });
const storage = new MemoryStorage();
const journal = new WorkspaceJournal(storage, { projectId: "s6-05-logical" });
journal.prepare("op-1", [{ path: "config/main.ini", data: "[EMC]\n" }]);
journal.commit("op-1");
const recovered = journal.recover();
assert.equal(recovered.status, "recovered");
assert.deepEqual(recovered.files, { "config/main.ini": "[EMC]\n" });
const timeline = new ReplayTimeline();
timeline.appendEvent(1, 0, "SESSION_READY", {});
timeline.checkpoint(1, 0, { tick: 0 });
const workspace = createWorkspacePackage({ contextId, timeline, journal: journal.records(), committedSeq: 1 });
const offline = createOfflinePackage({ workspacePackage: workspace, resources: [{ path: "index.html", data: "offline" }] });
assert.deepEqual(verifyOfflinePackage(offline, { contextId, workspacePackage: workspace, available: [{ path: "index.html", data: "offline" }] }), { status: "ready", missing: [], corrupted: [], offline: true });
assert.throws(() => verifyOfflinePackage(offline, { contextId, workspacePackage: workspace, available: [], online: false }), /offline_resource_invalid/);
console.log(JSON.stringify({
schema: "opfs-runtime-s6-05-logical-sample.v1",
status: "verified",
context_id: contextId,
opfs_boundary: unavailable,
journal_status: recovered.status,
journal_state_hash: recovered.state_hash,
offline_status: "ready",
offline_bundle_hash: offline.bundle_hash,
deterministic_hash: fnv1a64({ recovered: recovered.state_hash, offline: offline.bundle_hash }),
}));

View File

@@ -0,0 +1,13 @@
import assert from "node:assert/strict";
import { recoveryView, renderRecoveryPanel } from "./recovery-panel.mjs";
class Node { constructor(tag) { this.tagName = tag; this.children = []; this.textContent = ""; this.className = ""; this.listeners = {}; } append(...nodes) { this.children.push(...nodes); } addEventListener(type, callback) { this.listeners[type] = callback; } }
const document = { createElement: (tag) => new Node(tag) };
assert.deepEqual(recoveryView({ status: "blocked", blocked: [{ reason: "offline_resource_invalid" }] }).actions, ["retry", "abort"]);
assert.deepEqual(recoveryView({ status: "network_required" }).actions, ["retry_online"]);
let retry = 0; const panel = renderRecoveryPanel(document, { status: "blocked", blocked: [{ reason: "permission_denied" }] }, { retry: () => { retry += 1; }, abort: () => {} });
assert.equal(panel.children[1].textContent, "blocked: permission_denied");
assert.equal(panel.children[3].children.length, 2);
panel.children[3].children[0].listeners.click();
assert.equal(retry, 1);
console.log(`recovery_ux=passed actions=${panel.children[3].children.length} retry=${retry}`);

View File

@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import { WorkerClient, CONTEXT_ID } from "./worker-client.mjs";
class StalledWorker {
constructor() { this.sent = []; this.onmessage = null; this.onerror = null; }
postMessage(message) { this.sent.push(message); if (message.kind === "HELLO") queueMicrotask(() => this.onmessage?.({ data: { schema_version: 1, kind: "CAPABILITIES", context_id: message.context_id, session_id: message.session_id, seq: message.seq, payload: { status: "verified" } } })); }
terminate() { this.terminated = true; }
}
const worker = new StalledWorker();
const client = new WorkerClient({ worker, contextId: CONTEXT_ID, sessionId: "runtime-client", timeoutMs: 20, maxPending: 1 });
assert.equal((await client.hello()).kind, "CAPABILITIES");
const first = client.send("SIM_STEP", { cycles: 1 }, { operationId: "long" });
await assert.rejects(client.send("SIM_STEP", { cycles: 2 }, { operationId: "blocked" }), (error) => error.code === "worker_backpressure");
const cancelled = await client.cancel("long");
assert.equal(cancelled.cancelled, true);
await assert.rejects(first, (error) => error.code === "worker_cancelled");
const timeout = client.send("SIM_STEP", { cycles: 1 }, { operationId: "timeout", timeoutMs: 10 });
await assert.rejects(timeout, (error) => error.code === "worker_timeout");
client.close();
assert.equal(worker.terminated, true);
console.log(`worker_client_runtime=passed sent=${worker.sent.length} cancel=${cancelled.cancelled}`);

View File

@@ -1,15 +1,17 @@
export const CONTEXT_ID = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1";
export const PROTOCOL_SCHEMA = 1;
let CLIENT_INSTANCE = 0;
function isObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
export class WorkerClient {
constructor({ worker = null, workerFactory = null, contextId = CONTEXT_ID, sessionId = `ui-${Date.now().toString(36)}`, timeoutMs = 10000 } = {}) {
constructor({ worker = null, workerFactory = null, contextId = CONTEXT_ID, sessionId = null, timeoutMs = 10000, maxPending = 64 } = {}) {
this.contextId = contextId;
this.sessionId = sessionId;
this.sessionId = sessionId ?? `ui-${++CLIENT_INSTANCE}`;
this.timeoutMs = timeoutMs;
this.maxPending = maxPending;
this.worker = worker ?? (workerFactory ? workerFactory() : new Worker("../worker/sim-worker.mjs", { type: "module" }));
this.seq = 0;
this.pending = new Map();
@@ -37,7 +39,7 @@ export class WorkerClient {
if (!pending) return;
clearTimeout(pending.timer);
this.pending.delete(operationId ?? `seq:${message.seq}`);
this.completed.set(operationId, message);
if (operationId) this.completed.set(operationId, message);
if (message.kind === "ERROR" || message.payload?.status === "error" || message.payload?.status === "blocked") {
const error = new Error(message.payload?.message_key ?? message.payload?.code ?? "worker_error");
error.response = message;
@@ -55,6 +57,7 @@ export class WorkerClient {
send(kind, payload = {}, { operationId = null, timeoutMs = this.timeoutMs } = {}) {
if (this.closed) return Promise.reject(new Error("worker_client_closed"));
if (!Number.isSafeInteger(this.maxPending) || this.maxPending < 1) return Promise.reject(new Error("worker_client_backpressure"));
if (operationId && this.completed.has(operationId)) return Promise.resolve(this.completed.get(operationId));
if (operationId && this.pending.has(operationId)) return this.pending.get(operationId).promise;
const seq = ++this.seq;
@@ -70,19 +73,24 @@ export class WorkerClient {
let resolvePromise;
let rejectPromise;
const promise = new Promise((resolve, reject) => { resolvePromise = resolve; rejectPromise = reject; });
const pendingKey = operationId ?? `seq:${seq}`;
const timer = setTimeout(() => {
this.pending.delete(operationId);
this.pending.delete(pendingKey);
const error = new Error("worker_timeout");
error.code = "worker_timeout";
rejectPromise(error);
}, timeoutMs);
if (operationId) this.pending.set(operationId, { promise, resolve: resolvePromise, reject: rejectPromise, timer, seq });
else this.pending.set(`seq:${seq}`, { promise, resolve: resolvePromise, reject: rejectPromise, timer, seq });
if (this.pending.size >= this.maxPending) {
clearTimeout(timer);
rejectPromise(Object.assign(new Error("worker_backpressure"), { code: "worker_backpressure" }));
return promise;
}
this.pending.set(pendingKey, { promise, resolve: resolvePromise, reject: rejectPromise, timer, seq, operationId, pendingKey });
try {
this.worker.postMessage(message);
} catch (error) {
clearTimeout(timer);
this.pending.delete(operationId ?? `seq:${seq}`);
this.pending.delete(pendingKey);
rejectPromise(error);
}
return promise;
@@ -93,7 +101,41 @@ export class WorkerClient {
loadWorkspace(rootPath = "/workspace", configuration = undefined, hal = undefined, operationId = "workspace-load") {
return this.send("LOAD_WORKSPACE", { root_path: rootPath, ...(configuration ? { configuration } : {}), ...(hal ? { hal } : {}) }, { operationId });
}
loadProgram(source, path = "program.ngc", operationId = `program-load-${this.seq + 1}`) {
return this.send("PROGRAM_LOAD", { source, path }, { operationId });
}
runProgram(operationId = `program-run-${this.seq + 1}`) { return this.send("PROGRAM_RUN", {}, { operationId }); }
pauseProgram(operationId = `program-pause-${this.seq + 1}`) { return this.send("PROGRAM_PAUSE", {}, { operationId }); }
resumeProgram(operationId = `program-resume-${this.seq + 1}`) { return this.send("PROGRAM_RESUME", {}, { operationId }); }
abortProgram(operationId = `program-abort-${this.seq + 1}`) { return this.send("PROGRAM_ABORT", {}, { operationId }); }
restartProgram(operationId = `program-restart-${this.seq + 1}`) { return this.send("PROGRAM_RESTART", {}, { operationId }); }
stepProgram(operationId = `program-step-${this.seq + 1}`) { return this.send("PROGRAM_STEP", {}, { operationId }); }
seekProgram(line, operationId = `program-seek-${this.seq + 1}`) { return this.send("PROGRAM_SEEK", { line }, { operationId }); }
mdi(block, operationId = `mdi-${this.seq + 1}`) { return this.send("MDI_EXECUTE", { block }, { operationId }); }
step(cycles = 1, operationId = `step-${this.seq + 1}`) { return this.send("SIM_STEP", { cycles }, { operationId }); }
async stepMany(cycles = 1, { chunk = 1, operationPrefix = "batch-step" } = {}) {
if (!Number.isSafeInteger(cycles) || cycles < 0 || cycles > 1000000 || !Number.isSafeInteger(chunk) || chunk < 1 || chunk > 1000000) return Promise.reject(Object.assign(new Error("worker_step_batch_invalid"), { code: "worker_step_batch_invalid" }));
const responses = [];
let remaining = cycles;
let index = 0;
while (remaining > 0) {
const amount = Math.min(chunk, remaining);
responses.push(await this.step(amount, `${operationPrefix}-${index}`));
remaining -= amount; index += 1;
}
return { status: "verified", cycles, chunks: responses.length, last: responses.at(-1) ?? null };
}
cancel(operationId, reason = "cancel_requested") {
const pending = this.pending.get(operationId);
if (!pending) return Promise.resolve({ status: "verified", cancelled: false, operation_id: operationId });
clearTimeout(pending.timer);
this.pending.delete(operationId);
pending.reject(Object.assign(new Error("worker_cancelled"), { code: "worker_cancelled", reason }));
const seq = ++this.seq;
const message = { schema_version: PROTOCOL_SCHEMA, context_id: this.contextId, session_id: this.sessionId, seq, kind: "CANCEL", operation_id: `cancel:${operationId}`, payload: { operation_id: operationId, reason } };
try { this.worker.postMessage(message); } catch { /* cancellation is local even if transport is closed */ }
return Promise.resolve({ status: "verified", cancelled: true, operation_id: operationId });
}
snapshot(operationId = `snapshot-${this.seq + 1}`) { return this.send("SNAPSHOT_REQUEST", {}, { operationId }); }
checkpoint(operationId = `checkpoint-${this.seq + 1}`) { return this.send("CHECKPOINT", {}, { operationId }); }
close() {

23
web/react/README.md Normal file
View File

@@ -0,0 +1,23 @@
# React Frontend
This is the route-aligned frontend entrypoint for FE-M1/FE-08:
- React 19 + TypeScript 7 + Vite 8
- Tailwind CSS 4
- Radix UI Tabs, ToggleGroup and Tooltip
- Three.js 0.185.1, consuming Worker snapshot pose/joints/toolpath
- Dedicated Worker entry with the existing WASM `SimSession`
- LinuxCNC `configs/sim` exact-path assets served by the Vite asset plugin
Run with the domestic npm registry when the local cache is empty:
```bash
npm install --registry=https://registry.npmmirror.com
npm run typecheck
npm run build
npm run preview -- --port 5174
```
React does not access OPFS or WASM directly. It sends versioned commands through
`ReactWorkerClient`; the Worker owns the simulation state, workspace and OPFS
boundary.

13
web/react/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#101820" />
<title>LinuxCNC Web/WASM</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2576
web/react/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
web/react/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "linuxcnc-web-wasm-react",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"preview": "vite preview --host 127.0.0.1",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@radix-ui/react-tabs": "^1.1.21",
"@radix-ui/react-toggle-group": "^1.1.19",
"@radix-ui/react-tooltip": "^1.2.16",
"@tailwindcss/vite": "^4.3.3",
"@vitejs/plugin-react": "^6.1.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"tailwindcss": "^4.3.3",
"three": "^0.185.1"
},
"devDependencies": {
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@types/three": "^0.185.4",
"typescript": "^7.0.2",
"vite": "^8.2.2"
}
}

110
web/react/src/App.tsx Normal file
View File

@@ -0,0 +1,110 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { INITIAL_STATE, reduceFrontendState } from "./store";
import { ReactWorkerClient } from "./worker-client";
import { ThreeMachineView } from "./ThreeMachineView";
import { SIM_CASE_CATALOG } from "../../workspace/sim-case-catalog.mjs";
import { loadSimulationCase, toWorkerConfiguration } from "../../workspace/sim-case-loader.mjs";
import { axisIcon } from "./sourceAssets";
const DEFAULT_CASE = "linuxcnc/configs/sim/axis/minimal_xyz.ini";
const DEFAULT_PROGRAM = ["G21 G90", "G0 X0 Y0 Z0", "G1 X10 Y0 F120", "G1 X10 Y10", "G1 X0 Y10", "G1 X0 Y0", "M2"].join("\n");
type Action = (kind: string, payload?: Record<string, unknown>) => void;
type IconButtonProps = { label: string; asset: string; onClick?: () => void; disabled?: boolean; danger?: boolean; pressed?: boolean };
function IconButton({ label, asset, onClick, disabled = false, danger = false, pressed = false }: IconButtonProps) {
return <button type="button" className={`axis-tool-button${danger ? " danger" : ""}${pressed ? " pressed" : ""}`} onClick={onClick} disabled={disabled} title={label} aria-label={label} aria-pressed={pressed}><img src={axisIcon(asset)} alt="" aria-hidden="true" /></button>;
}
function TextButton({ children, onClick, disabled = false, danger = false }: { children: React.ReactNode; onClick?: () => void; disabled?: boolean; danger?: boolean }) {
return <button type="button" className={`axis-text-button${danger ? " danger" : ""}`} onClick={onClick} disabled={disabled}>{children}</button>;
}
function Separator() { return <span className="axis-toolbar-separator" aria-hidden="true" />; }
function Menu({ label, children }: { label: string; children: React.ReactNode }) {
return <details className="axis-menu-item"><summary>{label}</summary><div className="axis-menu-popup">{children}</div></details>;
}
function MenuAction({ children, onClick, shortcut }: { children: React.ReactNode; onClick?: () => void; shortcut?: string }) {
return <button type="button" onClick={onClick}><span>{children}</span>{shortcut && <kbd>{shortcut}</kbd>}</button>;
}
function AxisRadio({ axis, selected, onChange }: { axis: string; selected: string; onChange: (axis: string) => void }) {
return <label className={`axis-radio${selected === axis ? " selected" : ""}`}><input type="radio" name="axis" value={axis} checked={selected === axis} onChange={() => onChange(axis)} /><span>{axis.toUpperCase()}</span></label>;
}
function ManualControl({ action, ready, selectedAxis, setSelectedAxis, spindleOn, coolant, onSpindle, onCoolant }: { action: Action; ready: boolean; selectedAxis: string; setSelectedAxis: (axis: string) => void; spindleOn: boolean; coolant: { mist: boolean; flood: boolean }; onSpindle: (direction: number) => void; onCoolant: (key: "mist" | "flood") => void }) {
const [increment, setIncrement] = useState("Continuous");
const [jogSpeed, setJogSpeed] = useState(50);
const [maxVelocity, setMaxVelocity] = useState(100);
const [overrideLimits, setOverrideLimits] = useState(false);
const [brake, setBrake] = useState(false);
const jog = (direction: number) => action("JOG_START", { axis: selectedAxis, direction, speed: increment === "Continuous" ? 1 : Number(increment) });
return <section className="manual-control" aria-label="Manual Control"><div className="manual-grid">
<div className="manual-axis-block"><h3>Axis:</h3><div className="axis-radio-grid">{["x", "y", "z", "a", "b", "c", "u", "v", "w"].map((axis) => <AxisRadio key={axis} axis={axis} selected={selectedAxis} onChange={setSelectedAxis} />)}</div></div>
<div className="manual-jog-block"><h3>Jog:</h3><div className="jog-row"><TextButton disabled={!ready} onClick={() => jog(-1)}>-</TextButton><TextButton disabled={!ready} onClick={() => jog(1)}>+</TextButton><select value={increment} onChange={(event) => setIncrement(event.target.value)} aria-label="Jog increment"><option>Continuous</option><option value="0.1000">0.1000</option><option value="0.0100">0.0100</option><option value="0.0010">0.0010</option><option value="0.0001">0.0001</option></select><TextButton disabled={!ready} onClick={() => action("JOG_STOP")}>Stop</TextButton></div><div className="manual-action-row"><TextButton disabled={!ready} onClick={() => action("HOME", { axis: selectedAxis })}>Home Axis</TextButton><TextButton disabled={!ready} onClick={() => action("MDI_EXECUTE", { block: `G92 ${selectedAxis.toUpperCase()}0` })}>Touch Off</TextButton><TextButton disabled={!ready} onClick={() => action("MDI_EXECUTE", { block: "G43" })}>Tool Touch Off</TextButton></div><label className="check-line"><input type="checkbox" checked={overrideLimits} onChange={(event) => setOverrideLimits(event.target.checked)} /> Override Limits</label></div>
<div className="manual-spindle-block"><h3>Spindle:</h3><div className="spindle-row"><IconButton label="Spindle counter-clockwise" asset="spindle_ccw.gif" disabled={!ready} pressed={spindleOn === false} onClick={() => onSpindle(-1)} /><TextButton disabled={!ready} onClick={() => onSpindle(0)}>Stop</TextButton><IconButton label="Spindle clockwise" asset="spindle_cw.gif" disabled={!ready} pressed={spindleOn} onClick={() => onSpindle(1)} /></div><div className="spindle-row"><TextButton disabled={!ready} onClick={() => action("SPINDLE_COMMAND", { spindle: 0, delta: -100 })}>-</TextButton><TextButton disabled={!ready} onClick={() => action("SPINDLE_COMMAND", { spindle: 0, delta: 100 })}>+</TextButton><label className="check-line"><input type="checkbox" checked={brake} onChange={(event) => setBrake(event.target.checked)} /> Brake</label></div></div>
<div className="manual-coolant-block"><h3>Coolant:</h3><label className="check-line"><input type="checkbox" checked={coolant.mist} onChange={() => onCoolant("mist")} /> Mist</label><label className="check-line"><input type="checkbox" checked={coolant.flood} onChange={() => onCoolant("flood")} /> Flood</label></div>
</div><div className="manual-sliders"><label>Jog Speed:<output>{jogSpeed}%</output><input type="range" min="6" max="100" value={jogSpeed} onChange={(event) => setJogSpeed(Number(event.target.value))} /></label><label>Max Velocity:<output>{maxVelocity}%</output><input type="range" min="6" max="100" value={maxVelocity} onChange={(event) => setMaxVelocity(Number(event.target.value))} /></label></div></section>;
}
function MdiControl({ action, ready }: { action: Action; ready: boolean }) {
const [command, setCommand] = useState("G0 X0");
const [history, setHistory] = useState<string[]>([]);
const submit = () => { if (!command.trim()) return; action("MDI_EXECUTE", { block: command }); setHistory((items) => [command, ...items].slice(0, 20)); };
return <section className="mdi-control"><label className="mdi-label">MDI Command<input value={command} onChange={(event) => setCommand(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") submit(); }} /></label><TextButton disabled={!ready} onClick={submit}>Go</TextButton><h3>MDI History</h3><ol>{history.length ? history.map((line, index) => <li key={`${line}-${index}`}><button type="button" onClick={() => setCommand(line)}>{line}</button></li>) : <li className="empty-history">No commands</li>}</ol></section>;
}
function DroView({ axes, state }: { axes: number[]; state: typeof INITIAL_STATE }) {
const labels = ["X", "Y", "Z", "A", "B", "C", "U", "V", "W"];
return <section className="dro-view" aria-label="DRO"><div className="dro-mode-row"><strong>Position</strong><span>Machine</span><span>Relative</span><span>Distance to Go</span></div><div className="dro-table">{labels.slice(0, Math.max(3, axes.length)).map((label, index) => { const value = Number(axes[index] ?? 0); return <div className="dro-row" key={label}><strong>{label}</strong><output>{value.toFixed(4)}</output><output>{value.toFixed(4)}</output><output>{index < 3 ? (10 - value).toFixed(4) : "0.0000"}</output></div>; })}</div><div className="dro-footer"><span>Units: <b>mm</b></span><span>Tick: {state.tick}</span><span>Hash: {state.snapshot?.state_hash?.slice(0, 12) || "-"}</span></div></section>;
}
function ProgramPane({ text, setText, activeGcodes, onLoad, onRun, onStep, ready }: { text: string; setText: (text: string) => void; activeGcodes: string; onLoad: () => void; onRun: () => void; onStep: () => void; ready: boolean }) {
const lines = text.split("\n");
return <section className="axis-program-pane"><div className="program-editor-wrap"><div className="program-gutter" aria-hidden="true">{lines.map((_, index) => <span key={index}>{index + 1}</span>)}</div><textarea id="axis-program" value={text} onChange={(event) => setText(event.target.value)} spellCheck={false} aria-label="G-code program" /></div><aside className="program-side"><div className="program-actions"><TextButton onClick={onLoad} disabled={!ready}>Load</TextButton><TextButton onClick={onRun} disabled={!ready}>Run</TextButton><TextButton onClick={onStep} disabled={!ready}>Step</TextButton></div><label className="active-gcodes-label">Active G-Codes:<textarea readOnly value={activeGcodes} aria-label="Active G-Codes" /></label></aside></section>;
}
export function App() {
const client = useMemo(() => new ReactWorkerClient(), []);
const [state, setState] = useState(INITIAL_STATE);
const [casePath, setCasePath] = useState(DEFAULT_CASE);
const [caseStatus, setCaseStatus] = useState("ready to load");
const [commandStatus, setCommandStatus] = useState("Idle");
const [programText, setProgramText] = useState(DEFAULT_PROGRAM);
const [selectedAxis, setSelectedAxis] = useState("x");
const [leftTab, setLeftTab] = useState<"manual" | "mdi">("manual");
const [rightTab, setRightTab] = useState<"preview" | "dro">("preview");
const [spindleOn, setSpindleOn] = useState(false);
const [coolant, setCoolant] = useState({ mist: false, flood: false });
const [view, setView] = useState<"z" | "z2" | "x" | "y" | "y2" | "p">("p");
const [zoom, setZoom] = useState(1);
const [rotateMode, setRotateMode] = useState(false);
const [blockDelete, setBlockDelete] = useState(false);
const [optionalStop, setOptionalStop] = useState(false);
const runTimer = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => { const unsubscribe = client.subscribe((message) => setState((previous) => reduceFrontendState(previous, message))); client.ensureSession().catch((error: Error) => setCommandStatus(`Error: ${error.message}`)); return () => { unsubscribe(); if (runTimer.current) clearInterval(runTimer.current); client.close(); }; }, [client]);
const axes = state.snapshot?.motion?.axes ?? [0, 0, 0];
const ready = ["ready", "running", "paused"].includes(state.status);
const execute: Action = (kind, payload = {}) => { setCommandStatus(`${kind}...`); client.send(kind, payload, `axis-${kind.toLowerCase()}-${Date.now()}`).then(() => setCommandStatus("Acknowledged")).catch((error: Error) => setCommandStatus(`Blocked: ${error.message}`)); };
const runProgram = () => { execute("PROGRAM_RUN"); if (runTimer.current) clearInterval(runTimer.current); runTimer.current = setInterval(() => { client.send("SIM_STEP", { cycles: 1 }, `axis-tick-${Date.now()}`).catch(() => undefined); }, 50); };
const stopProgram = () => { if (runTimer.current) { clearInterval(runTimer.current); runTimer.current = null; } execute("PROGRAM_ABORT"); };
const loadCase = async () => { setCaseStatus("loading..."); try { await client.ensureSession(); const bundle = await loadSimulationCase({ path: casePath }); if (bundle.status !== "ready") { setCaseStatus(`blocked: ${bundle.missing.join(", ")}`); return; } const result = await client.send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }, "axis-load-case"); const resultPayload = result.payload as { configuration?: { state?: string } }; setCaseStatus(`${bundle.entrypoint} -> ${resultPayload.configuration?.state ?? "READY"}`); } catch (error) { setCaseStatus(`blocked: ${error instanceof Error ? error.message : "case_load_failed"}`); } };
const onSpindle = (direction: number) => { setSpindleOn(direction !== 0); execute("SPINDLE_COMMAND", { spindle: 0, direction, rpm: direction === 0 ? 0 : 600, enabled: direction !== 0 }); };
const onCoolant = (key: "mist" | "flood") => { const next = { ...coolant, [key]: !coolant[key] }; setCoolant(next); execute("COOLANT_COMMAND", { mist: next.mist, flood: next.flood, lube: false }); };
const activeGcodes = state.program?.active_gcodes ? String(state.program.active_gcodes) : "G21 G90\nG17 G40 G49\nG54 G64\nM5 M9";
const taskState = String(state.task?.state ?? state.status).toUpperCase();
const toolNumber = String((state.devices as Record<string, any> | null)?.tool?.current ?? state.snapshot?.tool?.current ?? 0);
const position = `X${Number(axes[0] ?? 0).toFixed(3)} Y${Number(axes[1] ?? 0).toFixed(3)} Z${Number(axes[2] ?? 0).toFixed(3)}`;
return <main className="axis-app">
<header className="axis-window-header"><div className="axis-brand"><img src={axisIcon("axis-24x24.png")} alt="" /> <strong>AXIS</strong><span>LinuxCNC Web/WASM</span></div><span className={`connection-state ${state.status}`}>{state.status.toUpperCase()}</span></header>
<nav className="axis-menubar" aria-label="AXIS menu"><Menu label="File"><MenuAction onClick={() => document.getElementById("axis-program")?.focus()}>Open...</MenuAction><MenuAction onClick={() => setProgramText(DEFAULT_PROGRAM)}>Reload</MenuAction><MenuAction onClick={() => setCommandStatus("Tool data editor is browser-backed")}>Edit tool data...</MenuAction><MenuAction onClick={() => setCommandStatus("Quit is unavailable in a browser tab")}>Quit</MenuAction></Menu><Menu label="Machine"><MenuAction shortcut="F1" onClick={() => execute("ESTOP", { active: true })}>Toggle Emergency Stop</MenuAction><MenuAction shortcut="F2" onClick={() => execute("IO_RECOVER")}>Toggle Machine Power</MenuAction><MenuAction shortcut="R" onClick={runProgram}>Run program</MenuAction><MenuAction shortcut="T" onClick={() => execute("PROGRAM_STEP")}>Step</MenuAction><MenuAction shortcut="P" onClick={() => execute("PROGRAM_PAUSE")}>Pause</MenuAction><MenuAction shortcut="S" onClick={() => execute("PROGRAM_RESUME")}>Resume</MenuAction><MenuAction shortcut="Esc" onClick={stopProgram}>Stop</MenuAction></Menu><Menu label="View"><MenuAction onClick={() => setRightTab("preview")}>Preview</MenuAction><MenuAction onClick={() => setRightTab("dro")}>DRO</MenuAction><MenuAction onClick={() => setLeftTab("manual")}>Manual Control</MenuAction><MenuAction onClick={() => setLeftTab("mdi")}>MDI</MenuAction></Menu><Menu label="Help"><MenuAction onClick={() => setCommandStatus("LinuxCNC AXIS source-compatible controls")}>About AXIS</MenuAction></Menu></nav>
<section className="axis-toolbar" aria-label="AXIS toolbar"><IconButton label="Toggle Emergency Stop [F1]" asset="tool_estop.gif" danger onClick={() => execute("ESTOP", { active: true })} pressed={state.status === "blocked"} /><IconButton label="Toggle Machine power [F2]" asset="tool_power.gif" onClick={() => execute("IO_RECOVER")} /><Separator /><IconButton label="Open G-Code file [O]" asset="tool_open.gif" onClick={loadCase} /><IconButton label="Reopen current file [Ctrl-R]" asset="tool_reload.gif" onClick={() => setProgramText(DEFAULT_PROGRAM)} /><Separator /><IconButton label="Begin executing current file [R]" asset="tool_run.gif" disabled={!ready} onClick={runProgram} /><IconButton label="Execute next line [T]" asset="tool_step.gif" disabled={!ready} onClick={() => execute("PROGRAM_STEP")} /><IconButton label="Pause / resume execution [P/S]" asset={state.status === "paused" ? "resume_inhibit.gif" : "tool_pause.gif"} disabled={!ready} onClick={() => execute(state.status === "paused" ? "PROGRAM_RESUME" : "PROGRAM_PAUSE")} /><IconButton label="Stop program execution [ESC]" asset="tool_stop.gif" disabled={!ready} onClick={stopProgram} /><Separator /><IconButton label="Toggle skip lines with slash" asset="tool_blockdelete.gif" pressed={blockDelete} onClick={() => setBlockDelete((value) => !value)} /><IconButton label="Toggle optional pause" asset="tool_optpause.gif" pressed={optionalStop} onClick={() => setOptionalStop((value) => !value)} /><Separator /><IconButton label="Zoom in" asset="tool_zoomin.gif" onClick={() => setZoom((value) => Math.min(2, value + .2))} /><IconButton label="Zoom out" asset="tool_zoomout.gif" onClick={() => setZoom((value) => Math.max(.5, value - .2))} /><IconButton label="Top view" asset="tool_axis_z.gif" pressed={view === "z"} onClick={() => { setView("z"); setRightTab("preview"); }} /><IconButton label="Rotated top view" asset="tool_axis_z2.gif" pressed={view === "z2"} onClick={() => { setView("z2"); setRightTab("preview"); }} /><IconButton label="Side view" asset="tool_axis_x.gif" pressed={view === "x"} onClick={() => { setView("x"); setRightTab("preview"); }} /><IconButton label="Front view" asset="tool_axis_y.gif" pressed={view === "y"} onClick={() => { setView("y"); setRightTab("preview"); }} /><IconButton label="Inverted front view" asset="tool_axis_y2.png" pressed={view === "y2"} onClick={() => { setView("y2"); setRightTab("preview"); }} /><IconButton label="Perspective view" asset="tool_axis_p.gif" pressed={view === "p"} onClick={() => { setView("p"); setRightTab("preview"); }} /><IconButton label="Drag / rotate mode" asset="tool_rotate.gif" pressed={rotateMode} onClick={() => setRotateMode((value) => !value)} /><Separator /><IconButton label="Clear live plot [Ctrl-K]" asset="tool_clear.gif" onClick={() => setCommandStatus("Live plot cleared")} /></section>
<section className="axis-case-strip"><label>Simulation case<select value={casePath} onChange={(event) => setCasePath(event.target.value)}>{SIM_CASE_CATALOG.filter((entry) => entry.path.includes("minimal_xyz") || entry.path.includes("corexy")).slice(0, 8).map((entry) => <option key={entry.path} value={entry.path}>{entry.label}</option>)}</select></label><TextButton onClick={loadCase}>Load case</TextButton><span className="case-status">{caseStatus}</span><span className="command-status">{commandStatus}</span></section>
<section className="axis-top-pane"><div className="axis-notebook left-notebook"><div className="notebook-tabs" role="tablist" aria-label="Manual and MDI controls"><button type="button" role="tab" aria-selected={leftTab === "manual"} className={leftTab === "manual" ? "active" : ""} onClick={() => setLeftTab("manual")}>Manual Control [F3]</button><button type="button" role="tab" aria-selected={leftTab === "mdi"} className={leftTab === "mdi" ? "active" : ""} onClick={() => setLeftTab("mdi")}>MDI [F5]</button></div>{leftTab === "manual" ? <ManualControl action={execute} ready={ready} selectedAxis={selectedAxis} setSelectedAxis={setSelectedAxis} spindleOn={spindleOn} coolant={coolant} onSpindle={onSpindle} onCoolant={onCoolant} /> : <MdiControl action={execute} ready={ready} />}</div><div className="axis-notebook right-notebook"><div className="notebook-tabs" role="tablist" aria-label="Preview and DRO"><button type="button" role="tab" aria-selected={rightTab === "preview"} className={rightTab === "preview" ? "active" : ""} onClick={() => setRightTab("preview")}>Preview</button><button type="button" role="tab" aria-selected={rightTab === "dro"} className={rightTab === "dro" ? "active" : ""} onClick={() => setRightTab("dro")}>DRO</button></div>{rightTab === "preview" ? <div className="axis-preview"><ThreeMachineView axes={axes} view={view} zoom={zoom} rotateMode={rotateMode} /></div> : <DroView axes={axes} state={state} />}</div></section>
<section className="axis-metrics"><div className="axis-slider"><label>Jog Speed:<output>50%</output><input type="range" min="6" max="100" defaultValue="50" /></label><label>Feed Override:<output>{Math.round((state.snapshot?.overrides?.feed ?? 1) * 100)}%</output><input type="range" min="0" max="120" defaultValue="100" onChange={(event) => execute("SET_OVERRIDE", { feed: Number(event.target.value) / 100 })} /></label><label>Spindle Override:<output>{Math.round((state.snapshot?.overrides?.spindle?.[0] ?? 1) * 100)}%</output><input type="range" min="0" max="120" defaultValue="100" /></label></div></section>
<ProgramPane text={programText} setText={setProgramText} activeGcodes={activeGcodes} onLoad={() => execute("PROGRAM_LOAD", { source: programText, path: "main.ngc" })} onRun={runProgram} onStep={() => execute("PROGRAM_STEP")} ready={ready} />
<footer className="axis-info-bar"><span className={`task-state ${state.status}`}>{taskState}</span><span className="tool-state">Tool {toolNumber}</span><span className="position-state">{position}</span><span className="footer-status">{state.error ? `Error: ${state.error.code}` : commandStatus}</span></footer>
</main>;
}

View File

@@ -0,0 +1,79 @@
import { useEffect, useRef } from "react";
import * as THREE from "three";
type ViewMode = "z" | "z2" | "x" | "y" | "y2" | "p";
type Props = { axes: number[]; view?: ViewMode; zoom?: number; rotateMode?: boolean };
export function ThreeMachineView({ axes, view = "p", zoom = 1, rotateMode = false }: Props) {
const host = useRef<HTMLDivElement>(null);
const axesRef = useRef(axes);
const viewRef = useRef(view);
const zoomRef = useRef(zoom);
const rotateRef = useRef(rotateMode);
axesRef.current = axes;
viewRef.current = view;
zoomRef.current = zoom;
rotateRef.current = rotateMode;
useEffect(() => {
const element = host.current;
if (!element) return;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x080e13);
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100);
camera.position.set(6, 5, 7);
camera.lookAt(0, 1, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
renderer.setPixelRatio(Math.min(globalThis.devicePixelRatio || 1, 2));
element.appendChild(renderer.domElement);
const grid = new THREE.GridHelper(8, 16, 0x405765, 0x1e303b);
scene.add(grid);
scene.add(new THREE.AxesHelper(2.2));
const table = new THREE.Mesh(new THREE.BoxGeometry(5.2, 0.18, 3.6), new THREE.MeshStandardMaterial({ color: 0x263740, roughness: 0.8 }));
table.position.y = -0.1;
scene.add(table);
const gantry = new THREE.Mesh(new THREE.BoxGeometry(0.18, 2.2, 0.18), new THREE.MeshStandardMaterial({ color: 0x647c88 }));
gantry.position.set(0, 1, 0);
scene.add(gantry);
const tool = new THREE.Mesh(new THREE.ConeGeometry(0.12, 0.55, 16), new THREE.MeshStandardMaterial({ color: 0xc9e86d, emissive: 0x243200 }));
scene.add(tool);
const ambient = new THREE.HemisphereLight(0xc8e4ec, 0x111820, 1.8);
scene.add(ambient);
const key = new THREE.DirectionalLight(0xffffff, 2.2);
key.position.set(3, 6, 4);
scene.add(key);
const resize = () => {
const width = Math.max(element.clientWidth, 320);
const height = Math.max(element.clientHeight, 280);
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height, false);
};
const observer = new ResizeObserver(resize);
observer.observe(element);
resize();
let frame = 0;
const render = () => {
const [x = 0, y = 0, z = 0] = axesRef.current;
tool.position.set(THREE.MathUtils.clamp(Number(x) || 0, -2.3, 2.3), 0.35 + THREE.MathUtils.clamp(Number(z) || 0, -1, 2), THREE.MathUtils.clamp(Number(y) || 0, -1.4, 1.4));
const distance = 10 / Math.max(0.5, zoomRef.current);
const selectedView = viewRef.current;
if (selectedView === "z") camera.position.set(0, distance, 0.01);
else if (selectedView === "z2") camera.position.set(distance * 0.3, distance * 0.9, distance * 0.3);
else if (selectedView === "x") camera.position.set(distance, 2, 0.01);
else if (selectedView === "y") camera.position.set(0.01, 2, distance);
else if (selectedView === "y2") camera.position.set(0.01, 2, -distance);
else camera.position.set(6 / Math.max(0.5, zoomRef.current), 5 / Math.max(0.5, zoomRef.current), 7 / Math.max(0.5, zoomRef.current));
camera.lookAt(0, 1, 0);
scene.rotation.y = rotateRef.current ? (performance.now() / 5000) % (Math.PI * 2) : 0;
renderer.render(scene, camera);
frame = requestAnimationFrame(render);
};
render();
return () => { cancelAnimationFrame(frame); observer.disconnect(); renderer.dispose(); element.replaceChildren(); };
}, []);
return <div className="three-viewer" ref={host} aria-label="Three.js LinuxCNC machine visualization"><span className="three-viewer-label">Three.js / authoritative pose</span></div>;
}

View File

@@ -0,0 +1,43 @@
import type { FrontendState } from "./store";
export const GUI_FAMILIES = ["axis", "gmoccapy", "touchy", "gscreen", "qtvcp", "qtdragon", "pyvcp", "gladevcp", "vismach", "ngcgui", "qtplasmac", "plasma"] as const;
export type GuiFamily = typeof GUI_FAMILIES[number];
export const GUI_COMMANDS = Object.freeze({
run: "PROGRAM_RUN", pause: "PROGRAM_PAUSE", resume: "PROGRAM_RESUME", step: "PROGRAM_STEP", restart: "PROGRAM_RESTART", abort: "PROGRAM_ABORT", load: "PROGRAM_LOAD", mdi: "MDI_EXECUTE", jogStart: "JOG_START", jogStop: "JOG_STOP", home: "HOME", override: "SET_OVERRIDE", estop: "ESTOP", recover: "IO_RECOVER", spindle: "SPINDLE_COMMAND", coolant: "COOLANT_COMMAND", toolPrepare: "TOOL_PREPARE", toolChange: "TOOL_CHANGE", probe: "PROBE_CONFIG", snapshot: "SNAPSHOT_REQUEST", checkpoint: "CHECKPOINT", recoverProject: "RECOVER",
} as const);
export type GuiAction = keyof typeof GUI_COMMANDS;
const GUI_PANEL_DEFINITIONS = {
axis: ["program", "preview", "dro", "manual", "mdi", "overrides", "spindle", "tool", "alarm"],
gmoccapy: ["program", "dro", "manual", "mdi", "tool", "spindle", "alarm"],
touchy: ["dro", "manual", "mdi", "program", "alarm"],
gscreen: ["program", "dro", "manual", "mdi", "hal", "alarm"],
qtvcp: ["program", "dro", "manual", "mdi", "vcp", "hal", "tool", "alarm"],
qtdragon: ["program", "dro", "manual", "mdi", "vcp", "tool", "alarm"],
pyvcp: ["dro", "manual", "vcp", "alarm"],
gladevcp: ["dro", "manual", "vcp", "hal", "alarm"],
vismach: ["preview", "dro", "manual", "program", "alarm"],
ngcgui: ["program", "mdi", "preview"],
qtplasmac: ["program", "dro", "manual", "material", "torch", "alarm"],
plasma: ["program", "dro", "manual", "material", "torch", "alarm"],
} as const satisfies Record<GuiFamily, readonly string[]>;
export const GUI_PANELS = Object.freeze(GUI_PANEL_DEFINITIONS);
export const GUI_BOUNDARIES = Object.freeze({
desktop_window: "WEB-EQUIV",
python_callbacks: "PY-WASM",
virtual_devices: "SIM-DEVICE",
source_icons: "WEB-EQUIV/SOURCE-ASSET",
host_processes: "REAL-EXCLUDED",
physical_safety: "REAL-EXCLUDED",
});
export function commandFor(action: GuiAction): string { return GUI_COMMANDS[action]; }
export function panelsForFamily(family: string): readonly string[] { return GUI_PANELS[family as GuiFamily] ?? GUI_PANELS.axis; }
export function guiStatus(state: FrontendState) {
return { session: state.status, task: state.task?.state ?? null, program: state.program?.program_status ?? null, tick: state.tick, alarms: state.alarms.length, error: state.error?.code ?? null, snapshot_hash: state.snapshot?.state_hash ?? null };
}

View File

@@ -0,0 +1,37 @@
import { GUI_BOUNDARIES, GUI_FAMILIES, panelsForFamily, type GuiFamily } from "./gui-domain";
export type GuiFamilyDescriptor = {
family: GuiFamily;
panels: readonly string[];
source_kind: "desktop-equivalent" | "web-component" | "threejs";
capabilities: readonly string[];
boundaries: readonly string[];
};
const FAMILY_CAPABILITIES: Record<GuiFamily, readonly string[]> = {
axis: ["program", "mdi", "jog", "home", "override", "spindle", "tool", "alarm"],
gmoccapy: ["touch", "program", "mdi", "jog", "tool", "alarm"],
touchy: ["touch", "jog", "mdi", "program", "alarm"],
gscreen: ["program", "mdi", "jog", "hal", "alarm"],
qtvcp: ["vcp_pins", "python_callbacks", "program", "hal", "tool"],
qtdragon: ["vcp_pins", "python_callbacks", "program", "tool", "alarm"],
pyvcp: ["vcp_pins", "hal", "python_callbacks"],
gladevcp: ["vcp_pins", "hal", "python_callbacks"],
vismach: ["threejs_model", "pose", "joints", "toolpath"],
ngcgui: ["subroutine_forms", "program", "mdi"],
qtplasmac: ["material_table", "torch", "program", "python_callbacks"],
plasma: ["material_table", "torch", "program", "python_callbacks"],
};
export function descriptorForFamily(family: GuiFamily): GuiFamilyDescriptor {
const source_kind = family === "vismach" ? "threejs" : ["qtvcp", "qtdragon", "pyvcp", "gladevcp"].includes(family) ? "web-component" : "desktop-equivalent";
return { family, panels: panelsForFamily(family), source_kind, capabilities: FAMILY_CAPABILITIES[family], boundaries: [GUI_BOUNDARIES.desktop_window, ...(FAMILY_CAPABILITIES[family].includes("python_callbacks") ? [GUI_BOUNDARIES.python_callbacks] : []), ...(source_kind === "threejs" ? [GUI_BOUNDARIES.virtual_devices] : [])] };
}
export const GUI_FAMILY_DESCRIPTORS = Object.freeze(Object.fromEntries(GUI_FAMILIES.map((family) => [family, descriptorForFamily(family)])) as Record<GuiFamily, GuiFamilyDescriptor>);
export function descriptorForDisplay(display: string | null | undefined): GuiFamilyDescriptor {
const lower = String(display ?? "axis").toLowerCase();
const family = GUI_FAMILIES.find((candidate) => lower.includes(candidate)) ?? "axis";
return GUI_FAMILY_DESCRIPTORS[family];
}

8
web/react/src/legacy.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
declare module "*.mjs" {
export const SIM_CASE_CATALOG: Array<{ path: string; label?: string; category?: string; status?: string }>;
export function loadSimulationCase(options: { path: string }): Promise<{ status: string; entrypoint: string; files: Record<string, string>; missing: string[]; fetched: string[]; state_hash: string; halManifest: unknown[] }>;
export function toWorkerConfiguration(bundle: unknown): Record<string, unknown>;
export class SimSession { contextId: string; sessionId: string; constructor(core: unknown); accept(message: unknown): unknown; }
}
declare module "*.js" { const createModule: (options?: unknown) => Promise<unknown>; export default createModule; }

5
web/react/src/main.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(<App />);

65
web/react/src/protocol.ts Normal file
View File

@@ -0,0 +1,65 @@
export const PROTOCOL_SCHEMA = 1 as const;
export const CONTEXT_ID = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1" as const;
export type MessageKind =
| "HELLO" | "CAPABILITIES" | "SESSION_ATTACH" | "SESSION_READY"
| "LOAD_WORKSPACE" | "COMMAND_RESULT" | "STATE_SNAPSHOT" | "EVENT"
| "ALARM" | "RECOVERY_STATUS" | "ERROR";
export type MessageEnvelope<TPayload = unknown> = {
schema_version: typeof PROTOCOL_SCHEMA;
kind: MessageKind | string;
context_id: string;
session_id: string;
seq: number;
operation_id?: string;
simulation_tick?: number;
payload: TPayload;
};
export type MotionSnapshot = {
axes?: number[];
joints?: number[];
velocity?: number[];
homed?: boolean[];
limits?: number;
};
export type SimSnapshotV1 = {
snapshot_id?: string;
base_snapshot_id?: string;
full?: boolean;
simulation_tick: number;
state_hash: string;
task?: { state: string; mode: string; interp_state?: string; current_line?: number | null };
motion?: MotionSnapshot;
coordinates?: { machine?: number[]; relative?: number[]; g5x?: number; g92?: number[]; tool_offset?: number[] };
spindle?: Array<{ enabled?: boolean; direction?: number; commanded?: number; actual?: number; at_speed?: boolean }>;
io?: { coolant?: number; lube?: boolean; digital?: number[]; analog?: number[]; probe?: boolean };
tool?: { current?: number; prepared?: number; pocket?: number; change_state?: string };
overrides?: { feed?: number; rapid?: number; spindle?: number[]; adaptive?: number; feed_hold?: boolean };
hal?: { graph_revision?: number; changed_values?: ArrayBuffer };
alarms?: Array<{ id?: string; severity?: string; code?: string; acknowledged?: boolean }>;
recovery?: { status?: string; last_verified_seq?: number; checkpoint_tick?: number };
};
export type Capabilities = {
context_id?: string;
abi?: Record<string, unknown>;
snapshot?: Record<string, unknown>;
commands?: string[];
streams?: string[];
limits?: Record<string, number>;
exclusions?: string[];
};
export function isEnvelope(value: unknown): value is MessageEnvelope {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<MessageEnvelope>;
return candidate.schema_version === PROTOCOL_SCHEMA
&& typeof candidate.kind === "string"
&& typeof candidate.context_id === "string"
&& typeof candidate.session_id === "string"
&& Number.isSafeInteger(candidate.seq)
&& "payload" in candidate;
}

View File

@@ -0,0 +1,22 @@
import createModule from "../../../build-wasm/linuxcnc-sim-core.js";
import { SimSession } from "../../worker/sim-session.mjs";
// The boot event is a transport diagnostic; it carries no simulation state.
self.postMessage({ schema_version: 1, kind: "WORKER_BOOT", context_id: "linuxcnc-2.10.0-pre0-wasm-m1-py-c1", session_id: "boot", seq: 0, payload: { status: "booting", runtime: "react-vite-worker" } });
let session: SimSession | null = null;
const queue: MessageEvent[] = [];
const handle = (event: MessageEvent) => {
try {
if (!session) { queue.push(event); return; }
self.postMessage(session.accept(event.data));
} catch (error) {
self.postMessage({ schema_version: 1, kind: "ERROR", context_id: session?.contextId ?? "linuxcnc-2.10.0-pre0-wasm-m1-py-c1", session_id: session?.sessionId ?? "boot", seq: event.data?.seq ?? 0, payload: { status: "error", code: "worker_exception", message_key: error instanceof Error ? error.message : String(error), retryable: false } });
}
};
self.onmessage = handle;
createModule().then((core) => {
session = new SimSession(core);
for (const event of queue.splice(0)) handle(event);
}).catch((error) => {
self.postMessage({ schema_version: 1, kind: "ERROR", context_id: "linuxcnc-2.10.0-pre0-wasm-m1-py-c1", session_id: "boot", seq: 0, payload: { status: "error", code: "worker_wasm_init", message_key: error instanceof Error ? error.message : String(error), retryable: false } });
});

View File

@@ -0,0 +1,6 @@
// AXIS ships these exact 21/22/24px bitmap assets in the source tree.
export const LINUXCNC_AXIS_ASSETS = "/linuxcnc/share/axis/images";
export function axisIcon(name: string): string {
return `${LINUXCNC_AXIS_ASSETS}/${name}`;
}

88
web/react/src/store.ts Normal file
View File

@@ -0,0 +1,88 @@
import type { Capabilities, MessageEnvelope, SimSnapshotV1 } from "./protocol";
export type SessionStatus = "disconnected" | "handshaking" | "attaching" | "loading" | "ready" | "running" | "paused" | "recovering" | "blocked" | "error";
export type FrontendState = {
status: SessionStatus;
contextId: string;
sessionId: string;
tick: number;
lastSeq: number;
capabilities: Capabilities | null;
snapshot: SimSnapshotV1 | null;
pending: string[];
error: { code: string; message?: string } | null;
alarms: Array<Record<string, unknown>>;
task: Record<string, unknown> | null;
program: Record<string, unknown> | null;
devices: Record<string, unknown> | null;
deviceIo: Record<string, unknown> | null;
hal: Record<string, unknown> | null;
plc: Record<string, unknown> | null;
python: Record<string, unknown> | null;
ioAck: Record<string, unknown> | null;
};
export const INITIAL_STATE: FrontendState = {
status: "disconnected",
contextId: "linuxcnc-2.10.0-pre0-wasm-m1-py-c1",
sessionId: "",
tick: 0,
lastSeq: 0,
capabilities: null,
snapshot: null,
pending: [],
error: null,
alarms: [],
task: null,
program: null,
devices: null,
deviceIo: null,
hal: null,
plc: null,
python: null,
ioAck: null,
};
function payload<T>(message: MessageEnvelope): T {
return message.payload as T;
}
function projection(result: Record<string, unknown>, message: MessageEnvelope, previous: FrontendState) {
const rawMotion = (result.motion ?? previous.snapshot?.motion ?? {}) as Record<string, unknown>;
const motionState = (rawMotion.state ?? rawMotion) as Record<string, unknown>;
const axesObject = motionState.axes as Record<string, unknown> | undefined;
const axes = Array.isArray(motionState.axes)
? motionState.axes as number[]
: [axesObject?.x, axesObject?.y, axesObject?.z].map((value) => typeof value === "number" ? value : 0);
const joints = Array.isArray(motionState.joints) ? motionState.joints as number[] : [];
const snapshot = { simulation_tick: message.simulation_tick ?? previous.tick, state_hash: String(result.state_hash ?? previous.snapshot?.state_hash ?? ""), task: result.task as SimSnapshotV1["task"], motion: { axes, joints, velocity: Array.isArray(motionState.velocity) ? motionState.velocity as number[] : [] } } satisfies SimSnapshotV1;
return { snapshot, task: (result.task as Record<string, unknown>) ?? previous.task, program: (result.program as Record<string, unknown>) ?? previous.program, devices: (result.devices as Record<string, unknown>) ?? previous.devices, deviceIo: (result.device_io as Record<string, unknown>) ?? previous.deviceIo, hal: (result.hal_runtime as Record<string, unknown>) ?? previous.hal, plc: (result.plc as Record<string, unknown>) ?? previous.plc, python: (result.python as Record<string, unknown>) ?? previous.python, ioAck: (result.io_ack as Record<string, unknown>) ?? previous.ioAck };
}
export function reduceFrontendState(state: FrontendState, message: MessageEnvelope): FrontendState {
const tick = message.simulation_tick ?? state.tick;
const base = { ...state, contextId: message.context_id, sessionId: message.session_id, lastSeq: Math.max(state.lastSeq, message.seq), tick, error: null };
switch (message.kind) {
case "CAPABILITIES": return { ...base, status: "handshaking", capabilities: payload<Capabilities>(message) };
case "SESSION_READY": return { ...base, status: "ready" };
case "RECOVERY_STATUS": return { ...base, status: payload<{ status?: string }>(message).status === "verified" ? "ready" : "recovering" };
case "STATE_SNAPSHOT": {
const value = payload<Record<string, unknown>>(message);
const projected = projection(value, message, state);
return { ...base, status: "ready", ...projected };
}
case "COMMAND_RESULT": {
const result = payload<Record<string, unknown>>(message);
const task = result.task as { state?: string } | undefined;
const status = result.status === "blocked" ? "blocked" : task?.state === "RUNNING" ? "running" : state.status === "running" ? "running" : "ready";
return { ...base, status, ...projection(result, message, state) };
}
case "ALARM": return { ...base, status: "blocked", alarms: [...state.alarms, payload<Record<string, unknown>>(message)] };
case "ERROR": {
const value = payload<{ code?: string; message_key?: string }>(message);
return { ...base, status: "error", error: { code: value.code ?? "worker_error", message: JSON.stringify(value) } };
}
default: return base;
}
}

158
web/react/src/styles.css Normal file
View File

@@ -0,0 +1,158 @@
@import "tailwindcss";
:root {
font-family: Arial, Helvetica, sans-serif;
color: #202020;
background: #cfcfcf;
font-size: 13px;
--face: #d8d8d8;
--face-light: #ededed;
--border: #8b8b8b;
--border-dark: #666;
--ink: #202020;
--muted: #5a5a5a;
--blue: #c8d7e8;
--focus: #356ca8;
}
* { box-sizing: border-box; }
html, body, #root { min-height: 100%; margin: 0; }
body { min-width: 320px; background: #cfcfcf; color: var(--ink); }
button, input, select, textarea { font: inherit; }
button { color: inherit; }
.axis-app { min-height: 100vh; display: flex; flex-direction: column; padding: 0; background: var(--face); }
.axis-window-header { display: flex; align-items: center; justify-content: space-between; min-height: 32px; padding: 3px 8px; border-bottom: 1px solid #777; background: #bcbcbc; }
.axis-brand { display: flex; align-items: center; gap: 6px; }
.axis-brand img { width: 24px; height: 24px; image-rendering: auto; }
.axis-brand strong { font-size: 15px; letter-spacing: .04em; }
.axis-brand span { color: #4b4b4b; font-size: 12px; }
.connection-state { padding: 2px 6px; border: 1px solid #777; background: #d6d6d6; font: 11px ui-monospace, monospace; }
.connection-state.ready, .connection-state.running { color: #185c1d; }
.connection-state.error, .connection-state.blocked { color: #8b1010; background: #f3d2d2; }
.axis-menubar { display: flex; align-items: stretch; min-height: 27px; padding-left: 3px; border-bottom: 1px solid #888; background: #d3d3d3; }
.axis-menu-item { position: relative; }
.axis-menu-item summary { padding: 5px 10px; cursor: pointer; list-style: none; user-select: none; }
.axis-menu-item summary::-webkit-details-marker { display: none; }
.axis-menu-item[open] summary, .axis-menu-item summary:hover { background: #b8cbe0; }
.axis-menu-popup { position: absolute; z-index: 20; top: 100%; left: 0; min-width: 220px; padding: 2px; border: 1px solid #777; box-shadow: 2px 2px 4px #777; background: #ededed; }
.axis-menu-popup button { display: flex; justify-content: space-between; width: 100%; padding: 5px 8px; border: 0; background: transparent; text-align: left; cursor: pointer; }
.axis-menu-popup button:hover, .axis-menu-popup button:focus-visible { outline: 1px solid var(--focus); background: #c8d7e8; }
.axis-menu-popup kbd { margin-left: 22px; color: #555; font: 11px ui-monospace, monospace; }
.axis-toolbar { display: flex; align-items: center; min-height: 35px; padding: 3px 5px; overflow-x: auto; border-bottom: 1px solid #8a8a8a; background: #d9d9d9; }
.axis-tool-button { display: inline-grid; width: 29px; height: 29px; flex: 0 0 29px; place-items: center; padding: 2px; border: 1px solid transparent; border-radius: 2px; background: transparent; cursor: pointer; }
.axis-tool-button img { width: 24px; height: 24px; object-fit: contain; pointer-events: none; }
.axis-tool-button:hover, .axis-tool-button:focus-visible { border-color: #6a8db6; background: #c8d7e8; outline: none; }
.axis-tool-button.pressed { border-color: #777; border-right-color: #eee; border-bottom-color: #eee; background: #c0c0c0; }
.axis-tool-button.danger { margin-right: 1px; }
.axis-tool-button:disabled { cursor: not-allowed; opacity: .48; }
.axis-toolbar-separator { width: 1px; height: 26px; flex: 0 0 1px; margin: 0 5px; border-left: 1px solid #969696; border-right: 1px solid #eee; }
.axis-case-strip { display: flex; align-items: center; gap: 7px; min-height: 34px; padding: 4px 8px; border-bottom: 1px solid #999; background: #c7c7c7; }
.axis-case-strip label { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; }
.axis-case-strip select { max-width: min(420px, 46vw); min-height: 24px; border: 1px solid var(--border-dark); background: white; }
.case-status, .command-status { overflow: hidden; color: #4d4d4d; font: 11px ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }
.command-status { margin-left: auto; }
.axis-top-pane { display: grid; grid-template-columns: minmax(360px, .48fr) minmax(430px, 1fr); height: clamp(360px, calc(100vh - 415px), 680px); min-height: 360px; flex: 0 0 auto; padding: 5px; gap: 5px; }
.axis-notebook { min-width: 0; display: flex; flex-direction: column; border: 1px solid var(--border-dark); background: var(--face-light); }
.notebook-tabs { display: flex; align-items: end; min-height: 29px; padding: 3px 3px 0; border-bottom: 1px solid #777; background: #c5c5c5; }
.notebook-tabs button { min-height: 26px; padding: 4px 12px; border: 1px solid transparent; border-bottom: 0; background: transparent; cursor: pointer; }
.notebook-tabs button.active { margin-bottom: -1px; border-color: #777; background: var(--face-light); font-weight: 700; }
.notebook-tabs button:hover { background: #dfe8f2; }
.manual-control, .mdi-control, .dro-view { min-height: 0; flex: 1; padding: 11px; overflow: auto; }
.manual-grid { display: grid; grid-template-columns: minmax(118px, .7fr) minmax(220px, 1.35fr); gap: 10px 14px; }
.manual-grid h3, .mdi-control h3 { margin: 0 0 6px; font-size: 13px; font-weight: 400; }
.manual-axis-block { grid-row: span 2; }
.axis-radio-grid { display: grid; grid-template-columns: repeat(3, 48px); gap: 5px; }
.axis-radio { display: inline-flex; align-items: center; gap: 3px; min-height: 26px; padding: 2px 3px; border: 1px solid transparent; cursor: pointer; }
.axis-radio input { margin: 0; }
.axis-radio.selected { border-color: #6685a5; background: var(--blue); }
.jog-row, .spindle-row, .manual-action-row { display: flex; align-items: center; flex-wrap: wrap; gap: 5px; }
.manual-action-row { margin-top: 8px; }
.manual-jog-block select { min-height: 25px; min-width: 104px; border: 1px solid var(--border-dark); background: white; }
.axis-text-button { min-height: 26px; padding: 3px 9px; border: 1px solid #858585; border-radius: 2px; background: linear-gradient(#f4f4f4, #d6d6d6); box-shadow: inset 1px 1px #fff; cursor: pointer; }
.axis-text-button:hover:not(:disabled), .axis-text-button:focus-visible { border-color: var(--focus); background: #d8e5f3; outline: none; }
.axis-text-button:active:not(:disabled), .axis-text-button.danger { background: #c3c3c3; box-shadow: inset 1px 1px #999; }
.axis-text-button:disabled { cursor: not-allowed; opacity: .5; }
.check-line { display: inline-flex; align-items: center; gap: 4px; min-height: 25px; margin-top: 6px; }
.check-line input { margin: 0; }
.manual-spindle-block, .manual-coolant-block { min-width: 0; }
.manual-coolant-block { grid-column: 1; }
.spindle-row .axis-tool-button { width: 28px; height: 28px; }
.spindle-row .axis-tool-button img { width: 16px; height: 16px; }
.manual-sliders { display: grid; grid-template-columns: repeat(2, minmax(180px, 1fr)); gap: 6px 16px; margin-top: 13px; padding-top: 9px; border-top: 1px solid #b0b0b0; }
.manual-sliders label, .axis-slider label { display: grid; grid-template-columns: auto 46px; align-items: center; gap: 4px; }
.manual-sliders input, .axis-slider input { grid-column: 1 / -1; width: 100%; accent-color: #527ba2; }
output { font: 12px ui-monospace, monospace; text-align: right; }
.mdi-control { max-width: 540px; }
.mdi-label { display: grid; gap: 5px; }
.mdi-label input { min-height: 29px; padding: 4px 6px; border: 1px solid #777; background: white; font: 14px ui-monospace, monospace; }
.mdi-control > .axis-text-button { margin-top: 7px; min-width: 74px; }
.mdi-control h3 { margin-top: 18px; padding-bottom: 5px; border-bottom: 1px solid #aaa; }
.mdi-control ol { max-height: 220px; margin: 0; padding-left: 28px; overflow: auto; font: 12px ui-monospace, monospace; }
.mdi-control li button { border: 0; background: transparent; cursor: pointer; }
.mdi-control li button:hover { color: #245d91; text-decoration: underline; }
.empty-history { color: var(--muted); }
.axis-preview { display: flex; min-height: 0; flex: 1; padding: 4px; overflow: hidden; background: #101419; }
.three-viewer { position: relative; width: 100%; height: 100%; min-height: 0; flex: 1; overflow: hidden; border: 1px solid #303b43; background: #080e13; }
.three-viewer canvas { display: block; width: 100%; height: 100%; }
.three-viewer-label { position: absolute; z-index: 1; top: 6px; left: 7px; padding: 2px 5px; color: #c9e86d; background: rgb(8 14 19 / 75%); font: 10px ui-monospace, monospace; pointer-events: none; }
.dro-view { background: #ececec; }
.dro-mode-row, .dro-row { display: grid; grid-template-columns: 42px repeat(3, minmax(100px, 1fr)); align-items: center; gap: 9px; }
.dro-mode-row { padding: 5px 6px; border-bottom: 1px solid #888; color: #555; font-size: 11px; }
.dro-mode-row span { text-align: right; }
.dro-table { margin-top: 9px; border: 1px solid #888; background: #fafafa; }
.dro-row { min-height: 38px; padding: 4px 7px; border-bottom: 1px solid #c6c6c6; }
.dro-row:last-child { border-bottom: 0; }
.dro-row strong { font-size: 15px; }
.dro-row output { color: #111; font-size: 17px; }
.dro-footer { display: flex; justify-content: space-between; gap: 10px; margin-top: 12px; color: #555; font: 11px ui-monospace, monospace; }
.axis-metrics { padding: 4px 9px; border-top: 1px solid #999; background: #cecece; }
.axis-slider { display: grid; grid-template-columns: repeat(3, minmax(170px, 1fr)); gap: 12px; }
.axis-slider label { grid-template-columns: auto 48px; }
.axis-program-pane { display: grid; grid-template-columns: minmax(0, 1fr) 230px; flex: 0 0 205px; height: 205px; min-height: 205px; margin: 0 5px 5px; border: 2px solid #777; background: #f7f7f7; }
.program-editor-wrap { display: grid; grid-template-columns: 34px minmax(0, 1fr); min-width: 0; min-height: 0; }
.program-gutter { display: flex; flex-direction: column; align-items: end; padding: 6px 6px 6px 0; overflow: hidden; border-right: 1px solid #c2c2c2; color: #888; background: #e4e4e4; font: 12px/1.45 ui-monospace, monospace; user-select: none; }
.program-gutter span { height: 17px; }
.program-editor-wrap textarea { width: 100%; height: 100%; min-height: 180px; padding: 6px 8px; resize: none; border: 0; outline: none; background: #fff; color: #111; font: 12px/1.45 ui-monospace, monospace; tab-size: 4; }
.program-side { display: flex; flex-direction: column; gap: 12px; padding: 8px; border-left: 1px solid #aaa; background: #d9d9d9; }
.program-actions { display: flex; flex-wrap: wrap; gap: 5px; }
.active-gcodes-label { display: grid; gap: 5px; color: #333; }
.active-gcodes-label textarea { width: 100%; min-height: 84px; resize: vertical; border: 1px solid #999; background: white; color: #222; font: 11px/1.35 ui-monospace, monospace; }
.axis-info-bar { display: flex; align-items: stretch; min-height: 27px; margin-top: auto; border-top: 1px solid #777; background: #c4c4c4; }
.axis-info-bar > span { display: flex; align-items: center; min-height: 25px; padding: 2px 9px; border-right: 1px solid #999; font: 12px ui-monospace, monospace; }
.task-state { min-width: 92px; background: #ececec; font-weight: 700 !important; }
.task-state.running { color: #145d1c; }
.task-state.blocked, .task-state.error { color: #9b111e; background: #f2d6d6; }
.tool-state { flex: 1; }
.position-state { min-width: 230px; }
.footer-status { min-width: 180px; color: #505050; }
@media (max-width: 900px) {
.axis-top-pane { grid-template-columns: 1fr; height: auto; min-height: 0; flex: 0 0 auto; }
.axis-notebook { min-height: 330px; }
.axis-preview { min-height: 320px; }
.axis-program-pane { flex: 0 0 auto; height: auto; min-height: 205px; grid-template-columns: 1fr; }
.program-side { border-top: 1px solid #aaa; border-left: 0; }
.active-gcodes-label textarea { min-height: 58px; }
}
@media (max-width: 620px) {
.axis-brand span { display: none; }
.axis-case-strip { flex-wrap: wrap; }
.axis-case-strip select { max-width: 62vw; }
.command-status { margin-left: 0; width: 100%; }
.manual-grid, .manual-sliders, .axis-slider { grid-template-columns: 1fr; }
.manual-axis-block, .manual-coolant-block { grid-row: auto; grid-column: auto; }
.dro-mode-row, .dro-row { grid-template-columns: 27px repeat(3, minmax(0, 1fr)); gap: 4px; }
.dro-mode-row { font-size: 9px; }
.dro-row output { font-size: 13px; }
.dro-footer { flex-direction: column; gap: 3px; }
.axis-info-bar { flex-wrap: wrap; }
.position-state, .footer-status { min-width: 0; flex: 1; }
}

View File

@@ -0,0 +1,26 @@
import type { GuiFamily } from "./gui-domain";
export type VisualWorkflowDescriptor = {
family: GuiFamily;
model: "axis" | "vismach" | "plasma" | "preview";
snapshot_fields: readonly string[];
worker_capabilities: readonly string[];
host_boundary: "WEB-EQUIV" | "PY-WASM" | "REAL-EXCLUDED";
};
export const VISUAL_WORKFLOWS: Record<GuiFamily, VisualWorkflowDescriptor> = {
axis: { family: "axis", model: "axis", snapshot_fields: ["motion.axes", "motion.joints", "program", "toolpath"], worker_capabilities: ["snapshot", "trajectory"], host_boundary: "WEB-EQUIV" },
gmoccapy: { family: "gmoccapy", model: "axis", snapshot_fields: ["motion.axes", "program", "tool", "alarms"], worker_capabilities: ["snapshot", "trajectory"], host_boundary: "WEB-EQUIV" },
touchy: { family: "touchy", model: "axis", snapshot_fields: ["motion.axes", "task", "program"], worker_capabilities: ["snapshot"], host_boundary: "WEB-EQUIV" },
gscreen: { family: "gscreen", model: "axis", snapshot_fields: ["motion.axes", "hal", "program"], worker_capabilities: ["snapshot", "hal_sample"], host_boundary: "WEB-EQUIV" },
qtvcp: { family: "qtvcp", model: "axis", snapshot_fields: ["motion.axes", "vcp", "tool", "hal"], worker_capabilities: ["snapshot", "vcp_pins", "hal_sample"], host_boundary: "PY-WASM" },
qtdragon: { family: "qtdragon", model: "axis", snapshot_fields: ["motion.axes", "vcp", "tool"], worker_capabilities: ["snapshot", "vcp_pins"], host_boundary: "PY-WASM" },
pyvcp: { family: "pyvcp", model: "axis", snapshot_fields: ["motion.axes", "vcp", "hal"], worker_capabilities: ["snapshot", "vcp_pins"], host_boundary: "PY-WASM" },
gladevcp: { family: "gladevcp", model: "axis", snapshot_fields: ["motion.axes", "vcp", "hal"], worker_capabilities: ["snapshot", "vcp_pins"], host_boundary: "PY-WASM" },
vismach: { family: "vismach", model: "vismach", snapshot_fields: ["motion.axes", "motion.joints", "pose", "toolpath", "stock"], worker_capabilities: ["snapshot", "trajectory", "model"], host_boundary: "WEB-EQUIV" },
ngcgui: { family: "ngcgui", model: "preview", snapshot_fields: ["program", "mdi", "toolpath"], worker_capabilities: ["snapshot", "trajectory"], host_boundary: "PY-WASM" },
qtplasmac: { family: "qtplasmac", model: "plasma", snapshot_fields: ["motion.axes", "program", "material", "torch", "alarms"], worker_capabilities: ["snapshot", "trajectory", "material_table"], host_boundary: "PY-WASM" },
plasma: { family: "plasma", model: "plasma", snapshot_fields: ["motion.axes", "program", "material", "torch", "alarms"], worker_capabilities: ["snapshot", "trajectory", "material_table"], host_boundary: "PY-WASM" },
};
export function visualWorkflowFor(family: GuiFamily): VisualWorkflowDescriptor { return VISUAL_WORKFLOWS[family]; }

View File

@@ -0,0 +1,59 @@
import { CONTEXT_ID, PROTOCOL_SCHEMA, isEnvelope, type MessageEnvelope } from "./protocol";
type Listener = (message: MessageEnvelope) => void;
export class ReactWorkerClient {
private readonly worker: Worker;
private readonly contextId: string;
private readonly sessionId: string;
private sequence = 0;
private listeners = new Set<Listener>();
private pending = new Map<number, { resolve: (message: MessageEnvelope) => void; reject: (error: Error) => void }>();
private readyPromise: Promise<void> | null = null;
constructor({ worker, contextId = CONTEXT_ID, sessionId = `react-${Date.now()}` }: { worker?: Worker; contextId?: string; sessionId?: string } = {}) {
// The entry imports the source-compatible SimSession; keep it an ES module
// so Vite does not wrap it as a classic worker and drop its imports.
this.worker = worker ?? new Worker(new URL("./sim-worker-entry.ts", import.meta.url), { type: "module" });
this.contextId = contextId;
this.sessionId = sessionId;
this.worker.onmessage = (event: MessageEvent<unknown>) => {
if (!isEnvelope(event.data)) return;
for (const listener of this.listeners) listener(event.data);
const pending = this.pending.get(event.data.seq);
if (pending) {
this.pending.delete(event.data.seq);
if (event.data.kind === "ERROR" || event.data.payload && typeof event.data.payload === "object" && (event.data.payload as { status?: string }).status === "blocked") pending.reject(new Error(String((event.data.payload as { code?: string }).code ?? "worker_error")));
else pending.resolve(event.data);
}
};
this.worker.onerror = (event) => {
const message: MessageEnvelope = {
schema_version: PROTOCOL_SCHEMA,
kind: "ERROR",
context_id: this.contextId,
session_id: this.sessionId,
seq: ++this.sequence,
payload: { code: "worker_transport_error", message_key: event.message || "worker_failed_to_load" },
};
for (const listener of this.listeners) listener(message);
for (const pending of this.pending.values()) pending.reject(new Error("worker_transport_error"));
this.pending.clear();
};
}
subscribe(listener: Listener): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); }
send(kind: string, payload: unknown = {}, operationId?: string): Promise<MessageEnvelope> {
const message: MessageEnvelope = { schema_version: PROTOCOL_SCHEMA, kind, context_id: this.contextId, session_id: this.sessionId, seq: ++this.sequence, ...(operationId ? { operation_id: operationId } : {}), payload };
return new Promise((resolve, reject) => { this.pending.set(message.seq, { resolve, reject }); this.worker.postMessage(message); });
}
hello(): Promise<MessageEnvelope> { return this.send("HELLO", { supported_schema_versions: [PROTOCOL_SCHEMA], requested_features: ["snapshot", "trajectory"] }); }
attach(): Promise<MessageEnvelope> { return this.send("SESSION_ATTACH", {}, "react-session-attach"); }
ensureSession(): Promise<void> {
if (!this.readyPromise) this.readyPromise = this.hello().then(() => this.attach()).then(() => undefined);
return this.readyPromise;
}
close(): void { this.worker.terminate(); }
}

21
web/react/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}

52
web/react/vite.config.ts Normal file
View File

@@ -0,0 +1,52 @@
import { cpSync, createReadStream, existsSync, statSync } from "node:fs";
import { extname, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig, type Plugin } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
const sourceRoot = resolve(repoRoot, "linuxcnc");
const contentTypes: Record<string, string> = {
".ini": "text/plain; charset=utf-8", ".hal": "text/plain; charset=utf-8", ".inc": "text/plain; charset=utf-8", ".tbl": "text/plain; charset=utf-8", ".var": "text/plain; charset=utf-8", ".tcl": "text/plain; charset=utf-8", ".ngc": "text/plain; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8", ".gif": "image/gif", ".png": "image/png", ".svg": "image/svg+xml",
};
function linuxcncAssets(): Plugin {
return {
name: "linuxcnc-exact-path-assets",
configureServer(server) {
server.middlewares.use((request, response, next) => {
const requestPath = request.url?.split("?", 1)[0] ?? "";
const mounts: Record<string, string> = { "/linuxcnc/": "linuxcnc", "/worker/": "web/worker", "/workspace/": "web/workspace", "/build-wasm/": "build-wasm" };
const mount = Object.keys(mounts).find((prefix) => requestPath.startsWith(prefix));
if (!mount) { next(); return; }
const mountRoot = resolve(repoRoot, mounts[mount]);
const relativePath = decodeURIComponent(requestPath.slice(mount.length));
const target = resolve(mountRoot, relativePath);
const safe = relative(mountRoot, target);
if (safe.startsWith("..") || safe.includes("\\") || !existsSync(target) || !statSync(target).isFile()) { response.statusCode = 404; response.end(); return; }
response.statusCode = 200;
response.setHeader("Content-Type", contentTypes[extname(target).toLowerCase()] ?? "application/octet-stream");
createReadStream(target).pipe(response);
});
},
writeBundle(options) {
const output = options.dir ? resolve(options.dir) : resolve(repoRoot, "web/react/dist");
const copies = [
["linuxcnc/configs/sim/axis", "linuxcnc/configs/sim/axis"],
["linuxcnc/share/axis/images", "linuxcnc/share/axis/images"],
["linuxcnc/lib/hallib", "linuxcnc/lib/hallib"],
["linuxcnc/configs/sim/qtvcp_screens/qtaxis_lathe/qtaxis/images", "linuxcnc/configs/sim/qtvcp_screens/qtaxis_lathe/qtaxis/images"],
];
for (const [from, to] of copies) cpSync(resolve(repoRoot, from), resolve(output, to), { recursive: true, dereference: false });
},
};
}
export default defineConfig({
plugins: [linuxcncAssets(), tailwindcss(), react()],
base: "./",
worker: { format: "iife" },
server: { fs: { allow: ["../.."] } },
build: { outDir: "dist", emptyOutDir: true },
});

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();

Some files were not shown because too many files have changed in this diff Show More