- PVSM.RU - https://www.pvsm.ru -
Очень приятно осуществлять свои желания, особенно из далёкого прошлого, такого далёкого что уже и забыл что этого когда-то хотел. Я мало знаю о демосцене и уж точно никогда не следил ни за авторами ни за их работами, мне просто нравилось смотреть то что получалось. Иногда мне хотелось в этом разобраться, но тогда мне не хватало знаний и опыта, позже усидчивости, а потом и вовсе у меня пропал к этому интерес. Но недавно мой друг, с кем мы учились в то время и который поставлял нам все новинки, включая демки, с BBS и Fidonet, потому что у него чуть ли ни у единственного был и телефон и модем и компьютер одновременно, посетил CAfePARTY [1] со своими работами, что заставило меня открыть архив моего первого компьютера, выбрать демку и разобраться.
Объективно оценивая свои силы я взял 128 байтовое интро которое мне понравилось визуально. Файл pentagra.com
за подписью Mcm, 128 байт, последнее изменение 24.09.1996 18:10:14, шестнадцатеричный дамп:
000000: b0 13 cd 10 68 00 a0 07 06 1f ac ba c8 03 ee 42
000010: b1 40 ee 40 6e 6e e2 fa b8 3f 3f bb 40 01 bf 40
000020: 05 57 b1 78 ab 03 fb e2 fb 5f b1 60 88 01 aa 03
000030: fb 03 fb e2 f7 b1 61 88 01 aa 2b fb 2b fb e2 f7
000040: bf d1 99 57 b1 78 ab 2b fb e2 fb 5f b1 8f f3 ab
000050: 81 fe 00 fa 73 12 ac 0a c0 74 0d 48 88 44 fe 88
000060: 04 88 40 ff 88 84 bf fe 03 f2 42 75 e3 e4 60 3c
000070: 01 75 a5 b8 03 00 cd 10 c3 00 00 00 00 4d 63 6d
Из того же архива я вытащил:
От экстремально-минимальной реализации стоило ожидать использования трюков и нестандартных подходов, но кроме некоторых допущений в начальных условиях я не увидел никаких технических уловок, но увидел уловку алгоритмическую. И тут пару слов следует сказать об опыте. В чём может заключаться сложность? Или в реализации или в алгоритме. Например, в команде mov di, 099d1h
, можно испугаться магической константы. Но если находиться в контексте использования то становится ясно, что это адрес для доступа по экранным координатам X и Y, где X=17, Y=123, 320 это разрешение экрана в пикселях по горизонтали. Вместе это даёт нам 17+123*320, преобразование двумерных координат к одномерным.
Смотря сейчас на то что происходит на экране, я достаточно легко представляю как смог бы это реализовать, пусть не в с 128 байтах, пусть не 100% похоже, но смог бы. А 20 лет назад не мог, хотя все используемые мною инструменты я вытащил с пыльных полок и мне не надо было заглядывать в Интернет, чтобы понять как это работает. Поэтому в первую очередь это контекст, понимание ЧТО происходит, а уж вопрос трюков и КАК это сделать на втором месте.
Что мы видим:
Осталось понять как это было сделано. Дальнейшее описание не заменит собой знаний про архитектуру компьютера и функций DOS или ассемблера, но имея эти знания позволит понять и сосредоточиться на сути происходящего. Начав писать, я понял что получается всё равно достаточно подробно, но не смог от этого отказаться чтобы не потерять в смысле повествования.
Программа в .com
файле это чистый код, никаких заголовков, надо просто поместить его в нужное место. Этим занимается DOS, точнее системный вызов 4Bh. Происходит достаточно много действий, остановимся на результате:
Я искренне считал, что в общем случае состояние регистров не определено. Но в разбираемом коде делается, на мой взгляд, весьма смелое предположение об их начальном состоянии, в частности о регистрах CX, SI и флаге направления DF. Я не нашёл этому подтверждения в том списке источников что приводил выше, поэтому отправился полистать исходники MS-DOS 2.0 [4]:
cld
, потому что в последней перед передачей управления пересылке строк используется прямое направление, следовательно DF сброшен. Хотя нет явного использования cld
именно в этом месте, команда сброса флага направления встречается достаточно часто перед многими другими пересылкамиИсходников более новых версий нет, но есть исходники DOSBox [5]:
reg_ax=reg_bx=0;reg_cx=0xff;
reg_dx=pspseg;
reg_si=RealOff(csip);
reg_di=RealOff(sssp);
То есть совпадает с тем что я увидел в исходниках MS-DOS (2-й версии!), видно и начальные значения других регистров, здесь это явная, специальная инициализация. Для MS-DOS значения регистров кроме AX, сегментных и стека, это рудименты их использования по другому назначению это не догма и не стандарт, поэтому про них нигде и не упоминается. Но зато становится немного понятным образовавшаяся экосистема и вся боль Microsoft по поддержке совместимости со старыми версиями, вынуждающая тащить за собой все случайно образовавшиеся значения, потому что к ним так привыкли программисты.
Наконец, для нас этих знаний достаточно, начинаем восстанавливать программу с заголовков:
.186
.model tiny
.code
.startup
Определяем тип процессора 80186, потому что используем команду outsb
, которая появились только в этой модели. Один сегмент кода и точка входа в программу, которая вместе с определением модели памяти tiny
позволит компилятору посчитать правильно все смещения переменных и переходов. При сборке tlink
используется ключ /t
, на выходе это даст .com
файл.
Для переключения в графический режим необходимо обратиться к функции BIOS, для чего вызывается прерывание 10h, AH=0, в AL помещаем идентификатор нужного режима — 13h:
mov al, 13h ;b0 13
int 10h ;cd 10
Обратите внимание, что AH мы не трогаем, предполагая что там ноль, согласно условиям загрузки программы. Выбранный режим соответствует графическому разрешению 320 на 200 точек с 256 цветной палитрой. Для отображение точки на экране нужно записать в область памяти, которая начинается с адреса A000h:0, байт соответствующий цвету. Сегментные регистры данных заполняем этим значением:
push 0a000h ;68 00 a0
pop es ;07
push es ;06
pop ds ;1f
Логически память организована в виде двумерного массива, в который отображаются экранные координаты, 0:0 соответствует левому верхнему углу. После переключения режима она заполнена нулями — чёрный цвет в палитре по умолчанию. Формула перевода в линейное смещение X+Y*L, где L — разрешение по горизонтали, в нашем случае 320. В этом виде я и буду писать в тех местах где используются константы, при трансляции текста программы они вычисляться автоматически.
Для смены палитры мы напрямую обращаемся к оборудованию используя порты ввода вывода:
lodsb ;ac
mov dx, 03c8h ;ba c8 03
out dx, al ;ee
Первая команда загружает в AL байт данных расположенный по адресу DS:SI. В DS у нас загружен сегментный адрес видеопамяти и мы знаем что она заполнена нулями, в SI — в общем случае неизвестно что, как минимум не 0. Нам это почти не важно, куда бы не указывал SI мы почти наверняка попадём в видеопамять которая занимает в этом разрешении 320*200=64000 байт, практически весь сегмент целиком. Таким образом мы ожидаем, что после этой команды AL=0. К SI прибавляется или вычитается единица, это зависит от установки флага направления DF. Пока нам это тоже не особо важно, куда бы не сдвинулся SI мы всё ещё остаёмся в области видеопамяти заполненной нулями.
Далее загружаем DX номером порта 03C8h, вывод в который определяет какой цвет из 256 мы собираемся переопределить. В нашем случае это 0 из AL.
Цвет кодируется в палитре RGB и для этого следует писать в порт 03C9h (на один больше чем 3C8h) три раза подряд, по разу для каждой из компонент. Максимальная яркость компоненты — 63, минимальная 0.
inc dx ;42
mov cl, 64 ;b1 40
PALETTE:
out dx, al ;ee
inc ax ;40
outsb ;6e
outsb ;6e
loop PALETTE ;e2 fa(-6), короткий переход на 6 байт назад
Увеличим DX на единицу, чтобы в нём был нужный номер порта. CL это наш счётчик цикла равный 64, при этом мы полагаем что CH=0, как описано ранее исходя из начальных условий загрузки. Далее мы выводим в порт первую компоненту — красную, яркость которой будет храниться в AL, именно она у нас будет изменяться, на первом шаге 0. После чего увеличиваем её яркость на единицу, чтобы вывести в следующей итерации. Далее выполняем две команды outsb
записывающие в порт, номер которого содержится в DX, байт из области памяти DS:SI, помним что у нас там нули. SI каждый раз изменяется на единицу.
Как только мы вывели три компоненты, то к номеру цвета автоматически прибавляется единица. Таким образом не надо повторно определять цвет выводом в порт 3C8h если цвета идут подряд, что и требуется. Команда loop
уменьшит CX на единицу, если получится значение отличное от нуля то перейдёт к началу цикла, если 0 то к следующей за циклом команде.
Всего 64 повтора. На каждом повторе определяем для цвета, начиная с 0 до 63, красную компоненту яркостью совпадающую с текущим номером цвета. Зелёную и синюю составляющие сбрасываем, чтобы получить вот такую палитру от минимальной до максимальной яркости красного:
Настраиваем начальные значения цвета и координат:
LINES:
mov ax, 03f3fh ;b8 3f 3f
mov bx, 0+1*320 ;bb 40 01
mov di, 64+4*320 ;bf 40 05
push di ;57
В AL и AH загружаем максимальный возможный (самый яркий) цвет 63(3Fh), соответственно AX определяет сразу две точки. BX — максимальное разрешение по горизонтали. В дальнейшем это будет использоваться, чтобы прибавить или отнять одну строку от текущих координат. DI — координаты 64:4, сохраняем их в стеке.
Рисуем первую линию из верхнего левого угла к правому крайнему:
mov cl, 120 ;b1 78
LINE1:
stosw ;ab
add di, bx ;03 fb
loop LINE1 ;e2 fb(-5)
Настраиваем счётчик — это будет количество строк. Далее сохраняем слово (два байта) из AX по адресу ES:DI. Это действие отобразит две точки на экране с максимальным цветом из нашей палитры, потому что ES настроен на видеопамять, а в DI установлены конкретные координаты. После этого действия к DI прибавится 2, так как были записаны два байта. Мы явно не устанавливаем флаг направления DF и полагаемся на то что он сброшен, опять вспоминаем наши начальные условия загрузки программы. В противном случае двойка бы отнималась, что не позволило бы нарисовать желаемую линию.
Далее DI=DI+BX, что эквивалентно увеличению координаты Y на единицу. Таким образом, в теле цикла рисуются две точки в одной строке, координата X увеличивается на 2, а координата Y на 1 и повторяется это действие 120 раз, картинка с результатом чуть ниже.
Вторая линия — из верхнего левого в вершину:
pop di ;5f
mov cl, 96 ;b1 60
LINE2:
mov [bx+di], al ;88 01
stosb ;aa
add di, bx ;03 fb
add di, bx ;03 fb
loop LINE2 ;e2 f7(-9)
Восстанавливаем начальные координаты 64:4 и настраиваем счётчик на 96 повторений. Выводим одну точку, но на одну строку ниже текущих координат. Как и раньше это достигается прибавление значения из BX, только не сохраняя новые координаты. Конструкция [bx+di]
или [bx][di]
называется адресация по базе с индексированием и работает на уровне процессора, а не транслятора. В качестве сегментного регистра по умолчанию с BX используется DS. После чего выводим вторую точку, но уже в текущие координаты. DI, а следовательно X увеличивается на единицу, так как использована только одна команда пересылки байта — stosb
. Последние две команды тела цикла — это увеличение Y на 2, для чего опять используем BX.
После отрисовки двух линий, получается следующее изображение около верхнего левого угла:
Слева и сверху координаты, справа адреса смещения строки в видеопамяти. Точка 64:4 будет нарисована дважды.
Третья линия — из вершины к верхнему правому углу:
mov cl, 97 ;b1 61
LINE3:
mov [bx+di], al ;88 01
stosb ;aa
sub di, bx ;2b fb
sub di, bx ;2b fb
loop LINE3 ;e2 f7(-9)
DI уже содержит нужное значение координат 160:196, нам надо нарисовать линию из вершины, где закончилась предыдущая линия, двигаясь вверх экрана сохраняя тот же угол наклона. Соответственно цикл почти идентичный. CX увеличен на 1, потому что текущая координата Y на 2 больше (ниже) того где закончилась предыдущая линия, она посчиталась уже для следующей итерации. Поэтому, чтобы попасть в верхний угол, надо сделать лишний шаг. Движение по X продолжается в том же направлении — плюс один после каждой итерации, а по Y вместо прибавления мы отнимаем двойку. Точки выводятся в том же порядке, сначала нижняя потом верхняя.
Четвёртая линия — из левого крайнего к верхнему правому углу:
mov di, 17+123*320 ;bf d1 99
push di ;57
mov cl, 120 ;b1 78
LINE4:
stosw ;ab
sub di, bx ;2b fb(-5)
loop LINE4
Мы опять находимся в нужных координатах, но это не используется, видимо для того чтобы не менять флаг направления DF. Поэтому в DI помещаются новые координаты и сохраняются в стеке.
Далее всё идентично первой линии, только координата Y не растёт, а уменьшается, мы поднимаемся вверх.
Пятая линия — горизонтальная:
pop di ;5f
mov cl, 143 ;b1 8f
rep stosw ;f3 ab
Тут всё просто, используется механизм повтора пересылки микропроцессора, так как горизонтальная линия соответствует простому увеличению адреса каждой следующей точки. В DI восстанавливается адрес соответствующий координате левого крайнего угла, запомненный на предыдущем шаге. Задаётся количество повторений в CX и применяется префикс повторения c командой пересылки слов.
После этого действия мы имеем полностью нарисованную пентаграмму самым ярким цветом. 80 использованных байт и 48 в запасе.
Задаём граничные условия для вычислений:
FLAME:
cmp si, 320*200 ;81 fe 00 fa
jae NEXT_PIXEL ;73 12
lodsb ;ac
or al,al ;0a c0
jz NEXT_PIXEL ;74 0d
В SI будет координата текущей точки для расчётов, если мы выходим за границы экрана то никаких расчётов с этой точкой не производим, переходим к вычислению следующей.
lodsb
загружает байт из области DS:SI в AL, то есть цвет точки в текущих координатах. Если он равен 0, то тоже ничего не предпринимаем и переходим к следующей точке.
Вычисление нового цвета
Это основной алгоритм изменения значений цвета на экране, это ещё не пламя, это база для него. Рассчитываем соседние точки и добиваемся непрерывности цвета:
dec ax ;48
mov [si-2], al ;88 44 fe
mov [si], al ;88 04
mov [bx+si-1], al ;88 40 ff
mov [si-1-1*320], al ;88 84 bf fe
Отнимаем от AX, фактически от AL, единицу, в котором содержится не нулевое значение цвета полученное из текущих координат. Далее полученное значение мы запишем во все соседние точки, относительно текущей координаты, то есть немного их притушим, исходя из нашей палитры.
Так как после lodsb
значение SI увеличилось на единицу и уже не соответствует той точке цвет которой мы прочитали в AL, то это приходится корректировать. Обратите внимание, что уже не используются команды пересылки байт stosb
, вместо этого применяется mov
, чтобы точно определить адрес куда будет помещено значение. Если принять что текущие координаты X:Y, для них SI-1, то:
mov [si-2], al
— запись нового цвета в точку X-1:Y, слева от текущей. От SI отнимается 2 по причине описанной чуть выше, так как к нему уже прибавлена лишняя единицаmov [si], al
— запись нового цвета в точку X+1:Y, справа от текущей. В SI уже X+1mov [bx+si-1], al
— запись нового цвета в точку X:Y+1, снизу от текущей. Опять используем BX для Y+1mov [si-1-1*320], al
— запись нового цвета в точку X:Y-1, сверху от текущей. BX мы не сможем использовать, так как нам надо отнимать координату, архитектура процессора не позволяет это сделать в таком виде, поэтому используется константа в соответствии с формулой приведения координатВ качестве сегментного регистра выступает DS, который используется по умолчанию вместе с SI и BX.
Нигде не проверяется ситуация когда точка попадает на край экрана. Это не может привести к сбою, так как мы всегда будем в границах видеосегмента. Соседняя точка может попасть либо в не отображаемую область с адресами больше 64000 либо на соседнюю строку, что нам никак не вредит и даже чуть-чуть помогает, как будет видно из дальнейшего описания.
Та самая магия, вычисление координат следующей точки
NEXT_PIXEL:
add si, dx ;03 f2
inc dx ;42
jnz FLAME ; 75 e3(-29)
Вернёмся чуть назад, мы нигде специально не устанавливали начальное значение SI, а в DX у нас остался номер порта ввода вывода который мы использовали для палитры. Выполняем всего три простых действия SI=SI+DX, очевидно это задаст новые координаты, какие? DX=DX+1 и если DX не равен 0, то возвращаемся обратно к базовому алгоритму получения и вычисления соседних точек, то есть DX это какой-то счётчик?
Мы знаем что надо обойти все точки и рассчитать изменения яркости их соседей. Если сделать это подряд, то вероятно мы получим статичный градиент, может не совсем ровный, но неизменный вокруг наших линий. Мы знаем размерность нашего экрана и сколько точек мы должны обойти, но здесь мы этим почти пренебрегаем, точнее выбираем близкое значение 65536 вместо точных 64000. DX это в самом деле счётчик, как раз 65536. Но почему не важно его начальное значение и почему мы берём конечное значение больше чем всего точек на экране?
Потому что мы обходим точки не подряд и не все. Каждая следующая линейная координата больше предыдущей на величину DX. То есть, в SI сумма из DX элементов простой арифметической прогрессии: 0,1,2,3,4,5,6,...,362,363,...,65535. Это уже даёт нам нелинейность, если начать с SI=0 и DX=0, то в SI получим: 0,1,3,4,6,10,15,21,...,65341,65703,...,2147450880.
Но это ещё не всё, так как размерность SI 16 бит, то значение больше 65535 мы получить не можем, происходит переполнение и в SI остаётся остаток по модулю 65536. Формула вычисления линейной координаты принимает такой вид SI=(SI+DX) MOD 65536, что совершенно ломает непрерывный порядок: 0,1,3,4,6,10,15,21,...,65341,167,530,894,…
Теперь вспомним что SI никак не инициализируется, то есть когда в следующий раз мы вернёмся к этому циклу то начнём с той координаты где мы остановились, а не с 0 или какой-то заданной. Это добавит хаоса в нашу последовательность — удлинит количество не повторяющихся элементов. В противном случае, обход точек был бы всегда одинаковым, пусть и нелинейным. Эффект пламени присутствовал бы, но не так явно. Если и говорить о трюке, то это как раз он и есть. DX, всегда, кроме первого использования, неявно начинается с 0 как результат переполнения inc dx
.
И ещё немного хаоса добавляется нашими граничными значениями, так как для SI>=64000 никаких точек не будет нарисовано и последовательность вывода слегка сбивается. А пропуск всех точек с нулевым значением приводит к эффекту воспламенения на первых нескольких секундах работы программы. Это происходит потому что полный цикл заканчивается быстрее, так как большинство точек не обрабатывается. Но главное, потому что яркость для большинства точек будет только нарастать, их не смогут затенить соседние более тусклые участки — их просто ещё нет, а нулевые значения не рассчитываются. После того как полностью чёрные области исчезнут установится баланс, какие-то области будут увеличивать яркость, а какие-то и уменьшать.
В результате, ни о каком порядке и градиенте речь уже не может идти, точки обходятся не подряд, каждый раз в новой последовательности, в том числе могут повторяться несколько раз или вовсе пропускаться. Что приводит к образованию областей различной яркости перемешенных друг с другом, изменяющихся на каждой новой итерации.
Но это ещё не всё, если не добавлять новых ярких точек, то в конечном итоге все они будут погашены. Поэтому после того как DX добежал до своего максимального значения мы отправляемся обратно, чтобы вновь нарисовать пять ярких линий и опять пересчитать все точки на экране:
in al, 60h ;e4 60
cmp al, 01h ;3c 01
jne LINES ;75 a5(-91)
Но перед этим считываем из порта 60h, это клавиатура, скан-код последней нажатой клавиши. Для ESC он равен 1. Если это так — была нажата клавиша ESC, двигаемся в сторону выхода.
Стоит обратить внимание что во время обновления текущего экрана, что занимает какое-то время, выйти из программы нельзя, то есть реакция на ESC будет отложенная. Если во время ожидания и после ESC будет нажата ещё какая-то клавиша, то мы всё равно останемся в программе, из порта можно считать только последний скан-код. Ещё один момент, мы не подменяем и не используем для этого системные функции DOS и BIOS, не зависимо от того что мы считали из порта нажатая клавиша помещается в циклический буфер и будет, вероятно, прочитана оттуда следующей программой после завершения нашей интро, скорее всего файловым менеджером или command.com
. Это приведёт к её обработке, например, Volkov Commander по ESC спрячет свои панели.
Осталось вернуться к текстовому режиму 3:
mov ax, 03h ;b8 03 00
int 10h ;cd 10
Предполагается, что мы были именно в этом режиме до запуска программы, но в общем случае это может быть не так. Здесь мы обновляем AX целиком, потому что точно знаем, что AH не содержит 0.
Теперь можно выходить:
retn ;c3
Это команда ближнего выхода из процедуры, которая возьмёт из стека значение помещённого там слова (два байта) и загрузит в счётчик команд IP. По начальным условиям в стеке у нас нули, это приведёт нас по адресу CS:0, где как мы знаем находится код команды int 20h
— завершение работы.
И 7 байт для копирайта:
dd 0h ;00 00 00 00
db 'Mcm' ;4d 63 6d
end
Можно сказать что ещё осталось место, которое я бы потратил на более строгую начальную инициализацию, но так как всё работает и в современном DOSBox, наверное, автор всё сделал правильно.
Пройдёмся ещё раз по порядку:
add si, dx
и inc dx
, две суммы ничего не значащих сами по себе и ничего не объясняющих, но имеющих колоссальное значение в результате.186
.model tiny
.code
.startup
mov al, 13h
int 10h
push 0a000h
pop es
push es
pop ds
lodsb
mov dx, 03c8h
out dx, al
inc dx
mov cl, 040h
PALETTE:
out dx, al
inc ax
outsb
outsb
loop PALETTE
LINES:
mov ax, 03f3fh
mov bx, 0+1*320
mov di, 64+4*320
push di
mov cl, 120
LINE1:
stosw
add di, bx
loop LINE1
pop di
mov cl, 96
LINE2:
mov [bx+di], al
stosb
add di, bx
add di, bx
loop LINE2
mov cl, 97
LINE3:
mov [bx+di], al
stosb
sub di, bx
sub di, bx
loop LINE3
mov di, 17+123*320
push di
mov cl, 120
LINE4:
stosw
sub di, bx
loop LINE4
pop di
mov cl, 143
rep stosw
FLAME:
cmp si, 320*200
jae NEXT_PIXEL
lodsb
or al,al
jz NEXT_PIXEL
dec ax
mov [si-2], al
mov [si], al
mov [bx+si-1], al
mov [si-1-1*320], al
NEXT_PIXEL:
add si, dx
inc dx
jnz FLAME
in al, 60h
cmp al, 01h
jne LINES
mov ax, 03h
int 10h
retn
dd 0h
db 'Mcm'
end
Для компиляции надо выполнить: tasm pentagra.asm
и tlink /t pentagra.obj
.
Не знаю, стало ли понятным для вас ЧТО и КАК было реализовано, но мне кажется был использован красивый и необычный подход при создании эффекта пламени. Хотя мне и не с чем сравнивать, может так делали все, а теперь и вы так же сможете.
Автор: Михаил Васильев
Источник [6]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/dos/344199
Ссылки в тексте:
[1] CAfePARTY: https://cafeparty.org.ru/2019/
[2] 6.50: http://www.hiew.ru/#hiew
[3] как это сделано в DOOM: https://habr.com/ru/post/435122/
[4] MS-DOS 2.0: https://github.com/microsoft/MS-DOS/tree/master/v2.0/source/EXEC.ASM
[5] исходники DOSBox: https://sourceforge.net/projects/dosbox/files/dosbox/
[6] Источник: https://habr.com/ru/post/482826/?utm_source=habrahabr&utm_medium=rss&utm_campaign=482826
Нажмите здесь для печати.