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

Оглавление

2 — Архитектура MOS 6502...
3 — Модуль neslib...
4 — Задний фон с прокруткой...
5 — Спрайты...
6 — Ушибленный спрайт...
7 — Музыка и звуки...
8 — Маппер MMC3 — страницы...
9 — Маппер MMC3 — перехват HBlank...

0. Предисловие

В этой серии статей я попытаюсь как можно быстрее ввести вас в программирование на ассемблере ca65 на 8-битной консоли Famicom/NES/Денди в среде программирования Nesicide.
Статьи не ставят своей целью учить кого либо программировать: вы уже должны быть программистом и понимать что такое программы, ассемблер и как работают процессоры. Многое я попытаюсь объяснить как можно более детально, но определенный багаж знаний и умений конечно надо будет иметь. В принципе в интернете немало переведённой литературы и про MOS 6502 и про Famicom/NES/Денди, поэтому особо даже не буду что-то рекомендовать.
Для первичного ознакомления с основами основ о том что из себя представляет обзорно для программмиста консоль могу отослать к своей же статье: /blog/868.html, хотя похожих статей вообще немало.
Огромная масса полезной информации находится на англоязычном сайте nesdev.com (бесценный источник и для этого моего цикла статьей!).
Если с английским туго, то могу еще отослать к обзору разных материалов от Shiru: hype.retroscene.org/blog/282.html
Начало положено этой статьёй, остальные будут дописываться по мере появления свободного времени, всячески приветствуются комментарии, замечания, корректировки и вопросы помогающие улучшить статьи и дополнить непонятные в них места.

Исходные коды к урокам на момент пока я пишу статьи можно скачать тут: yadi.sk/d/_THxg1gxuCCVNw



1. Установка

Качаем архив с Nesicide с официальной страницы: knob.phreneticappsllc.com/nesicide/?s=download
Распаковываем его в папку без русских букв и пробелов по всему пути, например c:\devel\nesicide
Чтобы запустить среду программирования (IDE) надо запустить файл nesicide.exe из этой папки. Для удобства сделайте до него ярлык с рабочего стола.
Обязательно зайдите в меню NESICIDE -> Environment Settings там нажмите на пункт Code Editor и выберите в поле «Encoding» вариант «utf-8» иначе редактор не будет отображать кириллицу. Так же рекомендую здесь же открыть закладку Whitespace и там поставить поле Tab Spacing в «8 spaces» и отключить галку «Replace Tabs with Spaces» если она стоит. Я пишу свой код с табами в 8 пробелов и при таких настройках он должен выглядеть наиболее симпатично.
После изменения настроек среда возможно закроется (если открыт проект) и её надо будет открывать снова — возможно это поведение изменится в будущих версиях Nesicide.

2. Первый проект — шаблон «Hello, world!»

Идём в меню Project -> New project. В качестве имени (name) проекта введём «Test». Поле «Target Machine» оставим на «Nintendo Entertaiment System», а вот в поле «Template» выберем «Hello world — CA65 Assembly». Т.е. мы будем программировать на ядрёном ассемблере MOS 6502.
Поле «Path» надо выставить в папку где будут хранится ваши проекты — наприсер c:\devel. Заметьте, что среда сама создаст там подпапку Test по имени проекта. Опять таки желательно чтобы в пути не было пробелом и русских букв.
Нажимаем «OK» и через несколько секунд откроется редактор. Если вдруг возникнет диалог с вопросом «Do you want to reload project?» — нажмите «Yes».
Итак, в правой части у нас сейчас открыта панель с файлами проекта и открытыми страницами. Если развернуть ветку Project -> Source Code то мы там увидим три файла:
  • header.s — файл описывающий секцию с заголовком INES-образа
  • main.s — основной код программы «Hello, world!»
  • nes.h — некоторые полезные объявления для основной программы (в основном объявление адресов портов ввода-вывода)
Однако с самого начала нас будут интересовать не эти файлы, а файл конфигурации линкера. Чтобы увидеть его из самой среды надо открыть меню Project -> Project properties и там нажать на пункт «Linker».
В нижней части диалога вы увидите текст файла nes.ini который лежит в корне проекта, чтобы получше его разглядеть можете увеличить размер окна:

# Linker script for NROM-128
# Copyright 2010 Damian Yerrick
#
# Copying and distribution of this file, with or without
# modification, are permitted in any medium without royalty
# provided the copyright notice and this notice are preserved.
# This file is offered as-is, without any warranty.
#
MEMORY {
  ZP:     start = $10, size = $f0, type = rw;
  # use first $10 zeropage locations as locals
  HEADER: start = 0, size = $0010, type = ro, file = %O, fill=yes;
  RAM:    start = $0300, size = $0500, type = rw;
  ROM7:    start = $C000, size = $4000, type = ro, file = %O, fill=yes, fillval=$FF;
}

SEGMENTS {
  INESHDR:  load = HEADER, type = ro;
  ZEROPAGE: load = ZP, type = zp;
  BSS:      load = RAM, type = bss, define = yes, align = $100;
  DMC:      load = ROM7, type = ro, align = 64, optional = yes;
  CODE:     load = ROM7, type = ro, align = $100;
  RODATA:   load = ROM7, type = ro, align = $100;
  VECTORS:  load = ROM7, type = ro, start = $FFFA;
}

FILES {
  %O: format = bin;
}

И вот тут нам надо немного отвлечься от программирования и понять что у нас вообще должно получиться сейчас как результат.
А получаться у нас должен образ картриджа для Famicom/NES/Денди в формате iNES который стал стандартом де-факто.
Формат iNES в подробностях можно рассмотреть тут: wiki.nesdev.com/w/index.php/INES но здесь для нас важно то, что файл в таком формате у нас должен состоять из трёх идущих друг за другом частей:
1. Заголовок: 16 байт в котором в том числе указано сколько страниц кода и сколько страниц графики в картридже содержится
2. 16-килобайтные страницы кода идущие друг за другом в количестве указанном в заголовке
3. 8-килобайтные страницы графики тайлов так же в указанном количестве
Картриджи с играми первого поколения (Pacman, Bomberman и т.п.) как правило содержали одну микросхему с ПЗУ кода/данных на 16Кб которая маппится на последние 32Кб адресного пространства процессора и одну микросхему с ПЗУ графики на 8Кб которая маппится на первые 8Кб адресного пространства видеочипа (PPU).
И заметьте — я не ошибаюсь когда пишу что 16-килобайтный чип ПЗУ маппится на 32Кб адресного пространства процессора — для простоты адресная линия A14 просто никуда не подключалась и поэтому ПЗУ просто зеркалировалось в двух последних четвертях адресного пространства процессора, но код логично писать как будто он работает в последней четверти (адреса $C000-$FFFF).
Когда аппетиты выросли стали ставить и полные 32Кб ПЗУ кода/данных (например в супер марио).
Но вот дальнейшее расширение потребовало включения в состав картриджа так называемого «маппера» — устройства организующего страничное переключение банков памяти кода и/или графики чтобы серьёзно увеличить количество кода, данных и графики. Используемый маппер тоже указывается в заголовке картриджа iNES.
Теперь отвлечёмся немного и от файла nes.ini (сделать это можно не закрывая окно со свойствами проекта) и откроем файл header.s. Здесь мы увидим следующее:

.segment        "INESHDR"
; ... пропускаю неважный тут комментарий
.byte $4e,$45,$53,$1a	; "NES"^Z
.byte $01               ; ines prg  - Specifies the number of 16k prg banks.
.byte $01            	; ines chr  - Specifies the number of 8k chr banks.
.byte $0  	; ines mir  - Specifies VRAM mirroring of the banks.
.byte $0  ; ines map  - Specifies the NES mapper used.
.byte 0,0,0,0,0,0,0,0   ; 8 zeroes

