feat(sim): Phase 1c F1 — NgSpiceNodeAdapter runs real WASM in Node tests
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) <noreply@anthropic.com>
This commit is contained in:
parent
5d64654668
commit
8db973675a
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -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<void> | 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<void> {
|
||||
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<void> {
|
||||
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<SolveResult> {
|
||||
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<string, SolveVector>();
|
||||
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<void> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, string>;
|
||||
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<NgSpiceEmscriptenModule> | 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<LoadNgSpiceConfig> = {},
|
||||
): Promise<NgSpiceEmscriptenModule> {
|
||||
if (cachedInit) return cachedInit;
|
||||
|
||||
const wasmDir = config.wasmDir ?? defaultWasmDir();
|
||||
const libPath = path.join(wasmDir, 'ngspice-lib.js');
|
||||
|
||||
const moduleConfig: Partial<NgSpiceEmscriptenModule> = {
|
||||
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<NgSpiceEmscriptenModule>((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;
|
||||
}
|
||||
Loading…
Reference in New Issue