620 lines
15 KiB
Markdown
620 lines
15 KiB
Markdown
# 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](#lifecycle)
|
||
- [Pins](#pins)
|
||
- [Attributes](#attributes)
|
||
- [I2C slave](#i2c-slave)
|
||
- [SPI slave](#spi-slave)
|
||
- [UART](#uart)
|
||
- [Timers and time](#timers-and-time)
|
||
- [Display / framebuffer](#display--framebuffer)
|
||
- [Logging](#logging)
|
||
- [Type & constant cheat sheet](#type--constant-cheat-sheet)
|
||
- [ABI guarantees](#abi-guarantees)
|
||
|
||
---
|
||
|
||
## Lifecycle
|
||
|
||
```c
|
||
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
|
||
|
||
```c
|
||
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`
|
||
|
||
```c
|
||
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()`.
|
||
|
||
```c
|
||
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`
|
||
|
||
```c
|
||
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`
|
||
|
||
```c
|
||
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`
|
||
|
||
```c
|
||
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`
|
||
|
||
```c
|
||
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`
|
||
|
||
```c
|
||
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`
|
||
|
||
```c
|
||
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).
|
||
|
||
```c
|
||
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`
|
||
|
||
```c
|
||
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`
|
||
|
||
```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`
|
||
|
||
```c
|
||
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`
|
||
|
||
```c
|
||
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.
|
||
|
||
```c
|
||
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
|
||
|
||
```c
|
||
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`
|
||
|
||
```c
|
||
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
|
||
|
||
```c
|
||
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.
|
||
|
||
```c
|
||
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.
|
||
|
||
```c
|
||
bool on_write(void* ud, uint8_t byte);
|
||
```
|
||
The master sent a byte. Return `true` to ACK, `false` to NACK (e.g. memory
|
||
full).
|
||
|
||
```c
|
||
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
|
||
|
||
```c
|
||
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
|
||
|
||
```c
|
||
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
|
||
|
||
```c
|
||
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:
|
||
|
||
```c
|
||
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:
|
||
|
||
```c
|
||
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
|
||
|
||
```c
|
||
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
|
||
|
||
```c
|
||
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
|
||
|
||
```c
|
||
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
|
||
|
||
```c
|
||
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.
|
||
|
||
```c
|
||
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`
|
||
|
||
```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
|
||
|
||
```c
|
||
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
|
||
|
||
```c
|
||
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
|
||
|
||
```c
|
||
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.
|
||
|
||
```c
|
||
vx_log("EEPROM ready");
|
||
printf("Temperature: %.2f °C\n", temp);
|
||
```
|
||
|
||
---
|
||
|
||
## Type & constant cheat sheet
|
||
|
||
```c
|
||
// 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.
|