
Вступление
Представьте типичный домашний сервер с Proxmox. Nextcloud, Jellyfin, торрент-качалка, MediaWiki, и ещё с десяток контейнеров под разные задачи. Думаю, многим читателям эта картина знакома.
Когда на таком сервере появляется медиасервер вроде Plex или Jellyfin, неизбежно возникает вопрос аппаратного ускорения: транскодировать видео на CPU это боль. Поэтому в сервер едет GPU.
GPU есть, сервисы его используют. И в какой-то момент приходит мысль: а что если там же запустить полноценный рабочий стол? С играми, с нормальной производительностью, как на настоящем PC?
Кто-то скажет: сделай виртуалку, пробрось GPU. Технически работает. Но есть несколько «но».
GPU уже занят другими сервисами. Отдать его целиком одной виртуалке значит убить всё остальное. Контейнеры умеют делить GPU между собой, виртуалки нет.
Производительность. В моём случае это сервер на AMD EPYC второго поколения: 48 ядер / 96 потоков, 8-канальная память. Звучит солидно. Но всё это серверное железо с частотой 3.3 GHz и латентностью RAM 120–140 наносекунд. Для рабочих нагрузок отлично, для игр катастрофа. Добавьте сверху накладные расходы гипервизора, и о нормальном гейминге можно забыть.
Мне важно было не выбиваться из общей концепции сервера. Всё работает как контейнеры, значит и рабочий стол тоже контейнер.
Итого задача: полноценный Linux с GPU внутри LXC-контейнера Proxmox, стриминг на любое устройство через Moonlight.
Почему Moonlight, а не VNC или RDP? Потому что это видеопоток, а не протокол удалённого рабочего стола. Нет артефактов, нормальная динамика, клиенты есть на всём от телефона до телевизора. Основа из двух компонентов: Sunshine как серверная часть на Linux и Moonlight как клиент на любом устройстве. Эта связка сразу стала фундаментом, потому что ничего лучше для такой задачи нет.
Я не системный администратор и не разработчик. Я инженер-конструктор, которому интересно копаться в данной области. Поэтому первым делом я пошёл искать: может, такое уже кто-то сделал и задокументировал?
Ответ оказался примерно таким: нет.
Не то чтобы совсем ничего не существовало. Существовали отдельные кусочки, идеи, форумные треды с «а вот если попробовать…». Но готового, воспроизводимого решения не было. Стало понятно, что придётся разбираться самому.
Глава 1. Первый шаг: X11, GPU, монитор которого нет
Что я вообще знал в начале
Если честно, почти ничего. Гугл, форумы, несколько часов чтения дали набор ключевых слов: Plasma, KWin, X11, Wayland, Sunshine. Часть из них была понятна интуитивно, часть нет.
С Sunshine всё стало ясно почти сразу: это сервер стриминга, клиент к нему - Moonlight. Та самая труба, по которой картинка с сервера попадёт на устройство пользователя. Значит Sunshine точно в схеме.
С X11 и Wayland я разобрался чуть позже. X11 это старый протокол, на котором работала графика Linux десятилетиями. Wayland - его современная замена, к которой всё постепенно движется. Логика подсказывала: раз будущее за Wayland, значит и строить нужно на нём.
И только потом пришло понимание, что между «запустить рабочий стол» и «запустить рабочий стол так, чтобы Sunshine мог его захватить внутри LXC» большая пропасть. Но об этом позже.
Первым делом попробовать что-нибудь вообще запустить. Самый простой путь на бумаге: Plasma + X11 + NvFBC + Sunshine.
Проброс GPU в контейнер
Контейнер это не виртуальная машина, своего ядра у него нет. Драйвер NVIDIA грузится один раз на хосте, а внутри контейнера работает только пользовательская часть: библиотеки и утилиты. Первое жёсткое правило: версия драйвера в контейнере должна совпадать с хостом бит в бит.
Установка в контейнер — стандартный .run установщик NVIDIA, но с флагом --no-kernel-module. Ядро нам не нужно, оно на хосте.
Сами устройства пробрасываются через конфиг LXC:
lxc.cgroup2.devices.allow: c 195:* rwm # NVIDIA lxc.cgroup2.devices.allow: c 226:* rwm # DRI lxc.mount.entry: /dev/nvidia0 dev/nvidia0 none bind,optional,create=file lxc.mount.entry: /dev/nvidiactl dev/nvidiactl none bind,optional,create=file lxc.mount.entry: /dev/nvidia-modeset dev/nvidia-modeset none bind,optional,create=file lxc.mount.entry: /dev/nvidia-uvm dev/nvidia-uvm none bind,optional,create=file lxc.mount.entry: /dev/dri/card1 dev/dri/card1 none bind,optional,create=file lxc.mount.entry: /dev/dri/renderD128 dev/dri/renderD128 none bind,optional,create=file
Плюс к этому права доступа. Непривилегированный контейнер использует смещённые UID/GID. Чтобы пользователь внутри контейнера мог обращаться к GPU-устройствам, нужно аккуратно пробросить GID групп video и render с хоста напрямую, без смещения. Это делается через lxc.idmap.
Для совсем ленивых есть ACL скрипт, который после каждого старта расставляет права вручную:
setfacl -m u:1000:rw /dev/nvidia0 /dev/nvidiactl /dev/dri/card1 /dev/dri/renderD128

