15 KiB
Custom Chips — C API Reference
Complete reference for velxio-chip.h. Every function, struct, enum, and
constant the chip can use.
The header is shipped at:
backend/sdk/velxio-chip.h— bundled with the backend Docker imagetest/test_custom_chips/sdk/include/velxio-chip.h— local sandbox copy
Both are kept in sync; either works as the include path.
Table of contents
- Lifecycle
- Pins
- Attributes
- I2C slave
- SPI slave
- UART
- Timers and time
- Display / framebuffer
- Logging
- Type & constant cheat sheet
- ABI guarantees
Lifecycle
void chip_setup(void);
Required, exported. Called once per chip instance when the simulation starts. Allocate state, register pins, attach peripherals, and subscribe to events here. Do not loop.
Pins
Types and constants
typedef int32_t vx_pin; // Opaque handle returned by vx_pin_register
#define VX_INPUT 0
#define VX_OUTPUT 1
#define VX_INPUT_PULLUP 2
#define VX_INPUT_PULLDOWN 3
#define VX_ANALOG 4
#define VX_OUTPUT_LOW 16 // Initialize the wired pin LOW at register time
#define VX_OUTPUT_HIGH 17 // Initialize the wired pin HIGH at register time
#define VX_LOW 0
#define VX_HIGH 1
#define VX_EDGE_RISING 1
#define VX_EDGE_FALLING 2
#define VX_EDGE_BOTH 3
Use VX_OUTPUT_LOW / VX_OUTPUT_HIGH instead of VX_OUTPUT when you want
the pin to power up at a known level. This eliminates the brief window
between vx_pin_register and your first vx_pin_write during which a plain
VX_OUTPUT pin would default to LOW.
vx_pin_register
vx_pin vx_pin_register(const char* name, vx_pin_mode mode);
Register a logical pin on the chip. name is what appears on the schematic
and what the diagram editor uses to wire your chip. Returns an opaque handle
you'll pass to all other pin functions.
Call only from chip_setup().
chip_state_t* s = malloc(sizeof(chip_state_t));
s->in = vx_pin_register("IN", VX_INPUT);
s->out = vx_pin_register("OUT", VX_OUTPUT_LOW); // starts LOW, no glitch
vx_pin_read
int vx_pin_read(vx_pin p);
Returns the digital state of a pin: 0 (LOW) or 1 (HIGH). If the pin
isn't wired to anything in the diagram, returns 0.
vx_pin_write
void vx_pin_write(vx_pin p, int value);
Drive an OUTPUT pin to value (0 or 1). The host propagates the change
through the wiring graph immediately — any other chip with a pin_watch on
the wired pin will see the edge.
vx_pin_read_analog
double vx_pin_read_analog(vx_pin p);
Read the analog voltage of a pin (0.0 V – 5.0 V on AVR, 0.0 V – 3.3 V on ESP32). Used by ADC chips to sample voltages from potentiometers or sensors.
vx_pin_dac_write
void vx_pin_dac_write(vx_pin p, double voltage);
Drive an analog voltage on a pin. Used by DAC chips.
vx_pin_set_mode
void vx_pin_set_mode(vx_pin p, vx_pin_mode mode);
Change a pin's direction after registration — useful for bidirectional buses (e.g. open-drain protocols where you switch between input and output).
vx_pin_watch
void vx_pin_watch(
vx_pin p,
vx_edge edge,
void (*cb)(void* user_data, vx_pin pin, int value),
void* user_data
);
Subscribe to edge events on a pin. The callback fires when the pin's state crosses the requested edge:
edge |
Fires on |
|---|---|
VX_EDGE_RISING |
LOW → HIGH only |
VX_EDGE_FALLING |
HIGH → LOW only |
VX_EDGE_BOTH |
every transition |
Inside the callback you have access to the pin handle, the new value, and
your user_data pointer (typically a pointer to your chip's state struct).
static void on_clk(void *ud, vx_pin pin, int value) {
chip_state_t *s = (chip_state_t*)ud;
if (value) { // rising edge
s->shift_register <<= 1;
s->shift_register |= vx_pin_read(s->data);
}
}
vx_pin_watch(clk_pin, VX_EDGE_RISING, on_clk, s);
vx_pin_watch_stop
void vx_pin_watch_stop(vx_pin p);
Cancels every watch registered for the given pin. Useful when entering a mode where the chip should ignore inputs (e.g. powered-down state).
Attributes
User-editable parameters that show up in the Custom Chip designer's Attributes panel as sliders or number inputs.
Schema in chip.json
"attributes": [
{ "name": "threshold", "label": "Pulses", "type": "int", "default": 4, "min": 1, "max": 1024 },
{ "name": "gain", "label": "Gain", "type": "float", "default": 1.0, "min": 0, "max": 10, "step": 0.1 }
]
| Field | Effect |
|---|---|
name |
Internal key — what the chip uses in vx_attr_register |
label |
Human-readable text shown next to the slider |
type |
int rounds to integer; float/number keeps decimals |
default |
Initial value |
min/max |
If both present, a slider is shown |
step |
Step size (default 1 for int, 0.01 for float) |
vx_attr_register
vx_attr vx_attr_register(const char* name, double default_val);
Register an attribute. Returns a handle. The default in chip.json takes
precedence over the C-side default if both are set — the C-side default
applies when an instance has no saved value yet.
vx_attr_read
double vx_attr_read(vx_attr a);
Read the current value. Always re-read inside callbacks — the user can change the slider while the simulation runs and your chip should pick up the new value on the next event.
static void on_pulse(void* ud, vx_pin pin, int value) {
chip_state_t* s = (chip_state_t*)ud;
s->count++;
uint32_t threshold = (uint32_t)vx_attr_read(s->threshold); // re-read live
if (s->count >= threshold) {
s->count = 0;
vx_pin_write(s->out, !s->state);
s->state = !s->state;
}
}
I2C slave
Velxio routes I2C bus events from the master (the Arduino sketch's
Wire.beginTransmission(addr)) to your chip when the address matches.
Config struct
typedef struct {
uint8_t address; /* 7-bit I2C address */
uint8_t _pad[3];
vx_pin scl;
vx_pin sda;
bool (*on_connect)(void* user_data, uint8_t addr, bool is_read);
uint8_t(*on_read) (void* user_data);
bool (*on_write) (void* user_data, uint8_t byte);
void (*on_stop) (void* user_data);
void* user_data;
uint32_t reserved[8];
} vx_i2c_config;
_Static_assert(sizeof(vx_i2c_config) == 64, "vx_i2c_config must be 64 bytes");
vx_i2c_attach
vx_i2c vx_i2c_attach(const vx_i2c_config* cfg);
Attach an I2C slave. Call only from chip_setup(). Two instances of the
same chip with different A0/A1/A2 settings can coexist — they get
different addresses.
Callbacks
bool on_connect(void* ud, uint8_t addr, bool is_read);
The master started a transaction. Return true for ACK, false for NACK.
For most chips: just return true;. is_read tells you whether the master
is about to read or write.
uint8_t on_read(void* ud);
The master is reading a byte from your chip. Return the byte to put on SDA. Called once per byte the master clocks out.
bool on_write(void* ud, uint8_t byte);
The master sent a byte. Return true to ACK, false to NACK (e.g. memory
full).
void on_stop(void* ud);
The master issued STOP. Reset any "transaction in progress" state your
chip has — the next on_connect is a fresh transaction.
Example: 24C01 EEPROM
typedef enum { ST_IDLE, ST_HAS_POINTER } ee_state;
typedef struct {
uint8_t pointer;
uint8_t mem[128];
ee_state state;
} chip_state_t;
static bool i2c_connect(void* ud, uint8_t addr, bool is_read) {
chip_state_t* s = ud;
if (!is_read) s->state = ST_IDLE; // fresh write transaction
return true;
}
static uint8_t i2c_read(void* ud) {
chip_state_t* s = ud;
uint8_t b = s->mem[s->pointer & 0x7f];
s->pointer++;
return b;
}
static bool i2c_write(void* ud, uint8_t byte) {
chip_state_t* s = ud;
if (s->state == ST_IDLE) {
s->pointer = byte;
s->state = ST_HAS_POINTER;
} else {
s->mem[s->pointer & 0x7f] = byte;
s->pointer++;
}
return true;
}
void chip_setup(void) {
chip_state_t* s = calloc(1, sizeof(chip_state_t));
vx_i2c_config cfg = {
.address = 0x50,
.scl = vx_pin_register("SCL", VX_INPUT),
.sda = vx_pin_register("SDA", VX_INPUT),
.on_connect = i2c_connect,
.on_read = i2c_read,
.on_write = i2c_write,
.on_stop = NULL, // optional
.user_data = s,
};
vx_i2c_attach(&cfg);
}
SPI slave
Buffer-based bidirectional transfer model. The chip pre-fills a buffer with the bytes to send on MISO; the bus overwrites those bytes with what it received on MOSI.
Config struct
typedef struct {
vx_pin sck;
vx_pin mosi;
vx_pin miso;
vx_pin cs; /* watched by the chip — runtime ignores this field */
uint32_t mode; /* 0..3 */
void (*on_done)(void* user_data, uint8_t* buffer, uint32_t count);
void* user_data;
uint32_t reserved[8];
} vx_spi_config;
_Static_assert(sizeof(vx_spi_config) == 60, "vx_spi_config must be 60 bytes");
Functions
vx_spi vx_spi_attach(const vx_spi_config* cfg);
void vx_spi_start (vx_spi s, uint8_t* buffer, uint32_t count);
void vx_spi_stop (vx_spi s);
How it works
vx_spi_attachregisters the chip on the bus.- The chip calls
vx_spi_start(handle, buf, N)to say "I want to exchange N bytes; here's my MISO data." - As the master clocks bytes, byte by byte:
- the master's MOSI byte overwrites
buf[i] - the chip's
buf[i](its MISO data) is shifted out to the master
- the master's MOSI byte overwrites
- After N bytes,
on_done(buf, N)fires.bufnow contains the N MOSI bytes the master sent.
Re-arming
The chip is not automatically armed for the next transfer. Call
vx_spi_start again inside on_done if you want continuous transfer:
static void on_spi_done(void* ud, uint8_t* buffer, uint32_t count) {
chip_state_t* s = ud;
s->shift_reg = buffer[0];
vx_spi_start(s->spi, s->buf, 1); // re-arm for next byte
}
This is needed for chips like 74HC595 that have no real CS — they shift on every SCK edge as long as data flows.
Using CS for transaction boundaries
For chips with a real chip-select (e.g. MCP3008), the chip watches its CS
pin and triggers vx_spi_start / vx_spi_stop accordingly:
static void on_cs_change(void* ud, vx_pin pin, int value) {
chip_state_t* s = ud;
if (value == VX_LOW) {
vx_spi_start(s->spi, s->buf, 3); // CS asserted — start exchange
} else {
vx_spi_stop(s->spi); // CS released
}
}
vx_pin_watch(s->cs, VX_EDGE_BOTH, on_cs_change, s);
UART
Config struct
typedef struct {
vx_pin rx;
vx_pin tx;
uint32_t baud_rate;
void (*on_rx_byte) (void* user_data, uint8_t byte);
void (*on_tx_done) (void* user_data);
void* user_data;
uint32_t reserved[8];
} vx_uart_config;
_Static_assert(sizeof(vx_uart_config) == 56, "vx_uart_config must be 56 bytes");
Functions
vx_uart vx_uart_attach(const vx_uart_config* cfg);
bool vx_uart_write (vx_uart u, const uint8_t* buffer, uint32_t count);
Example: ROT13 chip
static void on_rx(void* ud, uint8_t byte) {
chip_state_t* s = ud;
uint8_t out = byte;
if (out >= 'A' && out <= 'Z') out = ((out - 'A' + 13) % 26) + 'A';
if (out >= 'a' && out <= 'z') out = ((out - 'a' + 13) % 26) + 'a';
vx_uart_write(s->uart, &out, 1); // echo back transformed byte
}
void chip_setup(void) {
chip_state_t* s = malloc(sizeof(chip_state_t));
vx_uart_config cfg = {
.rx = vx_pin_register("RX", VX_INPUT),
.tx = vx_pin_register("TX", VX_INPUT_PULLUP),
.baud_rate = 115200,
.on_rx_byte = on_rx,
.on_tx_done = NULL,
.user_data = s,
};
s->uart = vx_uart_attach(&cfg);
}
When the user wires the chip's RX pin to the Arduino's pin 1 (TX0), the
host bridges them automatically: every byte the sketch sends with
Serial.write() triggers your on_rx callback. Your vx_uart_write calls
land in Serial.read()'s buffer.
Timers and time
uint64_t vx_sim_now_nanos(void);
vx_timer vx_timer_create(void (*cb)(void* user_data), void* user_data);
void vx_timer_start (vx_timer t, uint64_t period_nanos, bool repeat);
void vx_timer_stop (vx_timer t);
Timer ticks are anchored to simulated time — they fire deterministically relative to CPU cycles, not wall-clock seconds. A 1-ms timer will fire after exactly 1 ms of simulated AVR time regardless of how fast the host actually runs.
static void on_tick(void* ud) {
chip_state_t* s = ud;
vx_pin_write(s->led, !vx_pin_read(s->led)); // blink at 1 Hz
}
void chip_setup(void) {
chip_state_t* s = malloc(sizeof(chip_state_t));
s->led = vx_pin_register("LED", VX_OUTPUT_LOW);
vx_timer t = vx_timer_create(on_tick, s);
vx_timer_start(t, 500000000, true); // 500 ms, repeating
}
Display / framebuffer
For chips that drive a screen.
Schema in chip.json
"display": { "width": 128, "height": 64 }
Adding this enables a <canvas> inside the chip's web component on the
canvas. The chip writes RGBA pixels to a framebuffer; the host repaints the
canvas after each write.
Functions
typedef int32_t vx_buffer;
vx_buffer vx_framebuffer_init(uint32_t* out_width, uint32_t* out_height);
void vx_buffer_write (vx_buffer buf, uint32_t offset, const void* data, uint32_t data_len);
Pixel format
Row-major RGBA8888, no padding. Pixel (x, y) lives at byte offset
(y * width + x) * 4, bytes R G B A.
Example
uint32_t w, h;
vx_buffer fb = vx_framebuffer_init(&w, &h);
// Fill the screen green
uint8_t green[4] = {0, 0xFF, 0, 0xFF};
for (uint32_t y = 0; y < h; y++) {
for (uint32_t x = 0; x < w; x++) {
vx_buffer_write(fb, (y * w + x) * 4, green, 4);
}
}
For real LCDs you typically convert RGB565 → RGBA8888 inline before writing.
Logging
void vx_log(const char* msg);
Print a message to the host's chip log (browser dev console, prefixed with
[chip:<componentId>]).
printf also works — it's routed through WASI's fd_write syscall to the
same log.
vx_log("EEPROM ready");
printf("Temperature: %.2f °C\n", temp);
Type & constant cheat sheet
// Opaque handles (all int32_t under the hood)
vx_pin // pin handle
vx_attr // attribute handle
vx_i2c // I2C device handle
vx_uart // UART handle
vx_spi // SPI handle
vx_timer // timer handle
vx_buffer // framebuffer handle
// Pin modes
VX_INPUT, VX_OUTPUT, VX_INPUT_PULLUP, VX_INPUT_PULLDOWN, VX_ANALOG
VX_OUTPUT_LOW, VX_OUTPUT_HIGH
// Pin values
VX_LOW (0), VX_HIGH (1)
// Edge mask (combine with bitwise OR if needed)
VX_EDGE_RISING (1), VX_EDGE_FALLING (2), VX_EDGE_BOTH (3)
ABI guarantees
These are checked at compile time inside the header:
sizeof(vx_i2c_config) == 64sizeof(vx_uart_config) == 56sizeof(vx_spi_config) == 60
If any of these change, your chip won't compile until the runtime side is updated to match. This is intentional — it catches ABI drift early.
Each config struct also has a uint32_t reserved[8] field at the end. Zero
it out (the Velxio header initializer literally = {.field = ...} syntax
zeros unmentioned fields). Future versions may use those slots; today they
must be 0.