Делаем Pong для NES (Неделя №1)
Имейте в виду:
- Всё что я делаю может быть совершенно неверным
- Это пост может содержать нецензурную речь
- Это не туториал. Скорее это просто подробное описание разработки этой игры
- В посте может быть миллион грамматических ошибок
- Не стесняйтесь сообщать мне об ошибках в комментариях или через соцсети/email
Почему?
Docendo discimus
или если вы сегодня не собирались вызывать демонов:
Уча, мы познаём
Хотя, учить - это сильное слово, скорее я просто пытаюсь пояснить что я делаю, чаще всего себе же.
Почему NES/Famicom/Денди?
Я всегда хотел сделать игру для старого железа1. Ограничить себя “легковесным” ассемблером, малым количеством ROM/RAM памяти и медленным CPU. Выжать все соки из этого железа. Быть увереным, что весь мой говнокод будет работать одинаково на всех юнитах. И для этого, Я выбрал NES.
Отложим выжимание сока в сторону и начнём с чего-то простого, например Pong.
6502 ассемблер
Ассемблер не так сложен, как можно было бы подумать, особенно 6502 ассемблер. Разработка под NES тоже не выглядит слишком сложной задачей. Конечно, после использования 6502 ассемблера, ты будешь радоваться даже самому простому синтаксическому сахару. И всё же, это не слишком замарочено и точно не так скучно, раздражающе и положительно неудволительно как разработка на 1С.2
Если вы ещё не знаете 6502 ассемблер, но всё же хотите понимать что тут происходит, пролистайте эту страницу описание опкодов или можете найти туторилы в секции Ссылки.
Но основная идея такая:
- Грузим какие-нибудь данные в один из трёх регистров (
LDA
,LDX
,LDY
) - Делаем что-нибудь с этими данными (
INX
- увеличиваемX
регистор на 1, например) - Сохраняем результат где-нибудь (
STA
,STX
,STY
) - И потом ветвления, переносы между регистрами, сабрутины и прочая красота
Ну, или просто читайте этот пост ради развлечения, если вы думаете это того стоит.
Инструменты
Я буду использовать Windows, FCEUX эмулятор, NESst и nesasm3 ассемблер. Также, Я взял nesdefs.asm
и nesppu.asm
у 8bitworkshop3 и портировал их на nesasm4. Эти файлы содержат множество полезных констант и макросов.
iNES заголовок
Каждый rom начинается с заголовка, в нашем случае iNES заголовка:
.inesprg 1 ; 1x 16KB PRG code
.ineschr 1 ; 1x 8KB CHR data
.inesmap 0 ; маппер 0 = NROM, без смены банков
.inesmir 1 ; отзеркаливание задника
В итоге:
- 1 16kb PRG ROM - наш код игры/движка
- 1 8kb chr rom - наша графика.
- Нет смены банков (32 kb PRG максимум)
- вертикальное отзеркаливание задника
Для понга этой конфигурации более чем хватит.
Заголовок используется только эмуляторами (и скорее всего флеш катриджами).
Переменные
16 битная переменная для позиции левой ракетки выглядит так:
paddle1PosLo .rs 1 ; младший байт позиции левой ракетки
paddle1PosHi .rs 1 ; старший байт позиции левой ракетки
Формат: название_переменной
.rs
n байт
.rs
- резервирует n байт памяти для этой переменной.
Мы можем обозначить позицию одной переменной используя .rs 2 и потом с помощью LOW и HIGH получать младший и старший байт соответственно.
Вообще, сейчас нам младший байт позиции и не нужен, так как мы ничего сейчас двигать не собираемся, но это пригодится в будующем.
16 бит помогут нам добавить перемещению плавности. 16 бит позволяет учитывать сабпиксели в вычислениях, тогда как при 8 битах минимальное изменение переменной позиции/ускорения будет эквивалентно одному пикселю.
Объясню подробнее в следующем посте.
Прерывания
Прерывания - что-то вроде хардварных ивентов. Например, игрок жмёт ресет на консоли, и это вызывает CPU прерывание и счётчик команд перемещается к ресет вектору прерывания.
У NES 3 таких:
- NMI ($FFFA-$FFFB) или Non-Maskable Interrupt - вызывается в конце каждого кадра (или в начале v-blank периода)
- Reset ($FFFC-$FFFD) - вызывается каждый раз, когда игрок жмёт кнопку ресет (ага) или при каждом старте программы
- IRQ+BRK ($FFFE-$FFFF) - что-то, в чём я пока не разбирался. Сейчас оно нам не нужно.
Мы объявляем векторы прерываний вот так вот5:
.macro NES_VECTORS
.org $fffa ; начинаем в $fffa
.dw NMIHandler ; $fffa vblank nmi
.dw Reset ; $fffc reset
.dw 0 ; $fffe irq / brk
.ENDM
Reset прерывание
Инициализация
Сейчас нам нужно временно отключить PPU6, прерывания и режим десятичных вычислений (NES CPU всё равно этот режим не поддерживает), устанавливаем указатель стека и много чего ещё.
NES CPU (2A03) это урезанный 6502 CPU без режима десятичных вычислений, но имеет специальные привязанные к адрессам памяти регистрами для ввода/вывода, звука, PPU и т.д.
NES_INIT ; устанавливаем указатель стека, вырубаем PPU
jsr WaitSync ; ждём VSYNC
jsr ClearRAM ; чистим RAM
jsr WaitSync ; ждём VSYNC (и прогрева PPU)
NES_INIT
это макро в nesdefs.asm
и его можно добавить в главный файл (pong.asm
В моём случае) написав .include "nesdefs.asm"
в начале файла.
Вот что NES_INIT
делает:
.macro NES_INIT
sei ;отключаем прерывания
cld ;отключаем режим десятичных вычислений
ldx #$ff
txs ;устанавливаем указатель стека
inx ;увеличиваем X до 0 (FF переполняется в 0)
stx PPU_MASK ;вырубаем отрисовку
stx DMC_FREQ ;вырубаем DMC прерывания
stx PPU_CTRL ;вырубаем NMI прерывания
bit PPU_STATUS ;обнуляем VBL флаг
bit APU_CHAN_CTRL ; так и не понял что мы тут делаем
lda #$40
sta APU_FRAME ;вырубаем APU Frame прерывание
lda #$0F
sta APU_CHAN_CTRL ;вырубаем DMC прерывание, врубаем другие каналы.
.endm
VSYNC. Смотрим, если 7ой бит (самый старший который или же первый если считать слева-направо) PPU_STATUS
($2002) равен одному.
WaitSync:
bit PPU_STATUS ; тригерит минусовой CPU флаг
bpl WaitSync
rts
Clear RAM. Необходимый шаг, так как мы не знаем какие данные находятся в RAM когда срабатывает Reset прерывание. Поэтому мы всегда должны полагать, что RAM при перезагрузке может содержать мусор, а не все 0, как можно было бы подумать.
ClearRAM:
lda #0 ; A = 0
tax ; X = 0
.clearRAM
sta $0,x ; чистим $0-$ff
cpx #$fe ; последние 2 байта стека?
bcs .skipStack ; не чистим
sta $100,x ; чистим $100-$1fd
.skipStack
sta $200,x ; чистим $200-$2ff
sta $300,x ; чистим $300-$3ff
sta $400,x ; чистим $400-$4ff
sta $500,x ; чистим $500-$5ff
sta $600,x ; чистим $600-$6ff
sta $700,x ; чистим $700-$7ff
inx ; X = X + 1
bne .clearRAM ; и так 256 раз
rts
И ещё один VSYNC.
Палитра
Теперь мы должны сообщить где используемая палитра будет храниться ($3F00).
lda #$3f ; $3F -> A регистр
ldy #$00 ; $00 -> Y регистр
sta PPU_ADDR ; сначала записываем старший байт
sty PPU_ADDR ; и младший $3F00 -> PPU адресс
В NES_INIT макросе мы отключили NMI и отрисовку, теперь, после инициализации палитры, пришло время включить их.
lda #CTRL_NMI
sta PPU_CTRL ; включаем NMI
lda #MASK_COLOR
sta PPU_MASK ; включаем отрисовку
PPU_CTRL биты:
PPU_MASK биты:
Вы можете предвартильно посмотреть как будет выглядеть чёрно-белый вариант и цветовый акценты с вашей палитрой и спрайтами в NESst
Добавить палитру можно так:
Pallete:
incbin "palette.pal"
palette.pal
это простой бинарник без заголовка и прочего, который содержим эти данные:
$0f,$00,$28,$30,$0f,$01,$21,$31,$0f,$06,$16,$26,$0f,$09,$19,$29,
$0f,$00,$28,$30,$0f,$01,$21,$31,$0f,$06,$16,$26,$0f,$09,$19,$29
или можно добавить эти hex значения простыми текстом:
Palette:
.db $0f,$00,$28,$30,$0f,$01,$21,$31,$0f,$06,$16,$26,$0f,$09,$19,$29 ;;задники
.db $0f,$00,$28,$30,$0f,$01,$21,$31,$0f,$06,$16,$26,$0f,$09,$19,$29 ;;спрайты
Эти числа соответсвуют цветам из внутренней NES палитры: 7
1 набор из 4 палитр для задников и 1 набор для спрайтов.
В NESst вы можете ткнуть “Palettes” -> “Put to the clipboard” -> “ASM data” чтобы скопировать палитру. Будьте вниматльны, это копирует только 1 набор (16 цветов) за раз.
Загрузка палитр
LoadPalettes:
lda PPU_STATUS ; читаем PPU статус чтобы сбросить #HIGH/#LOW
lda #$3F
sta PPU_ADDR ; пишем старший байт $3F00 адресса
lda #$00
sta PPU_ADDR ; а теперь младший байт $3F00 адресса
ldx #$00 ; начинаем с нуля
LoadPalettesLoop:
lda Palette, x ; грузим данные из адресса (Palette + x)
sta PPU_DATA ; пишем в PPU_DATA
inx ; x += 1
cpx #$20 ; Сверяем X с шестнадцатеричным $20, десятичным 32 (палитры задников и спрайтов (4*4) * 2)
bne LoadPalettesLoop ; Если не равно, то уходим в луп LoadPalettesLoop
; иначе идём дальше
Весь этот код просто копирует палитры в $3f00.
Помните, как мы сообщали NES где мы будем хранить палитру? Да, это был $3f00
Спрайты
Вот первая страница (левая сторона) моего chr rom:
Загрузка спрайтов
lda #$00
sta OAM_ADDR ; устанавливаем младший байт (00) OAM RAM адресса
lda #$02
sta OAM_DMA ; устанавливаем старший байт (02) OAM RAM адресса, начинаем DMA перенос
Пишем $0200
в OAM RAM адресс.
$0200
-$02FF
теперь содержит копию OAM (64 записи из 4 байт каждая).
Этот макрос упростит загрузку спрайтов:
graphics.asm
;; \1 спрайты со смещением \2 смещение \3 количество спрайтов
LoadSprites .macro
ldx #$00
LoadSpritesLoop\@:
lda \1, x
sta OAM_RAM+\2, x
inx
cpx \3
bne LoadSpritesLoop\@
.endm
\@
- специальный параметр, который возвращает разные числа для каждого макроса.
\1-\3
- входные параметры.
В C это бы выглядело бы как-то так:
void LoadSprites(byte sprites, byte offset, byte spritesCount) { ... };
Итак, мы грузим наш метаспрайт (спрайты
) в OAM_RAM со смещением
пока X не равен длинне метаспрайта (количество спрайтов
)
Вызываем макрос так:
LoadSprites MiddlePadle, #$00, #$10
Кстати, Я только сейчас заметил, что называю метаспрайт ракетки - “MiddlePadle”. Не обращайте особого внимания, Я хотел сделать 3 разных размера ракеток в качестве паур-апов, так что…
Может сделаем
Вот как MiddlePaddle метаспрайт выглядит:
MiddlePadle:
.db 0, $03, $00, $00
.db 8, $04, $00, $00
.db 16, $04, $00, $00
.db 24, $02, $00, $00
Просто напоминание.
Чтобы указать систему счисления, перед числом нужно добавить:
%
- двоичное число
$
- шестнадцатеричное
no prefix
- десятичное.
Давайте рассмотрим первый ряд метаспрайта:
;vert sprite attr horiz
.db 0, $03, $00, $00
-
Первый байт - это
y
координата экрана. -
Второй байт - индекс спрайта. В нашем pong.chr файле $03 соответсвует этому спрайту.
- Третий байт - атрибуты 8
Два младших бита - номер палитры. 00 - первый набор палитры, 01 - второй, 10 - третий, 11 - четвёртый. В общем, 0-3 в двоичном представлении. Довольно просто.
- Четвёртый байт -
x
координата экрана.
В нашем случае и x
и y
экранные координаты не абсолютные, а относительные. То есть, 0
не y
позиция, а смещение y
координаты. Мы смещаем каждый спрайт на 8
(десятичное число), так как каждый спрайт 8x8 пикселей.9
У нас есть две ракетки (левая и правая, да), поэтому использовать смещения будет хорошей идеей, ведь мы сможем использовать один и тот же метаспрайт для обеих ракеток.
Инициализируем переменные. $80
- середина экрана.
lda #$80
sta paddle1PosHi
lda #$00
sta paddle1PosLo
Теперь, всё что остаётся - это ждать NMI прерывания.
.endless
jmp .endless ; бесконечный цикл
NMI
Подготовка PPU
Следующий шаг - подготовка PPU для рендера следующего кадра. И так как OAM RAM реализован через динамическую RAM, контент RAM быстро превращает в мусор, поэтому нужно обновлять содержание OAM каждый кадр.8
Также теперь мы может включить отображение спрайтов, записав единицу в 4ый старший бит в PPU_MASK.
lda #$00
sta OAM_ADDR ; устанавливаем младший байт (00) OAM RAM адресса
lda #$02
sta OAM_DMA ; устанавливаем старший байт (02) OAM RAM адресса, начинаем DMA перенорс
;;
lda #%10000000 ; включаем NMI
sta PPU_CTRL
lda #%00010000 ; включаем отображение спрайтов
sta PPU_MASK
lda #$00 ;; сообщаем PPU, что задники у нас пока не скролятся
sta $2005
sta $2005
Обновление позиции
Теперь нам осталось только обновить позицию ракетки и нарисовать её метаспрайт в этой новой позиции.
Пока мы на самом деле ничего не перемещаем, но этот макрос будет очень полезен позже
Следующий макрос просто добавляет текущую позицию ракетки к смещение метаспрайта.
transform.asm
;; \1 posYHi \2 posXHi \3 offset
UpdatePos .macro
ldx #$03
ldy #$00
UpdatePosLoop\@:
lda \1
clc
adc MiddlePadle, y ; добавляем старший байт текущей Y позиции к Y смещению (первый байт каждой строки OAM ROM ракетки)
sta OAM_RAM+\3, y ; храним получившуюся позицию Y в OAM RAM со смещением
lda \2 ; позиции X - константа
sta OAM_RAM+\3, x
inx
inx
inx
inx
iny
iny
iny
iny
cpy #$10 ; можете оставить константой или добавить 4ым аргументом
bne UpdatePosLoop\@
.endm
Вызываем так:
UpdatePos paddle1PosHi, #$0C, #$00
Вы навреное заметили 4 inx
и iny
инструкции подряд и подумали, что это выглядит тупо. Ну, в общем-то да, но на самом деле это не так тупо, если подумать об этом.
Вместо 4ёх inx
, вы могли бы написать так:
txa ; 2 cycles
clc ; 2 cycles
adc 4 ; 2 cycles
tax ; 2 cycles
4 инструкции, 8 циклов. Тоже самое, что написать inx
4 раза. Видите? Это не тупо, может быть лениво, но не тупо.
Метаспрайты
Давайте снова посмотим на OAM ROM нашей ракетки:
MiddlePadle:
.db 0, $03, $00, $00
.db 8, $04, $00, $00
.db 16, $04, $00, $00
.db 24, $02, $00, $00
Каждый спрайт размером 8x8 пикселей. Марио, например, чуть больше этого. Особенно большой Марио.
Чтобы сформировать метаспрайт большого Марио, нужно собрать его из нескольких спрайтов.
Взгляньте на эту гифку:
И так же с нашей ракеткой. Обратите особое внимание на x
и y
смещения, индекс спрайтов и атрибуты.
И не забудьте добавить опкод возвращения из прерывания (RTI
) в конце.
8bitworkshop использует SAVE_REGS и RESTORE_REGS в начале и конце NMI соответственно. Честно говоря, я не знаю зачем.
Этот макрос записывает регистры в стек (SAVE) и достаёт регистры из стека (RESTORE). Вы можете найти код макроса в nesdefs.asm
Распределение памяти
Пару слов о распределении памяти нашего понга.
В iNES загловке мы объявили 1 prg rom (2 банка, каждый по 8 kb, в общем 16kb) и 1 chr rom (1 банк, в общем 8 kb). Вы наверное уже заметили, каждый банк ровно 8 kb.
К PRG можно достучаться через адресса CPU $8000 - $FFF9
.
Взгляните на изображение от BunnyBoy:
Итак, мы располагаем PRG rom в первых 32 kb, зарезервированных под rom картриджа - первый банк в $8000
, второй в $A000
.
Если вам нужно больше, чем 32kb PRG, придётся использовать маппер
CHR rom подключен к PPU и копия chr rom’а находится в диапозоне $0000-$2000
.
И да, это третья банка.
- В первом банке мы храним
Reset
иNMI
код - Во втором банке - палитра и pattern tables (наш MiddlePaddle OAM ROM)
- В третьем - наш pong.chr
В коде это выглядит так:
.bank 0
.org $8000 ;первые 8 kb prg
Reset:
NES_INIT ; устанавливаем указатель стека, вырубаем PPU
jsr WaitSync ; ждём VSYNC
jsr ClearRAM ; чистим RAM
jsr WaitSync ; ждём VSYNC (и прогрева PPU)
...
Особо не переживайте, если ничего не понятно. Мы расмотрим маппинг и мапперы позже. Наверное. Может быть.
Результаты
И после крови, боли и пота, если луна в правильном цикле и вы хорошо себя вели весь год, вы увидете
Барабанная дробь
Куча работы, чтобы отобразить 32x8 метаспрайт на пустом 256x224 экране. Не знаю как вы, а я максимально доволен.
Ссылки
Исходники можно найти тут: github
Ссылки по теме:
- Благодаря @cppchriscpp у нас теперь есть зеркало серии туториалов Nerdy Nights. Категорически рекомендую. [eng]
- 8bitworkshop
- Волшебная серия ютуб роликов про 6502 от Ben Eater [eng]
- Куча полезной информации о 6502 6502.org [eng]
- Куча полезной информации о NES nesdev [eng]
-
Всё таки фамикому уже 36 лет. Охренеть ↩
-
Расслабьтесь, 1С разаботчики, всё хорошо ↩
-
Кстати, отличная онлайн IDE’шка для 6502 ассемблера/cc65 с кучей эмуляторов разных 8-ми битных платформ. Тонна разных полезных тулзов, подсветка синтаксиса и прочее. Гляньте, если у вас нормальное отношение к онлайн IDE ↩
-
Я портировал только часть кода, который собираюсь использовать в данном проекте ↩
-
извините ↩
-
PPU or Picture Processing Unit - rtx2080 в мире 8 битных спрайтов, хотя на самом деле ближе к adreno, так как это видеочип ↩
-
На 8, не сглупите так же как сглупил я. Если вы сместите
x
и/илиy
только на 1, у вас будет куча проблем, в том числе проблем с приоритетами спрайтов. Я знаю, что это всё звучит очевидно, но я умудрился проглядеть этот момент ↩