Проброс GPU в LXC: драйвер и устройства живут на хосте, в контейнер они пробрасываются bind-mount’ом и cgroup2, а версия userspace-библиотек должна совпадать с хостом бит в бит.
Проблема номер один: монитора нет
Настроил контейнер, установил KDE Plasma, X11, NVIDIA драйвер. Попытался запустить, ничего не работает.
Проблема оказалась элементарной, но совершенно неочевидной для человека, который раньше не думал о том, как вообще работает графический стек: X11 и NVIDIA требуют подключённого монитора. Без него X-сервер либо отказывается запускаться, либо деградирует до разрешения 640×480.
А у сервера никакого монитора нет.
Решение нашлось через EDID. Это небольшой бинарный файл (128 или 256 байт) с описанием характеристик монитора: поддерживаемые разрешения, частоты, HDR-возможности. Этот файл можно взять с реального монитора, найти готовый на GitHub, или сгенерировать самому под нужные параметры.
Файл кладётся в /etc/X11/edid.bin, а в xorg.conf прописывается:
Option "ConnectedMonitor" "DFP-0" Option "CustomEDID" "DFP-0:/etc/X11/edid.bin" Option "AllowEmptyInitialConfiguration" "True"
После этого NVIDIA “видит монитор” и нормально запускается. Разрешение и частота берутся из EDID-файла: хочешь 4K 120Hz, кладёшь соответствующий дамп.
Ещё одна засада: Fedora убила X11
Выбор дистрибутива пал на Fedora 43: это самый близкий к Steam Deck дистрибутив и доступный прямо в репозитории Proxmox как контейнер. Казалось бы, идеально.
Но начиная с Fedora 41, проект взял курс на агрессивный отказ от X11. В стандартных репозиториях Fedora 43 пакеты plasma-workspace-x11 и kwin-x11 отсутствуют. По умолчанию KDE Plasma работает только в Wayland-режиме.
Пришлось подключать COPR-репозиторий сообщества, который поддерживает X11-пакеты:
dnf copr enable @kdesig/plasma6-x11-unsupported dnf install plasma-workspace-x11 kwin-x11
Без этого шага X-сервер запустится, но запустить в нём KDE не получится: бинарника startplasma-x11 просто нет в системе.
NvFBC: ещё один патч
NvFBC это проприетарный API NVIDIA для захвата кадрового буфера напрямую с GPU, минуя системную память. За счёт этого стриминг быстрый и с минимальной задержкой. Sunshine умеет им пользоваться, и это лучший вариант для X11.
Но NVIDIA блокирует NvFBC на потребительских картах. Есть патч от keylase: он находит библиотеку libnvidia-fbc.so и убирает ограничение:
git clone https://github.com/keylase/nvidia-patch.git bash patch-fbc.sh # патч NvFBC bash patch.sh # снятие лимита на количество NVENC-сессий
После патча в логах Sunshine появляется долгожданная строка:
[Info]: Screencasting with NvFBC
Sunshine нельзя просто взять и установить
Официальных пакетов для Fedora 43 больше нет, только сборка из исходников. Сам по себе процесс сборки большая, но решаемая задача. На этом этапе я всё ещё запускал Sunshine от root, и проблем не было: стрим шёл, Moonlight подключался.
Но как только базовая картинка заработала, встал следующий вопрос стабилизации: запускать и Plasma, и Sunshine от обычного пользователя, а не от root. Так правильнее: audio, сессия, устройства от user работают предсказуемее.
И тут началось необъяснимое.
Sunshine от user запускался. Moonlight находил хост. Пытался подключиться и зависал. Initial Ping Timeout. Снова и снова. Никаких внятных ошибок в логах.

Та самая ошибка со стороны клиента: Moonlight находит хост, но соединение обрывается на RTSP handshake. Корень — file capability на бинарнике Sunshine и режим AT_SECURE.
Всё было проверено по несколько раз: права на устройства, группы пользователя, ACL на /dev/nvidia*, /dev/uinput. Попробовал дать контейнеру cap_sys_admin на уровне Proxmox: есть там отдельные настройки типов ОС и capabilities для контейнеров. Выдал всё что было. Опрос разрешений показывал что всё доступно. Стрим всё равно не шёл.
Разгадка пришла случайно.
В какой-то момент вместо установленного RPM-пакета я запустил Sunshine прямо из каталога сборки, бинарник до упаковки в пакет. И он заработал от user. Стрим пошёл.
Тот же код. Тот же бинарник по сути. Но из каталога сборки работает, из установленного RPM нет.
Разница оказалась в одном: RPM-пакет при установке выполняет setcap cap_sys_admin+p на бинарник через postinst скрипт. Это file capability, метка прямо на файле. Бинарник из каталога сборки этой метки не имел.
Когда Linux запускает файл с file capability, он включает secure execution mode (AT_SECURE=1). Это меняет поведение на низком уровне, предположительно ломает библиотеку ENet, которую Sunshine использует для control stream (порт 47999). Moonlight отправляет пинг, ENet его не принимает, сессия не открывается. Истинную причину я так и не нашёл — это моя рабочая гипотеза, подтверждённая экспериментом, но не доказанная до конца.
Никакие cap_sys_admin разрешения на уровне контейнера здесь не помогают, это другой механизм. Проблема в file capability на самом бинарнике.
Решение: при сборке выпилить установку capability из двух мест:
В cmake/packaging/linux.cmake:
# set(CPACK_RPM_USER_FILELIST "%caps(cap_sys_admin+p) ${SUNSHINE_EXECUTABLE_PATH}")
В src_assets/linux/misc/postinst закомментировать блок setcap. После этого файл собирается без метки, AT_SECURE=0, ENet работает нормально, стрим от user идёт.
Ещё одна ловушка, уже проще: system_tray=true в конфиге по умолчанию. GTK-трей пытается запустить bubblewrap (sandbox), который несовместим с LXC, Sunshine падает с SIGABRT. Лечится одной строкой: system_tray=false.
Или просто использовать бинарник.
Первый результат
После всего этого получилось. Moonlight подключился, картинка пошла. KDE Plasma на X11, захват через NvFBC, кодирование через NVENC на RTX 3090. Работает стабильно, задержки нормальные.

