Программирование для Famicom/NES/Денди в Nesicide+ca65: модуль neslib (3)

Пример 1 — скроллинг заднего фона — neslib

Тот тестовый проект Hello world что мы создали из шаблона Nesicide работает, но мы сейчас переделаем его полностью — от него останется только каркас проекта и два битмапа с двумя наборами тайлов где присутствуют изображения символов текста. Текущий набор исходников можно скачать тут: yadi.sk/d/_THxg1gxuCCVNw — учтите, что у меня они создавались в папке c:\devel\nes и проще всего развернуть их там же.



Перво наперво возвращаемся в Project -> Project properties в пункт «Linker» и заменяем тут содержимое файла конфигурации линкера на следующее:

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_L:		start = $8000,	size = $4000, type = ro, file = %O, fill=yes, fillval = $DD;
	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_L:		load = ROM_L,	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 для заголовка образа iNES.
Следом за ним идёт ZPAGE — начинается с адреса 0 и имеет размер 256 ($100) байт. Это та самая нулевая страница zero page.
Кусок памяти RAM соответствует оперативной памяти консоли которой всего 2Кб (включая zero page конечно же), т.е. $800 байт.
Мы знаем что сразу за zero page с адреса $0100 начинается страница стека ($100 байт) и её действительно нежелательно включать в куски памяти и сегменты, т.к. там не должно выделятся место под идентификаторы — стек вещь полностью динамическая и будет формироваться в процессе работы программы. Хотя все 256 байт стековой страницы под стек в машине с такой философией — это слишком жирно, поэтому весьма солидную порцию начала этой страницы скорее всего можно будет занять под еще что-нибудь. Но как минимум кусок RAM надо начинать с адреса $0200, но на деле еще лучше зарезервировать следующие $100 байт под буфер таблицы спрайтов и начать кусок RAM с адреса $0300. Тогда его длина составит остаток — $500 т.е. 1280 байт.
Дальше о грустном — Famicom/NES/Денди спроектирована так, что кроме ОЗУ в первые 16 Кб адресного пространства замаплены еще пара десятков портов ввода-вывода, но никак больше с пользой применить их нельзя. Фактически там просто зияют дыры зеркалирования ОЗУ и зеркалирования портов ввода-вывода (что значит что просто декодер адреса игнорирует некоторые линии адресной шины).
В следующих 16Кб изначально тоже зияли дыры, но с помощью чипов расширения картриджа их можно было пустить в дело с пользой, но про это позже. В последние 32Кб маппилась микросхема ПЗУ в 16 или 32 Кб с картриджа и при этом если был маппер и он позволял их переключать на другие страницы, то чаще всего переключать можно было только нижние 16Кб ПЗУ ($8000-BFFF), а верхние ($C000-FFFF) всегда оставались неизменными.
Вот поэтому я заранее разбил область ROM картриджа на две зоны ROM_L (low, нижняя половина) и ROM_H (high, верхняя половина). Пока мы будем поступать так — в первой будем размещать статичные данные, а во второй — код. Поэтому еще я задал байт заполнения для нижней области в $DD (как бы от data), а верхней — $CC (от code). Так что если рассматривать итоговый образ картриджа в двоичном редакторе, то неиспользованные области этих зон будут хорошо видны.

Далее идёт определение сегментов. В принципе они тут почти все прямолинейно отображаются на свои одноимённые куски памяти. Замечу только еще что атрибут align следует задавать если хочется использовать директиву ассемблера .align выравнивающую данные или код по границам адресов, при этом в ca65 действует правило, что параметр .align в коде не может превышать align в определении сегмента. Поэтому они тут заданы. Единственный сегмент выбивающийся из схемы — VECTORS мы уже обсудили.
Вообще традиции настаивают на других названиях сегментов, но мне нравится чтобы всё было прямолинейно. Так что пока будем делать всё так.
Сохраним эти изменения и закроем диалог свойств проекта.

Далее раскрываем в дереве проекта ветку Source Code и открываем в редакторе файл header.s. Меняем его на следующее:

header.s

.segment "HEADER"	; Переключимся на сегмент заголовка образа картриджа iNES

MAPPER		= 0	; 0 = без маппера
MIRRORING	= 1	; зеркалирование видеопамяти: 0 - горизонтальное, 1 - вертикальное
HAS_SRAM	= 0	; 1 - есть SRAM (как правило на батарейке) по адресам $6000-7FFF

.byte "NES", $1A	; заголовок
.byte 2 		; число 16-килобайтных банков кода/данных
.byte 1 		; число 8-килобайтных банков графики (битмапов тайлов)
; флаги зеркалирования, наличия SRAM и нижние 4 бита номера маппера
.byte MIRRORING | (HAS_SRAM << 1) | ((MAPPER & $0F) << 4)
.byte MAPPER & $F0	; верхние 4 бита номера маппера
.res 8, 0		; восемь нулевых байт (ключевое слово .res от "reserve")

Т.е. начинаем писать в сегмент HEADER и если кто-то совсем не знает ассемблера, то поясню.
Директивы ассемблера (ключевые слова) ca65 начинаются с точки. Директива .segment говорит что весь код и данные ниже будут записываться в соответствующий сегмент. Строки в которых идентификаторам через знак = присваиваются значения не генерируют ни кода ни данных, а только лишь создают compile-time идентификаторы, так сказать константы времени компиляции.
А вот директива .byte уже вставляет в текущий сегмент данные-байты которые через запятую перечисляются справа от неё. Заметьте, что можно указать строки и они не являются null-terminated как в Си и если такое надо, то надо делать явно: .byte «строка», 0.
Комментарии начинаются с символа точка-с-запятой и остаётся еще директива создания сразу массива байт — .res у которой первый обязательный параметр — сколько байт сгенерировать, а второй необязательный — какое значение им задать (0 по умолчанию).
Сохраняем этот файл и закрываем.

