Sega Master System 07: Улучшение читаемости. VBlank. Ввод с контроллера.

Сегодня мы с вами изучим ещё несколько возможностей wla dx которые облегчают нам написание программ для SMS. Мы с вами написали первую программу для SMS и уже улучшать, не рановато ли? А вот и нет. Читаемость собственных программ это не просто важно, это очень важно. Настолько важно, что многие просто недооценивают это. Но почему так происходит? Давайте разбираться.

По началу это может быть незаметно, но мы пишем программы примерно 2-5% всего времени, всё остальное время мы читаем то что понаписали. Это справедливо для любого языка программирования, ну разве что процент может немного плавать. В нашем случае это осложнено несколькими факторами. Во-первых язык ассемблера требует немного отличающегося мышления в отношении наших программ, во-вторых у нас нет чёткой структуры программы, мы можем писать игру как хотим у нас с вами полная свобода и никаких "движковых" ограничений. Помимо этого не стоит забывать про фактор времени. Если вы сделаете перерыв в разработке или даже естественным образом не будете долго возвращаться к какому-то коду. То в итоге вернувшись к нему может случиться так что у вас возникнет вопрос "что за идиот это писал?", а это окажетесь вы сами. И не стоит такому удивляться. Поэтому стоит озадачиться хорошей читаемостью сразу.

Определения.

Самое первое, что мы рассмотрим это определения. Мы уже выяснили с вами, что ассемблер для нас рассчитывает довольно много вещей, в числе которых и адреса меток. Определения очень похожи на метки, с той лишь разницей, что мы сами решаем, что ассемблер будет подставлять в места использования определений.

Для объявления определений существует директива .define.

.define <имя определения> <значение>

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

Очень полезная директива. Одним из главных методов улучшения читаемости программы является избавление от "магических чисел". Магические числа - это числа в коде нашей программы значение которых не сразу понятно из контекста, и приходится обращаться или к документации или, если совсем дела плохи, то к автору программы. Далеко ходить не будем, возьмём пример из нашего Hello World'а.

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

Вряд ли вы сразу вспомните что делают эти четыре строчки. Давайте попробуем улучшить этот фрагмент. В этом фрагменте происходит "включение экрана" при помощи установки специального значения внутреннего регистра VDP. Для начала давайте заменим номер порта на определение.

.define VDPControlPort $bf
 
; тут много кода
 
ld a, $40
out (VDPControlPort), a
ld a, $81
out (VDPControlPort), a

Стало гораздо лучше. Но давайте пойдём дальше. Здесь мы отправляем в VDP команду $8140. $81 - объединённый номер регистра и команда записи в регистр. Я предлагаю выделить отсюда тип командыв отдельное определение.

.define VDPControlPort $bf
.define VDPWriteRegister $80
 
; тут много кода
 
ld a, $40
out (VDPControlPort), a
ld a, VDPWriteRegister | $01
out (VDPControlPort), a

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

.define VDPControlPort $bf
.define VDPWriteRegister $80
 
; тут много кода
 
ld a, %01000000
;      |||||||`-- увеличение спрайтов в два раза.
;      |||||| `-- спрайт использует два тайла. 
;      ||||| `--- не используется  
;      |||| `---- высота экрана 30 тайлов. 
;      ||| `----- высота экрана 28 тайлов.
;      || `------ генерация VBlank прерывания
;      | `------- вывод изображения.
;       `-------- не используется  
out (VDPControlPort), a
ld a, VDPWriteRegister | $01
out (VDPControlPort), a

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

.define VDPControlPort $bf
.define VDPWriteRegister $80
 
.define VDPReg1EnableScreen  %01000000
.define VDPReg1EnableVblank  %00100000
.define VDPReg1Height28Rows  %00010000
.define VDPReg1Height30Rows  %00001000
.define VDPReg1DoubleTiles   %00000010
.define VDPReg1DoubleSprites %00000001
 
; тут много кода
 
ld a, VDPReg1EnableScreen | VDPReg1EnableVblank
out (VDPControlPort), a
ld a, VDPWriteRegister | $01
out (VDPControlPort), a

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

Внешние файлы.

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

Для включения другого содержимого файла в код вашей программы существует директива .include

.include "путь/к/файлу.asm"

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

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

; Порты
 
.define MemoryControlPort $3e
.define IOControlPort $3f
.define PSGPort $7e
.define VDPVCounterPort $7e
.define VDPHCounterPort $7f
.define VDPDataPort $be
.define VDPControlPort $bf
.define IOPort1 $dc
.define IOPort2 $dd
 
; Команды VDP
 
.define VDPReadVRAM $00
.define VDPWriteVRAM $40
.define VDPWriteRegister $80
.define VDPWriteCRAM $c0
 
; Регистры VDP
 
.define VDPReg0RightPanel      %10000000
.define VDPReg0TopPanel        %01000000
.define VDPReg0HideFirstCol    %00100000
.define VDPReg0EnableHblank    %00010000
.define VDPReg0ShiftSprites    %00001000
.define VDPReg0EnableMode4     %00000100
.define VDPReg0ExtendedHeight  %00000010
.define VDPReg0ExternalSync    %00000001
 
.define VDPReg1EnableScreen    %01000000
.define VDPReg1EnableVblank    %00100000
.define VDPReg1Height28Rows    %00010000
.define VDPReg1Height30Rows    %00001000
.define VDPReg1DoubleTiles     %00000010
.define VDPReg1DoubleSprites   %00000001

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

С кодом и вынесением отдельных участков в файлы всё абсолютно так же. Давайте подумаем что ещё неплохо бы было вынести в отдельные файлы. И првильный ответ - графику. В нашем примере тайлсет содержащий только шрифт - занимает бОльшую часть программы и его содержимое рассматривать побайтово нам почти никогда не нужно - идеальный кандитат на выселение из основного файла. Мы могли бы его так же перенести в отдельный файл и подключить при помощи директивы .include. Например

TilesetData:
.include "font.asm"
TilesetDataEnd:

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

TilesetData:
.include "font.bin" swap

Если после имени файла указано слово swap то каждый второй байт будет поменян местами с предыдущим. Это позовляет нам сменить формат данных с Little Endian на Big Endian и наоборот. Ещё существуют опции skip и read после которых указывается число байт. Например

; Включит в это место содержимое файла font.bin начиная с 9 байта
.include "font.bin" skip 8 
 
; Включит в это место только первые 16 байт из файла font.bin
.include "font.bin" read 16

Ещё есть очень полезная опция fsize. после которой идёт строка схожая по формату с меткой или определением.

TilesetData:
.include "font.bin" fsize TilesetDataSize

В даном случае дополнительно создастся определение TilesetDataSize которое будет равно размеру файла в байтах. То есть это избавляет нас от неоходимости введения конечной метки и потом вычислять размер блока данных при помощи вычитания меток.

А ещё опции можно объединять. например

TilesetData:
.include "font.bin" skip 2 read 20 fsize TilesetDataSize

В данном примере, в место по адресу TilesetData будут включены с 3 по 22й байты из файла font.bin. Обратите внимание что порядок опций здесь очень важен.

Анонимные метки.

Как вы помните, я говорил, что все метки в исходном коде должны быть уникальными. Это оправдано и понятно при разметке важных участков программы, именования функций и процедур. Но есть и такие места в программе как циклы и их будет очень много, и для каждого нужна будет своя метка, которая больше нигде не используется. Можно например нумеровать такие метки в духе Loop2122 и тд. Но как по мне тут довольно просто ошибиться и мы будем такую ошибку довольно долго отлавливать. Да и постоянно помнить какие метки использовались в таких случаях, а какие - нет, довольно утомительно. К счастью в WLA DX есть средства для решения такой проблемы - анонимные метки. Обозначаются они специальным образом, -, --, ---, +, ++, +++.

Мы можем объявлять и использовать такие метки по всему коду по многу раз. Их работа несколько отличается от обычных меток. Например используя метку состоящую из плюсов мы скажем ассемблеру "найди далее в коде ближайшее объявление такой метки и используй его адрес в этой команде". Аналогично для меток состоящих из минусов, только мы будем указывать искать ближайшее объявление такой же метки раньше по коду.

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

ld a, $00
out (VDPControlPort), a
ld a, VDPWriteVRAM
out (VDPControlPort), a
 
ld bc, $4000
-:
    ld a, $00
    out (VDPDataPort), a
    dec bc
    ld a, b
    or c
    jp nz, -

Ещё существует метка __ (два подчёркивания). Ассемблер может её искать как впереди по коду так и сзади, но для направления поиска надо указывать её в следующем формате _f для поиска ближайшей __ метки впереди по коду, _b - назад. Давайте посмотрим на том же примере.

ld a, $00
out (VDPControlPort), a
ld a, VDPWriteVRAM
out (VDPControlPort), a
 
ld bc, $4000
__:
    ld a, $00
    out (VDPDataPort), a
    dec bc
    ld a, b
    or c
    jp nz, _b

Текстовые данные.

Текст - неотъемлимая часть почти любой игры. Вряд ли вы встречали игры в которых вообще нет текста. А в играх которые я люблю текста обычно очень много. Давайте вспомним как у нас хранится текст в Hello World'е который мы написали в прошлой статье.

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:

Как вы помните, текст мы закодировали таким образом, что каждая буква сообщения это индекс тайла в тайлсете. Удобно для VDP, но неудобно для чтения. Гораздо удобнее было бы хранить текстовые сообщения в виде легко читаемого и редактируемого текста. И у WLA DX есть средства для этого. Подобную работу с текстом можно организовать при помощи специального набора директив. .asciitable, .enda и .asc. Делается это в два этапа. Сначала мы должны настроить соответствие символов номерам тайлов в нашем шрифте при помощи первых двух директив, подобно тому как мы делали разметку памяти. Давайте сразу опишем наш шрифт.

.asciitable
    map ' ' = 0
    map '0' to "9" = 1
    map 'a' to "z" = 11
    map 'A' to "Z" = 11
    map '[' = 37
    map '\' = 38
    map ']' = 39
    map '^' = 40
    map '_' = 41
    map ':' = 42
    map ';' = 43
    map '<' = 44
    map '=' = 45
    map '>' = 46
    map '?' = 47
.enda

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

После такого соответсвия мы можем пользоваться специальной директивой .asc. в качестве аргумента этой директивы указывается строка. И работает это как аналог .db с автоматически преобразованными символами из строки по схеме описанной директивой .asciitable

.asc "hello world"

Теперь давайте ещё немного улучшим вывод текста, сделаем подобие строк в Си. То есть мы не будем вычислять длину строки при помощи разности меток, а будем исходя из начального адреса ,выводить текст байт за байтом пока не встретим специальный байт означающий конец строки. Обычно (в том же си) для этого используют байт $00, но мы его уже определили как пробел. У нас два варианта разрешения этой проблемы, Перенести пробел на другой байт и исправить это в тайлсете или использовать другой символ конца строки.

Я использую второй метод как наиболее простой, но мы сделаем это максимально универсально, что позволит нам в случае если мы передумаем и захотим применить первый метод, то понадобится минимальное количество действий. Итак, для начала объявим новое определение.

.define EOL $ff

EOL - аббревиатура от end of line. Будем использовать байт $ff с рассчётом на то, что печатаемых символов таких нет. Теперь добавим его к нашей строке.

.asc "hello world", EOL

И переделаем вывод строки.

ld a, $00                  ; Нам нужно отправить команду $7800
out (VDPControlPort), a    ; запись в VRAM начиная с адреса $3800
ld a, VDPWriteVRAM | $38   ; $4000 | $3800 = $7800
out (VDPControlPort), a
 
ld hl, Message
ld b, EOL                   ; Сохраним символ конца строки в b
-:
    ld a, (hl)              ; в регистр a поместим текущий символ
    cp b                    ; сравним с символом конца строки
    jp z, +                 ; если это он, то выходим из цикла
    out (VDPDataPort), a    ; отправляем символ в виртуальный экран
    xor a
    out (VDPDataPort), a
    inc hl                  ; адрес следующего символа
    jp -                    ; возврат к началу цикла
+:

На этом, пожалуй, закончим улучшать нашу программу. Скачать полный код улучшенной программы можно здесь: https://github.com/w0rm49/sms-hello-world/releases/tag/sms-07.1

Прерывание VBlank

Закончили с улучшениями, теперь перейдём к добавлению чего-то нового. Попробуем читать ввод с контроллеров выводить результаты на экран. Считывать состояние игровых контроллеров нам надо с какой-то периодичностью. А как мы можем вообще ориентироваться во времени в условиях SMS? Например, нам известна частота процессора и мы можем сказать сколько времени будет выполняться та или иная команда. Однако, в глобальном смысле, это не очень удобный способ измерения времени. Наши процедуры редко когда могут выполняться точное количество тактов. Да и такт процессора - слишком маленькая единица измерения. Что у нас ещё есть? Есть вывод изображения в аналоговом формате. И VDP как раз генерирует для нас прерывания во время обратного хода луча. И он как должен быть точно синхронизирован по времени. Это наш идеальный кандидат. Мы будем это прерывание называть как во всей англоязычной литературе VBlank. Синхронизировать выполнение нашей программы по VBlank очень удобно. Он всегда происходит 60 раз в секунду для стандарта NTSC и 50 раз в секунду для PAL. Мы пока будем использовать NTSC как основной, а потом научимся работать с обоими стандартами.

Помимо синхронизации по времени VBlank сигнализирует ещё об одном важном моменте. Время обратного хода луча - это особый промежуток времени для VDP, потому что если он в данный момент занимается генерацией видеосигнала, то записывать данные в видео память нельзя. Мы конечно можем попробовать, но результат будет непредсказуем. Данные могут потеряться, поэтому нам такой вариант не подходит. Передавать данные в VDP можно только тогда когда он не занят генерацией видеосигнала, то есть либо во время обратного хода луча, либо во время нахождения луча в той области в которой отключена генерация видеосигнала, либо когда в VDP вообще отключена генерация изображения.

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

Обработка прерываний

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

; Включаем экран
ld a, VDPReg1EnableScreen | VDPReg1EnableVblank
out (VDPControlPort), a
ld a, VDPWriteRegister | $01
out (VDPControlPort), a

Ассемблер сам проведёт побитовое "или" между числами которые скрываются за этими определениями. Теперь нам надо включить обработку маскируемых прерываний и создать основной цикл нашей программы. Назовём его игровой цикл. Изменим метку End на GameLoop, чтобы её название соответствовало реальному назначению.

ei                      ; Включим маскируемые прерывания
GameLoop:
jp GameLoop

Цикл по прежнему пустой, но теперь он должен прерываться 60 раз в секунду переходить по адресу $0038 и возвращаться сюда же. Но на практике так не произойдёт, точнее произойдёт, но лишь один раз. Давайте разберёмся в чём тут дело. Помимо того что VDP генерирует прерывания, он ещё может сообщать процессору о своих событиях через контрольный порт, если мы из него попытаемся прочитать байт, то получим следующий набор флагов.

┌───┬───┬───┬───┬───┬───┬───┬───┐
│ I │ O │ C │ x │ x │ x │ x │ x │ 
└───┴───┴───┴───┴───┴───┴───┴───┘
  7   6   5   4   3   2   1   0
  • x - неиспользуемые биты, они не несут никакой полезной информации, за исключением случая когда мы запускаем нашу игру на mega drive/genesis
  • C - Столкновение спрайтов, этот флаг устанавливается в 1, когда какие-то из спрайтов пересеклись непрозрачными пикселями.
  • O - Перепонение спрайтов, этот флаг устанавливается в 1, когда количество спрайтов на одной строке превышает пороговое значение в 8 штук.
  • I - Это флаг прерываний. Устанавливается в 1 когда VDP сгенерировал прерывание.

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

