Gameboy был (и остается) отличным устройством. Не только с точки зрения геймеров, но и с точки зрения программиста, который хочет больше узнать о том, как компьютеры работают на более фундаментальном уровне. Язык ассемблера, коды операций ЦП, ввод-вывод с отображением памяти, все эти вещи, которые имеют фундаментальное значение для работы компьютеров, но на самом деле мы не можем много использовать в нашей повседневной работе, поскольку мы работаем на гораздо более высоком уровне абстракции. Теперь. Но может быть интересно и полезно узнать, как наши повседневные устройства работают под капотом. В сочетании с этим ретро-вычисления и игры - это всегда весело!

Как я уже сказал, Gameboy - идеальное устройство для этого. Выпущенный в 1989 году, он оснащен 8-разрядным процессором на базе Z80 (не слишком отличается от Intel 8080, о котором вы, возможно, знаете) с 16-разрядным адресным пространством и тактовой частотой 1 МГц, а также около 16 КБ ОЗУ. . Исполняемые файлы поставляются на картриджах, размер которых может составлять от 32 КБ до нескольких мегабайт. Сами картриджи - это просто аппаратное обеспечение, поэтому они также могут иметь дополнительную оперативную память, оперативную память с батарейным питанием для сохранений игр или даже более сумасшедшие вещи, такие как камера. А эмуляторы для запуска и отладки ваших программ легко найти.

В этой статье мы напишем простую игру для Gameboy, которая просто отображает спрайт на экране. Это не такая уж игра, но для этого нужно многому научиться. Мы будем использовать ассемблер, написанный мной на Swift, который вы можете найти на Github для macOS и Linux. В статье сначала будет объяснено, как работает ЦП Gameboy, затем как вы пишете программы сборки, затем как графика работает в Gameboy, и, наконец, мы объединим все эти части в рабочую программу. Давайте начнем с того, что узнаем немного о том, как работает Gameboy!

ЦП и адресное пространство

Есть две части, которые имеют решающее значение для понимания того, как Gameboy, и вообще любая вычислительная система, работает: ЦП, который принимает инструкции и выполняет их, и адресное пространство, которое используется для доступа к различным аппаратным компонентам. Когда инструкция выполняется в ЦП, она каким-то образом изменяет состояние системы; либо внутреннее состояние ЦП, либо состояние некоторого аппаратного компонента, доступ к которому осуществляется через адреса памяти. Gameboy имеет 16-битное адресное пространство, что означает, что вы можете получить доступ к адресам памяти от 0 до 65535 ($ ffff в шестнадцатеричном формате). Аппаратное обеспечение Gameboy сопоставило каждый из этих адресов некоторому оборудованию; это может быть ПЗУ картриджа, звуковой чип или ОЗУ общего назначения.

Когда вы компилируете свой ассемблерный код, он превращается в двоичный файл, который представляет собой серию кодов операций. Код операции - это число, представляющее одну инструкцию, которую может выполнить ЦП. Например, код операции $ 00 означает NOP, что означает отсутствие операции. $ 82 - это инструкция ADD, которая складывает два числа. Ваш двоичный файл представляет собой длинный список этих кодов операций, которые размещены в ПЗУ на картридже. ПЗУ картриджа - это память, отображаемая в первую половину пространства памяти Gameboy, то есть все адреса от $ 0000 до $ 7fff (эти значения легче назвать шестнадцатеричными числами. 16-битное значение - это четырехзначное шестнадцатеричное число). Это означает, что если вы прочитаете любой адрес в диапазоне от 0000 до 7fff, он прочитает один байт из картриджа. Обратите внимание, что адресное пространство 16-битное, что означает, что каждый адрес в ячейке памяти находится между $ 0000 и $ ffff, но значения, которые хранятся в этих местах, являются 8-битными, то есть между $ 00 и $ ff.

ЦП Gameboy сохраняет свое состояние во внутренних регистрах. Каждый регистр представляет собой 8- или 16-битное значение, которое хранится непосредственно внутри ЦП, и поэтому он очень быстро читает и записывает, поскольку ему не нужно взаимодействовать с каким-либо другим аппаратным компонентом через адресную шину. Gameboy имеет восемь 8-битных регистров и два 16-битных регистра:

8-битные регистры: A, F, B, C, D, E, H, L
16-битные регистры: SP (указатель стека), PC (счетчик программ)