Первый рабочий результат: KDE Plasma на X11, картинка в окне Moonlight. Захват через NvFBC, кодирование NVENC на RTX 3090.
Стало понятно: в целом это реально. Серьёзных ограничений нет. Остаётся только одна проблема, маленькая, но фундаментальная.
Неожиданное открытие: DRM master
У меня на сервере есть самописный микропроект, мониторинг и управление вентиляторами, в том числе GPU-вентиляторами. Чтобы управлять вентиляторами на NVIDIA, нужен хотя бы какой-то виртуальный X-сервер на хосте, такова особенность драйвера. Этот X-сервер тихо крутился на хосте уже давно.
Он и захватывал DRM master на карте.
DRM master это эксклюзивная блокировка на GPU для операций с дисплейным выводом. Один DRM master на одну карту. Если хост уже держит её через свой Xorg-процесс, контейнер просто не сможет поднять свой X-сервер.
Я долго не мог понять, почему иногда всё работает, а иногда нет. Причина нашлась случайно: fuser -v /dev/dri/card1 на хосте показал мой собственный мониторинг.
Это и есть фундаментальное ограничение метода: X11 + NVIDIA + DRM master = монополия. Два контейнера одновременно не поднять. Сервисы на хосте, использующие ту же карту через Xorg, дают конфликт.

DRM master — один на видеокарту: хостовый Xorg (управление вентиляторами) держит его, и контейнер не может поднять собственный X-сервер. fuser -v /dev/dri/card1 показывает владельца.
Для одного десктопа в одном контейнере работает. Для сетапа с несколькими контейнерами и общим GPU нет.
Нужно искать другой путь.
Глава 2. Wayland: долгожданный выход или новый лабиринт?
X11 с DRM master отпал. Но была ведь ещё Wayland, та самая «современная замена», к которой всё движется. Если X11 тупик, может Wayland — путь?
Первое, что нашлось при поиске: у KWin есть специальный режим --virtual. Запускает KWin без DRM и без монитора, никаких физических устройств не требуется. То, что нужно для headless контейнера. В документации было написано, что режим поддерживает запуск с GPU. Звучало идеально.
Начал копать.
Когда цепочка запуска становится головоломкой
Запустить kwin_wayland --virtual оказалось несложно, по логам всё поднималось быстро. Но дальше выяснилось, что одного KWin мало. Вокруг него выстраивается целая экосистема компонентов, и каждый должен стартовать в правильном порядке и в одном окружении.
Появилось новое понятие, D-Bus. Шина сообщений между процессами. Почти всё в KDE общается через неё: KWin, Plasma, порталы захвата. И если процессы стартуют в разных D-Bus сессиях, они друг друга не видят.
Пришлось разобраться как запустить сессионную шину, наследовать её адрес в каждый процесс и правильно прокинуть в активационное окружение. Потом в цепочку добавился PipeWire: без него портальный захват экрана работать не будет. Потом xdg-desktop-portal и его KDE-бэкенд. Потом krfb-virtualmonitor, утилита KDE для создания виртуального выхода, который только и может нормально видеть Sunshine.
Итоговая последовательность запуска выглядела примерно так:
dbus-daemon → pipewire → wireplumber → kwin_wayland --virtual → экспорт WAYLAND_DISPLAY → xdg-desktop-portal → plasmashell → krfb-virtualmonitor → sunshine
Каждый этап требовал правильных переменных окружения. Порядок имел значение. Один неверный шаг, и цепочка рассыпалась с непонятными ошибками.
Но в итоге подключение появилось. Moonlight нашёл хост.
Курица и яйцо
Радость была недолгой.
Wayland, в отличие от X11, устроен совсем по-другому с точки зрения безопасности. Захват экрана это привилегированная операция. Чтобы приложение получило доступ к экрану через xdg-desktop-portal, пользователь должен явно это подтвердить. Появляется диалог в духе «Sunshine хочет записывать ваш экран» — и предложение выбрать, какой именно output захватывать.

Тот самый диалог портала: KDE спрашивает, какой экран отдать приложению. В headless-контейнере нажать на нужный вариант некому — отсюда и тупик «курица и яйцо».
В headless контейнере нет пользователя, выбрать “Режим” некому. Диалог никогда не появится, а без него захват не начнётся.
Курица и яйцо: чтобы получить картинку на Moonlight, нужно подтвердить доступ. Чтобы подтвердить доступ, нужна картинка на мониторе.
Начал искать обходы. Нашёл упоминания о persist_mode, режиме в котором приложение может сохранить токен разрешения и в следующий раз не спрашивать. Но выяснилось что Sunshine всегда отправляет persist_mode=2 при старте сессии, и xdg-desktop-portal жёстко отвечает ошибкой: "Remote desktop sessions cannot persist". Это хардкод в логике портала.
Попробовал написать перехватчик на C++ который бы автоматически давал разрешение, не вышло добраться до нужного слоя.
В итоге пошёл туда, куда не хотел: в исходники самих пакетов.
Пришлось патчить то, что патчить не хочется
Два компонента нужно было собрать из исходников с правками.
Первый, xdg-desktop-portal (фронтенд портала, версия 1.20.3). Патч небольшой: вместо возврата ошибки при persist_mode тихо удалить этот параметр из запроса и пропустить дальше:
// Было: return fatal error "Remote desktop sessions cannot persist" // Стало: warning + вырезать persist_mode/restore_token из options, продолжить
Второй, xdg-desktop-portal-kde (KDE-бэкенд, версия 6.5.5). Здесь три правки, активируемые переменной окружения XDG_PORTAL_HEADLESS_AUTO_ACCEPT=1:
SelectDevicesпри пустом списке устройств дефолтить на клавиатуру и мышьStart(RemoteDesktop) пропустить GUI-диалог, сразу вызватьcontinueStart()Start(ScreenCast) автоматически выбрать первый реальный output, пропустить диалог
Два пакета, три правки в исходниках KDE Plasma, пересборка, ручная замена бинарников.
Запустил, и - неожиданно — пошёл стрим. Moonlight подключился, картинка появилась.

