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