Регистр SP отслеживает размер стека (мы вернемся к этому позже), а регистр ПК отслеживает, где в коде процессор в настоящее время выполняет. Эти два регистра 16-битные, как и адресное пространство. Это потому, что они указывают на место в адресном пространстве. Регистр PC буквально указывает на то место в памяти, которое он будет выполнять следующим, и вы перепрыгиваете в коде, изменяя значение PC.

Все 8-битные регистры, за исключением F, являются регистрами общего назначения, и вы можете читать и записывать в них значения по своему усмотрению. Например, если вы хотите сложить два числа вместе, вы можете поместить первое значение в регистр A, второе значение в регистр B, а затем вызвать инструкцию ADD A, B, которая будет читать значение A, читать значение B, сложите эти два вместе и поместите результат в A. Регистр F, регистр флагов, является специальным регистром, который содержит однобитовые флаги, которые устанавливаются из разных инструкций. Если вам интересно, почему порядок немного странный, потому что некоторые инструкции используют два 8-битных регистра как один 16-битный регистр, на случай, если вы хотите иметь возможность, скажем, складывать числа больше 255 ( $ ff). Эти 16-битные комбинированные регистры называются AF, BC, DE и HL.

Когда Gameboy загружается, первое, что вы видите, - это логотип Gameboy. После этого он будет вручную управлять всей системой в соответствии с вашим кодом, установив для ПК значение 100 долларов. Это точка входа в вашу программу. ЦП работает циклично, и каждый из этих циклов будет читать байт, на который указывает ПК, затем увеличивать ПК, чтобы он указывал на следующий байт в памяти, а затем фактически выполняет инструкцию, представленную значением байта, которое он только что прочитал. Доступные инструкции для ЦП и отображение между инструкциями ЦП и их кодами операций - это набор инструкций. Всего у Gameboy 499 инструкций, ссылки на все вы можете увидеть здесь. Например, вы можете видеть, что инструкция NOP имеет код операции $ 00, что означает, что если ЦП считывает $ 00 при выборке инструкции, он просто приостанавливается на некоторое время, а затем переходит к следующей инструкции.

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

Написание ассемблерных программ

В языках программирования, к которым вы привыкли, обычно есть такие вещи, как типы, поток управления, например цикл if и while, функции, принимающие аргументы и возвращаемые значения и т. Д. Сборка, будучи языком низкого уровня, не имеет ничего из этого. . На самом деле сборка - это просто читаемый слой поверх необработанных машинных байтов. Поэтому вместо того, чтобы писать $ 00 в шестнадцатеричный редактор, вы пишете мнемонику для этой инструкции, то есть NOP. Ассемблерный код - это просто длинный список этих инструкций. Единственный инструмент, который у вас есть, кроме написания мнемоники инструкций, - это метки, которые представляют собой имена, которые вы можете давать сериям инструкций. Вы используете метки для перехода к вашему коду. Итак, если у вас на C-подобных языках может быть такая функция:

void stopCPU() {
 halt()
}

В сборке это будет просто

stopCPU:
 halt
 ret

(HALT - это инструкция, которая временно останавливает ЦП. RET возвращается к вызывающему, восстанавливая ПК. Также обратите внимание, что сборка нечувствительна к регистру, поэтому, хотя я буду использовать ВЕРХНИЙ регистр при описании инструкций и регистров в тексте, я используя строчные буквы в самом коде)

У вас также могут быть переменные и функции, возвращающие значения на вашем обычном языке высокого уровня. В сборке вам всегда нужно куда-то помещать свои значения. Это может быть как в ОЗУ, так и в одном из регистров общего назначения. Итак, где вы обычно пишете что-то вроде этого:

int add_5(int value) {
 return value + 5;
}

В сборке это будет

add_5:
 ld A, B
 add A, 5
 ret

(LD - инструкция загрузки, копирует значение из одного регистра в другой)

Он принимает входное значение через регистр B и возвращает значение, изменяя регистр A. Обычно вы пишете, какие регистры используются, в комментарии к документации над меткой:

# Add 5 to a value
# B: input value
# A: output value
add_5:
 ld a, B
 add a, 5
 ret

Когда вы хотите вызвать эту «функцию», вы используете инструкцию вызова:

ld b, 10
call add_5

