Разработка игр под NES на C. Главы 1-3. От введения до Hello World

Original author: Nesdoug
  • Translation
  • Tutorial

Впервые я задумался о том, как разрабатывают игры под приставки где-то через 20 минут после того, как в самый первый раз увидел Turbo Pascal. На глаза иногда попадался Subor с клавиатурой, и появилась мысль: "Наверное можно набрать какую-то программу, а потом в нее поиграть". Но интерес быстро затух, потому что абсолютно никакой информации по этой теме тогда не было доступно. Следующий раз эта же идея всплыла, когда увидел вполне играбельные эмуляторы старых консолей. Тогда стало ясно, что вбивать листинг в саму консоль и необязательно. Где-то очень потом появился Хабр с благожелательной аудиторией для таких вещей. В какой-то момент даже начал собирать разрозненную инфу чтобы написать мануал самому, и вот сегодня наткнулся на готовый учебник, который явно надо перевести.


Разработка под старые консоли документирована вдоль и поперек, но именно по NES 99% информации относятся к разработке на Ассемблере. Меня почему-то зарубило, что надо освоить именно работу с С.


следующая >>>
image



Всем привет.
image


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


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


Также помните, что я не профессионал ни в разработке, ни в ведении блога. Если возникнут вопросы по NES, скорее всего ответы найдутся в Вики.


Я постараюсь максимально упростить обучение, и использовать самые простые примеры. Также рекомендую начать с простейшей идеи игры. Читателю явно захочется сделать новую Зелду, но это не получится. Простейшая игра потребует 2-3 месяца на разработку, Зелда — 2-3 года. Такой проект скорее всего будет заброшен. Ориентируйтесь на Пакман, хотя бы первое время.


Память консоли


Поговорим о структуре памяти. У NES два независимых адресных пространства — память процессора с диапазоном $0-$FFFF и память PPU — видеочипа.


Начнем с памяти процессора.


  • Первые $800 это RAM.
  • Диапазон $6000-$7FFF некоторые игры используют для работы с SRAM (сохранение на картрирдж с батарейкой), или как дополнительный Work RAM.
  • На пространство $8000-$FFFF отображается ROM. Некоторые мапперы (дополнительный процессор в картридже) могут использовать более 32k ROM, но они все равно обычно работают через $8000-$FFFF.
  • Адрес $FFFC-$FFFD это вектор reset, который указывает на начало программы.

Здесь более подробная информация.


У PPU свое, независимое адресное пространство. Оно имеет размер $3FFF, но местами зеркалируется. Доступ к нему идет через регистры в памяти процессора. Видеопамяти хватает на 4 экранных буфера, но в подавляющем большинстве игр используется только 2 — для реализации прокрутки.


  • $0-$1FFF = здесь хранятся спрайты
  • $2000-$23FF = Таблица имен 0
  • $2400-$27FF = Таблица имен 1
  • $2800-$2BFF = Таблица имен 2
  • $2C00-$2FFF = Таблица имен 3
    При этом таблицы 2 и 3 это зеркало таблиц 0 и 1
  • $3F00-$3F1F = палитра

Таблица имен, nametable, связывает тайлы фона и их позицию на экране.


Зеркалирование позволяет управлять горизонтальной или вертикальной прокруткой, но всему свое время.


image


Еще в PPU есть отдельная область памяти OAM, Object Attribute Memory, размером 256 байт. Доступ к ней реализован через регистры в адресном пространстве процессора, и она позволяет управлять отображением спрайтов.


Вот подробная информация по памяти PPU:
http://wiki.nesdev.com/w/index.php/PPU_memory_map


Еще один момент. Есть два типа картриджей. В некоторых два ROM чипа — PRG-ROM с исполняемым кодом и CHR-ROM с графикой. В таком случае графика автоматически отображается в адреса $0-1FFF PPU. Это позволяет очень просто сделать отрисовку — просто записать номер тайла в таблицу. Мы будем использовать этот формат.


Другой тип картриджа использует CHR-RAM вместо CHR-ROM. Это позволяет подгрузить часть графики в эту дополнительную оперативную память. Это сложная техника, и в этом туториале не рассматривается.