Флаги C и O мы рассмотрим когда будем работать со спрайтами. Нас интересует сейчас флаг I. Он устанавливается всякий раз когда VDP генерирует прерывание, и пока этот флаг равен 1, VDP не генерирует новых прерываний. Сегодня мы будем пользоваться только прерыванием VBlank поэтому нам достаточно лишь читать контрольный порт в обработчике прерываний для того чтобы сообщать VDP что мы обработали его прерывание.

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

.org $0038                  ; обработчик маскируемого прерывания
    ex af,af'               ; поменять регистры af на альтернативные
    exx                     ; поменять регистры bc, de, hl на альтернативные
    in a, (VDPControlPort)  ; читаем статус VDP
 
    ; код обработки прерываний
 
    exx                     ; поменять регистры bc, de, hl на альтернативные
    ex af,af'               ; поменять регистры af на альтернативные
    ei                      ; включить прерывания
    reti                    ; возврат из прерывания

Как вы помните адрес входа находится в самом верху нашего адресного пространства, а после него уже по адресу $0066 идёт обработчик немаскируемого прерывания, поэтому места здесь у нас очень мало. Разумнее всего будет просто вызвать нужную подпрограмму с помощью команды call. Но пока оставим так, для того чтобы прерывания работали корректно нам этого хватит.

Ввод с контроллера.

Теперь перейдём непосредственно к чтению состояния игровых контроллеров. Делается это чтением байтов из портов $dc и $dd. Каждый бит этих двух байтов отражает состояние какой-либо кнопки. Давайте посмотрим какие биты за какие кнопки отвечают.

На SMS есть два порта для геймпадов, чтобы не вносить путаницу с портами процессора, я буду называть кнопки по принадлежности игрокам. Первый игрок пользуется геймпадом подключенным в порт 1 или порт A, второй - в порт 2 или порт B.

Порт $dc

  • Бит 0 - Кнопка ВВЕРХ певого игрока
  • Бит 1 - Кнопка ВНИЗ первого игрока
  • Бит 2 - Кнопка ВЛЕВО первого игрока
  • Бит 3 - Кнопка ВПРАВО первого игрока
  • Бит 4 - Кнопка 1 первого игрока (TL)
  • Бит 5 - Кнопка 2 первого игрока (TR)
  • Бит 6 - Кнопка ВВЕРХ второго игрока
  • Бит 7 - Кнопка ВНИЗ второго игрока

Порт $dd

  • Бит 0 - Кнопка ВЛЕВО второго игрока
  • Бит 1 - Кнопка ВПРАВО второго игрока
  • Бит 2 - Кнопка 1 второго игрока (TL)
  • Бит 3 - Кнопка 2 второго игрока (TR)
  • Бит 4 - Кнока СБРОСА (RESET) - есть не во всех версиях
  • Бит 5 - не используется
  • Бит 6 - Курок светового пистолета первого игрока (TH)
  • Бит 7 - Курок светового пистолета второго игрока (TH)

Как видите распределение не сильно интуитивное, но не лишено своей логики, если вы делаете игру для одного игрока со стандартным геймпадом, то вам будет достаточно читать только порт $dc. Также стоит обратить внимание на то что если бит равен 1 - то кнопка отпущена, если 0 - то нажата, то есть состояние инвертировано.

Что нам будет необходимо сделать. Прочитать состояние кнопок, первого игрока, выяснить нажал он или отпустил кнопку. Сохранить результат в памяти для последующей обраотки. Давайте сделаем это в основном игровом цикле. Читать мы будем только порт $dc.

.define IO1State $c080
.define IO1Change $c081
 
ei                      ; Включаем обработку маскируемых прерываний
 
in a, (IOPort1)         ; читаем состояние контроллеров из порта
ld (IO1State), a        ; сохраним в памяти текущее состояние
xor a                   ; обнуляем a
ld (IO1Change), a       ; сохраняем 0 в IO1Change
 