Это есть ничто иное как определение этого самого заголовка iNES в 16 байт директивами ассемблера .byte, где указан сперва заголовок формата (строка «NES» заканчивающаяся байтом $1A), далее то, что в образе находится одна 16Кб-ая страница кода/данных и одна 8Кб-ая страница графических данных.
Нулевой байт в строке с «ines map» говорит что мапперы не используются. Т.е. мы имеем дело с самым простым и маленьким форматом картриджа «игр первой волны». Для «Hello, world!» этого конечно более чем достаточно.
Вернёмся к диалогу с файлом nes.ini и разберёмся как ориентируясь на него ассемблер и линкер CC65 понимают как скомпоновать образ картриджа.
Прежде всего рассмотрим блок MEMORY {… } — в нём грубо говоря описаны некие абстрактные пока куски памяти которые ассемблер может заполнять кодом и данными.
Главные атрибуты этих кусков памяти — это какой они будут иметь размер (size) каким адресом будет считаться их начало (start) и в какой файл линкер должен их сохранить в конце сборки (file = ...).
Если в качестве file указан один и тот же файл, то сохраняться в него куски памяти будет последовательно слитно в том порядке в котором они объявлены в блоке MEMORY.
Если атрибут file не указан, то такой блок памяти после сборки будет просто откинут, но для правильной работы ассемблера и линкера они могут быть важны.
В качестве файла указано особое значение %O — это тот файл который указан как конечная цель сборки — в нашем случае это hello_world.prg который попадёт в подпаку obj/nes по принятой в Nesicide схеме сборки — он сперва создаст из картинок с графикой 8-килобайтный файл страницы с графикой — obj/nes/hello_world.chr, потом по вышеприведённым правилам в nes.ini соберёт файл obj/nes/hello_world.prg с заголовком и секцией кода/данных и потом уже скомпонует из них полноценнй образ hello_world.nes в корневой папке проекта простым слиянием файлов.
То, что в prg-файл попадает заголовок и 16-килобайтный кусок памяти с кодом мы видим т.к. у них указано сохранение в файл в таком порядке.
Замечу тут, что если вам захочется уйти из IDE Nesicide и компилировать образ картриджа самостоятельно, то вы можете добавить файл с графическими данными как еще один кусок памяти в nes.ini и заполнить его данными, например, директивой .incbin ассемблера, такое практикуется и позже вы сами сообразите как такое можно сделать.
Атрибут fill=yes говорит, что кусок памяти надо изначально залить каким то значением, по умолчанию 0, но можно указать своё значение через атрибут fillvalue.
Так же немаловажен атрибут type который указывает тип куска памяти: ro — только для чтения (ROM) и rw — обычная память (RAM).
Давайте теперь по порядку рассмотрим какие куски памяти у нас существуют:
  • ZP — RAM первых 256 байт памяти которая в архитектуре процессора MOS 6502 называется zero page. однако файл содержит комментарий, что первые 10 байт zero page отданы под локальные переменные и поэтому не охватываются куском памяти ZP, почему тут именно так сделано будет понятно немного позже.
  • HEADER — 16 байт заголовка формата файла iNES. в рабочей программе они никак не участвуют, только для формирования заголовка файла прямо директивами ассемблера, что удобно.
  • RAM — остаток RAM консоли, причём заметьте, что в него не включен стек 6502 (страница памяти от $0100 до $01FF) и еще 256 байт за ним ($0200-02FF) — RAM начинается сразу с $0300 по похожим на то как опущен кусок zero page выше, о чём опять таки подробнее будет сказано ниже.
  • ROM7 — собственно образ с кодом и данными картриджа который мы будем заполнять программируя в ассемблере.

