Одна раскладка на все игры: как я обошёл ограничение Steam Deck и нашёл контроллер номер 15

На Steam Deck нельзя взять раскладку управления из одной игры и поставить её в другую. Настроил в одной визуальной новелле удобное управление, сохранил его под названием «Для чтения», открываешь следующую новеллу — а в списке пусто. Valve об этом давно просят, тема в сообществе Steam висит не первый год.
Мне это надоело, и я решил научить этому свою программу DeckDrop. По дороге выяснилось, почему Steam так себя ведёт, где он на самом деле хранит раскладки и что встроенное управление Steam Deck для самого Steam — это контроллер номер 15, а не 0. А ещё — как проверять код, который работает с Steam, когда самого Steam рядом нет.

Что за DeckDrop
Коротко, для тех, кто не читал прошлую статью. DeckDrop — маленький веб-сервер, который живёт на самом деке. С телефона или ПК в той же сети кидаешь ссылку, и дек сам скачивает игру, распаковывает и добавляет в Steam с Proton и обложками. Это один файл на Python, только стандартная библиотека, без root и без режима разработчика.
Для этой статьи важно одно: запущенным Steam DeckDrop управляет так же, как Decky Loader. Включает локальный отладочный порт CEF (файл-маркер .cef-enable-remote-debugging в папке Steam) и выполняет JavaScript в контексте SharedJSContext, где живёт объект SteamClient — внутренний API, которым пользуется сам интерфейс Steam.
Как я исследовал Steam
Документации на всё это нет, поэтому я шёл экспериментами: гипотеза, проверка на живом деке, выводы, следующая гипотеза. Для каждой проверки я делал небольшой скрипт-разведчик, который запускается прямо на деке. Главное правило: по умолчанию он только читает. Всё, что он пишет, делается по отдельному флагу и только в его собственные пробные файлы, а для отката есть --cleanup. Портить свой Steam ради эксперимента не хотелось.
Результат скрипт отдаёт по ссылке в локальной сети: открываешь её на телефоне и сразу видишь отчёт, без похода в режим рабочего стола. Отдаёт он только этот отчёт: на любой путь, включая /../../etc/passwd, возвращается тот же файл, это проверено тестом.
Таких раундов понадобилось четыре.
Раунд 1: где Steam хранит раскладки
Раскладки лежат вот здесь:
~/.local/share/Steam/steamapps/common/Steam Controller Configs/<AccountID>/config/<игра>/И сразу стало понятно, почему раскладка «привязана» к игре. Текущая раскладка игры — файл controller_neptune.vdf (Neptune — внутреннее имя контроллера Steam Deck). Когда сохраняешь свою раскладку под названием, Steam кладёт её файлом <название в нижнем регистре>_<n>.vdf в папку этой же игры. А список «Ваши раскладки» Steam собирает только из папки текущей игры. При этом внутри файла обычные назначения кнопок, к игре он ничем не привязан — просто лежит не там, где его ищут другие игры.
Какая раскладка выбрана, хранится отдельно, в configset_controller_neptune.vdf. Для каждой игры там одно из трёх:
"autosave" "1"— раскладка, отредактированная прямо в игре;"template" "CLOUD_<appid>/<имя>_<n>"— выбрана сохранённая раскладка;"workshop" "<id>"— раскладка из мастерской.
Для игр из магазина папка называется по AppID. А вот со сторонними играми интереснее: папка называется по имени ярлыка в нижнем регистре без знаков препинания, при этом пробелы и восклицательный знак остаются. Ярлык «Cool Game: Director's Cut» даст папку cool game directors cut, а ярлык Game.exe — папку gameexe.
Тут же нашлась ловушка. Папка называется по имени ярлыка на момент, когда Steam её впервые создал. Если ярлык потом переименовать, раскладка так и останется в старой папке. У меня был ровно такой случай: игра добавилась как Game.exe, я переименовал её по-человечески, а раскладка так и живёт в gameexe. Угадать папку по текущему имени нельзя, поэтому DeckDrop перебирает варианты: текущее имя ярлыка, имя, под которым он сам добавлял игру, имя exe с расширением и без. Позже нашёлся способ надёжнее — спросить саму Steam, об этом ниже.
Ещё из мелочей:
Бывают раскладки не на тип контроллера, а на конкретное устройство: рядом лежат
<серийник>.vdfиconfigset_<серийник>.vdf. Это потом ещё пригодится.У шаблонов Valve в
controller_base/templatesназвание читалось как пустое. Причина — BOM в начале файла, читать надо вutf-8-sig.У
SteamClient.Inputоколо сотни методов, и у всехlengthравен 0: это нативные обёртки, сигнатуры по ним не узнать. Зато по названиям видно, что искать:SetSelectedConfigForApp,GetConfigForAppAndController,QueryControllerConfigsForApp.
Раунд 2: шаблоны видны в каждой игре
Раз «свои» раскладки Steam ищет только в папке игры, нужно место, которое он показывает всем играм. Такое место есть — шаблоны. Эксперимент был простой: скопировать раскладку в ~/.local/share/Steam/controller_base/templates/ под именем controller_neptune_deckdrop_probe.vdf и поменять в ней название на «DeckDrop probe».
Проверил на деке: раскладка появилась во вкладке «Шаблоны» у других игр. Это и есть обход. Файл в папке шаблонов Steam предлагает каждой игре, и на самом деке его можно выбрать без всякого DeckDrop.
Раунд 3: кнопка «Применить» не работает
Выбирать шаблон на деке — уже хорошо, но хотелось кнопку «Применить» прямо в карточке игры на телефоне. В описании пулл-реквеста к одному Decky-плагину нашлась подсказка: у SetSelectedConfigForApp пять аргументов, а с четырьмя он возвращается без ошибки и ничего не выбирает. Из того PR я взял только этот факт. Код оттуда не брал, написал свой.
Первая попытка:
SetSelectedConfigForApp(appid, 0, "template://controller_neptune_deckdrop_probe.vdf", false, 1)Не сработало. Ни ошибки, ни результата. Номер контроллера я взял за 0 по аналогии с другими API — и зря. Стал разбираться, и всё прояснилось:
GetConfigForAppAndController(appid, i)для i от 0 до 3 возвращалbConfigurationEnabled: false. Контроллеров с такими номерами просто нет.В глобальном
ControllerStore.m_controllerListнашёлся «Steam Deck Controller» сnControllerIndex: 15иeControllerType: 4. Иm_nLastValidActiveControllerIndexтоже 15.
Вот и главная находка: встроенное управление Steam Deck для Steam — контроллер номер 15, а не 0.
Раунд 4: заработало
Теперь номер брался из ControllerStore. Перед переключением я копировал всю папку Steam Controller Configs в резервную, а после смотрел, какие файлы изменил Steam. Результат на деке: раскладка применилась.
До вызова GetConfigForAppAndController(appid, 15) возвращал:
{"Title": "Геймпад с управлением камерой",
"URL": "autosave:///home/deck/.local/share/Steam/steamapps/common/Steam Controller Configs/<id>/config/<игра>/controller_neptune.vdf",
"ProgenitorURL": "default://<игра>", "eSelectionType": 0, "bConfigurationEnabled": true}После:
{"Title": "DeckDrop probe",
"URL": "template://controller_neptune_deckdrop_probe.vdf",
"eExportType": 1, "eSelectionType": 1, "bSelected": true}Изменился только один файл — configset_<серийник>.vdf, где у игры появилось "template" "controller_neptune_deckdrop_probe.vdf". Выбор Steam записывает именно для конкретного устройства, а не для типа контроллера. Файл прежней раскладки игры не тронут, так что к ней можно вернуться.
И бонус: GetConfigForAppAndController отдаёт точный путь к текущей раскладке игры, в поле URL. Так DeckDrop находит папку игры, даже когда до неё не ведёт ни одно имя, — тот самый случай с переименованным ярлыком.
А номер 15 постоянный?
Это был мой первый вопрос. Номер относится к устройству, а не к игре, так что от игры к игре не меняется. А вот от дека к деку и при подключении внешнего геймпада может отличаться. Число 15 я видел только на своём деке. Поэтому DeckDrop его нигде не зашивает, а при каждом применении спрашивает у Steam список контроллеров:
// какой контроллер — встроенное управление дека: не 0, у Steam свой номер (15 на моём деке)
(() => {
const store = window.ControllerStore || {}, list = store.m_controllerList || [];
const deck = list.find(c => c.eControllerType === 4) || list[0];
const index = deck ? deck.nControllerIndex : store.m_nLastValidActiveControllerIndex;
return index === undefined ? null : index;
})()А после переключения перечитывает выбор и сообщает об успехе, только если Steam подтвердил новую раскладку:
// выбрать раскладку так же, как это делает окно Steam, и дождаться, пока Steam её подтвердит
await input.SetSelectedConfigForApp(appid, index, url, false, 1);
for (let i = 0; i < 12; i++) {
const now = await read(); // GetConfigForAppAndController(appid, index)
if (now.url === url) return {ok: true, index, ...now};
await new Promise(r => setTimeout(r, 250));
}
return {ok: false, reason: 'not_switched', index, ...(await read())};С внешним геймпадом я это не проверял.
Что получилось в 0.5.0
В карточке игры появился блок «Раскладки контроллера»: раскладки этой игры в Steam, кнопка «Сохранить в DeckDrop», строка «Сейчас в Steam: …» и «Раскладка из DeckDrop» → «Применить».
Хранилище. Сохранённая раскладка — это точная копия исходного файла в
~/.config/deckdrop/layouts/<id>.vdfи рядом<id>.jsonс названием, игрой и датой.Зеркало в шаблоны Steam:
controller_base/templates/controller_neptune_deckdrop_<id>.vdfс названием «DeckDrop: …». Так раскладку можно выбрать и на самом деке, без телефона.Синхронизация. Папка шаблонов лежит внутри файлов клиента Steam, и его обновление может её очистить. Поэтому DeckDrop возвращает свои шаблоны при запуске и при открытии списка, а лишние удаляет — только файлы со своим префиксом и правильным id. Шаблоны Valve и чужие файлы не трогаются.
«Применить» — тот самый
SetSelectedConfigForAppс проверкой. Выбор записывает сам Steam, DeckDrop в его файлы при этом ничего не пишет.«Раскладка для новых игр» в настройках. Выбранная раскладка ставится каждой игре, которую DeckDrop добавляет в Steam. Если управление Steam сейчас выключено, применение встаёт в очередь вместе с именем и Proton и выполнится, когда управление появится.
В настройках раскладки можно переименовать, удалить, скачать как
.vdfили загрузить, например с другого дека.

