...

понедельник, 8 ноября 2021 г.

Живое видео и цифровой звук на 8-битной игровой приставке

В 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-х, но в данном варианте с задачей воспроизведения звука и видео приставка (едва-едва) справляется всё же собственными силами.

Плата картриджа A Winner Is You с 64 мегабайтами ПЗУ.
Плата картриджа A Winner Is You с 64 мегабайтами ПЗУ.

Разработка и отладка

После выбора технической основы возник вопрос, как разрабатывать и отлаживать ПО под новую плату и маппер. Разумеется, на момент создания они не поддерживались ни в одном эмуляторе, и для начала работ пришлось модифицировать популярный эмулятор 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 уникальными символами.

Исходное изображение.
Исходное изображение.
256 символов.
256 символов.
128 символов.
128 символов.
64 символа.
64 символа.

Точный расчёт

Стандартное время вертикального гашения, когда возможен доступ к видеопамяти, составляет около 2300 тактов — 22 строки растра по 113.6 такта на строку. Чтобы максимально быстро передать данные в видеопамять, требуется использовать развёрнутый цикл. Здесь возможны варианты. Так, код с абсолютной адресацией источника позволит скопировать в видеопамять порядка 300 байт:

       lda SRC                         ;4
  sta PPU_DATA  ;4 - 8 тактов на байт
                ;повторяем порядка 300 раз

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

 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 килобайт. На каждый телекадр отводится по пакету объёмом 2 килобайта, который делится на 8 блоков по 256 байт. Данных располагаются в этих пакетах крайне замысловатым образом, который я затрудняюсь описать, потому что по прошествии времени сам плохо его понимаю. Такое сложное расположение данных вызвано несколькими факторами:

  • Чтение данных происходит в середине растра, а прочитанные данные распределяются по своим местам как в нижней части текущего телекадра, так и в верхней части следующего.

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

  • В схему расположения данных в процессе разработки всё время вносились изменения, в частности, на позднем этапе была добавена поддержка PAL, и было проще частично сохранить прежнее расположение некоторых данных, не переделывая лишний раз код.

Каждый из 2048-байтовых пакетов содержит:

Первые три 2048-байтовых пакета также содержат:

Последний пакет содержит другие графические данные:

  • 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-картридже), отказался нормально работать на новой версии платы — в меню возникали артефакты, проигрывание видео не запускалось вообще. Так как плата с таким объёмом ПЗУ до сих пор остаётся доступна только её разработчику, а совладать с её отладкой он пока не смог, было принято решение выпускать альбом на старой плате с меньшим обьёмом ПЗУ, в уполовиненном виде — всего шесть треков. Так и возникла мысль, что можно было бы разместить весь альбом на двух платах, а далее она развилась в идею разместить обе платы такого издания в одном двухстороннем корпусе и превратить это в дополнительную особую фишку проекта.

Adblock test (Why?)

Комментариев нет:

Отправить комментарий