Первый кадр, который Wayland-стек довёл до Moonlight: рабочий стол Plasma после патчей портала. Так же видно, что интерфейс и тема у Wayland и X11 режима разные.
Но ввод не работал совсем
Радоваться рано. Мышь и клавиатура не работали вообще.
Оказалось, что kwin_wayland --virtual — это режим для автоматизированного тестирования KDE, а не для remote desktop. Судя по тому, что удалось найти, он изначально создан для CI/CD-пайплайнов, где всё управление идёт программно через Wayland-протоколы. Возможно, я чего-то фундаментально не понял и там всё есть, но по факту физические устройства ввода этот режим не видит в принципе: libinput отключён, /dev/input/event* не читается.
Sunshine создаёт виртуальные устройства через uinput. Но KWin в режиме --virtual на них не смотрит.
Единственный способ передать ввод - через D-Bus, тот самый RemoteDesktop портал, по которому идёт и захват экрана. Написал Python-скрипт: читает evdev-события из uinput-устройств Sunshine, проксирует их через D-Bus NotifyPointerMotion / NotifyKeyboardKeycode / NotifyPointerButton в KWin.
Технически работало. Но мышь дёргалась, колесо прокрутки глючило, отзывчивость оставляла желать лучшего.
Итог по первому Wayland-пути
Стрим есть. Ввод какой-то есть. Но цена:
Два пропатченных системных пакета KDE
Python-мост для ввода
Любой
dnf upgradeломает всё, бинарники пакетов перезаписываются
Это не решение, это костыль на костыле. Порядок старта хрупкий, компоненты, которые мы правим, обновляются постоянно. Так жить нельзя.
Второй Wayland-путь: VKMS, дать KWin виртуальный DRM master
Раз --virtual режим такой ограниченный, может попробовать дать KWin настоящий DRM master, но виртуальный? В ядре Linux есть модуль VKMS (Virtual Kernel Modesetting): создаёт виртуальную DRM-карту без физического железа.
Идея: загрузить VKMS на хосте, пробросить card2 (VKMS) в контейнер. KWin берёт DRM master на виртуальной карте, проблемы с монополией нет. Рендерит через NVIDIA. Sunshine захватывает с VKMS через KMS.
На бумаге элегантно.
На практике сразу же новая проблема: KWin при DRM-режиме использует GBM для рендеринга, но открывает card device, а не render node. NVIDIA render node это /dev/dri/renderD128, а не card1. Пришлось написать LD_PRELOAD шим drm_render_redirect.so: перехватывает вызов gbm_create_device() и перенаправляет на нужный render node. Заодно помечает коннекторы NVIDIA как отключённые, чтобы KWin не пытался использовать физические выходы карты.
Sunshine при KMS захвате вызывает drmGetRenderDeviceNameFromFd() для VKMS и получает NULL, потому что у VKMS нет render node. Написал второй шим, sunshine_drm_shim.so: перехватывает этот вызов и возвращает NVIDIA render node.
Два LD_PRELOAD шима, оба написаны с нуля под задачу.
Запустил. Moonlight подключился. Видеопакеты пошли, счётчик кадров растёт… и чёрный экран.
Диагностика показала GL ошибку 0x502 (GL_INVALID_OPERATION) на каждом кадре. Причина архитектурная: VKMS хранит framebuffer в системной RAM (это просто программный виртуальный GPU). Sunshine пытается импортировать этот буфер через NVIDIA EGL, а NVIDIA не может работать с DMA-BUF от стороннего устройства, потому что буфер физически живёт не в видеопамяти.

