Sega Master System 06: Порты ввода-вывода. Hello World

Сегодня мы применим на практике все те знания которые получили в первых статьях и напишем первую программу для SMS. Это будет классическая программа "Hello World". Если кто не знает, то обычно, при изучении различных языков программирования, самая первая программа которую делают изучающие - это простейшая программа выводящая на экран сообщение "Hello world". Она позволяет разобраться со структурой программы и при этом, обычно такая программа обладает минимально возможной сложностью, для облегчения понимания.

Как правило, Hello world пишут как можно раньше и даже в ущерб полному пониманию того как эта программа работает, и лишь потом начинают полный разбор. Я предпочитаю немного другой подход - цикличного изучения, сначала мы получаем общее представление о предмете изучения достаточное для понимания определённых базовых принципов, затем некоторые аспекты мы разбираем более подробно, чтобы углубить общее понимание всего предмета и перейти к более сложным темам, и так далее. На текущий момент мы с вами уже изучили достаточно, чтобы в классической программе Hello world для нас не было никаких неожиданных моментов.

Итак, давайте посмотрим из чего будет состоять такая программа для SMS.

  1. Нужно сформировать образ картриджа в корректном формате.
  2. Настроить стек и работу прерываний.
  3. Инициализировать VDP.
  4. Передать VDP графическую информацию.
  5. Передать VDP выводимую строку
  6. Остановить выполнение программы.

Формат рома.

Перед началом написания программы нам надо сообщить ассемблеру кое-какую информацию о будущем картридже. Поскольку ассемблер wla dx рассчитан на достаточно широкий список архитектур и ещё более широкий список конкретных платформ, то нам надо ему указать что именно мы от него хотим получить в итоге.

Сначала нам нужно указать расположение слотов в адресном пространстве процессора. Для этого существует директива .memorymap. Эта директива имеет более сложную структуру чем те которые мы описывали раньше. Всё что идёт после этой директивы ассемблер считает описанием структуры адресного пространства, пока не встретит директиву .endme, которая даёт понять ассемблеру, что описание адресного пространства окончено. Само описание производится при помощи специальных служебных слов. Вот таким образом мы опишем что у нас будет два слота и размер слота равен 16кб.

.memorymap
    defaultslot 0
    slotsize $4000
    slot 0 $0000
    slot 1 $4000
.endme

После этого нам нужно так же предоставить ассемблеру описание банков в нашем картридже. Делается это похожим образом, при помощи пары директив .rombankmap, .endro и специальных слов между ними. Опишем структуру нашего картриджа. Он состоит из двух банков размером по 16кб каждый.

.rombankmap
    bankstotal 2
    banksize $4000
    banks 2
.endro

Нам могло бы хватить и одного слота и банка в 16кб, но размер в 32кб был выбран не случайно. Всё дело в том что некоторые модели SMS имеют встроенный BIOS содержащий программу выполняющуюся перед запуском программы из картриджа. Его наличие обусловлено несколькими факторами. Версии приставок с BIOS'ом обладали одной или несколькими встроенными играми. При включении приставки, BIOS показывал свою заставку и проверял наличие картриджа в слоте, и если не находил его, то запускал встроенную игру. Если же находил картридж, то сначала проверял, является ли картридж "корректным". В BIOS'е была специальная подпрограмма для проверки корректности картриджа, она и обращалась к заголовку ROM'а. Заголовок, как правило, распологается в последних 16 байтах ROM'а. В нашем случае по адресу $7ff0.

Не будем подробно останавливаться на содержимом заголовка, но стоит упомянуть о том, что в заголовке содержалась информация о версии игры, коде продукта, регионе, размере картриджа, а так же его контрольна сумма. BIOS исходя из указанного в заголовке размера картриджа и содержимого первых ячеек в ROM'е, рассчитывал контрольную сумму и сравнивал её указанной в заголовке. И только после того как BIOS определял что заголовок записан в правильном формате, регион картриджа и приставки совпадает, а рассчитанная контрольная сумма совпадает с записанной в заголовке - передавал полное управление приставкой картриджу. Иначе на экран выводилось сообщение о том, что данный картридж не подходит к этой приставке.

Но зачем мы сделали ром именно в 32кб? Всё дело в том что в определённых моделях BIOS'ов в механизме расчёта контрольной суммы встречались ошибки, которые не проявлялись на картриджах размером в 32кб.

Рассчитать правильную контрольную сумму и записать корректный заголовок нам позволяет директива .sdsctag.

.sdsctag 1.0, "", "", ""

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

