Создание демки специально для HABR — Часть 2

от автора


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

▍ 640 килобайт хватит всем, или как впихнуть невпихуемое

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

Как вы помните, видеопамять ПЭВМ «Микроша» занимает, грубо 78х30 символов, либо 2340 байт или 2,2 килобайта. Из характеристик ПЭВМ, оперативной памяти всего 32 кБ. И если я хочу показывать какие-либо мультики на данном вычислительном устройстве, мне нужно где-то хранить кадры. Если просто взять отдельные кадры, сохранить их в ОЗУ, и далее по очереди их выводить, то получится:

$32kB\div2,2kB \approx14$

Всего 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

После первого кадра, который является просто слепком видеопамяти, идут другие фреймы, которые представляют собой структуру:

  1. Количество элементов фрейма (2 байта).
  2. Адрес изменения (2 байта).
  3. Символ изменения (1 байт)
  4. Адрес изменения
  5. Последний фрейм содержит в количестве элементов фрейма «невозможное» число 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 МГц, соответственно каждый такт процессора занимает:

$1\;Такт = \frac{1}{1770000}\;Секунды$

Количество тактов для выполнения каждой инструкции мне удалось найти в великолепной книге «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 можно пренебречь, но всё же внесу их в общую формулу.

$(10 + 10) + (5 + 5 + 4 + 10) \cdot 2000) = 48020\;Тактов$

Итого 48020 тактов (20 тактов занимают ret и lxi). Количество итераций определяется константой 2000. Лично я выбрал такую, мне она подошла наиболее полно. Эта задержка будет длиться:

$T=48020\div1770000=0,027\;с$

Долго игрался с разными константами, остановился на этой. Четыре вызова этой процедуры, получится, грубо, около 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 МГц. Таким образом двухбайтовое магическое число для записи в регистр рассчитывается следующим образом:

$magic=\frac{1770000}{f}$

Где f — нужная частота звучания, и когда в примере из книжки я записал магическое число magic=0x2010, то частота звучания была равна примерно 216 Гц.

Всё, теперь всё готово, чтобы делать музыку!

▍ Поиск музыки и способов конвертации

Во всей демке эта часть оказалась самой сложной и затратной по времени.

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

Надо понимать, что в силу аппаратных особенностей, как я уже говорил, мелодия может быть только монофонической (один инструмент), без аккордов и второй руки. То есть, этакая «ученическая» игра одним пальцем. Это невероятно сужало круг поисков подходящей мелодии. Также, поскольку я не хотел вручную перебивать коэффициенты, то хотелось сразу взять подходящий формат и мой выбор пал на формат midi. Не буду вдаваться в подробности этого формата, всё хорошо изложено как на википедии, так и в статье на хабре. Грубо говоря, midi хранит номер ноты, её длительность, длительность паузы (в качестве паузы может выступать нулевая нота). А из номера ноты можно легко получить частоту, а уже из неё магическое число для записи в регистр таймера КР580ВИ53.

На вот этом сайте даётся хороший разбор, как это всё пересчитать. И там же приводится весьма удобная и достойная картинка соответствие номера ноты в MIDI и частоты.

И приводится также удобная формула по переводу номера ноты в частоту:

$f_{n}=2^{\frac{n}{12}}\cdot440\;Гц$

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

После того как я понял общую механику работы 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 было несколько примеров файлов, из которых я подобрал идеальный для моих целей.

▍ Заключение второй части

Итак, мне удалось подобраться к тому, как выводить музыку, но впереди ещё много работы: выбор мелодии, адаптация её для микроши и ещё большая борьба с видеоподсистемой…


ссылка на оригинал статьи https://habr.com/ru/company/ruvds/blog/669670/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *