Как мы делали Warface для Денди

В октябре 2020 мне написал мой друг Андрей Скочок, работающий в Mail.ru, и предложил сделать для них необычную промоакцию.

Суть в том, что в ноябре для их игры Warface выходило обновление, и Андрею пришла в голову идея разослать различным видеоблогерам картриджи для Денди, которые будут в качестве тизера демонстрировать несколько картинок из вселенной игры, а в конце показывать промокод.

Сделать всё это нужно было в максимально сжатые сроки, у нас был всего один месяц, так что даже заказывать что-либо из Китая — вообще не вариант. Впрочем, тупо отобразить картинки — не такая уж хитрая задача, тем более рисовать их будут сами дизайнеры Mail.ru, так что я прикинул свои возможности и согласился.

Аппаратные ограничения

Первым делом мы решили определиться, какой будет бюджет у железа картриджа. Дело в том, что чем проще и дешевле аппаратная начинка, тем более жёсткие рамки будут у художников-дизайнеров. Разработчикам самых первых игр для Famicom было весьма нелегко, тогда в картриджах не было ничего кроме непосредственно памяти с данными игры.

Давайте сразу определимся, что когда я пишу «Денди», «Famicom» или «NES», я подразумеваю по сути одно и то же. Для тех, кто не в курсе: то, что у нас в России называли «Денди», в Японии называлось «Фамиком», а в Европе и США — NES.

Почти 10 лет назад я писал на Хабре статью «Игры для NES/Famicom/Денди глазами программиста«. С тех пор моё понимание архитектуры NES выросло в разы, я сам научился программировать под эту консоль и даже начал на этом зарабатывать. Можете перечитать ту статью, но тут я попробую рассказать основные моменты заново, дополнив всё более низкоуровневыми подробностями.

Многие наверное замечали, что изображение в играх на Famicom состоит из блоков повторяющихся картинок. Они имеют размер 8 на 8 пикселей и называются тайлами.

image

Видеопроцессор консоли ищет эти изображения в младших восьми килобайтах видеопамяти (адреса $0000-$1FFF), эта область памяти называется «pattern table». Ну и раз размер памяти ограничен, то соответственно и количество используемых тайлов ограничено.

Восемь килобайт — это ровно 512 тайлов, они делятся на две области, по четыре килобайта. Обычно их называют «left pattern table» и «right pattern table», по тому, как они обычно отображаются в отладчике эмулятора.

Для отрисовки фона в каждый момент времени может использоваться только один из этих pattern tabl’ов, на выбор программиста. То есть я могу нарисовать фон либо любой комбинацией из первых 256 тайлов, либо любой комбинацией из вторых 256 тайлов, но никак не могу брать картинки сразу и там, и там. Вторые же 256 тайлов обычно используются для спрайтов. Это можно отчетливо увидеть в отладчике эмулятора. Впрочем, изображение на экране телевизора рисуется построчно, и если очень захотеть, можно переключить используемый pattern table где-то посреди экрана, когда уже нарисовалась верхняя часть, но ещё не нарисовалась нижняя. Но это надо ещё умудриться поймать нужный момент и сделать это без глюков.

Наверное вы спросите меня: а 256 тайлов — это вообще много или мало? Ну смотрите: разрешение изображения на NES — 256 на 240 пикселей. Это 32 тайла в ширину и 30 в высоту, итого 960. Выходит, что из уникальных 256 тайлов можно составить лишь чуть больше четверти изображения на экране. Вот мы и наблюдаем в играх на Денди, что фоновое изображение состоит из повторяющихся кусочков. Но я еще не сказал, где же хранится эта область памяти от $0000 до $1FFFF. А этой памяти вообще нет внутри консоли. Она всегда находится в картридже.

А теперь прибавьте к вышесказанному то, что в самых первых картриджах не было никаких вспомогательных схем и оперативки. Эти pattern tabl’ы хранились на неперезаписываемой памяти. И разработчикам нужно было составить из этих 256 тайлов всю игру. Однако, со временем стало проще, в картриджах начали появляться мапперы.

Маппер — это дополнительная логическая схема в картридже, задача которой — обманывать консоль. Область памяти под pattern table ограничена восемью килобайтами, и это никак не обойти. Но когда видеочип читает данные из этой памяти картриджа, не обязательно всегда отдавать одни и те же значения. Можно увеличить память в картридже и отдавать данные то из одной области, то из другой. Так один и тот же адрес в адресном пространстве видеочипа может вести в разные области памяти картриджа, в зависимости от того, как настроен маппер. Собственно, поэтому он и называется маппером — он маппит одну область памяти на другую. Такие области памяти называют банками. Обычно за один банк принимают минимальный переключаемый объем памяти.

Благодаря этому игры стали выглядеть гораздо более разнообразно. Вот мы играем в первый уровень, и маппер в картридже отдаёт один набор из 256 тайлов. Переходим на другой уровень, игра перенастраивает маппер на другой банк, и по тем же адресам доступны уже другие 256 тайлов.

На всю эту матчасть я сейчас отвлекся лишь для того, чтобы вам стало понятно, насколько важно сразу определиться с тем, какое железо будет стоять в картридже. Если сделаем картридж максимально дешевым, то художникам придется уложить все картинки в 256 тайлов. Ну ладно, в 512 тайлов, можно переключать pattern table между экранами, но это всё равно жесть.

К счастью, мы пришли к тому, что бюджет на картриджи хоть и ограниченный, но всё-таки есть, к тому же я нашел в продаже в Москве максимально дешевое железо. В качестве логики, которая возьмет на себя задачи маппера, я выбрал ПЛИС EPM3064. ПЛИС — это программируемая логика, и использовать ее — отличный вариант, когда мы не знаем точно заранее, какой функционал нам понадобится. У нее объем — 64 макроячейки, это очень мало, но для наших задач должно хватить. К тому же она толерантна к 5 вольтам, что избавит нас от необходимости ставить шифтеры и конвертировать напряжения в 3.3 вольта, на которых работают современные микросхемы.

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

