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

764 lines
28 KiB
TypeScript
Raw Normal View History

/**
* 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;
feat(chips): programmable retro CPU chips with external ROM Adds a new way to use the retro CPU chips: write your program in a project file (.s / .asm / .hex / .bin), click Compile, click Run, and the same chip emulates whatever you wrote. Same chip + different ROMs = mini PC, calculator, LED demo, Kill-the-Bit game, etc. SDK: - velxio-chip.h gets two new host imports: uint32_t vx_rom_size(void); void vx_rom_read(uint32_t off, uint8_t* dst, uint32_t len); CPU-emulator chips call these in chip_setup to pull their program out of the host's romBytes property. Frontend runtime: - ChipRuntime accepts opts.romBytes (Uint8Array) and exposes the new imports, copying bytes into chip memory on vx_rom_read. - CustomChipPart pulls component.properties.romBytes (base64) and passes it through. - Component registry declares three new custom-chip properties: romBytes (base64), programFile (matching project filename), and programTarget (cpu name). New programmable bundled chip: - frontend/src/components/customChips/examples/intel/i8080-cpu.{c,chip.json} Same clean-room 8080 emulator as i8080-repl/i8080-counter, but ROM is loaded externally via vx_rom_*. Has 8 LEDs, 8 buttons, UART, 16 KB RAM, 32 KB of external ROM. Backend: - New /api/compile-rom endpoint and rom_compile service that turns chip-program source into ROM bytes. 8080 ASM is assembled by the in-tree two-pass assembler (moved to backend/app/services/asm8080.py). Intel HEX records are parsed; raw .bin is passed through. Future targets (z80, 8086, 4004) are scaffolded but not wired yet. EditorToolbar: - Compile button detects when the active file is .s/.asm/.hex/.bin and routes to compile-rom instead of arduino-cli. The compiled bytes are injected into every custom-chip on the canvas whose programFile property matches the active filename (or is empty). Example: - /examples/i8080-killbits loads Dean McDaniel's 1975 Kill-the-Bit on the programmable i8080-cpu chip. killbits.s is shipped as a project file alongside sketch.ino; the user clicks Compile then Run and the LED walks across 8 outputs, buttons kill it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:38:18 +07:00
/** 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;
feat(chips): programmable retro CPU chips with external ROM Adds a new way to use the retro CPU chips: write your program in a project file (.s / .asm / .hex / .bin), click Compile, click Run, and the same chip emulates whatever you wrote. Same chip + different ROMs = mini PC, calculator, LED demo, Kill-the-Bit game, etc. SDK: - velxio-chip.h gets two new host imports: uint32_t vx_rom_size(void); void vx_rom_read(uint32_t off, uint8_t* dst, uint32_t len); CPU-emulator chips call these in chip_setup to pull their program out of the host's romBytes property. Frontend runtime: - ChipRuntime accepts opts.romBytes (Uint8Array) and exposes the new imports, copying bytes into chip memory on vx_rom_read. - CustomChipPart pulls component.properties.romBytes (base64) and passes it through. - Component registry declares three new custom-chip properties: romBytes (base64), programFile (matching project filename), and programTarget (cpu name). New programmable bundled chip: - frontend/src/components/customChips/examples/intel/i8080-cpu.{c,chip.json} Same clean-room 8080 emulator as i8080-repl/i8080-counter, but ROM is loaded externally via vx_rom_*. Has 8 LEDs, 8 buttons, UART, 16 KB RAM, 32 KB of external ROM. Backend: - New /api/compile-rom endpoint and rom_compile service that turns chip-program source into ROM bytes. 8080 ASM is assembled by the in-tree two-pass assembler (moved to backend/app/services/asm8080.py). Intel HEX records are parsed; raw .bin is passed through. Future targets (z80, 8086, 4004) are scaffolded but not wired yet. EditorToolbar: - Compile button detects when the active file is .s/.asm/.hex/.bin and routes to compile-rom instead of arduino-cli. The compiled bytes are injected into every custom-chip on the canvas whose programFile property matches the active filename (or is empty). Example: - /examples/i8080-killbits loads Dean McDaniel's 1975 Kill-the-Bit on the programmable i8080-cpu chip. killbits.s is shipped as a project file alongside sketch.ino; the user clicks Compile then Run and the LED walks across 8 outputs, buttons kill it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:38:18 +07:00
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;
feat(chips): programmable retro CPU chips with external ROM Adds a new way to use the retro CPU chips: write your program in a project file (.s / .asm / .hex / .bin), click Compile, click Run, and the same chip emulates whatever you wrote. Same chip + different ROMs = mini PC, calculator, LED demo, Kill-the-Bit game, etc. SDK: - velxio-chip.h gets two new host imports: uint32_t vx_rom_size(void); void vx_rom_read(uint32_t off, uint8_t* dst, uint32_t len); CPU-emulator chips call these in chip_setup to pull their program out of the host's romBytes property. Frontend runtime: - ChipRuntime accepts opts.romBytes (Uint8Array) and exposes the new imports, copying bytes into chip memory on vx_rom_read. - CustomChipPart pulls component.properties.romBytes (base64) and passes it through. - Component registry declares three new custom-chip properties: romBytes (base64), programFile (matching project filename), and programTarget (cpu name). New programmable bundled chip: - frontend/src/components/customChips/examples/intel/i8080-cpu.{c,chip.json} Same clean-room 8080 emulator as i8080-repl/i8080-counter, but ROM is loaded externally via vx_rom_*. Has 8 LEDs, 8 buttons, UART, 16 KB RAM, 32 KB of external ROM. Backend: - New /api/compile-rom endpoint and rom_compile service that turns chip-program source into ROM bytes. 8080 ASM is assembled by the in-tree two-pass assembler (moved to backend/app/services/asm8080.py). Intel HEX records are parsed; raw .bin is passed through. Future targets (z80, 8086, 4004) are scaffolded but not wired yet. EditorToolbar: - Compile button detects when the active file is .s/.asm/.hex/.bin and routes to compile-rom instead of arduino-cli. The compiled bytes are injected into every custom-chip on the canvas whose programFile property matches the active filename (or is empty). Example: - /examples/i8080-killbits loads Dean McDaniel's 1975 Kill-the-Bit on the programmable i8080-cpu chip. killbits.s is shipped as a project file alongside sketch.ino; the user clicks Compile then Run and the LED walks across 8 outputs, buttons kill it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:38:18 +07:00
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();
}
feat(chipbus): Galaksija home computer gallery example + browser perf throttle Ships the full Galaksija (1983 Z80 home computer) as a runnable Retro gallery example, plus the pieces needed to run a multi-chip bus live in the browser. Gallery example (examples-retro-intel.ts, id 'galaksija-z80-computer'): Z80 + galaksija-rom (public-domain ROM A+B) + ram-64k + inverter (A13 decode) + galaksija-display + a power-on reset chip, wired chip-to-chip over the bus (76 wires), no board. Click Resume and it boots the real ROM to the "READY" prompt on the green display. Chip wasm is embedded (wasmBase64) so it runs without a backend compile. - ChipRuntime.tickTimers gains a wall-clock budget (CustomChipPart passes 6 ms): a faithful-but-slow event-driven bus can't run a real-time CPU in one animation frame, so without a cap a Z80 fetching over the settle kernel froze the tab. With the budget the sim advances slower than real time (boots over a few seconds) and the UI stays responsive; fast single-chip examples finish under budget and are unaffected. - galaksija-display: blits its framebuffer on a ~30 fps timer instead of on every character write, so a clear-screen burst doesn't flood the canvas. - reset-gen: power-on reset (pulses RESET high, ties WAIT/BUSREQ/INT/NMI high) so the machine boots on Resume without a manual reset. - chipbus flag now defaults ON (override with ?chipbus=off): chip-to-chip buses are a core capability; single-chip and board nets never take this path, so the only thing enabled is multi-chip buses, previously broken. Verified live in the browser: the example boots and renders "@'READY" with the ">_" prompt, responsive. Full suite 2084 pass (5 pre-existing, unrelated env failures). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:49:33 +07:00
/**
* 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;
feat(chipbus): Galaksija home computer gallery example + browser perf throttle Ships the full Galaksija (1983 Z80 home computer) as a runnable Retro gallery example, plus the pieces needed to run a multi-chip bus live in the browser. Gallery example (examples-retro-intel.ts, id 'galaksija-z80-computer'): Z80 + galaksija-rom (public-domain ROM A+B) + ram-64k + inverter (A13 decode) + galaksija-display + a power-on reset chip, wired chip-to-chip over the bus (76 wires), no board. Click Resume and it boots the real ROM to the "READY" prompt on the green display. Chip wasm is embedded (wasmBase64) so it runs without a backend compile. - ChipRuntime.tickTimers gains a wall-clock budget (CustomChipPart passes 6 ms): a faithful-but-slow event-driven bus can't run a real-time CPU in one animation frame, so without a cap a Z80 fetching over the settle kernel froze the tab. With the budget the sim advances slower than real time (boots over a few seconds) and the UI stays responsive; fast single-chip examples finish under budget and are unaffected. - galaksija-display: blits its framebuffer on a ~30 fps timer instead of on every character write, so a clear-screen burst doesn't flood the canvas. - reset-gen: power-on reset (pulses RESET high, ties WAIT/BUSREQ/INT/NMI high) so the machine boots on Resume without a manual reset. - chipbus flag now defaults ON (override with ?chipbus=off): chip-to-chip buses are a core capability; single-chip and board nets never take this path, so the only thing enabled is multi-chip buses, previously broken. Verified live in the browser: the example boots and renders "@'READY" with the ">_" prompt, responsive. Full suite 2084 pass (5 pre-existing, unrelated env failures). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:49:33 +07:00
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;
}
feat(chipbus): Galaksija home computer gallery example + browser perf throttle Ships the full Galaksija (1983 Z80 home computer) as a runnable Retro gallery example, plus the pieces needed to run a multi-chip bus live in the browser. Gallery example (examples-retro-intel.ts, id 'galaksija-z80-computer'): Z80 + galaksija-rom (public-domain ROM A+B) + ram-64k + inverter (A13 decode) + galaksija-display + a power-on reset chip, wired chip-to-chip over the bus (76 wires), no board. Click Resume and it boots the real ROM to the "READY" prompt on the green display. Chip wasm is embedded (wasmBase64) so it runs without a backend compile. - ChipRuntime.tickTimers gains a wall-clock budget (CustomChipPart passes 6 ms): a faithful-but-slow event-driven bus can't run a real-time CPU in one animation frame, so without a cap a Z80 fetching over the settle kernel froze the tab. With the budget the sim advances slower than real time (boots over a few seconds) and the UI stays responsive; fast single-chip examples finish under budget and are unaffected. - galaksija-display: blits its framebuffer on a ~30 fps timer instead of on every character write, so a clear-screen burst doesn't flood the canvas. - reset-gen: power-on reset (pulses RESET high, ties WAIT/BUSREQ/INT/NMI high) so the machine boots on Resume without a manual reset. - chipbus flag now defaults ON (override with ?chipbus=off): chip-to-chip buses are a core capability; single-chip and board nets never take this path, so the only thing enabled is multi-chip buses, previously broken. Verified live in the browser: the example boots and renders "@'READY" with the ">_" prompt, responsive. Full suite 2084 pass (5 pre-existing, unrelated env failures). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:49:33 +07:00
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),
feat(chips): programmable retro CPU chips with external ROM Adds a new way to use the retro CPU chips: write your program in a project file (.s / .asm / .hex / .bin), click Compile, click Run, and the same chip emulates whatever you wrote. Same chip + different ROMs = mini PC, calculator, LED demo, Kill-the-Bit game, etc. SDK: - velxio-chip.h gets two new host imports: uint32_t vx_rom_size(void); void vx_rom_read(uint32_t off, uint8_t* dst, uint32_t len); CPU-emulator chips call these in chip_setup to pull their program out of the host's romBytes property. Frontend runtime: - ChipRuntime accepts opts.romBytes (Uint8Array) and exposes the new imports, copying bytes into chip memory on vx_rom_read. - CustomChipPart pulls component.properties.romBytes (base64) and passes it through. - Component registry declares three new custom-chip properties: romBytes (base64), programFile (matching project filename), and programTarget (cpu name). New programmable bundled chip: - frontend/src/components/customChips/examples/intel/i8080-cpu.{c,chip.json} Same clean-room 8080 emulator as i8080-repl/i8080-counter, but ROM is loaded externally via vx_rom_*. Has 8 LEDs, 8 buttons, UART, 16 KB RAM, 32 KB of external ROM. Backend: - New /api/compile-rom endpoint and rom_compile service that turns chip-program source into ROM bytes. 8080 ASM is assembled by the in-tree two-pass assembler (moved to backend/app/services/asm8080.py). Intel HEX records are parsed; raw .bin is passed through. Future targets (z80, 8086, 4004) are scaffolded but not wired yet. EditorToolbar: - Compile button detects when the active file is .s/.asm/.hex/.bin and routes to compile-rom instead of arduino-cli. The compiled bytes are injected into every custom-chip on the canvas whose programFile property matches the active filename (or is empty). Example: - /examples/i8080-killbits loads Dean McDaniel's 1975 Kill-the-Bit on the programmable i8080-cpu chip. killbits.s is shipped as a project file alongside sketch.ino; the user clicks Compile then Run and the LED walks across 8 outputs, buttons kill it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:38:18 +07:00
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`);
},
};
}
feat(chips): programmable retro CPU chips with external ROM Adds a new way to use the retro CPU chips: write your program in a project file (.s / .asm / .hex / .bin), click Compile, click Run, and the same chip emulates whatever you wrote. Same chip + different ROMs = mini PC, calculator, LED demo, Kill-the-Bit game, etc. SDK: - velxio-chip.h gets two new host imports: uint32_t vx_rom_size(void); void vx_rom_read(uint32_t off, uint8_t* dst, uint32_t len); CPU-emulator chips call these in chip_setup to pull their program out of the host's romBytes property. Frontend runtime: - ChipRuntime accepts opts.romBytes (Uint8Array) and exposes the new imports, copying bytes into chip memory on vx_rom_read. - CustomChipPart pulls component.properties.romBytes (base64) and passes it through. - Component registry declares three new custom-chip properties: romBytes (base64), programFile (matching project filename), and programTarget (cpu name). New programmable bundled chip: - frontend/src/components/customChips/examples/intel/i8080-cpu.{c,chip.json} Same clean-room 8080 emulator as i8080-repl/i8080-counter, but ROM is loaded externally via vx_rom_*. Has 8 LEDs, 8 buttons, UART, 16 KB RAM, 32 KB of external ROM. Backend: - New /api/compile-rom endpoint and rom_compile service that turns chip-program source into ROM bytes. 8080 ASM is assembled by the in-tree two-pass assembler (moved to backend/app/services/asm8080.py). Intel HEX records are parsed; raw .bin is passed through. Future targets (z80, 8086, 4004) are scaffolded but not wired yet. EditorToolbar: - Compile button detects when the active file is .s/.asm/.hex/.bin and routes to compile-rom instead of arduino-cli. The compiled bytes are injected into every custom-chip on the canvas whose programFile property matches the active filename (or is empty). Example: - /examples/i8080-killbits loads Dean McDaniel's 1975 Kill-the-Bit on the programmable i8080-cpu chip. killbits.s is shipped as a project file alongside sketch.ino; the user clicks Compile then Run and the LED walks across 8 outputs, buttons kill it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:38:18 +07:00
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;
}
}