Dual-Display Wayland Architecture: eDP-1 and Virtual GLASSES-1 Output
The Portal platform runs a single Wayfire compositor instance that manages two logically distinct displays: eDP-1, the physical 2240×1400 touchscreen on the Spaceboard laptop, and GLASSES-1, a virtual 2560×1080 headless output that serves as the rendering canvas for AR glasses. This dual-display design decouples the spatial application surface from the physical touch panel, allowing the compositor to composite a wide-aspect-ratio canvas optimized for the INMO AIR3 optical field of view while simultaneously driving the laptop’s built-in screen for keyboard and touch interaction.
System Overview: Two Outputs, One Compositor
Section titled “System Overview: Two Outputs, One Compositor”The architecture is best understood as a single-process Wayfire compositor owning the DRM backend, with a plugin dynamically attaching a headless virtual output. The physical eDP-1 panel handles direct user interaction (keyboard, touch fusion), while GLASSES-1 is never scanned out to any physical panel — it is captured frame-by-frame via the zwlr_export_dmabuf_v1 protocol, encoded to H.265, and broadcast over Wi-Fi to the glasses.
graph TB
subgraph "Wayfire Compositor (portal.service)"
WC["Wayfire 0.11<br/>WLR_BACKENDS=drm"]
subgraph "Physical Display"
EDP["eDP-1<br/>2240×1400<br/>Touchscreen Panel"]
KB["portal-input.service<br/>(layer_shell OVERLAY)<br/>Rive keyboard + touch"]
end
subgraph "Virtual Display"
PVO["portal-virtual-output<br/>plugin"]
HEAD["HEADLESS-1 (disabled)"]
GLASS["GLASSES-1<br/>2560×1080<br/>position=0,0"]
SPAT["portal-spatial plugin<br/>(zone placement)"]
WARP["portal-spatial-warp plugin<br/>(perspective correction)"]
end
WC --> EDP
WC --> PVO
PVO -->|creates| GLASS
WC -.->|disables| HEAD
WC --> GLASS
GLASS --> SPAT
GLASS --> WARP
end
subgraph "Streaming Pipeline"
PS["portal_stream<br/>dmabuf capture"]
EGL["EGL UBWC Linearizer"]
V4L2["v4l2h265enc<br/>(IRIS HW encoder)"]
RTP["rtph265pay → udpsink"]
GLASS -->|zwlr_export_dmabuf_v1| PS
PS --> EGL
EGL --> V4L2
V4L2 --> RTP
end
subgraph "Network"
AP["uap0 (5GHz AP)"]
GLS["INMO AIR3 Glasses"]
RTP -->|UDP 192.168.50.255:5000| AP
AP --> GLS
end
subgraph "Input Bridge"
UIN["/dev/uinput<br/>'Portal Virtual Pointer'"]
UIN -->|map_to_output| GLASS
KB -->|fusion → uinput| UIN
end
The compositor service runs Wayfire with WLR_BACKENDS=drm and binds the DRM device at /dev/dri/card0, establishing DRM master and performing atomic modeset on eDP-1. The portal-virtual-output plugin then injects a headless output into Wayfire’s multi-backend, which Wayfire picks up as a new Wayland output. All window placement, spatial zone logic, and perspective warp rendering operate exclusively on GLASSES-1.
Sources: portal/systemd/portal.service#L1-L29, portal/compositor/wayfire.ini#L1-L48, portal/wayfire-plugins/portal-virtual-output/plugin.cpp#L1-L61
The portal-virtual-output Plugin: Headless Output Injection
Section titled “The portal-virtual-output Plugin: Headless Output Injection”The virtual output is not configured statically in wayfire.ini — it is dynamically created at runtime by the portal-virtual-output Wayfire plugin. This C++ plugin operates at the wlroots backend level, creating a headless backend, adding it to Wayfire’s multi-backend, and then emitting the new_output signal so Wayfire’s output layout manager registers it.
The plugin’s init() method performs three critical steps: (1) it calls wlr_headless_backend_create() on the compositor’s event loop, (2) attaches the headless backend to the multi-backend via wlr_multi_backend_add(), and (3) creates a 2560×1080 output through wlr_headless_add_output(). The output is initially named HEADLESS-1 — a name that collides with the default headless output that wlroots’ headless backend auto-creates at 1280×720. The wayfire.ini configuration explicitly disables this default [output:HEADLESS-1] with mode = off to prevent a phantom output from claiming the (0,0) coordinate origin.
sequenceDiagram
participant WF as Wayfire Core
participant PVO as portal-virtual-output
participant HB as Headless Backend
participant MB as Multi-Backend
participant OL as Output Layout
WF->>PVO: init()
PVO->>HB: wlr_headless_backend_create(loop)
PVO->>MB: wlr_multi_backend_add(backend, headless)
PVO->>HB: wlr_headless_add_output(2560, 1080)
HB-->>PVO: wlr_output* (named "HEADLESS-1")
PVO->>MB: wl_signal_emit(new_output, virtual_output)
MB->>OL: register output
OL->>WF: layout configured (GLASSES-1 at 0,0)
WF-->>WF: HEADLESS-1 disabled (mode=off)
The manual signal emission via wl_signal_emit_mutable is necessary because Wayfire only auto-discovers outputs during initial backend setup — outputs added later by a plugin must be explicitly announced. Without this emission, the virtual output would exist at the wlroots level but Wayfire’s compositor logic would never create a wf::output_t for it, rendering it invisible to all window placement and rendering code.
Sources: portal/wayfire-plugins/portal-virtual-output/plugin.cpp#L22-L46, portal/wayfire-plugins/portal-virtual-output/CMakeLists.txt#L1-L28
Output Layout and Coordinate System
Section titled “Output Layout and Coordinate System”The wayfire.ini configuration file defines a three-output layout with precise geometric positioning to ensure correct window placement and capture semantics. The interplay between these output configurations is subtle — a misconfiguration results in windows landing on invisible surfaces or the streaming pipeline capturing blank frames.
| Output | Resolution | Position | Status | Purpose |
|---|---|---|---|---|
| eDP-1 | 2240×1400 | (2560, 0) | Active | Physical touchscreen — keyboard + touch fusion |
| HEADLESS-1 | 1280×720 (default) | N/A | Disabled (mode = off) |
Default wlroots headless ghost — suppressed |
| GLASSES-1 | 2560×1080 | (0, 0) | Active | Virtual spatial canvas — captured for streaming |
The critical design decision is forcing GLASSES-1 to position (0,0). Wayfire auto-positions outputs left-to-right based on registration order. Because the headless backend always creates a default 1280×720 HEADLESS-1 output that momentarily claims the (0,0) origin before the plugin’s virtual output is registered, auto-positioning would place GLASSES-1 at (1280, 0) — inside the disabled phantom area. The explicit position = 0,0 override ensures the spatial coordinate model (which assumes the 2560×1080 canvas starts at global origin) places windows inside the region that portal_stream captures.
The eDP-1 panel is positioned at (2560, 0) — to the right of GLASSES-1 in the virtual layout. This placement is irrelevant for the streaming pipeline (which only captures GLASSES-1) but matters for Wayfire’s pointer behavior: without the position directive, Wayfire would attempt to auto-layout both outputs and potentially overlap them.
Sources: portal/compositor/wayfire.ini#L7-L25, docs/RENDERING_ARCHITECTURE.md#L206-L226
Window Placement: Forcing All Apps to GLASSES-1
Section titled “Window Placement: Forcing All Apps to GLASSES-1”Two complementary mechanisms ensure every application window lands on the GLASSES-1 output rather than eDP-1 or the disabled HEADLESS-1.
Wayfire window-rules provide the first layer — a declarative rule forces any newly created view to start on GLASSES-1:
[window-rules]rule1 = on created then start_on_output "GLASSES-1"This fires for every view_created signal, redirecting the view to GLASSES-1 before Wayfire’s own placement logic runs.
The portal-spatial plugin provides the second layer — it intercepts view_mapped signals and applies zone-specific geometry within the GLASSES-1 canvas. When a window maps, the plugin calls into the Rust spatial library via FFI (portal_spatial_view_mapped) to determine which of three spatial zones the window should occupy. It then computes the zone’s X offset, clamps the window to zone dimensions (853×900 max), and centers it within the assigned zone.
Sources: portal/compositor/wayfire.ini#L46-L48, portal/wayfire-plugins/portal-spatial/plugin.cpp#L52-L87, portal/wm/src/zone_policy.rs#L1-L96
Zone Model: Three-Zone Canvas at 2560×1080
Section titled “Zone Model: Three-Zone Canvas at 2560×1080”The GLASSES-1 canvas is divided into three equal-width zones mapped to the INMO AIR3’s binocular field of view. The 21:9 ultrawide aspect ratio matches the glasses’ ~31° horizontal optical FOV and reduces H.265 encoder bandwidth by approximately 33% compared to the previous 3840×1080 (32:9) layout.
┌──────────────┬──────────────┬──────────────┐│ │ │ ││ LeftPeriph │ Center │ RightPeriph ││ x∈[0,853) │ x∈[853,1706)│ x∈[1706,2560]││ width 853 │ width 853 │ width 854 ││ Zone ID: 1 │ Zone ID: 0 │ Zone ID: 2 ││ │ │ │└──────────────┴──────────────┴──────────────┘The 2560-pixel width divides into three zones of 853.33 pixels each. Integer truncation yields 853 for the left and center zones, with the right peripheral zone absorbing the 1-pixel remainder at 854 pixels wide. The portal-spatial-config.h header documents this rounding explicitly to prevent it from being mistaken for a bug. Window height is clamped to 900px (below the 1080px canvas height) to leave room for future shell chrome and to reduce visual fatigue in the AR headset.
| Zone | ID | X Range | Width | Default Scale | Display Priority |
|---|---|---|---|---|---|
| Center | 0 | [853, 1706) | 853 | 1.0 | 100 |
| Left Peripheral | 1 | [0, 853) | 853 | 0.8 | 50 |
| Right Peripheral | 2 | [1706, 2560] | 854 | 0.8 | 50 |
| Overlay | 3 | — | — | 1.0 | 200 |
The ZonePolicy engine in the WM daemon assigns windows to zones using a two-tier priority system. Utility applications (terminals, file managers, text editors) prefer peripheral zones, filling left first then right. Non-utility applications (browsers, media players) prefer the center zone. When all zones in the preferred tier are occupied, the engine falls back to the opposite tier. This policy is implemented in pure Rust and communicates zone assignments to the spatial plugin via the FFI boundary.
Sources: portal/wayfire-plugins/portal-spatial-config.h#L1-L53, portal/compositor/spatial-warp.toml#L1-L32, portal/spatial/src/zone.rs#L19-L32, portal/wm/src/zone_policy.rs#L44-L81
Input Routing: Virtual Pointer Confinement to GLASSES-1
Section titled “Input Routing: Virtual Pointer Confinement to GLASSES-1”The Portal Virtual Pointer — a uinput device created by the input daemon — is explicitly mapped to GLASSES-1 in the compositor configuration. This mapping is essential for correct spatial interaction: pointer events from the touch fusion engine must resolve to coordinates within the 2560×1080 virtual canvas, not the 2240×1400 eDP-1 touchscreen geometry.
[input-device:Portal Virtual Pointer]map_to_output = GLASSES-1The input daemon (portal-input.service) runs as a Wayland layer-shell client on eDP-1, receiving raw touch events from the ELAN touchscreen via direct evdev. It fuses touch coordinates into pointer/button events and injects them through /dev/uinput as relative movement deltas. The compositor then applies these deltas to the cursor on GLASSES-1 (the mapped output), translating physical touches on the laptop screen into spatial pointer positions on the virtual canvas.
This decoupling means the eDP-1 panel serves only as a touch input surface for the keyboard daemon — the compositor’s pointer cursor never appears on eDP-1. The keyboard daemon renders its own UI directly on eDP-1 via the layer-shell OVERLAY layer, independently of Wayfire’s cursor rendering.
Sources: portal/compositor/wayfire.ini#L30-L31, portal/input/src/output/uinput_pointer.rs#L25-L58, portal/systemd/portal-input.service#L1-L45
The Capture Pipeline: From GLASSES-1 to AR Glasses
Section titled “The Capture Pipeline: From GLASSES-1 to AR Glasses”The streaming daemon (portal_stream) connects to Wayfire as a Wayland client and captures the GLASSES-1 output using the zwlr_export_dmabuf_manager_v1 protocol. Despite the plugin naming the virtual output HEADLESS-1, the systemd service passes HEADLESS-1 as the capture target argument — this works because the daemon selects outputs by substring match against the Wayland output name, and the virtual output registered under that name matches.
flowchart LR
subgraph "Capture"
DMABUF["zwlr_export_dmabuf_v1<br/>frame_frame → frame_object"]
UBWC["UBWC Modifier<br/>0x0500000000000001"]
end
subgraph "Linearization"
EGL["eglCreateImageKHR<br/>(modifier-aware import)"]
FBO["Scratch FBO +<br/>glReadPixels"]
LIN["Linear BGRx<br/>(malloc'd)"]
end
subgraph "Encode"
APPSRC["appsrc<br/>(zero-copy GstBuffer)"]
VC["videoconvert<br/>BGRx → NV12"]
V4L2["v4l2h265enc<br/>8 Mbps VBR"]
PAY["rtph265pay pt=96"]
end
subgraph "Transport"
UDP["udpsink<br/>192.168.50.255:5000"]
HMAC["HMAC-SHA256<br/>pad probe (optional)"]
end
DMABUF --> UBWC
UBWC --> EGL
EGL --> FBO
FBO --> LIN
LIN --> APPSRC
APPSRC --> VC
VC --> V4L2
V4L2 --> PAY
PAY --> HMAC
HMAC --> UDP
The EGL UBWC linearizer is the most architecturally significant component of the capture pipeline. wlroots’ gles2 renderer on the Adreno X1-85 allocates DMA-bufs with the DRM_FORMAT_MOD_QCOM_COMPRESSED modifier (Qualcomm Universal Bandwidth Compression). GStreamer’s videoconvert reads raw dmabuf bytes via mmap, which produces garbage because UBWC is a tiled/compressed layout. The linearizer solves this by importing each dmabuf via EGL (which understands UBWC through EGL_EXT_image_dma_buf_import_modifiers), rendering it to a scratch framebuffer, and calling glReadPixels to extract linear pixel data. The resulting CPU-allocated buffer is then wrapped in a GStreamer buffer and pushed to the encoding pipeline.
Sources: portal/streaming/portal_stream.c#L64-L94, portal/streaming/portal_stream.c#L276-L336, portal/systemd/portal-stream.service#L24-L24, portal/streaming/STREAMING_UBWC_FIX.md#L1-L60
Service Dependency Graph
Section titled “Service Dependency Graph”The dual-display architecture is orchestrated by four systemd services with precise ordering constraints. Each service depends on the compositor’s Wayland socket (/run/portal/wayland-1) but operates independently after initial connection.
graph TD
SEATD["seatd.service"] --> PORTAL["portal.service<br/>(Wayfire compositor)"]
PORTAL -->|wayland-1 socket| INPUT["portal-input.service<br/>(keyboard daemon on eDP-1)"]
PORTAL -->|wayland-1 socket| STREAM["portal-stream.service<br/>(captures GLASSES-1)"]
PORTAL -->|wayland-1 socket| WM["portal-wm.service<br/>(toplevel tracking + zones)"]
INPUT -->|BindsTo| PORTAL
STREAM -->|PartOf| PORTAL
PORTAL --> EDP["eDP-1<br/>2240×1400<br/>(DRM modeset)"]
PORTAL --> GLASS["GLASSES-1<br/>2560×1080<br/>(headless virtual)"]
STREAM --> NET["uap0 AP → Glasses"]
style PORTAL fill:#e1f5fe
style GLASS fill:#fff3e0
style EDP fill:#e8f5e9
The portal.service is the root dependency — it starts Wayfire, creates both outputs, and establishes the Wayland socket. The portal-input.service binds to the compositor (if Wayfire dies, the keyboard daemon also terminates) and renders on eDP-1. The portal-stream.service is a PartOf the portal service (it restarts when the compositor restarts) and captures GLASSES-1 output. The portal-wm.service connects independently and monitors toplevel windows via the ext_foreign_toplevel_list_v1 protocol, assigning them to spatial zones.
All services share /run/portal as the XDG_RUNTIME_DIR — a deliberate choice that avoids the systemd-logind session directory (/run/user/1000) which gets recreated and destroyed as login sessions start and stop, deleting Wayfire’s socket in the process.
Sources: portal/systemd/portal.service#L1-L29, portal/systemd/portal-stream.service#L1-L34, portal/systemd/portal-input.service#L1-L45, portal/systemd/portal-wm.service#L1-L22, docs/x-elite-deployment-state.md#L107-L115
Configuration Reference
Section titled “Configuration Reference”The dual-display architecture is configured through three layers of configuration files, each addressing a different concern:
| Configuration File | Deployment Path | Purpose |
|---|---|---|
wayfire.ini |
/etc/portal/wayfire.ini |
Output layout, plugin loading, input mapping, keybindings |
spatial-warp.toml |
/etc/portal/spatial-warp.toml |
Canvas geometry and zone boundaries (runtime source of truth) |
portal-stream.env |
/etc/portal/portal-stream.env |
Streaming daemon network config (bind/target/interface) |
The portal-spatial-config.h C++ header provides compile-time defaults that must stay in sync with the TOML. The header comment explicitly states that if the two ever diverge, the TOML wins because it is read at runtime by libportal_spatial_plugin.so. The WM daemon’s Rust config module mirrors the same defaults in Config::default() and loads the TOML from /etc/portal/spatial-warp.toml at startup, falling back to defaults if the file is missing.
Sources: portal/wayfire-plugins/portal-spatial-config.h#L1-L10, portal/wm/src/config.rs#L60-L82, portal/systemd/portal-stream.env.example#L1-L22
Key Design Decisions and Trade-offs
Section titled “Key Design Decisions and Trade-offs”Why a virtual output instead of a real HDMI output? The Spaceboard’s eDP-1 panel is the only physical display. The previous Orange Pi architecture used HDMI-A-2 as the glasses output, but the Spaceboard migration eliminated the HDMI path in favor of a headless virtual output. This approach avoids contention for DRM master on a physical connector and eliminates the need for a dummy display EDID.
Why 2560×1080 instead of 3840×1080? The 21:9 ultrawide resolution matches the INMO AIR3’s ~31° horizontal optical FOV while reducing H.265 encoder bandwidth by ~33% versus the previous 32:9 layout. The three-zone model divides cleanly into 853-pixel segments.
Why HEADLESS-1 naming instead of GLASSES-1? The plugin currently sets the output name to HEADLESS-1 in source code, but the wayfire.ini references GLASSES-1 for window placement and input mapping. This apparent mismatch exists because the streaming daemon captures by the HEADLESS-1 name (matching the actual wlroots output name), while the Wayfire configuration sections target GLASSES-1 — which is the logical alias used in deployment documentation and the deployment state tracker. The wayfire.ini comments reference both names, explaining that the default HEADLESS-1 output must be disabled so only the virtual GLASSES-1 canvas remains.
Why EGL linearization instead of TU_DEBUG=noubwc? The compositor service already sets TU_DEBUG=noubwc (Turnip Vulkan flag), but this does not affect wlroots’ GBM allocation path. wlroots uses gbm_bo_create_with_modifiers, which explicitly requests modifiers — the driver returns UBWC as a valid option and wlroots selects it regardless of debug flags. EGL is the only API layer that understands how to import UBWC-compressed dmabufs correctly on this platform.
Sources: docs/RENDERING_ARCHITECTURE.md#L206-L226, portal/streaming/STREAMING_UBWC_FIX.md#L27-L42, portal/compositor/wayfire.ini#L11-L25, docs/x-elite-deployment-state.md#L107-L122
Next Steps
Section titled “Next Steps”The dual-display architecture touches several adjacent subsystems that warrant deeper exploration:
-
Spatial Domain Model: Zones, Dimensions, and Assignment Policies — Explores the full zone assignment engine, dimension switching (Desktop/VR/AR/Tablet), and the assignment policy hierarchy beyond the three-zone canvas introduced here.
-
Wayfire Plugin Integration: Rust FFI via C++ Shims — Details the dlopen-based FFI bridge between the C++ Wayfire plugins and the Rust spatial library, including SHA-256 sidecar verification and the tracked-pointer allocation safety layer.
-
End-to-End Streaming: DMA-BUF to H.265 Hardware Encode to RTP/UDP — Covers the complete capture pipeline including GStreamer pipeline construction, HMAC-SHA256 stream authentication, and IRIS hardware encoder tuning.
-
Keyboard Daemon: DRM/KMS Direct Scanout, Cairo Rendering, and evdev Parsing — Explains how the keyboard daemon renders to eDP-1 as a layer-shell client and bridges touch input to the GLASSES-1 virtual canvas.