CALL примет текущее значение на ПК (указатель на следующую инструкцию, которая будет выполняться) и сохранит его в стеке. Стек - это область в памяти, в которой можно сохранять и извлекать данные. Он управляется указателем стека (SP), и каждый раз, когда значение выталкивается или выталкивается из стека, SP будет увеличиваться или уменьшаться. Вы можете нажимать и выдвигать значения вручную с помощью PUSH ‹register› и POP ‹register›. В этом случае ПК помещается в стек, поэтому он сохраняется в памяти, а затем модифицируется так, чтобы указывать на метку add_5, которая является местом в памяти первой инструкции под этой меткой. Затем ЦП будет запускать инструкции оттуда, пока не встретит команду RET, которая вытолкнет старое значение ПК обратно из стека, которое указывает на инструкцию после CALL, и продолжит выполнение с того места, где оно было остановлено.

Также возможно изменить ПК напрямую, не сохраняя значение в стеке. Это переход (или переход), который используется для управления потоком, как циклы. Возьмите этот цикл высокого уровня:

for (int i = 0; i < 10; i++) {
 // code goes here
}

Этот код выполняет один и тот же фрагмент кода 10 раз. Он пройдет через тело цикла один раз, затем увеличит i, проверит условие и, если оно выполняется, вернется к началу и выполнит цикл еще раз. В сборке мы делаем то же самое, только без всяких причудливых абстракций:

ld a, 10
loop:
 # code goes here
 dec a
 jp nz, loop

Это загрузит 10 в регистр A, пройдёт через тело цикла, затем уменьшит a на единицу и перейдет к метке цикла, если A не равно нулю (nz = не ноль). Инструкция JP может принимать метку или условие и метку (или прямой адрес памяти для перехода, но не делайте этого, если вы не знаете, что делаете). Условия проверяют биты в регистре F (флаги). Одним из этих флагов является флаг нуля, который устанавливается, когда арифметическая инструкция выполняет вычисление, результатом которого является нулевое значение. В этом случае, если DEC устанавливает A в ноль (что будет на последней итерации, когда A уменьшается с 1 до 0), он установит флаг Zero, и инструкция JP не вернется к началу.

Это в основном то, что вам нужно знать о сборке. Остальное - просто знать все инструкции, которые есть в вашем распоряжении. Вот некоторые из наиболее распространенных, которые вам, вероятно, понадобятся:

NOP: ничего не делает, просто приостанавливает работу процессора на один цикл

ДОБАВИТЬ ‹r1›, ‹r2›: складывает значение регистров r1 и r2 и сохраняет результат в r1. Например, ADD A, B добавит A и B и сохранит результат в A. r2 также может быть прямым значением, например ADD A, 5.

SUB ‹r1›, ‹r2›: вроде сложить, но вместо этого вычитает значения. Когда значение опускается ниже нуля, оно возвращается к 255.

LD ‹r1›, ‹r2›: копирует значение r2 в r1, отбрасывая старое значение в r1. r2 также может быть прямым значением.

JP [условие], ‹target›: переход к целевой метке или адресу памяти, если условие выполняется. Если нет условия, он просто прыгнет туда. Условие может быть Z (установлен нулевой флаг), NZ (нулевой флаг не установлен), C (установлен флаг переноса) или NC (флаг переноса не установлен).

ЗВОНИТЕ [условие], ‹target›: Подобно прыжку, но сохранит значение ПК в стек перед прыжком. Также может быть условие.

RET: возврат из CALL путем восстановления ПК из стека.

PUSH ‹r1›: помещает r1 в стек. r1 также может быть прямым значением.

POP ‹r1›: извлекает из стека последнее переданное значение и помещает его в r1.

INC ‹r1›: увеличивает значение в r1 на единицу.

DEC ‹r1›: Уменьшает значение в r1 на единицу.

AND ‹r1›: выполняет логическую операцию AND над A и r1, сохраняя результат в A. Логическое AND принимает каждый бит в каждом значении и возвращает 1, если установлены оба бита.

ИЛИ ‹r1›: выполняет операцию логического ИЛИ над A и r1, сохраняя результат в A. Логическое ИЛИ принимает каждый бит в каждом значении и возвращает 1, если какой-либо из двух битов установлен.

XOR ‹r1›: выполняет операцию логического ИЛИ над A и r1, сохраняя результат в A. Логическое ИЛИ принимает каждый бит в каждом значении и возвращает 1, если установлен * только * один из битов.

Чтобы получить полный список инструкций, вам нужно найти руководство по программированию Gameboy.

Теперь, после введения в сборку и наиболее распространенных инструкций, давайте приступим к написанию кода для Gameboy!

Начало вашей программы

Как я сказал ранее, Gameboy начнет запускать ваш код из ячейки памяти за 100 долларов. Как нам разместить код именно в этом месте? Мы делаем это, записывая ярлык и давая ему происхождение. Это говорит ассемблеру поместить код для этой метки в это место в ПЗУ.

[org(0x100)]
start:
 # Code goes here

Теперь код под этим ярлыком будет запущен при запуске нашей игры. И вообще, первое, что нам нужно сделать, это прыгнуть в другое место. У Gameboy есть определенные части ПЗУ, зарезервированные для информации об игровом картридже, например, размер ПЗУ, название игры и т. Д., И эта информация хранится от 104 до 14 франков, что означает, что до этого у нас было только 4 байта. информация об игре запускается! К счастью, этого достаточно, чтобы отскочить. Код операции инструкции JP - это один байт, за которым следует ячейка памяти, к которой он хочет перейти, то есть два байта (16-битный адрес). Поскольку оборудование выглядит странно, также рекомендуется использовать NOP в качестве первой инструкции, и на это мы использовали наши 4 байта пространства.

[org(0x100)]
start:
 nop
 jp game_init
[org(0x150)]
game_init:

Это перейдет к метке game_init, которую мы определили чуть ниже. Если бы мы определили метку без источника, ассемблер просто поместил бы ее сразу после процедуры запуска в памяти, и это именно то, чего мы не хотели, поскольку это зарезервировано, поэтому мы поместили метку game_init на 150 долларов, который находится сразу после окончания зарезервированной памяти.

Итак, теперь у нас есть точка входа для нашей программы. Мы должны попытаться запустить его, просто чтобы убедиться, что он не взорвался полностью. Перед тем как это сделать, нам нужно быстро настроить двоичный файл правильного размера. Минимальный размер ПЗУ картриджа Gameboy составляет 32 КБ (8000 байтов), поэтому нам нужно создать двоичный файл такой длины. Для этого мы создадим метку в позиции $ 7fff, последнем байте нашего ПЗУ, и поместим туда значение $ 00.

[org(0x7fff)] pad: db 0x00

БД не является инструкцией для Gameboy. Вы не найдете его нигде в наборе инструкций. Это специальная инструкция для ассемблера. Это означает «определить байт», и давайте поместим значения прямо в свой двоичный файл. Помните, что двоичный файл - это просто список чисел. Некоторые из них представляют собой коды операций, некоторые - игровую информацию, некоторые могут быть спрайтовыми и звуковыми ресурсами. И они также могут быть просто числами, которые вы определяете, как мы сделали здесь.

Теперь мы можем попробовать сохранить его как game.asm и скомпилировать. Вы можете получить ассемблер, просто клонировав его и запустив быструю сборку.

> git clone [email protected]:ulrikdamm/Assembler.git
> cd Assembler
> swift build

А затем просто запустите двоичный файл с того места, где вы разместили исходный код сборки.

> Assembler/.build/debug/Assembler game.asm

Это должно создать двоичный файл с именем game.gb, который вы можете попробовать запустить в эмуляторе. Если все прошло хорошо, у вас должен быть пустой экран. Прогресс!

Загрузка графики в память

Чтобы отображать графику на экране, нам нужен фрагмент графики, который нужно отобразить, и поместить его во VRAM Gameboy. В адресном пространстве ПЗУ на картридже отображается в диапазоне от $ 0000 до $ 7fff. Сразу после этого, от 8000 до 1000 долларов, идет VRAM, которая представляет собой встроенную в Gameboy память, куда вы можете поместить свои спрайты и данные тайлов. Эти данные будут отправлены как часть вашего ПЗУ, поэтому нам придется поместить их где-нибудь в нашем двоичном файле, а затем скопировать эти данные в область VRAM. Давайте начнем с создания спрайта, который мы хотим отобразить, и поместим его в ПЗУ. Спрайты хранятся в виде тайлов 8x8 пикселей, поэтому я сделал этот смайлик 8x8 пикселей, который мы можем отображать.

________
__#__#__
__#__#__
________
#______#
_######_
________
________

Экран Gameboy может одновременно отображать четыре разных «цвета». Я заключаю «цвета» в кавычки, потому что у Gameboy был монохромный дисплей, поэтому я действительно имел в виду четыре оттенка серого. Это означает, что каждому пикселю нашего спрайта требуется два бита информации, которые могут представлять значение от 0 до 3. В этом примере мы не будем использовать другие цвета, кроме черного и белого, поэтому мы просто будем использовать 0–0 для представляют белый цвет, а 1–1 (3) - черный. Какие цвета представлены какими значениями - это то, что мы можем указать сами, установив палитру, что мы и сделаем позже. Теперь нам нужно преобразовать наш спрайт в двоичные данные. Спрайт хранится в 16 байтах: 8 пикселей x 8 пикселей x 2 бита на пиксель. Он хранится в строках, поэтому первый байт - это первый бит первой строки; второй байт - это второй бит первой строки; третий байт - это первый бит второй строки и т. д.

00000000 $00
00000000 $00 — first line
00000000 $00
00000000 $00 — second line
00100100 $24
00100100 $24 — third line
00000000 $00
00000000 $00 — forth line
10000001 $81
10000001 $81 — fifth line
01111110 $7e
01111110 $7e — sixth line
00000000 $00
00000000 $00 — seventh line
00000000 $00
00000000 $00 — eighth line

Надеюсь, вы уловили идею. Итак, данные для нашего смайлика-спрайта

$00 $00 $00 $00 $24 $24 $00 $00 $81 $81 $7e $7e $00 $00 $00 $00

Чтобы поместить эти данные в наш двоичный файл, мы можем просто снова использовать команду DB, чтобы ассемблер просто поместил эти числа прямо в ПЗУ.

smiley_sprite: db 0x00, 0x00, 0x00, 0x00, 0x24, 0x24, 0x00, 0x00, 0x81, 0x81, 0x7e, 0x7e, 0x00, 0x00, 0x00, 0x00

Итак, мы идем. Теперь напишем процедуру копирования этих байтов в правильный адрес VRAM. Для этого мы будем использовать специальное значение регистра. Вспомните 8-битные регистры общего назначения: их было два с именами H и L, которые вместе образовали 16-битный регистр общего назначения HL. Что ж, когда у нас есть 16-битное значение, мы можем использовать его как указатель в нашей памяти, поэтому у Gameboy есть несколько удобных инструкций для обработки значения, на которое указывает HL, как регистра. Допустим, мы хотим поместить значение $ ff в ОЗУ по адресу $ 8100. Мы можем использовать LD, чтобы поместить число $ 8100 в HL, поместить $ ff в A, а затем сказать LD [HL], A, чтобы загрузить значение $ ff в адрес памяти $ 8100. Или наоборот, если мы хотим загрузить содержимое адреса памяти в A, мы просто говорим LD A, [HL]. Использование значения в [квадратных скобках] обычно означает, что значение является указателем, и оно должно работать с указанным байтом. Если вы не хотите использовать HL, вы также можете просто сказать LD [0x8100], A.

Это очень удобно, но этим даже не останавливается. Очень типичная операция - это перебрать большой объем памяти, копируя байты, как мы и хотим. Для этого вы используете LD [HL], A, загружая начальный адрес в HL, а затем увеличивая HL для каждой итерации цикла. Что ж, есть специальный синтаксис, чтобы сделать это еще проще: LD [HL +], A, который загружает A в адрес, на который указывает HL, а затем увеличивает HL на единицу. И, конечно, вы также можете сделать LD [HL-], A, если вы собираетесь пойти другим путем.

Итак, давайте воспользуемся этим и тем, что мы узнали о циклах ранее, для написания кода, который копирует наши графические данные в память по адресу $ 9000 (где хранятся данные фоновой плитки).

ld hl, 0x9000 + 16
ld de, smiley_sprite
ld b, 16
copy_loop:
 ld a, [de]
 inc de
 ld [hl+], a
 dec b
 jp nz, copy_loop

Давай пройдемся через это. Сначала мы вкладываем 9000 долларов в HL. Мы будем использовать HL в качестве целевого указателя. Затем в DE помещаем smiley_sprite. Помните, что это было имя метки, в которой мы определили данные нашего спрайта, поэтому, когда мы используем его в операторе, он станет местом в памяти этой метки. Итак, после этого DE будет указывать на первый байт нашего спрайта в ПЗУ. Затем мы загружаем 16 в регистр D, который является длиной данных, которые мы хотим скопировать. После этого мы запускаем цикл, добавляя метку, к которой мы можем вернуться. Структура должна быть похожа на цикл, который мы рассматривали ранее: после метки идет тело цикла, а после этого мы уменьшаем D и переходим к метке, если D не равно нулю. Внутри тела цикла мы переносим один байт с адреса, на который указывает BC, на адрес, на который указывает HL, и увеличиваем их оба на единицу (используя специальный синтаксис [HL +] для HL, но этого не существует для BC ). Чтобы скопировать байт между двумя адресами памяти, сначала загрузите его в регистр A, а затем загрузите из A в целевое расположение.

