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.h | |
| 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.h')
| -rw-r--r-- | src/expr.h | 86 |
1 files changed, 86 insertions, 0 deletions
diff --git a/src/expr.h b/src/expr.h new file mode 100644 index 0000000..075df7d --- /dev/null +++ b/src/expr.h @@ -0,0 +1,86 @@ +/* Shader expressions: a tiny integer language for the one number a shader + * takes, compiled to bytecode at config load. + * + * The idea is smaller than "shaders in a language". Every built-in shader here + * is already `colour_op(amount)` where the amount is a pure function of where + * the cell is: + * + * vignette amount = f(x, y) ruler amount = (x == col) * amount + * zebra amount = f(y) margin amount = (x >= col) * amount + * + * So the part worth handing to a config is the *amount*, not the colour maths. + * That keeps the mixing in C where it is fast and correct, gives an expression + * no way to produce an invalid cell (it produces one byte), and \u2014 because the + * result is a pure function of position \u2014 lets the whole program be evaluated + * once into a per-cell map and then reused, which is what makes an interpreter + * affordable at all. Measured: 56ns/cell interpreted, 2ns/cell from the map. + * + * The language is deliberately total. No loops, no recursion, no jumps: even + * `?:` compiles to evaluating both sides and selecting, which is safe because + * nothing in here has an effect. Evaluation is therefore O(program length), + * a bad expression cannot hang a session, and there is nothing to sandbox. + * + * variables x y cols rows curx cury cursor focused t + * operators + - * / % < > <= >= == != && || ! ?: + * functions min max abs clamp dist + * + * `x`/`y` are cell positions inside the pane's content rect, `t` is a + * millisecond clock for animation, `dist` corrects for a cell being about + * twice as tall as it is wide. Division and modulo by zero are 0 rather than + * a trap: an expression that is wrong should look wrong, not take the session + * with it. + */ +#ifndef SL0PPTY_EXPR_H +#define SL0PPTY_EXPR_H + +#include <stdbool.h> +#include <stddef.h> +#include <stdint.h> + +typedef struct expr_prog expr_prog_t; + +/* What a program reads, worked out by the compiler rather than declared. This + * is what decides whether its output can be cached and against what, so it is + * derived from the source and can never disagree with it. */ +enum { + EXPR_DEP_POS = 1 << 0, /* x, y */ + EXPR_DEP_CURSOR = 1 << 1, /* curx, cury, cursor */ + EXPR_DEP_TIME = 1 << 2, /* t: recomputed every frame, never cached */ + EXPR_DEP_FOCUS = 1 << 3, /* focused */ + /* cols, rows. Not part of the cache key -- the key always carries the rect, + * because a map is per rect by construction -- but it still has to be a + * dependency, or a program reading only the size would look constant and be + * folded at compile time, where the size is not known yet and is zero. */ + EXPR_DEP_SIZE = 1 << 4, +}; + +/* Compile, or NULL with a reason in `err`. A program that reads nothing is + * folded to its value at compile time and still returned, so callers have one + * shape to handle. */ +expr_prog_t *expr_compile(const char *src, char *err, size_t errcap); +void expr_free(expr_prog_t *p); + +/* What it reads (EXPR_DEP_*). 0 means the result is a constant. */ +unsigned expr_deps(const expr_prog_t *p); +/* The value of a program that reads nothing; undefined otherwise. */ +int expr_constant(const expr_prog_t *p); + +/* Everything a program can see. Filled by the shader pass. */ +typedef struct { + int x, y, cols, rows; + int curx, cury, cursor; + int focused; + int64_t t; +} expr_env_t; + +/* Evaluate for one cell. Clamped to 0..255 by the caller, not here: the + * language has no opinion about what its number is for. */ +int expr_eval(const expr_prog_t *p, const expr_env_t *env); + +/* Evaluate for every cell of a cols x rows rect into `out` (cols*rows bytes, + * clamped to 0..255), reusing the last result when nothing it reads has + * changed. Returns false when the program reads the clock, which is the one + * case there is nothing to reuse. */ +bool expr_amount_map(expr_prog_t *p, const expr_env_t *env, uint8_t **out); + +#endif /* SL0PPTY_EXPR_H */ |