Получается, что у нас нет ни жёсткого ограничения на объём, ни какого-либо лимита на тайлы. Дизайнеров-художников можно в этом плане не напрягать. Однако, все еще остаются ограничения по цветам, а они у Famicom очень суровые.

Всего Famicom может отображать около 50 цветов. Почему около, а не точное количество? Ну, потому что некоторые цвета повторяются или очень похожи.

А ещё некоторые цвета — это чёрный чернее чёрного, и их нельзя использовать, т.к. многие телевизоры воспринимают такой цвет как сигнал вертикальной синхронизации, и изображение начинает прыгать, ведь в NTSC кодировании именно таким пониженным напряжением, ниже напряжения чёрного цвета, отмечаются границы между кадрами.

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

Сама же последовательность выводимых на экран тайлов хранится в области памяти, которая называется nametable. Она располагается в диапазоне от $2000 до $2FFF, имеет размер в четыре килобайта и делится на четыре части по одному килобайту. Каждая часть отвечает за один экран. Зачем больше одного? Чтобы можно было заранее отрисовать фон за пределами экрана, а потом его просто двигать. Этот процесс можно наглядно отследить в отладчике эмулятора.

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

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

Первый располагается слева сверху и справа сверху, а второй слева снизу и справа снизу?

Или же под углом в 90 градусов?

А эту конфигурацию может менять сам картридж! Для этого в разъеме выделен отдельный контакт. У простейших картриджей эта конфигурация фиксированная, и если в игре экран двигается слева направо, мирроринг обычно вертикальный, а если снизу вверх, то горизонтальный. Посмотрите на скриншот выше — в Марио мирроринг вертикальный, т.к. экран двигается только слева направо. Бывает ещё одноэкранный мирроринг, когда каждый nametable ведёт в одну область памяти, ну а в теории можно получить совсем экзотические комбинации вроде мирроринга крест-накрест или в форме буквы L.

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

Но что-то я опять отвлекся. Что же такое nametable? В нём банально содержатся номера тайлов из pattern tabl’а. По одному байту на тайл, как раз 256 вариантов.

Но тайлов у нас на экране 960, а не 1024, поэтому в конце каждого nametabl’а остаётся 64 байта. Эти 64 байта называют «attribute table». Они в совокупности с цветовыми кодами в тайлах и отвечают за цвет, но номера цветов опять же содержатся не здесь.

Под цветовые палитры есть ещё одна область памяти в видеочипе, размером в 32 байта. Именно тут указываются номера используемых на экране цветов, по одному байту на цвет. 16 байт для фона и 16 байт для спрайтов.

Но каждые из этих 16 байт делятся на четыре палитры по четыре цвета. Четыре цвета — именно такой выбор у нас для каждого тайла, помните? Вот тут и задается какие конкретно четыре цвета используются. А в attribute table указывается, какая из этих четырех палитр в какой области экрана используется. При этом из-за ограничений памяти номер палитры задается не для каждого тайла на экране, а для блоков в два на два тайла, ведь attribute table имеет размер всего в 64 байта, а тайлов на экране 960.

Я прекрасно понимаю, что к текущему моменту почти все из вас окончательно запутались. Давайте все как-то обобщим. Значит, видеочип рисует фоновое изображение так:

  • Для каждого кусочка в 8 на 8 пикселей он берет из nametable номер тайла
  • По соответствующему этому тайлу адресу из pattern table он берет само изображение тайла
  • у этого тайла вместо цвета каждого пикселя указан номер одного из четырех цветов в палитре
  • в какой именно палитре из четырех доступных указано в attribute table для каждого блока в 16 на 16 пикселей

И как же мне объяснить все эти нюансы дизайнерам? Я с этим работаю регулярно, и то мне сложно все в голове держать. На самом деле им не нужно знать технические тонкости. Достаточно сказать, что нужно нарисовать картинку размером 256 на 240 пикселей, при этом можно использовать только определённые цвета, но каждый блок в 16 на 16 пикселей может использовать только четыре цвета, и комбинаций таких цветов тоже может быть только четыре.

Правда, и это не все ограничения.

Вот есть четыре палитры по четыре цвета, так вот у них нулевой цвет в каждой палитре должен быть одинаковым. Он считается цветом фона. В случае со спрайтами, кстати, первый цвет вообще игнорируется, т.к. всегда означает прозрачный цвет. И ещё наверное нужно предупредить дизайнеров, что картинка сплющивается до соотношения сторон 4:3, то есть пиксели не квадратные.

Я передал эту информацию и начал работать над аппаратной частью.

Схема и плата

Проектировать картридж для Фамикома мне не впервой, и по сравнению с другими моими работами тут все достаточно просто. Подключаем микросхемы памяти к соответствующим выводам разъема картриджа, а в качестве маппера добавляем ПЛИС, которая будет управлять старшими адресными линиями. Чтобы можно было управлять маппером, подключаем его еще и параллельно линиям данных процессора. Было решено сразу добавить управление линиями записи, чтобы можно было прошить микросхемы памяти уже прямо в картридже, это будет гораздо удобнее. И на всякий случай я подключил к ПЛИС линию прерываний, вдруг мне понадобится генерировать их из картриджа?

Развести такую плату не особо сложно, компонентов мало. Маппер я поместил на обратную сторону, а спереди разместил память и добавил логотип Warface.

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

Обработчик изображений

Что же там в этом время у художников… А там все плохо. Андрей прислал мне несколько картинок, спрашивая, подходят ли они.

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

На самом деле у меня уже были наработки такой программы на C#, ведь я уже работал с изображениями при программировании под NES, но эти наработки были очень сырыми, и это стало отличным поводом довести все до полноценного проекта. Цель такой программы — получать на входе картинки и конвертировать их в понятный для Фамикома формат, вписывая при этом изображения во все технические ограничения, если это необходимо. Для этого каждое изображение, а выводимая на экран картинка может состоять из нескольких исходных изображений, должно пройти целый ряд преобразований.

