新增解释器核心WASM验证

结论:已建立基于LinuxCNC解释器源码的最小WASM核心构建和minimal_linear夹具验证,并接入host smoke。
This commit is contained in:
2026-06-08 04:56:45 +08:00
parent 1a10e4f260
commit caa35820cd
9 changed files with 395 additions and 3 deletions

View File

@@ -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.

View File

@@ -0,0 +1,158 @@
#define private public
#include "emc/rs274ngc/rs274ngc_interp.hh"
#undef private
#include <cstdlib>
#include <cstring>
#include <sstream>
#include <string>
#include <vector>
#include <emscripten/emscripten.h>
#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<std::string> split_program_lines(const char *program_text)
{
std::vector<std::string> 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<char *>(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<std::string> 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<int>(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"

View File

@@ -0,0 +1,47 @@
#include <cstdlib>
#include <cstring>
#include <wordexp.h>
extern "C" {
int wordexp(const char *words, wordexp_t *pwordexp, int)
{
if (!words || !pwordexp) {
return WRDE_BADCHAR;
}
char **wordv = static_cast<char **>(std::calloc(2, sizeof(char *)));
if (!wordv) {
return WRDE_NOSPACE;
}
wordv[0] = static_cast<char *>(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"

View File

@@ -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

View File

@@ -0,0 +1,8 @@
#pragma once
#include <libgen.h>
inline char *basename(const char *path)
{
return ::basename(const_cast<char *>(path));
}

View File

@@ -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"

View File

@@ -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");

View File

@@ -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"

View File

@@ -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"]'