GameLoop:
    in a, (IOPort1)     ; читаем состояние контроллеров из порта
    ld b, a             ; сохраняем новое состояние в b 
    ld a, (IO1State)    ; загружаем предыдущее состояние в a
    xor b               ; в a теперь указано какие биты 
                        ; изменились с прошлого чтения
    ld (IO1Change), a   ; сохраним в памяти изменения
    ld a, b
    ld (IO1State), a    ; сохраним в памяти текущее состояние
    halt                ; дождёмся прерывания
jp GameLoop

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

Поскольку в основном цикле мы будем постоянно сравнивать текущее состояние контроллера с предыдущим, то для самой первой итерации нам нужно предыдущее значение, поэтому мы его прочитаем перед входом в цикл. Читаем из порта байт, сохраняем его в IO1State, а в IO1Change помещаем 0, то есть "ничего не изменилось".

Затем читаем текущее состояние контроллера из порта в регистр "a". Копируем его в регистр "b". Затем в регистр "a" загружаем предыдущее состояние сохранённое в IO1State. и с помощью xor получаем разницу между состояниями. Те биты которые изменились будут равны 1, те которые не изменились станут равны 0. Затем сохраним эту разницу в IO1Change, а текущее состояние в IO1State.

И последняя команда которую мы ещё не использовали на практике - halt. Это очень удобная команда, когда процессор встречает её, то он останавливается на ней и начинает исполнять nop'ы, пока не поступит маскируемое прерывание. После выхода из обработчика прерывания программа исполняется дальше. То есть в данном случае мы тормозим игровой цикл до события VBlank и синхронизируемся с ним. Нужно это для того чтобы и не занимать процессорное время внутри VBlank, но и не гонять лишние циклы чтения.

Индикация ввода на экране.

Состояние кнопок контроллера мы получили, теперь давайте отобразим это на экране. Будем выводить на второй строке тайлов названия нажатых кнопок. Делать мы это будем внутри прерывания VBlank так как именно в нём должен должно осуществляться обновление графической информации, если в vdp не отключен.

Итак. Создадим функцию PrintStatus, за пределами игрового цикла.

PrintStatus
ret

И будем вызывать её из нашего обработчика прерываний.

.org $0038                  ; обработчик маскируемого прерывания
    ex af,af'               ; поменять регистры af на альтернативные
    exx                     ; поменять регистры bc, de, hl на альтернативные
    in a, (VDPControlPort)  ; читаем статус VDP
    call PrintStatus        ; выводим названия нажатых кнопок
    exx                     ; поменять регистры bc, de, hl на альтернативные
    ex af,af'               ; поменять регистры af на альтернативные
    ei                      ; включить прерывания
    reti                    ; возврат из прерывания

Теперь снова вернёмся к функции PrintStatus. Какая у нас будет стратегия. Сначала мы проверим изменилось ли состояние кнопок с прошлого кадра. Если ни одна кнопка не поменялась, то все биты будут равны нулю, как и всё число целиком. В таком случае мы можем сразу завершать выполнение этой функции. Если всё же что-то изменилось, то сначала нам надо стереть нашу строку (заполнить пробелами), а потом вывести названия нажатых кнопок. Давайте напишем проверку изменений и стирание.

PrintStatus
    ld a, (IO1Change)     ; Загрушаем в a значение из IO1Change
    cp 0                  ; Если ничего не изменилось с прошлого чтения,
    ret z                 ; то завершаем выполнение функции.
 
    ld a, (IO1State)      
    ld d, a               ; загружаем содержимое IO1State в регистр e
 
    ; стираем строку
    ld b, 22                    ; Длина статусной строки
    ld a, $40                   ; Нам нужно отправить команду $7840
    out (VDPControlPort), a     ; запись в VRAM начиная с адреса $3840
    ld a, VDPWriteVRAM | $38    ; $4000 | $3840 = $7840
    out (VDPControlPort), a
    -:  xor a                   ; обнулим a - $00 - пробел
        out (VDPDataPort), a    ; отправляем символ в виртуальный экран
        dec b                   ; уменьшаем счётчик на один
        out (VDPDataPort), a    ; отправляем ещё один ноль
        jp nz, -                ; возврат к началу цикла
