Делаем игровую приставку. Как я собрал ретроконсоль в домашних условиях

В этой статье я расскажу, как собирал самопальную видеоконсоль с нуля — вдохновившись ретроконсолями и современными проектами, но придумав свою уникальную архитектуру. Мои друзья снова и снова повторяли, что нечего держать этот проект в тайне, нужно, мол, поделиться с общественностью. Ну вот я и делюсь.

INFO

Это перевод статьи Сержиу Виейры, впервые опубликованной в его блоге. Перевела Алёна Георгиева.

Как все начиналось

Меня зовут Сержиу Виейра, и я португальский парень, выросший в 80–90-е. Я всегда ностальгировал по ретроконсолям, особенно третьего и четвертого поколений. Несколько лет назад я решил глубже изучить электронику и попробовать собрать свою собственную видеоконсоль. Работаю я инженером софта, и ранее никакого опыта работы с электроникой у меня не было — если не считать сборки и апгрейда моего десктопа (что, конечно, не считается). Но, несмотря на отсутствие опыта, я сказал себе: «Почему нет?», купил несколько книг и комплектов электроники — и начал учиться.

Я хотел собрать консоль, похожую на те, что всегда мне нравились, — что-то среднее между NES и Super Nintendo или между Sega Master System и Mega Drive. Все эти консоли обладали CPU, кастомным видеочипом (тогда он еще не назывался GPU) и аудиочипом — интегрированным или выделенным. Игры к ним распространялись на картриджах, которые обычно представляли собой аппаратные расширения с чипом ПЗУ и иногда другими компонентами.

Моим изначальным планом было собрать консоль со следующими характеристиками.

  • Никакой эмуляции, все игры/программы должны запускаться на реальном железе — не обязательно железе «из тех времен», просто достаточно шустром для своих задач.
  • Настоящий ностальгический «ретрочип» процессора.
  • Вывод на телевизор (аналоговый сигнал).
  • Способность производить звук.
  • Поддержка двух контроллеров.
  • Прокрутка фона и движущиеся спрайты.
  • Поддержка платформеров в стиле «Марио» (и, конечно, других типов игр).
  • Возможность запускать игры и программы с карты SD.

Я решил использовать поддержку SD-карт вместо картриджей, потому что запускать игры с карты намного практичней — к тому же на нее проще копировать файлы с компьютера. Используй я картриджи, пришлось бы накрутить куда больше железа и завести отдельное железо для каждой программы.

Собираем консоль

Видеосигнал

Начал свою работу я с генерации видеосигнала. У всех консолей эпохи, на которую я ориентировался, были свои проприетарные графические чипы — что давало им очень разные характеристики. По этой причине я не стал использовать готовый графический чип — хотелось, чтобы у моей консоли были уникальные возможности графики. Но, поскольку собрать собственный чип я бы не потянул, а ПЛИС использовать не умел, я выбрал базирующийся на софте графический чип с двадцатимегагерцевым восьмибитным микроконтроллером. Это не перебор: у него ровно такая производительность, чтобы генерировать нужный мне тип графики.

Итак, я начал с микроконтроллера ATmega644, работающего на частоте 20 МГц, который посылал PAL-сигнал на телевизор. Поскольку сам по себе микроконтроллер этот формат не поддерживает, пришлось добавить внешний ЦАП.



Наш микроконтроллер выдает восьмибитную цветность (RGB332: три бита на красный, три бита на зеленый и два — на синий), а пассивный ЦАП конвертирует всю эту красоту в аналоговый RGB. По счастью, в Португалии внешние устройства к телевизору чаще всего подключают через разъем SCART — и большая часть телевизоров принимает RGB-сигнал через него же.

Подходящая графическая система

Поскольку первый микроконтроллер я хотел использовать исключительно для передачи сигнала на телевизор (я назвал его VPU — Video Processing Unit), для графики в целом я решил использовать способ двойной буферизации.

