В 2016 году американский музыкант Sergio Elisondo опубликовал музыкальный альбом инструментальных кавер-версий A Winner Is You (отсылка к древнему мему, происходящему из классической игры Pro Wrestling), в котором он в одиночку исполнял музыку из популярных игр для восьмибитной приставки NES на настоящих музыкальных инструментах. Необычным дополнением к этому релизу стала его версия в виде картриджа для игровой приставки NES, запускаемая на ней и воспроизводящая музыку из альбома в виде полноценного аудио, а не типичного для этой приставки довольно примитивного синтезированного звука. Я занимался разработкой программной части этого не вполне обычного проекта.
В этом году Sergio возвращается с новым большим релизом. На этот раз его музыкальный альбом You Are Error содержит полностью авторскую музыку, во многом вдохновлённую всё теми же видеоиграми. И на этот раз тоже не обошлось без необычного дополнения. Но теперь мы пошли дальше, и помимо звука картриджная версия включает почти полноэкранное силуэтное видео в стиле Bad Apple. Как и раньше, релиз финансируется через Kickstarter. Всего за семь часов кампания уже собрала скромную запрошенную сумму, но на момент публикации этой статьи ещё есть время поддержать проект и заполучить себе копию. Я же тем временем хочу рассказать, как устроена техническая сторона обоих релизов.
Много памяти
Как же заставить приставку 1983 года выпуска и ещё более раннего года разработки, обладающую считанными единицами килобайт памяти и мегагерц вычислительной мощности, воспроизводить оцифрованную аудиозапись, не говоря уже о видео?
Разумеется, можно разместить внутри картриджа микросхему аппаратного MP3-декодера, или даже целый одноплатник типа Raspberry Pi, и делать на нём что угодно, например, запускать классический Doom. Но это немного неспортивное решение, вызывающее вопросы об уместности наличия в данной схеме самой приставки NES. По крайней мере, с точки зрения технического пуризма нельзя будет утверждать, что на подобные чудеса способна сама приставка, хотя для многих неискушённых в технической части пользователей это всё равно будет выглядеть очень впечатляюще.
По этой и другим причинам мы выбрали чуть менее неспортивный подход — просто огромный объём ПЗУ картриджа. Для этих целей небезызвестным в NES-кругах разработчиком ретро-железа RetroUSB была разработана плата картриджа, содержащая 64 мегабайта (не мегабита) ПЗУ, и особый маппер (устройство управления памятью), очень похожий по устройству на простой классический UNROM, только позволяющий выбирать одну из 4096 16-килобайтных страниц. Это, конечно, тоже выходит далеко за рамки технологических возможностей начала 1980-х, но в данном варианте с задачей воспроизведения звука и видео приставка (едва-едва) справляется всё же собственными силами.

