Your first mod¶
The template, hello-az, is a complete mod: a page under Mod Menu > Community Mods >
Installed > Hello AZ with one setting (show track titles) and a live list of each deck's
BPM and track title. The sections below walk through its source, template/hello-az.c.
1. Start from the template¶
azmod-pack new ../beat-counter --id beat-counter --author your-name
azmod-pack build ../beat-counter
new copies the template and renames hello-az to the id in the source, the Makefile,
azmod.json and the release workflow. It points the Makefile's AZ_SDK and AZ_SDK_INCLUDE
at this SDK, relative to the new folder so that make test finds the harness, and fills
azmod.json with placeholders:
beat-counter/
beat-counter.c the mod
azmod.json the manifest
icon.png 256x256, shown in the Mod Menu's Browse grid
Makefile build flags (see the Makefile's header for each one)
LICENSE GPL-3.0
README.md
.github/workflows/release.yml release on a pushed tag
The mod's sources can be C, C++ or both; see Can I use C++?.
The id is 3 to 32 characters drawn from a-z, 0-9 and -, and cannot be any of enabled,
azmod, azstem, az-mods or azmods. It names the mod's folder on the USB, the prefix of
its settings in MODS/azmods.cfg, its log and its package, and it never changes once the mod
is published. It must also be unique across every repository a deck reads; see Pick an ID that stays yours.
2. The manifest: azmod.json¶
{
"azmod": 1,
"id": "beat-counter",
"name": "Beat Counter",
"version": "0.1.0",
"author": "your-name",
"summary": "Describe in one line what your mod does.",
"description": "Describe what your mod changes on the deck ...",
"license": "GPL-3.0-only",
"source": "https://github.com/your-name/beat-counter",
"api": 1,
"firmware": ["any"],
"hooks": false,
"min_core": "1.2.3",
"category": "tools"
}
The core reads the manifest before any of the mod's code runs.
| Field | For this mod |
|---|---|
firmware, hooks |
["any"] and false: the mod uses services only, so it loads on every firmware AZ-MODS supports. For hook mods, see Hooks and firmware tags |
source |
A public https:// link to the source. A deck refuses a mod without one (No source link in azmod.json) |
summary, description |
What users read before installing: up to 119 characters, in plain text |
min_core |
The oldest AZ-MODS release the mod runs on. An older core shows Needs AZ-MODS <min_core> or newer and does not load the mod |
category |
Where the mod is listed in Browse on the deck: visual (something on the screen), controls (buttons, pads, jog wheels), audio (sound, mixing, stems) or tools (library, settings, utilities). Without one, the mod is listed under Other |
beta |
Optional. true while the mod is not proven stable yet: the deck marks it BETA in Browse and on its page (AZ-MODS 1.2.5 and newer). Remove it, or set false, once it is |
shots/*.png |
Not a field: up to 3 screenshots in a shots/ folder beside azmod.json, shown on the mod's page on the deck, AZ-MODS 1.2.5 and newer (limits) |
For every field, see Packaging and publishing.
3. The descriptor and entry point¶
Every mod ends with its descriptor and entry point:
static const char *const FW_TAGS[] = { "any", NULL };
static const az_mod_v1 MOD = {
.abi = AZ_MOD_ABI,
.size = sizeof(az_mod_v1),
.id = MOD_ID, /* "beat-counter", same as azmod.json */
.fw_tags = FW_TAGS, /* same as "firmware" in azmod.json */
.init = init,
.tick = tick,
.on_frame = NULL,
};
const az_mod_v1 *az_mod_v1_entry(void)
{
return &MOD;
}
az_mod_v1_entry is the only exported symbol. The core calls it, checks the descriptor's ABI,
id and firmware tags against azmod.json, and then calls init.
4. init¶
static const az_core_v1 *C; /* the core's services, kept for later */
static int show_titles = 1;
static int init(const az_core_v1 *core)
{
if (!core || core->abi != AZ_MOD_ABI) return 1;
C = core;
show_titles = C->cfg_get(MOD_ID, "show_titles", 1) ? 1 : 0;
if (C->register_screen(MOD_ID, &SCREEN) != 0)
C->log(MOD_ID, "register_screen refused; staying loaded without a page");
C->log(MOD_ID, "loaded on %s, show_titles=%d", C->fw_tag ? C->fw_tag() : "?", show_titles);
return 0;
}
init runs once, on the core's UI thread, before any other callback.
corestays valid for the life of the process: keep the pointer.- Returning
0keeps the mod loaded. Returning non-zero means the core never calls the mod again and showsIts init() returned <n>in the Mod Menu; the library stays mapped. - The core's services do not exist yet in a C constructor, so do all setup here (Guidelines).
5. How a mod runs¶
After init, a mod runs only from callbacks. It can follow either of two models, or both:
| Polling | Event-driven | |
|---|---|---|
| Entry | tick(), called periodically, up to about 30 times a second at a varying rate |
Callbacks registered in init: deck events, frame and touch filters, surface paints, post_ui, and threads of its own |
| Suits | Reading state and repainting on a change | Reacting to something as it happens, or work that blocks |
| Examples | this template, Set Clock, Deck Overview | Deck Events (.tick = NULL) |
The API reference lists which thread runs each callback and its time budget.
6. Settings: cfg_get and cfg_set¶
Settings are whole numbers, stored as <id>:<key> lines in MODS/azmods.cfg on the USB, so
they follow the USB from deck to deck. Read each one once in init, keep it in a variable, and
write it when it changes:
static void toggle_titles(void)
{
show_titles = !show_titles;
C->cfg_set(MOD_ID, "show_titles", show_titles);
if (AZ_HAS(C, request_redraw)) C->request_redraw();
}
cfg_set returns at once; the core writes the file later, off the caller's thread. For limits,
text settings and timing, see the API reference.
7. A page in the Mod Menu¶
A page is three callbacks in a static const struct, registered from init. The core keeps
the pointer:
static const az_screen_v1 SCREEN = {
.size = sizeof(az_screen_v1),
.on_draw = on_draw, /* paint the page */
.on_input = on_input, /* knob, click, tap, BACK */
.on_enter = on_enter, /* the page just opened (may be NULL) */
};
Drawing¶
on_draw receives a paint context and the rectangle the page owns. Draw only with the core's
helpers, size everything in units of row_height, and take colors from color(AZ_COL_...) so
the page works in the light Day theme and the dark ones.
Keep everything inside area, bottom edge included. On the deck, drawing past the edge is
clipped; in the test harness it fails the draw-area check.
Pages range from the full screen down to the harness's smallest, 120x60 with 24-px rows,
which fits the header and one row. The template draws each checkbox row with
draw_setting_row(), which stops when the next row would not fit:
static int draw_setting_row(az_draw *d, az_rect area, int *y, const char *label, int on,
int highlight, az_rect *hit)
{
const int row = C->row_height(d);
const int pad = row / 4, box = row - 2 * pad;
int px = row / 2;
if (px < 8) px = 8;
if (px > 96) px = 96;
*hit = (az_rect){ 0, 0, 0, 0 };
if (*y + row > area.y + area.h) return 0; /* does not fit: draw nothing */
*hit = (az_rect){ area.x, *y, area.w, row }; /* remembered for taps */
C->draw_checkbox(d, area.x + pad, *y + pad, box, on, highlight);
draw_fitted(d, area.x + 2 * pad + box, *y + pad, label, px, C->color(AZ_COL_TEXT),
area.w - 3 * pad - box);
*y += row;
return 1;
}
A simplified on_draw follows. The template's version also draws a divider and a row per
deck, each with the same bottom check:
static void on_draw(az_draw *d, az_rect area, void *user)
{
int y = area.y;
C->draw_header(d, "Beat Counter");
y += C->row_height(d);
if (!draw_setting_row(d, area, &y, "Show track titles", show_titles, highlighted == 0,
&titles_row))
return; /* the page is full */
}
Text is cut off at the edge, not shortened. The template's draw_fitted() shortens a string by
whole UTF-8 characters until its text_width fits.
Input¶
on_input returns 1 for an event it handled and 0 otherwise. Returning 0 for
AZ_EV_BACK lets the core leave the page. Taps arrive only from inside area:
static int on_input(const az_event *ev, void *user)
{
switch (ev->kind) {
case AZ_EV_CLICK: /* browse knob pressed */
toggle_titles();
return 1;
case AZ_EV_TAP: /* a tap on the page (ev->x, ev->y) */
if (ev->x < titles_row.x || ev->x >= titles_row.x + titles_row.w ||
ev->y < titles_row.y || ev->y >= titles_row.y + titles_row.h)
return 0; /* not on the checkbox row */
toggle_titles();
return 1;
case AZ_EV_KNOB: /* browse knob turned, ev->dir = -1 or +1 */
return 1;
default:
return 0; /* BACK and anything else: the core decides */
}
}
8. Reading the decks¶
int n = C->deck_count(); /* 2 or 4 decks in the current view */
for (int deck = 0; deck < n; deck++) { /* deck 1 is 0 */
if (!C->deck_loaded(deck)) continue;
double bpm = C->deck_bpm(deck); /* 0 = unknown */
char title[96];
if (C->deck_title(deck, title, sizeof title) <= 0)
title[0] = '\0'; /* unknown: show nothing */
}
Each of these calls can report "unknown" (-1, 0 or an empty string). When one does, the mod
does nothing for that deck this time instead of substituting deck 1 or a guess
(Guidelines).
9. tick¶
tick runs on the same thread as the page callbacks, so they share variables without locks.
Its rate varies, so anything timed uses a clock. The template reads the decks, compares them
with what it last showed, and requests at most one repaint every 250 ms of CLOCK_MONOTONIC
time. The example below is simplified to deck 1; the template's tick() compares every deck in
the view:
#include <time.h>
#define MIN_REDRAW_MS 250
static long last_redraw_ms = -MIN_REDRAW_MS;
static long shown_bpm = -1; /* deck 1's BPM as last drawn, x100 */
static int dirty;
static long now_ms(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (long)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
}
static void tick(void)
{
double b = C->deck_bpm(0);
if (!(b > 0.0 && b < 1000.0)) b = 0.0; /* unknown or not a number: casting it is UB */
long bpm = (long)(b * 100.0 + 0.5);
if (bpm != shown_bpm) {
shown_bpm = bpm;
dirty = 1;
}
long now = now_ms();
if (dirty && now - last_redraw_ms >= MIN_REDRAW_MS) {
dirty = 0;
last_redraw_ms = now;
C->request_redraw();
}
}
tick is one of the busy paths: no file or network access, no sleeping, no large allocations,
and a log line only on a change (Guidelines).
The test harness holds tick to the repaint rate set by MAX_REDRAWS in the Makefile (4 for
the template).
10. Change it, build, test¶
To add a second setting, edit beat-counter.c:
- Beside
static int show_titles = 1;addstatic int show_bpm = 1;, and besidestatic az_rect titles_row;addstatic az_rect bpm_row;. - In
init, aftershow_titlesis read:show_bpm = C->cfg_get(MOD_ID, "show_bpm", 1) ? 1 : 0; - In
on_draw, right after thedraw_setting_row(...)call for the titles, add a second call:if (!draw_setting_row(d, area, &y, "Show BPM", show_bpm, highlighted == 1, &bpm_row)) return;On a page too short for it, the row is not drawn andbpm_rowstays empty. - Replace
on_inputwith:
static void toggle_bpm(void)
{
show_bpm = !show_bpm;
C->cfg_set(MOD_ID, "show_bpm", show_bpm);
C->request_redraw();
}
static int in_rect(az_rect r, int x, int y)
{
return x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h;
}
static int on_input(const az_event *ev, void *user)
{
(void)user;
switch (ev->kind) {
case AZ_EV_KNOB: /* two rows: 0 = titles, 1 = BPM */
highlighted = ev->dir < 0 ? 0 : 1;
C->request_redraw();
return 1;
case AZ_EV_CLICK:
if (highlighted == 0) toggle_titles();
else toggle_bpm();
return 1;
case AZ_EV_TAP:
if (in_rect(titles_row, ev->x, ev->y)) { toggle_titles(); return 1; }
if (in_rect(bpm_row, ev->x, ev->y)) { toggle_bpm(); return 1; }
return 0;
default:
return 0; /* BACK and anything else: the core decides */
}
}
azmod-pack build ../beat-counter # compile in the container, then validate
make -C ../beat-counter test # the host test harness (needs clang)
On Windows, make test runs in a Linux container; see Running it.
For what validate checks, see the azmod-pack reference.
For the harness, see Testing on your deck.
Next: Testing on your deck.