Я взял второй микроконтроллер для PPU (Picture Processing Unit) — ATmega1284 тоже на 20 МГц. Он должен генерировать изображение на чип ОЗУ (VRAM1), после чего первый микроконтроллер передаст содержимое другого чипа оперативки (VRAM2) на телевизор. После каждого кадра (два кадра в PAL или 1/25 секунды) VPU переключает чипы ОЗУ и передает изображение с VRAM1 на телевизор, пока PPU генерирует новое на VRAM2.

Видеоплата получилась довольно сложной: мне пришлось использовать дополнительное железо, чтобы дать микроконтроллерам доступ к обоим чипам ОЗУ, а также ускорить доступ к памяти, которая к тому же используется для вывода видеосигнала методом битбэнга. Для этого я добавил в цепочку несколько чипов 74-й серии в качестве счетчиков, линейных селекторов, трансиверов и прочего.

Прошивка для VPU и особенно для PPU тоже вышла довольно сложной, поскольку мне нужно было написать чрезвычайно производительный код — если я хотел получить все искомые графические возможности. Изначально я все писал на ассемблере, позже кое-что кодил на C.



В итоге мой PPU генерировал изображение 224 × 192 пикселя, которое VPU транслировал на экран телевизора. Разрешение может показаться слишком низким, но на самом деле оно немногим меньше, чем у консолей-прототипов — они обычно имели разрешение 256 × 224. Зато более низкое разрешение позволило мне втиснуть больше графических фич в тот временной отрезок, что уходил на отрисовку каждого кадра.

Прямо как в старые добрые времена, у моего PPU есть «фиксированные» графические возможности, которые можно настроить. Фон рендерится из символов размером 8 × 8 пикселей (их еще иногда называют тайлами). Это значит, что размер всего фона — 28 × 24 тайла. Для попиксельной прокрутки и возможности плавного обновления фона я сделал четыре виртуальных экрана 28 × 24 тайла — все они смежные и «обтекают» друг друга.



Поверх фона PPU рендерит до 64 спрайтов шириной и высотой от 8 до 16 пикселей (то есть один, два или четыре символа), которые можно повернуть по вертикали, горизонтали или по обеим осям. Еще над фоном можно отрендерить оверлей — такую плашку размером 28 × 6 тайлов. Она пригодится для игр, где нужны элементы интерфейса поверх основного экрана (HUD), фон скроллится, а спрайты используются не только для подачи информации, но и для других целей.

Другая «продвинутая» фича — возможность прокручивать фон в разных направлениях по отдельным строкам, что позволяет добавить эффекты типа ограниченного параллакс-скроллинга или разделенного экрана.

Еще есть таблица атрибуции, которая позволяет задать каждому тайлу значение от 0 до 3. А дальше можно, например, назначить все тайлы с определенным значением на конкретную тайловую страницу или увеличить номер их символа. Это полезно, когда конкретные элементы фона постоянно меняются, — в таком случае CPU не нужно обновлять каждый тайл в отдельности, он может просто передать команду вроде «все тайлы со значением 1 увеличивают свое значение на 2». Разными способами этот подход используется, например, в играх с Марио, где на фоне двигаются знаки вопроса, или в других играх с постоянно льющимися водопадами.

Процессор

Когда была готова функциональная видеоплата, я приступил к работе над CPU — для своей консоли я выбрал Zilog Z80. Помимо того что Z80 — просто крутой ретропроцессор, у него есть отдельно 16 бит на память и 16 бит для I/O, чем другие подобные восьмибитные процессоры, например знаменитый 6502, похвастаться не могут. У того же 6502 есть только 16 бит памяти, а значит, эти 16 бит придется делить между собственно памятью и дополнительными устройствами: аудио, видео, ввода и прочими. Если же у нас есть отдельный участок для I/O, то он возьмет на себя все внешние устройства, а 16 бит памяти (то есть 64 Кбайт кода или данных) мы сможем использовать по прямому назначению.