Далее надо удалить из проекта файл src/nes.h. Причём после того как вы удалите его (правая кнопка -> «Remove...») из дерева проекта, то он еще останется на диске — удалите и оттуда.
Вместо него создадим новый файл: правая кнопка мыши на ветке Source Code -> New -> Source File…
В открывшемся диалоге вбиваем в поле Name: src/neslib.inc (обратите внимание что не надо трогать поле Path — подпапку src надо писать прям в поле Name!). Это будет заголовочный файл модуля для небольшой библиотечки упрощающей наши первые шаги.
Сохраните пока и закройте. Таким же образом создайте, сохраните и закройте файл src/neslib.s — это будет тело модуля.

Теперь вернёмся в neslib.inc и начнём заполнять его кодом.
Но прежде чем делать это я хочу чтобы вы сразу увидели что должно получится в финале. Когда уже увидел результат, то проще будет понимать и код:



Т.е. что мы тут имеем — это бесконечно скроллящееся кнопками направлений поле из символов с двумя половинками — с белыми символами и синими. Каждая размером 32x30 символов/тайлов, т.е. это те экранные зоны или nametables. Кнопка SELECT сбрасывает параметры скроллинга в (0,0). Изображения тайлов хранятся в первых 8Кб VRAM видеочипа (PPU) — в проекте их можно найти в дереве Graphics Banks \ bank0. Откройте чтобы увидеть, что там два банка — в каждом 256 тайлов 8x8 пикселей для удобства восприятия выстроенные тут в квадратную сетку 16x16 тайлов. Первый банк пуст, а второй содержит те самые изображения символов текста — их то наша программа и выводит. Удачно для начала так же то, что эти символы лежат в своём банке по номерам тайлов которые совпадают с их ASCII-кодами, что позволяет легко их использовать. Левый-верхний тайл имеет номер 0, далее номера нарастают по строке, 16 в строке и таким образом, как видно, пробел имеет номер 32 ($20).
Тайлы имеют цветность 2 бита на пиксель, т.е. они четырёхцветные и раскрашиваются в финале в одну из восьми палитр — четыре для фона и четыре для спрайтов. В IDE эти банки представлены как обычные png-картинки, которые можно редактировать любым умеющим это делать редакторе — главное не выходить за рамки обозначенных четырёх цветов. Они раскрашены чёрно-серо-белыми цветами, но повторюсь что их следует рассматривать только как четыре индекса от 0 до 3 которые в финале обозначат цвет из назначенной палитры. Nesicide сам конвертирует эти картинки в битовое представление нужное для Famicom и присоединяет в конец образа картриджа iNES при компиляции проекта, поэтому мы нигде эти банки не упоминаем кроме как количество их пар в header.s.
В общем если кто-то еще не знает или забыл как соотносятся между собой битмапы тайлов (CHR/Characters) и экранные области (Nametables), то досконально всё пересказывать не вижу смысла, т.к. писал уже тут, можете освежить память.

Итак, переходим в редакторе в neslib.inc и начинаем заполнять его кодом:
(рекомендую сперва бегло пробежать глазами по коду особо не пытаясь сразу всё понять и сразу читать текст под кодом, там скорее всего будет пояснение на что надо, а на что не надо обращать внимание и в каком порядке — это правило справедливо будет всегда в этих статьях)

neslib.inc — начало

; ***************************************
; *   Famicom/NES/Денди API и утилиты   *
; ***************************************

; "Охранный ifndef" для исключения перекрёстных множественных подключений (как в C/C++)
.ifndef NESLIB_INC
NESLIB_INC		= 1

.p02	; режим процессора - MOS 6502

; *****************
; *  PPU (ВИДЕО)  *
; *****************

; *** PPU_CTRL - управление PPU (запись), ниже описаны битовые маски
PPU_CTRL		= $2000
; Бит 0: скроллинг по X увеличен на 256 (базовая экранная область справа)
PPU_SCR_X256		= %00000001
; Бит 1: скроллинг по Y увеличен на 240 (базовая экранная область снизу)
PPU_SCR_Y240		= %00000010
; Бит 2: PPU_ADDR инкрементируется на 32 (иначе - на 1)
PPU_ADDR_INC32		= %00000100
; Бит 3: таблица тайлов спрайтов находится по адресу $1000 (иначе - $0000)
PPU_SPR_TBL_1000	= %00001000
; Бит 4: таблица тайлов фона находится по адресу $1000 (иначе - $0000)
PPU_BGR_TBL_1000	= %00010000
; Бит 5: режим спрайтов 8x16 (иначе 8x8)
PPU_SPR_8x16		= %00100000
; Бит 6: подчинённый режим видео, не используется, выставлять крайне не рекомендуется.
;PPU_SLAVE_MODE		= %01000000	;	Не использовать!
; Бит 7: разрешение прерывания NMI по сигналу VBlank (когда PPU отрисовал кадр и можно с ним работать)
PPU_VBLANK_NMI		= %10000000

; *** PPU_MASK - флаги PPU (запись)
PPU_MASK		= $2001
; Бит 0: режим градаций серого
PPU_GRAYSCALE		= %00000001
; Бит 1: показывать задний фон в 8 левых столбцах пикселей экрана 
; (иначе весь этот столбец будет нулевого цвета)
PPU_SHOW_LEFT_BGR	= %00000010
; Бит 2: показывать спрайты в 8 левых столбцах пикселей экрана
; последние два флага полезны для реализации плавного появления
; спрайтов из-за левой границы экрана, т.к. спрайты не могут иметь координату меньше нуля
PPU_SHOW_LEFT_SPR	= %00000100
; Бит 3: показывать задний фон
PPU_SHOW_BGR		= %00001000
; Бит 4: показывать спрайты
; отключение двух последних бит выключает PPU и с ним можно работать не только в VBlank
PPU_SHOW_SPR		= %00010000
; Биты 5-7: выставленные биты выделяют свою компоненту RGB-цвета на экране приглушая остальные две
; в результате если выставить их все, то картинка в целом становится темнее
PPU_TINT_R		= %00100000
PPU_TINT_G		= %01000000
PPU_TINT_B		= %10000000