Блоки памяти заданы, но ассемблеру и линкеру нужны не они, а так называемые сегменты. Сегменты — это поименованные области в созданных кусках памяти.
Команда ассемблера .segment «имя сегмента» заставляет его добавлять новый код и данные которые составляют программу в указанный сегмент. При этом можно сколько угодно раз переключаться этой директивой между разными сегментами и данные и код будут добавляться еще не заполненное место в конце. Можно сказать что у каждого сегмента во время сборки есть свой счётчик текущего кода/данных который нарастает пока мы добавляем новый код и данные, хотя это неполная картина, до полной дойдём в следующей части.
Именно поэтому первая директива в файле header.s — это .segment «INESHDR» — переключение на сегмент INESHDR который располагается в кусе памяти HEADER и занимает его весь.
Несколько сегментов может лежать в одном и том же куске памяти (в каком именно — указывается атрибутом load). Сегменты добавляются в кусок памяти в порядке своего появления в секции SEGMENTS и если указан атрибут align, то начинаются после предыдущего с выровненного на указанное число адреса.
Иногда сегмент нужно «жёстко прибить» к некоему адресу — тогда нужно указать атрибут start. Как я понял запись производится опять таки в порядке появления, поэтому если сегменты таким образом пересекаются, то «выживет» содержимое памяти из сегмента указанного последним.
Атрибут type сегментов вводит несколько новых типов: zp — это семент zero page, где действует сокращённая форма адресации процессора 6502.
А вот bss — это сегмент «неинициализированных данных». Терминологически это сокращение от «Block Started by Symbol» из древнего ассемблера 50-х годов прошлого века. Т.к. после включения консоли содержимое RAM находится в неизвестном состоянии и сам сегмент с RAM не должен добавляться в образ картриджа, то нужно использовать такой тип сегмента. Главным образом он будет нужен чтобы присваивать переменным в ОЗУ метки — выделять им место, но инициализировать эту память обязательно надо будет самостоятельно.
Далее идут сегменты которые будут по порядку включены в образ ROM кода и данных:
  • Сегмент DMC судя по всему предназначен для хранения цифрового звука (звуковой канал DMC) и объявлен как опциональный (атрибут optional) — последнее значит что линкер не будет ругаться если программа ни разу его не упомянет.
  • Сегмент CODE — для кода.
  • Сегмент RODATA — для данных (неизменяемых).
  • И жёстко «прибитый» к адресу $FFFA сегмент VECTORS — это хранилище трёх адресов процедур обработки прерываний и стартового адреса при включении игры — согласено архитектуре процессора MOS 6502 они должны располагаться в последних трёх словах адресного пространства процессора.

В скором времени мы с вами изрядно перепишем файлы nes.ini и header.s так чтобы они в будущем легче расширялись под новые задачи и маппер MMC3, ну а пока соберём и запустим нашу программу в эмуляторе среды Nesicide.
Для этого выберем меню Project -> Compile project (или нажмём F5). Снизу замигает зеленый (если всё хорошо) прямоугольник «Compile Results» — нажмите на него чтобы открылась панель с результатами сборки.
Далее меню Project -> Load in Emulator (или F6) — слева откроется пока чёрная панель с эмулятором, а код прыгнет на строку с инструкцией sei в файле main.s. Если при этом исчезнет левая панель с файлами проекта, то нажмите меню View -> Project Browser.
И теперь нажмём Emulator -> Run чтобы программа запустилась — в окне эмулятора при этом появится текст «HELLO WORLD» на коричневом фоне.
Поздравляю, это ваш первый Hello, world! на денди. :)
Сейчас, когда эмулятор работает в полную силу среда разработки может весьма тормозно реагировать на ввод, поэтому для продолжения программирования следует поставить эмулятор на паузу нажав F7 или нажав F10 для сброса и опять таки постановки на паузу.
Можете найти в конце файла main.s строку «HELLO WORLD», как либо поменять её и снова повторить цепочку построения и запуска программы. Имхо это проще всего делать последовательно нажимая F5 — F6 — F7 и потом снова F7 для паузы.

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

avatar
файл в таком формате состоит минимум из трёх идущих друг за другом секций
Всё же минимум из двух. Секция графики часто может отсутствовать, это типично для UNROM-подобных конфигураций. Даже базовый NROM в принципе может не иметь секцию графики, а использовать CHR RAM либо даже собственное ОЗУ видеоконтроллера (на железе есть трюк, позволяющий замаппить 2K видеопамяти как память тайлов).
avatar
Секция графики часто может отсутствовать
Действительно. Спасибо, перепишу это предложение.