Первым делом нужно сделать так, чтобы в картинке использовались только те цвета, которые доступны на NES. Напомню, это около пятидесяти цветов. То есть нужно пройтись по каждому пикселю в изображении, посмотреть на его цвет и найти наиболее похожий из палитры доступных на NES цветов. И эта задача только на первый взгляд кажется такой простой. Это если живому человеку показать какой-нибудь цвет, то он скажет, какие цвета на него больше всего похожи. Но как это вычислить автоматически? Должна же быть какая-то математическая формула для вычисления коэффициента схожести двух цветов.

Сначала я подумал, что надо тупо взять RGB модель и вычислить, на сколько отличаются значения яркости красного, яркости зеленого и яркости синего, а потом эти разности сложить. И чем больше полученное число, тем сильнее отличаются цвета. Но это максимально тупо.

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

В общем, все придумано до нас, и не надо изобретать велосипед. На википедии есть целая статья посвященная этой задаче: https://en.wikipedia.org/wiki/Color_difference
Я ничего не понимаю в этих формулах, но самое главное, что я узнал название алгоритма — «CIEDE2000», а по названию уже легко найти библиотеку с его реализацией. Я воспользовался библиотекой «ColorMine».

C#
static Color FindSimilarColor(IEnumerable<Color> colors, Color color)
{
  Color result = Color.Black;
  double minDelta = double.MaxValue;
  foreach (var c in colors)
  {
      var delta = color.GetDelta(c);
      if (delta < minDelta)
      {
          minDelta = delta;
          result = c;
      }
  }
  return result;
}

// Change all colors in the images to colors from the NES palette
foreach (var imageNum in imagesOriginal.Keys)
{
  Console.WriteLine($"Adjusting colors for file #{imageNum} - {imageFiles[imageNum]}...");
  var image = new FastBitmap(imagesOriginal[imageNum].GetBitmap());
  imagesRecolored[imageNum] = image;
  for (int y = 0; y < image.Height; y++)
  {
    for (int x = 0; x < image.Width; x++)
    {
      var color = image.GetPixel(x, y);
      var similarColor = nesColors[FindSimilarColor(nesColors, color)];
      image.SetPixel(x, y, similarColor);
    }
  }
}


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

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

C#
var top4 = new List<Palette>();
// Calculate palettes if required
if ((new int[] { 0, 1, 2, 3 }).Select(i => paletteEnabled[i] && fixedPalettes[i] == null).Any())
{
    // Creating and counting the palettes
    Dictionary<Palette, int> paletteCounter = new Dictionary<Palette, int>();
    foreach (var imageNum in imagesOriginal.Keys)
    {
        Console.WriteLine($"Creating palettes for file #{imageNum} - {imageFiles[imageNum]}...");
        var image = imagesRecolored[imageNum];
        // For each tile/sprite
        for (int tileY = 0; tileY < image.Height / tilePalHeight; tileY++)
        {
            for (int tileX = 0; tileX < image.Width / tilePalWidth; tileX++)
            {
                // Create palette using up to three most popular colors
                var palette = new Palette(
                    image, tileX * tilePalWidth, tileY * tilePalHeight,
                    tilePalWidth, tilePalHeight, bgColor.Value);

                // Skip tiles with only background color
                if (!palette.Any()) continue;

                // Do not count predefined palettes
                if (fixedPalettes.Where(p => p != null && p.Contains(palette)).Any())
                    // Считаем количество использования таких палитр
                    continue;

                // Count palette usage
                if (!paletteCounter.ContainsKey(palette))
                    paletteCounter[palette] = 0;
                paletteCounter[palette]++;
            }
        }
    }
}


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

C#
// Group palettes
Console.WriteLine($"Calculating final palette list...");
// From most popular to less popular
var sortedKeys = paletteCounter.OrderByDescending(kv => kv.Value).Select(kv => kv.Key).ToArray();
// Some palettes can contain all colors from other palettes, so we need to combine them
foreach (var palette2 in sortedKeys)
    foreach (var palette1 in sortedKeys)
    {
        if ((palette2 != palette1) && (palette2.Count >= palette1.Count) && palette2.Contains(palette1))
        {
            // Move counter
            paletteCounter[palette2] += paletteCounter[palette1];
            paletteCounter[palette1] = 0;
        }
    }

// Remove unsed palettes
paletteCounter = paletteCounter.Where(kv => kv.Value > 0).ToDictionary(kv => kv.Key, kv => kv.Value);


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

C#
// Get 4 most popular palettes
top4 = sortedKeys.Take(4).ToList();
// Use free colors in palettes to store less popular palettes
foreach (var t in top4)
{
    if (t.Count < 3)
    {
        foreach (var p in sortedKeys)
        {
            var newColors = p.Where(c => !t.Contains(c));
            if (p != t && (paletteCounter[t] > 0) && (paletteCounter[p] > 0)
                && (newColors.Count() + t.Count <= 3))
            {
                var count1 = paletteCounter[t];
                var count2 = paletteCounter[p];
                paletteCounter[t] = 0;
                paletteCounter[p] = 0;
                foreach (var c in newColors) t.Add(c);
                paletteCounter[t] = count1 + count2;
            }
        }
    }
}

Следующим шагом обрабатываем изображение так, чтобы каждый блок в 16 на 16 пикселей соответствовал какой-либо палитре. Напомню, на предыдущих этапах мы могли потерять часть доступных цветов. Тут опять идёт в дело алгоритм для подсчета цветовой разницы. С его помощью проходимся по каждой из выбранных палитр, находим в них максимально похожий цвет для каждого пикселя, но при этом суммируем цветовую разницу. На основе полученной суммы выбираем максимально подходящую палитру. Запоминаем её индекс, он пригодится нам для attribute table, а затем уже заменяем каждый цвет в блоке на максимально похожий из выбранной палитры.

