velxio/docs/wiki/custom-chips-examples.md

14 KiB
Raw Permalink Blame History

Custom Chips — Examples walkthrough

Velxio ships an in-app gallery of 11 example chips. Each one is a complete, working chip you can study, fork, and modify. They cover every protocol the runtime supports: GPIO, I2C, SPI, UART, attributes, timers.

The C source for all of them lives at:

Each example is also covered by automated tests in test/test_custom_chips/test/ — read those alongside the C source to see the expected behavior under load.


The galleria

# Chip Tier Protocol Lines of C
1 Inverter Beginner GPIO + watch ~25
2 XOR Gate Beginner GPIO × 2 + watch ~30
3 Pulse Counter Beginner GPIO + attributes ~35
4 CD4094 Intermediate GPIO + state machine ~70
5 74HC595 Intermediate SPI slave ~75
6 MCP3008 Intermediate SPI + analog ~85
7 PCF8574 Intermediate I2C slave + GPIO bidir ~75
8 24C01 EEPROM Intermediate I2C slave ~80
9 24LC256 EEPROM Advanced I2C 16-bit addressing ~90
10 DS3231 RTC Advanced I2C state + BCD ~110
11 ROT13 UART Intermediate UART RX/TX ~50

1. Inverter

OUT is always the inverse of IN.

Why study it: simplest possible chip. Shows the full lifecycle: allocate state, register pins, watch one input, react.

typedef struct { vx_pin in, out; } chip_state_t;

static void on_in_change(void *ud, vx_pin pin, int value) {
  chip_state_t *s = ud;
  vx_pin_write(s->out, value ? VX_LOW : VX_HIGH);
}

void chip_setup(void) {
  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);
  vx_pin_write(s->out, vx_pin_read(s->in) ? VX_LOW : VX_HIGH);
  vx_pin_watch(s->in, VX_EDGE_BOTH, on_in_change, s);
}

chip.json:

{ "name": "Inverter", "pins": ["IN", "OUT", "GND", "VCC"], "attributes": [] }

Tested by: test/chips/01_inverter.test.js (3 tests).


2. XOR Gate

OUT = A xor B. Demonstrates watching multiple inputs.

typedef struct { vx_pin a, b, out; } chip_state_t;

static void update_output(chip_state_t* s) {
  int a = vx_pin_read(s->a);
  int b = vx_pin_read(s->b);
  vx_pin_write(s->out, (a ^ b) ? VX_HIGH : VX_LOW);
}

static void on_input_change(void *ud, vx_pin pin, int value) {
  update_output(ud);
}

void chip_setup(void) {
  chip_state_t *s = malloc(sizeof(chip_state_t));
  s->a   = vx_pin_register("A",   VX_INPUT);
  s->b   = vx_pin_register("B",   VX_INPUT);
  s->out = vx_pin_register("OUT", VX_OUTPUT);
  vx_pin_watch(s->a, VX_EDGE_BOTH, on_input_change, s);
  vx_pin_watch(s->b, VX_EDGE_BOTH, on_input_change, s);
  update_output(s);
}

Pattern: when output depends on multiple inputs, write a single update_output() and call it from every input's watch.


3. Pulse Counter

Counts rising edges on PULSE. Toggles OVF every N pulses, where N is user-editable from the UI.

Why study it: shows attributes (vx_attr_register/vx_attr_read) and how the UI's slider value flows into the chip at runtime.

typedef struct {
  vx_pin pulse, ovf, rst;
  vx_attr threshold;
  uint32_t count;
  int ovf_state;
} chip_state_t;

static void on_pulse(void *ud, vx_pin p, int v) {
  chip_state_t *s = ud;
  s->count++;
  uint32_t threshold = (uint32_t)vx_attr_read(s->threshold);  // re-read live
  if (s->count >= threshold) {
    s->count = 0;
    s->ovf_state = !s->ovf_state;
    vx_pin_write(s->ovf, s->ovf_state);
  }
}

static void on_reset(void *ud, vx_pin p, int v) {
  chip_state_t *s = ud;
  s->count = 0;
  s->ovf_state = 0;
  vx_pin_write(s->ovf, VX_LOW);
}

void chip_setup(void) {
  chip_state_t *s = calloc(1, sizeof(chip_state_t));
  s->pulse     = vx_pin_register("PULSE", VX_INPUT);
  s->ovf       = vx_pin_register("OVF",   VX_OUTPUT_LOW);
  s->rst       = vx_pin_register("RST",   VX_INPUT_PULLUP);
  s->threshold = vx_attr_register("threshold", 4.0);
  vx_pin_watch(s->pulse, VX_EDGE_RISING,  on_pulse, s);
  vx_pin_watch(s->rst,   VX_EDGE_FALLING, on_reset, s);
}

chip.json:

{
  "pins": ["PULSE", "OVF", "RST", "GND", "VCC"],
  "attributes": [
    { "name": "threshold", "type": "int", "default": 4, "min": 1, "max": 1024 }
  ]
}