есть трюк, позволяющий замаппить 2K видеопамяти как память тайлов
Эмм… интересно конечно же как? Маппер VRAM который возвращает на верхние линии адреса частично состояние нижних?
avatar
Маппер это конечно громко сказано, всего пара проводков: wiki.nesdev.com/w/index.php/INES_Mapper_218
avatar
А, точно, тут же и «возвращать» ничего не надо из одних линий в другие, зеркалирование от игнорированиясамо сработает как надо.
avatar
Shiru, ты везде!
Когда я искал аналогичные материалы, то наткнулся на статью на хабре про Си в рамках cc65 тут habr.com/ru/post/348022/
А это перевод англоязычных статей от Nesdoug: nesdoug.com/ где если в архив заглянуть, то в первоварианте от 2018 года написано: nesdoug.files.wordpress.com/2018/07/introduction-e28093-nesdoug.pdf
«As far asI know, there are no other tutorials for cc65 (not counting the example games over at Shiru’s site.)»
Воу, круто.
Тут еще на выходных пообщался с Кристофером который делает IDE Nesicide — он сейчас оказывается учавствует в джеме (сделать игру на NES за 48 часов) где вот: globalgamejam.org/2020/games/super-city-mayor-3
Credits:
Music by Shiru from the Famitone library.
xD И снова Shiru!
Ну прям респект как говорится.
avatar
Лет десять назад я сделал три вещи, которые оказались весьма востребованными в тот момент, и с помощью которых с тех пор была создана добрая половина homebrew для NES:

— Конвертер-редактор графики и экранов, а впоследствии также и составных спрайтов — NES Screen Tool, с довольно корявым, но вполне человеческим лицом. До этого были только редакторы тайлов типа Tile Layer Pro и YY-CHR, а также дикие утилиты под DOS, что очень затрудняло подготовку графики для игр. Альтернативы ему не появилось до сих пор (а надо).

— Библиотеку FamiTone, которая позволила легко и просто добавлять музыку и (решающий фактор) звуковые эффекты в игры, делая их в человеческом FamiTracker. В этом также большая заслуга jsr, Gradualore и rainwarrior, которые добавили и развили поддержку кастомных экспортёров из FamiTracker. До этого не было готового плеера, который можно было просто взять и вставить — все либо изобретали велосипед, обязательно включающий написание музыки в hex-кодах, либо приватно делились кодом. К тому же, в тот момент предпочтения в комьюнити касательно кросс-ассемблеров делились на три равные части, NESASM/asm6/CA65, код между которыми переносить довольно затруднительно, а я поддержал все три сразу. С тех пор появилось несколько альтернатив (в том числе форков моего кода), FamiTone ещё в лидерах, но его начинает теснить решение от Gradualore, которое встроили в NES Maker.

— Библиотеку neslib, которая позволила легко и просто писать на C. Ранее на сцене ходили настроения, что ничего путного сделать на C невозможно, не стоит даже и пытаться — никто и не пытался. А я видел, что делают Mojon Twins с компилятором C на ZX (как известно, ныне чурерра заполонила), и показал, что так можно было. Также я привлёк самих Mojon'ов, чтобы они показали класс (Sir Ababol и другие игры). Это в свою очередь сподвигло nesdoug'а не только самому начать писать на C, но и сделать серию туториалов, которые потом перевели на русский.

Соответственно, теперь куда не клюнь — ваш пострел везде поспел.
avatar
У меня как раз вопрос по FamiTone2 для следующих уроков. Верно ли что после того как мы целую страницу отдали под FT_BASE_ADR в ней всё-таки еще немало свободного места остаётся и им можно воспользоваться ориентируясь на FT_BASE_SIZE?
avatar
Да, переменные плеера занимают не всю страницу. В neslib они традиционно размещаются в странице стека, а также туда идёт буфер палитры, т.к. сам аппаратный стек в реальных программах не превышает 32 байт.
avatar
Логично, так и буду делать, спасибо.
avatar
Насколько я понял в последней версии Famitone2 (1.15) ( вроде бы одно и то же лежит и тут famitracker.com/downloads.php и тут shiru.untergrund.net/code.shtml ) закралась ошибка в версии исходника для CA65.
Комментарии и код предполагают как бы такое использование:

; FT_PAL_SUPPORT			;undefine to exclude PAL support
; FT_NTSC_SUPPORT			;undefine to exclude NTSC support
	.if(FT_PAL_SUPPORT)
	.if(FT_NTSC_SUPPORT)
FT_PITCH_FIX = (FT_PAL_SUPPORT|FT_NTSC_SUPPORT)	
	.endif
	.endif

т.е. идентификаторы воспринимаются как макросы которые могут быть defined/undefined.
Но справка по ключевому слову .IF в CA65 www.cc65.org/doc/ca65-11.html#ss11.47 говорит, что .IF воспринимает константу времени компиляции которая обязана быть defined и трактует её как число ровно как c-style if.
Соответственно попытка компиляции выдаёт кучу ошибок и здесь и ниже везде на .if и чтобы этого не было нужно присвоить идентификаторам 0 или 1 и вообще убрать .if в данном случае (но не ниже по коду), т.е.:

