Очень приятно осуществлять свои желания, особенно из далёкого прошлого, такого далёкого что уже и забыл что этого когда-то хотел. Я мало знаю о демосцене и уж точно никогда не следил ни за авторами ни за их работами, мне просто нравилось смотреть то что получалось. Иногда мне хотелось в этом разобраться, но тогда мне не хватало знаний и опыта, позже усидчивости, а потом и вовсе у меня пропал к этому интерес. Но недавно мой друг, с кем мы учились в то время и который поставлял нам все новинки, включая демки, с BBS и Fidonet, потому что у него чуть ли ни у единственного был и телефон и модем и компьютер одновременно, посетил CAfePARTY со своими работами, что заставило меня открыть архив моего первого компьютера, выбрать демку и разобраться.
Объективно оценивая свои силы я взял 128 байтовое интро которое мне понравилось визуально. Файл
Из того же архива я вытащил:
От экстремально-минимальной реализации стоило ожидать использования трюков и нестандартных подходов, но кроме некоторых допущений в начальных условиях я не увидел никаких технических уловок, но увидел уловку алгоритмическую. И тут пару слов следует сказать об опыте. В чём может заключаться сложность? Или в реализации или в алгоритме. Например, в команде
Смотря сейчас на то что происходит на экране, я достаточно легко представляю как смог бы это реализовать, пусть не в с 128 байтах, пусть не 100% похоже, но смог бы. А 20 лет назад не мог, хотя все используемые мною инструменты я вытащил с пыльных полок и мне не надо было заглядывать в Интернет, чтобы понять как это работает. Поэтому в первую очередь это контекст, понимание ЧТО происходит, а уж вопрос трюков и КАК это сделать на втором месте.
Что мы видим:
Осталось понять как это было сделано. Дальнейшее описание не заменит собой знаний про архитектуру компьютера и функций DOS или ассемблера, но имея эти знания позволит понять и сосредоточиться на сути происходящего. Начав писать, я понял что получается всё равно достаточно подробно, но не смог от этого отказаться чтобы не потерять в смысле повествования.
Программа в
Я искренне считал, что в общем случае состояние регистров не определено. Но в разбираемом коде делается, на мой взгляд, весьма смелое предположение об их начальном состоянии, в частности о регистрах CX, SI и флаге направления DF. Я не нашёл этому подтверждения в том списке источников что приводил выше, поэтому отправился полистать исходники MS-DOS 2.0:
Исходников более новых версий нет, но есть исходники DOSBox:
То есть совпадает с тем что я увидел в исходниках MS-DOS (2-й версии!), видно и начальные значения других регистров, здесь это явная, специальная инициализация. Для MS-DOS значения регистров кроме AX, сегментных и стека, это рудименты их использования по другому назначению это не догма и не стандарт, поэтому про них нигде и не упоминается. Но зато становится немного понятным образовавшаяся экосистема и вся боль Microsoft по поддержке совместимости со старыми версиями, вынуждающая тащить за собой все случайно образовавшиеся значения, потому что к ним так привыкли программисты.
Наконец, для нас этих знаний достаточно, начинаем восстанавливать программу с заголовков:
Определяем тип процессора 80186, потому что используем команду
Для переключения в графический режим необходимо обратиться к функции BIOS, для чего вызывается прерывание 10h, AH=0, в AL помещаем идентификатор нужного режима — 13h:
Обратите внимание, что AH мы не трогаем, предполагая что там ноль, согласно условиям загрузки программы. Выбранный режим соответствует графическому разрешению 320 на 200 точек с 256 цветной палитрой. Для отображение точки на экране нужно записать в область памяти, которая начинается с адреса A000h:0, байт соответствующий цвету. Сегментные регистры данных заполняем этим значением:
Логически память организована в виде двумерного массива, в который отображаются экранные координаты, 0:0 соответствует левому верхнему углу. После переключения режима она заполнена нулями — чёрный цвет в палитре по умолчанию. Формула перевода в линейное смещение X+Y*L, где L — разрешение по горизонтали, в нашем случае 320. В этом виде я и буду писать в тех местах где используются константы, при трансляции текста программы они вычисляться автоматически.
Для смены палитры мы напрямую обращаемся к оборудованию используя порты ввода вывода:
Первая команда загружает в 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.
Увеличим DX на единицу, чтобы в нём был нужный номер порта. CL это наш счётчик цикла равный 64, при этом мы полагаем что CH=0, как описано ранее исходя из начальных условий загрузки. Далее мы выводим в порт первую компоненту — красную, яркость которой будет храниться в AL, именно она у нас будет изменяться, на первом шаге 0. После чего увеличиваем её яркость на единицу, чтобы вывести в следующей итерации. Далее выполняем две команды
Как только мы вывели три компоненты, то к номеру цвета автоматически прибавляется единица. Таким образом не надо повторно определять цвет выводом в порт 3C8h если цвета идут подряд, что и требуется. Команда
Всего 64 повтора. На каждом повторе определяем для цвета, начиная с 0 до 63, красную компоненту яркостью совпадающую с текущим номером цвета. Зелёную и синюю составляющие сбрасываем, чтобы получить вот такую палитру от минимальной до максимальной яркости красного:
Настраиваем начальные значения цвета и координат:
В AL и AH загружаем максимальный возможный (самый яркий) цвет 63(3Fh), соответственно AX определяет сразу две точки. BX — максимальное разрешение по горизонтали. В дальнейшем это будет использоваться, чтобы прибавить или отнять одну строку от текущих координат. DI — координаты 64:4, сохраняем их в стеке.
Рисуем первую линию из верхнего левого угла к правому крайнему:
Настраиваем счётчик — это будет количество строк. Далее сохраняем слово (два байта) из AX по адресу ES:DI. Это действие отобразит две точки на экране с максимальным цветом из нашей палитры, потому что ES настроен на видеопамять, а в DI установлены конкретные координаты. После этого действия к DI прибавится 2, так как были записаны два байта. Мы явно не устанавливаем флаг направления DF и полагаемся на то что он сброшен, опять вспоминаем наши начальные условия загрузки программы. В противном случае двойка бы отнималась, что не позволило бы нарисовать желаемую линию.
Далее DI=DI+BX, что эквивалентно увеличению координаты Y на единицу. Таким образом, в теле цикла рисуются две точки в одной строке, координата X увеличивается на 2, а координата Y на 1 и повторяется это действие 120 раз, картинка с результатом чуть ниже.
Вторая линия — из верхнего левого в вершину:
Восстанавливаем начальные координаты 64:4 и настраиваем счётчик на 96 повторений. Выводим одну точку, но на одну строку ниже текущих координат. Как и раньше это достигается прибавление значения из BX, только не сохраняя новые координаты. Конструкция
После отрисовки двух линий, получается следующее изображение около верхнего левого угла:
Слева и сверху координаты, справа адреса смещения строки в видеопамяти. Точка 64:4 будет нарисована дважды.
Третья линия — из вершины к верхнему правому углу:
DI уже содержит нужное значение координат 160:196, нам надо нарисовать линию из вершины, где закончилась предыдущая линия, двигаясь вверх экрана сохраняя тот же угол наклона. Соответственно цикл почти идентичный. CX увеличен на 1, потому что текущая координата Y на 2 больше (ниже) того где закончилась предыдущая линия, она посчиталась уже для следующей итерации. Поэтому, чтобы попасть в верхний угол, надо сделать лишний шаг. Движение по X продолжается в том же направлении — плюс один после каждой итерации, а по Y вместо прибавления мы отнимаем двойку. Точки выводятся в том же порядке, сначала нижняя потом верхняя.
Четвёртая линия — из левого крайнего к верхнему правому углу:
Мы опять находимся в нужных координатах, но это не используется, видимо для того чтобы не менять флаг направления DF. Поэтому в DI помещаются новые координаты и сохраняются в стеке.
Далее всё идентично первой линии, только координата Y не растёт, а уменьшается, мы поднимаемся вверх.
Пятая линия — горизонтальная:
Тут всё просто, используется механизм повтора пересылки микропроцессора, так как горизонтальная линия соответствует простому увеличению адреса каждой следующей точки. В DI восстанавливается адрес соответствующий координате левого крайнего угла, запомненный на предыдущем шаге. Задаётся количество повторений в CX и применяется префикс повторения c командой пересылки слов.
После этого действия мы имеем полностью нарисованную пентаграмму самым ярким цветом. 80 использованных байт и 48 в запасе.
Задаём граничные условия для вычислений:
В SI будет координата текущей точки для расчётов, если мы выходим за границы экрана то никаких расчётов с этой точкой не производим, переходим к вычислению следующей.
Вычисление нового цвета
Это основной алгоритм изменения значений цвета на экране, это ещё не пламя, это база для него. Рассчитываем соседние точки и добиваемся непрерывности цвета:
Отнимаем от AX, фактически от AL, единицу, в котором содержится не нулевое значение цвета полученное из текущих координат. Далее полученное значение мы запишем во все соседние точки, относительно текущей координаты, то есть немного их притушим, исходя из нашей палитры.
Так как после
В качестве сегментного регистра выступает DS, который используется по умолчанию вместе с SI и BX.
Нигде не проверяется ситуация когда точка попадает на край экрана. Это не может привести к сбою, так как мы всегда будем в границах видеосегмента. Соседняя точка может попасть либо в не отображаемую область с адресами больше 64000 либо на соседнюю строку, что нам никак не вредит и даже чуть-чуть помогает, как будет видно из дальнейшего описания.
Та самая магия, вычисление координат следующей точки
Вернёмся чуть назад, мы нигде специально не устанавливали начальное значение 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 как результат переполнения
И ещё немного хаоса добавляется нашими граничными значениями, так как для SI>=64000 никаких точек не будет нарисовано и последовательность вывода слегка сбивается. А пропуск всех точек с нулевым значением приводит к эффекту воспламенения на первых нескольких секундах работы программы. Это происходит потому что полный цикл заканчивается быстрее, так как большинство точек не обрабатывается. Но главное, потому что яркость для большинства точек будет только нарастать, их не смогут затенить соседние более тусклые участки — их просто ещё нет, а нулевые значения не рассчитываются. После того как полностью чёрные области исчезнут установится баланс, какие-то области будут увеличивать яркость, а какие-то и уменьшать.
В результате, ни о каком порядке и градиенте речь уже не может идти, точки обходятся не подряд, каждый раз в новой последовательности, в том числе могут повторяться несколько раз или вовсе пропускаться. Что приводит к образованию областей различной яркости перемешенных друг с другом, изменяющихся на каждой новой итерации.
Но это ещё не всё, если не добавлять новых ярких точек, то в конечном итоге все они будут погашены. Поэтому после того как DX добежал до своего максимального значения мы отправляемся обратно, чтобы вновь нарисовать пять ярких линий и опять пересчитать все точки на экране:
Но перед этим считываем из порта 60h, это клавиатура, скан-код последней нажатой клавиши. Для ESC он равен 1. Если это так — была нажата клавиша ESC, двигаемся в сторону выхода.
Стоит обратить внимание что во время обновления текущего экрана, что занимает какое-то время, выйти из программы нельзя, то есть реакция на ESC будет отложенная. Если во время ожидания и после ESC будет нажата ещё какая-то клавиша, то мы всё равно останемся в программе, из порта можно считать только последний скан-код. Ещё один момент, мы не подменяем и не используем для этого системные функции DOS и BIOS, не зависимо от того что мы считали из порта нажатая клавиша помещается в циклический буфер и будет, вероятно, прочитана оттуда следующей программой после завершения нашей интро, скорее всего файловым менеджером или
Осталось вернуться к текстовому режиму 3:
Предполагается, что мы были именно в этом режиме до запуска программы, но в общем случае это может быть не так. Здесь мы обновляем AX целиком, потому что точно знаем, что AH не содержит 0.
Теперь можно выходить:
Это команда ближнего выхода из процедуры, которая возьмёт из стека значение помещённого там слова (два байта) и загрузит в счётчик команд IP. По начальным условиям в стеке у нас нули, это приведёт нас по адресу CS:0, где как мы знаем находится код команды
И 7 байт для копирайта:
Можно сказать что ещё осталось место, которое я бы потратил на более строгую начальную инициализацию, но так как всё работает и в современном DOSBox, наверное, автор всё сделал правильно.
Пройдёмся ещё раз по порядку:
Для компиляции надо выполнить:
Не знаю, стало ли понятным для вас ЧТО и КАК было реализовано, но мне кажется был использован красивый и необычный подход при создании эффекта пламени. Хотя мне и не с чем сравнивать, может так делали все, а теперь и вы так же сможете.
Объективно оценивая свои силы я взял 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
Из того же архива я вытащил:
- Hiew 6.11 (на сайте можно найти 6.50) — я его использовал в качестве дизассемблера
- Пакет TASM — которым я собрал обратно полученный код, чтобы убедиться что ничего не напутал
- Flambeaux Software's TECH Help! 6.0 — в меру подробный и исчерпывающий интерактивный справочник по DOS API, функциям BIOS, железу и ассемблеру
- Майко Г.В. Ассемблер для IBM PC — почти карманного формата справочник по всем основным командам Intel 8086 и правилами оформления текста программы. Без архитектурных подробностей и с элементарными примерами, только самые базовые вещи. Здесь есть почти всё необходимое, но писать на ассемблере в отрыве от среды не получается
- Поэтому вторая книга Зубков С.В. Assembler. Для DOS, Windows и Unix — путеводитель по аппаратным закоулкам и DOS
От экстремально-минимальной реализации стоило ожидать использования трюков и нестандартных подходов, но кроме некоторых допущений в начальных условиях я не увидел никаких технических уловок, но увидел уловку алгоритмическую. И тут пару слов следует сказать об опыте. В чём может заключаться сложность? Или в реализации или в алгоритме. Например, в команде
mov di, 099d1h
, можно испугаться магической константы. Но если находиться в контексте использования то становится ясно, что это адрес для доступа по экранным координатам X и Y, где X=17, Y=123, 320 это разрешение экрана в пикселях по горизонтали. Вместе это даёт нам 17+123*320, преобразование двумерных координат к одномерным.Смотря сейчас на то что происходит на экране, я достаточно легко представляю как смог бы это реализовать, пусть не в с 128 байтах, пусть не 100% похоже, но смог бы. А 20 лет назад не мог, хотя все используемые мною инструменты я вытащил с пыльных полок и мне не надо было заглядывать в Интернет, чтобы понять как это работает. Поэтому в первую очередь это контекст, понимание ЧТО происходит, а уж вопрос трюков и КАК это сделать на втором месте.
Что мы видим:
- 5 линий пентаграммы. Это не обязательно прямые неразрывные линии по всем канонам. Мы видим только общую фигуру, без деталей
- Эффект пламени, который состоит из двух важных частей: правильно подобранной палитры и алгоритма постоянного изменения цвета точек на экране с элементами неопределённости, но сохранением непрерывной последовательности палитры для соседних точек. Например, можно рассчитать весь текущий экран усреднив значения соседних пикселей с предыдущего экрана, а в случайных местах добавить более «яркие» точки, или не в случайных местах, но случайные по значению, или вовсе не случайно, достаточно отойти от линейного порядка. Один из вариантов как это сделано в DOOM. Результат должен получиться в виде перетекающих друг в друга цветов, от постоянно возникающих ярких областей к затухающим
Осталось понять как это было сделано. Дальнейшее описание не заменит собой знаний про архитектуру компьютера и функций DOS или ассемблера, но имея эти знания позволит понять и сосредоточиться на сути происходящего. Начав писать, я понял что получается всё равно достаточно подробно, но не смог от этого отказаться чтобы не потерять в смысле повествования.
DOS и загрузка .COM программ
Программа в
.com
файле это чистый код, никаких заголовков, надо просто поместить его в нужное место. Этим занимается DOS, точнее системный вызов 4Bh. Происходит достаточно много действий, остановимся на результате:- Все сегментные регистры CS, DS, ES, SS загружены одним значением
- Для всей программы зарезервировано 65536 байт, ровно один сегмент на который и указывают все сегментные регистры. Первые 256 байт занимает системный заголовок — PSP (Префикс Программного Сегмента). По адресу CS:0, первое поле PSP, располагается команда INT 20h — завершить текущую программу и передать управление родительскому процессу. Сама программа начинается с адреса CS:100h и занимает следующие 128 байт
- В стек помещено слово 0000h, регистр SP равен FFFEh. Это значит, что последние два байта в этом сегменте по адресу SS:FFFEh обнулены. Фактически это ближний адрес возврата из процедуры, который приведёт нас к команде завершения по адресу CS:0
- Регистры AL и AH содержат признак ошибки определения букв дисков из первого и второго аргумента при вызове программы. Если ошибок нет то они равны 0, если есть то FFh
Я искренне считал, что в общем случае состояние регистров не определено. Но в разбираемом коде делается, на мой взгляд, весьма смелое предположение об их начальном состоянии, в частности о регистрах CX, SI и флаге направления DF. Я не нашёл этому подтверждения в том списке источников что приводил выше, поэтому отправился полистать исходники MS-DOS 2.0:
- Про DF можно предположить, что он сброшен командой
cld
, потому что в последней перед передачей управления пересылке строк используется прямое направление, следовательно DF сброшен. Хотя нет явного использованияcld
именно в этом месте, команда сброса флага направления встречается достаточно часто перед многими другими пересылками - SI содержит 100h, потому что используется для определения смещения которое будет загружено в регистр счётчик команд IP
- CX равен FFh, потому что используется как счётчик с начальным значением 80h для пересылки содержимого всей командной строки и соответственно после пересылки равен 0. А после этого в CL, как временную переменную, загружается FFh и используется для выставления признака ошибки буквы диска в AL и AH
Исходников более новых версий нет, но есть исходники DOSBox:
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, наверное, автор всё сделал правильно.
Пройдёмся ещё раз по порядку:
- Переключаемся в графический режим, сохраняя области видеопамяти в сегментных регистрах данных
- Рисуем 4 линии под одинаковыми углами, но в разных направлениях манипулируя только скоростью изменения координат каждой точки: или X+1 и Y+2, или X+2 и Y+1. Элемент линии состоит у нас не из одной точки, а из двух расположенных горизонтально или вертикально. Пятая линия параллельна горизонтали, рисуем её в одну команду встроенными в процессор механизмами
- Выбираем следующую точку экрана согласно формуле SI=(SI+DX) MOD 65536, каждый раз прибавляя к DX единицу, что даёт нелинейную последовательность значений, которую мы продолжаем в следующем цикле, так как не обнуляем SI. Вокруг полученной координаты мы уменьшаем яркость точек на 1. Сделав так 65536 раз, возвращаемся к предыдущему шагу, чтобы опять нарисовать яркие линии. Весь эффект и вся соль, по сути вот в этих двух простых командах —
add si, dx
иinc dx
, две суммы ничего не значащих сами по себе и ничего не объясняющих, но имеющих колоссальное значение в результате - Проверяем если была нажата ESC то выходим, восстановив текстовый режим экрана и дописав в конце свой копирайт
Код программы целиком.
.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
.Не знаю, стало ли понятным для вас ЧТО и КАК было реализовано, но мне кажется был использован красивый и необычный подход при создании эффекта пламени. Хотя мне и не с чем сравнивать, может так делали все, а теперь и вы так же сможете.