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

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

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

0l419xwcgxljm3ddjlyzuisjjqq.jpeg


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

Видео


Статья


Сделать всё это нужно было в максимально сжатые сроки, у нас был всего один месяц, так что даже заказывать что-либо из Китая — вообще не вариант. Впрочем, тупо отобразить картинки — не такая уж хитрая задача, тем более рисовать их будут сами дизайнеры 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», по тому, как они обычно отображаются в отладчике эмулятора.

rpduk3vworooodpfwzyb6xox-ng.jpeg

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

uz7w1wo7slvo956drop29frpwma.png

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

qdm6jy9mceb6igrfbbmgev5xife.png

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

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

gf3j78ylxultwykie7blm4mvpfm.png

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

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

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

10tbly4vowclrkgalglzucpohno.png

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

kzqu7lip6uxp8vb95hpd40hnbde.png

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

b3hcgj7fzgie2yusti6j3rxlel4.pngsjxxjtdfrxdzpe6melip40glabk.png

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

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

w6jiwur9d6cyvya3tvndqkxilfo.png

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

wtg_js5u_mjjaj7oylrztitngqm.png

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

9sxvj0jqopoadtudgyfexf5kqni.png

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

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

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

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

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

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

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

Схема и плата

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

jmezckxq-u9ixpnn2zndhshcqe0.png

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

nbj2-umd0elfdeamst4ht2-wyjw.png

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

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


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

u0v4ienevgstl1mukinbzqkvz18.jpegldc37dcwy7n2qb6h1pi_rmf-4me.jpegu-urxxvjxh4zftclghdnz04f5tk.jpegovvxy5w2dwtlxyeese5wuj2knno.jpeg

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

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

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

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

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

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

static Color FindSimilarColor(IEnumerable 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 пикселей, составить список цветов для каждого блока и убедиться, что в каждом из них используется только три цвета помимо цвета фона. Фоновый цвет чаще всего проще указать вручную, хотя я добавил и функционал автоматического выбора на основе самого популярного цвета. Если же в блоке больше трех цветов, то, что поделать, картинка пострадает, берем три самых часто встречающихся. Так мы получаем четырехцветную палитру для каждого блока. Но у нас еще может использоваться только четыре палитры на весь экран, помните? Поэтому надо подсчитать, как часто используется каждая из палитр, и на следующем шаге выбрать только четыре.

var top4 = new List();
// 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 paletteCounter = new Dictionary();
    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]++;
            }
        }
    }

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

// 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);

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

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

// 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: github.com/ClusterM/NesTiler. Программа пока что весьма сырая и плохо документирована, как обычно.

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

r7gkeoyg7lii8svr08dlclru8og.jpegewnhfc_li4iyqpzvtp-ejzr2d-0.png

11gdap_j6xfrizyc9_zd7yrvtka.jpeg7ybwddtb0tkcldye-dsqnjiz6za.png

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

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

Код маппера


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

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

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

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

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

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

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

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

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

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

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. Это свободные адреса, так что никаких конфликтов не будет.

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. Зачем он это делает? Это загадка, никто не знает. Но это можно использовать для подсчёта отрисованных срок.

// 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й строках меняем значение регистра текущего блока. Если обращений к видеопамяти нет в течении долгого времени, обнуляем счетчик строк и сбрасываем номер блока на ноль.

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

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

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

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. Не забываем определить и функцию, которая будет вызываться при отрисовке следующей строки. 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.

  ; субрутина выбора PRG банка
select_prg_bank:
  sta 

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

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

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

  ; загружаем 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 

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

ratru-i1q4hxan8tlqpkptapb3u.png

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

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, то есть шестнадцать, при этом если значение получается меньше нуля, то заменяем его на код черного цвета. Также, стоит не забывать про запретные цвета. Как я уже говорил, можно получить значение чернее черного, от которого телевизоры будут сбоить. Так что на это тоже стоит сделать проверку.

  ; затемняет загруженную палитру
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

Выполнять операцию затемнения будем в цикле, для каж

© Habrahabr.ru