На 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.

Комментарии (0)