Я слышал, вы спросите, почему мы не можем просто перейти напрямую из [BC] в [HL]? Это потому, что этой инструкции нет в наборе инструкций. Если вы посмотрите в справочнике по набору команд, вы найдете LD A, [BC], LD [HL], A и т. Д., Но не LD [HL], [BC]. Это просто аппаратное ограничение. Если вы попытаетесь это написать, вы получите ошибку от ассемблера.

Вы также можете задаться вопросом о LD HL, 0x9000 + 16. Обычно, когда вы хотите сложить два числа, вы должны сделать это через регистр A. Однако в этом случае мы можем выяснить результат во время сборки, поэтому ассемблер будет достаточно умен, чтобы знать, что это действительно означает LD HL, 0x9010. Мы можем написать его таким образом, чтобы он был более удобочитаемым. Почему мы используем именно это место в памяти, мы поговорим в следующем разделе.

Вот и все, после этого наш спрайт был загружен во VRAM и может отображаться на экране. Для этого давайте немного поговорим о том, как данные плитки и спрайта размещаются во VRAM и как вы включаете дисплей.

Включение дисплея

Есть три способа отображать объекты на дисплее Gameboy: фон, который представляет собой слой плитки 32x32, который можно прокручивать, окно, которое представляет собой слой над фоном, который также можно прокручивать, а затем слой индивидуально перемещаемых спрайты. В этом примере мы будем использовать только фоновый слой. Мы установим первую плитку фона так, чтобы она указывала на спрайт, который мы загрузили в память. То, какие спрайты отображаются на заднем плане, - это просто список чисел в памяти от 9800 до 9 баррелей или 9c00-9 долларов США (вы можете выбрать одно из двух мест с помощью регистра ввода-вывода LCDC, к которому мы вернемся в немного). Значение для каждой плитки представляет собой смещение в данных спрайта, которое находится в диапазоне от 8800 до 97 долларов США или от 8000 до 8 долларов США (также выбирается через LCDC). Как вы можете видеть в нашем коде загрузки спрайта, мы скопировали данные спрайта в $ 9000 + 16, где находится второй спрайт. Спрайт № 0 находится на уровне 9000 долларов, когда мы выбрали диапазон от 8800 до 97 долларов для данных спрайта, поскольку это индекс со знаковыми числами, что означает от -128 до 127, где 0 находится посередине, по цене 9000 долларов. Итак, мы используем индекс плитки 1 для нашего спрайта.

Теперь нам нужно указать в фоновой тайловой карте. Мы используем диапазон от 9800 до 9 миллиардов долларов без знака. Это немного проще, поскольку мы отображаем только один спрайт и, следовательно, должны установить только один байт, первый байт этой памяти, со значением индекса спрайта, равным 1.

ld hl, 0x9800
ld [hl], 1

Как видите, набор инструкций поддерживает загрузку прямого значения в [HL], поэтому нам не нужно сначала загружать его в A. Удобно! Если бы мы хотели использовать BC вместо HL, нам пришлось бы загрузить 1 в A, а затем загрузить A в [BC].