После всех приготовлений мы уже можем начать наполнять наш ROM кодом, однако надо учесть ещё одну особенность адресации внутри рома и адресного пространства процессора. Поскольку наш код будет исполняться внутри адресного пространства процессора, то ассемблеру нужно рассчитывать правильные адреса именно для адресного пространства процессора, но наш код будет храниться в ROM'е на картридже и его фактический адрес размещения будет отличаться от того под которым он будет виден процессору. Для этого нам нужно при помощи директивы .bank дать понять ассемблеру в каком банке и слоте находится идущий за этой директивой код.

Рассмотренная нами ранее директива .org указывает смещение относительно начала последнего указанного банка.

.bank 0 slot 0

Мы указали что весь код после этой директивы будет находиться в банке 0 который будет подключен в слот 0.

Инициализация. Обработка прерываний.

Исполнение программы начинается с адреса $0000. Эта наша точка входа в программу. Первым делом нам надо отключить маскируемые прерывания. Нам они пока без надобности и прерывать процесс первичной настройки - плохая затея. Затем нам надо установить режим работы прерываний номер 1. Указать расположение стека и перейти к дальнейшему коду.

.org $0000
    di            ; отключаем обработку маскируемых прерываний
    im 1          ; переводим процессор в режим прерываний 1
    ld sp, $dff0  ; укажем начало стека, 
                  ; то есть установим его вершину по адресу $dff0
 
    jp main       ; переход к метке main

Давайте разберёмся с тем почему стек начинается именно с этого адреса. Обычно началом стека указывают самый конец оперативной памяти в нашем случае это был бы адрес $dfff, либо вообще самый конец адресного пространства. Однако мы не можем использовать самые последние байты нашей оперативной памяти так как они пересекаются с линиями управления маппером, которые расположены по адресам $fffc-$ffff. Адреса $fff1-$ffff зарезервированы для управления другим оборудованием, таким как 3д очки например. А раз эти адреса пересекаются с зеркалом оперативной памяти то и адреса $dff1-$dfff мы тоже использовать не можем. Поэтому наш стек начинается с адреса $dff0.

Зачем нам переходить куда-то почти сразу после старта? Всё дело в том что по адресам $0038 и $0066 должны располагаться обработчики прерываний и если мы и дальше будем продолжать программу с самого начала, то залезем в эти адреса, поэтому метку main мы расположим после обработчиков прерываний. Давайте же теперь опишем обработчики прерываний.

.org $0038  ; обработчик маскируемого прерывания
    ei      ; включить прерывания
    reti    ; возврат из прерывания
 
.org $0066  ; обработчик немаскируемого прерывания
    retn    ; возврат из немаскируемого прерывания
 
main:       ; здесь продолжается основная программа

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

В обработчике немаскируемого прерывания тоже ничего нет - только команда возврата из этого прерывания. На этом первичная инициализация закончена.

Порты.

Теперь нам необходимо поработать с VDP, общение с которым ведётся в основном через порты ввода-вывода. Как вы помните z80 использует для работы с портами те же самые шины данных и адреса что и для работы с памятью. Таким образом получается что z80 может работать с 65536 портами. Для SMS это конечно же запредельное количество и столько периферийных устройств у нас просто нет. Старший байт адресной шины в SMS для портов не используется вообще, только младший. Тем самым мы сокращаем диапазон портов до $00-$ff. Но в действительности в SMS используется всего от 8 до 11 портов в зависимости от ревизии. Вот полный список номеров портов и их назначение.

┌──────┬────────────────────────────────┬────────────────────────────────────┐
│ Порт │            Чтение              │             Запись                 │
├──────┼────────────────────────────────┼────────────────────────────────────┤
│ $3e  │ -                              │ Управление памятью                 │
│ $3f  │ Управление контроллером ввода  │ Управление контроллером ввода      │
│ $7e  │ Счётчик строк (V counter)      │ Управление чипом SN76489           │
│ $7f  │ Счётчик точек (H counter)      │ Управление чипом SN76489 (зеркало) │
│ $be  │ Порт данных для VDP            │ Порт данных для VDP                │
│ $bf  │ Статус VDP                     │ Порт управления VDP                │
│ $dc  │ Состояние игровых контроллеров │ -                                  │
│ $dd  │ Состояние игровых контроллеров │ -                                  │
│ $f0* │ -                              │ Порт выбора регистра чипа YM2413   │
│ $f1* │ -                              │ Порт данных чипа YM2413            │
│ $f2* │ Порт управления YM2413         │ Порт управления YM2413             │
└──────┴────────────────────────────────┴────────────────────────────────────┘

   (*) - Присутствуют только в японских версиях SMS

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

Как устроены порты ввода-выода

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

Вы могли задаться вопросом, почему если портов ввода-вывода так мало, то почему нельзя было бы дать им более простые и запоминающиеся номера, например $00-$0B. Их общее количество меньше 16 так что их можно было бы выразить всего одной шестнадцатеричной цифрой.

