# Headless 4K virtual output for Sunshine → Moonlight on a client TV. # # NVIDIA proprietary driver: both kernel params are required — # video=:e force-enables the connector # drm.edid_firmware=:… loads modes from the EDID blob # # Pick a connector that is disconnected on the host (check after boot): # grep -H . /sys/class/drm/card*-*/status # # Reboot after changing connector or EDID. 4K@60 is realistic on RTX 2070; # 4K@120 on a sink-less connector needs driver patches (see NVIDIA issue #1184). { config, lib, pkgs, ... }: let cfg = config.chiasson.system.streamingDisplay; edidFirmware = pkgs.runCommand "sunshine-virtual-4k-edid" { nativeBuildInputs = [ pkgs.python3 ]; } '' ${pkgs.python3}/bin/python3 ${./generate-virtual-edid.py} $TMPDIR/${cfg.edidFileName} mkdir -p $out/lib/firmware/edid cp $TMPDIR/${cfg.edidFileName} $out/lib/firmware/edid/${cfg.edidFileName} ''; # Sunshine 2025.924 wlr capture only accepts a numeric monitor index (not "DP-2"). # If the index drifts after hotplug, check: # journalctl --user -u sunshine -b | rg 'Monitor [0-9]+ is' streamDisplayUp = pkgs.writeShellScriptBin "stream-display-up" '' ${pkgs.niri}/bin/niri msg output ${cfg.connector} scale ${toString cfg.niriScale} ${pkgs.niri}/bin/niri msg action focus-monitor ${cfg.connector} ${pkgs.niri}/bin/niri msg action move-window-to-monitor ${cfg.connector} || true ''; streamDisplayDown = pkgs.writeShellScriptBin "stream-display-down" '' ${pkgs.niri}/bin/niri msg action focus-monitor ${cfg.focusMonitorOnEnd} ''; in { options.chiasson.system.streamingDisplay = { enable = lib.mkEnableOption '' Force-enable a spare NVIDIA DRM connector with a 4K EDID for Sunshine/Moonlight. Requires reboot. Pair with `chiasson.desktop.niri` output config in `displays.nix`. ''; connector = lib.mkOption { type = lib.types.str; default = "DP-2"; example = "HDMI-A-1"; description = '' DRM connector name (e.g. `DP-2`, `HDMI-A-1`). Must be unused on the host — 14900k desk uses DP-3, DP-4, and HDMI-A-3 by default. ''; }; edidFileName = lib.mkOption { type = lib.types.str; default = "virtual-4k.bin"; description = "Filename under `/lib/firmware/edid/` referenced by `drm.edid_firmware`."; }; mode = lib.mkOption { type = lib.types.str; default = "3840x2160@60"; description = "Mode Niri should use on the virtual output (must match EDID)."; }; niriPosition = lib.mkOption { type = lib.types.str; default = "x=7680 y=0"; description = '' Niri `position` for the virtual panel — keep it clear of desk monitors so streamed windows do not overlap your ultrawide layout. ''; }; niriScale = lib.mkOption { type = lib.types.float; default = 1.5; example = 1.5; description = '' Niri `scale` on the virtual panel. Values above 1.0 enlarge UI and text on the 4K framebuffer (helpful when viewing on a TV from the couch). ''; }; focusMonitorOnEnd = lib.mkOption { type = lib.types.str; default = "DP-3"; description = "Niri monitor to refocus when a Moonlight session ends."; }; sunshineOutputIndex = lib.mkOption { type = lib.types.str; default = "3"; example = "3"; description = '' Wayland/wlr monitor index for Sunshine `output_name`. Sunshine 2025.924 ignores connector strings like `DP-2` and falls back to monitor 0 (your primary). Find the index with: `journalctl --user -u sunshine -b | rg "Monitor [0-9]+ is ${cfg.connector}"` ''; }; }; config = lib.mkIf cfg.enable (lib.mkMerge [ { hardware.firmware = [ edidFirmware ]; boot.kernelParams = [ "video=${cfg.connector}:e" "drm.edid_firmware=${cfg.connector}:edid/${cfg.edidFileName}" ]; environment.systemPackages = [ streamDisplayUp streamDisplayDown ]; } (lib.mkIf config.chiasson.system.gaming.sunshine.enable { services.sunshine.settings = { capture = "wlr"; output_name = cfg.sunshineOutputIndex; global_prep_cmd = "[{\"do\":\"${streamDisplayUp}/bin/stream-display-up\",\"undo\":\"${streamDisplayDown}/bin/stream-display-down\"}]"; }; }) ]); }