Разработка и отладка
После выбора технической основы возник вопрос, как разрабатывать и отлаживать ПО под новую плату и маппер. Разумеется, на момент создания они не поддерживались ни в одном эмуляторе, и для начала работ пришлось модифицировать популярный эмулятор FCEUX. Он обладает многими достоинствами, но высокая точность не его конёк, и хотя её хватило для первого проекта, на втором пришлось также доработать ещё несколько более точных эмуляторов. Ни один из них не был пока выпущен в публичное пользование, так как не выпускались и дампы программ под новый маппер, и ему даже не был назначен свой особый номер в формате iNES.
Проверка же кода на реальной плате осложнялась очень своеобразной реализацией программатора — полная перезапись ПЗУ занимает примерно 4 часа — а также отсутствием этой платы, равно как и реальной приставки, в моих руках. Поэтому тесты изредка прошивал и запускал сам Sergio, после чего сообщал мне о результатах, а разработка и основная отладка шла чисто в эмуляторе методом гадания на кофейной гуще.
Для написания кода использовался мой обычный набор инструментов — кросс-ассемблер CA65 из пакета CC65, позволяющий гибко настраивать свой бинарный выход под практически любую целевую платформу с процессором 6502, текстовый редактор Notepad++, графические редакторы Graphics Gale и GIMP, звуковые редакторы Wavosaur и Audacity, видеоконвертор VirtuaDub, мой собственный конвертор-редактор графики NES Screen Tool, и некоторые другие. Для создания специальных консольных утилит-конверторов аудио и видеоконтента использовался Visual C++ Express, так как объёмы данных были довольно большие, и не хотелось тратить лишние минуты на выполнение конверсии — хотя надо отметить, что при малых объёмах в решении подобных задач хорошо показывает себя Python. Автоматизация процесса сборки велась через банальные batch-файлы и скрипты на Python (мои вкусы очень специфичны).
Играем звук
В первом проекте, A Winner Is You, требовалось воспроизводить цифровой звук, практически ничего не делая одновременно с этим, в частности, не выводя никакую анимацию на экран. Это довольно простая задача с точки зрения реализации в программе — читаем байты из ПЗУ, переключая банки по мере необходимости, и выводим их в ЦАП APU с равными промежутками времени. По сути с подобной задачей может справиться любой микрокомпьютер или игровая приставка, при условии наличия доступа к ПЗУ большого объёма и какому-либо ЦАП.
Главной особенностью в воспроизведении сэмплов на такой простой платформе, как NES, является отсутствие способов точной синхронизации с реальным временем, типа таймеров высокого разрешения, кроме собственно скорости выполнения команд кода центральным процессором. То есть для обеспечения стабильной скорости выборки сэмплов из ПЗУ и вывода их в ЦАП скорость выполнения кода должна была быть точ��о рассчитана. Впрочем, это достаточно типовая задача для подобной старой компьютерной техники, с хорошо отработанными решениями, и особых сложностей это не представляло. Скорости процессора хватало с избытком, теоретически она позволяла получить частоту дискретизации порядка 74 КГц в NTSC и 69 КГц в PAL, но исходя из доступного объёма ПЗУ и количества аудиоконтента, который требовалось в нём разместить, была выбрана традиционная частота дискретизации 44100 Гц. ЦАП на NES принимает 7-битные значения, в результате качество звука сравнимо с 8-битным Sound Blaster, где ЦАП 8-битный, но максимальная частота дискретизации вдвое ниже.
Оболочка проигрывателя предоставляет пользователю возможность быстрой перемотки по треку вперёд и назад с подзвучкой в стиле старых кассетных магнитофонов, а также паузу и замедление проигрывателя. Также поддерживается воспроизведение с одинаково правильной скоростью на приставках NTSC и PAL версий, у которых различается скорость работы процессора. Всё это реализовано через использование нескольких отдельных программных циклов воспроизведения с разными задержками. В момент нажатия кнопок на экране меняются иконки, отображающие текущий режим проигрывания. Для этого воспроизведение звука ненадолго останавливается, но так как все действия так или иначе влияют на звук — ускоряют, замедляют, останавливают его — эта задержка не слышна.
В качестве иллюстрации к вышесказанному привожу фрагмент кода, выполняющего воспроизведение заданного количества 256-байтовых блоков звука с нормальной скоростью на приставке NTSC версии. В комментариях указано количество тактов процессора, уходящее на выполнение соответствующих инструкций:
;1789773/44100=40t (fCpu/fSampleRate)
playLoopNTSC:
lda (BANK_OFFSET),y ;5+ читаем байт из текущего банка
sta APU_DMC_RAW ;4 выводим в ЦАП
sta LAST_DMC_RAW ;4 запоминаем для дальнейшего использования (нужно в плеере)
nop ;2 общая для двух веток задержка
nop ;2
nop ;2
nop ;2
nop ;2
nop ;2
iny ;2 увеличение младшего байта указателя
beq :+ ;2/3+ если произошло переполнение, переход на ветку увеличения старшего байта указателя
nop ;2 дополнительная задержка для более короткой ветки
nop ;2
nop ;2
nop ;2
nop ;2
jmp playLoopNTSC ;3=40t переход на цикл
:
inc <BANK_OFFSET+1 ;5 увеличение старшего байта указателя
dex ;2 счётчик 256-байтных блоков
bne playLoopNTSC ;2/3+ =40t переход на циклЭтот код предполагает, что блок воспроизводящихся звуковых данных не пересекает границу переключаемого банка ПЗУ. Своевременное переключение банков выполняется вызывающим кодом. Благодаря устройству маппера оно делается весьма эффективно:
ldy #0 ;нулевое дополнительное смещение
sta (BANK_NUMBER),y ;переключение банкаВ двухбайтовой переменной BANK_NUMBER хранится номер текущего банка. Номера банков имеют диапазон $8000..$8FFF, то есть 0..4095 с установленным старшим битом. Маппер перехватывает любые записи в адресное пространство ПЗУ и защёлкивает младшие 12 бит адреса записи в регистре выбора банка. Содержимое самой записи не имеет значения, только её адрес.
Показываем видео
Выполнение каких-либо действий одновременно с воспроизведением цифрового звука, тем более отображение полноэкранной анимации — гораздо более амбициозная задача в рамках возможностей NES. Помимо того, что синхронизация с реальным временем выполняется точным потактовым расчётом времени выполнения кода, доступ к видеопамяти возможен только в определённые моменты, а именно во время вертикального гашения при обратном ходе луча, и не непосредственно, а через однобайтовый порт с последовательным доступом.
Дополнительные сложности создаёт также устройство видеосистемы NES, по сути представляющее собой текстовый режим с поддержкой аппаратных спрайтов. Слой графики фона может отображать только 256 уникальных символов из хранящегося в ПЗУ или загружаемого программно набора. При изменении графики символа в наборе его изображение сразу же изменится повсюду, где он используется на экране. Таким образом, становится очень затруднительно включить или выключить отдельный пиксель в произвольном месте экрана и оставить его там во всех последующих кадрах, а в процессе анимации часто приходится обновлять данные сразу в нескольких разных местах — в графике символов, в их карте, а также в области цветовых атрибутов
В таких условиях даже простое копирование несжатых кадров в видеопамять становится довольно запутанной и ресурсоёмкой задачей, поэтому в моей реализации видеопроигрывателя никакого межкадрового сжатия, типичного для всех видеоформатов, включая многие древнейшие, не предусмотрено.
Как бы сжатие
Тем не менее, формат видео использует минимальное внутрикадровое сжатие, причём это сжатие с потерями. Но его цель не в уменьшении объёма данных кадров самого по себе, а в уменьшении количества уникальных символов, составляющих изображение в кадре анимации — ведь количество символов, графику которых можно успеть обновить за время телекадра, ограничено временем доступа к видеопамяти.
Технику подобного "сжатия", или оптимизации набора символов, я реализовал и успешно использую её в проектах для NES уже довольно давно. Суть её очень проста — найти в исходном наборе пару максимально похожих друг на друга визуально символов (главная сложность в выборе критерия сходства и способа его оценки), убрать один из них, заменив его на другой, и повторять процесс до тех пор, пока количество символов не уменьшится до заданного. Этот подход даёт визуальные артефакты, заметность которых зависит от степени "сжатия", и довольно плохо работает с мелкой детализацией, быстро сводя её к шуму.
Необходимость применения подобной оптимизации продиктовала стилистику содержания видеоклипов — в основном это силуэтное видео, схожее с нашумевшим клипом Bad Apple, который был выполнен в подобной технике. Собственно, сам этот клип давно уже стал своего рода бенчмарком для подобных проектов, и я тоже использовал его в качестве пробного материала во время разработки и тестирования видеоформата и проигрывателя.
Изображения ниже наглядно иллюстрируют процесс оптимизации — исходная ка��тинка, состоящая из 960 уникальных символов, и её упрощённые версии с 256, 128 и 64 уникальными символами.




