У меня есть старый геймпад от игровой приставки Денди (клон NES). Задача: подключить его к ПК, чтобы играть в старые игры на “оригинальном” геймпаде. Я уже публиковал статью про подключение геймпада Sega Mega Drive к ПК. Теперь точно так же подключим геймпад от Денди. Изменений в конструкцию геймпада вносить не будем, вместо этого будем опрашивать геймпад точно так же, как это делала сама игровая приставка.
Вот что должно получиться в итоге:


Содержание
Геймпад от игровой приставки NES
Оригинальная игровая приставка называлась Nintendo Entertainment System (NES) на рынке США и Европы или Famicom (Family Computer) в Японии. В России в основном были неофициальные клоны этой приставки с разными названиями, обычно их все называли Денди (Dendy) по названию самого известного клона. Подробнее в Википедии в соответствующих статьях про NES и Dendy.
От какой именно приставки у меня геймпад и откуда он взялся я не помню, известно только, что это какой-то клон NES.

Компоновка геймпада стандартная:
Крестовина слева
Кнопки Start и Select посредине
Кнопки B и A (именно в таком порядке) справа. Над ними дублирующие кнопки с режимом Turbo (зажатую кнопку приставка воспринимает как серию быстрых нажатий)
Дальше рассмотрим протокол опроса геймпада и обязательно проверим работу Turbo-кнопок в конце статьи.
Подключение и протокол опроса геймпада NES
У разных версий приставки были разные разъемы для подключения геймпадов. У оригинальной Famicom (версия NES для Японии) геймпады вообще не имели внешнего разъема для подключения и не отсоединялись от консоли.

У оригинальной NES был 7-контактный разъем своей собственной конструкции, а у клонов приставки 9 или 15-контактные разъемы, представляющие собой стандартные разъемы D-Sub: DB-9 и DA-15. Соответственно к ним можно купить стандартные гнезда для подключения и подключать геймпад оригинальным разъемом.
У моего геймпада 15-контактный разъем, на фото ниже он справа.

На все разъемы выведены одни и те же контакты геймпада:
Питание: +5 В и GND.
Latch
Pulse (Clock)
Data

Для считывания значений кнопок нужно подать импульс высокого уровня сигнала на контакт Latch, а затем последовательно считывать состояния кнопок геймпада с контакта Data, после считывания каждого значения подавая импульс высокого уровня сигнала на контакт Pulse (Clock).
Последовательность считывания кнопок такая: A, B, Select, Start, Up, Down, Left, Right.
Сигналы при опросе геймпада приведены на графике ниже.

Длина импульса на контакте Latch равна 12 микросекунд, импульсы на контакте Pulse (Clock) должны быть длиной 6 микросекунд с паузами между импульсами тоже по 6 микросекунд.
Приставка повторяет опрос геймпада каждый кадр (50-60 Гц), но можно повторять опрос с максимальной частотой, добавляя задержку 6 микросекунд после каждого опроса.
Вот код для проверки геймпада, в котором реализован алгоритм опроса и вывод значений кнопок в последовательный порт:
Код для проверки работы геймпада
// No actually a NesGamepad library example but just reading buttons from NES gamepad and print to serial port // also blink led const int latchPin = A0; const int pulsePin = A1; const int dataPin = A2; const unsigned int delayTimeMicroseconds = 6; const int btnsCount = 8; bool btns[8]; const char* btnNames[8] = { "A", "B", "Select", "Start", "Up", "Down", "Left", "Right" }; void setup() { Serial.begin(115200); pinMode(latchPin, OUTPUT); pinMode(pulsePin, OUTPUT); digitalWrite(latchPin, LOW); digitalWrite(pulsePin, LOW); pinMode(dataPin, INPUT_PULLUP); } void loop() { digitalWrite(pulsePin, LOW); digitalWrite(latchPin, HIGH); delayMicroseconds(delayTimeMicroseconds * 2); digitalWrite(latchPin, LOW); delayMicroseconds(delayTimeMicroseconds); for (int i = 0; i < btnsCount; i++) { btns[i] = !digitalRead(dataPin); digitalWrite(pulsePin, HIGH); delayMicroseconds(delayTimeMicroseconds); digitalWrite(pulsePin, LOW); delayMicroseconds(delayTimeMicroseconds); } String s = String(); for (int i = 0; i < btnsCount; i++) { s = s + btnNames[i] + ":" + btns[i] + " "; } Serial.println(s); digitalWrite(LED_BUILTIN, LOW); delay(100); digitalWrite(LED_BUILTIN, HIGH); delay(200); }
Пример данных, выводимых в последовательный порт:
A:0 B:0 Select:0 Start:0 Up:0 Down:0 Left:0 Right:0 A:1 B:0 Select:0 Start:0 Up:0 Down:0 Left:0 Right:0 A:1 B:1 Select:0 Start:0 Up:0 Down:0 Left:0 Right:0 A:1 B:1 Select:0 Start:0 Up:0 Down:0 Left:0 Right:0 A:0 B:0 Select:0 Start:0 Up:1 Down:0 Left:0 Right:0 A:0 B:0 Select:0 Start:1 Up:1 Down:0 Left:0 Right:0 A:0 B:0 Select:0 Start:1 Up:0 Down:0 Left:0 Right:0 A:0 B:0 Select:0 Start:1 Up:0 Down:0 Left:0 Right:0
Сборка переходника
Для переходника я использовал разъем D-Sub DA-15 в пластиковом корпусе и Arduino Pro Micro (ATmega32u4).





