Moonlight: headless compositor on the 3060, Sunshine capturing it

mainframe has no desktop and the only connected output hangs off the Intel
iGPU, while the RTX 3060 that encodes drives no monitor. Capturing the real
output would copy every frame between GPUs. So: a minimal sway session on
the headless wlroots backend, pinned to card0, with one 1920x1080 virtual
output for Sunshine to capture. Verified end to end — Sunshine finds
zwlr_screencopy_manager_v1 and creates h264_nvenc and hevc_nvenc.
(av1_nvenc fails because Ampere has no AV1 encoder; that is expected.)

Two things had to be fixed to get there. The installed sunshine-git was
built against boost 1.89 against a system now on 1.92 and could not load
five libraries — which is why the service had been enabled but never once
run. Replaced with sunshine-bin, which is current and prebuilt.

And Sunshine now gets a unit of ours rather than a drop-in: the source AUR
package ships sunshine.service while -bin ships
app-dev.lizardbyte.app.Sunshine.service, so a drop-in silently stops
applying the moment you switch between them.

apps.json replaces the stock file, whose "Low Res Desktop" entry shelled
out to xrandr and would have done nothing on Wayland.
This commit is contained in:
2026-09-14 15:41:55 -04:00
parent c2f7bcca5b
commit d5cb5f5690
8 changed files with 95 additions and 16 deletions
+5 -15
View File
@@ -1,34 +1,24 @@
{ {
"env": { "env": {
"PATH": "$(PATH):$(HOME)/.local/bin" "PATH": "$(PATH):$(HOME)/.local/bin:$(HOME)/.dotfiles/common/bin"
}, },
"apps": [ "apps": [
{ {
"name": "Desktop", "name": "Desktop",
"image-path": "desktop.png" "image-path": "desktop.png"
}, },
{
"name": "Low Res Desktop",
"image-path": "desktop.png",
"prep-cmd": [
{
"do": "xrandr --output HDMI-1 --mode 1920x1080",
"undo": "xrandr --output HDMI-1 --mode 1920x1200"
}
]
},
{ {
"name": "Steam Big Picture", "name": "Steam Big Picture",
"image-path": "steam.png",
"detached": [ "detached": [
"setsid steam steam://open/bigpicture" "setsid steam -gamepadui"
], ],
"prep-cmd": [ "prep-cmd": [
{ {
"do": "", "do": "",
"undo": "setsid steam steam://close/bigpicture" "undo": "setsid steam -shutdown"
} }
], ]
"image-path": "steam.png"
} }
] ]
} }
+16
View File
@@ -1 +1,17 @@
# RTX 3060 (01:00.0). The Intel iGPU is renderD128 and owns the only physically
# connected output; this machine deliberately captures a virtual output on the
# NVIDIA card instead, so capture and NVENC happen without a cross-GPU copy.
adapter_name = /dev/dri/renderD129 adapter_name = /dev/dri/renderD129
# The single output created by hosts/mainframe/sway/headless.conf.
output_name = HEADLESS-1
# wlroots screencopy rather than a KMS grab. This is why the sunshine binary
# needs no cap_sys_admin here.
capture = wlr
encoder = nvenc
nvenc_preset = 1
nvenc_twopass = quarter_res
min_log_level = warning
+22
View File
@@ -0,0 +1,22 @@
# A compositor that exists only so Sunshine has something to capture.
# mainframe has no desktop: the only connected output (HDMI-A-2) hangs off the
# Intel iGPU, while the RTX 3060 that does the encoding drives no monitor at
# all. Capturing the real output would mean grabbing on Intel and encoding on
# NVIDIA — a cross-GPU copy every frame. A virtual output on the 3060 keeps
# capture and NVENC on one card.
output HEADLESS-1 mode 1920x1080@60Hz position 0,0
output HEADLESS-1 background #101920 solid_color
# Sunshine is a systemd user unit, so it needs WAYLAND_DISPLAY in the user
# manager's environment. Import it before starting Sunshine, not after.
exec systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP
exec systemctl --user start sunshine.service
# Nothing is attached to this session locally, so keybinds are only useful
# through a Moonlight client. Keep the set minimal.
set $mod Mod4
bindsym $mod+Shift+q kill
bindsym $mod+Shift+e exec swaymsg exit
# No bar, no idle daemon, no lock screen — there is no one sitting here.
+23
View File
@@ -0,0 +1,23 @@
[Unit]
Description=Sunshine game stream host for Moonlight
Documentation=https://docs.lizardbyte.dev/projects/sunshine/
# Singleton: binds the streaming ports. Same guard as every other unit here.
ConditionHost=mainframe
# Nothing to capture without the compositor, and Sunshine should die with it.
Requires=sway-headless.service
After=sway-headless.service
PartOf=sway-headless.service
[Service]
Type=simple
# Our own unit rather than a drop-in: the AUR source package ships
# sunshine.service and the -bin package ships
# app-dev.lizardbyte.app.Sunshine.service, so a drop-in silently stops
# applying the moment you switch between them.
ExecStart=/usr/bin/sunshine
Restart=on-failure
RestartSec=5s
StartLimitBurst=3
[Install]
WantedBy=sway-headless.service
@@ -0,0 +1,22 @@
[Unit]
Description=Headless Wayland compositor for Sunshine
Documentation=https://docs.lizardbyte.dev/projects/sunshine/
ConditionHost=mainframe
[Service]
Type=simple
# headless backend: no seat, no input devices, one virtual output.
Environment=WLR_BACKENDS=headless
Environment=WLR_LIBINPUT_NO_DEVICES=1
# Pin the compositor to the NVIDIA card (card0 = 01:00.0, renderD129), which is
# also what sunshine.conf names as its encoder. Without this wlroots picks the
# Intel iGPU and every captured frame crosses GPUs.
Environment=WLR_DRM_DEVICES=/dev/dri/card0
Environment=XDG_CURRENT_DESKTOP=sway
ExecStart=/usr/bin/sway -c %h/.config/sway/headless.conf
ExecStopPost=/usr/bin/systemctl --user stop sunshine.service
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=default.target
+5
View File
@@ -10,6 +10,9 @@
force: true force: true
- link: - link:
~/.config/sway/headless.conf: hosts/mainframe/sway/headless.conf
~/.config/systemd/user/sway-headless.service: hosts/mainframe/systemd/sway-headless.service
~/.config/systemd/user/sunshine.service: hosts/mainframe/systemd/sunshine.service
~/.config/inbox-tidy: hosts/mainframe/config/inbox-tidy ~/.config/inbox-tidy: hosts/mainframe/config/inbox-tidy
~/.config/llmfit: hosts/mainframe/config/llmfit ~/.config/llmfit: hosts/mainframe/config/llmfit
~/.ssh/config.local: hosts/mainframe/ssh/config.local ~/.ssh/config.local: hosts/mainframe/ssh/config.local
@@ -38,3 +41,5 @@
description: Reloading user units description: Reloading user units
- command: systemctl --user enable --now inbox-tidy.timer movie-recs.timer rent-utilities.timer ddns.timer - command: systemctl --user enable --now inbox-tidy.timer movie-recs.timer rent-utilities.timer ddns.timer
description: Enabling the singleton timers (mainframe only) description: Enabling the singleton timers (mainframe only)
- command: systemctl --user enable sway-headless.service sunshine.service
description: Enabling the headless compositor and Sunshine (not started here — it takes the GPU)
+1 -1
View File
@@ -9,5 +9,5 @@ paru
paru-debug paru-debug
pistol-bin pistol-bin
scc-bin scc-bin
sunshine-git sunshine-bin
swayfx swayfx
+1
View File
@@ -146,6 +146,7 @@ steam
stress-ng stress-ng
sudo sudo
sushi sushi
swaybg
sysbench sysbench
tecla tecla
tldr tldr