Посмотрим как обстоят дела на самом деле. Все периферийные устройства подключены только к трём адресным линиям A0 A6 A7. Давайте посмотрим как формируются в таком случае номера портов.

00xx xxx0
00xx xxx1
01xx xxx0
01xx xxx1
10xx xxx0
10xx xxx1
11xx xxx0
11xx xxx1

Буквой "x" обозначены разряды не имеющие значения. Однако если мы их примем за единицы то как раз получим почти весь список наших портов.

00xx xxx0 $3e
00xx xxx1 $3f
01xx xxx0 $7e
01xx xxx1 $7f
10xx xxx0 $be
10xx xxx1 $bf
11xx xxx0 $fe
11xx xxx1 $ff

Различия состоят только в портах для FM чипа и последних двух, которые обозначены номерами $dc и $dd. Если вы вспомните материал из прошлой статьи, где мы разбирали устройство и способ подключения оперативной памяти в адресное пространство, то наверняка заметите массу сходств. За исключением того что в случае с оперативной памятью был всего один неподключенный разряд, а здесь их целых пять. Поэтому получаем по 2^5 = 32 зеркала каждого порта. И фактическое расположение портов будет выглядеть следующим образом.

В диапазоне $00-$3f все чётные порты являются зеркалами порта $3е, нечётные - зеркалами $3f.

В диапазоне $40-$7f все чётные порты являются зеркалами порта $7е, нечётные - зеркалами $7f.

В диапазоне $80-$bf все чётные порты являются зеркалами порта $bе, нечётные - зеркалами $bf.

В диапазоне $c0-$ff все чётные порты являются зеркалами порта $dc, нечётные - зеркалами $dd.

На последнем диапазоне давайте остановимся поподробнее. Как видите, все официальные номера портов обычно являются последними вариантами в диапазоне, а здесь $dc и $dd. И как вы могли заметить порты чипа YM2413 попадают в этот же диапазон. Однако для основных портов $f0 и $f1 это не представляет никакой проблемы, так как они подключены только на запись, а порты $dc и $dd только на чтение. Проблема возникает только с портом $f2. Существуют обходные пути решения этой проблемы. Но работать с этим портом нам предстоит только в том случае, если мы хотим определять есть ли в приставке чип YM2413. Так что сегодня мы не будем это рассматривать.

Некоторые игры используют нестандартные номера портов. $bd вместо $bf, и $c0 и $c1 вместо $dc и $dd соответственно. Это может пригодиться если вы будете разбирать код коммерческих игр. Однако важно понимать, что когда вы будете писать что-то своё надо обязательно использовать стандартные номера портов. Это должно стать непреложным правилом, потому что в различных ревизиях приставок и эмуляторах нестандартные зеркала могут либо отсутствовать, либо работать некорректно. А о запуске ваших игр в режиме совместимости на Game Gear или Mega Drive/Genesis стоит помнить особенно внимательно, потому что там расположение зеркал точно другое.

Работа с портами.

Для работы с портами у z80 есть целый набор команд. В нашем примере мы будем только записывать данные в порт и для этого будем использовать две команды: out и otir. Команда out работает следующим образом.

ld a, $22     ; загрузим в регистр A число $22
out ($be), a  ; отправим в порт $be содержимое регистра A

Если мы указываем точное значение порта, то данные берутся только из регистра a. Но есть и второй вариант использования этой команды

ld с, $be   ; загрузим в регистр C число $be
ld d, $22   ; загрузим в регистр D число $22
out (c), b  ; отправим в порт номер которого содержится 
            ; в регистре C байт из регистра B

При использовании этого варианта, номер порта должен быть только в регистре с, а отправляемое значение может быть взято из любого 8-битного регистра общего назначения. Если нам необходимо отправить данные в порт, адрес которого превышает 255, то старший байт номера порта обязательно должен быть помещён в регистр b.

Теперь рассмотрим команду otir. Она позволяет нам отсылать в порт последовательно большие серии байт из памяти. Типичное использование этой команды выглядит так.

ld hl, $2233   ; загрузим в регистровую пару HL число $2233
ld b, $43      ; загрузим в регистр B число $43
ld c, $be      ; загрузим в регистр C число $be
otir           ; начать отправку

У самой команды нет операндов, однако она активно использует значения регистров. Давайте пошагово разберёмся как работает эта команда. Сначала эта команда читает байт из памяти по адресу хранящемуся в регистровой паре hl, уменьшает значение регистра b на один, отправляет прочитанное из памяти значение в порт, номер которого указан в регистре с. Затем увеличивает значение регистровой пары hl на один. Затем проверяет значение регистра b и если оно не равно нулю, то уменьшает значение регистра pc на два, тем самым эта инструкция повторяет сама себя. pc уменьшается на 2 потому что инструкция otir занимает всего 2 байта. Таким образом команда будет повторять сама себя пока значение b не станет нулём.

