Создание демки специально для HABR — Часть 2
В предыдущей части мы только начали входить во вкус создания демки, как статья неожиданно закончилась на самом интересном месте. Не буду сильно томить и продолжу описывать свой квест по созданию этой интересной программы. Борьба за размеры памяти, задержки, звук, всё в этой серии.
▍ 640 килобайт хватит всем, или как впихнуть невпихуемое
Остановился я на том, что в процессе компиляции раскадровка не влезла в размер памяти, который может быть непосредственно адресован на архитектуре i8080. И тут следует внимательно разобраться, кто виноват и что с этим делать.
Как вы помните, видеопамять ПЭВМ «Микроша» занимает, грубо 78×30 символов, либо 2340 байт или 2,2 килобайта. Из характеристик ПЭВМ, оперативной памяти всего 32 кБ. И если я хочу показывать какие-либо мультики на данном вычислительном устройстве, мне нужно где-то хранить кадры. Если просто взять отдельные кадры, сохранить их в ОЗУ, и далее по очереди их выводить, то получится:
Всего 14 кадров, и это без учёта размещения кода, который их будет выводить!
Много мультиков тут не посмотришь, поэтому требуется думать над сжатием каждого фрейма. Есть несколько путей для того, чтобы решить эту задачку:
- Делать вычислительную отрисовку, то есть картинку выводить с помощью формул, рисуя это с помощью формул. Как уже говорил, это медленно и мне такой вариант не подходит.
- Использовать сжатие. Например, алгоритмы RLE и LZ77, неплохая статья на хабре. Вариант вполне достойный, но узнал я о нём, уже после после того как сделал свой вариант.
- Хранить разницу между кадрами. Этот вариант хорош тем, если каждый кадр меняется не сильно, по сравнению с предыдущим, то diff будет занимать мало места. Алгоритм отлично подходит для задачи вращения. Но нужно помнить, что при полной смене кадра, объём разницы может занимать тройной размер видеопамяти, что весьма расточительно и тут проще применить банальное копирование областей памяти.
Оглядываясь назад, могу сказать, что возможно это было не самое оптимальное решение, но рабочее. Проиллюстрирую как это выглядит.
Первый кадр или фрейм — это просто картинка, которая копируется функцией memcpy в область памяти.
Первый фрейм.
Функция memcpy
достаточно простая и грех не показать её код:
; bc: number of bytes to copy
; de: source block
; hl: target block
memcpy:
mov a,b ;Copy register B to register A
ora c ;Bitwise OR of A and C into register A
rz ;Return if the zero-flag is set high.
loop:
ldax d ;Load A from the address pointed by DE
mov m,a ;Store A into the address pointed by HL
inx d ;Increment DE
inx h ;Increment HL
dcx b ;Decrement BC (does not affect Flags)
mov a,b ;Copy B to A (so as to compare BC with zero)
ora c ;A = A | C (set zero)
jnz loop ;Jump to 'loop:' if the zero-flag is not set.
ret ;Return
После первого кадра, который является просто слепком видеопамяти, идут другие фреймы, которые представляют собой структуру:
- Количество элементов фрейма (2 байта).
- Адрес изменения (2 байта).
- Символ изменения (1 байт)
- Адрес изменения
- …
- Последний фрейм содержит в количестве элементов фрейма «невозможное» число
0xFFFF
, что свидетельствует, что мы подошли к концу.
Проще посмотреть код:
initial_frame:
db 020h, 020h, 020h ...
...
frame_001: dw 029eh
dw 772fh
db 020h
dw 7730h
db 020h
dw 7731h
...
frame_002: dw 0275h
dw 7732h
db 020h
dw 7733h
db 020h
...
frame_016: dw 0ffffh
initial_frame
: — это первая картинка, которая просто копируется.frame_001
: — первый diff-фрейм. Первые два байта dw 029eh
— это количество изменений. Два следующих байта dw 772fh
— это адрес куда внести изменения. Последний байт db 020h
— символ изменения (в данном случае пробел). Последний фрейм frame_016: dw 0ffffh
содержит «невозможное число».
В программе converter, которая и генерирует этот ассемблеровский файл (я разбирал её в предыдущей части), конвертация идёт следующим образом:
save_to_asmfile(new_canvas_m, old_canvas_m, frames++);
tmp_m = old_canvas_m;
old_canvas_m = new_canvas_m;
new_canvas_m = tmp_m;
Функция save_to_asmfile
сохраняет разницу между холстами и именем фрейма. Далее указатель на новый холст становится указателем на старый, а старый будет перезаписан под видом нового. Сама функция достаточно объёмна, но все желающие могут с ней ознакомиться тут.
Алгоритм генерации сжатых фреймов вроде понятен, осталось понять как уместить мультфильм вращения в памяти. Совершенно очевидно, что 360 кадров, даже сжатых, никак не влезет. И я начал эмпирическим путём подбирать шаг, с которым проводить вращение, так чтобы это влезло в ОЗУ, но было понятно, что на экране происходит трёхмерное вращение картинки, а не просто набор какие-то случайных кадров.
Самым естественным было генерация кадра, каждые 20 градусов поворота, но при таком шаге количество фреймов получалось очень большим, и они никак не хотели умещаться в памяти. В результате, самым оптимальным по размещению в памяти, но не самым красивым, стало генерация кадра через каждые 30 градусов. Однако всё же оно оказалось резковатым. Поэтому пришлось добавить дополнительный фрейм на 80, 110, 260 и 280 градусах. Это не очень элегантно, но зато при этом вращение выглядит более естественно. Результат меня вполне устроил.
Получившийся «мультфильм» вращения, эмуляция в консоли.
Можно скомпилировать получившийся файл frames.asm
и взвесить, сколько же он будет занимать в памяти.
24 килобайта, вполне сносный объём. Ещё остаётся 8 кБ на код программы и музыку, есть где разбежаться.
▍ Процедура показа мультиков
Как ни странно, но процедура смены фреймов оказалась очень простой. Не буду лукавить, я подсмотрел её у begoon в его демке, только адаптировал под свой формат diff-фреймов.
nit_frame_start:
lxi b, (78*30);размер
lxi d, initial_frame
lxi h, video_area
call memcpy
lxi h, frame_001 ;7C52
next_frame:
push h
call long_frame_delay
pop h
mov a, m
inx h
mov c, a
mov b, m
inx h
ora b ; если всё по нулям, значит следующий фрейм
jz next_frame
cpi 0ffh
jz init_frame_start; подошли к концу
frame_loop:
mov e, m
inx h
mov d, m
inx h
mov a, m
inx h
stax d
dcx b
mov a, c
ora b
jnz frame_loop
jmp next_frame
Вначале идёт копирование инициализационного фрейма в область видеопамяти, после чего вызывается процедура задержки (между каждым фреймом), затем чего считывается количество изменений в регистровую пару BC
, если она равна «несуществующему» числу 0xFFFF
, то начинаем сначала. Иначе, в регистровую пару DC
считывается адрес, где изменить, и считывается в аккумулятор A
символ изменения, и записывается по адресу DC
. Декрементируется регистровая пара BC
, и если она не равна нулю, процедура повторяется.
Если вам кажется это сложным, то это самый простой кусок кода во всей демке. Дальше будет хуже.
▍ Функция задержки
Вообще, для меня логичным способом организовать задержку, был бы таймер с прерыванием, но увы, таймер можно организовать только опросом, и то не совсем понятно, как это грамотно сделать. Поэтому задержка организована по-другому.
Одним из назначений процедуры задержки — это организация паузы между фреймами. Самая процедура long_frame_delay
является обёрткой, над небольшими задержками.
long_frame_delay:
call frame_delay
call frame_delay
call frame_delay
call frame_delay
call frame_delay
call frame_delay
ret
Задержки — это вообще, чуть ли не самая эмпирическая часть этого проекта, потому, что нужно было подобрать её такой, чтобы было видно вращение и смена кадров была естественной и не очень долгой. Поэтому, методом тыка, написал для себя самую оптимальную процедуру задержки, которую уже можно было масштабировать в нужных количествах.
frame_delay:
lxi d, 2000
frame_delay_loop:
dcx d
mov a, d
ora e
jnz frame_delay_loop
ret
Как это работает и как посчитать задержку? Ничего сложного, всё основано на времени выполнения отдельных инструкций. Регистровая пара DE
выполняет роль счётчика, далее она декрементируется, загружается содержимое регистра D
в аккумулятор и с помощью логического «или» аккумулятора с регистром E
идёт проверка на нуль, если не нуль, цикл повторяется. Все ассемблеровские инструкции dcx, mov, ora, jnz
— имеют время исполнения в тактах процессора. Процессор в ПЭВМ «Микроша» работает на частоте 1,77 МГц, соответственно каждый такт процессора занимает:
Количество тактов для выполнения каждой инструкции мне удалось найти в великолепной книге «Intel 8080 Assembly Language Programming Manual». Если записать функцию выше вместе с количеством тактов, то получится.
frame_delay:
lxi d, 2000 ;10
frame_delay_loop:
dcx d ;5
mov a, d ;5
ora e ;4
jnz frame_delay_loop; 10
ret ;10
Инструкциями lxi
и ret
можно пренебречь, но всё же внесу их в общую формулу.
Итого 48020 тактов (20 тактов занимают ret
и lxi
). Количество итераций определяется константой 2000. Лично я выбрал такую, мне она подошла наиболее полно. Эта задержка будет длиться:
Долго игрался с разными константами, остановился на этой. Четыре вызова этой процедуры, получится, грубо, около 0,1 с.
Функция задержки между фреймами long_frame_delay
содержит шесть вызовов этой функции, и как раз занимает 0,163 с.
Как вы понимаете, такие сложности с задержкой неспроста. Всё можно было бы сделать и проще, но они нужны ещё и для другой части проекта.
▍ Да будет звук!
Для меня демка без звука — это уже что-то не то. Поэтому с самого начала для себя твёрдо решил организовать поддержку звука и музыки. И, это, оказалось одним из сложнейших этапов всей разработки, потому, что я вообще не представлял как же программировать звук.
Изначально я пошёл вообще самым тернистым путём: скачал все журналы «Радио» с 1985 по 1995 года и просмотрел все статьи по ЭВМ за этот период. Были и по программированию звука, но на деле они меня больше запутали, чем помогли.
Как оказалось, наиболее полезная и важная информация, которая мне была нужна уже была под рукой в той чудесной книжечке, которая шла с ПЭВМ «Микроша». Для начала следует взглянуть на схему организации аудио на этом вычислительном устройстве. Часто ли вы смотрите схему вашего компьютера, для его программирования? Вот, а тут приходилось часто.
Схема организации звука на ПЭВМ «Микроша».
Схема генерации звука выполнена на таймере КР580ВИ53, при этом для аудиовыхода используется канал 2. Обратите внимание, на цифру 92
— этот сигнал идёт к параллельному порту КР580ВВ55, порт C, бит №1. Устанавливая или снимая этот бит в порту, можно включать или отключать воспроизведение звука. Это нужно, если мы играем музыку по нотам, то там есть кроме воспроизведения ещё и паузы, вот это позволяет включать или отключать звук.
Не хочу подробно останавливаться на всех режимах работы БИС таймера КР580ВИ53, они подробно изложены в мануале, и будем честны, именно сейчас нас мало интересуют. В данном проекте нас нужен режим 3.
Проще говоря, в этом режиме можно генерировать прямоугольный сигнал заданной частоты, и это то что мне нужно. В документации на таймер-счётчик есть также пример кода, однако он не будет работать без настроек порта КР580ВВ55. А вот в документации на порт есть уже полный пример кода, как настроить таймер-счётчик и вывести на нём звук. Читайте документацию полностью и внимательно!
Вкратце поясню, что тут происходит. В регистровую пару HL
записывается адрес регистра БИС таймера КР580ВИ53 — 0xD803
. По данному адресу записывается конфигурационное число 0xB6, что говорит что мы будем передавать двоичные данные, таймер-счётчик работает в режиме 3, используется младший и старший байт, работает второй канал (10110110
: 0
— двоичный, *11
— режим 3, 11
мл, ст байт, 10
— канал 2).
Далее декрементируется регистровая пара HL
, и она начинает содержать значение адреса регистра таймера 0xD802
, по данному адресу уже записывается значение таймера, которое и будет звучать. В данном случае 0x2010
(сначала младший байт, потом старший).
Для включения динамической головки, в регистровую пару HL
записывается адрес регистра управляющего слова параллельно порта КР580ВВ55. Запись 0x80
, говорит о том, что весь порт C идёт на выход. Идёт декремент адреса, и мы пишем в порт C значение 0x06
(можно было бы только 0x02
, так как управление идёт только первым битом).
Если данный код скомпилировать и выполнить на «Микроше», он будет весьма неприятно верещать.
Всё это я оформил в весьма удобных функциях. Как показала практика, инициализировать звук не обязательно, потому что программа «Монитор» и так его инициализирует, можно сразу его использовать уже. Но приведу пример выше в более оформленном виде.
m55regcfg equ 0c003h
portc_reg equ 0c002h
tim_regcfg equ 0d803h
init_sound:; Никогда не вызывается. Работает и так
lxi h, m55regcfg; регистр управляющего слова для клавиатуры
mvi m, 80h ;все на вывод
lxi h, tim_regcfg; запись команды для таймера
mvi m, 0b6h ;10110110 (0 - двоичный, *11 - режим 3, 11 мл, ст байт, 10 - канал 2)
ret
disable_sound:
mvi a, 0
sta portc_reg
ret
enable_sound:
mvi a, 06h
sta portc_reg
ret
Как видно, всё пока относительно просто.
Теперь важный момент, а как определить частоту с которой будет генерироваться сигнал? И как пересчитать частоту в те магические цифры, которые будут уже записаны в регистр таймера 0xD802
?
Всё просто, таймер работает на частоте системной шины, как и процессор, с частотой 1,77 МГц. Таким образом двухбайтовое магическое число для записи в регистр рассчитывается следующим образом:
Где f
— нужная частота звучания, и когда в примере из книжки я записал магическое число magic=0x2010
, то частота звучания была равна примерно 216 Гц.
Всё, теперь всё готово, чтобы делать музыку!
▍ Поиск музыки и способов конвертации
Во всей демке эта часть оказалась самой сложной и затратной по времени.
Поскольку познаний музыки и умения у меня немного, было принято решение использовать готовую мелодию.
Надо понимать, что в силу аппаратных особенностей, как я уже говорил, мелодия может быть только монофонической (один инструмент), без аккордов и второй руки. То есть, этакая «ученическая» игра одним пальцем. Это невероятно сужало круг поисков подходящей мелодии. Также, поскольку я не хотел вручную перебивать коэффициенты, то хотелось сразу взять подходящий формат и мой выбор пал на формат midi. Не буду вдаваться в подробности этого формата, всё хорошо изложено как на википедии, так и в статье на хабре. Грубо говоря, midi хранит номер ноты, её длительность, длительность паузы (в качестве паузы может выступать нулевая нота). А из номера ноты можно легко получить частоту, а уже из неё магическое число для записи в регистр таймера КР580ВИ53.
На вот этом сайте даётся хороший разбор, как это всё пересчитать. И там же приводится весьма удобная и достойная картинка соответствие номера ноты в MIDI и частоты.
И приводится также удобная формула по переводу номера ноты в частоту:
Далее просто подставить полученный результат в формулу расчёта магического числа, и можно получить результат.
После того как я понял общую механику работы MIDI-файла, разработка была отложена и начался поиск той самой мелодии, что может мне подойти. Ещё слабо представлял, как мне конвертировать данный файл в ассемблеровский код, и как его потом воспроизводить.
Я посетил тысячи сайтов с midi-файлами, были скачаны всевозможные торренты, хранящие гигабайты этих файлов. Самое большое удивление вызвало то, что до сих пор живы сайты типа «Отправь смс на короткий номер» и даже wap-сайты, с этими предложениями. Их же кто-то оплачивает!
Основная сложность заключалась в том, что я искал монофонический midi-файл, он должен был быть достаточно длинным (более минуты), при этом должен хорошо зацикливаться (логично звучать в цикле), не иметь никаких аккордов и партий левой руки. Вообще, больше всего мне хотелось использовать «Турецкий марш» В.А. Моцарта, потому что это была первая электронная музыка, которую я услышал ещё в часах, но подходящую длинную midi найти мне не удалось.
После гигабайтов скачанных и переслушанных midi-файлов ко мне начало уже приходить отчаяние и начал думать, что быть может проще будет написать мелодию самому. И именно в момент моего практически полного отчаяния мне пишет man_of_letters:
Помню ты возился с midi. Если хочешь посмотреть питоновский код для проигрывания midi на писиспикере, то он у меня есть.
И скидывает мне следующий код:
from mido import MidiFile
from pathlib import Path
import winsound
import time
def noteToFreq(note):
a = 440 #frequency of A (coomon value is 440Hz)
return (a / 32) * (2 ** ((note - 9) / 12))
f1_in_object = Path(r'c:\Users\User\Downloads\1.mid')
mid = MidiFile(f1_in_object, clip=True)
print('number of tracks', len(mid.tracks))
note_time_scale = 4
pause_time_scale = 4
note = {'wait':0, 'freq':0, 'dur': 0 }
last_note = None
for x in mid.tracks[1]:
if x.type == 'note_on':
note['wait'] = x.time
note['freq'] = int(noteToFreq(x.note))
if x.type == 'note_off':
note['dur'] = x.time
if note['wait']>4:
time.sleep(note['wait'] * pause_time_scale / 1000)
else:
time.sleep(0.01)
note_length = int(note['dur'] * note_time_scale)
winsound.Beep(note['freq'], note_length)
last_note = note
И вместе с этим скидывает пример midi-файлов, который он гонял с этим примером — ППК «Воскрешение».
Для ностальгирующих оригинал мелодии:
И это всё вместе оказалось практически исчерпывающим ответом на вопросы, которые у меня возникали! У man_of_letters было несколько примеров файлов, из которых я подобрал идеальный для моих целей.
▍ Заключение второй части
Итак, мне удалось подобраться к тому, как выводить музыку, но впереди ещё много работы: выбор мелодии, адаптация её для микроши и ещё большая борьба с видеоподсистемой…