Программирование для 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Кб):

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
Следуя этой схеме мы задаём кускам памяти ROM_0-ROM_3 атрибуты start = $8000 и size = $2000, а кускам памяти ROM_4-ROM_5: start = $A000 и size = $2000.
Далее в секции 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 комментариев

Только зарегистрированные и авторизованные пользователи могут оставлять комментарии.