; *** PPU_STATUS - состояние PPU (чтение)
PPU_STATUS		= $2002
; Бит 5: появилось переполнение спрайтов по строке (не более 8 в сканлайне)
PPU_STAT_OVERFLOW	= %00100000
; Бит 6: отрисовался первый непрозрачный пиксель спрайта номер 0
PPU_STAT_SPR0_HIT	= %01000000
; Бит 7: начался период VBlank. Этот бит сам сбрасывается аппаратурой в двух случаях:
; 1) при считывании PPU_STATUS (т.е. этого же самого порта)
; 2) при окончании периода VBlank
PPU_STAT_VBLANK		= %10000000

; *** OAM_ADDR - байт адреса данных спрайтов в PPU
; Запись в этот порт портит само содержимое данных спрайтов, поэтому
; рекомендуется использовать OAM_DMA
OAM_ADDR		= $2003
; *** OAM_DATA - запись в данные спрайтов (по адресу OAM_ADDR)
; Прямое использование имеет ряд проблем, лучше использовать OAM_DMA
OAM_DATA		= $2004
; *** OAM_DMA - при записи байта XX передаст из адресного пространства процессора
; 256 байт начиная с адреса $XX00 в память спрайтов PPU. 
; Рекомендованная схема работы: записать $00 в OAM_ADDR и активировать OAM_DMA.
OAM_DMA			= $4014
; *** PPU_SCROLL - первая запись выставляет скроллинг по X (0-255), а вторая - по Y (0-239)
; в пределах базовой экранной области (нижние биты PPU_CTRL). Пиксель с координатами
; (x, y) - левый верхний на экране, но надо учитывать что на NTSC первые 8 сканлайнов
; экрана не видно. PPU_SCROLL и PPU_ADDR делят одни и те же внутренние регистры PPU, поэтому
; PPU_SCROLL следует заполнять после всех записей в PPU_ADDR до начала VDraw.
PPU_SCROLL		= $2005
; *** PPU_ADDR - адрес в VRAM куда будет осуществляться доступ через PPU_DATA
; Первая запись выставляет старший байт, вторая - младший. Делит регистры с PPU_SCROLL.
PPU_ADDR		= $2006
; *** PPU_DATA - запись пишет в VRAM байт, а чтение - получает данные из временного регистра, а
; во временный регистр помещает прочитанные из VRAM данные, т.е. отстаёт на один шаг.
; После доступа продвигает PPU_ADDR на 1 или 32 байта (бит PPU_ADDR_INC32 в PPU_CTRL).
PPU_DATA		= $2007

; ******************
; *  INPUT (ВВОД)  *
; ******************

; *** JOY_PAD1 - порт управления вводом (при записи) и считывания первого геймпада (при чтении)
JOY_PAD1		= $4016
; *** JOY_PAD2 - считывание второго геймпада (по совместительству APU_FRAME_COUNTER)
JOY_PAD2		= $4017

; Битовые маски кнопок стандартных геймпадов
KEY_RIGHT		= %00000001
KEY_LEFT		= %00000010
KEY_DOWN		= %00000100
KEY_UP			= %00001000
KEY_START		= %00010000
KEY_SELECT		= %00100000
KEY_B			= %01000000
KEY_A			= %10000000

; ******************
; *   APU (ЗВУК)   *
; ******************

; *** APU_PULSE1 - первый канал "прямоугольного" звука (меандр)
APU_PULSE1_0		= $4000
APU_PULSE1_1		= $4001
APU_PULSE1_2		= $4002
APU_PULSE1_3		= $4003

; *** APU_PULSE2 - второй канал "прямоугольного" звука
APU_PULSE2_0		= $4004
APU_PULSE2_1		= $4005
APU_PULSE2_2		= $4006
APU_PULSE2_3		= $4007

; *** APU_TRIANGLE - канал пилообразного звука
APU_TRIANGLE_0		= $4008
APU_TRIANGLE_2		= $400A
APU_TRIANGLE_3		= $400B

; *** APU_NOISE - канал шумового звука
APU_NOISE_0		= $400C
APU_NOISE_2		= $400E
APU_NOISE_3		= $400F

; *** APU_DMC - канал DPCM-звука
APU_DMC_0		= $4010
APU_DMC_1		= $4011
APU_DMC_2		= $4012
APU_DMC_3		= $4013

; *** APU_CONTROL/APU_STATUS - управление звуков (запись) и опрос состояния звука (чтение)
APU_CONTROL		= $4015
APU_STATUS		= $4015
; Флаги участвующие и при чтении и при записи
APU_PULSE1_ON		= %00000001
APU_PULSE2_ON		= %00000010
APU_TRIANGLE_ON		= %00000100
APU_NOISE_ON		= %00001000
APU_DMC_ON		= %00010000
; Флаги только на запись (APU_CONTROL)
APU_FRAME_IRQ		= %01000000
APU_DMC_IRQ		= %10000000

; *** APU_FRAME_COUNTER - управление секвенсором аудио
APU_FRAME_COUNTER	= $4017
; Бит 6 - запрещение прерываний IRQ от секвенсора
; (судя по всему это IRQ - менее удобная альтернатива VBlank NMI и не особо нужна)
APU_FC_IRQ_OFF		= %01000000
; Бит 7 - 5-шаговый режим секвенсора (4-шаговый если 0)
; Прим.: в 5-шаговом режиме IRQ от секвенсера никогда не генерируется
APU_FC_5_STEP		= %10000000