FT_PAL_SUPPORT        = 0 ; set 0 or 1
FT_NTSC_SUPPORT        = 1

FT_PITCH_FIX = (FT_PAL_SUPPORT|FT_NTSC_SUPPORT)	

Тогда вроде компилируется, хотя до запуска я еще не скоро дойду чтобы точно сказать что работает.
.if-ы в этих строках вообще получается что не нужны, т.к. могут сделать символ undefined и это вызовет ошибку ниже где он тестируется в .if опять же.
avatar
Я без понятия, видимо так. Надо понимать, что это старый проект, в который на протяжении многих лет предлагали изменения какие-то люди, ожидая, что я всё время только о нём и думаю — но мне, чтобы ответить на любой вопрос, теперь нужно разбираться столько же времени, сколько и любому другому человеку. Это вообще типичная история поддержки homebrew-проектов — первые несколько лет после выхода они не нужны и не интересны никому. А когда успел разочароваться в результатах, сделать десяток других, заняться вообще принципиально другими делами в жизни, люди вдруг начинают писать — а почему эта запятая не на том месте? А почему в конвертере в версии XYZ пятилетней давности было $30, а в версии XYZZ четырёхлетней давности стало $c0, но код плеера не поменялся? А почему окно конвертера открывается и сразу закрывается?

Версия CA65 автоматически генерируется из мастер-исходника для NESASM, вероятно отсюда растут ноги у проблемы. Вероятно, когда и если я с этим сталкивался сколько-то лет назад, я один раз пофиксил по месту и забыл. Но скорее просто за всё это время данные дефайны ни разу никому никаким образом не понадобились, включая меня — в рабочем коде пары текущих проектов они у меня точно в таком же виде, как в цитате выше.
avatar
Подозреваю еще, что в культуре где как минимум три разных ассемблера на вкус и цвет многие просто правят не задумываясь «адаптируя исходник под другой ассемблер на свой» и не считая нужным сообщать что есть какие то проблемы, т.к. такое часто бывает.
avatar
P.S.
Сегодня понял как вкравшаяся ошибка осталась незамеченной — если одновременно и FT_PAL_SUPPORT = 1 и FT_NTSC_SUPPORT = 1, то всё скомпилируется потому что выполнение пойдёт по ветке когда все символы определены и проблем не возникает. А вот попытка хоть что-то из этого отключить вызовет ошибку отсутствия определения символа FT_PITCH_FIX.
avatar
Еще у меня есть вопрос к старожилам ресурса — недавно развлекаясь с нереализованными в детстве идеями на ZX Spectrum я написал еще две статьи на менее профильном ресурсе:
Перехват прерываний на ZX Spectrum
и как бы её продолжение:
Многопоточность на ZX Spectrum
Я знаю что про перехват прерываний не писал только ленивый и возможно это уже просто моветон. Про многопоточность вроде меньше материалов — поэтому вопрос: стоит ли тащить что либо из этого сюда?
avatar
P.S. Первая ссылка поломалась, вот она: retrocoder.d3.ru/perekhvat-preryvanii-na-zx-spectrum-1915801
avatar
Аллилуйя! Автор Nesicide наконец то устал с чем то там разбираться на своей стороне и добавил поддержку utf-8 и убрал дурацкое насильное автодополнение единственного оставшегося выбора в списке автодополнений (последнее вообще съедало мозг). Если кто-то из любопытства качал сборку для виндовс из-за этого поста, то обязательно перекачайте архив и зайдите в настройки среды — там настройки редактора и поставьте Encoding в utf-8.
avatar
Хехехе, первая тестовая программа с обильными комментариями на русском готова и крутится в эмуляторе:


Так что скоро наверное будет вторая часть. Правда таки разрываюсь на тему того не сделать ли сперва одну часть краткого введения в ассемблер и архитектуру MOS 6502 чтобы статья в целом лучше заходила тем кто в этом ни бум-бум изначально, а так, из Си пришёл. Хм…
Только зарегистрированные и авторизованные пользователи могут оставлять комментарии.