From 8db973675abfc606e84e702d026fe0e2c4eba9ee Mon Sep 17 00:00:00 2001 From: davidmonterocrespo24 Date: Fri, 15 May 2026 20:49:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(sim):=20Phase=201c=20F1=20=E2=80=94=20NgSp?= =?UTF-8?q?iceNodeAdapter=20runs=20real=20WASM=20in=20Node=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loads the vendored ngspice-interactive WASM directly in the Vitest Node process, no Web Worker required. Implements the same SolverPort contract as NgSpiceWorkerAdapter, so production code and tests share ONE solver — closing the "no hybrid" gap. loadNgSpiceForNode (Node-only loader): - Reads ngspice-lib.js as text, wraps with a hoisted `var Module = config` so the emscripten singleton picks up our locateFile + callbacks. - Re-wires Module.onRuntimeInitialized to copy closure-local FS / HEAP* into Module._velxio_* (the vendored build doesn't export them via EXPORTED_RUNTIME_METHODS so direct Module.FS triggers an abort accessor). NgSpiceNodeAdapter: - bindApi (cwrap), registerCallbacks (no-op via addFunction), stageFilesystem (recursive mkdir + writeFile of model .cm + spinit), initialiseNgspice (null callback pointers; the build still solves fine without print/data hooks). - loadCircuit writes the netlist to /circuit.spc on the FS and issues `source /circuit.spc` — sidesteps `_malloc` (not exported by this build) that the obvious ngSpice_Circ path would need. - solve() dispatches op/tran/ac, reads requested vectors via ngGet_Vec_Info using the actual struct offsets verified against the live build dump: flags=8, realdata=12, imagdata=16, length=20. - alterSource issues `alter` for incremental re-solves. 5/5 SolverPort contract tests pass against real ngspice: - init idempotent - DC op solves a 100Ω/100Ω divider → V(mid) = 2.5 V exactly - omits requested vectors that don't exist - alterSource changes V1 → V(mid) tracks the new voltage - transient RC charge (τ=1ms) reaches >4.5V after 5τ Next: F2 — migrate the ~22 test files that use eecircuit-engine via `runNetlist` to this adapter. After F2, F3 deletes eecircuit-engine and `SpiceEngine.ts` for good. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/load-ngspice-for-node.test.ts | 48 +++ .../__tests__/ngspice-node-adapter.test.ts | 103 ++++++ .../spice/adapters/NgSpiceNodeAdapter.ts | 293 ++++++++++++++++++ .../spice/adapters/node/loadNgSpiceForNode.ts | 181 +++++++++++ 4 files changed, 625 insertions(+) create mode 100644 frontend/src/__tests__/load-ngspice-for-node.test.ts create mode 100644 frontend/src/__tests__/ngspice-node-adapter.test.ts create mode 100644 frontend/src/simulation/spice/adapters/NgSpiceNodeAdapter.ts create mode 100644 frontend/src/simulation/spice/adapters/node/loadNgSpiceForNode.ts diff --git a/frontend/src/__tests__/load-ngspice-for-node.test.ts b/frontend/src/__tests__/load-ngspice-for-node.test.ts new file mode 100644 index 00000000..7b167bfc --- /dev/null +++ b/frontend/src/__tests__/load-ngspice-for-node.test.ts @@ -0,0 +1,48 @@ +/** + * Phase 1c F1 smoke test — does the vendored WASM load cleanly in + * the Vitest Node process? If this fails, the entire Node adapter + * path is dead and we have to rethink F1. + */ +import { describe, it, expect } from 'vitest'; +import { + loadNgSpiceForNode, + __resetNgSpiceForTests, +} from '../simulation/spice/adapters/node/loadNgSpiceForNode'; + +describe('Phase 1c F1 — Node WASM loader', () => { + it( + 'boots the ngspice emscripten module and exposes cwrap', + { timeout: 30_000 }, + async () => { + __resetNgSpiceForTests(); + const stdout: string[] = []; + const stderr: string[] = []; + const Module = await loadNgSpiceForNode({ + onStdout: (t) => stdout.push(t), + onStderr: (t) => stderr.push(t), + }); + expect(typeof Module.cwrap).toBe('function'); + expect(typeof Module.addFunction).toBe('function'); + expect(typeof Module.UTF8ToString).toBe('function'); + // The vendored build doesn't export FS; the loader exposes it + // via _velxio_fs to esquivar the abort accessor. + expect(Module._velxio_fs).toBeDefined(); + expect(typeof Module._velxio_fs?.writeFile).toBe('function'); + }, + ); + + it('cwrap binds ngSpice_Reset (a known no-arg ngspice symbol)', async () => { + const Module = await loadNgSpiceForNode(); + const reset = Module.cwrap('ngSpice_Reset', 'number', []); + expect(typeof reset).toBe('function'); + }); + + it('addFunction is callable and returns a function-table pointer', async () => { + const Module = await loadNgSpiceForNode(); + expect(typeof Module.addFunction).toBe('function'); + const noop = () => 0; + const ptr = Module.addFunction(noop, 'iiii'); + expect(typeof ptr).toBe('number'); + expect(ptr).toBeGreaterThan(0); + }); +}); diff --git a/frontend/src/__tests__/ngspice-node-adapter.test.ts b/frontend/src/__tests__/ngspice-node-adapter.test.ts new file mode 100644 index 00000000..f68e0c9e --- /dev/null +++ b/frontend/src/__tests__/ngspice-node-adapter.test.ts @@ -0,0 +1,103 @@ +/** + * NgSpiceNodeAdapter integration test — runs the SAME SolverPort + * contract suite as solver-port-contract.test.ts but against the + * real WASM running in the Vitest Node process. + * + * If this passes, we have proof that production code and tests can + * share a single solver path (the "no hybrid" rule). + * + * Test cases include a real DC operating-point solve so we know + * the netlist pipeline + readVec extraction actually work. + */ +import { describe, it, expect, afterAll } from 'vitest'; +import { NgSpiceNodeAdapter } from '../simulation/spice/adapters/NgSpiceNodeAdapter'; + +const adapter = new NgSpiceNodeAdapter(); + +afterAll(() => { + adapter.dispose(); +}); + +describe('NgSpiceNodeAdapter — SolverPort contract on real WASM', () => { + it('init is idempotent', { timeout: 30_000 }, async () => { + await adapter.init(); + await adapter.init(); + // No throw. + }); + + it( + 'loads + solves an .op on a simple divider, returns the expected v(net)', + { timeout: 30_000 }, + async () => { + const netlist = [ + '* simple divider', + 'V1 1 0 DC 5', + 'R1 1 mid 100', + 'R2 mid 0 100', + '.end', + ].join('\n'); + await adapter.loadCircuit(netlist); + const result = await adapter.solve( + { kind: 'op' }, + { vectorsOfInterest: ['v(1)', 'v(mid)'] }, + ); + expect(result.vectors.size).toBeGreaterThanOrEqual(2); + expect(result.vectors.get('v(1)')?.real[0]).toBeCloseTo(5, 3); + expect(result.vectors.get('v(mid)')?.real[0]).toBeCloseTo(2.5, 3); + }, + ); + + it( + 'omits missing vectors silently', + { timeout: 30_000 }, + async () => { + const result = await adapter.solve( + { kind: 'op' }, + { vectorsOfInterest: ['v(1)', 'v(does_not_exist)'] }, + ); + expect(result.vectors.has('v(1)')).toBe(true); + expect(result.vectors.has('v(does_not_exist)')).toBe(false); + }, + ); + + it( + 'alterSource updates a V source and the next solve sees the new value', + { timeout: 30_000 }, + async () => { + // Same divider as before — V1 is still loaded. + await adapter.alterSource('V1', 10); + const result = await adapter.solve( + { kind: 'op' }, + { vectorsOfInterest: ['v(mid)'] }, + ); + expect(result.vectors.get('v(mid)')?.real[0]).toBeCloseTo(5, 3); + }, + ); + + it( + 'transient analysis returns a time axis + samples for an RC step', + { timeout: 30_000 }, + async () => { + // Single step at t=0 — uic forces V_cap(0)=0, then DC source + // charges through R into C. τ = R·C = 1k·1u = 1 ms. + const netlist = [ + '* RC charge from a DC step', + 'V1 1 0 DC 5', + 'R1 1 mid 1k', + 'C1 mid 0 1u IC=0', + '.end', + ].join('\n'); + await adapter.loadCircuit(netlist); + const result = await adapter.solve( + { kind: 'tran', step: '1e-5', stop: '5e-3' }, + { vectorsOfInterest: ['v(mid)'] }, + ); + expect(result.timeAxis.length).toBeGreaterThan(10); + expect(result.vectors.get('v(mid)')?.real.length).toBe(result.timeAxis.length); + // After 5τ, vmid ≈ 0.993·5 ≈ 4.97 V. + const lastV = result.vectors.get('v(mid)')?.real.at(-1) ?? 0; + expect(lastV).toBeGreaterThan(4.5); + expect(lastV).toBeLessThan(5.1); + }, + ); +}); diff --git a/frontend/src/simulation/spice/adapters/NgSpiceNodeAdapter.ts b/frontend/src/simulation/spice/adapters/NgSpiceNodeAdapter.ts new file mode 100644 index 00000000..6cdfe3ab --- /dev/null +++ b/frontend/src/simulation/spice/adapters/NgSpiceNodeAdapter.ts @@ -0,0 +1,293 @@ +/** + * NgSpiceNodeAdapter — SolverPort implementation that runs the + * vendored ngspice WASM directly in the Vitest Node process, no + * Web Worker required. + * + * Architecture: + * loadNgSpiceForNode → emscripten Module (with _velxio_fs et al. + * patched on) + * ↓ + * stageFilesystem — write the .cm files + spinit to the WASM FS + * ↓ + * bindApi — Module.cwrap the C functions we need + * ↓ + * initialiseNgspice — register callbacks, call ngSpice_Init, + * set xspice, source /spinit + * + * Once initialised, SolverPort methods translate domain calls to + * ngspice commands and read results via ngGet_Vec_Info. + * + * Concurrency: ngspice C API is not thread-safe; this adapter + * serialises every call. Vitest's worker-per-test-file isolation + * means each test file gets a fresh process and adapter. + */ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { + SolverPort, + SolveAnalysis, + SolveResult, + SolveOptions, + SolveVector, +} from '../ports/SolverPort'; +import { + loadNgSpiceForNode, + type NgSpiceEmscriptenModule, +} from './node/loadNgSpiceForNode'; + +const MODEL_FILES = [ + 'analog.cm', + 'digital.cm', + 'spice2poly.cm', + 'table.cm', + 'tlines.cm', + 'xtradev.cm', + 'xtraevt.cm', +]; + +// ngspice vector_info struct field offsets (32-bit pointers). +// Confirmed against the live struct dump from the vendored build. +const VECTOR_INFO_FLAGS_OFFSET = 8; +const VECTOR_INFO_REALDATA_OFFSET = 12; +const VECTOR_INFO_IMAGDATA_OFFSET = 16; +const VECTOR_INFO_LENGTH_OFFSET = 20; +const VECTOR_FLAG_COMPLEX = 1 << 1; + +interface NgSpiceApi { + init: ( + print: number, + status: number, + exit: number, + data: number, + dataInit: number, + bg: number, + arg: number, + ) => number; + command: (cmd: string) => number; + circ: (linesPtr: number) => number; + allVecs: (plot: string) => number; + curPlot: () => string; + getVecInfo: (name: string) => number; + reset: () => number; + nospiceinit: () => number; + setInputPath: (path: string) => number; +} + +function defaultWasmDir(): string { + const here = fileURLToPath(import.meta.url); + return path.resolve(here, '../../../../../public/wasm/ngspice-interactive'); +} + +export class NgSpiceNodeAdapter implements SolverPort { + private module: NgSpiceEmscriptenModule | null = null; + private api: NgSpiceApi | null = null; + private initialised = false; + private initPromise: Promise | null = null; + private readonly wasmDir: string; + private stdoutBuffer: string[] = []; + private stderrBuffer: string[] = []; + private callbackPointers: number[] = []; + + constructor(opts: { wasmDir?: string } = {}) { + this.wasmDir = opts.wasmDir ?? defaultWasmDir(); + } + + async init(): Promise { + if (this.initialised) return; + if (this.initPromise) return this.initPromise; + this.initPromise = (async () => { + this.module = await loadNgSpiceForNode({ + wasmDir: this.wasmDir, + onStdout: (text) => this.stdoutBuffer.push(text), + onStderr: (text) => this.stderrBuffer.push(text), + }); + this.bindApi(); + this.registerCallbacks(); + this.stageFilesystem(); + this.initialiseNgspice(); + this.initialised = true; + })(); + return this.initPromise; + } + + private bindApi(): void { + if (!this.module) throw new Error('Module not ready'); + const M = this.module; + this.api = { + init: M.cwrap('ngSpice_Init', 'number', [ + 'number', 'number', 'number', 'number', 'number', 'number', 'number', + ]), + command: M.cwrap('ngSpice_Command', 'number', ['string']), + circ: M.cwrap('ngSpice_Circ', 'number', ['number']), + allVecs: M.cwrap('ngSpice_AllVecs', 'number', ['string']), + curPlot: M.cwrap('ngSpice_CurPlot', 'string', []), + getVecInfo: M.cwrap('ngGet_Vec_Info', 'number', ['string']), + reset: M.cwrap('ngSpice_Reset', 'number', []), + nospiceinit: M.cwrap('ngSpice_nospiceinit', 'number', []), + setInputPath: M.cwrap('ngCM_Input_Path', 'number', ['string']), + }; + } + + private registerCallbacks(): void { + if (!this.module) throw new Error('Module not ready'); + const M = this.module; + // ngspice C callback signatures (from sharedspice.h): + // SendChar(char*, int, void*) → 4 ints (i ptr, i id, ptr) but emscripten counts ptrs as i + // SendStat(char*, int, void*) → iiii + // ControlledExit(int, bool, bool, int, void*) → iiiiii + // SendData(vecvaluesall*, int, int, void*) → iiiii + // SendInitData(vecinfoall*, int, void*) → iiii + // BGThreadRunning(bool, int, void*) → iiii + const noop = () => 0; + this.callbackPointers = [ + M.addFunction(noop, 'iiii'), // print + M.addFunction(noop, 'iiii'), // status + M.addFunction(noop, 'iiiiii'), // exit + M.addFunction(noop, 'iiiii'), // data + M.addFunction(noop, 'iiii'), // dataInit + M.addFunction(noop, 'iiii'), // bg + ]; + } + + private stageFilesystem(): void { + const M = this.module; + if (!M || !M._velxio_fs) throw new Error('Module FS not ready'); + const FS = M._velxio_fs; + // Recursive mkdir — emscripten FS.mkdir is one level at a time + // and the default mounted root only has `/`. + const ensureDir = (full: string): void => { + const parts = full.split('/').filter((p) => p.length > 0); + let cur = ''; + for (const p of parts) { + cur += '/' + p; + try { FS.mkdir(cur); } catch { /* exists */ } + } + }; + ensureDir('/usr/local/lib/ngspice'); + ensureDir('/usr/local/share/ngspice/scripts'); + + for (const name of MODEL_FILES) { + const data = readFileSync(path.join(this.wasmDir, name)); + FS.writeFile(`/usr/local/lib/ngspice/${name}`, new Uint8Array(data)); + } + const spinit = readFileSync(path.join(this.wasmDir, 'spinit'), 'utf8'); + FS.writeFile('/usr/local/share/ngspice/scripts/spinit', spinit); + FS.writeFile('/spinit', spinit); + } + + private initialiseNgspice(): void { + if (!this.api) throw new Error('API not ready'); + this.api.nospiceinit(); + // Pass 0 (null) for every callback — ngspice tolerates null and + // skips the corresponding event. This sidesteps a + // "function signature mismatch" we hit when feeding addFunction + // pointers; investigate the worker's exact emscripten build + // settings later if we need real callbacks (progress / stderr). + const rc = this.api.init(0, 0, 0, 0, 0, 0, 0); + if (rc !== 0) throw new Error(`ngSpice_Init returned ${rc}`); + this.api.setInputPath('/'); + this.api.command('set xspice_enabled'); + this.api.command('source /spinit'); + } + + async loadCircuit(netlist: string): Promise { + await this.init(); + if (!this.module || !this.api || !this.module._velxio_fs) throw new Error('not ready'); + // The build doesn't export `_malloc`, so we can't construct the + // char** the `ngSpice_Circ` API needs from JS. Workaround: write + // the netlist to the virtual FS and ask ngspice to `source` it. + this.module._velxio_fs.writeFile('/circuit.spc', netlist); + const rc = this.api.command('source /circuit.spc'); + if (rc !== 0) throw new Error(`source /circuit.spc returned ${rc}`); + } + + async solve(analysis: SolveAnalysis, options: SolveOptions): Promise { + await this.init(); + if (!this.api) throw new Error('not ready'); + const t0 = performance.now(); + this.stderrBuffer.length = 0; + this.stdoutBuffer.length = 0; + + // ngspice has two ways to run an analysis: + // .control / .endc block in the netlist (ran by 'source') + // bare `op` / `tran ...` / `ac ...` commands after sourcing + // Bare commands often fail silently in interactive mode unless + // a circuit is "current"; the safer pattern is to inject the + // analysis directive INTO the netlist before source. But since + // the source already ran, we issue `run` which runs whatever + // last `.tran` / `.op` directive was in the netlist + falls + // back to our manual analysis card. + const cmd = (() => { + switch (analysis.kind) { + case 'op': return 'op'; + case 'tran': return `tran ${analysis.step} ${analysis.stop}`; + case 'ac': return `ac ${analysis.sweep} ${analysis.points} ${analysis.fstart} ${analysis.fstop}`; + } + })(); + const rc = this.api.command(cmd); + if (rc !== 0) throw new Error(`ngspice command '${cmd}' returned ${rc}`); + + + const vectors = new Map(); + let timeAxis: Float64Array = new Float64Array(0); + + for (const name of options.vectorsOfInterest) { + const vec = this.readVector(name); + if (vec) vectors.set(name.toLowerCase(), vec); + } + if (analysis.kind === 'tran') { + const t = this.readVector('time'); + if (t) timeAxis = t.real; + } + + const solveMs = performance.now() - t0; + return { + analysis, + vectors, + timeAxis, + solveMs, + warnings: [...this.stderrBuffer, ...this.stdoutBuffer.map((s) => `stdout: ${s}`)], + }; + } + + async alterSource(name: string, dcValue: number): Promise { + await this.init(); + if (!this.api) throw new Error('not ready'); + this.api.command(`alter ${name} dc ${dcValue}`); + } + + dispose(): void { + if (this.api) { + try { this.api.reset(); } catch { /* ignore */ } + } + this.initialised = false; + this.initPromise = null; + } + + private readVector(name: string): SolveVector | null { + if (!this.module || !this.api) return null; + const M = this.module; + let infoPtr = this.api.getVecInfo(name); + if (!infoPtr && !name.includes('.')) { + const plot = this.api.curPlot(); + if (plot) infoPtr = this.api.getVecInfo(`${plot}.${name}`); + } + if (!infoPtr) return null; + const heap32 = M._velxio_heap32; + const heapu32 = M._velxio_heapu32; + const heapf64 = M._velxio_heapf64; + if (!heap32 || !heapu32 || !heapf64) return null; + const realDataPtr = heapu32[(infoPtr + VECTOR_INFO_REALDATA_OFFSET) >> 2]!; + const imagDataPtr = heapu32[(infoPtr + VECTOR_INFO_IMAGDATA_OFFSET) >> 2]!; + const length = heap32[(infoPtr + VECTOR_INFO_LENGTH_OFFSET) >> 2]!; + const flags = heap32[(infoPtr + VECTOR_INFO_FLAGS_OFFSET) >> 2]!; + const complex = (flags & VECTOR_FLAG_COMPLEX) !== 0; + if (!realDataPtr || length <= 0) return null; + const real = new Float64Array(heapf64.buffer, realDataPtr, length).slice(); + const imag = complex && imagDataPtr + ? new Float64Array(heapf64.buffer, imagDataPtr, length).slice() + : null; + return { name: name.toLowerCase(), real, imag }; + } +} diff --git a/frontend/src/simulation/spice/adapters/node/loadNgSpiceForNode.ts b/frontend/src/simulation/spice/adapters/node/loadNgSpiceForNode.ts new file mode 100644 index 00000000..1d908728 --- /dev/null +++ b/frontend/src/simulation/spice/adapters/node/loadNgSpiceForNode.ts @@ -0,0 +1,181 @@ +/** + * loadNgSpiceForNode — runtime loader for the vendored ngspice + * emscripten module in a Node (Vitest) process. + * + * The vendored `ngspice-lib.js` is an emscripten singleton build + * (not MODULARIZE), so the obvious `require('./ngspice-lib.js')` + * pattern doesn't pass a config to the Module factory. The CJS + * module evaluates `var Module = typeof Module != 'undefined' ? + * Module : {}` in its own scope, which can't see + * `globalThis.Module`. + * + * Workaround: read the source as text, wrap with a hoisted + * `var Module = config;` so the emscripten guard finds Module + * already defined (var hoisting + non-redeclaration semantics make + * the two `var Module` lines refer to the same variable), and + * evaluate via `new Function`. Result: our `config` survives and + * `locateFile` + callbacks work as expected. + * + * Only used in Node test contexts. The browser path + * (`NgSpiceWorkerAdapter`) goes through the Web Worker which evaluates + * the lib in its own scope. + */ +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * Subset of the emscripten Module surface we use. The vendored + * build adds plenty more, but we only depend on these. + */ +export interface NgSpiceEmscriptenModule { + cwrap: ( + name: string, + returnType: string | null, + argTypes: readonly string[], + ) => (...args: unknown[]) => unknown; + addFunction: (fn: (...args: unknown[]) => unknown, signature: string) => number; + removeFunction: (ptr: number) => void; + UTF8ToString: (ptr: number) => string; + stringToUTF8: (str: string, ptr: number, max: number) => void; + lengthBytesUTF8: (str: string) => number; + _malloc: (bytes: number) => number; + _free: (ptr: number) => void; + _velxio_heap8?: Int8Array; + _velxio_heap32?: Int32Array; + _velxio_heapu32?: Uint32Array; + _velxio_heapf64?: Float64Array; + /** + * Note: the vendored build does NOT include `FS` in + * EXPORTED_RUNTIME_METHODS, so `Module.FS` triggers an abort + * accessor on this build. We expose the closure-captured locals + * via the `_velxio_*` namespace instead — see the wrapper in + * loadNgSpiceForNode. + */ + _velxio_fs?: { + writeFile: (path: string, data: Uint8Array | string) => void; + mkdir: (path: string) => void; + analyzePath: (path: string) => { exists: boolean }; + }; + _velxio_env?: Record; + noInitialRun: boolean; + print?: (text: string) => void; + printErr?: (text: string) => void; + locateFile?: (path: string) => string; + onRuntimeInitialized?: () => void; +} + +export interface LoadNgSpiceConfig { + /** Directory containing ngspice-lib.{js,wasm} + the .cm files. */ + wasmDir: string; + /** Optional stdout sink (one line at a time). */ + onStdout?: (text: string) => void; + /** Optional stderr sink. */ + onStderr?: (text: string) => void; +} + +function defaultWasmDir(): string { + // Resolve relative to this file's package location: + // frontend/src/simulation/spice/adapters/node/loadNgSpiceForNode.ts + // → frontend/public/wasm/ngspice-interactive + const here = fileURLToPath(import.meta.url); + return path.resolve(here, '../../../../../../public/wasm/ngspice-interactive'); +} + +let cachedModule: NgSpiceEmscriptenModule | null = null; +let cachedInit: Promise | null = null; + +/** + * Load the ngspice WASM module in the current Node process. Returns + * the initialised Module on success. + * + * Caches the singleton — calling twice returns the same Module. + * emscripten singleton builds can't be re-initialised within a + * process, so dispose() is best-effort (resets ngspice state via + * `ngSpice_Reset`, doesn't free the WASM). + */ +export function loadNgSpiceForNode( + config: Partial = {}, +): Promise { + if (cachedInit) return cachedInit; + + const wasmDir = config.wasmDir ?? defaultWasmDir(); + const libPath = path.join(wasmDir, 'ngspice-lib.js'); + + const moduleConfig: Partial = { + noInitialRun: true, + locateFile: (filename) => { + // Resolve all asset files (.wasm, .cm, etc.) relative to wasmDir. + return path.join(wasmDir, filename); + }, + print: (text: string) => config.onStdout?.(text), + printErr: (text: string) => config.onStderr?.(text), + }; + + const src = readFileSync(libPath, 'utf8'); + // Wrapping strategy: + // 1. Hoisted `var Module = config` so the emscripten body's + // `var Module = typeof Module != 'undefined' ? Module : {}` + // finds Module already defined and keeps our config. + // 2. After the emscripten body runs, FS / HEAP* are bound to + // `var` declarations inside the same closure. We re-wire + // `Module.onRuntimeInitialized` to ALSO copy those locals + // onto Module so external code can read them via + // `Module.FS`, `Module.HEAPU32`, etc. The build doesn't + // include FS in EXPORTED_RUNTIME_METHODS so this is the + // only path that doesn't require recompiling. + const wrapped = ` + var Module = config; + ${src} + var __velxioOriginalInit = Module.onRuntimeInitialized; + Module.onRuntimeInitialized = function () { + // Build does not export FS / HEAP* in EXPORTED_RUNTIME_METHODS; + // those keys on Module trigger abort accessors. Assign to the + // _velxio_ namespace instead and the NgSpiceNodeAdapter reads + // from there. + if (typeof FS !== 'undefined') Module._velxio_fs = FS; + if (typeof HEAP8 !== 'undefined') Module._velxio_heap8 = HEAP8; + if (typeof HEAP32 !== 'undefined') Module._velxio_heap32 = HEAP32; + if (typeof HEAPU32 !== 'undefined') Module._velxio_heapu32 = HEAPU32; + if (typeof HEAPF64 !== 'undefined') Module._velxio_heapf64 = HEAPF64; + if (typeof ENV !== 'undefined') Module._velxio_env = ENV; + if (__velxioOriginalInit) __velxioOriginalInit(); + }; + return Module; + `; + const factory = new Function('config', 'require', '__dirname', '__filename', wrapped) as ( + config: unknown, + req: typeof require, + dirname: string, + filename: string, + ) => NgSpiceEmscriptenModule; + + const req = createRequire(import.meta.url); + + cachedInit = new Promise((resolve, reject) => { + moduleConfig.onRuntimeInitialized = () => { + cachedModule = moduleConfig as NgSpiceEmscriptenModule; + resolve(cachedModule); + }; + try { + factory(moduleConfig, req, wasmDir, libPath); + } catch (err) { + reject(err instanceof Error ? err : new Error(String(err))); + } + setTimeout(() => reject(new Error('ngspice WASM init timeout (30s)')), 30_000); + }); + + return cachedInit; +} + +/** + * Reset the cached module reference (NOT free the WASM — that's not + * supported by emscripten singletons). Subsequent `loadNgSpiceForNode` + * calls will re-init — which only works if the previous Module was + * reset via `ngSpice_Reset`. Best-effort. + */ +export function __resetNgSpiceForTests(): void { + cachedModule = null; + cachedInit = null; +}