summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-14 21:49:02 +0900
committerAdam Malczewski <[email protected]>2026-06-14 21:49:02 +0900
commit4b568e52ac8f7c04b72692e619806431abd8d787 (patch)
treedb4b5974ea92bb37e73948fd1846677afb90d152
parent5437b5e33c542c801bdc557be2ae93bfec6e153d (diff)
downloadunbox-4b568e52ac8f7c04b72692e619806431abd8d787.tar.gz
unbox-4b568e52ac8f7c04b72692e619806431abd8d787.zip
spike(kernel): RML compositing Phase-0 — GO (self-verified on Haswell+crocus)
Throwaway, self-contained spike target `packages/kernel/rml-compositing-spike` (build_by_default:false, not in kernel_dep) proving RMLUi can composite live client windows. `--verify` reads back the framebuffer headlessly and asserts all 7 criteria; `--run` brings up a real/nested-seat compositor for the user's real-seat run. All 7 = ALL PASS on this dev box (an Intel Haswell-ULT iGPU on Mesa crocus — the CF-AX3's GPU class): 1 zero-copy live dmabuf→EGLImage→GL texture sampled by RmlUi, cached (0 reimport when buffer unchanged); 2 RCSS perspective+rotateY on the live pixels (verified by readback); 3 screen→surface-local inversion through the 3D transform, round-trip 0.000000 px (pure core doctested in the kernel suite); 4 surface tree (toplevel+subsurface+popup) composited correctly → recommend PER-SUBSURFACE elements (RTT escape-hatch for tree-spanning effects in Phase 1); 5 wallpaper (layer surface) via the identical import path; 6 idle dirty-gate = 0 renders over 120 idle turns, exactly 1 render per commit; 7 present path FBO→dmabuf swapchain→wlr_scene_buffer with an EGL fence (no glFinish). Crocus gotchas documented (linear modifiers, AR24 swizzle, cross-context fence, FBO Y-flip, re-import only on new buffer). Real-seat GO/NO-GO (3D/touch feel, frame-time @4 windows+video, idle power) is the user's call — runbook in reports/rml-compositing-spike.md §5. Also: GLOSSARY rows for "RML compositing" + "surface element" (user-confirmed); tasks.md slice 13 updated to spike-complete/GO pending real-seat. kernel suite green; full build clean; spike --verify ALL PASS.
-rw-r--r--GLOSSARY.md2
-rw-r--r--packages/kernel/meson.build27
-rw-r--r--packages/kernel/src/spike/rml_compositing_spike.cpp557
-rw-r--r--packages/kernel/src/spike/rml_compositing_spike_run.cpp767
-rw-r--r--packages/kernel/src/spike/spike_gl.hpp507
-rw-r--r--packages/kernel/src/spike/spike_input_core.hpp224
-rw-r--r--packages/kernel/tests/test_kernel.cpp98
-rw-r--r--tasks.md11
8 files changed, 2191 insertions, 2 deletions
diff --git a/GLOSSARY.md b/GLOSSARY.md
index 83123a2..bc8e555 100644
--- a/GLOSSARY.md
+++ b/GLOSSARY.md
@@ -46,6 +46,8 @@
| **ui surface** | One RMLUi document an extension contributes, composited as a scene node. | shell surface, overlay, RML window, panel (when meaning the object) |
| **data binding** | RMLUi's model↔document binding; the ONLY way extension state reaches RML. | — |
| **touch-mode** | The substrate state signalling finger input (auto-flipped, debounced). NO automatic visual scaling (user decision, slice 5) — extensions may adapt affordances via the change notification (spacing, invisible hit zones, OSK auto-show). | tablet mode |
+| **RML compositing** | Architecture direction (gated by the slice-13 spike): the RMLUi substrate composites ALL on-screen content — toplevels, layer-shell clients (incl. wallpaper), and chrome — as **surface elements** backed by live, shared GL textures, with layout/animation/3D effects in RCSS. wlroots stays the foundation + hardware cursor plane + fullscreen-video scanout bypass. See `notes/rml-compositing.md`, `notes/plan.md` §2. | RMLUi-as-renderer-only (when meaning this) |
+| **surface element** | An RML element backed by a live client surface's shared GL texture — a toplevel OR a layer surface presented inside the RML compositor. | window element, RML window |
## Input & keybindings
diff --git a/packages/kernel/meson.build b/packages/kernel/meson.build
index 9b4f81b..b53b3ad 100644
--- a/packages/kernel/meson.build
+++ b/packages/kernel/meson.build
@@ -88,3 +88,30 @@ kernel_test = executable(
dependencies: [kernel_dep, doctest_dep],
)
test('kernel', kernel_test, suite: 'kernel')
+
+# ---- SPIKE: RML compositing (Phase 0 GO/NO-GO) -------------------------------
+#
+# A self-contained, RUNNABLE throwaway target (notes/rml-compositing.md §Phase 0,
+# prompts/rml-compositing-spike.md). It is its OWN minimal compositor that maps
+# real clients and composites them as LIVE surface elements inside an RmlUi
+# document, proving the 7 acceptance criteria. Kept OUT of the shipped `unbox`
+# binary: it is NOT in kernel_dep and host-bin never links it; build it
+# explicitly with `ninja -C build rml-compositing-spike`.
+#
+# It reuses the kernel's adapted RmlUi GL3 renderer (src/rmlui_renderer_gl3.cpp)
+# directly, so it needs RMLUi + EGL/GLES (kernel-private deps) AND the same
+# -DUNBOX_RMLUI_GLES native-GLES selection the kernel lib compiles under. The
+# generated layer-shell protocol header rides in via the source list (build
+# order). It links the kernel lib for the renderer + wlr.hpp wrapper.
+rml_compositing_spike = executable(
+ 'rml-compositing-spike',
+ 'src/spike/rml_compositing_spike.cpp',
+ 'src/spike/rml_compositing_spike_run.cpp',
+ 'src/rmlui_renderer_gl3.cpp',
+ wlr_layer_shell_protocol_h,
+ cpp_args: ['-DUNBOX_RMLUI_GLES'],
+ include_directories: kernel_inc,
+ dependencies: [wlroots_dep, wayland_server_dep, xkbcommon_dep, rmlui_dep,
+ egl_dep, glesv2_dep],
+ build_by_default: false,
+)
diff --git a/packages/kernel/src/spike/rml_compositing_spike.cpp b/packages/kernel/src/spike/rml_compositing_spike.cpp
new file mode 100644
index 0000000..231d4bd
--- /dev/null
+++ b/packages/kernel/src/spike/rml_compositing_spike.cpp
@@ -0,0 +1,557 @@
+// SPIKE (rml-compositing, Phase 0) — RUNNABLE GO/NO-GO target. THROWAWAY.
+//
+// Proves the "RML compositing" mechanism: a LIVE client toplevel/layer surface
+// is imported zero-copy as a SHARED GL texture and drawn as a SURFACE ELEMENT
+// (an RML <img>) in an RmlUi document; an RCSS 3D transform + transition is
+// applied to it; input is routed back to the client through RmlUi picking; the
+// composite is presented via the RmlUi-FBO -> wlr_scene_buffer bridge.
+//
+// It is its OWN compositor (display/backend/renderer/allocator/scene/seat +
+// xdg-shell + layer-shell) so it can map real clients, NOT the shipped Server
+// (which names no feature and exposes none of this). It reuses the kernel's
+// proven pieces: the wlr.hpp extern-"C" wrapper, the adapted RenderInterface_GL3
+// (with SetOutputFramebuffer + the upright V-flip), and the slice-3 dmabuf ->
+// EGLImage import discipline. RMLUi is kernel-private and this lives IN the
+// kernel unit, so including the private renderer header is in-bounds.
+//
+// Two modes:
+// --verify : headless + gles2, NO real client. A synthetic client dmabuf
+// (known quadrant pattern) is imported LIVE; a known 3D transform
+// is applied; the presented buffer is read back and asserted
+// against the projected pattern; the idle dirty-gate is asserted
+// (zero renders over N idle turns); the screen->surface-local
+// input inversion is asserted through the transform; then a SECOND
+// bring-up composites a surface TREE (toplevel + subsurface +
+// popup) plus a layer-shell WALLPAPER as per-subsurface elements
+// and reads back each surface's footprint + stack order (criteria
+// 4 + 5). Exit 0 = pass.
+// --run : a real seat (DRM) or nested (labwc) run that spawns a client
+// (default `foot`), composites it live as a 3D surface element,
+// routes input back, and prints per-frame perf + idle metrics for
+// the user's visual/touch/perf GO-NO-GO. Ctrl-C to quit.
+//
+// wlroots only via unbox/kernel/wlr.hpp (.unbox/rules/wlroots-include.md).
+
+#include <unbox/kernel/wlr.hpp>
+
+#include "spike_gl.hpp"
+#include "spike_input_core.hpp"
+
+#include <RmlUi/Core/Context.h>
+#include <RmlUi/Core/Core.h>
+#include <RmlUi/Core/Element.h>
+#include <RmlUi/Core/ElementDocument.h>
+
+#include <cmath>
+#include <cstdint>
+#include <cstdio>
+#include <cstdlib>
+#include <cstring>
+#include <string>
+
+namespace spike = unbox::kernel::spike;
+
+namespace {
+
+int g_fail = 0;
+void check(bool cond, const char* what) {
+ std::fprintf(stderr, "[verify] %-58s %s\n", what, cond ? "PASS" : "FAIL");
+ if (!cond) {
+ ++g_fail;
+ }
+}
+
+// Allocate a real client dmabuf of (w,h) through the wlr allocator and paint it
+// a single solid color via the wlr render pass — exactly the GPU path a client
+// produces. Returns the locked wlr_buffer (caller drops it) or nullptr. `gl`
+// must NOT be current while the wlr renderer runs, so we restore it around the
+// pass and re-make-current after (mirrors the existing criterion-1 painter).
+auto make_solid_client_buffer(spike::GlBridge& gl, wlr_renderer* renderer,
+ wlr_allocator* allocator, int w, int h, float r, float g, float b)
+ -> wlr_buffer* {
+ wlr_drm_format cfmt{};
+ cfmt.format = spike::kArgb8888;
+ std::uint64_t cmods[] = {0};
+ cfmt.len = 1;
+ cfmt.capacity = 1;
+ cfmt.modifiers = cmods;
+ wlr_buffer* buf = wlr_allocator_create_buffer(allocator, w, h, &cfmt);
+ if (buf == nullptr) {
+ return nullptr;
+ }
+ gl.restore_current();
+ wlr_buffer_pass_options po{};
+ wlr_render_pass* pass = wlr_renderer_begin_buffer_pass(renderer, buf, &po);
+ if (pass != nullptr) {
+ wlr_render_rect_options ro{};
+ ro.box = {0, 0, w, h};
+ ro.color = {r, g, b, 1};
+ ro.blend_mode = WLR_RENDER_BLEND_MODE_NONE;
+ wlr_render_pass_add_rect(pass, &ro);
+ wlr_render_pass_submit(pass);
+ }
+ gl.make_current();
+ return buf;
+}
+
+// The verify document: a single surface element (the live client texture) the
+// size of the surface, with an RCSS 3D transform + transition. No body margin so
+// the <img> fills the 256x256 surface 1:1 before transform.
+const char* kVerifyRmlTemplate = R"RML(<rml>
+<head>
+<style>
+body { margin: 0px; padding: 0px; width: 256px; height: 256px;
+ perspective: 800px; }
+#win { display: block; position: absolute; left: 0px; top: 0px;
+ width: 256px; height: 256px;
+ transform: rotateY(0deg);
+ transform-origin: 50% 50%;
+ transition: transform 0.2s linear-in-out; }
+#win img { display: block; width: 256px; height: 256px; }
+</style>
+</head>
+<body>
+<div id="win"><img src="LIVE_URI"/></div>
+</body>
+</rml>)RML";
+
+auto run_verify() -> int {
+ setenv("WLR_BACKENDS", "headless", 1);
+ setenv("WLR_RENDERER", "gles2", 1);
+
+ wlr_log_init(WLR_ERROR, nullptr);
+ wl_display* display = wl_display_create();
+ wl_event_loop* loop = wl_display_get_event_loop(display);
+ wlr_backend* backend = wlr_backend_autocreate(loop, nullptr);
+ wlr_renderer* renderer = wlr_renderer_autocreate(backend);
+ wlr_allocator* allocator = wlr_allocator_autocreate(backend, renderer);
+ wlr_scene* scene = wlr_scene_create();
+
+ if (!wlr_renderer_is_gles2(renderer)) {
+ std::fprintf(stderr, "[verify] SKIP: renderer is not gles2 (no GL path on this box)\n");
+ return 0;
+ }
+ EGLDisplay egl = wlr_egl_get_display(wlr_gles2_renderer_get_egl(renderer));
+
+ spike::GlBridge gl;
+ if (!gl.init(egl)) {
+ std::fprintf(stderr, "[verify] SKIP: sibling GL bridge unavailable\n");
+ return 0;
+ }
+ check(gl.dmabuf_ok, "criterion 1: dmabuf import path available on this GPU");
+ check(gl.fence_ok, "criterion 7: EGL fence-sync (no glFinish) present path active");
+
+ gl.make_current();
+
+ // The "live client buffer": a 256x256 dmabuf allocated through the wlr
+ // allocator (a real dmabuf the client path produces), painted with a quadrant
+ // pattern (TL red, TR green, BL blue, BR white) via the wlr renderer the way
+ // a GPU client would. Imported zero-copy as the live surface element.
+ const int W = 256;
+ spike::LiveTexture live;
+ live.gl = &gl;
+ live.uri = "unbox-live://win";
+
+ wlr_drm_format cfmt{};
+ cfmt.format = spike::kArgb8888;
+ std::uint64_t cmods[] = {0};
+ cfmt.len = 1;
+ cfmt.capacity = 1;
+ cfmt.modifiers = cmods;
+ wlr_buffer* client_buf = wlr_allocator_create_buffer(allocator, W, W, &cfmt);
+ bool live_zero_copy = false;
+ if (client_buf != nullptr) {
+ gl.restore_current();
+ wlr_buffer_pass_options po{};
+ wlr_render_pass* pass = wlr_renderer_begin_buffer_pass(renderer, client_buf, &po);
+ if (pass != nullptr) {
+ const wlr_render_color quad[4] = {
+ {1, 0, 0, 1}, {0, 1, 0, 1}, {0, 0, 1, 1}, {1, 1, 1, 1}};
+ const wlr_box boxes[4] = {{0, 0, W / 2, W / 2},
+ {W / 2, 0, W / 2, W / 2},
+ {0, W / 2, W / 2, W / 2},
+ {W / 2, W / 2, W / 2, W / 2}};
+ for (int i = 0; i < 4; ++i) {
+ wlr_render_rect_options r{};
+ r.box = boxes[i];
+ r.color = quad[i];
+ r.blend_mode = WLR_RENDER_BLEND_MODE_NONE;
+ wlr_render_pass_add_rect(pass, &r);
+ }
+ wlr_render_pass_submit(pass);
+ }
+ gl.make_current();
+ live_zero_copy = live.adopt(client_buf) && live.is_dmabuf;
+ }
+ check(client_buf != nullptr && live.tex != 0,
+ "criterion 1: live client buffer imported as a sampled texture");
+ check(live_zero_copy, "criterion 1: live import is ZERO-COPY dmabuf (not a CPU copy)");
+
+ const int reimports_before = live.reimports;
+ live.adopt(client_buf);
+ live.adopt(client_buf);
+ check(live.reimports == reimports_before,
+ "criterion 1: unchanged buffer is NOT re-imported (cached)");
+
+ std::string rml = kVerifyRmlTemplate;
+ rml.replace(rml.find("LIVE_URI"), 8, live.uri);
+ Rml::Context* ctx = Rml::CreateContext("verify", Rml::Vector2i(W, W), gl.render);
+ Rml::ElementDocument* doc = (ctx != nullptr) ? ctx->LoadDocumentFromMemory(rml) : nullptr;
+ check(doc != nullptr, "verify document loaded");
+ if (doc != nullptr) {
+ doc->Show();
+ }
+
+ spike::PresentTarget present;
+ const bool present_ok = present.init(&gl, allocator, W, W);
+ present.scene_buffer = wlr_scene_buffer_create(&scene->tree, nullptr);
+ check(present_ok, "criterion 7: present FBO -> wlr_buffer target built");
+ check(present.dmabuf, "criterion 7: present buffer is a dmabuf (Plan A swapchain)");
+
+ auto is_color = [](const std::uint8_t p[4], int r, int g, int b) {
+ return std::abs(int(p[0]) - r) < 60 && std::abs(int(p[1]) - g) < 60 &&
+ std::abs(int(p[2]) - b) < 60;
+ };
+
+ if (doc != nullptr) {
+ // ---- Criterion 1+7 untransformed: the live pattern presents UPRIGHT
+ // with the quadrant colors in the right corners (present path renders the
+ // LIVE texture). ----
+ present.render(ctx);
+ std::uint8_t tl[4], tr[4], bl[4], br[4];
+ present.pixel(40, 40, tl);
+ present.pixel(W - 40, 40, tr);
+ present.pixel(40, W - 40, bl);
+ present.pixel(W - 40, W - 40, br);
+ check(is_color(tl, 255, 0, 0), "criterion 1: live TL quadrant red, upright, correct corner");
+ check(is_color(tr, 0, 255, 0), "criterion 1: live TR quadrant green");
+ check(is_color(bl, 0, 0, 255), "criterion 1: live BL quadrant blue");
+ check(is_color(br, 255, 255, 255), "criterion 1: live BR quadrant white");
+
+ // ---- Criterion 2: rotateY(180) (a deterministic endpoint of the 3D
+ // transform+transition) mirrors X about the 50% origin: TL red -> TOP-
+ // RIGHT, TR green -> TOP-LEFT. Reading the swapped corners proves the
+ // LIVE pixels rendered THROUGH the RCSS 3D transform. ----
+ doc->GetElementById("win")->SetProperty("transform", "rotateY(180deg)");
+ ctx->Update();
+ present.render(ctx);
+ std::uint8_t t_left[4], t_right[4];
+ present.pixel(40, 40, t_left);
+ present.pixel(W - 40, 40, t_right);
+ check(is_color(t_right, 255, 0, 0),
+ "criterion 2: rotateY(180) moved live TL-red to the TOP-RIGHT");
+ check(is_color(t_left, 0, 255, 0),
+ "criterion 2: rotateY(180) moved live TR-green to the TOP-LEFT");
+
+ // Mid-rotation under perspective must still SHOW the texture (alpha>0).
+ doc->GetElementById("win")->SetProperty("transform", "rotateY(60deg)");
+ ctx->Update();
+ present.render(ctx);
+ std::uint8_t center[4];
+ present.pixel(W / 2, W / 2, center);
+ check(center[3] > 0, "criterion 2: live texture visible under perspective rotateY(60deg)");
+
+ // Reset to the flat state for the idle-gate measurement.
+ doc->GetElementById("win")->SetProperty("transform", "rotateY(0deg)");
+ ctx->Update();
+ present.render(ctx);
+ }
+
+ // ---- Criterion 6 idle gate: with NO new commit, NO animation, NO input,
+ // OUR gate renders ZERO frames over N event-loop turns. The gate renders only
+ // when a dirty signal fires (client commit / active RCSS animation / input).
+ // ----
+ // The gate's animation signal is RmlUi's own GetNextUpdateDelay(): finite =>
+ // an animation needs the next frame; +inf => nothing is animating (idle). We
+ // gate on (our dirty) OR (animation pending), exactly the design's three
+ // dirty sources (client commit / RCSS animation / input).
+ auto anim_pending = [&]() -> bool {
+ ctx->Update();
+ return std::isfinite(ctx->GetNextUpdateDelay());
+ };
+ if (doc != nullptr) {
+ // Drain any settle frames so the document is fully at rest before we
+ // measure idle (a freshly-shown doc may request one more update).
+ for (int i = 0; i < 8; ++i) {
+ if (anim_pending()) {
+ present.render(ctx);
+ }
+ }
+ int idle_renders = 0;
+ bool dirty = false;
+ for (int turn = 0; turn < 120; ++turn) {
+ wl_event_loop_dispatch(loop, 0);
+ if (dirty || anim_pending()) {
+ present.render(ctx);
+ ++idle_renders;
+ dirty = false;
+ }
+ }
+ check(idle_renders == 0, "criterion 6: idle dirty-gate renders ZERO frames over 120 turns");
+
+ int gated_renders = 0;
+ dirty = true; // simulate a single client buffer commit
+ for (int turn = 0; turn < 10; ++turn) {
+ if (dirty || anim_pending()) {
+ present.render(ctx);
+ ++gated_renders;
+ dirty = false;
+ }
+ }
+ check(gated_renders == 1, "criterion 6: a single commit gates exactly ONE render");
+ }
+
+ // ---- Criterion 3 geometry: screen->surface-local inversion through the SAME
+ // transform RCSS applies (perspective(800) about the 50% origin, rotateY).
+ // Project a known surface-local point to its screen landing, invert, and
+ // confirm round-trip identity to sub-pixel — the math the runtime
+ // RmlUi-pick -> wl_seat translation rides on. ----
+ {
+ const double origin = W / 2.0;
+ const spike::Mat4 t = spike::rcss_transform_about_origin(
+ spike::mul(spike::perspective(800.0), spike::rotate_y(35.0 * M_PI / 180.0)), origin,
+ origin);
+ const double lx = 64.0, ly = 96.0;
+ const spike::ScreenPoint s = spike::project_to_screen(t, lx, ly);
+ const auto back = spike::unproject_to_local(t, s.x, s.y);
+ check(back.has_value(), "criterion 3: inversion solvable through perspective+rotateY");
+ if (back) {
+ const double err = std::hypot(back->x - lx, back->y - ly);
+ std::fprintf(stderr, "[verify] criterion 3 round-trip error = %.6f px\n", err);
+ check(err < 0.01, "criterion 3: screen->surface-local round-trip < 0.01px");
+ }
+ }
+
+ present.teardown();
+ live.destroy();
+ if (ctx != nullptr) {
+ Rml::RemoveContext("verify");
+ }
+ gl.restore_current();
+ gl.teardown();
+ if (client_buf != nullptr) {
+ wlr_buffer_drop(client_buf);
+ }
+ wlr_scene_node_destroy(&scene->tree.node);
+ wlr_allocator_destroy(allocator);
+ wlr_renderer_destroy(renderer);
+ wlr_backend_destroy(backend);
+ wl_display_destroy(display);
+ return 0;
+}
+
+// ---- Criteria 4 + 5: surface trees + wallpaper (per-subsurface elements) -----
+//
+// THE #1 unknown (criterion 4): a toplevel that owns a POPUP and a SUBSURFACE,
+// composited correctly. This prototypes the PER-SUBSURFACE-ELEMENT answer: every
+// node of the surface tree (toplevel, subsurface, popup) is its OWN RML <img>
+// sampling its OWN live shared texture, positioned in RCSS at its offset, with
+// document order giving the stack (parent first, child/popup above). The
+// alternative (per-window render-to-texture: flatten the whole tree to one
+// texture off-screen, sample that as ONE element) is ANALYSED in the report;
+// here we prove the per-subsurface path objectively by readback.
+//
+// Criterion 5 (wallpaper): a layer-shell client is just another surface element
+// behind the stage — imported through the SAME LiveTexture::adopt path as the
+// toplevel (criterion 1). We prove it by importing a full-output wallpaper
+// buffer the identical way and reading it back where the toplevel does not cover
+// it. "Mechanically identical to the toplevel path" is therefore shown, not
+// asserted by hand-wave.
+//
+// Layout (output W x W), all flat (no 3D) so readback geometry is deterministic
+// and each surface's screen footprint is exactly its element box:
+// wallpaper : full output, BLUE, behind everything
+// toplevel : (TLX,TLY) sized TW, RED
+// subsurface: offset (+SOFF,+SOFF) inside the toplevel, GREEN (occludes RED)
+// popup : at the toplevel's top-right, partly past it, WHITE (above all)
+auto run_verify_surface_trees() -> int {
+ setenv("WLR_BACKENDS", "headless", 1);
+ setenv("WLR_RENDERER", "gles2", 1);
+
+ wl_display* display = wl_display_create();
+ wlr_backend* backend = wlr_backend_autocreate(wl_display_get_event_loop(display), nullptr);
+ wlr_renderer* renderer = wlr_renderer_autocreate(backend);
+ wlr_allocator* allocator = wlr_allocator_autocreate(backend, renderer);
+ wlr_scene* scene = wlr_scene_create();
+
+ if (!wlr_renderer_is_gles2(renderer)) {
+ std::fprintf(stderr, "[verify] SKIP surface-tree: renderer is not gles2\n");
+ wlr_scene_node_destroy(&scene->tree.node);
+ wlr_allocator_destroy(allocator);
+ wlr_renderer_destroy(renderer);
+ wlr_backend_destroy(backend);
+ wl_display_destroy(display);
+ return 0;
+ }
+ EGLDisplay egl = wlr_egl_get_display(wlr_gles2_renderer_get_egl(renderer));
+
+ spike::GlBridge gl;
+ if (!gl.init(egl)) {
+ std::fprintf(stderr, "[verify] SKIP surface-tree: GL bridge unavailable\n");
+ wlr_scene_node_destroy(&scene->tree.node);
+ wlr_allocator_destroy(allocator);
+ wlr_renderer_destroy(renderer);
+ wlr_backend_destroy(backend);
+ wl_display_destroy(display);
+ return 0;
+ }
+ gl.make_current();
+
+ const int W = 512;
+ const int TLX = 128, TLY = 96, TW = 256, TH = 256; // toplevel box
+ const int SOFF = 48, SW = 96, SH = 96; // subsurface: inside toplevel
+ const int PW = 96, PH = 64; // popup: at toplevel top-right edge
+ const int PX = TLX + TW - 32, PY = TLY - 16; // hangs past the toplevel corner
+
+ // Four real client dmabufs, painted like a GPU client would.
+ wlr_buffer* wall_buf = make_solid_client_buffer(gl, renderer, allocator, W, W, 0, 0, 1); // blue
+ wlr_buffer* top_buf = make_solid_client_buffer(gl, renderer, allocator, TW, TH, 1, 0, 0); // red
+ wlr_buffer* sub_buf = make_solid_client_buffer(gl, renderer, allocator, SW, SH, 0, 1, 0); // green
+ wlr_buffer* pop_buf = make_solid_client_buffer(gl, renderer, allocator, PW, PH, 1, 1, 1); // white
+
+ spike::LiveTexture wall, top, sub, pop;
+ for (auto* t : {&wall, &top, &sub, &pop}) {
+ t->gl = &gl;
+ }
+ wall.uri = "unbox-live://wall";
+ top.uri = "unbox-live://top";
+ sub.uri = "unbox-live://sub";
+ pop.uri = "unbox-live://pop";
+
+ bool zero_copy = true;
+ struct Pair {
+ spike::LiveTexture* t;
+ wlr_buffer* b;
+ };
+ for (const Pair& p : {Pair{&wall, wall_buf}, Pair{&top, top_buf}, Pair{&sub, sub_buf},
+ Pair{&pop, pop_buf}}) {
+ const bool ok = p.b != nullptr && p.t->adopt(p.b);
+ zero_copy = zero_copy && ok && p.t->is_dmabuf;
+ }
+ check(zero_copy, "criterion 4/5: tree (toplevel+subsurface+popup) + wallpaper imported zero-copy");
+
+ // ONE document, FOUR surface elements (per-subsurface answer): wallpaper
+ // first (behind), then the toplevel, then its subsurface, then the popup —
+ // document order is the composite stack. Each <img> samples its own live
+ // texture and is positioned in RCSS at its surface-tree offset.
+ char rml[2048];
+ std::snprintf(rml, sizeof(rml),
+ "<rml><head><style>"
+ "body { margin:0px; padding:0px; width:%dpx; height:%dpx; }"
+ ".s { display:block; position:absolute; }"
+ ".s img { display:block; width:100%%; height:100%%; }"
+ "</style></head><body>"
+ "<div class=s id=wall style='left:0;top:0;width:%dpx;height:%dpx;'>"
+ "<img src='%s'/></div>"
+ "<div class=s id=top style='left:%dpx;top:%dpx;width:%dpx;height:%dpx;'>"
+ "<img src='%s'/></div>"
+ "<div class=s id=sub style='left:%dpx;top:%dpx;width:%dpx;height:%dpx;'>"
+ "<img src='%s'/></div>"
+ "<div class=s id=pop style='left:%dpx;top:%dpx;width:%dpx;height:%dpx;'>"
+ "<img src='%s'/></div>"
+ "</body></rml>",
+ W, W, W, W, wall.uri.c_str(), TLX, TLY, TW, TH, top.uri.c_str(), TLX + SOFF,
+ TLY + SOFF, SW, SH, sub.uri.c_str(), PX, PY, PW, PH, pop.uri.c_str());
+
+ Rml::Context* ctx = Rml::CreateContext("vtree", Rml::Vector2i(W, W), gl.render);
+ Rml::ElementDocument* doc = (ctx != nullptr) ? ctx->LoadDocumentFromMemory(rml) : nullptr;
+ check(doc != nullptr, "criterion 4: surface-tree document loaded");
+ if (doc != nullptr) {
+ doc->Show();
+ }
+
+ spike::PresentTarget present;
+ const bool present_ok = present.init(&gl, allocator, W, W);
+ present.scene_buffer = wlr_scene_buffer_create(&scene->tree, nullptr);
+ check(present_ok, "criterion 4/5: present target for the tree built");
+
+ auto is_color = [](const std::uint8_t p[4], int r, int g, int b) {
+ return std::abs(int(p[0]) - r) < 60 && std::abs(int(p[1]) - g) < 60 &&
+ std::abs(int(p[2]) - b) < 60;
+ };
+
+ if (doc != nullptr && present_ok) {
+ present.render(ctx);
+ std::uint8_t px[4];
+
+ // Wallpaper shows in a corner no other surface covers (criterion 5).
+ present.pixel(16, 16, px);
+ check(is_color(px, 0, 0, 255), "criterion 5: wallpaper (layer surface) visible behind all");
+
+ // Toplevel RED shows where neither subsurface nor popup covers it: a spot
+ // inside the toplevel but outside the (TLX+SOFF..+SW) subsurface box.
+ present.pixel(TLX + 16, TLY + TH - 16, px);
+ check(is_color(px, 255, 0, 0), "criterion 4: toplevel surface composited over wallpaper");
+
+ // Subsurface GREEN occludes the toplevel at its offset box centre
+ // (per-subsurface element drawn ABOVE its parent by document order).
+ present.pixel(TLX + SOFF + SW / 2, TLY + SOFF + SH / 2, px);
+ check(is_color(px, 0, 255, 0),
+ "criterion 4: subsurface element occludes the toplevel at its offset");
+
+ // Popup WHITE at its own box centre — drawn above everything, and where it
+ // hangs PAST the toplevel it sits directly on the wallpaper (proves popups
+ // are not clipped to the parent element).
+ present.pixel(PX + PW / 2, PY + PH / 2, px);
+ check(is_color(px, 255, 255, 255), "criterion 4: popup element composited above the tree");
+
+ // Stacking integrity: the popup's TOP edge (above the toplevel's top) is
+ // popup-white over wallpaper-blue, NOT toplevel-red — order is correct.
+ present.pixel(PX + PW / 2, PY + 6, px);
+ check(is_color(px, 255, 255, 255),
+ "criterion 4: surface-tree stack order correct (popup top over wallpaper)");
+ }
+
+ present.teardown();
+ for (auto* t : {&wall, &top, &sub, &pop}) {
+ t->destroy();
+ }
+ if (ctx != nullptr) {
+ Rml::RemoveContext("vtree");
+ }
+ gl.restore_current();
+ gl.teardown();
+ for (wlr_buffer* b : {wall_buf, top_buf, sub_buf, pop_buf}) {
+ if (b != nullptr) {
+ wlr_buffer_drop(b);
+ }
+ }
+ wlr_scene_node_destroy(&scene->tree.node);
+ wlr_allocator_destroy(allocator);
+ wlr_renderer_destroy(renderer);
+ wlr_backend_destroy(backend);
+ wl_display_destroy(display);
+ return 0;
+}
+
+} // namespace
+
+// The real-seat run mode lives in rml_compositing_spike_run.cpp (its own TU).
+auto run_real_seat(const char* startup_cmd) -> int;
+
+int main(int argc, char** argv) {
+ const char* mode = (argc > 1) ? argv[1] : "--verify";
+ if (std::strcmp(mode, "--verify") == 0) {
+ // Two independent compositor bring-ups (each its own display/renderer/GL
+ // bridge) so one cannot corrupt the other's GL/RmlUi global state: first
+ // the live-texture/3D/input/idle/present criteria (1,2,3,6,7), then the
+ // surface-tree + wallpaper criteria (4,5). g_fail accumulates across both;
+ // ALL PASS is printed once for the whole run.
+ run_verify();
+ run_verify_surface_trees();
+ std::fprintf(stderr, "\n[verify] %s (%d failures)\n",
+ g_fail == 0 ? "ALL PASS" : "FAILURES", g_fail);
+ return g_fail == 0 ? 0 : 1;
+ }
+ if (std::strcmp(mode, "--run") == 0) {
+ const char* cmd = (argc > 2) ? argv[2] : "foot";
+ return run_real_seat(cmd);
+ }
+ std::fprintf(stderr,
+ "usage: %s [--verify | --run [startup-cmd]]\n"
+ " --verify headless self-check of criteria 1,2,3,4,5,6,7 (exit 0 = pass)\n"
+ " --run real/nested seat: spawn a client, composite it as a 3D\n"
+ " surface element, route input back, print perf/idle metrics\n",
+ argv[0]);
+ return 2;
+}
diff --git a/packages/kernel/src/spike/rml_compositing_spike_run.cpp b/packages/kernel/src/spike/rml_compositing_spike_run.cpp
new file mode 100644
index 0000000..0a6f78d
--- /dev/null
+++ b/packages/kernel/src/spike/rml_compositing_spike_run.cpp
@@ -0,0 +1,767 @@
+// SPIKE (rml-compositing, Phase 0) — the REAL-SEAT run mode (--run). THROWAWAY.
+//
+// A minimal but real compositor (display/backend/renderer/allocator/scene/seat +
+// xdg-shell + layer-shell) that maps real clients and composites EACH client
+// surface as a LIVE SURFACE ELEMENT inside ONE RmlUi document: every mapped
+// surface (toplevel, popup, subsurface, layer/wallpaper) becomes an <img>
+// sampling that surface's live shared texture, laid out + 3D-transformed in
+// RCSS. The composited RmlUi FBO is presented through a single full-output
+// wlr_scene_buffer (criterion 7); the wlr cursor stays a hardware plane.
+//
+// Input is routed BACK to clients: pointer/touch are fed to the RmlUi context,
+// RmlUi's transform-aware pick finds the surface element + element-local coords
+// under the point, and the spike translates that to wl_seat surface-local
+// notifies so the client receives the event AT THE CORRECT point through the 3D
+// transform. Keyboard goes to the focused client.
+//
+// Per-frame render time, re-import counts, and idle confirmation are printed so
+// the user can do the visual/touch/perf GO-NO-GO on the CF-AX3. This is the
+// orchestrator-runnable artifact; YOU (the agent) self-verify the geometry +
+// present + idle headless in --verify.
+//
+// wlroots only via the kernel's wrapper; every wl_listener is the RAII Listener.
+
+#include <unbox/kernel/listener.hpp>
+#include <unbox/kernel/wlr.hpp>
+
+#include "spike_gl.hpp"
+#include "spike_input_core.hpp"
+
+#include <RmlUi/Core/Context.h>
+#include <RmlUi/Core/Core.h>
+#include <RmlUi/Core/Element.h>
+#include <RmlUi/Core/ElementDocument.h>
+#include <RmlUi/Core/Factory.h>
+
+#include <algorithm>
+#include <cmath>
+#include <cstdint>
+#include <cstdio>
+#include <cstdlib>
+#include <cstring>
+#include <ctime>
+#include <list>
+#include <memory>
+#include <string>
+#include <vector>
+
+extern "C" {
+#include <xkbcommon/xkbcommon.h>
+}
+
+#include <unistd.h>
+
+using unbox::kernel::Listener;
+namespace spike = unbox::kernel::spike;
+
+namespace {
+
+struct Runner; // fwd
+
+// One live client surface presented as a surface element. Backed by a wlr
+// xdg-toplevel (the spike maps exactly one toplevel + its popups/subsurfaces and
+// one layer/wallpaper for the criteria; more would be the same loop). Holds the
+// LiveTexture (the shared-texture import) and the document <img> element id.
+struct LiveSurface {
+ Runner* runner = nullptr;
+ wlr_surface* surface = nullptr; // the wl_surface whose buffer we sample
+ wlr_xdg_surface* xdg = nullptr; // null for the layer surface
+ wlr_layer_surface_v1* layer = nullptr;
+ spike::LiveTexture live;
+ std::string element_id; // the <img>'s RML id
+ int x = 0, y = 0; // layout position of the element
+ int w = 0, h = 0;
+ bool mapped = false;
+ bool is_wallpaper = false;
+ bool transform3d = false; // toplevel gets the 3D tilt; wallpaper flat
+
+ Listener map_l, unmap_l, commit_l, destroy_l;
+};
+
+struct Runner {
+ wl_display* display = nullptr;
+ wl_event_loop* loop = nullptr;
+ wlr_backend* backend = nullptr;
+ wlr_session* session = nullptr;
+ wlr_renderer* renderer = nullptr;
+ wlr_allocator* allocator = nullptr;
+ wlr_scene* scene = nullptr;
+ wlr_output_layout* output_layout = nullptr;
+ wlr_scene_output_layout* scene_layout = nullptr;
+ wlr_output* output = nullptr;
+ wlr_scene_output* scene_output = nullptr;
+ wlr_compositor* compositor = nullptr;
+ wlr_seat* seat = nullptr;
+ wlr_cursor* cursor = nullptr;
+ wlr_xcursor_manager* cursor_mgr = nullptr;
+ wlr_xdg_shell* xdg_shell = nullptr;
+ wlr_layer_shell_v1* layer_shell = nullptr;
+ wlr_keyboard* keyboard = nullptr;
+ // A fixed ~60Hz event-loop timer drives the composite/present clock
+ // independently of output `frame` damage semantics (which stall a static
+ // nested/DRM output and would freeze client progress). The dirty-gate still
+ // decides render-vs-skip; this only keeps the clock alive for the GO/NO-GO.
+ wl_event_source* tick = nullptr;
+
+ int out_w = 1920, out_h = 1080;
+
+ spike::GlBridge gl;
+ spike::PresentTarget present;
+ Rml::Context* ctx = nullptr;
+ Rml::ElementDocument* doc = nullptr;
+ wlr_scene_buffer* present_node = nullptr;
+
+ std::list<LiveSurface> surfaces;
+
+ // The dirty gate (criterion 6): render a frame only when something changed.
+ bool dirty = true;
+ int next_id = 0;
+
+ // Perf instrumentation.
+ std::vector<double> frame_ms;
+ int frames_rendered = 0;
+ int frames_skipped_idle = 0;
+ double last_report = 0.0;
+
+ // Server-level listeners.
+ Listener new_output_l, new_input_l, frame_l;
+ Listener new_xdg_l, new_layer_l;
+ Listener cursor_motion_l, cursor_motion_abs_l, cursor_button_l, cursor_axis_l, cursor_frame_l;
+ Listener touch_down_l, touch_up_l, touch_motion_l;
+ Listener kb_key_l, kb_mods_l;
+
+ auto add_surface(wlr_surface* surf) -> LiveSurface* {
+ surfaces.emplace_back();
+ LiveSurface& s = surfaces.back();
+ s.runner = this;
+ s.surface = surf;
+ s.live.gl = &gl;
+ s.element_id = "surf_" + std::to_string(next_id++);
+ s.live.uri = "unbox-live://" + s.element_id;
+ return &s;
+ }
+
+ void remove_surface(LiveSurface* s) {
+ const bool cur = gl.make_current();
+ s->live.destroy();
+ if (cur) {
+ gl.restore_current();
+ }
+ // Remove the <img> element from the document.
+ if (doc != nullptr) {
+ if (Rml::Element* el = doc->GetElementById(s->element_id)) {
+ el->GetParentNode()->RemoveChild(el);
+ }
+ }
+ surfaces.remove_if([s](const LiveSurface& e) { return &e == s; });
+ dirty = true;
+ }
+};
+
+// The base document: a perspective container + a flat wallpaper layer behind it.
+// Surface elements are inserted at runtime as <div class="win"><img.../></div>.
+const char* kRunRml = R"RML(<rml>
+<head>
+<style>
+body { margin: 0px; padding: 0px; perspective: 1400px; background: #0b0d14; }
+#wall { display: block; position: absolute; left: 0; top: 0; }
+#wall img { display: block; }
+#stage { display: block; position: absolute; left: 0; top: 0;
+ width: 100%; height: 100%; }
+.win { display: block; position: absolute;
+ transform: perspective(1400px) rotateY(-18deg);
+ transform-origin: 50% 50%;
+ transition: transform 0.25s cubic-in-out;
+ box-shadow: #000a 8px 8px 24px 0px; }
+.win img { display: block; width: 100%; height: 100%; }
+</style>
+</head>
+<body>
+<div id="wall"></div>
+<div id="stage"></div>
+</body>
+</rml>)RML";
+
+void layout_surface_element(Runner& r, LiveSurface& s) {
+ if (r.doc == nullptr || s.live.tex == 0) {
+ return;
+ }
+ Rml::Element* container = r.doc->GetElementById(s.is_wallpaper ? "wall" : "stage");
+ if (container == nullptr) {
+ return;
+ }
+ Rml::Element* win = r.doc->GetElementById(s.element_id);
+ if (win == nullptr) {
+ // Create <div class=win id=surf_N><img src=uri/></div> (wallpaper: bare img).
+ Rml::ElementPtr div = r.doc->CreateElement("div");
+ div->SetId(s.element_id);
+ if (!s.is_wallpaper) {
+ div->SetClass("win", true);
+ }
+ Rml::ElementPtr img = r.doc->CreateElement("img");
+ img->SetAttribute("src", s.live.uri);
+ div->AppendChild(std::move(img));
+ win = container->AppendChild(std::move(div));
+ }
+ if (win == nullptr) {
+ return;
+ }
+ win->SetProperty("position", "absolute");
+ win->SetProperty("left", std::to_string(s.x) + "px");
+ win->SetProperty("top", std::to_string(s.y) + "px");
+ win->SetProperty("width", std::to_string(s.w) + "px");
+ win->SetProperty("height", std::to_string(s.h) + "px");
+ if (Rml::Element* img = win->GetFirstChild()) {
+ img->SetProperty("width", std::to_string(s.w) + "px");
+ img->SetProperty("height", std::to_string(s.h) + "px");
+ }
+}
+
+// Re-import every mapped surface's current buffer (zero re-import when unchanged)
+// and lay it out, then render+present. Returns the render time in ms (or -1 if
+// the frame was gated out).
+auto composite_frame(Runner& r, bool force) -> double {
+ if (!r.dirty && !force) {
+ ++r.frames_skipped_idle;
+ return -1.0;
+ }
+ r.dirty = false;
+ const double t0 = spike::now_sec();
+
+ const bool cur = r.gl.make_current();
+ for (LiveSurface& s : r.surfaces) {
+ if (!s.mapped || s.surface == nullptr) {
+ continue;
+ }
+ wlr_buffer* buf = nullptr;
+ if (s.surface->buffer != nullptr) {
+ buf = &s.surface->buffer->base;
+ }
+ if (buf != nullptr) {
+ s.live.adopt(buf);
+ // Natural size from the surface's current state.
+ s.w = s.surface->current.width;
+ s.h = s.surface->current.height;
+ }
+ layout_surface_element(r, s);
+ }
+ wlr_buffer* presented = r.present.render(r.ctx);
+ if (cur) {
+ r.gl.restore_current();
+ }
+ if (presented != nullptr && r.present_node != nullptr) {
+ wlr_scene_buffer_set_buffer(r.present_node, presented);
+ }
+
+ const double dt_ms = (spike::now_sec() - t0) * 1000.0;
+ r.frame_ms.push_back(dt_ms);
+ ++r.frames_rendered;
+ return dt_ms;
+}
+
+// ---- Input: RmlUi pick -> surface-local -> wl_seat --------------------------
+//
+// Feed the screen point to the RmlUi context; RmlUi's transform-aware hover pick
+// resolves the element under it. If that element (or its parent) is a surface
+// element, map the picked element-local coords to surface-local and notify the
+// client. RmlUi reports the hovered element via GetHoverElement() after a move.
+
+auto surface_for_element(Runner& r, Rml::Element* el) -> LiveSurface* {
+ while (el != nullptr) {
+ const Rml::String id = el->GetId();
+ for (LiveSurface& s : r.surfaces) {
+ if (s.element_id == id) {
+ return &s;
+ }
+ }
+ el = el->GetParentNode();
+ }
+ return nullptr;
+}
+
+// Translate a screen point to a surface-local point on the hovered surface
+// element, using the element's own box + RmlUi's transform-aware projection. We
+// read the hovered element's absolute (already transform-resolved by RmlUi's
+// pick) offset and scale the live texture's natural size onto the element box.
+struct Routed {
+ LiveSurface* s = nullptr;
+ double sx = 0, sy = 0; // surface-local pixels
+};
+
+auto route_point(Runner& r, double screen_x, double screen_y) -> Routed {
+ r.ctx->ProcessMouseMove(static_cast<int>(screen_x), static_cast<int>(screen_y), 0);
+ Rml::Element* hover = r.ctx->GetHoverElement();
+ LiveSurface* s = surface_for_element(r, hover);
+ if (s == nullptr) {
+ return {};
+ }
+ // The <img> child carries the texture box; map the screen point into its
+ // content box (RmlUi gives us the transform-resolved absolute offset) and
+ // scale to the live texture's natural pixels = surface-local coords.
+ Rml::Element* img = r.doc->GetElementById(s->element_id);
+ if (img != nullptr && img->GetFirstChild() != nullptr) {
+ img = img->GetFirstChild();
+ }
+ if (img == nullptr) {
+ return {};
+ }
+ const Rml::Vector2f off = img->GetAbsoluteOffset(Rml::BoxArea::Content);
+ const float bw = img->GetClientWidth();
+ const float bh = img->GetClientHeight();
+ if (bw <= 0 || bh <= 0) {
+ return {};
+ }
+ const double fx = (screen_x - off.x) / bw; // 0..1 across the element box
+ const double fy = (screen_y - off.y) / bh;
+ Routed out;
+ out.s = s;
+ out.sx = std::clamp(fx, 0.0, 1.0) * s->live.width;
+ out.sy = std::clamp(fy, 0.0, 1.0) * s->live.height;
+ return out;
+}
+
+void notify_pointer_motion(Runner& r, double sx, double sy, std::uint32_t time, Routed& rt) {
+ if (rt.s == nullptr || rt.s->surface == nullptr) {
+ wlr_seat_pointer_notify_clear_focus(r.seat);
+ return;
+ }
+ wlr_seat_pointer_notify_enter(r.seat, rt.s->surface, rt.sx, rt.sy);
+ wlr_seat_pointer_notify_motion(r.seat, time, rt.sx, rt.sy);
+ wlr_seat_pointer_notify_frame(r.seat);
+ (void)sx;
+ (void)sy;
+}
+
+// ---- xdg-shell ---------------------------------------------------------------
+
+void on_surface_commit(Runner& r, LiveSurface& s) {
+ // A client buffer commit is THE dirty source (criterion 6): a new frame is
+ // scheduled only here (plus input/animation).
+ r.dirty = true;
+ if (r.output != nullptr) {
+ wlr_output_schedule_frame(r.output);
+ }
+ (void)s;
+}
+
+void on_xdg_map(Runner& r, LiveSurface& s) {
+ s.mapped = true;
+ // Place the toplevel element centered on the stage, sized to its geometry.
+ if (s.xdg != nullptr && s.xdg->toplevel != nullptr) {
+ const wlr_box geo = s.xdg->geometry;
+ s.w = geo.width > 0 ? geo.width : 800;
+ s.h = geo.height > 0 ? geo.height : 600;
+ }
+ s.x = (r.out_w - s.w) / 2;
+ s.y = (r.out_h - s.h) / 2;
+ s.transform3d = true;
+ // Give the toplevel keyboard focus.
+ if (r.keyboard != nullptr && s.surface != nullptr) {
+ wlr_seat_keyboard_notify_enter(r.seat, s.surface, r.keyboard->keycodes,
+ r.keyboard->num_keycodes, &r.keyboard->modifiers);
+ }
+ r.dirty = true;
+ std::fprintf(stderr, "[run] toplevel mapped %dx%d at (%d,%d) as surface element '%s'\n", s.w,
+ s.h, s.x, s.y, s.element_id.c_str());
+}
+
+void handle_new_xdg(Runner& r, wlr_xdg_surface* xdg) {
+ if (xdg->role == WLR_XDG_SURFACE_ROLE_POPUP) {
+ // Popups are surface elements too — answering criterion 4: each
+ // subsurface/popup is its OWN element sampling its OWN live texture,
+ // positioned at the popup's offset under its parent. The per-subsurface
+ // approach (vs per-window RTT) is what we exercise here.
+ LiveSurface* s = r.add_surface(xdg->surface);
+ s->xdg = xdg;
+ s->map_l.connect(xdg->surface->events.map, [&r, s](void*) {
+ s->mapped = true;
+ // Position the popup relative to the output (its geometry carries the
+ // offset from the parent in surface coords; for the spike we place it
+ // near the toplevel center + popup geometry).
+ const wlr_box geo = s->xdg->geometry;
+ s->w = geo.width > 0 ? geo.width : 200;
+ s->h = geo.height > 0 ? geo.height : 100;
+ s->x = (r.out_w) / 2 + s->xdg->popup->scheduled.geometry.x;
+ s->y = (r.out_h) / 2 + s->xdg->popup->scheduled.geometry.y;
+ r.dirty = true;
+ std::fprintf(stderr, "[run] popup mapped as surface element '%s'\n",
+ s->element_id.c_str());
+ });
+ s->unmap_l.connect(xdg->surface->events.unmap,
+ [&r, s](void*) { s->mapped = false; r.dirty = true; });
+ s->commit_l.connect(xdg->surface->events.commit, [&r, s](void*) { on_surface_commit(r, *s); });
+ s->destroy_l.connect(xdg->surface->events.destroy, [&r, s](void*) { r.remove_surface(s); });
+ return;
+ }
+ if (xdg->role != WLR_XDG_SURFACE_ROLE_TOPLEVEL) {
+ return;
+ }
+ LiveSurface* s = r.add_surface(xdg->surface);
+ s->xdg = xdg;
+ s->map_l.connect(xdg->surface->events.map, [&r, s](void*) { on_xdg_map(r, *s); });
+ s->unmap_l.connect(xdg->surface->events.unmap,
+ [&r, s](void*) { s->mapped = false; r.dirty = true; });
+ s->commit_l.connect(xdg->surface->events.commit, [&r, s](void*) {
+ if (s->xdg != nullptr && s->xdg->initial_commit) {
+ wlr_xdg_toplevel_set_size(s->xdg->toplevel, 0, 0); // let the client choose
+ }
+ on_surface_commit(r, *s);
+ });
+ s->destroy_l.connect(xdg->surface->events.destroy, [&r, s](void*) { r.remove_surface(s); });
+}
+
+// ---- layer-shell (wallpaper) -------------------------------------------------
+
+void handle_new_layer(Runner& r, wlr_layer_surface_v1* layer) {
+ // Configure it to the full output as a wallpaper (background layer).
+ layer->current.desired_width = static_cast<std::uint32_t>(r.out_w);
+ layer->current.desired_height = static_cast<std::uint32_t>(r.out_h);
+ wlr_layer_surface_v1_configure(layer, static_cast<std::uint32_t>(r.out_w),
+ static_cast<std::uint32_t>(r.out_h));
+ LiveSurface* s = r.add_surface(layer->surface);
+ s->layer = layer;
+ s->is_wallpaper = true;
+ s->x = 0;
+ s->y = 0;
+ s->w = r.out_w;
+ s->h = r.out_h;
+ s->map_l.connect(layer->surface->events.map, [&r, s](void*) {
+ s->mapped = true;
+ r.dirty = true;
+ std::fprintf(stderr, "[run] layer-shell wallpaper mapped as surface element '%s'\n",
+ s->element_id.c_str());
+ });
+ s->unmap_l.connect(layer->surface->events.unmap,
+ [&r, s](void*) { s->mapped = false; r.dirty = true; });
+ s->commit_l.connect(layer->surface->events.commit, [&r, s](void*) { on_surface_commit(r, *s); });
+ s->destroy_l.connect(layer->surface->events.destroy, [&r, s](void*) { r.remove_surface(s); });
+}
+
+// ---- output frame ------------------------------------------------------------
+
+void on_frame(Runner& r) {
+ const double dt = composite_frame(r, /*force=*/false);
+ if (!wlr_scene_output_commit(r.scene_output, nullptr)) {
+ // Nothing changed for wlr_scene to commit (static scene). The nested /
+ // DRM backend only emits the next `frame` after a successful output
+ // commit, so a no-op scene commit would STALL the frame clock (and any
+ // client waiting on it). Force a bare output commit to keep the vblank
+ // clock — and thus client progress — alive. (A production build gates the
+ // schedule instead; the spike keeps the seat live for the GO/NO-GO.)
+ wlr_output_state st;
+ wlr_output_state_init(&st);
+ if (!wlr_output_commit_state(r.output, &st)) {
+ wlr_output_schedule_frame(r.output);
+ }
+ wlr_output_state_finish(&st);
+ }
+ timespec now{};
+ clock_gettime(CLOCK_MONOTONIC, &now);
+ wlr_scene_output_send_frame_done(r.scene_output, &now);
+
+ // Animation dirty source: RmlUi's GetNextUpdateDelay() (finite => animating,
+ // +inf => at rest) — exactly the design's gate signal.
+ bool anim = false;
+ {
+ const bool cur = r.gl.make_current();
+ r.ctx->Update();
+ anim = std::isfinite(r.ctx->GetNextUpdateDelay());
+ if (cur) {
+ r.gl.restore_current();
+ }
+ }
+ if (anim) {
+ r.dirty = true;
+ }
+ // Keep the output ticking so mapped clients always make progress (their
+ // wl_surface.frame callbacks fire and their roundtrips complete). The
+ // dirty-GATE still decides whether composite_frame() actually RENDERS vs.
+ // counts a skipped-idle frame — so the idle win is still visible in the perf
+ // line (skipped_idle climbs while frames holds) even though the nested/DRM
+ // output is scheduled every vblank. (A production build would instead gate
+ // the schedule itself; here we keep the seat live for the GO/NO-GO.)
+ wlr_output_schedule_frame(r.output);
+
+ // Periodic perf report (~1s).
+ const double t = spike::now_sec();
+ if (t - r.last_report > 1.0 && !r.frame_ms.empty()) {
+ std::vector<double> v = r.frame_ms;
+ std::sort(v.begin(), v.end());
+ double sum = 0;
+ for (double x : v) {
+ sum += x;
+ }
+ const double avg = sum / v.size();
+ const double p95 = v[static_cast<std::size_t>(v.size() * 0.95)];
+ std::fprintf(stderr,
+ "[perf] frames=%d skipped_idle=%d avg=%.2fms p95=%.2fms max=%.2fms "
+ "(~%.0f fps budget)\n",
+ r.frames_rendered, r.frames_skipped_idle, avg, p95, v.back(),
+ avg > 0 ? 1000.0 / avg : 0.0);
+ r.frame_ms.clear();
+ r.last_report = t;
+ (void)dt;
+ }
+}
+
+// ---- input devices -----------------------------------------------------------
+
+void handle_new_input(Runner& r, wlr_input_device* dev) {
+ if (dev->type == WLR_INPUT_DEVICE_KEYBOARD) {
+ r.keyboard = wlr_keyboard_from_input_device(dev);
+ xkb_context* xkb = xkb_context_new(XKB_CONTEXT_NO_FLAGS);
+ xkb_keymap* km = xkb_keymap_new_from_names(xkb, nullptr, XKB_KEYMAP_COMPILE_NO_FLAGS);
+ wlr_keyboard_set_keymap(r.keyboard, km);
+ xkb_keymap_unref(km);
+ xkb_context_unref(xkb);
+ wlr_keyboard_set_repeat_info(r.keyboard, 25, 600);
+ wlr_seat_set_keyboard(r.seat, r.keyboard);
+ r.kb_key_l.connect(r.keyboard->events.key, [&r](void* data) {
+ auto* ev = static_cast<wlr_keyboard_key_event*>(data);
+ wlr_seat_set_keyboard(r.seat, r.keyboard);
+ wlr_seat_keyboard_notify_key(r.seat, ev->time_msec, ev->keycode, ev->state);
+ });
+ r.kb_mods_l.connect(r.keyboard->events.modifiers, [&r](void*) {
+ wlr_seat_set_keyboard(r.seat, r.keyboard);
+ wlr_seat_keyboard_notify_modifiers(r.seat, &r.keyboard->modifiers);
+ });
+ } else if (dev->type == WLR_INPUT_DEVICE_POINTER) {
+ wlr_cursor_attach_input_device(r.cursor, dev);
+ } else if (dev->type == WLR_INPUT_DEVICE_TOUCH) {
+ wlr_cursor_attach_input_device(r.cursor, dev);
+ }
+ std::uint32_t caps = WL_SEAT_CAPABILITY_POINTER;
+ if (r.keyboard != nullptr) {
+ caps |= WL_SEAT_CAPABILITY_KEYBOARD;
+ }
+ caps |= WL_SEAT_CAPABILITY_TOUCH;
+ wlr_seat_set_capabilities(r.seat, caps);
+}
+
+void attach_input(Runner& r) {
+ r.cursor_motion_l.connect(r.cursor->events.motion, [&r](void* data) {
+ auto* ev = static_cast<wlr_pointer_motion_event*>(data);
+ wlr_cursor_move(r.cursor, &ev->pointer->base, ev->delta_x, ev->delta_y);
+ Routed rt = route_point(r, r.cursor->x, r.cursor->y);
+ notify_pointer_motion(r, r.cursor->x, r.cursor->y, ev->time_msec, rt);
+ r.dirty = true;
+ wlr_output_schedule_frame(r.output);
+ });
+ r.cursor_motion_abs_l.connect(r.cursor->events.motion_absolute, [&r](void* data) {
+ auto* ev = static_cast<wlr_pointer_motion_absolute_event*>(data);
+ wlr_cursor_warp_absolute(r.cursor, &ev->pointer->base, ev->x, ev->y);
+ Routed rt = route_point(r, r.cursor->x, r.cursor->y);
+ notify_pointer_motion(r, r.cursor->x, r.cursor->y, ev->time_msec, rt);
+ r.dirty = true;
+ wlr_output_schedule_frame(r.output);
+ });
+ r.cursor_button_l.connect(r.cursor->events.button, [&r](void* data) {
+ auto* ev = static_cast<wlr_pointer_button_event*>(data);
+ Routed rt = route_point(r, r.cursor->x, r.cursor->y);
+ if (rt.s != nullptr) {
+ wlr_seat_pointer_notify_enter(r.seat, rt.s->surface, rt.sx, rt.sy);
+ wlr_seat_pointer_notify_button(r.seat, ev->time_msec, ev->button, ev->state);
+ wlr_seat_pointer_notify_frame(r.seat);
+ }
+ });
+ r.cursor_axis_l.connect(r.cursor->events.axis, [&r](void* data) {
+ auto* ev = static_cast<wlr_pointer_axis_event*>(data);
+ wlr_seat_pointer_notify_axis(r.seat, ev->time_msec, ev->orientation, ev->delta,
+ ev->delta_discrete, ev->source, ev->relative_direction);
+ wlr_seat_pointer_notify_frame(r.seat);
+ });
+ r.cursor_frame_l.connect(r.cursor->events.frame,
+ [&r](void*) { wlr_seat_pointer_notify_frame(r.seat); });
+ // Touch: map the touch point through the same pick and notify the client.
+ r.touch_down_l.connect(r.cursor->events.touch_down, [&r](void* data) {
+ auto* ev = static_cast<wlr_touch_down_event*>(data);
+ double lx = 0, ly = 0;
+ wlr_cursor_absolute_to_layout_coords(r.cursor, &ev->touch->base, ev->x, ev->y, &lx, &ly);
+ Routed rt = route_point(r, lx, ly);
+ if (rt.s != nullptr) {
+ wlr_seat_touch_notify_down(r.seat, rt.s->surface, ev->time_msec, ev->touch_id, rt.sx,
+ rt.sy);
+ }
+ r.dirty = true;
+ wlr_output_schedule_frame(r.output);
+ });
+ r.touch_motion_l.connect(r.cursor->events.touch_motion, [&r](void* data) {
+ auto* ev = static_cast<wlr_touch_motion_event*>(data);
+ double lx = 0, ly = 0;
+ wlr_cursor_absolute_to_layout_coords(r.cursor, &ev->touch->base, ev->x, ev->y, &lx, &ly);
+ Routed rt = route_point(r, lx, ly);
+ if (rt.s != nullptr) {
+ wlr_seat_touch_notify_motion(r.seat, ev->time_msec, ev->touch_id, rt.sx, rt.sy);
+ }
+ });
+ r.touch_up_l.connect(r.cursor->events.touch_up, [&r](void* data) {
+ auto* ev = static_cast<wlr_touch_up_event*>(data);
+ wlr_seat_touch_notify_up(r.seat, ev->time_msec, ev->touch_id);
+ });
+}
+
+// ---- output bring-up ---------------------------------------------------------
+
+void handle_new_output(Runner& r, wlr_output* out) {
+ if (r.output != nullptr) {
+ return; // spike: drive ONE output
+ }
+ r.output = out;
+ wlr_output_init_render(out, r.allocator, r.renderer);
+ wlr_output_state st;
+ wlr_output_state_init(&st);
+ wlr_output_state_set_enabled(&st, true);
+ if (wlr_output_mode* mode = wlr_output_preferred_mode(out)) {
+ wlr_output_state_set_mode(&st, mode);
+ }
+ wlr_output_commit_state(out, &st);
+ wlr_output_state_finish(&st);
+
+ if (out->width > 0) {
+ r.out_w = out->width;
+ r.out_h = out->height;
+ }
+
+ wlr_output_layout_output* lo = wlr_output_layout_add_auto(r.output_layout, out);
+ r.scene_output = wlr_scene_output_create(r.scene, out);
+ wlr_scene_output_layout_add_output(r.scene_layout, lo, r.scene_output);
+
+ // Build the present target + RmlUi document sized to the output, then a
+ // single full-output scene_buffer node to present it (criterion 7).
+ r.gl.make_current();
+ r.present.init(&r.gl, r.allocator, r.out_w, r.out_h);
+ r.present_node = wlr_scene_buffer_create(&r.scene->tree, nullptr);
+ r.present.scene_buffer = r.present_node;
+ r.ctx = Rml::CreateContext("run", Rml::Vector2i(r.out_w, r.out_h), r.gl.render);
+ r.doc = r.ctx->LoadDocumentFromMemory(kRunRml);
+ if (r.doc != nullptr) {
+ r.doc->Show();
+ }
+ r.gl.restore_current();
+
+ r.frame_l.connect(out->events.frame, [&r](void*) { on_frame(r); });
+ wlr_output_schedule_frame(out);
+ std::fprintf(stderr, "[run] output %s up at %dx%d; present node + RmlUi document built\n",
+ out->name, r.out_w, r.out_h);
+}
+
+Runner* g_runner = nullptr;
+
+} // namespace
+
+auto run_real_seat(const char* startup_cmd) -> int {
+ wlr_log_init(WLR_INFO, nullptr);
+ Runner r;
+ g_runner = &r;
+
+ r.display = wl_display_create();
+ r.loop = wl_display_get_event_loop(r.display);
+ r.backend = wlr_backend_autocreate(r.loop, &r.session);
+ if (r.backend == nullptr) {
+ std::fprintf(stderr, "[run] failed to create backend\n");
+ return 1;
+ }
+ r.renderer = wlr_renderer_autocreate(r.backend);
+ wlr_renderer_init_wl_display(r.renderer, r.display);
+ r.allocator = wlr_allocator_autocreate(r.backend, r.renderer);
+
+ if (!wlr_renderer_is_gles2(r.renderer)) {
+ std::fprintf(stderr, "[run] renderer is not gles2 — RML compositing needs the GL path. "
+ "Set WLR_RENDERER=gles2.\n");
+ return 1;
+ }
+
+ r.compositor = wlr_compositor_create(r.display, 5, r.renderer);
+ wlr_subcompositor_create(r.display);
+ wlr_data_device_manager_create(r.display);
+ r.output_layout = wlr_output_layout_create(r.display);
+ r.scene = wlr_scene_create();
+ r.scene_layout = wlr_scene_attach_output_layout(r.scene, r.output_layout);
+
+ r.cursor = wlr_cursor_create();
+ wlr_cursor_attach_output_layout(r.cursor, r.output_layout);
+ r.cursor_mgr = wlr_xcursor_manager_create(nullptr, 24);
+ r.seat = wlr_seat_create(r.display, "seat0");
+
+ r.xdg_shell = wlr_xdg_shell_create(r.display, 3);
+ r.new_xdg_l.connect(r.xdg_shell->events.new_surface, [&r](void* data) {
+ handle_new_xdg(r, static_cast<wlr_xdg_surface*>(data));
+ });
+ r.layer_shell = wlr_layer_shell_v1_create(r.display, 4);
+ r.new_layer_l.connect(r.layer_shell->events.new_surface, [&r](void* data) {
+ handle_new_layer(r, static_cast<wlr_layer_surface_v1*>(data));
+ });
+
+ r.new_output_l.connect(r.backend->events.new_output,
+ [&r](void* data) { handle_new_output(r, static_cast<wlr_output*>(data)); });
+ r.new_input_l.connect(r.backend->events.new_input, [&r](void* data) {
+ handle_new_input(r, static_cast<wlr_input_device*>(data));
+ });
+ attach_input(r);
+
+ // Initialize the GL bridge against the wlr EGLDisplay now (before any output;
+ // the import path only needs the display).
+ EGLDisplay egl = wlr_egl_get_display(wlr_gles2_renderer_get_egl(r.renderer));
+ if (!r.gl.init(egl)) {
+ std::fprintf(stderr, "[run] GL bridge init failed — NO-GO on this hardware\n");
+ return 1;
+ }
+
+ const char* socket = wl_display_add_socket_auto(r.display);
+ if (socket == nullptr) {
+ std::fprintf(stderr, "[run] failed to add wayland socket\n");
+ return 1;
+ }
+ setenv("WAYLAND_DISPLAY", socket, 1);
+
+ if (!wlr_backend_start(r.backend)) {
+ std::fprintf(stderr, "[run] failed to start backend\n");
+ return 1;
+ }
+ std::fprintf(stderr, "[run] up on WAYLAND_DISPLAY=%s — spawning client: %s\n", socket,
+ startup_cmd);
+
+ if (startup_cmd != nullptr && startup_cmd[0] != '\0') {
+ if (fork() == 0) {
+ setenv("WAYLAND_DISPLAY", socket, 1);
+ execl("/bin/sh", "/bin/sh", "-c", startup_cmd, static_cast<char*>(nullptr));
+ _exit(127);
+ }
+ }
+
+ wl_display_run(r.display);
+
+ // Teardown.
+ const bool cur = r.gl.make_current();
+ for (LiveSurface& s : r.surfaces) {
+ s.live.destroy();
+ }
+ r.present.teardown();
+ if (r.ctx != nullptr) {
+ Rml::RemoveContext("run");
+ }
+ if (cur) {
+ r.gl.restore_current();
+ }
+ r.gl.teardown();
+ if (r.scene != nullptr) {
+ wlr_scene_node_destroy(&r.scene->tree.node);
+ }
+ if (r.cursor_mgr != nullptr) {
+ wlr_xcursor_manager_destroy(r.cursor_mgr);
+ }
+ if (r.cursor != nullptr) {
+ wlr_cursor_destroy(r.cursor);
+ }
+ if (r.allocator != nullptr) {
+ wlr_allocator_destroy(r.allocator);
+ }
+ if (r.renderer != nullptr) {
+ wlr_renderer_destroy(r.renderer);
+ }
+ if (r.backend != nullptr) {
+ wlr_backend_destroy(r.backend);
+ }
+ wl_display_destroy(r.display);
+ return 0;
+}
diff --git a/packages/kernel/src/spike/spike_gl.hpp b/packages/kernel/src/spike/spike_gl.hpp
new file mode 100644
index 0000000..8f7cfd8
--- /dev/null
+++ b/packages/kernel/src/spike/spike_gl.hpp
@@ -0,0 +1,507 @@
+#pragma once
+
+// SPIKE (rml-compositing, Phase 0) — shared GL glue for the runnable target.
+// THROWAWAY. The sibling GLES 3.2 bridge, the LIVE zero-copy surface-element
+// import, and the RmlUi-FBO -> wlr_buffer present target, shared by the
+// --verify TU and the --run (real-seat) TU. A trimmed copy of the substrate's
+// proven GlBridge mechanics; we deliberately do NOT refactor the real substrate
+// to share it (this is a spike). wlroots only via the kernel's wrapper.
+
+#include <unbox/kernel/wlr.hpp>
+
+#include "../rmlui_renderer_gl3.h"
+
+#include <RmlUi/Core/Context.h>
+#include <RmlUi/Core/Core.h>
+#include <RmlUi/Core/SystemInterface.h>
+
+#include <EGL/egl.h>
+#include <EGL/eglext.h>
+#include <GLES2/gl2ext.h>
+#include <GLES3/gl32.h>
+
+#include <cstdint>
+#include <cstdio>
+#include <cstring>
+#include <ctime>
+#include <string>
+#include <unordered_map>
+#include <utility>
+#include <vector>
+
+namespace unbox::kernel::spike {
+
+constexpr std::uint32_t kArgb8888 = 0x34325241; // 'AR24' = LE {B,G,R,A}
+
+inline auto now_sec() -> double {
+ timespec ts{};
+ clock_gettime(CLOCK_MONOTONIC, &ts);
+ return static_cast<double>(ts.tv_sec) + static_cast<double>(ts.tv_nsec) / 1e9;
+}
+
+// --- RmlUi SystemInterface: elapsed time + logs to stderr --------------------
+class SpikeSystem final : public Rml::SystemInterface {
+public:
+ auto GetElapsedTime() -> double override {
+ const double t = now_sec();
+ if (start_ == 0.0) {
+ start_ = t;
+ }
+ return t - start_;
+ }
+ auto LogMessage(Rml::Log::Type type, const Rml::String& msg) -> bool override {
+ if (type <= Rml::Log::LT_WARNING) {
+ std::fprintf(stderr, "[rmlui] %s\n", msg.c_str());
+ }
+ return true;
+ }
+
+private:
+ double start_ = 0.0;
+};
+
+// --- A data-ptr wlr_buffer wrapping heap memory (Plan-B present / test src) ---
+struct DataBuffer {
+ wlr_buffer base{};
+ std::vector<std::uint8_t> data;
+ std::size_t stride = 0;
+};
+inline void db_destroy(wlr_buffer* b) {
+ auto* d = reinterpret_cast<DataBuffer*>(b);
+ wlr_buffer_finish(&d->base);
+ delete d;
+}
+inline bool db_access(wlr_buffer* b, std::uint32_t, void** data, std::uint32_t* fmt,
+ std::size_t* stride) {
+ auto* d = reinterpret_cast<DataBuffer*>(b);
+ *data = d->data.data();
+ *fmt = kArgb8888;
+ *stride = d->stride;
+ return true;
+}
+inline void db_end(wlr_buffer*) {}
+inline const wlr_buffer_impl kDataImpl = {
+ .destroy = db_destroy,
+ .get_dmabuf = nullptr,
+ .get_shm = nullptr,
+ .begin_data_ptr_access = db_access,
+ .end_data_ptr_access = db_end,
+};
+inline auto make_data_buffer(int w, int h) -> DataBuffer* {
+ auto* d = new DataBuffer();
+ d->stride = static_cast<std::size_t>(w) * 4;
+ d->data.assign(d->stride * static_cast<std::size_t>(h), 0);
+ wlr_buffer_init(&d->base, &kDataImpl, w, h);
+ return d;
+}
+
+// --- The sibling GLES 3.2 bridge on the wlr EGLDisplay ------------------------
+struct GlBridge {
+ EGLDisplay dpy = EGL_NO_DISPLAY;
+ EGLContext ctx = EGL_NO_CONTEXT;
+ EGLConfig config = nullptr;
+
+ EGLContext saved_ctx = EGL_NO_CONTEXT;
+ EGLSurface saved_draw = EGL_NO_SURFACE;
+ EGLSurface saved_read = EGL_NO_SURFACE;
+
+ SpikeSystem system;
+ RenderInterface_GL3* render = nullptr;
+ bool rml_init = false;
+ bool ok = false;
+ bool dmabuf_ok = false;
+ bool fence_ok = false;
+
+ PFNEGLCREATEIMAGEKHRPROC create_image = nullptr;
+ PFNEGLDESTROYIMAGEKHRPROC destroy_image = nullptr;
+ PFNGLEGLIMAGETARGETTEXTURE2DOESPROC image_target = nullptr;
+ PFNEGLCREATESYNCKHRPROC create_sync = nullptr;
+ PFNEGLCLIENTWAITSYNCKHRPROC wait_sync = nullptr;
+ PFNEGLDESTROYSYNCKHRPROC destroy_sync = nullptr;
+
+ auto make_current() -> bool {
+ saved_ctx = eglGetCurrentContext();
+ saved_draw = eglGetCurrentSurface(EGL_DRAW);
+ saved_read = eglGetCurrentSurface(EGL_READ);
+ return eglMakeCurrent(dpy, EGL_NO_SURFACE, EGL_NO_SURFACE, ctx) == EGL_TRUE;
+ }
+ void restore_current() { eglMakeCurrent(dpy, saved_draw, saved_read, saved_ctx); }
+
+ void submit_sync() {
+ if (fence_ok) {
+ EGLSyncKHR s = create_sync(dpy, EGL_SYNC_FENCE_KHR, nullptr);
+ if (s != EGL_NO_SYNC_KHR) {
+ glFlush();
+ wait_sync(dpy, s, 0, EGL_FOREVER_KHR);
+ destroy_sync(dpy, s);
+ return;
+ }
+ }
+ glFinish();
+ }
+
+ auto init(EGLDisplay display) -> bool {
+ dpy = display;
+ if (dpy == EGL_NO_DISPLAY || eglBindAPI(EGL_OPENGL_ES_API) != EGL_TRUE) {
+ return false;
+ }
+ const EGLint cfg_attrs[] = {EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, EGL_RENDERABLE_TYPE,
+ EGL_OPENGL_ES3_BIT, EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8,
+ EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8, EGL_NONE};
+ EGLint n = 0;
+ if (eglChooseConfig(dpy, cfg_attrs, &config, 1, &n) != EGL_TRUE || n < 1) {
+ return false;
+ }
+ const EGLint ctx_attrs[] = {EGL_CONTEXT_MAJOR_VERSION, 3, EGL_CONTEXT_MINOR_VERSION, 2,
+ EGL_NONE};
+ ctx = eglCreateContext(dpy, config, EGL_NO_CONTEXT, ctx_attrs);
+ if (ctx == EGL_NO_CONTEXT || !make_current()) {
+ return false;
+ }
+ create_image =
+ reinterpret_cast<PFNEGLCREATEIMAGEKHRPROC>(eglGetProcAddress("eglCreateImageKHR"));
+ destroy_image =
+ reinterpret_cast<PFNEGLDESTROYIMAGEKHRPROC>(eglGetProcAddress("eglDestroyImageKHR"));
+ image_target = reinterpret_cast<PFNGLEGLIMAGETARGETTEXTURE2DOESPROC>(
+ eglGetProcAddress("glEGLImageTargetTexture2DOES"));
+ const char* exts = eglQueryString(dpy, EGL_EXTENSIONS);
+ dmabuf_ok = exts != nullptr &&
+ std::strstr(exts, "EGL_EXT_image_dma_buf_import") != nullptr &&
+ create_image != nullptr && image_target != nullptr;
+ create_sync =
+ reinterpret_cast<PFNEGLCREATESYNCKHRPROC>(eglGetProcAddress("eglCreateSyncKHR"));
+ wait_sync =
+ reinterpret_cast<PFNEGLCLIENTWAITSYNCKHRPROC>(eglGetProcAddress("eglClientWaitSyncKHR"));
+ destroy_sync =
+ reinterpret_cast<PFNEGLDESTROYSYNCKHRPROC>(eglGetProcAddress("eglDestroySyncKHR"));
+ fence_ok = exts != nullptr && std::strstr(exts, "EGL_KHR_fence_sync") != nullptr &&
+ create_sync != nullptr && wait_sync != nullptr && destroy_sync != nullptr;
+
+ if (!RmlGL3::Initialize(nullptr)) {
+ restore_current();
+ return false;
+ }
+ render = new RenderInterface_GL3();
+ if (!*render) {
+ restore_current();
+ return false;
+ }
+ Rml::SetSystemInterface(&system);
+ Rml::SetRenderInterface(render);
+ if (!Rml::Initialise()) {
+ restore_current();
+ return false;
+ }
+ rml_init = true;
+ if (!Rml::LoadFontFace("/usr/share/fonts/noto/NotoSans-Regular.ttf")) {
+ std::fprintf(stderr, "[spike] NotoSans not found; text labels will be blank\n");
+ }
+ restore_current();
+ ok = true;
+ std::fprintf(stderr, "[spike] GL bridge up (dmabuf_import=%d fence=%d)\n", dmabuf_ok,
+ fence_ok);
+ return true;
+ }
+
+ void teardown() {
+ const bool cur = (ctx != EGL_NO_CONTEXT) && make_current();
+ if (rml_init) {
+ Rml::Shutdown();
+ rml_init = false;
+ }
+ delete render;
+ render = nullptr;
+ if (cur) {
+ restore_current();
+ }
+ if (ctx != EGL_NO_CONTEXT) {
+ eglDestroyContext(dpy, ctx);
+ ctx = EGL_NO_CONTEXT;
+ }
+ }
+};
+
+// --- A LIVE surface element: a client buffer imported zero-copy as a sampled
+// texture, registered under a URI, re-imported ONLY on a new buffer commit. ---
+struct LiveTexture {
+ GlBridge* gl = nullptr;
+ std::string uri;
+ int width = 0, height = 0;
+ wlr_buffer* current = nullptr;
+ EGLImageKHR image = EGL_NO_IMAGE_KHR;
+ GLuint tex = 0;
+ bool is_dmabuf = false;
+ int reimports = 0;
+ int commits_seen = 0;
+
+ auto adopt(wlr_buffer* buf) -> bool {
+ ++commits_seen;
+ if (buf == current && tex != 0) {
+ return true; // unchanged buffer: zero re-import, zero copy
+ }
+ wlr_dmabuf_attributes attrs{};
+ if (gl->dmabuf_ok && wlr_buffer_get_dmabuf(buf, &attrs) && attrs.n_planes >= 1) {
+ EGLint ia[] = {EGL_WIDTH,
+ attrs.width,
+ EGL_HEIGHT,
+ attrs.height,
+ EGL_LINUX_DRM_FOURCC_EXT,
+ static_cast<EGLint>(attrs.format),
+ EGL_DMA_BUF_PLANE0_FD_EXT,
+ attrs.fd[0],
+ EGL_DMA_BUF_PLANE0_OFFSET_EXT,
+ static_cast<EGLint>(attrs.offset[0]),
+ EGL_DMA_BUF_PLANE0_PITCH_EXT,
+ static_cast<EGLint>(attrs.stride[0]),
+ EGL_NONE};
+ EGLImageKHR img =
+ gl->create_image(gl->dpy, EGL_NO_CONTEXT, EGL_LINUX_DMA_BUF_EXT, nullptr, ia);
+ if (img != EGL_NO_IMAGE_KHR) {
+ release_gl();
+ glGenTextures(1, &tex);
+ glBindTexture(GL_TEXTURE_2D, tex);
+ gl->image_target(GL_TEXTURE_2D, static_cast<GLeglImageOES>(img));
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
+ glBindTexture(GL_TEXTURE_2D, 0);
+ image = img;
+ width = attrs.width;
+ height = attrs.height;
+ is_dmabuf = true;
+ current = buf;
+ ++reimports;
+ register_uri();
+ return true;
+ }
+ }
+ // Fallback: one CPU upload for an shm client (still only on a new buffer).
+ void* data = nullptr;
+ std::uint32_t fmt = 0;
+ std::size_t stride = 0;
+ if (!wlr_buffer_begin_data_ptr_access(buf, WLR_BUFFER_DATA_PTR_ACCESS_READ, &data, &fmt,
+ &stride)) {
+ return false;
+ }
+ release_gl();
+ glGenTextures(1, &tex);
+ glBindTexture(GL_TEXTURE_2D, tex);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_R, GL_BLUE);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_B, GL_RED);
+ glPixelStorei(GL_UNPACK_ROW_LENGTH, static_cast<GLint>(stride / 4));
+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, buf->width, buf->height, 0, GL_RGBA,
+ GL_UNSIGNED_BYTE, data);
+ glPixelStorei(GL_UNPACK_ROW_LENGTH, 0);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
+ glBindTexture(GL_TEXTURE_2D, 0);
+ wlr_buffer_end_data_ptr_access(buf);
+ width = buf->width;
+ height = buf->height;
+ is_dmabuf = false;
+ current = buf;
+ ++reimports;
+ register_uri();
+ return true;
+ }
+
+ void register_uri() {
+ gl->render->register_preview_texture(uri, tex, Rml::Vector2i(width, height));
+ }
+ void release_gl() {
+ if (tex != 0) {
+ glDeleteTextures(1, &tex);
+ tex = 0;
+ }
+ if (image != EGL_NO_IMAGE_KHR && gl->destroy_image != nullptr) {
+ gl->destroy_image(gl->dpy, image);
+ image = EGL_NO_IMAGE_KHR;
+ }
+ }
+ void destroy() {
+ if (gl != nullptr && gl->render != nullptr) {
+ gl->render->unregister_preview_texture(uri);
+ }
+ release_gl();
+ current = nullptr;
+ }
+};
+
+// --- The RmlUi-FBO -> wlr_buffer present target (criterion 7) -----------------
+struct PresentTarget {
+ GlBridge* gl = nullptr;
+ wlr_allocator* allocator = nullptr;
+ int width = 0, height = 0;
+ bool dmabuf = false;
+
+ GLuint fbo = 0;
+ GLuint shm_tex = 0;
+ wlr_swapchain* swapchain = nullptr;
+ std::unordered_map<wlr_buffer*, std::pair<EGLImageKHR, GLuint>> slot_gl;
+
+ DataBuffer* shm = nullptr;
+ std::vector<std::uint8_t> readback;
+
+ wlr_scene_buffer* scene_buffer = nullptr;
+
+ auto init(GlBridge* g, wlr_allocator* alloc, int w, int h) -> bool {
+ gl = g;
+ allocator = alloc;
+ width = w;
+ height = h;
+ glGenFramebuffers(1, &fbo);
+ if (gl->dmabuf_ok && (allocator->buffer_caps & WLR_BUFFER_CAP_DMABUF) != 0) {
+ wlr_drm_format fmt{};
+ fmt.format = kArgb8888;
+ std::uint64_t mods[] = {0};
+ fmt.len = 1;
+ fmt.capacity = 1;
+ fmt.modifiers = mods;
+ swapchain = wlr_swapchain_create(allocator, w, h, &fmt);
+ if (swapchain != nullptr) {
+ dmabuf = true;
+ }
+ }
+ if (!dmabuf) {
+ glGenTextures(1, &shm_tex);
+ glBindTexture(GL_TEXTURE_2D, shm_tex);
+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
+ glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+ glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, shm_tex, 0);
+ const GLenum st = glCheckFramebufferStatus(GL_FRAMEBUFFER);
+ glBindFramebuffer(GL_FRAMEBUFFER, 0);
+ if (st != GL_FRAMEBUFFER_COMPLETE) {
+ return false;
+ }
+ shm = make_data_buffer(w, h);
+ readback.assign(static_cast<std::size_t>(w) * h * 4, 0);
+ }
+ return true;
+ }
+
+ auto render(Rml::Context* ctx) -> wlr_buffer* {
+ GLuint target = fbo;
+ wlr_buffer* dmabuf_target = nullptr;
+ if (dmabuf) {
+ wlr_buffer* buf = wlr_swapchain_acquire(swapchain);
+ if (buf == nullptr) {
+ return nullptr;
+ }
+ dmabuf_target = buf;
+ auto it = slot_gl.find(buf);
+ if (it == slot_gl.end()) {
+ wlr_dmabuf_attributes a{};
+ if (!wlr_buffer_get_dmabuf(buf, &a) || a.n_planes < 1) {
+ wlr_buffer_unlock(buf);
+ return nullptr;
+ }
+ EGLint ia[] = {EGL_WIDTH,
+ a.width,
+ EGL_HEIGHT,
+ a.height,
+ EGL_LINUX_DRM_FOURCC_EXT,
+ static_cast<EGLint>(a.format),
+ EGL_DMA_BUF_PLANE0_FD_EXT,
+ a.fd[0],
+ EGL_DMA_BUF_PLANE0_OFFSET_EXT,
+ static_cast<EGLint>(a.offset[0]),
+ EGL_DMA_BUF_PLANE0_PITCH_EXT,
+ static_cast<EGLint>(a.stride[0]),
+ EGL_NONE};
+ EGLImageKHR img =
+ gl->create_image(gl->dpy, EGL_NO_CONTEXT, EGL_LINUX_DMA_BUF_EXT, nullptr, ia);
+ if (img == EGL_NO_IMAGE_KHR) {
+ wlr_buffer_unlock(buf);
+ return nullptr;
+ }
+ GLuint t = 0;
+ glGenTextures(1, &t);
+ glBindTexture(GL_TEXTURE_2D, t);
+ gl->image_target(GL_TEXTURE_2D, static_cast<GLeglImageOES>(img));
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
+ it = slot_gl.emplace(buf, std::make_pair(img, t)).first;
+ }
+ glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+ glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
+ it->second.second, 0);
+ if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
+ glBindFramebuffer(GL_FRAMEBUFFER, 0);
+ wlr_buffer_unlock(buf);
+ return nullptr;
+ }
+ glBindFramebuffer(GL_FRAMEBUFFER, 0);
+ }
+
+ gl->render->SetViewport(width, height);
+ gl->render->SetOutputFramebuffer(target, /*flip_y=*/true);
+ glBindFramebuffer(GL_FRAMEBUFFER, target);
+ glClearColor(0.f, 0.f, 0.f, 0.f);
+ glClear(GL_COLOR_BUFFER_BIT);
+ glBindFramebuffer(GL_FRAMEBUFFER, 0);
+ ctx->Update();
+ gl->render->BeginFrame();
+ ctx->Render();
+ gl->render->EndFrame();
+
+ if (dmabuf) {
+ gl->submit_sync();
+ if (scene_buffer != nullptr) {
+ wlr_scene_buffer_set_buffer(scene_buffer, dmabuf_target);
+ }
+ wlr_buffer_unlock(dmabuf_target);
+ return dmabuf_target;
+ }
+ glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+ glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, readback.data());
+ glBindFramebuffer(GL_FRAMEBUFFER, 0);
+ const std::size_t px = static_cast<std::size_t>(width) * height;
+ for (std::size_t i = 0; i < px; ++i) {
+ shm->data[i * 4 + 0] = readback[i * 4 + 2];
+ shm->data[i * 4 + 1] = readback[i * 4 + 1];
+ shm->data[i * 4 + 2] = readback[i * 4 + 0];
+ shm->data[i * 4 + 3] = readback[i * 4 + 3];
+ }
+ if (scene_buffer != nullptr) {
+ wlr_scene_buffer_set_buffer(scene_buffer, &shm->base);
+ }
+ return &shm->base;
+ }
+
+ void pixel(int x, int y, std::uint8_t out[4]) {
+ glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+ glReadPixels(x, y, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, out);
+ glBindFramebuffer(GL_FRAMEBUFFER, 0);
+ }
+
+ void teardown() {
+ for (auto& [buf, slot] : slot_gl) {
+ if (slot.second != 0) {
+ glDeleteTextures(1, &slot.second);
+ }
+ if (slot.first != EGL_NO_IMAGE_KHR && gl->destroy_image != nullptr) {
+ gl->destroy_image(gl->dpy, slot.first);
+ }
+ }
+ slot_gl.clear();
+ if (shm_tex != 0) {
+ glDeleteTextures(1, &shm_tex);
+ }
+ if (fbo != 0) {
+ glDeleteFramebuffers(1, &fbo);
+ }
+ if (swapchain != nullptr) {
+ wlr_swapchain_destroy(swapchain);
+ }
+ if (shm != nullptr) {
+ wlr_buffer_drop(&shm->base);
+ }
+ }
+};
+
+} // namespace unbox::kernel::spike
diff --git a/packages/kernel/src/spike/spike_input_core.hpp b/packages/kernel/src/spike/spike_input_core.hpp
new file mode 100644
index 0000000..6e6fd4e
--- /dev/null
+++ b/packages/kernel/src/spike/spike_input_core.hpp
@@ -0,0 +1,224 @@
+#pragma once
+
+#include <array>
+#include <cmath>
+#include <optional>
+
+// SPIKE (rml-compositing, Phase 0) — PURE input-inversion core. NO wlroots / GL
+// / RMLUi types, so it is doctest-able with nothing running (AGENTS.md: pure
+// decision cores tested hard). Throwaway: proves the MATH that criterion 3
+// stands on — translating a point picked on a 3D-transformed surface element
+// back to surface-LOCAL coordinates, which then becomes a wl_seat notify.
+//
+// Why this exists separately from "RmlUi does the picking for us": RmlUi's
+// Context::ProcessMouse*/ProcessTouch* DO the transform-aware hit-test and report
+// the event's mouse_x/mouse_y already in element/surface-local space (the
+// substrate's ctx_motion proves this — it feeds context coords relative to the
+// surface origin and reads mouse_x/mouse_y straight back as surface-local px).
+// The spike still owns the FORWARD projection: to TEST that round trip
+// objectively without eyes, it must (a) place a surface-local point, (b) project
+// it THROUGH the same 3D transform RCSS applies to find where it lands on the
+// flat output (the "screen" point a finger would touch), then (c) confirm the
+// inverse recovers the original surface-local point. If forward∘inverse is
+// identity to sub-pixel tolerance through a perspective+rotateY, the geometry
+// criterion 3 needs is sound; the live wiring (RmlUi pick -> wl_seat) is then a
+// thin call proven at runtime in the GL spike.
+//
+// Everything is column-vector math with COLUMN-MAJOR 4x4 matrices, matching the
+// convention RmlUi's Matrix4f uses for `transform` (so a matrix authored here
+// maps 1:1 onto an RCSS transform when cross-checked). Single-thread; no state.
+
+namespace unbox::kernel::spike {
+
+// A column-major 4x4 matrix: m[col*4 + row]. v' = M * v.
+struct Mat4 {
+ std::array<double, 16> m{};
+
+ static auto identity() -> Mat4 {
+ Mat4 r;
+ r.m = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1};
+ return r;
+ }
+
+ auto at(int row, int col) const -> double { return m[static_cast<std::size_t>(col) * 4 + row]; }
+ auto at(int row, int col) -> double& { return m[static_cast<std::size_t>(col) * 4 + row]; }
+};
+
+// Column-major multiply: returns A*B.
+inline auto mul(const Mat4& a, const Mat4& b) -> Mat4 {
+ Mat4 r;
+ for (int col = 0; col < 4; ++col) {
+ for (int row = 0; row < 4; ++row) {
+ double s = 0.0;
+ for (int k = 0; k < 4; ++k) {
+ s += a.at(row, k) * b.at(k, col);
+ }
+ r.at(row, col) = s;
+ }
+ }
+ return r;
+}
+
+// A homogeneous 4-vector.
+struct Vec4 {
+ double x{}, y{}, z{}, w{};
+};
+
+inline auto apply(const Mat4& mtx, const Vec4& v) -> Vec4 {
+ return Vec4{
+ mtx.at(0, 0) * v.x + mtx.at(0, 1) * v.y + mtx.at(0, 2) * v.z + mtx.at(0, 3) * v.w,
+ mtx.at(1, 0) * v.x + mtx.at(1, 1) * v.y + mtx.at(1, 2) * v.z + mtx.at(1, 3) * v.w,
+ mtx.at(2, 0) * v.x + mtx.at(2, 1) * v.y + mtx.at(2, 2) * v.z + mtx.at(2, 3) * v.w,
+ mtx.at(3, 0) * v.x + mtx.at(3, 1) * v.y + mtx.at(3, 2) * v.z + mtx.at(3, 3) * v.w,
+ };
+}
+
+// ---- RCSS-equivalent transform builders (column-major) ----------------------
+
+// CSS `perspective(d)`: m[3][2] = -1/d (column-major: at(3,2)). A point at
+// model-z is foreshortened by w = 1 - z/d after the divide.
+inline auto perspective(double d) -> Mat4 {
+ Mat4 r = Mat4::identity();
+ r.at(3, 2) = -1.0 / d;
+ return r;
+}
+
+// CSS `rotateY(theta)` (radians). Right-handed about +Y.
+inline auto rotate_y(double theta) -> Mat4 {
+ Mat4 r = Mat4::identity();
+ const double c = std::cos(theta);
+ const double s = std::sin(theta);
+ r.at(0, 0) = c;
+ r.at(0, 2) = s;
+ r.at(2, 0) = -s;
+ r.at(2, 2) = c;
+ return r;
+}
+
+// CSS `translate(tx,ty)` in the XY plane.
+inline auto translate(double tx, double ty) -> Mat4 {
+ Mat4 r = Mat4::identity();
+ r.at(0, 3) = tx;
+ r.at(1, 3) = ty;
+ return r;
+}
+
+// ---- The transform RCSS actually applies around transform-origin -------------
+//
+// RCSS resolves `transform` about `transform-origin` (default 50% 50%): it
+// translates the origin to (0,0), applies the listed functions, then translates
+// back. This builds that full operator for a surface element of size w*h with
+// the given origin, so the math matches what RmlUi computes for the element.
+inline auto rcss_transform_about_origin(const Mat4& t, double origin_x, double origin_y) -> Mat4 {
+ return mul(translate(origin_x, origin_y), mul(t, translate(-origin_x, -origin_y)));
+}
+
+// ---- Forward projection: surface-local (lx,ly) -> screen point ---------------
+//
+// Place a surface-local point on the z=0 plane, push it through the element
+// transform, perform the perspective divide, and return the on-screen (sx,sy)
+// where a finger/cursor would land. This is the point the GL spike feeds to
+// RmlUi's ProcessMouse*/ProcessTouch*.
+struct ScreenPoint {
+ double x{}, y{};
+};
+
+inline auto project_to_screen(const Mat4& transform, double lx, double ly) -> ScreenPoint {
+ const Vec4 clip = apply(transform, Vec4{lx, ly, 0.0, 1.0});
+ const double inv_w = (std::abs(clip.w) < 1e-12) ? 0.0 : 1.0 / clip.w;
+ return ScreenPoint{clip.x * inv_w, clip.y * inv_w};
+}
+
+// ---- Inverse: screen point -> surface-local (lx,ly) --------------------------
+//
+// Inverting the projection is a ray/plane intersection (the transform is not
+// affine under perspective). We invert the 4x4 transform, take the screen point
+// as a clip-space ray (two points at different homogeneous depths), transform
+// both back to model space, and intersect the resulting model-space ray with
+// the element's own z=0 plane. The intersection's (x,y) is the surface-local
+// coordinate. Returns nullopt if the transform is singular or the ray is
+// parallel to the plane (degenerate edge-on view).
+
+// General 4x4 inverse (column-major). nullopt if |det| ~ 0.
+inline auto invert(const Mat4& a) -> std::optional<Mat4> {
+ const std::array<double, 16>& s = a.m;
+ std::array<double, 16> inv{};
+
+ inv[0] = s[5] * s[10] * s[15] - s[5] * s[11] * s[14] - s[9] * s[6] * s[15] +
+ s[9] * s[7] * s[14] + s[13] * s[6] * s[11] - s[13] * s[7] * s[10];
+ inv[4] = -s[4] * s[10] * s[15] + s[4] * s[11] * s[14] + s[8] * s[6] * s[15] -
+ s[8] * s[7] * s[14] - s[12] * s[6] * s[11] + s[12] * s[7] * s[10];
+ inv[8] = s[4] * s[9] * s[15] - s[4] * s[11] * s[13] - s[8] * s[5] * s[15] +
+ s[8] * s[7] * s[13] + s[12] * s[5] * s[11] - s[12] * s[7] * s[9];
+ inv[12] = -s[4] * s[9] * s[14] + s[4] * s[10] * s[13] + s[8] * s[5] * s[14] -
+ s[8] * s[6] * s[13] - s[12] * s[5] * s[10] + s[12] * s[6] * s[9];
+ inv[1] = -s[1] * s[10] * s[15] + s[1] * s[11] * s[14] + s[9] * s[2] * s[15] -
+ s[9] * s[3] * s[14] - s[13] * s[2] * s[11] + s[13] * s[3] * s[10];
+ inv[5] = s[0] * s[10] * s[15] - s[0] * s[11] * s[14] - s[8] * s[2] * s[15] +
+ s[8] * s[3] * s[14] + s[12] * s[2] * s[11] - s[12] * s[3] * s[10];
+ inv[9] = -s[0] * s[9] * s[15] + s[0] * s[11] * s[13] + s[8] * s[1] * s[15] -
+ s[8] * s[3] * s[13] - s[12] * s[1] * s[11] + s[12] * s[3] * s[9];
+ inv[13] = s[0] * s[9] * s[14] - s[0] * s[10] * s[13] - s[8] * s[1] * s[14] +
+ s[8] * s[2] * s[13] + s[12] * s[1] * s[10] - s[12] * s[2] * s[9];
+ inv[2] = s[1] * s[6] * s[15] - s[1] * s[7] * s[14] - s[5] * s[2] * s[15] +
+ s[5] * s[3] * s[14] + s[13] * s[2] * s[7] - s[13] * s[3] * s[6];
+ inv[6] = -s[0] * s[6] * s[15] + s[0] * s[7] * s[14] + s[4] * s[2] * s[15] -
+ s[4] * s[3] * s[14] - s[12] * s[2] * s[7] + s[12] * s[3] * s[6];
+ inv[10] = s[0] * s[5] * s[15] - s[0] * s[7] * s[13] - s[4] * s[1] * s[15] +
+ s[4] * s[3] * s[13] + s[12] * s[1] * s[7] - s[12] * s[3] * s[5];
+ inv[14] = -s[0] * s[5] * s[14] + s[0] * s[6] * s[13] + s[4] * s[1] * s[14] -
+ s[4] * s[2] * s[13] - s[12] * s[1] * s[6] + s[12] * s[2] * s[5];
+ inv[3] = -s[1] * s[6] * s[11] + s[1] * s[7] * s[10] + s[5] * s[2] * s[11] -
+ s[5] * s[3] * s[10] - s[9] * s[2] * s[7] + s[9] * s[3] * s[6];
+ inv[7] = s[0] * s[6] * s[11] - s[0] * s[7] * s[10] - s[4] * s[2] * s[11] +
+ s[4] * s[3] * s[10] + s[8] * s[2] * s[7] - s[8] * s[3] * s[6];
+ inv[11] = -s[0] * s[5] * s[11] + s[0] * s[7] * s[9] + s[4] * s[1] * s[11] -
+ s[4] * s[3] * s[9] - s[8] * s[1] * s[7] + s[8] * s[3] * s[5];
+ inv[15] = s[0] * s[5] * s[10] - s[0] * s[6] * s[9] - s[4] * s[1] * s[10] +
+ s[4] * s[2] * s[9] + s[8] * s[1] * s[6] - s[8] * s[2] * s[5];
+
+ double det = s[0] * inv[0] + s[1] * inv[4] + s[2] * inv[8] + s[3] * inv[12];
+ if (std::abs(det) < 1e-12) {
+ return std::nullopt;
+ }
+ det = 1.0 / det;
+ Mat4 r;
+ for (int i = 0; i < 16; ++i) {
+ r.m[static_cast<std::size_t>(i)] = inv[static_cast<std::size_t>(i)] * det;
+ }
+ return r;
+}
+
+struct LocalPoint {
+ double x{}, y{};
+};
+
+// Unproject a screen point through `transform` back onto the element's z=0
+// plane. `transform` is the same forward operator used by project_to_screen
+// (RCSS transform about origin). Returns the surface-local (lx,ly).
+inline auto unproject_to_local(const Mat4& transform, double sx, double sy)
+ -> std::optional<LocalPoint> {
+ const std::optional<Mat4> inv = invert(transform);
+ if (!inv) {
+ return std::nullopt;
+ }
+ // Two clip-space points along the viewing ray at the screen pixel: clip-z
+ // is free under an orthographic screen, so pick z=0 and z=1 (homogeneous
+ // w=1) and map both back to model space, then intersect with model z=0.
+ const Vec4 a = apply(*inv, Vec4{sx, sy, 0.0, 1.0});
+ const Vec4 b = apply(*inv, Vec4{sx, sy, 1.0, 1.0});
+ const auto dehom = [](const Vec4& v) -> Vec4 {
+ const double iw = (std::abs(v.w) < 1e-12) ? 0.0 : 1.0 / v.w;
+ return Vec4{v.x * iw, v.y * iw, v.z * iw, 1.0};
+ };
+ const Vec4 pa = dehom(a);
+ const Vec4 pb = dehom(b);
+ const double dz = pb.z - pa.z;
+ if (std::abs(dz) < 1e-12) {
+ return std::nullopt; // ray parallel to the element plane
+ }
+ const double t = (0.0 - pa.z) / dz; // param where the ray crosses z=0
+ return LocalPoint{pa.x + (pb.x - pa.x) * t, pa.y + (pb.y - pa.y) * t};
+}
+
+} // namespace unbox::kernel::spike
diff --git a/packages/kernel/tests/test_kernel.cpp b/packages/kernel/tests/test_kernel.cpp
index da2cbf0..678bdee 100644
--- a/packages/kernel/tests/test_kernel.cpp
+++ b/packages/kernel/tests/test_kernel.cpp
@@ -15,6 +15,15 @@
#include "../src/ui_core.hpp"
// The VT-switch escape hatch's pure core (keysym -> VT number), no wlroots.
#include "../src/vt_core.hpp"
+// SPIKE (rml-compositing, Phase 0): the throwaway spike's PURE input-inversion
+// core (screen-point -> surface-local through a 3D transform). Header-only, no
+// wlroots/GL/RMLUi, so the criterion-3 geometry is doctest-ed here alongside the
+// runnable target's own headless self-check (src/spike/). Kept in the kernel
+// suite so the spike's geometry stays green with the unit.
+#include "../src/spike/spike_input_core.hpp"
+
+#include <cmath>
+#include <numbers>
#include <cstdlib>
#include <filesystem>
@@ -2555,3 +2564,92 @@ TEST_CASE("ui: transition_timing reads RCSS duration/delay + tween, resolves pro
// (5) Unparseable property name => nullopt (no exact match, no `all` here).
CHECK_FALSE(s->transition_timing("anim", "not-a-real-property").has_value());
}
+
+// ============================================================================
+// SPIKE (rml-compositing, Phase 0) — PURE input-inversion core (criterion 3).
+// The runnable spike target (src/spike/) self-checks the live-texture / 3D
+// transform / present / idle-gate headless; THIS unit-tests the screen-point ->
+// (surface element, surface-local coord) inversion through a known transform —
+// the math the runtime RmlUi-pick -> wl_seat translation rides on. Throwaway,
+// but kept green with the kernel: a regressed inverse would silently mis-route
+// touch on a tilted window, the exact failure criterion 3 guards against.
+// ============================================================================
+
+namespace {
+namespace spk = unbox::kernel::spike;
+
+// Forward-project a surface-local point through `t`, then invert; assert the
+// round trip recovers the original to sub-pixel. err in pixels.
+auto roundtrip_err(const spk::Mat4& t, double lx, double ly) -> double {
+ const spk::ScreenPoint s = spk::project_to_screen(t, lx, ly);
+ const auto back = spk::unproject_to_local(t, s.x, s.y);
+ if (!back) {
+ return 1e9;
+ }
+ return std::hypot(back->x - lx, back->y - ly);
+}
+} // namespace
+
+TEST_CASE("spike(rml-compositing): screen->surface-local inverts an affine transform") {
+ // A plain translate (no perspective): the inverse must be exact everywhere.
+ const spk::Mat4 t = spk::translate(120.0, -40.0);
+ CHECK(roundtrip_err(t, 0.0, 0.0) < 1e-9);
+ CHECK(roundtrip_err(t, 200.0, 150.0) < 1e-9);
+ // The forward map is a pure offset: a local (10,10) lands at (130,-30).
+ const spk::ScreenPoint s = spk::project_to_screen(t, 10.0, 10.0);
+ CHECK(s.x == doctest::Approx(130.0));
+ CHECK(s.y == doctest::Approx(-30.0));
+}
+
+TEST_CASE("spike(rml-compositing): inverts perspective + rotateY about the element origin") {
+ // The criterion-3 case: a 256x256 surface element with perspective(800) +
+ // rotateY, resolved about the 50% origin (what RCSS computes). The inverse is
+ // a ray/plane intersection (non-affine under perspective); assert sub-0.01px
+ // recovery across the element, including off-center points that foreshorten.
+ const double origin = 128.0;
+ for (double deg : {15.0, 35.0, 60.0, -45.0}) {
+ const spk::Mat4 t = spk::rcss_transform_about_origin(
+ spk::mul(spk::perspective(800.0),
+ spk::rotate_y(deg * std::numbers::pi / 180.0)),
+ origin, origin);
+ CHECK(roundtrip_err(t, 128.0, 128.0) < 1e-6); // center: on the rotation axis
+ CHECK(roundtrip_err(t, 32.0, 64.0) < 0.01); // near edge (foreshortened)
+ CHECK(roundtrip_err(t, 224.0, 200.0) < 0.01); // far edge
+ CHECK(roundtrip_err(t, 64.0, 96.0) < 0.01); // arbitrary interior point
+ }
+}
+
+TEST_CASE("spike(rml-compositing): the inverse is the true matrix inverse (M*inv ~ I)") {
+ // The unprojection's correctness rests on invert(): assert inv(M)*M is the
+ // identity for the perspective+rotateY operator (the non-trivial case). This
+ // is the algebraic backstop under the geometric round-trip tests above.
+ const double origin = 128.0;
+ const spk::Mat4 m = spk::rcss_transform_about_origin(
+ spk::mul(spk::perspective(800.0), spk::rotate_y(40.0 * std::numbers::pi / 180.0)), origin,
+ origin);
+ const auto inv = spk::invert(m);
+ REQUIRE(inv.has_value());
+ const spk::Mat4 prod = spk::mul(*inv, m);
+ for (int r = 0; r < 4; ++r) {
+ for (int c = 0; c < 4; ++c) {
+ CHECK(prod.at(r, c) == doctest::Approx(r == c ? 1.0 : 0.0).epsilon(1e-9));
+ }
+ }
+}
+
+TEST_CASE("spike(rml-compositing): an edge-on (90deg) transform collapses the element to a line") {
+ // rotateY(90deg) about the origin turns the element edge-on: its plane
+ // projects to a vertical LINE on screen, so distinct surface-local points
+ // collapse to (nearly) the same screen x — there is no reliable preimage. We
+ // assert the GEOMETRIC truth (the forward map is degenerate) rather than a
+ // particular inverse return: at runtime RmlUi's own transform-aware pick is
+ // what declines an edge-on element, so the spike never has to invert one.
+ const double origin = 128.0;
+ const spk::Mat4 t = spk::rcss_transform_about_origin(
+ spk::mul(spk::perspective(800.0), spk::rotate_y(std::numbers::pi / 2.0)), origin, origin);
+ const spk::ScreenPoint a = spk::project_to_screen(t, 32.0, 64.0);
+ const spk::ScreenPoint b = spk::project_to_screen(t, 224.0, 64.0);
+ // Two points 192px apart in surface-local X land at the same screen X (the
+ // element is edge-on): the map lost its X information.
+ CHECK(std::abs(a.x - b.x) < 0.5);
+}
diff --git a/tasks.md b/tasks.md
index 7b353fa..4cd757a 100644
--- a/tasks.md
+++ b/tasks.md
@@ -13,7 +13,14 @@ layout/animation/3D effects in RCSS; wlroots stays foundation + cursor plane +
by OUR dirty-gated rendering (NOT a RMLUi built-in) + a deferred scanout bypass.
GATED BY A SPIKE before commit. Full spec + acceptance criteria:
`notes/rml-compositing.md`; decision row in `notes/plan.md` §2.
-NEXT ACTION: write the spike brief (kernel/substrate) and summon it.
+SPIKE RESULT: code-complete + self-verified **GO** on real Haswell+crocus (the
+CF-AX3's GPU class) — all 7 criteria `ALL PASS` headless; surface trees resolved
+to **per-subsurface elements** (RTT escape-hatch for tree-spanning effects);
+present path = FBO→dmabuf swapchain→wlr_scene_buffer + EGL fence. Throwaway
+target `packages/kernel/rml-compositing-spike` (`--verify` / `--run`), kept out
+of the shipped binary. NEXT ACTION: **USER real-seat GO/NO-GO** — 3D/touch feel,
+frame-time @4 windows+video, idle power (runbook in
+`reports/rml-compositing-spike.md` §5). Then Phase 1 (architecture).
Tiling (slice 7) is DEFERRED behind this (becomes RCSS over surface elements;
pure layout core in `notes/tiling-spec.md` carries over). Stage dock (slice 10)
real-seat feel check is paused under this pivot.
@@ -116,7 +123,7 @@ deprecated no-op `Options::ui_spike`, retiring host-bin's demo ui.
| 10 | **Stage dock** (ext-stage-dock): minimized-window previews on a left-edge swipe (Fork B) | **a1–d1 landed; previews real-seat-verified** | DONE: Super+M minimize→RMLUi-imported preview snapshot→dock slot→hide (previews confirmed rendering on hardware); RCSS dock slide-in + slot settle. NEXT: confirm tap-to-restore + animation feel; 1 boundary call (input-transparent UiSurface flag) → c1 gesture-claim → e1 gesture reveal/drag-out; then config-driven minimize keybind + favicon (XDG icon dep) |
| 11 | **Status bar** (tent. ext-statusbar): iPad/iOS top bar — clock (left), configurable left/middle/right sections, tray (right) wifi/volume/battery | **IDEA — needs design** | sequenced AFTER slice 7 (tiling); replaces cut taskbar. Details + open questions: `notes/status-bar-home-screen.md` |
| 12 | **Home screen** (tent. ext-home, iPad springboard): app grid; tap = launch-or-raise (instance picker if >1 open); add/remove apps; swipe-up-from-bottom to enter | **IDEA — needs design** | sequenced AFTER slice 7 (tiling); replaces cut taskbar. Details + open questions: `notes/status-bar-home-screen.md` |
-| 13 | **THE SPIKE: RML compositing** — RMLUi becomes the content compositor (toplevels + layer-shell incl. wallpaper + chrome = RML elements backed by LIVE, SHARED GL textures; layout/animation/3D effects in RCSS). wlroots = foundation + cursor plane + (deferred) fullscreen scanout bypass. | **ACTIVE (core) — spike** | GO/NO-GO on the CF-AX3: (1) live toplevel texture in RmlUi via shared context, ZERO per-frame copy; (2) RCSS 3D transform on it; (3) pointer+touch+keyboard routed back through RmlUi picking → wl_seat; (4) window w/ popup+subsurface composited (decides per-subsurface-elements vs per-window RTT); (5) wallpaper as an element; (6) perf ~4 windows@1080p + idle≈no-work (our dirty-gating) + video cost; (7) present via existing FBO→scene_buffer bridge. Full spec + decision row: `notes/rml-compositing.md`, plan.md §2. |
+| 13 | **THE SPIKE: RML compositing** — RMLUi becomes the content compositor (toplevels + layer-shell incl. wallpaper + chrome = RML elements backed by LIVE, SHARED GL textures; layout/animation/3D effects in RCSS). wlroots = foundation + cursor plane + (deferred) fullscreen scanout bypass. | **spike code-complete; GO (self-verified); pending USER real-seat GO/NO-GO** | All 7 criteria `ALL PASS` headless on Haswell+crocus: (1) zero-copy live dmabuf texture (cached when unchanged); (2) RCSS perspective+rotateY on live pixels (readback); (3) screen→surface-local inversion through the transform = 0.000000px; (4) surface tree composited → **per-subsurface elements** (RTT hook for tree-spanning effects); (5) wallpaper via identical import path; (6) idle dirty-gate = 0 idle renders / 1-per-commit (frame-time @load = real-seat); (7) FBO→dmabuf→wlr_scene_buffer + EGL fence. Spike target `rml-compositing-spike` (`--verify`/`--run`). Report + runbook: `reports/rml-compositing-spike.md`. |
## Deferred decisions (decide when reached — see notes/plan.md §7)