Enhance 14900k configuration with streaming display support and virtual EDID generation
- Added a new `streaming-display.nix` module to configure a headless 4K virtual output for Sunshine and Moonlight. - Introduced a Python script `generate-virtual-edid.py` to create a virtual display EDID with 4K capabilities. - Updated `configuration.nix` to integrate the new streaming display module and enable related services. - Modified `displays.nix` to accommodate the new streaming display settings and ensure compatibility with existing GPU passthrough configurations.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
# Monitor layout for 14900k.
|
||||
# NVIDIA (default): DP-2 ultrawide, HDMI-A-3 + DP-4 side/top stack.
|
||||
# NVIDIA (default): DP-3 ultrawide, HDMI-A-3 + DP-4 side/top stack; DP-2 virtual 4K when streamingDisplay enabled.
|
||||
# Intel iGPU (gpu passthru): DP-1 ultrawide, HDMI-A-2 Samsung to the left.
|
||||
# Niri: `extraSettings` (KDL via `extraConfig`, other wrapper-modules keys) + `extraBinds` (merged keybinds).
|
||||
# Hyprland: `chiasson.desktop.hyprland.settings` (merged in HM when `chiasson.desktop.hyprland.enable`).
|
||||
@@ -8,9 +8,20 @@
|
||||
{ config, lib, ... }:
|
||||
let
|
||||
gpuPassthru = config.chiasson.system.gpuPassthru.enable;
|
||||
streamingDisplay = config.chiasson.system.streamingDisplay.enable or false;
|
||||
streamingCfg = config.chiasson.system.streamingDisplay or { };
|
||||
|
||||
virtualOutputNiri =
|
||||
lib.optionalString streamingDisplay ''
|
||||
output "${streamingCfg.connector}" {
|
||||
mode "${streamingCfg.mode}"
|
||||
scale ${toString (streamingCfg.niriScale or 1.0)}
|
||||
position ${streamingCfg.niriPosition}
|
||||
}
|
||||
'';
|
||||
|
||||
niriOutputs =
|
||||
if gpuPassthru then
|
||||
(if gpuPassthru then
|
||||
''
|
||||
output "DP-1" {
|
||||
mode "2560x1080@144"
|
||||
@@ -42,10 +53,15 @@ let
|
||||
scale 1.0
|
||||
position x=0 y=-1080
|
||||
}
|
||||
'';
|
||||
'')
|
||||
+ virtualOutputNiri;
|
||||
|
||||
virtualOutputHyprland =
|
||||
lib.optionalString streamingDisplay
|
||||
"${streamingCfg.connector}, ${streamingCfg.mode}, 7680x0, ${toString (streamingCfg.niriScale or 1.0)}";
|
||||
|
||||
hyprlandMonitors =
|
||||
if gpuPassthru then
|
||||
(if gpuPassthru then
|
||||
[
|
||||
"DP-1, 2560x1080@144, 0x0, 1"
|
||||
"HDMI-A-2, 1920x1080@60, -1920x0, 1"
|
||||
@@ -55,7 +71,8 @@ let
|
||||
"DP-3, 2560x1080@144, 0x0, 1"
|
||||
"DP-4, 1920x1080@144, 0x-1080, 1"
|
||||
"HDMI-A-3, 1920x1080@60, -1920x0, 1"
|
||||
];
|
||||
])
|
||||
++ lib.optional streamingDisplay virtualOutputHyprland;
|
||||
|
||||
hyprlandWorkspaces =
|
||||
if gpuPassthru then
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a virtual-display EDID with 4K CTA VICs (trimmed from EnriqueWood POC)."""
|
||||
|
||||
import math
|
||||
import sys
|
||||
|
||||
|
||||
def cvt_rb(w, h, refresh):
|
||||
hblank, hfront, hsync = 160, 48, 32
|
||||
htotal = w + hblank
|
||||
vfront, vsync = 3, 8
|
||||
rb_min_vblank = 460e-6
|
||||
vblank = math.ceil(rb_min_vblank * refresh * h / (1.0 - rb_min_vblank * refresh))
|
||||
vblank = max(vblank, vfront + vsync + 6)
|
||||
vtotal = h + vblank
|
||||
pixclk_khz = int(round(htotal * vtotal * refresh / 10000.0)) * 10
|
||||
return dict(
|
||||
w=w,
|
||||
h=h,
|
||||
refresh=refresh,
|
||||
htotal=htotal,
|
||||
vtotal=vtotal,
|
||||
hblank=hblank,
|
||||
hfront=hfront,
|
||||
hsync=hsync,
|
||||
vblank=vblank,
|
||||
vfront=vfront,
|
||||
vsync=vsync,
|
||||
pixclk_khz=pixclk_khz,
|
||||
)
|
||||
|
||||
|
||||
def make_dtd(t, hmm=600, vmm=340):
|
||||
w, h = t["w"], t["h"]
|
||||
hblank, vblank = t["hblank"], t["vblank"]
|
||||
hfront, hsync = t["hfront"], t["hsync"]
|
||||
vfront, vsync = t["vfront"], t["vsync"]
|
||||
pc = t["pixclk_khz"] // 10
|
||||
d = bytearray(18)
|
||||
d[0] = pc & 0xFF
|
||||
d[1] = (pc >> 8) & 0xFF
|
||||
d[2] = w & 0xFF
|
||||
d[3] = hblank & 0xFF
|
||||
d[4] = ((w >> 4) & 0xF0) | ((hblank >> 8) & 0x0F)
|
||||
d[5] = h & 0xFF
|
||||
d[6] = vblank & 0xFF
|
||||
d[7] = ((h >> 4) & 0xF0) | ((vblank >> 8) & 0x0F)
|
||||
d[8] = hfront & 0xFF
|
||||
d[9] = hsync & 0xFF
|
||||
d[10] = ((vfront & 0x0F) << 4) | (vsync & 0x0F)
|
||||
d[11] = (
|
||||
(((hfront >> 8) & 3) << 6)
|
||||
| (((hsync >> 8) & 3) << 4)
|
||||
| (((vfront >> 4) & 3) << 2)
|
||||
| ((vsync >> 4) & 3)
|
||||
)
|
||||
d[12] = hmm & 0xFF
|
||||
d[13] = vmm & 0xFF
|
||||
d[14] = ((hmm >> 4) & 0xF0) | ((vmm >> 8) & 0x0F)
|
||||
d[17] = 0x1E
|
||||
return bytes(d)
|
||||
|
||||
|
||||
def make_name(name):
|
||||
d = bytearray(18)
|
||||
d[3] = 0xFC
|
||||
nb = name.encode("ascii")[:13]
|
||||
d[5 : 5 + len(nb)] = nb
|
||||
pos = 5 + len(nb)
|
||||
if pos < 18:
|
||||
d[pos] = 0x0A
|
||||
pos += 1
|
||||
for i in range(pos, 18):
|
||||
d[i] = 0x20
|
||||
return bytes(d)
|
||||
|
||||
|
||||
def make_range(min_v, max_v, min_h, max_h, max_pixclk_mhz):
|
||||
d = bytearray(18)
|
||||
d[3] = 0xFD
|
||||
flags = 0
|
||||
mv, mh = max_v, max_h
|
||||
if mh > 255:
|
||||
flags |= 0x08
|
||||
mh -= 255
|
||||
if mv > 255:
|
||||
flags |= 0x02
|
||||
mv -= 255
|
||||
d[4] = flags
|
||||
d[5] = min_v
|
||||
d[6] = mv
|
||||
d[7] = min_h
|
||||
d[8] = mh
|
||||
d[9] = max_pixclk_mhz // 10
|
||||
d[10] = 0x01
|
||||
for i in range(11, 18):
|
||||
d[i] = 0x0A if i == 11 else 0x20
|
||||
return bytes(d)
|
||||
|
||||
|
||||
def checksum(block):
|
||||
return (256 - (sum(block[:-1]) % 256)) % 256
|
||||
|
||||
|
||||
VICS = [97, 118, 96, 95, 63, 16, 4]
|
||||
|
||||
|
||||
def build_base(base_dtds, name, max_pixclk_mhz):
|
||||
b = bytearray(128)
|
||||
b[0:8] = b"\x00\xFF\xFF\xFF\xFF\xFF\xFF\x00"
|
||||
b[8:10] = b"\x50\x74"
|
||||
b[10:12] = b"\x70\x02"
|
||||
b[16] = 22
|
||||
b[17] = 34
|
||||
b[18] = 1
|
||||
b[19] = 4
|
||||
b[20] = 0xA5
|
||||
b[21] = 60
|
||||
b[22] = 34
|
||||
b[23] = 120
|
||||
b[24] = 0x2E
|
||||
b[25:35] = bytes([0x35, 0x85, 0xA6, 0x56, 0x48, 0x9A, 0x24, 0x12, 0x50, 0x54])
|
||||
for i in range(8):
|
||||
b[38 + i * 2] = 0x01
|
||||
b[39 + i * 2] = 0x01
|
||||
b[54:72] = make_dtd(base_dtds[0])
|
||||
b[72:90] = make_dtd(base_dtds[1])
|
||||
b[90:108] = make_range(24, 240, 30, 510, max_pixclk_mhz)
|
||||
b[108:126] = make_name(name)
|
||||
b[126] = 1
|
||||
b[127] = checksum(b)
|
||||
return b
|
||||
|
||||
|
||||
def build_cta_primary(dtds):
|
||||
b = bytearray(128)
|
||||
b[0] = 0x02
|
||||
b[1] = 0x03
|
||||
pos = 4
|
||||
b[pos] = (2 << 5) | len(VICS)
|
||||
for i, v in enumerate(VICS):
|
||||
b[pos + 1 + i] = v
|
||||
pos += 1 + len(VICS)
|
||||
b[pos] = (1 << 5) | 3
|
||||
b[pos + 1], b[pos + 2], b[pos + 3] = 0x17, 0x7F, 0x07
|
||||
pos += 4
|
||||
b[pos] = (4 << 5) | 3
|
||||
b[pos + 1] = 0x4F
|
||||
pos += 4
|
||||
b[2] = pos
|
||||
b[3] = 0x40
|
||||
used, p = [], pos
|
||||
for t in dtds:
|
||||
if p + 18 > 127:
|
||||
break
|
||||
b[p : p + 18] = make_dtd(t)
|
||||
used.append(t)
|
||||
p += 18
|
||||
b[127] = checksum(b)
|
||||
return b, used
|
||||
|
||||
|
||||
def build(custom, name, max_pixclk_mhz=1200):
|
||||
if len(custom) < 2:
|
||||
custom = custom + custom[:1] * (2 - len(custom))
|
||||
base = build_base(custom[0:2], name, max_pixclk_mhz)
|
||||
rest = custom[2:]
|
||||
cta1, _used = build_cta_primary(rest)
|
||||
base[126] = 1
|
||||
base[127] = checksum(base)
|
||||
return bytes(base + cta1)
|
||||
|
||||
|
||||
def main():
|
||||
output = sys.argv[1]
|
||||
custom = [
|
||||
cvt_rb(3840, 2160, 60),
|
||||
cvt_rb(2560, 1440, 60),
|
||||
cvt_rb(1920, 1080, 60),
|
||||
]
|
||||
edid = build(custom, "SUN-4K")
|
||||
with open(output, "wb") as f:
|
||||
f.write(edid)
|
||||
assert len(edid) % 128 == 0
|
||||
for i in range(len(edid) // 128):
|
||||
block = edid[i * 128 : (i + 1) * 128]
|
||||
assert sum(block) % 256 == 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,126 @@
|
||||
# Headless 4K virtual output for Sunshine → Moonlight on a client TV.
|
||||
#
|
||||
# NVIDIA proprietary driver: both kernel params are required —
|
||||
# video=<connector>:e force-enables the connector
|
||||
# drm.edid_firmware=<connector>:… 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\"}]";
|
||||
};
|
||||
})
|
||||
]);
|
||||
}
|
||||
@@ -76,6 +76,10 @@
|
||||
gaming.sunshine.enable = true;
|
||||
gaming.sunshine.cudaSupport = true;
|
||||
|
||||
streamingDisplay.enable = true;
|
||||
# Spare NVIDIA port — verify with: grep -H . /sys/class/drm/card*-*/status
|
||||
streamingDisplay.connector = "DP-2";
|
||||
|
||||
monitorInput.enable = true;
|
||||
|
||||
flatpak.enable = true;
|
||||
|
||||
Reference in New Issue
Block a user