Подключаем геймпад от Денди (NES) к ПК

от автора

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

Переходник с подключенным геймпадом, подключенный через Micro-USB к ПК

Переходник с подключенным геймпадом, подключенный через Micro-USB к ПК
Переходник с подключенным геймпадом

Переходник с подключенным геймпадом

Содержание

Геймпад от игровой приставки NES

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

От какой именно приставки у меня геймпад и откуда он взялся я не помню, известно только, что это какой-то клон NES.

Мой геймпад от Денди

Мой геймпад от Денди

Компоновка геймпада стандартная:

  • Крестовина слева

  • Кнопки Start и Select посредине

  • Кнопки B и A (именно в таком порядке) справа. Над ними дублирующие кнопки с режимом Turbo (зажатую кнопку приставка воспринимает как серию быстрых нажатий)

Дальше рассмотрим протокол опроса геймпада и обязательно проверим работу Turbo-кнопок в конце статьи.

Подключение и протокол опроса геймпада NES

У разных версий приставки были разные разъемы для подключения геймпадов. У оригинальной Famicom (версия NES для Японии) геймпады вообще не имели внешнего разъема для подключения и не отсоединялись от консоли.

У Famicom не было внешнего разъема для подключения геймпадов (источник)

У Famicom не было внешнего разъема для подключения геймпадов (источник)

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

Варианты разъемов геймпадов NES

Варианты разъемов геймпадов NES

На все разъемы выведены одни и те же контакты геймпада:

  • Питание: +5 В и GND.

  • Latch

  • Pulse (Clock)

  • Data

Распиновка разъемов геймпадов NES

Распиновка разъемов геймпадов NES

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

Опрос геймпада NES

Опрос геймпада NES

Длина импульса на контакте Latch равна 12 микросекунд, импульсы на контакте Pulse (Clock) должны быть длиной 6 микросекунд с паузами между импульсами тоже по 6 микросекунд. 
Приставка повторяет опрос геймпада каждый кадр (50-60 Гц), но можно повторять опрос с максимальной частотой, добавляя задержку 6 микросекунд после каждого опроса. 

Вот код для проверки геймпада, в котором реализован алгоритм опроса и вывод значений кнопок в последовательный порт:

Код для проверки работы геймпада

https://github.com/IvoryRubble/ArduinoNesGamepadLibrary/blob/master/examples/NesGamepad_test_without_lib/NesGamepad_test_without_lib.ino

// 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 клавиатуры

https://github.com/IvoryRubble/nes_gamepad_usb_adapter/blob/master/NesGamepad_keyboard/NesGamepad_keyboard.ino

// 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 геймпад

https://github.com/IvoryRubble/nes_gamepad_usb_adapter/blob/master/NesGamepad_keyboard_and_joystick/NesGamepad_keyboard_and_joystick.ino 

// 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 мс — отпущена).

Раскладка виртуальной USB клавиатуры для геймпада

Раскладка виртуальной USB клавиатуры для геймпада
Конфигурация виртуального USB геймпада

Конфигурация виртуального USB геймпада

Заключение

Теперь можно играть в игры с Денди с “оригинальным” геймпадом

Теперь можно играть в игры с Денди с “оригинальным” геймпадом

Библиотека 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

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

Как лучше играть в старые игры?

25% На оригинальном железе (старые консоли, ЭЛТ-монитор, старые геймпады и т. п.)2
37.5% Через эмуляторы на современном ПК или смартфоне3
0% В ремастерах/переизданиях0
25% Всё равно, главное — сама игра2
12.5% Старые игры не нужны1

Проголосовали 8 пользователей. Воздержался 1 пользователь.

ссылка на оригинал статьи https://habr.com/ru/articles/906662/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *