Программирование NES (dendy), assembler 6502

У меня с детства была мечта написать игру для любимой приставки денди, шло время, мечта то появлялась то затихала снова. Она меня направляла в сторону магии программирование, и вот прошло больше 20-ти лет я программист, и снова в который раз пытаюсь постигнуть магию той самой денди что так будоражило моё воображение в тяжелом, но счастливом детстве. Сегодня Я расскажу вам как постиг секреты этой магии, наконец то смог вывести спрайты на экран и научился рисовать фон.

39a8d8e267a4ee41b3467351fb442bad.png

Я 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 (таблица имен), таблица атрибутов.

  1. Палитра — палитра для фона содержит 16 цветов 4 вариации по 4 цвета. Как и палитра спрайтов собственно.

  2. Nametable — таблица 32×30 спрайтов, которая заполнена для отображения спрайтов на одном экране, таких таблиц может быть 2 без дополнительной оперативной памяти, если картридж содержит дополнительную память в размере 2КБайт то таких таблиц может быть 4-ре процессор поддерживает 4 таблицы. Но из за ограничения по памяти их две из коробки, в денди существует функционал зеркального отображения фона, который мы разберем в следующих статьях.

  3. Таблица атрибутов — в 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 не такой сложный если сломать стереотипы программирования которыми мы сейчас пользуемся, и взглянуть на все это проще, когда наступает момент понимание того что, асемблером возможно обратиться в доступный участок памяти записать туда некоторую информацию, которая потом волшебным образом отобразиться на экране. Это маленькая победа над собой, маленький шаг к мечте. Спасибо всем за внимание и до скорых встреч.

© Habrahabr.ru