Всем привет!
В этом посте я расскажу, как и зачем написал свой эмулятор Денди с нуля. Той самой приставки, с которой в 90-ые и даже начале 2000-ых началось знакомство с компьютерными играми многих из нас.

Главная цель - рассказать про свой опыт и, возможно открыть кому-то мир таких хобби-проектов.
Сразу признаюсь: при написании было сложно искать баланс. С одной стороны хотелось дать представление для тех, кому интересна техническая часть. При этом не хотелось уйти в слишком душные детали. Посмотрим получилось ли! 🙂
Рассказ состоит из нескольких частей:
- а зачем вообще такое писать в 2026?
- технический обзор: из чего состоит Денди, как устроена её графика, как вообще выглядит написание такого проекта
- запускаем и любуемся классикой - Марио, Танчики, Чип и Дейл, Робокоп и многое другое
(Бонусом идут описания моих эмоциональных взлетов и падений на разных этапах)
Конечно будет много картинок! Правда не сразу, сначала сделай домашку, а потом играйся более техническая часть для заинтересованных.
Впрочем, если про моделирование железа вам не так интересно читать, а вы пришли посмотреть на игры и пустить слезу ностальгии, то смело сразу мотайте вниз.
Что же, достаточно вступлений - начнем!
Напоминаю, что такое эмулятор
Денди не имеет ничего общего с моим макбуком - разные архитектуры и эпохи процессоров, остального железа, программ. Как же поиграть в те игры? Ведь скачанный образ с картриджа игры - это просто набор бессмысленных байт для моего мака.
Эмулятор - это программа которая моделирует работу какого-то другого устройства. Цель - сделать нашу модель достаточно близкой к реальности, тогда мы сможем запустить игры прямо на своем компе.
Денди - это пиратский клон игровой консоли Nintendo Entertainment System (NES). Дальше я буду обычно писать NES.
Зачем писать такой проект?
Как вы наверняка знаете, эмуляторы NES уже существуют. Если бы я просто хотел поиграть в старые игры, то скачал бы уже готовый (и написанный куда более опытными людьми).
Недавно у меня выдался большой перерыв между работами. Помимо путешествий и хобби - это ещё и хорошее время что-нибудь поизучать и поработать над пет-проектами, на которые обычно бы не нашлось время.
Мне нравится низкоуровневое программирование и разбираться в работе компьютеров. Написание эмулятора звучит как проект, где можно приобрести новые знания, но при этом у него понятная и веселая цель 🙂
Вообще NES - частая цель для любителей эмуляторов. Его можно осилить в одиночку за адекватное время. В то же время - это реальная игровая консоль, в которую играли сотни миллионов человек по всему миру. Идеальное сочетание🤝
Проект сознательно писался без подсматривания в другие эмуляторы и с ограниченным использованием AI - в основном для самой рутинной работы вроде юнит-тестов. Проектирование и ключевые части мне хотелось написать вручную, а возможность допустить свои ошибки была скорее целью.
Для референса пользовался сайтом nesdev - это старый форум любителей делать игры и эмуляторы под NES.
Теперь расскажу про техническую часть. Можно спокойно пропускать то, что глубже уровня, который вас интересует.
Обзор проекта
Что предстоит сделать?
NES сделана из нескольких компонент. Каждую надо промоделировать по отдельности, а также научить их общаться друг с другом:
- основной процессор (CPU)
- графический процессор (PPU)
- аудио-процессор (APU),
- джойстики
- вставляемый в саму консоль картридж
Никакой операционной системы нет - при вставке картриджа процессор просто начинает выполнять инструкции с определенного адреса. То есть просто сразу выполнять программу с игрой.
Основной процессор CPU
Основной процессор - 8-битный архитектуры MOS 6502 середины 70-ых.
Это семейство использовалось в домашнем компьютере Apple II - первом большом коммерческом успехе Apple. Тактовая частота аж 1.79 МГц.
Сам MOS 6502 не имеет прямого отношения к современным x86 или ARM, но ключевые идеи абсолютно те же:
- код программы живет в памяти вместе с данными (всё по заветам фон Неймана)
- несколько однобайтовых регистров и двухбайтный регистр с текущим адресом исполнения (program counter)
- 56 инструкций в духе: прочитай байт из адреса памяти в регистр А, запиши значение регистра в память, просуммируй регистр и константу, переведи исполнение программы на другой адрес: jump (JMP) и conditional jump
К слову, инструкций умножения и деления нет! Программы сами реализуют такие функции через сложения, вычитания и битовые сдвиги.
Работает наш CPU в 64-килобайтном адресном пространстве. Да, раньше 64 килобайта было достаточно чтобы запускать космические корабли к луне без Outlook и делать крутые игры!
Написание эмулятора начинается именно с процессора - это сердце консоли, которое выполняет логику игры и управляет другими устройствами, например графическим процессором PPU
Первые приколы из реального мира - неофициальные процессорные инструкции
Неважно пишете вы на JavaScript или C++ - для компьютера любая программа в итоге выглядит как-то так