Архитектурная причина чёрного экрана: framebuffer VKMS лежит в системной RAM, а импортирует его NVIDIA EGL. Кросс-девайс DMA-BUF из RAM на NVIDIA не проходит — GL_INVALID_OPERATION.
KWin рендерит текстуру на NVIDIA (renderD128) → scanout на VKMS plane (card2) → Sunshine захватывает DMA-BUF с VKMS → eglCreateImage → EGLImageTargetTexture2DOES → GL_INVALID_OPERATION (NVIDIA не может) → чёрные пиксели
Теоретически решаемо: модифицировать Sunshine, чтобы при кросс-девайс DMA-BUF читал буфер через CPU (mmap + DMA_BUF_SYNC) и загружал в текстуру через glTexSubImage2D. Паттерн в исходниках Sunshine даже есть, он используется для захвата курсора. Но это уже правки в самом Sunshine, ещё один пропатченный пакет.
На этом остановился.
Итог по обоим Wayland-путям
Подводя черту под обоими экспериментами:
KWin |
KWin DRM + VKMS |
|
|---|---|---|
DRM master |
не нужен |
виртуальный (VKMS) |
Захват |
portal (с патчами) |
KMS (чёрный экран) |
Ввод |
Python мост |
libinput (работает) |
Пропатченных пакетов |
2 (portal + portal-kde) |
нужны правки Sunshine |
Стабильность |
ломается при |
не завершено |
Оба пути упираются в одно: нет ни одного готового компонента, который работает как надо в связке с Sunshine в headless Wayland. Каждый стык требует правок чужого кода. А чужой код обновляется.
Значит, проблема не в конфигурации. Проблема в том, что правильного инструмента просто я просто не нахожу.
Глава 3. Gamescope: протоколы есть, просто спят
После двух тупиков стало очевидно: я не нашёл ни одного готового стека, который просто соединяется и работает. Каждый стык ломается. Значит, нужно что-то менять в самих компонентах. Вопрос лишь в том, что и насколько.
Следующим кандидатом был Gamescope, compositor от Valve, на котором работает Steam Deck.
Первая попытка: наивная
На просторах полно упоминаний «Gamescope + Sunshine». Попробовал в лоб: запустить Gamescope headless, указать Sunshine capture = wlr, направить на сокет Gamescope.
Результат — ничего. Sunshine не подключается. KWin внутрь не садится.
Начал разбираться почему. Оказалось, что Gamescope изначально создан для одной задачи: взять одно fullscreen-приложение (обычно игру), отрендерить его поверх всего и при необходимости масштабировать. Это специализированный gaming compositor, а не универсальная Wayland-платформа. Для работы KWin как вложенного композитора ему нужен набор стандартных Wayland-протоколов, и большинства из них в Gamescope нет:
Протокол |
Зачем нужен KWin |
Есть в Gamescope? |
|---|---|---|
|
Многослойный рендеринг, подповерхности |
нет |
|
Масштабирование и обрезка поверхностей |
нет |
|
Оптимизация фонов |
нет |
|
Захват экрана для Sunshine |
нет |
Казалось бы, ещё один тупик. Но тут обнаружилась одна деталь, которая изменила всё направление.
Ключевое открытие: протоколы уже есть в коде
Gamescope построен на библиотеке wlroots, она включена как подмодуль прямо в его исходники. А wlroots это полноценный конструктор для Wayland-композиторов, и в нём реализованы все нужные протоколы. Просто не включены в Gamescope.
Нужный инструмент лежит в той же коробке, его просто не активировали.
Каких именно протоколов не хватает? На деле KWin требует от родительского композитора заметно больше, чем четыре из таблицы выше. Вот полный набор, который в итоге понадобился, — и всё это уже реализовано в wlroots, нужно лишь вызвать соответствующую функцию создания:
Поверхности и окна
wl_compositor— базовый протокол: создание поверхностей (wl_surface).wl_subcompositor— вложенные подповерхности; именно так KWin отдаёт итоговый кадр.xdg_wm_base— окна верхнего уровня и всплывающие меню (toplevel/popup).
Буферы и масштабирование
wp_viewporter— масштабирование и обрезка поверхностей.wp_single_pixel_buffer_v1— однопиксельные буферы (дёшево заливать сплошной фон).
Вывод и захват
zwlr_screencopy_manager_v1— захват экрана; через него работает Sunshine.
Большую часть этого набора Gamescope уже создаёт для своих задач — не хватало буквально нескольких протоколов.
Не нужно писать реализацию протоколов с нуля, нужно добавить несколько строк вызовов уже готового кода. И поскольку Gamescope в любом случае собирается из исходников (готовых пакетов нет), вопрос обновлений и патчей здесь звучит иначе: это мой форк, и он не ломается от dnf upgrade.
Решение: форкнуть Gamescope и доработать под задачу.
Первый результат: скриншот
Прежде чем браться за полноценный стриминг, нужно было убедиться что концепция вообще рабочая. Первым шагом написал кастомный бэкенд для Gamescope, SunshineBackend. По сути это плагин, который получает готовый скомпозированный кадр и что-то с ним делает. Для начала просто сохраняет в PNG-файл.
Запустил Gamescope с этим бэкендом, внутри простое X11-приложение (xterm). В /tmp/ появился файл. Открыл, xterm виден.
Концепция рабочая, кадры доходят.
Следующий шаг — запустить внутрь KWin с Plasma. Добавил в wlserver.cpp три строки, вызовы wlr_subcompositor_create(), wlr_viewporter_create(), wlr_single_pixel_buffer_manager_v1_create(). Перепробовал протоколы из готовых реализаций wlroots. KWin теперь запускался и подключался к Gamescope.
Но в PNG-файлах был чёрный экран.
Папка внутри папки: самая нетривиальная находка
Это заняло больше всего времени. Логи ничего явного не говорили. AI-агенты предлагали разные гипотезы, но ни одна не попадала в точку. Пришлось разбирать архитектуру послойно.
Выяснилось вот что. KWin6 при рендеринге создаёт не одну поверхность, а две вложенных:
Родительская поверхность — пустышка размером 1×1 пиксель, чёрная. Технический контейнер.
Дочерняя подповерхность — реальный кадр 1920×1080 с рабочим столом.
Gamescope смотрел на родительскую поверхность, видел 1×1 чёрный пиксель, и это отдавал бэкенду. Дочерняя подповерхность с реальным контентом игнорировалась: она не была ни X11-окном, ни XDG-окном, поэтому попадала в очередь “необработанных коммитов” и там тихо пропадала.

