diff options
| author | user <user@clank> | 2026-08-14 22:39:18 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-14 22:39:18 +0200 |
| commit | c0b88416fc9543fa348b63be07905f7a33fdbcc3 (patch) | |
| tree | f669fdf1066b2ffeed7d61baae572f4834b7ce62 /src/expr.c | |
| parent | sl0ppty: a tab is something you can name and move (diff) | |
| download | slosh-c0b88416fc9543fa348b63be07905f7a33fdbcc3.tar.gz slosh-c0b88416fc9543fa348b63be07905f7a33fdbcc3.tar.xz slosh-c0b88416fc9543fa348b63be07905f7a33fdbcc3.zip | |
sl0ppty: a shader's strength can be an expression
Adding an effect meant writing C -- in the binary, or since D15 in a shared
library. Both are a lot of ceremony for "dim every other row".
So: `amount` may be an expression, compiled to bytecode when the config is
read and evaluated per cell.
shaders {
dim amount="(y % 2) * 40" // scanlines
dim amount="(x > cols - 10) * 120" // a right margin
tint amount="255 - dist(x, y, curx, cury) * 12" color="#ff5fd7"
}
The scope is the argument, and that is the whole design. Every built-in here
is already colour_op(amount) with the amount a pure function of position --
vignette, zebra, ruler, margin and spotlight differ only in that function --
so the part worth handing to a config is the *number*, not the colour maths.
Three things follow at once: the mixing stays in C where it is fast and
correct; an expression cannot produce an invalid cell, because it produces
one byte; and that byte is cacheable.
The cache is what makes an interpreter affordable, and the numbers are why
this exists at all. Measured on a 200x50 pane, one pass:
native vignette 6.1 ns/cell
constant expression, folded 5.6 ns/cell
positional expression, mapped 5.2 ns/cell
animated (reads the clock) 35.0 ns/cell
A shader you wrote in your config costs what a compiled one does. A program
that does not read the clock is evaluated once per cell into a byte map and
reused until something it reads changes; one that does read the clock is
interpreted per cell, per frame, which is the honest price of animation and
is opt-in. (The earlier measurement of 56ns for interpretation was per
evaluation -- correct, and the reason the map exists rather than an argument
against the language.)
What a program reads is derived by the compiler rather than declared, and
that dependency set *is* the cache key. A stale map is therefore
unrepresentable rather than merely unlikely -- the same rule the layout
follows, arrived at from the other direction. A unit test caught the first
violation of it before anything else did: `cols` and `rows` carried no
dependency flag, on the theory that the key always contains the rect, and so
a program reading only the size looked constant and was folded at compile
time, where every size is zero.
The language is deliberately total. No loops, no recursion, no jumps: even
`?:` compiles to evaluating both sides and selecting, because nothing in it
has an effect. Evaluation is O(program length), so a config cannot spin, and
there is nothing to sandbox. Division by zero is 0 rather than a trap: an
expression that is wrong should look wrong, not take the session with it. One
that does not compile drops that one shader with a warning -- not the config,
and not the half-strength version of what you asked for, which is what the
number path did with garbage and is now also fixed.
shader_t gained the compiled program, so the plugin ABI goes to 2. A plugin
built against 1 is refused with "shader ABI 1, we speak 2" and needs nothing
but a rebuild -- the check earning its keep the first time it was asked to.
Diffstat (limited to 'src/expr.c')
| -rw-r--r-- | src/expr.c | 504 |
1 files changed, 504 insertions, 0 deletions
diff --git a/src/expr.c b/src/expr.c new file mode 100644 index 0000000..3400e34 --- /dev/null +++ b/src/expr.c @@ -0,0 +1,504 @@ +/* The shader expression compiler and its VM. See expr.h for the model. */ +#define _GNU_SOURCE +#include "expr.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +/* ---- bytecode ----------------------------------------------------------- */ + +enum { + OP_PUSH, /* imm */ + OP_VAR, /* imm = which */ + OP_ADD, OP_SUB, OP_MUL, OP_DIV, OP_MOD, OP_NEG, + OP_LT, OP_GT, OP_LE, OP_GE, OP_EQ, OP_NE, + OP_AND, OP_OR, OP_NOT, + OP_MIN, OP_MAX, OP_ABS, OP_CLAMP, OP_DIST, + OP_SELECT, /* cond a b -> a or b, both already evaluated */ +}; + +/* Variable slots, in the order the names table lists them. */ +enum { V_X, V_Y, V_COLS, V_ROWS, V_CURX, V_CURY, V_CURSOR, V_FOCUSED, V_T }; + +static const struct { + const char *name; + int slot; + unsigned dep; +} VARS[] = { + {"x", V_X, EXPR_DEP_POS}, + {"y", V_Y, EXPR_DEP_POS}, + {"cols", V_COLS, EXPR_DEP_SIZE}, + {"rows", V_ROWS, EXPR_DEP_SIZE}, + {"curx", V_CURX, EXPR_DEP_CURSOR}, + {"cury", V_CURY, EXPR_DEP_CURSOR}, + {"cursor", V_CURSOR, EXPR_DEP_CURSOR}, + {"focused", V_FOCUSED, EXPR_DEP_FOCUS}, + {"t", V_T, EXPR_DEP_TIME}, +}; + +/* cols/rows are a dependency even though the map's key always carries the + * rect: what the flag prevents is *folding* them at compile time, where the + * rect is not known and every size reads as zero. Found by a unit test that + * asked what `cols` evaluates to. */ + +typedef struct { + uint8_t op; + int32_t imm; +} insn_t; + +#define MAX_INSNS 256 +#define MAX_STACK 32 + +struct expr_prog { + insn_t code[MAX_INSNS]; + size_t n; + unsigned deps; + bool constant; + int value; /* when constant */ + + /* The memo: the last map this program produced, and what it was produced + * from. Held here rather than by the caller because a program is compiled + * once and shared by every copy of the shader that uses it, so this is the + * one place that outlives a frame. The key is every input the compiler says + * the program reads, which is what makes a stale map unrepresentable rather + * than merely unlikely. */ + uint8_t *map; + size_t map_cap; + bool map_valid; + int k_cols, k_rows, k_curx, k_cury, k_cursor, k_focused; +}; + +/* ---- lexer -------------------------------------------------------------- */ + +typedef struct { + const char *p; + const char *start; + char *err; + size_t errcap; + bool failed; +} parser_t; + +static void fail(parser_t *ps, const char *what) { + if (ps->failed) return; + ps->failed = true; + if (ps->err && ps->errcap) + snprintf(ps->err, ps->errcap, "%s at offset %d", what, + (int)(ps->p - ps->start)); +} + +static void skip_ws(parser_t *ps) { + while (*ps->p == ' ' || *ps->p == '\t' || *ps->p == '\n' || *ps->p == '\r') + ps->p++; +} + +static bool eat(parser_t *ps, const char *tok) { + skip_ws(ps); + size_t n = strlen(tok); + if (strncmp(ps->p, tok, n) != 0) return false; + /* `<` must not eat the `<` of `<=`; callers try the longer one first, but + * an identifier-like token must also not match a prefix of a longer name. */ + ps->p += n; + return true; +} + +static void emit(parser_t *ps, expr_prog_t *pr, uint8_t op, int32_t imm) { + if (pr->n >= MAX_INSNS) { + fail(ps, "expression too long"); + return; + } + pr->code[pr->n++] = (insn_t){op, imm}; +} + +/* ---- parser (precedence climbing) --------------------------------------- */ + +static void parse_expr(parser_t *ps, expr_prog_t *pr); + +static void parse_primary(parser_t *ps, expr_prog_t *pr) { + skip_ws(ps); + if (ps->failed) return; + + if (*ps->p == '(') { + ps->p++; + parse_expr(ps, pr); + skip_ws(ps); + if (*ps->p != ')') { + fail(ps, "expected )"); + return; + } + ps->p++; + return; + } + + if (*ps->p == '-') { + ps->p++; + parse_primary(ps, pr); + emit(ps, pr, OP_NEG, 0); + return; + } + if (*ps->p == '!') { + ps->p++; + parse_primary(ps, pr); + emit(ps, pr, OP_NOT, 0); + return; + } + + if (*ps->p >= '0' && *ps->p <= '9') { + long v = strtol(ps->p, (char **)&ps->p, 10); + if (v > INT32_MAX) v = INT32_MAX; + emit(ps, pr, OP_PUSH, (int32_t)v); + return; + } + + if ((*ps->p >= 'a' && *ps->p <= 'z') || (*ps->p >= 'A' && *ps->p <= 'Z') || + *ps->p == '_') { + const char *s = ps->p; + while ((*ps->p >= 'a' && *ps->p <= 'z') || (*ps->p >= 'A' && *ps->p <= 'Z') || + (*ps->p >= '0' && *ps->p <= '9') || *ps->p == '_') + ps->p++; + size_t len = (size_t)(ps->p - s); + char name[32]; + if (len >= sizeof name) { + fail(ps, "name too long"); + return; + } + memcpy(name, s, len); + name[len] = 0; + + skip_ws(ps); + if (*ps->p == '(') { /* a call */ + ps->p++; + static const struct { + const char *name; + int args; + uint8_t op; + } FNS[] = { + {"min", 2, OP_MIN}, {"max", 2, OP_MAX}, {"abs", 1, OP_ABS}, + {"clamp", 3, OP_CLAMP}, {"dist", 4, OP_DIST}, + }; + for (size_t i = 0; i < sizeof FNS / sizeof *FNS; i++) { + if (strcmp(FNS[i].name, name) != 0) continue; + for (int a = 0; a < FNS[i].args; a++) { + if (a) { + skip_ws(ps); + if (*ps->p != ',') { + fail(ps, "expected ,"); + return; + } + ps->p++; + } + parse_expr(ps, pr); + if (ps->failed) return; + } + skip_ws(ps); + if (*ps->p != ')') { + fail(ps, "expected )"); + return; + } + ps->p++; + emit(ps, pr, FNS[i].op, 0); + return; + } + fail(ps, "unknown function"); + return; + } + + for (size_t i = 0; i < sizeof VARS / sizeof *VARS; i++) { + if (strcmp(VARS[i].name, name) != 0) continue; + pr->deps |= VARS[i].dep; + emit(ps, pr, OP_VAR, VARS[i].slot); + return; + } + fail(ps, "unknown name"); + return; + } + + fail(ps, "expected a value"); +} + +static void parse_mul(parser_t *ps, expr_prog_t *pr) { + parse_primary(ps, pr); + for (;;) { + if (ps->failed) return; + skip_ws(ps); + uint8_t op; + if (*ps->p == '*') op = OP_MUL; + else if (*ps->p == '/') op = OP_DIV; + else if (*ps->p == '%') op = OP_MOD; + else return; + ps->p++; + parse_primary(ps, pr); + emit(ps, pr, op, 0); + } +} + +static void parse_add(parser_t *ps, expr_prog_t *pr) { + parse_mul(ps, pr); + for (;;) { + if (ps->failed) return; + skip_ws(ps); + uint8_t op; + if (*ps->p == '+') op = OP_ADD; + else if (*ps->p == '-') op = OP_SUB; + else return; + ps->p++; + parse_mul(ps, pr); + emit(ps, pr, op, 0); + } +} + +static void parse_cmp(parser_t *ps, expr_prog_t *pr) { + parse_add(ps, pr); + for (;;) { + if (ps->failed) return; + skip_ws(ps); + uint8_t op; + /* Longest first: `<` would otherwise eat the `<` of `<=` and leave an `=` + * that parses as nothing. */ + if (eat(ps, "<=")) op = OP_LE; + else if (eat(ps, ">=")) op = OP_GE; + else if (eat(ps, "==")) op = OP_EQ; + else if (eat(ps, "!=")) op = OP_NE; + else if (*ps->p == '<') { ps->p++; op = OP_LT; } + else if (*ps->p == '>') { ps->p++; op = OP_GT; } + else return; + parse_add(ps, pr); + emit(ps, pr, op, 0); + } +} + +static void parse_and(parser_t *ps, expr_prog_t *pr) { + parse_cmp(ps, pr); + while (!ps->failed && (skip_ws(ps), eat(ps, "&&"))) { + parse_cmp(ps, pr); + emit(ps, pr, OP_AND, 0); + } +} + +static void parse_or(parser_t *ps, expr_prog_t *pr) { + parse_and(ps, pr); + while (!ps->failed && (skip_ws(ps), eat(ps, "||"))) { + parse_and(ps, pr); + emit(ps, pr, OP_OR, 0); + } +} + +/* `c ? a : b` evaluates all three and selects. There are no side effects to + * skip, and no jumps means the VM stays a straight walk over the code, which + * is what keeps evaluation O(program length) and the language total. */ +static void parse_expr(parser_t *ps, expr_prog_t *pr) { + parse_or(ps, pr); + if (ps->failed) return; + skip_ws(ps); + if (*ps->p != '?') return; + ps->p++; + parse_expr(ps, pr); + skip_ws(ps); + if (*ps->p != ':') { + fail(ps, "expected :"); + return; + } + ps->p++; + parse_expr(ps, pr); + emit(ps, pr, OP_SELECT, 0); +} + +/* ---- VM ----------------------------------------------------------------- */ + +static int vm_run(const expr_prog_t *pr, const expr_env_t *env) { + int32_t st[MAX_STACK]; + int sp = 0; + const int32_t vars[] = { + [V_X] = env->x, [V_Y] = env->y, + [V_COLS] = env->cols, [V_ROWS] = env->rows, + [V_CURX] = env->curx, [V_CURY] = env->cury, + [V_CURSOR] = env->cursor, [V_FOCUSED] = env->focused, + [V_T] = (int32_t)(env->t & 0x7fffffff), + }; + + for (size_t i = 0; i < pr->n; i++) { + const insn_t *in = &pr->code[i]; + /* The compiler bounds the stack (see expr_compile), so an overflow here + * would be a compiler bug rather than a program's doing; the guard costs + * a predictable branch and turns that into a wrong colour, not a smash. */ + if (sp >= MAX_STACK - 1) return 0; + switch (in->op) { + case OP_PUSH: st[sp++] = in->imm; break; + case OP_VAR: st[sp++] = vars[in->imm]; break; + case OP_NEG: if (sp) st[sp - 1] = -st[sp - 1]; break; + case OP_NOT: if (sp) st[sp - 1] = !st[sp - 1]; break; + case OP_ABS: if (sp) st[sp - 1] = st[sp - 1] < 0 ? -st[sp - 1] : st[sp - 1]; break; + default: { + int need = in->op == OP_CLAMP ? 3 : in->op == OP_DIST ? 4 + : in->op == OP_SELECT ? 3 : 2; + if (sp < need) return 0; + int32_t b = st[sp - 1], a = st[sp - 2]; + switch (in->op) { + case OP_ADD: st[sp - 2] = a + b; sp--; break; + case OP_SUB: st[sp - 2] = a - b; sp--; break; + case OP_MUL: st[sp - 2] = a * b; sp--; break; + /* Defined rather than trapping: an expression that divides by zero + * should look wrong, not take the session with it. */ + case OP_DIV: st[sp - 2] = b ? a / b : 0; sp--; break; + case OP_MOD: st[sp - 2] = b ? a % b : 0; sp--; break; + case OP_LT: st[sp - 2] = a < b; sp--; break; + case OP_GT: st[sp - 2] = a > b; sp--; break; + case OP_LE: st[sp - 2] = a <= b; sp--; break; + case OP_GE: st[sp - 2] = a >= b; sp--; break; + case OP_EQ: st[sp - 2] = a == b; sp--; break; + case OP_NE: st[sp - 2] = a != b; sp--; break; + case OP_AND: st[sp - 2] = a && b; sp--; break; + case OP_OR: st[sp - 2] = a || b; sp--; break; + case OP_MIN: st[sp - 2] = a < b ? a : b; sp--; break; + case OP_MAX: st[sp - 2] = a > b ? a : b; sp--; break; + case OP_CLAMP: { + int32_t hi = st[sp - 1], lo = st[sp - 2], v = st[sp - 3]; + st[sp - 3] = v < lo ? lo : v > hi ? hi : v; + sp -= 2; + break; + } + case OP_SELECT: { + int32_t f = st[sp - 1], t = st[sp - 2], c = st[sp - 3]; + st[sp - 3] = c ? t : f; + sp -= 2; + break; + } + case OP_DIST: { + /* A cell is about twice as tall as it is wide, so a distance in + * cells counts a row double -- the same correction the layout's + * own centre-distance makes. Without it every "circle" here is an + * ellipse, which is the first thing anyone notices. */ + int32_t y2 = st[sp - 1], x2 = st[sp - 2], y1 = st[sp - 3], + x1 = st[sp - 4]; + int32_t dx = x1 - x2, dy = (y1 - y2) * 2; + int32_t d2 = dx * dx + dy * dy; + /* Integer square root, so `dist` is a distance and not its + * square: expressions that scale linearly with it are what people + * write, and squaring is easy to ask for and hard to undo. */ + int32_t r = 0; + while ((r + 1) * (r + 1) <= d2 && r < 4096) r++; + st[sp - 4] = r; + sp -= 3; + break; + } + default: return 0; + } + break; + } + } + } + return sp ? st[sp - 1] : 0; +} + +/* ---- compile ------------------------------------------------------------ */ + +/* The deepest the stack can get, which is what makes MAX_STACK a fact rather + * than a hope: every opcode's effect on depth is known, so the maximum is a + * walk over the code. */ +static bool stack_fits(const expr_prog_t *pr) { + int sp = 0, peak = 0; + for (size_t i = 0; i < pr->n; i++) { + switch (pr->code[i].op) { + case OP_PUSH: + case OP_VAR: sp++; break; + case OP_NEG: + case OP_NOT: + case OP_ABS: break; + case OP_CLAMP: + case OP_SELECT: sp -= 2; break; + case OP_DIST: sp -= 3; break; + default: sp--; break; + } + if (sp > peak) peak = sp; + if (sp < 0) return false; + } + return peak < MAX_STACK - 1 && sp == 1; +} + +expr_prog_t *expr_compile(const char *src, char *err, size_t errcap) { + if (err && errcap) err[0] = 0; + if (!src || !*src) { + if (err) snprintf(err, errcap, "empty expression"); + return NULL; + } + + expr_prog_t *pr = calloc(1, sizeof *pr); + if (!pr) return NULL; + + parser_t ps = {.p = src, .start = src, .err = err, .errcap = errcap}; + parse_expr(&ps, pr); + skip_ws(&ps); + if (!ps.failed && *ps.p) fail(&ps, "unexpected trailing input"); + if (!ps.failed && !stack_fits(pr)) fail(&ps, "malformed expression"); + + if (ps.failed) { + free(pr); + return NULL; + } + + /* An expression that reads nothing is a number written the long way, and + * every later stage is happier not knowing the difference. */ + if (!pr->deps) { + expr_env_t zero = {0}; + pr->value = vm_run(pr, &zero); + pr->constant = true; + } + return pr; +} + +void expr_free(expr_prog_t *p) { + if (!p) return; + free(p->map); + free(p); +} + +unsigned expr_deps(const expr_prog_t *p) { return p ? p->deps : 0; } +int expr_constant(const expr_prog_t *p) { return p ? p->value : 0; } + +int expr_eval(const expr_prog_t *p, const expr_env_t *env) { + if (!p) return 0; + if (p->constant) return p->value; + return vm_run(p, env); +} + +static uint8_t clamp8(int v) { return (uint8_t)(v < 0 ? 0 : v > 255 ? 255 : v); } + +bool expr_amount_map(expr_prog_t *p, const expr_env_t *env, uint8_t **out) { + if (!p || !out) return false; + if (p->deps & EXPR_DEP_TIME) return false; /* nothing to reuse */ + + size_t cells = (size_t)env->cols * (size_t)env->rows; + if (!cells) return false; + + bool same = p->map_valid && p->k_cols == env->cols && p->k_rows == env->rows; + if (same && (p->deps & EXPR_DEP_CURSOR)) + same = p->k_curx == env->curx && p->k_cury == env->cury && + p->k_cursor == env->cursor; + if (same && (p->deps & EXPR_DEP_FOCUS)) same = p->k_focused == env->focused; + + if (!same) { + if (p->map_cap < cells) { + uint8_t *grown = realloc(p->map, cells); + if (!grown) return false; + p->map = grown; + p->map_cap = cells; + } + expr_env_t e = *env; + for (int y = 0; y < env->rows; y++) { + e.y = y; + for (int x = 0; x < env->cols; x++) { + e.x = x; + p->map[(size_t)y * env->cols + x] = clamp8(vm_run(p, &e)); + } + } + p->map_valid = true; + p->k_cols = env->cols; + p->k_rows = env->rows; + p->k_curx = env->curx; + p->k_cury = env->cury; + p->k_cursor = env->cursor; + p->k_focused = env->focused; + } + *out = p->map; + return true; +} |
