summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-15 05:52:20 +0900
committerAdam Malczewski <[email protected]>2026-06-15 05:52:20 +0900
commit12e50166948b7554ccbdd382c8769774033b6e2c (patch)
tree1531853f3e2bbcb59a73dcad163bd2dc83caffc2
parent5437b5e33c542c801bdc557be2ae93bfec6e153d (diff)
downloadunbox-12e50166948b7554ccbdd382c8769774033b6e2c.tar.gz
unbox-12e50166948b7554ccbdd382c8769774033b6e2c.zip
chore: seed feat/rml-compositing from main + RML-compositing design & spike reference
Phase 2 of RML compositing starts here, off main. Brings over from the throwaway spike branch: - design/planning: notes/rml-compositing-phase1.md (the Phase 1 design), notes/plan.md row 71 (ADOPTED/GO + Option-2 contract), GLOSSARY.md terms (RML compositing, surface element), tasks.md, .gitignore. - spike sources as IN-TREE REFERENCE only (packages/kernel/src/spike + build_by_default:false target + the input-core doctest): the proven mechanics Phase-2 ports (live seq-gated import, Element::Project input-back, FBO->dmabuf present, dirty-gate). Deleted once Wave 1 lands. Real Phase-2 implementation lands in subsequent wave commits.
-rw-r--r--.gitignore3
-rw-r--r--GLOSSARY.md2
-rw-r--r--notes/plan.md2
-rw-r--r--notes/rml-compositing-phase1.md257
-rw-r--r--packages/kernel/meson.build27
-rw-r--r--packages/kernel/src/spike/rml_compositing_spike.cpp577
-rw-r--r--packages/kernel/src/spike/rml_compositing_spike_run.cpp2127
-rw-r--r--packages/kernel/src/spike/spike_gl.hpp653
-rw-r--r--packages/kernel/src/spike/spike_input_core.hpp224
-rw-r--r--packages/kernel/tests/test_kernel.cpp98
-rw-r--r--tasks.md20
11 files changed, 3986 insertions, 4 deletions
diff --git a/.gitignore b/.gitignore
index a9d1f2f..72b9759 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,3 +15,6 @@ packaging/remote.local
subprojects/*
!subprojects/*.wrap
!subprojects/packagefiles/
+
+# local spike launcher (run-spike.sh)
+/run-spike.sh
diff --git a/GLOSSARY.md b/GLOSSARY.md
index 83123a2..bc8e555 100644
--- a/GLOSSARY.md
+++ b/GLOSSARY.md
@@ -46,6 +46,8 @@
| **ui surface** | One RMLUi document an extension contributes, composited as a scene node. | shell surface, overlay, RML window, panel (when meaning the object) |
| **data binding** | RMLUi's model↔document binding; the ONLY way extension state reaches RML. | — |
| **touch-mode** | The substrate state signalling finger input (auto-flipped, debounced). NO automatic visual scaling (user decision, slice 5) — extensions may adapt affordances via the change notification (spacing, invisible hit zones, OSK auto-show). | tablet mode |
+| **RML compositing** | Architecture direction (gated by the slice-13 spike): the RMLUi substrate composites ALL on-screen content — toplevels, layer-shell clients (incl. wallpaper), and chrome — as **surface elements** backed by live, shared GL textures, with layout/animation/3D effects in RCSS. wlroots stays the foundation + hardware cursor plane + fullscreen-video scanout bypass. See `notes/rml-compositing.md`, `notes/plan.md` §2. | RMLUi-as-renderer-only (when meaning this) |
+| **surface element** | An RML element backed by a live client surface's shared GL texture — a toplevel OR a layer surface presented inside the RML compositor. | window element, RML window |
## Input & keybindings
diff --git a/notes/plan.md b/notes/plan.md
index b0ea7cd..6e1d824 100644
--- a/notes/plan.md
+++ b/notes/plan.md
@@ -68,7 +68,7 @@ solves), and the trigger that would reopen it.
| **VT switching (Ctrl+Alt+Fn) is kernel-hardwired** before the key_filter (`wlr_session_change_vt`) | It is the session escape hatch — must work even if an extension throws or greedily consumes keys; not a rebindable feature (user decision) | — |
| **Stage dock** (ext-stage-dock, standard) = the Stage-Manager-style left-edge dock of minimized-window **previews**, revealed by a left-edge **swipe**. **Fork B**: previews are toplevel snapshots imported as textures INTO the ui substrate's RMLUi context and shown as `<img>` in ONE RML document | Closest to the iPad Stage Manager north star; one ui surface animates as a unit via RCSS; reuses the slice-3 dmabuf/EGLImage bridge in reverse (wlr pixels → RMLUi texture) instead of two-layer scene/RML lockstep | Cross-context texture import proves infeasible on crocus → fall back to Fork A (previews as `wlr_scene` snapshot nodes) |
| **Mechanism in kernel/core, policy in ext-stage-dock.** Kernel ui substrate gains: preview-snapshot, list/container bindings, a gesture-CLAIM input path. ext-xdg-shell gains: `Toplevel::hide()/show()` (≠ unmap), `geometry()`, `scene_tree()`. ext-stage-dock owns: the "minimized" set, dock layout, gesture recognition, easing | Keeps "kernel names no feature" — snapshot/claim/list-bindings are generic primitives; minimize-to-dock is the only policy and lives in one standard extension | — |
-| **RML compositing (DIRECTION, gated by a spike — reopens the compositing half of row 51).** RMLUi becomes the CONTENT compositor: toplevels, layer-shell clients (incl. wallpaper), and UI chrome are all RMLUi elements backed by LIVE, SHARED GL textures (zero per-frame copy via the existing shared EGLDisplay). Layout/animation/3D effects are RCSS. wlroots stays the foundation + plane manager (backend/output/renderer/seat, hardware cursor plane, fullscreen-video scanout bypass). Lost wlr_scene damage/scanout is mitigated by OUR dirty-gated rendering (idle ≈ no work) + a deferred fullscreen scanout bypass. Full design + spike acceptance criteria: `notes/rml-compositing.md` | The appeal is Hyprland-grade window effects via DECLARATIVE RCSS (layout, transitions, 3D transforms, blur/shaders) with hot-reload — and RmlUi already does transform-aware hit-testing + RTT/filters, while slice 3 + the stage dock already proved dmabuf/EGLImage texture handoff on this exact crocus HW. Doing windows in RMLUi means tiling/effects/stage-manager are all RCSS policy on top | The spike fails on the CF-AX3 (no zero-copy shared-texture path, input-routing dead-end, surface-tree composition impractical, or idle/perf out of budget) → fall back to wlr_scene compositing + transient snapshot-through-RMLUi effects |
+| **RML compositing (ADOPTED — Phase 0 spike PASSED GO on real Haswell+crocus; supersedes the compositing half of row 51).** RMLUi is the CONTENT compositor: toplevels, layer-shell clients (incl. wallpaper), and UI chrome are all RMLUi elements backed by LIVE, SHARED GL textures (zero per-frame copy via the existing shared EGLDisplay). Layout/animation/3D effects are RCSS. wlroots stays the foundation + plane manager (backend/output/renderer/seat, hardware cursor plane, fullscreen-video scanout bypass). Lost wlr_scene damage/scanout is mitigated by OUR dirty-gated rendering (idle ≈ no work) + damage-limited compositing + a deferred fullscreen scanout bypass. **Contract decision (user): RCSS is the single source of truth for ALL layout + animation; C++/extensions DRIVE the document through a TYPED substrate API but never own placement geometry directly — the typed-symbol rule governs cross-extension dependency discovery, not the RCSS styling vocabulary.** Spike report: `reports/rml-compositing-spike.md`; Phase-1 design: `notes/rml-compositing-phase1.md` | The appeal is Hyprland-grade window effects via DECLARATIVE RCSS (layout, transitions, 3D transforms, blur/shaders) with hot-reload — and RmlUi already does transform-aware hit-testing + RTT/filters, while slice 3 + the stage dock already proved dmabuf/EGLImage texture handoff on this exact crocus HW. Doing windows in RMLUi means tiling/effects/stage-manager are all RCSS policy on top. **Phase 0 proved on the real CF-AX3 GPU class: zero-copy live import, RCSS 3D transform on live pixels, input accurate through the transform, per-subsurface surface trees, idle dirty-gate, FBO→dmabuf present; ~30fps under a 4-window load, fill-bound (~10–15ms whole-output composite) with damage limiting as the known recovery** | A Phase-2 implementation blocker on the real seat (perf regresses below budget after damage limiting + scanout bypass, or the RCSS-only contract proves unworkable for a real tiling/effects extension) → fall back to wlr_scene compositing + transient snapshot-through-RMLUi effects |
## 3. Architecture
diff --git a/notes/rml-compositing-phase1.md b/notes/rml-compositing-phase1.md
new file mode 100644
index 0000000..45c8e8c
--- /dev/null
+++ b/notes/rml-compositing-phase1.md
@@ -0,0 +1,257 @@
+# RML compositing — Phase 1 architecture (the design doc)
+
+> **Status: ACTIVE design, gated only by Phase-2 implementation.** Phase 0 (the
+> spike) **PASSED GO** on the real CF-AX3 GPU class — see
+> `reports/rml-compositing-spike.md`. This doc settles the architecture the
+> direction note (`notes/rml-compositing.md`) deferred to Phase 1. Phase 2 then
+> implements it wave-by-wave, behind a flag, per `ORCHESTRATOR.md`.
+
+## 0. Inputs that are already settled (do not relitigate)
+- **GO**: live zero-copy import, RCSS 3D transform on live pixels, input accurate
+ through the transform, per-subsurface surface trees, idle dirty-gate, and the
+ FBO→dmabuf→`wlr_scene_buffer` present path all proven on Haswell+crocus.
+- **Perf reality (Phase 0 Stage-0 measurement)**: ~30fps under a 4-window load,
+ **fill-bound** — the whole-output composite is ~10–15ms GPU, CPU work ~2ms,
+ present dominated by the fence wait. Damage limiting is the recovery lever.
+- **Surface-tree answer (spike §3)**: **per-subsurface elements** by default, with
+ a **per-window render-to-texture (RTT) hook** for the one case that needs it
+ (a single effect that must treat a whole window tree as one flat surface —
+ genie warp, cross-seam blur).
+- **Contract decision (user)**: **RCSS is the single source of truth for ALL
+ layout and animation.** C++/extensions DRIVE the document (what exists, which
+ data, which classes) through a **typed** substrate API, but never own placement
+ geometry imperatively. Tiling, stage-manager, floating, effects = RCSS.
+- **Naming (GLOSSARY)**: *RML compositing* (the approach), *surface element* (an
+ RML element backed by a live client surface's shared texture).
+
+## 1. The contract principle (Option 2), reconciled with the constitution
+AGENTS.md forbids **string-keyed cross-extension dependency discovery** ("a
+missing dependency must be a compile/link error"). RCSS-as-layout does **not**
+violate this: that rule governs how *units find each other*, not the styling
+vocabulary. So:
+
+- **Units couple through typed C++ symbols** (service handles, hook descriptors,
+ the substrate API) — unchanged. A missing `ext-xdg-shell` is still a link error.
+- **Within a surface, layout/animation is RCSS.** C++ pushes *data* (typed
+ `bind_*`/`bind_list_*`) and *intent* (set a class, dirty a binding); the
+ document decides geometry and tweens. This is already how `UiSurface` works
+ (`notes`/the `ui.hpp` contract) — Phase 1 does not invent a paradigm, it adds a
+ **live** surface primitive and **input-back** to the existing one.
+
+Litmus: an extension may never read another extension's state by string name, and
+may never compute a window's on-screen rectangle and command "draw it there." It
+provides the window list + per-window data; RCSS lays them out and animates.
+
+## 2. What already exists and is REUSED verbatim
+The `kernel` `UiSubstrate`/`UiSurface` contract (`packages/kernel/include/unbox/
+kernel/ui.hpp`) already provides everything Option 2 needs **except live windows**:
+- `create_surface(UiSurfaceSpec)` → a `UiSurface` = one RML document = one
+ composited node; per-pixel alpha; `SceneLayer`.
+- Typed data bindings: `bind_int/double/bool/string`, **`bind_list` +
+ `bind_list_string/int/...` + `bind_list_event`** (the list pattern), `dirty()`.
+- Interaction: `bind_event`, **`bind_drag`** (captured drag stream in surface-local
+ px), `on_touch_mode_changed`.
+- **`transition_timing(element_id, property)`** — read RCSS-authored
+ duration/delay/easing from C++ so animators reuse hot-reloadable RCSS values.
+- **`Preview`** = a FROZEN toplevel snapshot imported as a texture, shown via
+ `<img src=source_uri()>` in any ui surface. `create_preview(wlr_scene_tree*)`.
+- Dev **hot-reload** of RML/RCSS, error-isolated.
+
+**Phase 1 = make `Preview` LIVE + route input back into it.** That is the whole
+new mechanism; the layout/animation/contract machinery is already shipped.
+
+## 3. New kernel primitive: the live surface element
+A live analogue of `Preview`. Proposed contract (in `ui.hpp`, kernel-owned):
+
+ class SurfaceElement { // GLOSSARY: "surface element"
+ public:
+ // The <img src> URI resolving to this surface's LIVE shared texture inside
+ // any ui surface of this substrate (e.g. "unbox-surface://7"). Stable for life.
+ virtual auto source_uri() const -> std::string = 0;
+ virtual auto width() const -> int = 0; // current surface px (tracks commits)
+ virtual auto height() const -> int = 0;
+ // NO refresh(): unlike Preview, this updates itself every client commit
+ // (seq-gated re-import) and drives the client's frame callbacks.
+ virtual ~SurfaceElement() = default;
+ };
+
+ // On UiSubstrate:
+ virtual auto create_surface_element(wlr_surface* client) // a BORROW
+ -> std::unique_ptr<SurfaceElement> = 0;
+
+Semantics (all proven in the spike, generalized from `spike_gl.hpp`):
+- **Zero-copy, seq-gated**: re-imports the client's current buffer only when
+ `wlr_surface_state.seq` advances (pool-reuse-proof); double-buffered
+ `wlr_buffer_lock`/unlock; idle client ⇒ zero work.
+- **Drives the client loop**: the substrate sends `wl_surface` frame callbacks
+ each composited frame (a live element, unlike `Preview`, is responsible for the
+ client's progress — the spike's "stuck-frame" fix).
+- **Surface tree** (see §6): one `create_surface_element(toplevel root)` manages
+ the toplevel + its subsurfaces + popups as **child elements**, each its own live
+ texture at its tree offset.
+- **Lifetime**: owned by the contributing extension via `unique_ptr`; destroying
+ it drops the import + frame-callback duty. The `wlr_surface*` is a borrow valid
+ until the owner drops the element (extensions already track map/unmap).
+
+Wallpaper/layer-shell surfaces use the **same** `create_surface_element` (spike
+criterion 5).
+
+## 4. The compositor document + window-layout model (RCSS-driven)
+This is the heart of Option 2. Windows are not per-window scene nodes; they are
+**surface elements inside a ui surface document**, laid out by RCSS.
+
+- A window-management extension (today `ext-xdg-shell`; later a tiling/stage
+ extension) owns **one ui surface** at `SceneLayer` for app content (call it the
+ *window field*). It does NOT compute geometry.
+- It feeds windows through the **existing list binding**: `bind_list("wins", …)`
+ with per-row fields — crucially a `live_uri` string field returning each
+ window's `SurfaceElement::source_uri()`, plus whatever the RCSS layout keys off
+ (focused bool, app_id string, a layout-slot int/percent, z-order, etc.).
+- The RML authors the layout:
+
+ <div data-model="wm">
+ <div class="field tiling"> <!-- class chosen by C++ intent -->
+ <div data-for="w : wins" class="win"
+ data-class-focused="w.focused"
+ style="--slot: {{ w.slot }};">
+ <img src="{{ w.live_uri }}"/>
+ </div>
+ </div>
+ </div>
+
+ Tiling = RCSS flex/grid keyed on `--slot`; stage-manager = the same list under a
+ `.stage` class with `transform`/perspective per card; floating = absolutely
+ positioned from bound `x/y`. **Switching layout = swapping a class / changing
+ bound data**, animated by RCSS `transition` — the user's "everything is laid out
+ and animated in RCSS."
+- **Animation timing** comes from RCSS; C++ that must coordinate (e.g. a gesture)
+ reads it via `transition_timing()` (already shipped) and drives progress with
+ bound values — never hand-rolled geometry.
+
+This makes tiling/stage/effects *policies expressed as RML+RCSS + a window list*,
+exactly the constitution's "kernel names no feature."
+
+## 5. Unified input (pick → wl_seat), folded into the substrate
+The substrate already routes `data-event*`/`bind_drag` for ui surfaces. Phase 1
+adds **client input-back** for surface elements:
+- On pointer/touch, the substrate feeds the screen point to `Context::Process*`
+ (transform-aware pick). If the hovered element is a surface element, it maps the
+ point to surface-local via **`Element::Project()`** (the spike's fix — projects
+ through the element's real 3D transform, no-op when untransformed) and forwards
+ via `wlr_seat_pointer/touch_notify_*`.
+- Keyboard focus follows the focused window (the wm extension calls a focus path;
+ `wlr_seat_keyboard_notify_*`).
+- **Cursor stays a wlr hardware plane**, never drawn in RMLUi (recompose-on-move
+ would be fatal).
+- Implicit grab / click-to-focus stays wm-extension policy; the substrate only
+ provides the pick→local→seat translation as a typed primitive.
+
+Contract sketch (kernel): a surface element created from a `wlr_surface` is
+**automatically** input-routed by the substrate (it knows the element↔surface
+map); the wm extension does not wire seat calls itself. This subsumes
+`ext-xdg-shell`'s current pointer/touch routing.
+
+## 6. Surface trees: per-subsurface + RTT hook
+- Default: `create_surface_element(root)` builds **one child element per
+ subsurface/popup**, positioned at its tree offset; DOM order = composite order;
+ popups are not parent-clipped (own absolutely-positioned elements). This is the
+ spike's criterion-4 result.
+- A small **"place child relative to parent's resolved box"** layout helper is
+ needed so a moving/transformed parent drags its children (spike §3 edge note) —
+ pure layout glue.
+- **RTT hook** (do not build until an effect needs it): a per-element opt-in that
+ flattens a window's whole tree to one texture (RmlUi `SaveLayerAsTexture`) so a
+ tree-spanning effect transforms/filters one surface. Element-level policy, not a
+ global mode.
+
+## 7. Present + performance posture
+- **Present path**: reuse Phase-0 Plan A — RMLUi composites into an FBO on a
+ `wlr_swapchain` dmabuf, handed to a `wlr_scene_buffer`; EGL fence, no `glFinish`.
+ `wlr_scene` is reduced to **presenter of one full-output buffer + the cursor
+ plane + (later) scanout bypass**.
+- **Dirty-gate (ours)**: schedule + `Render()` only on a real signal — a client
+ commit (wlroots), an active RCSS animation (`GetNextUpdateDelay()` finite), or an
+ input-driven state change. Idle ≈ no GPU. Proven in the spike.
+- **Damage-limited compositing — Option B (build here, correctly).** Now that we
+ own the real compositor (not a throwaway), do it the production way:
+ 1. Per-element dirt → screen-space damage region: project each changed surface's
+ `wl_surface` damage rect through its element transform (forward of the spike's
+ `project_to_screen`) → AABB → union (cap; fall back to full-frame when it
+ explodes); static-transform elements use AABB, animating ones are full-damage
+ for the animation.
+ 2. Render damage into the swapchain with **buffer-age accumulation**
+ (`wlr_damage_ring` keyed on the presented buffer; repaint the union over the
+ buffer's age) and a **scissor** on RmlUi's draw; redraw all elements
+ intersecting the region in z-order (blending-correct).
+ 3. Feed the region to **`wlr_scene_buffer_set_buffer_with_damage`** so output +
+ KMS partial-update benefit (battery/thermal on a 15W fanless panel).
+ - A **damage-debug tint** toggle (same trick as the spike's click crosshair) to
+ watch the reshaded region shrink and catch buffer-age staleness.
+- **Fullscreen-video scanout bypass (deferred, separate from damage).** When one
+ opaque, untransformed, fullscreen surface has nothing composited on top, pull it
+ out of the RMLUi composite and hand it to `wlr_scene`/scanout directly (RMLUi
+ draws nothing that frame). Trigger = the fullscreen STATE. Damage limiting can't
+ help a full-rate video; this can. Size it by measurement; not a blocker.
+
+## 8. Cross-unit contract changes (what Phase 2 touches)
+| Unit | Change | Contract impact |
+|---|---|---|
+| **kernel** (`ui.hpp`, present/frames, input) | add `SurfaceElement` + `create_surface_element`; auto input-back for surface elements; damage-limited present; dirty-gate as the scheduler | NEW public surface in `ui.hpp`; present internals private |
+| **ext-window-field** (NEW core unit; user decision §10.1) | owns the window-field ui surface + the window list (each row a `SurfaceElement` `live_uri` + layout data) + layout policy (tiling/stage/floating as RCSS); subscribes to `ext-xdg-shell`'s map/unmap/focus; drives focus + click-to-focus policy | NEW contract: the layout/tiling service (typed); consumes `ext-xdg-shell::Service` + kernel `SurfaceElement` |
+| **ext-xdg-shell** | stop owning a `wlr_scene_tree` per toplevel for COMPOSITING; expose each toplevel's **root `wlr_surface`** so `ext-window-field` can make a `SurfaceElement`; `hide()/show()` become list membership / a hidden class, not scene-node toggles; pointer/touch routing moves to the substrate | `Toplevel::scene_tree()` **retired**; add `Toplevel::wl_surface()` (typed borrow); `geometry()` becomes the RCSS-resolved element box (read-back) — a real change-request |
+| **ext-layer-shell** | layer surfaces become surface elements at the right `SceneLayer`; wallpaper via the identical path | analogous to xdg-shell |
+| **ext-stage-dock** | minimize/restore re-expressed as **RCSS over the live window list** (a `.minimized` class / a dock list of live URIs); drop the frozen-`Preview` snapshot path (previews can stay live now) | consumes the new list/`SurfaceElement`; coordinates with `ext-window-field`; `Preview` may remain for thumbnails of hidden windows |
+| **host-bin** (orchestrator-owned) | composition-root wiring (+ the NEW `ext-window-field` unit) + the **Phase-2 feature flag** to switch compositing path | — |
+
+The biggest contract churn is **`ext-xdg-shell`** losing window compositing
+(`scene_tree()`/`hide()`/`show()`/`geometry()` redesigned around exposing the root
+`wlr_surface`) **and the new `ext-window-field` unit** owning layout. Settle both
+contracts before Wave 2 fans out.
+
+## 9. Unit/ownership map + Phase 2 wave plan
+Topological, disjoint-where-possible (per `ORCHESTRATOR.md` §2). Behind a flag so
+the session stays usable each wave.
+
+1. **Wave 1 — kernel substrate.** `SurfaceElement` + `create_surface_element`
+ (live import, seq-gate, frame-callback duty, surface-tree children) + auto
+ input-back. Damage-limited present + dirty-gate scheduler. (Pure-core damage
+ math is doctested; glue tested on the headless backend.) *opus agent.*
+2. **Wave 2 — ext-xdg-shell + ext-layer-shell** (disjoint): retire per-window
+ scene-tree compositing; `ext-xdg-shell` exposes `Toplevel::wl_surface()` + the
+ new focus/geometry contract; `ext-layer-shell` exposes its surfaces likewise.
+ Depends on Wave 1's contract. (These two are disjoint and can summon together.)
+3. **Wave 3 — ext-window-field** (NEW): owns the window-field ui surface, the
+ `bind_list` of live windows, and RCSS layout (tiling/stage/floating); subscribes
+ to ext-xdg-shell map/unmap/focus; drives focus policy. Depends on Wave 2.
+4. **Wave 4 — ext-stage-dock**: minimize/restore as RCSS over the live window
+ list; coordinate with ext-window-field; thumbnails via live elements or
+ `Preview`. Depends on Wave 3.
+5. **Wave 5 — perf hardening**: damage-debug tooling, scanout bypass, real-seat
+ numbers; refine tiling (now an RCSS layout over the window field).
+
+## 10. Open sub-decisions (USER — boundary calls before/within Phase 2)
+1. **RESOLVED (user): a NEW `ext-window-field` / `ext-tiling` core extension owns
+ the window-field ui surface, the window list, and layout policy** (tiling /
+ stage / floating as RCSS). `ext-xdg-shell` keeps the xdg protocol and only
+ supplies toplevel handles + their root `wlr_surface`s; it no longer owns window
+ compositing. This is the new unit added in Wave 2.
+2. **One window-field document vs one per output/workspace?** Recommendation: one
+ per output to start; workspaces = bound class/data on it.
+3. **`Preview` retirement vs coexistence.** Keep `Preview` for thumbnails of
+ *hidden* windows (no live buffer), use live `SurfaceElement` everywhere else?
+ Recommendation: coexist.
+4. **Flag strategy for Phase 2** (config `unbox.toml` key vs build flag) to run
+ old `wlr_scene` compositing and new RML compositing side-by-side during the
+ migration. Recommendation: `unbox.toml` runtime key.
+
+## 11. Risks & fallback
+- **Perf after damage limiting + scanout bypass still below budget on the real
+ panel** → fall back to `wlr_scene` compositing with transient
+ snapshot-through-RMLUi effects (the stage-dock `Preview` path already proves that
+ half). This is the row-71 reopen trigger.
+- **RCSS-only layout proves unworkable for a real tiling/effects extension**
+ (e.g. needs imperative geometry RCSS can't express) → revisit the contract
+ decision with a typed-placement-service escape hatch for that one case (kept out
+ unless earned, per the rules discipline).
+- **`ext-xdg-shell` contract churn** is the integration risk; settle its new
+ window-list/geometry contract with its owner-agent before Wave 2 fans out.
diff --git a/packages/kernel/meson.build b/packages/kernel/meson.build
index 9b4f81b..b53b3ad 100644
--- a/packages/kernel/meson.build
+++ b/packages/kernel/meson.build
@@ -88,3 +88,30 @@ kernel_test = executable(
dependencies: [kernel_dep, doctest_dep],
)
test('kernel', kernel_test, suite: 'kernel')
+
+# ---- SPIKE: RML compositing (Phase 0 GO/NO-GO) -------------------------------
+#
+# A self-contained, RUNNABLE throwaway target (notes/rml-compositing.md §Phase 0,
+# prompts/rml-compositing-spike.md). It is its OWN minimal compositor that maps
+# real clients and composites them as LIVE surface elements inside an RmlUi
+# document, proving the 7 acceptance criteria. Kept OUT of the shipped `unbox`
+# binary: it is NOT in kernel_dep and host-bin never links it; build it
+# explicitly with `ninja -C build rml-compositing-spike`.
+#
+# It reuses the kernel's adapted RmlUi GL3 renderer (src/rmlui_renderer_gl3.cpp)
+# directly, so it needs RMLUi + EGL/GLES (kernel-private deps) AND the same
+# -DUNBOX_RMLUI_GLES native-GLES selection the kernel lib compiles under. The
+# generated layer-shell protocol header rides in via the source list (build
+# order). It links the kernel lib for the renderer + wlr.hpp wrapper.
+rml_compositing_spike = executable(
+ 'rml-compositing-spike',
+ 'src/spike/rml_compositing_spike.cpp',
+ 'src/spike/rml_compositing_spike_run.cpp',
+ 'src/rmlui_renderer_gl3.cpp',
+ wlr_layer_shell_protocol_h,
+ cpp_args: ['-DUNBOX_RMLUI_GLES'],
+ include_directories: kernel_inc,
+ dependencies: [wlroots_dep, wayland_server_dep, xkbcommon_dep, rmlui_dep,
+ egl_dep, glesv2_dep],
+ build_by_default: false,
+)
diff --git a/packages/kernel/src/spike/rml_compositing_spike.cpp b/packages/kernel/src/spike/rml_compositing_spike.cpp
new file mode 100644
index 0000000..2ffa2bf
--- /dev/null
+++ b/packages/kernel/src/spike/rml_compositing_spike.cpp
@@ -0,0 +1,577 @@
+// SPIKE (rml-compositing, Phase 0) — RUNNABLE GO/NO-GO target. THROWAWAY.
+//
+// Proves the "RML compositing" mechanism: a LIVE client toplevel/layer surface
+// is imported zero-copy as a SHARED GL texture and drawn as a SURFACE ELEMENT
+// (an RML <img>) in an RmlUi document; an RCSS 3D transform + transition is
+// applied to it; input is routed back to the client through RmlUi picking; the
+// composite is presented via the RmlUi-FBO -> wlr_scene_buffer bridge.
+//
+// It is its OWN compositor (display/backend/renderer/allocator/scene/seat +
+// xdg-shell + layer-shell) so it can map real clients, NOT the shipped Server
+// (which names no feature and exposes none of this). It reuses the kernel's
+// proven pieces: the wlr.hpp extern-"C" wrapper, the adapted RenderInterface_GL3
+// (with SetOutputFramebuffer + the upright V-flip), and the slice-3 dmabuf ->
+// EGLImage import discipline. RMLUi is kernel-private and this lives IN the
+// kernel unit, so including the private renderer header is in-bounds.
+//
+// Two modes:
+// --verify : headless + gles2, NO real client. A synthetic client dmabuf
+// (known quadrant pattern) is imported LIVE; a known 3D transform
+// is applied; the presented buffer is read back and asserted
+// against the projected pattern; the idle dirty-gate is asserted
+// (zero renders over N idle turns); the screen->surface-local
+// input inversion is asserted through the transform; then a SECOND
+// bring-up composites a surface TREE (toplevel + subsurface +
+// popup) plus a layer-shell WALLPAPER as per-subsurface elements
+// and reads back each surface's footprint + stack order (criteria
+// 4 + 5). Exit 0 = pass.
+// --run : a real seat (DRM) or nested (labwc) run that spawns a client
+// (default `foot`), composites it live as a 3D surface element,
+// routes input back, and prints per-frame perf + idle metrics for
+// the user's visual/touch/perf GO-NO-GO. Ctrl-C to quit.
+//
+// wlroots only via unbox/kernel/wlr.hpp (.unbox/rules/wlroots-include.md).
+
+#include <unbox/kernel/wlr.hpp>
+
+#include "spike_gl.hpp"
+#include "spike_input_core.hpp"
+
+#include <RmlUi/Core/Context.h>
+#include <RmlUi/Core/Core.h>
+#include <RmlUi/Core/Element.h>
+#include <RmlUi/Core/ElementDocument.h>
+
+#include <cmath>
+#include <cstdint>
+#include <cstdio>
+#include <cstdlib>
+#include <cstring>
+#include <string>
+
+namespace spike = unbox::kernel::spike;
+
+namespace {
+
+int g_fail = 0;
+void check(bool cond, const char* what) {
+ std::fprintf(stderr, "[verify] %-58s %s\n", what, cond ? "PASS" : "FAIL");
+ if (!cond) {
+ ++g_fail;
+ }
+}
+
+// Allocate a real client dmabuf of (w,h) through the wlr allocator and paint it
+// a single solid color via the wlr render pass — exactly the GPU path a client
+// produces. Returns the locked wlr_buffer (caller drops it) or nullptr. `gl`
+// must NOT be current while the wlr renderer runs, so we restore it around the
+// pass and re-make-current after (mirrors the existing criterion-1 painter).
+auto make_solid_client_buffer(spike::GlBridge& gl, wlr_renderer* renderer,
+ wlr_allocator* allocator, int w, int h, float r, float g, float b)
+ -> wlr_buffer* {
+ wlr_drm_format cfmt{};
+ cfmt.format = spike::kArgb8888;
+ std::uint64_t cmods[] = {0};
+ cfmt.len = 1;
+ cfmt.capacity = 1;
+ cfmt.modifiers = cmods;
+ wlr_buffer* buf = wlr_allocator_create_buffer(allocator, w, h, &cfmt);
+ if (buf == nullptr) {
+ return nullptr;
+ }
+ gl.restore_current();
+ wlr_buffer_pass_options po{};
+ wlr_render_pass* pass = wlr_renderer_begin_buffer_pass(renderer, buf, &po);
+ if (pass != nullptr) {
+ wlr_render_rect_options ro{};
+ ro.box = {0, 0, w, h};
+ ro.color = {r, g, b, 1};
+ ro.blend_mode = WLR_RENDER_BLEND_MODE_NONE;
+ wlr_render_pass_add_rect(pass, &ro);
+ wlr_render_pass_submit(pass);
+ }
+ gl.make_current();
+ return buf;
+}
+
+// The verify document: a single surface element (the live client texture) the
+// size of the surface, with an RCSS 3D transform + transition. No body margin so
+// the <img> fills the 256x256 surface 1:1 before transform.
+const char* kVerifyRmlTemplate = R"RML(<rml>
+<head>
+<style>
+body { margin: 0px; padding: 0px; width: 256px; height: 256px;
+ perspective: 800px; }
+#win { display: block; position: absolute; left: 0px; top: 0px;
+ width: 256px; height: 256px;
+ transform: rotateY(0deg);
+ transform-origin: 50% 50%;
+ transition: transform 0.2s linear-in-out; }
+#win img { display: block; width: 256px; height: 256px; }
+</style>
+</head>
+<body>
+<div id="win"><img src="LIVE_URI"/></div>
+</body>
+</rml>)RML";
+
+auto run_verify() -> int {
+ setenv("WLR_BACKENDS", "headless", 1);
+ setenv("WLR_RENDERER", "gles2", 1);
+
+ wlr_log_init(WLR_ERROR, nullptr);
+ wl_display* display = wl_display_create();
+ wl_event_loop* loop = wl_display_get_event_loop(display);
+ wlr_backend* backend = wlr_backend_autocreate(loop, nullptr);
+ wlr_renderer* renderer = wlr_renderer_autocreate(backend);
+ wlr_allocator* allocator = wlr_allocator_autocreate(backend, renderer);
+ wlr_scene* scene = wlr_scene_create();
+
+ if (!wlr_renderer_is_gles2(renderer)) {
+ std::fprintf(stderr, "[verify] SKIP: renderer is not gles2 (no GL path on this box)\n");
+ return 0;
+ }
+ EGLDisplay egl = wlr_egl_get_display(wlr_gles2_renderer_get_egl(renderer));
+
+ spike::GlBridge gl;
+ if (!gl.init(egl)) {
+ std::fprintf(stderr, "[verify] SKIP: sibling GL bridge unavailable\n");
+ return 0;
+ }
+ check(gl.dmabuf_ok, "criterion 1: dmabuf import path available on this GPU");
+ check(gl.fence_ok, "criterion 7: EGL fence-sync (no glFinish) present path active");
+
+ gl.make_current();
+
+ // The "live client buffer": a 256x256 dmabuf allocated through the wlr
+ // allocator (a real dmabuf the client path produces), painted with a quadrant
+ // pattern (TL red, TR green, BL blue, BR white) via the wlr renderer the way
+ // a GPU client would. Imported zero-copy as the live surface element.
+ const int W = 256;
+ spike::LiveTexture live;
+ live.gl = &gl;
+ live.uri = "unbox-live://win";
+
+ wlr_drm_format cfmt{};
+ cfmt.format = spike::kArgb8888;
+ std::uint64_t cmods[] = {0};
+ cfmt.len = 1;
+ cfmt.capacity = 1;
+ cfmt.modifiers = cmods;
+ wlr_buffer* client_buf = wlr_allocator_create_buffer(allocator, W, W, &cfmt);
+ bool live_zero_copy = false;
+ if (client_buf != nullptr) {
+ gl.restore_current();
+ wlr_buffer_pass_options po{};
+ wlr_render_pass* pass = wlr_renderer_begin_buffer_pass(renderer, client_buf, &po);
+ if (pass != nullptr) {
+ const wlr_render_color quad[4] = {
+ {1, 0, 0, 1}, {0, 1, 0, 1}, {0, 0, 1, 1}, {1, 1, 1, 1}};
+ const wlr_box boxes[4] = {{0, 0, W / 2, W / 2},
+ {W / 2, 0, W / 2, W / 2},
+ {0, W / 2, W / 2, W / 2},
+ {W / 2, W / 2, W / 2, W / 2}};
+ for (int i = 0; i < 4; ++i) {
+ wlr_render_rect_options r{};
+ r.box = boxes[i];
+ r.color = quad[i];
+ r.blend_mode = WLR_RENDER_BLEND_MODE_NONE;
+ wlr_render_pass_add_rect(pass, &r);
+ }
+ wlr_render_pass_submit(pass);
+ }
+ gl.make_current();
+ live_zero_copy = live.adopt(client_buf, /*seq=*/1) && live.is_dmabuf;
+ }
+ check(client_buf != nullptr && live.tex != 0,
+ "criterion 1: live client buffer imported as a sampled texture");
+ check(live_zero_copy, "criterion 1: live import is ZERO-COPY dmabuf (not a CPU copy)");
+
+ // Re-adopting the SAME buffer at the SAME commit seq is the idle-gate case:
+ // no commit happened, so the seq is unchanged and we must NOT re-import.
+ const int reimports_before = live.reimports;
+ live.adopt(client_buf, /*seq=*/1);
+ live.adopt(client_buf, /*seq=*/1);
+ check(live.reimports == reimports_before,
+ "criterion 1: unchanged surface state (same seq) is NOT re-imported (cached)");
+
+ // But a NEW commit (seq advances) of the SAME pooled buffer pointer with new
+ // contents MUST re-import — the frozen-frame fix. Proven here directly.
+ live.adopt(client_buf, /*seq=*/2);
+ check(live.reimports == reimports_before + 1,
+ "criterion 1: a new commit (seq++) of a reused buffer pointer DOES re-import "
+ "(frozen-frame fix: pool reuse no longer skips the update)");
+
+ std::string rml = kVerifyRmlTemplate;
+ rml.replace(rml.find("LIVE_URI"), 8, live.uri);
+ Rml::Context* ctx = Rml::CreateContext("verify", Rml::Vector2i(W, W), gl.render);
+ Rml::ElementDocument* doc = (ctx != nullptr) ? ctx->LoadDocumentFromMemory(rml) : nullptr;
+ check(doc != nullptr, "verify document loaded");
+ if (doc != nullptr) {
+ doc->Show();
+ }
+
+ spike::PresentTarget present;
+ const bool present_ok = present.init(&gl, allocator, W, W);
+ present.scene_buffer = wlr_scene_buffer_create(&scene->tree, nullptr);
+ check(present_ok, "criterion 7: present FBO -> wlr_buffer target built");
+ check(present.dmabuf, "criterion 7: present buffer is a dmabuf (Plan A swapchain)");
+
+ auto is_color = [](const std::uint8_t p[4], int r, int g, int b) {
+ return std::abs(int(p[0]) - r) < 60 && std::abs(int(p[1]) - g) < 60 &&
+ std::abs(int(p[2]) - b) < 60;
+ };
+
+ if (doc != nullptr) {
+ // ---- Criterion 1+7 untransformed: the live pattern presents UPRIGHT
+ // with the quadrant colors in the right corners (present path renders the
+ // LIVE texture). ----
+ present.render(ctx);
+ std::uint8_t tl[4], tr[4], bl[4], br[4];
+ present.pixel(40, 40, tl);
+ present.pixel(W - 40, 40, tr);
+ present.pixel(40, W - 40, bl);
+ present.pixel(W - 40, W - 40, br);
+ check(is_color(tl, 255, 0, 0), "criterion 1: live TL quadrant red, upright, correct corner");
+ check(is_color(tr, 0, 255, 0), "criterion 1: live TR quadrant green");
+ check(is_color(bl, 0, 0, 255), "criterion 1: live BL quadrant blue");
+ check(is_color(br, 255, 255, 255), "criterion 1: live BR quadrant white");
+
+ // ---- Criterion 2: rotateY(180) (a deterministic endpoint of the 3D
+ // transform+transition) mirrors X about the 50% origin: TL red -> TOP-
+ // RIGHT, TR green -> TOP-LEFT. Reading the swapped corners proves the
+ // LIVE pixels rendered THROUGH the RCSS 3D transform. ----
+ doc->GetElementById("win")->SetProperty("transform", "rotateY(180deg)");
+ ctx->Update();
+ present.render(ctx);
+ std::uint8_t t_left[4], t_right[4];
+ present.pixel(40, 40, t_left);
+ present.pixel(W - 40, 40, t_right);
+ check(is_color(t_right, 255, 0, 0),
+ "criterion 2: rotateY(180) moved live TL-red to the TOP-RIGHT");
+ check(is_color(t_left, 0, 255, 0),
+ "criterion 2: rotateY(180) moved live TR-green to the TOP-LEFT");
+
+ // Mid-rotation under perspective must still SHOW the texture (alpha>0).
+ doc->GetElementById("win")->SetProperty("transform", "rotateY(60deg)");
+ ctx->Update();
+ present.render(ctx);
+ std::uint8_t center[4];
+ present.pixel(W / 2, W / 2, center);
+ check(center[3] > 0, "criterion 2: live texture visible under perspective rotateY(60deg)");
+
+ // Reset to the flat state for the idle-gate measurement.
+ doc->GetElementById("win")->SetProperty("transform", "rotateY(0deg)");
+ ctx->Update();
+ present.render(ctx);
+ }
+
+ // ---- Criterion 6 idle gate: with NO new commit, NO animation, NO input,
+ // OUR gate renders ZERO frames over N event-loop turns. The gate renders only
+ // when a dirty signal fires (client commit / active RCSS animation / input).
+ // ----
+ // The gate's animation signal is RmlUi's own GetNextUpdateDelay(): finite =>
+ // an animation needs the next frame; +inf => nothing is animating (idle). We
+ // gate on (our dirty) OR (animation pending), exactly the design's three
+ // dirty sources (client commit / RCSS animation / input).
+ auto anim_pending = [&]() -> bool {
+ ctx->Update();
+ return std::isfinite(ctx->GetNextUpdateDelay());
+ };
+ if (doc != nullptr) {
+ // Drain any settle frames so the document is fully at rest before we
+ // measure idle (a freshly-shown doc may request one more update).
+ for (int i = 0; i < 8; ++i) {
+ if (anim_pending()) {
+ present.render(ctx);
+ }
+ }
+ int idle_renders = 0;
+ bool dirty = false;
+ for (int turn = 0; turn < 120; ++turn) {
+ wl_event_loop_dispatch(loop, 0);
+ if (dirty || anim_pending()) {
+ present.render(ctx);
+ ++idle_renders;
+ dirty = false;
+ }
+ }
+ check(idle_renders == 0, "criterion 6: idle dirty-gate renders ZERO frames over 120 turns");
+
+ int gated_renders = 0;
+ dirty = true; // simulate a single client buffer commit
+ for (int turn = 0; turn < 10; ++turn) {
+ if (dirty || anim_pending()) {
+ present.render(ctx);
+ ++gated_renders;
+ dirty = false;
+ }
+ }
+ check(gated_renders == 1, "criterion 6: a single commit gates exactly ONE render");
+ }
+
+ // ---- Criterion 3 geometry: screen->surface-local inversion through the SAME
+ // transform RCSS applies (perspective(800) about the 50% origin, rotateY).
+ // Project a known surface-local point to its screen landing, invert, and
+ // confirm round-trip identity to sub-pixel — the math the runtime
+ // RmlUi-pick -> wl_seat translation rides on. ----
+ {
+ const double origin = W / 2.0;
+ const spike::Mat4 t = spike::rcss_transform_about_origin(
+ spike::mul(spike::perspective(800.0), spike::rotate_y(35.0 * M_PI / 180.0)), origin,
+ origin);
+ const double lx = 64.0, ly = 96.0;
+ const spike::ScreenPoint s = spike::project_to_screen(t, lx, ly);
+ const auto back = spike::unproject_to_local(t, s.x, s.y);
+ check(back.has_value(), "criterion 3: inversion solvable through perspective+rotateY");
+ if (back) {
+ const double err = std::hypot(back->x - lx, back->y - ly);
+ std::fprintf(stderr, "[verify] criterion 3 round-trip error = %.6f px\n", err);
+ check(err < 0.01, "criterion 3: screen->surface-local round-trip < 0.01px");
+ }
+ }
+
+ present.teardown();
+ live.destroy();
+ if (ctx != nullptr) {
+ Rml::RemoveContext("verify");
+ }
+ gl.restore_current();
+ gl.teardown();
+ if (client_buf != nullptr) {
+ wlr_buffer_drop(client_buf);
+ }
+ wlr_scene_node_destroy(&scene->tree.node);
+ wlr_allocator_destroy(allocator);
+ wlr_renderer_destroy(renderer);
+ wlr_backend_destroy(backend);
+ wl_display_destroy(display);
+ return 0;
+}
+
+// ---- Criteria 4 + 5: surface trees + wallpaper (per-subsurface elements) -----
+//
+// THE #1 unknown (criterion 4): a toplevel that owns a POPUP and a SUBSURFACE,
+// composited correctly. This prototypes the PER-SUBSURFACE-ELEMENT answer: every
+// node of the surface tree (toplevel, subsurface, popup) is its OWN RML <img>
+// sampling its OWN live shared texture, positioned in RCSS at its offset, with
+// document order giving the stack (parent first, child/popup above). The
+// alternative (per-window render-to-texture: flatten the whole tree to one
+// texture off-screen, sample that as ONE element) is ANALYSED in the report;
+// here we prove the per-subsurface path objectively by readback.
+//
+// Criterion 5 (wallpaper): a layer-shell client is just another surface element
+// behind the stage — imported through the SAME LiveTexture::adopt path as the
+// toplevel (criterion 1). We prove it by importing a full-output wallpaper
+// buffer the identical way and reading it back where the toplevel does not cover
+// it. "Mechanically identical to the toplevel path" is therefore shown, not
+// asserted by hand-wave.
+//
+// Layout (output W x W), all flat (no 3D) so readback geometry is deterministic
+// and each surface's screen footprint is exactly its element box:
+// wallpaper : full output, BLUE, behind everything
+// toplevel : (TLX,TLY) sized TW, RED
+// subsurface: offset (+SOFF,+SOFF) inside the toplevel, GREEN (occludes RED)
+// popup : at the toplevel's top-right, partly past it, WHITE (above all)
+auto run_verify_surface_trees() -> int {
+ setenv("WLR_BACKENDS", "headless", 1);
+ setenv("WLR_RENDERER", "gles2", 1);
+
+ wl_display* display = wl_display_create();
+ wlr_backend* backend = wlr_backend_autocreate(wl_display_get_event_loop(display), nullptr);
+ wlr_renderer* renderer = wlr_renderer_autocreate(backend);
+ wlr_allocator* allocator = wlr_allocator_autocreate(backend, renderer);
+ wlr_scene* scene = wlr_scene_create();
+
+ if (!wlr_renderer_is_gles2(renderer)) {
+ std::fprintf(stderr, "[verify] SKIP surface-tree: renderer is not gles2\n");
+ wlr_scene_node_destroy(&scene->tree.node);
+ wlr_allocator_destroy(allocator);
+ wlr_renderer_destroy(renderer);
+ wlr_backend_destroy(backend);
+ wl_display_destroy(display);
+ return 0;
+ }
+ EGLDisplay egl = wlr_egl_get_display(wlr_gles2_renderer_get_egl(renderer));
+
+ spike::GlBridge gl;
+ if (!gl.init(egl)) {
+ std::fprintf(stderr, "[verify] SKIP surface-tree: GL bridge unavailable\n");
+ wlr_scene_node_destroy(&scene->tree.node);
+ wlr_allocator_destroy(allocator);
+ wlr_renderer_destroy(renderer);
+ wlr_backend_destroy(backend);
+ wl_display_destroy(display);
+ return 0;
+ }
+ gl.make_current();
+
+ const int W = 512;
+ const int TLX = 128, TLY = 96, TW = 256, TH = 256; // toplevel box
+ const int SOFF = 48, SW = 96, SH = 96; // subsurface: inside toplevel
+ const int PW = 96, PH = 64; // popup: at toplevel top-right edge
+ const int PX = TLX + TW - 32, PY = TLY - 16; // hangs past the toplevel corner
+
+ // Four real client dmabufs, painted like a GPU client would.
+ wlr_buffer* wall_buf = make_solid_client_buffer(gl, renderer, allocator, W, W, 0, 0, 1); // blue
+ wlr_buffer* top_buf = make_solid_client_buffer(gl, renderer, allocator, TW, TH, 1, 0, 0); // red
+ wlr_buffer* sub_buf = make_solid_client_buffer(gl, renderer, allocator, SW, SH, 0, 1, 0); // green
+ wlr_buffer* pop_buf = make_solid_client_buffer(gl, renderer, allocator, PW, PH, 1, 1, 1); // white
+
+ spike::LiveTexture wall, top, sub, pop;
+ for (auto* t : {&wall, &top, &sub, &pop}) {
+ t->gl = &gl;
+ }
+ wall.uri = "unbox-live://wall";
+ top.uri = "unbox-live://top";
+ sub.uri = "unbox-live://sub";
+ pop.uri = "unbox-live://pop";
+
+ bool zero_copy = true;
+ struct Pair {
+ spike::LiveTexture* t;
+ wlr_buffer* b;
+ };
+ for (const Pair& p : {Pair{&wall, wall_buf}, Pair{&top, top_buf}, Pair{&sub, sub_buf},
+ Pair{&pop, pop_buf}}) {
+ const bool ok = p.b != nullptr && p.t->adopt(p.b, /*seq=*/1);
+ zero_copy = zero_copy && ok && p.t->is_dmabuf;
+ }
+ check(zero_copy, "criterion 4/5: tree (toplevel+subsurface+popup) + wallpaper imported zero-copy");
+
+ // ONE document, FOUR surface elements (per-subsurface answer): wallpaper
+ // first (behind), then the toplevel, then its subsurface, then the popup —
+ // document order is the composite stack. Each <img> samples its own live
+ // texture and is positioned in RCSS at its surface-tree offset.
+ char rml[2048];
+ std::snprintf(rml, sizeof(rml),
+ "<rml><head><style>"
+ "body { margin:0px; padding:0px; width:%dpx; height:%dpx; }"
+ ".s { display:block; position:absolute; }"
+ ".s img { display:block; width:100%%; height:100%%; }"
+ "</style></head><body>"
+ "<div class=s id=wall style='left:0;top:0;width:%dpx;height:%dpx;'>"
+ "<img src='%s'/></div>"
+ "<div class=s id=top style='left:%dpx;top:%dpx;width:%dpx;height:%dpx;'>"
+ "<img src='%s'/></div>"
+ "<div class=s id=sub style='left:%dpx;top:%dpx;width:%dpx;height:%dpx;'>"
+ "<img src='%s'/></div>"
+ "<div class=s id=pop style='left:%dpx;top:%dpx;width:%dpx;height:%dpx;'>"
+ "<img src='%s'/></div>"
+ "</body></rml>",
+ W, W, W, W, wall.uri.c_str(), TLX, TLY, TW, TH, top.uri.c_str(), TLX + SOFF,
+ TLY + SOFF, SW, SH, sub.uri.c_str(), PX, PY, PW, PH, pop.uri.c_str());
+
+ Rml::Context* ctx = Rml::CreateContext("vtree", Rml::Vector2i(W, W), gl.render);
+ Rml::ElementDocument* doc = (ctx != nullptr) ? ctx->LoadDocumentFromMemory(rml) : nullptr;
+ check(doc != nullptr, "criterion 4: surface-tree document loaded");
+ if (doc != nullptr) {
+ doc->Show();
+ }
+
+ spike::PresentTarget present;
+ const bool present_ok = present.init(&gl, allocator, W, W);
+ present.scene_buffer = wlr_scene_buffer_create(&scene->tree, nullptr);
+ check(present_ok, "criterion 4/5: present target for the tree built");
+
+ auto is_color = [](const std::uint8_t p[4], int r, int g, int b) {
+ return std::abs(int(p[0]) - r) < 60 && std::abs(int(p[1]) - g) < 60 &&
+ std::abs(int(p[2]) - b) < 60;
+ };
+
+ if (doc != nullptr && present_ok) {
+ present.render(ctx);
+ std::uint8_t px[4];
+
+ // Wallpaper shows in a corner no other surface covers (criterion 5).
+ present.pixel(16, 16, px);
+ check(is_color(px, 0, 0, 255), "criterion 5: wallpaper (layer surface) visible behind all");
+
+ // Toplevel RED shows where neither subsurface nor popup covers it: a spot
+ // inside the toplevel but outside the (TLX+SOFF..+SW) subsurface box.
+ present.pixel(TLX + 16, TLY + TH - 16, px);
+ check(is_color(px, 255, 0, 0), "criterion 4: toplevel surface composited over wallpaper");
+
+ // Subsurface GREEN occludes the toplevel at its offset box centre
+ // (per-subsurface element drawn ABOVE its parent by document order).
+ present.pixel(TLX + SOFF + SW / 2, TLY + SOFF + SH / 2, px);
+ check(is_color(px, 0, 255, 0),
+ "criterion 4: subsurface element occludes the toplevel at its offset");
+
+ // Popup WHITE at its own box centre — drawn above everything, and where it
+ // hangs PAST the toplevel it sits directly on the wallpaper (proves popups
+ // are not clipped to the parent element).
+ present.pixel(PX + PW / 2, PY + PH / 2, px);
+ check(is_color(px, 255, 255, 255), "criterion 4: popup element composited above the tree");
+
+ // Stacking integrity: the popup's TOP edge (above the toplevel's top) is
+ // popup-white over wallpaper-blue, NOT toplevel-red — order is correct.
+ present.pixel(PX + PW / 2, PY + 6, px);
+ check(is_color(px, 255, 255, 255),
+ "criterion 4: surface-tree stack order correct (popup top over wallpaper)");
+ }
+
+ present.teardown();
+ for (auto* t : {&wall, &top, &sub, &pop}) {
+ t->destroy();
+ }
+ if (ctx != nullptr) {
+ Rml::RemoveContext("vtree");
+ }
+ gl.restore_current();
+ gl.teardown();
+ for (wlr_buffer* b : {wall_buf, top_buf, sub_buf, pop_buf}) {
+ if (b != nullptr) {
+ wlr_buffer_drop(b);
+ }
+ }
+ wlr_scene_node_destroy(&scene->tree.node);
+ wlr_allocator_destroy(allocator);
+ wlr_renderer_destroy(renderer);
+ wlr_backend_destroy(backend);
+ wl_display_destroy(display);
+ return 0;
+}
+
+} // namespace
+
+// The real-seat run mode lives in rml_compositing_spike_run.cpp (its own TU).
+// `demo` selects the curated 4-window perf-load scenario (3 foot + 1 firefox,
+// one per inward-angled corner, with a live FPS HUD + per-5s min/max fps log and
+// a 120s default dead-man) over the plain single-client `--run`.
+auto run_real_seat(const char* startup_cmd, bool demo) -> int;
+
+int main(int argc, char** argv) {
+ const char* mode = (argc > 1) ? argv[1] : "--verify";
+ if (std::strcmp(mode, "--verify") == 0) {
+ // Two independent compositor bring-ups (each its own display/renderer/GL
+ // bridge) so one cannot corrupt the other's GL/RmlUi global state: first
+ // the live-texture/3D/input/idle/present criteria (1,2,3,6,7), then the
+ // surface-tree + wallpaper criteria (4,5). g_fail accumulates across both;
+ // ALL PASS is printed once for the whole run.
+ run_verify();
+ run_verify_surface_trees();
+ std::fprintf(stderr, "\n[verify] %s (%d failures)\n",
+ g_fail == 0 ? "ALL PASS" : "FAILURES", g_fail);
+ return g_fail == 0 ? 0 : 1;
+ }
+ if (std::strcmp(mode, "--run") == 0) {
+ const char* cmd = (argc > 2) ? argv[2] : "foot";
+ return run_real_seat(cmd, /*demo=*/false);
+ }
+ if (std::strcmp(mode, "--demo") == 0) {
+ // The curated perf scenario spawns its OWN fixed client set (3 foot + 1
+ // firefox), so no startup-cmd is taken.
+ return run_real_seat(nullptr, /*demo=*/true);
+ }
+ std::fprintf(stderr,
+ "usage: %s [--verify | --run [startup-cmd] | --demo]\n"
+ " --verify headless self-check of criteria 1,2,3,4,5,6,7 (exit 0 = pass)\n"
+ " --run real/nested seat: spawn a client, composite it as a 3D\n"
+ " surface element, route input back, print perf/idle metrics\n"
+ " --demo real-seat perf load: 4 windows (3 foot + 1 firefox), one per\n"
+ " screen corner angled INWARD, a live FPS HUD + per-5s min/max\n"
+ " fps log; 120s default dead-man (P resets, Esc quits)\n",
+ argv[0]);
+ return 2;
+}
diff --git a/packages/kernel/src/spike/rml_compositing_spike_run.cpp b/packages/kernel/src/spike/rml_compositing_spike_run.cpp
new file mode 100644
index 0000000..189582a
--- /dev/null
+++ b/packages/kernel/src/spike/rml_compositing_spike_run.cpp
@@ -0,0 +1,2127 @@
+// SPIKE (rml-compositing, Phase 0) — the REAL-SEAT run mode (--run). THROWAWAY.
+//
+// A minimal but real compositor (display/backend/renderer/allocator/scene/seat +
+// xdg-shell + layer-shell) that maps real clients and composites EACH client
+// surface as a LIVE SURFACE ELEMENT inside ONE RmlUi document: every mapped
+// surface (toplevel, popup, subsurface, layer/wallpaper) becomes an <img>
+// sampling that surface's live shared texture, laid out + 3D-transformed in
+// RCSS. The composited RmlUi FBO is presented through a single full-output
+// wlr_scene_buffer (criterion 7); the wlr cursor stays a hardware plane.
+//
+// Input is routed BACK to clients: pointer/touch are fed to the RmlUi context,
+// RmlUi's transform-aware pick finds the surface element + element-local coords
+// under the point, and the spike translates that to wl_seat surface-local
+// notifies so the client receives the event AT THE CORRECT point through the 3D
+// transform. Keyboard goes to the focused client.
+//
+// Per-frame render time, re-import counts, and idle confirmation are printed so
+// the user can do the visual/touch/perf GO-NO-GO on the CF-AX3. This is the
+// orchestrator-runnable artifact; YOU (the agent) self-verify the geometry +
+// present + idle headless in --verify.
+//
+// wlroots only via the kernel's wrapper; every wl_listener is the RAII Listener.
+
+#include <unbox/kernel/listener.hpp>
+#include <unbox/kernel/wlr.hpp>
+
+#include "spike_gl.hpp"
+#include "spike_input_core.hpp"
+
+#include "../vt_core.hpp" // VT-switch decision core, mirrored from input.cpp
+
+#include <RmlUi/Core/Context.h>
+#include <RmlUi/Core/Core.h>
+#include <RmlUi/Core/Element.h>
+#include <RmlUi/Core/ElementDocument.h>
+#include <RmlUi/Core/Factory.h>
+
+#include <algorithm>
+#include <cmath>
+#include <cstdarg>
+#include <cstdint>
+#include <cstdio>
+#include <cstdlib>
+#include <cstring>
+#include <ctime>
+#include <list>
+#include <memory>
+#include <string>
+#include <vector>
+
+extern "C" {
+#include <xkbcommon/xkbcommon.h>
+}
+
+#include <signal.h>
+#include <unistd.h>
+
+using unbox::kernel::Listener;
+namespace spike = unbox::kernel::spike;
+
+namespace {
+
+// ---- Verbose, crash-survivable diagnostic log --------------------------------
+//
+// The field failure was a BLACK SCREEN that forced a hard reboot — and the log
+// was on /tmp (tmpfs), so the reboot WIPED it. Now the log goes to a PERSISTENT
+// path that survives a reboot: $UNBOX_SPIKE_LOG if set, else $HOME/rml-spike.log
+// (NEVER /tmp). Every interesting step (backend/renderer pick, output modeset,
+// each commit heartbeat, client spawn/exec, EACH client connect, EACH surface
+// map/unmap, EACH live-texture import, scene insertion) is logged to BOTH stderr
+// AND that file. We FLUSH **and fsync()** after every line so a hard reboot (or
+// a freeze followed by a power-cycle) still preserves the log up to the freeze
+// point. Single-threaded event loop ⇒ no locking.
+FILE* g_log = nullptr;
+std::string g_log_path;
+
+void log_open() {
+ if (const char* env = getenv("UNBOX_SPIKE_LOG"); env != nullptr && env[0] != '\0') {
+ g_log_path = env;
+ } else if (const char* home = getenv("HOME"); home != nullptr && home[0] != '\0') {
+ g_log_path = std::string(home) + "/rml-spike.log";
+ } else {
+ // Last resort only (no HOME): the cwd, still NOT tmpfs by default.
+ g_log_path = "rml-spike.log";
+ }
+ g_log = std::fopen(g_log_path.c_str(), "w"); // truncate on start
+}
+void log_close() {
+ if (g_log != nullptr) {
+ std::fflush(g_log);
+ ::fsync(::fileno(g_log));
+ std::fclose(g_log);
+ g_log = nullptr;
+ }
+}
+[[gnu::format(printf, 1, 2)]] void slog(const char* fmt, ...) {
+ char buf[1024];
+ va_list ap;
+ va_start(ap, fmt);
+ std::vsnprintf(buf, sizeof(buf), fmt, ap);
+ va_end(ap);
+ const double t = spike::now_sec();
+ std::fprintf(stderr, "[%.3f] %s\n", t, buf);
+ if (g_log != nullptr) {
+ std::fprintf(g_log, "[%.3f] %s\n", t, buf);
+ std::fflush(g_log); // push out of stdio's buffer...
+ ::fsync(::fileno(g_log)); // ...AND down to disk: a hard reboot keeps the tail
+ }
+}
+
+// --- --demo dedicated FPS log -------------------------------------------------
+//
+// Separate from the diagnostic $HOME/rml-spike.log: a clean, append-only record
+// of the per-5s compositor-FPS min/max so the user can chart FPS over a long
+// video watch. Path: $UNBOX_SPIKE_FPS_LOG if set, else $HOME/rml-spike-fps.log
+// (NEVER /tmp). fflush + fsync per line so it survives a reboot mid-run.
+struct Runner; // fwd
+
+FILE* fps_log_open(std::string& out_path) {
+ if (const char* env = getenv("UNBOX_SPIKE_FPS_LOG"); env != nullptr && env[0] != '\0') {
+ out_path = env;
+ } else if (const char* home = getenv("HOME"); home != nullptr && home[0] != '\0') {
+ out_path = std::string(home) + "/rml-spike-fps.log";
+ } else {
+ out_path = "rml-spike-fps.log";
+ }
+ return std::fopen(out_path.c_str(), "w"); // truncate at start of each demo run
+}
+
+// One keyboard device. MIRRORS the shipped kernel's src/input.cpp: every
+// keyboard from the seat gets its OWN key + modifiers + destroy listeners, held
+// in a list (NOT a single shared pointer that the last device clobbers). On a
+// real DRM seat there can be several keyboard devices; the escape hatch must
+// fire on a key from ANY of them, so EACH needs its own live key listener.
+struct Keyboard {
+ Runner* runner = nullptr;
+ wlr_keyboard* keyboard = nullptr;
+ Listener key_l, mods_l, destroy_l;
+};
+
+// One live client surface presented as a surface element. Backed by a wlr
+// xdg-toplevel (the spike maps exactly one toplevel + its popups/subsurfaces and
+// one layer/wallpaper for the criteria; more would be the same loop). Holds the
+// LiveTexture (the shared-texture import) and the document <img> element id.
+struct LiveSurface {
+ Runner* runner = nullptr;
+ wlr_surface* surface = nullptr; // the wl_surface whose buffer we sample
+ wlr_xdg_surface* xdg = nullptr; // null for the layer surface
+ wlr_layer_surface_v1* layer = nullptr;
+ spike::LiveTexture live;
+ std::string element_id; // the <img>'s RML id
+ int x = 0, y = 0; // layout position of the element
+ int w = 0, h = 0;
+ bool mapped = false;
+ bool is_wallpaper = false;
+ bool transform3d = false; // toplevel gets the 3D tilt; wallpaper flat
+ int corner = -1; // --demo: corner slot (0..3) this surface owns, -1 if none
+
+ Listener map_l, unmap_l, commit_l, destroy_l;
+};
+
+// --- --demo corner geometry --------------------------------------------------
+//
+// Four windows, one per screen corner, angled INWARD so the panels read as the
+// inside of a box: each panel's OUTER edges recede toward the screen centre. We
+// tilt about the panel's own 50%/50% origin under the document's perspective.
+// rotateX(+A): top edge recedes (back), bottom comes forward -> for TOP rows
+// rotateX(-A): bottom edge recedes -> for BOTTOM rows
+// rotateY(-A): left edge recedes -> for LEFT cols
+// rotateY(+A): right edge recedes -> for RIGHT cols
+// So TL faces down-right, TR down-left, BL up-right, BR up-left.
+struct CornerSpec {
+ const char* name;
+ bool right; // column: false=left, true=right
+ bool bottom; // row: false=top, true=bottom
+ double rot_x; // degrees
+ double rot_y; // degrees
+};
+// Inward tilt angle (degrees). Tasteful: steep enough to clearly read as a box
+// interior, shallow enough to keep the client text legible on the panel.
+constexpr double kCornerTilt = 20.0;
+constexpr CornerSpec kCorners[4] = {
+ {"top-left", false, false, +kCornerTilt, -kCornerTilt}, // faces down-right
+ {"top-right", true, false, +kCornerTilt, +kCornerTilt}, // faces down-left
+ {"bottom-left", false, true, -kCornerTilt, -kCornerTilt}, // faces up-right
+ {"bottom-right", true, true, -kCornerTilt, +kCornerTilt}, // faces up-left
+};
+
+struct Runner {
+ wl_display* display = nullptr;
+ wl_event_loop* loop = nullptr;
+ wlr_backend* backend = nullptr;
+ wlr_session* session = nullptr;
+ wlr_renderer* renderer = nullptr;
+ wlr_allocator* allocator = nullptr;
+ wlr_scene* scene = nullptr;
+ wlr_output_layout* output_layout = nullptr;
+ wlr_scene_output_layout* scene_layout = nullptr;
+ wlr_output* output = nullptr;
+ wlr_scene_output* scene_output = nullptr;
+ wlr_compositor* compositor = nullptr;
+ wlr_seat* seat = nullptr;
+ wlr_cursor* cursor = nullptr;
+ wlr_xcursor_manager* cursor_mgr = nullptr;
+ wlr_xdg_shell* xdg_shell = nullptr;
+ wlr_layer_shell_v1* layer_shell = nullptr;
+ // Per-device keyboards (mirrors input.cpp's `keyboards` list). `focus_kb` is
+ // the one currently driving wlr_seat focus (the last to send a key), used to
+ // hand a newly-mapped toplevel keyboard focus. Never dereferenced for input
+ // routing — each device's own listener carries its own wlr_keyboard.
+ std::list<Keyboard> keyboards;
+ wlr_keyboard* focus_kb = nullptr;
+ // The toplevel wl_surface that currently holds keyboard focus on the seat
+ // (set via wlr_seat_keyboard_notify_enter). Tracking it lets us (a) re-assert
+ // focus idempotently if a key arrives before any enter landed (device/map
+ // ordering on a real DRM seat is not guaranteed), and (b) focus a toplevel
+ // that mapped BEFORE the first keyboard device appeared. Mirrors the shipped
+ // ext-xdg-shell discipline of holding the focused surface and re-entering.
+ wlr_surface* focused_surface = nullptr;
+ // The last toplevel that mapped — the focus candidate. Kept so a keyboard
+ // that is hot-plugged AFTER the toplevel mapped can still be handed focus.
+ wlr_surface* last_toplevel = nullptr;
+ // Per-key forward instrumentation: keys forwarded to the focused client so
+ // the log proves typing is reaching foot even though the agent cannot see it.
+ long keys_forwarded = 0;
+ // Cursor liveness: true once we have shown a default xcursor image on the
+ // wlr cursor plane. The cursor stays a wlr plane (NEVER drawn into RmlUi) per
+ // the plan; we just make sure it HAS an image so it is visible.
+ bool cursor_shown = false;
+ // Pointer/touch routing instrumentation (counts, not per-event spam after the
+ // first few): so the log shows events ARE landing on a client surface.
+ long pointer_enters = 0;
+ long pointer_motions = 0;
+ long pointer_misses = 0;
+ long touch_downs = 0;
+ // LIVE-UPDATE LOOP instrumentation (the field "stuck on a single frame" fix).
+ // The client lives as an imported texture (NOT a wlr_scene surface node), so
+ // the spike must DRIVE the client update loop itself: (a) send frame-done to
+ // every mapped client surface + its subsurfaces/popups each composited frame
+ // (without it the client draws ONCE and waits forever -> stuck frame), and
+ // (b) re-import the surface's CURRENT buffer on each commit (the buffer ptr
+ // changes per frame). These counters prove the loop is alive in the log.
+ long client_commits = 0; // per-surface wl_surface.commit count (all surfaces)
+ long live_reimports = 0; // live-texture re-imports (a real new buffer adopted)
+ long frame_done_sends = 0; // wlr_surface_send_frame_done calls (tree-walked)
+ double last_loop_report = 0.0;
+ // A fixed ~60Hz event-loop timer drives the composite/present clock
+ // independently of output `frame` damage semantics (which stall a static
+ // nested/DRM output and would freeze client progress). The dirty-gate still
+ // decides render-vs-skip; this only keeps the clock alive for the GO/NO-GO.
+ wl_event_source* tick = nullptr;
+
+ // SAFETY (criterion A): the guaranteed backstops that make a real DRM seat
+ // un-lockable. A self-timeout timer terminates the loop after N seconds; two
+ // signal sources turn SIGINT/SIGTERM into a CLEAN wl_display_terminate (so
+ // wlroots restores the VT to text mode — no hard reboot). VT switching +
+ // quit keys are handled inline in the keyboard handler, BEFORE any forward.
+ wl_event_source* safety_timer = nullptr;
+ wl_event_source* sigint_src = nullptr;
+ wl_event_source* sigterm_src = nullptr;
+ // The dead-man interval (ms). Pressing `P` re-arms safety_timer to this full
+ // interval — so holding the session open past the interval REQUIRES periodic
+ // P presses. That doubles as a real-seat keyboard-input liveness test: if the
+ // session survives past the interval, P is reaching the handler ⇒ input works.
+ int deadman_ms = 15000;
+
+ // A guaranteed-visible non-black background + test marker, composited UNDER
+ // the RmlUi present node as plain wlr_scene_rects. This makes "black screen"
+ // (nothing presenting at all) visibly different from "presenting but the
+ // RmlUi/client layer is empty" (dark blue + a marker square show through).
+ wlr_scene_rect* bg_rect = nullptr;
+ wlr_scene_rect* marker_rect = nullptr;
+
+ int out_w = 1920, out_h = 1080;
+
+ // --- --demo perf-load scenario state -------------------------------------
+ // The curated 4-window scenario: 3 foot + 1 firefox, one per inward-angled
+ // corner, with a live FPS HUD and a per-5s min/max fps log. `demo` gates all
+ // of it; plain `--run` is untouched.
+ bool demo = false;
+ // Corner occupancy: corner_taken[i] true once a client has claimed slot i.
+ // firefox is steered to kFirefoxCorner; foot fills the rest in order. Cleared
+ // on unmap/destroy so a slot frees cleanly (multiple clients now).
+ bool corner_taken[4] = {false, false, false, false};
+ // FPS HUD: a centered RmlUi text element showing the live compositor FPS,
+ // refreshed ~1/s. The compositor's rendered-frames-per-second == output
+ // frames presented (on_frame ticks), NOT client commits.
+ Rml::Element* hud_el = nullptr;
+ long demo_frames = 0; // total output frames since start (for FPS)
+ long fps_last_frames = 0; // demo_frames at the last HUD/bucket sample
+ double fps_last_sample = 0.0; // wall time of the last FPS sample
+ double last_fps = 0.0; // most recent computed FPS (shown on the HUD)
+ // Per-5s min/max bucket, written to the dedicated fps log.
+ FILE* fps_log = nullptr;
+ std::string fps_log_path;
+ double bucket_start = 0.0; // wall time the current 5s bucket opened
+ double bucket_min = 0.0; // lowest FPS sample seen this bucket
+ double bucket_max = 0.0; // highest FPS sample seen this bucket
+ long bucket_frames0 = 0; // demo_frames at the bucket open (for frames=N)
+ bool bucket_has = false; // a sample has landed in this bucket yet
+
+ // --- click-accuracy debug overlay (toggle 'D') ---------------------------
+ // Off by default. When on, route_point places a per-surface crosshair marker
+ // at the mapped hit point (rendered through the surface's own transform, so it
+ // overlays the wlr cursor iff the mapping is correct) and writes a numeric
+ // readout (screen -> projected element-local -> surface-local) to #dbg + log.
+ bool debug_overlay = false;
+ Rml::Element* dbg_el = nullptr; // the #dbg numeric readout box
+ double last_cursor_x = 0.0; // last screen cursor pos, to re-place on toggle
+ double last_cursor_y = 0.0;
+
+ spike::GlBridge gl;
+ spike::PresentTarget present;
+ Rml::Context* ctx = nullptr;
+ Rml::ElementDocument* doc = nullptr;
+ wlr_scene_buffer* present_node = nullptr;
+
+ std::list<LiveSurface> surfaces;
+
+ // The dirty gate (criterion 6): render a frame only when something changed.
+ bool dirty = true;
+ int next_id = 0;
+
+ // Perf instrumentation.
+ std::vector<double> frame_ms;
+ int frames_rendered = 0;
+ int frames_skipped_idle = 0;
+ double last_report = 0.0;
+
+ // Stage-0 per-phase budget accumulators (summed per rendered frame, averaged
+ // and reset in the ~1s [perf] report). `import` = client buffer re-import +
+ // element layout; the rest come from PresentTarget::render's RenderTimings.
+ // gpu_ms is summed only over frames that produced a timer-query result.
+ double sum_import_ms = 0.0;
+ double sum_clear_ms = 0.0;
+ double sum_update_ms = 0.0;
+ double sum_render_ms = 0.0;
+ double sum_present_ms = 0.0;
+ double sum_gpu_ms = 0.0;
+ int gpu_samples = 0;
+
+ // Commit/present heartbeat (criterion B): a count of output commits so the
+ // log shows the present loop is actually ticking even on a static scene.
+ long commits = 0;
+
+ // Client diagnosis: count connects, and a ~5s watchdog that screams if NO
+ // client surface ever maps (the "foot did not appear" field failure). Set
+ // true the first time ANY surface maps; the watchdog reads it.
+ int client_connects = 0;
+ bool any_surface_mapped = false;
+ wl_event_source* client_watchdog = nullptr;
+ wl_listener client_created_l{}; // raw: wl_display client-created is not a wlr signal
+ wl_global* compositor_global = nullptr;
+
+ // Server-level listeners.
+ Listener new_output_l, new_input_l, frame_l;
+ Listener new_toplevel_l, new_popup_l, new_layer_l;
+ Listener cursor_motion_l, cursor_motion_abs_l, cursor_button_l, cursor_axis_l, cursor_frame_l;
+ Listener touch_down_l, touch_up_l, touch_motion_l;
+ // Seat protocol glue (mirrors src/input.cpp::attach_seat_handlers): let a
+ // client set its own cursor over its surface, and restore the default
+ // xcursor when the pointer focus leaves all client surfaces.
+ Listener seat_request_cursor_l, seat_pointer_focus_change_l;
+
+ auto add_surface(wlr_surface* surf) -> LiveSurface* {
+ surfaces.emplace_back();
+ LiveSurface& s = surfaces.back();
+ s.runner = this;
+ s.surface = surf;
+ s.live.gl = &gl;
+ s.element_id = "surf_" + std::to_string(next_id++);
+ s.live.uri = "unbox-live://" + s.element_id;
+ return &s;
+ }
+
+ void remove_surface(LiveSurface* s) {
+ const bool cur = gl.make_current();
+ s->live.destroy();
+ if (cur) {
+ gl.restore_current();
+ }
+ // Remove the <img> element from the document.
+ if (doc != nullptr) {
+ if (Rml::Element* el = doc->GetElementById(s->element_id)) {
+ el->GetParentNode()->RemoveChild(el);
+ }
+ }
+ // --demo: free this client's corner slot on destroy (an unmap may not have
+ // fired before destroy on some teardown paths — release it here too).
+ if (demo && s->corner >= 0) {
+ corner_taken[s->corner] = false;
+ s->corner = -1;
+ }
+ surfaces.remove_if([s](const LiveSurface& e) { return &e == s; });
+ dirty = true;
+ }
+};
+
+// The base document: a perspective container + a flat wallpaper layer behind it.
+// Surface elements are inserted at runtime as <div class="win"><img.../></div>.
+const char* kRunRml = R"RML(<rml>
+<head>
+<style>
+body { margin: 0px; padding: 0px; perspective: 1400px; background: #0b0d14; }
+#wall { display: block; position: absolute; left: 0; top: 0; }
+#wall img { display: block; }
+#stage { display: block; position: absolute; left: 0; top: 0;
+ width: 100%; height: 100%; }
+.win { display: block; position: absolute;
+ transform: perspective(1400px) rotateY(-18deg);
+ transform-origin: 50% 50%;
+ transition: transform 0.25s cubic-in-out;
+ box-shadow: #000a 8px 8px 24px 0px; }
+.win img { display: block; width: 100%; height: 100%; }
+</style>
+</head>
+<body>
+<div id="wall"></div>
+<div id="stage"></div>
+</body>
+</rml>)RML";
+
+// The --demo document: same wallpaper + perspective stage, but a deeper
+// perspective so the four inward-angled corner panels read clearly as the inside
+// of a box, and a centered FPS HUD on top of everything. The corner panels carry
+// NO fixed transform in the stylesheet — each gets its own per-corner
+// perspective+rotateX/rotateY applied at map time (layout_corner_element). The
+// #hud is a small translucent square holding live FPS text, centered so it is
+// readable and not hidden behind the corner windows.
+const char* kDemoRml = R"RML(<rml>
+<head>
+<style>
+body { margin: 0px; padding: 0px; perspective: 1100px; background: #05070d; }
+#wall { display: block; position: absolute; left: 0; top: 0; }
+#wall img { display: block; }
+#stage { display: block; position: absolute; left: 0; top: 0;
+ width: 100%; height: 100%; }
+.corner { display: block; position: absolute;
+ transform-origin: 50% 50%;
+ box-shadow: #000c 6px 6px 28px 0px; }
+.corner img { display: block; width: 100%; height: 100%; }
+#hud { display: block; position: absolute;
+ width: 220px; height: 84px;
+ background: #000000c0; border: 2px #00e0ffd0;
+ border-radius: 10px;
+ text-align: center;
+ font-size: 22px; color: #00e0ff;
+ font-family: "Noto Sans"; }
+#hud p { display: block; margin: 8px 0px 0px 0px; }
+#hud .big { font-size: 34px; color: #ffffff; }
+/* Click-accuracy debug overlay (toggle with 'D'). #dbg is the numeric readout;
+ each surface gets a .xhair marker child drawn THROUGH its own transform at the
+ computed hit point — if the mapping is correct it sits exactly under the wlr
+ cursor, so any gap is the live click error, visible directly (no screenshots). */
+#dbg { display: none; position: absolute; left: 8px; top: 8px;
+ width: 760px; padding: 10px;
+ background: #000000d0; border: 2px #ff2e9a;
+ font-size: 16px; color: #ff66cc; font-family: "Noto Sans"; }
+#dbg p { display: block; margin: 0px 0px 4px 0px; }
+.xhair { display: block; position: absolute; width: 18px; height: 18px;
+ border: 2px #ff2e9aff; background: #ff2e9a44; }
+.xhair .dot { display: block; position: absolute; left: 7px; top: 7px;
+ width: 4px; height: 4px; background: #ffffffff; }
+</style>
+</head>
+<body>
+<div id="wall"></div>
+<div id="stage"></div>
+<div id="hud"><p>compositor FPS</p><p class="big">--</p></div>
+<div id="dbg"><p>click-debug: press D to toggle</p></div>
+</body>
+</rml>)RML";
+
+void layout_surface_element(Runner& r, LiveSurface& s) {
+ if (r.doc == nullptr || s.live.tex == 0) {
+ return;
+ }
+ Rml::Element* container = r.doc->GetElementById(s.is_wallpaper ? "wall" : "stage");
+ if (container == nullptr) {
+ return;
+ }
+ Rml::Element* win = r.doc->GetElementById(s.element_id);
+ if (win == nullptr) {
+ // Create <div class=win id=surf_N><img src=uri/></div> (wallpaper: bare img).
+ Rml::ElementPtr div = r.doc->CreateElement("div");
+ div->SetId(s.element_id);
+ if (!s.is_wallpaper) {
+ div->SetClass("win", true);
+ }
+ Rml::ElementPtr img = r.doc->CreateElement("img");
+ img->SetAttribute("src", s.live.uri);
+ div->AppendChild(std::move(img));
+ win = container->AppendChild(std::move(div));
+ }
+ if (win == nullptr) {
+ return;
+ }
+ win->SetProperty("position", "absolute");
+ win->SetProperty("left", std::to_string(s.x) + "px");
+ win->SetProperty("top", std::to_string(s.y) + "px");
+ win->SetProperty("width", std::to_string(s.w) + "px");
+ win->SetProperty("height", std::to_string(s.h) + "px");
+ if (Rml::Element* img = win->GetFirstChild()) {
+ img->SetProperty("width", std::to_string(s.w) + "px");
+ img->SetProperty("height", std::to_string(s.h) + "px");
+ }
+}
+
+// --demo: lay out a surface element into its assigned screen corner and apply
+// the inward-angled per-corner 3D transform. Each panel is sized to ~its corner
+// quadrant and positioned hard into that corner so its OUTER edges recede toward
+// the centre (rotateX/rotateY per kCorners). The client buffer is sampled into
+// the <img> exactly as layout_surface_element does; only the box + transform
+// differ. Built lazily once the element exists (texture imported).
+// Compute (deterministically, without needing the texture imported yet) the
+// corner panel's screen box: ~its quadrant minus a gutter + HUD clearance, hard
+// into its corner. Sets s.x/s.y/s.w/s.h. Safe to call at map time so the log +
+// early positioning are accurate before the first buffer import.
+void compute_corner_box(Runner& r, LiveSurface& s) {
+ if (s.corner < 0 || s.corner > 3) {
+ return;
+ }
+ const CornerSpec& c = kCorners[s.corner];
+ // A small inset so the panels don't bleed off the bezel and the angled outer
+ // edges stay on-screen. Each panel ~ its quadrant minus the gutter.
+ const int gutter = 28;
+ const int hud_pad = 56; // keep panels clear of the centered HUD square
+ const int pw = r.out_w / 2 - gutter - hud_pad;
+ const int ph = r.out_h / 2 - gutter - hud_pad;
+ s.w = pw > 64 ? pw : 64;
+ s.h = ph > 64 ? ph : 64;
+ s.x = c.right ? (r.out_w - s.w - gutter) : gutter;
+ s.y = c.bottom ? (r.out_h - s.h - gutter) : gutter;
+}
+
+void layout_corner_element(Runner& r, LiveSurface& s) {
+ if (r.doc == nullptr || s.live.tex == 0 || s.corner < 0 || s.corner > 3) {
+ return;
+ }
+ Rml::Element* stage = r.doc->GetElementById("stage");
+ if (stage == nullptr) {
+ return;
+ }
+ Rml::Element* win = r.doc->GetElementById(s.element_id);
+ if (win == nullptr) {
+ Rml::ElementPtr div = r.doc->CreateElement("div");
+ div->SetId(s.element_id);
+ div->SetClass("corner", true);
+ Rml::ElementPtr img = r.doc->CreateElement("img");
+ img->SetAttribute("src", s.live.uri);
+ div->AppendChild(std::move(img));
+ win = stage->AppendChild(std::move(div));
+ }
+ if (win == nullptr) {
+ return;
+ }
+ const CornerSpec& c = kCorners[s.corner];
+ // The corner box is derived in compute_corner_box (texture-independent), so it
+ // overrides any natural client size assigned in composite_frame: each panel is
+ // sized to ~its quadrant, NOT the client's own dimensions.
+ compute_corner_box(r, s);
+
+ win->SetProperty("position", "absolute");
+ win->SetProperty("left", std::to_string(s.x) + "px");
+ win->SetProperty("top", std::to_string(s.y) + "px");
+ win->SetProperty("width", std::to_string(s.w) + "px");
+ win->SetProperty("height", std::to_string(s.h) + "px");
+ // Inward tilt: perspective(...) rotateX(...) rotateY(...) about the panel's
+ // own 50%/50% origin. The outer edges recede; the panel faces the centre.
+ char xform[160];
+ std::snprintf(xform, sizeof(xform), "perspective(1100px) rotateX(%.1fdeg) rotateY(%.1fdeg)",
+ c.rot_x, c.rot_y);
+ win->SetProperty("transform", xform);
+ if (Rml::Element* img = win->GetFirstChild()) {
+ img->SetProperty("width", std::to_string(s.w) + "px");
+ img->SetProperty("height", std::to_string(s.h) + "px");
+ }
+}
+
+// --demo: claim the next free corner slot for a connecting client. firefox is
+// steered to its designated corner; foot fills the others in order. Returns the
+// slot index (0..3) or -1 if all four are taken (extra clients fall back to the
+// plain centered toplevel layout). Deterministic by slot order.
+constexpr int kFirefoxCorner = 0; // top-left gets firefox; foot takes 1,2,3
+auto claim_corner(Runner& r, bool is_firefox) -> int {
+ if (is_firefox) {
+ if (!r.corner_taken[kFirefoxCorner]) {
+ r.corner_taken[kFirefoxCorner] = true;
+ return kFirefoxCorner;
+ }
+ }
+ for (int i = 0; i < 4; ++i) {
+ if (i == kFirefoxCorner && !is_firefox) {
+ continue; // reserve the firefox corner for firefox until it's clearly absent
+ }
+ if (!r.corner_taken[i]) {
+ r.corner_taken[i] = true;
+ return i;
+ }
+ }
+ // All preferred slots taken — fall back to ANY free slot (e.g. firefox never
+ // connected and a 4th foot wants the reserved corner).
+ for (int i = 0; i < 4; ++i) {
+ if (!r.corner_taken[i]) {
+ r.corner_taken[i] = true;
+ return i;
+ }
+ }
+ return -1;
+}
+
+// Re-import every mapped surface's current buffer (zero re-import when unchanged)
+// and lay it out, then render+present. Returns the render time in ms (or -1 if
+// the frame was gated out).
+auto composite_frame(Runner& r, bool force) -> double {
+ if (!r.dirty && !force) {
+ ++r.frames_skipped_idle;
+ return -1.0;
+ }
+ r.dirty = false;
+ const double t0 = spike::now_sec();
+
+ const bool cur = r.gl.make_current();
+ const double t_import0 = spike::now_sec();
+ for (LiveSurface& s : r.surfaces) {
+ if (!s.mapped || s.surface == nullptr) {
+ continue;
+ }
+ wlr_buffer* buf = nullptr;
+ if (s.surface->buffer != nullptr) {
+ buf = &s.surface->buffer->base;
+ }
+ if (buf != nullptr) {
+ const int reimports_before = s.live.reimports;
+ // Gate the re-import on the surface's COMMIT SEQUENCE, not the buffer
+ // pointer: foot recycles a small buffer pool, so the SAME wlr_buffer
+ // pointer is re-committed with NEW contents. wlr_surface_state.seq
+ // advances on every commit regardless of pool reuse, so this re-imports
+ // the current buffer each new frame (the frozen-frame fix) while a
+ // static client (no commit => no seq change) still does zero work.
+ s.live.adopt(buf, s.surface->current.seq);
+ // Natural size from the surface's current state.
+ s.w = s.surface->current.width;
+ s.h = s.surface->current.height;
+ if (s.live.reimports != reimports_before) {
+ ++r.live_reimports;
+ slog("live-texture import: '%s' %dx%d dmabuf=%d tex=%u (reimport #%d)",
+ s.element_id.c_str(), s.live.width, s.live.height, s.live.is_dmabuf,
+ s.live.tex, s.live.reimports);
+ }
+ }
+ // --demo corner panels keep their corner box + inward tilt; everything
+ // else (plain --run, wallpaper) uses the centered/stage layout. The
+ // corner element overrides w/h to its quadrant, so DON'T let the natural
+ // size above clobber it — layout_corner_element re-derives the box.
+ if (r.demo && s.corner >= 0) {
+ layout_corner_element(r, s);
+ } else {
+ layout_surface_element(r, s);
+ }
+ }
+ const double t_import_ms = (spike::now_sec() - t_import0) * 1000.0;
+ spike::RenderTimings tm;
+ wlr_buffer* presented = r.present.render(r.ctx, &tm);
+ if (cur) {
+ r.gl.restore_current();
+ }
+ if (presented != nullptr && r.present_node != nullptr) {
+ wlr_scene_buffer_set_buffer(r.present_node, presented);
+ }
+
+ r.sum_import_ms += t_import_ms;
+ r.sum_clear_ms += tm.clear_ms;
+ r.sum_update_ms += tm.update_ms;
+ r.sum_render_ms += tm.render_ms;
+ r.sum_present_ms += tm.present_ms;
+ if (tm.gpu_ms >= 0.0) {
+ r.sum_gpu_ms += tm.gpu_ms;
+ ++r.gpu_samples;
+ }
+
+ const double dt_ms = (spike::now_sec() - t0) * 1000.0;
+ r.frame_ms.push_back(dt_ms);
+ ++r.frames_rendered;
+ return dt_ms;
+}
+
+// ---- The LIVE-UPDATE loop: frame callbacks to every client surface ----------
+//
+// THE STUCK-FRAME FIX. The shipped kernel's output frame handler (server.cpp)
+// ends each frame with wlr_scene_output_send_frame_done(scene_output, now),
+// which walks every SCENE surface node and completes its frame callbacks so the
+// client is told "now is a good time to draw your next frame". But in this spike
+// the client surfaces are NOT scene nodes — they live as imported live textures
+// inside RmlUi, and the scene holds only our background rects + the single
+// composited present buffer. So wlr_scene_output_send_frame_done NEVER reaches
+// foot: the client draws its first buffer, its frame callback is never completed,
+// and it waits forever -> the window is stuck on one frame (no typing/output/
+// cursor-blink). We must drive the callbacks ourselves.
+//
+// This walks EVERY mapped client surface tree (toplevel + its subsurfaces, each
+// popup + its subsurfaces, the wallpaper) — exactly what wlr_scene_output_send_
+// frame_done does for scene nodes — and calls wlr_surface_send_frame_done on
+// every mapped surface in the tree. wlr_surface_for_each_surface visits the
+// surface and all its subsurfaces (root -> leaves), so subsurface callbacks are
+// covered; xdg popups are tracked as their OWN LiveSurface (added in
+// new_popup), so their tree is walked here too. Mirrors the SHIPPED behaviour.
+void send_frame_done_to_clients(Runner& r) {
+ timespec now{};
+ clock_gettime(CLOCK_MONOTONIC, &now);
+ struct WalkData {
+ Runner* r;
+ timespec* now;
+ } wd{&r, &now};
+ for (LiveSurface& s : r.surfaces) {
+ if (!s.mapped || s.surface == nullptr) {
+ continue;
+ }
+ wlr_surface_for_each_surface(
+ s.surface,
+ [](wlr_surface* surf, int /*sx*/, int /*sy*/, void* data) {
+ auto* w = static_cast<WalkData*>(data);
+ wlr_surface_send_frame_done(surf, w->now);
+ ++w->r->frame_done_sends;
+ },
+ &wd);
+ }
+}
+
+// ---- --demo: live FPS HUD + per-5s min/max FPS log --------------------------
+//
+// Compositor FPS == output frames presented per second (on_frame ticks), NOT
+// client commits. Every output frame bumps demo_frames; ~once a second we sample
+// the rate (frames since the last sample / elapsed), push it onto the HUD text
+// element, and fold it into the current 5s min/max bucket. When a 5s bucket
+// closes we write one line to the dedicated fps log (fflush + fsync per line so
+// it survives a reboot mid-video). Called from on_frame; a no-op outside --demo.
+void demo_fps_tick(Runner& r) {
+ if (!r.demo) {
+ return;
+ }
+ ++r.demo_frames;
+ const double t = spike::now_sec();
+ const double dt = t - r.fps_last_sample;
+ if (dt < 1.0) {
+ return; // sample the rate ~1/sec, not every frame
+ }
+ const long dframes = r.demo_frames - r.fps_last_frames;
+ const double fps = dt > 0 ? static_cast<double>(dframes) / dt : 0.0;
+ r.last_fps = fps;
+ r.fps_last_frames = r.demo_frames;
+ r.fps_last_sample = t;
+
+ // Update the HUD text (the <p class="big"> number). The cursor stays a wlr
+ // plane; this is RmlUi text, re-rendered through the normal dirty-gate.
+ if (r.hud_el != nullptr) {
+ char hud[32];
+ std::snprintf(hud, sizeof(hud), "%.1f", fps);
+ r.hud_el->SetInnerRML(hud);
+ r.dirty = true; // the HUD text changed -> render it this frame
+ }
+
+ // Fold this sample into the current 5s bucket.
+ if (!r.bucket_has) {
+ r.bucket_min = fps;
+ r.bucket_max = fps;
+ r.bucket_has = true;
+ } else {
+ r.bucket_min = std::min(r.bucket_min, fps);
+ r.bucket_max = std::max(r.bucket_max, fps);
+ }
+
+ // Close the 5s bucket and write a line.
+ if (t - r.bucket_start >= 5.0) {
+ const long bframes = r.demo_frames - r.bucket_frames0;
+ if (r.fps_log != nullptr) {
+ std::fprintf(r.fps_log, "[%.1f] 5s bucket: min=%.1f max=%.1f fps (frames=%ld)\n",
+ t, r.bucket_has ? r.bucket_min : 0.0, r.bucket_has ? r.bucket_max : 0.0,
+ bframes);
+ std::fflush(r.fps_log);
+ ::fsync(::fileno(r.fps_log));
+ }
+ slog("[fps-bucket] 5s: min=%.1f max=%.1f fps (frames=%ld) — written to %s",
+ r.bucket_has ? r.bucket_min : 0.0, r.bucket_has ? r.bucket_max : 0.0, bframes,
+ r.fps_log_path.c_str());
+ r.bucket_start = t;
+ r.bucket_frames0 = r.demo_frames;
+ r.bucket_has = false;
+ }
+}
+
+// ---- Input: RmlUi pick -> surface-local -> wl_seat --------------------------
+//
+// Feed the screen point to the RmlUi context; RmlUi's transform-aware hover pick
+// resolves the element under it. If that element (or its parent) is a surface
+// element, map the picked element-local coords to surface-local and notify the
+// client. RmlUi reports the hovered element via GetHoverElement() after a move.
+
+auto surface_for_element(Runner& r, Rml::Element* el) -> LiveSurface* {
+ while (el != nullptr) {
+ const Rml::String id = el->GetId();
+ for (LiveSurface& s : r.surfaces) {
+ if (s.element_id == id) {
+ return &s;
+ }
+ }
+ el = el->GetParentNode();
+ }
+ return nullptr;
+}
+
+// Translate a screen point to a surface-local point on the hovered surface
+// element, using the element's own box + RmlUi's transform-aware projection. We
+// read the hovered element's absolute (already transform-resolved by RmlUi's
+// pick) offset and scale the live texture's natural size onto the element box.
+struct Routed {
+ LiveSurface* s = nullptr;
+ double sx = 0, sy = 0; // surface-local pixels
+};
+
+// Click-accuracy debug overlay: find-or-create the per-surface crosshair marker
+// as a child of the surface element `win`, so RmlUi draws it THROUGH win's own
+// transform — i.e. at the on-screen forward-projection of the mapped point.
+auto find_or_make_xhair(Runner& r, LiveSurface& s, Rml::Element* win) -> Rml::Element* {
+ const Rml::String xid = s.element_id + "_xhair";
+ Rml::Element* x = r.doc->GetElementById(xid);
+ if (x == nullptr) {
+ Rml::ElementPtr xp = r.doc->CreateElement("div");
+ xp->SetId(xid);
+ xp->SetClass("xhair", true);
+ Rml::ElementPtr dot = r.doc->CreateElement("div");
+ dot->SetClass("dot", true);
+ xp->AppendChild(std::move(dot));
+ x = win->AppendChild(std::move(xp));
+ }
+ return x;
+}
+
+// Place the crosshair at the mapped local point (win-local space) and update the
+// #dbg readout. The marker rides win's transform, so if route_point's inverse is
+// consistent with RmlUi's forward render it lands exactly under the wlr cursor;
+// any visible gap is the live click error. Also logged so numbers reach the file.
+void update_debug_marker(Runner& r, LiveSurface& s, Rml::Element* win, const Rml::Vector2f& doc_pt,
+ double sx, double sy, double screen_x, double screen_y) {
+ if (win == nullptr) {
+ return;
+ }
+ Rml::Element* x = find_or_make_xhair(r, s, win);
+ const Rml::Vector2f pad = win->GetAbsoluteOffset(Rml::BoxArea::Padding);
+ x->SetProperty("left", std::to_string(doc_pt.x - pad.x - 9.0) + "px");
+ x->SetProperty("top", std::to_string(doc_pt.y - pad.y - 9.0) + "px");
+ x->SetProperty("display", "block");
+ if (r.dbg_el != nullptr) {
+ char buf[320];
+ std::snprintf(buf, sizeof(buf),
+ "<p>DEBUG (D toggles) — marker should sit UNDER the cursor</p>"
+ "<p>surface '%s' tex %dx%d</p>"
+ "<p>screen (%.1f, %.1f) -&gt; elem-local (%.1f, %.1f)</p>"
+ "<p>surface-local (%.1f, %.1f)</p>",
+ s.element_id.c_str(), s.live.width, s.live.height, screen_x, screen_y, doc_pt.x,
+ doc_pt.y, sx, sy);
+ r.dbg_el->SetInnerRML(buf);
+ r.dbg_el->SetProperty("display", "block");
+ }
+ r.dirty = true;
+}
+
+// Hide every surface's crosshair + the readout (debug toggled off, or a miss).
+void hide_debug_markers(Runner& r) {
+ for (LiveSurface& s : r.surfaces) {
+ if (Rml::Element* x = r.doc->GetElementById(s.element_id + "_xhair")) {
+ x->SetProperty("display", "none");
+ }
+ }
+ if (r.dbg_el != nullptr) {
+ r.dbg_el->SetProperty("display", "none");
+ }
+ r.dirty = true;
+}
+
+auto route_point(Runner& r, double screen_x, double screen_y) -> Routed {
+ r.last_cursor_x = screen_x;
+ r.last_cursor_y = screen_y;
+ r.ctx->ProcessMouseMove(static_cast<int>(screen_x), static_cast<int>(screen_y), 0);
+ Rml::Element* hover = r.ctx->GetHoverElement();
+ LiveSurface* s = surface_for_element(r, hover);
+ if (s == nullptr) {
+ if (r.debug_overlay && r.dbg_el != nullptr) {
+ char buf[160];
+ std::snprintf(buf, sizeof(buf),
+ "<p>DEBUG (D toggles)</p><p>no surface under (%.1f, %.1f)</p>", screen_x,
+ screen_y);
+ r.dbg_el->SetInnerRML(buf);
+ r.dbg_el->SetProperty("display", "block");
+ }
+ return {};
+ }
+ // The surface element `win` carries the per-corner transform; its <img> child
+ // carries the texture box. Map the screen point into the img content box.
+ Rml::Element* win = r.doc->GetElementById(s->element_id);
+ Rml::Element* img = (win != nullptr && win->GetFirstChild() != nullptr) ? win->GetFirstChild()
+ : win;
+ if (img == nullptr) {
+ return {};
+ }
+ // THE FIX: project the screen point onto the element's OWN (possibly
+ // 3D-transformed) plane FIRST, using the element's accumulated transform.
+ // Element::Project() ray-casts the window point through the inverse transform
+ // onto the element's z=0 plane and returns it in the element's UNTRANSFORMED
+ // document space — the same space as GetAbsoluteOffset below. For an
+ // untransformed element it is a no-op (returns the point unchanged), so the
+ // plain --run path is unaffected. Previously we fed the RAW screen point into
+ // the box math while `off` was in untransformed layout space: the two spaces
+ // coincide only for an axis-aligned window, so on a tilted --demo corner the
+ // click landed in the wrong place. Project() is the live-path analogue of the
+ // pure-core unproject_to_local that criterion 3 verifies.
+ Rml::Vector2f p(static_cast<float>(screen_x), static_cast<float>(screen_y));
+ if (!img->Project(p)) {
+ return {}; // edge-on view: ray parallel to the element plane, no valid hit
+ }
+ const Rml::Vector2f off = img->GetAbsoluteOffset(Rml::BoxArea::Content);
+ const float bw = img->GetClientWidth();
+ const float bh = img->GetClientHeight();
+ if (bw <= 0 || bh <= 0) {
+ return {};
+ }
+ const double fx = (p.x - off.x) / bw; // 0..1 across the element box (post-projection)
+ const double fy = (p.y - off.y) / bh;
+ Routed out;
+ out.s = s;
+ out.sx = std::clamp(fx, 0.0, 1.0) * s->live.width;
+ out.sy = std::clamp(fy, 0.0, 1.0) * s->live.height;
+ if (r.debug_overlay) {
+ update_debug_marker(r, *s, win, p, out.sx, out.sy, screen_x, screen_y);
+ }
+ return out;
+}
+
+// Make the wlr cursor VISIBLE by giving its plane a default xcursor image. The
+// cursor stays a wlr plane (hardware/output cursor), NEVER drawn into RmlUi —
+// exactly as the plan requires. Called once a pointer/touch device exists and
+// re-asserted whenever the pointer is not over a client surface (a client may
+// have set its own cursor surface; when it leaves we restore the default).
+// MIRRORS the shipped kernel (input.cpp seat_pointer_focus_change -> "default").
+void show_default_cursor(Runner& r) {
+ if (r.cursor == nullptr || r.cursor_mgr == nullptr) {
+ return;
+ }
+ wlr_cursor_set_xcursor(r.cursor, r.cursor_mgr, "default");
+ if (!r.cursor_shown) {
+ r.cursor_shown = true;
+ slog("CURSOR shown: default xcursor set on the wlr cursor plane (visible, hardware plane)");
+ }
+}
+
+void notify_pointer_motion(Runner& r, double sx, double sy, std::uint32_t time, Routed& rt) {
+ if (rt.s == nullptr || rt.s->surface == nullptr) {
+ // No client surface under the cursor (over the document body / wallpaper
+ // gap / a tilt's empty corner): clear client pointer focus and make sure
+ // OUR default cursor is showing (the client can't have set one here).
+ wlr_seat_pointer_notify_clear_focus(r.seat);
+ show_default_cursor(r);
+ ++r.pointer_misses;
+ if (r.pointer_misses <= 4 || (r.pointer_misses % 240) == 0) {
+ slog("pointer motion: NO surface under point (%.0f,%.0f) — misses=%ld "
+ "(cursor over document/empty area; default cursor shown)",
+ sx, sy, r.pointer_misses);
+ }
+ return;
+ }
+ wlr_seat_pointer_notify_enter(r.seat, rt.s->surface, rt.sx, rt.sy);
+ wlr_seat_pointer_notify_motion(r.seat, time, rt.sx, rt.sy);
+ wlr_seat_pointer_notify_frame(r.seat);
+ ++r.pointer_motions;
+ if (r.pointer_motions <= 4 || (r.pointer_motions % 240) == 0) {
+ slog("pointer -> client surface '%s' at surface-local (%.1f,%.1f) [screen (%.0f,%.0f) "
+ "through the 3D transform] motions=%ld",
+ rt.s->element_id.c_str(), rt.sx, rt.sy, sx, sy, r.pointer_motions);
+ }
+}
+
+// ---- xdg-shell ---------------------------------------------------------------
+
+void on_surface_commit(Runner& r, LiveSurface& s) {
+ // A client buffer commit is THE dirty source (criterion 6): a new frame is
+ // scheduled only here (plus input/animation). This is ALSO the second half of
+ // the stuck-frame fix: the client's per-frame buffer POINTER changes on each
+ // commit, and LiveTexture::adopt early-returns when the buffer is unchanged —
+ // so a real new buffer must be RE-IMPORTED. We mark the scene dirty so the
+ // dirty-gate actually renders the updated texture this frame (composite_frame
+ // re-adopts s.surface->buffer for every mapped surface). Marking dirty here
+ // is what stops the idle gate from suppressing a REAL client update: a static
+ // client (no commits) => no extra renders; an updating client (a commit per
+ // frame) => one render per committed frame. Covers the whole surface tree:
+ // the toplevel commit AND each subsurface/popup commit (each its own
+ // LiveSurface with its own commit listener) both land here.
+ ++r.client_commits;
+ r.dirty = true;
+ if (r.output != nullptr) {
+ wlr_output_schedule_frame(r.output);
+ }
+ (void)s; // re-import happens in composite_frame (re-adopts s.surface->buffer)
+}
+
+// Give `surface` keyboard focus on the seat: set the active keyboard (so the
+// client receives the keymap) and send the enter with the keyboard's current
+// pressed keys + modifiers. Idempotent — calling it again for the already-
+// focused surface is harmless and just re-asserts. MIRRORS input.cpp's
+// wlr_seat_set_keyboard discipline + the ext-xdg-shell notify_enter on focus.
+//
+// CRITICAL FIX (real-seat "cannot type into foot"): the previous code only
+// entered focus at MAP and ONLY if a keyboard already existed (r.focus_kb !=
+// nullptr). On a real DRM seat the keyboard device and the client map can land
+// in EITHER order, and wlr_seat_set_keyboard had not necessarily run for the
+// keyboard that ends up sending keys — so the client never got an `enter` and
+// every wlr_seat_keyboard_notify_key fell on a surface with no keyboard focus.
+// Routing it through this helper, called on map AND on keyboard-add AND lazily
+// on the first key, guarantees the focused client actually receives keys.
+void focus_toplevel(Runner& r, wlr_surface* surface) {
+ if (surface == nullptr) {
+ return;
+ }
+ // Need a keyboard set on the seat so the enter ships the keymap. Prefer the
+ // last device that drove the seat; else any device we have; else bail (we
+ // will retry from new_keyboard once a device exists).
+ wlr_keyboard* kb = r.focus_kb;
+ if (kb == nullptr && !r.keyboards.empty()) {
+ kb = r.keyboards.back().keyboard;
+ }
+ if (kb == nullptr) {
+ slog("focus deferred: toplevel mapped but NO keyboard device yet "
+ "(will enter on keyboard-add)");
+ return;
+ }
+ wlr_seat_set_keyboard(r.seat, kb);
+ wlr_seat_keyboard_notify_enter(r.seat, surface, kb->keycodes, kb->num_keycodes,
+ &kb->modifiers);
+ r.focused_surface = surface;
+ slog("KEYBOARD FOCUS ENTER -> client surface %p (kb='%s') — keys now route to this client",
+ static_cast<void*>(surface), kb->base.name != nullptr ? kb->base.name : "?");
+}
+
+// True if this toplevel's app_id looks like the browser (so --demo steers it to
+// its designated corner). Vivaldi (Chromium) under Wayland reports an app_id like
+// "vivaldi-stable"; also match chromium/firefox so any browser lands right.
+auto toplevel_is_firefox(LiveSurface& s) -> bool {
+ if (s.xdg == nullptr || s.xdg->toplevel == nullptr || s.xdg->toplevel->app_id == nullptr) {
+ return false;
+ }
+ const std::string id = s.xdg->toplevel->app_id;
+ return id.find("vivaldi") != std::string::npos || id.find("Vivaldi") != std::string::npos ||
+ id.find("chromium") != std::string::npos || id.find("Chromium") != std::string::npos ||
+ id.find("firefox") != std::string::npos || id.find("Firefox") != std::string::npos ||
+ id.find("mozilla") != std::string::npos;
+}
+
+void on_xdg_map(Runner& r, LiveSurface& s) {
+ s.mapped = true;
+ if (s.xdg != nullptr && s.xdg->toplevel != nullptr) {
+ const wlr_box geo = s.xdg->geometry;
+ s.w = geo.width > 0 ? geo.width : 800;
+ s.h = geo.height > 0 ? geo.height : 600;
+ }
+ s.transform3d = true;
+ // --demo: assign this client the next free corner slot (deterministic order;
+ // firefox -> its designated corner, foot -> the rest) and lay it out angled
+ // inward. The plain --run path keeps the centered single-toplevel layout.
+ if (r.demo) {
+ const bool ff = toplevel_is_firefox(s);
+ s.corner = claim_corner(r, ff);
+ if (s.corner >= 0) {
+ // Compute the corner box now (texture-independent) so the log is
+ // accurate; layout_corner_element re-applies it + the tilt to the DOM
+ // element once the first buffer imports (it early-returns until then).
+ compute_corner_box(r, s);
+ layout_corner_element(r, s);
+ slog("CLIENT SURFACE MAP (--demo): %s '%s' -> CORNER SLOT %d (%s) box %dx%d at "
+ "(%d,%d), inward tilt rotateX(%.1f) rotateY(%.1f)",
+ ff ? "vivaldi" : "foot", s.element_id.c_str(), s.corner, kCorners[s.corner].name,
+ s.w, s.h, s.x, s.y, kCorners[s.corner].rot_x, kCorners[s.corner].rot_y);
+ } else {
+ // No free corner (a 5th client): fall back to a centered panel.
+ s.x = (r.out_w - s.w) / 2;
+ s.y = (r.out_h - s.h) / 2;
+ slog("CLIENT SURFACE MAP (--demo): no free corner for '%s' -> centered fallback",
+ s.element_id.c_str());
+ }
+ } else {
+ // Place the toplevel element centered on the stage, sized to its geometry.
+ s.x = (r.out_w - s.w) / 2;
+ s.y = (r.out_h - s.h) / 2;
+ }
+ // Give the toplevel keyboard focus (robust helper — handles the case where
+ // no keyboard device exists yet by deferring to new_keyboard).
+ r.last_toplevel = s.surface;
+ focus_toplevel(r, s.surface);
+ r.dirty = true;
+ r.any_surface_mapped = true;
+ if (!r.demo) {
+ slog("CLIENT SURFACE MAP: toplevel %dx%d at (%d,%d) -> added to scene as live surface "
+ "element '%s' (it WILL be composited as a live texture this frame)",
+ s.w, s.h, s.x, s.y, s.element_id.c_str());
+ }
+}
+
+// MIRRORS the shipped ext-xdg-shell: wire from the xdg_shell's `new_toplevel` /
+// `new_popup` signals, NOT `new_surface`. CRITICAL: on `new_surface` the surface
+// has NO role yet (the client has not called get_toplevel/get_popup), so the old
+// `xdg->role == TOPLEVEL` test was ALWAYS false there and the spike wired NOTHING
+// — no commit handler, so the initial-commit `configure` was never sent, so the
+// client (foot) waited forever for a configure and NEVER mapped. That is exactly
+// the "foot did not appear" field failure. These signals fire with the role
+// assigned, so the handshake completes and the client maps.
+void handle_new_toplevel(Runner& r, wlr_xdg_toplevel* toplevel) {
+ wlr_xdg_surface* xdg = toplevel->base;
+ LiveSurface* s = r.add_surface(xdg->surface);
+ s->xdg = xdg;
+ slog("xdg TOPLEVEL created (app_id='%s' title='%s') — awaiting initial commit -> configure",
+ toplevel->app_id != nullptr ? toplevel->app_id : "?",
+ toplevel->title != nullptr ? toplevel->title : "?");
+ s->map_l.connect(xdg->surface->events.map, [&r, s](void*) { on_xdg_map(r, *s); });
+ s->unmap_l.connect(xdg->surface->events.unmap, [&r, s](void*) {
+ s->mapped = false;
+ r.dirty = true;
+ // Drop keyboard focus if this was the focused toplevel (mirrors ext-xdg-
+ // shell: an unmapped surface must not keep the seat's keyboard focus).
+ if (r.focused_surface == s->surface) {
+ wlr_seat_keyboard_notify_clear_focus(r.seat);
+ r.focused_surface = nullptr;
+ }
+ if (r.last_toplevel == s->surface) {
+ r.last_toplevel = nullptr;
+ }
+ // --demo: release this client's corner slot so it frees cleanly for a
+ // re-map (multiple clients now — slot bookkeeping must drop on unmap).
+ if (r.demo && s->corner >= 0) {
+ r.corner_taken[s->corner] = false;
+ s->corner = -1;
+ }
+ slog("client surface UNMAP: toplevel element '%s'", s->element_id.c_str());
+ });
+ s->commit_l.connect(xdg->surface->events.commit, [&r, s](void*) {
+ // The initial commit REQUIRES a configure reply before the client may
+ // attach a buffer + map. 0x0 size lets the client pick its own dims
+ // (tinywl/ext-xdg-shell discipline); set_size schedules the configure.
+ if (s->xdg != nullptr && s->xdg->initial_commit) {
+ slog("toplevel '%s' initial commit -> sending 0x0 configure (client picks size)",
+ s->element_id.c_str());
+ wlr_xdg_toplevel_set_size(s->xdg->toplevel, 0, 0);
+ }
+ on_surface_commit(r, *s);
+ });
+ s->destroy_l.connect(xdg->surface->events.destroy, [&r, s](void*) { r.remove_surface(s); });
+}
+
+void handle_new_popup(Runner& r, wlr_xdg_popup* popup) {
+ // Popups are surface elements too — answering criterion 4: each
+ // subsurface/popup is its OWN element sampling its OWN live texture,
+ // positioned at the popup's offset under its parent.
+ wlr_xdg_surface* xdg = popup->base;
+ LiveSurface* s = r.add_surface(xdg->surface);
+ s->xdg = xdg;
+ s->map_l.connect(xdg->surface->events.map, [&r, s](void*) {
+ s->mapped = true;
+ const wlr_box geo = s->xdg->geometry;
+ s->w = geo.width > 0 ? geo.width : 200;
+ s->h = geo.height > 0 ? geo.height : 100;
+ s->x = (r.out_w) / 2 + s->xdg->popup->scheduled.geometry.x;
+ s->y = (r.out_h) / 2 + s->xdg->popup->scheduled.geometry.y;
+ r.dirty = true;
+ r.any_surface_mapped = true;
+ slog("CLIENT SURFACE MAP: popup -> added to scene as surface element '%s'",
+ s->element_id.c_str());
+ });
+ s->unmap_l.connect(xdg->surface->events.unmap, [&r, s](void*) {
+ s->mapped = false;
+ r.dirty = true;
+ slog("client surface UNMAP: popup element '%s'", s->element_id.c_str());
+ });
+ s->commit_l.connect(xdg->surface->events.commit, [&r, s](void*) {
+ // A popup also needs its initial configure before it can map.
+ if (s->xdg != nullptr && s->xdg->initial_commit) {
+ wlr_xdg_surface_schedule_configure(s->xdg);
+ }
+ on_surface_commit(r, *s);
+ });
+ s->destroy_l.connect(xdg->surface->events.destroy, [&r, s](void*) { r.remove_surface(s); });
+}
+
+// ---- layer-shell (wallpaper) -------------------------------------------------
+
+void handle_new_layer(Runner& r, wlr_layer_surface_v1* layer) {
+ // Configure it to the full output as a wallpaper (background layer).
+ layer->current.desired_width = static_cast<std::uint32_t>(r.out_w);
+ layer->current.desired_height = static_cast<std::uint32_t>(r.out_h);
+ wlr_layer_surface_v1_configure(layer, static_cast<std::uint32_t>(r.out_w),
+ static_cast<std::uint32_t>(r.out_h));
+ LiveSurface* s = r.add_surface(layer->surface);
+ s->layer = layer;
+ s->is_wallpaper = true;
+ s->x = 0;
+ s->y = 0;
+ s->w = r.out_w;
+ s->h = r.out_h;
+ s->map_l.connect(layer->surface->events.map, [&r, s](void*) {
+ s->mapped = true;
+ r.dirty = true;
+ r.any_surface_mapped = true;
+ slog("CLIENT SURFACE MAP: layer-shell wallpaper -> added to scene as surface element '%s'",
+ s->element_id.c_str());
+ });
+ s->unmap_l.connect(layer->surface->events.unmap, [&r, s](void*) {
+ s->mapped = false;
+ r.dirty = true;
+ slog("client surface UNMAP: layer-shell wallpaper element '%s'", s->element_id.c_str());
+ });
+ s->commit_l.connect(layer->surface->events.commit, [&r, s](void*) { on_surface_commit(r, *s); });
+ s->destroy_l.connect(layer->surface->events.destroy, [&r, s](void*) { r.remove_surface(s); });
+}
+
+// ---- output frame ------------------------------------------------------------
+
+void on_frame(Runner& r) {
+ const double dt = composite_frame(r, /*force=*/false);
+ ++r.commits;
+ // --demo: live compositor-FPS HUD + per-5s min/max FPS log (no-op in --run).
+ // Done first so the HUD text update sets r.dirty before the dirty-gate decides
+ // render-vs-skip below (the HUD must advance ~1/sec even on an otherwise idle
+ // scene). The output present already happened this turn; the HUD lands next.
+ demo_fps_tick(r);
+ // Heartbeat (criterion B): prove the present/commit loop is alive even on a
+ // static scene. First few commits are logged individually (catches an early
+ // freeze); after that, once a second via the [perf] line below.
+ if (r.commits <= 5) {
+ slog("output commit heartbeat #%ld (rendered=%d skipped_idle=%d present_node=%p)",
+ r.commits, r.frames_rendered, r.frames_skipped_idle,
+ static_cast<void*>(r.present_node));
+ }
+ if (!wlr_scene_output_commit(r.scene_output, nullptr)) {
+ // Nothing changed for wlr_scene to commit (static scene). The nested /
+ // DRM backend only emits the next `frame` after a successful output
+ // commit, so a no-op scene commit would STALL the frame clock (and any
+ // client waiting on it). Force a bare output commit to keep the vblank
+ // clock — and thus client progress — alive. (A production build gates the
+ // schedule instead; the spike keeps the seat live for the GO/NO-GO.)
+ wlr_output_state st;
+ wlr_output_state_init(&st);
+ if (!wlr_output_commit_state(r.output, &st)) {
+ wlr_output_schedule_frame(r.output);
+ }
+ wlr_output_state_finish(&st);
+ }
+ timespec now{};
+ clock_gettime(CLOCK_MONOTONIC, &now);
+ wlr_scene_output_send_frame_done(r.scene_output, &now);
+
+ // THE STUCK-FRAME FIX: drive the client update loop. The client surfaces are
+ // imported live textures, NOT scene nodes, so the wlr_scene_output_send_frame_
+ // done above never reaches them. Walk every mapped client surface tree and
+ // complete its frame callbacks ourselves — without this foot draws ONCE and
+ // waits forever (the field "stuck on a single frame"). This tells every
+ // mapped surface + subsurface + popup "now is a good time to draw the next
+ // frame", so typing/output/cursor-blink advance. Mirrors server.cpp.
+ send_frame_done_to_clients(r);
+
+ // Animation dirty source: RmlUi's GetNextUpdateDelay() (finite => animating,
+ // +inf => at rest) — exactly the design's gate signal.
+ bool anim = false;
+ {
+ const bool cur = r.gl.make_current();
+ r.ctx->Update();
+ anim = std::isfinite(r.ctx->GetNextUpdateDelay());
+ if (cur) {
+ r.gl.restore_current();
+ }
+ }
+ if (anim) {
+ r.dirty = true;
+ }
+ // Keep the output ticking so mapped clients always make progress (their
+ // wl_surface.frame callbacks fire and their roundtrips complete). The
+ // dirty-GATE still decides whether composite_frame() actually RENDERS vs.
+ // counts a skipped-idle frame — so the idle win is still visible in the perf
+ // line (skipped_idle climbs while frames holds) even though the nested/DRM
+ // output is scheduled every vblank. (A production build would instead gate
+ // the schedule itself; here we keep the seat live for the GO/NO-GO.)
+ wlr_output_schedule_frame(r.output);
+
+ // Periodic perf report (~1s).
+ const double t = spike::now_sec();
+ if (t - r.last_report > 1.0 && !r.frame_ms.empty()) {
+ std::vector<double> v = r.frame_ms;
+ std::sort(v.begin(), v.end());
+ double sum = 0;
+ for (double x : v) {
+ sum += x;
+ }
+ const double avg = sum / v.size();
+ const double p95 = v[static_cast<std::size_t>(v.size() * 0.95)];
+ slog("[perf] frames=%d skipped_idle=%d commits=%ld avg=%.2fms p95=%.2fms max=%.2fms "
+ "(~%.0f fps budget)",
+ r.frames_rendered, r.frames_skipped_idle, r.commits, avg, p95, v.back(),
+ avg > 0 ? 1000.0 / avg : 0.0);
+ // Stage-0 budget split (per-rendered-frame averages over this window).
+ // CPU phases are submit wall-clock; gpu= is the REAL GPU fill from a timer
+ // query (the number that tells us if we're fill-bound and damage limiting
+ // will pay off). 'n/a' if EXT_disjoint_timer_query is unavailable.
+ const std::size_t nf = v.size();
+ char gpu[24];
+ if (r.gpu_samples > 0) {
+ std::snprintf(gpu, sizeof(gpu), "%.2fms", r.sum_gpu_ms / r.gpu_samples);
+ } else {
+ std::snprintf(gpu, sizeof(gpu), "n/a");
+ }
+ slog("[perf-split] per-frame CPU: import=%.2f clear=%.2f update=%.2f render=%.2f "
+ "present=%.2f ms | GPU fill (ctx->Render)=%s",
+ r.sum_import_ms / nf, r.sum_clear_ms / nf, r.sum_update_ms / nf, r.sum_render_ms / nf,
+ r.sum_present_ms / nf, gpu);
+ r.sum_import_ms = r.sum_clear_ms = r.sum_update_ms = 0.0;
+ r.sum_render_ms = r.sum_present_ms = r.sum_gpu_ms = 0.0;
+ r.gpu_samples = 0;
+ r.frame_ms.clear();
+ r.last_report = t;
+ (void)dt;
+ }
+
+ // Periodic LIVE-UPDATE-LOOP heartbeat (~1s): proves the client update loop is
+ // ALIVE — commits coming in, buffers re-imported, and frame-done sent back so
+ // the client keeps producing frames. On the user's next run a CLIMBING
+ // frame_done (with client_commits + reimports climbing as they type) means
+ // foot is no longer stuck on one frame: typing/output/cursor-blink advance.
+ // (Counts only, not per-event spam, per the brief.)
+ if (t - r.last_loop_report > 1.0) {
+ slog("[live-loop] client commits=%ld reimports=%ld frame_done=%ld (mapped surfaces "
+ "are being told to draw their next frame -> live update)",
+ r.client_commits, r.live_reimports, r.frame_done_sends);
+ r.last_loop_report = t;
+ }
+}
+
+// ---- input devices -----------------------------------------------------------
+
+// Re-arm the dead-man self-timeout to its full interval. Called when it is first
+// armed and EVERY time `P` is pressed; if the session outlives the interval, P
+// reached the handler ⇒ real-seat keyboard input is alive (the liveness test).
+void deadman_rearm(Runner& r) {
+ if (r.safety_timer != nullptr && r.deadman_ms > 0) {
+ wl_event_source_timer_update(r.safety_timer, r.deadman_ms);
+ }
+}
+
+// The kernel-hardwired escape-hatch + dead-man check, run on EVERY key event
+// from EVERY keyboard device, BEFORE anything else. Returns true if the key was
+// CONSUMED here (so it must NOT be forwarded to a client). MIRRORS the shipped
+// kernel's src/input.cpp keysym-resolution + VT-switch discipline.
+auto handle_escape_keys(Runner& r, Keyboard& kb, wlr_keyboard_key_event* ev) -> bool {
+ const std::uint32_t keycode = ev->keycode + 8; // libinput keycode -> xkb
+ const xkb_keysym_t* syms = nullptr;
+ const int nsyms = xkb_state_key_get_syms(kb.keyboard->xkb_state, keycode, &syms);
+ const std::uint32_t mods = wlr_keyboard_get_modifiers(kb.keyboard);
+ const bool pressed = ev->state == WL_KEYBOARD_KEY_STATE_PRESSED;
+ const bool ctrl_alt = (mods & (WLR_MODIFIER_CTRL | WLR_MODIFIER_ALT)) ==
+ (WLR_MODIFIER_CTRL | WLR_MODIFIER_ALT);
+
+ for (int i = 0; i < nsyms; ++i) {
+ const xkb_keysym_t sym = syms[i];
+
+ // `P` (with NO ctrl/alt) -> reset the dead-man timer AND prove input is
+ // live. This is the keep-alive: holding the session open past the
+ // dead-man interval REQUIRES pressing P periodically. We do NOT consume
+ // P — let it pass through to the client too (it is a normal letter); the
+ // keep-alive is a side-effect, not a grab.
+ if (pressed && !ctrl_alt && (sym == XKB_KEY_p || sym == XKB_KEY_P)) {
+ deadman_rearm(r);
+ slog("P pressed -> dead-man reset to %ds (real-seat keyboard input is LIVE)",
+ r.deadman_ms / 1000);
+ // fall through: do not consume.
+ }
+
+ // `D` (no ctrl/alt, --demo only) -> toggle the click-accuracy debug
+ // overlay (per-surface crosshair marker + #dbg readout). Consumed so it
+ // does NOT type a 'd' into the focused client.
+ if (r.demo && !ctrl_alt && (sym == XKB_KEY_d || sym == XKB_KEY_D)) {
+ if (pressed) {
+ r.debug_overlay = !r.debug_overlay;
+ if (r.debug_overlay) {
+ // Place the marker immediately at the current cursor position.
+ (void)route_point(r, r.last_cursor_x, r.last_cursor_y);
+ slog("DEBUG overlay ON — crosshair should sit UNDER the cursor; #dbg shows "
+ "screen -> elem-local -> surface-local. Any gap = the click error.");
+ } else {
+ hide_debug_markers(r);
+ slog("DEBUG overlay OFF");
+ }
+ r.dirty = true;
+ wlr_output_schedule_frame(r.output);
+ }
+ return true; // consume press AND release; never forward
+ }
+
+ // Esc OR Ctrl+Alt+Backspace -> terminate the session cleanly.
+ if (sym == XKB_KEY_Escape || (ctrl_alt && sym == XKB_KEY_BackSpace) ||
+ (ctrl_alt && sym == XKB_KEY_Terminate_Server)) {
+ if (pressed) {
+ slog("QUIT KEY pressed (Esc / Ctrl+Alt+Backspace) -> terminating");
+ wl_display_terminate(r.display);
+ }
+ return true; // consume press AND release; never forward
+ }
+
+ // Ctrl+Alt+F1..F12 -> switch the Linux VT (escape to a console).
+ // vt_for_keysym() is the SAME decision core input.cpp uses.
+ if (const std::optional<unsigned> vt = unbox::kernel::vt_for_keysym(sym)) {
+ if (pressed) {
+ if (r.session != nullptr) {
+ slog("VT-SWITCH key -> wlr_session_change_vt(%u)", *vt);
+ wlr_session_change_vt(r.session, *vt);
+ } else {
+ slog("VT-SWITCH key but no session (nested/headless) -> no-op");
+ }
+ }
+ return true; // consume: no client forward (press or release)
+ }
+ }
+ return false;
+}
+
+void update_seat_caps(Runner& r) {
+ std::uint32_t caps = WL_SEAT_CAPABILITY_POINTER | WL_SEAT_CAPABILITY_TOUCH;
+ if (!r.keyboards.empty()) {
+ caps |= WL_SEAT_CAPABILITY_KEYBOARD;
+ }
+ wlr_seat_set_capabilities(r.seat, caps);
+}
+
+// MIRRORS src/input.cpp::new_keyboard — per-device key/modifiers/destroy
+// listeners, default XKB keymap, repeat info, seat keyboard set. The previous
+// spike kept ONE shared listener that the last device clobbered; on a real DRM
+// seat with several keyboard devices that could leave keys arriving on a device
+// with no live listener — which is exactly the "no key events reached the
+// handler" field failure. Per-device listeners fix that by construction.
+void new_keyboard(Runner& r, wlr_input_device* dev) {
+ wlr_keyboard* wlr_kb = wlr_keyboard_from_input_device(dev);
+
+ r.keyboards.emplace_back();
+ Keyboard& kb = r.keyboards.back();
+ kb.runner = &r;
+ kb.keyboard = wlr_kb;
+
+ xkb_context* xkb = xkb_context_new(XKB_CONTEXT_NO_FLAGS);
+ xkb_keymap* km = xkb_keymap_new_from_names(xkb, nullptr, XKB_KEYMAP_COMPILE_NO_FLAGS);
+ wlr_keyboard_set_keymap(wlr_kb, km);
+ xkb_keymap_unref(km);
+ xkb_context_unref(xkb);
+ wlr_keyboard_set_repeat_info(wlr_kb, 25, 600);
+
+ kb.key_l.connect(wlr_kb->events.key, [&r, &kb](void* data) {
+ auto* ev = static_cast<wlr_keyboard_key_event*>(data);
+ // Escape hatch + dead-man FIRST, kernel-hardwired, before any forward.
+ if (handle_escape_keys(r, kb, ev)) {
+ return;
+ }
+ r.focus_kb = kb.keyboard;
+ wlr_seat_set_keyboard(r.seat, kb.keyboard);
+ // Lazily (re)assert keyboard focus on the mapped toplevel if the seat is
+ // not already focused on a client surface — covers the real-seat case
+ // where the FIRST key arrives before any enter landed (e.g. keyboard
+ // hot-plugged after map, or map/enter raced). Without this the notify_key
+ // below would fall on a surface with no keyboard focus and never reach
+ // the client (the field "cannot type into foot" symptom).
+ if (wlr_seat_get_keyboard(r.seat) == nullptr || r.focused_surface == nullptr) {
+ if (r.last_toplevel != nullptr) {
+ focus_toplevel(r, r.last_toplevel);
+ }
+ }
+ wlr_seat_keyboard_notify_key(r.seat, ev->time_msec, ev->keycode, ev->state);
+ ++r.keys_forwarded;
+ if (r.keys_forwarded <= 8 || (r.keys_forwarded % 64) == 0) {
+ slog("key FORWARDED to client (keycode=%u state=%u) — total forwarded=%ld%s",
+ ev->keycode, static_cast<unsigned>(ev->state), r.keys_forwarded,
+ r.focused_surface == nullptr ? " [WARN: no focused surface!]" : "");
+ }
+ });
+ kb.mods_l.connect(wlr_kb->events.modifiers, [&r, &kb](void*) {
+ r.focus_kb = kb.keyboard;
+ wlr_seat_set_keyboard(r.seat, kb.keyboard);
+ wlr_seat_keyboard_notify_modifiers(r.seat, &kb.keyboard->modifiers);
+ });
+ kb.destroy_l.connect(dev->events.destroy, [&r, &kb](void*) {
+ slog("keyboard device REMOVED: '%s'", kb.keyboard->base.name ? kb.keyboard->base.name : "?");
+ if (r.focus_kb == kb.keyboard) {
+ r.focus_kb = nullptr;
+ }
+ Keyboard* self = &kb;
+ r.keyboards.remove_if([self](const Keyboard& e) { return &e == self; });
+ update_seat_caps(r);
+ });
+
+ r.focus_kb = wlr_kb;
+ wlr_seat_set_keyboard(r.seat, wlr_kb);
+ slog("keyboard device ADDED: '%s' (escape-hatch + P-keepalive listener attached)",
+ dev->name != nullptr ? dev->name : "?");
+ // If a toplevel mapped BEFORE this keyboard appeared, its focus enter was
+ // deferred (no keyboard then) — hand it focus now so keys reach the client.
+ if (r.focused_surface == nullptr && r.last_toplevel != nullptr) {
+ focus_toplevel(r, r.last_toplevel);
+ }
+}
+
+void handle_new_input(Runner& r, wlr_input_device* dev) {
+ switch (dev->type) {
+ case WLR_INPUT_DEVICE_KEYBOARD:
+ new_keyboard(r, dev);
+ break;
+ case WLR_INPUT_DEVICE_POINTER:
+ slog("pointer device ADDED: '%s'", dev->name != nullptr ? dev->name : "?");
+ wlr_cursor_attach_input_device(r.cursor, dev);
+ // Make the cursor visible immediately (a default xcursor image on the
+ // wlr plane) so it shows even before the first motion event.
+ show_default_cursor(r);
+ break;
+ case WLR_INPUT_DEVICE_TOUCH:
+ slog("touch device ADDED: '%s'", dev->name != nullptr ? dev->name : "?");
+ wlr_cursor_attach_input_device(r.cursor, dev);
+ show_default_cursor(r);
+ break;
+ default:
+ slog("input device ADDED (other type=%d): '%s'", static_cast<int>(dev->type),
+ dev->name != nullptr ? dev->name : "?");
+ break;
+ }
+ update_seat_caps(r);
+}
+
+void attach_input(Runner& r) {
+ r.cursor_motion_l.connect(r.cursor->events.motion, [&r](void* data) {
+ auto* ev = static_cast<wlr_pointer_motion_event*>(data);
+ wlr_cursor_move(r.cursor, &ev->pointer->base, ev->delta_x, ev->delta_y);
+ Routed rt = route_point(r, r.cursor->x, r.cursor->y);
+ notify_pointer_motion(r, r.cursor->x, r.cursor->y, ev->time_msec, rt);
+ r.dirty = true;
+ wlr_output_schedule_frame(r.output);
+ });
+ r.cursor_motion_abs_l.connect(r.cursor->events.motion_absolute, [&r](void* data) {
+ auto* ev = static_cast<wlr_pointer_motion_absolute_event*>(data);
+ wlr_cursor_warp_absolute(r.cursor, &ev->pointer->base, ev->x, ev->y);
+ Routed rt = route_point(r, r.cursor->x, r.cursor->y);
+ notify_pointer_motion(r, r.cursor->x, r.cursor->y, ev->time_msec, rt);
+ r.dirty = true;
+ wlr_output_schedule_frame(r.output);
+ });
+ r.cursor_button_l.connect(r.cursor->events.button, [&r](void* data) {
+ auto* ev = static_cast<wlr_pointer_button_event*>(data);
+ Routed rt = route_point(r, r.cursor->x, r.cursor->y);
+ if (rt.s != nullptr) {
+ wlr_seat_pointer_notify_enter(r.seat, rt.s->surface, rt.sx, rt.sy);
+ wlr_seat_pointer_notify_button(r.seat, ev->time_msec, ev->button, ev->state);
+ wlr_seat_pointer_notify_frame(r.seat);
+ ++r.pointer_enters;
+ slog("pointer BUTTON %u state=%u -> client surface '%s' at surface-local (%.1f,%.1f) "
+ "[screen (%.0f,%.0f) through the 3D transform]",
+ ev->button, static_cast<unsigned>(ev->state), rt.s->element_id.c_str(), rt.sx,
+ rt.sy, r.cursor->x, r.cursor->y);
+ } else {
+ slog("pointer BUTTON %u state=%u: NO surface under cursor (%.0f,%.0f) — not forwarded",
+ ev->button, static_cast<unsigned>(ev->state), r.cursor->x, r.cursor->y);
+ }
+ });
+ r.cursor_axis_l.connect(r.cursor->events.axis, [&r](void* data) {
+ auto* ev = static_cast<wlr_pointer_axis_event*>(data);
+ wlr_seat_pointer_notify_axis(r.seat, ev->time_msec, ev->orientation, ev->delta,
+ ev->delta_discrete, ev->source, ev->relative_direction);
+ wlr_seat_pointer_notify_frame(r.seat);
+ });
+ r.cursor_frame_l.connect(r.cursor->events.frame,
+ [&r](void*) { wlr_seat_pointer_notify_frame(r.seat); });
+ // Touch: map the touch point through the same pick and notify the client.
+ r.touch_down_l.connect(r.cursor->events.touch_down, [&r](void* data) {
+ auto* ev = static_cast<wlr_touch_down_event*>(data);
+ double lx = 0, ly = 0;
+ wlr_cursor_absolute_to_layout_coords(r.cursor, &ev->touch->base, ev->x, ev->y, &lx, &ly);
+ Routed rt = route_point(r, lx, ly);
+ if (rt.s != nullptr) {
+ wlr_seat_touch_notify_down(r.seat, rt.s->surface, ev->time_msec, ev->touch_id, rt.sx,
+ rt.sy);
+ ++r.touch_downs;
+ slog("TOUCH DOWN id=%d -> client surface '%s' at surface-local (%.1f,%.1f) [screen "
+ "(%.0f,%.0f) through the 3D transform] downs=%ld",
+ ev->touch_id, rt.s->element_id.c_str(), rt.sx, rt.sy, lx, ly, r.touch_downs);
+ } else {
+ slog("TOUCH DOWN id=%d: NO surface under point (%.0f,%.0f) — not forwarded",
+ ev->touch_id, lx, ly);
+ }
+ r.dirty = true;
+ wlr_output_schedule_frame(r.output);
+ });
+ r.touch_motion_l.connect(r.cursor->events.touch_motion, [&r](void* data) {
+ auto* ev = static_cast<wlr_touch_motion_event*>(data);
+ double lx = 0, ly = 0;
+ wlr_cursor_absolute_to_layout_coords(r.cursor, &ev->touch->base, ev->x, ev->y, &lx, &ly);
+ Routed rt = route_point(r, lx, ly);
+ if (rt.s != nullptr) {
+ wlr_seat_touch_notify_motion(r.seat, ev->time_msec, ev->touch_id, rt.sx, rt.sy);
+ }
+ });
+ r.touch_up_l.connect(r.cursor->events.touch_up, [&r](void* data) {
+ auto* ev = static_cast<wlr_touch_up_event*>(data);
+ wlr_seat_touch_notify_up(r.seat, ev->time_msec, ev->touch_id);
+ slog("TOUCH UP id=%d -> client", ev->touch_id);
+ });
+
+ // Seat protocol glue (mirrors src/input.cpp::attach_seat_handlers). A client
+ // (foot) may request its OWN cursor surface (e.g. the text I-beam) while the
+ // pointer is over it; honor that only for the currently pointer-focused
+ // client. When the pointer focus leaves all client surfaces, restore OUR
+ // default xcursor so the cursor never goes invisible over the document.
+ r.seat_request_cursor_l.connect(r.seat->events.request_set_cursor, [&r](void* data) {
+ auto* ev = static_cast<wlr_seat_pointer_request_set_cursor_event*>(data);
+ if (r.seat->pointer_state.focused_client == ev->seat_client) {
+ wlr_cursor_set_surface(r.cursor, ev->surface, ev->hotspot_x, ev->hotspot_y);
+ slog("client set its own cursor surface (e.g. text I-beam over foot)");
+ }
+ });
+ r.seat_pointer_focus_change_l.connect(
+ r.seat->pointer_state.events.focus_change, [&r](void* data) {
+ auto* ev = static_cast<wlr_seat_pointer_focus_change_event*>(data);
+ if (ev->new_surface == nullptr) {
+ show_default_cursor(r);
+ }
+ });
+}
+
+// ---- output bring-up ---------------------------------------------------------
+
+void handle_new_output(Runner& r, wlr_output* out) {
+ if (r.output != nullptr) {
+ return; // spike: drive ONE output
+ }
+ r.output = out;
+ wlr_output_init_render(out, r.allocator, r.renderer);
+ wlr_output_state st;
+ wlr_output_state_init(&st);
+ wlr_output_state_set_enabled(&st, true);
+ wlr_output_mode* mode = wlr_output_preferred_mode(out);
+ if (mode != nullptr) {
+ wlr_output_state_set_mode(&st, mode);
+ }
+ const bool modeset_ok = wlr_output_commit_state(out, &st);
+ wlr_output_state_finish(&st);
+
+ if (out->width > 0) {
+ r.out_w = out->width;
+ r.out_h = out->height;
+ }
+ slog("output ADDED + MODESET: name='%s' %dx%d refresh=%dmHz preferred_mode=%d commit=%s",
+ out->name, r.out_w, r.out_h, mode != nullptr ? mode->refresh : 0, mode != nullptr,
+ modeset_ok ? "OK" : "FAILED");
+ if (!modeset_ok) {
+ slog("WARNING: modeset commit FAILED — the panel will likely stay black. "
+ "Check WLR_RENDERER=gles2 and DRM permissions.");
+ }
+
+ wlr_output_layout_output* lo = wlr_output_layout_add_auto(r.output_layout, out);
+ r.scene_output = wlr_scene_output_create(r.scene, out);
+ wlr_scene_output_layout_add_output(r.scene_layout, lo, r.scene_output);
+
+ // Load the xcursor theme for this output's scale BEFORE we ever set an
+ // xcursor image (wlr_cursor_set_xcursor needs the theme loaded at the right
+ // scale to produce a buffer for the plane). Without this the cursor plane
+ // has no image => the "no mouse cursor visible" field symptom. Mirrors the
+ // shipped kernel, which loads the theme on output bring-up.
+ if (r.cursor_mgr != nullptr) {
+ wlr_xcursor_manager_load(r.cursor_mgr, out->scale);
+ slog("xcursor theme loaded for output scale %.2f (cursor can now show an image)",
+ out->scale);
+ }
+ // If a pointer/touch device already exists, show the default cursor now that
+ // the theme is loaded (device-add may have run before the output came up).
+ show_default_cursor(r);
+
+ // Guaranteed-visible NON-BLACK background + a test marker (criterion C),
+ // created in the scene tree FIRST so they sit UNDER the RmlUi present node.
+ // If the RmlUi/dmabuf present path works, the opaque composite covers these
+ // (you see the tilted window on the document's own dark-blue body). If the
+ // present path is BROKEN (no buffer reaches present_node), wlr_scene still
+ // paints these — so "totally black" (nothing presents / modeset failed) is
+ // visibly distinct from "dark blue + marker" (presenting, but the RmlUi
+ // layer is empty). Dark blue: an unmistakable "the spike is alive" signal.
+ const float kBlue[4] = {0.05f, 0.08f, 0.20f, 1.0f};
+ const float kAmber[4] = {1.0f, 0.65f, 0.0f, 1.0f};
+ r.bg_rect = wlr_scene_rect_create(&r.scene->tree, r.out_w, r.out_h, kBlue);
+ r.marker_rect = wlr_scene_rect_create(&r.scene->tree, 64, 64, kAmber);
+ wlr_scene_node_set_position(&r.marker_rect->node, 24, 24);
+
+ // Build the present target + RmlUi document sized to the output, then a
+ // single full-output scene_buffer node to present it (criterion 7). Created
+ // AFTER the background rects so it renders ON TOP of them.
+ r.gl.make_current();
+ const bool present_ok = r.present.init(&r.gl, r.allocator, r.out_w, r.out_h);
+ r.present_node = wlr_scene_buffer_create(&r.scene->tree, nullptr);
+ r.present.scene_buffer = r.present_node;
+ r.ctx = Rml::CreateContext("run", Rml::Vector2i(r.out_w, r.out_h), r.gl.render);
+ // --demo loads the curated 4-corner document (deeper perspective + the FPS
+ // HUD square); plain --run keeps the single-stage kRunRml. The corner panels
+ // get their per-corner inward tilt at map time (layout_corner_element); here
+ // we only center the HUD and grab its live-FPS text element.
+ r.doc = r.ctx->LoadDocumentFromMemory(r.demo ? kDemoRml : kRunRml);
+ if (r.doc != nullptr) {
+ r.doc->Show();
+ }
+ if (r.demo && r.doc != nullptr) {
+ if (Rml::Element* hud = r.doc->GetElementById("hud")) {
+ // Center the HUD square on the output so it is readable and NOT hidden
+ // behind the corner windows (the corners are inset into the quadrants;
+ // the centre is clear). Position it in absolute output pixels.
+ const int hud_w = 220, hud_h = 84;
+ hud->SetProperty("left", std::to_string((r.out_w - hud_w) / 2) + "px");
+ hud->SetProperty("top", std::to_string((r.out_h - hud_h) / 2) + "px");
+ // The <p class="big"> holds the live FPS number; cache it for updates.
+ for (int i = 0; i < hud->GetNumChildren(); ++i) {
+ Rml::Element* child = hud->GetChild(i);
+ if (child != nullptr && child->IsClassSet("big")) {
+ r.hud_el = child;
+ break;
+ }
+ }
+ slog("--demo: FPS HUD centered at output centre (%dx%d square), hud_el=%p",
+ hud_w, hud_h, static_cast<void*>(r.hud_el));
+ }
+ // Grab the click-accuracy debug readout box (hidden until 'D' toggles it).
+ r.dbg_el = r.doc->GetElementById("dbg");
+ slog("--demo: click-debug overlay ready (press D to toggle), dbg_el=%p",
+ static_cast<void*>(r.dbg_el));
+ // Open the dedicated per-5s min/max FPS log (separate from the diagnostic
+ // log). fps_log_open picks $UNBOX_SPIKE_FPS_LOG else $HOME/rml-spike-fps.log.
+ r.fps_log = fps_log_open(r.fps_log_path);
+ if (r.fps_log != nullptr) {
+ slog("--demo: per-5s min/max FPS log open at '%s'", r.fps_log_path.c_str());
+ } else {
+ slog("--demo: WARNING could not open FPS log at '%s' — FPS still shown on the HUD",
+ r.fps_log_path.c_str());
+ }
+ const double t = spike::now_sec();
+ r.fps_last_sample = t;
+ r.bucket_start = t;
+ r.bucket_frames0 = 0;
+ }
+ r.gl.restore_current();
+ slog("present target init=%d dmabuf=%d; RmlUi document=%s (%s); background+marker rects placed",
+ present_ok, r.present.dmabuf, r.doc != nullptr ? "loaded" : "FAILED",
+ r.demo ? "--demo 4-corner + HUD" : "--run single-stage");
+
+ r.frame_l.connect(out->events.frame, [&r](void*) { on_frame(r); });
+ wlr_output_schedule_frame(out);
+ slog("output '%s' up at %dx%d; present node + RmlUi document built", out->name, r.out_w,
+ r.out_h);
+}
+
+Runner* g_runner = nullptr;
+
+// EACH client connect: wl_display's client-created signal is a raw wl_listener
+// (not a wlr signal, so no RAII Listener wraps it). For a single-TU throwaway
+// spike this is in-bounds; we never let it outlive the display (removed in
+// teardown). Loud per-connect logging answers "did foot even connect?".
+void on_client_created(wl_listener* l, void* data) {
+ auto* client = static_cast<wl_client*>(data);
+ pid_t pid = 0;
+ uid_t uid = 0;
+ gid_t gid = 0;
+ wl_client_get_credentials(client, &pid, &uid, &gid);
+ ++g_runner->client_connects;
+ slog("CLIENT CONNECT #%d: a wl_client connected (pid=%d uid=%d) — now waiting for it to "
+ "create + MAP a surface",
+ g_runner->client_connects, static_cast<int>(pid), static_cast<int>(uid));
+ (void)l;
+}
+
+} // namespace
+
+auto run_real_seat(const char* startup_cmd, bool demo) -> int {
+ log_open();
+ wlr_log_init(WLR_INFO, nullptr);
+ slog("=== rml-compositing-spike --%s START (persistent log: %s) ===",
+ demo ? "demo" : "run", g_log_path.c_str());
+ slog("env: WLR_BACKENDS=%s WLR_RENDERER=%s", getenv("WLR_BACKENDS") ? getenv("WLR_BACKENDS") : "(auto)",
+ getenv("WLR_RENDERER") ? getenv("WLR_RENDERER") : "(auto)");
+ Runner r;
+ r.demo = demo; // the curated 4-window perf-load scenario; plain --run leaves it false
+ g_runner = &r;
+
+ r.display = wl_display_create();
+ r.loop = wl_display_get_event_loop(r.display);
+ // Loudly log EACH client connect (diagnose "did foot connect?"). Raw
+ // wl_listener — removed in teardown before the display dies.
+ r.client_created_l.notify = on_client_created;
+ wl_display_add_client_created_listener(r.display, &r.client_created_l);
+ r.backend = wlr_backend_autocreate(r.loop, &r.session);
+ if (r.backend == nullptr) {
+ slog("FATAL: failed to create backend");
+ log_close();
+ return 1;
+ }
+ slog("backend created: session=%s (NULL session => nested/headless, no VT switching)",
+ r.session != nullptr ? "present (real seat)" : "NULL");
+ r.renderer = wlr_renderer_autocreate(r.backend);
+ wlr_renderer_init_wl_display(r.renderer, r.display);
+ r.allocator = wlr_allocator_autocreate(r.backend, r.renderer);
+ slog("renderer selected: gles2=%d (RML compositing requires gles2)",
+ wlr_renderer_is_gles2(r.renderer));
+
+ if (!wlr_renderer_is_gles2(r.renderer)) {
+ slog("FATAL: renderer is not gles2 — RML compositing needs the GL path. "
+ "Set WLR_RENDERER=gles2.");
+ log_close();
+ return 1;
+ }
+
+ // SAFETY (criterion A) — signal handlers FIRST, so even an early hang during
+ // bring-up can be killed cleanly. wl_event_loop_add_signal turns the signal
+ // into a normal event-loop dispatch on the single thread: SIGINT/SIGTERM ->
+ // wl_display_terminate -> wl_display_run returns -> clean wlroots/session
+ // teardown restores the VT to text mode. This is what lets `kill`/`timeout`/
+ // an SSH `pkill` exit WITHOUT a hard reboot.
+ r.sigint_src = wl_event_loop_add_signal(r.loop, SIGINT, [](int, void* data) {
+ slog("SIGINT received -> wl_display_terminate (clean exit)");
+ wl_display_terminate(static_cast<wl_display*>(data));
+ return 0;
+ }, r.display);
+ r.sigterm_src = wl_event_loop_add_signal(r.loop, SIGTERM, [](int, void* data) {
+ slog("SIGTERM received -> wl_display_terminate (clean exit)");
+ wl_display_terminate(static_cast<wl_display*>(data));
+ return 0;
+ }, r.display);
+
+ // SAFETY — the GUARANTEED backstop: a DEAD-MAN self-timeout that terminates
+ // the session no matter what, so the machine can NEVER be locked again.
+ // DEFAULT 15s (was 120). Override with UNBOX_SPIKE_TIMEOUT seconds (0 =
+ // disabled, for a deliberate long real-seat session once you trust the key
+ // escapes). Pressing `P` re-arms it to the FULL interval (see handle_escape_
+ // keys) — so keeping the session alive past 15s REQUIRES periodic P presses,
+ // which doubles as the real-seat keyboard-input liveness test.
+ // Default 15s for plain --run; 120s for --demo (still a backstop, but long
+ // enough to play an HD video and watch the FPS HUD/log). UNBOX_SPIKE_TIMEOUT
+ // overrides either (0 = disabled).
+ int timeout_s = demo ? 120 : 15;
+ if (const char* env = getenv("UNBOX_SPIKE_TIMEOUT")) {
+ timeout_s = std::atoi(env);
+ }
+ r.deadman_ms = timeout_s * 1000;
+ if (timeout_s > 0) {
+ // The timer fires against the Runner so it can log + re-arm. It is a
+ // ONE-SHOT (we never re-arm it ourselves on expiry): when it fires, the
+ // session dies — UNLESS a `P` press re-armed it first.
+ r.safety_timer = wl_event_loop_add_timer(r.loop, [](void* data) {
+ auto* rr = static_cast<Runner*>(data);
+ slog("DEAD-MAN FIRED (no `P` press within %ds) -> wl_display_terminate. "
+ "If you expected the session to stay open, real-seat keyboard input is DEAD "
+ "(P never reached the handler).",
+ rr->deadman_ms / 1000);
+ wl_display_terminate(rr->display);
+ return 0;
+ }, &r);
+ deadman_rearm(r);
+ slog("DEAD-MAN self-timeout armed: %ds (press P to reset; UNBOX_SPIKE_TIMEOUT=0 to "
+ "disable). Survival past %ds == keyboard input WORKS.",
+ timeout_s, timeout_s);
+ } else {
+ slog("DEAD-MAN self-timeout DISABLED (UNBOX_SPIKE_TIMEOUT=0) — rely on Esc / "
+ "Ctrl+Alt+Backspace / Ctrl+Alt+F-key / signals to exit");
+ }
+ slog("ESCAPE HATCH: Esc or Ctrl+Alt+Backspace = quit; Ctrl+Alt+F1..F12 = switch VT; "
+ "SIGINT/SIGTERM = clean quit; P = reset dead-man (keyboard liveness test)");
+
+ r.compositor = wlr_compositor_create(r.display, 5, r.renderer);
+ wlr_subcompositor_create(r.display);
+ wlr_data_device_manager_create(r.display);
+ r.output_layout = wlr_output_layout_create(r.display);
+ r.scene = wlr_scene_create();
+ r.scene_layout = wlr_scene_attach_output_layout(r.scene, r.output_layout);
+
+ r.cursor = wlr_cursor_create();
+ wlr_cursor_attach_output_layout(r.cursor, r.output_layout);
+ r.cursor_mgr = wlr_xcursor_manager_create(nullptr, 24);
+ r.seat = wlr_seat_create(r.display, "seat0");
+
+ r.xdg_shell = wlr_xdg_shell_create(r.display, 3);
+ // Wire from new_toplevel/new_popup (role assigned) — NOT new_surface (no role
+ // yet). This is what makes the configure handshake complete so clients map.
+ r.new_toplevel_l.connect(r.xdg_shell->events.new_toplevel, [&r](void* data) {
+ handle_new_toplevel(r, static_cast<wlr_xdg_toplevel*>(data));
+ });
+ r.new_popup_l.connect(r.xdg_shell->events.new_popup, [&r](void* data) {
+ handle_new_popup(r, static_cast<wlr_xdg_popup*>(data));
+ });
+ r.layer_shell = wlr_layer_shell_v1_create(r.display, 4);
+ r.new_layer_l.connect(r.layer_shell->events.new_surface, [&r](void* data) {
+ handle_new_layer(r, static_cast<wlr_layer_surface_v1*>(data));
+ });
+
+ r.new_output_l.connect(r.backend->events.new_output,
+ [&r](void* data) { handle_new_output(r, static_cast<wlr_output*>(data)); });
+ r.new_input_l.connect(r.backend->events.new_input, [&r](void* data) {
+ handle_new_input(r, static_cast<wlr_input_device*>(data));
+ });
+ attach_input(r);
+
+ // Initialize the GL bridge against the wlr EGLDisplay now (before any output;
+ // the import path only needs the display).
+ EGLDisplay egl = wlr_egl_get_display(wlr_gles2_renderer_get_egl(r.renderer));
+ if (!r.gl.init(egl)) {
+ slog("FATAL: GL bridge init failed — NO-GO on this hardware");
+ log_close();
+ return 1;
+ }
+
+ const char* socket = wl_display_add_socket_auto(r.display);
+ if (socket == nullptr) {
+ slog("FATAL: failed to add wayland socket");
+ log_close();
+ return 1;
+ }
+ // Export WAYLAND_DISPLAY in OUR environment so EVERY child (the spawn below
+ // AND anything it forks) inherits our socket, not the stale parent value.
+ // Mirrors the shipped kernel (server.cpp): without this the client connects
+ // to the WRONG compositor and nothing shows — a black-screen cause.
+ setenv("WAYLAND_DISPLAY", socket, 1);
+ slog("WAYLAND_DISPLAY=%s (exported into process env; children inherit it)", socket);
+
+ if (!wlr_backend_start(r.backend)) {
+ slog("FATAL: failed to start backend");
+ log_close();
+ return 1;
+ }
+ slog("backend started; up on WAYLAND_DISPLAY=%s", socket);
+
+ // Spawn a child running `cmd` via /bin/sh, with WAYLAND_DISPLAY exported and
+ // optionally an extra env var (KEY=VALUE) set in the child (firefox needs
+ // MOZ_ENABLE_WAYLAND=1). `label` is logged. Returns the pid (>0) or -1.
+ auto spawn_client = [&](const char* cmd, const char* extra_env, const char* label) -> pid_t {
+ const pid_t pid = fork();
+ if (pid == 0) {
+ setenv("WAYLAND_DISPLAY", socket, 1);
+ if (extra_env != nullptr && extra_env[0] != '\0') {
+ // extra_env is "KEY=VALUE"; split once on '='.
+ const char* eq = std::strchr(extra_env, '=');
+ if (eq != nullptr) {
+ const std::string key(extra_env, eq);
+ setenv(key.c_str(), eq + 1, 1);
+ }
+ }
+ execl("/bin/sh", "/bin/sh", "-c", cmd, static_cast<char*>(nullptr));
+ std::fprintf(stderr, "[run] exec of client failed: %s\n", cmd);
+ _exit(127);
+ }
+ if (pid > 0) {
+ slog("client SPAWN: pid=%d %s cmd='%s'%s%s (watch for a 'CLIENT SURFACE MAP' line)",
+ static_cast<int>(pid), label, cmd, extra_env != nullptr ? " env=" : "",
+ extra_env != nullptr ? extra_env : "");
+ } else {
+ slog("WARNING: fork() failed; no client spawned for %s", label);
+ }
+ return pid;
+ };
+
+ if (r.demo) {
+ // The curated perf load: 3x foot + 1x firefox, one per inward-angled
+ // corner. firefox is steered to its designated corner at map time
+ // (claim_corner). If firefox is not installed / cannot connect, the
+ // NO-CLIENT/corner bookkeeping degrades gracefully — the terminals still
+ // fill their corners — and we log a loud warning here AND from the
+ // watchdog. We probe for the firefox binary first so the warning is loud
+ // even before any connection attempt.
+ slog("--demo: spawning the curated 4-window perf load (3x foot + 1x vivaldi), one per "
+ "inward-angled corner. HD-video-friendly: 120s default dead-man, FPS HUD + 5s "
+ "min/max fps log.");
+ spawn_client("foot", nullptr, "[foot 1/3]");
+ spawn_client("foot", nullptr, "[foot 2/3]");
+ spawn_client("foot", nullptr, "[foot 3/3]");
+ // vivaldi (Chromium): forced onto Wayland via --ozone-platform=wayland.
+ // Probe PATH for the binary (vivaldi-stable / vivaldi / vivaldi-snapshot)
+ // so a missing browser is a loud warning, not a silently-empty corner.
+ const char* vivaldi_bin = nullptr;
+ if (const char* path = getenv("PATH"); path != nullptr) {
+ static const char* const kNames[] = {"vivaldi-stable", "vivaldi", "vivaldi-snapshot"};
+ std::string p = path, dir;
+ std::size_t i = 0;
+ while (i <= p.size() && vivaldi_bin == nullptr) {
+ if (i == p.size() || p[i] == ':') {
+ for (const char* name : kNames) {
+ if (!dir.empty() && ::access((dir + "/" + name).c_str(), X_OK) == 0) {
+ vivaldi_bin = name;
+ break;
+ }
+ }
+ dir.clear();
+ } else {
+ dir.push_back(p[i]);
+ }
+ ++i;
+ }
+ }
+ if (vivaldi_bin != nullptr) {
+ const std::string cmd =
+ std::string(vivaldi_bin) + " --ozone-platform=wayland --ozone-platform-hint=auto";
+ spawn_client(cmd.c_str(), nullptr, "[vivaldi 1/1, Wayland]");
+ } else {
+ slog("*** WARNING: vivaldi NOT found on PATH (tried vivaldi-stable/vivaldi/"
+ "vivaldi-snapshot) — the browser corner (slot %d, %s) will stay empty. "
+ "Continuing with the 3 foot terminals. ***",
+ kFirefoxCorner, kCorners[kFirefoxCorner].name);
+ }
+ } else if (startup_cmd != nullptr && startup_cmd[0] != '\0') {
+ spawn_client(startup_cmd, nullptr, "[--run client]");
+ } else {
+ slog("no startup command — connect your own client to WAYLAND_DISPLAY=%s", socket);
+ }
+
+ // NO-CLIENT watchdog (~5s): if nothing maps a surface by then, scream loudly
+ // in the log so the "background+marker but no foot" case is unambiguous.
+ r.client_watchdog = wl_event_loop_add_timer(r.loop, [](void* data) {
+ auto* rr = static_cast<Runner*>(data);
+ if (!rr->any_surface_mapped) {
+ slog("*** NO CLIENT MAPPED — foot did not connect/render within ~5s. ***");
+ slog(" connects-so-far=%d. If 0: the client could NOT connect (wrong "
+ "WAYLAND_DISPLAY, client crash, or missing binary). If >0: it connected but "
+ "produced no buffer (missing fonts, GL/shm failure). Only background+marker "
+ "will show.",
+ rr->client_connects);
+ } else {
+ slog("client-mapped check OK: at least one surface mapped within ~5s.");
+ }
+ return 0; // one-shot
+ }, &r);
+ wl_event_source_timer_update(r.client_watchdog, 5000);
+
+ slog("entering event loop (wl_display_run)");
+ wl_display_run(r.display);
+ slog("event loop exited — tearing down cleanly (wlroots restores the VT to text mode)");
+
+ // Teardown. Disconnect every RAII Listener bound to a wlr signal BEFORE the
+ // wlr objects (cursor/backend/seat) are destroyed — a still-linked listener
+ // trips wlr_cursor_destroy's `wl_list_empty(listener_list)` assertion (the
+ // Runner's Listener members would otherwise unsubscribe only at Runner's
+ // destruction, AFTER these destroys). Also drop per-surface listeners.
+ for (LiveSurface& s : r.surfaces) {
+ s.map_l.disconnect();
+ s.unmap_l.disconnect();
+ s.commit_l.disconnect();
+ s.destroy_l.disconnect();
+ }
+ r.new_output_l.disconnect();
+ r.new_input_l.disconnect();
+ r.frame_l.disconnect();
+ r.new_toplevel_l.disconnect();
+ r.new_popup_l.disconnect();
+ r.new_layer_l.disconnect();
+ r.cursor_motion_l.disconnect();
+ r.cursor_motion_abs_l.disconnect();
+ r.cursor_button_l.disconnect();
+ r.cursor_axis_l.disconnect();
+ r.cursor_frame_l.disconnect();
+ r.touch_down_l.disconnect();
+ r.touch_up_l.disconnect();
+ r.touch_motion_l.disconnect();
+ r.seat_request_cursor_l.disconnect();
+ r.seat_pointer_focus_change_l.disconnect();
+ for (Keyboard& kb : r.keyboards) {
+ kb.key_l.disconnect();
+ kb.mods_l.disconnect();
+ kb.destroy_l.disconnect();
+ }
+ // The raw client-created wl_listener must not outlive the display.
+ wl_list_remove(&r.client_created_l.link);
+
+ const bool cur = r.gl.make_current();
+ for (LiveSurface& s : r.surfaces) {
+ s.live.destroy();
+ }
+ r.present.teardown();
+ if (r.ctx != nullptr) {
+ Rml::RemoveContext("run");
+ }
+ if (cur) {
+ r.gl.restore_current();
+ }
+ r.gl.teardown();
+ if (r.scene != nullptr) {
+ wlr_scene_node_destroy(&r.scene->tree.node);
+ }
+ if (r.cursor_mgr != nullptr) {
+ wlr_xcursor_manager_destroy(r.cursor_mgr);
+ }
+ if (r.cursor != nullptr) {
+ wlr_cursor_destroy(r.cursor);
+ }
+ if (r.allocator != nullptr) {
+ wlr_allocator_destroy(r.allocator);
+ }
+ if (r.renderer != nullptr) {
+ wlr_renderer_destroy(r.renderer);
+ }
+ if (r.backend != nullptr) {
+ wlr_backend_destroy(r.backend);
+ }
+ if (r.safety_timer != nullptr) {
+ wl_event_source_remove(r.safety_timer);
+ }
+ if (r.client_watchdog != nullptr) {
+ wl_event_source_remove(r.client_watchdog);
+ }
+ if (r.sigint_src != nullptr) {
+ wl_event_source_remove(r.sigint_src);
+ }
+ if (r.sigterm_src != nullptr) {
+ wl_event_source_remove(r.sigterm_src);
+ }
+ wl_display_destroy(r.display);
+ if (r.fps_log != nullptr) {
+ std::fflush(r.fps_log);
+ ::fsync(::fileno(r.fps_log));
+ std::fclose(r.fps_log);
+ r.fps_log = nullptr;
+ }
+ slog("=== rml-compositing-spike --%s EXIT 0 (VT restored) ===", demo ? "demo" : "run");
+ log_close();
+ return 0;
+}
diff --git a/packages/kernel/src/spike/spike_gl.hpp b/packages/kernel/src/spike/spike_gl.hpp
new file mode 100644
index 0000000..1ad6819
--- /dev/null
+++ b/packages/kernel/src/spike/spike_gl.hpp
@@ -0,0 +1,653 @@
+#pragma once
+
+// SPIKE (rml-compositing, Phase 0) — shared GL glue for the runnable target.
+// THROWAWAY. The sibling GLES 3.2 bridge, the LIVE zero-copy surface-element
+// import, and the RmlUi-FBO -> wlr_buffer present target, shared by the
+// --verify TU and the --run (real-seat) TU. A trimmed copy of the substrate's
+// proven GlBridge mechanics; we deliberately do NOT refactor the real substrate
+// to share it (this is a spike). wlroots only via the kernel's wrapper.
+
+#include <unbox/kernel/wlr.hpp>
+
+#include "../rmlui_renderer_gl3.h"
+
+#include <RmlUi/Core/Context.h>
+#include <RmlUi/Core/Core.h>
+#include <RmlUi/Core/SystemInterface.h>
+
+#include <EGL/egl.h>
+#include <EGL/eglext.h>
+#include <GLES2/gl2ext.h>
+#include <GLES3/gl32.h>
+
+#include <cstdint>
+#include <cstdio>
+#include <cstring>
+#include <ctime>
+#include <string>
+#include <unordered_map>
+#include <utility>
+#include <vector>
+
+namespace unbox::kernel::spike {
+
+constexpr std::uint32_t kArgb8888 = 0x34325241; // 'AR24' = LE {B,G,R,A}
+
+inline auto now_sec() -> double {
+ timespec ts{};
+ clock_gettime(CLOCK_MONOTONIC, &ts);
+ return static_cast<double>(ts.tv_sec) + static_cast<double>(ts.tv_nsec) / 1e9;
+}
+
+// --- RmlUi SystemInterface: elapsed time + logs to stderr --------------------
+class SpikeSystem final : public Rml::SystemInterface {
+public:
+ auto GetElapsedTime() -> double override {
+ const double t = now_sec();
+ if (start_ == 0.0) {
+ start_ = t;
+ }
+ return t - start_;
+ }
+ auto LogMessage(Rml::Log::Type type, const Rml::String& msg) -> bool override {
+ if (type <= Rml::Log::LT_WARNING) {
+ std::fprintf(stderr, "[rmlui] %s\n", msg.c_str());
+ }
+ return true;
+ }
+
+private:
+ double start_ = 0.0;
+};
+
+// --- A data-ptr wlr_buffer wrapping heap memory (Plan-B present / test src) ---
+struct DataBuffer {
+ wlr_buffer base{};
+ std::vector<std::uint8_t> data;
+ std::size_t stride = 0;
+};
+inline void db_destroy(wlr_buffer* b) {
+ auto* d = reinterpret_cast<DataBuffer*>(b);
+ wlr_buffer_finish(&d->base);
+ delete d;
+}
+inline bool db_access(wlr_buffer* b, std::uint32_t, void** data, std::uint32_t* fmt,
+ std::size_t* stride) {
+ auto* d = reinterpret_cast<DataBuffer*>(b);
+ *data = d->data.data();
+ *fmt = kArgb8888;
+ *stride = d->stride;
+ return true;
+}
+inline void db_end(wlr_buffer*) {}
+inline const wlr_buffer_impl kDataImpl = {
+ .destroy = db_destroy,
+ .get_dmabuf = nullptr,
+ .get_shm = nullptr,
+ .begin_data_ptr_access = db_access,
+ .end_data_ptr_access = db_end,
+};
+inline auto make_data_buffer(int w, int h) -> DataBuffer* {
+ auto* d = new DataBuffer();
+ d->stride = static_cast<std::size_t>(w) * 4;
+ d->data.assign(d->stride * static_cast<std::size_t>(h), 0);
+ wlr_buffer_init(&d->base, &kDataImpl, w, h);
+ return d;
+}
+
+// --- The sibling GLES 3.2 bridge on the wlr EGLDisplay ------------------------
+struct GlBridge {
+ EGLDisplay dpy = EGL_NO_DISPLAY;
+ EGLContext ctx = EGL_NO_CONTEXT;
+ EGLConfig config = nullptr;
+
+ EGLContext saved_ctx = EGL_NO_CONTEXT;
+ EGLSurface saved_draw = EGL_NO_SURFACE;
+ EGLSurface saved_read = EGL_NO_SURFACE;
+
+ SpikeSystem system;
+ RenderInterface_GL3* render = nullptr;
+ bool rml_init = false;
+ bool ok = false;
+ bool dmabuf_ok = false;
+ bool fence_ok = false;
+
+ PFNEGLCREATEIMAGEKHRPROC create_image = nullptr;
+ PFNEGLDESTROYIMAGEKHRPROC destroy_image = nullptr;
+ PFNGLEGLIMAGETARGETTEXTURE2DOESPROC image_target = nullptr;
+ PFNEGLCREATESYNCKHRPROC create_sync = nullptr;
+ PFNEGLCLIENTWAITSYNCKHRPROC wait_sync = nullptr;
+ PFNEGLDESTROYSYNCKHRPROC destroy_sync = nullptr;
+
+ // GPU timer queries (EXT_disjoint_timer_query) — Stage-0 perf instrumentation.
+ // Used to measure the REAL GPU cost of ctx->Render() without a glFinish stall
+ // (results read back a frame late, non-blocking). nullptr/false when absent.
+ bool timer_ok = false;
+ PFNGLGENQUERIESEXTPROC gen_queries = nullptr;
+ PFNGLDELETEQUERIESEXTPROC delete_queries = nullptr;
+ PFNGLBEGINQUERYEXTPROC begin_query = nullptr;
+ PFNGLENDQUERYEXTPROC end_query = nullptr;
+ PFNGLGETQUERYOBJECTUIVEXTPROC get_query_uiv = nullptr;
+ PFNGLGETQUERYOBJECTUI64VEXTPROC get_query_ui64v = nullptr;
+
+ auto make_current() -> bool {
+ saved_ctx = eglGetCurrentContext();
+ saved_draw = eglGetCurrentSurface(EGL_DRAW);
+ saved_read = eglGetCurrentSurface(EGL_READ);
+ return eglMakeCurrent(dpy, EGL_NO_SURFACE, EGL_NO_SURFACE, ctx) == EGL_TRUE;
+ }
+ void restore_current() { eglMakeCurrent(dpy, saved_draw, saved_read, saved_ctx); }
+
+ void submit_sync() {
+ if (fence_ok) {
+ EGLSyncKHR s = create_sync(dpy, EGL_SYNC_FENCE_KHR, nullptr);
+ if (s != EGL_NO_SYNC_KHR) {
+ glFlush();
+ wait_sync(dpy, s, 0, EGL_FOREVER_KHR);
+ destroy_sync(dpy, s);
+ return;
+ }
+ }
+ glFinish();
+ }
+
+ auto init(EGLDisplay display) -> bool {
+ dpy = display;
+ if (dpy == EGL_NO_DISPLAY || eglBindAPI(EGL_OPENGL_ES_API) != EGL_TRUE) {
+ return false;
+ }
+ const EGLint cfg_attrs[] = {EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, EGL_RENDERABLE_TYPE,
+ EGL_OPENGL_ES3_BIT, EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8,
+ EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8, EGL_NONE};
+ EGLint n = 0;
+ if (eglChooseConfig(dpy, cfg_attrs, &config, 1, &n) != EGL_TRUE || n < 1) {
+ return false;
+ }
+ const EGLint ctx_attrs[] = {EGL_CONTEXT_MAJOR_VERSION, 3, EGL_CONTEXT_MINOR_VERSION, 2,
+ EGL_NONE};
+ ctx = eglCreateContext(dpy, config, EGL_NO_CONTEXT, ctx_attrs);
+ if (ctx == EGL_NO_CONTEXT || !make_current()) {
+ return false;
+ }
+ create_image =
+ reinterpret_cast<PFNEGLCREATEIMAGEKHRPROC>(eglGetProcAddress("eglCreateImageKHR"));
+ destroy_image =
+ reinterpret_cast<PFNEGLDESTROYIMAGEKHRPROC>(eglGetProcAddress("eglDestroyImageKHR"));
+ image_target = reinterpret_cast<PFNGLEGLIMAGETARGETTEXTURE2DOESPROC>(
+ eglGetProcAddress("glEGLImageTargetTexture2DOES"));
+ const char* exts = eglQueryString(dpy, EGL_EXTENSIONS);
+ dmabuf_ok = exts != nullptr &&
+ std::strstr(exts, "EGL_EXT_image_dma_buf_import") != nullptr &&
+ create_image != nullptr && image_target != nullptr;
+ create_sync =
+ reinterpret_cast<PFNEGLCREATESYNCKHRPROC>(eglGetProcAddress("eglCreateSyncKHR"));
+ wait_sync =
+ reinterpret_cast<PFNEGLCLIENTWAITSYNCKHRPROC>(eglGetProcAddress("eglClientWaitSyncKHR"));
+ destroy_sync =
+ reinterpret_cast<PFNEGLDESTROYSYNCKHRPROC>(eglGetProcAddress("eglDestroySyncKHR"));
+ fence_ok = exts != nullptr && std::strstr(exts, "EGL_KHR_fence_sync") != nullptr &&
+ create_sync != nullptr && wait_sync != nullptr && destroy_sync != nullptr;
+
+ gen_queries =
+ reinterpret_cast<PFNGLGENQUERIESEXTPROC>(eglGetProcAddress("glGenQueriesEXT"));
+ delete_queries =
+ reinterpret_cast<PFNGLDELETEQUERIESEXTPROC>(eglGetProcAddress("glDeleteQueriesEXT"));
+ begin_query =
+ reinterpret_cast<PFNGLBEGINQUERYEXTPROC>(eglGetProcAddress("glBeginQueryEXT"));
+ end_query = reinterpret_cast<PFNGLENDQUERYEXTPROC>(eglGetProcAddress("glEndQueryEXT"));
+ get_query_uiv = reinterpret_cast<PFNGLGETQUERYOBJECTUIVEXTPROC>(
+ eglGetProcAddress("glGetQueryObjectuivEXT"));
+ get_query_ui64v = reinterpret_cast<PFNGLGETQUERYOBJECTUI64VEXTPROC>(
+ eglGetProcAddress("glGetQueryObjectui64vEXT"));
+ const char* gl_exts = reinterpret_cast<const char*>(glGetString(GL_EXTENSIONS));
+ timer_ok = gl_exts != nullptr &&
+ std::strstr(gl_exts, "GL_EXT_disjoint_timer_query") != nullptr &&
+ gen_queries != nullptr && delete_queries != nullptr && begin_query != nullptr &&
+ end_query != nullptr && get_query_uiv != nullptr && get_query_ui64v != nullptr;
+
+ if (!RmlGL3::Initialize(nullptr)) {
+ restore_current();
+ return false;
+ }
+ render = new RenderInterface_GL3();
+ if (!*render) {
+ restore_current();
+ return false;
+ }
+ Rml::SetSystemInterface(&system);
+ Rml::SetRenderInterface(render);
+ if (!Rml::Initialise()) {
+ restore_current();
+ return false;
+ }
+ rml_init = true;
+ if (!Rml::LoadFontFace("/usr/share/fonts/noto/NotoSans-Regular.ttf")) {
+ std::fprintf(stderr, "[spike] NotoSans not found; text labels will be blank\n");
+ }
+ restore_current();
+ ok = true;
+ std::fprintf(stderr, "[spike] GL bridge up (dmabuf_import=%d fence=%d gpu_timer=%d)\n",
+ dmabuf_ok, fence_ok, timer_ok);
+ return true;
+ }
+
+ void teardown() {
+ const bool cur = (ctx != EGL_NO_CONTEXT) && make_current();
+ if (rml_init) {
+ Rml::Shutdown();
+ rml_init = false;
+ }
+ delete render;
+ render = nullptr;
+ if (cur) {
+ restore_current();
+ }
+ if (ctx != EGL_NO_CONTEXT) {
+ eglDestroyContext(dpy, ctx);
+ ctx = EGL_NO_CONTEXT;
+ }
+ }
+};
+
+// --- A LIVE surface element: a client buffer imported zero-copy as a sampled
+// texture, registered under a URI, re-imported on each NEW surface commit. ---
+//
+// FROZEN-FRAME FIX. The re-import was gated on the wlr_buffer POINTER changing
+// (`buf == current`). That is WRONG for real clients: Wayland clients (foot)
+// recycle a SMALL POOL of buffers, and wlroots re-uses the SAME wlr_client_buffer
+// for a re-attached wl_buffer — so the identical pointer is re-committed with
+// BRAND-NEW contents. The pointer-equality early-return then wrongly skipped the
+// update and the displayed texture stayed stuck on buffer #1 (`commits=3` but
+// `reimports=1` in the headless log). The correct dirty signal is the surface's
+// COMMIT SEQUENCE (`wlr_surface_state.seq`), which increments on EVERY commit
+// regardless of pool reuse. We re-import whenever the seq advances, re-binding
+// the EGLImage to the current buffer (a live dmabuf view => new pixels) or
+// re-uploading for shm, so new contents show even on a reused buffer pointer.
+//
+// BUFFER LIFECYCLE. `surface->buffer` is a wlr_client_buffer (the renderer-side
+// import); wlroots has ALREADY released the client's underlying wl_buffer back
+// to its pool, so reading it never starves the client. We still LOCK the buffer
+// we are importing (so its dmabuf FDs stay valid while we build the EGLImage and
+// sample it) and UNLOCK the PREVIOUS one once the new import is live — a
+// double-buffered lock that mirrors wlroots' consumer lock/release discipline
+// and guarantees we never pin more than one buffer at a time.
+struct LiveTexture {
+ GlBridge* gl = nullptr;
+ std::string uri;
+ int width = 0, height = 0;
+ wlr_buffer* current = nullptr; // the buffer currently imported + LOCKED
+ std::uint32_t current_seq = 0; // surface commit seq of `current`
+ bool have_seq = false; // false until the first adopt()
+ EGLImageKHR image = EGL_NO_IMAGE_KHR;
+ GLuint tex = 0;
+ bool is_dmabuf = false;
+ int reimports = 0;
+ int commits_seen = 0;
+
+ // Re-import the surface's CURRENT committed buffer for commit sequence `seq`.
+ // `seq` MUST be the surface's wlr_surface_state.seq (advances every commit) —
+ // NOT the buffer pointer, which a pooled client recycles. Returns true if the
+ // sampled texture reflects the current buffer afterwards.
+ auto adopt(wlr_buffer* buf, std::uint32_t seq) -> bool {
+ ++commits_seen;
+ // Idle gate: a static client never commits, so its seq never advances and
+ // we do zero work (the dirty-gate stays intact). A re-committed buffer —
+ // even the SAME pointer with new contents — bumps seq and re-imports.
+ if (have_seq && seq == current_seq && buf == current && tex != 0) {
+ return true; // truly unchanged surface state: zero re-import, zero copy
+ }
+ // Lock the buffer we are about to sample so its storage (dmabuf FDs / shm)
+ // stays valid for the whole import+sample; unlock the PREVIOUS one once the
+ // new import is live (double-buffered: at most one buffer pinned).
+ wlr_buffer* prev = current;
+ wlr_buffer_lock(buf);
+ wlr_dmabuf_attributes attrs{};
+ if (gl->dmabuf_ok && wlr_buffer_get_dmabuf(buf, &attrs) && attrs.n_planes >= 1) {
+ EGLint ia[] = {EGL_WIDTH,
+ attrs.width,
+ EGL_HEIGHT,
+ attrs.height,
+ EGL_LINUX_DRM_FOURCC_EXT,
+ static_cast<EGLint>(attrs.format),
+ EGL_DMA_BUF_PLANE0_FD_EXT,
+ attrs.fd[0],
+ EGL_DMA_BUF_PLANE0_OFFSET_EXT,
+ static_cast<EGLint>(attrs.offset[0]),
+ EGL_DMA_BUF_PLANE0_PITCH_EXT,
+ static_cast<EGLint>(attrs.stride[0]),
+ EGL_NONE};
+ EGLImageKHR img =
+ gl->create_image(gl->dpy, EGL_NO_CONTEXT, EGL_LINUX_DMA_BUF_EXT, nullptr, ia);
+ if (img != EGL_NO_IMAGE_KHR) {
+ release_gl();
+ glGenTextures(1, &tex);
+ glBindTexture(GL_TEXTURE_2D, tex);
+ gl->image_target(GL_TEXTURE_2D, static_cast<GLeglImageOES>(img));
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
+ glBindTexture(GL_TEXTURE_2D, 0);
+ image = img;
+ width = attrs.width;
+ height = attrs.height;
+ is_dmabuf = true;
+ adopt_commit(prev, buf, seq);
+ register_uri();
+ return true;
+ }
+ }
+ // Fallback: one CPU upload for an shm client.
+ 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 false;
+ }
+ release_gl();
+ glGenTextures(1, &tex);
+ glBindTexture(GL_TEXTURE_2D, tex);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_R, GL_BLUE);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_B, GL_RED);
+ glPixelStorei(GL_UNPACK_ROW_LENGTH, static_cast<GLint>(stride / 4));
+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, buf->width, buf->height, 0, GL_RGBA,
+ GL_UNSIGNED_BYTE, data);
+ glPixelStorei(GL_UNPACK_ROW_LENGTH, 0);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
+ glBindTexture(GL_TEXTURE_2D, 0);
+ wlr_buffer_end_data_ptr_access(buf);
+ width = buf->width;
+ height = buf->height;
+ is_dmabuf = false;
+ adopt_commit(prev, buf, seq);
+ register_uri();
+ return true;
+ }
+
+ // Commit a successful import: adopt `buf` (already locked) at sequence `seq`
+ // and release the PREVIOUSLY-locked buffer (double-buffered lock). Counts a
+ // reimport. NB: prev may equal buf when a pooled client re-commits the same
+ // pointer with new contents — lock/unlock balance still holds (net +1 then
+ // -1 => the single live lock we took above for THIS adopt).
+ void adopt_commit(wlr_buffer* prev, wlr_buffer* buf, std::uint32_t seq) {
+ current = buf;
+ current_seq = seq;
+ have_seq = true;
+ ++reimports;
+ if (prev != nullptr) {
+ wlr_buffer_unlock(prev);
+ }
+ }
+
+ void register_uri() {
+ gl->render->register_preview_texture(uri, tex, Rml::Vector2i(width, height));
+ }
+ void release_gl() {
+ if (tex != 0) {
+ glDeleteTextures(1, &tex);
+ tex = 0;
+ }
+ if (image != EGL_NO_IMAGE_KHR && gl->destroy_image != nullptr) {
+ gl->destroy_image(gl->dpy, image);
+ image = EGL_NO_IMAGE_KHR;
+ }
+ }
+ void destroy() {
+ if (gl != nullptr && gl->render != nullptr) {
+ gl->render->unregister_preview_texture(uri);
+ }
+ release_gl();
+ if (current != nullptr) {
+ wlr_buffer_unlock(current); // release the buffer we held locked
+ current = nullptr;
+ }
+ have_seq = false;
+ current_seq = 0;
+ }
+};
+
+// --- The RmlUi-FBO -> wlr_buffer present target (criterion 7) -----------------
+// Stage-0 per-frame budget breakdown (milliseconds). CPU phases are wall-clock
+// around the GL calls (the submit cost, not the GPU work); `gpu_ms` is the REAL
+// GPU time of ctx->Render() from a timer query, read back a frame late so it
+// never stalls the pipeline (-1 until the first result lands / if unsupported).
+struct RenderTimings {
+ double clear_ms = 0.0;
+ double update_ms = 0.0;
+ double render_ms = 0.0; // CPU submit time of BeginFrame+Render+EndFrame
+ double present_ms = 0.0;
+ double gpu_ms = -1.0;
+};
+
+struct PresentTarget {
+ GlBridge* gl = nullptr;
+ wlr_allocator* allocator = nullptr;
+ int width = 0, height = 0;
+ bool dmabuf = false;
+
+ GLuint fbo = 0;
+ GLuint shm_tex = 0;
+ wlr_swapchain* swapchain = nullptr;
+ std::unordered_map<wlr_buffer*, std::pair<EGLImageKHR, GLuint>> slot_gl;
+
+ // GPU timer-query ring (2-deep): begin/end around ctx->Render() each frame,
+ // read the OTHER slot's result non-blocking so the answer is one frame late
+ // but never serializes the GPU. last_gpu_ms holds the most recent reading.
+ GLuint gpu_q[2] = {0, 0};
+ bool gpu_q_active[2] = {false, false};
+ int gpu_q_write = 0;
+ double last_gpu_ms = -1.0;
+
+ DataBuffer* shm = nullptr;
+ std::vector<std::uint8_t> readback;
+
+ wlr_scene_buffer* scene_buffer = nullptr;
+
+ auto init(GlBridge* g, wlr_allocator* alloc, int w, int h) -> bool {
+ gl = g;
+ allocator = alloc;
+ width = w;
+ height = h;
+ glGenFramebuffers(1, &fbo);
+ if (gl->timer_ok) {
+ gl->gen_queries(2, gpu_q);
+ }
+ if (gl->dmabuf_ok && (allocator->buffer_caps & WLR_BUFFER_CAP_DMABUF) != 0) {
+ wlr_drm_format fmt{};
+ fmt.format = kArgb8888;
+ std::uint64_t mods[] = {0};
+ fmt.len = 1;
+ fmt.capacity = 1;
+ fmt.modifiers = mods;
+ swapchain = wlr_swapchain_create(allocator, w, h, &fmt);
+ if (swapchain != nullptr) {
+ dmabuf = true;
+ }
+ }
+ if (!dmabuf) {
+ glGenTextures(1, &shm_tex);
+ glBindTexture(GL_TEXTURE_2D, shm_tex);
+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
+ glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+ glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, shm_tex, 0);
+ const GLenum st = glCheckFramebufferStatus(GL_FRAMEBUFFER);
+ glBindFramebuffer(GL_FRAMEBUFFER, 0);
+ if (st != GL_FRAMEBUFFER_COMPLETE) {
+ return false;
+ }
+ shm = make_data_buffer(w, h);
+ readback.assign(static_cast<std::size_t>(w) * h * 4, 0);
+ }
+ return true;
+ }
+
+ auto render(Rml::Context* ctx, RenderTimings* tm = nullptr) -> wlr_buffer* {
+ GLuint target = fbo;
+ wlr_buffer* dmabuf_target = nullptr;
+ if (dmabuf) {
+ wlr_buffer* buf = wlr_swapchain_acquire(swapchain);
+ if (buf == nullptr) {
+ return nullptr;
+ }
+ dmabuf_target = buf;
+ auto it = slot_gl.find(buf);
+ if (it == slot_gl.end()) {
+ wlr_dmabuf_attributes a{};
+ if (!wlr_buffer_get_dmabuf(buf, &a) || a.n_planes < 1) {
+ wlr_buffer_unlock(buf);
+ return nullptr;
+ }
+ EGLint ia[] = {EGL_WIDTH,
+ a.width,
+ EGL_HEIGHT,
+ a.height,
+ EGL_LINUX_DRM_FOURCC_EXT,
+ static_cast<EGLint>(a.format),
+ EGL_DMA_BUF_PLANE0_FD_EXT,
+ a.fd[0],
+ EGL_DMA_BUF_PLANE0_OFFSET_EXT,
+ static_cast<EGLint>(a.offset[0]),
+ EGL_DMA_BUF_PLANE0_PITCH_EXT,
+ static_cast<EGLint>(a.stride[0]),
+ EGL_NONE};
+ EGLImageKHR img =
+ gl->create_image(gl->dpy, EGL_NO_CONTEXT, EGL_LINUX_DMA_BUF_EXT, nullptr, ia);
+ if (img == EGL_NO_IMAGE_KHR) {
+ wlr_buffer_unlock(buf);
+ return nullptr;
+ }
+ GLuint t = 0;
+ glGenTextures(1, &t);
+ glBindTexture(GL_TEXTURE_2D, t);
+ gl->image_target(GL_TEXTURE_2D, static_cast<GLeglImageOES>(img));
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
+ it = slot_gl.emplace(buf, std::make_pair(img, t)).first;
+ }
+ glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+ glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
+ it->second.second, 0);
+ if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
+ glBindFramebuffer(GL_FRAMEBUFFER, 0);
+ wlr_buffer_unlock(buf);
+ return nullptr;
+ }
+ glBindFramebuffer(GL_FRAMEBUFFER, 0);
+ }
+
+ const double t_clear0 = now_sec();
+ gl->render->SetViewport(width, height);
+ gl->render->SetOutputFramebuffer(target, /*flip_y=*/true);
+ glBindFramebuffer(GL_FRAMEBUFFER, target);
+ glClearColor(0.f, 0.f, 0.f, 0.f);
+ glClear(GL_COLOR_BUFFER_BIT);
+ glBindFramebuffer(GL_FRAMEBUFFER, 0);
+
+ const double t_update0 = now_sec();
+ ctx->Update();
+
+ // Drain the previous frame's GPU timer (non-blocking) before opening a new
+ // one, then bracket the actual draw (BeginFrame..EndFrame == the fill).
+ if (gl->timer_ok) {
+ const int prev = gpu_q_write ^ 1;
+ if (gpu_q_active[prev]) {
+ GLuint avail = 0;
+ gl->get_query_uiv(gpu_q[prev], GL_QUERY_RESULT_AVAILABLE_EXT, &avail);
+ if (avail != 0) {
+ GLuint64 ns = 0;
+ gl->get_query_ui64v(gpu_q[prev], GL_QUERY_RESULT_EXT, &ns);
+ last_gpu_ms = static_cast<double>(ns) / 1.0e6;
+ gpu_q_active[prev] = false;
+ }
+ }
+ gl->begin_query(GL_TIME_ELAPSED_EXT, gpu_q[gpu_q_write]);
+ }
+ const double t_render0 = now_sec();
+ gl->render->BeginFrame();
+ ctx->Render();
+ gl->render->EndFrame();
+ if (gl->timer_ok) {
+ gl->end_query(GL_TIME_ELAPSED_EXT);
+ gpu_q_active[gpu_q_write] = true;
+ gpu_q_write ^= 1;
+ }
+ const double t_present0 = now_sec();
+ if (tm != nullptr) {
+ tm->clear_ms = (t_update0 - t_clear0) * 1000.0;
+ tm->update_ms = (t_render0 - t_update0) * 1000.0;
+ tm->render_ms = (t_present0 - t_render0) * 1000.0;
+ tm->gpu_ms = last_gpu_ms;
+ }
+
+ if (dmabuf) {
+ gl->submit_sync();
+ if (scene_buffer != nullptr) {
+ wlr_scene_buffer_set_buffer(scene_buffer, dmabuf_target);
+ }
+ wlr_buffer_unlock(dmabuf_target);
+ if (tm != nullptr) {
+ tm->present_ms = (now_sec() - t_present0) * 1000.0;
+ }
+ return dmabuf_target;
+ }
+ glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+ glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, readback.data());
+ glBindFramebuffer(GL_FRAMEBUFFER, 0);
+ const std::size_t px = static_cast<std::size_t>(width) * height;
+ for (std::size_t i = 0; i < px; ++i) {
+ shm->data[i * 4 + 0] = readback[i * 4 + 2];
+ shm->data[i * 4 + 1] = readback[i * 4 + 1];
+ shm->data[i * 4 + 2] = readback[i * 4 + 0];
+ shm->data[i * 4 + 3] = readback[i * 4 + 3];
+ }
+ if (scene_buffer != nullptr) {
+ wlr_scene_buffer_set_buffer(scene_buffer, &shm->base);
+ }
+ if (tm != nullptr) {
+ tm->present_ms = (now_sec() - t_present0) * 1000.0;
+ }
+ return &shm->base;
+ }
+
+ void pixel(int x, int y, std::uint8_t out[4]) {
+ glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+ glReadPixels(x, y, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, out);
+ glBindFramebuffer(GL_FRAMEBUFFER, 0);
+ }
+
+ void teardown() {
+ for (auto& [buf, slot] : slot_gl) {
+ if (slot.second != 0) {
+ glDeleteTextures(1, &slot.second);
+ }
+ if (slot.first != EGL_NO_IMAGE_KHR && gl->destroy_image != nullptr) {
+ gl->destroy_image(gl->dpy, slot.first);
+ }
+ }
+ slot_gl.clear();
+ if (gl != nullptr && gl->timer_ok && gpu_q[0] != 0) {
+ gl->delete_queries(2, gpu_q);
+ }
+ if (shm_tex != 0) {
+ glDeleteTextures(1, &shm_tex);
+ }
+ if (fbo != 0) {
+ glDeleteFramebuffers(1, &fbo);
+ }
+ if (swapchain != nullptr) {
+ wlr_swapchain_destroy(swapchain);
+ }
+ if (shm != nullptr) {
+ wlr_buffer_drop(&shm->base);
+ }
+ }
+};
+
+} // namespace unbox::kernel::spike
diff --git a/packages/kernel/src/spike/spike_input_core.hpp b/packages/kernel/src/spike/spike_input_core.hpp
new file mode 100644
index 0000000..6e6fd4e
--- /dev/null
+++ b/packages/kernel/src/spike/spike_input_core.hpp
@@ -0,0 +1,224 @@
+#pragma once
+
+#include <array>
+#include <cmath>
+#include <optional>
+
+// SPIKE (rml-compositing, Phase 0) — PURE input-inversion core. NO wlroots / GL
+// / RMLUi types, so it is doctest-able with nothing running (AGENTS.md: pure
+// decision cores tested hard). Throwaway: proves the MATH that criterion 3
+// stands on — translating a point picked on a 3D-transformed surface element
+// back to surface-LOCAL coordinates, which then becomes a wl_seat notify.
+//
+// Why this exists separately from "RmlUi does the picking for us": RmlUi's
+// Context::ProcessMouse*/ProcessTouch* DO the transform-aware hit-test and report
+// the event's mouse_x/mouse_y already in element/surface-local space (the
+// substrate's ctx_motion proves this — it feeds context coords relative to the
+// surface origin and reads mouse_x/mouse_y straight back as surface-local px).
+// The spike still owns the FORWARD projection: to TEST that round trip
+// objectively without eyes, it must (a) place a surface-local point, (b) project
+// it THROUGH the same 3D transform RCSS applies to find where it lands on the
+// flat output (the "screen" point a finger would touch), then (c) confirm the
+// inverse recovers the original surface-local point. If forward∘inverse is
+// identity to sub-pixel tolerance through a perspective+rotateY, the geometry
+// criterion 3 needs is sound; the live wiring (RmlUi pick -> wl_seat) is then a
+// thin call proven at runtime in the GL spike.
+//
+// Everything is column-vector math with COLUMN-MAJOR 4x4 matrices, matching the
+// convention RmlUi's Matrix4f uses for `transform` (so a matrix authored here
+// maps 1:1 onto an RCSS transform when cross-checked). Single-thread; no state.
+
+namespace unbox::kernel::spike {
+
+// A column-major 4x4 matrix: m[col*4 + row]. v' = M * v.
+struct Mat4 {
+ std::array<double, 16> m{};
+
+ static auto identity() -> Mat4 {
+ Mat4 r;
+ r.m = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1};
+ return r;
+ }
+
+ auto at(int row, int col) const -> double { return m[static_cast<std::size_t>(col) * 4 + row]; }
+ auto at(int row, int col) -> double& { return m[static_cast<std::size_t>(col) * 4 + row]; }
+};
+
+// Column-major multiply: returns A*B.
+inline auto mul(const Mat4& a, const Mat4& b) -> Mat4 {
+ Mat4 r;
+ for (int col = 0; col < 4; ++col) {
+ for (int row = 0; row < 4; ++row) {
+ double s = 0.0;
+ for (int k = 0; k < 4; ++k) {
+ s += a.at(row, k) * b.at(k, col);
+ }
+ r.at(row, col) = s;
+ }
+ }
+ return r;
+}
+
+// A homogeneous 4-vector.
+struct Vec4 {
+ double x{}, y{}, z{}, w{};
+};
+
+inline auto apply(const Mat4& mtx, const Vec4& v) -> Vec4 {
+ return Vec4{
+ mtx.at(0, 0) * v.x + mtx.at(0, 1) * v.y + mtx.at(0, 2) * v.z + mtx.at(0, 3) * v.w,
+ mtx.at(1, 0) * v.x + mtx.at(1, 1) * v.y + mtx.at(1, 2) * v.z + mtx.at(1, 3) * v.w,
+ mtx.at(2, 0) * v.x + mtx.at(2, 1) * v.y + mtx.at(2, 2) * v.z + mtx.at(2, 3) * v.w,
+ mtx.at(3, 0) * v.x + mtx.at(3, 1) * v.y + mtx.at(3, 2) * v.z + mtx.at(3, 3) * v.w,
+ };
+}
+
+// ---- RCSS-equivalent transform builders (column-major) ----------------------
+
+// CSS `perspective(d)`: m[3][2] = -1/d (column-major: at(3,2)). A point at
+// model-z is foreshortened by w = 1 - z/d after the divide.
+inline auto perspective(double d) -> Mat4 {
+ Mat4 r = Mat4::identity();
+ r.at(3, 2) = -1.0 / d;
+ return r;
+}
+
+// CSS `rotateY(theta)` (radians). Right-handed about +Y.
+inline auto rotate_y(double theta) -> Mat4 {
+ Mat4 r = Mat4::identity();
+ const double c = std::cos(theta);
+ const double s = std::sin(theta);
+ r.at(0, 0) = c;
+ r.at(0, 2) = s;
+ r.at(2, 0) = -s;
+ r.at(2, 2) = c;
+ return r;
+}
+
+// CSS `translate(tx,ty)` in the XY plane.
+inline auto translate(double tx, double ty) -> Mat4 {
+ Mat4 r = Mat4::identity();
+ r.at(0, 3) = tx;
+ r.at(1, 3) = ty;
+ return r;
+}
+
+// ---- The transform RCSS actually applies around transform-origin -------------
+//
+// RCSS resolves `transform` about `transform-origin` (default 50% 50%): it
+// translates the origin to (0,0), applies the listed functions, then translates
+// back. This builds that full operator for a surface element of size w*h with
+// the given origin, so the math matches what RmlUi computes for the element.
+inline auto rcss_transform_about_origin(const Mat4& t, double origin_x, double origin_y) -> Mat4 {
+ return mul(translate(origin_x, origin_y), mul(t, translate(-origin_x, -origin_y)));
+}
+
+// ---- Forward projection: surface-local (lx,ly) -> screen point ---------------
+//
+// Place a surface-local point on the z=0 plane, push it through the element
+// transform, perform the perspective divide, and return the on-screen (sx,sy)
+// where a finger/cursor would land. This is the point the GL spike feeds to
+// RmlUi's ProcessMouse*/ProcessTouch*.
+struct ScreenPoint {
+ double x{}, y{};
+};
+
+inline auto project_to_screen(const Mat4& transform, double lx, double ly) -> ScreenPoint {
+ const Vec4 clip = apply(transform, Vec4{lx, ly, 0.0, 1.0});
+ const double inv_w = (std::abs(clip.w) < 1e-12) ? 0.0 : 1.0 / clip.w;
+ return ScreenPoint{clip.x * inv_w, clip.y * inv_w};
+}
+
+// ---- Inverse: screen point -> surface-local (lx,ly) --------------------------
+//
+// Inverting the projection is a ray/plane intersection (the transform is not
+// affine under perspective). We invert the 4x4 transform, take the screen point
+// as a clip-space ray (two points at different homogeneous depths), transform
+// both back to model space, and intersect the resulting model-space ray with
+// the element's own z=0 plane. The intersection's (x,y) is the surface-local
+// coordinate. Returns nullopt if the transform is singular or the ray is
+// parallel to the plane (degenerate edge-on view).
+
+// General 4x4 inverse (column-major). nullopt if |det| ~ 0.
+inline auto invert(const Mat4& a) -> std::optional<Mat4> {
+ const std::array<double, 16>& s = a.m;
+ std::array<double, 16> inv{};
+
+ inv[0] = s[5] * s[10] * s[15] - s[5] * s[11] * s[14] - s[9] * s[6] * s[15] +
+ s[9] * s[7] * s[14] + s[13] * s[6] * s[11] - s[13] * s[7] * s[10];
+ inv[4] = -s[4] * s[10] * s[15] + s[4] * s[11] * s[14] + s[8] * s[6] * s[15] -
+ s[8] * s[7] * s[14] - s[12] * s[6] * s[11] + s[12] * s[7] * s[10];
+ inv[8] = s[4] * s[9] * s[15] - s[4] * s[11] * s[13] - s[8] * s[5] * s[15] +
+ s[8] * s[7] * s[13] + s[12] * s[5] * s[11] - s[12] * s[7] * s[9];
+ inv[12] = -s[4] * s[9] * s[14] + s[4] * s[10] * s[13] + s[8] * s[5] * s[14] -
+ s[8] * s[6] * s[13] - s[12] * s[5] * s[10] + s[12] * s[6] * s[9];
+ inv[1] = -s[1] * s[10] * s[15] + s[1] * s[11] * s[14] + s[9] * s[2] * s[15] -
+ s[9] * s[3] * s[14] - s[13] * s[2] * s[11] + s[13] * s[3] * s[10];
+ inv[5] = s[0] * s[10] * s[15] - s[0] * s[11] * s[14] - s[8] * s[2] * s[15] +
+ s[8] * s[3] * s[14] + s[12] * s[2] * s[11] - s[12] * s[3] * s[10];
+ inv[9] = -s[0] * s[9] * s[15] + s[0] * s[11] * s[13] + s[8] * s[1] * s[15] -
+ s[8] * s[3] * s[13] - s[12] * s[1] * s[11] + s[12] * s[3] * s[9];
+ inv[13] = s[0] * s[9] * s[14] - s[0] * s[10] * s[13] - s[8] * s[1] * s[14] +
+ s[8] * s[2] * s[13] + s[12] * s[1] * s[10] - s[12] * s[2] * s[9];
+ inv[2] = s[1] * s[6] * s[15] - s[1] * s[7] * s[14] - s[5] * s[2] * s[15] +
+ s[5] * s[3] * s[14] + s[13] * s[2] * s[7] - s[13] * s[3] * s[6];
+ inv[6] = -s[0] * s[6] * s[15] + s[0] * s[7] * s[14] + s[4] * s[2] * s[15] -
+ s[4] * s[3] * s[14] - s[12] * s[2] * s[7] + s[12] * s[3] * s[6];
+ inv[10] = s[0] * s[5] * s[15] - s[0] * s[7] * s[13] - s[4] * s[1] * s[15] +
+ s[4] * s[3] * s[13] + s[12] * s[1] * s[7] - s[12] * s[3] * s[5];
+ inv[14] = -s[0] * s[5] * s[14] + s[0] * s[6] * s[13] + s[4] * s[1] * s[14] -
+ s[4] * s[2] * s[13] - s[12] * s[1] * s[6] + s[12] * s[2] * s[5];
+ inv[3] = -s[1] * s[6] * s[11] + s[1] * s[7] * s[10] + s[5] * s[2] * s[11] -
+ s[5] * s[3] * s[10] - s[9] * s[2] * s[7] + s[9] * s[3] * s[6];
+ inv[7] = s[0] * s[6] * s[11] - s[0] * s[7] * s[10] - s[4] * s[2] * s[11] +
+ s[4] * s[3] * s[10] + s[8] * s[2] * s[7] - s[8] * s[3] * s[6];
+ inv[11] = -s[0] * s[5] * s[11] + s[0] * s[7] * s[9] + s[4] * s[1] * s[11] -
+ s[4] * s[3] * s[9] - s[8] * s[1] * s[7] + s[8] * s[3] * s[5];
+ inv[15] = s[0] * s[5] * s[10] - s[0] * s[6] * s[9] - s[4] * s[1] * s[10] +
+ s[4] * s[2] * s[9] + s[8] * s[1] * s[6] - s[8] * s[2] * s[5];
+
+ double det = s[0] * inv[0] + s[1] * inv[4] + s[2] * inv[8] + s[3] * inv[12];
+ if (std::abs(det) < 1e-12) {
+ return std::nullopt;
+ }
+ det = 1.0 / det;
+ Mat4 r;
+ for (int i = 0; i < 16; ++i) {
+ r.m[static_cast<std::size_t>(i)] = inv[static_cast<std::size_t>(i)] * det;
+ }
+ return r;
+}
+
+struct LocalPoint {
+ double x{}, y{};
+};
+
+// Unproject a screen point through `transform` back onto the element's z=0
+// plane. `transform` is the same forward operator used by project_to_screen
+// (RCSS transform about origin). Returns the surface-local (lx,ly).
+inline auto unproject_to_local(const Mat4& transform, double sx, double sy)
+ -> std::optional<LocalPoint> {
+ const std::optional<Mat4> inv = invert(transform);
+ if (!inv) {
+ return std::nullopt;
+ }
+ // Two clip-space points along the viewing ray at the screen pixel: clip-z
+ // is free under an orthographic screen, so pick z=0 and z=1 (homogeneous
+ // w=1) and map both back to model space, then intersect with model z=0.
+ const Vec4 a = apply(*inv, Vec4{sx, sy, 0.0, 1.0});
+ const Vec4 b = apply(*inv, Vec4{sx, sy, 1.0, 1.0});
+ const auto dehom = [](const Vec4& v) -> Vec4 {
+ const double iw = (std::abs(v.w) < 1e-12) ? 0.0 : 1.0 / v.w;
+ return Vec4{v.x * iw, v.y * iw, v.z * iw, 1.0};
+ };
+ const Vec4 pa = dehom(a);
+ const Vec4 pb = dehom(b);
+ const double dz = pb.z - pa.z;
+ if (std::abs(dz) < 1e-12) {
+ return std::nullopt; // ray parallel to the element plane
+ }
+ const double t = (0.0 - pa.z) / dz; // param where the ray crosses z=0
+ return LocalPoint{pa.x + (pb.x - pa.x) * t, pa.y + (pb.y - pa.y) * t};
+}
+
+} // namespace unbox::kernel::spike
diff --git a/packages/kernel/tests/test_kernel.cpp b/packages/kernel/tests/test_kernel.cpp
index da2cbf0..678bdee 100644
--- a/packages/kernel/tests/test_kernel.cpp
+++ b/packages/kernel/tests/test_kernel.cpp
@@ -15,6 +15,15 @@
#include "../src/ui_core.hpp"
// The VT-switch escape hatch's pure core (keysym -> VT number), no wlroots.
#include "../src/vt_core.hpp"
+// SPIKE (rml-compositing, Phase 0): the throwaway spike's PURE input-inversion
+// core (screen-point -> surface-local through a 3D transform). Header-only, no
+// wlroots/GL/RMLUi, so the criterion-3 geometry is doctest-ed here alongside the
+// runnable target's own headless self-check (src/spike/). Kept in the kernel
+// suite so the spike's geometry stays green with the unit.
+#include "../src/spike/spike_input_core.hpp"
+
+#include <cmath>
+#include <numbers>
#include <cstdlib>
#include <filesystem>
@@ -2555,3 +2564,92 @@ TEST_CASE("ui: transition_timing reads RCSS duration/delay + tween, resolves pro
// (5) Unparseable property name => nullopt (no exact match, no `all` here).
CHECK_FALSE(s->transition_timing("anim", "not-a-real-property").has_value());
}
+
+// ============================================================================
+// SPIKE (rml-compositing, Phase 0) — PURE input-inversion core (criterion 3).
+// The runnable spike target (src/spike/) self-checks the live-texture / 3D
+// transform / present / idle-gate headless; THIS unit-tests the screen-point ->
+// (surface element, surface-local coord) inversion through a known transform —
+// the math the runtime RmlUi-pick -> wl_seat translation rides on. Throwaway,
+// but kept green with the kernel: a regressed inverse would silently mis-route
+// touch on a tilted window, the exact failure criterion 3 guards against.
+// ============================================================================
+
+namespace {
+namespace spk = unbox::kernel::spike;
+
+// Forward-project a surface-local point through `t`, then invert; assert the
+// round trip recovers the original to sub-pixel. err in pixels.
+auto roundtrip_err(const spk::Mat4& t, double lx, double ly) -> double {
+ const spk::ScreenPoint s = spk::project_to_screen(t, lx, ly);
+ const auto back = spk::unproject_to_local(t, s.x, s.y);
+ if (!back) {
+ return 1e9;
+ }
+ return std::hypot(back->x - lx, back->y - ly);
+}
+} // namespace
+
+TEST_CASE("spike(rml-compositing): screen->surface-local inverts an affine transform") {
+ // A plain translate (no perspective): the inverse must be exact everywhere.
+ const spk::Mat4 t = spk::translate(120.0, -40.0);
+ CHECK(roundtrip_err(t, 0.0, 0.0) < 1e-9);
+ CHECK(roundtrip_err(t, 200.0, 150.0) < 1e-9);
+ // The forward map is a pure offset: a local (10,10) lands at (130,-30).
+ const spk::ScreenPoint s = spk::project_to_screen(t, 10.0, 10.0);
+ CHECK(s.x == doctest::Approx(130.0));
+ CHECK(s.y == doctest::Approx(-30.0));
+}
+
+TEST_CASE("spike(rml-compositing): inverts perspective + rotateY about the element origin") {
+ // The criterion-3 case: a 256x256 surface element with perspective(800) +
+ // rotateY, resolved about the 50% origin (what RCSS computes). The inverse is
+ // a ray/plane intersection (non-affine under perspective); assert sub-0.01px
+ // recovery across the element, including off-center points that foreshorten.
+ const double origin = 128.0;
+ for (double deg : {15.0, 35.0, 60.0, -45.0}) {
+ const spk::Mat4 t = spk::rcss_transform_about_origin(
+ spk::mul(spk::perspective(800.0),
+ spk::rotate_y(deg * std::numbers::pi / 180.0)),
+ origin, origin);
+ CHECK(roundtrip_err(t, 128.0, 128.0) < 1e-6); // center: on the rotation axis
+ CHECK(roundtrip_err(t, 32.0, 64.0) < 0.01); // near edge (foreshortened)
+ CHECK(roundtrip_err(t, 224.0, 200.0) < 0.01); // far edge
+ CHECK(roundtrip_err(t, 64.0, 96.0) < 0.01); // arbitrary interior point
+ }
+}
+
+TEST_CASE("spike(rml-compositing): the inverse is the true matrix inverse (M*inv ~ I)") {
+ // The unprojection's correctness rests on invert(): assert inv(M)*M is the
+ // identity for the perspective+rotateY operator (the non-trivial case). This
+ // is the algebraic backstop under the geometric round-trip tests above.
+ const double origin = 128.0;
+ const spk::Mat4 m = spk::rcss_transform_about_origin(
+ spk::mul(spk::perspective(800.0), spk::rotate_y(40.0 * std::numbers::pi / 180.0)), origin,
+ origin);
+ const auto inv = spk::invert(m);
+ REQUIRE(inv.has_value());
+ const spk::Mat4 prod = spk::mul(*inv, m);
+ for (int r = 0; r < 4; ++r) {
+ for (int c = 0; c < 4; ++c) {
+ CHECK(prod.at(r, c) == doctest::Approx(r == c ? 1.0 : 0.0).epsilon(1e-9));
+ }
+ }
+}
+
+TEST_CASE("spike(rml-compositing): an edge-on (90deg) transform collapses the element to a line") {
+ // rotateY(90deg) about the origin turns the element edge-on: its plane
+ // projects to a vertical LINE on screen, so distinct surface-local points
+ // collapse to (nearly) the same screen x — there is no reliable preimage. We
+ // assert the GEOMETRIC truth (the forward map is degenerate) rather than a
+ // particular inverse return: at runtime RmlUi's own transform-aware pick is
+ // what declines an edge-on element, so the spike never has to invert one.
+ const double origin = 128.0;
+ const spk::Mat4 t = spk::rcss_transform_about_origin(
+ spk::mul(spk::perspective(800.0), spk::rotate_y(std::numbers::pi / 2.0)), origin, origin);
+ const spk::ScreenPoint a = spk::project_to_screen(t, 32.0, 64.0);
+ const spk::ScreenPoint b = spk::project_to_screen(t, 224.0, 64.0);
+ // Two points 192px apart in surface-local X land at the same screen X (the
+ // element is edge-on): the map lost its X information.
+ CHECK(std::abs(a.x - b.x) < 0.5);
+}
diff --git a/tasks.md b/tasks.md
index 7b353fa..ba8ffe1 100644
--- a/tasks.md
+++ b/tasks.md
@@ -5,7 +5,7 @@
## Now
-**ACTIVE (core, user-driven) — Slice 13: RML COMPOSITING SPIKE.** Big direction
+**ACTIVE (core, user-driven) — Slice 13: RML COMPOSITING (Phase 0 GO → Phase 2 impl).** Big direction
change: RMLUi becomes the content compositor — toplevels + layer-shell (incl.
wallpaper) + chrome are RML elements backed by LIVE, SHARED GL textures, with
layout/animation/3D effects in RCSS; wlroots stays foundation + cursor plane +
@@ -13,7 +13,21 @@ layout/animation/3D effects in RCSS; wlroots stays foundation + cursor plane +
by OUR dirty-gated rendering (NOT a RMLUi built-in) + a deferred scanout bypass.
GATED BY A SPIKE before commit. Full spec + acceptance criteria:
`notes/rml-compositing.md`; decision row in `notes/plan.md` §2.
-NEXT ACTION: write the spike brief (kernel/substrate) and summon it.
+SPIKE RESULT: **PHASE 0 CLOSED — GO, real-seat CONFIRMED.** All 7 criteria
+`ALL PASS` headless on Haswell+crocus (CF-AX3 GPU class); on the real seat:
+input accurate through the 3D transform (after the `Element::Project()` routing
+fix), and ~30fps under the 4-window `--demo` load. Stage-0 instrumentation
+(per-phase split + GPU timer) shows it is **fill-bound** (~10–15ms whole-output
+composite, ~2ms CPU) → damage limiting is the recovery lever, built properly in
+Phase 1 (not the throwaway). Surface trees = **per-subsurface elements** (RTT
+hook); present = FBO→dmabuf swapchain→wlr_scene_buffer + EGL fence. Throwaway
+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.
Tiling (slice 7) is DEFERRED behind this (becomes RCSS over surface elements;
pure layout core in `notes/tiling-spec.md` carries over). Stage dock (slice 10)
real-seat feel check is paused under this pivot.
@@ -116,7 +130,7 @@ deprecated no-op `Options::ui_spike`, retiring host-bin's demo ui.
| 10 | **Stage dock** (ext-stage-dock): minimized-window previews on a left-edge swipe (Fork B) | **a1–d1 landed; previews real-seat-verified** | DONE: Super+M minimize→RMLUi-imported preview snapshot→dock slot→hide (previews confirmed rendering on hardware); RCSS dock slide-in + slot settle. NEXT: confirm tap-to-restore + animation feel; 1 boundary call (input-transparent UiSurface flag) → c1 gesture-claim → e1 gesture reveal/drag-out; then config-driven minimize keybind + favicon (XDG icon dep) |
| 11 | **Status bar** (tent. ext-statusbar): iPad/iOS top bar — clock (left), configurable left/middle/right sections, tray (right) wifi/volume/battery | **IDEA — needs design** | sequenced AFTER slice 7 (tiling); replaces cut taskbar. Details + open questions: `notes/status-bar-home-screen.md` |
| 12 | **Home screen** (tent. ext-home, iPad springboard): app grid; tap = launch-or-raise (instance picker if >1 open); add/remove apps; swipe-up-from-bottom to enter | **IDEA — needs design** | sequenced AFTER slice 7 (tiling); replaces cut taskbar. Details + open questions: `notes/status-bar-home-screen.md` |
-| 13 | **THE SPIKE: RML compositing** — RMLUi becomes the content compositor (toplevels + layer-shell incl. wallpaper + chrome = RML elements backed by LIVE, SHARED GL textures; layout/animation/3D effects in RCSS). wlroots = foundation + cursor plane + (deferred) fullscreen scanout bypass. | **ACTIVE (core) — spike** | GO/NO-GO on the CF-AX3: (1) live toplevel texture in RmlUi via shared context, ZERO per-frame copy; (2) RCSS 3D transform on it; (3) pointer+touch+keyboard routed back through RmlUi picking → wl_seat; (4) window w/ popup+subsurface composited (decides per-subsurface-elements vs per-window RTT); (5) wallpaper as an element; (6) perf ~4 windows@1080p + idle≈no-work (our dirty-gating) + video cost; (7) present via existing FBO→scene_buffer bridge. Full spec + decision row: `notes/rml-compositing.md`, plan.md §2. |
+| 13 | **RML compositing** — RMLUi becomes the content compositor (toplevels + layer-shell incl. wallpaper + chrome = RML elements backed by LIVE, SHARED GL textures; layout/animation/3D effects in RCSS). wlroots = foundation + cursor plane + (deferred) fullscreen scanout bypass. | **Phase 0 spike CLOSED — GO, real-seat CONFIRMED; Phase 1 design DONE (`notes/rml-compositing-phase1.md`); Phase 2 impl NEXT** | All 7 criteria `ALL PASS` headless on Haswell+crocus: (1) zero-copy live dmabuf texture (cached when unchanged); (2) RCSS perspective+rotateY on live pixels (readback); (3) screen→surface-local inversion through the transform = 0.000000px; (4) surface tree composited → **per-subsurface elements** (RTT hook for tree-spanning effects); (5) wallpaper via identical import path; (6) idle dirty-gate = 0 idle renders / 1-per-commit (frame-time @load = real-seat); (7) FBO→dmabuf→wlr_scene_buffer + EGL fence. Spike target `rml-compositing-spike` (`--verify`/`--run`). Report + runbook: `reports/rml-compositing-spike.md`. |
## Deferred decisions (decide when reached — see notes/plan.md §7)