mirror of
https://github.com/litruv/lit.ruv.wtf.git
synced 2026-09-10 18:19:39 +10:00
588 lines
14 KiB
Markdown
588 lines
14 KiB
Markdown
---
|
||
|
||
title: moonlight-config
|
||
date: 2026-08-29
|
||
author: Max Litruv Boonzaayer
|
||
-----------------------------
|
||
|
||
# Headless Linux, Sunshine, and Arbitrary Resolution
|
||
|
||
*How I got a headless NVIDIA box running Sunshine to switch to basically whatever resolution Moonlight asks for — including stupid shit like 5120×1440@100, 4096×2160@100, and 2416×1080@60.*
|
||
|
||
---
|
||
|
||
## The goal
|
||
|
||
The setup is:
|
||
|
||
* RTX 3090
|
||
* no physical monitor
|
||
* Fedora
|
||
* KDE Plasma Wayland
|
||
* Sunshine
|
||
* Moonlight
|
||
* virtual DisplayPort exposed as `DP-1`
|
||
|
||
Different clients want different resolutions:
|
||
|
||
* Steam Deck — `1280×800@90`
|
||
* Steam Link — `1920×1080@60`
|
||
* 4K display — `4096×2160@100`
|
||
* ultrawide — `5120×1440@100`
|
||
* phone — `2416×1080@60`
|
||
|
||
I wanted the host to **match whatever the client asks for when it connects**, then restore the previous mode when the stream ends.
|
||
|
||
No manually fucking around with `xrandr`, no changing KDE settings every time, and no getting stuck at whatever resolution the virtual display happened to boot with.
|
||
|
||
This is what ended up working on **Fedora + KDE Plasma Wayland + NVIDIA 610.x**, using a virtual DP connector called `DP-1` with an EDID named `ModeSwitch`.
|
||
|
||
---
|
||
|
||
## Why this is such a pain in the arse
|
||
|
||
### There's no actual monitor
|
||
|
||
The GPU still wants a connected display for modesetting, and Sunshine needs something it can capture through KMS.
|
||
|
||
So `DP-1` is a virtual display with a fake EDID. As far as the GPU is concerned, there's a monitor sitting there.
|
||
|
||
There isn't.
|
||
|
||
It's lying.
|
||
|
||
### NVIDIA doesn't like KDE's custom modes
|
||
|
||
KDE/KScreen will happily create custom resolutions using CVT timings. They'll show up in Display Settings and `kscreen-doctor`.
|
||
|
||
Then NVIDIA goes:
|
||
|
||
> nah cunt
|
||
|
||
and rejects the configuration.
|
||
|
||
Modes advertised by the EDID as proper native timings work instead:
|
||
|
||
* standard detailed timings
|
||
* DisplayID Type I timings
|
||
|
||
So arbitrary resolutions are handled by changing the EDID rather than creating KDE custom modes.
|
||
|
||
### Sunshine prep commands don't expand `$(VAR)`
|
||
|
||
Sunshine exposes the client resolution through:
|
||
|
||
```text
|
||
SUNSHINE_CLIENT_WIDTH
|
||
SUNSHINE_CLIENT_HEIGHT
|
||
SUNSHINE_CLIENT_FPS
|
||
```
|
||
|
||
But `$(SUNSHINE_CLIENT_WIDTH)` in an `apps.json` prep command doesn't get expanded.
|
||
|
||
The prep command therefore just calls a wrapper, which reads the variables from its environment.
|
||
|
||
---
|
||
|
||
# How it works
|
||
|
||
1. Moonlight connects
|
||
- Sunshine prep-cmd
|
||
- sunshine-custom-res apply
|
||
- try existing native EDID mode
|
||
- if it doesn't exist
|
||
- generate+inject EDID
|
||
- hotplug DP-1
|
||
- poke KWin
|
||
- apply new mode
|
||
2. On Disconnect
|
||
- sunshine-custom res undo
|
||
|
||
|
||
The virtual display gets a baseline EDID at boot. Runtime switching replaces that EDID with one containing the exact client mode.
|
||
|
||
Sunshine captures `DP-1` through KMS/NVENC.
|
||
|
||
---
|
||
|
||
# Boot-time virtual display
|
||
|
||
The kernel command line is:
|
||
|
||
```text
|
||
drm.edid_firmware=DP-1:edid/custom-hires.bin video=DP-1:D
|
||
^ loads the EDID ^ forces connector on boot
|
||
```
|
||
|
||
The EDID comes from:
|
||
|
||
```text
|
||
/lib/firmware/edid/custom-hires.bin
|
||
```
|
||
|
||
This gives me a stable `DP-1` before KDE or Sunshine starts.
|
||
|
||
The boot EDID just needs to provide a usable baseline. Runtime switching replaces it when a client connects.
|
||
|
||
---
|
||
|
||
# Generating the client EDID
|
||
|
||
The generator is:
|
||
|
||
```text
|
||
~/.local/bin/gen-sunshine-edid.py
|
||
```
|
||
|
||
For example:
|
||
|
||
```bash
|
||
gen-sunshine-edid.py 5120 1440 100 \
|
||
-o ~/.config/sunshine/client.edid
|
||
```
|
||
|
||
It builds an NVIDIA-friendly EDID containing the requested mode:
|
||
|
||
1. Width is aligned to 8 pixels and height is even
|
||
2. Requested mode goes into DTD #1
|
||
3. A DisplayID Type I block contains the requested mode
|
||
4. A few fallback timings are included
|
||
5. The template's CTA-861 block is omitted
|
||
|
||
The CTA block is intentionally removed because it can advertise its own preferred VICs and cause KWin to pick the wrong mode.
|
||
|
||
The generated EDID therefore makes the requested resolution the preferred/native mode instead of relying on KDE's custom mode support.
|
||
|
||
---
|
||
|
||
# Injecting the EDID
|
||
|
||
Handled by:
|
||
|
||
```text
|
||
~/.local/bin/sunshine-vdisplay-inject
|
||
```
|
||
|
||
The process is:
|
||
|
||
1. Write the EDID to DRM debugfs `edid_override`
|
||
2. Turn `DP-1` off
|
||
3. Wait ~1.5 seconds
|
||
4. Turn it back on
|
||
5. Send a `change` uevent
|
||
6. Tell KWin to reconfigure
|
||
|
||
The KWin refresh is important because the kernel and KScreen don't necessarily update at the same time.
|
||
|
||
The kernel can already show the new mode:
|
||
|
||
```bash
|
||
cat /sys/class/drm/card0-DP-1/modes
|
||
```
|
||
|
||
while KScreen is still holding the old mode list.
|
||
|
||
```bash
|
||
kscreen-doctor -o
|
||
```
|
||
|
||
After the connector hotplug and:
|
||
|
||
```text
|
||
org.kde.KWin reconfigure
|
||
```
|
||
|
||
KScreen reloads the display information and the new native mode becomes available.
|
||
|
||
---
|
||
|
||
# Matching the display
|
||
|
||
The main script is:
|
||
|
||
```text
|
||
~/.local/bin/sunshine-match-display
|
||
```
|
||
|
||
It supports:
|
||
|
||
```bash
|
||
sunshine-match-display do
|
||
sunshine-match-display undo
|
||
```
|
||
|
||
On `do` it:
|
||
|
||
1. Saves the current mode and geometry
|
||
2. Reads `SUNSHINE_CLIENT_WIDTH`
|
||
3. Reads `SUNSHINE_CLIENT_HEIGHT`
|
||
4. Reads `SUNSHINE_CLIENT_FPS`
|
||
5. Looks for an exact native mode
|
||
6. Generates and injects an EDID if necessary
|
||
7. Waits for KScreen to see the new mode
|
||
8. Applies it
|
||
9. Verifies the resulting geometry
|
||
|
||
`undo` restores the saved mode.
|
||
|
||
There is intentionally **no nearest-resolution fallback**.
|
||
|
||
If I ask for `2560×1440` and it can't apply `2560×1440`, I want it to fail rather than silently leave the display at `4096×2160`.
|
||
|
||
Fuck that.
|
||
|
||
---
|
||
|
||
# Sunshine configuration
|
||
|
||
The global prep command is:
|
||
|
||
```text
|
||
~/.config/sunshine/sunshine.conf
|
||
```
|
||
|
||
```json
|
||
global_prep_cmd = [{
|
||
"do": "/home/litruv/.local/bin/sunshine-match-display do",
|
||
"undo": "/home/litruv/.local/bin/sunshine-match-display undo"
|
||
}]
|
||
```
|
||
|
||
Capture:
|
||
|
||
```ini
|
||
capture = kms
|
||
output_name = DP-1
|
||
encoder = nvenc
|
||
minimum_fps_target = 100
|
||
```
|
||
|
||
KMS grabs the virtual DP directly, avoiding the Wayland screencast path.
|
||
|
||
---
|
||
|
||
# Custom resolution apps
|
||
|
||
Fixed-resolution Moonlight apps can call the same wrapper with explicit values:
|
||
|
||
```json
|
||
{
|
||
"name": "Custom Res 4096x2160@100",
|
||
"image-path": "/home/litruv/.config/sunshine/custom-res-4096x2160-100.png",
|
||
"exclude-global-prep-cmd": true,
|
||
"prep-cmd": [{
|
||
"do": "/home/litruv/.local/bin/sunshine-custom-res apply 4096 2160 100",
|
||
"undo": "/home/litruv/.local/bin/sunshine-custom-res undo"
|
||
}]
|
||
}
|
||
```
|
||
|
||
For automatic client matching:
|
||
|
||
```json
|
||
"do": "/home/litruv/.local/bin/sunshine-custom-res apply"
|
||
```
|
||
|
||
The wrapper uses the `SUNSHINE_CLIENT_*` environment variables in that case.
|
||
|
||
`exclude-global-prep-cmd` prevents a fixed-resolution app from running the global matcher as well.
|
||
|
||
---
|
||
|
||
# The wrapper
|
||
|
||
`sunshine-custom-res` is just the entry point Sunshine uses.
|
||
|
||
```bash
|
||
sunshine-custom-res apply
|
||
```
|
||
|
||
Uses the resolution supplied by Sunshine.
|
||
|
||
```bash
|
||
sunshine-custom-res apply 5120 1440 100
|
||
```
|
||
|
||
Uses an explicit resolution.
|
||
|
||
```bash
|
||
sunshine-custom-res undo
|
||
```
|
||
|
||
Restores the previous mode.
|
||
|
||
The actual display handling stays in `sunshine-match-display`.
|
||
|
||
---
|
||
|
||
# Keeping Sunshine alive
|
||
|
||
The rest of the setup is mostly there to make the box behave like an always-on streaming server.
|
||
|
||
## Systemd
|
||
|
||
Sunshine runs as:
|
||
|
||
```text
|
||
app-dev.lizardbyte.app.Sunshine.service
|
||
```
|
||
|
||
with a `sunshine.service` symlink.
|
||
|
||
Drop-ins:
|
||
|
||
| Drop-in | Job |
|
||
| --------------- | ------------------------------------------- |
|
||
| `override.conf` | Wait for Wayland/X11 before Sunshine starts |
|
||
| `restart.conf` | Restart Sunshine if it dies |
|
||
| `latency.conf` | Give Sunshine more CPU/IO priority |
|
||
|
||
`sunshine-headless-layout.service` runs when the graphical session starts and makes sure the virtual DP is active while the physical outputs are off.
|
||
|
||
## Staying awake
|
||
|
||
```text
|
||
sunshine-stay-awake.service
|
||
```
|
||
|
||
uses `systemd-inhibit` to prevent sleep.
|
||
|
||
```text
|
||
sunshine-power-inhibit.service
|
||
```
|
||
|
||
does the same through KDE's PowerDevil D-Bus interface.
|
||
|
||
Because apparently Linux needs multiple independent systems to decide whether my streaming box is allowed to fucking sleep.
|
||
|
||
## Watchdog
|
||
|
||
```text
|
||
sunshine-watchdog.timer
|
||
```
|
||
|
||
runs every two minutes and checks port `47989`.
|
||
|
||
If the display exists but Sunshine isn't listening, it restarts the service.
|
||
|
||
Enable it with:
|
||
|
||
```bash
|
||
systemctl --user enable --now sunshine-watchdog.timer
|
||
```
|
||
|
||
---
|
||
|
||
# Sunshine performance
|
||
|
||
Current relevant settings:
|
||
|
||
```ini
|
||
capture = kms
|
||
output_name = DP-1
|
||
encoder = nvenc
|
||
minimum_fps_target = 100
|
||
nvenc_twopass = quarter_res
|
||
nvenc_latency_over_power = enabled
|
||
qp = 8
|
||
```
|
||
|
||
KMS + NVENC is the setup I'm using for high-refresh LAN streaming.
|
||
|
||
---
|
||
|
||
# Current client setup
|
||
|
||
| Client | Resolution | App |
|
||
| --------------- | --------------- | ------------------------ |
|
||
| Steam Deck OLED | `1280×800@90` | Custom Res 1280x800@90 |
|
||
| Steam Link | `1920×1080@60` | Custom Res 1920x1080@60 |
|
||
| 4K display | `4096×2160@100` | Custom Res 4096x2160@100 |
|
||
| Super ultrawide | `5120×1440@100` | Custom Res 5120x1440@100 |
|
||
| Phone | `2416×1080@60` | Custom Res 2416x1080@60 |
|
||
|
||
---
|
||
|
||
# Debugging
|
||
|
||
When something breaks, check these in order.
|
||
|
||
### Sunshine
|
||
|
||
```text
|
||
~/.config/sunshine/sunshine.log
|
||
```
|
||
|
||
Check that the prep command actually ran.
|
||
|
||
If the automatic command shows:
|
||
|
||
```text
|
||
apply
|
||
```
|
||
|
||
with no resolution values, the Sunshine environment variables aren't reaching the wrapper.
|
||
|
||
### Match log
|
||
|
||
```text
|
||
~/.config/sunshine/match-display.log
|
||
```
|
||
|
||
Look for:
|
||
|
||
```text
|
||
request WxH@FPS
|
||
```
|
||
|
||
and whether it found a native mode or performed an EDID injection.
|
||
|
||
### Kernel EDID
|
||
|
||
```bash
|
||
edid-decode /sys/class/drm/card0-DP-1/edid
|
||
```
|
||
|
||
Check that the preferred timing matches what you requested.
|
||
|
||
### Kernel modes
|
||
|
||
```bash
|
||
cat /sys/class/drm/card0-DP-1/modes | head
|
||
```
|
||
|
||
If the requested mode isn't here, the problem is still on the EDID/kernel side.
|
||
|
||
### KScreen
|
||
|
||
```bash
|
||
kscreen-doctor -o
|
||
```
|
||
|
||
If the kernel has the new mode but KScreen doesn't, the KWin reconfigure/hotplug step isn't doing its job.
|
||
|
||
### Native vs custom
|
||
|
||
If the mode only appears under `Custom modes` in `kscreen-doctor`, NVIDIA will probably reject it.
|
||
|
||
Use the EDID path instead.
|
||
|
||
## Manual test
|
||
|
||
You can bypass Sunshine:
|
||
|
||
```bash
|
||
SUNSHINE_CLIENT_WIDTH=2560 \
|
||
SUNSHINE_CLIENT_HEIGHT=1440 \
|
||
SUNSHINE_CLIENT_FPS=100 \
|
||
sunshine-custom-res apply
|
||
```
|
||
|
||
Then:
|
||
|
||
```bash
|
||
kscreen-doctor -o | grep Geometry
|
||
```
|
||
|
||
---
|
||
|
||
# A few gotchas
|
||
|
||
### EDID injection needs sudo
|
||
|
||
The debugfs override requires root.
|
||
|
||
The injector is allowed through sudo with NOPASSWD so Sunshine can do it unattended.
|
||
|
||
### NVIDIA + Wayland
|
||
|
||
This setup is specifically targeting NVIDIA + KDE Wayland.
|
||
|
||
Don't assume the same behaviour on AMD or X11.
|
||
|
||
### Resolution changes take a few seconds
|
||
|
||
An EDID fallback has to:
|
||
|
||
```text
|
||
inject
|
||
→ hotplug
|
||
→ wait
|
||
→ KWin reconfigure
|
||
→ KScreen reload
|
||
→ apply mode
|
||
```
|
||
|
||
So there's a brief black screen when switching to a mode that isn't already available.
|
||
|
||
### Bandwidth
|
||
|
||
`4096×2160@100` is roughly **28 Gbps** of raw RGB8 data.
|
||
|
||
DP 1.4 HBR3 can handle it, assuming the rest of the chain isn't the weak link.
|
||
|
||
### Don't use KDE custom resolutions
|
||
|
||
For this virtual DP, don't use KDE's **Add Custom Resolution** stuff.
|
||
|
||
If NVIDIA is going to reject the CVT mode anyway, there's not much point asking KDE to make it.
|
||
|
||
---
|
||
|
||
# Files
|
||
|
||
## Display / resolution
|
||
|
||
| File | Purpose |
|
||
| ----------------------------------------------- | ------------------------------- |
|
||
| `~/.local/bin/gen-sunshine-edid.py` | Generates client EDIDs |
|
||
| `~/.local/bin/sunshine-vdisplay-inject` | Injects EDID and refreshes KWin |
|
||
| `~/.local/bin/sunshine-vdisplay-up` | Forces the boot EDID |
|
||
| `~/.local/bin/sunshine-match-display` | Switches/restores display modes |
|
||
| `~/.local/bin/sunshine-custom-res` | Sunshine prep wrapper |
|
||
| `~/.local/bin/sunshine-headless-layout` | Sets up the virtual DP layout |
|
||
| `~/.local/bin/gen-sunshine-custom-res-icons.py` | Generates app icons |
|
||
| `~/.config/sunshine/client.edid` | Last generated EDID |
|
||
| `~/.config/sunshine/match-display.log` | Resolution switch log |
|
||
| `~/.config/sunshine/last-display-mode.env` | Saved mode for undo |
|
||
| `~/.config/sunshine/apps.json` | Moonlight app definitions |
|
||
| `~/.config/sunshine/custom-res-*.png` | Custom resolution icons |
|
||
| `~/.config/sunshine/sunshine.conf` | Sunshine config |
|
||
| `/lib/firmware/edid/custom-hires.bin` | Boot EDID |
|
||
| `~/edid-custom-hires.bin` | Source copy of boot EDID |
|
||
|
||
## Sunshine / systemd
|
||
|
||
| File | Purpose |
|
||
| ------------------------------------------------------------------- | ---------------------------- |
|
||
| `~/.local/bin/import-gui-env.sh` | Waits for the compositor |
|
||
| `~/.config/systemd/user/import-gui-env.service` | Starts the environment setup |
|
||
| `~/.config/systemd/user/sunshine-headless-layout.service` | Sets up the headless layout |
|
||
| `~/.config/systemd/user/sunshine-stay-awake.service` | Prevents sleep |
|
||
| `~/.config/systemd/user/sunshine-power-inhibit.service` | KDE power inhibition |
|
||
| `~/.config/systemd/user/sunshine-watchdog.{service,timer}` | Sunshine watchdog |
|
||
| `~/.config/systemd/user/app-dev.lizardbyte.app.Sunshine.service.d/` | Sunshine drop-ins |
|
||
| `~/.local/bin/sunshine-renice` | Sunshine priority |
|
||
| `~/.local/bin/sunshine-watchdog` | Watchdog script |
|
||
|
||
---
|
||
|
||
# TL;DR
|
||
|
||
The trick is **stop trying to make KDE create arbitrary NVIDIA modes**.
|
||
|
||
Give the GPU a virtual DP with an EDID, then change that EDID when a client asks for a mode KDE doesn't already have:
|
||
|
||
1. Boot with a baseline EDID
|
||
2. Read the client's resolution from Sunshine
|
||
3. Generate an EDID containing that exact mode
|
||
4. Inject it into `DP-1`
|
||
5. Hotplug the connector and reconfigure KWin
|
||
6. Apply the native mode with `kscreen-doctor`
|
||
7. Capture with KMS + NVENC
|
||
8. Restore the previous mode on disconnect
|
||
|
||
Once that's in place, arbitrary Moonlight resolutions are basically just an EDID + shell script problem.
|
||
|
||
Which is considerably less painful than trying to convince KDE and NVIDIA to cooperate.
|