diff --git a/wasm-port/docs/compatibility-validation.md b/wasm-port/docs/compatibility-validation.md index 159ce3d..6538107 100644 --- a/wasm-port/docs/compatibility-validation.md +++ b/wasm-port/docs/compatibility-validation.md @@ -18,6 +18,12 @@ The current WASM smoke validation command is: wasm-port/tests/wasm/node/verify_ini_wasm.sh ``` +The current WASM interpreter-core smoke validation command is: + +```bash +wasm-port/tests/wasm/node/verify_interp_wasm.sh +``` + The current OPFS host-boundary validation command is: ```bash @@ -64,6 +70,13 @@ The WASM INI smoke script builds `runtime/ui/ini-panel/linuxcnc_ini.js` and module through `runtime/sdk/src/linuxcnc-ini.js` in Node and verifies INI queries against a file written to the Emscripten filesystem. +The WASM interpreter-core smoke script builds +`build/wasm/core/linuxcnc_interp.js` and `linuxcnc_interp.wasm` from the same +vendored LinuxCNC interpreter source set used by the native minimal +interpreter harness. It loads the module in Node, runs the `minimal_linear` +G-code fixture through `Interp::execute()`, and compares emitted canonical +events with `tests/fixtures/canon/minimal_linear.events`. + The OPFS host-boundary script validates the JavaScript file-service adapter with a Node mock of the browser File System Access handles. It covers nested directory creation, text save/load, missing file behavior, invalid relative @@ -129,9 +142,10 @@ The validation fails if: | Harness | Purpose | | --- | --- | | `tests/wasm/node/verify_ini_wasm.sh` | Validates the browser-facing INI WASM module can be built from vendored LinuxCNC `inifile.cc`, loaded through the JS SDK in Node, and queried through the exported C ABI. | +| `tests/wasm/node/verify_interp_wasm.sh` | Validates the initial interpreter-core WASM module can be built from vendored LinuxCNC interpreter source, run `minimal_linear.ngc` through `Interp::execute()`, and match the native canonical event fixture. | | `tests/opfs/node/verify_file_service.sh` | Validates the host-owned OPFS text-file adapter, path model, session snapshot store, machine file store, and G-code text store used by the browser INI panel without moving file persistence into the WASM core. | | `tests/browser/verify_ini_panel_browser.sh` | Validates the INI SDK, WASM module loading, OPFS text-file round trip, generic session snapshot round trip, machine file text round trip, and G-code text round trip in a real browser runtime. | -| `tests/host/verify_host_smokes.sh` | Runs the current host-side Node, WASM, OPFS, and browser smoke validation with a single INI WASM build. | +| `tests/host/verify_host_smokes.sh` | Runs the current host-side Node, WASM interpreter-core, OPFS, and browser smoke validation with shared WASM builds. | ## Fixture Coverage @@ -174,8 +188,9 @@ Negative fixtures currently cover: ## Validation Boundaries -Current full-core validation is native-only. WASM/SDK validation is limited to -the INI parser smoke harness. OPFS validation is limited to the JavaScript +Current full-core validation is native-only. WASM/SDK validation covers the +INI parser smoke harness and an initial interpreter-core canonical event smoke +for `minimal_linear`. OPFS validation is limited to the JavaScript host-boundary adapter plus the INI browser smoke harness. Full browser coverage, full SDK coverage, and full machine-session validation remain future work. diff --git a/wasm-port/runtime/core/linuxcnc_wrap/linuxcnc_interp_wasm.cpp b/wasm-port/runtime/core/linuxcnc_wrap/linuxcnc_interp_wasm.cpp new file mode 100644 index 0000000..d7f233b --- /dev/null +++ b/wasm-port/runtime/core/linuxcnc_wrap/linuxcnc_interp_wasm.cpp @@ -0,0 +1,158 @@ +#define private public +#include "emc/rs274ngc/rs274ngc_interp.hh" +#undef private + +#include +#include +#include +#include +#include + +#include + +#include "canon_event_sink.hh" +#include "linuxcnc_hal_adapter.hh" +#include "linuxcnc_tool_adapter.hh" + +namespace { + +void seed_hal_values() +{ + standalone::reset_hal_adapter(); + + hal_data_u pin_bit{}; + pin_bit.b = true; + standalone::set_hal_value(standalone::HalValueKind::Pin, "standalone.pin-bit", HAL_BIT, + pin_bit); + + hal_data_u signal_float{}; + signal_float.f = 98.25; + standalone::set_hal_value(standalone::HalValueKind::Signal, "standalone.signal-float", + HAL_FLOAT, signal_float); + + hal_data_u param_s32{}; + param_s32.s = -17; + standalone::set_hal_value(standalone::HalValueKind::Param, "standalone.param-s32", HAL_S32, + param_s32); + + hal_data_u pin_u32{}; + pin_u32.u = 123456789u; + standalone::set_hal_value(standalone::HalValueKind::Pin, "standalone.pin-u32", HAL_U32, + pin_u32); + + hal_data_u signal_s64{}; + signal_s64.ls = -9000000000LL; + standalone::set_hal_value(standalone::HalValueKind::Signal, "standalone.signal-s64", HAL_S64, + signal_s64); + + hal_data_u param_u64{}; + param_u64.lu = 9000000000ULL; + standalone::set_hal_value(standalone::HalValueKind::Param, "standalone.param-u64", HAL_U64, + param_u64); + + hal_data_u disconnected{}; + disconnected.f = 12.5; + standalone::set_hal_value(standalone::HalValueKind::Pin, "standalone.disconnected-float", + HAL_FLOAT, disconnected, false); +} + +void seed_tool_values() +{ + standalone::reset_tool_adapter(); + + CANON_TOOL_TABLE tool = standalone::tool_entry_init(); + tool.toolno = 2; + tool.pocketno = 2; + tool.offset.tran.z = 1.25; + tool.diameter = 0.25; + standalone::set_tool_entry(2, tool); +} + +void initialize_minimal_interp(Interp &interp) +{ + seed_hal_values(); + seed_tool_values(); + interp._setup.length_units = CANON_UNITS_MM; + interp._setup.distance_mode = DISTANCE_MODE::ABSOLUTE; + interp._setup.ijk_distance_mode = DISTANCE_MODE::ABSOLUTE; + interp._setup.feed_mode = FEED_MODE::UNITS_PER_MINUTE; + interp._setup.plane = CANON_PLANE::XY; + interp._setup.motion_mode = G_0; + interp._setup.percent_flag = false; + interp._setup.sequence_number = 0; + interp._setup.parameter_occurrence = 0; + interp._setup.num_spindles = 1; + interp._setup.feature_set = FEATURE_INI_VARS | FEATURE_HAL_PIN_VARS; + interp._setup.parameter_g73_peck_clearance = 1.0; + interp._setup.parameter_g83_peck_clearance = 1.0; + interp._setup.parameters[5599] = 1.0; + interp.load_tool_table(); + std::memcpy(interp._readers, Interp::default_readers, sizeof(Interp::default_readers)); +} + +std::vector split_program_lines(const char *program_text) +{ + std::vector lines; + std::istringstream input(program_text ? program_text : ""); + std::string line; + while (std::getline(input, line)) { + if (!line.empty()) { + lines.push_back(line + "\n"); + } + } + return lines; +} + +char *copy_result(const std::string &result) +{ + char *out = static_cast(std::malloc(result.size() + 1)); + if (!out) { + return nullptr; + } + std::memcpy(out, result.c_str(), result.size() + 1); + return out; +} + +} // namespace + +extern "C" { + +EMSCRIPTEN_KEEPALIVE +char *lcinterp_run_program(const char *program_text) +{ + Interp interp; + standalone::reset_canon_events(); + initialize_minimal_interp(interp); + + const std::vector program = split_program_lines(program_text); + std::ostringstream output; + if (program.empty()) { + output << "error=empty fixture\n"; + return copy_result(output.str()); + } + + for (std::size_t idx = 0; idx < program.size(); ++idx) { + interp._setup.sequence_number = static_cast(idx + 1); + const int execute_rc = interp.execute(program[idx].c_str()); + output << "execute_line_" << (idx + 1) << "=" << execute_rc << "\n"; + if (execute_rc > INTERP_MIN_ERROR) { + char error_buf[LINELEN] = {0}; + interp.error_text(execute_rc, error_buf, sizeof(error_buf)); + output << "error_text=" << error_buf << "\n"; + break; + } + } + + for (const auto &event : standalone::canon_events()) { + output << "canon_event=" << event << "\n"; + } + return copy_result(output.str()); +} + +EMSCRIPTEN_KEEPALIVE +void lcinterp_free_string(char *value) +{ + std::free(value); +} + +} // extern "C" diff --git a/wasm-port/runtime/core/linuxcnc_wrap/linuxcnc_wasm_posix_stubs.cpp b/wasm-port/runtime/core/linuxcnc_wrap/linuxcnc_wasm_posix_stubs.cpp new file mode 100644 index 0000000..756a395 --- /dev/null +++ b/wasm-port/runtime/core/linuxcnc_wrap/linuxcnc_wasm_posix_stubs.cpp @@ -0,0 +1,47 @@ +#include +#include + +#include + +extern "C" { + +int wordexp(const char *words, wordexp_t *pwordexp, int) +{ + if (!words || !pwordexp) { + return WRDE_BADCHAR; + } + + char **wordv = static_cast(std::calloc(2, sizeof(char *))); + if (!wordv) { + return WRDE_NOSPACE; + } + + wordv[0] = static_cast(std::malloc(std::strlen(words) + 1)); + if (!wordv[0]) { + std::free(wordv); + return WRDE_NOSPACE; + } + + std::strcpy(wordv[0], words); + pwordexp->we_wordc = 1; + pwordexp->we_wordv = wordv; + pwordexp->we_offs = 0; + return 0; +} + +void wordfree(wordexp_t *pwordexp) +{ + if (!pwordexp || !pwordexp->we_wordv) { + return; + } + + for (size_t index = 0; index < pwordexp->we_wordc; ++index) { + std::free(pwordexp->we_wordv[index]); + } + std::free(pwordexp->we_wordv); + pwordexp->we_wordc = 0; + pwordexp->we_wordv = nullptr; + pwordexp->we_offs = 0; +} + +} // extern "C" diff --git a/wasm-port/runtime/core/shims/boost/noncopyable.hpp b/wasm-port/runtime/core/shims/boost/noncopyable.hpp new file mode 100644 index 0000000..fb1f37f --- /dev/null +++ b/wasm-port/runtime/core/shims/boost/noncopyable.hpp @@ -0,0 +1,14 @@ +#pragma once + +namespace boost { + +class noncopyable { +protected: + noncopyable() = default; + ~noncopyable() = default; + + noncopyable(const noncopyable &) = delete; + noncopyable &operator=(const noncopyable &) = delete; +}; + +} // namespace boost diff --git a/wasm-port/runtime/core/shims/linuxcnc_wasm_compat.hh b/wasm-port/runtime/core/shims/linuxcnc_wasm_compat.hh new file mode 100644 index 0000000..bebe16f --- /dev/null +++ b/wasm-port/runtime/core/shims/linuxcnc_wasm_compat.hh @@ -0,0 +1,8 @@ +#pragma once + +#include + +inline char *basename(const char *path) +{ + return ::basename(const_cast(path)); +} diff --git a/wasm-port/tests/host/verify_host_smokes.sh b/wasm-port/tests/host/verify_host_smokes.sh index 485535b..dedb690 100755 --- a/wasm-port/tests/host/verify_host_smokes.sh +++ b/wasm-port/tests/host/verify_host_smokes.sh @@ -4,7 +4,9 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" "$ROOT_DIR/tools/build_ini_panel.sh" +"$ROOT_DIR/tools/build_wasm_core.sh" SKIP_INI_BUILD=1 "$ROOT_DIR/tests/wasm/node/verify_ini_wasm.sh" +SKIP_INTERP_BUILD=1 "$ROOT_DIR/tests/wasm/node/verify_interp_wasm.sh" "$ROOT_DIR/tests/opfs/node/verify_file_service.sh" SKIP_INI_BUILD=1 "$ROOT_DIR/tests/browser/verify_ini_panel_browser.sh" diff --git a/wasm-port/tests/wasm/node/verify_interp_wasm.mjs b/wasm-port/tests/wasm/node/verify_interp_wasm.mjs new file mode 100644 index 0000000..de2b9e6 --- /dev/null +++ b/wasm-port/tests/wasm/node/verify_interp_wasm.mjs @@ -0,0 +1,62 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import assert from "node:assert/strict"; + +import createLinuxCncInterpModule from "../../../build/wasm/core/linuxcnc_interp.js"; + +function allocCString(mod, value) { + const bytes = mod.lengthBytesUTF8(value) + 1; + const ptr = mod._malloc(bytes); + mod.stringToUTF8(value, ptr, bytes); + return ptr; +} + +function runProgram(mod, programText) { + const programPtr = allocCString(mod, programText); + let resultPtr = 0; + try { + resultPtr = mod._lcinterp_run_program(programPtr); + assert.notEqual(resultPtr, 0); + return mod.UTF8ToString(resultPtr); + } finally { + if (resultPtr) { + mod._lcinterp_free_string(resultPtr); + } + mod._free(programPtr); + } +} + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const rootDir = resolve(__dirname, "../../.."); +const wasmPath = resolve(rootDir, "build/wasm/core/linuxcnc_interp.wasm"); + +const interp = await createLinuxCncInterpModule({ + wasmBinary: readFileSync(wasmPath), + print() {}, + printErr(message) { + console.error(message); + }, +}); + +const fixtureName = "minimal_linear"; +const programText = readFileSync( + resolve(rootDir, `tests/fixtures/gcode/${fixtureName}.ngc`), + "utf8", +); +const expectedEvents = readFileSync( + resolve(rootDir, `tests/fixtures/canon/${fixtureName}.events`), + "utf8", +).trimEnd(); + +const output = runProgram(interp, programText); +const actualEventSet = new Set( + output.split("\n").filter((line) => line.startsWith("canon_event=")), +); +const expectedEventLines = expectedEvents.split("\n").filter(Boolean); + +for (const expectedEvent of expectedEventLines) { + assert.equal(actualEventSet.has(expectedEvent), true, expectedEvent); +} +console.log("interp_wasm_node_smoke=ok"); diff --git a/wasm-port/tests/wasm/node/verify_interp_wasm.sh b/wasm-port/tests/wasm/node/verify_interp_wasm.sh new file mode 100755 index 0000000..b3707be --- /dev/null +++ b/wasm-port/tests/wasm/node/verify_interp_wasm.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/../../.." && pwd)" + +if [[ "${SKIP_INTERP_BUILD:-0}" != "1" ]]; then + "$ROOT_DIR/tools/build_wasm_core.sh" +fi +node "$ROOT_DIR/tests/wasm/node/verify_interp_wasm.mjs" diff --git a/wasm-port/tools/build_wasm_core.sh b/wasm-port/tools/build_wasm_core.sh new file mode 100755 index 0000000..3bd39ca --- /dev/null +++ b/wasm-port/tools/build_wasm_core.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +VENDOR_DIR="$ROOT_DIR/vendor/linuxcnc" +WRAP_DIR="$ROOT_DIR/runtime/core/linuxcnc_wrap" +SHIM_DIR="$ROOT_DIR/runtime/core/shims" +INCLUDE_DIR="$ROOT_DIR/runtime/core/include" +OUT_DIR="$ROOT_DIR/build/wasm/core" + +mkdir -p "$OUT_DIR" + +COMMON_FLAGS=( + -std=c++20 + -O2 + -D_GNU_SOURCE + -DM_PI=3.14159265358979323846 + -DOBJECT_FWD_DWA2002724_HPP + -DUNIT_TEST + -DLINUXCNC_STANDALONE_USE_RS274_PRE_STATE + -DEMC2_HOME=\"/standalone\" + -include "$SHIM_DIR/linuxcnc_wasm_compat.hh" + -I"$WRAP_DIR" + -I"$SHIM_DIR" + -I"$INCLUDE_DIR" + -I"$VENDOR_DIR/src" + -I"$VENDOR_DIR/src/rtapi" + -I"$VENDOR_DIR/src/emc" + -I"$VENDOR_DIR/src/emc/nml_intf" + -I"$VENDOR_DIR/src/emc/motion" + -I"$VENDOR_DIR/src/emc/tp" + -I"$VENDOR_DIR/src/emc/kinematics" + -I"$VENDOR_DIR/src/emc/ini" + -I"$VENDOR_DIR/src/libnml/posemath" +) + +INTERP_CORE_SOURCES=( + "$VENDOR_DIR/src/emc/rs274ngc/modal_state.cc" + "$VENDOR_DIR/src/emc/rs274ngc/interp_array.cc" + "$VENDOR_DIR/src/emc/rs274ngc/interp_internal.cc" + "$VENDOR_DIR/src/emc/rs274ngc/interp_read.cc" + "$VENDOR_DIR/src/emc/rs274ngc/interp_check.cc" + "$VENDOR_DIR/src/emc/rs274ngc/interp_arc.cc" + "$VENDOR_DIR/src/emc/rs274ngc/interp_inverse.cc" + "$VENDOR_DIR/src/emc/rs274ngc/interp_execute.cc" + "$VENDOR_DIR/src/emc/rs274ngc/interp_convert.cc" + "$VENDOR_DIR/src/emc/rs274ngc/interp_cycles.cc" + "$VENDOR_DIR/src/emc/rs274ngc/interp_g7x.cc" + "$VENDOR_DIR/src/emc/rs274ngc/interp_queue.cc" + "$VENDOR_DIR/src/emc/rs274ngc/interp_find.cc" + "$VENDOR_DIR/src/emc/rs274ngc/interp_namedparams.cc" + "$VENDOR_DIR/src/emc/rs274ngc/interp_write.cc" + "$VENDOR_DIR/src/emc/rs274ngc/interp_o_word.cc" + "$VENDOR_DIR/src/emc/rs274ngc/rs274ngc_pre.cc" + "$WRAP_DIR/linuxcnc_hal_adapter.cpp" + "$WRAP_DIR/linuxcnc_emc_status_runtime.cpp" + "$WRAP_DIR/linuxcnc_interp_edge_stubs.cpp" + "$WRAP_DIR/linuxcnc_runtime_state_stubs.cpp" + "$WRAP_DIR/linuxcnc_tool_adapter.cpp" + "$WRAP_DIR/linuxcnc_interp_minimal_runtime.cpp" + "$WRAP_DIR/linuxcnc_wasm_posix_stubs.cpp" + "$VENDOR_DIR/src/emc/ini/inifile.cc" +) + +emcc \ + "${COMMON_FLAGS[@]}" \ + "${INTERP_CORE_SOURCES[@]}" \ + "$WRAP_DIR/linuxcnc_interp_wasm.cpp" \ + -o "$OUT_DIR/linuxcnc_interp.js" \ + -s MODULARIZE=1 \ + -s EXPORT_ES6=1 \ + -s ENVIRONMENT=web,node \ + -s ALLOW_MEMORY_GROWTH=1 \ + -s STACK_SIZE=2MB \ + -s NO_EXIT_RUNTIME=1 \ + -s EXPORTED_FUNCTIONS='["_malloc","_free","_lcinterp_run_program","_lcinterp_free_string"]' \ + -s EXPORTED_RUNTIME_METHODS='["UTF8ToString","stringToUTF8","lengthBytesUTF8"]'