Теперь нам нужно установить данные палитры. Мы говорили об этом ранее; так мы определяем «цвета» наших спрайтов. Для наших данных спрайта мы использовали 0–0 (0) для обозначения белого цвета и 1–1 (3) для обозначения черного. Данные палитры - это регистр ввода-вывода в памяти. Это означает, что это специальный байт в верхней памяти (адреса $ ff ##), который настраивает Gameboy определенным образом при его изменении. Палитра фона и окна расположена по адресу $ ff47 и имеет следующую битовую раскладку:

Бит 0–1: Цвет для значения 0
Бит 2–3: Цвет для значения 1
Бит 4–5: Цвет для значения 2
Бит 6–7: Цвет для значения 3

Каждый цвет определяется как 2-битное значение. Мы установим для него значение, которое означает, что 0 - самый светлый, 3 - самый темный, а два других - промежуточное значение.

ld hl, 0xff47
ld [hl], 0b1110_0100

(Вы можете использовать префикс 0b для указания двоичных чисел, которые часто легче обрабатывать, чем шестнадцатеричные коды для битовых полей. Вы также можете разместить символы подчеркивания, чтобы сделать число более читаемым.)

После этого последнее, что нам нужно сделать, - это указать, какие области памяти мы хотим использовать для данных плитки и спрайта, и включить отображение. Эта информация хранится в регистре ввода-вывода LCDC (LCD Control) по адресу $ ff40. Он имеет следующую битовую раскладку:

Бит 0: включение / выключение фона и отображения окон
Бит 1: включение / выключение слоя спрайта
Бит 2: размер спрайта (0: 8x8, 1: 8x16)
Бит 3: фон мозаичной карты select (0: 9800–9 млрд долларов США, 1: 9c00–9 долларов США)
Бит 4: выбор данных фона и плитки окна (0: 8800–97 долларов США, 1: 8000–8 долларов США)
Бит 5: отображение окна вкл. / Выкл.
Бит 6: выбор мозаичной карты окна (0: 9800–9 млрд долл., 1: 9c00– 9 млрд долл.)
Бит 7: включение / выключение отображения

В этом примере мы хотим установить следующие параметры:

Фон и отображение окон: включен (1)
Слой спрайта: выключен (0)
Размер спрайта: неважно (0)
Выбор карты фоновой плитки: 9800–9 млрд долларов (0)
Выбор данных плитки фона и окна: 8800-97 долларов США (0)
Отображение окна: выключено (0)
Выбор карты мозаики окна: все равно (0)
Отображение : на (1)

Итак, для байта, представляющего параметры LCDC, нам нужно установить бит номер 0 и бит номер 7 и загрузить это значение в правильную ячейку памяти.

ld hl, 0xff40
ld [hl], 0b1000_0001

Вот и все! Дисплей включен, фоновый слой включен, он считывает карту тайлов от 9800 до 9 баррелей, где первый байт равен 1, что означает, что он будет читать этот спрайт из 9010 долларов, куда мы загрузили наш смайлик-спрайт. в.

Теперь все, что нам не хватает, - это сказать Gameboy, чтобы он прекратил работу. Если бы мы просто оставили код в таком виде, ПК просто продолжал бы расти и просто считывал бы мусорные инструкции из памяти. Останавливаем выполнение очень простым оператором:

end: jp end

Это навсегда заблокирует компьютер, поскольку он всегда будет просто прыгать в одно и то же место (мы также могли бы использовать инструкцию HALT, но пока давайте не будем усложнять задачу).

Соберем все вместе!

[org(0x100)]
start:
 nop
 jp game_init
[org(0x150)]
game_init:
 # Copy the sprite data into VRAM
 ld hl, 0x9000 + 16
 ld de, smiley_sprite
 ld b, 16
 copy_loop:
  ld a, [de]
  inc de
  ld [hl+], a
  dec b
  jp nz, copy_loop
 
 # Set the first byte of the background tile map
 ld hl, 0x9800
 ld [hl], 1
 
 # Set the background palette
 ld hl, 0xff47
 ld [hl], 0b1110_0100
 
 # Set LCDC to turn on the display
 ld hl, 0xff40
 ld [hl], 0b1000_0001
 
 # Stop execution
 end: jp end
smiley_sprite: db 0x00, 0x00, 0x00, 0x00, 0x24, 0x24, 0x00, 0x00, 0x81, 0x81, 0x7e, 0x7e, 0x00, 0x00, 0x00, 0x00
[org(0x7fff)] pad: db 0x00

Соберите его и откройте двоичный файл в эмуляторе Gameboy, и вы увидите счастливый смайлик в верхнем левом углу!

В зависимости от эмулятора вы также можете увидеть на экране некоторые странные символы, потому что мы не очищали память перед использованием, поэтому она может не быть заполнена нулями. Это будет упражнением для читателя. Если вы это видите, попробуйте написать цикл, который обнуляет всю память фоновых плиток в начале программы.

Успех!

На этот раз это все, но есть еще много чего узнать о Gameboy. Я надеюсь осветить больше тем в другой статье в будущем. Если вам все еще интересно узнать больше, подпишитесь на меня в Twitter @ulrikdamm.