Корень «чёрного экрана»: KWin6 отдаёт реальный кадр в дочерней subsurface, а Gamescope коммитит только пустого родителя 1×1.
Исправление потребовало трёх патчей в wlserver.cpp:
Forwarding подповерхностей: когда приходит коммит от подповерхности, перенаправить его на родительское XDG-окно, которое Gamescope знает.
Игнорировать пустой родитель: когда у окна есть дочерние подповерхности, а само оно отправляет буфер 1×1, пропустить, не затирать реальный контент.
Передавать размер: при первом подключении KWin явно сообщить ему нужное разрешение, иначе он выбирал дефолтные 1024×768.
После этих трёх патчей в PNG начал появляться рабочий стол Plasma со всем что положено: панель, иконки, фон.
Статичная картинка и глубокое погружение в пайплайн
Следующий шаг: вместо сохранения в PNG передавать кадры в Sunshine. Технически подключил. Moonlight увидел поток, подключился.
Картинка была, но статичная. Один кадр, и больше ничего не обновлялось.
Пришлось разбираться в том, как вообще движется кадр внутри Gamescope от момента рендеринга до момента отдачи бэкенду. Пайплан там нелинейный: есть путь через PipeWire, есть прямой путь через Present() бэкенда, и они работают по-разному.
Выяснилось что бэкенд получает кадр, но сигнал «готово, отдавай следующий» не возвращался корректно, и Gamescope переставал рендерить новые кадры. Пришлось разобраться с синхронизацией между бэкендом и рендер-циклом.
Это заняло ещё несколько дней.
Главный принцип: всё только в VRAM
Параллельно сформировалось жёсткое техническое требование, которое стало основным правилом проекта: ни одного копирования данных из видеопамяти в системную RAM на всём пути от рендеринга до сети.
Производительность GPU возможна только когда данные остаются в видеопамяти. Как только кадр копируется в RAM, теряется весь смысл аппаратного ускорения. Для игр и тяжёлых приложений это критично.
Итоговый пайплайн кадра:
KWin рендерит → DMA-BUF (в VRAM) → Gamescope Vulkan-композитор (в VRAM) → CSunshineBackend::Present() (указатель на текстуру) → CUDA interop (регистрация в VRAM) → конвертация цветового пространства RGB→NV12 (GPU, в VRAM) → NVENC кодирует (аппаратно, в VRAM) → готовый H.264/HEVC поток → сеть (~1 МБ)
На всём этом пути ни одного memcpy из видеопамяти в оперативную. Кадр покидает VRAM только в виде уже сжатого видеопотока.

Главный принцип: кадр не покидает VRAM до самого NVENC — ни одного memcpy в системную RAM. Наружу уходит только уже сжатый видеопоток.
Ввод: снова мост, но на C++
В nested-режиме KWin не видит физические устройства ввода. Это уже знакомая история по KWin --virtual. Мост нужен и здесь.
На этот раз мост написан на C++, а не Python. Работает значительно быстрее и надёжнее: читает evdev-события от uinput-устройств Sunshine, транслирует их в KWin через Wayland input протоколы напрямую.
Курсор тоже пришлось делать отдельно, через дополнительную поверхность, которую Gamescope накладывает поверх картинки.
Steam и проблема с X11-приложениями
Когда стриминг заработал стабильно, поставил Steam. Steam не увидел монитор и отказался запускаться нормально.
Дело в том, что Steam это X11-приложение. Внутри Wayland-сессии X11-приложения работают через XWayland, слой совместимости, который эмулирует X11-сервер поверх Wayland. Но XWayland в этой схеме нужно было правильно поднять и связать с окружением.
В итоге скрипт запуска стал многоступенчатым: PipeWire → Gamescope с кастомным бэкендом → KWin nested → XWayland → plasmashell → C++ input bridge → Sunshine. Нагроможденно, но каждый компонент здесь по делу.
Итог: Cyberpunk 2077
После отладки Steam установил несколько игр. Главный бенчмарк - Cyberpunk 2077. Запустился. Выдал 44 FPS.
Звучит скромно, но это честные 44 кадра на EPYC с его 3.3 GHz и серверными задержками RAM. GPU при этом загружен на треть, ему просто неоткуда взять больше кадров, бутылочное горлышко на CPU из-за физики и игровой логики. Это не баг рендеринга, а особенность платформы. Главное, что работает, стабильно, без артефактов и без копирований через RAM.
Проект был выложен на GitHub: форк Gamescope с документацией, скриптами запуска и инструкцией по сборке.
Но было одно «но», которое не давало покоя.
Запуск всего стека это длинный скрипт из ~15 шагов в точном порядке. Внутри Gamescope живёт сложный пайплайн с кастомным бэкендом, Vulkan-композицией, CUDA interop. Sunshine встроен как компонент внутрь Gamescope, а не работает рядом самостоятельно. При каждом апдейте самого Gamescope нужно мержить патчи.
Это работало. Но инженерная часть мозга говорила: здесь слишком много сложности ради задачи, которая в теории должна решаться проще.
Так начался следующий этап, тот, который привёл к написанию собственного композитора с нуля.
Глава 4. С нуля: когда понимаешь задачу, инструмент пишется за два дня
Форк Gamescope работал. Plasma работает, игры работают. Cyberpunk работает. Казалось бы, проект готов.
Но если честно с собой, что-то было не так.
Gamescope это примерно 160 000 строк кода. Из них в моём сетапе реально работали около 5 000. Остальное: X11-логика для Steam, различные бэкенды под DRM и KMS, поддержка VRR, HDR-пайплайн, steamcompmgr, механика HDR-тонмаппинга. Весь этот балласт висел в бинарнике и в процессе.
Это само по себе не страшно, но практические последствия ощущались. Смена разрешения была нестабильной. CPU всегда нагружен на 100% пары ядер, даже когда на экране ничего не происходит. Больше 60 fps стабильно получить не удавалось, где-то бутылочное горлышко, но в проекте такого масштаба найти его без глубокого погружения в архитектуру Valve нереально.
Каждое дальнейшее улучшение требовало всё более глубокого понимания чужого огромного кодового проекта.
Вот здесь и пригодилось то, что два месяца исследований всё-таки дали.
Понимание как фундамент
За всё время работы (через X11, KWin --virtual, VKMS, форк Gamescope) сформировалось понимание того, как должен выглядеть пайплайн. Не в деталях кода, а в общих чертах архитектуры.
Нужен headless Wayland-compositor без DRM и без физического монитора. Он принимает KWin как единственного клиента, получает от него один готовый кадр всего рабочего стола, и отдаёт этот кадр Sunshine через стандартный wlroots-протокол wlr_screencopy. Sunshine делает своё дело: кодирует через NVENC и отправляет в сеть.
Всё. Никаких Vulkan-пайплайнов и промежуточных бэкендов, никакого PipeWire на пути кадра. Минималистичная схема.
Библиотека wlroots идеальный конструктор для такой задачи. Она предоставляет все нужные примитивы: headless бэкенд, рендерер, управление протоколами, обработку ввода. По сути готовые строительные блоки, нужно только правильно их собрать.
Решение: написать свой compositor с нуля на C++. Около 2000 строк, не 160 000.