Т.к. мы работаем с независимо компилируемыми модулями как в Си, то заголовочный файл полностью соответствует в идеологии тому что вы скорее всего знаете из Си. В нём декларируются константы времени компиляции, экспортируемые идентификаторы (символы) и макросы. Заголовочный файл подключают в себя как просто текст директивой .include все модули которые хотят «увидеть» всё что данный модуль хочет им экспортировать. Но поэтому в нём никогда не должно быть выделения переменных или объявлений тел процедур — иначе они будут многократно повторены во всех подключивших заголовочный файл модулях. Выделения переменных и объявления тел процедур должны находится в теле модуля — у нас файле с расширением *.s. Здесь возникает проблема перекрёстных включений и решается она очень просто — охранной директивой .ifndef которая в конце модуля имеет парный .endif — это директива условной компиляции которая пропустит всё между своим началом и концом если не выполнится условие. В ca65 есть обобщённая директива условной компиляции .if, но для простоты существует .ifndef (if not defined) которая принимает имя идентификатора и если он уже определён, то выкидывает своё тело из исходного кода который поглощает ассемблер.
А если идентификатор еще не определён — то первая же инструкция в теле определяет его. Насколько мне известно всё это Си как раз позаимствовал у ассемблеров своего времени и недостатки такой древней схемы сильно аукаются в C++ в силу того что «модули ненастоящие», но это тема другого разговора.

Далее в neslib.inc у нас находятся объявления символьных имён портов ввода-вывода Famicom/NES/Денди и символьных представлений значений их бит. Не все из них сразу мы будем использовать, поэтому если что-то не описано — значит сейчас нам не надо. У MOS 6502 нет специализированного механизма портов ввода-вывода как в i8080 или Z80, поэтому они маппятся в ячейки памяти. В Famicom — на адреса начинающиеся с $2000 (это внешнее по отношению к чипу Ricoh 2A03, главным образом нас интересует PPU) и на адреса начинающиеся с $4000 (а это уже внутренние схемы Ricoh 2A03, такие как генератор звука и DMA).
Многие определения снабжены краткими комментариями, так что если любопытно можете их поразглядывать, но наверное лучше вернуться к ним когда мы дойдём до кода их использующего и я начну их пояснять по его ходу более подробно.
Следом идёт несколько полезных макросов:

neslib.inc — вторая часть

; ********************
; * Полезные макросы *
; ********************

; store dest, src - сохранить байт в память
; чтобы избежать обильных многословных конструкций вида из:
;	lda значение
;	sta переменная	; переменная = значение
; которые заполняют переменные в памяти этот макрос
; позволяет записывать в одну строку:
;	store переменная, значение	; переменная = значение
; dest и src могут быть любыми аргументами инструкций lda/sta
; так что обратите внимание, что нужен префикс # для констант!
; портит аккумулятор!
.macro store dest, src
	lda src
	sta dest
.endmacro

; store_addr dest, addr - сохранить адрес в слово в памяти
; чтобы избежать многословных конструкций вида:
;	lda # < addr	; загрузить lsb байт адреса
;	sta dest	; сохранить в начало слова
;	lda # > addr	; загрузить msb байт адреса
;	sta dest + 1	; сохранить в конец слова
; которые сохраняет адрес переменной в слово в памяти этот макрос
; позволяет записывать в одну строку:
;	store_addr переменная, адрес	; переменная = адрес
; dest и addr должны быть адресами в памяти
; портит аккумулятор!
.macro store_addr dest, addr
	lda # < (addr)
	sta dest
	lda # > (addr)
	sta dest + 1
.endmacro

; store_word dest, word - сохранить слово в память.
; по сути то же самое что и store_addr, но название
; подчёркивает что сохраняется данное, а не адрес.
; портит аккумулятор!
.macro store_word dest, word
	lda # < (word)
	sta dest
	lda # > (word)
	sta dest + 1
.endmacro

; fill_ppu_addr - записать в PPU_ADDR адрес в VRAM
; чтобы избежать многословной конструкции (в теле макроса)
; можно записать в одну строку 
;	fill_ppu_addr адрес-в-vram
; vaddr должен быть адресом, переставлять нижний и верхний
; байты не нужно
; портит аккумулятор!
.macro fill_ppu_addr vaddr
	lda # > (vaddr)
	sta PPU_ADDR
	lda # < (vaddr)
	sta PPU_ADDR
.endmacro

; jump_if_keys1_is_not_down - перейти на метку label если в keys1_is_down
; не зажжён хотя бы один бит в переданном сканкоде key_code.
; Т.е. можно передать несколько битовых паттернов кнопок 
; наложенных по OR и если хоть одна окажется нажата - перехода не будет.
; портит аккумулятор!
.macro jump_if_keys1_is_not_down key_code, label
	lda keys1_is_down
	and # key_code
	beq label
.endmacro

; jump_if_keys2_is_not_down - перейти на метку label если в keys2_is_down
; не зажжён хотя бы один бит в переданном сканкоде key_code.
; портит аккумулятор!
.macro jump_if_keys2_is_not_down key_code, label
	lda keys2_is_down
	and # key_code
	beq label
.endmacro

Тут дело вот в чём — столкнувшись с программированием на 6502 на практике я сразу увидел что весь код покрывается безобразным полотном из одних и тех же конструкций:

lda что-то
sta куда-то

И значительная часть кода начала превращаться в полотно из пар таких инструкций, потому что в 6502 данное сохранить куда-то его надо сперва загрузить в регистр. Мне сильно не понравился разрыв по строкам между этими «что-то» и «куда-то», поэтому я написал макросы с помощью директив .macro и .endmacro. Макросы просто подставляют написанное между этими директивами тело туда где упоминается их имя, при этом кроме имени возможно передать параметры которые опять таки подставятся в тело макроса по месту применения.
В макросах save_word и save_addr проскакивает еще одна особенность ассемблера 6502 — если нужно из слова получить нижний байт, то используется префикс < (как бы от слова «меньше»), а если верхний, то > (от «больше»). По мне они мне сильно причесали и разгрузили код, и наверное я их слишком подробно задокументировал в самом коде, так что идём дальше.
Макросы jump_if_keysX_is_not_down лучше рассмотреть позже, когда уже будет понятно с чем они работают.
Остаток файла такой:

neslib.inc — конец

; ****************************************
; * Экспорт/импорт глобальных переменных *
; ****************************************

; Экспорт/импорт из zero page надо делать через .globalzp
.globalzp arg0w
.globalzp arg1w
.globalzp arg2w
.globalzp arg3w

.globalzp arg0b
.globalzp arg1b
.globalzp arg2b
.globalzp arg3b
.globalzp arg4b
.globalzp arg5b
.globalzp arg6b
.globalzp arg7b

.globalzp keys1_is_down
.globalzp keys2_is_down

; Экспорт/импорт из остальной памяти - .global
.global keys1_was_pressed
.global keys2_was_pressed
.global update_keys
.global warm_up

.endif

В этой секции экспортируются символы во внешние модули. Очень похоже на extern в Си опять таки. Все метки (имена переменных и процедур) которые модуль хочет чтобы видели другие модули надо описать здесь в ключевых словах .global и .gloablzp (можно помногу за раз через запятую). Здесь пока хочу обратить внимание только на пару вещей: во первых идентификаторы определяемые через = в самом заголовке не надо экспортировать — они не создают внутри образа с картриджем каких то сущностей и существуют как удобные мнемонические константы только во время компиляции. Во вторых если экспортируемая переменная или процедура (и то и другое называется обобщённо меткой или символом) находится в zero page, то ассемблеру надо сообщить об этом используя директиву .globalzp, иначе он не сможет сформировать правильный объектный файл для линкера и линкер будет выдавать ошибку.
Определим заголовом модуля neslib сохраним его и начнём заполнять тело модуля — файл neslib.s:

neslib.s — начало

.include "neslib.inc"	; подключим заголовк neslib.inc

; Сегмент нулевой страницы zero page (помечен явно через :zp).
.segment "ZPAGE": zp

; Временные переменные и параметры в zero page общим объёмом 8 байт.
; Если процедуры используют их как входные параметры или портят, то 
; это следует описать в комментариях.
; Четыре двухбайтовых слова или адреса...
arg0w:		.word 0
arg1w:		.word 0
arg2w:		.word 0
arg3w:		.word 0
; ...и восемь байт, которые занимают места в соответсвующих словах по порядку,
; т.е., например, arg2w и arg4b/arg5b занимают одно и то же место в zero page.
arg0b		= arg0w + 0
arg1b		= arg0w + 1
arg2b		= arg1w + 0
arg3b		= arg1w + 1
arg4b		= arg2w + 0
arg5b		= arg2w + 1
arg6b		= arg3w + 0
arg7b		= arg3w + 1

Первым делом мы подключаем neslib.inc чтобы сразу объявить всё что в нём находится. Другие модули будут делать то же самое чтобы просто «увидеть» то что в этом модуле экспортируется, а в самом модуле же мы начнём всё это определять/создавать.

Переключимся на генерацию кода и данных в сегмент ZP — наш zero page. Из-за того что я в файле nes.ini использовал нестандартное имя ZPAGE (стандартное — ZEROPAGE) через двоеточие надо явно обозначить его сущность как zp, иначе опять таки ассемблер не поймёт сам и линкер начнёт выдавать ошибки.
Далее мы выделяем директивами .word места под четыре слова которые изначально заполнены нулевыми словами. Но делаем это предварительно прописав метки — в начале строк идентификаторы arg0w...arg3w после которых расположено двоеточие. Это означает что будет создана метка привязанная к текущему адресу — тому адресу где сейчас будет (после метки) выделяться место под следующий байт для кода или данных. Именно метки и создают как бы имена переменных, процедур и точек для передачи управления являясь в терминах ассемблера «символами» указывающими на какой то адрес в памяти. Метки будут, например, видны в отладчике.
Теперь главное — зачем нам эти 4 слова в zero page? Zero page не очень большая область памяти, но желанная из-за быстрой адресации и некоторых режимов адресации, поэтому как можно больше места в ней надо повторно использовать. Довольно стандартным, как я понял, является такой трюк, что первые 16 байт в zero page вообще не включаются в сегмент «ZEROPAGE» (мы это видели в первой статье) чтобы процедуры пользовались ими как хотели — для передачи параметров или размещения временных переменных. Мне такой подход не очень понравился, поэтому я в сегмент «ZPAGE» включаю все первых 256 байт памяти, а временные переменные и параметры выделяю здесь явно. Неизвестно в какие именно адреса после линковки всех модулей они попадут, поэтому все модули которые захотят ими пользоваться должны их импортировать (например просто подключив neslib.inc).
Но чаще всего в 8-битном 6502 нам нужны не слова, а байты, однако размещать еще отдельно место под байтовые переменные излишне. Поэтому следом мы определяем символы arg0b...arg7b которые присваиванием приравниваются меткам argXw с разными смещениями. Что тут происходит: идентификаторы определенные через присваивание тоже можно рассматривать как метки и в целом они ими и являются и их можно присваивать другим меткам — в дальнейшем они поведут себя точно так же. Поэтому в общем то это тоже символы, но из-за того что в них можно сохранять и просто числовые константы, то на каком то этапе компиляции их пути несколько расходятся и в окне «Symbol Inspetor» отладчика, к примеру, по крайней мере по умолчанию их не видно. Однако для нас они станут хорошим подспорьем позволяя создавать псевдонимы других меток, особенно когда этим вот временным переменным надо временно же дать более читаемые имена. Как написано в комментарии байтовые времянки занимают места в тех же вермянках словах по порядку, так что их нельзя использовать одновременно.

Переходим к следующей части кода — работе с геймпадами:

neslib.s — середина

; Текущие нажатые на геймпадах кнопки (на момент последнего вызова update_keys).
keys1_is_down:	.byte 0
keys2_is_down:	.byte 0

; Сегмент неинициализированных данных в RAM консоли.
; Все заданные здесь переменные и данные должны быть заполнены нулями
; иначе линкер будет ругаться на инициализированную переменную. 
; Однако во время запуска программы их содержимое неизвестно и будет 
; занулятся явным образом в процедуре warm_up.
.segment "RAM"			
				