Точный расчёт
Стандартное время вертикального гашения, когда возможен доступ к видеопамяти, составляет около 2300 тактов — 22 строки растра по 113.6 такта на строку. Чтобы максимально быстро передать данные в видеопамять, требуется использовать развёрнутый цикл. Здесь возможны варианты. Так, код с абсолютной адресацией источника позволит скопировать в видеопамять порядка 300 байт:
lda SRC ;4
sta PPU_ADDR ;4 - 8 тактов на байтЕсть и более быстрые способы, но они имеют свои ограничения и сложности. Вот пара средних вариантов с буферизацией данных в нулевой странице ОЗУ или в странице стека:
lda <SRC ;3
sta PPU_DATA ;4 - 7 тактов на байт
pla ;3
sta PPU_ADDR ;4 - тоже 7 тактов на байтИх недостаток в ограниченном объёме указанных локаций ОЗУ — максимум 256 байт в каждой, и эти байты также очень нужны всему остальному коду.
Самый быстрый вариант предполагает буферизацию данных непосредственно в кодах команд в самомодифицирующемся коде:
lda #NN ;2
sta PPU_ADDR ;4 - 6 тактов на байтЗа счёт такого трюка можно получить пропускную способность около 400 байт, но на код пересылки одного байта потребуется 5 байт кода в ОЗУ, а 400*5 = 2000 байт, это почти весь объём имеющегося у NES ОЗУ. Поэтому далее в расчётах за основной способ копирования данных принимается всё же вариант с 8 тактами на байт.
Для полного обновления экрана — всего набора символов и их карты — нужно скопировать в видеопамять 5 килобайт. Учитывая ранее приведённые цифры по скорости передачи данных, это потребовало бы 5120/300=17 телевизионных кадров, и частота кадров в видео составила бы 60/17=3.5 кадра в секунду.
Чтобы передать больше данных, нужно расширить время гашения, отключая отображение активного растра в некоторых строках экрана. Они в это время будут отображаться цветом общего фона. За одну строку при использовании 8-тактового копирования можно передать около 14 байт. Даже если полностью выключить отображение, за время одного телекадра можно скопировать менее 4 килобайт данных.
Такое множество вводных вызывает необходимость нахождения баланса между необходимым объёмом передаваемых данных, количеством отображаемых и отданных на расширение периода гашения строк, и количеством телевизионных кадров, затрачиваемых на полное обновление экрана для обеспечения достаточной частоты смены кадров в видео.
Как известно, общепринятым минимальным значением для поддержания ощущения плавности движения считается 12-18 кадров в секунду. Также не следует забывать, что помимо анимации проигрыватель должен постоянно поддерживать чтение и выдачу в ЦАП сэмплов звукового сопровождения, делая это каждые несколько десятков тактов процессора, с равномерными промежутками. То есть не всё время гашения реально доступно для передачи данных в видеопамять, часть его приходится тратить на вывод звука.
После множества экспериментов был выбран и утверждён следующий баланс:
Разрешение видео 256x160 (32x20 символов)
4 цвета, отдельная палитра для каждого кадра
212 уникальных символов в кадре
15 кадров в секунду для NTSC, 12.5 кадров в секунду в PAL
Частота дискретизации звука 27360 Гц для NTSC, 25450 Гц для PAL
Также проводились эксперименты с конверсией исходного изображения в 4 палитры с цветовыми атрибутами, чтобы повысить общее количество цветов в одном кадре до 13. Но возникли затруднения на этапе конверсии — из-за низкого разрешения цветовых атрибутов оказалось непросто найти алгоритм, который эффективно делил бы картинку на области с разной раскраской, не делая границы цветовых переходов между этими областями слишком заметными. Так как необходимый алгоритм не представлялся даже в общих чертах, и было неизвестно, сколько времени займёт его поиск, было решено ограничиться минимальным раскрашиванием некоторых кадров в сепию.
Цикл полного обновления экрана в NTSC и PAL занимает четыре телевизионных кадра. Разница в частоте кадров видео и в частоте дискретизации звука возникает из-за разной частоты телекадров (60/4=15 и 50/4=12.5) и разной тактовой частоты центрального процессора. Частота дискретизации определяется выводом сэмпла в ЦАП каждые 64 такта, это число остаётся неизменным в обеих версиях.