The user gets a slider labeled "threshold" with range 1..1024. Move it during simulation → next pulse picks up the new value.


4. CD4094 shift register

8-stage shift-and-store bus register. Real-world chip with three control pins (CLK / DATA / STR), parallel outputs Q1..Q8, and cascade outputs.

Why study it: state machine with multiple input watches, edge masks (VX_EDGE_RISING for STR, VX_EDGE_BOTH for CLK), and a "no power" guard.

Highlights:

static int has_power(chip_state_t *s) {
  return vx_pin_read(s->VDD) && !vx_pin_read(s->VSS);
}

static void on_clk_change(void *ud, vx_pin pin, int value) {
  chip_state_t *s = ud;
  if (!has_power(s)) {
    for (int i = 0; i < 8; i++) vx_pin_write(s->Q[i], VX_LOW);
    return;
  }
  if (vx_pin_read(s->CLK)) {
    /* rising edge: shift DATA into bit position s->bit */
    if (vx_pin_read(s->DATA)) s->reg |= (1 << s->bit);
    else                      s->reg &= ~(1 << s->bit);
    s->bit = s->bit > 0 ? s->bit - 1 : 7;
  }
}

static void on_strobe_change(void *ud, vx_pin pin, int value) {
  chip_state_t *s = ud;
  for (int i = 0; i < 8; i++) {
    vx_pin_write(s->Q[i], (s->reg >> i) & 1 ? VX_HIGH : VX_LOW);
  }
}

void chip_setup(void) {
  chip_state_t *s = calloc(1, sizeof(chip_state_t));
  /* register VDD/VSS/CLK/DATA/STR/OE/QS/QSN and Q1..Q8 */
  vx_pin_watch(s->CLK, VX_EDGE_BOTH,    on_clk_change,    s);
  vx_pin_watch(s->STR, VX_EDGE_RISING,  on_strobe_change, s);
}

Tested by: test/chips/03_cd4094.test.js (4 tests including a power-gate test).


5. 74HC595 SPI shift register

8-bit SIPO shift register driven over SPI.

Why study it: SPI without CS — the chip shifts on every SCK clock as long as data flows, so it must re-arm vx_spi_start after each byte.

static void on_spi_done(void *ud, uint8_t *buffer, uint32_t count) {
  chip_state_t *s = ud;
  if (count > 0) s->shift_reg = buffer[0];
  vx_spi_start(s->spi, s->spi_buf, 1);   // re-arm immediately
}

static void on_rclk(void *ud, vx_pin pin, int value) {
  chip_state_t *s = ud;
  s->latch_reg = s->shift_reg;
  for (int i = 0; i < 8; i++) {
    vx_pin_write(s->Q[i], (s->latch_reg >> i) & 1);
  }
}

void chip_setup(void) {
  chip_state_t *s = calloc(1, sizeof(chip_state_t));
  /* register SER/SRCLK/RCLK/SRCLR/OE/QH and Q0..Q7 */
  vx_spi_config cfg = {
    .sck = s->SRCLK, .mosi = s->SER, .miso = s->QH,
    .cs = s->RCLK, .mode = 0, .on_done = on_spi_done, .user_data = s,
  };
  s->spi = vx_spi_attach(&cfg);
  vx_spi_start(s->spi, s->spi_buf, 1);   // arm initial transfer

  vx_pin_watch(s->RCLK,  VX_EDGE_RISING,  on_rclk,  s);
}

Pattern: chips with a real CS pin should call vx_spi_start / vx_spi_stop from a CS pin watch instead. See MCP3008 below.


6. MCP3008 SPI ADC

8-channel, 10-bit ADC.

Why study it: SPI with CS-driven transactions, two-phase exchange (the master sends a command, then clocks more bytes to read the result), and analog reads via vx_pin_read_analog.

static void on_cs_change(void *ud, vx_pin pin, int value) {
  chip_state_t *s = ud;
  if (value == VX_LOW) {
    s->buf[0] = s->buf[1] = s->buf[2] = 0xff;
    vx_spi_start(s->spi, s->buf, 3);
  } else {
    vx_spi_stop(s->spi);
  }
}

static void on_spi_done(void *ud, uint8_t *buffer, uint32_t count) {
  chip_state_t *s = ud;
  if (count < 3) return;
  uint8_t channel = (buffer[1] >> 4) & 0x07;
  double voltage = vx_pin_read_analog(s->CH[channel]);
  uint16_t result = (voltage / 5.0) * 1023.0 + 0.5;

  /* Pre-fill response: byte[0] don't care, byte[1] = upper 2 bits, byte[2] = lower 8 */
  s->buf[0] = 0;
  s->buf[1] = (result >> 8) & 0x03;
  s->buf[2] = result & 0xff;
  vx_spi_start(s->spi, s->buf, 3);   // arm response phase
}

Pattern: when a chip needs to send a response, fill the buffer and call vx_spi_start again — the master clocks more bytes which read out the buffer (and overwrite it with whatever the master happens to send next, which the chip can ignore).


7. PCF8574 IO expander