Библиотека NesGamepad
Для удобства работы с геймпадом завернул логику опроса в библиотеку NesGamepad. Код библиотеки опубликовал на GitHub, на PlatformIO и в Arduino library-registry (в менеджере библиотек Arduino IDE ввести в поиске “Nes Gamepad”).
Для использования библиотеки нужно создать экземпляр класса NesGamepad. В конструктор передать порты контроллера, к которым подключены контакты геймпада Latch, Pulse (Clock) и Data. Также можно передать значение длины импульсов при опросе геймпада (по умолчанию 6 миллисекунд для импульсов Pulse и 6*2=12 миллисекунд для импульса Latch).
const int latchPin = A0; const int pulsePin = A1; const int dataPin = A2; const unsigned int delayBeforeReadMicros = 6; NesGamepad gamepad(latchPin, pulsePin, dataPin, delayBeforeReadMicros);
Далее в процедуре setup() вызвать метод NesGamepad::init() для инициализации портов контроллера.
gamepad.init();
После этого в цикле loop() нужно вызывать метод NesGamepad::update() и читать состояния кнопок геймпада из полей объекта gamepad.
gamepad.update(); Serial.println(gamepad.btnA); Serial.println(gamepad.btnStart); Serial.println(gamepad.btnUp);
Прошивка переходника
Для переходника сделал две версии прошивки: с эмуляцией USB клавиатуры и с переключением режимов USB клавиатура/USB геймпад.
Код прошивок выложил в отдельный репозиторий на GitHub.
Для сборки нужно использовать Arduino IDE установленными библиотеками NesGamepad и ArduinoJoystickLibrary для версии с переключением режимов.
Также в коде используется класс ButtonDebounce для фильтрации ложных срабатываний кнопок при замыкании/размыкании контактов.
Код прошивки переходника с эмуляцией USB клавиатуры
// Press Start on gamepad during startup to enable serial output #include <Keyboard.h> // Install NesGamepad lib from here: https://github.com/IvoryRubble/ArduinoNesGamepadLibrary #include <NesGamepad.h> #include "ButtonDebounce.h" bool serialPrintEnabled = false; unsigned long previousBtnUpdateTime = 0; const int latchPin = A0; const int pulsePin = A1; const int dataPin = A2; const unsigned int delayBeforeReadMicros = 6; NesGamepad gamepad(latchPin, pulsePin, dataPin, delayBeforeReadMicros); unsigned long debounceDelay = 25; ButtonDebounce btnDebouces[gamepad.btnsCount] = { {debounceDelay}, {debounceDelay}, {debounceDelay}, {debounceDelay}, {debounceDelay}, {debounceDelay}, {debounceDelay}, {debounceDelay} }; const char* btnNames[gamepad.btnsCount] = { "A", "B", "Select", "Start", "Up", "Down", "Left", "Right" }; const uint8_t keysKeyboard[gamepad.btnsCount] = { 'k', 'j', '\\', KEY_RETURN, 'w', 's', 'a', 'd' }; void setup() { gamepad.init(); delay(2000); int gamepadReadigsToDiscard = 2; for (int i = 0; i < gamepadReadigsToDiscard + 1; i++) { gamepad.update(); } initSerialPrintEnableFlag(); Keyboard.begin(); } void loop() { gamepad.update(); btnDebouces[0].updateState(gamepad.btnA); btnDebouces[1].updateState(gamepad.btnB); btnDebouces[2].updateState(gamepad.btnSelect); btnDebouces[3].updateState(gamepad.btnStart); btnDebouces[4].updateState(gamepad.btnUp); btnDebouces[5].updateState(gamepad.btnDown); btnDebouces[6].updateState(gamepad.btnLeft); btnDebouces[7].updateState(gamepad.btnRight); updateKeyboard(); if (serialPrintEnabled) { printGamepadStatus(); } } void initSerialPrintEnableFlag() { if (gamepad.btnStart) { serialPrintEnabled = true; Serial.begin(115200); delay(5000); Serial.println(); Serial.println("Please stand by..."); delay(1000); Serial.println(); Serial.println("Enabled serial output by pressing Start on gamepad during startup"); } else { serialPrintEnabled = false; } } void updateKeyboard() { for (int i = 0; i < gamepad.btnsCount; i++) { if (btnDebouces[i].isBtnPressed) { Keyboard.press(keysKeyboard[i]); } if (btnDebouces[i].isBtnReleased) { Keyboard.release(keysKeyboard[i]); } } } void printGamepadStatus() { unsigned long currentTime = millis(); unsigned long longDelayTimeout = 1000; for (int i = 0; i < gamepad.btnsCount; i++) { if (btnDebouces[i].isBtnPressed) { if (currentTime - previousBtnUpdateTime > longDelayTimeout) Serial.println(); Serial.print("+ "); Serial.print(currentTime - previousBtnUpdateTime); Serial.print(" ms "); Serial.print(btnNames[i]); Serial.println(" pressed"); previousBtnUpdateTime = currentTime; } if (btnDebouces[i].isBtnReleased) { if (currentTime - previousBtnUpdateTime > longDelayTimeout) Serial.println(); Serial.print("+ "); Serial.print(currentTime - previousBtnUpdateTime); Serial.print(" ms "); Serial.print(btnNames[i]); Serial.println(" released"); previousBtnUpdateTime = currentTime; } } }
Код прошивки переходника с эмуляцией переключением режимов USB клавиатура/USB геймпад
// Press Start on gamepad during startup to enable serial output // Press Start+A on gamepad during startup to change output mode to keyboard // Press Start+B on gamepad during startup to change output mode to joystick #include <Keyboard.h> // Install Joystick lib from here: https://github.com/MHeironimus/ArduinoJoystickLibrary #include <Joystick.h> #include <EEPROM.h> // Install NesGamepad lib from here: https://github.com/IvoryRubble/ArduinoNesGamepadLibrary #include <NesGamepad.h> #include "ButtonDebounce.h" bool serialPrintEnabled = false; unsigned long previousBtnUpdateTime = 0; const int outputModesCount = 2; enum OutputMode { keyboardOutputMode = 0, joystickOutputMode = 1 }; const char* outputModeNames[outputModesCount] = { "keyboard", "joystick" }; OutputMode outputMode = keyboardOutputMode; int outputModeStorageAddress = 24; const int latchPin = A0; const int pulsePin = A1; const int dataPin = A2; const unsigned int delayBeforeReadMicros = 6; NesGamepad gamepad(latchPin, pulsePin, dataPin, delayBeforeReadMicros); Joystick_ joystick(JOYSTICK_DEFAULT_REPORT_ID, JOYSTICK_TYPE_GAMEPAD, 4, 1, false, false, false, false, false, false, false, false, false, false, false); unsigned long debounceDelay = 25; ButtonDebounce btnDebouces[gamepad.btnsCount] = { {debounceDelay}, {debounceDelay}, {debounceDelay}, {debounceDelay}, {debounceDelay}, {debounceDelay}, {debounceDelay}, {debounceDelay} }; const char* btnNames[gamepad.btnsCount] = { "A", "B", "Select", "Start", "Up", "Down", "Left", "Right" }; enum ButtonIndex { btnUpIndex = 4, btnDownIndex = 5, btnLeftIndex = 6, btnRightIndex = 7 }; const uint8_t keysKeyboard[gamepad.btnsCount] = { 'k', 'j', '\\', KEY_RETURN, 'w', 's', 'a', 'd' }; const uint8_t keysJoystick[gamepad.btnsCount] = { 0, 1, 2, 3, 0, 0, 0, 0 }; void setup() { gamepad.init(); delay(2000); int gamepadReadigsToDiscard = 2; for (int i = 0; i < gamepadReadigsToDiscard + 1; i++) { gamepad.update(); } initSerialPrintEnableFlag(); initOutputMode(); if (serialPrintEnabled) { printOutputModeInfo(); } switch (outputMode) { case OutputMode::keyboardOutputMode: Keyboard.begin(); break; case OutputMode::joystickOutputMode: joystick.begin(); break; } } void loop() { gamepad.update(); btnDebouces[0].updateState(gamepad.btnA); btnDebouces[1].updateState(gamepad.btnB); btnDebouces[2].updateState(gamepad.btnSelect); btnDebouces[3].updateState(gamepad.btnStart); btnDebouces[4].updateState(gamepad.btnUp); btnDebouces[5].updateState(gamepad.btnDown); btnDebouces[6].updateState(gamepad.btnLeft); btnDebouces[7].updateState(gamepad.btnRight); switch (outputMode) { case OutputMode::keyboardOutputMode: updateKeyboard(); break; case OutputMode::joystickOutputMode: updateJoystick(); break; } if (serialPrintEnabled) { printGamepadStatus(); } } void initSerialPrintEnableFlag() { if (gamepad.btnStart) { serialPrintEnabled = true; Serial.begin(115200); delay(5000); Serial.println(); Serial.println("Please stand by..."); delay(1000); Serial.println(); Serial.println("Enabled serial output by pressing Start on gamepad during startup"); } else { serialPrintEnabled = false; } } void initOutputMode() { if (gamepad.btnStart && (gamepad.btnA || gamepad.btnB)) { if (gamepad.btnA) outputMode = OutputMode::keyboardOutputMode; if (gamepad.btnB) outputMode = OutputMode::joystickOutputMode; EEPROM.put(outputModeStorageAddress, outputMode); } else { EEPROM.get(outputModeStorageAddress, outputMode); outputMode = (OutputMode)(abs(outputMode) % outputModesCount); } } void printOutputModeInfo() { Serial.println("Press Start+A on gamepad during startup to change output mode to keyboard"); Serial.println("Press Start+B on gamepad during startup to change output mode to joystick"); Serial.print("Current output mode: "); Serial.println(outputModeNames[outputMode]); Serial.println(); } void updateKeyboard() { for (int i = 0; i < gamepad.btnsCount; i++) { if (btnDebouces[i].isBtnPressed) { Keyboard.press(keysKeyboard[i]); } if (btnDebouces[i].isBtnReleased) { Keyboard.release(keysKeyboard[i]); } } } void updateJoystick() { for (int i = 0; i < 4; i++) { if (btnDebouces[i].isBtnPressed) { joystick.pressButton(keysJoystick[i]); } if (btnDebouces[i].isBtnReleased) { joystick.releaseButton(keysJoystick[i]); } } bool isArrowChanged = false; for (int i = 4; i < gamepad.btnsCount; i++) { isArrowChanged = isArrowChanged || (btnDebouces[i].isBtnPressed || btnDebouces[i].isBtnReleased); } if (isArrowChanged) { if (btnDebouces[btnUpIndex].btnState && btnDebouces[btnRightIndex].btnState) { joystick.setHatSwitch(0, 45); } else if (btnDebouces[btnRightIndex].btnState && btnDebouces[btnDownIndex].btnState) { joystick.setHatSwitch(0, 135); } else if (btnDebouces[btnDownIndex].btnState && btnDebouces[btnLeftIndex].btnState) { joystick.setHatSwitch(0, 225); } else if (btnDebouces[btnLeftIndex].btnState && btnDebouces[btnUpIndex].btnState) { joystick.setHatSwitch(0, 315); } else if (btnDebouces[btnUpIndex].btnState) { joystick.setHatSwitch(0, 0); } else if (btnDebouces[btnRightIndex].btnState) { joystick.setHatSwitch(0, 90); } else if (btnDebouces[btnDownIndex].btnState) { joystick.setHatSwitch(0, 180); } else if (btnDebouces[btnLeftIndex].btnState) { joystick.setHatSwitch(0, 270); } else { joystick.setHatSwitch(0, -1); } } } void printGamepadStatus() { unsigned long currentTime = millis(); unsigned long longDelayTimeout = 1000; for (int i = 0; i < gamepad.btnsCount; i++) { if (btnDebouces[i].isBtnPressed) { if (currentTime - previousBtnUpdateTime > longDelayTimeout) Serial.println(); Serial.print("+ "); Serial.print(currentTime - previousBtnUpdateTime); Serial.print(" ms "); Serial.print(btnNames[i]); Serial.println(" pressed"); previousBtnUpdateTime = currentTime; } if (btnDebouces[i].isBtnReleased) { if (currentTime - previousBtnUpdateTime > longDelayTimeout) Serial.println(); Serial.print("+ "); Serial.print(currentTime - previousBtnUpdateTime); Serial.print(" ms "); Serial.print(btnNames[i]); Serial.println(" released"); previousBtnUpdateTime = currentTime; } } }
Для включения вывода данных в последовательный порт нужно зажать кнопку Start на геймпаде при подключении переходника к ПК. Для переключения режимов нужно зажать кнопки Start+A (режим USB клавиатуры) или Start+B (режим USB геймпада) при подключении переходника к ПК.
Пример выводимых в последовательный порт данных:
Please stand by... Enabled serial output by pressing Start on gamepad during startup Press Start+A on gamepad during startup to change output mode to keyboard Press Start+B on gamepad during startup to change output mode to joystick Current output mode: keyboard + 2623 ms Up pressed + 465 ms Up released + 1170 ms Start pressed + 196 ms Start released + 32 ms B pressed + 32 ms B released + 32 ms B pressed + 31 ms B released + 32 ms A pressed + 31 ms A released + 32 ms A pressed + 31 ms A released + 32 ms A pressed + 32 ms A released
В конце лога видно, как нажаты Turbo-кнопки B и A. Кнопки работают, период нажатия составляет примерно 60 миллисекунд (30 мс кнопка нажата и 30 мс - отпущена).


Заключение

Библиотека NesGamepad:
https://github.com/IvoryRubble/ArduinoNesGamepadLibrary
https://registry.platformio.org/libraries/ivoryrubble/NesGamepad
Репозиторий прошивок для переходника:
https://github.com/IvoryRubble/nes_gamepad_usb_adapter
Источники и полезные ссылки
Старая статья с описанием протокола работы геймпада NES:
https://tresi.github.io/nes/
https://web.archive.org/web/20150829043041/https://www.mit.edu/~tarvizo/nes-controller.html
Исследование протокола опроса геймпада NES на оригинальной консоли с помощью логического анализатора:
https://www.raspberryfield.life/2018/09/01/nespi-project-part-4-the-nes-controller-protocol/
Другие статьи на Хабре по использованию геймпада NES:
https://habr.com/ru/articles/147356/
https://habr.com/ru/articles/191936/
Эмулятор NES, который я использую:
https://github.com/punesemu/puNES
Другие эмуляторы NES:
https://emulation.gametechwiki.com/index.php/Nintendo_Entertainment_System_emulators
