velxio/docs/wiki/custom-chips-build-and-test.md

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

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 with VELXIO_BACKEND_URL.
  • For ESP32 tests: the lcgamboa libqemu-xtensa.{dll,so} must be at backend/app/services/. The Docker image bundles it; for local dev you can docker cp it 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 extern thinking the host would provide it. The host only provides the functions in velxio-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_setup complete? Look at [chip:<id>] lines in the browser console — vx_log and printf output land there.

Chip works in the sandbox but not in the browser

  • Did you click "Compile" + "Save & Place" after editing the C? The wasm_base64 is 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.md for 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_txqemu_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.