; Кнопки нажатые на геймпадах во время предыдущего вызова update_keys.
keys1_prev:	.byte 0
keys2_prev:	.byte 0
; Кнопки которые не были нажаты на предыдущем вызове update_keys и оказавшиеся нажатыми на текущем.
keys1_was_pressed:	.byte 0
keys2_was_pressed:	.byte 0

; Сегмент кода в ROM картриджа, причём последние его 16 Кб ($C000-FFFF).
; Третья четверть ROM ($8000-BFFF) пока зарезервирована под использование с мапперами.
.segment "ROM_H"

; update_keys - перечитать кнопки с геймпадов 1 и 2.
; текущие зажатые кнопки -> keysX_is_down
; предыдущие зажатые кнопки -> keysX_prev
; кнопки нажатые между этими состояниями -> keyX_was_pressed
; Код адаптирован из https://wiki.nesdev.com/w/index.php/Controller_reading_code
.proc update_keys
	; Сохраняем предыдущие нажатые кнопки
	store keys1_prev, keys1_is_down
	store keys2_prev, keys1_is_down
	; Инициируем опрос геймпадов записью 1 в нижний бит JOY_PAD1
	lda # $01		; После записи в порт 1 состояния кнопок начинают в геймпадах
	sta JOY_PAD1		; постоянно записываться в регистры-защёлки...
	sta keys2_is_down	; Этот же единичный бит используем для остановки цикла ниже
	lsr a			; Обнуляем аккумулятор (тут быстрее всего сделать это сдвигом вправо)
	sta JOY_PAD1		; Запись 0 в JOY_PAD1 фиксирует регистры-защёлки и их можно считывать
	
loop:	lda JOY_PAD1		; Грузим очередную кнопку от первого контроллера
	and # %00000011		; Нижний бит - стандартный контроллер, следующий - от порта расширения
	cmp # $01		; Бит Carry установится в 1 только если в аккумуляторе не 0 (т.е. нажатие)
	rol keys1_is_down	; Прокрутка keys1_pressed через Carry, если Ki - это i-ый бит, то:
				; NewCarry <- K7 <- K6 <- ... <- K1 <- K0 <- OldCarry
	lda JOY_PAD2		; Делаем всё то же самое для второго геймпада...
	and # %00000011
	cmp # $01
	rol keys2_is_down	; Однако на прокрутке keys2_pressed в восьмой раз в Carry выпадет
	bcc loop		; единица которую мы положили в самом начале и цикл завершится.
	; Далее обновляем keysX_was_pressed - логический AND нового состояния кнопок с NOT предыдущего,
	; т.е. "то что было отжато ранее, но нажато сейчас".
	lda keys1_prev		; берём предыдущее состояние,
	eor # $FF		; инвертируем (через A XOR $FF),
	and keys1_is_down	; накладываем по AND на новое состояние,
	sta keys1_was_pressed	; и сохраняем в keys_was_pressed
	
	lda keys2_prev		; и всё то же самое для второго геймпада...
	eor # $FF
	and keys2_is_down
	sta keys2_was_pressed
	rts			; возвращаемся из процедуры
.endproc


В нём в ZPAGE мы помещаем битовые состояния текущих зажатых кнопок для первого и второго геймпадов (keysX_is_down), ибо они будут самыми часто опрашиваемыми статусами геймпадов, а далее меняем сегмент на «RAM» где создаём менее используемые переменные — предыдущее состояние зажатости кнопок (keysX_prev) и признаки была ли нажата кнопка на последнем вызове update_keys, т.е. была ли она нажата мгновение назад. Если вы забыли какие битовые маски у соответствующих кнопок — сейчас можно их посмотреть параллельно в заголовке neslib.inc в секции «INPUT (ВВОД)». Восемь битов кнопок удачно ложатся на один байт каждой из этих переменных.

Дальше мы переходим к сегменту «ROM_H», т.е. там где будет код и пишем процедуру опрашивающую состояние кнопок. Это всё пока библиотечный код и то как стартует программа и с чего она начинается будет в следующей главе. Сейчас изучим модуль neslib который будем там использовать.

И вот мы впервые используем порты ввода-вывода. Сперва учимся на геймпадах. Они в Famicom работают довольно расширяемым образом — несмотря на соблазн получать состояние кнопок сразу как 1 байт делается иначе — сперва мы должны записать 1 в нулевой бит порта JOY_PAD1 — после этого в каждом из геймпадов состояние всех восьми кнопок непрерывно начинает записываться во внутренний регистр сдвига. Записью 0 в нулевой бит JOY_PAD1 мы прекращаем этот процесс и фиксируем состояние кнопок в этих регистрах. А далее считывание из JOY_PADN считывает из соответствующего регистра сдвига очередной бит (1-нажато, 0-отжато) при этом всё содержимое регистра тоже сдвигается ближе к «выходу». Код взят с nesdev.com и довольно таки оптимизирован он использует несколько трюков, которые я откомментировал, но если сразу непонятно — не страшно, можете двигаться дальше. Главное помнить что CMP как и SBC выставляет в Carry 0 если произошёл заём и 1 если нет (ADC действует как бы наоборот — пишет в Carry 1 если произошёл перенос и 0 если нет).
В конце процедуры мы вычисляем keysX_was_pressed — т.е. те кнопки что были отжаты в keysX_prev и оказались нажаты в текущем уже keysX_is_down.

Нелишним будет заметить, что в ca65 директива .proc не только автоматически создаёт метку с тем именем что указано как параметр, но и замыкает все другие метки создаваемые в ней внутри своего контекста который заканчивается директивой .endproc. Это позволяет в разных процедурах использовать одни и те же говорящие метки типа loop, skip и т.п. Это так называемые локальные метки и чтобы использовать их извне процедуры надо писать имя_процедуры:: имя_локальной_метки.