Теперь можно посмотреть на софт, используемый для разработки.


  • Компилятор
  • Редактор тайлов
  • Графический редактор
  • Notepad++
  • Хороший эмулятор
  • Упаковщик тайлов

В этом туториале рассматривается только cc65. Это один из лучших компиляторов для 6502, процессора NES.


Я использую версию 2.15 (для проверки введите ‘cc65 --version’ в консоли). Файлы из разных версий несовместимы, поэтому при необходимости используйте nes.lib из комплекта вашего компилятора.


Во-вторых, надо создать графику. Я использую YY-CHR


Для предобработки графики нужен любой графический редактор: Photoshop или GIMP, по вкусу.


Код удобно писать в Notepad++. У него есть подсветка сишного синтаксиса и нумерация строк — это облегчает отладку.


image


А теперь эмулятор. Я использую FCEUX 90% времени, потому что в нем есть крутой дебаггер и инструменты для работы с памятью, просмотрщики спрайтов и все такое. Но он не самый точный в эмуляции. Игры надо будет тестировать где-то еще. Судя по отзывам, самые точные эмуляторы это Nintendulator, Nestopia, и puNES. Еще желательно подгрузить более точную палитру — лежит здесь.


Есть две версии FCEUX — SDL и Win32. Первая работает почти везде, вторая только в Windows. Так вот, отладчик есть только во второй. Так что в случае альтернативной ОС придется воспользоваться виртуалкой или Wine.


И наконец расстановщик тайлов. Мы можем сделать игру без него, но он точно поможет. Я рекомендую NES Screen Tool. Он отлично показывает ограничения консоли по цветам и отлично подходит для одноэкранных игр. Для игр с прокруткой лучше подойдет Tiled map editor.


Как же всем этим пользоваться?


image


Надо сжать изображение до адекватного размера, например 128 пикселей в ширину. Потом преобразовать в 4 цвета и подправить при необходимости огрехи. Теперь можно копипастить в YY-CHR.


В YY-CHR надо проверить, чтобы цвет был двухбитный.


image


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


Как работает сс65


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


Дл упрощения работы будем использовать .bat-скрипты и Makefile. Это позволит автоматизировать процесс и собирать образ картриджа в одно касание.


Процесс примерно такой. cc65 компилирует файл с кодом на С в ассемблерный код. ca65 собирает объектный файл. ld65 линкует его в образ картриджа .nes, который можно запустить в эмуляторе. Настройки хранятся в .cfg файле.


В приставке используется 8-битный процессор MOS 6502. Он не умеет просто обращаться к переменным больше 8 бит. Адресация 16-битная, из математики есть только сложение, вычитание и битовые сдвиги. Так что код придется писать с учетом этих факторов.


  • Большая часть переменных должна быть типа unsigned char — 8 бит, значения 0-255
  • Лучше не передавать значения в функции, или делать это через директиву fastcall, которая передает аргументы через 3 регистра — A,X,Y
  • Массивы не должны быть длинее 256 байт
  • printf отсутствует
  • ++g заметно быстрее, чем g++
  • cc65 не может ни передавать структуры по значению, ни возвращать их из функции
  • Глобальные переменные намного быстрее локальных, даже структуры

Испольуйте опцию -O для оптимизации. Есть еще опции i,r,s, которые иногда комбинируют в -Oirs, но они, например, могут удалить чтение из регистра процессора, значение которого не используется. А это фатально.


Здесь еще немного рекомендаций по использованию компилятора.


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


Поддерживается импорт переменных из других файлов. cc65 умеет импортировать переменные и массивы из ассемблерных модулей командой


extern unsigned char foo;

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


#pragma zpsym (“foo”);

В дальнейшем курсе эти конструкции будут использоваться редко. Единственное исключение — импорт большого бинарного файла. В этом случае оптимально будет завернуть его в ассемблерный файл:


.export _foo
_foo:
.incbin "foo.bin"

а потом импортировать в С как


extern unsigned char foo[];

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


Можно вызывать функции, написанные на ассемблере, через __fastcall__. В этом случае аргументы передадутся в функцию через регистры, а не стек — экономит время. В некоторых случаях без ассемблерного кода не обойтись, например при инициализации приставки. В любом случае, чем меньше аргументов передается в функцию, тем лучше. Сравним две функции, причем переменные test и A глобальные:


void Test (char A) {
 test = A;
}
// функция с одним аргументом компилируется в 19 команд ассемблера

_Test
jsr pusha
 ldy #$00
 lda (sp),y
 sta _test  ; test = A;

 jmp incsp1

pusha: ldy sp 
 beq @L1 
 dec sp
 ldy #0 
 sta (sp),y 
 rts 

@L1: dec sp+1 
 dec sp 
 sta (sp),y 
 rts 

incsp1:

 inc sp
 bne @L1
 inc sp+1
@L1: rts

void Test (void) {
 test = A;
}
// аргумент не передается, компилируется в 3 команды

_Test
 lda _A
 sta _test
 rts

Еще можно вставлять ассемблерный код прямо в сишный. Я так почти никогда не делаю, но наверное иногда это необходимо. Выглядит примерно так:



 asm ("Z: bit $2002") ;
 asm ("bpl Z") ;

Кроме того, я заменил громоздкий код инициализации crt0.s на компактный reset.s, и подправил конфигурацию для всего этого. Эти файлы иногда будут меняться. nes.lib используется стандартный, из состава компилятора. Проект собирается с опцией –add-source, которая не удаляет промежуточные ассемблерные файлы — можно порассматривать сгенерированный код.


Удобней определить переменные в сишном коде, а потом импортировать в ассемблерный через


.import _Foo

Но это вопрос вкуса, на мой взгляд, такой код наглядней.


Hello World


Эта программа будет просто печатать текст на экране. Надо помнить, что приставка вообще не знает про кодировку ASCII и работу с текстом в любом виде. Но зато есть возможность вывести картинки размером 8х8 поверх фона.


Так что делаем массив спрайтов-букв, чтобы адреса букв в нем соответсвовали их ASCII-кодам. Потом их можно будет дернуть из кода на С.


image


Код инициализации приставки пока берем как есть, после его выполнения происходит переход на main().


Нам надо сделать такие операции:


  • Выключить экран
  • Настроить палитру
  • Вывести заветные слова
  • Отключить прокрутку
  • Включить экран
  • Повторить

Выключение экрана нужно, потому что работа с видеопамятью вызывает мусор на экране. Надо или выключить экран, или ждать кадровый гасящий импульс (V-Blank). Детально этот вопрос мы рассмотрим в следующий раз.


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


Для вывода на экран надо записать координаты начала заливки начиная со старшего байта по адресу $2006, а потом записывать номера тайлов в $2007. PPU будет выводить тайлы с соответствующими номерами один за другим, с переходом на новую строку. Можно перенастроить PPU на шаг вывода, равный 32 — тайлы будут выводиться один под другим. Нам же надо выставить шаг 1, через регистр $2000. Пересчитать координаты экрана в адрес можно через NES screen tool.


Нам также надо заполнить первые 4 цвета палитры — они отвечают за фон. Они записываются по адресу $3F00.


Запись в регистры PPU ломает положение прокрутки, так что ее тоже надо сбросить. Иначе картинка может уехать за экран. Мы делаем это через регистры $2006 и $2005.


lesson1.c

#define PPU_CTRL  *((unsigned char*)0x2000)
#define PPU_MASK  *((unsigned char*)0x2001)
#define PPU_STATUS  *((unsigned char*)0x2002)
#define SCROLL   *((unsigned char*)0x2005)
#define PPU_ADDRESS  *((unsigned char*)0x2006)
#define PPU_DATA  *((unsigned char*)0x2007)

unsigned char index;
const unsigned char TEXT[]={
"Hello World!"};

const unsigned char PALETTE[]={
0x1f, 0x00, 0x10, 0x20
}; //black, gray, lt gray, white

