Делаем Pong для NES (Неделя №1)

14 minute read

You can always read in a different language


🇺🇸 English
🇷🇺 Русский

Имейте в виду:

  • Всё что я делаю может быть совершенно неверным
  • Это пост может содержать нецензурную речь
  • Это не туториал. Скорее это просто подробное описание разработки этой игры
  • В посте может быть миллион грамматических ошибок
  • Не стесняйтесь сообщать мне об ошибках в комментариях или через соцсети/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_ctrl

PPU_MASK биты: ppu_mask

Вы можете предвартильно посмотреть как будет выглядеть чёрно-белый вариант и цветовый акценты с вашей палитрой и спрайтами в NESst 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

NES palette

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:
128x128 tileset

Загрузка спрайтов

  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 соответсвует этому спрайту.

paddle_graphics

  • Третий байт - атрибуты 8

attribute bits

Два младших бита - номер палитры. 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 пикселей. Марио, например, чуть больше этого. Особенно большой Марио. Чтобы сформировать метаспрайт большого Марио, нужно собрать его из нескольких спрайтов.
Взгляньте на эту гифку:

Mario's metasprite

И так же с нашей ракеткой. Обратите особое внимание на x и y смещения, индекс спрайтов и атрибуты.

paddle's metasprite

И не забудьте добавить опкод возвращения из прерывания (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: cpu memory map

Итак, мы располагаем 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)
  ...

Особо не переживайте, если ничего не понятно. Мы расмотрим маппинг и мапперы позже. Наверное. Может быть.

Результаты

И после крови, боли и пота, если луна в правильном цикле и вы хорошо себя вели весь год, вы увидете

Барабанная дробь

hey, it's pong

Куча работы, чтобы отобразить 32x8 метаспрайт на пустом 256x224 экране. Не знаю как вы, а я максимально доволен.

Ссылки

Исходники можно найти тут: github

Ссылки по теме:

  • Благодаря @cppchriscpp у нас теперь есть зеркало серии туториалов Nerdy Nights. Категорически рекомендую. [eng]
  • 8bitworkshop
  • Волшебная серия ютуб роликов про 6502 от Ben Eater [eng]
  • Куча полезной информации о 6502 6502.org [eng]
  • Куча полезной информации о NES nesdev [eng]
  1. Всё таки фамикому уже 36 лет. Охренеть 

  2. Расслабьтесь, 1С разаботчики, всё хорошо 

  3. Кстати, отличная онлайн IDE’шка для 6502 ассемблера/cc65 с кучей эмуляторов разных 8-ми битных платформ. Тонна разных полезных тулзов, подсветка синтаксиса и прочее. Гляньте, если у вас нормальное отношение к онлайн IDE 

  4. Я портировал только часть кода, который собираюсь использовать в данном проекте 

  5. извините 

  6. PPU or Picture Processing Unit - rtx2080 в мире 8 битных спрайтов, хотя на самом деле ближе к adreno, так как это видеочип 

  7. PPU palettes (nesdev’s wiki) 

  8. PPU OAM (nesdev’s wiki)  2

  9. На 8, не сглупите так же как сглупил я. Если вы сместите x и/или y только на 1, у вас будет куча проблем, в том числе проблем с приоритетами спрайтов. Я знаю, что это всё звучит очевидно, но я умудрился проглядеть этот момент