C#
// Calculate palette as color indices and save them to files
var bgColorId = FindSimilarColor(nesColors, bgColor.Value);
for (int p = 0; p < palettes.Length; p++)
{
    if (paletteEnabled[p] && outPalette.ContainsKey(p))
    {
        var paletteRaw = new byte[4];
        paletteRaw[0] = bgColorId;
        for (int c = 1; c <= 3; c++)
        {
            if (palettes[p] == null)
                paletteRaw[c] = 0;
            else if (palettes[p][c].HasValue)
                paletteRaw[c] = FindSimilarColor(nesColors, palettes[p][c].Value);
        }
        File.WriteAllBytes(outPalette[p], paletteRaw);
        Console.WriteLine($"Palette #{p} saved to {outPalette[p]}");
    }
}

Не буду вставлять сюда прям весь код, он очень объёмный. Код я уже выложил её на GitHub: https://github.com/ClusterM/NesTiler. Программа пока что весьма сырая и плохо документирована, как обычно.

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

Я отправил Андрею эту программу в комплекте с .bat файлом, чтобы он передал её дизайнерам, и они могли сами с легкостью просмотреть на то, что у них получается.

Андронидзе же мне в ответ прислал полный набор абсолютно не адаптированных под железо картинок и сценарии… Стоп. Сценарии?

Код маппера

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

Помимо этого Андрей спросил, можно ли добавить в нашу программу музыку. Сам я музыку писать не умею и ответил, что смогу добавить её, если мне пришлют готовую мелодию в формате NSF. А написать её можно, например, в программе FamiTracker. Это специальный трекерный редактор музыки для Famicom, и он умеет экспортировать написанную музыку в NSF. Андрей сказал, что поищет музыканта. Я же в это время приступил к написанию кода. Начал я с кода для ПЛИС в картридже.

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

Нам же нужно обойти ограничение в 256 тайлов на экран. Pattern table с тайлами хранится в картридже, а изображение отрисовывается построчно. Можно сделать так, чтобы в верхней части экрана использовался один набор тайлов из адресов от $0000 до $1FFF, а при отрисовке нижней части экрана переключаться в картридже на другой банк, чтобы использовались уже другие 256 тайлов. Видеочип будет брать данные по тем же адресам от $0000 до $1FFF, но там уже будут другие рисунки. Точнее нам нужно разбить экран аж на четыре части, ведь 256 тайлов — это только 64 строки. То есть на отрисовку одного экрана с полноценной картинкой нам нужно переключать за кадр четыре банка по четыре килобайта.

16 килобайт на картинку. В своё время столько могла весить целая игра!

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

Так почему бы не разработать свой собственный маппер, который будет автоматически на заданной строке переключать банки памяти с тайлами? Прошивку для ПЛИС я как обычно пишу на языке Verilog.

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

Verilog
reg chr_auto_switch = 0;
reg [4:0] chr_bank;
reg [1:0] chr_latch;

Когда на выводе A12, то есть на двенадцатом бите адресной шины видеочипа, низкий уровень, то есть видеочип обращается к первым четырем килобайтам памяти, где у нас хранится левый pattern table, мы управляем старшими адресными линиями микросхемы памяти на основе регистров.