void main (void) {
 // turn off the screen
 PPU_CTRL = 0;
 PPU_MASK = 0;

 // load the palette
 PPU_ADDRESS = 0x3f; // set an address in the PPU of 0x3f00
 PPU_ADDRESS = 0x00;
 for(index = 0; index < sizeof(PALETTE); ++index){
  PPU_DATA = PALETTE[index];
  }

 // load the text
 PPU_ADDRESS = 0x21; // set an address in the PPU of 0x21ca
 PPU_ADDRESS = 0xca;  // about the middle of the screen
 for( index = 0; index < sizeof(TEXT); ++index ){
  PPU_DATA = TEXT[index];
  }

 // reset the scroll position 
 PPU_ADDRESS = 0;
 PPU_ADDRESS = 0;
 SCROLL = 0;
 SCROLL = 0;

 // turn on screen
 PPU_CTRL = 0x90; // NMI on
 PPU_MASK = 0x1e; // screen on

 // infinite loop
 while (1); 
}

image


Ссылка на код:


Дропбокс
Гитхаб
На Гитхабе чуть исправил Makefile, чтобы корректно работал под Windows.


Строка
ONCE: load = PRG, type = ro, optional = yes;
внутри секции segments{} в файлах .cfg нужна для совместимости со свежей версией cc65.


Включение экрана через “PPUMASK = 0x1e” описано в Вики.


Все файлы здесь размером 0х4000. Это самый маленький возможный размер PRG ROM. 90% игр сюда не влезут, и будут отображаться на адреса $8000-$FFFF. У нас же игра загружается в адреса $C000-$FFFF и зеркалируется в $8000-$BFFF. Для разработки большей игры надо будет перенастроить адрес начала ROM на $8000, и выставить размер тоже $8000. А еще включить второй банк PRG ROM в секции header.

  • +52
  • 21.9k
  • 5
Share post

Comments 5

    +1
    Жду продолжения, интересно про звук ещё почитать.
      0
      Как-то совсем примитивно описана графика NES. Подано так, что на экране может быть всего 4 цвета. На самом деле каждый экранный тайл может иметь свою мини-палитру из 4 цветов, это атрибут тайла. Там еще есть странности с хранением бит для группы тайлов в разных местах, с учётом этого количество одновременно отображаемых цветов на экране можно довести до 25 (12 для фоновых тайлов + 13 для спрайтов), хотя казалось бы логичным иметь «круглые» 64 или 32.
        0
        Про палитры детально в следующей части. Сейчас просто демонстрация концепции — что можно написать что-то осязаемое
        –1
        Шрифт весьма ущербный, на мой взгяд и вкус. Впрочем 8х8 сложно нормально нарисовать. Интересна была бы работа со шрифтом 8х16, литеры для которого можно выдрать из досовых консольных шрифтов, которых создано огромное множество, либо из *.psf, которые попадаются в Linux (один такой я для себя нарисовал). Или 6х8, чтобы в строку больше букв влезало.

        Вторым уроком хочу видеть полноценную реализацию printf(). Третьим уроком — реальзацию примитивного текстового редактора.
          +5
          Как раз недавно тоже изучал тему программирования на си под NES. У меня получилась такая демка:

          Как и nesdoug, начинал с движка shiru и модифицировал его немного под себя.

          Хорошо, что вы в своих примерах портируете код под новую версию cc65, буду следить за вашими переводами и добавлять замечания, может напишу от себя статью по темам, которые не раскрыты у nesdoug'а:

          Отладка и профилирование под fceux с помощью lua, использование более продвинутого звукового движка от famitraker'а, использование различных блочных систем для экономии памяти и экспорт данных уровней из готовых игр с использованием редактора CadEditor, реальные примеры реализации спецэффектов из игр, реализация систем паролей/использование батарейки, написание своих компрессоров данных, возможно, создание меню для многоигровок.

          Замечание и неточности в статье:
          1. Флаг компилятору -Cl позволяет сделать все локальные переменные статическими, что позволит не терять в скорости. Однако переменные вообще лучше не заводить без особых на то причин.
          2. Квалификатор типа volatile позволяет отмечать переменные, используемые для чтения/записи регистров процессора и внешних устройств, такие переменные не будут удалены как неиспользуемые при оптимизации.
          3. Директива __fastcall__ задаёт передачу аргументов в функцию через регистры A,X и 2 специально отведённых компилятором байта из нулевой страницы памяти, регистр Y не используется. Если для передачи аргументов требуется больше 4х байт, то всё равно будет задействован стек.

          Only users with full accounts can post comments. Log in, please.