velxio/test/test_custom_chips/src/ChipRuntime.js

525 lines
18 KiB
JavaScript

/**
* ChipRuntime — Loads a Velxio custom-chip WASM, wires its imports to the
* host services (PinManager, I2CBus, attribute storage, timer queue), and
* dispatches its callbacks back into the simulator.
*
* Each ChipInstance owns:
* - its own WebAssembly.Memory
* - its own WebAssembly.Instance
* - its own logical-pin → real-arduino-pin wiring map
* - its own attribute and timer registries
*
* Lifecycle:
* const inst = await ChipInstance.create({ wasm, pinManager, i2cBus, wires, attrs, simNanos });
* inst.start(); // calls chip_setup
* ... simulation runs ...
* inst.tickTimers(now);
* inst.dispose();
*/
import { WasiShim } from './WasiShim.js';
import { SPIDevice } from './SPIBus.js';
/** Decode a NUL-terminated C string from linear memory. */
function readCString(memory, ptr) {
const u8 = new Uint8Array(memory.buffer);
let end = ptr;
while (end < u8.length && u8[end] !== 0) end++;
return new TextDecoder().decode(u8.subarray(ptr, end));
}
/**
* Layouts must match velxio-chip.h. Static asserts in the header guard them
* on the chip side; the test `04_runtime_imports` verifies the host side.
*
* vx_i2c_config: 64 bytes (with reserved[8]).
*/
function readI2CConfig(memory, ptr) {
const dv = new DataView(memory.buffer);
return {
address: dv.getUint8(ptr + 0),
scl: dv.getInt32(ptr + 4, true),
sda: dv.getInt32(ptr + 8, true),
on_connect: dv.getUint32(ptr + 12, true),
on_read: dv.getUint32(ptr + 16, true),
on_write: dv.getUint32(ptr + 20, true),
on_stop: dv.getUint32(ptr + 24, true),
user_data: dv.getUint32(ptr + 28, true),
};
}
/** vx_uart_config: 56 bytes (with reserved[8]). */
function readUartConfig(memory, ptr) {
const dv = new DataView(memory.buffer);
return {
rx: dv.getInt32(ptr + 0, true),
tx: dv.getInt32(ptr + 4, true),
baud_rate: dv.getUint32(ptr + 8, true),
on_rx_byte: dv.getUint32(ptr + 12, true),
on_tx_done: dv.getUint32(ptr + 16, true),
user_data: dv.getUint32(ptr + 20, true),
};
}
/** vx_spi_config: 60 bytes (with reserved[8]). */
function readSpiConfig(memory, ptr) {
const dv = new DataView(memory.buffer);
return {
sck: dv.getInt32(ptr + 0, true),
mosi: dv.getInt32(ptr + 4, true),
miso: dv.getInt32(ptr + 8, true),
cs: dv.getInt32(ptr + 12, true),
mode: dv.getUint32(ptr + 16, true),
on_done: dv.getUint32(ptr + 20, true),
user_data: dv.getUint32(ptr + 24, true),
};
}
export class ChipInstance {
/**
* @param {object} opts
* @param {WebAssembly.Module|ArrayBuffer|Uint8Array} opts.wasm
* @param {import('./PinManager.js').PinManager} opts.pinManager
* @param {import('./I2CBus.js').I2CBus|null} [opts.i2cBus]
* @param {Map<string, number>} [opts.wires] logical pin name → arduino pin number
* @param {Map<string, number>} [opts.attrs] attribute name → numeric value
* @param {() => bigint | number} [opts.simNanos]
* @param {(text: string) => void} [opts.log]
*/
static async create(opts) {
const inst = new ChipInstance(opts);
await inst._instantiate();
return inst;
}
constructor({ wasm, pinManager, i2cBus = null, spiBus = null, wires = new Map(), attrs = new Map(), simNanos, log }) {
this.wasm = wasm;
this.pinManager = pinManager;
this.i2cBus = i2cBus;
this.spiBus = spiBus;
this.wires = wires;
this.attrs = attrs;
// Per-instance state
this.memory = null;
this.instance = null;
this.exports = null;
this.disposed = false;
// Logical pin registry: handle → { name, mode, arduinoPin|null }
this.pins = [];
// Attribute registry: handle → { name, default }
this.attrHandles = [];
// Per-pin watch unsubscribers (keyed by chip-pin handle)
this._pinWatches = new Map();
// Timer queue
this.timers = [];
// UART devices
this.uarts = [];
// Listener that receives bytes the chip writes via uart_write
this._uartTxListener = null;
// SPI devices (each is an SPIDevice + bookkeeping)
this.spiDevices = [];
// WASI shim — `log` is forwarded as-is. WasiShim already prefixes its
// own writes via writeStdout, so we don't double-prefix here.
this.wasi = new WasiShim(
simNanos ?? (() => 0n),
log ?? ((s) => process.stdout.write(s)),
);
// Build the import object for the chip API.
this._velxioImports = this._buildVelxioImports();
}
async _instantiate() {
this.memory = new WebAssembly.Memory({ initial: 2, maximum: 16 });
this.wasi.setMemory(this.memory);
const importObject = {
env: {
memory: this.memory,
...this._velxioImports,
},
...this.wasi.imports(),
};
let module;
if (this.wasm instanceof WebAssembly.Module) {
module = this.wasm;
} else {
module = await WebAssembly.compile(this.wasm);
}
// Strip imports we don't provide so we can give a clean error.
const expected = WebAssembly.Module.imports(module);
const missing = [];
for (const imp of expected) {
const ns = importObject[imp.module];
if (!ns || ns[imp.name] === undefined) {
missing.push(`${imp.module}.${imp.name}`);
}
}
if (missing.length) {
throw new Error(
`Chip WASM imports missing in host:\n - ${missing.join('\n - ')}\n` +
`If these are WASI calls, extend WasiShim. If they're chip API, extend ChipRuntime.`,
);
}
this.instance = await WebAssembly.instantiate(module, importObject);
this.exports = this.instance.exports;
}
/** Run the chip's `chip_setup` once. */
start() {
if (!this.exports.chip_setup) {
throw new Error('Chip WASM does not export chip_setup');
}
this.exports.chip_setup();
this.wasi.flush();
}
/**
* Drive timer callbacks whose deadline has arrived.
* Should be called periodically from the simulator step loop.
* @param {bigint|number} nowNanos
*/
tickTimers(nowNanos) {
const now = BigInt(nowNanos);
const table = this.exports.__indirect_function_table;
if (!table) return;
for (const t of this.timers) {
if (!t.active) continue;
while (t.active && now >= t.nextFire) {
const fn = table.get(t.cbIdx);
try { fn(t.userData); } catch (e) { /* noop */ }
if (t.repeat) {
t.nextFire += t.period;
} else {
t.active = false;
}
}
}
this.wasi.flush();
}
dispose() {
if (this.disposed) return;
for (const set of this._pinWatches.values()) {
for (const u of set) u();
}
this._pinWatches.clear();
this.timers = [];
if (this.i2cBus && this._i2cAddress != null) {
this.i2cBus.removeDevice(this._i2cAddress);
}
if (this.spiBus) {
for (const d of this.spiDevices) this.spiBus.removeDevice(d.device);
}
this.spiDevices = [];
this.disposed = true;
}
// ── velxio-chip imports ──────────────────────────────────────────────────
_buildVelxioImports() {
return {
// Pins
vx_pin_register: (namePtr, mode) => this._pin_register(namePtr, mode),
vx_pin_read: (handle) => this._pin_read(handle),
vx_pin_write: (handle, value) => this._pin_write(handle, value),
vx_pin_read_analog: (handle) => this._pin_read_analog(handle),
vx_pin_dac_write: (handle, voltage) => this._pin_dac_write(handle, voltage),
vx_pin_set_mode: (handle, mode) => this._pin_mode(handle, mode),
vx_pin_watch: (handle, edge, cbIdx, userData) =>
this._pin_watch(handle, edge, cbIdx, userData),
vx_pin_watch_stop: (handle) => this._pin_watch_stop(handle),
// Attributes
vx_attr_register: (namePtr, defaultVal) => this._attr_register(namePtr, defaultVal),
vx_attr_read: (handle) => this._attr_read(handle),
// I2C
vx_i2c_attach: (cfgPtr) => this._i2c_attach(cfgPtr),
// UART
vx_uart_attach: (cfgPtr) => this._uart_attach(cfgPtr),
vx_uart_write: (handle, bufPtr, count) => this._uart_write(handle, bufPtr, count),
// SPI
vx_spi_attach: (cfgPtr) => this._spi_attach(cfgPtr),
vx_spi_start: (handle, bufPtr, count) => this._spi_start(handle, bufPtr, count),
vx_spi_stop: (handle) => this._spi_stop(handle),
// Time + timers
vx_sim_now_nanos: () => BigInt(this.wasi.simNanos()),
vx_timer_create: (cbIdx, userData) => this._timer_create(cbIdx, userData),
vx_timer_start: (handle, period, repeat) => this._timer_start(handle, period, repeat),
vx_timer_stop: (handle) => this._timer_stop(handle),
// Logging
vx_log: (msgPtr) => {
const msg = readCString(this.memory, msgPtr);
this.wasi.writeStdout(`[chip] ${msg}\n`);
},
};
}
// ── Pin implementations ──────────────────────────────────────────────────
/** Pin mode constants — must match velxio-chip.h. */
static MODE_OUTPUT_LOW = 16;
static MODE_OUTPUT_HIGH = 17;
_pin_register(namePtr, mode) {
const name = readCString(this.memory, namePtr);
const handle = this.pins.length;
const arduinoPin = this.wires.has(name) ? this.wires.get(name) : null;
this.pins.push({ name, mode, arduinoPin });
// Initialize the wired PinManager pin if the mode requires a starting level.
if (arduinoPin != null) {
if (mode === ChipInstance.MODE_OUTPUT_LOW) this.pinManager.triggerPinChange(arduinoPin, false);
if (mode === ChipInstance.MODE_OUTPUT_HIGH) this.pinManager.triggerPinChange(arduinoPin, true);
}
return handle;
}
_pin_read(handle) {
const p = this.pins[handle];
if (!p || p.arduinoPin == null) return 0;
return this.pinManager.getPinState(p.arduinoPin) ? 1 : 0;
}
_pin_write(handle, value) {
const p = this.pins[handle];
if (!p || p.arduinoPin == null) return;
this.pinManager.triggerPinChange(p.arduinoPin, value !== 0);
}
_pin_read_analog(handle) {
const p = this.pins[handle];
if (!p || p.arduinoPin == null) return 0.0;
return this.pinManager.getPwmValue(p.arduinoPin) * 5.0;
}
_pin_dac_write(handle, voltage) {
const p = this.pins[handle];
if (!p || p.arduinoPin == null) return;
// Drive an analog voltage on the pin; route through PinManager.setAnalogVoltage
// which existing tests/components subscribe to via onAnalogChange.
this.pinManager.setAnalogVoltage(p.arduinoPin, voltage);
}
_pin_mode(handle, mode) {
const p = this.pins[handle];
if (!p) return;
p.mode = mode;
if (p.arduinoPin != null) {
if (mode === ChipInstance.MODE_OUTPUT_LOW) this.pinManager.triggerPinChange(p.arduinoPin, false);
if (mode === ChipInstance.MODE_OUTPUT_HIGH) this.pinManager.triggerPinChange(p.arduinoPin, true);
}
}
_pin_watch(handle, edge, cbIdx, userData) {
const p = this.pins[handle];
if (!p || p.arduinoPin == null) return;
let lastState = this.pinManager.getPinState(p.arduinoPin) ? 1 : 0;
const unsub = this.pinManager.onPinChange(p.arduinoPin, (_pin, state) => {
const newState = state ? 1 : 0;
const isRising = lastState === 0 && newState === 1;
const isFalling = lastState === 1 && newState === 0;
lastState = newState;
const wantRising = (edge & 1) !== 0;
const wantFalling = (edge & 2) !== 0;
if ((isRising && wantRising) || (isFalling && wantFalling)) {
const table = this.exports.__indirect_function_table;
if (!table) return;
const fn = table.get(cbIdx);
try { fn(userData, handle, newState); } catch (e) { /* swallow chip errors */ }
this.wasi.flush();
}
});
if (!this._pinWatches.has(handle)) this._pinWatches.set(handle, new Set());
this._pinWatches.get(handle).add(unsub);
}
_pin_watch_stop(handle) {
const set = this._pinWatches.get(handle);
if (!set) return;
for (const u of set) u();
this._pinWatches.delete(handle);
}
// ── Attributes ───────────────────────────────────────────────────────────
_attr_register(namePtr, defaultVal) {
const name = readCString(this.memory, namePtr);
const handle = this.attrHandles.length;
this.attrHandles.push({ name, default: defaultVal });
if (!this.attrs.has(name)) this.attrs.set(name, defaultVal);
return handle;
}
_attr_read(handle) {
const a = this.attrHandles[handle];
if (!a) return 0.0;
return this.attrs.get(a.name) ?? a.default;
}
// ── I2C ──────────────────────────────────────────────────────────────────
_i2c_attach(cfgPtr) {
if (!this.i2cBus) {
throw new Error('Chip called vx_i2c_attach but no I2CBus is wired to the host');
}
const cfg = readI2CConfig(this.memory, cfgPtr);
const table = this.exports.__indirect_function_table;
const callFn = (idx, ...args) => {
const fn = table.get(idx);
try { return fn(...args); } catch (e) { return 0; }
};
const device = {
address: cfg.address,
writeByte: (value) => {
if (cfg.on_connect && this._i2cConnectPending) {
// Fire on_connect before the first write of a transaction.
callFn(cfg.on_connect, cfg.user_data, cfg.address, 0);
this._i2cConnectPending = false;
}
const ack = callFn(cfg.on_write, cfg.user_data, value);
this.wasi.flush();
return !!ack;
},
readByte: () => {
if (cfg.on_connect && this._i2cConnectPending) {
callFn(cfg.on_connect, cfg.user_data, cfg.address, 1);
this._i2cConnectPending = false;
}
const b = callFn(cfg.on_read, cfg.user_data) & 0xff;
this.wasi.flush();
return b;
},
stop: () => {
if (cfg.on_stop) callFn(cfg.on_stop, cfg.user_data);
this._i2cConnectPending = true;
this.wasi.flush();
},
};
// Mark "next op begins a transaction" so on_connect fires once at the boundary.
this._i2cConnectPending = true;
this.i2cBus.addDevice(device);
this._i2cAddress = cfg.address;
return 0; // handle (only one I2C per chip in MVP)
}
// ── SPI ──────────────────────────────────────────────────────────────────
_spi_attach(cfgPtr) {
if (!this.spiBus) {
throw new Error('Chip called vx_spi_attach but no SPIBus is wired to the host');
}
const cfg = readSpiConfig(this.memory, cfgPtr);
const handle = this.spiDevices.length;
const device = new SPIDevice();
const onDoneCallback = (buffer, count) => {
if (cfg.on_done) {
const table = this.exports.__indirect_function_table;
const fn = table.get(cfg.on_done);
// The buffer pointer was passed in; call back with original ptr + count.
try { fn(cfg.user_data, this._currentSpiBufPtr ?? 0, count); } catch (_) { /* noop */ }
this.wasi.flush();
}
};
this.spiDevices.push({ device, cfg, onDoneCallback });
this.spiBus.addDevice(device);
return handle;
}
_spi_start(handle, bufPtr, count) {
const entry = this.spiDevices[handle];
if (!entry) return;
// Live view into WASM memory at the chip's buffer address.
const buf = new Uint8Array(this.memory.buffer, bufPtr, count);
this._currentSpiBufPtr = bufPtr;
entry.device.startTransfer(buf, count, (b, c) => entry.onDoneCallback(b, c));
}
_spi_stop(handle) {
const entry = this.spiDevices[handle];
if (!entry) return;
entry.device.stopTransfer();
}
// ── UART ─────────────────────────────────────────────────────────────────
_uart_attach(cfgPtr) {
const cfg = readUartConfig(this.memory, cfgPtr);
const handle = this.uarts.length;
this.uarts.push(cfg);
return handle;
}
_uart_write(handle, bufPtr, count) {
const u = this.uarts[handle];
if (!u) return 0;
const u8 = new Uint8Array(this.memory.buffer);
const bytes = u8.slice(bufPtr, bufPtr + count);
if (this._uartTxListener) {
for (const b of bytes) this._uartTxListener(b);
}
// Notify the chip that the write completed (synchronous in our sim).
if (u.on_tx_done) {
const table = this.exports.__indirect_function_table;
const fn = table.get(u.on_tx_done);
try { fn(u.user_data); } catch (_) { /* noop */ }
}
this.wasi.flush();
return 1;
}
/**
* Push a received byte into the chip's UART (simulates a byte arriving
* on the chip's RX pin). Triggers the chip's on_rx_byte callback.
*/
feedUart(byte, handle = 0) {
const u = this.uarts[handle];
if (!u || !u.on_rx_byte) return;
const table = this.exports.__indirect_function_table;
const fn = table.get(u.on_rx_byte);
try { fn(u.user_data, byte & 0xff); } catch (_) { /* noop */ }
this.wasi.flush();
}
/** Register a listener for bytes the chip emits via uart_write. */
onUartTx(cb) {
this._uartTxListener = cb;
}
// ── Timers ───────────────────────────────────────────────────────────────
_timer_create(cbIdx, userData) {
const handle = this.timers.length;
this.timers.push({ cbIdx, userData, active: false, period: 0n, nextFire: 0n, repeat: false });
return handle;
}
_timer_start(handle, periodNanos, repeat) {
const t = this.timers[handle];
if (!t) return;
t.period = BigInt(periodNanos);
t.repeat = !!repeat;
t.nextFire = BigInt(this.wasi.simNanos()) + t.period;
t.active = true;
}
_timer_stop(handle) {
const t = this.timers[handle];
if (t) t.active = false;
}
}