diff options
| author | Adam Malczewski <[email protected]> | 2026-06-14 21:49:02 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-14 21:49:02 +0900 |
| commit | 4b568e52ac8f7c04b72692e619806431abd8d787 (patch) | |
| tree | db4b5974ea92bb37e73948fd1846677afb90d152 | |
| parent | 5437b5e33c542c801bdc557be2ae93bfec6e153d (diff) | |
| download | unbox-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.md | 2 | ||||
| -rw-r--r-- | packages/kernel/meson.build | 27 | ||||
| -rw-r--r-- | packages/kernel/src/spike/rml_compositing_spike.cpp | 557 | ||||
| -rw-r--r-- | packages/kernel/src/spike/rml_compositing_spike_run.cpp | 767 | ||||
| -rw-r--r-- | packages/kernel/src/spike/spike_gl.hpp | 507 | ||||
| -rw-r--r-- | packages/kernel/src/spike/spike_input_core.hpp | 224 | ||||
| -rw-r--r-- | packages/kernel/tests/test_kernel.cpp | 98 | ||||
| -rw-r--r-- | tasks.md | 11 |
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 = ≷ + 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 = ≷ + } + 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 = ≷ + 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); +} @@ -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) |
