Главная arrow Документация arrow Курс по разработке игр для Sega Master System: 06
07.10.2024 г.

Последние Комментарии

Chrono Trigger 27.08.2024 г.
Dragon Warrior IV 21.07.2024 г.
Eternal Eyes 12.06.2024 г.

Гостевая Книга

Андрей
Архив с переводом Один дома 2 перезалит. Ошибочно
Краткие новости
Второе небольшое обновление после долгих новогодних праздников. Мы решили немного порадовать посетителей сайта и устроить целый релизный уикенд. На этот раз перевод на SNES, он один, и это Star Fox (Звёздный лис). Культовый шутер, с которого началась история знакомства с Фоксом Макклаудом, его командой и системой Лайлет.

В завершение небольшой бонус: мы обновили перевод Gley Lancer до версии 1.14. Основное из исправлений - корректное отображение секретного дебаггерного меню!

star_frog

 
Цитаты
Жуковский Василий Андреевич: "Переводчик в прозе — раб, переводчик в стихах — соперник."
Внимание! Всем-всем-всем!
Товарищи! Если у кого-то из вас вдруг завалялись ненужные (или не очень нужные) картриджи денди - не дайте пропасть добру! Приму в дар, скопирую и верну хозяину или куплю/обменяю любые интересные картриджи, особенно редкие или пиратские. С предложениями обращайтесь НА ФОРУМ или В ЛИЧКУ. Подпись: Guyver.
Курс по разработке игр для Sega Master System: 06 Печать E-mail
Автор Kir   
10.06.2024 г.
 

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

2018-04-14

Сегодня мы применим на практике все те знания которые получили в первых статьях и напишем первую программу для 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

$7f

$be

$bf

$dc

$dd

$f0*

$f1*

$f2* 

 -

Управление контроллером ввода

Счётчик строк (V counter)

Счётчик строк (V counter) 

Порт данных для VDP 

Статус VDP

Состояние игровых контроллеров

Состояние игровых контроллеров

-

-

Порт управления YM2413 

 Управление памятью

 Управление контроллером ввода

 Управление чипом SN76489

 Управление чипом SN76489 (зеркало)

 Порт данных для VDP 

 Порт данных для VDP

 -

 -

 Порт выбора регистра чипа YM2413

 Порт данных чипа 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
                  xx
                  xx
                  xx
        --->   xxxxxxx
                  xx
                  xx
                  

Я использовал наиболее контрастные друг к другу символы. Но давайте вспомним сколько вариантов цветов может принимать один пиксель внутри тайла. Их 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  
;    xx       10000010  
;    xx       10000010  
;    xxxxxx      11111100  
;    xx       10000010  
;    xx       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 - Второй регистр графического режима.

  • Бит 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'а можно здесь: https://github.com/w0rm49/sms-hello-world/releases/tag/sms-06
Последнее обновление ( 11.06.2024 г. )
 
« Пред.   След. »
home contact search contact search