diff options
| author | Adam Malczewski <[email protected]> | 2026-06-15 05:52:20 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-15 05:52:20 +0900 |
| commit | 12e50166948b7554ccbdd382c8769774033b6e2c (patch) | |
| tree | 1531853f3e2bbcb59a73dcad163bd2dc83caffc2 | |
| parent | 5437b5e33c542c801bdc557be2ae93bfec6e153d (diff) | |
| download | unbox-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-- | .gitignore | 3 | ||||
| -rw-r--r-- | GLOSSARY.md | 2 | ||||
| -rw-r--r-- | notes/plan.md | 2 | ||||
| -rw-r--r-- | notes/rml-compositing-phase1.md | 257 | ||||
| -rw-r--r-- | packages/kernel/meson.build | 27 | ||||
| -rw-r--r-- | packages/kernel/src/spike/rml_compositing_spike.cpp | 577 | ||||
| -rw-r--r-- | packages/kernel/src/spike/rml_compositing_spike_run.cpp | 2127 | ||||
| -rw-r--r-- | packages/kernel/src/spike/spike_gl.hpp | 653 | ||||
| -rw-r--r-- | packages/kernel/src/spike/spike_input_core.hpp | 224 | ||||
| -rw-r--r-- | packages/kernel/tests/test_kernel.cpp | 98 | ||||
| -rw-r--r-- | tasks.md | 20 |
11 files changed, 3986 insertions, 4 deletions
@@ -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 = ≷ + 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 = ≷ + } + 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 = ≷ + 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) -> 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); +} @@ -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) |