Путь к решению: четыре тупика (X11, KWin --virtual, VKMS, форк Gamescope) накопили понимание архитектуры, на котором CDH написался за два дня.
Первый кадр через два дня
Это был самый быстрый прогресс за весь проект.
На второй день работы KWin подключился к новому композитору, отдал кадр, и он корректно отобразился. Ещё через два дня уже был стрим через Sunshine, Moonlight подключился, картинка обновлялась в реальном времени.
Почему так быстро? Потому что архитектура была понятна заранее. Не было слепого перебора. Каждый шаг делался с пониманием зачем.
Нетривиальные решения
В процессе разработки CDH накопился ряд неочевидных решений, каждое из которых пришлось найти самостоятельно.
Present refresh fixup. Headless бэкенд wlroots при каждом отрисованном кадре отправляет клиентам (KWin, Sunshine) событие с частотой refresh = 0. KWin это видит и думает, что работает на 0 Гц, перестаёт правильно синхронизировать анимации. Sunshine считает аналогично. Смена частоты через SetOutputMode внешне применяется, но клиенты её не видят. Решение: перехватить это событие ещё до того, как оно дойдёт до клиентов, и подставить реальное значение из текущих настроек. Одна строка регистрации обработчика, но в правильной позиции в цепочке слушателей.
PID-фильтр. Sunshine помимо стриминга имеет системный трей, небольшое GTK-приложение. Когда оно видит в Wayland-окружении протокол xdg_wm_base (управление окнами), то пытается создать своё окно и входит в бесконечный цикл ожидания ответа. Compositor зависает. Решение: показывать xdg_wm_base только процессам с нужными именами (kwin_wayland, gamescope, Xwayland). Остальные, включая Sunshine GTK-трей, его не видят.
Damage-driven рендер. В Gamescope рендер шёл принудительно с заданной частотой: 60 fps значит GPU работает каждые 16мс, даже если на экране ничего не изменилось. Wayland-архитектура предполагает другую модель: рендерить только когда что-то поменялось. CDH рендерит новый кадр в трёх случаях: KWin прислал новый commit, пришло событие ввода, либо сработал heartbeat-таймер. При статичном рабочем столе GPU выдаёт ~2 кадра в секунду, загрузка на idle околонулевая.
Heartbeat 500 мс. Если Sunshine не получает кадр больше 1000 мс, он считает что поток пропал, сбрасывает буфер и начинает стримить чёрный экран. Чтобы это исправить, compositor раз в 500 мс принудительно отправляет кадр. Минимально, но достаточно.
Два режима мыши и почему это важно для игр
Во время тестирования с клавиатурой и мышью (в отличие от предыдущего форка, где всё тестировалось с геймпадом) обнаружился баг: в играх с видом от первого лица, когда курсор скрывается, управление камерой переставало работать.
Причина в том, как работает ввод в nested-режиме KWin. Он поддерживает два режима указателя:
FREE. Обычный режим. Мышь двигается по экрану, её позиция абсолютная. Используется для рабочего стола, меню, кликов по элементам.
LOCKED. Режим захваченного курсора. Приложение (игра) говорит «захвати мышь», курсор скрывается, а в KWin начинают приходить только относительные смещения: на сколько пикселей сдвинулась мышь, а не где она сейчас. Это нужно для управления камерой в FPS.
В режиме LOCKED KWin рисует курсор не как отдельный объект, а прямо в финальном кадре рабочего стола, в нужной позиции внутри игры. Значит курсор автоматически попадает в стрим и виден в Moonlight.
Разобравшись с этим, убрал отдельную поверхность для курсора, она была в форке Gamescope. Теперь CDH просто правильно переключает режимы и курсор всегда отображается корректно.