Verilog
assign ppu_addr_out[16:10] = !ppu_addr_in[12] 
	? (
      chr_auto_switch
		? {chr_bank[4:2], chr_latch[1:0], ppu_addr_in[11:10]} // $0000-$0FFF is autoswitchable
		: {chr_bank[4:0], ppu_addr_in[11:10]} // $0000-$0FFF is switchable manually
	)
	: {5'b11111, ppu_addr_in[11:10]}; // $1000-$1FFF is fixed to the last

Если включено автопереключение, то значение старших адресных линий берется из регистра с номером банка, а младших — из регистра с номером блока. Если автопереключение выключено, то используем только регистр с номером банка. Когда же видеочип обращается к правому pattern table, будем на всех линиях выдавать единички, то есть фиксировано последний банк.

Значениями регистров нам нужно ещё как-то управлять. Для этого будем ловить обращения процессора к памяти и реагировать на операцию записи по адресам от $6000 до $7FFF. Это свободные адреса, так что никаких конфликтов не будет.

Verilog
always @ (negedge m2)
begin
  if (romsel && !cpu_rw && cpu_a13 && cpu_a14) // write to $6000-$7FFF
  begin
      chr_auto_switch = cpu_data[7];
      chr_bank[4:0] = cpu_data[4:0];
  end 
end

Значения с шины данных в этот момент, то есть записанные по этому адресу данные, копируем в регистры автопереключения и номера банка. Значение же регистра с текущим номером блока будем менять в счетчике строк. Принцип его работы я скопировал из моего же кода маппера MMC5. Он основан на том, что в конце отрисовки каждой строки видеочип делает два пустых обращения к nametable. Зачем он это делает? Это загадка, никто не знает. Но это можно использовать для подсчёта отрисованных срок.

Verilog
// scanline counter, counts dummy PPU reads, detects v-blank automatically
reg [3:0] ppu_rd_hi_time = 0;       // counts how long there is no reads from PPU to detect v-blank
reg [1:0] ppu_nt_read_count;        // nametable read counter
reg [7:0] scanline = 0;             // current scanline
reg new_screen = 0;                 // stores 1 when v-blank detected ("in frame" flag)
reg new_screen_clear = 0;           // flag to clear new_screen flag

// V-blank detector  
always @ (negedge m2, negedge ppu_rd)
begin
   if (~ppu_rd)
   begin
      ppu_rd_hi_time = 0;
      if (new_screen_clear) new_screen = 0;
   end else if (ppu_rd_hi_time < 4'b1111)
   begin
      // Counting how long there is no PPU reads
      ppu_rd_hi_time = ppu_rd_hi_time + 1'b1;
   end else begin
      // Too long, v-blank detected
      new_screen = 1;
   end
end   

// Scanline counter
always @ (negedge ppu_rd)
begin 
  if (!new_screen && new_screen_clear) new_screen_clear = 0;
  if (new_screen & !new_screen_clear)
  begin
    scanline = 0;        
    new_screen_clear = 1;
    chr_latch <= 0;
  end else if (ppu_addr_in[13:12] == 2'b10)
  begin
    if (ppu_nt_read_count < 3)
    begin
      ppu_nt_read_count = ppu_nt_read_count + 1'b1;
    end else begin
      if (scanline == 64) chr_latch <= 1;
      if (scanline == 128) chr_latch <= 2;
      if (scanline == 192) chr_latch <= 3;
      scanline = scanline + 1'b1;
    end
  end else begin
    ppu_nt_read_count = 0;
  end
end

На 64й, 128й и 192й строках меняем значение регистра текущего блока. Если обращений к видеопамяти нет в течении долгого времени, обнуляем счетчик строк и сбрасываем номер блока на ноль.

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

Verilog
reg [2:0] prg_bank;
assign prg_addr = cpu_a14 
 ? {3'b111, cpu_a13} // fixed last bank @ $C000-$FFFF
 : {prg_bank, cpu_a13}; // selectable @ $8000-$BFFF

Чтобы различать запись в регистр видеопамяти и в регистр основной памяти, будем смотреть на нулевую адресную линию. Получается, один регистр будет записываться по четным адресам, а другой по нечетным.

Verilog
always @ (negedge m2)
begin
  if (romsel && !cpu_rw && cpu_a13 && cpu_a14) // write to $6000-$7FFF
  begin
    if (!cpu_a0)
    begin // even
      prg_bank[2:0] = cpu_data[2:0];
    end else begin // odd
      chr_auto_switch = cpu_data[7];
      chr_bank[4:0] = cpu_data[4:0];
    end
  end 
end

Эмулятор

По мере написания самой программы для NES её надо как-то отлаживать. Я не настолько крутой, чтобы долго писать код на ассемблере, потом долго записывать картридж, и только после этого видеть, что оно не работает. Ну и потом вслепую искать свою ошибку. Конечно же тестировать и отлаживать свой код можно на эмуляторе. Я предпочитаю использовать эмулятор fceux, в нем хороший отладчик. Вот только мы сделали картридж с кастомным железом, и эмулятор о нём не знает.

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

C++
static uint8 prg_bank = 0;
static uint8 chr_bank = 0;

static void WARFACE_Sync(void) {
  setprg16(0x8000, prg_bank);
  setprg16(0xC000, ~0);

  if (chr_bank & 0x80)
  {
    if (scanline >= 64)
      setchr4(0x0000, (chr_bank & 0x1C) | 0);
    else if (scanline >= 128)
      setchr4(0x0000, (chr_bank & 0x1C) | 1);
    else if (scanline >= 192)
      setchr4(0x0000, (chr_bank & 0x1C) | 2);
    else
      setchr4(0x0000, (chr_bank & 0x1C) | 3);
  }
  else {
    setchr4(0x0000, chr_bank & 0x1F);
  }
  setchr4(0x1000, ~0);
}

static DECLFW(WARFACE_WRITE) {
  if ((A & 1) == 0)
    prg_bank = V;
  else
    chr_bank = V;
  WARFACE_Sync();
}

Написание кода для NES

Теперь можно наконец-то начать писать код основной программы. Под Фамиком я как обычно программирую на ассемблере.

Помимо стандартного кода инициализации первым делом я напишу подпрограммы для переключения банков основной памяти и памяти видеочипа. Для последнего нужны два режима — обычный и с автопереключением в картридже. Там мы храним pattern table.

ASM
  ; субрутина выбора PRG банка
select_prg_bank:
  sta <ACTIVE_BANK
  sta $6000
  rts

  ; субрутина для выбора CHR банка с автопереключением
select_chr_auto_bank:
  ora #%10000000
  sta $6001
  rts

  ; субрутина для выбора CHR банка без автопереключения
select_chr_solid_bank:
  sta $6001
  rts


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

ASM
  ; субрутина простого ожидания vblank
wait_blank_simple:
  jsr read_controller
  bit $2002
.loop:
  lda $2002
  bpl .loop
  rts

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

ASM
  ; загружаем nametable в $2000
  ; в A номер банка
  ; в COPY_SOURCE_ADDR - адрес данных
load_name_table:
  tax
  lda ACTIVE_BANK
  pha
  txa
  jsr select_prg_bank
  lda #$20
  sta $2006
  lda #$00
  sta $2006
  ldy #$00
  ldx #$04
.loop:
  lda [COPY_SOURCE_ADDR], y
  sta $2007
  iny
  bne .loop
  inc <COPY_SOURCE_ADDR+1
  dex
  bne .loop
  pla
  jsr select_prg_bank
  rts

Компилируем, запускаем в эмуляторе и… работает!

Но что-то немного не так. Банки переключаются не на тех строках. И это очевидно косяк эмуляции, а не самого ROM’а. Конечно же, счетчик строк обновляется в конце строки, а не в начале. Надо это учитывать.

C++
static void WARFACE_Sync(void) {
  setprg16(0x8000, prg_bank);
  setprg16(0xC000, ~0);

  if (chr_bank & 0x80)
  {
    if (scanline < 63 || scanline > 240)
      setchr4(0x0000, (chr_bank & 0x1C) | 0);
    else if (scanline < 127)
      setchr4(0x0000, (chr_bank & 0x1C) | 1);
    else if (scanline < 191)
      setchr4(0x0000, (chr_bank & 0x1C) | 2);
    else
      setchr4(0x0000, (chr_bank & 0x1C) | 3);
  }
  else {
    setchr4(0x0000, chr_bank & 0x1F);
  }
  setchr4(0x1000, ~0);
}

Вот теперь всё верно. Мне не нравится только, что картинка появляется сразу. Во всех хороших играх яркость картинки нарастает постепенно. Ну, на сколько это позволяют доступные на NES цвета. Попробую сделать так же.

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

ASM
  ; затемняет загруженную палитру
dim:
  ldx #0
.loop:
  lda PALETTE_CACHE, x
  sec
  sbc #$10
  bpl .not_minus
  lda #$1D  
.not_minus:
  cmp #$0D
  bne .not_very_black
  lda #$1D
.not_very_black:
  sta PALETTE_CACHE, x
  inx
  cpx #16
  bne .loop
  rts

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

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

ASM
  ; плавно затухает экран
dim_out:
  jsr preload_palette
  jsr dim
  jsr load_palette
  ldx #5
  jsr wait_blank_x
  jsr dim
  jsr load_palette
  ldx #5
  jsr wait_blank_x
  jsr dim
  jsr load_palette
  ldx #5
  jsr wait_blank_x
  jsr load_black
  jsr wait_blank
  rts

А вот когда надо наоборот постепенно проявить картинку, всё несколько сложнее. Сначала показываем чёрный цвет. Затем загружаем палитру во временную память. После этого три раза затемняем её и загружаем результат в видеопамять. Делаем паузу в несколько кадров и повторяем весь процесс, затемняя уже два раза. Потом так же, затемняя один раз и в конце концов уже без затемнения.

ASM
  ; плавно проявляет экран
dim_in:
  jsr preload_palette
  jsr dim
  jsr dim
  jsr load_palette
  ldx #5
  jsr wait_blank_x
  jsr preload_palette
  jsr dim
  jsr load_palette
  ldx #5
  jsr wait_blank_x
  jsr preload_palette
  jsr load_palette
  jsr wait_blank
  rts

Напомню, между отображением картинок надо сделать, чтобы выводился текст. Точнее Андрей хотел, чтобы текст шел поверх картинок, но NES такое не осилит. И картинка, и текст являются фоном, а на этой консоли доступен только один фон. Можно было бы вывести текст спрайтами, но спрайтов в каждой строке может быть только восемь. Кстати, в титрах американской версии Super Mario Bros. 2 так и сделали, поэтому имена всех монстров не длиннее восьми букв, хех.

Я решил сделать по классике, текст на чёрном фоне. Вот только в моем шрифте номера тайлов с буквами не совпадают с номерами букв в стандартных кодировках, поэтому надо написать простенькую программу-конвертер из текста в специальный бинарный файл, который уже можно будет подключить в ассемблере.

Выводим постепенно текст по одному символу, ничего особенного. Но надо ещё учитывать, не встретили ли мы символ перехода на следующую строку. Если встретили, обнуляем положение воображаемого курсора и увеличиваем счетчик строк.

Ассемблерный код слишком длинный, чтобы вставлять его в статью. Давайте я просто оставлю ниже ссылку на GitHub.

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

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

Музыка

Вскоре со мной связался музыкант, которого Андронидзе подключил к этому проекту. И для меня стало приятным сюрпризом, что это аж сам Manwe из демогруппы SandS. Он же Александр Мачуговский. Это достаточно известный демосценер, музыкант и просто классный мужик.

Я объяснил ему, что мне надо получить музыку в формате NSF и рассказал, какие инструменты можно для этого использовать. Велосипед изобретать не надо, уже существует несколько хороших трекерных редакторов, чтобы писать музыку под Famicom. Трекерные редакторы — это как раз то, с чем работают демосценеры, когда им нужно уложить полноценную музыкальную композицию в очень маленький объём данных. А нам будет доступно для музыки только 32 килобайта памяти, так как подружить музыку с переключением банков будет весьма проблемно. Семплы же вообще нужно уложить в 16 килобайт, т.к. процессор Фамикома может читать их только из старших 16 килобайт памяти. Это аппаратное ограничение.

В итоге Manwe прислал мне музыку в виде NSF файла. И тут надо рассказать, что такое NSF файл. Понятное дело, что это такой контейнер с музыкой из игры для NES. Но что он из себя представляет? На самом деле в этом файле содержится непосредственно программный код для консоли. И проигрыватели NSF файлов по сути являются эмуляторами. Помимо кода там есть ещё заголовок размером в 128 байт.

В нём нас в первую очередь интересуют три адреса: $008, $00A и $00C Это адрес, по которому мы должны разместить код из файла, адрес функции, которую нужно вызвать для инициализации, и адрес функции которую мы должны вызывать каждый кадр для воспроизведения музыки.

В Makefile я написал вот такую вот длинную строчку, чтобы путём использования утилит dd и hexdump вытащить из бинарного NSF файла эти адреса и сохранить их в виде ассемблерного файла с константами.

Makefile
$(MUSIC_ASM): $(MUSIC)
  printf \
  "NSF_LOAD_ADDR .equ `hexdump $(MUSIC) --skip 8 --length 2 \
  --format '"$$%X"'`\nNSF_INIT_ADDR .equ `hexdump $(MUSIC) --skip 10 --length 2 \
  --format '"$$%X"'`\nNSF_PLAY_ADDR .equ `hexdump $(MUSIC) --skip 12 --length 2 \
  --format '"$$%X"'`" > music.asm

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

ASM
  .org NSF_LOAD_ADDR
music:
  .incbin music_bin ; Код музыки
ASM
  ; инициализация музыки
init_music:
  lda ACTIVE_BANK
  pha
  lda #BANK(music)/2
  jsr select_prg_bank
  ; номер трека
  lda #0
  ; в регистре X задаётся регион: PAL или NTSC
  ldx #0
  ; инициализируем музыкальный проигрыватель
  jsr NSF_INIT_ADDR
  pla
  jsr select_prg_bank
  rts

У нас же уже есть код, который вызывается для ожидания окончания отрисовки экрана и который вызывается каждый кадр. Добавим работу с музыкой непосредственно перед ожиданием конца кадра, чтобы функция музыки вызывалась, пока видеочип занят выводом изображения, и процессор ничем не занят.

ASM
  ; вызываем код музыки из предпоследнего банка
  ; заодно это делает ack прерыванию
  lda #BANK(music)/2
  sta $6000
  ; играем музыку
  jsr NSF_PLAY_ADDR

И вуаля! Музыка играет, но… Явно фальшивит. Очевидно, это из-за того, что функция воспроизведения вызывается не с равным интервалом. В какие-то моменты процессор освобождается раньше, в какие-то позже. Ну хорошо, тогда будем вызывать музыкальную функцию точно в момент, когда мы дождались окончания отрисовки изображения. Можно даже делать это в специальном прерывании, которое срабатывает именно в этот момент. И музыка стала воспроизводиться нормально, но появились визуальные артефакты.

Очевидно, это происходит по той причине, что из-за слишком долгой работы с музыкальным кодом процедура загрузки данных в nametable стала выполняться слишком поздно, не успевая завершиться до начала отрисовки нового кадра. Что же делать… Как заставить этот кусок кода выполняться с точным интервалом во время вывода изображения, когда не надо выполнять другой код? В Фамикоме нет никаких таймеров или иных способов отсчитывать время. Я не знаю точно, как решали эту проблему разработчики игр. По идее можно использовать так называемый sprite-0 hit. Но раз уж я могу программно модифицировать маппер в картридже, почему бы не добавить таймер в сам картридж?

Я сделал так, чтобы старший бит в регистре программных банков запускал таймер, счетчик которого будет уменьшаться при каждом такте процессора, а при достижении нуля пусть картридж дергает линию генерации прерываний. Хватит счетчика размером в 14 бит, это 4095 тактов.

Verilog
reg [13:0] timer = 0;
reg timer_elapsed = 0;
assign irq = timer_elapsed ? 1'b0 : 1'bZ;

always @ (negedge m2)
begin
  if (timer != 0)
  begin
    timer = timer - 1'b1;
  if (timer == 0) timer_elapsed = 1;
  end
  if (romsel && !cpu_rw && cpu_a13 && cpu_a14) // write to $6000-$7FFF
  begin
    if (!cpu_a0)
    begin // even
      prg_bank[2:0] = cpu_data[2:0];
      if (cpu_data[7]) timer = 4095;
      timer_elapsed = 0;
    end else begin // odd
      chr_auto_switch = cpu_data[7];
      chr_bank[4:0] = cpu_data[4:0];
    end
  end 
end


Будем запускать его строго в момент окончания отрисовки экрана, затем заниматься всеми срочными делами, а через 4095 тактов сработает прерывание, в котором можно будет заниматься музыкой. В этот момент процессор свободен, а прерывание обеспечивает точность. Заодно туда можно перенести и код опроса контроллера.

ASM
  ; основное прерывание по таймеру картриджа
IRQ:
  php
  pha
  tya
  pha
  txa
  pha

  ; вызываем код музыки из предпоследнего банка
  ; заодно это делает ack прерыванию
  lda #BANK(music)/2
  sta $6000
  ; играем музыку
  jsr NSF_PLAY_ADDR
  ; читаем контроллер
  lda #BANK(read_controller)/2
  sta $6000
  jsr read_controller
  ; возвращаем назад активный банк
  lda <ACTIVE_BANK
  sta $6000

  pla
  tax
  pla
  tay
  pla
  plp
  rti

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

C++
static void WARFACE_CpuCounter(int a) {
  while (a--)
  {
    if (timer > 0)
    {
      timer--;
      if (!timer) X6502_IRQBegin(FCEU_IQEXT);
    }
  }
}

static void WARFACE_Power(void) {
  SetReadHandler(0x8000, 0xFFFF, CartBR);
  SetWriteHandler(0x6000, 0x7FFF, WARFACE_WRITE);
  GameHBIRQHook = WARFACE_ScanlineCounter;
  MapIRQHook = WARFACE_CpuCounter;
  WARFACE_Reset();
}

Теперь музыка работает прекрасно! Однако, после тестирования ROM’а на эмуляторе Manwe заметил, что не всё так гладко. Дело в том, что привычная нам Денди сильно отличается от тех консолей, которые были во всём цивилизованном мире. По сути NES и Famicom делятся на два региона, их обычно называют по стандартам кодирования видео сигнала — NTSC и PAL.

В Японии и Америке NTSC регион. Да, в Японии был Фамиком, в Америке NES, консоли разные, но платформа и регион одинаковые. Они выдают соответственно NTSC сигнал с частотой 60 кадров в секунду. Игры для японского Фамикома и американской NES тоже по пути вполне совместимы между собой, и отличаются только совместимостью с дополнительными аксессуарами, языком и собственно разъёмом картриджа. Иногда это вообще абсолютно одинаковые ROM’ы, и в первых картриджах для NES можно было встретить тупо пассивный переходник. С помощью аналогичных переходников можно легко переносить игры между этими консолями.

В Европе же тоже была NES, но уже PAL региона. Она отличается не только кодированием видеосигнала и частотой кадров в 50 Гц. Там уже другая частота процессора, другие делители. Даже если соорудить какие-то переходники и обойти защиту от пиратства, многие игры NTSC региона не будут корректно работать на PAL консолях и наоборот. В Европе даже есть свои эксклюзивы, которые недоступны для остального мира, а все это из-за того, что игры для PAL региона заточены под совсем другие частоты.

В основном эмуляторы заточены под эти два региона. И программа для написания музыки, которую использовал Manwe, тоже. Но что же с нашей Денди? Вопреки распространенным заблуждениям Денди не является консолью PAL-региона. Да, у неё PAL сигнал, но на самом деле это чертов гибрид! Всё сделано так, чтобы Денди кодировала видео в PAL, но при этом была совместима с японскими картриджами NTSC региона! У неё свои собственные частоты, и хоть она и выводит сигнал с частотой в 50 Гц, с европейскими эксклюзивами она несовместима. Она заточена под игры NTSC региона, хоть они на ней и тормозят, и выглядят сплющенными. Можно сказать, что пираты создали свой собственный регион.

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

Есть даже мнение, что пираты сделали всё как раз по уму в отличии от разработчиков официальных консолей, ведь они не сломали совместимость с японскими играми, хотя их всё равно пришлось бы адаптировать под другую скорость. Но вернёмся к нашей музыке. Что же делать, если на Денди DPCM канал звучит неправильно? Было решено тупо сделать в NSF файле второй трек, уже без DPCM канала. Чтобы определять при запуске тип консоли, и если это Денди, то запускать урезанный вариант мелодии. Определить тип консоли достаточно легко, подсчитав примерное количество тактов процессора за один кадр. И у PAL, и у NTSC, и у Денди это разные значения.

ASM
  ; определяем тип консоли
  ; отключаем NMI
  lda #%00000000 
  sta PPUCTRL
  bit PPUSTATUS
console_detect_init:
  bit PPUSTATUS
  bpl console_detect_init
console_detect_loop:
  inx
  bne console_detect_s
  iny
console_detect_s:  
  bit PPUSTATUS
  bpl console_detect_loop
  lda #$01
  cpy #$09
  bne console_detect_end
  lda #$00
console_detect_end:
  sta <CONSOLE_TYPE
  ; включаем NMI
  lda #%10000000
  sta PPUCTRL


Да, на Денди музыка теперь звучит не так насыщенно. Но, увы, лучше уж так, чем с искаженной мелодией.

Когда с музыкой разобрались, Manwe посмотрел на то, что у нас получается с картинками и решил ещё заняться их обработкой, чтобы они не просто вписывались в ограничения консоли, но ещё и оставались при этом красивыми.

Как он это делал — я не представляю. В видео он сам рассказывает об этом процессе.

Осталось добавить самые мелочи. Андрей просил, чтобы при наборе на геймпаде культового Konami-кода отображались титры. Ничего особо сложного. Наспех набросал картинку в Paint’е и добавил в функцию чтения контроллера проверку на Konami-код.

ASM
konami_code_check:
  ldy <KONAMI_CODE_STATE
  lda <BUTTONS
  beq .end
  cmp <LAST_KONAMI_BUTTON
  beq .end
  cmp konami_code, y
  bne .check_fail
  iny
  cpy #konami_code_length
  bne .end
  ldy #1
  sty KONAMI_CODE_TRIGGERED
  jmp .end
.check_fail:
  ldy #0
  lda konami_code ; на случай, если нажатая кнопка - начало новой последовательности
  cmp <BUTTONS
  bne .end
  iny
.end:
  sty <KONAMI_CODE_STATE
  sta <LAST_KONAMI_BUTTON
  rts

konami_code:
  .db $10, $10, $20, $20, $40, $80, $40, $80, $02, $01
konami_code_length .equ 10


Запуск на реальном железе

Пока мы всем этим занимались, приехали платы и компоненты. Внешне платы получились очень красивыми. Глянцевая черная маска, симметрия и логотип Warface. Красота. Фото есть в самом начале статьи.

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

На самом деле энтузиастами давно уже составлен так называемый Errata список для NES. Это список, который описывает, в каких случаях железо может вести себя некорректно или неочевидно: https://www.nesdev.org/wiki/Errata. И там я нашёл свою ошибку: https://www.nesdev.org/wiki/NMI#Race_condition. Если ждать завершения отрисовки экрана одновременно и через опрос регистра, и по прерыванию, то возникает состояние гонки, и прерывание срабатывает не всегда. Поэтому музыка и играет с плавающей скоростью, ведь она завязана на это прерывание. И да, для запуска игр эмулировать эту особенность совсем не нужно, вот эмулятор fceux и не учитывает её.

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

ASM
  ; прерывание по vblank
NMI:
  php
  inc <FRAMES
  plp
  rti

  ; субрутина простого ожидания vblank
wait_blank_simple:
  pha
  lda <FRAMES
.loop:
  cmp <FRAMES
  beq .loop
  pla
  rts


Музыка стала звучать прекрасно. Но на телевизоре все картинки почему-то стали зелёными. Вот причину этой проблемы я уже долго не мог понять. И ведь на эмуляторе опять же все в порядке. Я пробовал одновременно открыть рядом эмулятор и захват с реальной консоли. Показывал это Manwe, и мы вместе долго пытались понять причину появления вездесущего зелёного цвета.

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

Хотя на самом деле в те времена некоторые разработчики умудрялись даже использовать эти артефакты, чтобы сделать игры красивее. Самый известный пример — это полупрозрачный водопад в Сонике на Sega Mega Drive.

И это часто используют как аргумент в холиварах о том, что же лучше — RGB подключение или классический композит. Ведь RGB делает изображение гораздо чётче, но разработчики часто делали игры без расчёта на то, что мы будем видеть их такими. Я думаю, что тут нет какого-то однозначного ответа.

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

Причём, хорошо так имитирует, даже есть лёгкое дрожание изображения. Это помогло Manwe доработать картинки так, чтобы они в итоге выглядели хорошо. Местами это даже сыграло нам на руку, например сделав траву более зелёной.

Завершали мы всё буквально в последний день. Я записал 15 картриджей, ещё несколько мы оставили себе на память. Всё это я отправил курьером Андрею, и он их получил прямо в последнюю ночь перед сдачей проекта.

Весьма непростой задачей было ещё найти подходящие консоли. Казалось бы, клоны Фамикома сейчас где только не продают, иногда даже до сих пор под брендом Денди. Но Mail.ru серьёзная компания, и если нелицензированные клоны игровых консолей ещё можно отправить, то вот наличие встроенных игр — это уже распространение пиратского контента. И консоли без встроенных игр найти было непросто, но Андрей как-то с этим справился. Правда, все они конечно же оказались так называемого «Денди»-региона с PAL видеовыходом, и игры на них тормозят, как это было у нас на Денди в девяностые.

Как бы там ни было, я очень доволен результатом. Получилась весьма интересная и необычная промоакция, в создании которой было действительно классно участвовать, и это даже принесло мне определенный опыт.

Исходный код проекта: https://github.com/ClusterM/nes-warface
Исходный код NesTiler: https://github.com/ClusterM/NesTiler/
Моя ветка эмулятора fceux: https://github.com/ClusterM/fceux
Моя ветка компилятора NES: https://github.com/ClusterM/nesasm

Добавить комментарий