Для начала я соединил свой CPU с EEPROM, добросив немного тестового кода. Еще я прикрутил к CPU через участок I/O микроконтроллер, который связывается с ПК по RS-232, — чтобы проверить, нормально ли работает мой процессор и все остальные соединения. Этот микроконтроллер (двадцатимегагерцевый ATmega324) должен был стать IO MCU (микроконтроллером ввода-вывода) и отвечать за доступ к игровым контроллерам, карте SD, клавиатуре PS/2 и коммуникацию с компом через RS-232.


Потом я прикрутил к процессору чип ОЗУ на 128 Кбайт, из которых были доступны 56 (это может показаться пустой тратой ресурса, но у меня были чипы ОЗУ только по 128 и по 32 Кбайт). Таким образом, вся память процессора состоит из 8 Кбайт ПЗУ и 56 Кбайт ОЗУ.

Следом я обновил прошивку своего микроконтроллера ввода-вывода с помощью этой библиотеки и добавил ему поддержку карт SD. Теперь CPU научился перемещаться по директориям SD-карты, просматривать их содержимое, открывать и читать файлы — считывая и записывая данные на конкретные адреса участка ввода-вывода.

Соединяем CPU и PPU

Пришло время реализовать взаимодействие между CPU и PPU. Для этого я нашел «простое решение» — чип ОЗУ с двойным портом (то есть тот, который можно одновременно подключить по двум разным шинам). Он спас меня от накручивания новых микросхем типа линейных селекторов — и к тому же сделал доступ к оперативной памяти для обоих чипов практически одновременным. Также PPU связывается с CPU напрямую каждый кадр, активируя его немаскируемое прерывание (NMI). Это значит, что каждый кадр процессор прерывается (ценное умение для синхронизации и своевременного обновления графики).

Каждый кадр взаимодействие между CPU, PPU и VPU развивается по следующему сценарию.

  • PPU копирует информацию с внешнего ОЗУ (на рисунке ниже обозначено как PPU RAM) на встроенное ОЗУ.
  • PPU посылает CPU сигнал немаскируемого прерывания.
  • Одновременно с этим:
    • CPU немедленно обращается к функции немаскируемого прерывания и обновляет в PPU RAM информацию о состоянии графики в следующем кадре (программа должна выйти из прерывания до его начала);
    • PPU рендерит изображение, основываясь на информации, которую перед этим скопировал в один из двух ОЗУ графической системы (VRAM1 или VRAM2);
    • VPU посылает изображение с другого VRAM на телевизор.

Примерно в то же время я добавил поддержку игровых контроллеров. Изначально я хотел использовать контроллеры Super Nintendo, но их разъем проприетарный — и достать его непросто. Поэтому я выбрал совместимые шестикнопочные контроллеры Mega Drive/Genesis: они используют стандартные, распространенные и доступные разъемы DB-9.


Время для первой настоящей игры

У меня был процессор с поддержкой игровых контроллеров, который мог управлять PPU и загружать программы с SD-карты, так что… пришло время сделать игру. Я написал ее, конечно, на языке ассемблера Z80 — это заняло у меня пару дней (исходный код игры).

Добавляем кастомную графику

Все отлично, у меня есть рабочая консоль, но… этого недостаточно. Игры пока не могут использовать кастомную графику — только ту, что хранится в прошивке PPU. А единственный способ поменять встроенную графику — обновить прошивку. Поэтому я решил добавить отдельный чип ОЗУ с графикой (символьное ОЗУ, Character RAM) — он должен быть доступен PPU и загружать графику согласно инструкциям, пришедшим из CPU. При этом нужно было использовать как можно меньше новых компонентов, потому что консоль уже получилась довольно большой и сложной.

Я нашел следующий выход: доступ к новому ОЗУ будет только у PPU, а CPU станет передавать ему информацию через PPU. И пока эти данные передаются, наше новое ОЗУ не будет использоваться для графики — его функции временно возьмет на себя встроенная графика.

