eeschema simulator: lazy ngspice_service worker — static sharedspice (XSPICE registry + CIDER), init_dll ifdef, e2e both engines
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
004412c53d
commit
703cb010b7
30 changed files with 2872 additions and 82 deletions
|
|
@ -81,7 +81,7 @@ trap 'kw_fail 130; exit 130' INT TERM
|
|||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
VALID_APPS="kicad_editor | pcbnew | eeschema | calculator | pl_editor | gerbview | kicad_tools | occ_service | all"
|
||||
VALID_APPS="kicad_editor | pcbnew | eeschema | calculator | pl_editor | gerbview | kicad_tools | occ_service | ngspice_service | all"
|
||||
|
||||
usage() {
|
||||
echo "Usage: ./docker/build.sh <app>[,<app>...] [args...]" >&2
|
||||
|
|
@ -112,12 +112,12 @@ shift
|
|||
# it finalizes in-container (no host wasm-opt tail), so it never contends
|
||||
# with the editor's critical path.
|
||||
if [[ "$APP_NAME" == "all" ]]; then
|
||||
APPS=(kicad_editor occ_service calculator pl_editor gerbview kicad_tools)
|
||||
APPS=(kicad_editor occ_service ngspice_service calculator pl_editor gerbview kicad_tools)
|
||||
else
|
||||
IFS=',' read -r -a APPS <<< "$APP_NAME"
|
||||
for app in "${APPS[@]}"; do
|
||||
case "$app" in
|
||||
kicad_editor|pcbnew|eeschema|calculator|pl_editor|gerbview|kicad_tools|occ_service) ;;
|
||||
kicad_editor|pcbnew|eeschema|calculator|pl_editor|gerbview|kicad_tools|occ_service|ngspice_service) ;;
|
||||
*)
|
||||
echo "Error: unknown app '$app' (expected: ${VALID_APPS})" >&2
|
||||
usage
|
||||
|
|
@ -275,10 +275,10 @@ postprocess_app() {
|
|||
local app="$1"
|
||||
local out_dir="output"
|
||||
|
||||
# The headless CLI and the OCC service are finalized in-container (real
|
||||
# tools, small -g0 wasm) and build with ASYNCIFY=0, so they need no host
|
||||
# post-processing (no dyncall shims, no finalize, no asyncify).
|
||||
if [ "$app" = "kicad_tools" ] || [ "$app" = "occ_service" ]; then
|
||||
# The headless CLI and the OCC/ngspice services are finalized in-container
|
||||
# (real tools, small -g0 wasm) and build with ASYNCIFY=0, so they need no
|
||||
# host post-processing (no dyncall shims, no finalize, no asyncify).
|
||||
if [ "$app" = "kicad_tools" ] || [ "$app" = "occ_service" ] || [ "$app" = "ngspice_service" ]; then
|
||||
echo "Skipping host post-processing for ${app} (finalized in-container)"
|
||||
return 0
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
# 11 — Re-enable the SPICE simulator
|
||||
|
||||
> **Verdict: port, ~1–2 weeks.** The groundwork is half-done in-repo: a wasm ngspice build
|
||||
> script already exists, the simulator sources already compile, and the only unavoidable
|
||||
> upstream-file edit is a ~25-line block swapping `wxDynamicLibrary` for direct static
|
||||
> symbols. Multiple working emscripten ngspice ports exist as precedent.
|
||||
> **DONE (2026-07-17), as a worker service rather than a static link — see
|
||||
> [docs/features/ngspice-split/](../ngspice-split/README.md).** The analysis
|
||||
> below predates the implementation; the "static link into eeschema.wasm"
|
||||
> recipe it recommends was superseded by the occ_service-style split (crash
|
||||
> isolation, lazy loading, and an in-service solution for XSPICE's .cm dlopen
|
||||
> via a static code-model registry). The ~25-line `init_dll()` edit happened
|
||||
> as predicted; `KICAD_SPICE=OFF` and the model-data stubs are gone.
|
||||
|
||||
## Current state (more nuanced than "KICAD_SPICE=OFF")
|
||||
|
||||
|
|
|
|||
213
docs/features/ngspice-split/README.md
Normal file
213
docs/features/ngspice-split/README.md
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
# ngspice-split — the eeschema simulator as a lazy worker service
|
||||
|
||||
> Status: implemented (2026-07-17). The SPICE analog of the [occ split](../occ-split/):
|
||||
> ngspice's sharedspice engine runs in a dedicated Web Worker module
|
||||
> (`ngspice_service.wasm`, ~5.4 MB, `-O2`), the editor binds a statically linked
|
||||
> RPC client, and Inspect → Simulator works with **full native parity** —
|
||||
> XSPICE (all seven bundled code models), CIDER, the complete BSIM4/B3SOI/
|
||||
> B4SOI/HSIM parameter tables, and real background-run semantics (`bg_halt`
|
||||
> interrupts a running simulation).
|
||||
|
||||
## Why a worker service
|
||||
|
||||
- **No dlopen in wasm.** Native KiCad dlopens libngspice and resolves ~10
|
||||
symbols (`eeschema/sim/ngspice.cpp init_dll`). A static emscripten build has
|
||||
no dynamic linking, and the occ-split analysis rates MAIN_MODULE dynamic
|
||||
linking RED for the editor (wasm-EH + pthreads + asyncify).
|
||||
- **Crash isolation.** KiCad's native crash recovery installs SIGSEGV/SIGABRT/
|
||||
SIGFPE handlers around the simulation thread — emscripten stubs these
|
||||
(emscripten#8567, wontfix). In-process, a hard ngspice fault kills the tab;
|
||||
in a worker, the provider fails in-flight requests, KiCad's `m_error` →
|
||||
`NGSPICE::validate()` path re-inits, and a **fresh worker** boots.
|
||||
- **The editor stays lean.** kicad_editor.wasm carries zero ngspice; the 5.4 MB
|
||||
service is fetched on the first simulator open (lazy boundary asserted in
|
||||
e2e).
|
||||
- **KiCad's usage is RPC-friendly.** It passes nullptr for the streaming
|
||||
SendData/SendInitData callbacks and pulls vectors after (or during) the run
|
||||
via `ngGet_Vec_Info`; only console/status text and run-state transitions
|
||||
stream mid-run.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
kicad_editor (eeschema, ASYNCIFY=1) ngspice_service (ASYNCIFY=0, pthreads)
|
||||
┌────────────────────────────────────┐ ┌──────────────────────────────────┐
|
||||
│ NGSPICE (upstream; ONE ifdef in │ postMessage │ ngspice_service_main.cpp (embind)│
|
||||
│ init_dll binds pcbjam_ngSpice_*) │◄───────────►│ libngspice.a: sharedspice static,│
|
||||
│ wasm/stubs/sharedspice_client.cpp: │ │ XSPICE static registry, CIDER │
|
||||
│ EM_ASYNC_JS request suspend, │ {evt} │ callbacks → MAIN_THREAD_ASYNC_ │
|
||||
│ event dispatch (fresh entries), │◄────────────│ EM_ASM → Module.ngspiceEmit │
|
||||
│ vec arena, .include shipper, │ │ bg_run = real ngspice pthread; │
|
||||
│ atomic running mirror │ │ main thread free → bg_halt works │
|
||||
└────────────────────────────────────┘ └──────────────────────────────────┘
|
||||
provider: web/standalone/src/wasm/ngspice-service.ts (lazy blob worker)
|
||||
worker wrapper (shared app/tests): web/standalone/src/wasm/ngspice-worker.js
|
||||
```
|
||||
|
||||
### Pieces
|
||||
|
||||
| Piece | Where |
|
||||
|---|---|
|
||||
| dep build (sharedspice static + code models) | `scripts/deps/build-ngspice.sh` (ngspice 46) |
|
||||
| static code-model registry sources | `scripts/deps/ngspice-wasm/{ngcm_registry.c,ngcm_dlmain_static.c}` |
|
||||
| Gate-1 node smoke (rc/xspice/cider/halt) | `scripts/deps/ngspice-wasm/smoke/` |
|
||||
| service module | `wasm/ngspice-service/{CMakeLists.txt,ngspice_service_main.cpp}` |
|
||||
| service build (standalone emcmake, NOT the kicad tree) | `scripts/kicad/build-ngspice_service.sh` |
|
||||
| editor client stub | `wasm/stubs/sharedspice_client.cpp` + decls in `wasm/stubs/ngspice/sharedspice.h` |
|
||||
| the single kicad-fork edit | `eeschema/sim/ngspice.cpp` `init_dll()` `#ifdef __EMSCRIPTEN__` block |
|
||||
| model-data tables restored at `-O2` | `eeschema/CMakeLists.txt` EMSCRIPTEN block |
|
||||
| app provider / bundle | `web/standalone/src/wasm/{ngspice-service.ts,ngspice-worker.js}`, `constants.ts`, `boot.ts` |
|
||||
| e2e | `tests/kicad/{ngspice-probe,eeschema-sim}.spec.ts`, harness stub `tests/kicad/utils/ngspice-service.ts` |
|
||||
|
||||
## The ngspice dep build (`build-ngspice.sh`)
|
||||
|
||||
`--with-ngshared --disable-shared --enable-static` builds `libngspice.a` with
|
||||
the sharedspice API and no CLI (upstream gates `bin_PROGRAMS` on
|
||||
`!SHARED_MODULE`). Idempotent (marker-guarded) edits to the extracted tarball,
|
||||
each load-bearing:
|
||||
|
||||
1. **libtool static-mode override.** ngspice hardwires libtool's `-shared`
|
||||
mode for the ngshared build (`STATIC=-shared` consumed as AM_CFLAGS
|
||||
everywhere, plus literal `-shared` in `libngspice_la_{CFLAGS,LDFLAGS}`);
|
||||
libtool hard-errors on `-shared` without shared-lib support. Fixed with
|
||||
`make STATIC=-static` (command line beats makefile) + a sed on the two
|
||||
generated `src/Makefile` lines.
|
||||
2. **XSPICE code models without dlopen.** Natively each `.cm` is dlopen'd via
|
||||
`load_opus()` (`src/spicelib/devices/dev.c`). Statically: the icm build is
|
||||
redirected (env `NGCM_STATIC`/`NGCM_DLMAIN`) to compile per-cm renamed
|
||||
tables (`ngcm_<cm>_cmDEVices…`, `ngcm_dlmain_static.c`) and `emar` each
|
||||
model into a `.cm` archive; a registry appended to dev.c resolves the seven
|
||||
bundled basenames straight into `add_device`/`add_udn`, falling through to
|
||||
dlopen (→ ngspice's normal error) for unknown paths. dlmain.c's coreitf
|
||||
wrapper section is deliberately dropped (it would shadow real core symbols
|
||||
with calls through a never-initialized coreitf); its utility tail
|
||||
(`fopen_with_path`, `cm_message_printf`, `cm_is_inertial`) is extracted at
|
||||
build time into `ngcm_common.a`, with `cm_getvar` bound directly to the
|
||||
core's `cp_getvar` (the dllitf mapping, `cmexport.c`).
|
||||
3. **cmpp runs on the build host** — automatic in ngspice 46 when
|
||||
`cross_compiling=yes` (`src/xspice/cmpp/build/`), BUT the icm makefile's
|
||||
`$(shell cmpp -p …)` model-list calls hardcode the CROSS-compiled cmpp:
|
||||
patched to `$(CMPP)`. Symptom of the unpatched bug: `.cm` archives quietly
|
||||
containing only `dlmain.o` ("Permission denied" from the wasm binary).
|
||||
4. **verilog/vhdl subdirs skipped** (generated `src/xspice/Makefile` sed):
|
||||
ivlng/ivlngvpi VPI co-simulation shims are inherently shared objects
|
||||
plugging into an external Icarus/GHDL process — impossible in wasm; the
|
||||
d_cosim model fails at runtime exactly like a native install without a
|
||||
cosimulator.
|
||||
5. **Upstream 32-bit bug fixed (TODO: upstream).** CIDER card parsing frees
|
||||
through `dataType & IF_REALVEC` — a composite mask (0x8004) that also
|
||||
matches scalar `IF_SET|IF_REAL` (0x2004) parameters and then frees the
|
||||
vec-pointer union member overlaying the parsed scalar double. On 64-bit
|
||||
the misread lands in zero padding (free(NULL)); on wasm32 the pointer
|
||||
member overlays the HIGH half of the double → heap fault on e.g.
|
||||
`.model … numd … defa=1p`. Patched to exact `IF_VARTYPES` tests.
|
||||
6. `/proc/meminfo` header check forced off (`ac_cv_header__proc_meminfo=no`):
|
||||
configure runs on a Linux build host, the browser has no procfs, and
|
||||
ngspice treats "0 bytes available" as OOM.
|
||||
7. `-pthread` everywhere: without `HAVE_LIBPTHREAD`, `bg_run` silently
|
||||
degrades to a synchronous blocking call (sharedspice.c `runc()`).
|
||||
8. spinit installs to the sysroot and is `--embed-file`'d into the service at
|
||||
`/ngspice/scripts/spinit`; `main()` sets `SPICE_LIB_DIR=/ngspice` so
|
||||
ngspice's env-first search finds it regardless of the baked build prefix.
|
||||
Its `codemodel <prefix>/<cm>.cm` lines resolve by basename in the registry.
|
||||
|
||||
Gate 1 (`scripts/deps/ngspice-wasm/smoke/run-smoke.sh`, node): static link,
|
||||
RC transient numerics, XSPICE gain block through the registry, CIDER numd DC
|
||||
sweep, and bg_run → mid-run bg_halt → BGThreadRunning(finished). **The
|
||||
emscripten default 64 KB stack overflows in ngspice's parser** — the smoke
|
||||
and the service both run `-sSTACK_SIZE=4MB -sDEFAULT_PTHREAD_STACK_SIZE=2MB`
|
||||
(the unchecked overflow corrupts the heap and detonates later as free()
|
||||
faults; found via `-sSAFE_HEAP=1`).
|
||||
|
||||
## The service module
|
||||
|
||||
Pure libngspice + a ~350-line embind shim — **no KiCad/wx code**, so it
|
||||
builds standalone with emcmake (`build-ngspice_service.sh`, seconds, no
|
||||
docker) rather than through the kicad CMake tree like occ_service; the
|
||||
artifact lands in the standard `build-wasm/kicad-ngspice_service/ngspice_service/`
|
||||
layout so docker copy / test staging / publish treat it like any app.
|
||||
|
||||
- RPC surface mirrors sharedspice 1:1: `init/circ/command/getVecInfo/curPlot/
|
||||
allPlots/allVecs/running/cmInputPath`.
|
||||
- **Events**: callbacks fire on ngspice's bg pthread → `strdup` +
|
||||
`MAIN_THREAD_ASYNC_EM_ASM` → `Module.ngspiceEmit` (per-target FIFO keeps
|
||||
order; the service main thread is idle during a run so it drains promptly);
|
||||
the worker wrapper batches char/stat lines per microtask into one `{evt}`
|
||||
postMessage (flood guard) and flushes the batch before bg/exit events.
|
||||
- **bg_halt works mid-run** because the simulation occupies its own pthread —
|
||||
the module main thread stays free to service the halt RPC. Never shrink the
|
||||
pthread pool (occ lesson: a blocked browser thread cannot spawn workers).
|
||||
- **Vector reads are copied under `ngSpice_LockRealloc`** inside the service —
|
||||
this replaces KiCad's client-side RAII lock (a no-op across RPC; the ifdef
|
||||
leaves `m_ngSpice_LockRealloc` null) and makes the UI's mid-run plot
|
||||
refresh safe against the growing tran vectors.
|
||||
- getVecInfo returns heap views; the worker wrapper copies them into fresh
|
||||
arrays before postMessage (**a SAB-backed view cannot be transferred**).
|
||||
|
||||
## The editor side
|
||||
|
||||
- `NGSPICE::init_dll()` gets one `#ifdef __EMSCRIPTEN__` block binding the
|
||||
`m_ngSpice_*` pointers to `pcbjam_ngSpice_*` (prefix required: the class-
|
||||
scope typedef names would shadow same-named globals inside the member),
|
||||
plus a second `#ifndef __EMSCRIPTEN__` guard skipping the client-side
|
||||
spinit/codemodel staging. That staging is NOT harmless in wasm: its
|
||||
`wxSetWorkingDirectory( exe dir )` always fails in MEMFS (error 44), the
|
||||
queued wxLogError then flushes as a MODAL dialog over the freshly opened
|
||||
simulator frame, and the modal event pump dies with an asyncify-corruption
|
||||
signature ("index out of bounds" / "indirect call to null" — the known
|
||||
nested-modal-inside-doRewind limitation documented in
|
||||
wxwidgets/src/wasm/dialog.cpp; the pump's cancel-recovery keeps the app
|
||||
alive, but the corruption gate in eeschema-sim.spec.ts rightly fails). The
|
||||
service embeds its own spinit + code models, so the staging has nothing to
|
||||
do here anyway. Still the ONLY kicad-fork source file touched.
|
||||
- `wasm/stubs/sharedspice_client.cpp`: EM_ASYNC_JS request bridge (suspends
|
||||
the editor; `__asyncjs__*` is pre-covered by asyncify-imports.txt — do NOT
|
||||
add any of this path to the removelist), a dedicated get_vec bridge that
|
||||
mallocs vector doubles straight into the editor heap, a per-call
|
||||
`vector_info` arena (every NGSPICE consumer copies within the same call),
|
||||
an atomic `ngSpice_running` mirror (the UI polls on a timer; zero RPC per
|
||||
poll), and the **`.include`/`.lib` shipper**: `NETLIST_EXPORTER_SPICE`
|
||||
emits absolute `.include` paths (Sim.Library models, the IBIS cache) that
|
||||
ngspice must open in ITS filesystem — the stub scans the deck recursively
|
||||
(depth ≤ 4), reads the files from editor MEMFS and ships `{path,text}`
|
||||
pairs for the service to stage at identical paths.
|
||||
- **Events into a suspended editor**: the provider hands `{evt}` frames to
|
||||
`globalThis.__ngspiceOnEvent` (installed by the stub at first init), which
|
||||
calls the exported `pcbjam_ngspice_event` — a fresh wasm entry from JS,
|
||||
the same mechanism every wx-dom DOM event uses while the main loop is
|
||||
asyncify-suspended. KiCad's callbacks only take a mutex + `wxQueueEvent`,
|
||||
so nothing on the path can suspend.
|
||||
- The four giant model-data files (bsim4/b3soi/b4soi/hsim) are restored with
|
||||
per-source `-O2` (the "too many locals" limit was an -O0 artifact; same
|
||||
workaround as the msys block in eeschema/CMakeLists.txt) — full parameter
|
||||
tables in the model dialogs.
|
||||
|
||||
## Traps (learned the hard way)
|
||||
|
||||
- **`MIF*/` inside a C block comment terminates it** — the registry's early
|
||||
drafts broke the build with prose. (clangd flagged it; the diagnostics were
|
||||
right.)
|
||||
- The dev.c registry hook must be inserted BEFORE the registry body is
|
||||
appended, and its idempotency guard must match the CALL (`ngcm_static_load(name)`),
|
||||
not the name — the appended definition otherwise masks the hook forever.
|
||||
- ngspice's `BGThreadRunning` callback argument is *"not running"* (true =
|
||||
finished) — KiCad treats it as `aFinished`; keep the polarity.
|
||||
- The `running` mirror flips true at `bg_run` ACCEPTANCE (not at the bg
|
||||
'started' event) so an immediate `IsRunning()` poll already sees it.
|
||||
- Emscripten's `HEAPF64.set` after `_malloc` inside EM_ASYNC_JS is safe under
|
||||
memory growth (the views are refreshed), but always re-read the global
|
||||
after allocating.
|
||||
- sharedspice error recovery longjmps (`errbufm`/`errbufc`) — fine under the
|
||||
tree-wide wasm-SjLj model and an ASYNCIFY=0 module; never let it meet an
|
||||
asyncify-instrumented stack.
|
||||
|
||||
## Known limitations (parity-consistent)
|
||||
|
||||
- User-compiled `.cm` code models and `.osdi` (OpenVAF) binaries cannot load —
|
||||
wasm can't dlopen user binaries. ngspice reports them with its native error
|
||||
text. (Native parity minus the ability to install binary plugins.)
|
||||
- Verilog/GHDL co-simulation (`d_cosim`) needs an external simulator process —
|
||||
same failure text as a native install without Icarus.
|
||||
- OpenMP is off (unsupported in emscripten) — BSIM model evaluation runs
|
||||
single-threaded per timestep; the simulation itself still runs on its own
|
||||
background thread.
|
||||
2
kicad
2
kicad
|
|
@ -1 +1 @@
|
|||
Subproject commit d0e1705a24b5d446c27694aa03a6ed7c8ad10970
|
||||
Subproject commit cf1dd3fbd6cf14d4ba5e721ba4082b4e0f3cea42
|
||||
|
|
@ -106,6 +106,18 @@ echo " BINARYEN_CORES=${BINARYEN_CORES} LD_PRELOAD=${WASM_OPT_PRELOAD:-<none>}
|
|||
|
||||
SRC="${INPUT_WASM}"
|
||||
|
||||
# Refuse to double-instrument. A postprocess re-run on an artifact that a
|
||||
# previous (killed/partial) run already asyncified re-instruments the
|
||||
# instrumented module: the pass balloons to OOM/jetsam death, and the output
|
||||
# would be broken anyway. The asyncify export names only exist in a module
|
||||
# the pass already touched. Recover by re-copying the pristine post-link
|
||||
# artifact from the docker volume (docker/build.sh compile copy step).
|
||||
if LC_ALL=C grep -aq "asyncify_start_unwind" "${INPUT_WASM}"; then
|
||||
echo "ERROR: ${INPUT_WASM} already contains asyncify exports - refusing to" >&2
|
||||
echo "double-instrument. Restore the pristine post-link wasm first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 1. (native wasm-EH only) hoist C++ catch arms so Asyncify can suspend from inside them.
|
||||
if [ "$DO_HOIST" = 1 ]; then
|
||||
echo "Running --hoist-cpp-catches${HOIST_G:+ (keeping names for removelist matching)}..."
|
||||
|
|
|
|||
|
|
@ -24,3 +24,9 @@ BRepCheck_ParallelAnalyzer::operator*
|
|||
ShapeFix_Wire::FixGap3d*
|
||||
ShapeFix_Wire::FixGap2d*
|
||||
PCB_EDIT_FRAME::setupUIConditions*
|
||||
# The ngspice model-parameter table initializers (sim_model_ngspice_data_*.cpp,
|
||||
# restored for the simulator split): thousands of straight-line emplace_backs,
|
||||
# nothing that can suspend. Uninstrumented they stay under the JS engines'
|
||||
# per-function locals limit and shave the post-asyncify module size
|
||||
# (bsim4/b3soi/b4soi/hsim alone are the four largest functions in eeschema).
|
||||
NGSPICE_MODEL_INFO_MAP::add*
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export KICAD_VERSION="8.99"
|
|||
|
||||
# From vcpkg.json overrides (pinned versions)
|
||||
export GLM_VERSION="0.9.9.8"
|
||||
export NGSPICE_VERSION="45.2"
|
||||
export NGSPICE_VERSION="46"
|
||||
export PROTOBUF_VERSION="3.21.12"
|
||||
export PYTHON_VERSION="3.11.5"
|
||||
export WXWIDGETS_VERSION="3.3.1"
|
||||
|
|
@ -51,7 +51,9 @@ export CAIRO_URL="https://cairographics.org/releases/cairo-${CAIRO_VERSION}.tar.
|
|||
export PIXMAN_URL="https://cairographics.org/releases/pixman-${PIXMAN_VERSION}.tar.gz"
|
||||
export OCC_URL="https://github.com/Open-Cascade-SAS/OCCT/archive/refs/tags/V${OCC_VERSION//./_}.tar.gz"
|
||||
export RAPIDJSON_URL="https://github.com/Tencent/rapidjson/archive/${RAPIDJSON_COMMIT}.tar.gz"
|
||||
export NGSPICE_URL="https://sourceforge.net/projects/ngspice/files/ng-spice-rework/${NGSPICE_VERSION}/ngspice-${NGSPICE_VERSION}.tar.gz/download"
|
||||
# downloads.sourceforge.net serves the file directly; the projects/... /download
|
||||
# form returns an HTML redirect page that breaks curl-based fetches.
|
||||
export NGSPICE_URL="https://downloads.sourceforge.net/project/ngspice/ng-spice-rework/${NGSPICE_VERSION}/ngspice-${NGSPICE_VERSION}.tar.gz"
|
||||
|
||||
# SHA256 checksums (to be filled in after first successful download)
|
||||
# export ZSTD_SHA256=""
|
||||
|
|
|
|||
|
|
@ -43,12 +43,13 @@ const TOOLS = [
|
|||
"gerbview",
|
||||
"calculator",
|
||||
"occ_service",
|
||||
"ngspice_service",
|
||||
];
|
||||
|
||||
// Files that make up a self-contained tool bundle. `<tool>` is substituted.
|
||||
const SHARED_FILES = ["wx.js", "wx-dom.js", "images.tar.gz"];
|
||||
const toolFiles = (tool) =>
|
||||
tool === "occ_service"
|
||||
tool === "occ_service" || tool === "ngspice_service"
|
||||
? [`${tool}.wasm`, `${tool}.js`]
|
||||
: [`${tool}.wasm`, `${tool}.js`, ...SHARED_FILES];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,30 @@
|
|||
#!/bin/bash
|
||||
# Build ngspice for WebAssembly
|
||||
# ngspice provides SPICE simulation for KiCad's Eeschema
|
||||
w# Build ngspice for WebAssembly: sharedspice STATIC library + statically
|
||||
# registered XSPICE code models. Consumed by the ngspice_service worker that
|
||||
# backs KiCad eeschema's simulator.
|
||||
#
|
||||
# Deviations from a stock ngspice build, all load-bearing for wasm:
|
||||
# - --with-ngshared + --disable-shared: libtool builds libngspice.a with the
|
||||
# sharedspice API (ngSpice_Init & co.) compiled in and no CLI programs
|
||||
# (bin_PROGRAMS is gated !SHARED_MODULE upstream, so no duplicate main()).
|
||||
# - XSPICE code models (.cm) are dlopen'd at runtime natively, which a static
|
||||
# wasm module cannot do. Under NGCM_STATIC the icm build archives each code
|
||||
# model instead of linking a shared object (per-cm renamed table symbols via
|
||||
# ngcm_dlmain_static.c), and a registry appended to dev.c resolves the seven
|
||||
# bundled .cm basenames without dlopen. dlopen remains the fallback for
|
||||
# unknown paths so user code models fail with ngspice's normal error text.
|
||||
# Sources in scripts/deps/ngspice-wasm/; edits to the ngspice tree are
|
||||
# idempotent (marker-guarded) since the tree is an extracted tarball.
|
||||
# - /proc/meminfo header check is forced off: configure runs on the build
|
||||
# host (Linux in docker), the browser runtime has no procfs, and ngspice's
|
||||
# memory guard treats "0 bytes available" as out-of-memory.
|
||||
# - -pthread everywhere: sharedspice's bg_run/bg_halt background thread is
|
||||
# gated on HAVE_LIBPTHREAD; without it bg_run silently degrades to a
|
||||
# synchronous blocking call.
|
||||
# - Exception model must match the rest of the tree (DEPS_EH_FLAGS).
|
||||
# - XSPICE + CIDER enabled: parity with native KiCad's bundled ngspice.
|
||||
# - cmpp (XSPICE preprocessor) runs on the build host; ngspice's configure
|
||||
# handles that itself when cross_compiling=yes (src/xspice/cmpp/build/).
|
||||
|
||||
set -e
|
||||
|
||||
|
|
@ -12,6 +36,7 @@ source "${SCRIPT_DIR}/../common/functions.sh"
|
|||
NGSPICE_DIR="${DEPS_ROOT}/ngspice-${NGSPICE_VERSION}"
|
||||
NGSPICE_BUILD="${BUILD_ROOT}/deps/ngspice"
|
||||
NGSPICE_STAMP="${BUILD_ROOT}/stamps/ngspice.stamp"
|
||||
NGCM_SRC_DIR="${SCRIPT_DIR}/ngspice-wasm"
|
||||
|
||||
# Parse arguments
|
||||
CLEAN=0
|
||||
|
|
@ -41,50 +66,285 @@ if [ ! -d "${NGSPICE_DIR}" ]; then
|
|||
mkdir -p "${DEPS_ROOT}"
|
||||
cd "${DEPS_ROOT}"
|
||||
|
||||
NGSPICE_URL="https://sourceforge.net/projects/ngspice/files/ng-spice-rework/${NGSPICE_VERSION}/ngspice-${NGSPICE_VERSION}.tar.gz/download"
|
||||
curl -L "${NGSPICE_URL}" -o "ngspice-${NGSPICE_VERSION}.tar.gz"
|
||||
download_file "${NGSPICE_URL}" "ngspice-${NGSPICE_VERSION}.tar.gz"
|
||||
tar -xzf "ngspice-${NGSPICE_VERSION}.tar.gz"
|
||||
rm "ngspice-${NGSPICE_VERSION}.tar.gz"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static code-model support (NGCM_STATIC) - idempotent source edits
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEV_C="${NGSPICE_DIR}/src/spicelib/devices/dev.c"
|
||||
ICM_MK="${NGSPICE_DIR}/src/xspice/icm/GNUmakefile.in"
|
||||
|
||||
# 1. Hook load_opus(): consult the registry before attempting dlopen.
|
||||
# Must run BEFORE the registry append: the registry body contains the
|
||||
# ngcm_static_load definition, so a plain grep for the name would mask the
|
||||
# hook forever. The guard matches the call site only.
|
||||
if ! grep -q "ngcm_static_load(name)" "${DEV_C}"; then
|
||||
log_info "Inserting registry hook into load_opus()"
|
||||
python3 - "${DEV_C}" <<'EOF'
|
||||
import sys
|
||||
|
||||
path = sys.argv[1]
|
||||
src = open(path).read()
|
||||
anchor = " lib = dlopen(name, RTLD_NOW);"
|
||||
hook = """#ifdef NGCM_STATIC
|
||||
{
|
||||
extern int ngcm_static_load(const char *path);
|
||||
int ngcm_ret = ngcm_static_load(name);
|
||||
if (ngcm_ret >= 0)
|
||||
return ngcm_ret;
|
||||
/* not a bundled code model: fall through to dlopen */
|
||||
}
|
||||
#endif
|
||||
"""
|
||||
assert src.count(anchor) == 1, "load_opus dlopen anchor not unique"
|
||||
open(path, "w").write(src.replace(anchor, hook + anchor))
|
||||
EOF
|
||||
fi
|
||||
|
||||
# 2. Append the code-model registry to dev.c (marker-guarded).
|
||||
if ! grep -q "NGCM_REGISTRY_MARKER" "${DEV_C}"; then
|
||||
log_info "Appending static code-model registry to dev.c"
|
||||
cat "${NGCM_SRC_DIR}/ngcm_registry.c" >> "${DEV_C}"
|
||||
fi
|
||||
|
||||
# 3. Fix an upstream 32-bit union-punning bug in the CIDER card parser
|
||||
# (found by the Gate-1 smoke, ngspice 46). The cleanup tests use
|
||||
# `dataType & IF_REALVEC` (0x8004): every scalar IF_SET|IF_REAL parameter
|
||||
# (0x2004) matches through the shared 0x4 bit, and the code then frees the
|
||||
# vec-pointer union member overlaying the parsed scalar double. On 64-bit
|
||||
# hosts that misread lands in zeroed padding after the double
|
||||
# (free(NULL) no-op), which is why upstream never noticed; on wasm32 the
|
||||
# pointer member overlays the HIGH HALF of the double and free() faults.
|
||||
# TODO: upstream this to ngspice.
|
||||
python3 - "${NGSPICE_DIR}/src/spicelib/parser/inpgmod.c" <<'EOF'
|
||||
import sys
|
||||
|
||||
path = sys.argv[1]
|
||||
src = open(path).read()
|
||||
old = """ if (info->cardParms[idx].dataType & IF_STRING) {
|
||||
FREE(value->sValue);
|
||||
} else if (info->cardParms[idx].dataType & IF_REALVEC) {
|
||||
FREE(value->v.vec.rVec);
|
||||
} else if (info->cardParms[idx].dataType & IF_INTVEC) {
|
||||
FREE(value->v.vec.iVec);
|
||||
}"""
|
||||
new = """ /* kicad-wasm: exact variant-type tests. The original
|
||||
* `& IF_REALVEC` composite masks also match scalar
|
||||
* IF_SET|IF_REAL parameters (shared 0x4 bit) and free the
|
||||
* vec-pointer union member overlaying the scalar double -
|
||||
* benign on 64-bit (lands in zero padding), heap fault on
|
||||
* wasm32. */
|
||||
int ngcm_vt = info->cardParms[idx].dataType & IF_VARTYPES;
|
||||
if (ngcm_vt == IF_STRING) {
|
||||
FREE(value->sValue);
|
||||
} else if (ngcm_vt == IF_REALVEC) {
|
||||
FREE(value->v.vec.rVec);
|
||||
} else if (ngcm_vt == IF_INTVEC) {
|
||||
FREE(value->v.vec.iVec);
|
||||
}"""
|
||||
if new in src:
|
||||
pass # already applied
|
||||
else:
|
||||
assert src.count(old) == 1, "inpgmod.c cleanup-tests anchor not found"
|
||||
open(path, "w").write(src.replace(old, new))
|
||||
print("patched inpgmod.c IF_VARTYPES cleanup tests")
|
||||
EOF
|
||||
|
||||
# 4. Redirect the icm build: archive code models instead of shared-linking,
|
||||
# compile our renamed-tables TU instead of dlmain.c, and pass the cm name.
|
||||
# All three edits are exact-string replacements, applied once.
|
||||
python3 - "${ICM_MK}" <<'EOF'
|
||||
import sys
|
||||
|
||||
path = sys.argv[1]
|
||||
src = open(path).read()
|
||||
|
||||
edits = [
|
||||
# .cm link recipe -> archive under NGCM_STATIC. The three common objects
|
||||
# are excluded: dstring.o duplicates the core's, and the tline commons are
|
||||
# shipped once via ngcm_common.a (they are prerequisites of every cm here
|
||||
# but only the tlines models reference them).
|
||||
("\t$(CC) $(CFLAGS) $(EXTRA_CFLAGS) $(VIS_CFLAGS) $(LDFLAGS) $^ $(LIBS) -o $@",
|
||||
"\t$(if $(NGCM_STATIC),emar rcs $@ $(filter-out dstring.o msline_common.o tline_common.o,$^),$(CC) $(CFLAGS) $(EXTRA_CFLAGS) $(VIS_CFLAGS) $(LDFLAGS) $^ $(LIBS) -o $@)"),
|
||||
# dlmain.o compiles our static-tables TU under NGCM_STATIC ($< follows the
|
||||
# first prerequisite).
|
||||
("$(cm)/dlmain.o : $(srcdir)/dlmain.c $(cm-descr)",
|
||||
"$(cm)/dlmain.o : $(if $(NGCM_STATIC),$(NGCM_DLMAIN),$(srcdir)/dlmain.c) $(cm-descr)"),
|
||||
# Per-cm symbol prefix for the tables TU (harmless for the other objects).
|
||||
("COMPILE = $(CC) $(INCLUDES) -I$(cm) -I$(srcdir)/$(cm) $(CFLAGS) $(EXTRA_CFLAGS) $(VIS_CFLAGS)",
|
||||
"COMPILE = $(CC) $(INCLUDES) -I$(cm) -I$(srcdir)/$(cm) $(CFLAGS) $(EXTRA_CFLAGS) $(VIS_CFLAGS) $(if $(NGCM_STATIC),-DNGCM_NAME=$(cm))"),
|
||||
# The $(shell cmpp -p) model-list calls hardcode the in-tree cmpp, which
|
||||
# is the CROSS-compiled (wasm) binary the build host cannot execute -
|
||||
# the lists come back empty and the code models silently lose all their
|
||||
# cfunc/ifspec objects. makedefs' CMPP is the host-built one under
|
||||
# cross-compilation (configure.ac:1470-1475).
|
||||
("""ifeq ($(OS),Windows_NT)
|
||||
cmpp = ../cmpp/cmpp.exe
|
||||
else
|
||||
cmpp = ../cmpp/cmpp
|
||||
endif""",
|
||||
"cmpp = $(CMPP)"),
|
||||
]
|
||||
|
||||
changed = False
|
||||
for old, new in edits:
|
||||
if new in src:
|
||||
continue # already applied
|
||||
assert old in src, f"icm GNUmakefile.in anchor not found: {old!r}"
|
||||
src = src.replace(old, new)
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
open(path, "w").write(src)
|
||||
print("patched icm GNUmakefile.in")
|
||||
EOF
|
||||
|
||||
log_info "Building ngspice ${NGSPICE_VERSION} for WASM..."
|
||||
|
||||
mkdir -p "${NGSPICE_BUILD}"
|
||||
cd "${NGSPICE_BUILD}"
|
||||
|
||||
# ngspice uses autoconf
|
||||
# Set compiler flags based on debug mode
|
||||
# ngspice must run at real speed even in debug builds of the rest of the tree:
|
||||
# the simulator is compute-bound and its own module is finalized separately.
|
||||
if [ "${DEBUG_BUILD:-1}" = "1" ]; then
|
||||
export CFLAGS="-g -O0 -pthread"
|
||||
export CXXFLAGS="-g -O0 -pthread"
|
||||
NGSPICE_DEBUG_FLAG="--enable-debug"
|
||||
NGSPICE_OPT="-O2 -g"
|
||||
else
|
||||
export CFLAGS="-O2 -pthread"
|
||||
export CXXFLAGS="-O2 -pthread"
|
||||
NGSPICE_DEBUG_FLAG="--disable-debug"
|
||||
NGSPICE_OPT="-O2"
|
||||
fi
|
||||
export LDFLAGS="-pthread"
|
||||
|
||||
# Configure ngspice as a static library for WASM
|
||||
# Note: --with-ngshared requires shared libs which WASM doesn't support
|
||||
# We build static lib instead
|
||||
# -Wno-error guards: CIDER and parts of XSPICE are legacy C that modern clang
|
||||
# (emcc >= llvm 16) rejects by default.
|
||||
export CFLAGS="${NGSPICE_OPT} -pthread ${DEPS_EH_FLAGS} -DNGCM_STATIC \
|
||||
-Wno-error=implicit-function-declaration -Wno-error=implicit-int"
|
||||
export CXXFLAGS="${NGSPICE_OPT} -pthread ${DEPS_EH_FLAGS}"
|
||||
export LDFLAGS="-pthread ${DEPS_EH_FLAGS}"
|
||||
|
||||
# Makefile-level knobs for the icm (code model) build, read from the
|
||||
# environment by our GNUmakefile.in edits above.
|
||||
export NGCM_STATIC=1
|
||||
export NGCM_DLMAIN="${NGCM_SRC_DIR}/ngcm_dlmain_static.c"
|
||||
|
||||
emconfigure "${NGSPICE_DIR}/configure" \
|
||||
--prefix="${SYSROOT}" \
|
||||
--host=wasm32-unknown-emscripten \
|
||||
--build=$(uname -m)-linux-gnu \
|
||||
--build="$("${NGSPICE_DIR}/config.guess")" \
|
||||
--with-ngshared \
|
||||
--disable-shared \
|
||||
--enable-static \
|
||||
${NGSPICE_DEBUG_FLAG} \
|
||||
--disable-dependency-tracking \
|
||||
--disable-openmp \
|
||||
--enable-cider \
|
||||
--enable-xspice \
|
||||
--enable-cider \
|
||||
--disable-openmp \
|
||||
--disable-debug \
|
||||
--without-x \
|
||||
--without-readline \
|
||||
--without-editline
|
||||
--with-readline=no \
|
||||
--without-editline \
|
||||
ac_cv_header__proc_meminfo=no
|
||||
|
||||
emmake make -j${JOBS}
|
||||
emmake make install
|
||||
# ngspice hardwires libtool's -shared mode for the ngshared build: configure
|
||||
# sets STATIC=-shared (consumed as AM_CFLAGS by every convenience lib) and
|
||||
# src/Makefile.am gives libngspice_la_{CFLAGS,LDFLAGS} a literal -shared.
|
||||
# libtool refuses -shared outright on a target without shared-library support
|
||||
# ("Fatal configuration error"), so force static mode: the make command line
|
||||
# overrides $(STATIC) everywhere, and the two hardwired lines are rewritten in
|
||||
# the generated Makefile (regenerated by configure on every build, so this
|
||||
# stays idempotent).
|
||||
sed -i.ngcm.bak \
|
||||
-e 's/^\(libngspice_la_[A-Z]*FLAGS *=.*\)-shared/\1-static/' \
|
||||
src/Makefile
|
||||
|
||||
# The XSPICE verilog/vhdl subdirs build VPI co-simulation shims (ivlng.la,
|
||||
# ivlngvpi.la) that are inherently SHARED objects plugging into an external
|
||||
# Icarus/GHDL process - impossible in wasm and unbuildable without shared-lib
|
||||
# support. Skipping them matches native behaviour when no cosimulator is
|
||||
# installed: the d_cosim code model fails at runtime with ngspice's normal
|
||||
# error message.
|
||||
sed -i.ngcm.bak \
|
||||
-e 's/^\(SUBDIRS = mif cm enh evt idn cmpp icm\) verilog vhdl$/\1/' \
|
||||
src/xspice/Makefile
|
||||
|
||||
emmake make -j${JOBS} STATIC=-static
|
||||
emmake make install STATIC=-static
|
||||
|
||||
# dlmain.c's tail (fopen_with_path, cm_message_printf, cm_is_inertial) is
|
||||
# utility code the cfunc objects call but that exists nowhere in the core -
|
||||
# natively each .cm DLL carries its own copy. Extract it once from the
|
||||
# pristine dlmain.c (BSD-3) so the seven archives stay collision-free; the
|
||||
# coreitf wrapper section above the marker must NOT come along (it would
|
||||
# shadow real core functions with calls through a never-initialized coreitf).
|
||||
python3 - "${NGSPICE_DIR}/src/xspice/icm/dlmain.c" "${NGSPICE_BUILD}/ngcm_cmutil.c" <<'EOF'
|
||||
import sys
|
||||
|
||||
src_path, out_path = sys.argv[1], sys.argv[2]
|
||||
src = open(src_path).read()
|
||||
marker = "#define DFLT_BUF_SIZE 256"
|
||||
assert src.count(marker) == 1, "dlmain.c utility-tail marker not unique"
|
||||
tail = src[src.index(marker):]
|
||||
preamble = """/* Generated by build-ngspice.sh: utility tail of ngspice's
|
||||
* src/xspice/icm/dlmain.c (BSD-3-Clause, Copyright 2000 The ngspice team),
|
||||
* shared once across the statically linked code models. */
|
||||
#include <stdarg.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "ngspice/config.h"
|
||||
#include "ngspice/cpextern.h"
|
||||
#include "ngspice/devdefs.h"
|
||||
#include "ngspice/dstring.h"
|
||||
#include "ngspice/dllitf.h"
|
||||
#include "ngspice/evtudn.h"
|
||||
#include "ngspice/inpdefs.h"
|
||||
#include "ngspice/inertial.h"
|
||||
#include "ngspice/cmproto.h"
|
||||
|
||||
/* In the DLL world cm_getvar is a dlmain.c wrapper that reaches the core's
|
||||
* cp_getvar through coreitf (see cmexport.c binding dllitf_cm_getvar to
|
||||
* cp_getvar). Statically there is no coreitf; bind it directly. */
|
||||
bool cm_getvar(char *name, enum cp_types type, void *retval, size_t rsize)
|
||||
{
|
||||
return cp_getvar(name, type, retval, rsize);
|
||||
}
|
||||
|
||||
"""
|
||||
open(out_path, "w").write(preamble + tail)
|
||||
EOF
|
||||
|
||||
emcc -c "${NGSPICE_BUILD}/ngcm_cmutil.c" -o "${NGSPICE_BUILD}/ngcm_cmutil.o" \
|
||||
${CFLAGS} \
|
||||
-I"${NGSPICE_DIR}/src/include" \
|
||||
-I"${NGSPICE_BUILD}/src/include"
|
||||
|
||||
# The tline/msline common objects every cm build compiles but only the tlines
|
||||
# models reference; shipped once so whole-archiving the .cm archives stays
|
||||
# duplicate-free.
|
||||
emar rcs "${SYSROOT}/lib/ngspice/ngcm_common.a" \
|
||||
src/xspice/icm/msline_common.o \
|
||||
src/xspice/icm/tline_common.o \
|
||||
src/xspice/icm/dstring.o \
|
||||
"${NGSPICE_BUILD}/ngcm_cmutil.o"
|
||||
|
||||
# Sanity: everything the ngspice_service link needs must exist.
|
||||
for f in \
|
||||
"${SYSROOT}/lib/libngspice.a" \
|
||||
"${SYSROOT}/include/ngspice/sharedspice.h" \
|
||||
"${SYSROOT}/lib/ngspice/analog.cm" \
|
||||
"${SYSROOT}/lib/ngspice/digital.cm" \
|
||||
"${SYSROOT}/lib/ngspice/spice2poly.cm" \
|
||||
"${SYSROOT}/lib/ngspice/table.cm" \
|
||||
"${SYSROOT}/lib/ngspice/tlines.cm" \
|
||||
"${SYSROOT}/lib/ngspice/xtradev.cm" \
|
||||
"${SYSROOT}/lib/ngspice/xtraevt.cm" \
|
||||
"${SYSROOT}/share/ngspice/scripts/spinit"; do
|
||||
if [ ! -f "$f" ]; then
|
||||
log_error "ngspice install incomplete: missing $f"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
create_stamp "${NGSPICE_STAMP}"
|
||||
log_info "ngspice build complete!"
|
||||
|
|
|
|||
57
scripts/deps/ngspice-wasm/ngcm_dlmain_static.c
Normal file
57
scripts/deps/ngspice-wasm/ngcm_dlmain_static.c
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* Static-build replacement for ngspice's src/xspice/icm/dlmain.c.
|
||||
*
|
||||
* In the WASM build the XSPICE code models (.cm) cannot be dlopen'd, so each
|
||||
* code-model directory is compiled into a static archive instead of a shared
|
||||
* object (see build-ngspice.sh, NGCM_STATIC). This TU provides only the two
|
||||
* model tables, renamed per code model (ngcm_<cm>_cmDEVices etc.) so all seven
|
||||
* archives can coexist in one image. Everything else dlmain.c contains - the
|
||||
* CMdevs()/CMudns() accessor exports and the coreitf-forwarding wrappers for
|
||||
* the MIF core functions - is deliberately omitted: the registry appended to
|
||||
* dev.c (ngcm_registry.c) reads the tables directly, and in a static link the
|
||||
* code-model objects bind straight to the real core functions, which the
|
||||
* wrappers would otherwise collide with.
|
||||
*
|
||||
* Compiled once per code model with -DNGCM_NAME=<cm> and -I<cm-build-dir> so
|
||||
* the cmpp-generated cmextrn.h/cminfo.h/udnextrn.h/udninfo.h of that model
|
||||
* are picked up.
|
||||
*/
|
||||
|
||||
#include <stdarg.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "ngspice/config.h"
|
||||
#include "ngspice/cpextern.h"
|
||||
#include "ngspice/devdefs.h"
|
||||
#include "ngspice/dstring.h"
|
||||
#include "ngspice/dllitf.h"
|
||||
#include "ngspice/evtudn.h"
|
||||
#include "ngspice/inpdefs.h"
|
||||
#include "ngspice/inertial.h"
|
||||
#include "cmextrn.h"
|
||||
#include "udnextrn.h"
|
||||
|
||||
#ifndef NGCM_NAME
|
||||
#error "ngcm_dlmain_static.c must be compiled with -DNGCM_NAME=<code model name>"
|
||||
#endif
|
||||
|
||||
#define NGCM_PASTE2(a, b) a##b
|
||||
#define NGCM_PASTE(a, b) NGCM_PASTE2(a, b)
|
||||
#define NGCM_SYM(s) NGCM_PASTE(NGCM_PASTE(ngcm_, NGCM_NAME), NGCM_PASTE2(_, s))
|
||||
|
||||
SPICEdev *NGCM_SYM(cmDEVices)[] = {
|
||||
#include "cminfo.h"
|
||||
NULL
|
||||
};
|
||||
|
||||
int NGCM_SYM(cmDEVicesCNT) =
|
||||
sizeof(NGCM_SYM(cmDEVices)) / sizeof(SPICEdev *) - 1;
|
||||
|
||||
Evt_Udn_Info_t *NGCM_SYM(cmEVTudns)[] = {
|
||||
#include "udninfo.h"
|
||||
NULL
|
||||
};
|
||||
|
||||
int NGCM_SYM(cmEVTudnCNT) =
|
||||
sizeof(NGCM_SYM(cmEVTudns)) / sizeof(Evt_Udn_Info_t *) - 1;
|
||||
82
scripts/deps/ngspice-wasm/ngcm_registry.c
Normal file
82
scripts/deps/ngspice-wasm/ngcm_registry.c
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
/* NGCM_REGISTRY_MARKER - appended to src/spicelib/devices/dev.c by
|
||||
* scripts/deps/build-ngspice.sh (idempotent: guarded by this marker).
|
||||
*
|
||||
* Registry of the statically-linked XSPICE code models. In the WASM build the
|
||||
* bundled .cm files are static archives (see ngcm_dlmain_static.c) and cannot
|
||||
* be dlopen'd; load_opus() consults this registry first (hook inserted by
|
||||
* build-ngspice.sh) and only falls back to dlopen - which then fails with
|
||||
* ngspice's normal error reporting - for paths it does not recognize, e.g.
|
||||
* user-compiled code models, which cannot exist as loadable binaries in wasm.
|
||||
*
|
||||
* Matching is by basename so the spinit "codemodel <path>/analog.cm" lines
|
||||
* keep working regardless of the install prefix baked into spinit.
|
||||
*
|
||||
* No coreitf wiring happens here: in a static link the code-model objects call
|
||||
* the MIF and cm_ core functions directly, so the dlmain.c indirection table
|
||||
* the dlopen path has to fill in does not exist.
|
||||
*/
|
||||
#if defined(NGCM_STATIC) && defined(XSPICE)
|
||||
|
||||
#include "ngspice/devdefs.h"
|
||||
#include "ngspice/evtudn.h"
|
||||
#include <string.h>
|
||||
|
||||
#define NGCM_DECL(cm) \
|
||||
extern SPICEdev *ngcm_##cm##_cmDEVices[]; \
|
||||
extern int ngcm_##cm##_cmDEVicesCNT; \
|
||||
extern Evt_Udn_Info_t *ngcm_##cm##_cmEVTudns[]; \
|
||||
extern int ngcm_##cm##_cmEVTudnCNT;
|
||||
|
||||
NGCM_DECL(analog)
|
||||
NGCM_DECL(digital)
|
||||
NGCM_DECL(spice2poly)
|
||||
NGCM_DECL(table)
|
||||
NGCM_DECL(tlines)
|
||||
NGCM_DECL(xtradev)
|
||||
NGCM_DECL(xtraevt)
|
||||
|
||||
struct ngcm_static_entry {
|
||||
const char *basename;
|
||||
SPICEdev **devs;
|
||||
int *devnum;
|
||||
Evt_Udn_Info_t **udns;
|
||||
int *udnnum;
|
||||
};
|
||||
|
||||
#define NGCM_ENTRY(cm) \
|
||||
{ #cm ".cm", ngcm_##cm##_cmDEVices, &ngcm_##cm##_cmDEVicesCNT, \
|
||||
ngcm_##cm##_cmEVTudns, &ngcm_##cm##_cmEVTudnCNT }
|
||||
|
||||
static const struct ngcm_static_entry ngcm_static_entries[] = {
|
||||
NGCM_ENTRY(analog),
|
||||
NGCM_ENTRY(digital),
|
||||
NGCM_ENTRY(spice2poly),
|
||||
NGCM_ENTRY(table),
|
||||
NGCM_ENTRY(tlines),
|
||||
NGCM_ENTRY(xtradev),
|
||||
NGCM_ENTRY(xtraevt),
|
||||
};
|
||||
|
||||
/* Returns load_opus()-compatible status: 0 = registered, -1 = not a bundled
|
||||
* code model (caller falls through to the dlopen path). */
|
||||
int ngcm_static_load(const char *path)
|
||||
{
|
||||
const char *base = strrchr(path, '/');
|
||||
size_t i;
|
||||
|
||||
base = base ? base + 1 : path;
|
||||
|
||||
for (i = 0; i < sizeof(ngcm_static_entries) / sizeof(ngcm_static_entries[0]); i++) {
|
||||
const struct ngcm_static_entry *e = &ngcm_static_entries[i];
|
||||
|
||||
if (strcmp(base, e->basename) == 0) {
|
||||
add_device(*e->devnum, e->devs, 1);
|
||||
add_udn(*e->udnnum, e->udns);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
#endif /* NGCM_STATIC && XSPICE */
|
||||
40
scripts/deps/ngspice-wasm/smoke/run-smoke.sh
Executable file
40
scripts/deps/ngspice-wasm/smoke/run-smoke.sh
Executable file
|
|
@ -0,0 +1,40 @@
|
|||
#!/bin/bash
|
||||
# Gate-1 smoke for the wasm ngspice build: compiles smoke.c against the
|
||||
# installed sysroot artifacts and runs it under node. Proves, in one shot:
|
||||
# static sharedspice links; RC transient numerics; XSPICE code models resolve
|
||||
# through the static registry; CIDER (numd) simulates; bg_run/bg_halt work on
|
||||
# a real pthread. See smoke.c for the assertions.
|
||||
#
|
||||
# Usage: scripts/deps/ngspice-wasm/smoke/run-smoke.sh
|
||||
# (build ngspice first: scripts/deps/build-ngspice.sh)
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
QUIET=1 source "${SCRIPT_DIR}/../../../common/env.sh"
|
||||
|
||||
SMOKE_BUILD="${BUILD_ROOT}/ngspice-smoke"
|
||||
mkdir -p "${SMOKE_BUILD}"
|
||||
|
||||
# NODERAWFS: the wasm module sees the real filesystem, so the NGSPICEDATADIR
|
||||
# baked into libngspice (the sysroot prefix) resolves and spinit + code-model
|
||||
# registration run exactly as they will in the service worker.
|
||||
# PROXY_TO_PTHREAD: main() may block (usleep) while ngspice's bg thread runs.
|
||||
emcc "${SCRIPT_DIR}/smoke.c" -o "${SMOKE_BUILD}/smoke.js" \
|
||||
-I"${SYSROOT}/include" \
|
||||
-O1 -g -pthread ${DEPS_EH_FLAGS} \
|
||||
"${SYSROOT}/lib/libngspice.a" \
|
||||
"${SYSROOT}"/lib/ngspice/*.cm \
|
||||
"${SYSROOT}/lib/ngspice/ngcm_common.a" \
|
||||
-sENVIRONMENT=node \
|
||||
-sNODERAWFS=1 \
|
||||
-sALLOW_MEMORY_GROWTH=1 \
|
||||
-sINITIAL_MEMORY=256MB \
|
||||
-sPROXY_TO_PTHREAD \
|
||||
-sPTHREAD_POOL_SIZE=8 \
|
||||
-sEXIT_RUNTIME=1 \
|
||||
-sSTACK_SIZE=4MB \
|
||||
-sDEFAULT_PTHREAD_STACK_SIZE=2MB
|
||||
|
||||
# Optional argument: run a single scenario (rc|xspice|cider|halt).
|
||||
node "${SMOKE_BUILD}/smoke.js" "$@"
|
||||
330
scripts/deps/ngspice-wasm/smoke/smoke.c
Normal file
330
scripts/deps/ngspice-wasm/smoke/smoke.c
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
/*
|
||||
* Gate-1 smoke test for the wasm ngspice sharedspice static build.
|
||||
* Runs under node (see run-smoke.sh). Four scenarios, each printing
|
||||
* "SMOKE PASS <name>" on success; any failure prints "SMOKE FAIL <name>: why"
|
||||
* and exits non-zero at the end.
|
||||
*
|
||||
* rc - foreground .tran of an RC charge curve, numeric check
|
||||
* xspice - gain a-device .op (proves the static code-model registry:
|
||||
* the model only exists if spinit's codemodel lines resolved)
|
||||
* cider - numd (CIDER) silicon resistor DC sweep completes
|
||||
* halt - bg_run on a heavy deck, bg_halt mid-run, BGThreadRunning
|
||||
* callback fires with finished=true (proves the real pthread path)
|
||||
*
|
||||
* Built with -sPROXY_TO_PTHREAD so main() may block (usleep) while ngspice's
|
||||
* own background thread simulates.
|
||||
*/
|
||||
|
||||
#include <stdatomic.h>
|
||||
#include <stdbool.h> /* sharedspice.h's NG_BOOL fallback typedef needs it */
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "ngspice/sharedspice.h"
|
||||
|
||||
static atomic_int g_bg_finished_events;
|
||||
static atomic_int g_exit_called;
|
||||
static atomic_int g_char_lines;
|
||||
static int g_failures;
|
||||
|
||||
static int cb_send_char(char *what, int id, void *user)
|
||||
{
|
||||
(void) id;
|
||||
(void) user;
|
||||
atomic_fetch_add(&g_char_lines, 1);
|
||||
if (getenv("SMOKE_VERBOSE"))
|
||||
fprintf(stderr, "[ngspice] %s\n", what);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int cb_send_stat(char *what, int id, void *user)
|
||||
{
|
||||
(void) what;
|
||||
(void) id;
|
||||
(void) user;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int cb_controlled_exit(int status, NG_BOOL immediate, NG_BOOL quit,
|
||||
int id, void *user)
|
||||
{
|
||||
(void) immediate;
|
||||
(void) quit;
|
||||
(void) id;
|
||||
(void) user;
|
||||
fprintf(stderr, "[smoke] ControlledExit status=%d\n", status);
|
||||
atomic_store(&g_exit_called, 1);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int cb_bg_running(NG_BOOL finished, int id, void *user)
|
||||
{
|
||||
(void) id;
|
||||
(void) user;
|
||||
if (finished)
|
||||
atomic_fetch_add(&g_bg_finished_events, 1);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void fail(const char *name, const char *why)
|
||||
{
|
||||
printf("SMOKE FAIL %s: %s\n", name, why);
|
||||
g_failures++;
|
||||
}
|
||||
|
||||
static void pass(const char *name)
|
||||
{
|
||||
printf("SMOKE PASS %s\n", name);
|
||||
}
|
||||
|
||||
/* Fetch a vector, returning its length and (optionally) the last real value. */
|
||||
static int vec_last(const char *vec, int *len_out, double *last_out)
|
||||
{
|
||||
pvector_info vi = ngGet_Vec_Info((char *) vec);
|
||||
|
||||
if (!vi || vi->v_length <= 0)
|
||||
return -1;
|
||||
if (len_out)
|
||||
*len_out = vi->v_length;
|
||||
if (last_out) {
|
||||
if (vi->v_realdata)
|
||||
*last_out = vi->v_realdata[vi->v_length - 1];
|
||||
else if (vi->v_compdata)
|
||||
*last_out = vi->v_compdata[vi->v_length - 1].cx_real;
|
||||
else
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int run_circ(const char *const *lines)
|
||||
{
|
||||
/* ngSpice_Circ wants a NULL-terminated array of writable strings. */
|
||||
int n = 0;
|
||||
while (lines[n])
|
||||
n++;
|
||||
|
||||
char **arr = malloc((size_t) (n + 1) * sizeof(char *));
|
||||
for (int i = 0; i < n; i++)
|
||||
arr[i] = strdup(lines[i]);
|
||||
arr[n] = NULL;
|
||||
|
||||
int ret = ngSpice_Circ(arr);
|
||||
|
||||
for (int i = 0; i < n; i++)
|
||||
free(arr[i]);
|
||||
free(arr);
|
||||
return ret;
|
||||
}
|
||||
|
||||
static void test_rc(void)
|
||||
{
|
||||
static const char *const deck[] = {
|
||||
"rc smoke",
|
||||
"V1 in 0 1",
|
||||
"R1 in out 1k",
|
||||
"C1 out 0 1u",
|
||||
".tran 10u 5m",
|
||||
".end",
|
||||
NULL,
|
||||
};
|
||||
|
||||
if (run_circ(deck) != 0)
|
||||
return fail("rc", "ngSpice_Circ failed");
|
||||
if (ngSpice_Command("run") != 0)
|
||||
return fail("rc", "run command failed");
|
||||
|
||||
int len = 0;
|
||||
double last = 0.0;
|
||||
if (vec_last("out", &len, &last) != 0)
|
||||
return fail("rc", "vector 'out' missing");
|
||||
|
||||
/* 5 tau: v = 1 - exp(-5) = 0.99326 */
|
||||
if (last < 0.98 || last > 1.0) {
|
||||
char buf[128];
|
||||
snprintf(buf, sizeof buf, "v(out) final %.5f not in [0.98, 1.0] (len %d)",
|
||||
last, len);
|
||||
return fail("rc", buf);
|
||||
}
|
||||
pass("rc");
|
||||
}
|
||||
|
||||
static void test_xspice(void)
|
||||
{
|
||||
static const char *const deck[] = {
|
||||
"xspice smoke",
|
||||
"V1 in 0 2",
|
||||
"A1 in aout gainblk",
|
||||
".model gainblk gain(gain=3)",
|
||||
"R1 aout 0 1k",
|
||||
".op",
|
||||
".end",
|
||||
NULL,
|
||||
};
|
||||
|
||||
if (run_circ(deck) != 0)
|
||||
return fail("xspice", "ngSpice_Circ failed (code models not registered?)");
|
||||
if (ngSpice_Command("run") != 0)
|
||||
return fail("xspice", "run command failed");
|
||||
|
||||
double v = 0.0;
|
||||
if (vec_last("aout", NULL, &v) != 0)
|
||||
return fail("xspice", "vector 'aout' missing");
|
||||
if (v < 5.999 || v > 6.001) {
|
||||
char buf[96];
|
||||
snprintf(buf, sizeof buf, "v(aout) %.6f != 6.0", v);
|
||||
return fail("xspice", buf);
|
||||
}
|
||||
pass("xspice");
|
||||
}
|
||||
|
||||
static void test_cider(void)
|
||||
{
|
||||
/* Reduced examples/cider/resistor/sires.cir: numd level=1 needs the whole
|
||||
* CIDER machinery (mesh, doping, mobility models) to produce a current. */
|
||||
static const char *const deck[] = {
|
||||
"cider smoke - silicon resistor",
|
||||
"VPP 1 0 2v",
|
||||
"VNN 2 0 0.0v",
|
||||
"D1 1 2 M_RES AREA=1",
|
||||
".MODEL M_RES numd level=1",
|
||||
"+ options resistor defa=1p",
|
||||
"+ x.mesh loc=0.0 num=1",
|
||||
"+ x.mesh loc=1.0 num=21",
|
||||
"+ domain num=1 material=1",
|
||||
"+ material num=1 silicon",
|
||||
"+ doping unif n.type conc=2.5e16",
|
||||
"+ models bgn srh conctau auger concmob fieldmob",
|
||||
".DC VPP 0.0v 2.01v 0.5v",
|
||||
".END",
|
||||
NULL,
|
||||
};
|
||||
|
||||
if (run_circ(deck) != 0)
|
||||
return fail("cider", "ngSpice_Circ failed (CIDER not compiled in?)");
|
||||
if (ngSpice_Command("run") != 0)
|
||||
return fail("cider", "run command failed");
|
||||
|
||||
int len = 0;
|
||||
double i_last = 0.0;
|
||||
if (vec_last("vpp#branch", &len, &i_last) != 0)
|
||||
return fail("cider", "vector 'vpp#branch' missing");
|
||||
if (len < 4)
|
||||
return fail("cider", "DC sweep produced too few points");
|
||||
if (!(i_last < 0.0) || i_last < -1.0)
|
||||
return fail("cider", "resistor current magnitude implausible");
|
||||
pass("cider");
|
||||
}
|
||||
|
||||
static void test_halt(void)
|
||||
{
|
||||
/* Heavy enough that bg_halt lands mid-run: a long transient of a 100-stage
|
||||
* nonlinear RC/diode ladder, storage bounded via .save. */
|
||||
const int stages = 100;
|
||||
const char **deck = malloc((size_t) (stages * 3 + 8) * sizeof(char *));
|
||||
char **owned = malloc((size_t) (stages * 3 + 8) * sizeof(char *));
|
||||
int n = 0;
|
||||
|
||||
owned[n] = strdup("halt smoke - rc/diode ladder");
|
||||
deck[n] = owned[n];
|
||||
n++;
|
||||
owned[n] = strdup("V1 n0 0 SIN(0 5 10k)");
|
||||
deck[n] = owned[n];
|
||||
n++;
|
||||
for (int i = 0; i < stages; i++) {
|
||||
char line[96];
|
||||
snprintf(line, sizeof line, "R%d n%d n%d 100", i + 1, i, i + 1);
|
||||
owned[n] = strdup(line);
|
||||
deck[n] = owned[n];
|
||||
n++;
|
||||
snprintf(line, sizeof line, "C%d n%d 0 10n", i + 1, i + 1);
|
||||
owned[n] = strdup(line);
|
||||
deck[n] = owned[n];
|
||||
n++;
|
||||
snprintf(line, sizeof line, "D%d n%d 0 dmod", i + 1, i + 1);
|
||||
owned[n] = strdup(line);
|
||||
deck[n] = owned[n];
|
||||
n++;
|
||||
}
|
||||
owned[n] = strdup(".model dmod d(is=1e-14)");
|
||||
deck[n] = owned[n];
|
||||
n++;
|
||||
owned[n] = strdup(".save v(n100)");
|
||||
deck[n] = owned[n];
|
||||
n++;
|
||||
owned[n] = strdup(".tran 100n 10");
|
||||
deck[n] = owned[n];
|
||||
n++;
|
||||
owned[n] = strdup(".end");
|
||||
deck[n] = owned[n];
|
||||
n++;
|
||||
deck[n] = NULL;
|
||||
owned[n] = NULL;
|
||||
|
||||
int circ_ret = ngSpice_Circ((char **) deck);
|
||||
for (int i = 0; i < n; i++)
|
||||
free(owned[i]);
|
||||
free(owned);
|
||||
free(deck);
|
||||
|
||||
if (circ_ret != 0)
|
||||
return fail("halt", "ngSpice_Circ failed");
|
||||
|
||||
int before = atomic_load(&g_bg_finished_events);
|
||||
|
||||
if (ngSpice_Command("bg_run") != 0)
|
||||
return fail("halt", "bg_run command failed");
|
||||
|
||||
/* Give the background thread time to actually start and chew. */
|
||||
usleep(400 * 1000);
|
||||
|
||||
if (!ngSpice_running())
|
||||
return fail("halt", "ngSpice_running false 400ms into a 10s transient "
|
||||
"(bg thread never started or deck too light)");
|
||||
|
||||
if (ngSpice_Command("bg_halt") != 0)
|
||||
return fail("halt", "bg_halt command failed");
|
||||
|
||||
/* bg_halt joins the bg thread; give the finished callback a moment. */
|
||||
for (int i = 0; i < 50 && atomic_load(&g_bg_finished_events) == before; i++)
|
||||
usleep(100 * 1000);
|
||||
|
||||
if (atomic_load(&g_bg_finished_events) == before)
|
||||
return fail("halt", "BGThreadRunning(finished) never fired after bg_halt");
|
||||
if (ngSpice_running())
|
||||
return fail("halt", "still running after bg_halt");
|
||||
|
||||
pass("halt");
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
/* Optional argv[1]: run only the named scenario (rc|xspice|cider|halt). */
|
||||
const char *only = argc > 1 ? argv[1] : NULL;
|
||||
|
||||
int ret = ngSpice_Init(cb_send_char, cb_send_stat, cb_controlled_exit,
|
||||
NULL, NULL, cb_bg_running, NULL);
|
||||
if (ret != 0) {
|
||||
printf("SMOKE FAIL init: ngSpice_Init returned %d\n", ret);
|
||||
return 1;
|
||||
}
|
||||
printf("SMOKE PASS init\n");
|
||||
|
||||
if (!only || !strcmp(only, "rc"))
|
||||
test_rc();
|
||||
if (!only || !strcmp(only, "xspice"))
|
||||
test_xspice();
|
||||
if (!only || !strcmp(only, "cider"))
|
||||
test_cider();
|
||||
if (!only || !strcmp(only, "halt"))
|
||||
test_halt();
|
||||
|
||||
if (atomic_load(&g_exit_called))
|
||||
fail("exit", "ControlledExit fired during the smoke run");
|
||||
|
||||
printf(g_failures ? "SMOKE RESULT: %d failure(s)\n" : "SMOKE RESULT: all passed\n",
|
||||
g_failures);
|
||||
return g_failures ? 1 : 0;
|
||||
}
|
||||
|
|
@ -235,7 +235,9 @@ else
|
|||
fi
|
||||
|
||||
# Step 2: Build dependencies
|
||||
# Note: --with-occ for OpenCASCADE, but NOT ngspice since KICAD_SPICE=OFF
|
||||
# Note: --with-occ for OpenCASCADE. The ngspice dep is NOT needed here — the
|
||||
# editor links only the sharedspice client stub; the engine is the separate
|
||||
# ngspice_service app (scripts/kicad/build-ngspice_service.sh builds its dep).
|
||||
if [ $SKIP_DEPS -eq 0 ]; then
|
||||
kw_stage deps
|
||||
log_info "Building dependencies..."
|
||||
|
|
@ -547,7 +549,6 @@ emcmake cmake "${KICAD_DIR}" \
|
|||
-DwxWidgets_CONFIG_EXECUTABLE="${WX_BUILD}/wx-config" \
|
||||
\
|
||||
-DKICAD_BUILD_QA_TESTS=OFF \
|
||||
-DKICAD_SPICE=OFF \
|
||||
-DKICAD_USE_EGL=OFF \
|
||||
-DKICAD_USE_BUNDLED_GLEW=ON \
|
||||
-DKICAD_BUILD_3D_VIEWER_WASM=${BUILD_3D_VIEWER} \
|
||||
|
|
|
|||
60
scripts/kicad/build-ngspice_service.sh
Executable file
60
scripts/kicad/build-ngspice_service.sh
Executable file
|
|
@ -0,0 +1,60 @@
|
|||
#!/bin/bash
|
||||
# Build ngspice_service.{js,wasm} — the eeschema simulator's ngspice worker
|
||||
# module (wasm/ngspice-service/). Unlike occ_service this does NOT go through
|
||||
# the kicad CMake tree: the module contains no KiCad/wx code, so it configures
|
||||
# standalone against the sysroot ngspice artifacts (much faster to iterate,
|
||||
# and buildable without the full editor dependency set).
|
||||
#
|
||||
# Artifacts: ${BUILD_ROOT}/kicad-ngspice_service/ngspice_service/ngspice_service.{js,wasm}
|
||||
# — the kicad-<app>/<subdir>/ layout every other app uses, so docker/build.sh's
|
||||
# output copy and tests/scripts/setup-kicad-wasm.sh's docker-volume fallback
|
||||
# find them without special-casing.
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "${SCRIPT_DIR}/../common/env.sh"
|
||||
|
||||
# Parse arguments
|
||||
CLEAN=0
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--clean)
|
||||
CLEAN=1
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
NGSPICE_SERVICE_BUILD="${BUILD_ROOT}/kicad-ngspice_service"
|
||||
|
||||
if [ $CLEAN -eq 1 ]; then
|
||||
log_info "Cleaning ngspice_service build..."
|
||||
rm -rf "${NGSPICE_SERVICE_BUILD}"
|
||||
fi
|
||||
|
||||
# The ngspice dep (sharedspice static lib + code-model archives + spinit) is
|
||||
# stamped, so this is a no-op when already built.
|
||||
"${SCRIPT_DIR}/../deps/build-ngspice.sh"
|
||||
|
||||
log_info "Building ngspice_service..."
|
||||
|
||||
mkdir -p "${NGSPICE_SERVICE_BUILD}"
|
||||
cd "${NGSPICE_SERVICE_BUILD}"
|
||||
|
||||
emcmake cmake "${WASM_COMPAT}/ngspice-service" \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_RUNTIME_OUTPUT_DIRECTORY="${NGSPICE_SERVICE_BUILD}/ngspice_service" \
|
||||
-DNGSPICE_SYSROOT="${SYSROOT}" \
|
||||
-DNGSPICE_EH_FLAGS="${DEPS_EH_FLAGS}"
|
||||
|
||||
emmake make -j${JOBS}
|
||||
|
||||
for f in ngspice_service.js ngspice_service.wasm; do
|
||||
if [ ! -f "${NGSPICE_SERVICE_BUILD}/ngspice_service/$f" ]; then
|
||||
log_error "ngspice_service build incomplete: missing $f"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
log_info "ngspice_service build complete: ${NGSPICE_SERVICE_BUILD}/ngspice_service/ngspice_service.{js,wasm}"
|
||||
204
tests/kicad/eeschema-sim.spec.ts
Normal file
204
tests/kicad/eeschema-sim.spec.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import { test, expect } from './fixtures';
|
||||
import * as path from 'path';
|
||||
import { PNG } from 'pngjs';
|
||||
import {
|
||||
clickByTooltip,
|
||||
clickMenuBarItem,
|
||||
clickMenuItemByText,
|
||||
findByTooltip,
|
||||
stableShot,
|
||||
waitForEditorReady,
|
||||
} from '../e2e/utils/element-tracker';
|
||||
import { injectFileIntoMemfs } from './utils/fs-inject';
|
||||
|
||||
/**
|
||||
* eeschema simulator end-to-end (docs/features/ngspice-split/): the historic
|
||||
* kill-point was SIMULATOR_FRAME never opening (no dlopen for libngspice);
|
||||
* now NGSPICE binds the sharedspice client stub and the engine runs in the
|
||||
* lazy ngspice_service worker. These specs drive the REAL UI path:
|
||||
* project open → Inspect → Simulator → Run → plot, asserting the RPC/event
|
||||
* plumbing (window.__ngspiceEvents / __ngspiceLog from the harness provider,
|
||||
* tests/kicad/utils/ngspice-service.ts) and the rendered result.
|
||||
*
|
||||
* Fixture: the complete kicad demo rectifier project — its 1N4148 lives in a
|
||||
* sibling diode.mod pulled in via `.include`, so a passing transient also
|
||||
* proves the client stub's netlist file shipping (a missing model fails the
|
||||
* run with "unable to find definition of model").
|
||||
*/
|
||||
|
||||
const RECTIFIER_DIR = path.resolve(__dirname, '..', '..',
|
||||
'kicad', 'demos', 'simulation', 'rectifier');
|
||||
const MEMFS_DIR = '/home/kicad/documents/rectifier';
|
||||
const PROJECT_FILES = ['rectifier.kicad_sch', 'rectifier.kicad_pro', 'diode.mod',
|
||||
'rectifier_schlib.kicad_sym', 'sym-lib-table', 'rectifier.wbk'];
|
||||
|
||||
async function loadRectifier(page: import('@playwright/test').Page): Promise<void> {
|
||||
for (const f of PROJECT_FILES)
|
||||
await injectFileIntoMemfs(page, path.join(RECTIFIER_DIR, f), `${MEMFS_DIR}/${f}`);
|
||||
|
||||
await page.evaluate((sch: string) => {
|
||||
(window as any).Module.kicadOpenFile(sch);
|
||||
}, `${MEMFS_DIR}/rectifier.kicad_sch`);
|
||||
|
||||
await expect
|
||||
.poll(async () => page.title(), { timeout: 120000 })
|
||||
.toMatch(/rectifier/i);
|
||||
}
|
||||
|
||||
// Open Inspect → Simulator and return the new top-level window's DOM id.
|
||||
async function openSimulator(page: import('@playwright/test').Page): Promise<string> {
|
||||
const idsBefore = await page.$$eval('#window-container [id^="window-"]',
|
||||
(els) => els.map((e) => e.id));
|
||||
|
||||
expect(await clickMenuBarItem(page, 'Inspect'), 'Inspect menu').toBe(true);
|
||||
await clickMenuItemByText(page, 'Simulator');
|
||||
|
||||
await page.waitForFunction((before: string[]) => {
|
||||
const ids = Array.from(
|
||||
document.querySelectorAll('#window-container [id^="window-"]'),
|
||||
(e) => e.id);
|
||||
return ids.some((id) => !before.includes(id));
|
||||
}, idsBefore, { timeout: 60000 });
|
||||
|
||||
const idsAfter = await page.$$eval('#window-container [id^="window-"]',
|
||||
(els) => els.map((e) => e.id));
|
||||
const simWin = idsAfter.find((id) => !idsBefore.includes(id));
|
||||
expect(simWin, 'simulator window appeared').toBeTruthy();
|
||||
return simWin!;
|
||||
}
|
||||
|
||||
// Run the loaded workbook's analysis and wait for the background run to
|
||||
// finish (the bg 'finished' event lands after ngspice's thread joins).
|
||||
async function runSimulation(page: import('@playwright/test').Page): Promise<void> {
|
||||
const evtsBefore = await page.evaluate(
|
||||
() => (window as any).__ngspiceEvents.length as number);
|
||||
|
||||
// The simulator window div appears while the frame ctor is still
|
||||
// suspended in the init RPC; the toolbar registers its tools only after
|
||||
// init completes and the frame first paints.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const el = await findByTooltip(page, 'Run Simulation', { elementType: 'tool' });
|
||||
return !!el && el.enabled;
|
||||
}, { timeout: 60000 })
|
||||
.toBe(true);
|
||||
|
||||
expect(await clickByTooltip(page, 'Run Simulation', { elementType: 'tool' }),
|
||||
'Run tool').toBe(true);
|
||||
|
||||
await page.waitForFunction((n: number) => {
|
||||
const evts = (window as any).__ngspiceEvents as Array<{ kind: string; finished?: boolean }>;
|
||||
return evts.slice(n).some((e) => e.kind === 'bg' && e.finished === true);
|
||||
}, evtsBefore, { timeout: 120000 });
|
||||
}
|
||||
|
||||
function distinctColors(png: PNG): number {
|
||||
const colors = new Set<number>();
|
||||
// 8x8 grid sampling, same spirit as the 3d-viewer render check.
|
||||
const stepX = Math.max(1, Math.floor(png.width / 8));
|
||||
const stepY = Math.max(1, Math.floor(png.height / 8));
|
||||
|
||||
for (let y = 0; y < png.height; y += stepY) {
|
||||
for (let x = 0; x < png.width; x += stepX) {
|
||||
const i = (png.width * y + x) << 2;
|
||||
colors.add((png.data[i] << 16) | (png.data[i + 1] << 8) | png.data[i + 2]);
|
||||
}
|
||||
}
|
||||
return colors.size;
|
||||
}
|
||||
|
||||
test.describe('eeschema simulator', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
test.setTimeout(300000);
|
||||
|
||||
test('Inspect → Simulator opens the frame; service fetches lazily', async ({ page, testLogger }) => {
|
||||
const ngspiceFetches: string[] = [];
|
||||
page.on('request', (r) => {
|
||||
if (r.url().includes('ngspice_service')) ngspiceFetches.push(r.url());
|
||||
});
|
||||
|
||||
await page.goto('/kicad/eeschema.html');
|
||||
await waitForEditorReady(page);
|
||||
await loadRectifier(page);
|
||||
|
||||
expect(ngspiceFetches,
|
||||
'ngspice_service must NOT be fetched before the simulator opens')
|
||||
.toHaveLength(0);
|
||||
|
||||
await openSimulator(page);
|
||||
await stableShot(page, 'eeschema-sim-frame.png');
|
||||
|
||||
// NGSPICE::init_dll ran inside the frame ctor → the client stub's init
|
||||
// RPC booted the worker.
|
||||
expect(ngspiceFetches.length,
|
||||
'ngspice_service fetched lazily by the simulator open')
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
const all = [...testLogger.consoleLogs, ...testLogger.errors];
|
||||
expect(all.filter((l) => l.includes('Aborted(')), 'no aborts').toHaveLength(0);
|
||||
});
|
||||
|
||||
test('transient run: live console stream, vectors reach the plot, plot renders', async ({ page, testLogger }) => {
|
||||
await page.goto('/kicad/eeschema.html');
|
||||
await waitForEditorReady(page);
|
||||
await loadRectifier(page);
|
||||
const simWin = await openSimulator(page);
|
||||
|
||||
await runSimulation(page);
|
||||
|
||||
const evts = await page.evaluate(() => (window as any).__ngspiceEvents as Array<{
|
||||
kind: string; lines?: string[]; finished?: boolean; t: number }>);
|
||||
|
||||
// Live streaming: console/status output must precede the finish event.
|
||||
const finishT = evts.filter((e) => e.kind === 'bg' && e.finished).map((e) => e.t)[0];
|
||||
const streamed = evts.filter(
|
||||
(e) => (e.kind === 'char' || e.kind === 'stat') && e.t <= finishT);
|
||||
expect(streamed.length, 'ngspice output streamed during the run')
|
||||
.toBeGreaterThan(3);
|
||||
|
||||
// The model shipped via .include resolved (a miss fails the run with
|
||||
// "unable to find definition" and produces no transient).
|
||||
const charText = evts.flatMap((e) => e.lines ?? []).join('\n');
|
||||
expect(charText, 'no missing-model errors').not.toMatch(/unable to find definition/i);
|
||||
|
||||
// The plot pulled real vector data through get_vec_info.
|
||||
const vecPulls = await page.evaluate(() =>
|
||||
((window as any).__ngspiceLog as Array<{ kind: string; length?: number }>)
|
||||
.filter((l) => l.kind === 'get_vec_info' && (l.length ?? 0) > 100).length);
|
||||
expect(vecPulls, 'plot fetched transient vectors').toBeGreaterThan(0);
|
||||
|
||||
// The plot area rendered something beyond a flat background.
|
||||
const shot = await page.locator(`#${simWin}`).screenshot({
|
||||
scale: 'css', animations: 'disabled' });
|
||||
const png = PNG.sync.read(shot);
|
||||
expect(distinctColors(png), 'plot window shows structure (axes/trace)')
|
||||
.toBeGreaterThan(6);
|
||||
|
||||
await stableShot(page, 'eeschema-sim-plot.png');
|
||||
|
||||
const all = [...testLogger.consoleLogs, ...testLogger.errors];
|
||||
expect(all.filter((l) => l.includes('Aborted(')), 'no aborts').toHaveLength(0);
|
||||
const corruption = all.filter((l) =>
|
||||
l.includes('index out of bounds') || l.includes('indirect call to null')
|
||||
|| l.includes('uncaught exception: unwind'));
|
||||
expect(corruption, 'no asyncify corruption').toHaveLength(0);
|
||||
});
|
||||
|
||||
test('a second run after the first succeeds (engine reset path)', async ({ page, testLogger }) => {
|
||||
await page.goto('/kicad/eeschema.html');
|
||||
await waitForEditorReady(page);
|
||||
await loadRectifier(page);
|
||||
await openSimulator(page);
|
||||
|
||||
await runSimulation(page);
|
||||
await runSimulation(page);
|
||||
|
||||
const finishCount = await page.evaluate(() =>
|
||||
((window as any).__ngspiceEvents as Array<{ kind: string; finished?: boolean }>)
|
||||
.filter((e) => e.kind === 'bg' && e.finished === true).length);
|
||||
expect(finishCount, 'two completed runs').toBeGreaterThanOrEqual(2);
|
||||
|
||||
const all = [...testLogger.consoleLogs, ...testLogger.errors];
|
||||
expect(all.filter((l) => l.includes('Aborted(')), 'no aborts').toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { test as base } from '@playwright/test';
|
||||
import * as path from 'path';
|
||||
import { setupTestLogger, writeTestLogs, TestLogger, MAIN_CANVAS, waitForApp, tryLoadApp, getCanvasBox, KICAD_LOGS_DIR, getTestFileName } from '../e2e/utils/test-utils';
|
||||
import { installNgspiceServiceStub } from './utils/ngspice-service';
|
||||
import { installOccServiceStub } from './utils/occ-service';
|
||||
|
||||
// Extend base test with automatic logging
|
||||
|
|
@ -14,6 +15,10 @@ export const test = base.extend<{
|
|||
// the lazy-load boundary.
|
||||
page: async ({ page }, use) => {
|
||||
await installOccServiceStub(page);
|
||||
// The ngspice_service provider follows the same ambient pattern (the
|
||||
// standalone installs it for every kicad_editor boot); the worker is only
|
||||
// fetched on the first simulator request.
|
||||
await installNgspiceServiceStub(page);
|
||||
await use(page);
|
||||
},
|
||||
|
||||
|
|
|
|||
176
tests/kicad/ngspice-probe.spec.ts
Normal file
176
tests/kicad/ngspice-probe.spec.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { test, expect } from './fixtures';
|
||||
|
||||
/**
|
||||
* Minimal ngspice_service probe (no eeschema boot): drive the sharedspice RPC
|
||||
* surface directly from the page. Isolates worker/module behavior from the
|
||||
* editor entirely — when the simulator breaks, this answers "service module
|
||||
* or editor-side bridge?" in seconds. The provider arrives via the fixtures'
|
||||
* ambient init script (tests/kicad/utils/ngspice-service.ts), which also
|
||||
* captures every event frame into window.__ngspiceEvents.
|
||||
*
|
||||
* Covers Gate 2 of docs/features/ngspice-split/: browser-side parity of the
|
||||
* Gate-1 node smoke — foreground transient numerics, XSPICE via the static
|
||||
* code-model registry, CIDER, live event streaming during bg_run, mid-run
|
||||
* bg_halt, and the lazy-load boundary.
|
||||
*/
|
||||
|
||||
type SvcRes = {
|
||||
ret?: number; error?: string; found?: boolean; length?: number;
|
||||
real?: number[] | Float64Array | null; name?: string; names?: string[];
|
||||
running?: boolean;
|
||||
};
|
||||
|
||||
async function svcRequest(page: import('@playwright/test').Page, req: unknown): Promise<SvcRes> {
|
||||
return await page.evaluate(async (r: any) => {
|
||||
const res = await (globalThis as any).ngspiceService.request(r);
|
||||
// Float64Array doesn't survive evaluate serialization on all engines —
|
||||
// flatten to a plain array (probe vectors are small).
|
||||
if (res && res.real) res.real = Array.from(res.real);
|
||||
if (res && res.comp) res.comp = Array.from(res.comp);
|
||||
return res;
|
||||
}, req as any);
|
||||
}
|
||||
|
||||
test.describe('ngspice_service probe', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
test.setTimeout(240000);
|
||||
|
||||
test('lazy boundary + RC transient + vector readback', async ({ page }) => {
|
||||
const ngspiceFetches: string[] = [];
|
||||
page.on('request', (r) => {
|
||||
if (r.url().includes('ngspice_service')) ngspiceFetches.push(r.url());
|
||||
});
|
||||
|
||||
// Any harness page gives the COI (COOP/COEP) context; don't wait for wasm.
|
||||
await page.goto('/kicad/eeschema.html', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
expect(ngspiceFetches, 'ngspice_service must NOT be fetched before first use')
|
||||
.toHaveLength(0);
|
||||
|
||||
const init = await svcRequest(page, { kind: 'init' });
|
||||
expect(init.error, 'init error').toBeUndefined();
|
||||
expect(init.ret, 'ngSpice_Init').toBe(0);
|
||||
|
||||
expect(ngspiceFetches.length, 'ngspice_service was fetched lazily by init')
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
const circ = await svcRequest(page, {
|
||||
kind: 'circ',
|
||||
lines: ['rc probe', 'V1 in 0 1', 'R1 in out 1k', 'C1 out 0 1u',
|
||||
'.tran 10u 5m', '.end'],
|
||||
});
|
||||
expect(circ.ret, 'ngSpice_Circ').toBe(0);
|
||||
|
||||
const run = await svcRequest(page, { kind: 'command', cmd: 'run' });
|
||||
expect(run.ret, 'run command').toBe(0);
|
||||
|
||||
const plot = await svcRequest(page, { kind: 'cur_plot' });
|
||||
expect(plot.name, 'a tran plot exists').toMatch(/^tran/);
|
||||
|
||||
const vecs = await svcRequest(page, { kind: 'all_vecs', plot: plot.name! });
|
||||
expect(vecs.names, 'tran vectors').toContain('out');
|
||||
|
||||
const vi = await svcRequest(page, { kind: 'get_vec_info', name: 'out' });
|
||||
expect(vi.found, 'v(out) found').toBe(true);
|
||||
expect(vi.length ?? 0, 'plausible point count').toBeGreaterThan(100);
|
||||
const last = (vi.real as number[])[(vi.length ?? 1) - 1];
|
||||
// DC operating point seeds the transient at the steady state: flat 1V.
|
||||
expect(last, 'v(out) end value').toBeGreaterThan(0.98);
|
||||
expect(last, 'v(out) end value').toBeLessThanOrEqual(1.0);
|
||||
});
|
||||
|
||||
test('XSPICE code model resolves through the static registry', async ({ page }) => {
|
||||
await page.goto('/kicad/eeschema.html', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
await svcRequest(page, { kind: 'init' });
|
||||
const circ = await svcRequest(page, {
|
||||
kind: 'circ',
|
||||
lines: ['xspice probe', 'V1 in 0 2', 'A1 in aout gainblk',
|
||||
'.model gainblk gain(gain=3)', 'R1 aout 0 1k', '.op', '.end'],
|
||||
});
|
||||
expect(circ.ret, 'circ with a-device').toBe(0);
|
||||
expect((await svcRequest(page, { kind: 'command', cmd: 'run' })).ret).toBe(0);
|
||||
|
||||
const vi = await svcRequest(page, { kind: 'get_vec_info', name: 'aout' });
|
||||
expect(vi.found, 'v(aout) found').toBe(true);
|
||||
expect((vi.real as number[])[0], 'gain block output 2*3').toBeCloseTo(6.0, 3);
|
||||
});
|
||||
|
||||
test('CIDER numd device simulates', async ({ page }) => {
|
||||
await page.goto('/kicad/eeschema.html', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
await svcRequest(page, { kind: 'init' });
|
||||
const circ = await svcRequest(page, {
|
||||
kind: 'circ',
|
||||
lines: ['cider probe - silicon resistor',
|
||||
'VPP 1 0 2v', 'VNN 2 0 0.0v', 'D1 1 2 M_RES AREA=1',
|
||||
'.MODEL M_RES numd level=1',
|
||||
'+ options resistor defa=1p',
|
||||
'+ x.mesh loc=0.0 num=1', '+ x.mesh loc=1.0 num=21',
|
||||
'+ domain num=1 material=1', '+ material num=1 silicon',
|
||||
'+ doping unif n.type conc=2.5e16',
|
||||
'+ models bgn srh conctau auger concmob fieldmob',
|
||||
'.DC VPP 0.0v 2.01v 0.5v', '.END'],
|
||||
});
|
||||
expect(circ.ret, 'circ with numd model').toBe(0);
|
||||
expect((await svcRequest(page, { kind: 'command', cmd: 'run' })).ret).toBe(0);
|
||||
|
||||
const vi = await svcRequest(page, { kind: 'get_vec_info', name: 'vpp#branch' });
|
||||
expect(vi.found, 'sweep current vector found').toBe(true);
|
||||
expect(vi.length ?? 0, 'DC sweep points').toBeGreaterThanOrEqual(4);
|
||||
const iLast = (vi.real as number[])[(vi.length ?? 1) - 1];
|
||||
expect(iLast, 'resistor draws current (negative through VPP)').toBeLessThan(0);
|
||||
expect(Math.abs(iLast), 'plausible magnitude').toBeLessThan(1.0);
|
||||
});
|
||||
|
||||
test('bg_run streams events live and bg_halt stops mid-run', async ({ page, testLogger }) => {
|
||||
await page.goto('/kicad/eeschema.html', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
await svcRequest(page, { kind: 'init' });
|
||||
|
||||
// Heavy enough that the halt lands mid-run: 150-stage nonlinear
|
||||
// RC/diode ladder, 20s transient, storage bounded via .save.
|
||||
const deck = ['halt probe', 'V1 n0 0 SIN(0 5 10k)'];
|
||||
for (let i = 0; i < 150; i++) {
|
||||
deck.push(`R${i + 1} n${i} n${i + 1} 100`);
|
||||
deck.push(`C${i + 1} n${i + 1} 0 10n`);
|
||||
deck.push(`D${i + 1} n${i + 1} 0 dmod`);
|
||||
}
|
||||
deck.push('.model dmod d(is=1e-14)', '.save v(n150)', '.tran 100n 20', '.end');
|
||||
|
||||
expect((await svcRequest(page, { kind: 'circ', lines: deck })).ret).toBe(0);
|
||||
|
||||
const evtsBefore = await page.evaluate(
|
||||
() => (window as any).__ngspiceEvents.length as number);
|
||||
|
||||
expect((await svcRequest(page, { kind: 'command', cmd: 'bg_run' })).ret,
|
||||
'bg_run accepted').toBe(0);
|
||||
|
||||
// Live streaming: char/stat frames must arrive WHILE the background
|
||||
// thread simulates (not only after completion).
|
||||
await page.waitForFunction((n: number) => {
|
||||
const evts = (window as any).__ngspiceEvents as Array<{ kind: string }>;
|
||||
return evts.slice(n).filter((e) => e.kind === 'char' || e.kind === 'stat').length >= 3;
|
||||
}, evtsBefore, { timeout: 60000 });
|
||||
|
||||
const midRunning = await svcRequest(page, { kind: 'running' });
|
||||
expect(midRunning.running,
|
||||
'still running while events streamed (deck heavy enough)').toBe(true);
|
||||
|
||||
expect((await svcRequest(page, { kind: 'command', cmd: 'bg_halt' })).ret,
|
||||
'bg_halt accepted').toBe(0);
|
||||
|
||||
// BGThreadRunning(finished) must arrive after the halt joins the thread.
|
||||
await page.waitForFunction((n: number) => {
|
||||
const evts = (window as any).__ngspiceEvents as Array<{ kind: string; finished?: boolean }>;
|
||||
return evts.slice(n).some((e) => e.kind === 'bg' && e.finished === true);
|
||||
}, evtsBefore, { timeout: 60000 });
|
||||
|
||||
const after = await svcRequest(page, { kind: 'running' });
|
||||
expect(after.running, 'stopped after bg_halt').toBe(false);
|
||||
|
||||
// Standard corruption gate.
|
||||
const all = [...testLogger.consoleLogs, ...testLogger.errors];
|
||||
expect(all.filter((l) => l.includes('Aborted(')), 'no aborts').toHaveLength(0);
|
||||
});
|
||||
});
|
||||
125
tests/kicad/utils/ngspice-service.ts
Normal file
125
tests/kicad/utils/ngspice-service.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Install a REAL `globalThis.ngspiceService` provider into a harness page —
|
||||
* the same worker-backed ngspice_service boot the standalone app does
|
||||
* (web/standalone/src/wasm/ngspice-service.ts), minus the CDN manifest
|
||||
* resolution: the harness serves ngspice_service.{js,wasm} same-origin next
|
||||
* to the tool page (tests/scripts/setup-kicad-wasm.sh copies them from
|
||||
* output/).
|
||||
*
|
||||
* The worker-side wrapper is the SHARED source of truth
|
||||
* (web/standalone/src/wasm/ngspice-worker.js — the standalone imports it via
|
||||
* vite `?raw`; the harness reads it off disk and injects it verbatim), so the
|
||||
* boot logic cannot drift between app and tests.
|
||||
*
|
||||
* Additions for assertability:
|
||||
* - every `{ evt }` frame is appended to window.__ngspiceEvents
|
||||
* ({ kind, lines?, finished?, status?, t: ms-since-install }) BEFORE being
|
||||
* forwarded to globalThis.__ngspiceOnEvent (the editor client stub's
|
||||
* dispatcher, when integrated) — specs assert live streaming by comparing
|
||||
* event timestamps against run boundaries;
|
||||
* - request/response summaries are appended to window.__ngspiceLog.
|
||||
*
|
||||
* The worker fetches ngspice_service.js lazily on the FIRST request — specs
|
||||
* assert the lazy-load boundary by watching network requests.
|
||||
*/
|
||||
|
||||
const NGSPICE_WORKER_SRC = fs.readFileSync(
|
||||
path.resolve(__dirname, '..', '..', '..',
|
||||
'web', 'standalone', 'src', 'wasm', 'ngspice-worker.js'),
|
||||
'utf8');
|
||||
|
||||
export async function installNgspiceServiceStub(page: Page): Promise<void> {
|
||||
await page.addInitScript((workerSrc: string) => {
|
||||
if ((globalThis as any).ngspiceService) return;
|
||||
|
||||
const t0 = Date.now();
|
||||
(window as any).__ngspiceEvents = [];
|
||||
(window as any).__ngspiceLog = [];
|
||||
|
||||
let workerP: Promise<Worker> | null = null;
|
||||
const pending = new Map<number, (res: any) => void>();
|
||||
let nextId = 1;
|
||||
|
||||
const evtQueue: any[] = [];
|
||||
const dispatchEvt = (evt: any) => {
|
||||
(window as any).__ngspiceEvents.push({ ...evt, t: Date.now() - t0 });
|
||||
const handler = (globalThis as any).__ngspiceOnEvent;
|
||||
if (handler) {
|
||||
while (evtQueue.length) handler(evtQueue.shift());
|
||||
handler(evt);
|
||||
} else {
|
||||
evtQueue.push(evt);
|
||||
}
|
||||
};
|
||||
|
||||
const failAllPending = (why: string) => {
|
||||
for (const [, resolve] of pending) resolve({ error: why });
|
||||
pending.clear();
|
||||
};
|
||||
|
||||
const ensureWorker = (): Promise<Worker> => {
|
||||
if (!workerP) {
|
||||
workerP = (async () => {
|
||||
const glue = new URL('ngspice_service.js', window.location.href).href;
|
||||
console.log(`[TEST-NGSPICE] booting ngspice_service from ${glue}`);
|
||||
const worker = new Worker(URL.createObjectURL(new Blob(
|
||||
[`self.NGSPICE_GLUE_URL = ${JSON.stringify(glue)};\n`, workerSrc],
|
||||
{ type: 'text/javascript' })));
|
||||
worker.onmessage = (e) => {
|
||||
const data = e.data ?? {};
|
||||
if (data.evt) { dispatchEvt(data.evt); return; }
|
||||
if (typeof data.id !== 'number') return;
|
||||
const resolve = pending.get(data.id);
|
||||
if (resolve) { pending.delete(data.id); resolve(data.res); }
|
||||
};
|
||||
worker.onerror = (e) => {
|
||||
console.log(`[TEST-NGSPICE] worker error: ${e.message} — resetting service`);
|
||||
failAllPending(`ngspice_service crashed: ${e.message}`);
|
||||
workerP = null;
|
||||
try { worker.terminate(); } catch { /* already gone */ }
|
||||
};
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onFirst = (e: MessageEvent) => {
|
||||
if (e.data?.ready) { worker.removeEventListener('message', onFirst); resolve(); }
|
||||
else if (e.data?.bootError) reject(new Error(e.data.bootError));
|
||||
};
|
||||
worker.addEventListener('message', onFirst);
|
||||
});
|
||||
console.log('[TEST-NGSPICE] ngspice_service ready');
|
||||
return worker;
|
||||
})().catch((e) => { workerP = null; throw e; });
|
||||
}
|
||||
return workerP;
|
||||
};
|
||||
|
||||
const request = async (req: any) => {
|
||||
let worker: Worker;
|
||||
try {
|
||||
worker = await ensureWorker();
|
||||
} catch (e) {
|
||||
return { error: `ngspice_service unavailable: ${e}` };
|
||||
}
|
||||
const id = nextId++;
|
||||
const res: any = await new Promise((resolve) => {
|
||||
pending.set(id, resolve);
|
||||
worker.postMessage({ id, req });
|
||||
});
|
||||
(window as any).__ngspiceLog.push({
|
||||
kind: req.kind,
|
||||
cmd: req.cmd,
|
||||
name: req.name,
|
||||
ret: res?.ret,
|
||||
error: res?.error,
|
||||
length: res?.length,
|
||||
t: Date.now() - t0,
|
||||
});
|
||||
return res;
|
||||
};
|
||||
|
||||
(globalThis as any).ngspiceService = { request };
|
||||
}, NGSPICE_WORKER_SRC);
|
||||
}
|
||||
|
|
@ -89,6 +89,8 @@ copy_app pl_editor && found_any=1
|
|||
copy_app gerbview && found_any=1
|
||||
# OCC 3D service (lazy worker module; pcbnew's STEP export + model parsing)
|
||||
copy_app occ_service || true
|
||||
# ngspice simulation service (lazy worker module; eeschema's simulator)
|
||||
copy_app ngspice_service || true
|
||||
|
||||
if [ "$found_any" -eq 0 ]; then
|
||||
echo "Error: no kicad_editor/calculator/pl_editor/gerbview artifacts found in output/ or docker volume" >&2
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
# Findngspice.cmake - Stub for WASM builds
|
||||
# ngspice is not available in WASM builds (KICAD_SPICE=OFF)
|
||||
# We provide stub values so CMake configuration succeeds
|
||||
# The editor never links libngspice: the simulator engine runs in the
|
||||
# ngspice_service worker (docs/features/ngspice-split/), and eeschema binds to
|
||||
# the statically linked sharedspice CLIENT (wasm/stubs/sharedspice_client.cpp)
|
||||
# in NGSPICE::init_dll()'s __EMSCRIPTEN__ branch.
|
||||
|
||||
if(EMSCRIPTEN OR NOT KICAD_SPICE)
|
||||
message(STATUS "ngspice not available for WASM build (using header stub)")
|
||||
message(STATUS "ngspice: WASM build uses the sharedspice client header stub")
|
||||
|
||||
# Set variables to indicate ngspice is "found" but disabled
|
||||
# Set variables to indicate ngspice is "found"
|
||||
set(ngspice_FOUND TRUE)
|
||||
set(NGSPICE_FOUND TRUE)
|
||||
|
||||
# Point at our header-only stub at wasm/stubs/ngspice/sharedspice.h so
|
||||
# eeschema's sim/ngspice.{h,cpp} can compile. The library link line stays
|
||||
# empty — the simulator frame is never instantiated in WASM.
|
||||
# Point at wasm/stubs/ngspice/sharedspice.h: the sharedspice types plus
|
||||
# the pcbjam_ngSpice_* client declarations. The library link line stays
|
||||
# empty — the engine lives in ngspice_service.wasm.
|
||||
set(NGSPICE_INCLUDE_DIR "${CMAKE_CURRENT_LIST_DIR}/../stubs")
|
||||
set(NGSPICE_LIBRARY "")
|
||||
set(NGSPICE_LIBRARIES "")
|
||||
|
|
|
|||
79
wasm/ngspice-service/CMakeLists.txt
Normal file
79
wasm/ngspice-service/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# ngspice_service — ngspice's sharedspice engine as a standalone emscripten
|
||||
# module run in a dedicated Web Worker (the SPICE analog of wasm/occ-service).
|
||||
# The eeschema simulator drives it over postMessage RPC via the sharedspice
|
||||
# client stub (wasm/stubs/sharedspice_client.cpp); the editor bundle links no
|
||||
# ngspice code at all.
|
||||
#
|
||||
# Unlike occ_service this module contains NO KiCad or wxWidgets code, so it is
|
||||
# NOT hooked into the kicad CMake tree: it configures standalone against the
|
||||
# sysroot artifacts produced by scripts/deps/build-ngspice.sh (see
|
||||
# scripts/kicad/build-ngspice_service.sh).
|
||||
#
|
||||
# Threading: ngspice's bg_run spawns its own pthread; the module main thread
|
||||
# stays free to service RPC (bg_halt, status, vector reads) mid-simulation.
|
||||
# The 4MB/2MB stacks are load-bearing: ngspice's parser overflows emscripten's
|
||||
# default 64KB stack (found by the Gate-1 smoke; the CIDER deck died parsing
|
||||
# spinit). ASYNCIFY stays off: nothing in the service suspends.
|
||||
# PTHREAD_POOL_DELAY_LOAD: boot must not block on the pool — Firefox stalls
|
||||
# nested-worker spawning under multi-page pressure (the SECOND simulator
|
||||
# session in an e2e run hung forever in module boot without it); bg_run's
|
||||
# thread starts as soon as a pool worker lands.
|
||||
|
||||
cmake_minimum_required( VERSION 3.20 )
|
||||
project( ngspice_service CXX )
|
||||
|
||||
if( NOT EMSCRIPTEN )
|
||||
message( FATAL_ERROR "ngspice_service is an emscripten-only target (use emcmake)" )
|
||||
endif()
|
||||
|
||||
if( NOT NGSPICE_SYSROOT )
|
||||
message( FATAL_ERROR "pass -DNGSPICE_SYSROOT=<build-wasm/sysroot> (run scripts/deps/build-ngspice.sh first)" )
|
||||
endif()
|
||||
|
||||
# The exception model must match the objects in libngspice.a (DEPS_EH_FLAGS
|
||||
# from scripts/common/env.sh: wasm EH + wasm setjmp/longjmp — sharedspice's
|
||||
# error recovery longjmps through errbufm/errbufc). Passed by
|
||||
# scripts/kicad/build-ngspice_service.sh.
|
||||
if( NOT NGSPICE_EH_FLAGS )
|
||||
message( FATAL_ERROR "pass -DNGSPICE_EH_FLAGS=\"\${DEPS_EH_FLAGS}\" (see scripts/common/env.sh)" )
|
||||
endif()
|
||||
separate_arguments( NGSPICE_EH_FLAGS_LIST UNIX_COMMAND "${NGSPICE_EH_FLAGS}" )
|
||||
|
||||
set( NGSPICE_LIB_DIR "${NGSPICE_SYSROOT}/lib" )
|
||||
set( NGSPICE_CM_DIR "${NGSPICE_SYSROOT}/lib/ngspice" )
|
||||
|
||||
add_executable( ngspice_service ngspice_service_main.cpp )
|
||||
|
||||
target_include_directories( ngspice_service PRIVATE "${NGSPICE_SYSROOT}/include" )
|
||||
|
||||
# The sysroot libngspice.a is built with XSPICE; sharedspice.h guards the
|
||||
# XSPICE-only declarations (ngCM_Input_Path & co.) behind this define.
|
||||
target_compile_definitions( ngspice_service PRIVATE XSPICE )
|
||||
|
||||
# -pthread at COMPILE stage too (atomics/bulk-memory object features must
|
||||
# match --shared-memory at link).
|
||||
target_compile_options( ngspice_service PRIVATE -pthread ${NGSPICE_EH_FLAGS_LIST} )
|
||||
|
||||
# The .cm archives resolve through the static code-model registry appended to
|
||||
# dev.c (its extern table references pull each archive's tables member, which
|
||||
# pulls that model's cfunc/ifspec objects); ngcm_common.a carries the shared
|
||||
# dlmain utility tail + tline commons. lld's implicit group semantics handle
|
||||
# the cm -> core back-references.
|
||||
target_link_libraries( ngspice_service PRIVATE
|
||||
"${NGSPICE_LIB_DIR}/libngspice.a"
|
||||
"${NGSPICE_CM_DIR}/analog.cm"
|
||||
"${NGSPICE_CM_DIR}/digital.cm"
|
||||
"${NGSPICE_CM_DIR}/spice2poly.cm"
|
||||
"${NGSPICE_CM_DIR}/table.cm"
|
||||
"${NGSPICE_CM_DIR}/tlines.cm"
|
||||
"${NGSPICE_CM_DIR}/xtradev.cm"
|
||||
"${NGSPICE_CM_DIR}/xtraevt.cm"
|
||||
"${NGSPICE_CM_DIR}/ngcm_common.a"
|
||||
)
|
||||
|
||||
# spinit is embedded at the path main() points SPICE_LIB_DIR at, so ngspice's
|
||||
# own init (incl. the `codemodel .../<cm>.cm` lines the registry resolves by
|
||||
# basename) runs identically to a native install regardless of the prefix the
|
||||
# dep build happened to use.
|
||||
set_target_properties( ngspice_service PROPERTIES
|
||||
LINK_FLAGS "-O2 -g0 --bind -pthread ${NGSPICE_EH_FLAGS} -sASYNCIFY=0 -sMODULARIZE=1 -sEXPORT_NAME=NgspiceService -sENVIRONMENT=worker,node -sEXIT_RUNTIME=0 -sALLOW_MEMORY_GROWTH=1 -sINITIAL_MEMORY=256MB -sMAXIMUM_MEMORY=4GB -sPTHREAD_POOL_SIZE=4 -sPTHREAD_POOL_SIZE_STRICT=0 -sPTHREAD_POOL_DELAY_LOAD=1 -sSTACK_SIZE=4MB -sDEFAULT_PTHREAD_STACK_SIZE=2MB --embed-file ${NGSPICE_SYSROOT}/share/ngspice/scripts/spinit@/ngspice/scripts/spinit" )
|
||||
309
wasm/ngspice-service/ngspice_service_main.cpp
Normal file
309
wasm/ngspice-service/ngspice_service_main.cpp
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
/*
|
||||
* ngspice_service — worker-side entry points wrapping ngspice's sharedspice
|
||||
* API for the eeschema simulator (see wasm/ngspice-service/CMakeLists.txt and
|
||||
* docs/features/ngspice-split/).
|
||||
*
|
||||
* RPC surface (embind, called by web/standalone/src/wasm/ngspice-worker.js):
|
||||
* init() -> int ngSpice_Init with the service callbacks
|
||||
* circ(lines, files) -> int stage .include files, then ngSpice_Circ
|
||||
* command(cmd) -> int ngSpice_Command
|
||||
* getVecInfo(name) -> {found, vname, vtype, flags, length, real, comp}
|
||||
* curPlot() -> string
|
||||
* allPlots()/allVecs(p) -> string[]
|
||||
* running() -> bool
|
||||
* cmInputPath(path) -> void
|
||||
*
|
||||
* Event stream: sharedspice callbacks fire on ngspice's background pthread
|
||||
* during bg_run (and synchronously on this module's main thread during
|
||||
* commands). Every callback is forwarded with MAIN_THREAD_ASYNC_EM_ASM to
|
||||
* Module.ngspiceEmit — the per-target FIFO of emscripten's proxying queue
|
||||
* preserves order, and the main thread is idle while a bg simulation runs, so
|
||||
* the queue drains promptly. The worker wrapper batches char/stat lines and
|
||||
* postMessages { evt } frames to the editor-side provider.
|
||||
*
|
||||
* KiCad parity notes: SendData/SendInitData are nullptr exactly like
|
||||
* eeschema's NGSPICE::init_dll (vectors are pulled after the run); vector
|
||||
* copies happen under ngSpice_LockRealloc so mid-run plot refresh cannot race
|
||||
* the growing simulation vectors (this replaces KiCad's client-side lock,
|
||||
* which is a no-op across the RPC boundary).
|
||||
*/
|
||||
|
||||
#include <atomic>
|
||||
#include <cerrno>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
#include <emscripten.h>
|
||||
#include <emscripten/bind.h>
|
||||
|
||||
#include "ngspice/sharedspice.h"
|
||||
|
||||
using namespace emscripten;
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
enum EvtKind
|
||||
{
|
||||
EVT_CHAR = 0, // SendChar console line
|
||||
EVT_STAT = 1, // SendStat status line
|
||||
EVT_BG = 2, // BGThreadRunning: a = 1 when finished/not-running
|
||||
EVT_EXIT = 3, // ControlledExit: a = status, b = immediate|quit<<1
|
||||
};
|
||||
|
||||
// Safe from any thread. The string is strdup'd and freed by the proxied JS
|
||||
// after conversion (the pointer must outlive the async hop).
|
||||
void emitEvt( int aKind, const char* aText, int aA, int aB )
|
||||
{
|
||||
char* copy = aText ? strdup( aText ) : nullptr;
|
||||
|
||||
MAIN_THREAD_ASYNC_EM_ASM(
|
||||
{
|
||||
var s = $1 ? UTF8ToString( $1 ) : null;
|
||||
if( $1 )
|
||||
_free( $1 );
|
||||
if( Module.ngspiceEmit )
|
||||
Module.ngspiceEmit( $0, s, $2, $3 );
|
||||
},
|
||||
aKind, copy, aA, aB );
|
||||
}
|
||||
|
||||
int cbSendChar( char* aWhat, int, void* )
|
||||
{
|
||||
emitEvt( EVT_CHAR, aWhat, 0, 0 );
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cbSendStat( char* aWhat, int, void* )
|
||||
{
|
||||
emitEvt( EVT_STAT, aWhat, 0, 0 );
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cbControlledExit( int aStatus, NG_BOOL aImmediate, NG_BOOL aQuit, int, void* )
|
||||
{
|
||||
emitEvt( EVT_EXIT, nullptr, aStatus, ( aImmediate ? 1 : 0 ) | ( aQuit ? 2 : 0 ) );
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cbBGThreadRunning( NG_BOOL aNotRunning, int, void* )
|
||||
{
|
||||
emitEvt( EVT_BG, nullptr, aNotRunning ? 1 : 0, 0 );
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::atomic<bool> s_inited{ false };
|
||||
|
||||
int svcInit()
|
||||
{
|
||||
if( s_inited.load() )
|
||||
return 0;
|
||||
|
||||
int ret = ngSpice_Init( cbSendChar, cbSendStat, cbControlledExit,
|
||||
nullptr, nullptr, cbBGThreadRunning, nullptr );
|
||||
|
||||
if( ret == 0 )
|
||||
s_inited.store( true );
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
void mkdirRecursive( const std::string& aDir )
|
||||
{
|
||||
std::string partial;
|
||||
|
||||
for( size_t i = 0; i < aDir.size(); i++ )
|
||||
{
|
||||
partial += aDir[i];
|
||||
|
||||
if( aDir[i] == '/' && partial.size() > 1 )
|
||||
::mkdir( partial.c_str(), 0777 );
|
||||
}
|
||||
|
||||
if( !partial.empty() )
|
||||
::mkdir( partial.c_str(), 0777 );
|
||||
}
|
||||
|
||||
// lines: string[]; files: [{ path, text }] — netlist-referenced .include /
|
||||
// .lib files read out of the editor's MEMFS, staged here at identical
|
||||
// absolute paths so ngspice's own file access resolves them.
|
||||
int svcCirc( val aLines, val aFiles )
|
||||
{
|
||||
const unsigned nFiles = aFiles["length"].as<unsigned>();
|
||||
|
||||
for( unsigned i = 0; i < nFiles; i++ )
|
||||
{
|
||||
val f = aFiles[i];
|
||||
std::string path = f["path"].as<std::string>();
|
||||
std::string text = f["text"].as<std::string>();
|
||||
|
||||
size_t slash = path.find_last_of( '/' );
|
||||
|
||||
if( slash != std::string::npos && slash > 0 )
|
||||
mkdirRecursive( path.substr( 0, slash ) );
|
||||
|
||||
FILE* fp = fopen( path.c_str(), "w" );
|
||||
|
||||
if( !fp )
|
||||
{
|
||||
fprintf( stderr, "[ngspice_service] cannot stage %s: %s\n",
|
||||
path.c_str(), strerror( errno ) );
|
||||
return 1;
|
||||
}
|
||||
|
||||
fwrite( text.data(), 1, text.size(), fp );
|
||||
fclose( fp );
|
||||
}
|
||||
|
||||
const unsigned n = aLines["length"].as<unsigned>();
|
||||
std::vector<std::string> strs;
|
||||
strs.reserve( n );
|
||||
|
||||
for( unsigned i = 0; i < n; i++ )
|
||||
strs.push_back( aLines[i].as<std::string>() );
|
||||
|
||||
// ngSpice_Circ wants a NULL-terminated char* array; it copies the lines.
|
||||
std::vector<char*> arr( n + 1 );
|
||||
|
||||
for( unsigned i = 0; i < n; i++ )
|
||||
arr[i] = const_cast<char*>( strs[i].c_str() );
|
||||
|
||||
arr[n] = nullptr;
|
||||
|
||||
return ngSpice_Circ( arr.data() );
|
||||
}
|
||||
|
||||
int svcCommand( std::string aCmd )
|
||||
{
|
||||
return ngSpice_Command( const_cast<char*>( aCmd.c_str() ) );
|
||||
}
|
||||
|
||||
val svcGetVecInfo( std::string aName )
|
||||
{
|
||||
val out = val::object();
|
||||
|
||||
// Persistent copy buffers: the returned typed_memory_views stay valid
|
||||
// until the next call; the worker structured-clones them into fresh
|
||||
// arrays before posting (a SAB-backed view cannot be transferred).
|
||||
static std::vector<double> s_realBuf;
|
||||
static std::vector<double> s_compBuf;
|
||||
|
||||
ngSpice_LockRealloc();
|
||||
|
||||
pvector_info vi = ngGet_Vec_Info( const_cast<char*>( aName.c_str() ) );
|
||||
|
||||
if( !vi )
|
||||
{
|
||||
ngSpice_UnlockRealloc();
|
||||
out.set( "found", false );
|
||||
return out;
|
||||
}
|
||||
|
||||
out.set( "found", true );
|
||||
out.set( "vname", std::string( vi->v_name ? vi->v_name : "" ) );
|
||||
out.set( "vtype", vi->v_type );
|
||||
out.set( "flags", (int) vi->v_flags );
|
||||
out.set( "length", vi->v_length );
|
||||
|
||||
if( vi->v_realdata && vi->v_length > 0 )
|
||||
{
|
||||
s_realBuf.assign( vi->v_realdata, vi->v_realdata + vi->v_length );
|
||||
out.set( "real", val( typed_memory_view( s_realBuf.size(), s_realBuf.data() ) ) );
|
||||
}
|
||||
else
|
||||
{
|
||||
out.set( "real", val::null() );
|
||||
}
|
||||
|
||||
if( vi->v_compdata && vi->v_length > 0 )
|
||||
{
|
||||
s_compBuf.resize( (size_t) vi->v_length * 2 );
|
||||
|
||||
for( int i = 0; i < vi->v_length; i++ )
|
||||
{
|
||||
s_compBuf[(size_t) i * 2] = vi->v_compdata[i].cx_real;
|
||||
s_compBuf[(size_t) i * 2 + 1] = vi->v_compdata[i].cx_imag;
|
||||
}
|
||||
|
||||
out.set( "comp", val( typed_memory_view( s_compBuf.size(), s_compBuf.data() ) ) );
|
||||
}
|
||||
else
|
||||
{
|
||||
out.set( "comp", val::null() );
|
||||
}
|
||||
|
||||
ngSpice_UnlockRealloc();
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string svcCurPlot()
|
||||
{
|
||||
char* name = ngSpice_CurPlot();
|
||||
return name ? name : "";
|
||||
}
|
||||
|
||||
val svcAllPlots()
|
||||
{
|
||||
val out = val::array();
|
||||
char** names = ngSpice_AllPlots();
|
||||
|
||||
for( int i = 0; names && names[i]; i++ )
|
||||
out.call<void>( "push", std::string( names[i] ) );
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
val svcAllVecs( std::string aPlot )
|
||||
{
|
||||
val out = val::array();
|
||||
char** names = ngSpice_AllVecs( const_cast<char*>( aPlot.c_str() ) );
|
||||
|
||||
for( int i = 0; names && names[i]; i++ )
|
||||
out.call<void>( "push", std::string( names[i] ) );
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
bool svcRunning()
|
||||
{
|
||||
return ngSpice_running();
|
||||
}
|
||||
|
||||
void svcCmInputPath( std::string aPath )
|
||||
{
|
||||
ngCM_Input_Path( aPath.empty() ? nullptr : aPath.c_str() );
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
EMSCRIPTEN_BINDINGS( ngspice_service )
|
||||
{
|
||||
function( "init", &svcInit );
|
||||
function( "circ", &svcCirc );
|
||||
function( "command", &svcCommand );
|
||||
function( "getVecInfo", &svcGetVecInfo );
|
||||
function( "curPlot", &svcCurPlot );
|
||||
function( "allPlots", &svcAllPlots );
|
||||
function( "allVecs", &svcAllVecs );
|
||||
function( "running", &svcRunning );
|
||||
function( "cmInputPath", &svcCmInputPath );
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
// ngspice resolves spinit via $SPICE_LIB_DIR/scripts/spinit before the
|
||||
// compiled-in NGSPICEDATADIR (whose baked host path is meaningless in
|
||||
// MEMFS); the CMake link embeds spinit at this fixed location.
|
||||
setenv( "SPICE_LIB_DIR", "/ngspice", 1 );
|
||||
|
||||
fprintf( stderr, "[ngspice_service] ready\n" );
|
||||
|
||||
// EXIT_RUNTIME=0: the module stays alive for embind calls from onmessage.
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
/*
|
||||
* Empty replacements for the four largest ngspice model data initializers.
|
||||
*
|
||||
* Each of sim_model_ngspice_data_{bsim4,b3soi,b4soi,hsim}.cpp defines a
|
||||
* single function (addBSIM4/addB3SOI/addB4SOI/addHSIM) that pushes hundreds
|
||||
* of entries into NGSPICE_MODEL_INFO_MAP::modelInfos[...]. Once compiled to
|
||||
* WASM these functions exceed the V8/SpiderMonkey limit on locals per
|
||||
* function ("too many locals"), so Firefox refuses to instantiate the
|
||||
* resulting module.
|
||||
*
|
||||
* The simulator UI is never reachable in the WASM build (FRAME_SIMULATOR
|
||||
* fails to instantiate via the ngspice header stub at
|
||||
* wasm/stubs/ngspice/sharedspice.h), so leaving these tables empty is safe.
|
||||
*
|
||||
* eeschema/CMakeLists.txt excludes the original four sources from
|
||||
* EESCHEMA_SIM_SRCS for EMSCRIPTEN and adds this file instead.
|
||||
*/
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include <sim/sim_model_ngspice.h>
|
||||
|
||||
void NGSPICE_MODEL_INFO_MAP::addBSIM4() {}
|
||||
void NGSPICE_MODEL_INFO_MAP::addB3SOI() {}
|
||||
void NGSPICE_MODEL_INFO_MAP::addB4SOI() {}
|
||||
void NGSPICE_MODEL_INFO_MAP::addHSIM() {}
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
/*
|
||||
* Minimal stub of ngspice's sharedspice.h for KiCad WASM builds.
|
||||
*
|
||||
* Only the type names referenced by kicad/eeschema/sim/ngspice.{h,cpp} need
|
||||
* to exist. The eeschema sim layer compiles but the simulator frame is never
|
||||
* instantiated in WASM (FRAME_SIMULATOR's try/catch in IFACE::CreateKiWindow
|
||||
* catches the init failure and returns nullptr).
|
||||
* Provides the type names referenced by kicad/eeschema/sim/ngspice.{h,cpp}
|
||||
* plus the declarations of the sharedspice CLIENT (sharedspice_client.cpp):
|
||||
* the real engine runs in the ngspice_service worker
|
||||
* (docs/features/ngspice-split/), and NGSPICE::init_dll()'s __EMSCRIPTEN__
|
||||
* branch binds its function pointers to the pcbjam_ngSpice_* forwarders
|
||||
* declared below instead of dlopen'ing libngspice.
|
||||
*
|
||||
* We intentionally do NOT define NGSPICE_PACKAGE_VERSION so that ngspice.h's
|
||||
* fallback `typedef bool NG_BOOL;` (line 46) provides the boolean type.
|
||||
|
|
@ -48,6 +50,23 @@ typedef int (SendData)(pvecvaluesall, int, int, void*);
|
|||
typedef int (SendInitData)(pvecinfoall, int, void*);
|
||||
typedef int (BGThreadRunning)(bool, int, void*);
|
||||
|
||||
/*
|
||||
* The sharedspice client (wasm/stubs/sharedspice_client.cpp): RPC forwarders
|
||||
* to the ngspice_service worker, signature-compatible with NGSPICE's private
|
||||
* function-pointer typedefs (the pcbjam_ prefix avoids shadowing by those
|
||||
* class-scope typedef names inside init_dll).
|
||||
*/
|
||||
void pcbjam_ngSpice_Init(SendChar*, SendStat*, ControlledExit*, SendData*,
|
||||
SendInitData*, BGThreadRunning*, void*);
|
||||
int pcbjam_ngSpice_Circ(char** circarray);
|
||||
int pcbjam_ngSpice_Command(char* command);
|
||||
pvector_info pcbjam_ngGet_Vec_Info(char* vecname);
|
||||
char* pcbjam_ngCM_Input_Path(const char* path);
|
||||
char* pcbjam_ngSpice_CurPlot(void);
|
||||
char** pcbjam_ngSpice_AllPlots(void);
|
||||
char** pcbjam_ngSpice_AllVecs(char* plotname);
|
||||
bool pcbjam_ngSpice_Running(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
490
wasm/stubs/sharedspice_client.cpp
Normal file
490
wasm/stubs/sharedspice_client.cpp
Normal file
|
|
@ -0,0 +1,490 @@
|
|||
/*
|
||||
* sharedspice client stub — the editor side of the ngspice_service split
|
||||
* (docs/features/ngspice-split/; the SPICE analog of exporter_step_stub.cpp).
|
||||
*
|
||||
* eeschema's NGSPICE class normally dlopens libngspice and binds ~10 function
|
||||
* pointers; in WASM the simulator engine lives in a separate worker module
|
||||
* (wasm/ngspice-service/), and NGSPICE::init_dll()'s __EMSCRIPTEN__ branch
|
||||
* binds its pointers to the pcbjam_ngSpice_* functions here instead. Each
|
||||
* forwards over the `globalThis.ngspiceService` provider
|
||||
* (web/standalone/src/wasm/ngspice-service.ts) via EM_ASYNC_JS — the editor
|
||||
* suspends through Asyncify while the worker answers (the `__asyncjs__*`
|
||||
* import is auto-covered by scripts/common/asyncify-imports.txt).
|
||||
*
|
||||
* Callbacks: KiCad registers its cbSendChar/cbSendStat/cbControlledExit/
|
||||
* cbBGThreadRunning with pcbjam_ngSpice_Init; the worker streams `{ evt }`
|
||||
* frames which the provider hands to `globalThis.__ngspiceOnEvent` (installed
|
||||
* here). The dispatcher calls the exported pcbjam_ngspice_event — a fresh
|
||||
* WASM entry from JS, safe while the main C++ stack is Asyncify-suspended
|
||||
* (the wx-dom DOM-event mechanism, wxwidgets/src/wasm/domevents.cpp); KiCad's
|
||||
* callbacks only take a mutex and wxQueueEvent, so nothing on this path can
|
||||
* suspend.
|
||||
*
|
||||
* ngSpice_running stays cheap: a client-side atomic mirror maintained from
|
||||
* command results and bg events — the simulator UI polls it on a refresh
|
||||
* timer and an RPC per poll would be pure overhead.
|
||||
*
|
||||
* Netlist file shipping: NETLIST_EXPORTER_SPICE emits `.include "<abs path>"`
|
||||
* lines (Sim.Library models, the IBIS cache) that ngspice opens from ITS
|
||||
* filesystem — pcbjam_ngSpice_Circ scans the deck, reads those files from the
|
||||
* editor MEMFS (recursively, bounded), and ships them with the circ request
|
||||
* so the service stages them at identical paths.
|
||||
*/
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
|
||||
#include <atomic>
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <emscripten.h>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <ngspice/sharedspice.h>
|
||||
|
||||
using nlohmann::json;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// JS bridges
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// Generic request: JSON in, JSON out (malloc'd; caller frees). Vector data
|
||||
// never travels this path — see js_ngspice_get_vec.
|
||||
// clang-format off
|
||||
EM_ASYNC_JS( char*, js_ngspice_request, ( const char* aReqJson ), {
|
||||
let res;
|
||||
try {
|
||||
const svc = globalThis.ngspiceService;
|
||||
if( !svc )
|
||||
res = { error: 'ngspiceService provider not installed' };
|
||||
else
|
||||
res = await svc.request( JSON.parse( UTF8ToString( aReqJson ) ) );
|
||||
} catch( e ) {
|
||||
res = { error: String( e ) };
|
||||
}
|
||||
const s = JSON.stringify( res ?? {} );
|
||||
const n = lengthBytesUTF8( s ) + 1;
|
||||
const p = _malloc( n );
|
||||
stringToUTF8( s, p, n );
|
||||
return p;
|
||||
} );
|
||||
|
||||
// Vector fetch: fills editor-heap buffers directly (no JSON for MB arrays).
|
||||
// aMeta: int[4] = { found, vtype, flags, length }; aReal/aComp receive
|
||||
// malloc'd double buffers (comp interleaved re,im — the ngcomplex_t layout);
|
||||
// aVName receives a malloc'd name string. Returns non-zero on transport error.
|
||||
EM_ASYNC_JS( int, js_ngspice_get_vec,
|
||||
( const char* aName, int* aMeta, double** aReal, double** aComp, char** aVName ), {
|
||||
let res;
|
||||
try {
|
||||
const svc = globalThis.ngspiceService;
|
||||
res = svc ? await svc.request( { kind: 'get_vec_info', name: UTF8ToString( aName ) } )
|
||||
: { error: 'ngspiceService provider not installed' };
|
||||
} catch( e ) {
|
||||
res = { error: String( e ) };
|
||||
}
|
||||
HEAP32[aMeta >> 2] = 0;
|
||||
HEAPU32[aReal >> 2] = 0;
|
||||
HEAPU32[aComp >> 2] = 0;
|
||||
HEAPU32[aVName >> 2] = 0;
|
||||
if( !res || res.error )
|
||||
return 1;
|
||||
if( !res.found )
|
||||
return 0;
|
||||
HEAP32[( aMeta >> 2 ) + 1] = res.vtype | 0;
|
||||
HEAP32[( aMeta >> 2 ) + 2] = res.flags | 0;
|
||||
HEAP32[( aMeta >> 2 ) + 3] = res.length | 0;
|
||||
if( res.real && res.real.length ) {
|
||||
const p = _malloc( res.real.length * 8 );
|
||||
HEAPF64.set( res.real, p >> 3 );
|
||||
HEAPU32[aReal >> 2] = p;
|
||||
}
|
||||
if( res.comp && res.comp.length ) {
|
||||
const p = _malloc( res.comp.length * 8 );
|
||||
HEAPF64.set( res.comp, p >> 3 );
|
||||
HEAPU32[aComp >> 2] = p;
|
||||
}
|
||||
const s = res.vname || '';
|
||||
const n = lengthBytesUTF8( s ) + 1;
|
||||
const vp = _malloc( n );
|
||||
stringToUTF8( s, vp, n );
|
||||
HEAPU32[aVName >> 2] = vp;
|
||||
HEAP32[aMeta >> 2] = 1;
|
||||
return 0;
|
||||
} );
|
||||
|
||||
// Event dispatcher: provider `{ evt }` frames -> KiCad's registered callbacks
|
||||
// via the exported pcbjam_ngspice_event (fresh wasm entries; see header
|
||||
// comment). Installed once, at first pcbjam_ngSpice_Init.
|
||||
EM_JS( void, js_ngspice_install_events, (), {
|
||||
if( globalThis.__ngspiceOnEvent )
|
||||
return;
|
||||
globalThis.__ngspiceOnEvent = ( evt ) => {
|
||||
const call = ( kind, text, a, b ) => {
|
||||
let p = 0;
|
||||
if( text != null ) {
|
||||
const n = lengthBytesUTF8( text ) + 1;
|
||||
p = _malloc( n );
|
||||
stringToUTF8( text, p, n );
|
||||
}
|
||||
Module._pcbjam_ngspice_event( kind, p, a | 0, b | 0 );
|
||||
};
|
||||
if( evt.kind === 'char' || evt.kind === 'stat' ) {
|
||||
for( const line of evt.lines || [] )
|
||||
call( evt.kind === 'char' ? 0 : 1, line, 0, 0 );
|
||||
} else if( evt.kind === 'bg' ) {
|
||||
call( 2, null, evt.finished ? 1 : 0, 0 );
|
||||
} else if( evt.kind === 'exit' ) {
|
||||
call( 3, null, evt.status | 0,
|
||||
( evt.immediate ? 1 : 0 ) | ( evt.quit ? 2 : 0 ) );
|
||||
}
|
||||
};
|
||||
} );
|
||||
// clang-format on
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Client state
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
SendChar* s_sendChar = nullptr;
|
||||
SendStat* s_sendStat = nullptr;
|
||||
ControlledExit* s_controlledExit = nullptr;
|
||||
BGThreadRunning* s_bgThreadRunning = nullptr;
|
||||
void* s_user = nullptr;
|
||||
|
||||
// Mirror of the service's bg-run state (see header comment).
|
||||
std::atomic<bool> s_bgRunning{ false };
|
||||
|
||||
json rpc( const json& aReq )
|
||||
{
|
||||
char* raw = js_ngspice_request( aReq.dump().c_str() );
|
||||
json res = json::parse( raw ? raw : "{}", nullptr, /* allow_exceptions */ false );
|
||||
std::free( raw );
|
||||
|
||||
if( res.is_discarded() )
|
||||
res = json::object();
|
||||
|
||||
if( res.contains( "error" ) )
|
||||
{
|
||||
fprintf( stderr, "[sharedspice_client] %s: %s\n",
|
||||
aReq.value( "kind", "?" ).c_str(),
|
||||
res["error"].dump().c_str() );
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
// Read an editor-MEMFS file; returns false if it doesn't exist.
|
||||
bool readFile( const std::string& aPath, std::string* aOut )
|
||||
{
|
||||
FILE* fp = fopen( aPath.c_str(), "rb" );
|
||||
|
||||
if( !fp )
|
||||
return false;
|
||||
|
||||
fseek( fp, 0, SEEK_END );
|
||||
long size = ftell( fp );
|
||||
fseek( fp, 0, SEEK_SET );
|
||||
|
||||
aOut->resize( size > 0 ? (size_t) size : 0 );
|
||||
|
||||
if( size > 0 && fread( aOut->data(), 1, (size_t) size, fp ) != (size_t) size )
|
||||
{
|
||||
fclose( fp );
|
||||
return false;
|
||||
}
|
||||
|
||||
fclose( fp );
|
||||
return true;
|
||||
}
|
||||
|
||||
// Extract the file path from a `.include "<path>"` / `.inc` / `.lib "<path>"
|
||||
// [section]` deck line; empty if the line is not an include directive.
|
||||
std::string includePathFromLine( const std::string& aLine )
|
||||
{
|
||||
size_t i = 0;
|
||||
|
||||
while( i < aLine.size() && isspace( (unsigned char) aLine[i] ) )
|
||||
i++;
|
||||
|
||||
if( i >= aLine.size() || aLine[i] != '.' )
|
||||
return std::string();
|
||||
|
||||
size_t wordEnd = i;
|
||||
|
||||
while( wordEnd < aLine.size() && !isspace( (unsigned char) aLine[wordEnd] ) )
|
||||
wordEnd++;
|
||||
|
||||
std::string word = aLine.substr( i, wordEnd - i );
|
||||
|
||||
for( char& c : word )
|
||||
c = (char) tolower( (unsigned char) c );
|
||||
|
||||
if( word != ".include" && word != ".inc" && word != ".lib" )
|
||||
return std::string();
|
||||
|
||||
size_t p = wordEnd;
|
||||
|
||||
while( p < aLine.size() && isspace( (unsigned char) aLine[p] ) )
|
||||
p++;
|
||||
|
||||
if( p >= aLine.size() )
|
||||
return std::string();
|
||||
|
||||
if( aLine[p] == '"' || aLine[p] == '\'' )
|
||||
{
|
||||
char quote = aLine[p++];
|
||||
size_t end = aLine.find( quote, p );
|
||||
return end == std::string::npos ? std::string() : aLine.substr( p, end - p );
|
||||
}
|
||||
|
||||
size_t end = p;
|
||||
|
||||
while( end < aLine.size() && !isspace( (unsigned char) aLine[end] ) )
|
||||
end++;
|
||||
|
||||
return aLine.substr( p, end - p );
|
||||
}
|
||||
|
||||
// Collect the deck's referenced model files (recursively — a shipped library
|
||||
// may itself .include others), bounded against cycles and runaway depth.
|
||||
void collectIncludeFiles( const std::vector<std::string>& aLines, json* aFiles,
|
||||
std::set<std::string>* aSeen, int aDepth )
|
||||
{
|
||||
if( aDepth > 4 )
|
||||
return;
|
||||
|
||||
for( const std::string& line : aLines )
|
||||
{
|
||||
std::string path = includePathFromLine( line );
|
||||
|
||||
if( path.empty() || aSeen->count( path ) )
|
||||
continue;
|
||||
|
||||
aSeen->insert( path );
|
||||
|
||||
std::string text;
|
||||
|
||||
if( !readFile( path, &text ) )
|
||||
continue; // ngspice will report the miss with its native error
|
||||
|
||||
aFiles->push_back( { { "path", path }, { "text", text } } );
|
||||
|
||||
std::vector<std::string> nested;
|
||||
size_t start = 0;
|
||||
|
||||
while( start <= text.size() )
|
||||
{
|
||||
size_t nl = text.find( '\n', start );
|
||||
|
||||
if( nl == std::string::npos )
|
||||
{
|
||||
nested.push_back( text.substr( start ) );
|
||||
break;
|
||||
}
|
||||
|
||||
nested.push_back( text.substr( start, nl - start ) );
|
||||
start = nl + 1;
|
||||
}
|
||||
|
||||
collectIncludeFiles( nested, aFiles, aSeen, aDepth + 1 );
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Event entry from JS (fresh wasm entry; must never suspend)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
extern "C" EMSCRIPTEN_KEEPALIVE void pcbjam_ngspice_event( int aKind, char* aText, int aA, int aB )
|
||||
{
|
||||
switch( aKind )
|
||||
{
|
||||
case 0: // char
|
||||
if( s_sendChar )
|
||||
s_sendChar( aText ? aText : const_cast<char*>( "" ), 0, s_user );
|
||||
break;
|
||||
|
||||
case 1: // stat
|
||||
if( s_sendStat )
|
||||
s_sendStat( aText ? aText : const_cast<char*>( "" ), 0, s_user );
|
||||
break;
|
||||
|
||||
case 2: // bg: aA = finished
|
||||
s_bgRunning.store( aA == 0 );
|
||||
|
||||
if( s_bgThreadRunning )
|
||||
s_bgThreadRunning( aA != 0, 0, s_user );
|
||||
break;
|
||||
|
||||
case 3: // exit: aA = status, aB = immediate|quit<<1
|
||||
s_bgRunning.store( false );
|
||||
|
||||
if( s_controlledExit )
|
||||
s_controlledExit( aA, ( aB & 1 ) != 0, ( aB & 2 ) != 0, 0, s_user );
|
||||
break;
|
||||
}
|
||||
|
||||
std::free( aText );
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The sharedspice API surface NGSPICE::init_dll binds to
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void pcbjam_ngSpice_Init( SendChar* aSendChar, SendStat* aSendStat, ControlledExit* aExit,
|
||||
SendData*, SendInitData*, BGThreadRunning* aBgRunning, void* aUser )
|
||||
{
|
||||
s_sendChar = aSendChar;
|
||||
s_sendStat = aSendStat;
|
||||
s_controlledExit = aExit;
|
||||
s_bgThreadRunning = aBgRunning;
|
||||
s_user = aUser;
|
||||
|
||||
js_ngspice_install_events();
|
||||
|
||||
// Boots the worker lazily; a failure surfaces on the first command too
|
||||
// (KiCad ignores ngSpice_Init's status, matching its native call).
|
||||
rpc( { { "kind", "init" } } );
|
||||
}
|
||||
|
||||
int pcbjam_ngSpice_Circ( char** aCircArray )
|
||||
{
|
||||
std::vector<std::string> lines;
|
||||
|
||||
for( int i = 0; aCircArray && aCircArray[i]; i++ )
|
||||
lines.emplace_back( aCircArray[i] );
|
||||
|
||||
json files = json::array();
|
||||
std::set<std::string> seen;
|
||||
collectIncludeFiles( lines, &files, &seen, 0 );
|
||||
|
||||
json req = { { "kind", "circ" }, { "lines", lines }, { "files", std::move( files ) } };
|
||||
|
||||
return rpc( req ).value( "ret", 1 );
|
||||
}
|
||||
|
||||
int pcbjam_ngSpice_Command( char* aCommand )
|
||||
{
|
||||
std::string cmd = aCommand ? aCommand : "";
|
||||
int ret = rpc( { { "kind", "command" }, { "cmd", cmd } } ).value( "ret", 1 );
|
||||
|
||||
// The bg 'started' event arrives asynchronously; flip the mirror at the
|
||||
// acceptance edge so an immediate IsRunning() poll already sees it.
|
||||
if( ret == 0 && cmd.rfind( "bg_run", 0 ) == 0 )
|
||||
s_bgRunning.store( true );
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
pvector_info pcbjam_ngGet_Vec_Info( char* aVecName )
|
||||
{
|
||||
// Per-call arena: valid until the next call, matching every NGSPICE
|
||||
// consumer (they copy within the same call).
|
||||
static vector_info s_vi;
|
||||
static char* s_name = nullptr;
|
||||
static double* s_real = nullptr;
|
||||
static double* s_comp = nullptr;
|
||||
|
||||
std::free( s_name );
|
||||
std::free( s_real );
|
||||
std::free( s_comp );
|
||||
s_name = nullptr;
|
||||
s_real = nullptr;
|
||||
s_comp = nullptr;
|
||||
|
||||
int meta[4] = { 0, 0, 0, 0 };
|
||||
double* real = nullptr;
|
||||
double* comp = nullptr;
|
||||
char* vname = nullptr;
|
||||
|
||||
if( js_ngspice_get_vec( aVecName ? aVecName : "", meta, &real, &comp, &vname ) != 0 )
|
||||
return nullptr;
|
||||
|
||||
if( !meta[0] )
|
||||
return nullptr;
|
||||
|
||||
s_name = vname;
|
||||
s_real = real;
|
||||
s_comp = comp;
|
||||
|
||||
s_vi.v_name = s_name;
|
||||
s_vi.v_type = meta[1];
|
||||
s_vi.v_flags = (short) meta[2];
|
||||
s_vi.v_length = meta[3];
|
||||
s_vi.v_realdata = s_real;
|
||||
// Interleaved re,im doubles ARE the ngcomplex_t array layout.
|
||||
s_vi.v_compdata = reinterpret_cast<ngcomplex_t*>( s_comp );
|
||||
|
||||
return &s_vi;
|
||||
}
|
||||
|
||||
char* pcbjam_ngSpice_CurPlot( void )
|
||||
{
|
||||
static std::string s_plot;
|
||||
s_plot = rpc( { { "kind", "cur_plot" } } ).value( "name", "" );
|
||||
return s_plot.data();
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
// Shared marshalling for the two NULL-terminated string-array calls.
|
||||
char** stringArrayResult( const json& aRes )
|
||||
{
|
||||
static std::vector<std::string> s_store;
|
||||
static std::vector<char*> s_ptrs;
|
||||
|
||||
s_store.clear();
|
||||
s_ptrs.clear();
|
||||
|
||||
if( aRes.contains( "names" ) && aRes["names"].is_array() )
|
||||
{
|
||||
for( const auto& n : aRes["names"] )
|
||||
s_store.push_back( n.get<std::string>() );
|
||||
}
|
||||
|
||||
for( std::string& s : s_store )
|
||||
s_ptrs.push_back( s.data() );
|
||||
|
||||
s_ptrs.push_back( nullptr );
|
||||
return s_ptrs.data();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
char** pcbjam_ngSpice_AllPlots( void )
|
||||
{
|
||||
return stringArrayResult( rpc( { { "kind", "all_plots" } } ) );
|
||||
}
|
||||
|
||||
char** pcbjam_ngSpice_AllVecs( char* aPlotName )
|
||||
{
|
||||
return stringArrayResult(
|
||||
rpc( { { "kind", "all_vecs" }, { "plot", aPlotName ? aPlotName : "" } } ) );
|
||||
}
|
||||
|
||||
bool pcbjam_ngSpice_Running( void )
|
||||
{
|
||||
return s_bgRunning.load();
|
||||
}
|
||||
|
||||
char* pcbjam_ngCM_Input_Path( const char* aPath )
|
||||
{
|
||||
static std::string s_path;
|
||||
s_path = aPath ? aPath : "";
|
||||
rpc( { { "kind", "cm_input_path" }, { "path", s_path } } );
|
||||
return s_path.data();
|
||||
}
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from "./constants";
|
||||
import { installModel3dHandler } from "./libs/models-bridge";
|
||||
import type { Model3dSource } from "./libs/models-source";
|
||||
import { installNgspiceService } from "./ngspice-service";
|
||||
import { installOccService } from "./occ-service";
|
||||
import {
|
||||
buildFpLibTable,
|
||||
|
|
@ -262,6 +263,9 @@ async function doBoot(opts: BootOptions): Promise<void> {
|
|||
// set; the worker itself is only fetched lazily on first use.
|
||||
if (bundle === "kicad_editor") {
|
||||
installOccService(log);
|
||||
// ngspice service (eeschema simulator): same lazy pattern — synchronous
|
||||
// provider install here, worker fetched on first Inspect → Simulator.
|
||||
installNgspiceService(log);
|
||||
}
|
||||
|
||||
if (libsSource && libKinds.length) {
|
||||
|
|
|
|||
|
|
@ -50,7 +50,11 @@ export type Bundle =
|
|||
// Headless lazy OCC worker module (docs/features/occ-split/) — fetched by the
|
||||
// occService provider on first STEP export / STEP-IGES model parse; backs no
|
||||
// tool/route of its own.
|
||||
| "occ_service";
|
||||
| "occ_service"
|
||||
// Headless lazy ngspice worker module (docs/features/ngspice-split/) —
|
||||
// fetched by the ngspiceService provider when eeschema's simulator first
|
||||
// initializes; backs no tool/route of its own.
|
||||
| "ngspice_service";
|
||||
|
||||
/**
|
||||
* Which deployed WASM bundle actually backs each tool. The four editors share the
|
||||
|
|
|
|||
191
web/standalone/src/wasm/ngspice-service.ts
Normal file
191
web/standalone/src/wasm/ngspice-service.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
// The worker-side wrapper as text (vite ?raw): one shared source of truth,
|
||||
// also injected by the e2e harness stub (tests/kicad/utils/ngspice-service.ts).
|
||||
import ngspiceWorkerSource from "./ngspice-worker.js?raw";
|
||||
import { resolveWasmBase } from "./wasm-assets";
|
||||
|
||||
/**
|
||||
* `globalThis.ngspiceService` — the lazy ngspice simulation service provider
|
||||
* (docs/features/ngspice-split/; the SPICE analog of occ-service.ts).
|
||||
*
|
||||
* kicad_editor.wasm carries no ngspice: eeschema's NGSPICE class binds to the
|
||||
* sharedspice client stub (wasm/stubs/sharedspice_client.cpp), whose
|
||||
* EM_ASYNC_JS bridges suspend the editor and land here. The ngspice_service
|
||||
* module (own emscripten instance, pthreads, `-sASYNCIFY=0`) boots in a
|
||||
* dedicated Worker on the FIRST request — a session that never opens the
|
||||
* simulator never fetches it.
|
||||
*
|
||||
* Requests mirror the sharedspice API 1:1 (init/circ/command/get_vec_info/
|
||||
* cur_plot/all_plots/all_vecs/running/cm_input_path). The worker additionally
|
||||
* streams `{ evt }` frames (batched SendChar/SendStat lines, BGThreadRunning
|
||||
* transitions, ControlledExit) which are handed to
|
||||
* `globalThis.__ngspiceOnEvent` — installed by the client stub at first init;
|
||||
* frames arriving earlier are queued.
|
||||
*
|
||||
* A worker death (hard ngspice crash — wasm has no SIGSEGV recovery) settles
|
||||
* every in-flight request with { error } and resets the boot promise: KiCad's
|
||||
* normal error path (`m_error` → `NGSPICE::validate()` → re-init) then
|
||||
* transparently boots a FRESH worker. That worker-restart isolation is the
|
||||
* whole reason the simulator lives out-of-process.
|
||||
*/
|
||||
|
||||
export interface NgspiceEvent {
|
||||
kind: "char" | "stat" | "bg" | "exit";
|
||||
lines?: string[];
|
||||
finished?: boolean;
|
||||
status?: number;
|
||||
immediate?: boolean;
|
||||
quit?: boolean;
|
||||
}
|
||||
|
||||
export type NgspiceRequest =
|
||||
| { kind: "init" }
|
||||
| { kind: "circ"; lines: string[]; files?: { path: string; text: string }[] }
|
||||
| { kind: "command"; cmd: string }
|
||||
| { kind: "get_vec_info"; name: string }
|
||||
| { kind: "cur_plot" }
|
||||
| { kind: "all_plots" }
|
||||
| { kind: "all_vecs"; plot: string }
|
||||
| { kind: "running" }
|
||||
| { kind: "cm_input_path"; path: string };
|
||||
|
||||
// Response shape depends on the request kind; `error` is set on any failure
|
||||
// (including worker death).
|
||||
export interface NgspiceResponse {
|
||||
ret?: number;
|
||||
found?: boolean;
|
||||
vname?: string;
|
||||
vtype?: number;
|
||||
flags?: number;
|
||||
length?: number;
|
||||
real?: Float64Array | null;
|
||||
comp?: Float64Array | null;
|
||||
name?: string;
|
||||
names?: string[];
|
||||
running?: boolean;
|
||||
ok?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var ngspiceService:
|
||||
| { request(req: NgspiceRequest): Promise<NgspiceResponse> }
|
||||
| undefined;
|
||||
// eslint-disable-next-line no-var
|
||||
var __ngspiceOnEvent: ((evt: NgspiceEvent) => void) | undefined;
|
||||
}
|
||||
|
||||
/** Worker blob parts: prelude with the glue URL + the shared wrapper source. */
|
||||
export function ngspiceWorkerBlobParts(glueHref: string): string[] {
|
||||
return [
|
||||
`self.NGSPICE_GLUE_URL = ${JSON.stringify(glueHref)};\n`,
|
||||
ngspiceWorkerSource,
|
||||
];
|
||||
}
|
||||
|
||||
export function installNgspiceService(log: (msg: string) => void): void {
|
||||
if (globalThis.ngspiceService) return;
|
||||
|
||||
let nextId = 1;
|
||||
const pending = new Map<number, (res: NgspiceResponse) => void>();
|
||||
let workerP: Promise<Worker> | null = null;
|
||||
|
||||
// Events can arrive before the client stub installs __ngspiceOnEvent
|
||||
// (the handler comes with the first editor-side ngSpice_Init).
|
||||
const evtQueue: NgspiceEvent[] = [];
|
||||
const dispatchEvt = (evt: NgspiceEvent) => {
|
||||
const handler = globalThis.__ngspiceOnEvent;
|
||||
if (handler) {
|
||||
while (evtQueue.length) handler(evtQueue.shift()!);
|
||||
handler(evt);
|
||||
} else {
|
||||
evtQueue.push(evt);
|
||||
}
|
||||
};
|
||||
|
||||
const failAllPending = (why: string) => {
|
||||
for (const [, resolve] of pending) resolve({ error: why });
|
||||
pending.clear();
|
||||
};
|
||||
|
||||
const ensureWorker = (): Promise<Worker> => {
|
||||
if (!workerP) {
|
||||
workerP = (async () => {
|
||||
const base = await resolveWasmBase("ngspice_service");
|
||||
const glue = new URL(`${base}/ngspice_service.js`, window.location.href).href;
|
||||
log(`[ngspice] booting ngspice_service from ${base}`);
|
||||
|
||||
const worker = new Worker(
|
||||
URL.createObjectURL(
|
||||
new Blob(ngspiceWorkerBlobParts(glue), { type: "text/javascript" }),
|
||||
),
|
||||
);
|
||||
|
||||
worker.onmessage = (e) => {
|
||||
const data = e.data ?? {};
|
||||
if (data.evt) {
|
||||
dispatchEvt(data.evt as NgspiceEvent);
|
||||
return;
|
||||
}
|
||||
if (typeof data.id !== "number") return;
|
||||
const resolve = pending.get(data.id);
|
||||
if (resolve) {
|
||||
pending.delete(data.id);
|
||||
resolve(data.res as NgspiceResponse);
|
||||
}
|
||||
};
|
||||
|
||||
// A dead worker (hard ngspice fault) must not strand the editor
|
||||
// suspended in an EM_ASYNC_JS bridge: fail everything in flight and
|
||||
// make the next request boot a fresh worker.
|
||||
worker.onerror = (e) => {
|
||||
log(`[ngspice] worker error: ${e.message} — resetting service`);
|
||||
failAllPending(`ngspice_service crashed: ${e.message}`);
|
||||
workerP = null;
|
||||
try {
|
||||
worker.terminate();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
};
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onFirst = (e: MessageEvent) => {
|
||||
if (e.data?.ready) {
|
||||
worker.removeEventListener("message", onFirst);
|
||||
resolve();
|
||||
} else if (e.data?.bootError) {
|
||||
reject(new Error(e.data.bootError));
|
||||
}
|
||||
};
|
||||
worker.addEventListener("message", onFirst);
|
||||
});
|
||||
|
||||
log("[ngspice] ngspice_service ready");
|
||||
return worker;
|
||||
})().catch((e) => {
|
||||
workerP = null; // a failed boot must stay retryable
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
return workerP;
|
||||
};
|
||||
|
||||
const request = async (req: NgspiceRequest): Promise<NgspiceResponse> => {
|
||||
let worker: Worker;
|
||||
try {
|
||||
worker = await ensureWorker();
|
||||
} catch (e) {
|
||||
return { error: `ngspice_service unavailable: ${e}` };
|
||||
}
|
||||
|
||||
const id = nextId++;
|
||||
return new Promise<NgspiceResponse>((resolve) => {
|
||||
pending.set(id, resolve);
|
||||
worker.postMessage({ id, req });
|
||||
});
|
||||
};
|
||||
|
||||
globalThis.ngspiceService = { request };
|
||||
log("[ngspice] ngspice_service provider installed (lazy)");
|
||||
}
|
||||
141
web/standalone/src/wasm/ngspice-worker.js
Normal file
141
web/standalone/src/wasm/ngspice-worker.js
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
/*
|
||||
* Worker-side wrapper for the ngspice_service MODULARIZE module — the SINGLE
|
||||
* source of truth for the worker boot, shared verbatim by:
|
||||
* - the standalone app provider (ngspice-service.ts, vite `?raw` import), and
|
||||
* - the e2e harness stub (tests/kicad/utils/ngspice-service.ts, read off disk).
|
||||
*
|
||||
* The host prepends one prelude line to the blob before this file's content:
|
||||
* self.NGSPICE_GLUE_URL = "<absolute URL of ngspice_service.js>";
|
||||
*
|
||||
* Protocol (docs/features/ngspice-split/):
|
||||
* host -> worker { id, req } req.kind: init | circ | command |
|
||||
* get_vec_info | cur_plot | all_plots |
|
||||
* all_vecs | running | cm_input_path
|
||||
* worker -> host { id, res }
|
||||
* worker -> host { evt } unsolicited event stream:
|
||||
* { evt: { kind: "char"|"stat", lines: [...] } } batched console/status
|
||||
* { evt: { kind: "bg", finished: bool } } BGThreadRunning
|
||||
* { evt: { kind: "exit", status, immediate, quit } } ControlledExit
|
||||
* boot: one-shot { ready: true } | { bootError }.
|
||||
*/
|
||||
const GLUE = self.NGSPICE_GLUE_URL;
|
||||
|
||||
self.addEventListener("error", (e) =>
|
||||
console.error("[ngspice_service] worker error:", e.message, e.filename, e.lineno));
|
||||
self.addEventListener("unhandledrejection", (e) =>
|
||||
console.error("[ngspice_service] unhandled rejection:", e.reason));
|
||||
|
||||
importScripts(GLUE);
|
||||
|
||||
const modP = NgspiceService({
|
||||
onAbort: (what) => console.error("[ngspice_service] ABORT:", what),
|
||||
// Same blob-importScripts trick as boot.ts / occ-worker.js: the module's own
|
||||
// pthread children (ngspice's bg_run thread) must boot from a same-origin
|
||||
// script even when the glue lives on a CDN.
|
||||
mainScriptUrlOrBlob: new Blob(
|
||||
["importScripts(" + JSON.stringify(GLUE) + ");"],
|
||||
{ type: "text/javascript" }),
|
||||
// A blob: worker has no http base URL — absolutize every asset path against
|
||||
// the glue's URL or the .wasm fetch dies with "Failed to parse URL".
|
||||
locateFile: (f) => new URL(f, GLUE).href,
|
||||
print: (s) => console.log("[ngspice_service]", s),
|
||||
printErr: (s) => console.warn("[ngspice_service]", s),
|
||||
});
|
||||
|
||||
// --- event stream -----------------------------------------------------------
|
||||
// char/stat lines are batched per microtask: a chatty simulation can emit
|
||||
// thousands of SendChar lines per second, and one postMessage per line would
|
||||
// swamp the editor's main thread. bg/exit events flush the pending batch first
|
||||
// so relative order is preserved.
|
||||
const EVT_CHAR = 0, EVT_STAT = 1, EVT_BG = 2, EVT_EXIT = 3;
|
||||
let pendingLines = null; // { kind, lines } of the open batch
|
||||
let flushQueued = false;
|
||||
|
||||
function flushLines() {
|
||||
flushQueued = false;
|
||||
if (pendingLines) {
|
||||
const batch = pendingLines;
|
||||
pendingLines = null;
|
||||
postMessage({ evt: { kind: batch.kind, lines: batch.lines } });
|
||||
}
|
||||
}
|
||||
|
||||
function onEmit(kind, text, a, b) {
|
||||
if (kind === EVT_CHAR || kind === EVT_STAT) {
|
||||
const k = kind === EVT_CHAR ? "char" : "stat";
|
||||
if (pendingLines && pendingLines.kind !== k) flushLines();
|
||||
if (!pendingLines) pendingLines = { kind: k, lines: [] };
|
||||
pendingLines.lines.push(text);
|
||||
if (!flushQueued) {
|
||||
flushQueued = true;
|
||||
queueMicrotask(flushLines);
|
||||
}
|
||||
return;
|
||||
}
|
||||
flushLines();
|
||||
if (kind === EVT_BG) {
|
||||
postMessage({ evt: { kind: "bg", finished: !!a } });
|
||||
} else if (kind === EVT_EXIT) {
|
||||
postMessage({ evt: { kind: "exit", status: a, immediate: !!(b & 1), quit: !!(b & 2) } });
|
||||
}
|
||||
}
|
||||
|
||||
modP.then((mod) => {
|
||||
mod.ngspiceEmit = onEmit;
|
||||
postMessage({ ready: true });
|
||||
}, (e) => postMessage({ bootError: String(e) }));
|
||||
|
||||
// --- request dispatch -------------------------------------------------------
|
||||
onmessage = async (e) => {
|
||||
const { id, req } = e.data;
|
||||
if (typeof id !== "number") return;
|
||||
let res;
|
||||
const transfer = [];
|
||||
try {
|
||||
const mod = await modP;
|
||||
switch (req.kind) {
|
||||
case "init":
|
||||
res = { ret: mod.init() };
|
||||
break;
|
||||
case "circ":
|
||||
res = { ret: mod.circ(req.lines, req.files ?? []) };
|
||||
break;
|
||||
case "command":
|
||||
res = { ret: mod.command(req.cmd) };
|
||||
break;
|
||||
case "get_vec_info": {
|
||||
const vi = mod.getVecInfo(req.name);
|
||||
// The module returns views over its (shared) heap; copy into fresh
|
||||
// non-shared arrays so they can be transferred out.
|
||||
const real = vi.real ? new Float64Array(vi.real) : null;
|
||||
const comp = vi.comp ? new Float64Array(vi.comp) : null;
|
||||
res = { found: vi.found, vname: vi.vname, vtype: vi.vtype,
|
||||
flags: vi.flags, length: vi.length, real, comp };
|
||||
if (real) transfer.push(real.buffer);
|
||||
if (comp) transfer.push(comp.buffer);
|
||||
break;
|
||||
}
|
||||
case "cur_plot":
|
||||
res = { name: mod.curPlot() };
|
||||
break;
|
||||
case "all_plots":
|
||||
res = { names: mod.allPlots() };
|
||||
break;
|
||||
case "all_vecs":
|
||||
res = { names: mod.allVecs(req.plot) };
|
||||
break;
|
||||
case "running":
|
||||
res = { running: mod.running() };
|
||||
break;
|
||||
case "cm_input_path":
|
||||
mod.cmInputPath(req.path ?? "");
|
||||
res = { ok: true };
|
||||
break;
|
||||
default:
|
||||
res = { error: "ngspice_service: unknown request kind " + req.kind };
|
||||
}
|
||||
} catch (err) {
|
||||
res = { error: "ngspice_service worker: " + err };
|
||||
}
|
||||
postMessage({ id, res }, transfer);
|
||||
};
|
||||
Loading…
Reference in a new issue