8-bit I2C IO expander. Master writes a byte → 8 GPIO pins reflect that byte. Master reads → returns the current state of the 8 pins.

static bool on_write(void *ud, uint8_t byte) {
  chip_state_t *s = ud;
  s->latched = byte;
  for (int i = 0; i < 8; i++) {
    vx_pin_write(s->P[i], (byte >> i) & 1);
  }
  return true;
}

static uint8_t on_read(void *ud) {
  chip_state_t *s = ud;
  uint8_t v = 0;
  for (int i = 0; i < 8; i++) {
    if (vx_pin_read(s->P[i])) v |= (1 << i);
  }
  return v;
}

Address: A0/A1/A2 are read at chip_setup() time and form the low 3 bits of the 7-bit address (base 0x20). Wire them HIGH/LOW differently to put two chips on the same bus at 0x20 and 0x21.


8. 24C01 EEPROM

128-byte I2C EEPROM with a write pointer that auto-increments.

Why study it: classic I2C protocol with an internal state machine (IDLE → HAS_POINTER) and the standard "first byte after addressing is the register pointer" pattern.

See the full code in API reference → I2C example.

Tested by: test/chips/04_eeprom_24c01.test.js — 4 tests covering basic write/read, auto-increment, wrap at 0x80, and address-pin selection. Also full E2E with a real Arduino sketch using Wire.h in test/e2e/07_chip_eeprom_avr_e2e.test.js.


9. 24LC256 EEPROM

32 KB I2C EEPROM. Same idea as 24C01 but with two-byte addressing.

typedef enum { ST_IDLE, ST_HAS_HIGH, ST_HAS_FULL_ADDRESS } ee_state;

static bool i2c_write(void *ud, uint8_t byte) {
  chip_state_t *s = ud;
  switch (s->state) {
    case ST_IDLE:
      s->pointer = (byte & 0x7f) << 8;       // address high
      s->state = ST_HAS_HIGH;
      break;
    case ST_HAS_HIGH:
      s->pointer = (s->pointer & 0xff00) | byte;   // address low
      s->state = ST_HAS_FULL_ADDRESS;
      break;
    case ST_HAS_FULL_ADDRESS:
      s->mem[s->pointer & (EEPROM_SIZE - 1)] = byte;
      s->pointer = (s->pointer + 1) & (EEPROM_SIZE - 1);
      break;
  }
  return true;
}

The state machine grows naturally as the protocol gets richer. Same pattern extends to 24LC512, 24LC1024, and other big EEPROMs.


10. DS3231 RTC

I2C real-time clock with 19 registers and BCD-encoded values.

Why study it: register pointer + multi-register read auto-increment + encoded values. This is the workhorse pattern for most I2C peripherals (sensors, displays, motor drivers).

static bool on_write(void *ud, uint8_t byte) {
  chip_state_t *s = ud;
  if (s->state == ST_IDLE) {
    s->pointer = byte % RTC_REG_COUNT;     // first byte = register pointer
    s->state = ST_HAS_POINTER;
  } else {
    s->regs[s->pointer] = byte;            // subsequent bytes = data
    s->pointer = (s->pointer + 1) % RTC_REG_COUNT;
  }
  return true;
}

static uint8_t on_read(void *ud) {
  chip_state_t *s = ud;
  uint8_t b = s->regs[s->pointer];
  s->pointer = (s->pointer + 1) % RTC_REG_COUNT;
  return b;
}

Seed values: this chip pre-seeds time to 2026-01-15 12:34:56 (Thursday) inside chip_setup. A real RTC would tick — extending this with a 1-second timer is a great exercise.


11. ROT13 UART

UART loopback that ROT13-shifts every received byte.

Why study it: simplest possible UART chip. Establishes the pattern for any UART peripheral (GPS modules, BT modems, anything Serial-based).

static uint8_t rot13(uint8_t v) {
  if (v >= 'A' && v <= 'Z') return ((v - 'A' + 13) % 26) + 'A';
  if (v >= 'a' && v <= 'z') return ((v - 'a' + 13) % 26) + 'a';
  return v;
}

static void on_rx(void *ud, uint8_t byte) {
  chip_state_t *s = ud;
  uint8_t out = rot13(byte);
  vx_uart_write(s->uart, &out, 1);
}

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 wired to the Arduino's Serial, every Serial.print('A') from the sketch makes the chip echo 'N' — visible in the Serial Monitor.


How to learn from these

A productive workflow:

  1. Pick the example closest to what you want to build. EEPROM-like? Start from 24C01. Logic gate? Start from XOR. Display? See the chip with a display field in chip.json.
  2. Open it in the Custom Chip Designer. Examples tab → click the chip.
  3. Modify it. Change a constant, add a pin, adjust a callback.
  4. Compile and place it. Save & Place puts it on the canvas.
  5. Wire it up. The Arduino sketch in your editor talks to it via the normal Wire/SPI/Serial APIs.
  6. Run. The chip console (browser dev tools) shows your vx_log / printf output.

If something breaks, see Build & test → Troubleshooting.