Ну и наконец то рассмотрим последнюю часть файла neslib.s — инициализационную:

neslib.s — конец

; clear_ram - очистка памяти zero page и участка $0200-07FF
; портит: arg0w
.proc clear_ram
	; Очистка zero page
	lda # $00		; a = 0
	ldx # $00		; x = 0
loop1:	sta $00, x		; [ $00 + x ] = y
	inx			; x++
	bne loop1		; if ( x != 0 ) goto loop1
	; Очищаем участок памяти с $200-$7FF
	store_addr arg0w, $0200	; arg0w = $2000
	lda # $00		; a = 0
	ldx # $08		; x = 8
	ldy # $00		; y = 0
loop2:	sta (arg0w), y		; [ [ arg0w ] + y ] = a
	iny			; y++
	bne loop2		; if ( y != 0 ) goto loop2
	inc arg0w + 1		; увеличиваем старший байт arg0w
	cpx arg0w + 1		; и если он не достиг границы в X
	bne loop2		; то повторяем цикл
	rts			; возврат из процедуры
.endproc

; warm_up - "разогрев" - после включения дождаться пока PPU дойдёт
; до рабочего состояния после чего с ним можно работать.
.proc warm_up
	lda # 0			; a = 0
	sta PPU_CTRL		; Отключим прерывание NMI по VBlank
	sta PPU_MASK		; Отключим вывод графики (фона и спрайтов)
	sta APU_DMC_0		; Отключить прерывание IRQ цифрового звука
	bit APU_STATUS		; Тоже как то влияет на отключение IRQ
	sta APU_CONTROL		; Отключить все звуковые каналы
	; Отключить IRQ FRAME_COUNTER (звук)
	store APU_FRAME_COUNTER, # APU_FC_IRQ_OFF	
	cld			; Отключить десятичный режим (который на Ricoh 2A03 и не работает)

	; Ждём наступления первого VBlank от видеочипа
	bit PPU_STATUS		; Первый надо пропустить из-за ложного состояния при включении
wait1:	bit PPU_STATUS		; Инструкция bit записывает старший бит аргумента во флаг знака
	bpl wait1		; Поэтому bpl срабатывает при нулевом бите PPU_STAT_VBLANK

	; Пока ждём второго VBlank - занулим RAM
	jsr clear_ram

	; Ждём еще одного VBlank
wait2:	bit PPU_STATUS
	bpl wait2	
	rts			; Выходим из процедуры
.endproc


Первая процедура clear_ram сперва зачищает zero page, а потом всю память в NES выше стека. Её надо будет использовать при старте чтобы привести RAM консоли в занулённое состояние. Здесь вы можете посмотреть разные виды циклов и сложной формы индексации.
Эту процедуру использует следующая — warm_up — «разогрев».
И дело тут вот в чём: сразу после включения PPU консоли находится в «оглушенном» состоянии и некоторое время «приводит себя в чувство» — общаться с ним стоит только по ряду ограниченных портов и протоколов. Это время довольно длинное по процессорным меркам — тысячи тактов и самый простой способ их гарантированно выждать — это выждать два кадра синхроинмульса телевизионного сигнала.
Сперва мы записываем во все порты которые кажется еще на что-то реагируют и могут отключить всё что можно отключить нули. Единственным исключением тут является APU_FRAME_COUNTER в котором отключение прерывания делается записью битового флага APU_FC_IRQ_OFF.
Далее мы начинаем постоянно опрашивать инструкцией BIT порт ввода PPU_STATUS — самый верхний его бит это признак того наступил ли сейчас период VBlank (вспомните что инструкция BIT пишет верхний бит аргумента сразу во флаг отрицательности — т.е. его можно тестировать сразу инструкциями BPL/BMI). Отсчитав два таких интервала мы уверены что прошло достаточно времени. Между этими подсчётами можно вызвать как раз зачистку памяти. И далее возвращаемся из процедуры.
Вызов процедуры warm_up — это одно из первых действий которое должна будет сделать наша программа после того как настроит стек.

Для одной статьи получается уже довольно много текста, так что окончание этого урока с кодом main.s будет в следующей.

В первую часть (оглавление)...

10 комментариев

avatar
Чтобы не разбрасываться параметрами конфигурации образа iNES по разным файлам, можно в конфиг-файле линкера задать символы типа:

SYMBOLS {
	NES_MAPPER	  = 0;
	NES_PRG_BANKS = 2;
	NES_CHR_BANKS = 1;
	NES_MIRRORING = 0;
}

И в теле объявления заголовка использовать эти определения, типа .byte NES_PRG_BANKS.
avatar
Попробовал так сделать — ассемблер ругается что символ неопределён. Логично, ведь ini-файл есть параметр в линкер, но не в ассемблер. А если сообщаешь через .global что есть символ который потом пытаешься положить в .byte, то ругается что range error. Опять таки логично, ведь символы это адреса как правило и обойти можно сделав <NAME, т.е. взять младший байт и видимо в простой ситуации как PRG_BANKS уже сработало бы, но я попытался воткнуть это в вычисление по битовым маскам
byte MIRRORING | (HAS_SRAM << 1) | ((MAPPER & $0F) << 4) | NAME
и тут линкер выдал ошибку «Attribute expected» которая даже не гуглится особо да и я не понимаю как линкер может такое вообще отрабатывать нормально. Если только весь байт сразу там и определить разве что, но есть ли там битовые операции?
avatar
Да, забыл упомянуть, что нужно сделать .import объявленным символам (ну или .autoimport + ). Битовые операции и прочее работают нормально.
avatar
У меня появилось два вопроса:
Первое — для теории мне надо понять как в Famitone2 Sfx совмещаются с музыкой и как они совмещаются друг с другом. Подозреваю что Sfx-каналы с меньшими номерами пишут в порты в Update позже больших и таким образом просто перетирают их осцилляторы при коллизиях. А вот как с музыкой оно сосуществует?
Второе — для урока нужны примеры и музыки и звуков. Можно ли взять (естественно с указанием авторства) их из самой Famitone2 с одной стороны, а с другой стороны нет ли желания какую нибудь новую мелодию выставить? В license.txt я для всех сторонних материалов по умолчанию пишу «автор дал разрешение использовать в рамках данного урока, любые другие применения надо обсуждать с ним».
avatar
Вывод данных регистров идёт не напрямую в APU, а в промежуточный буфер. Сначала выводятся данные регистров для музыки. Далее для каждого из виртуальных каналов эффектов для Pulse и Noise каналов проверяется текущая моментальная громкость канала эффекта, если она выше той, что записана в буфер, значения соответствующего канала APU перезаписываются данными для эффекта; для Triangle перезапись идёт всегда, если он активен (т.к. громкость у него бинарная, вкл-выкл). Таким образом в итоге в буфере остаются данные только самых громких частей, если эффект громче музыки — он глушит соответствующие каналы музыки, если эффекты одинаковой громкости, приоритет у старшего канала эффектов. Это не самое оптимальное решение, но оно вполне рабочее, и в принципе нет однозначно лучшего решения, у каждого есть свои плюсы и минусы.