У машинного кода есть четкая структура: сначала идет opcode (номер инструкции), например A9 - загрузи значение в регистр A. Потом фиксированное число байтов - аргумент. Затем следующая инструкция.
Некоторые разработчики игр были ещё те упоранты.. Они проверили, что происходит если дать машине незадокументированные наборы байт. Предсказуемо, чаще всего ничего хорошего. Иногда машина их просто игнорирует, а иногда просто зависает.
Но некоторые "неофициальные" байты оказались вполне рабочими инструкциями, с очень экзотическим поведением! Некоторые нашли этому применение в своих играх 🤦
Применяется на самом деле в небольшом числе игр, но эти инструкции были и в опенсорсных-тестах процессора, а значит чтобы получить 100% покрытие надо с ними разобраться.
Я делегировал LLM покрытие неофициальных инструкций - с первой попытки он осилил почти все. Кроме двух - их починить у AI не получалось и тесты упорно падали. Крайне неочевидное объяснение я нашел где-то в ютубе и лишь с этими знаниями пофиксил руками.
Кожаные мешки всё еще порой полезны! Правда с тех пор модели опять поумнели, так что может уже и нет...
Графический процессор PPU
PPU - это отдельный процессор со своей внутренней памятью, регистрами и состоянием. В некотором смысле можно назвать предком современных видеокарт.
Тактовая частота около 5.37 МГц - солидно по тем временам!
PPU живет самостоятельной жизнью, отдельно от CPU. По сути, его главная задача - заниматься отрисовкой картинки размером 240 x 256 пикселей. PPU постоянно рассчитывает цвет очередного пикселя кадра из данных в своей памяти/регистрах и выводит один из 64 цветов в виде сигнала на кабель телевизора.

Таких прорисовок кадра он делает 50 раз в секунду (50 frames per second)
Аппаратные баги
Баги существуют не только в коде, но и в железе. В отличии от Android приложения, когда консоль произвели в миллионах экземплярах, то поезд ушел. Выкатить обновление и исправить проблему невозможно.
Самый известный баг NES - в супер базовой инструкции: JMP (поменяй адрес исполняемой сейчас инструкции). При некоторых условиях исполнение программы прыгает совершенно не на тот адрес, который можно ожидать.
Игры писались и подгонялись под реальное железо, а значит, если эмулятор отклоняется в любых мелочах, то поведение разойдется. Нужно воспроизвести и ошибки!🙂
Как общаются между собой CPU, PPU и другие компоненты
Логика игры выполняется на CPU и управляет другими частями консоли. Например, CPU пишет в память PPU чтобы обновить картинку для следующего кадра, считывает нажатые кнопки джойстика и т.д
Взаимодействие между разными компонентами консоли - это самая коварная часть проекта, на мой взгляд. Здесь много нюансов поведения, которые нужно точно смоделировать и сложно тестировать.
Простой пример - задержка между тем, какое реальное значение в памяти PPU сейчас, и тем, что CPU увидит, если прочитает эту память прямо сейчас.
Проблемы проявляются зависанием игр, внезапно ломающейся графикой, миганием частей экрана.
Супер кратко (для контекста) опишу основные механизмы общения между CPU и другими устройствами:
Memory-mapped I/O
Самый частый способ. Некоторые адреса RAM, с которыми работает CPU, связаны с другими компонентами на уровне железа. Например чтение из адреса 0x4016 вернет текущие нажатые кнопки джойстика.Аппаратные прерывания (hardware interrupts)
Периферийное устройство, чаще всего PPU, дает сигнал CPU остановить (иными словами прервать, отсюда и название) выполнение текущей программы и вместо этого выполнять какой-то другой код-обработчик.DMA (Direct Memory Access)
CPU может напрямую скопировать страницу своей памяти в память PPU. Используется всеми поздними играми с более продвинутой графикой
Короче, всё по серьезному: точно такие же механизмы используются в современных компьютерах! Например, с помощью прерываний и DMA ваши любимые мемы c котами попадают с сетевой карты компьютера в его оперативную память, а оттуда - к вам на экран
Графика
Откуда берется картинка?
На катридже, помимо программы с игровой логикой также лежат артефакты для графики - тайлы (tiles), такие плитки размером 8 x 8 пикселей.

