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

5nfjuhj-kwj4cjc64ydkbssxgac.gif


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

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


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

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

$32kB\div2,2kB \approx14$


Всего 14 кадров, и это без учёта размещения кода, который их будет выводить!

Много мультиков тут не посмотришь, поэтому требуется думать над сжатием каждого фрейма. Есть несколько путей для того, чтобы решить эту задачку:

  • Делать вычислительную отрисовку, то есть картинку выводить с помощью формул, рисуя это с помощью формул. Как уже говорил, это медленно и мне такой вариант не подходит.
  • Использовать сжатие. Например, алгоритмы RLE и LZ77, неплохая статья на хабре. Вариант вполне достойный, но узнал я о нём, уже после после того как сделал свой вариант.
  • Хранить разницу между кадрами. Этот вариант хорош тем, если каждый кадр меняется не сильно, по сравнению с предыдущим, то diff будет занимать мало места. Алгоритм отлично подходит для задачи вращения. Но нужно помнить, что при полной смене кадра, объём разницы может занимать тройной размер видеопамяти, что весьма расточительно и тут проще применить банальное копирование областей памяти.


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

Первый кадр или фрейм — это просто картинка, которая копируется функцией memcpy в область памяти.

j-sk6qhckci4_fjsa7ces04draa.png
Первый фрейм.

Функция 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 градусах. Это не очень элегантно, но зато при этом вращение выглядит более естественно. Результат меня вполне устроил.

bctrxurac52r4qupeocbxzw-nl8.gif
Получившийся «мультфильм» вращения, эмуляция в консоли.

Можно скомпилировать получившийся файл frames.asm и взвесить, сколько же он будет занимать в памяти.

f505vsd20wdbdrr1ipfw7ieesau.png

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 года и просмотрел все статьи по ЭВМ за этот период. Были и по программированию звука, но на деле они меня больше запутали, чем помогли.

Как оказалось, наиболее полезная и важная информация, которая мне была нужна уже была под рукой в той чудесной книжечке, которая шла с ПЭВМ «Микроша». Для начала следует взглянуть на схему организации аудио на этом вычислительном устройстве. Часто ли вы смотрите схему вашего компьютера, для его программирования? Вот, а тут приходилось часто.

t9j1uq5frihytdzyhftgv58negi.png
Схема организации звука на ПЭВМ «Микроша».

Схема генерации звука выполнена на таймере КР580ВИ53, при этом для аудиовыхода используется канал 2. Обратите внимание, на цифру 92 — этот сигнал идёт к параллельному порту КР580ВВ55, порт C, бит №1. Устанавливая или снимая этот бит в порту, можно включать или отключать воспроизведение звука. Это нужно, если мы играем музыку по нотам, то там есть кроме воспроизведения ещё и паузы, вот это позволяет включать или отключать звук.

Не хочу подробно останавливаться на всех режимах работы БИС таймера КР580ВИ53, они подробно изложены в мануале, и будем честны, именно сейчас нас мало интересуют. В данном проекте нас нужен режим 3.

7xhpyculqjfb9gwwwfjodtks1xo.png

Проще говоря, в этом режиме можно генерировать прямоугольный сигнал заданной частоты, и это то что мне нужно. В документации на таймер-счётчик есть также пример кода, однако он не будет работать без настроек порта КР580ВВ55. А вот в документации на порт есть уже полный пример кода, как настроить таймер-счётчик и вывести на нём звук. Читайте документацию полностью и внимательно!

d4vnypxkmzunwnyp1_moimyiimo.png

Вкратце поясню, что тут происходит. В регистровую пару 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 и частоты.

48f8e42afade8816ae240dcbcefcbc15.gif

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

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

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


mykrsoj2a9k_txgmjbqpdl2yrwk.jpeg

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

xbo4gmrlicdllfwrmtuypqrlcgg.jpeg

© Habrahabr.ru