Программирование для Famicom/NES/Денди в Nesicide+ca65: маппер MMC3 - страницы (8)
Итак на этот момент нам подвластны 32Кб кода/данных в PRG ROM и 8Кб графики в CHR ROM. Но когда этого перестало хватать в ход пошли мапперы — микросхемы встроенные в картриджи переключающие банки памяти. О них у меня есть отдельная обзорная статья. Одним из крайне популярных мапперов был MMC3 и кроме собственной популярности на его базе было создано огромное число производных чипов. В этой статье мы научимся использовать его для увеличения доступного для игры ROM картриджа.
Маппер MMC3 позволяет переключать ROM PRG страницами по 8Кб и может обрабатывать 64 таких страницы. Т.е. кода/данных в картридже с MMC3 может быть до 512Кб. ROM CHR же обрабатывается страницами по 1Кб и таких страниц может быть уже 256 штук, т.е. графических данных в картридже с MMC3 может быть до 256Кб.
Посмотрим для начала на карту памяти консоли с использованием этого маппера (здесь каждая строка это 8Кб):
Вообще то это одна из двух возможных раскладок. Во второй банки A000 и C000 меняются местами и это может быть полезно для размещения большого количества данных для звукового DPCM канала, т.к. они обязательно должны размещаться выше адреса C000, но нам здесь это не надо и мы будем использовать эту, более простую раскладку.
Что касается графических данных, то первые 8Кб адресного пространства PPU маппятся следующим образом:
Т.е. первый банк из 256 тайлов (CHR0) разбит на две страницы по 2Кб каждая, а второй (CHR1) — на четыре страницы по 1Кб каждая. Тут надо заметить, что когда мы в двухкилобайтном банке выбираем страницу мы можем выбрать только чётный номер N общего числа однокилобайтных страниц графики, при этом вторая страница для банка будет взята из страницы N+1.
Опять же — это первый вариант раскладки и тот который мы будем использовать. Во втором уже первый банк тайлов разбит на четыре страницы, а второй — на две, т.е. отображения H* и Q* меняются местами.
Следующий момент который нам нужно проделать — это изменить формат заголовка iNES на версию 2.0.
Тут дело в том, что маппер MMC3 так часто встречался, что стал одним из первых реализованных в эмуляторах, в т.ч. iNES и исторически в первых версиях программы на этот тип маппера, скажем так, наложилось слишком много картриджей на самом деле с лёгкими различиями в мапперах. В результате возникли некоторые проблемы которые следует избегать используя более совершенный формат iNES 2.0 где разночтения ликвидированы и содержится больше детальной информации.
Создадим новый проект на базе предыдущих или возьмём готовый из архива (Example05): yadi.sk/d/_THxg1gxuCCVNw
Внесём необходимые изменения в header.s:
header.s
Признак формата iNES 2.0 находится там же где верхние 4 бита номера маппера. Ранее неиспользованные байты в конце заголовка здесь все начали что-то значить, однако они по прежнему заполнены нулями, т.к. мы их не используем. В остальном здесь всё почти так же как было.
Чтобы поменьше было писанины мы используем минималистичный вариант картриджа — 64Кб на код/данные и 64Кб на графику. Такие варианты картриджа были физически и назывались TBROM.
Графика нам сейчас не особо важна, поэтому наводним банки графики в Nesicide gamegfx.png из Example03 (ушибленный спрайт) — создадим банки от bank0-bank7 и выберем в каждом два раза tilesets/gamegfx.png:
С точки зрения маппера эти 8 банков по 8Кб каждый образуют линейное пространство из 64 страниц по 1Кб каждая.
Важно понять как они будут нумероваться:
Т.е. в «bank0» располагаются первые 8 страниц, каждая охватывает блок из 16x4 тайлов и на банки в Nesicide они накладываются так как показано на картинке. Далее в «bank1» идут следующие 8 страниц и так далее.
А вот для того чтобы сформировать образ картриджа в части кода/данных надо редактировать nes.ini:
nes.ini
Куски памяти HEADER, ZPAGE, RAM и ROM_H остались прежними. ROM_H в маппере MMC3 у нас будет всё так же занимать диапазон $C000-FFFF (последние 16Кб памяти консоли). А вот кусок памяти ROM_L исчез и на его месте появилось шесть новых кусков ROM_0..ROM_5 размером 8Кб каждый. Тут надо понимать, что с точки зрения маппера это всё 64 килобайта восьми последовательно расположенных кусков 0..7 по 8Кб каждый. Но два последних мы слили в один 16-килобайтный ROM_H для удобства, т.к. это фиксированные страницы. А вот ROM_0-ROM_5 мы вольны выбирать в банках памяти PRG_H0 и PRG_H1.
Тут следует выработать схему маппинга этих двух банков чтобы нацелить нужные куски памяти в nes.ini на нужные адреса в памяти консоли.
Для этих уроков я возьму следующую схему:
Далее в секции SEGMENTS мы просто сопоставляем одноимённые сегменты с одноимёнными кусками памяти и идём дальше.
Файлы neslib.inc и neslib.s в этом проекте остаются такими же как в предыдущем (Example04 — Звук и музыка).
Зато добавляется новый файл — mmc3.inc:
mmc3.inc
Маппер MMC3 на картридже добавляет в систему 8 новых портов ввода-вывода. Интересно то как он это делает. Как и наверное все другие мапперы он отслеживает попытки процессора записать что либо в ROM картриджа. В обычной конфигурации это ни к чему бы не привело — ROM есть ROM и перезаписи не подлежит. Но маппер определяя такую ситуацию начинает выполнять какие-то действия.
Запись в любой адрес ROM картриджа будет обработан MMC3 как запись в один из восьми своих портов. Если присмотреться к системе того какие 16-битные адреса какими портами считаются, то становится понятно, что маппер воспринимает их как следующую битовую маску:
1XY----- -------Z, где XYZ это номер порта.
Т.е. самый верхний бит адреса = 1, т.к. мы говорим о верхних 32Кб адресного пространства процессора и следующие два самых старших бита вместе с самым младшим и выбирают порт ввода-вывода маппера. Например $8000 и $9004 — это один и тот же порт, поскольку чётный адрес и верхние 3 бита одни и те же.
В этом уроке нас интересуют в первую очередь порты MMC3_BANK и MMC3_PAGE. В первый надо записать номер банка для которого хотим изменить страницу которая в нём отображается. Эти номера прописаны в заголовке под мнемоническими обозначениями MMC3_CHR_H0/1 для первых двух половинок банка графики, MMC3_CHR_Q0/1/2/3 для четвертинок второго банка графики и MMC3_PRG_H0/1 для двух банков кода/данных.
После этого можно записать в порт MMC3_PAGE номер страницы которую в банке надо выбрать. Это число 0-255 для банков графики и 0-63 для банков кода/данных.
Кроме основной функции по расширению доступной памяти маппер позволяет делать еще несколько весьма полезных вещей.
Первое — порт MMC3_MIRROR позволяет на лету управлять режимом зеркалирования видеостраниц. Для этого достаточно записать в него MMC3_MIRROR_V для вертикального зеркалирования или MMC3_MIRROR_H для горизонтального.
При наличии на картридже плашки SRAM в 8Кб отображаемой на адреса $6000-7FFF маппер позволяет защищать её от записи и вообще отключать. Это полезно когда картридж использует сохранения игр на SRAM подпитываемой батарейкой. Как я понял есть проблема когда процессор может выполнить хаотичную запись в непредсказуемый адрес памяти то ли при включении то ли при выключении питания консоли. Поэтому такой механизм защиты SRAM весьма полезен.
Следующая группа портов: MMC3_IRQ_COUNTER, MMC3_IRQ_RELOAD, MMC3_IRQ_OFF и MMC3_IRQ_ON управляет прерыванием IRQ по счётчику сканлайнов. Это очень важная фича MMC3 очень часто используемая в играх, но опишу я их работу в следующей статье, а в этой сконцентрируемся на переключении страниц памяти.
Пришло время начать писать тело основной программы — main.s:
main.s — первая часть
Что тут у нас нового:
Переменная glob_temp0 в zero-page — особый случай временных переменных когда мы точно знаем что они используются только в нескольких следующих инструкциях. В отличие от переменных argXw или argXb в них ничего никуда не передаётся поэтому их всегда можно портить.
Для демонстрации переключения банков кода мы реализуем технику «межстраничного перехода» — когда код находящийся в одной странице ROM картриджа вызовет код из другой страницы. Для этого нам надо передать процедуре перехода номер страницы и адрес в этой странице куда переходить. Это две следующих переменных в zero-page — far_jsr_page и fa_jsr_addr. Чтобы в одну строчку кода выставить значения в этих переменных служит макрос set_far_dest.
Идём дальше.
main.s — вторая часть
Как мы и договаривались в сегментах ROM_0-ROM_3 будут лежать данные в банке PRG_H0. Здесь это будут просто 4 разных строки текста заканчивающиеся нулевым байтом. Но обратите внимание, что первым байтом в каждой из переключаемых страниц задан номер этой самой страницы. Это позволит нам легко определять какая страница сейчас выбрана в любом банке. Т.е. по адресу $8000 будет лежать номер страницы в банке PRG_H0, а по адресу $A000 — номер страницы в банке PRG_H1.
Тут важно заметить вот какую вещь — в ассемблере CA65 если один и тот же сегмент наполняется в разных модулях программы, то как будут скомпонованы получившиеся части вопрос неопределённый. Поэтому для таких сегментов где местоположение данных и кода должно строго соответствовать ожидаемому нужно обязательно выполнять то условие, что они должны быть определены в программе единожды!
Итак в сегментах ROM_0-ROM_3 у нас заданы номера сегментов и разные строки текста.
Сегменты ROM_4 и ROM_5 мы отводили под код и они будут выбираться в банке PRG_H1. Так же первым байтом в них задан номер самой страницы. А вот далее идут две процедуры.
В сегменте ROM_4 это процедура print_some которая выводит в первую видеостраницу null-terminated текст с адреса $8001 и вызвает межстраничным переходом вторую процедуру в ROM_5.
Это процедура inc_some которая инкрементирует тайл по координатам (10,10) в первой видеостранице.
Обе эти процедуры находятся по одним и тем же адресам адресного пространства процессора и не могут вызывать друг друга напрямую или возвращаться друг в друга напрямую. Для того чтобы это сделать они должны передавать и получать управление в/из неизменных банков памяти — и это делается через процедуру far_jsr.
Дальше идёт ряд данных и процедур уже знакомых нам по предыдущим урокам:
main.s — третья часть
Данные палитр, полупустые процедуры обработки прерываний и заливки экранных областей — всё это мы уже видели.
Поэтому переходим к самому вкусному:
main.s — четвёртая часть, процедура far_jsr
Процедура far_jsr — это «перемычка» между кодом в разных страницах PRG_H1. Она запоминает в стеке текущую страницу памяти в банке PRG_H1, переключает её на far_jsr_page и вызывает процедуру по адресу в слове far_jst_addr, а когда управление из этой процедуры вернётся обратно, то восстанавливает страницу в банке памяти PRG_H1 из стека и сама возвращается туда откуда её вызвали.
Это позволяет выполнять межстраничные вызовы процедур. Причём заметьте, что процедура которую вызывают не обязана вообще знать, что её вызвали межстраничным переходом. Переключение и восстановление банков полностью скрыто «под капотом» процедуры far_jsr. Даже регистры не портятся ни при вызове ни при возврате, поэтому «перемычка» полностью прозрачна.
Однако поэтому она очень «толстая» и в десяток другой раз медленнее чем обычный вызов процедуры jsr proc_name. Поэтому для скорости кода всегда предпочитайте прямое выставление банков памяти и вызов процедур напрямую, а такой вот универсальный межстраничный переход используйте только в случае крайней необходимости.
Тем не менее, имхо, он является отличной демонстрацией переключения банков.
Переходим к основному телу программы. Инициализация:
main.s — пятая часть
Сперва здесь всё происходит как обычно, но одним из первых действий деактивируются прерывания маппера, а потом уже начинается процедура разогрева.
Далее заполняются палитры и экран заливается тайлом-полоской (для следующего урока) и выставляется маппинг видеопамяти.
Выставляется он в самые первые страницы видеоROM так как будто бы маппинга и нету — банк видеоданных «bank0» поступает в видеопамять в неизменённом виде.
Следом выставляются страницы PRG_H0 и PRG_1 и выставляются режим зеркалирования и на всякий случай блокируется отсутствующий чип SRAM.
В этом уроке это на самом деле не надо, но мы включим в нём спрайты, поэтому все спрайты в SPR_TBL прячутся за нижним краем экрана и наконец то включается видеочип.
Основной цикл программы выглядит так:
main.s — конец
Спрайты (спрятанные) выводятся на экран и по нажатию кнопок (A) и (B) переключается банк памяти с данными — по восходящей или нисходящей последовательности, но так что номер остаётся заключён в диапазоне 0-3.
После чего осуществляется дальний вызов процедуры print_some в банке 4. Она, как мы помним, в свою очередь вызовет процедуру inc_some из банка 5 и та увеличит номер тайла (10,10) на экране.
Посмотрим что получилось в результате:
Когда нажимаются кнопки (A) или (B) текст начинает считываться из другого банка памяти и всё время на экране меняется тайл в процедуре вызываемой межстраничным переходом. Работает!
В следующей статье мы рассмотрим переключение банков видеоданных, а самое главное — обработка прерываний от счётчика сканлайнов — намного более совершенная техника контроля HBlank-отсечения который мы рассматривали в уроке про «ушибленный спрайт».
В первую часть (оглавление)...
Маппер MMC3 позволяет переключать ROM PRG страницами по 8Кб и может обрабатывать 64 таких страницы. Т.е. кода/данных в картридже с MMC3 может быть до 512Кб. ROM CHR же обрабатывается страницами по 1Кб и таких страниц может быть уже 256 штук, т.е. графических данных в картридже с MMC3 может быть до 256Кб.
Посмотрим для начала на карту памяти консоли с использованием этого маппера (здесь каждая строка это 8Кб):
0000-1FFF - 2Кб ОЗУ зеркалированные на 8 бесполезно использованных килобайт
2000-3FFF - жалкий десяток портов видеочипа сожравших еще 8Кб адресного пространства
4000-5FFF - пара десятков портов звука и DMA пустивших псу под хвост еще 8Кб
6000-7FFF - опциональный банк в 8Кб RAM, но это жир. чаще всего здесь дыра
8000-9FFF - переключаемый маппером MMC3 первый банк в 8Кб (PRG_H0)
A000-BFFF - переключеемый маппером MMC3 второй банк в 8Кб (PRG_H1)
C000-DFFF \
E000-FFFF -+- непереключаемый банк в 16Кб "ядра игры"
Вообще то это одна из двух возможных раскладок. Во второй банки A000 и C000 меняются местами и это может быть полезно для размещения большого количества данных для звукового DPCM канала, т.к. они обязательно должны размещаться выше адреса C000, но нам здесь это не надо и мы будем использовать эту, более простую раскладку.
Что касается графических данных, то первые 8Кб адресного пространства PPU маппятся следующим образом:
0000-07FF - 2Кб в CHR0 (CHR_H0)
0800-0FFF - 2Кб в CHR0 (CHR_H1)
1000-13FF - 1Кб в CHR1 (CHR_Q0)
1400-17FF - 1Кб в CHR1 (CHR_Q1)
1800-1BFF - 1Кб в CHR1 (CHR_Q2)
1C00-1FFF - 1Кб в CHR1 (CHR_Q3)
Т.е. первый банк из 256 тайлов (CHR0) разбит на две страницы по 2Кб каждая, а второй (CHR1) — на четыре страницы по 1Кб каждая. Тут надо заметить, что когда мы в двухкилобайтном банке выбираем страницу мы можем выбрать только чётный номер N общего числа однокилобайтных страниц графики, при этом вторая страница для банка будет взята из страницы N+1.
Опять же — это первый вариант раскладки и тот который мы будем использовать. Во втором уже первый банк тайлов разбит на четыре страницы, а второй — на две, т.е. отображения H* и Q* меняются местами.
Следующий момент который нам нужно проделать — это изменить формат заголовка iNES на версию 2.0.
Тут дело в том, что маппер MMC3 так часто встречался, что стал одним из первых реализованных в эмуляторах, в т.ч. iNES и исторически в первых версиях программы на этот тип маппера, скажем так, наложилось слишком много картриджей на самом деле с лёгкими различиями в мапперах. В результате возникли некоторые проблемы которые следует избегать используя более совершенный формат iNES 2.0 где разночтения ликвидированы и содержится больше детальной информации.
Создадим новый проект на базе предыдущих или возьмём готовый из архива (Example05): yadi.sk/d/_THxg1gxuCCVNw
Внесём необходимые изменения в header.s:
header.s
.segment "HEADER" ; Переключимся на сегмент заголовка образа картриджа iNES
MAPPER = 4 ; 4 = MMC3, тут в варианте NES-TBROM (64Кб кода/данных + 64Кб графики)
MIRRORING = 0 ; зеркалирование видеопамяти: 0 - горизонтальное, 1 - вертикальное
HAS_SRAM = 0 ; 1 - есть SRAM (как правило на батарейке) по адресам $6000-7FFF
.byte "NES", $1A ; заголовок
.byte 4 ; число 16-килобайтных банков кода/данных
.byte 8 ; число 8-килобайтных банков графики (битмапов тайлов)
; флаги зеркалирования, наличия SRAM и нижние 4 бита номера маппера
.byte MIRRORING | (HAS_SRAM << 1) | ((MAPPER & $0F) << 4)
.byte (MAPPER & $F0) | %1000 ; верхние 4 бита номера маппера и признак iNES 2.0
.byte 0 ; mapper/submapper numbers
.byte 0 ; prg/chr high bits
.byte 0 ; prg-ram size
.byte 0 ; chr-ram size
.byte 0 ; cpu/ppu timings
.byte 0 ; extended console type
.byte 0 ; misc roms count
.byte 0 ; default expansion device
Признак формата iNES 2.0 находится там же где верхние 4 бита номера маппера. Ранее неиспользованные байты в конце заголовка здесь все начали что-то значить, однако они по прежнему заполнены нулями, т.к. мы их не используем. В остальном здесь всё почти так же как было.
Чтобы поменьше было писанины мы используем минималистичный вариант картриджа — 64Кб на код/данные и 64Кб на графику. Такие варианты картриджа были физически и назывались TBROM.
Графика нам сейчас не особо важна, поэтому наводним банки графики в Nesicide gamegfx.png из Example03 (ушибленный спрайт) — создадим банки от bank0-bank7 и выберем в каждом два раза tilesets/gamegfx.png:
С точки зрения маппера эти 8 банков по 8Кб каждый образуют линейное пространство из 64 страниц по 1Кб каждая.
Важно понять как они будут нумероваться:
Т.е. в «bank0» располагаются первые 8 страниц, каждая охватывает блок из 16x4 тайлов и на банки в Nesicide они накладываются так как показано на картинке. Далее в «bank1» идут следующие 8 страниц и так далее.
А вот для того чтобы сформировать образ картриджа в части кода/данных надо редактировать nes.ini:
nes.ini
MEMORY
{
HEADER: start = $0000, size = $0010, type = ro, file = %O, fill=yes;
ZPAGE: start = $0000, size = $0100, type = rw;
RAM: start = $0300, size = $0500, type = rw;
ROM_0: start = $8000, size = $2000, type = ro, file = %O, fill=yes, fillval = $D0;
ROM_1: start = $8000, size = $2000, type = ro, file = %O, fill=yes, fillval = $D1;
ROM_2: start = $8000, size = $2000, type = ro, file = %O, fill=yes, fillval = $D2;
ROM_3: start = $8000, size = $2000, type = ro, file = %O, fill=yes, fillval = $D3;
ROM_4: start = $A000, size = $2000, type = ro, file = %O, fill=yes, fillval = $D4;
ROM_5: start = $A000, size = $2000, type = ro, file = %O, fill=yes, fillval = $D5;
ROM_H: start = $C000, size = $4000, type = ro, file = %O, fill=yes, fillval = $CC;
}
SEGMENTS
{
HEADER: load = HEADER, type = ro;
ZPAGE: load = ZPAGE, type = zp;
RAM: load = RAM, type = bss, define = yes;
ROM_0: load = ROM_0, type = ro, align = $0100;
ROM_1: load = ROM_1, type = ro, align = $0100;
ROM_2: load = ROM_2, type = ro, align = $0100;
ROM_3: load = ROM_3, type = ro, align = $0100;
ROM_4: load = ROM_4, type = ro, align = $0100;
ROM_5: load = ROM_5, type = ro, align = $0100;
ROM_H: load = ROM_H, type = ro, align = $0100;
VECTORS: load = ROM_H, type = ro, start = $FFFA;
}
FILES
{
%O: format = bin;
}
Куски памяти HEADER, ZPAGE, RAM и ROM_H остались прежними. ROM_H в маппере MMC3 у нас будет всё так же занимать диапазон $C000-FFFF (последние 16Кб памяти консоли). А вот кусок памяти ROM_L исчез и на его месте появилось шесть новых кусков ROM_0..ROM_5 размером 8Кб каждый. Тут надо понимать, что с точки зрения маппера это всё 64 килобайта восьми последовательно расположенных кусков 0..7 по 8Кб каждый. Но два последних мы слили в один 16-килобайтный ROM_H для удобства, т.к. это фиксированные страницы. А вот ROM_0-ROM_5 мы вольны выбирать в банках памяти PRG_H0 и PRG_H1.
Тут следует выработать схему маппинга этих двух банков чтобы нацелить нужные куски памяти в nes.ini на нужные адреса в памяти консоли.
Для этих уроков я возьму следующую схему:
- в банке PRG_H0 ($8000-9FFF) мы будем выбирать страницы 0-3 (ROM_0-ROM_3) и будем располагать в них данные
- в банке PRG_H1 ($A000-BFFF) мы будем выбирать страницы 4-5 (ROM_4-ROM_5) и будем располагать в них код
- адреса $C000-FFFF заняты несменяемыми страницами 6-7 и обе будут лежать в сегменте ROM_H
Далее в секции SEGMENTS мы просто сопоставляем одноимённые сегменты с одноимёнными кусками памяти и идём дальше.
Файлы neslib.inc и neslib.s в этом проекте остаются такими же как в предыдущем (Example04 — Звук и музыка).
Зато добавляется новый файл — mmc3.inc:
mmc3.inc
; "Охранная" проверка на недопущение
; повторного ключения файла
.ifndef MMC3_INC_GUARD
MMC3_INC_GUARD = 1 ; Охранный символ
; *** MMC_BANK - выбор банка страницу которого мы можем переключить (запись)
MMC3_BANK = $8000
; Три нижних бита порта определяют какому банку мы будем менять маппинг:
MMC3_CHR_H0 = %00000000 ; Банк $0000-07FF, 2Кб в CHR0
MMC3_CHR_H1 = %00000001 ; Банк $0800-0FFF, 2Кб в CHR0
MMC3_CHR_Q0 = %00000010 ; Банк $1000-13FF, 1Кб в CHR1
MMC3_CHR_Q1 = %00000011 ; Банк $1400-17FF, 1Кб в CHR1
MMC3_CHR_Q2 = %00000100 ; Банк $1800-1BFF, 1Кб в CHR1
MMC3_CHR_Q3 = %00000101 ; Банк $1C00-1FFF, 1Кб в CHR1
MMC3_PRG_H0 = %00000110 ; Банк $8000-9FFF в RAM (8Кб) от 0 до 63
MMC3_PRG_H1 = %00000111 ; Банк $A000-BFFF в RAM (8Кб) от 0 до 63
; Флаг альтернативной раскладки банка PRG_H1. Если взведён, то PRG_H1 находится
; по адресам $C000-DFFF, а на $A000-BFFF маппится предпоследняя страница PRG ROM.
MMC3_PRG_ALT_MODE = %01000000
; Флаг альтернативной раскладки банков CHR. Если взведён, то CHR_Hx находятся
; в CHR1, а CHR_Qx - в CHR0, т.е. CHR0 состоит из 4-х страниц, а CHR1 - из двух.
MMC3_CHR_ALT_MODE = %10000000
; *** MMC3_PAGE - выбор страницы отображаемой в выбранном банке (запись)
; Если записать в MMC3_PAGE байт, то банк выбранный в MMC3_BANK начнёт отображаться
; на соответствующую по номеру страницу ROM PRG или ROM CHR картриджа.
; Для банков MMC3_CHR1_Qx диапазон значений страниц 0-255, т.е. полный объём CHR ROM - 256Кб
; Для банков MMC3_CHR0_Hx диапазон значений страниц 0-254, только чётные значения, т.е. берутся
; те же блоки что и в CHR1_Qx просто выбираются два подряд идущих блока
; Для банков MMC3_PRG_* диапазон значений страниц 0-63, т.е. полный объём PRG ROM - 512Кб
MMC3_PAGE = $8001
; mmc3_set_bank_page - выставить для банка pbank маппинг на страницу ppage
.macro mmc3_set_bank_page pbank, ppage
store MMC3_BANK, pbank ; Выставим в MMC3_BANK номер банка
store MMC3_PAGE, ppage ; Выставим в MMC3_PAGE номер страницы для этого банка
.endmacro
; *** MMC3_MIRROR - режим зеркалирования видеостраниц (запись)
MMC3_MIRROR = $A000
; нижний бит выбирает вариант зеркалирования:
MMC3_MIRROR_V = 0 ; зеркалирование по вертикали
MMC3_MIRROR_H = 1 ; зеркалирование по горизонтали
; *** MMC3_RAM_PROTECT - режим защиты (S)RAM на картридже отображаемой
; на адреса процессора $6000-7FFF (8Кб) (запись).
MMC3_RAM_PROTECT = $A001
MMC3_RAM_ENABLED = %10000000 ; (S)RAM включена
MMC3_RAM_PROTECTED = %01000000 ; (S)RAM защищена от записи
; *** MMC3_IRQ_COUNTER - начальное значение для счётчика сканлайнов (запись).
; При записи в этот порт байта значение будет запомнено во внутреннем регистре маппера, но
; в сам счётчик оно попадёт либо когда он достигнет нуля либо в процессе RELOAD (см. ниже)
MMC3_IRQ_COUNTER = $C000
; *** MMC3_IRQ_RELOAD - при записи любого байта в этот порт будет взведён бит перезагрузки
; и на следующем сканлайне счётчик будет перезаписан начальным значением.
MMC3_IRQ_RELOAD = $C001
; *** MMC3_IRQ_OFF - запись любого значения в этот порт отключит генерацию прерываний (IRQ), но
; счётчик сканлайнов будет в остальном работать как ни в чём не бывало.
MMC3_IRQ_OFF = $E000
; *** MMC3_IRQ_ON - запись любого значния в этот порт разрешит генерацию прерываний от маппера.
MMC3_IRQ_ON = $E001
.endif ; MMC3_INC_GUARD
Маппер MMC3 на картридже добавляет в систему 8 новых портов ввода-вывода. Интересно то как он это делает. Как и наверное все другие мапперы он отслеживает попытки процессора записать что либо в ROM картриджа. В обычной конфигурации это ни к чему бы не привело — ROM есть ROM и перезаписи не подлежит. Но маппер определяя такую ситуацию начинает выполнять какие-то действия.
Запись в любой адрес ROM картриджа будет обработан MMC3 как запись в один из восьми своих портов. Если присмотреться к системе того какие 16-битные адреса какими портами считаются, то становится понятно, что маппер воспринимает их как следующую битовую маску:
1XY----- -------Z, где XYZ это номер порта.
Т.е. самый верхний бит адреса = 1, т.к. мы говорим о верхних 32Кб адресного пространства процессора и следующие два самых старших бита вместе с самым младшим и выбирают порт ввода-вывода маппера. Например $8000 и $9004 — это один и тот же порт, поскольку чётный адрес и верхние 3 бита одни и те же.
В этом уроке нас интересуют в первую очередь порты MMC3_BANK и MMC3_PAGE. В первый надо записать номер банка для которого хотим изменить страницу которая в нём отображается. Эти номера прописаны в заголовке под мнемоническими обозначениями MMC3_CHR_H0/1 для первых двух половинок банка графики, MMC3_CHR_Q0/1/2/3 для четвертинок второго банка графики и MMC3_PRG_H0/1 для двух банков кода/данных.
После этого можно записать в порт MMC3_PAGE номер страницы которую в банке надо выбрать. Это число 0-255 для банков графики и 0-63 для банков кода/данных.
Кроме основной функции по расширению доступной памяти маппер позволяет делать еще несколько весьма полезных вещей.
Первое — порт MMC3_MIRROR позволяет на лету управлять режимом зеркалирования видеостраниц. Для этого достаточно записать в него MMC3_MIRROR_V для вертикального зеркалирования или MMC3_MIRROR_H для горизонтального.
При наличии на картридже плашки SRAM в 8Кб отображаемой на адреса $6000-7FFF маппер позволяет защищать её от записи и вообще отключать. Это полезно когда картридж использует сохранения игр на SRAM подпитываемой батарейкой. Как я понял есть проблема когда процессор может выполнить хаотичную запись в непредсказуемый адрес памяти то ли при включении то ли при выключении питания консоли. Поэтому такой механизм защиты SRAM весьма полезен.
Следующая группа портов: MMC3_IRQ_COUNTER, MMC3_IRQ_RELOAD, MMC3_IRQ_OFF и MMC3_IRQ_ON управляет прерыванием IRQ по счётчику сканлайнов. Это очень важная фича MMC3 очень часто используемая в играх, но опишу я их работу в следующей статье, а в этой сконцентрируемся на переключении страниц памяти.
Пришло время начать писать тело основной программы — main.s:
main.s — первая часть
; Подключаем заголовок библиотеки Famicom/NES/Денди
.include "src/neslib.inc"
; Подключаем заголовок библиотеки маппера MMC3
.include "src/mmc3.inc"
; Сегмент векторов прерываний и сброса/включения - находится в самых
; последних шести байтах адресного пространства процессора ($FFFA-FFFF)
; и содержит адреса по которым процессор переходит при наступлении события
.segment "VECTORS"
.addr nmi ; Вектор прерывания NMI (процедура nmi ниже)
.addr reset ; Вектор сброса/включения (процедура reset ниже)
.addr irq ; Вектор прерывания IRQ (процедура irq ниже)
.segment "ZPAGE": zp ; Сегмент zero page, это надо пометить через ": zp"
vblank_counter: .byte 0 ; Счётчик прерываний VBlank
glob_temp0: .byte 0 ; Байт ультравременных данных в zero-page
far_jsr_page: .byte 0 ; Страница на которую надо перейти межстраничным переходом
far_jsr_addr: .word 0 ; Адрес на который надо перейти межстраничным переходом
; set_far_dest - макрос выставления страницы и адреса для межстраничного перехода
.macro set_far_dest ppage, paddr
store far_jsr_page, ppage ; сохраним страницу
store_addr far_jsr_addr, paddr ; сохраним адрес
.endmacro
.segment "RAM" ; Сегмент неинициализированных данных в RAM
Что тут у нас нового:
Переменная glob_temp0 в zero-page — особый случай временных переменных когда мы точно знаем что они используются только в нескольких следующих инструкциях. В отличие от переменных argXw или argXb в них ничего никуда не передаётся поэтому их всегда можно портить.
Для демонстрации переключения банков кода мы реализуем технику «межстраничного перехода» — когда код находящийся в одной странице ROM картриджа вызовет код из другой страницы. Для этого нам надо передать процедуре перехода номер страницы и адрес в этой странице куда переходить. Это две следующих переменных в zero-page — far_jsr_page и fa_jsr_addr. Чтобы в одну строчку кода выставить значения в этих переменных служит макрос set_far_dest.
Идём дальше.
main.s — вторая часть
.segment "ROM_0" ; Страница данных 0 (первые 8Кб из 64 ROM картриджа) для адреса $8000
.byte $00 ; Первый байт - номер страницы
.byte "This is the first page to show. ", 0 ; Строка текста
.segment "ROM_1" ; Страница данных 1 (вторые 8Кб из 64 ROM картриджа) для адреса $8000
.byte $01 ; номер страницы
.byte "And this is text from second one", 0 ; Строка текста
.segment "ROM_2" ; Страница данных 2...
.byte $02 ; номер страницы
.byte "Third is filled with this... ", 0
.segment "ROM_3" ; Страница данных 3...
.byte $03 ; номер страницы
.byte "And nothing is wrong with fourth", 0
.segment "ROM_4" ; Страница кода 4 (пятые 8Кб из 64 ROM картриджа) для адреса $A000
.byte $04 ; номер страницы
; print_some1 - процедура вывода строки с текстом по адресу $8001 на экран
; Главное что мы тут демонстрируем - это то, что процедура эта находясь в странице $04
; будет вызывать другую процедуру из страницы $05.
.proc print_some
locate_in_vpage PPU_SCR0, 0, 3 ; Позиционируемся на символ (0,3) в SCR0
ldy # 0 ; Обнуляем индексный регистр Y
fill_loop:
lda $8001, y ; Загружаем A из адреса ($8001+Y)
beq end ; если загруженный байт нулевой - идём на выход
sta PPU_DATA ; иначе сохраняем его в VRAM
iny ; инкрементируем Y
jmp fill_loop ; и возвращаемся в цикл
end:
; В конце настроим адрес дальнего межстраничного перехода на процедуру
; inc_some находящуюся в странице $05 и вызовем её межстраничным переходом.
set_far_dest # 5, inc_some ; сохраним адрес перехода в zero-page
jsr far_jsr ; и перейдём на процедуру межстраничного перехода
rts ; возврат из процедуры
.endproc
.segment "ROM_5" ; Страница кода 5 (шестые 8Кб из 64 ROM картриджа) для адреса $A000
.byte $05 ; номер страницы
; inc_some - процедура инкрементирующая символ в SCR0
; Главное что тут демонстрируется - что она сама находясь в странице кода 5
; в адресах $A000-BFFF вызывается из страницы код 4 по тем же адресам с помощью
; переключения банков маппером и возвращается куда надо.
.proc inc_some
; Споцизионируемся на символ (10,10) в SCR0
locate_in_vpage PPU_SCR0, 10, 10
ldx PPU_DATA ; Первое считывание из VRAM - "холостое"
ldx PPU_DATA ; Второе считывание даст значение байта по адресу
; Снова позиционируемся на том же символе
locate_in_vpage PPU_SCR0, 10, 10
inx ; увеличиваем его предыдущее значение
stx PPU_DATA ; и сохраняем обратно в VRAM
rts ; Возврат из процедуры
.endproc
Как мы и договаривались в сегментах ROM_0-ROM_3 будут лежать данные в банке PRG_H0. Здесь это будут просто 4 разных строки текста заканчивающиеся нулевым байтом. Но обратите внимание, что первым байтом в каждой из переключаемых страниц задан номер этой самой страницы. Это позволит нам легко определять какая страница сейчас выбрана в любом банке. Т.е. по адресу $8000 будет лежать номер страницы в банке PRG_H0, а по адресу $A000 — номер страницы в банке PRG_H1.
Тут важно заметить вот какую вещь — в ассемблере CA65 если один и тот же сегмент наполняется в разных модулях программы, то как будут скомпонованы получившиеся части вопрос неопределённый. Поэтому для таких сегментов где местоположение данных и кода должно строго соответствовать ожидаемому нужно обязательно выполнять то условие, что они должны быть определены в программе единожды!
Итак в сегментах ROM_0-ROM_3 у нас заданы номера сегментов и разные строки текста.
Сегменты ROM_4 и ROM_5 мы отводили под код и они будут выбираться в банке PRG_H1. Так же первым байтом в них задан номер самой страницы. А вот далее идут две процедуры.
В сегменте ROM_4 это процедура print_some которая выводит в первую видеостраницу null-terminated текст с адреса $8001 и вызвает межстраничным переходом вторую процедуру в ROM_5.
Это процедура inc_some которая инкрементирует тайл по координатам (10,10) в первой видеостранице.
Обе эти процедуры находятся по одним и тем же адресам адресного пространства процессора и не могут вызывать друг друга напрямую или возвращаться друг в друга напрямую. Для того чтобы это сделать они должны передавать и получать управление в/из неизменных банков памяти — и это делается через процедуру far_jsr.
Дальше идёт ряд данных и процедур уже знакомых нам по предыдущим урокам:
main.s — третья часть
; С MMC3 в сегменте ROM_H у нас располагаются последние страницы ROM картриджа
; т.е. в данной конфигурации с 64Кб ROM - 6 и 7 по порядку.
.segment "ROM_H" ; Сегмент данных в ПЗУ картриджа (страницы $C000-$FFFF)
palettes: ; Подготовленные наборы палитр (для фона и для спрайтов)
; Повторяем наборы 2 раза - первый для фона и второй для спрайтов
.repeat 2
.byte $0F, $00, $10, $20 ; Черный, серый, светло-серый, белый
.byte $0F, $16, $1A, $11 ; -, красный, зеленый, синий
.byte $0F, $1A, $11, $16 ; -, зеленый, синий, красный
.byte $0F, $11, $16, $1A ; -, синий, красный, зеленый
.endrep
.segment "ROM_H" ; Сегмент кода в ПЗУ картриджа (страницы $C000-$FFFF)
; irq - процедура обработки прерывания IRQ
; Пока сразу же возвращается из прерывания как заглушка.
.proc irq
rti ; Инструкция возврата из прерывания
.endproc
; nmi - процедура обработки прерывания NMI
; Обрабатывает наступление прерывания VBlank от PPU (см. процедуру wait_nmi)
.proc nmi
inc vblank_counter ; Просто увеличим vblank_counter
rti ; Возврат из прерывания
.endproc
; wait_nmi - ожидание наступления прерывания VBlank от PPU
; Согласно статье https://wiki.nesdev.com/w/index.php/NMI ожидание VBlank
; опросом верхнего бита PPU_STATUS в цикле может пропускать целые кадры из-за
; специфической гонки состояний, поэтому правильнее всего перехватывать прерывание,
; в нём наращивать счётчик (процедура nmi выше) и ожидать его изменения как в коде ниже.
.proc wait_nmi
lda vblank_counter
notYet: cmp vblank_counter
beq notYet
rts
.endproc
; fill_palettes - заполнить все наборы палитр данными из адреса в памяти
; вход:
; arg0w - адрес таблицы с набором палитр (2 * 4 * 4 байта)
.proc fill_palettes
fill_ppu_addr $3F00 ; палитры в VRAM находятся по адресу $3F00
ldy # 0 ; зануляем счётчик и одновременно индекс
loop:
lda (arg0w), y ; сложный режим адресации - к слову лежащему в zero page
; по однобайтовому адресу arg0w прибавляется Y и
; в A загружается байт из полученного адреса
sta PPU_DATA ; сохраняем в VRAM
iny ; инкрементируем Y
cpy # 2 * 4 * 4 ; проверяем на выход за границу цикла
bne loop ; и зацикливаемся если она еще не достигнута
rts ; выходим из процедуры
.endproc
; fill_attribs - заполнить область цетовых атрибутов байтом в аккумуляторе
; адрес в PPU_ADDR уже должен быть настроен на эту область атрибутов!
.proc fill_attribs
ldx # 64 ; надо залить 64 байта цветовых атрибутов
loop: sta PPU_DATA ; записываем в VRAM аккумулятор
dex ; декрементируем X
bne loop ; цикл по счётчику в X
rts ; возврат из процедуры
.endproc
Данные палитр, полупустые процедуры обработки прерываний и заливки экранных областей — всё это мы уже видели.
Поэтому переходим к самому вкусному:
main.s — четвёртая часть, процедура far_jsr
; far_jsr - вызов процедуры из нетекущего банка памяти
; вход: far_jsr_page - страница памяти где находится процедура
; far_jsr_addr - адрес процедуры внутри сегмента PRG_H1
; Регистры как на входе в процедуру так и на выходе из неё не портятся.
.proc far_jsr
sta glob_temp0 ; Сохраним A во времянку.
lda $A000 ; Возьмём текущий селектор страницы в PRG_H1
pha ; и сохраним в стек.
lda # MMC3_PRG_H1 ; Выбираем банк PRG_H1
sta MMC3_BANK ; в порту ввода-вывода MMC3_BANK.
lda far_jsr_page ; Загружаем страницу процедуры в A,
sta MMC3_PAGE ; Активируем эту страницу в текущем банке
lda glob_temp0 ; Восстановим A из glob_temp0
jsr invoke ; Переходим на активатор процедуры через jsr чтобы
; возврат произошёл на следующую инструкцию
sta glob_temp0 ; Сохраним возвращённый из процедуры A во времянке
lda # MMC3_PRG_H1 ; Выбираем банк PRG_H1
sta MMC3_BANK ; в порту MMC3_BANK.
pla ; Восстанавливаем старую страницу из стека в A
sta MMC3_PAGE ; Активируем её записью в порт MMC3_PAGE
lda glob_temp0 ; Восстанавливаем возвращённый процедурой аккумулятор
rts ; и выходим.
invoke: jmp (far_jsr_addr) ; Косвенный переход на адрес хранимый в far_jsr_addr
.endproc
Процедура far_jsr — это «перемычка» между кодом в разных страницах PRG_H1. Она запоминает в стеке текущую страницу памяти в банке PRG_H1, переключает её на far_jsr_page и вызывает процедуру по адресу в слове far_jst_addr, а когда управление из этой процедуры вернётся обратно, то восстанавливает страницу в банке памяти PRG_H1 из стека и сама возвращается туда откуда её вызвали.
Это позволяет выполнять межстраничные вызовы процедур. Причём заметьте, что процедура которую вызывают не обязана вообще знать, что её вызвали межстраничным переходом. Переключение и восстановление банков полностью скрыто «под капотом» процедуры far_jsr. Даже регистры не портятся ни при вызове ни при возврате, поэтому «перемычка» полностью прозрачна.
Однако поэтому она очень «толстая» и в десяток другой раз медленнее чем обычный вызов процедуры jsr proc_name. Поэтому для скорости кода всегда предпочитайте прямое выставление банков памяти и вызов процедур напрямую, а такой вот универсальный межстраничный переход используйте только в случае крайней необходимости.
Тем не менее, имхо, он является отличной демонстрацией переключения банков.
Переходим к основному телу программы. Инициализация:
main.s — пятая часть
; reset - стартовая точка всей программы - диктуется вторым адресом в сегменте
; VECTORS оформлена как процедура, но вход в неё происходит при включении консоли
; или сбросу её по кнопке RESET, поэтому ей некуда "возвращаться" и она
; принудительно инициализирует память и стек чтобы работать с чистого листа.
.proc reset
; ***********************************************************
; * Первым делом нужно привести систему в рабочее состояние *
; ***********************************************************
sei ; запрещаем прерывания
ldx # $FF ; чтобы инициализировать стек надо записать $FF в X
txs ; и передать его в регистр вершины стека командой
; Transfer X to S (txs)
sta MMC3_IRQ_OFF ; Выключим IRQ маппера
; Теперь можно пользоваться стеком, например вызывать процедуры
jsr warm_up ; вызовем процедуру "разогрева" (см. neslib.s)
store_addr arg0w, palettes ; параметр arg0w = адрес наборов палитр
jsr fill_palettes ; вызовем процедуру копирования палитр в PPU
fill_page_by PPU_SCR0, # 7 ; зальём SCR0 тайлом №7
fill_ppu_addr PPU_SCR0_ATTRS ; настроим PPU_ADDR на атрибуты SCR0
lda # 0 ; выберем в A нулевую палитру
jsr fill_attribs ; и зальём её область атрибутов SCR0
; Предварительно выставим банки памяти PPU просто по порядку
mmc3_set_bank_page # MMC3_CHR_H0, # 0
mmc3_set_bank_page # MMC3_CHR_H1, # 2
mmc3_set_bank_page # MMC3_CHR_Q0, # 4
mmc3_set_bank_page # MMC3_CHR_Q1, # 5
mmc3_set_bank_page # MMC3_CHR_Q2, # 6
mmc3_set_bank_page # MMC3_CHR_Q3, # 7
; Предварительно выставим банки памяти CPU
mmc3_set_bank_page # MMC3_PRG_H0, # 0 ; В банке данных выберем страницу 0
mmc3_set_bank_page # MMC3_PRG_H1, # 5 ; В банке кода выберем страницу 5
store MMC3_MIRROR, # MMC3_MIRROR_V ; Выставим вертикальное зеркалирование
store MMC3_RAM_PROTECT, # 0 ; Отключим RAM (если бы она даже была)
; Отключим все спрайты выводом их за границу отрисовки по Y
ldx # 0 ; В X разместим указатель на текущий спрайт
lda # $FF ; В A координата $FF по Y
sz_loop:
sta SPR_TBL, x ; Сохраним $FF в координату Y текущего спрайта
inx
inx
inx
inx ; И перейдём к следующему
bne sz_loop ; Если X не 0, то идём на следующую итерацию
; **********************************************
; * Стартуем видеочип и запускаем все процессы *
; **********************************************
; Включим генерацию прерываний по VBlank и источником тайлов для спрайтов
; сделаем второй банк видеоданных где у нас находится шрифт.
store PPU_CTRL, # PPU_VBLANK_NMI | PPU_SPR_TBL_1000
; Включим отображение спрайтов и то что они отображаются в левых 8 столбцах пикселей
store PPU_MASK, # PPU_SHOW_BGR | PPU_SHOW_LEFT_BGR | PPU_SHOW_SPR | PPU_SHOW_LEFT_SPR
cli ; Разрешаем прерывания
Сперва здесь всё происходит как обычно, но одним из первых действий деактивируются прерывания маппера, а потом уже начинается процедура разогрева.
Далее заполняются палитры и экран заливается тайлом-полоской (для следующего урока) и выставляется маппинг видеопамяти.
Выставляется он в самые первые страницы видеоROM так как будто бы маппинга и нету — банк видеоданных «bank0» поступает в видеопамять в неизменённом виде.
Следом выставляются страницы PRG_H0 и PRG_1 и выставляются режим зеркалирования и на всякий случай блокируется отсутствующий чип SRAM.
В этом уроке это на самом деле не надо, но мы включим в нём спрайты, поэтому все спрайты в SPR_TBL прячутся за нижним краем экрана и наконец то включается видеочип.
Основной цикл программы выглядит так:
main.s — конец
; ***************************
; * Основной цикл программы *
; ***************************
main_loop:
jsr wait_nmi ; ждём наступления VBlank
; Чтобы обновить таблицу спрайтов в видеочипе надо записать в OAM_ADDR ноль
store OAM_ADDR, # 0
; И активировать DMA записью верхнего байта адреса страницы с описаниями
store OAM_DMA, # >SPR_TBL
lda $8000 ; загрузим в A номер страницы в банке PRG_H0
sta arg0b ; сохраним его в arg0b
; если не нажата KEY_A - идём дальше
jump_if_keys1_was_not_pressed KEY_A, skip_A
inc arg0b ; иначе инкрементируем arg0b
skip_A:
; если не нажата KEY_B - идём дальше
jump_if_keys1_was_not_pressed KEY_B, skip_B
dec arg0b ; иначе декрементируем arg0b
skip_B:
lda # 3 ; загружаем в A 3,
and arg0b ; накладываем по AND с arg0b (т.е. оставляем только 2 нижних бита)
sta arg0b ; и сохраняем обратно в arg0b (замкнули значение в диапазоне 0-3)
; Выставляем текущей страницей в PRG_H0 значение в arg0b
mmc3_set_bank_page # MMC3_PRG_H0, arg0b
set_far_dest # 4, print_some ; выставим банк 4 и адрес print_some как
; цель межстраничного перехода
jsr far_jsr ; и совершим межстраничный переход
store PPU_SCROLL, # 0 ; Перед началом кадра выставим скроллинг
store PPU_SCROLL, # 0 ; в (0, 0) чтобы панель рисовалась фиксированно
; ********************************************************
; * После работы с VRAM можно заняться другими вещами... *
; ********************************************************
jsr update_keys ; Обновим состояние кнопок опросив геймпады
jmp main_loop ; И уходим ждать нового VBlank в бесконечном цикле
.endproc
Спрайты (спрятанные) выводятся на экран и по нажатию кнопок (A) и (B) переключается банк памяти с данными — по восходящей или нисходящей последовательности, но так что номер остаётся заключён в диапазоне 0-3.
После чего осуществляется дальний вызов процедуры print_some в банке 4. Она, как мы помним, в свою очередь вызовет процедуру inc_some из банка 5 и та увеличит номер тайла (10,10) на экране.
Посмотрим что получилось в результате:
Когда нажимаются кнопки (A) или (B) текст начинает считываться из другого банка памяти и всё время на экране меняется тайл в процедуре вызываемой межстраничным переходом. Работает!
В следующей статье мы рассмотрим переключение банков видеоданных, а самое главное — обработка прерываний от счётчика сканлайнов — намного более совершенная техника контроля HBlank-отсечения который мы рассматривали в уроке про «ушибленный спрайт».
В первую часть (оглавление)...
0 комментариев