ret

Первичная проверка на изменения состояния кнопок проводится следующим образом. Мы загружаем в регистр "a" содержимое ячейки памяти IO1Change, командой cp 0 мы сравниваем содержимое регистра "a" c нулём. Если результат сравнения верен, то флаг z становится равным единице. И только в этом случае сработает возврат из функции ret z.

Вывод 22 пробелов почти полностью аналогичен выводу строки "hello world", с той лишь разницей что выводить мы будем со второй строки. Как вы помните память виртуального экрана начинается в VRAM c адреса $3800 и каждый тайл представлен двумя байтами, а размер виртуального экрана 32*28 тайлов. То есть вторая строка начинается с тайла 32 (счёт начинается с нуля). Теперь высчитаем адрес начала нашей строки. $3800 + 32 * 2 = $3800 + $20 * 2 = $3840.

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

.define StrStart $40

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

; Напечатать слово
; c - позиция на экране
; hl - Адрес начала текста.
PrintStr:
    ld a, c                     ; В c помещаем младший байт адреса в vram 
    out (VDPControlPort), a     ; с которого начинаем запись
    ld a, VDPWriteVRAM | $38    ; $4000 | $3800 = $7800
    out (VDPControlPort), a
    ld b, EOL
 
    -:  ld a, (hl)              ; в регистр a поместим текущий символ 
        cp b                    ; сравним с символом конца строки
        ret z
        out (VDPDataPort), a    ; отправляем символ в виртуальный экран
        xor a                   ; обнуляем a
        out (VDPDataPort), a    ; отправляем байт аттрибутов
        inc hl
        jp -                    ; возврат к началу цикла
ret

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

Объявим названия кнопок. Кнопки 1 и 2 я назвал a и b как на Mega Drive, мне так просто привычнее, если хотите можете переименовать в 1 и 2, это ни на что не повлияет.

UpLabel:
  .asc "up", EOL
DownLabel:
  .asc "down", EOL
LeftLabel:
  .asc "left", EOL
RightLabel:
  .asc "right", EOL
ALabel:
  .asc "a", EOL
BLabel:
  .asc "b", EOL

И последнее что нам надо сделать это последовательно проверить каждый бит IO1State и вывести в соответствующее место на 2й строке название кнопки, если она нажата, а если не нажата то перейти к определению следующей. Давайте напишем этот код для первых двух кнопок, для остальных он аналогичен, вы найдёте его в полной версии исходного кода. Ссылка будет ниже.

; выводим нажатые кнопки
 
    bit 0, d
    jp nz, +
    ld hl, UpLabel              ; выводимый текст
    ld c, StrStart              ; адрес начала строки в VRAM
    call PrintStr               ; вывод текста
    +: 
 
    bit 1, d
    jp nz, +
    ld hl, DownLabel
    ld c, StrStart + (3 * 2)
    call PrintStr
    +:

Чуть ранее мы загрузили в регистр d содержимое IO1State, поэтому за стостоянием будем обращаться к этому регистру. Команда bit проверяет номер бита указаный в первом операнде, в числе из второго операнда. Если проверяемый бит равен нулю, то флаг Z становится равным 1 и наоборот соответственно. Поэтому если кнопка не нажата и то происходит переход к ближайшей следующей метке +:. Если нажата, то в регистр hl мы помещаем адрес начала названия кнопки, а в регистр c - с какого адреса в VRAM это выводить и вызовем функцию вывода текста. И так последовательно для всех шести кнопок контроллера.

Выглядит результат работы программы примерно так.

Input

На этом поставленную задачу можно считать выполненой. Полный текст программы как всегда можно скчать на гитхабе. https://github.com/w0rm49/sms-hello-world/releases/tag/sms-07.2

Финал.

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