Stream from a Linux Sunshine host to devices with non-standard native panels — a 19.5:9 phone (2416×1080@120), a notched MacBook Air 15" M2 (2880×1864@60), a 16:10 120 Hz tablet (2560×1600@120) — at each device's exact resolution and refresh, so nothing is letterboxed or downscaled.
The trick: add custom modes to the EDID of the headless HDMI dongle the host renders to, then point per-client Sunshine apps at those modes.
Sunshine on Linux captures a real display. For a headless streaming box you plug a cheap HDMI EDID emulator dongle into the GPU so it has a display to drive. But those dongles only advertise standard 16:9 modes (4K60, 1440p, 1080p…). Client devices have odd native geometries — 19.5:9 phones, notched laptops (full panel incl. the menu-bar strip), 16:10 120 Hz tablets — so the stream is either pillar/letter-boxed or scaled off the native grid.
On Wayland this is worse than X11: there is no xrandr --newmode. kscreen-doctor (KDE) can only select modes that already exist — you cannot inject a live modeline. The single lever is the EDID the kernel reads for that connector.
So: craft an EDID that advertises the exact modes you want, apply it to the connector (runtime for testing, kernel cmdline for permanent), and Sunshine can now solo the dongle at a client-matched mode.
- Linux, KMS amdgpu (Intel/Nvidia-KMS work too; boot-integration details differ), Wayland + KDE Plasma (
kscreen-doctor). - A passive HDMI EDID dongle on a connector, e.g.
HDMI-A-2(find yours:ls /sys/class/drm/card*-*). - Tools:
cvt(libxcvt),edid-decode, Python 3, Sunshine. Optional:rsvg-convertfor cover art. - For the runtime method, the kernel must not be in lockdown:
cat /sys/kernel/security/lockdown→[none].
# connector name + card number
ls /sys/class/drm/card*-HDMI-A-2
# dump its current EDID and read it
cp /sys/class/drm/card1-HDMI-A-2/edid dongle-orig.bin
edid-decode dongle-orig.bin | lessTwo things to note in edid-decode:
- Max TMDS (HDMI-Forum VSDB, e.g. "Maximum TMDS Character Rate: 600 MHz") — your new mode's pixel clock must stay under this. A 4K60 dongle usually declares 600 MHz, plenty for these modes.
- Whether HDR is advertised (Colorimetry BT.2020 + "HDR Static Metadata … SMPTE ST2084") — if so, you can stream HDR with no EDID change.
Compute a reduced-blanking timing and encode it as a Detailed Timing Descriptor, then add it to the dongle's EDID without losing its existing modes. edid_add_mode.py (in this gist) does all of it:
# reuses a cosmetic descriptor slot (serial/name) if free, else appends a CTA block
./edid_add_mode.py dongle-orig.bin dongle-custom.bin 2416x1080@120 # phone
./edid_add_mode.py dongle-custom.bin dongle-custom.bin 2880x1864@60 # macbook (notch-incl.)
./edid_add_mode.py dongle-custom.bin dongle-custom.bin 2560x1600@120 # tablet
edid-decode dongle-custom.bin # ALWAYS validate — must show 0 warnings/errorsUnder the hood it runs cvt -r W H R, packs the 16-byte-clock / active / blanking / sync fields into an 18-byte DTD, drops it into a free base-block descriptor slot (the cosmetic Serial/Name descriptors are fair game), and recomputes the checksum. A 128-byte base block holds 4 descriptors; once they're full it appends a DTD-only CTA-861 extension block (EDID grows 128 bytes). Multi-block EDIDs are completely standard.
MacBook notch note: the 15" M2 panel is
2880×1864(the below-notch 16:10 area is2880×1800). Use2880×1864for edge-to-edge including the notch, then on the Mac uncheck Get Info → “Scale to fit below built-in camera” on the Moonlight app and set a matching custom resolution.
DRM debugfs lets you inject an EDID and re-probe the connector live. Great for testing before you commit.
CARD=1 # the N in /sys/kernel/debug/dri/N that has your connector
sudo sh -c "cat dongle-custom.bin > /sys/kernel/debug/dri/$CARD/HDMI-A-2/edid_override
echo 1 > /sys/kernel/debug/dri/$CARD/HDMI-A-2/trigger_hotplug"
# the new modes now appear (this is RAM-only; a reboot reverts it):
kscreen-doctor -o | grep -A2 HDMI-A-2Blocked only if the kernel is in lockdown (Secure Boot). To revert without rebooting, re-inject the original dongle-orig.bin, or just reboot.
Install the EDID as firmware and tell the kernel to use it for that connector. Because the GPU driver is loaded early from the initramfs (KMS/plymouth), the EDID must be embedded in the initramfs too — this is the #1 thing people miss.
sudo install -Dm644 dongle-custom.bin /usr/lib/firmware/edid/dongle-custom.binmkinitcpio — add the file to FILES in /etc/mkinitcpio.conf:
FILES=(/usr/lib/firmware/edid/dongle-custom.bin)
Kernel cmdline — add (adjust connector name):
drm.edid_firmware=HDMI-A-2:edid/dongle-custom.bin
Do not add a video= parameter — you want the mode available, not the dongle force-enabled at boot.
Where the cmdline lives depends on your bootloader:
- GRUB:
GRUB_CMDLINE_LINUX_DEFAULTin/etc/default/grub, thengrub-mkconfig -o /boot/grub/grub.cfg. - systemd-boot: the entry in
/boot/loader/entries/*.conf(or/etc/kernel/cmdlinefor UKIs). - Limine (CachyOS):
KERNEL_CMDLINEin/etc/default/limine; regenerate withsudo limine-update.
Then rebuild the initramfs (sudo mkinitcpio -P, or on Limine sudo limine-update does both). Verify before rebooting:
# cmdline made it into the bootloader config
grep edid_firmware /boot/limine.conf # or grub.cfg / loader entry
# and the EDID is actually inside the initramfs
lsinitcpio /boot/.../initramfs | grep firmware/edidIf runtime (Method A) worked but boot didn't, it's almost always the initramfs/cmdline step above.
Sunshine doesn't auto-switch the host resolution on Linux, so each app's prep-cmd solos the dongle at the right mode and restores your desktop on exit. Two helper scripts (in this gist):
sunshine-display-state.py save|restore <profile>— snapshots/restores the full KDE display layout.sunshine-display-solo.py <output> <mode> <enable|disable>— enables one output at<mode>and disables the rest; the 3rd arg toggles HDR. Both handle the amdgpu "one head-change per atomic commit" quirk (single-head enables flakily fail the modeset test; retry + shed extra heads first).
Example app (Sunshine apps.json) — a phone at its native mode:
{
"name": "Phone",
"prep-cmd": [
{ "do": "sunshine-display-state.py save Phone",
"undo": "sunshine-display-state.py restore Phone" },
{ "do": "sunshine-display-solo.py HDMI-A-2 2416x1080@120 disable", "undo": "" }
],
"wait-all": true
}If the dongle EDID advertises HDR (check Step 0), just flip the solo command's last arg to enable:
sunshine-display-solo.py HDMI-A-2 2560x1600@120 enable
and make sure Sunshine advertises 10-bit codecs (sunshine.conf: hevc_mode = 3, av1_mode = 3). Enable HDR in the Moonlight client too. HDR looks best on actual HDR game content — streaming the SDR desktop through the HDR pipe tends to look washed out.
gen_covers.py renders clean 600×800 box-art tiles (SVG → PNG via rsvg-convert) — one per device with a vector glyph, the spec line, and an HDR badge on HDR variants — and you set each as the app's image-path.
python3 gen_covers.py && \
for f in covers_svg/*.svg; do rsvg-convert -w600 -h800 "$f" -o "covers/$(basename "$f" .svg).png"; done- Wayland/KDE has no live modeline. EDID injection is the only path;
kscreen-doctoronly selects existing modes. - Initramfs is mandatory for the boot method when KMS loads early — embed the EDID via
FILES. - Pixel-clock ceiling = the dongle's declared max TMDS (HDMI-Forum VSDB). Passive dongles don't decode the signal, so you're bound by that number, not the dongle's "4K60" label.
- Don't force-enable (
video=…e) unless you want a permanent phantom display. - CVT rounds horizontal active up to a multiple of 8 (e.g. 2412 → 2416) — a few px, harmless; the client scales.
- Always
edid-decodethe result. Zero warnings/errors before you trust it.
Files in this gist: edid_add_mode.py (build/patch EDIDs), sunshine-display-solo.py + sunshine-display-state.py (Sunshine prep-cmd helpers), gen_covers.py (cover art).