summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-15 06:27:04 +0900
committerAdam Malczewski <[email protected]>2026-06-15 06:27:04 +0900
commit74d75fdd8b8654446338ae03bf2a133512408ec6 (patch)
tree4aaee8bf36a988a17b485d15d4144a7909b14220
parent12e50166948b7554ccbdd382c8769774033b6e2c (diff)
downloadunbox-74d75fdd8b8654446338ae03bf2a133512408ec6.tar.gz
unbox-74d75fdd8b8654446338ae03bf2a133512408ec6.zip
kernel(rml-compositing W1): live SurfaceElement (zero-copy, self-updating)
Phase 2 Wave 1: the live sibling of Preview. A SurfaceElement is backed by a client wl_surface's current committed buffer, imported zero-copy into the RMLUi sibling GLES context and served under an unbox-surface://N URI, shown via <img src> in any UiSurface. Public contract (ui.hpp): - class SurfaceElement { source_uri(); width(); height(); } (no refresh()). - UiSubstrate::create_surface_element(wlr_surface* client) -> unique_ptr; nullptr on no-GL/import-fail, never throws; `client` is a borrow the caller must outlive and drop on unmap/destroy (lifetime documented per listener-lifetime). Behavior (ported from the in-tree spike, untouched): - seq-gated re-import (wlr_surface->current.seq), pool-reuse-proof; double- buffered wlr_buffer_lock/unlock (<=1 pinned, balanced incl. prev==buf). - frame-callback duty per composited frame so the client keeps drawing (the stuck-frame fix); a frame stays scheduled while >=1 element exists. - commit dirties the hosting ui surface (dirty-gate); static client = no work. - shm-upload + R<->B swizzle fallback when there is no dmabuf path. Pure-core predicate (surface_element_needs_reimport) doctested; headless integration test drives a REAL in-process Wayland client (re-import on seq advance, the pooled same-pointer case, zero idle re-imports, climbing frame-done). kernel suite + build-asan green on Haswell+crocus. Test-only wayland-client dep (kernel-tests scope; user-accepted). Scope held: single surface, no input-back/damage/scene changes (later waves).
-rw-r--r--packages/kernel/include/unbox/kernel/server.hpp27
-rw-r--r--packages/kernel/include/unbox/kernel/ui.hpp77
-rw-r--r--packages/kernel/meson.build10
-rw-r--r--packages/kernel/src/server.cpp92
-rw-r--r--packages/kernel/src/server_impl.hpp14
-rw-r--r--packages/kernel/src/ui_core.hpp42
-rw-r--r--packages/kernel/src/ui_substrate.cpp352
-rw-r--r--packages/kernel/src/ui_substrate.hpp76
-rw-r--r--packages/kernel/tests/test_kernel.cpp312
-rw-r--r--tasks.md16
10 files changed, 1006 insertions, 12 deletions
diff --git a/packages/kernel/include/unbox/kernel/server.hpp b/packages/kernel/include/unbox/kernel/server.hpp
index 9aeb143..fa34e02 100644
--- a/packages/kernel/include/unbox/kernel/server.hpp
+++ b/packages/kernel/include/unbox/kernel/server.hpp
@@ -97,6 +97,33 @@ public:
// The kernel suite asserts this is true on a gles2/dmabuf backend.
[[nodiscard]] auto ui_preview_import_is_dmabuf() const -> bool;
+ // ---- surface-element test instrumentation (kernel suite only) ----
+ // Total live re-imports across all surface elements (RML compositing Wave 1):
+ // a seq advance => exactly one bump; re-adopting the same seq => zero. Lets
+ // the suite assert the seq-gate (spike --verify criterion 1).
+ [[nodiscard]] auto ui_surface_element_reimport_count() const -> int;
+ // Total wl_surface frame-done sends across all surface elements: one per
+ // element per composited frame (the frame-callback duty / stuck-frame fix).
+ // Lets the suite assert frame-done is driven per frame.
+ [[nodiscard]] auto ui_surface_element_frame_done_count() const -> int;
+ // Whether the most recent surface-element import took the dmabuf path (vs the
+ // shm-upload fallback). The suite asserts true on a gles2/dmabuf backend.
+ [[nodiscard]] auto ui_surface_element_import_is_dmabuf() const -> bool;
+
+ // Build a SurfaceElement from the most recently committed CLIENT wl_surface
+ // (captured by the kernel's compositor test seam), via the same substrate
+ // path Host::ui().create_surface_element uses. Returns true if an element was
+ // created. A test has no other in-process way to obtain a real wl_surface, so
+ // this seam lets the suite exercise the live import/seq-gate/frame-done wiring
+ // against a real client surface. Test instrumentation; single-thread only.
+ auto ui_create_surface_element_for_test() -> bool;
+ // The current test surface element's URI / size (empty / 0 if none).
+ [[nodiscard]] auto ui_surface_element_uri() const -> std::string;
+ [[nodiscard]] auto ui_surface_element_width() const -> int;
+ [[nodiscard]] auto ui_surface_element_height() const -> int;
+ // Drop the test surface element (releases its import + commit hook + URI).
+ void ui_drop_surface_element_for_test();
+
// Packed 0xRRGGBBAA of the first shm-path ui surface's submitted buffer at
// layout pixel (x,y) (row 0 = top). 0 if no shm surface / no frame / out of
// bounds. Position-aware readback so the preview-spike test can assert a
diff --git a/packages/kernel/include/unbox/kernel/ui.hpp b/packages/kernel/include/unbox/kernel/ui.hpp
index 25ad406..13ea2ae 100644
--- a/packages/kernel/include/unbox/kernel/ui.hpp
+++ b/packages/kernel/include/unbox/kernel/ui.hpp
@@ -313,6 +313,57 @@ protected:
Preview() = default;
};
+// A LIVE surface element (GLOSSARY: "surface element") — the live sibling of a
+// Preview. It is backed by a client wl_surface's CURRENT committed buffer,
+// imported ZERO-COPY (dmabuf -> EGLImage -> GL texture, shm-upload fallback)
+// into the ui substrate's sibling GLES context and registered under a URI, so
+// putting source_uri() into an RML <img src="..."> in ANY ui surface of this
+// substrate samples the client's LIVE pixels. Owned by the contributing
+// extension via unique_ptr; destruction drops the import, ends the
+// frame-callback duty, and unregisters the URI. Event-loop thread only.
+//
+// HOW IT DIFFERS FROM Preview (which it otherwise mirrors):
+// - LIVE, not frozen: it re-imports the client's current buffer every commit
+// (seq-gated — a static client costs ZERO work, an updating one costs one
+// re-import per committed frame), so there is NO refresh(): it updates
+// itself.
+// - It DRIVES the client's frame callbacks: while the element exists the
+// substrate sends the backing wl_surface its frame-done each composited
+// frame, so the client keeps producing buffers (without this a client draws
+// once and waits forever — the spike's stuck-frame fix).
+// - A client commit DIRTIES the hosting ui surface(s): the next frame
+// re-renders the updated texture (a static client schedules nothing).
+//
+// LIFETIME (part of the contract — see .unbox/rules/listener-lifetime.md). The
+// wl_surface passed to create_surface_element is a BORROW: the substrate samples
+// it live, so the CALLER guarantees it outlives this element and MUST drop the
+// element the moment the surface unmaps or is destroyed (extensions already
+// track map/unmap). Sampling a surface element after its wl_surface has been
+// destroyed is UNDEFINED BEHAVIOUR — the substrate cannot detect a freed
+// wl_surface. (Wave 1 is SINGLE-SURFACE: one element per wl_surface, no
+// subsurface/popup child trees yet — that is Wave 1b.)
+class SurfaceElement {
+public:
+ virtual ~SurfaceElement() = default;
+ SurfaceElement(const SurfaceElement&) = delete;
+ auto operator=(const SurfaceElement&) -> SurfaceElement& = delete;
+
+ // The <img src> value resolving to this surface's LIVE texture inside any ui
+ // surface of this substrate (e.g. "unbox-surface://7"). Stable for life.
+ [[nodiscard]] virtual auto source_uri() const -> std::string = 0;
+ // The client surface's current pixel size (tracks commits). 0 until the
+ // first buffer has been imported.
+ [[nodiscard]] virtual auto width() const -> int = 0;
+ [[nodiscard]] virtual auto height() const -> int = 0;
+
+ // NO refresh() (unlike Preview): a surface element updates itself every
+ // client commit (seq-gated re-import) and drives the client's frame
+ // callbacks while it exists.
+
+protected:
+ SurfaceElement() = default;
+};
+
// The kernel's ui substrate, reached via Host::ui(). Per-session, kernel-owned;
// the reference is a borrow valid for your extension's lifetime. Carries your
// extension identity for error isolation.
@@ -347,6 +398,32 @@ public:
[[nodiscard]] virtual auto create_preview(wlr_scene_tree* source)
-> std::unique_ptr<Preview> = 0;
+ // Create a LIVE surface element backed by `client`'s current committed
+ // buffer (see the SurfaceElement docs above). The client's buffer is
+ // imported zero-copy into the RMLUi sibling context (dmabuf -> EGLImage ->
+ // texture; shm-upload + R<->B swizzle fallback when there is no dmabuf path)
+ // and registered under an "unbox-surface://N" URI; show it by putting the
+ // returned element's source_uri() into an RML <img src="..."> in any ui
+ // surface this substrate created. Ownership transfers to you (unique_ptr).
+ //
+ // Returns nullptr if the substrate has no GL path (e.g. the headless pixman
+ // renderer) or the initial import failed (graceful degrade). NEVER throws.
+ //
+ // CONTRAST WITH create_preview: a Preview is a FROZEN one-shot snapshot of a
+ // scene subtree that you refresh() manually; a SurfaceElement is LIVE and
+ // SELF-UPDATING — it re-imports `client`'s current buffer on every commit
+ // (seq-gated, so a static client is free) and the substrate DRIVES
+ // `client`'s frame callbacks while the element exists, so the client keeps
+ // drawing. There is no refresh().
+ //
+ // LIFETIME: `client` is a BORROW. The caller guarantees it outlives the
+ // returned element and MUST drop the element on the surface's unmap/destroy
+ // (see SurfaceElement above + .unbox/rules/listener-lifetime.md). Wave 1 is
+ // single-surface (one element per wl_surface; subsurface/popup child trees
+ // are Wave 1b).
+ [[nodiscard]] virtual auto create_surface_element(wlr_surface* client)
+ -> std::unique_ptr<SurfaceElement> = 0;
+
// Whether the substrate has a working GL bridge on this backend. When
// false, create_surface returns nullptr (degrade gracefully).
[[nodiscard]] virtual auto available() const -> bool = 0;
diff --git a/packages/kernel/meson.build b/packages/kernel/meson.build
index b53b3ad..bcb9364 100644
--- a/packages/kernel/meson.build
+++ b/packages/kernel/meson.build
@@ -82,10 +82,18 @@ kernel_dep = declare_dependency(
dependencies: [wlroots_dep, wayland_server_dep],
)
+# wayland-client (TEST-ONLY): the surface-element integration test spins an
+# in-process Wayland CLIENT thread that connects to the headless server, creates
+# a wl_surface + wl_shm buffer, and commits — the only in-process way to get a
+# REAL wlr_surface with an advancing commit seq to exercise the live import.
+# It is NOT a kernel-library dependency (the kernel is a compositor, never a
+# client); scoped to the test executable only.
+wayland_client_dep = dependency('wayland-client')
+
kernel_test = executable(
'kernel-tests',
'tests/test_kernel.cpp',
- dependencies: [kernel_dep, doctest_dep],
+ dependencies: [kernel_dep, doctest_dep, wayland_client_dep],
)
test('kernel', kernel_test, suite: 'kernel')
diff --git a/packages/kernel/src/server.cpp b/packages/kernel/src/server.cpp
index 15158a9..b79337c 100644
--- a/packages/kernel/src/server.cpp
+++ b/packages/kernel/src/server.cpp
@@ -85,6 +85,45 @@ auto Server::ui_preview_import_is_dmabuf() const -> bool {
return impl_->substrate != nullptr && impl_->substrate->preview_import_is_dmabuf();
}
+auto Server::ui_surface_element_reimport_count() const -> int {
+ return impl_->substrate != nullptr ? impl_->substrate->surface_element_reimport_count() : 0;
+}
+
+auto Server::ui_surface_element_frame_done_count() const -> int {
+ return impl_->substrate != nullptr ? impl_->substrate->surface_element_frame_done_count() : 0;
+}
+
+auto Server::ui_surface_element_import_is_dmabuf() const -> bool {
+ return impl_->substrate != nullptr && impl_->substrate->surface_element_import_is_dmabuf();
+}
+
+auto Server::ui_create_surface_element_for_test() -> bool {
+ if (impl_->test_surface_element != nullptr) {
+ return true; // already built (idempotent — no id churn on repeated polls)
+ }
+ if (impl_->substrate == nullptr || impl_->test_last_client_surface == nullptr) {
+ return false;
+ }
+ impl_->test_surface_element =
+ impl_->substrate->create_surface_element(impl_->test_last_client_surface);
+ return impl_->test_surface_element != nullptr;
+}
+
+auto Server::ui_surface_element_uri() const -> std::string {
+ return impl_->test_surface_element != nullptr ? impl_->test_surface_element->source_uri()
+ : std::string{};
+}
+
+auto Server::ui_surface_element_width() const -> int {
+ return impl_->test_surface_element != nullptr ? impl_->test_surface_element->width() : 0;
+}
+
+auto Server::ui_surface_element_height() const -> int {
+ return impl_->test_surface_element != nullptr ? impl_->test_surface_element->height() : 0;
+}
+
+void Server::ui_drop_surface_element_for_test() { impl_->test_surface_element.reset(); }
+
auto Server::ui_pixel(int x, int y) const -> unsigned int {
return impl_->substrate != nullptr ? impl_->substrate->surface_pixel(x, y) : 0U;
}
@@ -143,6 +182,14 @@ auto PerExtensionUi::create_preview(wlr_scene_tree* source) -> std::unique_ptr<P
return server_->substrate->create_preview(source);
}
+auto PerExtensionUi::create_surface_element(wlr_surface* client)
+ -> std::unique_ptr<SurfaceElement> {
+ if (server_->substrate == nullptr) {
+ return nullptr;
+ }
+ return server_->substrate->create_surface_element(client);
+}
+
auto PerExtensionUi::available() const -> bool {
return server_->substrate != nullptr && server_->substrate->available();
}
@@ -193,10 +240,27 @@ void Server::Impl::init() {
wlr_renderer_init_wl_display(renderer, display);
allocator = require(wlr_allocator_autocreate(backend, renderer), "wlr_allocator");
- wlr_compositor_create(display, 5, renderer);
+ compositor = wlr_compositor_create(display, 5, renderer);
wlr_subcompositor_create(display);
wlr_data_device_manager_create(display);
+ // Surface-element test seam (kernel suite only): track each new client
+ // wl_surface and record the latest one that has committed a buffer, so a
+ // headless test can build a real SurfaceElement from a real client surface.
+ // No production behaviour depends on this; it just observes new_surface.
+ if (compositor != nullptr) {
+ test_new_surface.connect(compositor->events.new_surface, [this](void* data) {
+ auto* surface = static_cast<wlr_surface*>(data);
+ test_surface_commits.emplace_back();
+ Listener& commit = test_surface_commits.back();
+ commit.connect(surface->events.commit, [this, surface](void*) {
+ if (surface->buffer != nullptr) {
+ test_last_client_surface = surface;
+ }
+ });
+ });
+ }
+
output_layout = require(wlr_output_layout_create(display), "wlr_output_layout");
new_output.connect(backend->events.new_output, [this](void* data) {
handle_new_output(static_cast<wlr_output*>(data));
@@ -383,7 +447,8 @@ void Server::Impl::start_substrate() {
// substrate uses the kernel's ONE shared FileWatcher for (UNBOX_DEV-gated)
// asset hot-reload — the same watcher Host::watch_file uses for config.
substrate = Substrate::create(display_egl, allocator, renderer, file_watcher(),
- [this](ExtensionId who) { disable(who); });
+ [this](ExtensionId who) { disable(who); },
+ [this] { schedule_driver_frame(); });
}
auto Server::Impl::file_watcher() -> FileWatcher* {
@@ -431,6 +496,13 @@ void Server::Impl::shutdown() {
}
extensions.clear();
+ // Surface-element test seam: drop the test element (releases its import +
+ // commit hook) and the capture listeners BEFORE the substrate/compositor go.
+ test_surface_element.reset();
+ test_new_surface.disconnect();
+ test_surface_commits.clear();
+ test_last_client_surface = nullptr;
+
// The ui substrate owns scene nodes + GL objects on a sibling context and
// borrows scene/renderer/allocator: tear it down before they die. (Its asset
// FileWatch handles release here, removing those watches from the watcher.)
@@ -587,6 +659,22 @@ void Server::Impl::handle_new_output(wlr_output* wlr_output) {
timespec now{};
clock_gettime(CLOCK_MONOTONIC, &now);
wlr_scene_output_send_frame_done(scene_output, &now);
+
+ // Live surface elements (RML compositing) are NOT wlr_scene surface
+ // nodes — they live as imported textures inside the substrate's RmlUi
+ // documents — so wlr_scene_output_send_frame_done above never reaches
+ // their backing wl_surfaces. Drive their frame callbacks ourselves so the
+ // client keeps producing buffers (the stuck-frame fix). Done on the
+ // PRIMARY output only (one frame-done per composited frame, like the
+ // request_frames drain) and unconditionally (a client needs callbacks to
+ // progress regardless of whether we re-rendered). While >=1 element exists
+ // keep a frame scheduled so the loop self-sustains even when otherwise
+ // idle (the continuous frame-callback duty).
+ if (substrate != nullptr && output->output == frame_driver_output &&
+ substrate->has_surface_elements()) {
+ substrate->send_frame_done_to_surface_elements(now);
+ wlr_output_schedule_frame(frame_driver_output);
+ }
});
output->request_state.connect(wlr_output->events.request_state, [output](void* data) {
const auto* event = static_cast<wlr_output_event_request_state*>(data);
diff --git a/packages/kernel/src/server_impl.hpp b/packages/kernel/src/server_impl.hpp
index f99fe16..0e69760 100644
--- a/packages/kernel/src/server_impl.hpp
+++ b/packages/kernel/src/server_impl.hpp
@@ -85,6 +85,19 @@ struct Server::Impl : detail::DisableSink {
wlr_seat* seat = nullptr;
std::string socket;
+ // ---- surface-element test seam (kernel suite only) ----
+ // The kernel-owned wlr_compositor + a new_surface listener that captures the
+ // most recently committed CLIENT wl_surface, so the suite can build a real
+ // SurfaceElement from a real client surface headlessly (the public path —
+ // Host::ui().create_surface_element — needs the wl_surface, which a test has
+ // no other way to reach in-process). Used ONLY by ui_* test probes; no
+ // production code path reads these.
+ wlr_compositor* compositor = nullptr;
+ Listener test_new_surface;
+ std::list<Listener> test_surface_commits; // per captured client surface
+ wlr_surface* test_last_client_surface = nullptr; // last surface with a buffer
+ std::unique_ptr<SurfaceElement> test_surface_element; // built on demand by the probe
+
// Ordered scene-tree z-bands (SceneLayer order). Created once over
// scene->tree in stacking order so background < … < overlay. Extensions
// attach nodes via Host::scene_layer(); the kernel owns them.
@@ -217,6 +230,7 @@ public:
auto create_surface(const UiSurfaceSpec& spec) -> std::unique_ptr<UiSurface> override;
auto create_preview(wlr_scene_tree* source) -> std::unique_ptr<Preview> override;
+ auto create_surface_element(wlr_surface* client) -> std::unique_ptr<SurfaceElement> override;
auto available() const -> bool override;
auto touch_mode() const -> bool override;
void set_touch_mode_override(TouchModeOverride ov) override;
diff --git a/packages/kernel/src/ui_core.hpp b/packages/kernel/src/ui_core.hpp
index e7c0723..25218d8 100644
--- a/packages/kernel/src/ui_core.hpp
+++ b/packages/kernel/src/ui_core.hpp
@@ -1,6 +1,7 @@
#pragma once
#include <cstdint>
+#include <string>
// Pure decision cores for the ui substrate — NO wlroots / GL / RMLUi types, so
// they are doctest-able with nothing running (AGENTS.md: effects at the edges,
@@ -11,6 +12,47 @@
namespace unbox::kernel {
+// ---- surface-element URI minting + the seq-gate decision (RML compositing) ---
+//
+// A surface element (GLOSSARY: "surface element") is the LIVE analogue of a
+// Preview: a client wl_surface's current buffer imported zero-copy as a sampled
+// GL texture, shown via <img src=source_uri()>. These two pure helpers are the
+// substrate's decision cores for it; the GL/wlroots effects wrap them in
+// ui_substrate.cpp.
+
+// The "<img src>" URI a surface element registers under, given its stable id.
+// Mirrors create_preview's "unbox-preview://N" minting; the live sibling is
+// "unbox-surface://N". Pure string math (doctest-able with nothing running).
+[[nodiscard]] inline auto surface_element_uri(int id) -> std::string {
+ return "unbox-surface://" + std::to_string(id);
+}
+
+// The seq-gate (the §0d frozen-frame fix): re-import the surface's CURRENT
+// committed buffer ONLY when the surface's commit SEQUENCE advances — NOT when
+// the buffer POINTER changes. Wayland clients (foot) recycle a small buffer
+// pool, so wlroots re-commits the SAME wlr_buffer pointer with NEW contents; a
+// pointer-equality gate wrongly skips those (the stuck-frame bug). The commit
+// seq (wlr_surface_state.seq) increments on EVERY commit regardless of pool
+// reuse, so it is the reuse-proof dirty signal. A static client never commits =>
+// its seq never advances => zero work (the idle dirty-gate stays intact).
+//
+// `have_seq` is false until the first import; `cur_seq` is the seq of the
+// currently-imported buffer; `new_seq` is the surface's current commit seq;
+// `same_ptr` is whether the surface's current buffer pointer equals the one we
+// already hold; `have_tex` is whether a texture already exists. Returns true iff
+// a (re)import is required. The first import (no seq yet, or no texture) always
+// imports; thereafter only a seq advance does (an unchanged seq on the same
+// pointer with a live texture is the only no-work case — matching the spike's
+// LiveTexture::adopt early-return exactly).
+[[nodiscard]] constexpr auto surface_element_needs_reimport(bool have_seq, std::uint32_t cur_seq,
+ std::uint32_t new_seq, bool same_ptr,
+ bool have_tex) -> bool {
+ if (!have_seq || !have_tex) {
+ return true; // first import (or texture lost): always import
+ }
+ return !(new_seq == cur_seq && same_ptr); // unchanged surface state => skip
+}
+
// touch-mode: the substrate-level theme state that scales hit targets for
// finger input. It flips automatically by last-input kind — a touch event
// turns it ON, pointer motion turns it OFF — but with debounce so a stray
diff --git a/packages/kernel/src/ui_substrate.cpp b/packages/kernel/src/ui_substrate.cpp
index 9b9593d..2265c5b 100644
--- a/packages/kernel/src/ui_substrate.cpp
+++ b/packages/kernel/src/ui_substrate.cpp
@@ -1,6 +1,7 @@
#include "ui_substrate.hpp"
#include "file_watcher.hpp"
+#include "listener.hpp" // RAII wl_listener for the surface-element commit hook
#include "rmlui_renderer_gl3.h"
#include <RmlUi/Core/Animation.h> // Transition / TransitionList / Tween
@@ -610,6 +611,45 @@ struct PreviewState {
bool dmabuf = false; // true once a dmabuf import succeeded
};
+// ---- SurfaceElementState ----------------------------------------------------
+//
+// A LIVE surface element: a client wl_surface's CURRENT committed buffer
+// imported zero-copy as a sampled GL texture in the RMLUi sibling context and
+// registered under an "unbox-surface://N" URI — the live sibling of
+// PreviewState. Ports spike `LiveTexture` (src/spike/spike_gl.hpp) into the real
+// substrate: the seq-gated re-import (§0d frozen-frame fix) + the double-buffered
+// wlr_buffer lock/unlock lifecycle (at most ONE buffer pinned). Re-import +
+// frame-done run on the sibling context inside tick_all (the caller makes it
+// current); the RAII commit Listener (kernel-private, never crosses the
+// contract) marks the element dirty + asks the kernel to schedule a frame.
+// Lives in Substrate::Impl::surface_elements (stable addresses).
+struct SurfaceElementState {
+ Substrate::Impl* owner = nullptr;
+ int id = 0;
+ std::string uri;
+
+ wlr_surface* surface = nullptr; // BORROW; caller outlives the element (ui.hpp)
+ int width = 0;
+ int height = 0;
+
+ // The live import (ported from spike LiveTexture). `current` is the buffer we
+ // hold LOCKED + have imported; `current_seq` its surface commit seq.
+ wlr_buffer* current = nullptr;
+ std::uint32_t current_seq = 0;
+ bool have_seq = false; // false until the first adopt()
+ EGLImageKHR image = EGL_NO_IMAGE_KHR;
+ GLuint tex = 0;
+ bool dmabuf = false; // last import took the dmabuf path
+ int reimports = 0; // REAL re-imports (seq advances) — test probe
+ int frame_done_sends = 0; // frame-done calls — test probe
+
+ // RAII commit hook: a client wl_surface.commit dirties this element + kicks
+ // the dirty-gate. Destruction (element drop) unsubscribes — never a bare
+ // wl_listener across the contract (.unbox/rules/listener-lifetime.md).
+ Listener commit_l;
+ bool needs_reimport = true; // a commit happened => re-adopt next tick
+};
+
// ---- Substrate::Impl --------------------------------------------------------
struct Substrate::Impl {
@@ -617,6 +657,10 @@ struct Substrate::Impl {
wlr_allocator* allocator = nullptr;
wlr_renderer* renderer = nullptr;
SubstrateDisableFn disable;
+ // Ask the kernel to schedule an output frame (the dirty-gate kick for live
+ // surface elements: a client commit, or the continuous frame-callback loop
+ // while >=1 element exists). No-op-safe before any output exists.
+ SubstrateScheduleFn schedule;
TouchModeTracker touch_mode_tracker;
@@ -655,6 +699,13 @@ struct Substrate::Impl {
bool last_preview_dmabuf = false; // test probe: last import took the dmabuf path
int resize_realloc_count = 0; // test probe: # of set_size GL-target reallocs
+ // Live surface elements (RML compositing Wave 1): stable addresses
+ // (SurfaceElementHandle borrows a SurfaceElementState*). next_surface_id
+ // numbers the "unbox-surface://N" URIs.
+ std::list<SurfaceElementState> surface_elements;
+ int next_surface_id = 0;
+ bool last_surface_element_dmabuf = false; // test probe: last import path
+
// Pointer implicit grab: the consumer of the first button press owns the
// whole press..release stream (standard seat behavior). `pointer_grab`
// (pure) tracks owner + down-count; `pointer_grab_surface` is the ui surface
@@ -733,6 +784,16 @@ struct Substrate::Impl {
bool import_snapshot(PreviewState& p);
void destroy_preview(PreviewState* p);
+ // Surface elements (RML compositing Wave 1). adopt_surface_element re-imports
+ // `s`'s surface's CURRENT buffer if (and only if) the commit seq advanced
+ // (seq-gate); it manages the double-buffered wlr_buffer lock and registers
+ // the URI. Caller holds the sibling context current. Returns true if the
+ // sampled texture reflects the current buffer afterwards. destroy_surface_
+ // element drops the import (texture/EGLImage/held lock), the URI, and the
+ // element from the list.
+ bool adopt_surface_element(SurfaceElementState& s);
+ void destroy_surface_element(SurfaceElementState* s);
+
// Forward a synthesized pointer event into a surface's Rml context. Returns
// whether RmlUi (or our hit-test) treats it as consumed.
void ctx_motion(Surface& s, double lx, double ly);
@@ -1380,15 +1441,174 @@ void Substrate::Impl::destroy_preview(PreviewState* p) {
previews.remove_if([p](const PreviewState& e) { return &e == p; });
}
+// ---- Surface elements: seq-gated live import (port of spike LiveTexture) -----
+//
+// The client surface's CURRENT committed buffer is `surface->buffer` — a
+// wlr_client_buffer (the renderer-side import; wlroots has ALREADY released the
+// client's underlying pool wl_buffer, so reading/locking it can never starve the
+// client). We re-import it ONLY when the surface commit SEQUENCE advances
+// (surface_element_needs_reimport, ui_core.hpp): a static client never commits =>
+// its seq never advances => zero work (idle dirty-gate intact); a pooled client
+// re-commits the SAME wlr_buffer pointer with NEW contents and a bumped seq =>
+// re-import (the §0d frozen-frame fix). The buffer we import is LOCKED for the
+// import+sample lifetime and the PREVIOUS one unlocked once the new import is
+// live — double-buffered, at most one buffer pinned, balanced in EVERY case incl.
+// the pooled same-pointer re-commit (prev == buf: net +1 then -1).
+
+bool Substrate::Impl::adopt_surface_element(SurfaceElementState& s) {
+ if (s.surface == nullptr) {
+ return s.tex != 0;
+ }
+ wlr_buffer* buf = nullptr;
+ if (s.surface->buffer != nullptr) {
+ buf = &s.surface->buffer->base;
+ }
+ if (buf == nullptr) {
+ // No buffer committed yet (e.g. a 0x0 configure-ack commit) — nothing to
+ // import; keep any prior texture. Not a failure.
+ return s.tex != 0;
+ }
+ const std::uint32_t seq = s.surface->current.seq;
+ if (!surface_element_needs_reimport(s.have_seq, s.current_seq, seq, buf == s.current,
+ s.tex != 0)) {
+ return true; // truly unchanged surface state: zero re-import, zero copy
+ }
+
+ // Lock the buffer we are about to sample (its dmabuf FDs / shm storage must
+ // stay valid for the whole import+sample); release the PREVIOUS one once the
+ // new import is live (double-buffered: at most one buffer pinned).
+ wlr_buffer* prev = s.current;
+ wlr_buffer_lock(buf);
+
+ auto commit = [&](bool is_dmabuf) {
+ s.current = buf;
+ s.current_seq = seq;
+ s.have_seq = true;
+ s.dmabuf = is_dmabuf;
+ last_surface_element_dmabuf = is_dmabuf;
+ ++s.reimports;
+ if (prev != nullptr) {
+ wlr_buffer_unlock(prev); // prev may == buf (pooled re-commit): net +1/-1
+ }
+ if (gl.render_iface) {
+ gl.render_iface->register_preview_texture(s.uri, s.tex,
+ Rml::Vector2i(s.width, s.height));
+ }
+ };
+
+ wlr_dmabuf_attributes attribs{};
+ if (gl.dmabuf_import_ok && gl.egl_create_image != nullptr &&
+ gl.gl_image_target_texture != nullptr && wlr_buffer_get_dmabuf(buf, &attribs) &&
+ attribs.n_planes >= 1) {
+ EGLint ia[] = {
+ EGL_WIDTH, attribs.width,
+ EGL_HEIGHT, attribs.height,
+ EGL_LINUX_DRM_FOURCC_EXT, static_cast<EGLint>(attribs.format),
+ EGL_DMA_BUF_PLANE0_FD_EXT, attribs.fd[0],
+ EGL_DMA_BUF_PLANE0_OFFSET_EXT, static_cast<EGLint>(attribs.offset[0]),
+ EGL_DMA_BUF_PLANE0_PITCH_EXT, static_cast<EGLint>(attribs.stride[0]),
+ EGL_NONE,
+ };
+ EGLImageKHR img =
+ gl.egl_create_image(gl.egl_display, EGL_NO_CONTEXT, EGL_LINUX_DMA_BUF_EXT, nullptr, ia);
+ if (img != EGL_NO_IMAGE_KHR) {
+ // Drop the old GL objects, build the new texture from the EGLImage.
+ if (s.tex != 0) {
+ glDeleteTextures(1, &s.tex);
+ s.tex = 0;
+ }
+ if (s.image != EGL_NO_IMAGE_KHR && gl.egl_destroy_image != nullptr) {
+ gl.egl_destroy_image(gl.egl_display, s.image);
+ }
+ glGenTextures(1, &s.tex);
+ glBindTexture(GL_TEXTURE_2D, s.tex);
+ gl.gl_image_target_texture(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);
+ s.image = img;
+ s.width = attribs.width;
+ s.height = attribs.height;
+ commit(true);
+ return true;
+ }
+ }
+
+ // Fallback: one CPU upload for an shm client (no dmabuf path). R<->B swizzle
+ // like Preview/the spike so AR24 {B,G,R,A} samples with correct colors.
+ 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)) {
+ wlr_buffer_unlock(buf); // import failed: drop the lock we just took
+ return s.tex != 0;
+ }
+ if (s.tex != 0) {
+ glDeleteTextures(1, &s.tex);
+ s.tex = 0;
+ }
+ if (s.image != EGL_NO_IMAGE_KHR && gl.egl_destroy_image != nullptr) {
+ gl.egl_destroy_image(gl.egl_display, s.image);
+ s.image = EGL_NO_IMAGE_KHR;
+ }
+ glGenTextures(1, &s.tex);
+ glBindTexture(GL_TEXTURE_2D, s.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);
+ s.width = buf->width;
+ s.height = buf->height;
+ commit(false);
+ return true;
+}
+
+void Substrate::Impl::destroy_surface_element(SurfaceElementState* s) {
+ s->commit_l.disconnect(); // stop dirtying after the element is gone
+ const bool cur = gl.make_current();
+ if (gl.render_iface) {
+ gl.render_iface->unregister_preview_texture(s->uri);
+ }
+ if (s->tex != 0) {
+ glDeleteTextures(1, &s->tex);
+ s->tex = 0;
+ }
+ if (s->image != EGL_NO_IMAGE_KHR && gl.egl_destroy_image != nullptr) {
+ gl.egl_destroy_image(gl.egl_display, s->image);
+ s->image = EGL_NO_IMAGE_KHR;
+ }
+ if (cur) {
+ gl.restore_current();
+ }
+ if (s->current != nullptr) {
+ wlr_buffer_unlock(s->current); // release the buffer we held locked
+ s->current = nullptr;
+ }
+ s->have_seq = false;
+ surface_elements.remove_if([s](const SurfaceElementState& e) { return &e == s; });
+}
+
// ---- Substrate (private surface) --------------------------------------------
auto Substrate::create(EGLDisplay egl_display, wlr_allocator* allocator, wlr_renderer* renderer,
- FileWatcher* watcher, SubstrateDisableFn disable)
+ FileWatcher* watcher, SubstrateDisableFn disable, SubstrateScheduleFn schedule)
-> std::unique_ptr<Substrate> {
auto impl = std::make_unique<Impl>();
impl->allocator = allocator;
impl->renderer = renderer;
impl->disable = std::move(disable);
+ impl->schedule = std::move(schedule); // dirty-gate kick for live surface elements
impl->watcher = watcher; // shared kernel-owned file watcher (asset hot-reload)
impl->gl.init(egl_display); // sets gl.ok; failure => unavailable substrate
return std::unique_ptr<Substrate>(new Substrate(std::move(impl)));
@@ -1402,6 +1622,9 @@ Substrate::~Substrate() {
// handle would dangle after this, but the contract (ui.hpp) is that the
// substrate outlives every Preview an extension holds (it is kernel-owned
// and torn down after extensions in Server::Impl::shutdown).
+ while (!impl_->surface_elements.empty()) {
+ impl_->destroy_surface_element(&impl_->surface_elements.front());
+ }
while (!impl_->previews.empty()) {
impl_->destroy_preview(&impl_->previews.front());
}
@@ -1517,8 +1740,101 @@ auto Substrate::create_preview(wlr_scene_tree* source) -> std::unique_ptr<Previe
auto Substrate::preview_import_is_dmabuf() const -> bool { return impl_->last_preview_dmabuf; }
+auto Substrate::create_surface_element(wlr_surface* client) -> std::unique_ptr<SurfaceElement> {
+ impl_->last_surface_element_dmabuf = false;
+ if (!impl_->available() || client == nullptr) {
+ return nullptr; // no GL path or no surface: graceful degrade (ui.hpp)
+ }
+
+ impl_->surface_elements.emplace_back();
+ SurfaceElementState& s = impl_->surface_elements.back();
+ s.owner = impl_.get();
+ s.id = ++impl_->next_surface_id;
+ s.uri = surface_element_uri(s.id);
+ s.surface = client;
+
+ // Import the client's current buffer now (so source_uri()/width()/height()
+ // are valid immediately, like create_preview). The sibling context must be
+ // current for the EGLImage/texture/RmlUi-registration work.
+ if (!impl_->gl.make_current()) {
+ impl_->destroy_surface_element(&s);
+ return nullptr;
+ }
+ const bool imported = impl_->adopt_surface_element(s);
+ impl_->gl.restore_current();
+ s.needs_reimport = false;
+ if (!imported || s.tex == 0) {
+ // The surface may simply have no buffer yet (pre-first-commit); that is a
+ // valid live element that will import on its first commit. Only fail if
+ // there is no GL path to ever import on — but available() already gated
+ // that, so a tex==0 here means no buffer yet: keep the element.
+ }
+
+ // RAII commit hook: a client commit dirties this element + kicks the
+ // dirty-gate so the next frame re-imports + re-renders. The borrow received
+ // in the handler is unused (we re-read s.surface->buffer in tick_all); the
+ // listener unsubscribes when the element (and its SurfaceElementState) dies.
+ SurfaceElementState* sp = &s;
+ Substrate::Impl* impl = impl_.get();
+ s.commit_l.connect(client->events.commit, [impl, sp](void*) {
+ sp->needs_reimport = true; // re-adopt this surface's current buffer next tick
+ if (impl->schedule) {
+ impl->schedule(); // dirty-gate: ensure a frame runs to show the update
+ }
+ });
+
+ // Kick the frame-callback loop on: while this element exists the kernel keeps
+ // a frame scheduled (has_surface_elements()) so the client keeps drawing.
+ if (impl_->schedule) {
+ impl_->schedule();
+ }
+ return std::make_unique<SurfaceElementHandle>(this, &s);
+}
+
+auto Substrate::has_surface_elements() const -> bool {
+ return !impl_->surface_elements.empty();
+}
+
+void Substrate::send_frame_done_to_surface_elements(const timespec& now) {
+ // Frame-callback duty (the stuck-frame fix, spike §0c
+ // send_frame_done_to_clients): tell every live-element-backing surface "now
+ // is a good time to draw your next frame", so the client keeps producing
+ // buffers. Wave 1 is single-surface — one wl_surface per element — so we send
+ // frame-done to that surface directly (subsurface/popup tree-walking is Wave
+ // 1b). Sent UNCONDITIONALLY per composited output frame (like
+ // wlr_scene_output_send_frame_done), NOT gated by re-render: the client needs
+ // callbacks to progress regardless of whether WE re-rendered.
+ for (SurfaceElementState& s : impl_->surface_elements) {
+ if (s.surface != nullptr) {
+ wlr_surface_send_frame_done(s.surface, const_cast<timespec*>(&now));
+ ++s.frame_done_sends;
+ }
+ }
+}
+
+auto Substrate::surface_element_reimport_count() const -> int {
+ int total = 0;
+ for (const SurfaceElementState& s : impl_->surface_elements) {
+ total += s.reimports;
+ }
+ return total;
+}
+
+auto Substrate::surface_element_frame_done_count() const -> int {
+ int total = 0;
+ for (const SurfaceElementState& s : impl_->surface_elements) {
+ total += s.frame_done_sends;
+ }
+ return total;
+}
+
+auto Substrate::surface_element_import_is_dmabuf() const -> bool {
+ return impl_->last_surface_element_dmabuf;
+}
+
void Substrate::tick_all() {
- if (!impl_->available() || impl_->surfaces.empty()) {
+ if (!impl_->available() ||
+ (impl_->surfaces.empty() && impl_->surface_elements.empty())) {
return;
}
// Apply any hot-reload requests coalesced from inotify since the last tick
@@ -1544,6 +1860,28 @@ void Substrate::tick_all() {
if (!impl_->gl.make_current()) {
return;
}
+ // Re-import every live surface element's CURRENT buffer FIRST (seq-gated), so
+ // a hosting ui surface's <img src=unbox-surface://N> samples the fresh
+ // texture this frame. The seq-gate makes a static client cost zero work (no
+ // commit => no seq advance => adopt_surface_element early-returns); a client
+ // that committed since the last tick re-imports exactly once. needs_reimport
+ // (set by the commit hook) is a cheap pre-filter so an idle element does not
+ // even touch its surface state.
+ for (SurfaceElementState& s : impl_->surface_elements) {
+ if (s.needs_reimport) {
+ const int before = s.reimports;
+ impl_->adopt_surface_element(s);
+ // Clear the flag only once the seq actually caught up: adopt is
+ // seq-gated, so if it did nothing (no new buffer) leaving the flag set
+ // would re-check next tick, which is harmless — but a successful adopt
+ // (or a no-op on an unchanged seq with a live texture) means we are
+ // current, so clear it. Keep it set only when there is still no
+ // texture (pre-first-buffer) so the first real buffer is picked up.
+ if (s.reimports != before || s.tex != 0) {
+ s.needs_reimport = false;
+ }
+ }
+ }
for (Surface& s : impl_->surfaces) {
if (s.is_visible) {
impl_->render_surface(s);
@@ -2238,4 +2576,14 @@ void PreviewHandle::refresh() {
impl.gl.restore_current();
}
+// ---- SurfaceElementHandle (public SurfaceElement impl) ----------------------
+
+SurfaceElementHandle::~SurfaceElementHandle() {
+ substrate_->impl_->destroy_surface_element(state_);
+}
+
+auto SurfaceElementHandle::source_uri() const -> std::string { return state_->uri; }
+auto SurfaceElementHandle::width() const -> int { return state_->width; }
+auto SurfaceElementHandle::height() const -> int { return state_->height; }
+
} // namespace unbox::kernel
diff --git a/packages/kernel/src/ui_substrate.hpp b/packages/kernel/src/ui_substrate.hpp
index 1b58d13..8eee390 100644
--- a/packages/kernel/src/ui_substrate.hpp
+++ b/packages/kernel/src/ui_substrate.hpp
@@ -6,6 +6,7 @@
#include "ui_core.hpp"
#include <cstdint>
+#include <ctime>
#include <memory>
#include <string>
#include <vector>
@@ -47,6 +48,12 @@ class FileWatcher;
// internals.
using SubstrateDisableFn = std::function<void(ExtensionId)>;
+// Callback the substrate invokes to ask the kernel to schedule an output frame
+// — the dirty-gate kick for live surface elements (a client commit, or the
+// continuous frame-callback loop while >=1 element exists). Injected by the
+// kernel (Server::Impl::schedule_driver_frame). No-op-safe before any output.
+using SubstrateScheduleFn = std::function<void()>;
+
class Substrate; // the concrete UiSubstrate, defined in ui_substrate.cpp
// One ui surface's private state (Rml context + GL target + scene node +
@@ -56,9 +63,15 @@ struct Surface;
// One preview's private state (snapshot dmabuf + imported EGLImage/texture +
// RmlUi URI registration). Defined in ui_substrate.cpp; declared here so the
-// public PreviewHandle can borrow one out of the Substrate's list.
+// public PreviewHandle can borrow one.
struct PreviewState;
+// One LIVE surface element's private state (the client wl_surface's seq-gated
+// zero-copy import + EGLImage/texture + RmlUi URI registration + the RAII commit
+// listener that dirties on a client commit). Defined in ui_substrate.cpp;
+// declared here so the public SurfaceElementHandle can borrow one.
+struct SurfaceElementState;
+
// Concrete Preview handed to an extension. A thin, owning handle over a
// PreviewState that lives in the Substrate's list; destruction frees the GL
// texture + EGLImage + dmabuf and unregisters the URI.
@@ -80,6 +93,27 @@ private:
PreviewState* state_;
};
+// Concrete SurfaceElement handed to an extension. A thin, owning handle over a
+// SurfaceElementState that lives in the Substrate's list; destruction drops the
+// live import (texture + EGLImage + held buffer lock), the commit listener, and
+// the URI registration, and ends the frame-callback duty for its surface.
+class SurfaceElementHandle final : public SurfaceElement {
+public:
+ SurfaceElementHandle(Substrate* substrate, SurfaceElementState* state)
+ : substrate_(substrate), state_(state) {}
+ ~SurfaceElementHandle() override;
+ SurfaceElementHandle(const SurfaceElementHandle&) = delete;
+ auto operator=(const SurfaceElementHandle&) -> SurfaceElementHandle& = delete;
+
+ [[nodiscard]] auto source_uri() const -> std::string override;
+ [[nodiscard]] auto width() const -> int override;
+ [[nodiscard]] auto height() const -> int override;
+
+private:
+ Substrate* substrate_;
+ SurfaceElementState* state_;
+};
+
// Concrete UiSurface handed to an extension. A thin, owning handle over a
// Surface that lives in the Substrate's list; destruction removes the Surface
// (document + scene node). Per-extension (carries no id itself — its Surface
@@ -139,7 +173,8 @@ public:
// nullptr to disable watching. Never throws.
static auto create(EGLDisplay egl_display, wlr_allocator* allocator,
wlr_renderer* renderer, FileWatcher* watcher,
- SubstrateDisableFn disable) -> std::unique_ptr<Substrate>;
+ SubstrateDisableFn disable, SubstrateScheduleFn schedule)
+ -> std::unique_ptr<Substrate>;
~Substrate();
Substrate(const Substrate&) = delete;
@@ -158,7 +193,25 @@ public:
// throws. (See ui.hpp UiSubstrate::create_preview for the public contract.)
auto create_preview(wlr_scene_tree* source) -> std::unique_ptr<Preview>;
- // Render every dirty surface (called from the output frame handler).
+ // Create a LIVE surface element backed by `client`'s current buffer (the
+ // live sibling of create_preview). Returns nullptr if unavailable or the
+ // initial import failed. `client` is a borrow the caller must outlive (see
+ // ui.hpp UiSubstrate::create_surface_element). Never throws.
+ auto create_surface_element(wlr_surface* client) -> std::unique_ptr<SurfaceElement>;
+
+ // True while >=1 surface element exists: the kernel keeps a frame scheduled
+ // so the frame-callback duty (send_frame_done_to_surface_elements) keeps the
+ // client drawing even when nothing else is dirty.
+ [[nodiscard]] auto has_surface_elements() const -> bool;
+
+ // Frame-callback duty (the stuck-frame fix): send wl_surface frame-done to
+ // EVERY live-element-backing surface. Called once per composited output frame
+ // by the kernel (unconditionally, like wlr_scene_output_send_frame_done), so
+ // the client keeps producing buffers regardless of whether we re-rendered.
+ void send_frame_done_to_surface_elements(const timespec& now);
+
+ // Render every dirty surface (called from the output frame handler). Also
+ // re-imports every surface element's current buffer first (seq-gated).
void tick_all();
// ---- Input routing (kernel calls these BEFORE emitting on the bus) ----
@@ -196,6 +249,22 @@ public:
// EGLImage -> sampled texture path (Plan A) rather than failing. Lets the
// suite assert the spike's GO path engaged on hardware that supports it.
[[nodiscard]] auto preview_import_is_dmabuf() const -> bool;
+
+ // ---- surface-element test instrumentation (kernel suite only) ----
+ // Total live re-imports across all surface elements: bumps once per REAL
+ // re-import (a seq advance). A static client (no commit => no seq change)
+ // never bumps it; an updating client bumps once per committed frame. Lets
+ // the suite assert the seq-gate (a new buffer/seq => exactly one re-import;
+ // re-adopting the same seq => zero) mirroring spike --verify criterion 1.
+ [[nodiscard]] auto surface_element_reimport_count() const -> int;
+ // Total wl_surface frame-done sends across all surface elements: bumps once
+ // per element per composited frame (the frame-callback duty). Lets the suite
+ // assert frame-done is driven per frame (spike --verify criterion 6 / §0c).
+ [[nodiscard]] auto surface_element_frame_done_count() const -> int;
+ // Whether the most recent surface-element import took the dmabuf ->
+ // EGLImage -> texture path (vs the shm-upload fallback). Lets the suite see
+ // which path engaged on the test backend.
+ [[nodiscard]] auto surface_element_import_is_dmabuf() const -> bool;
// Packed 0xRRGGBBAA of the first shm-path surface's submitted buffer at
// layout pixel (x,y) (readback row 0 = top). 0 if no shm surface / no frame
// / out of bounds. A position-aware probe (like orientation()) so the suite
@@ -240,6 +309,7 @@ private:
friend class SurfaceHandle;
friend class PreviewHandle;
+ friend class SurfaceElementHandle;
};
} // namespace unbox::kernel
diff --git a/packages/kernel/tests/test_kernel.cpp b/packages/kernel/tests/test_kernel.cpp
index 678bdee..f33e31a 100644
--- a/packages/kernel/tests/test_kernel.cpp
+++ b/packages/kernel/tests/test_kernel.cpp
@@ -34,6 +34,21 @@
#include <string>
#include <vector>
+// TEST-ONLY: an in-process Wayland CLIENT for the surface-element integration
+// test (the only in-process way to get a real wlr_surface with an advancing
+// commit seq). wayland-client is a test-executable-only dep (the kernel is a
+// compositor, never a client) — see packages/kernel/meson.build.
+#include <wayland-client.h>
+
+#include <atomic>
+#include <chrono>
+#include <cstring>
+#include <thread>
+
+#include <fcntl.h>
+#include <sys/mman.h>
+#include <unistd.h>
+
TEST_CASE("kernel compiles against and links wlroots + libwayland-server") {
CHECK(unbox::kernel::link_probe());
CHECK(unbox::kernel::wlroots_version().substr(0, 4) == "0.20");
@@ -2653,3 +2668,300 @@ TEST_CASE("spike(rml-compositing): an edge-on (90deg) transform collapses the el
// element is edge-on): the map lost its X information.
CHECK(std::abs(a.x - b.x) < 0.5);
}
+
+// ============================================================================
+// RML compositing Wave 1: surface-element PURE CORES (ui_core.hpp). The URI
+// minting and the seq-gate decision predicate are pure (no wlroots/GL), so they
+// are doctest-ed here with nothing running — the strict-core half of the
+// asymmetric testing rule. The live import/frame-done glue is covered by the
+// headless integration test below.
+// ============================================================================
+
+TEST_CASE("surface-element: source_uri mints a stable unbox-surface:// URI") {
+ using unbox::kernel::surface_element_uri;
+ CHECK(surface_element_uri(1) == "unbox-surface://1");
+ CHECK(surface_element_uri(7) == "unbox-surface://7");
+ // Distinct ids => distinct URIs (each element samples its own texture).
+ CHECK(surface_element_uri(1) != surface_element_uri(2));
+ // The scheme matches the public-contract example and is the LIVE sibling of
+ // the preview scheme (NOT the same — a live element is not a frozen preview).
+ CHECK(surface_element_uri(42).rfind("unbox-surface://", 0) == 0);
+ CHECK(surface_element_uri(42).rfind("unbox-preview://", 0) != 0);
+}
+
+TEST_CASE("surface-element: the seq-gate is reuse-proof (the frozen-frame fix)") {
+ using unbox::kernel::surface_element_needs_reimport;
+
+ // First import: no seq yet AND no texture => MUST import, whatever the rest.
+ CHECK(surface_element_needs_reimport(/*have_seq=*/false, /*cur=*/0, /*new=*/1,
+ /*same_ptr=*/false, /*have_tex=*/false));
+ CHECK(surface_element_needs_reimport(false, 0, 1, true, false));
+
+ // Texture lost but seq known (defensive): re-import to rebuild it.
+ CHECK(surface_element_needs_reimport(/*have_seq=*/true, /*cur=*/5, /*new=*/5,
+ /*same_ptr=*/true, /*have_tex=*/false));
+
+ // The IDLE case: same seq, same buffer pointer, live texture => NO re-import
+ // (a static client costs zero work — the idle dirty-gate is preserved).
+ CHECK_FALSE(surface_element_needs_reimport(true, 5, 5, /*same_ptr=*/true, /*have_tex=*/true));
+
+ // A NEW commit (seq advances) of the SAME pooled buffer pointer with new
+ // contents => MUST re-import. This is THE frozen-frame fix: a buffer-pointer
+ // gate would wrongly skip it (foot recycles a small buffer pool), the seq
+ // gate does not.
+ CHECK(surface_element_needs_reimport(true, 5, /*new=*/6, /*same_ptr=*/true, /*have_tex=*/true));
+
+ // A new commit with a DIFFERENT buffer pointer => re-import (obviously).
+ CHECK(surface_element_needs_reimport(true, 5, 6, /*same_ptr=*/false, /*have_tex=*/true));
+
+ // Same seq but a different pointer (should not happen in practice, but the
+ // predicate is conservative): re-import rather than show a stale texture.
+ CHECK(surface_element_needs_reimport(true, 5, 5, /*same_ptr=*/false, /*have_tex=*/true));
+}
+
+// ============================================================================
+// RML compositing Wave 1: surface-element HEADLESS INTEGRATION TEST. Mirrors the
+// spike --verify criteria 1 (zero-copy live import + seq-gate) + 6 (frame-done
+// driven per composited frame), but against a REAL client surface: an
+// in-process Wayland client thread connects to the headless server, creates a
+// wl_surface + wl_shm buffers, and commits; the kernel captures that wl_surface
+// (test seam) and builds a real SurfaceElement; the suite asserts URI/size, that
+// a new commit (seq++) re-imports exactly once while re-adopting the same seq
+// re-imports zero, and that wl_surface frame-done is sent per composited frame.
+// We cannot see pixels headless — we assert the counters/URIs, exactly as the
+// spike's --verify does. (Lenient shell test, AGENTS.md: glue on the wlr
+// headless backend, not unit-coverage chasing.)
+// ============================================================================
+
+namespace {
+
+// A minimal in-process Wayland client on its own thread. It binds wl_compositor
+// + wl_shm, creates ONE wl_surface, and commits an shm buffer on demand. Two
+// pre-made buffers let the test exercise BOTH a new pointer AND (by re-using
+// buffer A) the pooled same-pointer re-commit. Driven by atomics the test sets;
+// the client flushes after every commit and the TEST pumps the server loop so
+// the commits land.
+struct TestWaylandClient {
+ std::thread thread;
+ std::atomic<bool> ready{false}; // connected + surface created + first commit done
+ std::atomic<bool> stop{false};
+ std::atomic<int> commit_cmd{0}; // bump to request another commit (cycles buffers)
+ std::atomic<int> commit_done{0}; // echoes commit_cmd once that commit was sent
+ std::string socket;
+
+ explicit TestWaylandClient(std::string sock) : socket(std::move(sock)) {}
+
+ void start() { thread = std::thread([this] { run(); }); }
+ void join() {
+ stop = true;
+ if (thread.joinable()) {
+ thread.join();
+ }
+ }
+
+ // -- registry globals --
+ wl_registry* registry = nullptr;
+ wl_compositor* compositor = nullptr;
+ wl_shm* shm = nullptr;
+
+ static void reg_global(void* data, wl_registry* reg, uint32_t name, const char* iface,
+ uint32_t /*ver*/) {
+ auto* self = static_cast<TestWaylandClient*>(data);
+ if (std::strcmp(iface, "wl_compositor") == 0) {
+ self->compositor = static_cast<wl_compositor*>(
+ wl_registry_bind(reg, name, &wl_compositor_interface, 4));
+ } else if (std::strcmp(iface, "wl_shm") == 0) {
+ self->shm =
+ static_cast<wl_shm*>(wl_registry_bind(reg, name, &wl_shm_interface, 1));
+ }
+ }
+ static void reg_remove(void*, wl_registry*, uint32_t) {}
+
+ // Make a 64x64 ARGB8888 shm buffer of a solid color.
+ static auto make_buffer(wl_shm* shm, int w, int h, uint32_t argb) -> wl_buffer* {
+ const int stride = w * 4;
+ const int size = stride * h;
+ int fd = memfd_create("unbox-se-test", MFD_CLOEXEC);
+ if (fd < 0) {
+ return nullptr;
+ }
+ if (ftruncate(fd, size) < 0) {
+ close(fd);
+ return nullptr;
+ }
+ auto* px = static_cast<uint32_t*>(
+ mmap(nullptr, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0));
+ if (px == MAP_FAILED) {
+ close(fd);
+ return nullptr;
+ }
+ for (int i = 0; i < w * h; ++i) {
+ px[i] = argb;
+ }
+ munmap(px, size);
+ wl_shm_pool* pool = wl_shm_create_pool(shm, fd, size);
+ wl_buffer* buf = wl_shm_pool_create_buffer(pool, 0, w, h, stride,
+ WL_SHM_FORMAT_ARGB8888);
+ wl_shm_pool_destroy(pool);
+ close(fd);
+ return buf;
+ }
+
+ void run() {
+ wl_display* dpy = wl_display_connect(socket.c_str());
+ if (dpy == nullptr) {
+ return; // no server socket: the test will time out waiting on ready
+ }
+ registry = wl_display_get_registry(dpy);
+ static const wl_registry_listener reg_l = {reg_global, reg_remove};
+ wl_registry_add_listener(registry, &reg_l, this);
+ wl_display_roundtrip(dpy); // bind globals
+ if (compositor == nullptr || shm == nullptr) {
+ wl_display_disconnect(dpy);
+ return;
+ }
+ wl_surface* surface = wl_compositor_create_surface(compositor);
+ wl_buffer* buf_a = make_buffer(shm, 64, 64, 0xff2060c0);
+ wl_buffer* buf_b = make_buffer(shm, 64, 64, 0xff60c020);
+ if (surface == nullptr || buf_a == nullptr || buf_b == nullptr) {
+ wl_display_disconnect(dpy);
+ return;
+ }
+ // First commit: attach buffer A (seq advances to 1, surface->buffer set).
+ wl_surface_attach(surface, buf_a, 0, 0);
+ wl_surface_damage(surface, 0, 0, 64, 64);
+ wl_surface_commit(surface);
+ wl_display_flush(dpy);
+ ready = true;
+
+ int last = 0;
+ while (!stop) {
+ wl_display_dispatch_pending(dpy);
+ wl_display_flush(dpy);
+ const int cmd = commit_cmd.load();
+ if (cmd != last) {
+ // Command 1 attaches buffer B (a NEW pointer vs the current A);
+ // command 2+ RE-attaches buffer B (the SAME pointer as current =>
+ // the pooled same-pointer re-commit, §0d). Either way the surface
+ // commit seq advances, so the seq-gate must re-import exactly once
+ // — proving the gate keys on the seq, not the buffer pointer.
+ wl_surface_attach(surface, buf_b, 0, 0);
+ wl_surface_damage(surface, 0, 0, 64, 64);
+ wl_surface_commit(surface);
+ wl_display_flush(dpy);
+ last = cmd;
+ commit_done = cmd;
+ }
+ std::this_thread::sleep_for(std::chrono::milliseconds(2));
+ }
+ // Destroy every bound proxy before disconnect (mirrors the ext-*-client
+ // tests' teardown) so libwayland-client retains no proxy allocations —
+ // keeps the asan suite leak-clean.
+ wl_buffer_destroy(buf_a);
+ wl_buffer_destroy(buf_b);
+ wl_surface_destroy(surface);
+ if (compositor != nullptr) {
+ wl_compositor_destroy(compositor);
+ }
+ if (shm != nullptr) {
+ wl_shm_destroy(shm);
+ }
+ if (registry != nullptr) {
+ wl_registry_destroy(registry);
+ }
+ wl_display_flush(dpy);
+ wl_display_disconnect(dpy);
+ }
+};
+
+// Pump the server until `pred()` is true or `max_turns` elapses. Returns pred().
+template <typename Pred>
+auto pump_until_se(unbox::kernel::Server& s, Pred pred, int max_turns = 400) -> bool {
+ for (int i = 0; i < max_turns && !pred(); ++i) {
+ s.dispatch(5);
+ }
+ return pred();
+}
+
+} // namespace
+
+TEST_CASE("surface-element: live import + seq-gate + frame-done against a real client") {
+ setenv("WLR_BACKENDS", "headless", 1);
+ setenv("WLR_RENDERER", "gles2", 1);
+ setenv("WLR_HEADLESS_OUTPUTS", "1", 1);
+ unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM");
+
+ auto server = unbox::kernel::Server::create({});
+ server->activate_extensions();
+
+ // Spin the in-process client; it connects to our socket and commits buffer A.
+ TestWaylandClient client(server->socket_name());
+ client.start();
+
+ // Pump the server until the client has connected + committed its first buffer
+ // (the kernel test seam captures the latest committed client wl_surface).
+ const bool got_surface =
+ pump_until_se(*server, [&] { return client.ready.load(); }) &&
+ pump_until_se(*server, [&] {
+ return server->ui_create_surface_element_for_test();
+ });
+ if (!got_surface) {
+ // No GL path on this box, or the client could not connect: nothing the
+ // headless agent can prove here (mirrors the other GL-gated tests' skip).
+ client.join();
+ return;
+ }
+
+ // (criterion 1) The element reports a stable unbox-surface:// URI and the
+ // client's current pixel size.
+ CHECK(server->ui_surface_element_uri().rfind("unbox-surface://", 0) == 0);
+ CHECK(server->ui_surface_element_width() == 64);
+ CHECK(server->ui_surface_element_height() == 64);
+
+ // The initial import counts as one re-import (the first buffer adopted).
+ const int reimports_after_create = server->ui_surface_element_reimport_count();
+ CHECK(reimports_after_create >= 1);
+
+ // (criterion 6) Frame-done must be DRIVEN per composited frame while the
+ // element exists: pump and watch the counter climb (the stuck-frame fix —
+ // without it the client would draw once and wait forever).
+ const int fd_before = server->ui_surface_element_frame_done_count();
+ pump(*server, 40);
+ const int fd_after = server->ui_surface_element_frame_done_count();
+ CHECK(fd_after > fd_before);
+
+ // (criterion 1, the seq-gate) IDLE: with no new client commit, pumping more
+ // frames must NOT re-import (a static client costs zero import work — the
+ // idle dirty-gate is intact).
+ const int reimports_idle0 = server->ui_surface_element_reimport_count();
+ pump(*server, 40);
+ CHECK(server->ui_surface_element_reimport_count() == reimports_idle0);
+
+ // (criterion 1, the seq-gate / frozen-frame fix) A NEW commit (seq++) of a
+ // NEW buffer pointer => exactly ONE more re-import.
+ const int before_new = server->ui_surface_element_reimport_count();
+ const int cmd1 = client.commit_cmd.fetch_add(1) + 1; // even => buffer B (new ptr)
+ pump_until_se(*server, [&] { return client.commit_done.load() >= cmd1; });
+ pump(*server, 20); // let tick_all re-import the committed buffer
+ CHECK(server->ui_surface_element_reimport_count() == before_new + 1);
+
+ // (criterion 1, the §0d pooled re-commit) A new commit (seq++) re-using the
+ // SAME buffer pointer (buffer A) STILL re-imports exactly once — the gate is
+ // on the commit SEQ, not the buffer pointer (foot recycles a buffer pool).
+ const int before_reuse = server->ui_surface_element_reimport_count();
+ const int cmd2 = client.commit_cmd.fetch_add(1) + 1; // odd => buffer A (same ptr)
+ pump_until_se(*server, [&] { return client.commit_done.load() >= cmd2; });
+ pump(*server, 20);
+ CHECK(server->ui_surface_element_reimport_count() == before_reuse + 1);
+
+ // Dropping the element ends the frame-callback duty: with no element left the
+ // counts read 0 and stay 0 (the duty does not run for a destroyed element).
+ server->ui_drop_surface_element_for_test();
+ CHECK_FALSE(server->ui_surface_element_uri().rfind("unbox-surface://", 0) == 0);
+ CHECK(server->ui_surface_element_frame_done_count() == 0);
+ pump(*server, 20);
+ CHECK(server->ui_surface_element_frame_done_count() == 0);
+
+ client.join();
+ unsetenv("WLR_HEADLESS_OUTPUTS");
+}
diff --git a/tasks.md b/tasks.md
index ba8ffe1..5a07172 100644
--- a/tasks.md
+++ b/tasks.md
@@ -24,10 +24,18 @@ hook); present = FBO→dmabuf swapchain→wlr_scene_buffer + EGL fence. Throwawa
target `packages/kernel/rml-compositing-spike` (`--verify`/`--run`/`--demo`),
out of the shipped binary. **CONTRACT DECISION (user): RCSS is the single source
of truth for ALL layout + animation; C++ drives the document via a TYPED
-substrate API.** NEXT ACTION: **Phase 2 implementation** per the Phase-1 design
-doc `notes/rml-compositing-phase1.md` — Wave 1 = kernel substrate
-(`SurfaceElement` live import + input-back + damage-limited present). 4 user
-boundary calls open (design doc §10) before Wave 2 fans out.
+substrate API.** PHASE 2 on `feat/rml-compositing` (off main; spike sources carried
+as in-tree reference, `build_by_default:false`, deleted when the waves land).
+**Wave 1 DONE + verified**: kernel `SurfaceElement` (live sibling of `Preview`) —
+zero-copy seq-gated import, frame-callback duty, dirty-gate; public contract in
+`ui.hpp` (`create_surface_element(wlr_surface*)`); kernel suite + asan green
+(test-only `wayland-client` dep accepted, scoped to kernel-tests). Wave plan
+refined: **Wave 1** = the live primitive (done); **Wave 1b** = input-back
+(pick→surface-local→wl_seat via `Element::Project`) + subsurface/popup child
+trees; **Wave 2** = ext-xdg-shell (`Toplevel::wl_surface()`, retire scene
+compositing) + ext-layer-shell; **Wave 3** = NEW `ext-window-field` (window list +
+RCSS layout); **Wave 4** = ext-stage-dock; **Wave 5** = damage limiting (Option B)
++ scanout bypass. NEXT ACTION: **Wave 1b** (kernel input-back + surface trees).
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.