На первый взгляд кажется мусором, но при внимательном рассмотрении можно узнать куски Марио, черепашки, труб и буквы.
Теперь вспоминаем, что за картинку отвечает PPU.
Содержимое памяти PPU описывает две вещи: как выглядит фон (background) и список спрайтов (sprite) - объектов в игре, например ваш герой и его враги.
И фон, и персонажи строятся из таких плиток-тайлов. CPU периодически обновляет память PPU, пишет туда такую информацию:
- какие тайлы расположены на каком месте экрана
- какие палитры цветов для фона и спрайтов в разных частях экрана использовать
Наглядно:


Вроде понятно, но... можно увидеть несостыковку. Верхняя часть экрана со статусом игры (HUD) не совпадает с тем, что мы видимо в отдельном фоне - нет надписи Mario, а остальная часть статуса сдвинута влево.
Почему? На самом деле я показал не всю картинку. А ещё обратите внимание на странную точку вверху спрайтов - у нее важная роль.
Видимая область и события во время отрисовки
Показываю настоящую картину: в памяти PPU описано изображение размером на 2 экрана телевизора (в некоторых играх даже на 4) и CPU задает координаты области, которую надо отображать в данный момент.
Это нужно, чтобы делать длинные уровни. Видимую область показываем, а остальное в это время обновляем.
Наглядно это выглядит так:

Круто, но надпись Mario 00000 в черный прямоугольник не попадает. Опять обманываю?
Во время отрисовки кадра могут происходить разные события. Одно из таких: PPU прошел пиксели спрайта под номером 0 (в этом случае той странной точки вверху экрана спрайтов). В этот момент PPU обновит определенный флаг в своей памяти из 0 в 1 .
Код самой игры Mario Super Bros в этом время на CPU следит за этим флагом. Как только статус изменился - это значит отрисована определенная часть кадра. Игра обновит координаты видимой области.
Таким образом видимая область постоянно скачет между 2 вариантами - сначала рисуем HUD, а потом переключаемся на текущее положение в уровне.

Этот трюк используют практически все “продвинутые” игры.
Новые приколы: расхождение CPU и PPU
Способов измерять время в консоли нет. Как игре понимать сколько времени прошло, например, закончилась ли уже отрисовка нового кадра или произошли другие события?
Есть способы, описанные выше - смотреть на значения всяких флагов от PPU или аппаратные прерывания.
Есть ещё один способ. Уверен, к нему пришли примерно в таком разговоре:
Кодзима: надо как-то отмерить 50 миллисекунд, есть идеи?
Василий: хрен его знает. Ну давай на глазок 10000 итераций пустого цикла сделаем. Все равно игры только под одну консоль запускаем
Работает! Но есть нюанс - спустя годы, те кто пишут эмулятор должны заморочиться, чтобы работа CPU была достаточно точно синхронизирована с PPU.
Это не так просто. Количество процессорных циклов на выполнение может сильно отличаться даже для одной и той же инструкции. Например, один и тот же conditional jump занимать 1 до 3 циклов в зависимости от того случился он или нет.
Для многих игр это неважно, а для некоторых фатально. Например в игре Kung Fu это приводило к распиливанию тела героя

Точную синхронизацию сделать довольно сложно. Я напилил каких-то простых эвристик и подобрал в них пару констант - такого приближения хватило для подавляющего большинства игр.

И немного о картриджах
На картридже лежат байты самой программы и артефакты для графики. Больше данных на картридже нет, можно было бы подумать, что его устройство простое - не тут то было!
При вставке картридж с самой консолью образует одно целое - его содержимое не просто отображается в оперативной памяти (RAM), оно в буквальном смысле и есть часть RAM
Ранние игры были маленькими по размеру, начиная с 24 килобайт. Размер всего Mario Super Bros 40 килобайт! Однако, игры более позднего периода уже были с более продвинутой графикой и кучей уровней, разработчикам нужно было больше места.
Физическое устройство картриджей стало сложнее и они стали состоять из нескольких “банков” памяти. CPU общается с картриджем и переключает эти банки на ходу.
Игры позднего периода достигали почти 1 мегабайта в размере.
На этом часть с технический обзором проекта заканчиваю. Я упростил и опустил миллион деталей, но целью было просто дать представление, а не писать учебник!
Возможно в будущем я напишу более детальные технические статьи, вроде “введение в эмуляторы для новичков от новичка”. Пока не уверен где такое опубликую, но если вам интересно, то приходите на мой канал - там явно сообщу🙂
Можно переходить к самим игрочкам!
Беремся за игры
Первый блин комом запуск
CPU и PPU работают по отдельности. Коммуникация между ними написана. Нажатия на кнопки джойстика пока нереализованы, но это легко добавить.
Можно уже попробовать запустить игру и посмотреть появится ли хотя бы экран загрузки!
Пробуем наше чудо на одной из простых игр 80-ых - Donkey kong. Подсовываем эмулятору файл donkey_kong.nes, сердце замирает и…

