velxio/docs/wiki/custom-chips-api-reference.md

15 KiB
Raw Permalink Blame History

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 image
  • test/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

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

  1. vx_spi_attach registers the chip on the bus.
  2. The chip calls vx_spi_start(handle, buf, N) to say "I want to exchange N bytes; here's my MISO data."
  3. 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
  4. After N bytes, on_done(buf, N) fires. buf now 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) == 64
  • sizeof(vx_uart_config) == 56
  • sizeof(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.