velxio/frontend/src/simulation/customChips/ChipRuntime.ts

764 lines
28 KiB
TypeScript

/**
* ChipRuntime — TypeScript port of test/test_custom_chips/src/ChipRuntime.js.
*
* Loads a Velxio custom-chip WASM, wires its imports to host services
* (PinManager, I2CBusManager, SPIBus, attribute storage, timer queue), and
* dispatches its callbacks back into the simulator. One ChipInstance per
* chip dropped on the canvas.
*/
import type { PinManager } from '../PinManager';
import type { I2CBusManager } from '../I2CBusManager';
import { SPIBus, SPIDevice } from './SPIBus';
import { WasiShim, type SimNanosFn, type WriteStdoutFn } from './WasiShim';
import { setChipPinDrive } from './chipPinDrives';
import { isSyntheticChipPin, isSyntheticNetPin } from './syntheticPins';
import { requestElectricalResolve } from '../spice/electricalResolveHook';
import { chipBusEnabled } from './chipNets';
import { setBusDrive, clearBusDriversForChip } from './busNets';
import { modeToDrive } from './busLogic';
function readCString(memory: WebAssembly.Memory, ptr: number): string {
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));
}
interface I2CConfig {
address: number;
scl: number;
sda: number;
on_connect: number;
on_read: number;
on_write: number;
on_stop: number;
user_data: number;
}
interface UartConfig {
rx: number;
tx: number;
baud_rate: number;
on_rx_byte: number;
on_tx_done: number;
user_data: number;
}
interface SpiConfig {
sck: number;
mosi: number;
miso: number;
cs: number;
mode: number;
on_done: number;
user_data: number;
}
function readI2CConfig(memory: WebAssembly.Memory, ptr: number): I2CConfig {
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),
};
}
function readUartConfig(memory: WebAssembly.Memory, ptr: number): UartConfig {
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),
};
}
function readSpiConfig(memory: WebAssembly.Memory, ptr: number): SpiConfig {
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),
};
}
interface PinEntry {
name: string;
mode: number;
arduinoPin: number | null;
/** Last level written/initialized — used to compute the bus drive on a mode
* flip (e.g. OUTPUT -> INPUT releases the bus without forgetting the level). */
value: 0 | 1;
}
interface AttrEntry {
name: string;
default: number;
}
interface TimerEntry {
cbIdx: number;
userData: number;
active: boolean;
period: bigint;
nextFire: bigint;
repeat: boolean;
}
interface SpiEntry {
device: SPIDevice;
cfg: SpiConfig;
onDoneCallback: (buffer: Uint8Array, count: number) => void;
}
export interface ChipInstanceOptions {
/** Compiled chip.wasm — either bytes, ArrayBuffer, or pre-compiled Module. */
wasm: Uint8Array | ArrayBuffer | WebAssembly.Module;
pinManager: PinManager;
i2cBus?: I2CBusManager | null;
spiBus?: SPIBus | null;
/** Logical chip pin name → real Arduino pin number (resolved from wires). */
wires?: Map<string, number>;
/** User-editable attributes — keyed by name. */
attrs?: Map<string, number>;
/** Returns simulation time in nanos (used by vx_sim_now_nanos). */
simNanos?: SimNanosFn;
/** Callback for chip log/printf output (defaults to console.log). */
log?: WriteStdoutFn;
/** Optional display dimensions from chip.json's `display` field. */
display?: { width: number; height: number } | null;
/** Optional external ROM bytes (vx_rom_size / vx_rom_read).
* Used by CPU-emulator chips that load their program from a project file
* instead of hard-coding it as a C byte array. */
romBytes?: Uint8Array | null;
/** Canvas component id of this chip. Used to key its SPICE pin sources so
* the analog engine drives the nets wired to the chip's output pins. */
componentId?: string;
}
/** Logic-high voltage a chip output pin asserts on its SPICE net. */
const CHIP_OUTPUT_VCC = 5;
export class ChipInstance {
static MODE_OUTPUT_LOW = 16;
static MODE_OUTPUT_HIGH = 17;
private wasm: ChipInstanceOptions['wasm'];
private pinManager: PinManager;
private i2cBus: I2CBusManager | null;
private spiBus: SPIBus | null;
private wires: Map<string, number>;
private attrs: Map<string, number>;
private display: { width: number; height: number } | null;
private componentId: string;
memory: WebAssembly.Memory | null = null;
instance: WebAssembly.Instance | null = null;
exports: any = null;
disposed = false;
private pins: PinEntry[] = [];
private attrHandles: AttrEntry[] = [];
private _pinWatches = new Map<number, Set<() => void>>();
private timers: TimerEntry[] = [];
private uarts: UartConfig[] = [];
private _uartTxListener: ((byte: number) => void) | null = null;
private spiDevices: SpiEntry[] = [];
private _currentSpiBufPtr: number = 0;
private _romBytes: Uint8Array;
/** Framebuffer state — created on first vx_framebuffer_init call. */
private _framebuffer: { rgba: Uint8Array; width: number; height: number } | null = null;
private _onFramebufferUpdate: ((rgba: Uint8Array, w: number, h: number) => void) | null = null;
/** I2C device wrapper currently registered on the bus (for disposal). */
private _i2cDevice: { address: number } | null = null;
wasi: WasiShim;
private _velxioImports: Record<string, (...args: any[]) => any>;
static async create(opts: ChipInstanceOptions): Promise<ChipInstance> {
const inst = new ChipInstance(opts);
await inst._instantiate();
return inst;
}
constructor(opts: ChipInstanceOptions) {
this.wasm = opts.wasm;
this.pinManager = opts.pinManager;
this.i2cBus = opts.i2cBus ?? null;
this.spiBus = opts.spiBus ?? null;
this.wires = opts.wires ?? new Map();
this.attrs = opts.attrs ?? new Map();
this.display = opts.display ?? null;
this._romBytes = opts.romBytes ?? new Uint8Array(0);
this.componentId = opts.componentId ?? '';
this.wasi = new WasiShim(
opts.simNanos ?? (() => 0n),
opts.log ?? ((s) => console.log(`[chip] ${s.replace(/\n$/, '')}`)),
);
this._velxioImports = this._buildVelxioImports();
}
private async _instantiate(): Promise<void> {
// 4 pages (256 KB) initial: CPU-emulator chips like z80-cpu keep a 32 KB
// ROM + 32 KB RAM buffer as static data, which alone needs >2 pages once
// the WASM stack is added. Grows up to 16 pages on demand.
this.memory = new WebAssembly.Memory({ initial: 4, maximum: 16 });
this.wasi.setMemory(this.memory);
const importObject: WebAssembly.Imports = {
env: {
memory: this.memory,
...this._velxioImports,
},
...this.wasi.imports(),
};
let module: WebAssembly.Module;
if (this.wasm instanceof WebAssembly.Module) {
module = this.wasm;
} else {
module = await WebAssembly.compile(this.wasm as BufferSource);
}
// Sanity-check imports so we surface a helpful error if something's missing.
const expected = WebAssembly.Module.imports(module);
const missing: string[] = [];
for (const imp of expected) {
const ns = (importObject as any)[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` +
`Extend WasiShim or ChipRuntime to provide them.`,
);
}
this.instance = await WebAssembly.instantiate(module, importObject);
this.exports = this.instance.exports;
}
start(): void {
if (!this.exports?.chip_setup) {
throw new Error('Chip WASM does not export chip_setup');
}
this.exports.chip_setup();
this.wasi.flush();
}
/**
* Fire due timers up to sim-time `nowNanos`.
*
* `budgetMs` caps the wall-clock time spent in one call. A heavy multi-chip
* bus (e.g. a Z80 fetching from external ROM/RAM through the settle kernel)
* cannot run a real-time CPU clock in a single animation frame — without a
* cap the loop would fire tens of thousands of times and freeze the tab. With
* a budget the loop bails when exceeded, leaving each timer's nextFire where
* it is so the next call resumes from there: the simulation simply advances
* slower than real time (it boots over a few seconds) while the UI stays
* responsive. budgetMs = 0 (the default, used by headless tests) runs every
* due fire in one call.
*/
tickTimers(nowNanos: bigint | number, budgetMs = 0): void {
const now = BigInt(nowNanos);
const table = this.exports?.__indirect_function_table as WebAssembly.Table | undefined;
if (!table) return;
const startWall = budgetMs > 0 ? performance.now() : 0;
for (const t of this.timers) {
if (!t.active) continue;
while (t.active && now >= t.nextFire) {
const fn = table.get(t.cbIdx) as ((ud: number) => void) | null;
if (fn) {
try { fn(t.userData); } catch { /* swallow chip errors */ }
}
if (t.repeat) {
t.nextFire += t.period;
} else {
t.active = false;
}
if (budgetMs > 0 && performance.now() - startWall > budgetMs) {
this.wasi.flush();
return;
}
}
}
this.wasi.flush();
}
dispose(): void {
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._i2cDevice) {
this.i2cBus.removeDevice(this._i2cDevice.address);
}
if (this.spiBus) {
for (const d of this.spiDevices) this.spiBus.removeDevice(d.device);
}
this.spiDevices = [];
// Stop driving any bus nets this chip contributed to, then re-resolve them
// so a removed chip releases the bus (its drivers no longer count).
if (this.componentId) clearBusDriversForChip(this.pinManager, this.componentId);
this.disposed = true;
}
// ── Build host imports table ─────────────────────────────────────────────
private _buildVelxioImports(): Record<string, (...args: any[]) => any> {
return {
vx_pin_register: (namePtr: number, mode: number) => this._pin_register(namePtr, mode),
vx_pin_read: (handle: number) => this._pin_read(handle),
vx_pin_write: (handle: number, value: number) => this._pin_write(handle, value),
vx_pin_read_analog: (handle: number) => this._pin_read_analog(handle),
vx_pin_dac_write: (handle: number, voltage: number) => this._pin_dac_write(handle, voltage),
vx_pin_set_mode: (handle: number, mode: number) => this._pin_set_mode(handle, mode),
vx_pin_watch: (handle: number, edge: number, cbIdx: number, ud: number) =>
this._pin_watch(handle, edge, cbIdx, ud),
vx_pin_watch_stop: (handle: number) => this._pin_watch_stop(handle),
vx_attr_register: (namePtr: number, defaultVal: number) => this._attr_register(namePtr, defaultVal),
vx_attr_read: (handle: number) => this._attr_read(handle),
vx_i2c_attach: (cfgPtr: number) => this._i2c_attach(cfgPtr),
vx_uart_attach: (cfgPtr: number) => this._uart_attach(cfgPtr),
vx_uart_write: (handle: number, bufPtr: number, count: number) =>
this._uart_write(handle, bufPtr, count),
vx_spi_attach: (cfgPtr: number) => this._spi_attach(cfgPtr),
vx_spi_start: (handle: number, bufPtr: number, count: number) =>
this._spi_start(handle, bufPtr, count),
vx_spi_stop: (handle: number) => this._spi_stop(handle),
vx_sim_now_nanos: () => BigInt(this.wasi.simNanos() as number | bigint),
vx_timer_create: (cbIdx: number, ud: number) => this._timer_create(cbIdx, ud),
vx_timer_start: (handle: number, period: bigint, repeat: number) =>
this._timer_start(handle, period, repeat),
vx_timer_stop: (handle: number) => this._timer_stop(handle),
vx_framebuffer_init: (widthPtr: number, heightPtr: number) =>
this._framebuffer_init(widthPtr, heightPtr),
vx_buffer_write: (handle: number, offset: number, dataPtr: number, dataLen: number) =>
this._buffer_write(handle, offset, dataPtr, dataLen),
vx_rom_size: () => this._romBytes.length,
vx_rom_read: (offset: number, dstPtr: number, len: number) =>
this._rom_read(offset, dstPtr, len),
vx_log: (msgPtr: number) => {
const msg = readCString(this.memory!, msgPtr);
this.wasi.writeStdout(`[chip] ${msg}\n`);
},
};
}
private _rom_read(offset: number, dstPtr: number, len: number): void {
if (!this.memory || this._romBytes.length === 0) return;
const max = this._romBytes.length;
if (offset >= max) return;
const end = Math.min(offset + len, max);
const dst = new Uint8Array(this.memory.buffer, dstPtr, end - offset);
dst.set(this._romBytes.subarray(offset, end));
}
// ── Pin implementations ──────────────────────────────────────────────────
/**
* Mirror an output pin's logic level into the SPICE chip-source registry and
* request a re-solve when it changes — so LEDs / analog parts wired to a chip
* output light up through ngspice, not just the digital PinManager path.
* Only synthetic chip pins (chip wired directly to components, no board GPIO
* on the net) are emitted as chip sources; a chip pin wired to a real board
* pin is already driven by that board's voltage source.
*/
/** True if this pin sits on a multi-chip BUS net (Phase 1): its key is a
* syntheticNetPin and the chipbus flag is on. Such pins resolve through the
* driver-strength registry (busNets) instead of last-writer-wins PinManager. */
private _isBusPin(p: PinEntry): boolean {
return p.arduinoPin != null && chipBusEnabled() && isSyntheticNetPin(p.arduinoPin);
}
/** Register this pin's current (mode, value) as a bus driver and re-resolve. */
private _busDrive(p: PinEntry): void {
if (p.arduinoPin == null) return;
setBusDrive(
this.pinManager,
p.arduinoPin,
`${this.componentId}::${p.name}`,
modeToDrive(p.mode, p.value),
);
}
private _syncSpiceDrive(p: PinEntry): void {
// A bus net is served by the digital driver-strength path; emitting a SPICE
// chip source per chip on the same net would create false analog contention.
if (this._isBusPin(p)) return;
if (!this.componentId || !p.name) return;
if (p.arduinoPin == null || !isSyntheticChipPin(p.arduinoPin)) return;
const isOutput =
p.mode === ChipInstance.MODE_OUTPUT_LOW || p.mode === ChipInstance.MODE_OUTPUT_HIGH;
const changed = isOutput
? setChipPinDrive(
this.componentId,
p.name,
this.pinManager.getPinState(p.arduinoPin) ? CHIP_OUTPUT_VCC : 0,
)
: setChipPinDrive(this.componentId, p.name, null);
if (changed) requestElectricalResolve();
}
private _pin_register(namePtr: number, mode: number): number {
const name = readCString(this.memory!, namePtr);
const handle = this.pins.length;
const arduinoPin = this.wires.has(name) ? this.wires.get(name)! : null;
const value: 0 | 1 = mode === ChipInstance.MODE_OUTPUT_HIGH ? 1 : 0;
const p: PinEntry = { name, mode, arduinoPin, value };
this.pins.push(p);
if (this._isBusPin(p)) {
this._busDrive(p);
} else 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);
}
this._syncSpiceDrive(p);
return handle;
}
private _pin_read(handle: number): number {
const p = this.pins[handle];
if (!p || p.arduinoPin == null) return 0;
return this.pinManager.getPinState(p.arduinoPin) ? 1 : 0;
}
private _pin_write(handle: number, value: number): void {
const p = this.pins[handle];
if (!p || p.arduinoPin == null) return;
p.value = value !== 0 ? 1 : 0;
if (this._isBusPin(p)) {
this._busDrive(p);
} else {
this.pinManager.triggerPinChange(p.arduinoPin, value !== 0);
}
this._syncSpiceDrive(p);
}
private _pin_read_analog(handle: number): number {
const p = this.pins[handle];
if (!p || p.arduinoPin == null) return 0;
return this.pinManager.getPwmValue(p.arduinoPin) * 5.0;
}
private _pin_dac_write(handle: number, voltage: number): void {
const p = this.pins[handle];
if (!p || p.arduinoPin == null) return;
this.pinManager.setAnalogVoltage(p.arduinoPin, voltage);
}
private _pin_set_mode(handle: number, mode: number): void {
const p = this.pins[handle];
if (!p) return;
p.mode = mode;
// OUTPUT_LOW/HIGH carry an initial level; plain OUTPUT keeps the last value.
if (mode === ChipInstance.MODE_OUTPUT_LOW) p.value = 0;
if (mode === ChipInstance.MODE_OUTPUT_HIGH) p.value = 1;
if (this._isBusPin(p)) {
this._busDrive(p);
} else 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);
}
this._syncSpiceDrive(p);
}
private _pin_watch(handle: number, edge: number, cbIdx: number, userData: number): void {
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 as WebAssembly.Table | undefined;
if (!table) return;
const fn = table.get(cbIdx) as ((ud: number, pin: number, value: number) => void) | null;
if (fn) {
try { fn(userData, handle, newState); } catch { /* swallow */ }
}
this.wasi.flush();
}
});
if (!this._pinWatches.has(handle)) this._pinWatches.set(handle, new Set());
this._pinWatches.get(handle)!.add(unsub);
}
private _pin_watch_stop(handle: number): void {
const set = this._pinWatches.get(handle);
if (!set) return;
for (const u of set) u();
this._pinWatches.delete(handle);
}
// ── Attributes ───────────────────────────────────────────────────────────
private _attr_register(namePtr: number, defaultVal: number): number {
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;
}
private _attr_read(handle: number): number {
const a = this.attrHandles[handle];
if (!a) return 0;
return this.attrs.get(a.name) ?? a.default;
}
// ── I2C ──────────────────────────────────────────────────────────────────
private _i2c_attach(cfgPtr: number): number {
if (!this.i2cBus) {
throw new Error('Chip called vx_i2c_attach but no I2CBusManager is wired to the host');
}
const cfg = readI2CConfig(this.memory!, cfgPtr);
const callFn = (idx: number, ...args: any[]) => {
const table = this.exports?.__indirect_function_table as WebAssembly.Table | undefined;
if (!table) return 0;
const fn = table.get(idx) as ((...a: any[]) => any) | null;
if (!fn) return 0;
try { return fn(...args); } catch { return 0; }
};
let connectPending = true;
const device = {
address: cfg.address,
writeByte: (value: number): boolean => {
if (cfg.on_connect && connectPending) {
callFn(cfg.on_connect, cfg.user_data, cfg.address, 0);
connectPending = false;
}
const ack = !!callFn(cfg.on_write, cfg.user_data, value);
this.wasi.flush();
return ack;
},
readByte: (): number => {
if (cfg.on_connect && connectPending) {
callFn(cfg.on_connect, cfg.user_data, cfg.address, 1);
connectPending = false;
}
const b = callFn(cfg.on_read, cfg.user_data) & 0xff;
this.wasi.flush();
return b;
},
stop: (): void => {
if (cfg.on_stop) callFn(cfg.on_stop, cfg.user_data);
connectPending = true;
this.wasi.flush();
},
};
this.i2cBus.addDevice(device);
this._i2cDevice = device;
return 0;
}
// ── UART ─────────────────────────────────────────────────────────────────
private _uart_attach(cfgPtr: number): number {
const cfg = readUartConfig(this.memory!, cfgPtr);
const handle = this.uarts.length;
this.uarts.push(cfg);
return handle;
}
private _uart_write(handle: number, bufPtr: number, count: number): number {
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);
}
if (u.on_tx_done) {
const table = this.exports?.__indirect_function_table as WebAssembly.Table | undefined;
const fn = table?.get(u.on_tx_done) as ((ud: number) => void) | null;
if (fn) {
try { fn(u.user_data); } catch { /* swallow */ }
}
}
this.wasi.flush();
return 1;
}
feedUart(byte: number, handle = 0): void {
const u = this.uarts[handle];
if (!u || !u.on_rx_byte) return;
const table = this.exports?.__indirect_function_table as WebAssembly.Table | undefined;
const fn = table?.get(u.on_rx_byte) as ((ud: number, byte: number) => void) | null;
if (fn) {
try { fn(u.user_data, byte & 0xff); } catch { /* swallow */ }
}
this.wasi.flush();
}
onUartTx(cb: (byte: number) => void): void {
this._uartTxListener = cb;
}
/** True if the chip declared at least one UART (post-chip_setup). */
get hasUart(): boolean {
return this.uarts.length > 0;
}
// ── SPI ──────────────────────────────────────────────────────────────────
private _spi_attach(cfgPtr: number): number {
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: Uint8Array, count: number) => {
if (cfg.on_done) {
const table = this.exports?.__indirect_function_table as WebAssembly.Table | undefined;
const fn = table?.get(cfg.on_done) as ((ud: number, buf: number, c: number) => void) | null;
if (fn) {
try { fn(cfg.user_data, this._currentSpiBufPtr, count); } catch { /* swallow */ }
}
this.wasi.flush();
}
};
this.spiDevices.push({ device, cfg, onDoneCallback });
this.spiBus.addDevice(device);
return handle;
}
private _spi_start(handle: number, bufPtr: number, count: number): void {
const entry = this.spiDevices[handle];
if (!entry) return;
const buf = new Uint8Array(this.memory!.buffer, bufPtr, count);
this._currentSpiBufPtr = bufPtr;
entry.device.startTransfer(buf, count, (b, c) => entry.onDoneCallback(b, c));
}
private _spi_stop(handle: number): void {
const entry = this.spiDevices[handle];
if (!entry) return;
entry.device.stopTransfer();
}
// ── Framebuffer ──────────────────────────────────────────────────────────
private _framebuffer_init(widthPtr: number, heightPtr: number): number {
const w = this.display?.width ?? 128;
const h = this.display?.height ?? 64;
if (!this._framebuffer) {
this._framebuffer = { rgba: new Uint8Array(w * h * 4), width: w, height: h };
}
if (this.memory) {
const dv = new DataView(this.memory.buffer);
dv.setUint32(widthPtr, w, true);
dv.setUint32(heightPtr, h, true);
}
return 0;
}
private _buffer_write(_handle: number, offset: number, dataPtr: number, dataLen: number): void {
if (!this._framebuffer || !this.memory) return;
const src = new Uint8Array(this.memory.buffer, dataPtr, dataLen);
const dst = this._framebuffer.rgba;
const end = Math.min(offset + dataLen, dst.length);
const copyLen = Math.max(0, end - offset);
if (copyLen > 0) dst.set(src.subarray(0, copyLen), offset);
if (this._onFramebufferUpdate) {
try {
this._onFramebufferUpdate(this._framebuffer.rgba, this._framebuffer.width, this._framebuffer.height);
} catch { /* swallow */ }
}
}
/** Subscribe to framebuffer paint events. The callback fires after each
* vx_buffer_write, with the full RGBA buffer (consumer can blit it to a
* canvas). */
onFramebufferUpdate(cb: (rgba: Uint8Array, w: number, h: number) => void): void {
this._onFramebufferUpdate = cb;
// Fire once with the current state so the canvas reflects what's already there.
if (this._framebuffer) {
try { cb(this._framebuffer.rgba, this._framebuffer.width, this._framebuffer.height); } catch { /* swallow */ }
}
}
/** True if the chip declared a framebuffer (post-chip_setup). */
get hasFramebuffer(): boolean {
return this._framebuffer !== null;
}
// ── Keyboard (chips that export set_key, e.g. galaksija-keyboard) ─────────
/** True if the chip exposes a host-driven keyboard via an exported
* `set_key(offset, down)`. The host (CustomChipPart) bridges browser key
* events into it. */
get hasKeyboard(): boolean {
return typeof this.exports?.set_key === 'function';
}
/** Push a key state into the chip's key table. `offset` is the chip-specific
* matrix offset; `down` is press/release. No-op if the chip has no keyboard. */
setKey(offset: number, down: boolean): void {
try {
this.exports?.set_key?.(offset, down ? 1 : 0);
} catch {
/* swallow chip errors */
}
}
// ── Timers ───────────────────────────────────────────────────────────────
private _timer_create(cbIdx: number, userData: number): number {
const handle = this.timers.length;
this.timers.push({ cbIdx, userData, active: false, period: 0n, nextFire: 0n, repeat: false });
return handle;
}
private _timer_start(handle: number, periodNanos: bigint, repeat: number): void {
const t = this.timers[handle];
if (!t) return;
t.period = BigInt(periodNanos);
t.repeat = !!repeat;
t.nextFire = BigInt(this.wasi.simNanos() as number | bigint) + t.period;
t.active = true;
}
private _timer_stop(handle: number): void {
const t = this.timers[handle];
if (t) t.active = false;
}
}