Make machine configuration authoritative and restore native checkpoints exactly

This commit is contained in:
2026-09-18 09:09:41 +08:00
parent 6a69511276
commit 4a568f1f4c
94 changed files with 10882 additions and 301 deletions

View File

@@ -1,3 +1,4 @@
import { exportNativeCheckpoint, restoreNativeCheckpoint, validateNativeCheckpoint } from "./native-checkpoint.mjs";
import { ReplayTimeline, fnv1a64 } from "./replay.mjs";
import { capabilities, validateLimit } from "./capabilities.mjs";
import { mapCoreError } from "./error-map.mjs";
@@ -10,7 +11,7 @@ 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 { OperationLedger, LEDGER_SCHEMA } from "./operation-ledger.mjs";
import { MotionState } from "./motion-state.mjs";
import { PlcRuntime } from "./plc-runtime.mjs";
import { HalRuntime } from "./hal-runtime.mjs";
@@ -31,8 +32,12 @@ import { WasmXyzacIo } from "./wasm-xyzac-io.mjs";
import { WasmXyzacDeviceIo } from "./wasm-xyzac-device-io.mjs";
import { WasmProgramRuntime } from "./wasm-program-runtime.mjs";
import { axisGate, xyzacGate, xyzacGateMatrix } from "../app/axis-contract.mjs";
import { machineDefinition } from "../workspace/machine-definitions.mjs";
import { readTrtGeometry, TRT_GEOMETRY_KEYS } from "../workspace/trt-geometry.mjs";
import { machineDefinition, DEFAULT_MACHINE } from "../workspace/machine-definitions.mjs";
import { WEB_SETTINGS } from "../workspace/web-settings.mjs";
import { TRT_GEOMETRY_KEYS } from "../workspace/trt-geometry.mjs";
import { readTrtConfiguration } from "../workspace/trt-configuration.mjs";
const TRT_EXECUTION_KEYS = Object.freeze(["planner", "xyzacProgramKinematicsMode", "pendingProgramControl", "pendingTaskSync", "pendingXyzacMotion", "xyzacStepTargetId", "pendingServoSync", "xyzacExecutionWaitNextMotion", "xyzacExecutionWaitSamples", "xyzacLastEnqueuedKinematicsMode", "xyzacDeferredPhysicalSwitchMode", "xyzacDeferredBlockedActiveId", "xyzacDeferredControlMotionType", "xyzacQueuedLookaheadTargetId", "xyzacAllowQueuedLookahead", "xyzacLastServoActiveId", "xyzacLastQueuedMotion", "xyzacDeferredPhysicalPose", "xyzacDeferredPhysicalPoseApplied", "xyzacAppendDeferredOnActive", "xyzacDeferredCompletionActiveId"]);
const PROTOCOL_SCHEMA = 1;
const CONTEXT_ID = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1";
@@ -138,10 +143,11 @@ export class SimSession {
this.xyzacDeviceIo = null;
this.xyzacProgram = null;
this.machineDefinition = null;
this.machineConfigurationHash = null;
this.homingHistory = [];
this.xyzacProgramKinematicsMode = XYZAC_MODES.identity;
this.xyzacRapidVelocity = 35;
this.xyzacMotionCycleTime = 0.01;
this.xyzacRapidVelocity = null;
this.xyzacMotionCycleTime = WEB_SETTINGS.runtime.io.cycle_time;
this.xyzacTaskCycleTicks = 1;
this.xyzacAxisVelocityLimits = {};
this.xyzacAxisAccelerationLimits = {};
@@ -300,7 +306,7 @@ export class SimSession {
velocity: wasmMotion.status === "verified" ? wasmMotion.state.velocity : 0,
velocity_mm_min: Number(wasmMotion.state?.velocity_mm_min ?? 0),
feed_command_mm_min: Number(sourceProgram?.feed ?? 0),
feed_override: Number(wasmMotion.state?.feed_override ?? 1),
feed_override: Number(wasmMotion.state?.feed_override ?? this.wasmMotion.controls?.feed_override ?? 1),
spindle_override: Number(ioState?.spindles?.[0]?.override ?? 1),
spindle_speed_rpm: Number(spindle?.speed_rpm ?? 0),
spindle_command_rpm: Number(spindle?.command_rpm ?? 0),
@@ -310,6 +316,7 @@ export class SimSession {
jog_scale: Number(wasmMotion.state?.jog_scale ?? 1),
jog_velocity_linear: Number(wasmMotion.state?.jog_velocity_linear ?? 0),
jog_velocity_angular: Number(wasmMotion.state?.jog_velocity_angular ?? 0),
max_feed_override: this.wasmMotion.maxFeedOverride ?? null,
max_velocity: Number(wasmMotion.state?.velocity_limit ?? 0),
homed: Boolean(home.state?.homed),
homed_axes: Array.from({ length: 5 }, (_, index) => Boolean(Number(home.state?.homed_mask ?? 0) & (1 << index))),
@@ -330,7 +337,7 @@ export class SimSession {
if (this.xyzacProgram) return this.wasmMotionProjection();
if (this.motionAuthority !== "wasm") return this.motion.export();
const wasm = this.wasmMotion.read();
return wasm.status === "verified" ? { ...this.motion.export(), axes: { ...wasm.state.position } } : this.motion.export();
return wasm.status === "verified" ? { ...this.motion.export(), axes: { ...wasm.state.position }, queue_depth: wasm.state.queue_depth } : this.motion.export();
}
wasmDeviceProjection({ includeBytes = false } = {}) {
@@ -359,7 +366,7 @@ export class SimSession {
if (requirement.status !== "verified") return requirement;
if (!requirement.required) return { status: "verified", count: 0 };
const basePath = String(programPath ?? "").split("/").slice(0, -1).join("/");
const coordinates = this.machineDefinition?.coordinates ?? "XYZAC";
const coordinates = this.machineDefinition?.coordinates ?? DEFAULT_MACHINE.coordinates;
const stem = coordinates.toLowerCase();
const rows = [[`remap_subs/${stem}_switchkins_sub.ngc`, `${stem}_switchkins_sub`], [`remap_subs/helix_${stem.slice(3)}.ngc`, `helix_${stem.slice(3)}`]];
for (const [suffix, name] of rows) {
@@ -407,7 +414,7 @@ export class SimSession {
? { schema: "wasm_authority_boundary.v1", source: "linuxcnc-wasm-worker", state_hash: fnv1a64({ motion: wasmMotion, kinematics: this.xyzacKinematics?.snapshot() ?? null, task }) }
: this.authoritative.export();
const axis = axisState({ task, program: program ?? {}, motion, devices, io: this.xyzacIo ? this.xyzacIo.export() : {}, device_io: deviceIo });
const xyzac = this.xyzacKinematics ? { coordinates: this.machineDefinition?.coordinates ?? "XYZAC", joint_count: 5, hal_pin_vars: true, switchkins_net: true, finite_offsets: true, userk_capability: true, active_type: this.xyzacKinematics.snapshot().active_type } : null;
const xyzac = this.xyzacKinematics ? { coordinates: this.machineDefinition?.coordinates ?? DEFAULT_MACHINE.coordinates, joint_count: this.xyzacKinematics.coordinates.length, hal_pin_vars: true, switchkins_net: true, finite_offsets: true, userk_capability: true, active_type: this.xyzacKinematics.snapshot().active_type } : null;
const gateInput = { task, program: program ?? {}, motion, devices, io: this.xyzacIo ? this.xyzacIo.export() : {}, device_io: deviceIo, xyzac, taskfile: Boolean(this.xyzacProgram ? this.xyzacProgram.snapshot().status !== 0 : this.program.loaded), kinematicsIdentity: this.motionAuthority === "wasm" };
const gateActions = ["load", "run", "runLine", "step", "pause", "resume", "abort", "restart", "reload", "edit", "mdi", "jogStart", "jogStop", "home", "estop", "recover", "override", "overrideLimits", "reset", "spindle", "coolant", "touchOff", "toolTouchOff"];
const ui = axisSourceUi({ task_state: axis.source_task_state, interp_state: axis.source_interp_state,
@@ -473,7 +480,7 @@ export class SimSession {
// renderer can audit that its geometry matches the loaded LinuxCNC case.
const modelResource = this.configurationState?.exact_resources?.find?.((row) => row.role === "vismach_source");
const machineDefinition = this.machineDefinition;
const modelSourcePath = this.machineDefinition?.source_path ?? "linuxcnc/src/hal/user_comps/vismach/xyzac-trt-gui.py";
const modelSourcePath = this.machineDefinition?.source_path ?? DEFAULT_MACHINE.source_path;
const modelSourceText = this.workspaceFiles?.[modelSourcePath];
const machineModel = this.xyzacProgram ? {
schema: "linuxcnc-vismach-model.v1",
@@ -481,7 +488,7 @@ export class SimSession {
source: "linuxcnc-wasm-worker",
source_path: modelResource?.path ?? modelSourcePath,
source_hash: modelResource?.data_hash ?? this.visualModelSourceHash ?? (typeof modelSourceText === "string" ? fnv1a64(modelSourceText) : null),
coordinate_system: machineDefinition?.coordinates ?? "XYZAC",
coordinate_system: machineDefinition?.coordinates ?? DEFAULT_MACHINE.coordinates,
geometry_variant: machineDefinition?.coordinates === "XYZBC" ? "moving-head-y-tilt" : "moving-knee-x-tilt",
tilt_axis: machineDefinition?.coordinates === "XYZBC" ? "Y" : "X",
rotary_axis: "Z",
@@ -507,7 +514,7 @@ export class SimSession {
coordinate_offset: program?.coordinate_context?.offsets?.[(program?.coordinate_context?.system ?? 1) - 1]?.slice(0, 3) ?? null,
...(machineModel ? { machine_model: machineModel } : {}),
};
const coordinateAxes = machineDefinition?.axes ?? ["x", "y", "z", "a", "c"];
const coordinateAxes = machineDefinition?.axes ?? DEFAULT_MACHINE.axes;
const axisCoordinates = coordinateAxes.map((axis) => position[axis] ?? 0);
const coordinateContext = program?.coordinate_context;
const activeOffsets = coordinateContext?.offsets?.[coordinateContext.system - 1] ?? Array(9).fill(0);
@@ -540,7 +547,7 @@ export class SimSession {
authoritative,
motion_authority: this.motionAuthority,
task_authority: this.task.authority(),
...(this.xyzacKinematics ? { kinematics: this.xyzacKinematics.snapshot() } : {}),
...(this.xyzacKinematics ? { kinematics: this.xyzacKinematics.snapshot(), machine_configuration_hash: this.machineConfigurationHash } : {}),
...(includeStableRuntime && this.xyzacIo ? { io: this.xyzacIo.export() } : {}),
...(xyzac ? { xyzac: { ...xyzac, coordinates: machineDefinition?.coordinates ?? xyzac.coordinates } } : {}),
};
@@ -571,7 +578,7 @@ export class SimSession {
commandGate(input) {
const task = this.task.export();
const program = this.xyzacProgram ? this.xyzacProgram.snapshot() : this.program.loaded ? this.program.snapshot() : {};
const motion = this.xyzacProgram ? this.wasmMotionProjection() : this.motion.export();
const motion = this.motionProjection();
const devices = this.wasmDeviceProjection();
// The selected XYZAC case is governed by the native WASM task/IO/program
// handlers. JS only receives the result; it must not pre-reject a command
@@ -582,7 +589,7 @@ export class SimSession {
const type = Number(input.payload?.type);
const action = input.kind === "VISUAL_CLEAR" ? "vismachClear" : type === 0 ? "switchkinsIdentity" : type === 1 ? "switchkinsXyzac" : type === 2 ? "switchkinsUserk" : null;
if (!action) return { action: null, enabled: false, reason: "xyzac_kinematics_type_invalid" };
return xyzacGate(action, { task, program, motion, devices, xyzac: this.xyzacKinematics ? { coordinates: this.machineDefinition?.coordinates ?? "XYZAC", joint_count: 5, hal_pin_vars: true, switchkins_net: true, finite_offsets: true, userk_capability: true } : null });
return xyzacGate(action, { task, program, motion, devices, xyzac: this.xyzacKinematics ? { coordinates: this.machineDefinition?.coordinates ?? DEFAULT_MACHINE.coordinates, joint_count: this.xyzacKinematics.coordinates.length, hal_pin_vars: true, switchkins_net: true, finite_offsets: true, userk_capability: true } : null });
}
const action = PANEL_GATE_ACTIONS[input.kind];
if (!action) return { action: null, enabled: true, reason: "axis_internal_command" };
@@ -688,7 +695,7 @@ export class SimSession {
if (!this.xyzacProgram) return { status: "error", code: "xyzac_program_unavailable" };
const payload = input.payload ?? {};
const axis = String(payload.axis ?? "").trim().toUpperCase();
const coordinateAxes = this.machineDefinition?.axes ?? ["x", "y", "z", "a", "c"];
const coordinateAxes = this.machineDefinition?.axes ?? DEFAULT_MACHINE.axes;
if (!coordinateAxes.includes(axis.toLowerCase())) return { status: "error", code: "touch_off_axis_invalid" };
const value = Number(payload.value);
if (!Number.isFinite(value)) return { status: "error", code: "touch_off_value_invalid" };
@@ -732,6 +739,14 @@ export class SimSession {
}
}
checkpointLedger() {
// Durable receipts must not recursively contain older checkpoints or
// complete UI snapshots. Preserve intent/result identity for deduplication.
const omitted = new Set(["panel_snapshot", "configuration", "program", "motion", "wasm_motion", "wasm_home_limit", "device_io", "devices", "hal_runtime", "snapshot", "state", "checkpoint", "recovery_checkpoint", "preview", "operations", "ledger"]);
const entries = [...this.ledger.entries.values()].map((entry) => ({ ...entry, response: entry.response ? { ...entry.response, payload: Object.fromEntries(Object.entries(entry.response.payload ?? {}).filter(([key]) => !omitted.has(key))) } : null }));
return { schema: LEDGER_SCHEMA, entries: structuredClone(entries) };
}
stateForCheckpoint() {
const state = {
context_id: this.contextId,
@@ -742,7 +757,7 @@ export class SimSession {
devices: this.wasmDeviceProjection({ includeBytes: true }),
motion: this.xyzacProgram ? this.wasmMotionProjection() : this.motion.export(),
plc: this.plc.export(),
hal_runtime: this.xyzacIo ? this.xyzacIo.halExport() : this.hal.export(),
hal_runtime: this.xyzacIo ? this.xyzacIo.halExport({ includeState: true }) : this.hal.export(),
device_io: this.xyzacDeviceIo ? this.xyzacDeviceIo.export() : this.xyzacIo ? this.xyzacIo.export() : this.deviceIo.export(),
configuration_batch: this.configurationBatch.export(),
io_ack: this.ioAck.export(),
@@ -754,15 +769,19 @@ export class SimSession {
wasm_motion: this.wasmMotion.exportState(),
task_authority: this.task.authority(),
wasm_home_limit: this.wasmHomeLimit.exportState(),
...(this.xyzacKinematics ? { kinematics: this.xyzacKinematics.snapshot() } : {}),
...(this.xyzacKinematics ? { kinematics: this.xyzacKinematics.snapshot(), machine_configuration_hash: this.machineConfigurationHash } : {}),
preview: this.previewState,
live_path_epoch: this.livePathEpoch,
task: this.task.export(),
scheduler: this.scheduler.export(),
authoritative: this.xyzacProgram ? { schema: "wasm_authority_boundary.v1", source: "linuxcnc-wasm-worker" } : this.authoritative.export(),
ledger: this.ledger.export(),
ledger: this.xyzacProgram ? this.checkpointLedger() : this.ledger.export(),
};
if (this.xyzacProgram) state.program = { ...this.xyzacProgram.snapshot(), source_text: this.xyzacProgram.source };
if (this.xyzacProgram) {
state.program = { ...this.xyzacProgram.checkpoint(), source_text: this.xyzacProgram.source };
state.trt_execution = Object.fromEntries(TRT_EXECUTION_KEYS.map((key) => [key, structuredClone(this[key])]));
state.native_runtime = Object.fromEntries(["sim_core", "sim_protocol", "sim_event"].map((prefix) => [prefix, exportNativeCheckpoint(this.core, prefix)]));
}
else if (this.program.loaded) state.program = this.program.exportState();
return state;
}
@@ -792,7 +811,7 @@ export class SimSession {
task_authority: this.task.authority(),
wasm_home_limit: this.wasmHomeLimit.exportState(),
live_path_epoch: this.livePathEpoch,
...(this.xyzacKinematics ? { kinematics: this.xyzacKinematics.snapshot() } : {}),
...(this.xyzacKinematics ? { kinematics: this.xyzacKinematics.snapshot(), machine_configuration_hash: this.machineConfigurationHash } : {}),
};
if (this.xyzacProgram) value.program = { ...this.xyzacProgram.snapshot(), source_text: this.xyzacProgram.source };
else if (this.program.loaded) value.program = this.program.exportState();
@@ -824,6 +843,8 @@ export class SimSession {
// recovery command, but are redundant for a power-off restart. Omitting
// them keeps the browser durable record bounded during long JOG runs.
delete state.ledger;
state.power_off_preview_hash = state.preview?.preview_hash ?? null;
delete state.preview;
const compact = {
schema: "xyzac-power-off-checkpoint.v1",
context_id: this.contextId,
@@ -834,10 +855,51 @@ export class SimSession {
return { ...compact, checkpoint_hash: fnv1a64(compact) };
}
validateRecoveryGeometry(state) {
if (!this.xyzacKinematics) return;
const current = this.xyzacKinematics.snapshot();
restoreTrtExecution(state, transport = true) {
if (!state.trt_execution || !state.native_runtime || !TRT_EXECUTION_KEYS.every((key) => Object.hasOwn(state.trt_execution, key))) throw new Error("trt_checkpoint_execution_required");
for (const key of TRT_EXECUTION_KEYS) this[key] = structuredClone(state.trt_execution[key]);
restoreNativeCheckpoint(this.core, "sim_core", state.native_runtime.sim_core);
if (transport) for (const prefix of ["sim_protocol", "sim_event"]) restoreNativeCheckpoint(this.core, prefix, state.native_runtime[prefix]);
this.xyzacIo.restoreHalState(state.hal_runtime);
}
validateRecoveryGeometry(state, configuration = null) {
if (!this.xyzacKinematics && !configuration) return;
const supplied = configuration ? readTrtConfiguration(configuration) : null;
const expectedHash = supplied ? fnv1a64({ kinematics: supplied.kinematics, home_limits: supplied.home_limits, motion_scale_min: WEB_SETTINGS.ui.motion_scale_min }) : this.machineConfigurationHash;
if (state?.machine_configuration_hash !== expectedHash) throw new Error("trt_recovery_configuration_mismatch");
const current = supplied?.kinematics ?? this.xyzacKinematics.snapshot();
if (!state?.kinematics || state.kinematics.coordinates !== current.coordinates || TRT_GEOMETRY_KEYS.some((key) => Number(state.kinematics.offsets?.[key] ?? 0) !== current.offsets[key])) throw new Error("trt_recovery_geometry_mismatch");
if (!state.trt_execution || !TRT_EXECUTION_KEYS.every((key) => Object.hasOwn(state.trt_execution, key))) throw new Error("trt_checkpoint_execution_required");
for (const [prefix, value] of [["sim_program", state.program?.native_state], ["sim_linuxcnc_tp", state.wasm_motion?.native_state], ["sim_hal", state.hal_runtime?.native_state], ...["sim_core", "sim_protocol", "sim_event"].map((key) => [key, state.native_runtime?.[key]])]) validateNativeCheckpoint(this.core, prefix, value);
}
restoreTrtProgram(state) {
if (typeof state.program?.source_text !== "string") throw new Error("trt_checkpoint_program_missing");
const previous = this.xyzacProgram;
const candidate = new WasmProgramRuntime(this.core);
try {
if (state.program.source_text) {
const loaded = candidate.load(state.program.source_text, state.program.path ?? null);
if (loaded.status !== "verified") throw new Error(loaded.code);
if (Array.isArray(state.program.subroutine_sources)) {
for (const sub of state.program.subroutine_sources) {
const added = candidate.addSubroutine(sub.name, sub.source, sub.path);
if (added.status !== "verified") throw new Error(added.code);
}
} else {
this.xyzacProgram = candidate;
const subroutines = this.loadXyzacSubroutines(state.program.path ?? null);
if (subroutines.status !== "verified") throw new Error(subroutines.code);
}
}
const result = candidate.restoreState(state.program);
if (result.status === "error") throw new Error(result.code ?? "trt_checkpoint_program_restore_failed");
this.xyzacProgram = candidate;
previous.destroy();
} catch (error) {
this.xyzacProgram = previous; candidate.destroy(); throw error;
}
}
restoreCompactPowerOff(record, transportSeq) {
@@ -850,17 +912,13 @@ export class SimSession {
const liveOperations = [...this.operations.values()];
try {
this.validateRecoveryGeometry(state);
if (!this.xyzacProgram || !state.program?.source_text) throw new Error("power_off_program_missing");
const loaded = this.xyzacProgram.load(state.program.source_text, state.program.path ?? null);
if (loaded.status !== "verified") throw new Error(loaded.code);
const subroutines = this.loadXyzacSubroutines(state.program.path ?? null);
if (subroutines.status !== "verified") throw new Error(subroutines.code);
if (this.xyzacProgram.restoreState(state.program).status === "error") throw new Error("power_off_program_restore_failed");
this.restoreTrtProgram(state);
if (state.wasm_motion) this.wasmMotion.restoreState(state.wasm_motion);
if (state.wasm_home_limit) this.wasmHomeLimit.restoreState(state.wasm_home_limit);
if (state.device_io && this.xyzacDeviceIo) this.xyzacDeviceIo.restoreState(state.device_io);
if (state.devices && this.xyzacIo) this.xyzacIo.restoreState(state.devices);
if (state.kinematics && this.xyzacKinematics) this.xyzacKinematics.restoreState(state.kinematics);
this.restoreTrtExecution(state, false);
if (state.task) {
const task = { ...state.task, session_id: this.sessionId, seq: transportSeq, simulation_tick: record.simulation_tick };
delete task.state_hash;
@@ -875,7 +933,12 @@ export class SimSession {
this.callbacks = new PythonCallbacks(); if (state.callbacks) this.callbacks.restore(state.callbacks);
this.configurationState = state.configuration ?? this.configurationState;
this.halState = state.hal ?? this.halState;
this.previewState = state.preview ?? this.previewState;
if (state.preview) this.previewState = state.preview;
else if (state.program.source_text) {
const preview = this.xyzacProgram.preview();
if (preview.status !== "verified" || state.power_off_preview_hash && state.power_off_preview_hash !== preview.preview_hash) throw new Error("power_off_preview_restore_mismatch");
this.previewState = preview;
} else this.previewState = null;
this.livePathEpoch = Number.isSafeInteger(state.live_path_epoch) ? state.live_path_epoch : this.livePathEpoch;
this.python = new PythonBridge(); if (state.python) this.python.restoreState(state.python);
const liveEvents = liveTimeline.events.events;
@@ -897,6 +960,7 @@ export class SimSession {
restorePersistedPowerOff(checkpoint, transportSeq) {
if (!checkpoint || !Number.isSafeInteger(transportSeq) || transportSeq < 1) return { status: "blocked", code: "runtime_checkpoint_input_invalid", reason: "checkpoint_or_sequence_invalid" };
if (Number.isSafeInteger(checkpoint.simulation_tick) && checkpoint.simulation_tick < this.timeline.events.tick) return { status: "blocked", code: "runtime_checkpoint_requires_new_session", reason: "recovery_must_not_rewrite_transport_history" };
if (checkpoint.schema === "xyzac-power-off-checkpoint.v1") return this.restoreCompactPowerOff(checkpoint, transportSeq);
const liveTimeline = this.timeline.export();
const liveLedger = this.ledger;
@@ -914,7 +978,7 @@ export class SimSession {
const unsigned = Object.fromEntries(Object.entries(checkpointForRestore).filter(([key]) => key !== "checkpoint_hash"));
checkpointForRestore.checkpoint_hash = fnv1a64(unsigned);
}
const restored = this.restoreCheckpoint(checkpointForRestore);
const restored = this.restoreCheckpoint(checkpointForRestore, null, undefined, false);
if (restored.status !== "recovered") return restored;
const restoredTick = this.tick;
const restoredTask = this.task.export();
@@ -953,6 +1017,12 @@ export class SimSession {
importTrace(trace) {
const validated = validateTrace(trace, { contextId: this.contextId });
this.validateRecoveryGeometry(trace.state);
if (this.xyzacProgram && trace.state?.program?.native_state) {
const checkpoint = createCheckpoint({ contextId: this.contextId, timeline: validated.timeline, state: trace.state, operations: trace.state.ledger?.entries ?? [] });
const result = this.restoreCheckpoint(checkpoint);
if (result.status !== "recovered") throw new Error(result.blocked?.[0]?.reason ?? "trt_trace_restore_failed");
return { status: "verified", trace_hash: trace.trace_hash, state_hash: fnv1a64(trace.state), seq: trace.seq, tick: trace.tick };
}
this.timeline = validated.timeline;
this.created = Boolean(trace.state?.created);
if (trace.state?.ledger?.entries) this.ledger = new OperationLedger(trace.state.ledger.entries);
@@ -986,27 +1056,22 @@ export class SimSession {
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) {
restoreCheckpoint(checkpoint, events = null, reconcile = undefined, transport = true) {
try {
validateCheckpoint(checkpoint, { contextId: this.contextId });
this.validateRecoveryGeometry(checkpoint.state);
} catch (error) {
return { schema: "simulation-recovery.v1", status: "blocked", context_id: this.contextId, checkpoint_hash: checkpoint?.checkpoint_hash ?? null, blocked: [{ reason: String(error?.message ?? error) }] };
}
const tailEvents = Array.isArray(events) ? events : events?.events?.events ?? events?.events ?? [];
if (this.xyzacProgram && Array.isArray(tailEvents) && tailEvents.some((event) => event.seq > checkpoint.event_cursor)) return { schema: "simulation-recovery.v1", status: "blocked", context_id: this.contextId, blocked: [{ reason: "trt_checkpoint_tail_requires_explicit_commands" }] };
const replayMotionAuthority = checkpoint?.state?.motion_authority ?? this.motionAuthority;
if (replayMotionAuthority === "wasm" && !checkpoint?.state?.wasm_motion) {
return { schema: "simulation-recovery.v1", status: "blocked", context_id: this.contextId, checkpoint_hash: checkpoint?.checkpoint_hash ?? null, blocked: [{ reason: "wasm_motion_checkpoint_missing" }] };
}
if (this.xyzacProgram && checkpoint?.state?.program?.source_text) {
if (this.xyzacProgram && checkpoint?.state?.program) {
try {
const loaded = this.xyzacProgram.load(checkpoint.state.program.source_text, checkpoint.state.program.path ?? null);
if (loaded.status !== "verified") throw new Error(loaded.code);
const subroutines = this.loadXyzacSubroutines(checkpoint.state.program.path ?? null);
if (subroutines.status !== "verified") throw new Error(subroutines.code);
const targetLine = Number.isSafeInteger(checkpoint.state.program.current_line) ? checkpoint.state.program.current_line : 0;
const seek = this.xyzacProgram.seek(targetLine);
if (seek.status !== "verified") throw new Error(seek.code);
if (checkpoint.state.program) this.xyzacProgram.restoreState(checkpoint.state.program);
this.restoreTrtProgram(checkpoint.state);
if (checkpoint.state.wasm_motion) this.wasmMotion.restoreState(checkpoint.state.wasm_motion);
if (checkpoint.state.wasm_home_limit) this.wasmHomeLimit.restoreState(checkpoint.state.wasm_home_limit);
let restoredWasmDeviceIo = false;
@@ -1028,11 +1093,11 @@ export class SimSession {
this.livePathEpoch = Number.isSafeInteger(checkpoint.state.live_path_epoch) ? checkpoint.state.live_path_epoch : this.livePathEpoch;
this.python = new PythonBridge(); if (checkpoint.state.python) this.python.restoreState(checkpoint.state.python);
this.timeline = new ReplayTimeline(checkpoint.timeline);
if (checkpoint.state.hal_runtime?.schema === "wasm_xyzac_hal.v1" && this.xyzacIo) this.xyzacIo.halCommand({ action: "step", ticks: checkpoint.state.hal_runtime.tick ?? 0 });
this.restoreTrtExecution(checkpoint.state, transport);
if (!restoredWasmDeviceIo && checkpoint.state.device_io?.schema === "wasm_xyzac_device_io.v1" && this.xyzacDeviceIo) this.xyzacDeviceIo.command("IO_STEP", { ticks: checkpoint.state.device_io.state?.tick ?? 0 });
const currentOperations = [...this.operations.values()];
this.operations = new Map([...checkpoint.operations ?? [], ...currentOperations].map((operation) => [operation.operation_id, operation]));
const checkpointEntries = checkpoint.operations ?? [];
const checkpointEntries = checkpoint.state.ledger?.entries ?? checkpoint.operations ?? [];
const currentEntries = this.ledger.export().entries.filter((entry) => !checkpointEntries.some((operation) => operation.operation_id === entry.operation_id));
this.ledger = new this.ledger.constructor();
for (const operation of [...checkpointEntries, ...currentEntries]) {
@@ -1317,6 +1382,7 @@ export class SimSession {
let ledgerPreparation;
try {
ledgerPreparation = this.ledger.prepare(input);
if (ledgerPreparation.replay && !ledgerPreparation.entry?.response) throw new Error("ledger_replay_result_missing");
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 });
@@ -1324,7 +1390,9 @@ export class SimSession {
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) };
const cached = ledgerPreparation.entry.response;
const payload = this.xyzacProgram ? { ...cached.payload, task: this.task.export(), program: this.xyzacProgram.snapshot(), motion: this.wasmMotionProjection(), kinematics: this.xyzacKinematics.snapshot() } : cached.payload;
return { ...this.envelope(cached.kind, input, payload), replayed: true, ledger: this.ledger.reconcile(input.operation_id) };
}
if (ledgerPreparation.entry) this.ledger.execute(input.operation_id);
} catch (error) {
@@ -1610,8 +1678,7 @@ export class SimSession {
let configuration = input.payload?.configuration ?? null;
if (machineDefinition(configuration?.entrypoint) || machineDefinition(configuration?.kinematics?.type)) {
try {
const geometry = readTrtGeometry(configuration);
configuration = { ...configuration, kinematics: { ...configuration.kinematics, offsets: geometry.offsets } };
configuration = { ...configuration, ...readTrtConfiguration(configuration) };
} catch (error) {
return finish(this.error(input, "trt_geometry", error.message), "failed");
}
@@ -1619,7 +1686,7 @@ export class SimSession {
if (configuration) {
this.machineDefinition = machineDefinition(configuration.entrypoint) ?? machineDefinition(configuration.kinematics?.type) ?? this.machineDefinition;
this.workspaceFiles = { ...(configuration.files ?? configuration.workspace ?? configuration.workspace_files ?? {}) };
const visualSourcePath = (this.machineDefinition ?? machineDefinition(configuration.entrypoint))?.source_path ?? "linuxcnc/src/hal/user_comps/vismach/xyzac-trt-gui.py";
const visualSourcePath = (this.machineDefinition ?? machineDefinition(configuration.entrypoint))?.source_path ?? DEFAULT_MACHINE.source_path;
this.visualModelSourceHash = typeof this.workspaceFiles[visualSourcePath] === "string" ? fnv1a64(this.workspaceFiles[visualSourcePath]) : null;
const startupWorkspace = configuration.workspace ?? configuration.files ?? configuration.workspace_files;
if (startupWorkspace) {
@@ -1672,22 +1739,23 @@ export class SimSession {
this.xyzacKinematics?.destroy();
this.xyzacKinematics = new WasmXyzacKinematics(this.core, { ...configuration.kinematics.offsets, coordinates: configuredMachine?.coordinates ?? configuration.kinematics.coordinates ?? "XYZAC", limits: configuration.home_limits });
this.xyzacIo?.destroy();
this.xyzacIo = new WasmXyzacIo(this.core, { spindleCount: 2, toolCapacity: 1001 });
this.xyzacIo = new WasmXyzacIo(this.core, { spindleCount: configuration.kinematics.io.spindle_count, toolCapacity: configuration.kinematics.io.tool_capacity, cycleTime: configuration.kinematics.io.cycle_time, spindleAcceleration: configuration.kinematics.io.spindle_acceleration, spindleTolerance: configuration.kinematics.io.spindle_tolerance });
const initialPower = this.xyzacIo.command("POWER", { active: false });
if (initialPower.status !== "verified") throw new Error(initialPower.code ?? "wasm_power_off_init");
this.xyzacDeviceIo?.destroy();
this.xyzacDeviceIo = new WasmXyzacDeviceIo(this.core, { encoderCount: 2 });
this.xyzacDeviceIo = new WasmXyzacDeviceIo(this.core, { encoderCount: configuration.kinematics.io.encoder_count, cycleTime: configuration.kinematics.io.cycle_time });
this.xyzacProgram?.destroy();
this.wasmMotion.destroy();
this.xyzacMotionCycleTime = Number(configuration.kinematics?.servo_cycle_time ?? configuration.kinematics?.cycle_time ?? 0.01);
this.xyzacMotionCycleTime = configuration.kinematics.servo_cycle_time;
if (!Number.isFinite(this.xyzacMotionCycleTime) || this.xyzacMotionCycleTime <= 0) throw new Error("wasm_xyzac_cycle_time_invalid");
const taskCycleTime = Number(configuration.kinematics?.task_cycle_time ?? this.xyzacMotionCycleTime);
this.xyzacTaskCycleTicks = Math.max(1, Math.round(taskCycleTime / this.xyzacMotionCycleTime));
const taskCycleTime = configuration.kinematics.task_cycle_time;
this.xyzacTaskCycleTicks = Math.round(taskCycleTime / this.xyzacMotionCycleTime);
this.xyzacRapidVelocity = configuration.kinematics.max_linear_velocity;
// The selected XYZAC TRT case always uses the upstream LinuxCNC TP;
// a caller must not silently downgrade it to the compatibility
// motion queue by omitting the planner option.
this.wasmMotion = isTrt
? new WasmLinuxcncTp(this.core, { cycleTime: this.xyzacMotionCycleTime, velocityLimit: this.xyzacRapidVelocity, accelerationLimit: Number(configuration.kinematics?.max_acceleration ?? 300), limits: configuration.home_limits ?? {} })
? new WasmLinuxcncTp(this.core, { cycleTime: this.xyzacMotionCycleTime, velocityLimit: this.xyzacRapidVelocity, accelerationLimit: configuration.kinematics.max_acceleration, limits: configuration.kinematics.axis_limits, axisVelocities: configuration.kinematics.axis_velocity_limits, axisAccelerations: configuration.kinematics.axis_acceleration_limits, maxFeedOverride: configuration.kinematics.max_feed_override, blend: configuration.kinematics.blend })
: new WasmMotionQueue(this.core, { cycleTime: this.xyzacMotionCycleTime, velocityLimit: 1e9 });
this.xyzacProgram = new WasmProgramRuntime(this.core);
this.previewState = null;
@@ -1696,14 +1764,8 @@ export class SimSession {
this.homingHistory = [];
this.xyzacProgramKinematicsMode = XYZAC_MODES.identity;
if (typeof this.wasmMotion.setKinematics === "function") this.wasmMotion.setKinematics(this.xyzacKinematics);
this.xyzacRapidVelocity = Number(configuration.kinematics?.max_linear_velocity ?? 35);
const linearLimit = Number(configuration.kinematics?.axis_linear_velocity_limit ?? 20);
const angularLimit = Number(configuration.kinematics?.axis_angular_velocity_limit ?? 30);
const rotaryAxis = (configuredMachine?.coordinates ?? "XYZAC")[3].toLowerCase();
this.xyzacAxisVelocityLimits = { x: linearLimit, y: linearLimit, z: linearLimit, [rotaryAxis]: angularLimit, c: angularLimit };
const accelerationLimit = Number(configuration.kinematics?.max_acceleration ?? 300);
this.xyzacAxisAccelerationLimits = { x: accelerationLimit, y: accelerationLimit, z: accelerationLimit, [rotaryAxis]: accelerationLimit, c: accelerationLimit };
this.wasmMotion.setDynamics(Number(configuration.kinematics?.max_acceleration ?? 300), Number(configuration.kinematics?.max_jerk ?? 1000000000));
this.xyzacAxisVelocityLimits = configuration.kinematics.axis_velocity_limits;
this.xyzacAxisAccelerationLimits = configuration.kinematics.axis_acceleration_limits;
this.xyzacProgram.setRapidVelocity(this.xyzacRapidVelocity);
}
if (configuration?.home_limits && this.motionAuthority === "wasm") {
@@ -1734,6 +1796,7 @@ export class SimSession {
};
}
const configuredKinematics = input.payload?.kinematics ?? configuration?.kinematics?.type ?? null;
if (isTrt) this.machineConfigurationHash = fnv1a64({ kinematics: configuration.kinematics, home_limits: configuration.home_limits, motion_scale_min: WEB_SETTINGS.ui.motion_scale_min });
if (configuredKinematics && !(this.motionAuthority === "wasm" && isTrt)) {
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");
@@ -1757,7 +1820,7 @@ export class SimSession {
motion_authority: this.motionAuthority,
state_hash: this.task.hash(),
task: this.task.export(),
...(powerOffRecovery ? { power_off_recovery: powerOffRecovery } : {}),
...(powerOffRecovery ? { power_off_recovery: { ...powerOffRecovery, program: { source: this.xyzacProgram.source, path: this.xyzacProgram.path } } } : {}),
...(this.configurationState?.schema === "configuration-startup-runtime.v1" ? { configuration: this.configurationState } : {}),
})) : finish(this.coreError(input, loadResult, "sim_load_workspace"), "failed");
}
@@ -2081,7 +2144,7 @@ export class SimSession {
motion_trace.push({
tick: nextTick,
pose: { ...traceState.position },
joints: traceKinematics?.joints?.length === 5 ? [...traceKinematics.joints] : [],
joints: Array.isArray(traceState.joint_commanded) ? [...traceState.joint_commanded] : traceKinematics?.joints?.length === 5 ? [...traceKinematics.joints] : [],
active_segment_id: traceState.active_id,
active_progress: traceState.progress,
});
@@ -2618,7 +2681,7 @@ export class SimSession {
}
if (input.kind === "SET_OVERRIDE") {
const feed = Number(input.payload?.feed ?? input.payload?.feed_override);
if (!Number.isFinite(feed) || feed < 0 || feed > 2) return finish(this.error(input, "motion_override", "motion_override_range"), "failed");
if (!Number.isFinite(feed) || feed < 0 || feed > (this.wasmMotion.maxFeedOverride ?? WEB_SETTINGS.runtime.planner.max_feed_override)) return finish(this.error(input, "motion_override", "motion_override_range"), "failed");
try {
this.wasmMotion.setFeedOverride(feed);
} catch (error) {
@@ -2629,7 +2692,7 @@ export class SimSession {
}
if (input.kind === "SET_JOG_SCALE" || input.kind === "SET_MAX_VELOCITY") {
const scale = Number(input.payload?.scale ?? Number(input.payload?.percent) / 100);
if (!Number.isFinite(scale) || scale < 0.06 || scale > 1) return finish(this.error(input, "motion_scale", input.kind === "SET_JOG_SCALE" ? "jog_scale_range" : "max_velocity_scale_range"), "failed");
if (!Number.isFinite(scale) || scale < WEB_SETTINGS.ui.motion_scale_min || scale > 1) return finish(this.error(input, "motion_scale", input.kind === "SET_JOG_SCALE" ? "jog_scale_range" : "max_velocity_scale_range"), "failed");
try {
if (input.kind === "SET_JOG_SCALE") this.wasmMotion.setJogScale(scale);
else this.wasmMotion.setVelocityScale(scale);
@@ -2641,7 +2704,7 @@ export class SimSession {
}
if (input.kind === "FOLLOWING_ERROR") return finish(this.error(input, "motion_following_error", "wasm_following_error_unavailable"), "failed");
const axis = String(input.payload?.axis ?? "").toLowerCase();
const availableAxes = this.machineDefinition?.axes ?? ["x", "y", "z", "a", "c"];
const availableAxes = this.machineDefinition?.axes ?? DEFAULT_MACHINE.axes;
if (!availableAxes.includes(axis)) return finish(this.error(input, "motion_axis", "motion_axis_invalid"), "failed");
const direction = input.payload?.direction === -1 ? -1 : input.payload?.direction === 1 ? 1 : null;
if (direction === null) return finish(this.error(input, "motion_jog", "motion_jog_range"), "failed");
@@ -2790,7 +2853,13 @@ export class SimSession {
}
}
case "RECOVER": {
const result = this.restoreCheckpoint(input.payload?.checkpoint, input.payload?.events, input.payload?.reconcile);
const recoveryEvents = input.payload?.events;
const tailEvents = Array.isArray(recoveryEvents) ? recoveryEvents : recoveryEvents?.events?.events ?? recoveryEvents?.events ?? [];
if (this.xyzacProgram && Array.isArray(tailEvents) && tailEvents.some((event) => event.seq > (input.payload?.checkpoint?.event_cursor ?? 0))) return finish(this.envelope("RECOVERY_STATUS", input, { status: "blocked", code: "trt_checkpoint_tail_requires_explicit_commands" }), "failed");
let result = this.xyzacProgram
? this.restorePersistedPowerOff(input.payload?.checkpoint, input.seq)
: this.restoreCheckpoint(input.payload?.checkpoint, input.payload?.events, input.payload?.reconcile);
if (this.xyzacProgram && result.status === "verified") result = { ...result, schema: "simulation-recovery.v1", status: "recovered", storage: "checkpoint", state: this.stateForCheckpoint() };
return finish(this.envelope("RECOVERY_STATUS", input, result), result.status === "blocked" ? "failed" : "verified");
}
case "CANCEL": {