Skip to content

Instantly share code, notes, and snippets.

@HarryAnkers
Last active June 3, 2026 13:54
Show Gist options
  • Select an option

  • Save HarryAnkers/8dbf551d66f00e8156ef4dd2b2b090a0 to your computer and use it in GitHub Desktop.

Select an option

Save HarryAnkers/8dbf551d66f00e8156ef4dd2b2b090a0 to your computer and use it in GitHub Desktop.
NVIDIA Virtual Display for Sunshine/Moonlight on Linux — No Dummy Plug Required (4K@120Hz, HDR, Custom Resolutions)

NVIDIA Virtual Display for Sunshine/Moonlight on Linux — No Dummy Plug Required

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.

The Problem

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_firmware alone doesn't work — NVIDIA's proprietary driver reads the EDID metadata but doesn't enumerate modes from it
  • kscreen-doctor addCustomMode adds modes to KDE but NVIDIA's DRM rejects them during modeset
  • No HDR without proper EDID metadata declaring HDR10 capability

The Solution

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>:e forces the connector enabled even with nothing plugged in
  • drm.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.

The HDMI VSDB Requirement

Even with the above, NVIDIA caps modes at ~165 MHz pixel clock (HDMI 1.4 bandwidth) unless the EDID contains:

  1. HDMI Vendor Specific Data Block (OUI 00-0C-03) — declares max TMDS clock
  2. 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.

Prerequisites

  • 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-decode for validation (pacman -S edid-decode or equivalent)
  • Python 3 for the EDID generator script
  • Sunshine installed with cap_sys_admin capability

Step 1: Create the Custom EDID

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()

Customizing resolutions

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
]

Generate and validate

python3 create-edid.py virtual-display.bin
edid-decode virtual-display.bin

Verify in the output:

  • HDMI-a interface and 10 bits per primary color
  • Vendor-Specific Data Block (HDMI), OUI 00-0C-03 with Maximum TMDS Character Rate: 600 MHz
  • Vendor-Specific Data Block (HDMI Forum), OUI C4-5D-D8 with SCDC Present
  • HDR Static Metadata Data Block with SMPTE ST2084
  • All your DTDs listed with correct resolutions

Step 2: Install the EDID

sudo mkdir -p /usr/lib/firmware/edid
sudo cp virtual-display.bin /usr/lib/firmware/edid/virtual-display.bin

Add the file to your initramfs. On Arch/CachyOS, edit /etc/mkinitcpio.conf:

FILES=(/usr/lib/firmware/edid/virtual-display.bin)

Rebuild:

sudo mkinitcpio -P

Step 3: Add Kernel Parameters

Choose 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)"
done

For a DisplayPort connector (e.g. DP-2):

drm.edid_firmware=DP-2:edid/virtual-display.bin video=DP-2:e

For an HDMI connector (e.g. HDMI-A-1):

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.

Adding to your bootloader

GRUB — edit /etc/default/grub:

GRUB_CMDLINE_LINUX_DEFAULT="... drm.edid_firmware=DP-2:edid/virtual-display.bin video=DP-2:e"
sudo update-grub

systemd-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

Step 4: Reboot and Verify

sudo reboot

After 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.

Step 5: Configure Sunshine

# ~/.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-detect

Ensure 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).

Headless Operation (No Physical Login)

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=plasma

plasmalogin (/etc/plasmalogin.conf):

[Autologin]
User=yourusername
Session=plasma

Prevent Display Sleep (Important!)

KDE'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 0

Reboot 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.

HDR Limitation

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.

Troubleshooting

Modes capped at 1080p@60

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 clock
  • Vendor-Specific Data Block (HDMI Forum), OUI C4-5D-D8 — must include SCDC

Connector shows "disconnected"

  • Check cat /proc/cmdline — the video=<CONNECTOR>:e parameter 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

Sunshine can't find the monitor

  • Check journalctl --user -u sunshine | grep "Found monitor" — the virtual display should appear
  • Ensure cap_sys_admin is set on the sunshine binary
  • If two displays are active, Sunshine's KMS scan may only find one (NVIDIA limitation). Disconnect the other display

HDR shows "incapable"

  • 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

Card numbering changed

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 nvidia

If it shows amdgpu, your render device is renderD129 — update adapter_name in Sunshine config.

What Doesn't Work

  • drm.edid_firmware without video=:e — NVIDIA reads the EDID metadata but doesn't enumerate modes
  • kscreen-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

Tested On

  • 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!

@Avapaa
Copy link
Copy Markdown

Avapaa commented Apr 25, 2026

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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment