aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-14 22:06:57 +0200
committeruser <user@clank>2026-08-14 22:06:57 +0200
commit2ae507a8a331799875e07c327730a1a4ca0363f4 (patch)
tree685b167c2a87f5e9f877cca31d31f43fd4e71734 /tests
parentsl0ppty: expand ~ in a path that a config file wrote (diff)
downloadslosh-2ae507a8a331799875e07c327730a1a4ca0363f4.tar.gz
slosh-2ae507a8a331799875e07c327730a1a4ca0363f4.tar.xz
slosh-2ae507a8a331799875e07c327730a1a4ca0363f4.zip
sl0ppty: shaders can be loaded from shared libraries
Adding an effect meant editing shader.c and rebuilding, which is fine for us and useless to anyone else. Now any `*.so` in the shader directory that exports a plugin table is loaded at startup, and what it declares is named in the config exactly like a built-in -- same syntax, same three parameters, same pass. Default location is a `shaders` directory beside the config file, so dropping the .so next to config.kdl is the whole installation. The interesting part is what a plugin can see, which is one header. shader_abi.h holds cell_t, color_t, shade_ctx_t, shader_t and nothing else: no libghostty-vt, no pty, no layout, no session. A shader is therefore unable to reach anything but the cell it was handed, not by rule but because nothing else is declared where it can find it. cell_t and color_t moved there from sl0ppty.h for the same reason -- that is the one place they are a contract with code compiled outside this tree rather than an internal type. Loading is deliberately narrow: - refused unless the library reports our ABI version *and* matching struct sizes. The version says which contract; the sizes catch someone editing their copy of the header, which a version number alone calls fine and a running session calls garbled colours; - refused if it redefines an existing name, because a config naming `dim` means the `dim` it was written against; - all-or-nothing per file, and per file: a bad library is a line on stderr and everything else still loads (D9 fail-open); - nothing is ever unloaded. A shade_fn may be sitting in a live config or on a pane, so dlclose() is a jump into an unmapped page. A reload picks up newly *added* plugins; replacing one needs a new session. This is the single exception to D9's "a plugin is a subprocess", and it is argued in D15 rather than drifted into: a shader is called per cell, per pass, per frame, which no subprocess can be in the path of, and the measured alternative -- interpreting bytecode per cell -- is 56ns against 8ns for the compiled function. On trust, `shell` and a layout's `command=` already let a file in the config directory run anything as you, so a .so beside them is not a new boundary. The docs say so plainly rather than implying a sandbox. contrib/shader-plugin is a working skeleton: Makefile, two commented effects (one positional, one animated), and the three rules a shader has to keep. It is built and loaded by the test suite, so a broken example is a failing test rather than someone else's afternoon.
Diffstat (limited to 'tests')
-rw-r--r--tests/shader_plugin_test.c35
-rw-r--r--tests/test_shader_plugin.py163
2 files changed, 198 insertions, 0 deletions
diff --git a/tests/shader_plugin_test.c b/tests/shader_plugin_test.c
new file mode 100644
index 0000000..15639d6
--- /dev/null
+++ b/tests/shader_plugin_test.c
@@ -0,0 +1,35 @@
+/* Fixture for test_shader_plugin.py: a shader plugin with nothing to it.
+ *
+ * Built twice — once as itself, once with -DBAD_ABI to produce a library that
+ * announces a version we do not speak, which is the case that has to fail
+ * safely rather than crash.
+ */
+#include "shader_abi.h"
+
+#ifdef BAD_ABI
+#undef SL0PPTY_SHADER_ABI
+#define SL0PPTY_SHADER_ABI 9999
+#endif
+
+/* Paints every cell's background the configured colour, at full strength when
+ * amount is 255. Deterministic and position-independent, so the test can
+ * assert one cell and mean it. */
+static void sh_fill(const shader_t *sh, const shade_ctx_t *ctx, cell_t *c) {
+ (void)ctx;
+ if (!sh->amount) return;
+ c->bg = sh->color;
+}
+
+/* Uses param, so the test can prove a plugin sees the config's number. */
+static void sh_stripe(const shader_t *sh, const shade_ctx_t *ctx, cell_t *c) {
+ uint16_t n = sh->param ? sh->param : 2;
+ if (ctx->x % n) return;
+ c->bg = sh->color;
+}
+
+static const shader_def_t SHADERS[] = {
+ {"testfill", sh_fill},
+ {"teststripe", sh_stripe},
+};
+
+SL0PPTY_SHADER_PLUGIN("test-fixture", SHADERS)
diff --git a/tests/test_shader_plugin.py b/tests/test_shader_plugin.py
new file mode 100644
index 0000000..9648a0d
--- /dev/null
+++ b/tests/test_shader_plugin.py
@@ -0,0 +1,163 @@
+#!/usr/bin/env python3
+"""Shaders loaded from shared libraries at runtime.
+
+The built-ins are compiled in; these are not. What matters is that a plugin's
+shader is indistinguishable from a built-in once loaded (same config syntax,
+same parameters, same pass), and that everything which can be wrong with a
+library fails safely: a wrong ABI, a name that already exists, a directory
+that is not there.
+
+The example in contrib/ is built and loaded here too, so it cannot rot.
+"""
+import os
+import shutil
+import sys
+import tempfile
+
+from harness import Session, check, report
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+BUILD = os.path.join(HERE, "..", "build")
+GOOD = os.path.join(BUILD, "testshader.so")
+BAD = os.path.join(BUILD, "badshader.so")
+EXAMPLE = os.path.join(BUILD, "exampleshader.so")
+
+# A pane that paints one known cell and then sits still.
+SH = ["/bin/sh", "-c", 'printf "\\033]2;p\\007"; printf "BLOCK\\n"; read x']
+
+
+def session_dir(plugins, config, shaders_subdir=False):
+ """A config file, and the plugins it should find beside it."""
+ d = tempfile.mkdtemp(prefix="sl0ppty-plug-")
+ dest = os.path.join(d, "shaders") if shaders_subdir else d
+ os.makedirs(dest, exist_ok=True)
+ for p in plugins:
+ shutil.copy(p, dest)
+ path = os.path.join(d, "config.kdl")
+ with open(path, "w") as f:
+ f.write(config)
+ return path, dest
+
+
+def bg_at(snap, pane, row=0, col=0):
+ run = snap.style_at(pane["content_x"] + col, pane["content_y"] + row)
+ return (run or {}).get("bg")
+
+
+def fg_at(snap, pane, row=0, col=0):
+ run = snap.style_at(pane["content_x"] + col, pane["content_y"] + row)
+ return (run or {}).get("fg")
+
+
+def test_a_plugin_shader_is_used_like_a_builtin():
+ cfg, d = session_dir([GOOD], 'shader_dir "%s"\nshaders {\n'
+ ' testfill amount=255 color="#00ff00"\n}\n')
+ # the directory is known only after it exists, so write the config now
+ with open(cfg, "w") as f:
+ f.write('shader_dir "%s"\nshaders {\n'
+ ' testfill amount=255 color="#00ff00"\n}\n' % d)
+
+ with Session(SH, cols=40, rows=10, config=cfg) as s:
+ snap = s.until_text("BLOCK")
+ pane = s.pane()
+ check("the loaded shader ran", bg_at(snap, pane) == "#00ff00",
+ str(snap.style_at(pane["content_x"], pane["content_y"])))
+
+
+def test_it_is_found_beside_the_config_by_default():
+ cfg, d = session_dir([GOOD], "", shaders_subdir=True)
+ with open(cfg, "w") as f:
+ f.write('shaders {\n testfill amount=255 color="#0000ff"\n}\n')
+
+ with Session(SH, cols=40, rows=10, config=cfg) as s:
+ snap = s.until_text("BLOCK")
+ check("no shader_dir needed", bg_at(snap, s.pane()) == "#0000ff",
+ snap.screen())
+
+
+def test_the_config_number_reaches_the_plugin():
+ cfg, d = session_dir([GOOD], "")
+ with open(cfg, "w") as f:
+ f.write('shader_dir "%s"\nshaders {\n'
+ ' teststripe amount=255 band=3 color="#ff0000"\n}\n' % d)
+
+ with Session(SH, cols=40, rows=10, config=cfg) as s:
+ snap = s.until_text("BLOCK")
+ pane = s.pane()
+ got = [bg_at(snap, pane, 0, x) for x in range(6)]
+ check("every third column, as asked",
+ got[0] == "#ff0000" and got[3] == "#ff0000" and
+ got[1] != "#ff0000" and got[2] != "#ff0000", str(got))
+
+
+def test_a_stale_plugin_is_refused_and_the_rest_still_load():
+ cfg, d = session_dir([GOOD, BAD], "")
+ with open(cfg, "w") as f:
+ f.write('shader_dir "%s"\nshaders {\n'
+ ' testfill amount=255 color="#00ff00"\n}\n' % d)
+
+ with Session(SH, cols=40, rows=10, config=cfg) as s:
+ snap = s.until_text("BLOCK")
+ check("the session is alive", s.alive())
+ check("the good plugin still loaded",
+ bg_at(snap, s.pane()) == "#00ff00", snap.screen())
+
+
+def test_an_unknown_shader_is_skipped_not_fatal():
+ cfg, d = session_dir([], "")
+ with open(cfg, "w") as f:
+ f.write('shaders {\n nosuchshader amount=255\n dim amount=0\n}\n')
+
+ with Session(SH, cols=40, rows=10, config=cfg) as s:
+ snap = s.until_text("BLOCK")
+ check("the session survives a name it does not know", s.alive())
+ check("and still draws", "BLOCK" in snap.screen(), snap.screen())
+
+
+def test_no_shader_directory_at_all_is_normal():
+ cfg, d = session_dir([], "")
+ with open(cfg, "w") as f:
+ f.write('shader_dir "/nonexistent/sl0ppty/shaders"\n'
+ 'shaders {\n dim amount=100\n}\n')
+
+ with Session(SH, cols=40, rows=10, config=cfg) as s:
+ snap = s.until_text("BLOCK")
+ check("a missing directory is not an error", s.alive())
+ check("and the built-ins are unaffected", "BLOCK" in snap.screen())
+
+
+def test_the_directory_may_be_written_with_a_tilde():
+ cfg, d = session_dir([GOOD], "", shaders_subdir=True)
+ home = os.path.dirname(d)
+ with open(cfg, "w") as f:
+ f.write('shader_dir "~/shaders"\nshaders {\n'
+ ' testfill amount=255 color="#00ff00"\n}\n')
+
+ with Session(SH, cols=40, rows=10, config=cfg, env={"HOME": home}) as s:
+ snap = s.until_text("BLOCK")
+ check("~ in shader_dir is the home directory",
+ bg_at(snap, s.pane()) == "#00ff00", snap.screen())
+
+
+def test_the_contrib_example_builds_and_loads():
+ cfg, d = session_dir([EXAMPLE], "")
+ with open(cfg, "w") as f:
+ f.write('shader_dir "%s"\nshaders {\n'
+ ' checker amount=255 band=2\n}\n' % d)
+
+ with Session(SH, cols=40, rows=10, config=cfg) as s:
+ snap = s.until_text("BLOCK")
+ pane = s.pane()
+ # Asserted on the foreground: the checkerboard darkens towards black
+ # and the default background already *is* black, so two squares that
+ # differ would still read the same there. Reading the wrong channel
+ # would make a working effect look like a broken one.
+ a = fg_at(snap, pane, 0, 0)
+ b = fg_at(snap, pane, 0, 2)
+ check("the shipped example draws something", a != b, f"{a} {b}")
+
+
+for name, fn in sorted(list(globals().items())):
+ if name.startswith("test_"):
+ fn()
+sys.exit(report())