From 2c0ab04bdf576f19c4427513f5a80ca7bfa49731 Mon Sep 17 00:00:00 2001 From: litruv Date: Tue, 8 Sep 2026 22:29:58 +1000 Subject: [PATCH] chore(blog): remove time-to-relearn post and add hidden folder --- .../data/blog/{ => hidden}/time-to-relearn.md | 0 website/data/blog/moonlight-config.md | 587 ++++++++++++++++++ 2 files changed, 587 insertions(+) rename website/data/blog/{ => hidden}/time-to-relearn.md (100%) create mode 100644 website/data/blog/moonlight-config.md diff --git a/website/data/blog/time-to-relearn.md b/website/data/blog/hidden/time-to-relearn.md similarity index 100% rename from website/data/blog/time-to-relearn.md rename to website/data/blog/hidden/time-to-relearn.md diff --git a/website/data/blog/moonlight-config.md b/website/data/blog/moonlight-config.md new file mode 100644 index 0000000..20dca51 --- /dev/null +++ b/website/data/blog/moonlight-config.md @@ -0,0 +1,587 @@ +--- + +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.