Исходя из всего вышеописанного мы теперь понимаем что происходит в нашем примере. в порт $be последовательно отправляется 67($43) байт которые последовательно хранятся в памяти начиная с адреса $2233. Замечательная команда. Которую мы будем активно использовать, но к сожалению она не может передавать последовательности байт длиннее 256. Так как это максимально возможное значение регистра B который используется в качестве счётчика.

Формат загрузки данных в VDP

Вернёмся к нашей цели нам надо как-то пообщаться с VDP. Общение с ним происходит по двум портам $be и $bf. Порт $be называется портом данных VDP - через него мы отправляем данные в VDP или читаем из него. Порт bf называется Контрольным портом VDP. Через этот порт мы объясняем VDP как именно VDP должен интерпретировать то, что мы ему отправляем в порт данных.

Контрольный порт принимает сформированные специальным образом команды каждая из которых состоит из двух байт. Поскольку через порты мы можем отправлять информацию только по одному байту за раз, то каждая команда для VDP будет состоять из двух последовательно отправленных байт в контрольный порт.

Важно! Сначала в контрольный порт отправляется младший байт, а затем старший байт команды.

Давайте теперь разберём какие команды мы можем отправить VDP и в каком формате. Для управления VDP существует всего четыре типа команд.

  1. Чтение данных из VRAM по указанному адресу
  2. Запись данных в VRAM по указанному адресу
  3. Запись значения в регистр VDP
  4. Запись данных в CRAM по указанному адресу.

Раз у нас всего четыре типа команд, то тип команды можно закодировать всего двумя битами. Так и сделано. Два старших бита команды кодируют её тип. 00 - Чтение из VRAM, 01 - Запись в VRAM, 10 - запись в регистр, 11 - Запись в CRAM. Для первых двух типов команд формат такой:

Старший байт
┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
│ C0  │ C1  │ A13 │ A12 │ A11 │ A10 │ A09 │ A08 │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘
Младший байт
┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
│ A07 │ A06 │ A05 │ A04 │ A03 │ A02 │ A01 │ A00 │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘

После отправки команды чтения из VRAM в контрольный порт, следующее чтение порта данных даст нам значение находящееся в VRAM по адресу переданному в команде. VDP сохраняет последнее сообщённое ему значение адреса и с каждым чтением увеличивает его на один. То есть, чтобы прочитать последовательность данных нам достаточно отправить в контрольный порт команду чтения из VRAM содержащую начальный адрес и прочитать столько байт из порта данных сколько нам нужно, потому что с каждым чтением адрес хранящийся в VDP будет увеличиваться сам. Это очень удобно для работы с массивами данных.

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

Следующая команда - запись во внутренний регистр VDP. Такая команда имеет следующий формат.

Старший байт
┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
│ C0  │ C1  │  X  │  X  │ R03 │ R02 │ R01 │ R00 │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘
Младший байт
┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
│ D07 │ D06 │ D05 │ D04 │ D03 │ D02 │ D01 │ D00 │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘

Поскольку регистров в VDP всего 10, то номер регистра можно закодировать 4 битами R00 - R03 в старшем байте команды. Значение регистра передаётся во втором байте команды. Таким образом для изменения значения регистра достаточно только отправить команду и ничего не отправлять в порт данных. Описание регистров и их значений мы уже рассматривал в статье посвящённой VDP.

Последний тип команды - запись в CRAM, структура команды аналогична первым двум, за исключением того что в адресе имеют значение только младшие 5 бит. Адрес точно так же после каждой записи увеличивается на единицу. Код цвета передаётся в порт данных вот в таком формате.

┌───┬───┬───┬───┬───┬───┬───┬───┐
│ 0 │ 0 │ B │ B │ G │ G │ R │ R │
└───┴───┴───┴───┴───┴───┴───┴───┘

Инициализация VDP

Под инициализацией VDP понимается заполнение всех его регистров стандартными начальными значениями, так как мы не можем точно знать их содержимое после включения. Объявим вот такую последовательность байт.

VdpInit:
  .db $04,$80,$00,$81,$ff,$82,$ff,$85,$ff,$86,$ff,$87,$00,$88,$00,$89,$ff,$8a
VdpInitEnd:

Если посмотреть внимательно на эту последовательность, то можно увидеть что каждый второй байт имеет вид $80, $81, $81 и так далее до $8a. Старшая половина каждого из этих чисел это %1000, как вы видим два старших байта соответствуют команде записи в регистр VDP. Младшая половина числа это номер регистра. А все нечётные числа нашей последовательности это значения этих регистров.

