# 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
`
` 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
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 = 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:
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.