Как стать автором
Обновить

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

Уровень сложностиПростой
Время на прочтение12 мин
Количество просмотров4.1K

У меня есть старый геймпад от игровой приставки Денди (клон 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

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Как лучше играть в старые игры?
35.9% На оригинальном железе (старые консоли, ЭЛТ-монитор, старые геймпады и т. п.)14
15.38% Через эмуляторы на современном ПК или смартфоне6
2.56% В ремастерах/переизданиях1
41.03% Всё равно, главное — сама игра16
5.13% Старые игры не нужны2
Проголосовали 39 пользователей. Воздержались 2 пользователя.
Теги:
Хабы:
+32
Комментарии17

Публикации

Ближайшие события