Теперь нам надо это отправить всё это в контрольный порт.

ld hl, VdpInit
ld b, VdpInitEnd-VdpInit
ld c, $bf
otir

Единственный вопрос может возникнуть в строке VdpInitEnd-VdpInit. Поскольку ассемблер для нас умеет рассчитывать адреса, мы таким образом вычисляем длину последовательности. Такое выражение <Число> - <Число> тоже может посчитать для нас на этапе перевода нашего ассемблерного кода в машинный код.

После инициализации регистров, нам надо очистить видеопамять. Завайте заполним её нулями.

; Нам нужно отправить команду $4000
                  ; запись в VRAM начиная с адреса $0000
                  ; %01000000 %0000000 = $4000
 
ld a, $00         ; Отправляем младший байт команды
out ($bf),a       ; $00
ld a, $40         ; Отправляем старший байт команды
out ($bf),a       ; $04
 
ld bc, $4000      ; Используем регистр bc как счётчик
                  ; Мы $4000 раз отправим ноль, чтобы забить ими всю память
ClearVRAMLoop:
    ld a, $00     ; загружаем в a $00
    out ($be), a  ; отправляем содержимое регистра A в порт $be
    dec bc        ; уменьшаем счётчик на единицу
    ld a, b       ; теперь нам необходимо проверить обнулился ли регистр bc
    or c          ; мы просто делаем битовое "или" между старшим и младшим байтом
                  ; результат будет равен нулю только в случае 
                  ; когда оба байта равны нулю
 
    jp nz, ClearVRAMLoop

Загрузка палитры.

Теперь загрузим палитру по-умолчанию. Здесь почти ничего нового. Разместим палитру в ROM'е рядом с начальным содержимым регистров VDP

PaletteData:
  .db $00,$3f,$00,$00,$00,$00,$00,$00,$00,$00,$00,$00,$00,$00,$00,$00
  .db $00,$00,$00,$00,$00,$00,$00,$00,$00,$00,$00,$00,$00,$00,$00,$00
PaletteDataEnd:

Мы будем отправлять содержимое обеих палитр. Все цвета - чёрные, кроме цвета в ячейке 1. - Это код белого цвета, которым мы будем писать наше сообщение. Теперь давайте это загрузим в CRAM.

; Нам нужно отправить команду $c000
                  ; запись в CRAM начиная с адреса $0000
                  ; %11000000 %0000000 = $c000
 
ld a, $00         ; Отправляем младший байт команды
out ($bf), a      ; $00
ld a, $c0         ; Отправляем старший байт команды
out ($bf), a      ; $c0
 
ld hl, PaletteData                  ; Адрес начала отправляемой последовательности
ld b, (PaletteDataEnd-PaletteData)  ; Количество отправляемых байт
ld c, $be                           ; Номер порта
otir                                ; Отправка

Шрифт

Наша основная задача сейчас - вывести текстовое сообщение на экран. Встроенных текстовых функций в основном графическом режиме SMS - нет. Значит текст мы будем "рисовать". Графика у нас как вы помните тайловая, в тайловой графике довольно удобно выводить текст, если каждый отдельный символ будет умещаться в один тайл. Значит нам нужно создать шрифт. Один символ на тайл.

Если вы бросились открывать графический редактор, то не спешите, сегодня мы не будем им пользоваться. Мы нарисуем шрифт в текстовом редакторе. Конечно же мы не будем это делать постоянно для всей графики. Я просто хочу чтобы на сегодняшнем примере мы с вами пришли к определенного рода пониманию и визуализировали процесс того как хранится графика в памяти. Как все эти числа, биты и байты выстраиваются в картинку на экране. А ещё шрифт - это идеальный для такой демонстрации пример, потому что для шрифта достаточно всего двух цветов.

Итак, мы с вами для примера нарисуем первые две буквы, A и B, остальные вы найдёте в исходном коде нашей программы. Каждый тайл это квадрат 8 на 8 пикселей. Это вполне можно выразить текстом.

     Шаг 1                 Шаг 2

   Обозначим           Нарисуем в нём 
наш квадрат точками       букву "A"

    ........              ..XXX...
    ........              .X...X..
    ........              X.....X.
    ........              X.....X.
    ........     --->     XXXXXXX.
    ........              X.....X.
    ........              X.....X.
    ........              ........

Я использовал наиболее контрастные друг к другу символы. Но давайте вспомним сколько вариантов цветов может принимать один пиксель внутри тайла. Их 16, как раз можно обозначить цвета их номерами из палитры при помощи одной шестнадцатеричной цифры от 0 до f. Давайте теперь заменим символы фона и буквы на номера их будущих цветов.

     Шаг 3                             Шаг 4

   заменяем                       Представим теперь
услоные символы            номера цветов в двоичной системе
на номера цветов                   

    00111000        %0000 %0000 %0001 %0001 %0001 %0000 %0000 %0000 
    01000100        %0000 %0001 %0000 %0000 %0000 %0001 %0000 %0000 
    10000010        %0001 %0000 %0000 %0000 %0000 %0000 %0001 %0000 
    10000010        %0001 %0000 %0000 %0000 %0000 %0000 %0001 %0000 
    11111110  --->  %0001 %0001 %0001 %0001 %0001 %0001 %0001 %0000 
    10000010        %0001 %0000 %0000 %0000 %0000 %0000 %0001 %0000 
    10000010        %0001 %0000 %0000 %0000 %0000 %0000 %0001 %0000 
    00000000        %0000 %0000 %0000 %0000 %0000 %0000 %0000 %0000 

Дальше изображение кодируется не самым очевидным способом, однако давайте разберёмся. Первый байт составляется из младших разрядов номера цвета в первой строке

%...0 %...0 %...1 %...1 %...1 %...0 %...0 %...0  = %00111000 = $38
%.... %.... %.... %.... %.... %.... %.... %....
%.... %.... %.... %.... %.... %.... %.... %....
%.... %.... %.... %.... %.... %.... %.... %....
%.... %.... %.... %.... %.... %.... %.... %....
%.... %.... %.... %.... %.... %.... %.... %....
%.... %.... %.... %.... %.... %.... %.... %....
%.... %.... %.... %.... %.... %.... %.... %....

Как вы можете видеть с точки зрения получившегося числа пиксели в нём появляются в обратном порядке. То есть младший разряд берётся из цвета последнего пикселя, следующий разряд из предпоследнего и так далее до первого пикселя в строке значение из которого, уходит в старший разряд.

Мы получили первый байт, это $38. Теперь давайте сформируем следующие 3 байта

%..0. %..0. %..0. %..0. %..0. %..0. %..0. %..0. = %00000000 = $00
%.... %.... %.... %.... %.... %.... %.... %....
%.... %.... %.... %.... %.... %.... %.... %....
%.... %.... %.... %.... %.... %.... %.... %....
%.... %.... %.... %.... %.... %.... %.... %....
%.... %.... %.... %.... %.... %.... %.... %....
%.... %.... %.... %.... %.... %.... %.... %....
%.... %.... %.... %.... %.... %.... %.... %....

%.0.. %.0.. %.0.. %.0.. %.0.. %.0.. %.0.. %.0.. = %00000000 = $00
%.... %.... %.... %.... %.... %.... %.... %....
%.... %.... %.... %.... %.... %.... %.... %....
%.... %.... %.... %.... %.... %.... %.... %....
%.... %.... %.... %.... %.... %.... %.... %....
%.... %.... %.... %.... %.... %.... %.... %....
%.... %.... %.... %.... %.... %.... %.... %....
%.... %.... %.... %.... %.... %.... %.... %....

%0... %0... %0... %0... %0... %0... %0... %0... = %00000000 = $00
%.... %.... %.... %.... %.... %.... %.... %....
%.... %.... %.... %.... %.... %.... %.... %....
%.... %.... %.... %.... %.... %.... %.... %....
%.... %.... %.... %.... %.... %.... %.... %....
%.... %.... %.... %.... %.... %.... %.... %....
%.... %.... %.... %.... %.... %.... %.... %....
%.... %.... %.... %.... %.... %.... %.... %....

Мы получили следующие три байта, и это получились нули $00, $00, $00 что в целом неудивительно, потому что мы используем только два цвета с номерами 0 и 1.

Далее в разборе точно так же переходим ко второй строке, и после её разбора получим последовательность байт $44, $00, $00, $00. Вот пример разбора первого байта из второй строки.

%.... %.... %.... %.... %.... %.... %.... %.... 
%...0 %...1 %...0 %...0 %...0 %...1 %...0 %...0 = %01000100 = $44
%.... %.... %.... %.... %.... %.... %.... %....
%.... %.... %.... %.... %.... %.... %.... %....
%.... %.... %.... %.... %.... %.... %.... %....
%.... %.... %.... %.... %.... %.... %.... %....
%.... %.... %.... %.... %.... %.... %.... %....
%.... %.... %.... %.... %.... %.... %.... %....

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

.db $38,$00,$00,$00,$44,$00,$00,$00,$82,$00,$00,$00,$82,$00,$00,$00
.db $FE,$00,$00,$00,$82,$00,$00,$00,$82,$00,$00,$00,$00,$00,$00,$00

Вот и готовая буква "A" в формате пригодном для загрузки в VDP. Давайте быстро повторим то же самое для буквы "B"

; Будем рисовать прямо в коде =)
;
;  ........  XXXXXX..  11111100  
;  ........  X.....X.  10000010  
;  ........  X.....X.  10000010  
;  ........  XXXXXX..  11111100  
;  ........  X.....X.  10000010  
;  ........  X.....X.  10000010  
;  ........  XXXXXX..  11111100  
;  ........  ........  00000000  
;
;  %0001 %0001 %0001 %0001 %0001 %0001 %0000 %0000
;  %0001 %0000 %0000 %0000 %0000 %0000 %0001 %0000
;  %0001 %0000 %0000 %0000 %0000 %0000 %0001 %0000
;  %0001 %0001 %0001 %0001 %0001 %0001 %0000 %0000
;  %0001 %0000 %0000 %0000 %0000 %0000 %0001 %0000
;  %0001 %0000 %0000 %0000 %0000 %0000 %0001 %0000
;  %0001 %0001 %0001 %0001 %0001 %0001 %0000 %0000
;  %0000 %0000 %0000 %0000 %0000 %0000 %0000 %0000
;   
;  %11111100 = $FC, $00, $00, $00
;  %10000010 = $82, $00, $00, $00
;  %10000010 = $82, $00, $00, $00
;  %11111100 = $FC, $00, $00, $00
;  %10000010 = $82, $00, $00, $00
;  %10000010 = $82, $00, $00, $00
;  %11111100 = $FC, $00, $00, $00
;  %00000000 = $00, $00¸ $00, $00
 
.db $FC,$00,$00,$00,$82,$00,$00,$00,$82,$00,$00,$00,$FC,$00,$00,$00
.db $82,$00,$00,$00,$82,$00,$00,$00,$FC,$00,$00,$00,$00,$00,$00,$00

Представим что мы так преобразовали весь остальной алфавит. Давайте теперь добавим эти данные в нашу программу и загрузим весь тайлсет в VRAM

Добавим это к остальным данным в конце нашего рома.

TilesetData:
.db $38,$00,$00,$00,$44,$00,$00,$00,$82,$00,$00,$00,$82,$00,$00,$00
.db $FE,$00,$00,$00,$82,$00,$00,$00,$82,$00,$00,$00,$00,$00,$00,$00
.db $FC,$00,$00,$00,$82,$00,$00,$00,$82,$00,$00,$00,$FC,$00,$00,$00
.db $82,$00,$00,$00,$82,$00,$00,$00,$FC,$00,$00,$00,$00,$00,$00,$00
; ... все остальные символы
TilesetDataEnd:

А теперь загрузим их в VRAM. Здесь почти то же самое что мы делали при очистке видеопамяти.

ld a, $00         ; Нам нужно отправить команду $4000
out ($bf), a      ; запись в VRAM начиная с адреса $0000
ld a, $40         ; %01000000 %0000000 = $c000
out ($bf), a
 
ld hl, TilesetData                 ; Начало данных тайлсета
ld bc, TilesetDataEnd-TilesetData  ; Количество байт
TilesetLoop:
    ld a, (hl)                     ; Получить текущий байт по адресу hl
    out ($be), a                   ; Отправить в порт данных
    inc hl                         ; Изменяем указатель чтобы 
                                   ; он указывал на следующий байт
    dec bc                         ; Уменьшаем счётчик
    ld a, b                        ; Проверяем обнулился ли счётчик,
                                   ; и если нет то повторяем
    or c
    jp nz, TilesetLoop

Вывод сообщения.

Итак, шрифт мы загрузили, всё что надо проинициализировали, теперь надо вывести сообщение. Как вы помните виртуальный экран VDP состоит из 32*28 тайлов. Виртуальный экран хранится в VDP. В диапазоне адресов $3800-$3eff и занимает 1792 байта, где каждый тайл на экране представлен двумя байтами. Первый байт содержит преимущественно аттрибуты, такие как показ тайла поверх спрайтов, его поворот и так далее. Младший же бит нам здесь понадобится только если мы будем обращаться к тайлам в тайлсете номер которых больше 255. Так что весь старший байт в нашем случае всегда будет равен нулю. А младший байт будет индикатором того какой по счету символ мы будем показывать на этом месте.

Как выглядит наш шрифт (начинается с пробела):

 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_:;<=>? 

Теперь нам надо закодировать фразу "HELLO WORLD" так чтобы эта последовательность букв превратилась в номера тайлов.

H = 17 = $12
E = 15 = $0f
L = 22 = $16
L = 22 = $16
O = 25 = $19
  = 00 = $00