Офигенно, оно прям так сразу заработало? Так ведь не бывает?
Недолго музыка играла... Происходит что-то странное.. Спустя пару секунд игра загружала уровень и начинала играть сама 😨

Первая мысль - программа игры вычитывает какой-то мусор из адреса памяти джойстика.
Проверяю в дебаггере - всё нормально. Хм. Реализую джойстик - посмотрим как эмулятор будет реагировать если я сам буду нажимать на кнопки.
Результат ещё более странный. Работает только одна кнопка, она переключает курсор в стартовом экране по уровням. На всё остальное никакой реакции. Игра по прежнему иногда играет сама.
...Спустя 2 часа дебага и реверс-инжиниринга дизассемблера игры
Я не сильно ближе к разгадке. Всё выглядит правильным.
Может проблемы с CPU? Но я его тщательно протестировал мегабайтами своих и опенсорсных тестов, неужели первая же игра нашла что-то непокрытое?
Опять смотрю на игру, которая сама по себе играет. Уж что-то больно точно Марио перепрыгивает препятствия... Начинают закрадываться подозрения.
Сдаюсь, скачиваю известный эмулятор Mesen, запускаю на том же файле. Как уже к тому моменту ожидал - вижу абсолютно такое же поведение!
Разгадка проста:

Почти во всех играх NES был демо-режим. Например, изначально Donkey Kong был сделан под аркадные машины, игра рекламировала себя посетителям заведений. И лишь потом портирован на NES
Почему я об этом вообще не вспомнил... За знание геймдева получаю заслуженную двойку и отплачиваю потерянным временем!
Еще у игр того времени специфичный интерфейс: в меню выбора уровня работали только 1-2 кнопки, а нажимать select было не надо - игра загружала демо-режим уровня, а из него можно войти в игру!
Я перебрал в голове примерно все существующие матные слова, но был сделан важный вывод:
Урок: точно знайте ожидаемый результат работы эмулятора, а не ориентируйтесь на ощущения. То что для вас странно, вполне может быть правильным поведением
Вскоре я пофиксил перепутанные местами кнопки джойстика и смог играть в Donkey Kong. В игре всего 3 уровня и Марио встретил принцессу!

Беремся за всё более сложные игры
Одна игра уже идет хорошо, значит проект уже близок к концу завершен, да?
Хаха, это было большим заблуждением. Да, основная часть кода к тому моменту была написана, но всё самое сложное только начиналось
Процесс состоял примерно в следующем: я брался за новые игры, более поздних более годов выпуска и постепенно всё с более продвинутыми уровнями, графикой
Игры ранних 80-ых были обычно портами с аркадных машин или симуляторами всяких видов спорта, обычно они шли без проблем

Забавные, но как правило такие игры я не помнил с детства. Конечно основной моей целью были более поздние хиты!
Впрочем, были и исключения, например Pacman

Или Тетрис (да, на Денди он тоже был!)

Этот экран стал для меня открытием: государственное предприятие “Электрооноргтехника” экспортировало игры для Nintendo на Запад в конце 80-ых. Вероятно способ зарабатывания "валютного долора". Интересные времена.
В чем вообще сложности?
Почти каждая новая игра обнаруживали изъяны и расхождения между эмулятором и поведением железа, которые нужно было исправлять. “Красивая” архитектура все чаще оказывалась неверной, а private члены классов становились public..
Признаюсь о самом серьезном пробеле в моем эмуляторе: нереализованный звук. Изначально я оставил эту задачу на попозже, но в какой-то момент взял в проекте паузу на неограниченный срок 🙂
Ну и ладно, пойдем же теперь смотреть на то, что заработало!
Bomberman(1987)

