This guide walks through building a complete rgbx animation extension from nothing, in either C or C++. The animation we build — a bar that sweeps across the display — is deliberately unremarkable; the point is that it touches every concept you get: parameters the phone can edit, inputs from the hardware (motion, audio, buttons), pixel output, and the good-moment signal.
By the end you will have a .llext you can copy onto the glasses and a .wasm you can run in a browser.
You do not need a firmware checkout, a Zephyr toolchain, or hardware to follow along.
1. Get the template
Fork or "Use this template" on rgbx-extension-template, then clone your copy and build it once to make sure the toolchain works:
The first run downloads the pinned compilers and the rgbx-sdk for the firmware release you target; after that it is fast. It produces both outputs side by side:
build/arm/<name>.llext # runs on the glasses
build/wasm/<name>.wasm # runs in the simulator
Your extension is one source file — src/main.c in the template, which you can rename to a .cpp extension to write C++ instead. One extension is one translation unit.
2. What an extension actually is
The firmware runs your code in a sandbox: a user-mode thread with its own memory. Your extension never calls into the firmware. Instead you export a few symbols, and the host reads and writes them around each frame:
- Before every frame it fills in a
rgbx_inputs struct — the current parameter values plus a snapshot of the sensors.
- It calls your
rgbx_tick().
- It copies your
rgbx_framebuffer to the display.
That is the whole contract. It lives in rgbx_api.h, and every symbol in it is documented in this reference.
Because there are no function imports, there is nothing to "call wrong" — but it also means a few rules are absolute:
- Your dimensions must match the display (40 × 12 on proto0) or the firmware refuses to load the extension.
- Globals reset on every activation. The extension is unloaded whenever it is not the active animation, so
rgbx_init() runs fresh each time. Do not expect state to survive a switch away and back.
- Overrun the per-tick budget and you get killed. A crash or a hang aborts the sandbox, shows a
FAULT: banner, and switches the animation off — the rest of the firmware keeps running.
C++ authors can use rgbx_animation.h, a header-only wrapper that turns the above into a class with typed accessors. It compiles down to exactly the same C symbols. We will use it for the main walkthrough and show the raw-C equivalent at the end.
3. Declare your parameters
Parameters are how the companion app controls your animation. Each one becomes a BLE characteristic and shows up automatically in the app — you write no app code. There are five types:
| Type | Control in the app | You read it with |
RGBX_PARAM_UINT32 | number field | paramU32(i) |
RGBX_PARAM_COLOR | color picker | paramColor(i) → 0x00RRGGBB |
RGBX_PARAM_BOOL | toggle | paramBool(i) |
RGBX_PARAM_STRING | text field | paramString(i) |
RGBX_PARAM_FLOAT | decimal field | paramF32(i) |
Declare them where you instantiate your animation:
#define RGBX_ANIMATION(ClassName, DisplayName, W, H,...)
Instantiates ClassName as the extension's animation and emits the five required C exports (see rgbx_a...
Definition rgbx_animation.h:308
#define RGBX_PARAM_F32(name_, default_f32_)
Initializer for one RGBX_PARAM_FLOAT rgbx_param_desc entry.
Definition rgbx_api.h:221
#define RGBX_PARAM(name_, type_, default_u32_)
Initializer for one scalar (UINT32/COLOR/BOOL) rgbx_param_desc entry.
Definition rgbx_api.h:195
#define RGBX_PARAM_STR(name_, default_str_)
Initializer for one RGBX_PARAM_STRING rgbx_param_desc entry.
Definition rgbx_api.h:205
@ RGBX_PARAM_UINT32
plain unsigned integer (4-byte BLE value)
Definition rgbx_api.h:69
@ RGBX_PARAM_COLOR
0x00RRGGBB, high byte ignored (color picker in the companion app)
Definition rgbx_api.h:70
@ RGBX_PARAM_BOOL
0 or 1 (toggle in the companion app; 1-byte BLE value)
Definition rgbx_api.h:72
You get up to 16 parameters, of which at most 4 may be strings (31 bytes each).
Float params need their own macro and accessor. Declare with RGBX_PARAM_F32(...), never RGBX_PARAM("Gain", RGBX_PARAM_FLOAT, 1.5) — that compiles, but stores (uint32_t)1.5 == 1 in the union's integer member, which read back as float bits is ~1.4e-45, so your default is silently ~0. Read with paramF32(i); paramU32(i) on a float param returns the raw IEEE-754 bit pattern. Float params also require firmware at or above the release that introduced them — an older device rejects the whole extension with "bad param type".
Index by declaration order. paramU32(0) is the first parameter listed, and there is no name lookup. Give the indices names — an enum at the top of the file — and keep it next to the RGBX_ANIMATION() list, because inserting a parameter in the middle silently renumbers everything after it.
4. Initialize
init() runs once per activation, before the first tick:
void init() override {
headX_ = 0.0f;
phase_ = 0.0f;
}
void printk(const char *fmt,...)
Print a formatted message to the device console.
Parameters are not readable yet. They arrive with the first tick(). Reading them in init() gets you defaults at best. If your setup depends on a parameter, do it on the first tick instead.
printk() works from inside the sandbox and shows up on the serial console — handy while bringing an animation up. It comes from <rgbx/rgbx_sys.h>:
SPDX-License-Identifier: MIT Copyright (c) 2026 Stuart Alldritt.
Never hand-write a prototype for printk or anything else on the allowed list. extern "C" matches on the name, so a wrong signature still links — and then does something different on each target. The classic is void printk(...) vs int printk(...): on ARM the wrong one is usually survivable, but WebAssembly types calls by full signature, so in the simulator it traps on the first call with RuntimeError: unreachable and nothing in the build says why. <rgbx/rgbx_sys.h> has the right declarations for the whole sanctioned surface (it pulls in <string.h> and <math.h> for you) — include it and the mistake becomes a compile error instead.
For the record, printk returns void. It is Zephyr's, not printf's.
5. Read the inputs
Everything for the current frame arrives together. Every input reads zero when its source is absent, so you never need to check whether a board has an IMU or a microphone:
const float tilt = accelX() / 9.81f;
const bool kick = isBeat(0);
const float level = bandEnergy(0);
const float bucket = displayBucket(3);
if (buttonWasPressed(0)) {
reverse_ = !reverse_;
}
tick() also receives dt_ms, the nominal milliseconds since the last frame. Scale motion by it rather than assuming a frame rate.
6. Draw
fill() clears, setPixel() writes one pixel, and out-of-range coordinates are ignored:
fill(0, 0, 0);
setPixel(x, y, r, g, b);
Draw at full scale. The firmware multiplies every pixel by a global brightness factor — 0.02 by default. An animation that "dims itself" to 32/255 is simply invisible on the panel. Use the full 0–255 range and let the firmware scale it.
In raw C you write the framebuffer yourself, using RGBX_PIXEL_INDEX to find the offset:
#define RGBX_PIXEL_INDEX(w, x, y)
Byte offset of pixel (x, y) in rgbx_framebuffer for a display w pixels wide.
Definition rgbx_api.h:237
uint8_t rgbx_framebuffer[]
The scratch framebuffer the extension renders into, sized exactly width * height * 3 bytes (see RGBX_...
7. Signal good moments
The firmware has a shuffle mode that rotates between animations. If it switches mid-sweep your animation looks like it glitched. goodMoment() lets you say when a switch would look natural:
bool goodMoment() const override { return wrapped_; }
Return true at real boundaries — the end of a scroll, a clip, a cycle. The default is true for every frame, which is fine for animations with no structure. In raw C this is the optional rgbx_good_moment export; set it to 1 or 0 during your tick.
8. Build
The build is gated, and the gates are the useful part. The ARM build checks that every symbol you reference actually exists on the device, that your section layout is loadable, and that you fit in the 24 KB extension heap. The wasm build checks you import nothing.
The most common failure is calling something the firmware does not export. You get the string and memory functions, printk/vprintk, single-precision libm (sinf, cosf, sqrtf, atan2f, …), and the 64-bit division helpers — the full list is allowed-symbols.txt, and <rgbx/rgbx_sys.h> declares all of it. Notably all double-precision math is unavailable: write sinf(x), not sin(x), and 1.0f, not 1.0.
Including <rgbx/rgbx_sys.h> does not restrict what you can call — it pulls in the real <string.h> and <math.h>, which declare plenty the device does not export. That is what this gate is for; the header's job is making sure the sanctioned calls have the right signature, which no gate can check for you.
9. Try it in the simulator
Drag build/wasm/<name>.wasm onto https://rgb-sunglasses.autom8ed.com/sim/. It runs your actual source with the firmware's tick semantics and the real audio DSP, reads your device's microphone and motion sensors, and renders the true panel layout — no hardware needed. This is the fast iteration loop.
A green simulator run does not mean it loads on the device. The simulator links libc and libm statically, so a call outside the exported surface (that sin() instead of sinf()) works there and fails on the glasses. The ARM build in step 8 is what proves that — always do both.
10. Install it on your glasses
Copy the .llext into /NAND:/ext/ on the board's USB mass-storage disk, then reboot so the firmware re-mounts the filesystem and rescans:
cp build/arm/starter.llext /mnt/sunglasses-fs/ext/
sync && umount /mnt/sunglasses-fs
Your animation then appears in the companion app like any built-in one, with a control for each parameter you declared.
Extensions must come from the same firmware release they were built against — the ABI version and display dimensions both have to match, or the firmware rejects the file.
11. Publish it
Add your repo to the registry and every firmware release will rebuild your extension from a pinned commit and ship it to users automatically. See Community extension registry.
The complete example (C++)
Everything above, in one file. This compiles and passes the device gates as-is.
#define WIDTH 40
#define HEIGHT 12
enum {
kSpeed = 0,
kColor,
kMirror,
kLabel,
};
static const float kTwoPi = 6.2831853f;
public:
headX_ = 0.0f;
phase_ = 0.0f;
reverse_ = false;
wrapped_ = false;
}
void tick(uint32_t dt_ms)
override {
const float dt = static_cast<float>(dt_ms) / 1000.0f;
const float speed =
static_cast<float>(
paramU32(kSpeed));
int barW = 0;
for (
const char *p =
paramString(kLabel); *p !=
'\0'; p++) {
barW++;
}
if (barW < 1) {
barW = 1;
}
reverse_ = !reverse_;
}
const float tilt =
accelX() / 9.81f;
const int centerRow = HEIGHT / 2 + static_cast<int>(tilt * (HEIGHT / 2));
headX_ += speed * dt * (reverse_ ? -1.0f : 1.0f);
wrapped_ = false;
if (headX_ >= static_cast<float>(WIDTH)) {
headX_ -= static_cast<float>(WIDTH);
wrapped_ = true;
} else if (headX_ < 0.0f) {
headX_ += static_cast<float>(WIDTH);
wrapped_ = true;
}
phase_ += dt;
if (phase_ >= kTwoPi) {
phase_ -= kTwoPi;
}
const float pulse = kick ? 1.0f : 0.75f + 0.25f * sinf(phase_);
const uint8_t r = scale((color >> 16) & 0xFFu, pulse);
const uint8_t g = scale((color >> 8) & 0xFFu, pulse);
const uint8_t b = scale(color & 0xFFu, pulse);
const int head = static_cast<int>(headX_);
for (int i = 0; i < barW; i++) {
const int x = wrap(head - i, WIDTH);
drawAt(x, centerRow, r, g, b);
if (mirror) {
drawAt(WIDTH - 1 - x, HEIGHT - 1 - centerRow, r, g, b);
}
}
for (int i = 0; i < buckets && i < WIDTH; i++) {
static_cast<size_t>(i),
const uint8_t v = scale(255u, h);
drawAt(i, HEIGHT - 1, v, v, v);
}
}
bool goodMoment()
const override {
return wrapped_; }
private:
static int wrap(int v, int n) { return ((v % n) + n) % n; }
static uint8_t scale(uint32_t channel, float k) {
if (k < 0.0f) {
k = 0.0f;
} else if (k > 1.0f) {
k = 1.0f;
}
return static_cast<uint8_t>(static_cast<float>(channel) * k);
}
void drawAt(int x, int y, uint8_t r, uint8_t g, uint8_t b) {
if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) {
return;
}
setPixel(
static_cast<size_t>(x),
static_cast<size_t>(y), r, g, b);
}
float headX_ = 0.0f;
float phase_ = 0.0f;
bool reverse_ = false;
bool wrapped_ = false;
};
Extension-side analog of the firmware's BaseAnimation.
Definition rgbx_animation.h:59
static constexpr size_t numDisplayBuckets()
Number of fine-grained display buckets.
Definition rgbx_animation.h:242
bool paramBool(size_t i) const
Value of BOOL parameter i.
Definition rgbx_animation.h:155
virtual bool goodMoment() const
Queried after every tick(): return true when the frame just rendered ended at a natural switch bounda...
Definition rgbx_animation.h:82
const char * paramString(size_t i) const
Value of STRING parameter i.
Definition rgbx_animation.h:182
void setPixel(size_t x, size_t y, uint8_t r, uint8_t g, uint8_t b)
Write one pixel (out-of-range coordinates are ignored).
Definition rgbx_animation.h:108
float displayBucket(size_t i) const
Energy of one fine-grained spectrum bucket, for bar-graph style visualisation.
Definition rgbx_animation.h:252
uint32_t paramColor(size_t i) const
Value of COLOR parameter i.
Definition rgbx_animation.h:150
void fill(uint8_t r, uint8_t g, uint8_t b)
Fill the whole framebuffer with one color.
Definition rgbx_animation.h:123
bool buttonWasPressed(size_t id) const
Whether one button was pressed since the previous tick.
Definition rgbx_animation.h:273
virtual void tick(uint32_t dt_ms)=0
Called once per frame; render into the framebuffer via setPixel()/fill().
float accelX() const
Accelerometer X for this tick.
Definition rgbx_animation.h:204
bool isBeat(size_t b) const
Whether a beat fired in one band this frame.
Definition rgbx_animation.h:237
uint32_t paramU32(size_t i) const
Value of UINT32 parameter i.
Definition rgbx_animation.h:138
virtual void init()
Called once, on the sandboxed thread, after every (re)load and before the first tick.
Definition rgbx_animation.h:63
SPDX-License-Identifier: MIT Copyright (c) 2026 Stuart Alldritt.
#define RGBX_AUDIO_BAR_TILT_DB_PER_OCTAVE
Default treble lift: dB added per octave above bucket 0 (pink-noise slope).
Definition rgbx_audio_bars.h:63
static float rgbx_audio_bar_height(float energy, size_t bucket, float floor_db, float range_db, float tilt_db_per_octave)
Bar-height fraction for one bucket under a dB window.
Definition rgbx_audio_bars.h:104
#define RGBX_AUDIO_BAR_FLOOR_DB
Default floor: bucket power (dB) at which a bar starts to light.
Definition rgbx_audio_bars.h:55
#define RGBX_AUDIO_BAR_RANGE_DB
Default range: dB from an empty bar to a full one (3 dB per row on the 12-row proto0 panel).
Definition rgbx_audio_bars.h:59
The same thing in C
If you would rather not use C++, here is the identical animation against the raw ABI. The wrapper generates roughly this.
#include <zephyr/llext/symbol.h>
#define WIDTH 40
#define HEIGHT 12
enum {
P_SPEED = 0,
P_COLOR,
P_MIRROR,
P_LABEL,
};
static const float kTwoPi = 6.2831853f;
};
};
static float head_x;
static float phase;
static int reverse;
static int wrap(int v, int n) { return ((v % n) + n) % n; }
static uint8_t scale(uint32_t channel, float k) {
if (k < 0.0f) {
k = 0.0f;
} else if (k > 1.0f) {
k = 1.0f;
}
return (uint8_t)((float)channel * k);
}
static void draw_at(int x, int y, uint8_t r, uint8_t g, uint8_t b) {
size_t i;
if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) {
return;
}
}
head_x = 0.0f;
phase = 0.0f;
reverse = 0;
}
float tilt, pulse;
int center_row, head, bar_w = 0, i, kick;
uint8_t r, g, b;
const char *label;
while (label[bar_w] != '\0') {
bar_w++;
}
if (bar_w < 1) {
bar_w = 1;
}
reverse = !reverse;
}
center_row = HEIGHT / 2 + (int)(tilt * (HEIGHT / 2));
head_x += speed * dt * (reverse ? -1.0f : 1.0f);
if (head_x >= (float)WIDTH) {
head_x -= (float)WIDTH;
} else if (head_x < 0.0f) {
head_x += (float)WIDTH;
}
phase += dt;
if (phase >= kTwoPi) {
phase -= kTwoPi;
}
pulse = kick ? 1.0f : 0.75f + 0.25f * sinf(phase);
r = scale((color >> 16) & 0xFFu, pulse);
g = scale((color >> 8) & 0xFFu, pulse);
b = scale(color & 0xFFu, pulse);
head = (int)head_x;
for (i = 0; i < bar_w; i++) {
const int x = wrap(head - i, WIDTH);
draw_at(x, center_row, r, g, b);
if (mirror) {
draw_at(WIDTH - 1 - x, HEIGHT - 1 - center_row, r, g, b);
}
}
const uint8_t v = scale(255u, h);
draw_at(i, HEIGHT - 1, v, v, v);
}
}
SPDX-License-Identifier: MIT Copyright (c) 2026 Stuart Alldritt.
void rgbx_tick(void)
Called once per frame, on the sandboxed thread.
void rgbx_init(void)
Called once, on the sandboxed thread, after every (re)load and before the first tick.
uint8_t rgbx_good_moment
OPTIONAL.
#define RGBX_AUDIO_NUM_DISPLAY_BUCKETS
Number of fine-grained audio display buckets (bar-graph style).
Definition rgbx_api.h:62
#define RGBX_ABI_VERSION
ABI version this header describes.
Definition rgbx_api.h:45
Extension self-description.
Definition rgbx_api.h:128
const struct rgbx_param_desc * params
NULL iff param_count == 0.
Definition rgbx_api.h:137
One user-tunable parameter, surfaced as a BLE characteristic named name on the extension's auto-gener...
Definition rgbx_api.h:111
Things that bite people
- Nothing shows on the panel. You are probably drawing dim. The global brightness factor is 0.02 — draw at full 0–255.
- It builds for wasm but not for ARM. You called something outside the exported surface. Double-precision math is the usual culprit:
sinf, not sin.
- It runs fine for a minute, then stutters. An unbounded phase accumulator feeding
sinf. Wrap it. This is real — it shipped twice; see "Bound your
phase accumulators" in Animation Extensions.
- The wrong parameter changed. Parameters are indexed by declaration order. Adding one in the middle renumbers the rest.
- A string parameter reads empty. In raw C, strings are not in
params[] — the i-th string-typed parameter lives in param_strings[i], counting only strings.
- State vanished. Globals reset on every activation; the extension is unloaded whenever it is not the active animation.
- It loaded yesterday, not today. Extension and firmware must be from the same release — the ABI version and display dimensions have to match.