Приветствую всех любителей программирования всяческих ретро-железок. Могу предположить, что у большинства из читателей этой статьи была в детстве Dendy (а может и сейчас есть) или другой клон Famicom (клонов NES в СНГ я не встречал). Сегодня предлагаю обсудить особенности разработки игр для приставок Dendy, NES и Famicom с маппером (mapper) UNROM. Те из вас, кто хоть немного углублялся в особенности архитектуры игр для 8-битных приставок, наверняка слышал про мапперы. Это электронная схема, которая находится на плате картриджа и расширяет возможности консоли, подключаясь напрямую к шинам процессора.
Мапперов для Dendy существует сотни, так как очень часто компании, разрабатывающие игры, делали уникальные мапперы под свои нужды. Поэтому сегодня они есть на любой вкус и цвет. Самые простые мапперы позволяют переключать банки памяти (это было обычным делом для всех компьютеров 1980-х), а самые продвинутые (например, MMC5) уже позволяли использовать дополнительные аппаратные прерывания, улучшенный звук, прокрутку по двум осям и т. д.
Архитектура Dendy
Сначала пара слов об архитектуре консоли. Подробно её здесь описывать нет смысла, так как она представлена во многих источниках [1]. Тут я приведу только основные моменты, которые важны для описания взаимодействия с маппером.
Во-первых, память консоли разделена на два отдельных адресных пространства: память программ (PRG) и видеопамять (CHR). Память программ занимает адреса от 0x0000 до 0xFFFF. Вот таблица с общей структурой памяти:
Диапазон адресов |
Размер в байтах |
Описание участка памяти |
$0000–$07FF |
$0800 |
2 килобайта встроенной ОЗУ. |
$0800–$0FFF |
$0800 |
Зеркала участка $0000–$07FF. |
$1000–$17FF |
$0800 |
|
$1800–$1FFF |
$0800 |
|
$2000–$2007 |
$0008 |
Регистры PPU (Picture processing unit). |
$4000–$4017 |
$0018 |
Регистры APU (Audio processing unit) и I/O-регистры. |
$4018–$401F |
$0008 |
Дополнительные APU- и I/O-регистры. |
$4020–$FFFF |
$BFE0 |
Адресное пространство доступное для использования картриджем. |
$6000–$7FFF |
$2000 |
Обычно используется для установки дополнительных 8 килобайтов ОЗУ (части с питанием от батарейки для сохранений). |
$8000–$FFFF |
$8000 |
Используется для хранения памяти программ, часто делится на отдельные банки для удобства. Обычно используется ПЗУ-микросхема. |
$FFFA-$FFFF |
$0006 |
Векторы прерываний (хранят адреса функций обработчиков прерываний). |
Из этой таблицы нас интересует участок памяти $8000–$FFFF. Он хранит основную программу и все дополнительные ресурсы (музыку, звуки, дополнительную графику, тексты и т. д.). Почти всегда эти данные хранятся на ROM-микросхеме, то есть доступны только для чтения (но запись туда нам тоже пригодится).
Кроме основного адресного пространства используется отдельное адресное пространство PPU. В нём нас интересует только участок $0000–$1FFF (8 килобайтов). В нём хранится информация о текущем наборе тайлов (тайл — минимальный графический блок 8х8 пикселей), который используется для вывода всей графики в игре (фона и спрайтов).
Диапазон $0000–$1FFF хранится на микросхеме, расположенной в картридже (эту памяти называют CHR ROM или CHR RAM). Использование CHR ROM означает, что для хранения графики на плате картриджа используется микросхема ПЗУ, а CHR RAM подразумевает использование микросхемы ОЗУ.
Теперь давайте поговорим о структуре хранения тайлов в видеопамяти.
Структура хранения тайлов в памяти PPU
Как я уже говорил выше, все графика состоит из тайлов 8х8 пикселей. При этом каждый пиксель описывается 2 битами, то есть в рамках одного тайла нам доступно всего лишь 3 цвета (нулевой цвет означает прозрачность). Так как на каждый пиксель у нас уходит 2 бита, для хранения одного тайла требуется 16 байтов.
Способ хранения информации о цветах каждого пикселя довольно оригинальный. Изображение разбивается на два слоя по 8 байтов. В первом слое хранится информация о младших битах цветов, то есть на каждую строку тайла уходит ровно один байт (где младший бит — это крайний правый пиксель). Во втором слое всё то же самое, только для старших битов. Ниже привожу хорошую иллюстрацию кодирования тайла, который изображает символ «1/2»:
Левый столбец чисел — это первые 8 байтов тайла, которые описывают первый слой (младшие биты цвета), а правый столбец — это следующие 8 байтов (старшие биты цвета).
Байты, описывающие слои, хранятся в памяти последовательно, что очень удобно для редактирования CHR RAM, но об этом поговорим немного позже.
Особенности маппера UxROM
Вот мы и подошли к теме статьи, а именно — к особенностям маппера UxROM.
UxROM — это обобщающее название для нескольких мапперов, которые используют сходную схемотехнику и механизмы переключения банков памяти (UNROM, UOROM и ещё несколько их вариаций). UNROM имеет 64 или 128 Кб PRG-ROM (вся память разделена на банки по 16 Кб) и 8 Кб CHR-RAM.
В случае, если используется микросхема ПЗУ на 64 Кб, мы имеем всего четыре банка по 16 Кб. Первые три из них является переключаемыми (текущий выбранный банк располагается в диапазоне адресов $8000-$BFFF), а последний является фиксированным ($С000-$BFFF). Фиксированный банк используется для хранения основного кода программы (реализация обработчиков прерываний, функции загрузки данных в CHR-RAM, функции переключения банков памяти и т. д.). Переключаемые банки используются для хранения всех прочих данных.
Переключение банков памяти с маппером UNROM
Для переключения банков памяти требуется записать байт с номером требуемого банка в любой адрес из диапазона $8000-$FFFF. Переключение происходит за счёт того, что младшие 4 бита шины данных подключены к входам маппера, который реализован всего на двух микросхемах: 74161 (4-битный счётчик с параллельным входом и защёлкой) и 7432 (четыре логических элемента «ИЛИ»).
74161 служит для запоминания текущего номера банка, а элементы «ИЛИ» формируют старшие разряды адреса для PRG ROM. Описанная схема маппера приведена ниже.
Но стоит учитывать, что по адресу, в который происходит запись номера банка памяти, должен храниться байт, равный по значению номеру банка, который в него записывается. Проще всего решить эту задачу, создав массив с номерами банков памяти в фиксированном банке памяти.
Для наглядности давайте напишем функцию переключения банков памяти на языке Си (все примеры кода будут актуальны для компилятора cc65 [4]). Создаём массив с номерами банков памяти:
// Указываем сегмент памяти
// RW_PRG должен располагаться в фиксированном банке и
// иметь режим «Чтение и запись»
#pragma data-name (push, "RW_PRG")
// Массив в PRG ROM для переключения банков маппера
// Для переключения банка нужно записать в элемент массива число,
// которое хранится в этой ячейке памяти
unsigned char bank_table [] = {
0x00, 0x01, 0x02
};
// Возвращаемся в исходный сегмент кода
#pragma data-name (pop)
Функция переключения банков:
// Через current_bank задаём номер требуемого банка памяти
void switch_bank_prg_rom (void) {
bank_table [current_bank] = current_bank;
}
Организация структуры памяти
Механизм переключения банков памяти довольно простой и не должен вызывать вопросов. Но вот правильная организация структуры памяти PRG ROM не так очевидна. Давайте рассмотрим пример файла конфигурации из реальной игры на маппере UNROM. Представленный ниже конфигурационный файл требуется при сборке проекта, при его неправильном оформлении игра или не будет работать или вообще не скомпилируется.
# Разметка памяти
MEMORY {
#RAM Addresses:
# Zero page (Нулевая страница), часть адресов используется консолью, все 255 байт использовать не получится
ZP: start = $00, size = $100, type = rw, define = yes;
# Здесь хранится копия таблицы ОАМ (таблица информации о всех спрайтах - 64 штуки)
# 4 байта на один спрайт
OAM1: start = $0200, size = $0100, define = yes;
# ОЗУ для общего пользования - 1024 байта
RAM: start = $0300, size = $0400, define = yes;
#INES Header:
# Эта часть памяти используется для заголовка INES, который нужен для работы эмулятора
HEADER: start = $0, size = $10, file = %O ,fill = yes;
#ROM Addresses:
# Количество банков должно совпадать с количеством банков указанном в iNES заголовок
# Переключаемые банки по 16 килобайт ($4000)
PRG0: start = $8000, size = $4000, file = %O ,fill = yes, define = yes;
PRG1: start = $8000, size = $4000, file = %O ,fill = yes, define = yes;
PRG2: start = $8000, size = $4000, file = %O ,fill = yes, define = yes;
# Используется половина PRG ROM
PRGF: start = $c000, size = $3ffa, file = %O ,fill = yes, define = yes;
# Hardware Vectors at end of the ROM (тут хранятся адреса обработчиков прерываний, всего 3 прерывания)
VECTORS: start = $fffa, size = $6, file = %O, fill = yes;
}
# Тут объявляются сегменты кода и прикрепляются к реальным участкам памяти которые описаны в блоке MEMORY
# Так же указываются режимы работы этих сегментов, смотрите документацию компилятора сс65
SEGMENTS {
HEADER: load = HEADER, type = ro;
STARTUP: load = PRGF, type = ro, define = yes;
LOWCODE: load = PRGF, type = ro, optional = yes;
INIT: load = PRGF, type = ro, define = yes, optional = yes;
BANK0: load = PRG0, type = ro, define = yes;
BANK1: load = PRG1, type = ro, define = yes;
BANK2: load = PRG2, type = ro, define = yes;
CODE: load = PRGF, type = ro, define = yes;
RODATA: load = PRGF, type = ro, define = yes;
RW_PRG: load = PRGF, type = rw, define = yes;
#run = RAM, это означает, что данные или код, которые загружаются в PRG ROM (load = PRGF),
#будут копироваться и выполняться из RAM при запуске программы.
DATA: load = PRGF, run = RAM, type = rw, define = yes;
VECTORS: load = VECTORS, type = rw;
BSS: load = RAM, type = bss, define = yes;
HEAP: load = RAM, type = bss, optional = yes;
ZEROPAGE: load = ZP, type = zp;
OAM: load = OAM1, type = bss, define = yes;
ONCE: load = PRGF, type = ro, define = yes;
}
Блок MEMORY
описывает физическое расположение различных участков памяти, а блок SEGMENTS
описывает сегменты кода, которые указываются при программировании. Нас интересуют следующие строки из блока MEMORY
:
# Переключаемые банки по 16 килобайт ($4000)
PRG0: start = $8000, size = $4000, file = %O ,fill = yes, define = yes;
PRG1: start = $8000, size = $4000, file = %O ,fill = yes, define = yes;
PRG2: start = $8000, size = $4000, file = %O ,fill = yes, define = yes;
# Используется половина PRG ROM
PRGF: start = $c000, size = $3ffa, file = %O ,fill = yes, define = yes;
PRG0
, PRG1
, PRG2
— это переключаемые блоки памяти. Блок, который вы указали первым в списке, будет расположен в начале ROM-файла который хранит все данные игры и необходим для её запуска в эмуляторе, поэтому будьте внимательны при создании файла конфигурации. Переключаемые банки должны начинаться с одного адреса ($8000 в нашем случае) и иметь одинаковый размер ($4000 или 16 Кб в нашем примере).
Из блока SEGMENTS
нас интересуют эти строки:
BANK0: load = PRG0, type = ro, define = yes;
BANK1: load = PRG1, type = ro, define = yes;
BANK2: load = PRG2, type = ro, define = yes;
CODE: load = PRGF, type = ro, define = yes;
RW_PRG: load = PRGF, type = rw, define = yes;
BANK0
, BANK1
, BANK2
— имена сегментов переключаемых банков, которые мы будем указывать при разработке.
CODE
— это сегмент, который указывает сборщику размещать данные в фиксированный банк памяти.
RW_PRG
нужен для корректного размещения массива с индексами банков памяти (выше я приводил пример использования этого сегмента при создании массива).
В файле конфигурации есть ещё очень много возможностей и параметров, но подробное описание требует отдельной статьи. Тут есть подробная информация по структуре файла конфигурации [5].
Загрузка данных в CHR RAM
Возможность загрузки и редактирования данных в CHR RAM является самой интересной и полезной особенностью маппера UNROM. При использовании в картридже CHR RAM перед запуском самой игры требуется инициализировать видеопамять необходимым набором тайлов, так как после включения консоли видеопамять заполнена мусором. Для этого давайте напишем функцию загрузки данных в CHR RAM:
// Адреса регистров
#define PPU_CTRL *((volatile unsigned char*)0x2000)
#define PPU_MASK *((volatile unsigned char*)0x2001)
#define PPU_STATUS *((volatile unsigned char*)0x2002)
#define OAM_ADDRESS *((volatile unsigned char*)0x2003)
#define SCROLL *((volatile unsigned char*)0x2005)
#define PPU_ADDRESS *((volatile unsigned char*)0x2006)
#define PPU_DATA *((volatile unsigned char*)0x2007)
#define OAM_DMA *((volatile unsigned char*)0x4014)
#define JOYPAD1 (*(volatile unsigned char*)0x4016)
#define JOYPAD2 (*(volatile unsigned char*)0x4017)
#define PRG_DATA_ADDR ((volatile unsigned char*)0x8000)
// Функция для копирования данных из PRG ROM в CHR RAM
void copy_prg_rom_to_chr_ram() {
// Начинаем заполнять CHR RAM с нулевого адреса
PPU_ADDRESS = 0x0;
PPU_ADDRESS = 0x0;
// Копирование данных (8 килобайт копируем из PRG ROM)
// i_long — unsigned int
for (i_long = 0; i_long < 0x2000; ++i_long) {
PPU_DATA = PRG_DATA_ADDR [i_long];
}
}
Запись в CHR RAM происходит через использование регистров PPU_DATA
(при записи в него байт записывает в указанный адрес PPU) и PPU_ADDRESS
(сначала в него записывается старший байт адреса, а затем младший). То есть для записи одного байта мы указывает адрес видеопамяти через регистр PPU_ADDRESS
, а затем в PPU_DATA
загружаем нужный байт данных. После записи байта в PPU_DATA
активный адрес (PPU_ADDRESS
) автоматически инкрементируется, поэтому его достаточно указать всего один раз. В приведённом выше примере загружается 8 Кб данных начиная с адреса 0x8000. В вашем случае адрес может быть любой (он должен указывать на начало .chr-файла). Загружается именно 8 Кб, так как столько занимает весь набор тайлов.
После инициализации CHR RAM можно использовать точно так же, как и CHR ROM.
Редактирование отдельных тайлов CHR RAM
Редактирование отдельных тайлов позволяет реализовать анимацию как фонов, так и спрайтов. Особенно полезно редактирование CHR RAM при анимировании фонов, так как заменив тайл фона вы измените все части фона, где этот тайл используется (так можно реализовать анимацию большого количества свечей или факелов, вращение шестерёнок и т. д.).
Кроме удобного анимирования фонов редактирование видеопамяти позволяет экономить место в адресном пространстве 0x0000 — 0x1FFF, так как можно одновременно хранить только один кадр анимации (например, в игре Battletoads в каждый момент времени в CHR RAM хранится только один кадр анимации).
Чтобы лучше разобраться, как это работает, давайте напишем функцию, которая позволит заменить один конкретный тайл в CHR RAM.
// Перезаписывает тайл в CHR RAM из массив по указателю
// Указатель должен указывать на массив из 16 байт
// Тайл состоит из 16 байт (8 байт первый слой, 8 байт второй слой)
// Цвет хранится в виде 2 бит (от b00 до b11)
// Первые 8 байт - это младшие разряды кода цвета(слой 0), 1 байт - 1 стока тайла
// Вторые 8 байт - это старшие разряды тайла (слой 1)
/* Пример:
// Адрес тайла в CHR 0x_NN0
NN адреса - это номер тайла из таблицы имен
PPU_ADDRESS = 0x12;
PPU_ADDRESS = 0xC0;
// Массив с 16 байтами, которые хранят тайл
p_text = chr1;
set_tile_to_chr ();
*/
void set_tile_to_chr (void) {
// Записываем первый байт тайла (младший слой)
PPU_DATA = *(p_text + 0);
PPU_DATA = *(p_text + 1);
PPU_DATA = *(p_text + 2);
PPU_DATA = *(p_text + 3);
PPU_DATA = *(p_text + 4);
PPU_DATA = *(p_text + 5);
PPU_DATA = *(p_text + 6);
PPU_DATA = *(p_text + 7);
// Записываем старший байт байт тайла
PPU_DATA = *(p_text + 8);
PPU_DATA = *(p_text + 9);
PPU_DATA = *(p_text + 10);
PPU_DATA = *(p_text + 11);
PPU_DATA = *(p_text + 12);
PPU_DATA = *(p_text + 13);
PPU_DATA = *(p_text + 14);
PPU_DATA = *(p_text + 15);
}
Указатель p_text
хранит адрес начального элемента массива, который хранит 16 байтов, описывающих один тайл (структура хранения тайлов описана в начале статьи). В функции не используется цикл, так как его реализация требует проверки условий, инкремента счётчика и копирования его значения. Оптимизация очень важна при разработке программ для старого железа (редактировать видеопамять мы можем только в момент между кадрами, который по времени занимает всего лишь около 2200 тактов процессора).
Для выбора нужного тайла через PPU_ADDRESS
задаём номер тайла (они пронумерованы от 0x00 до 0xFF) с помощью маски 0xPNN0, где P — номер страницы видеопамяти — 0 или 1, NN — номера тайла. Адрес состоит из 4 байтов. Старший байт задаёт страницу видеопамяти (их всего две, одна используется для вывода фонов, а другая для спрайтов), а следующие два байта (средние) указывают номер тайла, всё очень просто.
Архитектура NES очень неплохо продумана, но хватает в ней и странных моментов. Например, записав 16 байтов по адресу 0x1FF0, вы отредактируете тайл под номером 0xFF (255-й тайл) из второй страницы видеопамяти.
Давайте посмотрим на реальном примере, как работает редактирование CHR RAM. Создаём массив, который хранит данные тайла (байты легко получить, открыв .chr как бинарный файл):
// Символ восклицательного знака
const unsigned char new_tile [] = {
0x38, 0x7c, 0x7c, 0x7c, 0x38, 0x00, 0x38, 0x00,
0x30, 0x78, 0x78, 0x78, 0x30, 0x00, 0x30, 0x00};
Теперь пишем код, который будет ожидать нажатия кнопки «A» пользователем, а после нажатия загрузим наш новый тайл в видеопамять:
while (1)
{
Wait_Vblank();
Get_Input ();
if (A_PRESSED) {
Wait_Vblank();
PPU_ADDRESS = 0x00;
PPU_ADDRESS = 0x00;
// Массив с 16 байтами, которые хранят тайл
p_text = new_tile;
set_tile_to_chr();
PPU_ADDRESS = 0x00;
PPU_ADDRESS = 0x00;
break;
}
}
Wait_Vblank();
реализует ожидание конца кадра. Это необходимо, так как работать с видеопамятью мы можем только между кадрами (время возврата луча ЭЛТ).
Теперь запустим наш код в эмуляторе Fceux [6] и откроем окно PPU Viewer, которое показывает содержимое видеопамяти в виде тайлов. Как оно выглядит до и после редактирования:
После редактирования CHR ROM на месте тайла с номером 0x00 появился символ восклицательного знака. Всё работает.
Заключение
В одной публикации довольно сложно охватить все особенности разработки игр для Dendy, поэтому я попытался максимально подробно описать взаимодействие с маппером UNROM. Я выбрал его для статьи потому, что он довольно прост в использовании и даёт широкие возможности для программиста. Кроме этого картридж с UNROM довольно легко сделать самому, так как он основан на дешёвых микросхемах (ПЗУ на 64/128 Кб, ОЗУ на 8 Кб и две микросхемы дискретной логики), а плату можно напечатать ЛУТ-ом или заказать готовую у китайцев. Проблемы могут быть только с прошивкой ПЗУ.
Программаторы для прошивки микросхем с параллельным вводом данных довольно дорогие (а если у вас УФ-стираемое ПЗУ, то нужна будет ещё и УФ-лампа). Например, с маппером MMC3 (самый распространённый) было бы уже намного больше проблем, так как он требует специальный одноимённый чип (ASIC), который пришлось бы снимать с донора или реализовывать на FPGA, что уже значительно дороже и сложнее.
На этой лирической ноте я буду заканчивать своё повествование. Надеюсь, что статья оказалась вам полезна или хотя бы сделала немного понятнее устройство любимых игр из детства.
Делайте и играйте в хорошие игры. Всем спасибо за внимание.
Полезные ссылки
[1] https://www.nesdev.org/wiki/NES_reference_guide
[2] https://www.nesdev.org/wiki/PPU_pattern_tables
Breathe_the_pressure
Познавательно. Я правильно понимаю, что это в рамках поручения президента по импортозамещению приставок?
NutsUnderline
Да нет, это в банковской сфере... Сбер же.