Два режима указателя: в FREE курсор обычный (рабочий стол, абсолютная позиция), в LOCKED KWin рисует его прямо в кадр — поэтому в FPS-играх курсор виден в Moonlight.
Steam Deck Mode: Gamescope возвращается
Когда базовый Desktop Mode был готов, захотелось сделать и игровой режим, тот самый Steam Deck UI, который используется на железном деке.
Steam умеет запускаться в нескольких режимах. Обычный оконный режим знаком всем. Есть GamepadUI, большой экранный интерфейс для дивана. И наконец Steam Deck Mode (-steamos3), полноценный интерфейс Deck с карточками игр, оверлеями и менеджером производительности. Последний работает только в связке с Gamescope, Steam проверяет ряд специфических признаков окружения.
Так что Gamescope вернулся, но уже в другой роли. Не как главный compositor, а как клиент CDH. Gamescope подключается к CDH точно так же как KWin, отдаёт свою поверхность, CDH передаёт её Sunshine.
Схема стала такой:
CDH (headless, wayland-cd) ├── Desktop Mode: KWin → Plasma → всё что угодно └── Deck Mode: Gamescope nested → Steam -steamos3 → игры
При этом Gamescope в nested-режиме не нужен DRM master, он просто Wayland-клиент. Проблема монополии снята.
Но сразу обнаружилась новая сложность: Steam при запуске в -steamos3 режиме проверяет наличие Gamescope через X11-атомы на root window. Это специфические флаги которые в норме выставляет steamcompmgr, внутренний менеджер Gamescope. В нашей схеме steamcompmgr работает внутри nested Gamescope и с X11-поверхностями снаружи не взаимодействует.
Решение: небольшой демон gamescope-focus-daemon, который запускается рядом, подключается к X11 дисплею и выставляет все нужные атомы (GAMESCOPE_PID, GAMESCOPE_FOCUSED_APP, GAMESCOPE_FOCUSED_WINDOW и другие). Steam их видит, считает что находится внутри настоящего Gamescope, и запускается в нужном режиме.
Отдельно пришлось разобраться с кнопкой Steam на геймпаде (Xbox-кнопка). В Deck UI она должна открывать боковое меню прямо во время игры. Это работает через те же X11-атомы, Gamescope переключает фокус между игрой и Steam overlay. Потребовалось правильно настроить взаимодействие атомов и логику переключения фокуса.

Steam Deck Mode, доехавший до Moonlight: nested Gamescope + Steam -steamos3 с карточками игр и оверлеями — как на железном деке.
Что пользователь видит в Moonlight
В интерфейсе Moonlight отображаются два приложения.

Что видит пользователь при подключении к хосту: два приложения — Desktop и Steam Deck Mode. Выбор переключает весь стек автоматически.
Desktop — полноценный KDE Plasma рабочий стол. При подключении скрипт автоматически применяет разрешение и частоту которые выбрал пользователь, запускает KWin и Plasma если они ещё не запущены.
Steam Deck Mode - игровой интерфейс Steam Deck. При подключении убивает Plasma если она была, запускает Gamescope nested + Steam в steamos3 режиме.
Переключение работает в обе стороны: закончил играть в Deck Mode, отключился от Moonlight, скрипты сами завершили Gamescope и Steam. Переподключился как Desktop, запустилась Plasma.

Под капотом всегда работают CDH + Sunshine; выбор приложения в Moonlight запускает prep-скрипт нужного режима, отключение — undo. Переключение Desktop ↔ Deck автоматическое, без ручного вмешательства.
Что не сделано
Есть один нюанс в Steam Deck Mode: хотя интерфейс выглядит как настоящий Steam Deck, Steam понимает что это не нативное железо. Кнопка ... на геймпаде открывает боковую панель, это работает. Но раздел производительности в этой панели пустой: не настраивается оверлей (включен всегда) с fps, нет переключателей TDP и прочих возможностей Deck.
Это связано с тем, что для работы этой части Steam ожидает специфических компонентов прошивки Deck, которых в обычном Linux контейнере нет. Задача решаемая, но требует отдельного исследования.

Что пока не сделано: боковая панель в Deck-режиме открывается, но раздел производительности пустой — Steam ждёт компонентов прошивки Deck.
Итог
Вот что получилось в конце пути.

Финальная архитектура: CDH стоит headless-композитором между Sunshine и рабочим столом, под ним взаимозаменяемо подключаются KWin + Plasma или Gamescope + Steam. DRM master не нужен — GPU остаётся общим.
Один LXC-контейнер на Proxmox, Fedora 43. RTX 3090 используется совместно с другими сервисами на хосте: Plex кодирует свои фильмы, контейнер с рабочим столом стримит игры, никто друг другу не мешает. DRM master не нужен, можно запустить несколько таких контейнеров одновременно.
При подключении через Moonlight выбор: рабочий стол или Steam Deck Mode. Любое разрешение, частота до 144 Hz, задержки сопоставимы с локальным запуском.
Я не программист. Я инженер-конструктор, которому интересно как это всё устроено внутри. Проект занял около двух месяцев реального времени. Со стороны это легко принять за «вайбкод», но я уверен, что это не он. Это не тот случай, когда лендинг рождается за пару промптов. Это было итеративное исследование: гипотеза, тест, тупик, понимание, следующая гипотеза. Агенты здесь инструмент для реализации идей, а не замена инженерного мышления.
Исходный код, документация и инструкции по сборке на GitHub:
cloud-desktop-host (Desktop + Steam Deck Mode)
Cloud-Desktop (форк Gamescope, первая рабочая версия)
Предупреждение честности: проект тестировался только на моём железе (Proxmox + EPYC + RTX 3090). Сборка ручная, готовых пакетов нет, настройка многоэтапная. Это не “поставил и работает”, это “разобрался и запустил”. В теории любая карта NVIDIA должна заработать сразу — пайплайн завязан на NVENC и CUDA. А вот под AMD Radeon почти наверняка потребуется отдельный пайплайн на Vulkan, и что будет с Intel — я пока не знаю.