14 KiB
Custom Chips — Build & Test guide
Toolchain setup, compilation pipeline, and testing — both the in-app "Compile" button and the local sandbox suite (70+ regression tests).
Table of contents
- Toolchain at a glance
- Installing WASI-SDK
- Compiling a single chip
- Compiling via the backend API
- The sandbox test suite
- Multi-board validation suite
- Writing tests for your own chip
- Troubleshooting
Toolchain at a glance
The whole compilation pipeline is two open-source tools plus our header:
| Component | What it does | License |
|---|---|---|
| clang (from WASI-SDK) | Compiles C → WebAssembly | Apache 2.0 |
| wasi-libc (from WASI-SDK) | C standard library (malloc, printf, memset, …) |
MIT |
velxio-chip.h |
The Velxio chip API header | MIT (Velxio) |
No third-party simulator code is involved. The Velxio backend Docker image
bundles WASI-SDK at /opt/wasi-sdk automatically; for local dev you install
it once.
Installing WASI-SDK
WASI-SDK is a single tarball with everything needed: clang, wasi-libc, runtime libraries.
Releases: https://github.com/WebAssembly/wasi-sdk/releases
Linux / macOS
cd /opt
sudo curl -L https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-22/wasi-sdk-22.0-linux.tar.gz \
| sudo tar -xz
sudo mv wasi-sdk-22.0 wasi-sdk
echo 'export WASI_SDK=/opt/wasi-sdk' >> ~/.bashrc
export WASI_SDK=/opt/wasi-sdk
Windows (PowerShell)
$ErrorActionPreference = 'Stop'
$tmp = "$env:TEMP\wasi-sdk.tar.gz"
Invoke-WebRequest `
https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-22/wasi-sdk-22.0-mingw.tar.gz `
-OutFile $tmp
tar -xzf $tmp -C C:\
Move-Item C:\wasi-sdk-22.0 C:\wasi-sdk
[Environment]::SetEnvironmentVariable('WASI_SDK', 'C:\wasi-sdk', 'User')
$env:WASI_SDK = 'C:\wasi-sdk'
Reopen your terminal so the env var sticks, then verify:
$WASI_SDK/bin/clang --version
# clang version 22.x ... Target: wasm32-unknown-wasip1
If your installed version reports a different default target, pass
--target=wasm32-unknown-wasip1 explicitly (the compile script already does).
Verify the backend has it
When the backend is running:
curl http://localhost:8001/api/compile-chip/status
# {"available":true,"wasi_sdk":"/opt/wasi-sdk","sdk_include":"/app/sdk"}
If available: false, set WASI_SDK and restart uvicorn, or rebuild the
Docker image.
Compiling a single chip
The exact command Velxio uses internally:
$WASI_SDK/bin/clang \
--target=wasm32-unknown-wasip1 \
-O2 \
-nostartfiles \
-Wl,--import-memory \
-Wl,--export-table \
-Wl,--no-entry \
-Wl,--export=chip_setup \
-Wl,--allow-undefined \
-I /path/to/sdk/include \
mychip.c -o mychip.wasm
Each flag explained
| Flag | Why |
|---|---|
--target=wasm32-unknown-wasip1 |
Backend = WASM, libc = WASI preview-1 |
-O2 |
Reasonable optimisation; -Os is fine too if you want smaller |
-nostartfiles |
No crt0 — the chip has no main() |
-Wl,--import-memory |
The host provides the WASM linear memory |
-Wl,--export-table |
Exposes the function table so the host can invoke C function pointers (used for I2C / SPI / pin-watch callbacks) |
-Wl,--no-entry |
No _start function, only exports |
-Wl,--export=chip_setup |
Guarantees chip_setup survives DCE if linker is aggressive |
-Wl,--allow-undefined |
Treat unresolved vx_* symbols as WASM imports (the host provides them) |
Convenience scripts
The sandbox ships ready-to-use scripts:
# Linux/macOS
bash test/test_custom_chips/scripts/compile-chip.sh \
mychip.c fixtures/mychip.wasm
# Windows
.\test\test_custom_chips\scripts\compile-chip.ps1 `
mychip.c fixtures\mychip.wasm
Both auto-discover WASI_SDK from common install paths.
Compiling via the backend API
When the Custom Chip designer in the UI says "Compile", it POSTs to:
POST /api/compile-chip/
Content-Type: application/json
{
"source": "<full chip.c text>",
"chip_json": "<full chip.json text — currently ignored, future use>"
}
Response:
{
"success": true,
"wasm_base64": "AGFzbQEAAAAB...",
"stdout": "",
"stderr": "",
"error": null,
"byte_size": 63287
}
On compile errors success is false and stderr contains the clang
output. The endpoint never returns 5xx — it surfaces compiler errors as
data so the UI can show them.
You can also hit the endpoint manually:
curl -X POST http://localhost:8001/api/compile-chip/ \
-H 'Content-Type: application/json' \
-d "$(jq -Rn --rawfile s mychip.c '{source: $s}')"
A status endpoint reports availability:
curl http://localhost:8001/api/compile-chip/status
The sandbox test suite
Velxio ships a Node.js sandbox at
test/test_custom_chips/ that mirrors the
production runtime. It uses the same chip API, the same I2C bus
manager, and the same avr8js instance the browser uses — all the
chips work identically.
Quick start
cd test/test_custom_chips
npm install
# Build all 11 example chip fixtures (.wasm) — needs WASI-SDK
bash scripts/compile-all.sh
# Run the test suite
npm test
Expected output:
Test Files 26 passed (26)
Tests 70 passed (70)
What it tests
| Layer | Tests |
|---|---|
PinManager mirror |
Edge dispatch, PWM, analog, unsubscribe (5 tests) |
I2CBus mirror |
TWI event handler, address routing, NACK (3 tests) |
AVRHarness |
avr8js boots a real blink.hex (2 tests) |
ChipRuntime host imports |
Surface check, missing-import error reporting (4 tests) |
| Single chip behavior | Each of the 11 example chips (3-5 tests each) |
| Multi-chip | Chained logic, two EEPROMs, mixed I2C+UART (4 tests) |
| AVR + chip integration | avr8js blink hex driving a real chip (3 tests) |
Full E2E with Wire.h |
Compiled Arduino sketch + 24C01 EEPROM (1 test) |
| API extras | OUTPUT_LOW/HIGH, pin_watch_stop, DAC (4 tests) |
Running just one suite
npm run test:js # JS-only (no WASM needed) — 14 tests
npm run test:e2e # Tests requiring compiled .wasm — 7 tests
Multi-board validation suite
The pytest suite at
test/test_custom_chips_boards/
exercises the backend services that custom chips depend on, across all
supported board families.
Pre-requisites
- Backend running. By default the suite expects
http://127.0.0.1:8765. Override withVELXIO_BACKEND_URL. - For ESP32 tests: the lcgamboa
libqemu-xtensa.{dll,so}must be atbackend/app/services/. The Docker image bundles it; for local dev you candocker cpit from a running container.
Running
cd /path/to/velxio
VELXIO_BACKEND_URL=http://127.0.0.1:8765 \
pytest test/test_custom_chips_boards/ -v
Expected: 24 tests pass.
| Test file | Validates |
|---|---|
test_compile_endpoint.py |
All 11 chips compile via /api/compile-chip (14 tests) |
test_multi_board_sketch_compile.py |
Blink sketch builds for AVR/RP2040/ESP32 (5 tests) |
test_esp32_gpio_bridge.py |
ESP32 QEMU GPIO + serial round-trip via WS (2 tests) |
test_esp32_chip_i2c.py |
ESP32 sketch ↔ chip 24C01 EEPROM (Wire round-trip via backend WASM runtime) |
test_esp32_chip_uart.py |
ESP32 sketch ↔ chip ROT13 (Serial.write/read via backend WASM runtime) |
test_esp32_chip_spi.py |
ESP32 sketch ↔ chip 74HC595 (SPI byte + RCLK pin_watch + 8 GPIO outputs) |
The ESP32 tests skip cleanly if libqemu-xtensa is missing.
For pure-runtime tests (no QEMU, no WebSocket, just the Python WASM runtime in
isolation) see test/test_chip_backend_runtime/test_wasm_runtime.py
— 11 tests covering GPIO, I2C, UART, SPI, pin_watch and timers. Run with
pytest test/test_chip_backend_runtime/.
Writing tests for your own chip
Inside the sandbox, drop a test file under
test/test_custom_chips/test/chips/your_chip.test.js:
import { describe, it, expect } from 'vitest';
import { ChipInstance } from '../../src/ChipRuntime.js';
import { PinManager } from '../../src/PinManager.js';
import { loadChipWasm, chipWasmExists } from '../helpers.js';
const skip = !chipWasmExists('mychip');
describe('My chip', () => {
it.skipIf(skip)('does the thing', async () => {
const pm = new PinManager();
const chip = await ChipInstance.create({
wasm: loadChipWasm('mychip'),
pinManager: pm,
wires: new Map([['IN', 2], ['OUT', 3]]),
});
chip.start();
pm.triggerPinChange(2, true);
expect(pm.getPinState(3)).toBe(false);
chip.dispose();
});
});
For an I2C chip, instantiate an I2CBus:
import { I2CBus } from '../../src/I2CBus.js';
import { makeFakeTwi, i2cWrite, i2cRead } from '../helpers.js';
const twi = makeFakeTwi();
const bus = new I2CBus(twi);
const chip = await ChipInstance.create({
wasm: loadChipWasm('mychip'),
pinManager: pm,
i2cBus: bus,
wires: new Map([['SDA', 18], ['SCL', 19]]),
});
chip.start();
i2cWrite(bus, twi, 0x50, [0x00, 0xAA]); // pointer=0, data=0xAA
i2cWrite(bus, twi, 0x50, [0x00]); // reset pointer
expect(i2cRead(bus, twi, 0x50, 1)).toEqual([0xAA]);
For a SPI chip, use SPIBus:
import { SPIBus } from '../../src/SPIBus.js';
const spi = new SPIBus();
const chip = await ChipInstance.create({
wasm: loadChipWasm('mychip'),
pinManager: pm,
spiBus: spi,
wires: new Map([['SCK', 13], ['MOSI', 11], ['MISO', 12], ['CS', 10]]),
});
chip.start();
pm.triggerPinChange(10, false); // CS LOW
const responses = spi.transferBytes([0x01, 0xA0, 0x00]);
pm.triggerPinChange(10, true); // CS HIGH
expect(responses[2]).toBe(/* expected */);
For full E2E with a real Arduino sketch, see
test/test_custom_chips/test/e2e/07_chip_eeprom_avr_e2e.test.js
— it loads a compiled .hex of Wire.h code into avr8js and runs the
chip alongside.
Troubleshooting
Compile errors
undefined symbol: vx_pin_register
You forgot -Wl,--allow-undefined. The compile scripts already include
this — if you're invoking clang manually, add the flag.
#include "velxio-chip.h" — file not found
You missed -I path/to/sdk/include. The header lives at
test/test_custom_chips/sdk/include/velxio-chip.h (sandbox) or
backend/sdk/velxio-chip.h (backend).
error: "vx_i2c_config must be 64 bytes" (static assert)
Your local header is out of sync with the runtime. Re-pull the latest
velxio-chip.h from backend/sdk/.
argument '--target=wasm32-wasi' is deprecated
You're using the old target name. The compile scripts use
wasm32-unknown-wasip1 (the modern equivalent). Update your invocation.
Runtime errors
Chip console: Chip WASM imports missing in host: env.foo_bar
Your chip uses an extern that the runtime doesn't provide. Either:
- The function name is wrong (typo of a
vx_*function). - You added a custom
externthinking the host would provide it. The host only provides the functions invelxio-chip.h.
Chip seems to do nothing on canvas
- Is the chip wired? Open the chip and check pins are connected to the Arduino.
- Is the simulation running?
- Did
chip_setupcomplete? Look at[chip:<id>]lines in the browser console —vx_logandprintfoutput land there.
Chip works in the sandbox but not in the browser
- Did you click "Compile" + "Save & Place" after editing the C? The
wasm_base64is embedded into the component's properties at save time. - For AVR / RP2040, the chip runs in the browser. For ESP32, the chip runs in
the backend Python process — see
custom-chips-esp32-backend-runtime.mdfor the architecture. If logs and pin updates don't appear, check the worker stderr in the backend logs (search for[custom-chip]). - Check the board support matrix for protocol coverage per board.
ESP32 specific
On ESP32 the chip's .wasm is loaded inside the same Python process that
hosts QEMU (backend/app/services/wasm_chip_runtime.py via wasmtime). All
peripheral callbacks fire synchronously in the QEMU thread:
| Chip API | ESP32 hook | Notes |
|---|---|---|
vx_pin_write |
qemu_picsimlab_set_pin(gpio + 1, value) |
Drives GPIO input |
vx_pin_read |
Cached _pin_state[gpio] from _on_pin_change |
Live |
vx_pin_watch |
Dispatched from _on_pin_change (edge filtered) |
Sync, lock held |
vx_i2c_attach |
Registered as _i2c_slaves[addr] |
_on_i2c_event |
vx_uart_attach / vx_uart_write |
_on_uart_tx ↔ qemu_picsimlab_uart_receive |
UART0 only |
vx_spi_attach |
Dispatched from _on_spi_event (op = data << 8) |
Re-arm pattern supported |
vx_timer_* |
Dedicated scheduler thread that takes the IO-thread lock | Wakes on soonest deadline |
chip_log and chip_error events flow back over the WebSocket as a
telemetry channel. Stubs (vx_pin_dac_write, framebuffer) emit chip_warning
instead of crashing.
ESP32 QEMU exits immediately, "unsupported machine type: esp32"
The upstream qemu-system-xtensa binary doesn't include the ESP32 machine
model. Velxio uses the lcgamboa fork as a shared library
(libqemu-xtensa.dll/.so). The Docker image bundles it; for native dev,
copy it from a running container:
docker cp velxio-dev:/app/app/services/libqemu-xtensa.dll \
backend/app/services/
Then restart uvicorn so it picks up LIB_PATH.
File-size sanity
A typical chip is 40-80 KB of WASM (most of that is wasi-libc's
malloc/printf family). If yours is dramatically larger, check whether
you're pulling in <math.h> or <stdio.h> features you don't need.
Replacing printf with vx_log shaves about 15 KB.
If yours is smaller than 1 KB, something probably went wrong — the host
expects a real WASM module with the magic bytes \x00asm. Run
xxd mychip.wasm | head -2 to confirm the magic.