diff options
| author | Adam Malczewski <[email protected]> | 2026-06-15 06:27:04 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-15 06:27:04 +0900 |
| commit | 74d75fdd8b8654446338ae03bf2a133512408ec6 (patch) | |
| tree | 4aaee8bf36a988a17b485d15d4144a7909b14220 | |
| parent | 12e50166948b7554ccbdd382c8769774033b6e2c (diff) | |
| download | unbox-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.hpp | 27 | ||||
| -rw-r--r-- | packages/kernel/include/unbox/kernel/ui.hpp | 77 | ||||
| -rw-r--r-- | packages/kernel/meson.build | 10 | ||||
| -rw-r--r-- | packages/kernel/src/server.cpp | 92 | ||||
| -rw-r--r-- | packages/kernel/src/server_impl.hpp | 14 | ||||
| -rw-r--r-- | packages/kernel/src/ui_core.hpp | 42 | ||||
| -rw-r--r-- | packages/kernel/src/ui_substrate.cpp | 352 | ||||
| -rw-r--r-- | packages/kernel/src/ui_substrate.hpp | 76 | ||||
| -rw-r--r-- | packages/kernel/tests/test_kernel.cpp | 312 | ||||
| -rw-r--r-- | tasks.md | 16 |
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, ®_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"); +} @@ -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. |