W = 33 = $21
O = 25 = $19
R = 28 = $1c
L = 22 = $16
D = 14 = $0e

Объявим наше сообщение, но добавим нули старших байт. Здесь есть важный момент. Данные виртуального экрана хранятся в формате little endian, то есть сначала в памяти находится младший байт, потом старший. Поэтому в нашем примере сначала идут коды наших букв, а после них нули, а не наоборот.

Message:
  .db $12,$00,$0f,$00,$16,$00,$16,$00,$19,$00,$30,$00 ; HELLO 
  .db $21,$00,$19,$00,$1c,$00,$16,$00,$0e,$00         ; WORLD
MessageEnd:

Теперь давайте загрузим наше сообщение в виртуальный экран. Сообщение у нас короткое, выведем его при помощи otir.

ld a, $00       ; Нам нужно отправить команду $7800
out ($bf), a    ; запись в VRAM начиная с адреса $3800
ld a, $78       ; $4000 | $3800 = $7800
out ($bf), a
 
ld hl, Message
ld b, MessageEnd-Message
ld c, $be
otir

Отображение и остановка.

Мы записали наше сообщение в область экрана. И по идее оно должно было бы появиться на экране, но нет =) Всё дело в том что при инициализации регистров VDP мы отключили отрисовку изображения на экране. Мы отправили 0 в регистр #1. Давайте вспомним за что отвечает регистр #1.

Регистр #1 - Второй регистр графического режима.

  • Бит 0 - Увеличение спрайтов в два раза.
  • Бит 1 - Спрайт использует два тайла. В этом режиме спрайт состоит из двух тайлов, которые расположены один над другим. В качестве второго тайла берётся сделующий из тайлсета после того что указан в таблице тайлов.
  • Бит 2 - Не используется
  • Бит 3 - M3 Этот бит отвечает за активацию режима с увеличеной высотой экрана до 30 тайлов. Используется только в последних ревизиях SMS. И эта настройка имеет смысл тольков 4м графическом режиме.
  • Бит 4 - M1 Аналогично биту 3, но для высоты экрана в 28 тайлов.
  • Бит 5 - IE0 Включение генерации прерывания во время наступления VBLANK
  • Бит 6 - Вывод изображения. Если этот бит равен нулю, то VDP перестаёт выдавать изображение.
  • Бит 7 - Не используется

На данный момент нам нужен только бит 6, поэтому отправим в VDP новое значение регистра 1 равное %01000000 = $40. Составим команду:


10...... ........  - Тип команды - запись в регистр - это 10.
10..0001 ........  - Номер регистра 1
10..0001 01000000  - Значение регистра $40 - %01000000
10000001 01000000  - Неиспользуемые биты сделаем нулями

$81      $40

Получилась команда $8140. Отправим её.

ld a, $40
out ($bf), a
ld a, $81
out ($bf), a

Ура! Экран включился, и наше сообщение выводится! Но мы забыли последнюю важную вещь. Остановку нашей программы. Если после всех действий программу не остановить или не направить в нужное место, то процессор будет исполнять и дальше всё что найдёт в памяти, в том числе он может воспринять наши данные, которые как раз идут после основной программы как инструкции. Что приведёт в лучшем случае к непредвиденным последствиям и рано или поздно к зависанию. У нас есть два варианта действий. Первый - действительно остановить выполнение программы инструкцией halt Либо завести программу в бесконечный цикл. Оба этих варианта технически похожи, просто с halt чуть нам надо знать чуть больше тонкостей. Так что остановимся на втором варианте - бесконечный цикл.

End:
jp End

Вот и всё программа будет постоянно переходить по этому адресу в котором содержится команда перехода на этот же самый адрес.

Hello World

Давайте я напоследок немного расскажу о бесконечных циклах. Если вы когда-либо писали программы на высокоуровневых языках, то могли сталкиваться с бесконечными циклами в сугубо отрицательном контексте. Бесконечные циклы образовываются в следствие ошибок что приводит к "зависанию" программы. Однако в низкоуровневых языках, почти всегда - бесконечный цикл это основа любой более или менее серьёзной программы. Процессор всегда что-то выполняет, даже когда вам кажется что он "остановился" и "ничего не делает". На низком уровне любая игра или программа это бесконечный опрос устройств ввода и реакция на новую информацию поступившую от них, либо на изменение времени. Так что привыкайте общаться с бесконечными циклами.

В следующей статье мы как раз займёмся опросом игровых контроллеров и немного улучшим нашу исходную программу.

Скачать полный текст нашего Hello World'а можно здесь: https://github.com/w0rm49/sms-hello-world/releases/tag/sms-06