После передачи данных процессор переключится из режима встроенной графики в режим работы с символьным ОЗУ (CHR RAM на схеме ниже), и PPU сможет использовать кастомную графику. Возможно, это не идеальное решение, но оно работает. В итоге новое ОЗУ имело объем 128 Кбайт и могло хранить 1024 символа размером 8 × 8 пикселей для фона и 1024 символа того же размера для спрайтов.


И наконец, звук

Реализацию звука я оставил на финал. Изначально я собирался дать своей консоли те же звуковые возможности, что у Uzebox, и встроить микроконтроллер, который генерировал бы четыре канала PWM-звука. Однако я выяснил, что можно относительно легко достать винтажные чипы, — и заказал несколько чипов YM3438, работающих на принципе [частотно-модуляционного синтеза] (https://en.wikipedia.org/wiki/Frequency_modulation_synthesis). Они полностью совместимы с YM2612, которые установлены в Mega Drive/Genesis. Установив этот чип, я получаю музыку качества Mega Drive и звуковые эффекты, которые производит контроллер. CPU управляет звуковым модулем (я назвал его SPU, Sound Processor Unit, — он отдает команды YM3438 и сам производит звуки) снова через маленькое ОЗУ с двойным портом, на сей раз емкостью всего в 2 Кбайт.

Так же как у графического, у звукового модуля есть 128 Кбайт на хранение звуковых патчей и семплов PCM. Процессор же выгружает информацию в эту память через SPU. Таким образом, процессор может как велеть SPU воспроизводить команды из этого ОЗУ, так и обновлять команды для SPU каждый кадр.

CPU управляет четырьмя PWM-каналами через четыре кольцевых буфера, которые есть в специальном ОЗУ (SPU RAM на схеме ниже). SPU проходит через эти буферы и выполняет имеющиеся в них команды. Таким же образом работает еще один кольцевой буфер в SPU RAM — он обслуживает чип частотно-модуляционного синтеза (YM3438).

Взаимодействие между процессором и звуковым модулем похоже на историю с графикой — и устроено по следующей схеме.

  • SPU копирует информацию из SPU RAM во встроенную оперативку.
  • SPU ждет сигнала NMI от PPU (для синхронизации).
  • Одновременно с этим:
    • процессор обновляет буферы PWM-каналов и чипа частотно-модуляционного синтеза;
    • SPU выполняет команды в буферах согласно информации, сохраненной во встроенной памяти.
  • Пока все это происходит, SPU непрерывно обновляет PWM-звук с частотой 16 кГц.

Конечный результат

После разработки всех модулей я поместил некоторые из них на макетные платы. Для модуля CPU я сумел придумать и заказать кастомную плату. Не знаю, буду ли делать то же самое для других модулей, — полагаю, мне довольно сильно повезло получить рабочую кастомную плату с первой попытки. Только звуковой модуль пока что остается в виде макета.

Вот как выглядит консоль на момент написания этого текста.


Архитектура

Схема ниже иллюстрирует, какие компоненты входят в каждый модуль и как они взаимодействуют друг с другом. Единственное, что не показано, — это сигнал в форме NMI, который PPU передает непосредственно процессору каждый кадр, а также аналогичный сигнал, передаваемый SPU.


  • CPU: Zilog Z80, работающий на частоте 10 МГц.
  • CPU-ROM: EEPROM на 8 Кбайт, содержит код загрузчика.
  • CPU-RAM: 128 Кбайт оперативной памяти (из них используются 56 Кбайт), содержит код и данные для программ/игр.
  • IO MCU: ATmega324, служит интерфейсом между CPU и RS-232, клавиатурой PS/2, игровыми контроллерами и файловой системой SD-карты.
  • PPU-RAM: двухпортовое ОЗУ на 4 Кбайт, это интерфейсное ОЗУ между CPU и PPU.
  • CHRRAM: 128 Кбайт оперативки, содержит кастомные тайлы фона и спрайтовую графику (8 × 8 пикселей каждый символ).
  • VRAM1, VRAM2: 128 Кбайт оперативки (используются 43 008 байт), служат для хранения кадрового буфера; информацию в них записывает PPU, а считывает — VPU.
  • PPU (Picture Processing Unit): ATmega1284, отрисовывает кадр и отправляет его в кадровый буфер.
  • VPU (Video Processing Unit): ATmega324, считывает кадровый буфер и генерирует RGB и PAL-сигнал.
  • SPU-RAM: двухпортовое ОЗУ на 2 Кбайт, служит интерфейсом между CPU и SPU.
  • SNDRAM: 128 Кбайт оперативки, содержит патчи PWM, семплы PCM и блоки инструкций для частотно-модуляционного синтеза.
  • YM3438: одноименный чип частотно-модуляционного синтеза.
  • SPU (Sound Processing Unit): ATmega644, генерирует звук на базе PWM и управляет YM3438.

Итоговые характеристики

Процессор:

  • восьмибитный CPU Zilog Z80 с частотой 10 МГц;
  • 8 Кбайт постоянной памяти для загрузчика;
  • 56 Кбайт оперативной памяти.

Ввод/вывод (I/O):

  • чтение данных с SD-карт файловых систем FAT16/FAT32;
  • чтение и запись на порт RS-232;
  • два игровых контроллера, совместимых с Mega Drive/Genesis;
  • клавиатура PS/2.

Видео:

  • разрешение 224 × 192 пикселя;
  • 25 кадров в секунду;
  • 256 цветов (схема RGB332);
  • виртуальное фоновое пространство 2 × 2 (448 × 384 пикселя) с двунаправленной попиксельной прокруткой, которое описывают четыре именные таблицы;
  • 64 спрайта высотой и шириной 8 или 16 пикселей с возможностью развернуть их как по вертикали, так и по горизонтали;
  • фон и спрайты, состоящие из символов 8 × 8 пикселей каждый;
  • символьное ОЗУ с 1024 фоновыми и 1024 спрайтовыми символами;
  • независимая горизонтальная прокрутка фона по кастомным строкам на 64;
  • независимая вертикальная прокрутка фона по кастомным строкам на 8;
  • наложение плашки размером 224 × 48 пикселей с прозрачностью или без нее;
  • таблица атрибуции для фона;
  • RGB и композитный PAL-вывод через разъем SCART.

Звук:

  • генерируемый с помощью PWM восьмибитный четырехканальный звук с заранее заданными формами волны (меандр, синусоида, пилообразная, шумовая и так далее);
  • восьмибитные и восьмикилогерцевые семплы PCM на одном из каналов PWM;
  • чип частотно-модуляционного синтеза YM3438 с обновляемыми инструкциями на частоте 50 Гц.

Разработка софта для консоли

Первый кусок софта, написанный для консоли, — это загрузчик. Он хранится в постоянной памяти процессора и занимает до 8 Кбайт. Он же использует первые 256 байт оперативки процессора. Загрузчик — первый софт, запускаемый на процессоре. Его цель — показать программы, доступные на SD-карте. Эти программы хранятся в файлах, которые содержат скомпилированный код и могут также содержать данные кастомной графики и звука.

После выбора программы она загружается в оперативку процессора, символьное ОЗУ и ОЗУ звукового модуля. Там соответствующая программа выполняется. Код программ, загружаемых на консоль, может занимать до 56 Кбайт памяти — за исключением первых 256 байт; также, конечно, нужно учитывать объем стека и оставлять место для данных.

И загрузчик, и программы для этой консоли разрабатываются похожим образом. Коротко поясню, как я их сделал.

Маппинг памяти и ввода-вывода

При разработке для консоли следует обратить особое внимание на то, как CPU может получить доступ к другим модулям, поэтому представление памяти и ввода-вывода имеет решающее значение.

Процессор обращается к своему загрузчику на ПЗУ и ОЗУ через память. Представление памяти выглядит так.


К PPU-RAM и SPU-RAM, а также к IO MCU он обращается через участок ввода-вывода. Представление участка ввода-вывода процессора будет таким.


Внутри представления участка ввода-вывода IO MCU, PPU и SPU имеют свои конкретные адреса.

Управление PPU

Мы можем управлять PPU с помощью записи на PPU-RAM, а доступ к PPU-RAM, как мы знаем из таблицы выше, организован через участок ввода-вывода с адресов от 1000h до 1FFFh.

Вот как выглядит этот диапазон адресов, если представить его более подробно.


Состояние PPU (PPU Status) может принимать следующие значения:
0 — режим встроенной графики;
1 — режим кастомной графики;
2 — режим записи в символьное ОЗУ;
4 — запись закончена, ожидание подтверждения от CPU.

А вот пример того, как можно работать со спрайтами. Консоль может рендерить до 64 спрайтов одновременно. Информация об этих спрайтах передается через адреса с 1004h по 1143h (320 байт), по 5 байт информации на каждый спрайт (5 × 64 = 320 байт):

  1. Смешанный байт (каждый его бит — это флаг: Active, Flipped_X, Flipped_Y, PageBit0, PageBit1, AboveOverlay, Width16 и Height16).
  2. Символьный байт (какой символ является спрайтом на странице, описанной соответствующими флагами смешанного байта).
  3. Байт хромакея (описывает, какой цвет будет прозрачным).
  4. Байт позиции по горизонтали (оси X).
  5. Байт позиции по вертикали (оси Y).

Итак, чтобы сделать спрайт видимым, мы должны присвоить флагу Active значение 1, а также установить координаты, при которых он станет видимым (координаты x = 32 и y = 32 разместят спрайт в левый верхний угол экрана; если значения x и y будут меньше, то спрайт окажется за пределами экрана — частично или полностью). Затем мы можем присвоить ему символ и определить, какой цвет спрайта будет прозрачным.

Например, если мы хотим сделать видимым десятый спрайт, мы должны присвоить адресу ввода-вывода 4145 (1004h + (5 x 9)) значение 1. Затем устанавливаем координаты спрайта — скажем, x = 100 и y = 120, — присвоив адресу 4148 значение 100, а адресу 4149 — значение 120.

Кодим на ассемблере

Один из способов написать программу для нашей консоли — использовать язык ассемблера.

Ниже — пример кода, который заставляет первый спрайт двигаться и сталкиваться с углами экрана:

ORG 2100h

PPU_SPRITES: EQU $1004
SPRITE_CHR: EQU 72
SPRITE_COLORKEY: EQU $1F
SPRITE_INIT_POS_X: EQU 140
SPRITE_INIT_POS_Y: EQU 124

jp main

DS $2166-$
nmi:
    ld bc, PPU_SPRITES + 3
    ld a, (sprite_dir)
    and a, 1
    jr z, subX
    in a, (c) ; increment X
    inc a
    out (c), a
    cp 248
    jr nz, updateY
    ld a, (sprite_dir)
    xor a, 1
    ld (sprite_dir), a
    jp updateY
subX:
    in a, (c) ; decrement X
    dec a
    out (c), a
    cp 32
    jr nz, updateY    
    ld a, (sprite_dir)
    xor a, 1
    ld (sprite_dir), a
updateY:
    inc bc
    ld a, (sprite_dir)
    and a, 2
    jr z, subY
    in a, (c) ; increment Y
    inc a
    out (c), a
    cp 216
    jr nz, moveEnd
    ld a, (sprite_dir)
    xor a, 2
    ld (sprite_dir), a
    jp moveEnd
subY:
    in a, (c) ; decrement Y
    dec a
    out (c), a
    cp 32
    jr nz, moveEnd
    ld a, (sprite_dir)
    xor a, 2
    ld (sprite_dir), a
moveEnd:
    ret

main:
    ld bc, PPU_SPRITES
    ld a, 1
    out (c), a  ; Set Sprite 0 as active
    inc bc
    ld a, SPRITE_CHR
    out (c), a  ; Set Sprite 0 character
    inc bc
    ld a, SPRITE_COLORKEY
    out (c), a  ; Set Sprite 0 colorkey
    inc bc
    ld a, SPRITE_INIT_POS_X
    out (c), a  ; Set Sprite 0 position X
    inc bc
    ld a, SPRITE_INIT_POS_Y
    out (c), a  ; Set Sprite 0 position Y
mainLoop:    
    jp mainLoop

sprite_dir:     DB 0

Используем инструменты C

Еще можно писать программы для консоли на C, используя компилятор SDCC или другие кастомные инструменты. Разработка так идет быстрее, хотя производительность кода, конечно, падает.

В качестве примера покажу код на С, который выполняет ту же самую задачу, что и приведенный выше ассемблерный. Чтобы облегчить обращение к PPU, я здесь использовал библиотеку.

#include <console.h>

#define SPRITE_CHR 72
#define SPRITE_COLORKEY 0x1F
#define SPRITE_INIT_POS_X 140
#define SPRITE_INIT_POS_Y 124

struct s_sprite sprite = { 1, SPRITE_CHR, SPRITE_COLORKEY, SPRITE_INIT_POS_X, SPRITE_INIT_POS_Y };
uint8_t sprite_dir = 0;

void nmi() {
    if (sprite_dir & 1)
    {
        sprite.x++;
        if (sprite.x == 248)
        {
            sprite_dir ^= 1;
        }
    }
    else
    {
        sprite.x--;
        if (sprite.x == 32)
        {
            sprite_dir ^= 1;
        }
    }

    if (sprite_dir & 2)
    {
        sprite.y++;
        if (sprite.y == 216)
        {
            sprite_dir ^= 2;
        }
    }
    else
    {
        sprite.y--;
        if (sprite.x == 32)
        {
            sprite_dir ^= 2;
        }
    }

    set_sprite(0, sprite);
}

void main() {
    while(1) {
    }
}

Кастомная графика

У консоли есть встроенная и предназначенная только для чтения графика, которая хранится в прошивке PPU (одна страница тайлов для фона и одна страница графики для спрайтов). Однако для программ можно использовать и кастомную графику.

Цель в том, чтобы перевести всю необходимую графику в двоичную форму — в таком виде загрузчик консоли сможет грузить ее в символьное ОЗУ. Чтобы этого добиться, я начал с нескольких изображений уже нужного размера — в данном случае они предназначены для фона сразу в нескольких игровых ситуациях.


Поскольку кастомная графика состоит из четырех страниц по 256 символов размером 8 × 8 пикселей для фона и четырех таких же страниц для спрайтов, я преобразовал графику с картинки выше в файл PNG для каждой страницы, используя специальный инструмент (за исключением повторяющихся результирующих символов).


А следом я использовал еще один инструмент, чтобы сконвертировать результат в бинарник с символами 8 × 8 пикселей в цветовой схеме RGB332.


В результате получаются двоичные файлы, состоящие из символов 8 × 8 пикселей (символы в памяти являются смежными, каждый занимает 64 байта).

Звук

Образцы звуковых волн конвертируем в восьмибитные и восьмикилогерцевые семплы PCM. Патчи звуковых эффектов и музыки PWM можно составить, используя заранее определенные инструкции. Что касается ямаховского чипа частотно-модуляционного синтеза YM3438, то для него я нашел приложение DefleMask. С помощью DefleMask делают синхронизированную с PAL музыку для звукового чипа YM2612 от Genesis, который совместим с нашим YM3438.

DefleMask конвертирует музыку в формат VGM, а дальше я уже использую другой специальный инструмент, чтобы превратить VGM в самопальный звуковой бинарник.

Бинарники со всеми тремя видами звуков объединяются в один двоичный файл, который загрузчик потом сможет загрузить в ОЗУ звукового модуля (SNDRAM).


Соберем все вместе

Программный бинарник, графику и звук нужно соединить в один файл формата PRG. В файле PRG есть заголовок, который сообщает, использует ли программа кастомную графику и/или звук и каков размер каждого из этих компонентов. Там же содержится вся прочая соответствующая двоичная информация.

Затем получившийся файл можно разместить на карту SD, загрузчик консоли оттуда его прочитает, пошлет всю необходимую информацию соответствующим ОЗУ и запустит программу.


Используем эмулятор для разработки

Чтобы облегчить разработку софта для консоли, я написал на C++ эмулятор, используя wxWidgets. Чтобы эмулировать процессор, я обратился к библиотеке libz80.

Я добавил в эмулятор несколько отладочных функций. В частности, я могу оказаться в конкретной точке останова и пройти из нее по всем ассемблерным инструкциям. Также есть связь с исходным кодом, если программа была скомпилирована на C. Что касается графики, то тут я могу проверить, что хранится на страницах тайлов и в именных таблицах (представление фона размером в четыре экрана), а также что находится в символьном ОЗУ (CHRRAM).

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


Демонстрируем работу консоли

Видео из этого раздела — это съемка экрана электронно-лучевого телевизора на камеру телефона. Прошу прощения, что качество не очень высокое.

Запускаем с помощью бейсика и клавиатуры PS/2. На этом видео я — сразу после создания первой программы — записываю напрямую в ОЗУ графического модуля (PPU-RAM) через участок ввода-вывода команды включить и настроить спрайт, а в конце переместить его.

Демонстрация возможностей графики. На этом видео показана программа, которая отображает 64 спрайта размером 16 × 16 пикселей, кастомную прокрутку фона, а также наложенную плашку, которая двигается вверх и вниз — как перед спрайтами, так и за ними.

Демонстрация возможностей звука показывает, на что способен YM3438 в сочетании с проигрыванием семплов PCM. Частотно-модуляционная музыка вместе с семплами PCM на этом демо почти полностью занимают 128 Кбайт ОЗУ звукового модуля.

Тетрис, использующий почти исключительно фоновые тайлы для графики, YM3438 для музыки и патчи PWM для звуковых эффектов.

Заключение

Этот проект стал настоящей воплощенной мечтой, я работаю над ним уже несколько лет в свободное и не очень время. Никогда не думал, что зайду так далеко, пытаясь собрать собственную игровую консоль в ретростиле. Конечно, она не идеальна — я по-прежнему вовсе не эксперт в электронике. В консоли слишком много компонентов, и ее, несомненно, можно было бы сделать лучше и эффективней — наверняка кто-нибудь из читающих этот текст думает именно так. Тем не менее, пока я занимался этим проектом, я узнал много нового об электронике, игровых консолях и компьютерном дизайне, языке ассемблера и других интересных темах. Ну и сверх того я получил огромное удовлетворение, играя в игру, которую сделал сам, на железе, которое я тоже сдизайнил и сделал сам.

Я планирую собирать и другие консоли и компьютеры. На самом деле я уже почти закончил еще одну игровую приставку. Это упрощенная консоль в ретростиле, в основе которой лежит дешевая плата ПЛИС и несколько других компонентов (но их, очевидно, не так много, как в первом проекте). Она изначально задумана как дешевая и тиражируемая.

WWW

Сайт и каналы, которые не только вдохновили меня, но и помогли разрешить трудности, с которыми я столкнулся со время работы над проектом.

Комментарии (2)

  • Respect ! )

    Классненький самостоятельный, теплый ретро-проект ! )
    Можно на github для обучения и развития "школьников" )