Как доказать, что функция не портит чужие файлы
У меня было одно жёсткое требование: сохранение в DeckDrop и удаление из него должны менять только файлы раскладок и ничего больше. Трогать настройки Steam и чужие раскладки нельзя ни при каком раскладе. Для DeckDrop это вообще принцип: там уже есть обязательный тест на то, что обновление программы не теряет данные пользователя.
Проверяется это так. Настоящий сервер DeckDrop запускается на временной домашней папке с фейковым Steam, устроенным как на моём деке: раскладка по серийнику, сохранённая раскладка с BOM, раскладка для другого контроллера, битый файл, папки других игр, configset, шаблоны Valve. До и после каждого действия снимается SHA-256 всех файлов домашней папки, и сравнивается точная разница: что добавилось, что пропало, что изменилось.
сохранение — ровно три новых файла: сама раскладка, её описание и шаблон. Всё остальное байт в байт то же;
повторное сохранение с тем же названием — вторая раскладка, первая не переписана;
переименование — меняются только своё описание и свой шаблон;
удаление — пропадают только свои три файла;
раскладка другой игры, другого контроллера или битая,
../в пути, пустое название, кривой id, слишком большой файл — ошибка и ноль изменений;применение — в файлах Steam не меняется ничего, выбор делает сам Steam.
Всего в этом наборе 20 тестов. Плюс обязательный тест обновления теперь ставит прошлые релизы 0.4.0, 0.4.1 и 0.4.2, «нажимает» в них «Обновить» и проверяет, что сохранённые раскладки пережили обновление байт в байт и снова видны в списке.
Как тестировать Steam без Steam
Тут две половины.
Первая — подставной отладочный порт Steam, около 150 строк на стандартной библиотеке. Он отвечает на /json, выдаёт цель SharedJSContext и поднимает минимальный WebSocket-сервер. Выполнять JavaScript он не умеет, поэтому узнаёт запрос по вызову Steam внутри выражения и отвечает так, как отвечал дек. Есть режимы «Steam переключил», «Steam не переключил» и «нет контроллера». Так проверяется вся цепочка сервера: кнопка, API, CDP, проверка ответа и запись в карточке игры.
Вторая — настоящий браузер для самого JavaScript. Браузерная проверка в CI запускает в Chromium ровно те выражения, которые DeckDrop шлёт в Steam, против заглушки SteamClient, которая ведёт себя как дек: у встроенного управления номер 15, а выбор срабатывает только при пяти аргументах и только для этого контроллера.
А тесты вообще ловят ошибки?
Тесты, которые всегда зелёные, мало что доказывают. Поэтому в код специально вносились поломки, и проверялось, что тесты падают. Поймались все:
Поломка | Какой тест упал |
|---|---|
не удалять лишние шаблоны | тесты раскладок |
удаление не трогает шаблон | тесты раскладок |
искать папку игры без имени exe (случай с переименованным ярлыком) | тесты раскладок |
не проверять, что раскладка принадлежит этой игре | тесты раскладок |
не отделять раскладки другого контроллера | тесты раскладок |
менять все поля title в файле, а не только верхнее | тесты раскладок |
синхронизация переписывает сохранённую раскладку при запуске | тест обновления |
SetSelectedConfigForApp с четырьмя аргументами вместо пяти | браузерная проверка |
номер контроллера 0 вместо найденного | браузерная проверка |
не проверять, что Steam подтвердил переключение | браузерная проверка |
И пара честных эпизодов, когда тесты нашли ошибки по-настоящему:
Баг в самой программе. При пустом названии файл раскладки успевал записаться на диск до проверки названия. Тест «плохой ввод ничего не пишет» это поймал: в папке раскладок появлялся лишний файл. Теперь сначала проверка, потом запись.
Баг в тесте. Помощник теста подставлял рабочий id вместо пустого (
lid or self.lid), и тест «пустой id отклоняется» получал успешный ответ. Исправлено наis None.Баг в заглушке Steam. Случай «нет контроллера» падал, потому что у заглушки оставался «последний активный контроллер 15», а код намеренно на него откатывается. Правильной оказалась программа, а поправить пришлось заглушку.
Бонус: мои тесты накручивали счётчик скачиваний
Пока делалась 0.5.0, я заметил странное: у последнего релиза на GitHub за несколько часов было под полсотни скачиваний deckdrop.py, а установщик за это время скачали пару раз. Разгадка оказалась смешной. Самопроверка DeckDrop проверяла, доступен ли адрес обновления, запросом HEAD прямо на файл релиза. GitHub засчитывает такой запрос как скачивание. А самопроверку на каждом прогоне CI запускают сразу несколько тестов, и всё это на трёх версиях Python. То есть счётчик скачиваний показывал в основном мои же тесты.
Исправил в том же релизе. Если адрес обновления указывает на файл релиза GitHub, проверка идёт на страницу …/releases/latest, а сам файл не трогается. Тесты получают локальный адрес обновления и к GitHub вообще не ходят. И отдельный тест проверяет, что самопроверка отправляет ровно один запрос на тестовый адрес и больше никуда. Уже накрученные цифры, правда, со счётчика не уходят.
Если у вас в программе есть «проверка обновлений», проверьте, не качает ли она релиз и не бьёт ли по счётчику.
Ограничения
Всё про Steam проверено на одном деке с версией SteamOS и Steam на сентябрь 2026 года.
SteamClient— внутренний API без документации, Valve может поменять его в любой момент.Номер 15 видели на одном устройстве. Код его не зашивает, но с внешним геймпадом я не проверял.
Раскладки игр, которые используют Steam Input API (у игры свои действия вроде «прыжок»), в чужой игре бессмысленны. В моей библиотеке таких не нашлось.
Обновление Steam может очистить папку шаблонов. DeckDrop вернёт свои шаблоны, но до его следующего запуска их может не быть.
Для кнопки «Применить» нужно управление Steam через отладочный порт, как у Decky. Без него раскладку выбирают на самом деке во вкладке «Шаблоны».
Вместо заключения
Самое интересное в этой истории для меня — насколько по-разному Steam относится к почти одинаковым файлам. Раскладка в папке игры видна только этой игре, та же раскладка в папке шаблонов видна всем, а выбор вообще записывается под серийник конкретного устройства. И всё это держится на внутреннем API, где у сотни методов нет ни одной сигнатуры.
Если у вас есть Steam Deck и DeckDrop 0.5.0, мне очень интересно, какой номер контроллера у вашего дека и работает ли «Применить» с подключённым внешним геймпадом. Пишите в комментариях или в issues: github.com/Aniforka/deckdrop.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.