Программирование NES (dendy), assembler 6502
У меня с детства была мечта написать игру для любимой приставки денди, шло время, мечта то появлялась то затихала снова. Она меня направляла в сторону магии программирование, и вот прошло больше 20-ти лет я программист, и снова в который раз пытаюсь постигнуть магию той самой денди что так будоражило моё воображение в тяжелом, но счастливом детстве. Сегодня Я расскажу вам как постиг секреты этой магии, наконец то смог вывести спрайты на экран и научился рисовать фон.
Я php программист и по этому довольно далек от низкоуровневого программирования, по этому мне приходилось довольно долго изучать сам assembler, но он оказался проще чем я думал, оказалось надо просто освободить голову от стереотипов современного программирования и смотреть на assembler как на инструмент передачи информации в некое api черный ящик. Так вот этим черным ящиком и является для нас Dendy (или NES, Famicom), так вот у этого ящика есть некие области памяти, одни предназначены только для чтения, другие для записи, а третьи и для чтения и для записи. Приставка воспроизводя игру использует несколько ключевых подсистем (процессоров):
1. CPU — центральный процессор
2. PPU — Графический процессор
3. APU — Аудио процессор
Сегодня, Я хотел бы затронуть тему работы с PPU ибо это наверное ключевое для многих людей графическое представление данных. Для начала разберем из чего состоит картридж, важными в картридже являются 2 чипа с памятью один это CHR (тут храниться битовая карта спрайтов и фона) имеет два банка памяти. Битовая карта представляет из себя набор 0-й и 1-к, размером 8×8, один спрайт состоит из 2-х таких областей. Пример ниже.
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 1 0 0 0 1 0 0 0 0 0 0 0 0 0 0
0 1 0 0 0 1 0 0 0 0 0 0 0 0 0 0
0 1 0 0 0 1 0 0 0 0 0 0 0 0 0 0
0 1 1 1 1 1 0 0 + 1 1 1 1 1 1 1 1
0 1 0 0 0 1 0 0 1 0 0 0 0 0 1 0
0 1 0 0 0 1 0 0 1 0 0 0 0 0 1 0
0 1 0 0 0 1 0 0 1 0 0 0 0 0 1 0
При сложение этих двух банков они накладываются друг на друга и определяется цвет пикселя, таких цвета может быть 3-и для спрайта:
0 + 0 = 0 — прозрачный цвет
0 + 1 = 1 — первый цвет
1 + 1 = 2 — второй цвет
1 + 0 = 3 — третий цвет
Из этого примера и вытекают следующее умозаключение, что у background 4 цвета в палитре, а у спрайта всего 3-три. Потому что фон не имеет прозрачного цвета, 1-й цвет у него цвет фона.
Давайте перейдем к ассемблеру, у нес есть несколько аккумуляторов, их можно сравнить с переменными это A, X, Y. X, Y они имеют косвенное отношение к координатам. Чаще всего мы будем пользоваться аккумулятором A.
LDA {argument} - Загрузить в A значение argument (десятичное #20, hex #$20, двоичное %00010010)
STA {address} - Сохранить A в память (адрес ppu для примера)
Команды с аккумулятором X и Y
LDX {argument} - Загрузить аргумент в X
LDY {argument}
STX {address} - Сохранить X
STY {address} - Сохранить Y
INX - increment X
INY - increment Y
CPX {argument} - Сравнить x с аргументом
CPY {argument} - Сравнить y с аргументом
Команды ветвления:
JMP {address} - переход к адресу
BEQ {address} - перейти к адресу если CPX true
BNQ {address} - перейти если условие в CPX не равно
С первоначальными командами разобрались, дальше надо разобраться с двумя основными вопросами:
Как отобразить фон?
Как нарисовать спрайт?
Отображение фона
Разрешение NES, В системе PAL 256×240, в системе NTSC 256×224. Фон состоит из спрайтов 8×8, соответственно 32×30 спрайтов в PAL, в NTSC верхний ряд спрайтов и нижний обрезаются. Для того что бы отобразить фон, необходимо 3 условия: палитра, nametable (таблица имен), таблица атрибутов.
Палитра — палитра для фона содержит 16 цветов 4 вариации по 4 цвета. Как и палитра спрайтов собственно.
Nametable — таблица 32×30 спрайтов, которая заполнена для отображения спрайтов на одном экране, таких таблиц может быть 2 без дополнительной оперативной памяти, если картридж содержит дополнительную память в размере 2КБайт то таких таблиц может быть 4-ре процессор поддерживает 4 таблицы. Но из за ограничения по памяти их две из коробки, в денди существует функционал зеркального отображения фона, который мы разберем в следующих статьях.
Таблица атрибутов — в nes экран делиться на сетку с размером ячейки 32×32 или 2×2 спрайта, 8×8 ячеек, каждая ячейка содержит 1 байт битовой маски, %00000000. Что бы понять как она формируется давайте рассмотрим ячейку спрайтов 2 на 2 спрайта.
-----------------
| 0×1 |
-----------------
| 2×3 |
-----------------Я специально в разные ячейки поставил разные цифры, эти цифры означают порядковый номер политры, который будет применен к спрайту фона. Так вот каждые 2 бита маски обозначают какую палитру применить к спрайту в ячейке:
00 - 0-вая последовательность 4-х цветов
01 - 1-я
11 - 2-я
10 - 3-я
Немного отвлечемся и поговорим о принципе программирование на nes
Для того что бы отобразить к примеру спрайт, фон, или сделать скролинг экрана необходимо просто записать в соответствующий адрес данные, к примеру для прокрутки экрана по оси X нужно просто в адрес PPU $2005 записать сначала X координату потом Y, подчеркну последовательно.
LDA #25 ; десятичное значение X координаты
STA $2005 ; записываем #25 в адрес
LDA #00 ; 0 по оси Y
STA $2005 ; записываем #0 в адрес
Разберем другой пример рисование спрайта он довольно прост
LDA #100 ; координата Y спрайта
STA $2004 ; записываем в адрес OEM data PPU
LDA #00 ; номер спрайта в таблице тайлов (по сути таил и есть таблица спрайтов)
STA $2004 ; записываем в адрес
LDA #%00010111 ; маска которая определяет отражение спрайта по вертикали
; и горизонтали, а так же номер палитры
STA $2004 ; сохраняем в адрес
LDA #192 ; загружаем Y координату
STA $2004 ; и снова записываем
Такая последовательность команд выведет спрайт 8×8 в скриншоте таких спрайтов 6. Теперь перейдем к более сложному, допустим мы определили что у нас есть таблица имен адресное пространство таблицы от $2000 — $2400 видео памяти, вторая таблица $2400 — $2800 и так далее. Для начала наша задача записать в данную ячейку памяти данные nametable. Для этого нам надо просто
LDA #$20 ; старший байт
STA $2006 ; регистр 2байта тут мы указываем какой участок памяти мы бы хотели использовать
LDA #$00 ; младший байт
STA $2006
Тут мы снова видим принцип обычной последовательности записи, сначала X потом Y, либо адрес памяти в которую хотим произвести запись данных.
LDA $24 ; номер тайла или спрайта в банке CHR
STA $2007 ; регистр для записи данных
; далее возможно записывать каждый спрайт в таблицу имен отдельно
Конечно ни кто не записывает руками данные, а использует циклы, как и Я, приведу простой пример цикла на ассемблере
LDX #00 ; загружаем 0 в x значение decimal так проще чем #$00 - #$FF к примеру
section:
; какие то действия загрузка тех же спрайтов
LDA $24
STA $2007
CPX #255
INX
BNE section
Код секции выполниться 255 раз и заполнит 255 значений таблице имен, это как пример. Для заполнения всех значений необходимо использовать аккумулятор Y и пройтись 4 раза таким циклом. Далее используя ту же логику мы можем загрузить палитру, палитра в памяти храниться с адреса $3F00-$3F0F — 16 байт соответственно. Используя предыдущую логику загрузки nametable нам необходимо сделать следующее
LDA #$3F ; старший байт адреса $3F
STA $2006
LDA #$00 ; младший байт
STA $2006
LDA $22 ; цвет палитры nes синий
STA $2007 ; записываем цвет в адресс $3F00
LDA $05 ; красный
STA $2007 ; записываем цвет в адресс $3F01
; и так далее еще 14 раз
Опять же палитру можно определить как последовательность байт и загрузить ее циклом который пробежится 16 раз или 16 байт
background_pallete:
.byt $01, $02, $03, $04
.byt $05, $22, $23, $24
.byt $25, $26, $27, $28
.byt $31, $32, $33, $34
LDX #$00 ; x равен 0
LDA #$3F ; старший байт адреса $3F
STA $2006
LDA #$00 ; младший байт
STA $2006
loadPallete:
LDA background_pallete, x ; background_pallete ссылается на адресс
; в котором храниться байт , x добавляет +1 к адресу
STA $2007 ; записываем данные
CPX #$0F ; сравниваем X с 16
INX ; X = X+1
BNE loadPallete ; повторяем до тех пор пока X не будет 16
; Тогда будет загруженны все цвета палитры в видеопамять
Осталось разобраться с тем как загрузить атрибуты фона, это тоже довольно просто атрибуты хранятся в адресном пространстве видеопамяти начиная с $23C0 — $23FF каждый ряд начинается с $23C0, $23C8 и так далее то есть 8 рядов по 8 столбцов.
Теперь нам известны область памяти для применения определенной палитры для спрайта фона, так что, что бы ее применить нам необходимо, снова, очень просто сказать в какую ячейку памяти мы бы хотели записать нашу маску, загрузить ее в аккумулятор А и записать в регистр $2007
LDA #$23
STA $2006
LDA #$C2
STA $2006 ; получаем адрес $23C2
LDA %00001100 ; загружаем нашу маску атрибута, как ее вычислить я писал выше
STA $2007 ; записываем в регистр
Таким образом отображение фона превращается в довольно тривиальную задачу, результатом 2-х недель изучения темы асемблеры вы видели на скриншоте в заголовке статьи. На самом деле assembler nes не такой сложный если сломать стереотипы программирования которыми мы сейчас пользуемся, и взглянуть на все это проще, когда наступает момент понимание того что, асемблером возможно обратиться в доступный участок памяти записать туда некоторую информацию, которая потом волшебным образом отобразиться на экране. Это маленькая победа над собой, маленький шаг к мечте. Спасибо всем за внимание и до скорых встреч.