Всегда обожал игры этой серии.
Разработчики вообще не парились по поводу того как замаскировать служебный спрайт 0 под что-то красивое из интерфейса. Здесь это просто какая-то непонятная циферка в самом краю экрана (обвел на картинке), которая висит там всю игру.
Не поверил когда увидел у себя - пошел искать видео прохождения в ютубе и нашел у других 🙂
Super Mario Bros (1985)
Классический и, как оказалось, гадкий для воспроизведения Марио!
Эта игра считается одной из самых сложных “базовых” игр для эмулятора и оправдано. На нем я провел немало времени - были как и разные мелкие баги, так и большие сложности с синхронизацией спрайт 0 hit с CPU, о котором я писал в технической части.
Из-за одной из проблем игра просто зависала в середине первого уровня и я даже не мог пофиксить это нормально. В итоге добавил какой-то костыль с рандомом, которым одновременно и горжусь и стыжусь 🙂 Но идет идеально!


BattleCity (1985)
Танчики! Первая игра, где понадобилось поддержать режим больших тайлов (8 x 16 вместо 8 x 8 пикселей). После этого пошла без особых проблем.
Быстро вызывает зависимость! Наверное в неё я играл больше всего времени

Читы с помощью эмулятора
В процессе работы над эмулятором неизбежно сталкиваешься с ситуацией, когда доигрываешь до определенного момента в игре, а там эмулятор крашится или работает некорректно.
Естественным решением было сделать свой дебаггер - по нажатию на пробел эмулятор останавливается, на D открываются окошки с фоном и спрайтами (показывал выше когда рассказывал про графику), а нажав на S мы сохраняем текущее состояние, чтобы потом можно было запустить прямо с этого места.
Конечно же сохранялкой еще и удобно пользоваться чтобы начинать игру не с начала уровня, а сразу с середины🙂
Duck Tales (1989)
Первая из игр более позднего периода, которую я попробовал запустить. Сразу же пошла.
Графика уже впечатляет, разнообразные и детальные уровни, сюжетная линии. Специфичная механика прыжков и атаки, но всё равно шедевр!

Chip 'n Dale (1990)
Одна из любимых игр детства в похожем жанре

Contra (1988)

Одна из самых известных и популярных игр на NES. Видимо обошла меня в детстве (или я вообще её не помню), но отдаю дань классике. Разработчики молодцы - пошла без проблем
Robocop 3 (1992)
Популярная игра и не из самых простых для эмулятора - использует специфичные возможности железа и порой пишет.. в read-only память. Пришлось убирать из кода всякие проверки. Достаточно сложная, но чит эмулятора с сохранением может помочь 😀

The Little Mermaid (1991)
Как насчет чего-нибудь доброго и нежного? Может русалочка?

По факту игра тоже весьма боевая - Ариэль плывет на разборки с Урсулой и по дороге нехило наваливает плохим рыбам
Доктор Марио (1990)
Ладно, это уже точно расслабляющая добрая игра. Что-то вроде тетриса про лечение вирусов

Не покорились до конца
Я запустил эмулятор на десятках игр. Подавляющее большинство идет без проблем или с мелкими проблемами. Но не все.
Несколько хитов, которые я изначально хотел запустить, побороть не удалось: например, Battletoads (считается одной из сложнейших для эмулирования) и Аладдин. В них графика быстро начинает разъезжаться и становится полностью неиграбельной.
На этом этапе я уже примерно понимал, что надо переделать. Но фикс означал большое переписывание моих ошибок и приближений под более точное поведение. Я понял, что уже доволен результатом и готов остановиться.
Приведу пример игры в промежуточном состоянии - Черепашки Ниндзя 3 часть.
Проблемы с фоном и статусной частью экрана заметны, но игровой процесс при этом не пострадал и вполне можно получать удовольствие

Итог
Можно с нуля написать код, который воспроизводит в себе целую игровую приставку. Не упрощенную учебную модель в домашке, а что-то абсолютно реальное - то, что ты держал в руках, играл в своем детстве. Ощущения, как будто ты сделал свой компьютер - я был очень доволен!
Есть и практическая польза - лучше начинаешь понимать работу разных компонент компьютеров. Безусловно, консоль 40-летней давности в тысячу раз проще современных устройств. Тем не менее, многие ключевые принципы остаются неизменными.
Если вы любите задаваться вопросами “а как это работает”, разбираться в устройстве вещей и копать вглубь, то такие пет-проекты дают возможность получить практические понимание и интуицию, которые вы не найдете в книжках и ютубе.
Советую попробовать!
Немного офтопа
Во время перерыва между работами, помимо чила и написания таких проектов для души, я еще и завел телеграм-канал.
Пишу заметки как на разные программисткие и технические темы, так иногда и про индустрию где работаю (алгоритмический трейдинг - всякие хедж-фонды и HFT). Если вам понравился этот пост, то там вам тоже может быть интересно - приходите!