Новых мелодий у меня нет. FamiTone и всё ему сопутствующее опубликовано под лицензией CC0/PD, можно делать что угодно без ограничений и без указания авторства.
avatar
«Таким образом в итоге в буфере остаются данные только самых громких частей… Это не самое оптимальное решение, но оно вполне рабочее»

На практике звучит отлично. Давно удивлялся как при всего четырёх-пяти физических осцилляторах-каналах на практике игр у Famicom/NES/Денди и музыка играет и выстрелы-попадания-прыжки звучат как влитые. И ощущение, что музыка выпадает существует прям на грани восприятия. Ведь всё это было и звучало нормально.

«можно делать что угодно без ограничений и без указания авторства.»

Я вообще считаю обязательным такой тип лицензии для уроков/обучения, т.е. все мои уроки в той же лицензии. Но конечно в самих уроках нельзя не упоминать откуда берёшь материал.
Общаюсь по письму в неделю по поводу IDE Nesicide с Кристофером (который её автор) упомянул как то тебя и он мне написал следующее:
«Indeed. He is very talented. I created a nesicide project of his Alter Ego game to show debugging in C in nesicide.»
:)
avatar
Появились еще вопросы связанные с Samples. Тут мне не хватает уже видимо знаний самого Famitracker, как устроены его мелодии.
1. Правильно ли я понимаю, что Samples изначально могут быть как бы инструментами в мелодии?
2. Правильно ли я понимаю, что Samples поэтому как бы пристёгнуты в первую очередь к мелодии и чтобы их инициализировать нужно вызвать FamiToneMusicInit?
3. Когда мы Samples грузим через .incbin по адресам в верхних 16Кб ПЗУ — там внутри .dmc файла тоже есть некий заголовок который хранит сколько самплов тут есть? Потому что совсем непонятно почему в demo.asm вызывается так:

	lda #36						;play sample 36
	jsr FamiToneSamplePlay

но в readme.txt написано:

 FamiToneSamplePlay
  A is sample number 1..12 (i.e. note C..B with assigned sample).

Откуда эти числа берутся на самом деле?
avatar
FamiTracker отношения к вопросу не имеет, это связано с устройством приставки и возможностями FamiTone. Сэмплы в нотном тексте назначаются на ноты для удобства использования. В данных мелодии есть таблица соответствия нот и сэмплов, а также их параметров — смещения от начала блока, длительность, питч. Вызов FamiToneInit нужен, чтобы сохранить указатель на эту таблицу сэмплов (FT_DPCM_LIST), чтобы при вызове FamiToneSamplePlay знать, где она находится.

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

В старых версиях FamiTone поддерживалось только 12 сэмплов, была короткая табличка, с расчётом на то, что сэмплы будут использоваться только для немногочисленных ударных инструментов. Позже я сделал поддержку 63 сэмплов, чтобы можно было делать сэмплированный бас а-ля Sunsoft. Номер сэмпла 36 — это нота C-4. Описание в readme.txt просто не было обновлёно, как обычно.
avatar
«Номер сэмпла 36 — это нота C-4.»

Я реально не понимаю связи. Почему 36 какое то отношение имеет к ноте C-4 и какое отношение обе этих вещи вообще могут иметь к потоку однобитовых звуковых данных со звуком лающей собачки.
У музык и sfx всё было достаточно просто — 0..N и соответствующие API логичные и простые и их легко было увидеть в интерфейсе в поле «Song», а тут непонятно.
Но я всё-таки вообще не шарю как музыкант в трекерных этих делах потому что музыкантом даже близко не являюсь и в трекерах шарю как та лающая собачка в апельсинах. Поэтому не удивительно.
Ладно, я даже уже из-за необходимости 3 раза опрашивать контроллер выкинул DCPM из урока, проблем куча, а одно уже только объяснение почему я его выкинул заняло полэкрана. Со всеми этими фейковыми считываниями портов ввода и как там биты портятся.

Думаю это годится на «сложную» тему в будущем после мапперов и полноценного скроллинга реальной карты метатайлов. Посмотрим. Так или иначе спасибо за ответы.
avatar
В DMC файле может быть произвольное количество сэмплов, их расположение и длина неизвестны. В модуле с мелодией есть таблица сэмплов в 63 записи, по одной записи для каждой ноты, начиная с C-1. Эти записи указывают, где в DMC файле находятся данные нужных сэмплов, какова их длина и питч. В нотном тексте сэмплы с нужными параметрами вызываются просто проигрыванием соответствующей ноты. Сэмпл лая назначен на ноту C-4 в исходном модуле, из которого строится таблица параметров сэмплов. 36 — смещение в табличке, она начинается с C-1, и если добавить 36 (3*12), получается C-4.
Только зарегистрированные и авторизованные пользователи могут оставлять комментарии.