Формат данных
Для упрощения реализации навигации по видеофайлу (перемотка вперёд и назад) объём данных одного кадра фиксирован, он составляет 8 килобайт. Исходя из этой цифры и частоты кадров легко посчитать, сколько контента поместится на картридже — 120 килобайт в секунду, значит, на 64-мегабайтный картридж поместится около 546 секунд видео, то есть почти 10 минут.
На каждый телекадр отводится по пакету объёмом 2 килобайта, который делится на 8 блоков по 256 байт. Данных располагаются в этих пакетах крайне замысловатым образом, который я затрудняюсь описать, потому что по прошествии времени сам плохо его понимаю. Такое сложное расположение данных вызвано несколькими факторами:
Чтение данных происходит в середине растра, а прочитанные данные распределяются по своим местам как в нижней части текущего телекадра, так и в верхней части следующего.
Необходимость максимальной оптимизации кода, для чего фрагменты данных располагаются в таких местах, где доступ к ним наиболее удобен в каждый конкретный момент.
В схему расположения данных в процессе разработки всё время вносились изменения, в частности, на позднем этапе была добавена поддержка PAL, и было проще частично сохранить прежнее расположение некоторых данных, не переделывая лишний раз код.
Каждый из 2048-байтовых пакетов содержит:
456 байт звуковых данных для NTSC (456*60=27360 Гц)
509 байт звуковых данных для PAL (509*50=25450 Гц)
Первые три 2048-байтовых пакета также содержат:
1024 байта графики символов (64 символа)
Последний пакет содержит другие графические данные:
320 байт графики символов (20 символов)
13 байт палитры
640 байт карты символов
48 байт карты цветовых атрибутов
Для обеспечения применимости одного набора графических данных в NTSC и PAL, что необходимо, чтобы избежать дублирования всего контента, в режиме PAL пропускается часть кадров (3 из 15 в секунду) — благо, это достаточно просто делать при отсутствии межкадрового сжатия. Звуковая же дорожка хранится в двух копиях с разной частотой дискретизации, чтобы избежать дополнительного усложнения кода. Без второй копии понадобилось бы делать две версии кода с разными задержками между выводами в ЦАП, а это повлекло бы за собой значительные изменения и в коде чтения и пересылки данных в видеопамять.
Код
Код проигрывателя не просто читает данные из ПЗУ и передаёт в ЦАП либо видеопамять. Дело осложняется тем, что в архитектуре NES для доступа к ПЗУ отведено всего 32 килобайта адресного пространства процессора. Используемый маппер разделяет это пространство на две половины — одна фиксированная, то есть всегда содержит одну 16-килобайтную страницу из 4096 возможных (в ней располагаются векторы сброса и прерываний), а в другую подключается любая из 4096 16-килобайтных страниц. Так как для получения необходимой производительности используются полностью развёрнутые циклы, и они занимают многие килобайты, их код приходится размещать в подключаемых страницах. Но этому коду нужно обращаться к данным, которые тоже доступны только через подключаемые страницы, и значит, недоступны коду в подключаемых страницах. Для решения этой проблемы пришлось придумать схему буферизации данных в ОЗУ приставки, которое имеет объём всего 2 килобайта.
Код делится на две функциональные части — ридер и пушеры, то есть читалки и писалки. Они вызываются в разных частях растра и в разных телекадрах внешним циклом обновления кадра видео, по частям формируя следующий кадр в видеопамяти. Используется двойная буферизация, то есть процесс формирования частей кадра скрыт, кадр показывается на экране целиком только по завершении его формирования. Для этого используются оба переключаемых набора символов и обе карты символов, а значит, пушерам нужно пересылать данные в разные места видеопамяти в зависимости от чётного или нечётного кадра видео.
Ридеров всего два, по одному для NTSC и PAL. Ридер работает во время прохода луча по видимой части кадра, читая данные из подключённой страницы ПЗУ и сохраняя в ОЗУ для дальнейшего использования, а также выводя звуковую часть этих данных сразу в ЦАП без буферизации. Сохраняемые данные содержат графику символов, их карту, а также звуковые данные для воспроизведения при проходе луча по остальной части растра. Так как ридеру нужен доступ к подключаемой странице ПЗУ, сам он располагается в фиксированной странице, место в которой сильно ограничено — поэтому код ридеров максимально универсальный, и не использует полное разворачивание цикла (развёрнуто шесть итераций). Более того, для обеспечения максимальной скорости чтения данных из нужного места банка ПЗУ используется самомодификация кода, и перед выполнением этот код размещается в ОЗУ.
Код ридера для NTSC выполняется во время отображения видимых 160 строк растра и занимает около 18176 тактов процессора. Он читает 1536 байт данных из подключённого банка ПЗУ в буфер в ОЗУ, а также проигрывает 284 сэмпла звука, не буферизируя их. Код ридера для PAL выполняется дольше, 202 строки и примерно 21568 тактов, хотя он буферизирует столько же байт данных. Это вызвано тем, что ему требуется пр��играть 337 сэмплов звука (частота телекадров ниже, значит, сэмплов на один телекадр приходится больше). Лишние 42 строки входят в расширенный период гашения, который является особенностью режима PAL.
Задача пушеров заключается в как можно более быстром перемещении данных из буфера в ОЗУ в нужные места видеопамяти. Они забирают данные из ОЗУ и передают в нужное время в нужное место — звуковые данные в ЦАП каждые 64 такта, графику в видеопамять во всё оставшееся время. Всего есть восемь пушеров, по два для каждого из четырёх телекадров, во время которых происходит полное обновление кадра анимации. В каждом телекадре работает один пушер для верхней и один для нижней половины растра, во время которых выполняется принудительное гашение и возможен доступ к видеопамяти. Код пушеров одинаков для NTSC и PAL.
В первом верхнем пушере устанавливается палитра, загружаются в видеопамять карта символов и карта цветовых атрибутов, устанавливаются в карте символы для иконок OSD. Во всех последующих пушерах загружается графика различного количества символов, 38 во всех верхних, 26 в нижних, кроме последнего, который загружает графику оставшихся 20 символов (26+38+26+38+26+38+20, итого 212 символов).


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

Дело в том, что изначально планировалось использовать версию платы с 128 мегабайтами ПЗУ, также разработанную RetroUSB, и включить в релиз полный альбом, для чего уже был создан контент. Но по неизвестной причине код, нормально работающий в эмуляторах и в отдельных тестах на платах с меньшим объёмом ПЗУ (64M на предыдущей плате и совсем маленький тест для MMC3 на обычном Flash-картридже), отказался нормально работать на новой версии платы — в меню возникали артефакты, проигрывание видео не запускалось вообще. Так как плата с таким объёмом ПЗУ до сих пор остаётся доступна только её разработчику, а совладать с её отладкой он пока не смог, было принято решение выпускать альбом на старой плате с меньшим объёмом ПЗУ, в уполовиненном виде — всего шесть треков. Так и возникла мысль, что можно было бы разместить весь альбом на двух платах, а далее она развилась в идею разместить обе платы такого издания в одном двухстороннем корпусе и превратить это в дополнительную особую фишку проекта.
