A guide for creating a virtual display on an NVIDIA GPU (tested on RTX 5080, driver 595.58) with HDR, custom resolutions, and 4K@120Hz support for headless Sunshine/Moonlight streaming on Linux.
Works on both HDMI and DisplayPort connectors with no physical display or dummy plug connected.
Running Sunshine headless on Linux with NVIDIA is painful:
- No virtual display driver like Windows has (IddSampleDriver, etc.)
- Dummy plugs have bandwidth limits — cheap DP plugs cap at 1080p due to link training, cheap HDMI plugs register as HDMI 2.0
drm.edid_firmwarealone doesn't work — NVIDIA's proprietary driver reads the EDID metadata but doesn't enumerate modes from itkscreen-doctor addCustomModeadds modes to KDE but NVIDIA's DRM rejects them during modeset- No HDR without proper EDID metadata declaring HDR10 capability
Two kernel parameters, combined, force-enable a connector and load a custom EDID:
drm.edid_firmware=<CONNECTOR>:edid/<EDID_FILE> video=<CONNECTOR>:e
video=<CONNECTOR>:eforces the connector enabled even with nothing plugged indrm.edid_firmware=<CONNECTOR>:edid/<EDID_FILE>loads a custom EDID binary from initramfs
Both are required. drm.edid_firmware alone does nothing useful on NVIDIA. video=:e alone creates a connector with no modes.
Even with the above, NVIDIA caps modes at ~165 MHz pixel clock (HDMI 1.4 bandwidth) unless the EDID contains:
- HDMI Vendor Specific Data Block (OUI
00-0C-03) — declares max TMDS clock - HDMI Forum Vendor Specific Data Block (OUI
C4-5D-D8) — declares HDMI 2.1/SCDC support
Without these, you get 1080p@60 max. With them, you get the full bandwidth for 4K@120Hz.
This applies to both HDMI and DP connectors — the NVIDIA driver uses these VSDB blocks to determine bandwidth regardless of connector type.
- NVIDIA GPU with proprietary driver (tested: 595.58, should work on 550+)
- Linux with KDE Plasma Wayland (tested: CachyOS, should work on Arch/Fedora/etc.)
edid-decodefor validation (pacman -S edid-decodeor equivalent)- Python 3 for the EDID generator script
- Sunshine installed with
cap_sys_admincapability
Save the following Python script as create-edid.py. It generates a 256-byte EDID (base block + CTA-861-G extension) with:
- 4K@60/120Hz, 1440p@60/120Hz, 1080p@60/120Hz via CEA VICs
- Custom resolutions via Detailed Timing Descriptors (add your own)
- HDR10 (SMPTE ST 2084), BT.2020 colorimetry, 10-bit color
- HDMI VSDB + HDMI Forum VSDB for full bandwidth
create-edid.py (click to expand)
#!/usr/bin/env python3
"""
Generate a 256-byte EDID with HDR, custom resolutions, and HDMI 2.1 VSDBs.
Works with NVIDIA proprietary driver for virtual display on Linux.
"""
import struct, sys, math
def make_dtd(pixel_clock_khz, h_active, h_blank, h_front, h_sync,
v_active, v_blank, v_front, v_sync, h_mm=600, v_mm=340,
h_pol_pos=True, v_pol_pos=True):
dtd = bytearray(18)
struct.pack_into('<H', dtd, 0, pixel_clock_khz // 10)
dtd[2] = h_active & 0xFF
dtd[3] = h_blank & 0xFF
dtd[4] = ((h_active >> 8) & 0x0F) << 4 | ((h_blank >> 8) & 0x0F)
dtd[5] = v_active & 0xFF
dtd[6] = v_blank & 0xFF
dtd[7] = ((v_active >> 8) & 0x0F) << 4 | ((v_blank >> 8) & 0x0F)
dtd[8] = h_front & 0xFF
dtd[9] = h_sync & 0xFF
dtd[10] = ((v_front & 0x0F) << 4) | (v_sync & 0x0F)
dtd[11] = (((h_front >> 8) & 0x03) << 6 | ((h_sync >> 8) & 0x03) << 4 |
((v_front >> 4) & 0x03) << 2 | ((v_sync >> 4) & 0x03))
dtd[12] = h_mm & 0xFF
dtd[13] = v_mm & 0xFF
dtd[14] = ((h_mm >> 8) & 0x0F) << 4 | ((v_mm >> 8) & 0x0F)
dtd[15] = 0
dtd[16] = 0
flags = 0x18
if h_pol_pos: flags |= 0x02
if v_pol_pos: flags |= 0x04
dtd[17] = flags
return bytes(dtd)
def make_descriptor(tag, data):
desc = bytearray(18)
desc[3] = tag
for i, b in enumerate(data[:13]):
desc[5 + i] = b
return bytes(desc)
def fix_checksum(block):
block = bytearray(block)
block[127] = (256 - (sum(block[:127]) % 256)) % 256
return bytes(block)
def cvt_rb_timing(h_active, v_active, refresh):
"""CVT Reduced Blanking v1 timing parameters."""
RB_H_BLANK, RB_H_SYNC, RB_H_FRONT = 160, 32, 48
RB_V_SYNC = 8 if v_active < 1200 else (7 if v_active < 2000 else 10)
RB_V_FRONT = 3
h_total = h_active + RB_H_BLANK
v_blank = max(RB_V_FRONT + RB_V_SYNC + 1,
int(460 * refresh * (v_active + RB_V_FRONT + RB_V_SYNC + 1) / 1_000_000) + 1)
pixel_clock = h_total * (v_active + v_blank) * refresh
pixel_clock_khz = ((pixel_clock + 5000) // 10000) * 10
return (pixel_clock_khz, RB_H_BLANK, RB_H_FRONT, RB_H_SYNC, v_blank, RB_V_FRONT, RB_V_SYNC)
# ── Customize your resolutions here ──────────────────────────────────────────
# Standard resolutions use VICs (1 byte each, efficient).
# Custom resolutions use DTDs (18 bytes each, max ~5 fit in the CTA block).
#
# Common VICs: 4=720p60, 16=1080p60, 63=1080p120, 97=4K60, 118=4K120
VICS = [16, 63, 97, 118, 4, 31, 96]
# Custom DTDs: (width, height, refresh, h_mm, v_mm)
CUSTOM_DTDS = [
(2560, 1440, 60, 600, 340), # 1440p
(3024, 1890, 60, 600, 375), # MacBook Pro 14"
(2752, 2064, 60, 600, 450), # iPad Pro 13" M4
(2796, 1290, 60, 600, 277), # iPhone 14 Pro Max
]
# ─────────────────────────────────────────────────────────────────────────────
def build_base_block():
base = bytearray(128)
base[0:8] = b'\x00\xFF\xFF\xFF\xFF\xFF\xFF\x00'
base[8:10] = b'\x32\xF8' # Manufacturer "LWX"
base[10:12] = b'\x01\x00' # Product code
base[12:16] = b'\x00\x00\x00\x00'
base[16] = 1; base[17] = 36 # Week 1, 2026
base[18] = 1; base[19] = 4 # EDID 1.4
base[20] = 0xB2 # Digital, 10-bit, HDMI-a interface
base[21] = 60; base[22] = 34 # 60x34 cm
base[23] = 120 # Gamma 2.2
base[24] = 0x0B # RGB + YCbCr 4:4:4, continuous freq
# sRGB chromaticity
base[25:35] = bytes([0xEE, 0x95, 0xA3, 0x54, 0x4C, 0x99, 0x26, 0x0F, 0x50, 0x54])
base[35:38] = bytes([0x21, 0x08, 0x00]) # Established timings
# Standard timings
for i, (w, r) in enumerate([(1920,60),(1280,60),(1680,60),(1600,60)]):
base[38+i*2] = (w//8)-31
aspect = 0b00 if w/1050 > 1.59 and w == 1680 else 0b11
if w == 1680: aspect = 0b00
base[39+i*2] = (aspect << 6) | (r - 60)
for i in range(4, 8):
base[38+i*2] = 0x01; base[39+i*2] = 0x01
# DTD 1: 3840x2160@60Hz
base[54:72] = make_dtd(594000, 3840, 560, 176, 88, 2160, 90, 8, 10)
# DTD 2: 2560x1440@120Hz
pc, hb, hf, hs, vb, vf, vs = cvt_rb_timing(2560, 1440, 120)
base[72:90] = make_dtd(pc, 2560, hb, hf, hs, 1440, vb, vf, vs, h_pol_pos=True, v_pol_pos=False)
# Range limits
rl = bytearray(18)
rl[0:4] = b'\x00\x00\x00\xFD'
rl[5]=24; rl[6]=120; rl[7]=15; rl[8]=200; rl[9]=70
rl[10]=0x00; rl[11:18] = b'\x0A\x20\x20\x20\x20\x20\x20'
base[90:108] = rl
# Display name
base[108:126] = make_descriptor(0xFC, b'VirtDisplay\n ')
base[126] = 1 # 1 extension block
return bytearray(fix_checksum(base))
def build_cta_extension():
ext = bytearray(128)
ext[0] = 0x02; ext[1] = 0x03
data = bytearray()
# Video Data Block
data.append(0x40 | len(VICS))
data.extend(VICS)
# HDR Static Metadata
data.extend([0xE6, 0x06, 0x07, 0x01,
int(32 * math.log2(1000/50)), # ~1000 nits peak
int(32 * math.log2(400/50)), # ~400 nits avg
int(255 * math.sqrt(0.01 * 100 / 1000))]) # ~0.01 nits min
# Colorimetry (BT.2020)
data.extend([0xE3, 0x05, 0xC0, 0x00])
# HDMI VSDB — REQUIRED for NVIDIA to unlock >HDMI1.4 bandwidth
data.extend([0x66, 0x03, 0x0C, 0x00, 0x10, 0x00, 0x78]) # max TMDS 600MHz
# HDMI Forum VSDB — declares HDMI 2.1 / SCDC
data.extend([0x67, 0xD8, 0x5D, 0xC4, 0x01, 0x78, 0x80, 0x00])
# Video Capability
data.extend([0xE2, 0x00, 0x00])
dtd_offset = 4 + len(data)
ext[2] = dtd_offset
ext[3] = 0x30 # YCbCr 4:4:4 + 4:2:2
ext[4:4+len(data)] = data
# Custom DTDs
pos = dtd_offset
for w, h, r, hmm, vmm in CUSTOM_DTDS:
if pos + 18 > 127: break
pc, hb, hf, hs, vb, vf, vs = cvt_rb_timing(w, h, r)
ext[pos:pos+18] = make_dtd(pc, w, hb, hf, hs, h, vb, vf, vs, hmm, vmm,
h_pol_pos=True, v_pol_pos=False)
pos += 18
return bytearray(fix_checksum(ext))
def main():
output = sys.argv[1] if len(sys.argv) > 1 else 'virtual-display.bin'
edid = build_base_block() + build_cta_extension()
assert len(edid) == 256
with open(output, 'wb') as f:
f.write(edid)
print(f"Written {len(edid)} bytes to {output}")
print(f"Validate with: edid-decode {output}")
if __name__ == '__main__':
main()Edit the two lists near the top of the script:
# Standard resolutions — use VICs (1 byte each)
# Full list: https://en.wikipedia.org/wiki/Extended_Display_Identification_Data#CTA_data_block
VICS = [16, 63, 97, 118, 4, 31, 96]
# Custom resolutions — use DTDs (18 bytes each, max ~5 fit)
# Format: (width, height, refresh_hz, physical_width_mm, physical_height_mm)
CUSTOM_DTDS = [
(2560, 1440, 60, 600, 340), # 1440p
(3024, 1890, 60, 600, 375), # MacBook Pro 14"
(2752, 2064, 60, 600, 450), # iPad Pro 13" M4
(2796, 1290, 60, 600, 277), # iPhone 14 Pro Max
]python3 create-edid.py virtual-display.bin
edid-decode virtual-display.binVerify in the output:
HDMI-a interfaceand10 bits per primary colorVendor-Specific Data Block (HDMI), OUI 00-0C-03withMaximum TMDS Character Rate: 600 MHzVendor-Specific Data Block (HDMI Forum), OUI C4-5D-D8withSCDC PresentHDR Static Metadata Data BlockwithSMPTE ST2084- All your DTDs listed with correct resolutions
sudo mkdir -p /usr/lib/firmware/edid
sudo cp virtual-display.bin /usr/lib/firmware/edid/virtual-display.binAdd the file to your initramfs. On Arch/CachyOS, edit /etc/mkinitcpio.conf:
FILES=(/usr/lib/firmware/edid/virtual-display.bin)
Rebuild:
sudo mkinitcpio -PChoose a free connector on your NVIDIA GPU. Check which are available:
for p in /sys/class/drm/card*-*/status; do
con=${p%/status}; echo "$(basename $con): $(cat $p)"
donedrm.edid_firmware=DP-2:edid/virtual-display.bin video=DP-2:e
drm.edid_firmware=HDMI-A-1:edid/virtual-display.bin video=HDMI-A-1:e
Both work identically — the HDMI VSDBs in the EDID unlock full bandwidth on either connector type.
GRUB — edit /etc/default/grub:
GRUB_CMDLINE_LINUX_DEFAULT="... drm.edid_firmware=DP-2:edid/virtual-display.bin video=DP-2:e"
sudo update-grubsystemd-boot — edit your entry in /boot/loader/entries/*.conf:
options ... drm.edid_firmware=DP-2:edid/virtual-display.bin video=DP-2:e
Limine — edit /etc/default/limine:
KERNEL_CMDLINE[default]+=" drm.edid_firmware=DP-2:edid/virtual-display.bin video=DP-2:e"
sudo limine-update
sudo rebootAfter reboot:
# Check connector is force-enabled
cat /sys/class/drm/card*-DP-2/status
# Expected: connected
# Check EDID loaded
cat /sys/class/drm/card*-DP-2/edid | edid-decode | grep "Product Name"
# Expected: VirtDisplay
# Check modes
cat /sys/class/drm/card*-DP-2/modes
# Expected: 3840x2160, 2560x1440, 1920x1080, your custom resolutions...
# Check HDR
kscreen-doctor -o | grep HDR
# Expected: HDR: disabled (means capable but not enabled — "incapable" means it didn't work)Note: NVIDIA card numbering (card0/card1) can swap between boots if you have multiple GPUs. The kernel parameters use connector names without card prefix, so they work regardless.
# ~/.config/sunshine/sunshine.conf
adapter_name = /dev/dri/renderD128 # check: ls -la /sys/class/drm/renderD128/device/driver
capture = kms
encoder = nvenc
# Don't set output_name — let Sunshine auto-detectEnsure capabilities are set:
sudo setcap cap_sys_admin+p $(readlink -f $(which sunshine))If you have multiple displays active (e.g. virtual display + real monitor), Sunshine may capture the wrong one. Keep only the virtual display connected for reliable capture, or set output_name to the connector name (but note this can be unreliable with NVIDIA's Wayland-to-KMS ID mapping).
For the virtual display to work, a desktop session must be running. Configure auto-login for your display manager:
SDDM (/etc/sddm.conf.d/autologin.conf):
[Autologin]
User=yourusername
Session=plasmaplasmalogin (/etc/plasmalogin.conf):
[Autologin]
User=yourusername
Session=plasmaKDE's power management will blank the virtual display after inactivity. Since there's no physical screen, the NVIDIA driver disables the CRTC and it cannot be woken without a reboot — Sunshine will return 503 errors.
Disable screen blanking, dimming, and auto-lock:
kwriteconfig6 --file kscreenlockerrc --group Daemon --key Autolock false
kwriteconfig6 --file powermanagementprofilesrc --group AC --group DPMSControl --key idleTime 0
kwriteconfig6 --file powermanagementprofilesrc --group AC --group DPMSControl --key lockBeforeTurnOff 0
kwriteconfig6 --file powermanagementprofilesrc --group AC --group DimDisplay --key idleTime 0Reboot for the settings to take effect. This has no impact on physical displays — TVs and monitors manage their own power independently.
Other desktop environments will have equivalent settings — the key is preventing DPMS from turning off the virtual display.
The EDID declares HDR10 support and KDE reports HDR as "disabled" (capable), but enabling HDR fails — the driver rejects the configuration.
This is a confirmed NVIDIA driver limitation. The driver only creates the required DRM properties (HDR_OUTPUT_METADATA, Colorspace, max_bpc) when it detects a real physical HDMI 2.1 link with SCDC negotiation. On virtual/force-enabled connectors, these properties simply don't exist.
Workarounds:
- Plug a real TV/monitor into HDMI and stream from that output for HDR
- An HDMI 2.1 dummy plug with proper SCDC circuitry may trigger the driver to create HDR properties (unconfirmed)
- Future NVIDIA driver updates may fix this — the 580+ drivers added Vulkan HDR metadata on Wayland, so active development is ongoing
Everything else works: 4K@120Hz, custom resolutions, 10-bit color depth, all via SDR.
The EDID is missing HDMI VSDBs. This is the most common issue. Run edid-decode on your EDID and confirm both are present:
Vendor-Specific Data Block (HDMI), OUI 00-0C-03— must include max TMDS clockVendor-Specific Data Block (HDMI Forum), OUI C4-5D-D8— must include SCDC
- Check
cat /proc/cmdline— thevideo=<CONNECTOR>:eparameter must be present - Ensure the EDID file is in the initramfs — run
lsinitcpio /boot/initramfs-linux.img | grep edid - Try a different connector — some NVIDIA GPUs don't force-enable all connectors equally
- Check
journalctl --user -u sunshine | grep "Found monitor"— the virtual display should appear - Ensure
cap_sys_adminis set on the sunshine binary - If two displays are active, Sunshine's KMS scan may only find one (NVIDIA limitation). Disconnect the other display
- The EDID needs the HDR Static Metadata Data Block in the CTA extension
- The HDMI VSDBs must also be present (NVIDIA won't enable HDR without them)
- If HDR shows "disabled" but enabling fails, this is the NVIDIA virtual connector limitation described above — HDR requires a physical HDMI 2.1 connection
NVIDIA can be card0 or card1 depending on boot order with multi-GPU systems. Check:
ls -la /sys/class/drm/renderD128/device/driver # should show nvidiaIf it shows amdgpu, your render device is renderD129 — update adapter_name in Sunshine config.
drm.edid_firmwarewithoutvideo=:e— NVIDIA reads the EDID metadata but doesn't enumerate modeskscreen-doctor addCustomMode— adds modes to KDE but NVIDIA's DRM rejects them during modeset- DP dummy plugs for high bandwidth — cheap ones only train at HBR (2.7 Gbps), capping at ~150 MHz pixel clock regardless of EDID
- EDID override via debugfs (
/sys/kernel/debug/dri/*/DP-*/edid_override) — stored but NVIDIA doesn't re-enumerate modes from it - Flashing DP dummy plug EEPROM — many have write-protected ROM
- GPU: NVIDIA RTX 5080
- Driver: 595.58
- OS: CachyOS (Arch-based), kernel 6.19.11
- Desktop: KDE Plasma 6, Wayland
- Streaming: Sunshine 2025.924.154138 + Moonlight
- Connectors tested: DP-2 (virtual, no physical connection), HDMI-A-1 (virtual, no physical connection)
This guide was created with the help of Claude Code after a long debugging session figuring out why NVIDIA's proprietary driver behaves the way it does with virtual displays on Linux. Sharing it here in the hope it saves someone else the hours of trial and error. If it helped you, let me know!
Hey, Harry! Thank you so much for this guide! I could finally replicate my Windows Sunshine setup on CachyOS and I can't believe how perfectly this works. I'm over the moon!