Подробный разбор 64b intro: radar

в 18:24, , рубрики: demoscene, intro, sizecoding, x86, ассемблер, Демосцена, невероятное, сайзкодинг

Да здравствует мыло душистое демосцена! И вам привет, дорогой читатель ;)

Начинаю цикл статей с разборами своих работ:

  1. 64b intro: radar (вы находитесь здесь)

  2. 64b nano game: snake64

С демосценой я познакомился примерно 25 лет назад (или чуть больше). Но тогда это выражалось лишь в просмотре 128–256-байтовых интро (и демок, конечно же) с изумлением а‑ля: «А что так можно было?» Думаю, у многих знакомство с этой киберкультурой начинается похожим образом :). Если вам эти слова мало о чём говорят, почитайте о демосцене скудную статью на Вики, ну и/или послушайте подкаст, а также посмотрите что люди умудряются сделать, укладываясь всего лишь, например, в 256 байт кода (справа у большинства работ есть ссылка на видео YouTube).

Полноценные интро на любимом ассемблере x86 я начал писать только 5 лет назад, в 2018 году. Именно тогда я отправил на знаменитый фестиваль Chaos Constructions (который, кстати, организаторы обещают возродить в 2024) два прода (от слова «production»): 256b intro StarLine (заняла 1-е место) и 64b intro radar (заняла 6-е место в том же compo). После этого демосцена меня засосала стала частью моей жизни, в которую время от времени я с энтузиазмом погружаюсь.

radar, 64b intro (дерганная GIF-версия, нормальную смотрите на YouTube по ссылке выше)

radar, 64b intro (дерганная GIF-версия, нормальную смотрите на YouTube по ссылке выше)

Пусть вас не смущает, что разбор я начал с работы, занявшей шестое место, а не первое. Такой выбор обусловлен тем, что её код более простой и короткий. Вместе с тем, там не так уж и мало трюков, с которыми начинающему (а может, и не только) самураю демосцены может быть интересно познакомиться. Так что не надейтесь на лёгкое расслабляющее чтиво :)

Погнали!

Давайте не будем тянуть кота за... усы, и посмотрим на код (качайте fasm 1).

; ------------------------------------------------------
; Radar 64-byte intro [main variant] (c) 2018-2019 Jin X
; ------------------------------------------------------

video_shift     =       10h     ; we use 9FFFh segment for video output instead of 0A000h
radar_radius    =       64      ; should be multiple of circles_step for the best effect
radbg_color     =       11h     ; [radar background color]
arrow_color     =       21h     ; should be more than radbg_color
circles_color   =       2       ; should be less than radbg_color
circles_step    =       10h     ; should be power of 2 for the best effect

use16
org     100h

        ; Assume: ax=bx=0 (if no cmd line params), cx=0FFh, dx=cs=ds=es=ss, si=100h, di=sp=0FFFEh (as a rule), bp=91Xh, flags=7202h or 0202h (all base flags including cf=0; if=1)

        ; Init
        mov     al,13h
        int     10h
        fild    qword [si]
        lds     ax,[bx]         ; ds=9FFFh (as a rule), ax=020CDh
        fptan                   ; st0=1=angle, st1=delta (we need about 0.02 - this order of first instructions allows to get near value)

        ; Main cycle            ; di=sp=0FFFEh, ds=9FFFh
.repeat:
        ; Fadeout
@@:     add     [di],dh
        sbb     [di],dh         ; for source [di]=0 result=0, cf=0
        inc     di
        jnz     @B              ; di=0, cf=0 (cos [0FFFFh]=0)

        ; Radar
        mov     cl,radar_radius
.next:  fld     st
        fsincos                 ; st0=cos(angle), st1=sin(angle), st2=angle, st3=delta
@@:     mov     [di],cx
        fimul   word [di]       ; cx*cos then cx*sin
        fistp   word [di]
        mov     ax,[di]
        xchg    bx,ax
        out     61h,al          ; sound
        cmc
        jc      @B              ; second pass (cos then sin)
        ; st0=angle, st1=delta
        imul    si,ax,320
        test    cl,circles_step-1 ; cf=0
        mov     dx,arrow_color + (not radbg_color)*256
        jnz     @F
        mov     dl,circles_color
@@:     mov     byte [bx+si+(160+100*320)+video_shift],dl
        loop    .next
        ; dh=not radbg_color

        fadd    st,st1          ; increase angle a tiny bit

        jmp     .repeat         ; dh=not radbg_color

;       in      al,60h
;       dec     ax
;       jnz     .repeat                 
;       ret

Здесь приведён чуть‑чуть модифицированный код (две инструкции fild + fmulp заменены на аналогичную одну fimul, в результате чего бинарь сократился до 61 байта вместо исходных 63-х).

Начну с того, что мы создаём программу в формате COM под DOS, поэтому при старте почти всегда (за исключением случаев запуска в экзотических типах/версиях DOS) регистры имеют следующие значения:

  • AX = BX = 0 (если в командной строке нет параметров с некорректным именем диска);

  • CX = 0FFh;

  • DX = CS = DS = ES = SS;

  • SI = 100h;

  • DI = SP = 0FFFEh (в некоторых экзотических случаях SP, но не DI, может быть равно 0FFFCh, но лично мне такого не встречалось);

  • BP=9??h (как правило даже 91?h, где «?» зависит от типа/версии DOS);

  • сброшены основные арифметические флаги (ZF, CF, SF, OF, PF, AF), а также DF (как после cld); флаг IF установлен (как после sti), т. е. прерывания разрешены.

Вы можете почитать об этом здесь. Сайзкодеры (люди, занимающиеся жёсткой оптимизацией кода по размеру исполняемого файла) очень часто используют эти значения.

Первое, что мы делаем — устанавливаем графический видеорежим 13h (320×200, 256 цветов), наиболее популярный в демосцене под DOS, поскольку каждый пиксель кодируется одним байтом, а весь экран (имеющий размер 64 000 байт) помещается в 64 КБ сегмент. И уже здесь мы используем наши сакральные знания о том, что на старте AH = 0, поэтому просто записываем номер видеорежима в AL и вызываем int 10h. Вжух — графический режим установлен!

Видеопамять отображается на сегмент 0A000h, поэтому для доступа к пикселям нам нужно записать это значение в какой‑то из сегментных регистров. Ну... или примерно такое значение. В смысле, примерно? Инструкция lds ax,[bx] загружает в пару регистров DS:AX значение dword по адресу BX. А что у нас по этому адресу (по адресу DS:0)? PSP, первое слово которого содержит значение 20CDh (опкод int 20h) — оно идёт лесом в AX (нам оно не нужно), а второе — сегмент за областью нашей программы. Так как COM‑программе отводится вся доступная память, то здесь будет значение 9FFFh, которое отправится в регистр DS (а в этом 16-байтовом блоке располагается инфа о том, что память закончилась... помните: «640 КБ хватит всем»?) В некоторых типах DOS такой блок отсутствует, и там записано 0A000h. Другие некоторые DOS резервируют несколько килобайт, и там может быть, скажем, 9F80h. Уф! Да, это немного опасный трюк, так как в последних 2-х случаях картинка поедет. Но он довольно популярен среди интро на 32 или 64 байта. Скажу по секрету (только никому!), интро немного заточено под DOSBox, а там всё будет ровно :). Теперь, имея в DS значение сегмента 9FFFh вместо 0A000h, нам нужно будет просто добавлять к адресу (смещению) значение 10h (video_shift), и всё будет работать так, как при DS = 0A000h. Вообще говоря, поскольку мне удалось сократить размер интро на пару байт (см. примечание после кода), и до предела в 64 байта есть целый мир аж 3 байта, мы могли бы заменить lds на пару push 0A000h + pop ds. Но не спешите радоваться. О причинах возможной печали я расскажу через абзац (и да, про fild qword [si] + fptan я тоже не забыл).

Не помню, что мотивировало меня разместить блок «Fadeout» перед основных блоком рисования «Radar», потому что сейчас, глядя на это безобразие, я не вижу особых на то поводов. Обычно в таких случаях говорят, что так сложилось по историческим причинам. Ну а раз так, то и об этом я расскажу немного позже, поскольку для объяснения даже такого небольшого куска кода нужно понимать, что (какая картинка) у нас поступает на вход цикла.

Кручу-верчу, запутать хочу

Записываем в CX радиус радара в пикселях: radar_radius = 64 (вернее, в CL, мы же знаем, что на старте CH = 0). Дублируем ST0 с помощью fld st. Стоп! А что у нас в ST0? Вернёмся в началу кода и посмотрим на инструкцию fild qword [si], которая загружает в ST0 значение qword из памяти по адресу SI = 100h. По адресу DS(CS):100h у нас начинается наш код (это же бубль-гум точка входа). А именно следующий блок:

        mov     al,13h
        int     10h
        fild    qword [si]
        lds     ax,[bx]

Эти 4 инструкции занимают ровно 8 байт (по 2 каждая), которые и загружаются в ST0. WTF? Да, бро, мы используем наш код как данные (вполне нормальная тема для сайзкодинга, привыкайте). Вот этот qword: 2CDF10CD13B0h. Отладкой выясняем, что мы загрузили число ≈ 5.599E+17. Красивое! А дальше следите за руками: после lds у нас идёт fptan (тут вы его не видите, но он есть). Который преобразует наше число в пару: 1.0 (в ST0) и ≈ 0.0294 (в ST1). It's a magic! Учите матчасть. Первым числом (1.0) будет исходный/текущий угол расположения стрелки радара (нас устроит любое значение, мы не гордые), а вторым (0.0294) — приращение угла в радианах после каждой отрисовки кадра (1.684 градуса, тоже норм). Так вот, если бы вместо lds у нас было что‑то другое, это не сработало бы, так как qword был бы другим, и значение приращения угла было бы непонятно каким. Однако британские учёные обнаружили, что если очень хочется сделать всё по науке (DS = $A000), то сделав финт ушами переставив некоторые инструкции, результат будет примерно таким же:

        fild    qword [si]
        mov     al,13h
        push    0A000h
        fptan
        int     10h
        pop     ds

Эта конструкция занимает на 2 байта больше и даёт нам значения 1.0 и ≈ 0.0337, которые нас вполне удовлетворяют (а если не видно разницы, зачем платить больше? Ну разве что в некоторых версиях DOS картинка не поедет, но тогда нужно выставить video_shift = 0). Вообще, сейчас я бы так и сделал, поскольку результат в 63 байта, как вы понимаете, не превышает 64-х байтов, но мне же нужно было познакомить вас с трюком с lds.

Как я догадался о том, что вот это всё так сработает и даст нужные значения? Ответ прост: мне очень хотелось, чтобы так случилось :))). А дальше методом экспериментов: перестановок инструкций, перебора типов (fld, fild) и размеров загружаемого значения (dword, qword, tbyte) и веря в успех, я нашёл нужную комбинацию (не просто же так fild стоит в таком странном месте). Ну и... magic, конечно же! Если вы думаете, что я шучу, то мне придётся вас разочаровать: sizecoding иногда требует и не таких экспериментов.

Отладчик Turbo Debugger, обратите внимание на область PSP (CD 20 FF 9F)

Отладчик Turbo Debugger, обратите внимание на область PSP (CD 20 FF 9F)

Ладно, вернёмся к нашему стаду коду. После дублирования ST0 у нас в стеке FPU наблюдается такой ряд чисел (от ST0 до ST2): 1.0 (angle), 1.0 (angle), 0.0294 (delta). Следующая инструкция fsincos вычисляет синус и косинус от ST0 (в первом кадре — от 1.0), записывая результат в ST1 и ST0 соответственно: 0.54 (cos), 0.841 (sin), 1.0, 0.294 (см. картинку выше). По адресу DS:DI мы записываем значение CX (радиус). Напомню, что DS = 9FFFh, DI = 0 (после цикла «Fadeout», см. ниже), а значит мы пишем в невидимую область около видеопамяти — это будет наша временная переменная (назовём её temp). Инструкцией fimul word [di] умножаем ST0 = 0.54 (косинус угла, изначально равного 1.0) на целое число temp, т. е. на CX, записывая результат обратно в ST0. Таким образом, мы получаем координату X (для первой итерации цикла CX первого кадра: round(64 * 0.54) = 35). Записываем её обратно в temp (fistp word [di]), удаляя из ST0, и оттуда загружаем в AX (да, обмен значениями с FPU возможен только через память, это печально). Меняем местами AX и BX (зачем — будет понятно позже). Записью в порт 61h (out 61h,al) выводим звук (лёгкое потрескивание... слышите?). Да, этот странный интересный звук генерирует одна 2-байтовая инструкция. Иногда её размещение в разных рандомных местах даёт вполне интересный аудиоэффект (хотя нередко получается лютый трэш, который только убивает всё впечатление от интро). Пользуйтесь, без регистрации и СМС.

Едем дальше. Инструкция cmc меняет флаг CF на противоположный. Последней инструкцией, которая влияла на этот флаг, была инструкция sbb (всё в том же «Fadeout»), которая сбросила CF в 0 (просто поверьте), дальше будет test, которая сделает то же самое. Так что, будьте уверены: на первой итерации внутреннего цикла (от первой метки @@ до jc @B) CF = 0, а после cmc CF = 1, поэтому в первом заходе jc @B осуществит прыжок к предыдущей метке @@. Это довольно распространённый трюк: организовывать так 2 или 3 итерации, меняя какой‑то флаг (иногда CF, но чаще даже PF с помощью однобайтовой инструкции inc или dec): не нужно инициализировать CX и делать loop, особенно когда CX уже используется (как раз наш случай).

Давайте посмотрим, что у нас осталось в стеке FPU: 0.841 (sin), 1.0, 0.294. Снова записываем в temp значение CX, далее умножаем его на 0.841 (уже синус угла). Получаем координату Y (для первой итерации цикла CX первого кадра: round(0.841 * 64) = 54). Записываем в temp (удаляя из ST0), затем в AX. Снова обмениваем AX и BX (успеваете?) В итоге у нас стек FPU содержит 1.0 (angle), 0.0294 (delta). Регистры: AX = X, BX = Y. Следующее выполнение cmc установит CF = 0, и jc @B не сработает.

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

Пора брать холст, палитру и кисть

Палитра цветов по умолчанию для видеорежима VGA 13h

Палитра цветов по умолчанию для видеорежима VGA 13h

Палитра уже есть. Кисти рук — тоже. В качестве холста будет видеопамять. Так что, дело за малым. Но сперва несколько слов об организации этой самой видеопамяти (если вдруг кто не знает). Первый байт задаёт цвет самого левого верхнего пикселя на основании палитры, далее двигаемся вправо до конца строки (до байта с индексом 319, если считать с 0). Байт с индексом 320 — левый пиксель второй строки, 63 999-й — правый нижний. т. е. мы двигаемся слева направо и сверху вниз.

Инструкция imul si,ax,320 умножает AX (т. е. X) на 320 (кол-во точек в строке, т. е. на ширину экрана), записывая результат в SI. Далее с помощью test cl,15 мы проверяем, кратно ли текущее значение радиуса CX значению 16 (в этом случае будет ZF = 0, как и младшие 4 бита). С помощью mov dx,arrow_color + (not radbg_color)*256 заносим в DX значение 0EE21h. Здесь младший байт — цвет стрелки радара (смотрим на палитру — сине‑фиолетовый, можно было бы указать 20h — синий, — то тогда стрелка была бы более тонкой). О старшем байте пока не беспокойтесь. Инструкция jnz @F осуществляет переход к следующей метке @@, если радиус не кратен 16 (напоминаю, что mov на флаги не влияет). В противном случае меняем цвет DL на circles_color = 2 (зелёный). Здесь важно, чтобы значение arrow_color (цвет стрелки) было больше, чем radbg_color = 11h (серый цвет фона радара), а circles_color — меньше (скоро поймёте почему). И наконец, с помощью простой инструкции mov [bx+si+(160+100*320)+video_shift],dl (где 160+100*320 — координата центра экрана, которой требуется смещение video_shift, так как у нас DS = 9FFFh, а не 0A000h) мы выводим на экран кусочек нашего радара (один пиксель) :). Как говорил Иван Васильевич из известного фильма: «И всего делов‑то!» Внимательный читатель заметит подвох: «У нас AX = X, BX = Y, а мы умножаем X на 320 и добавляем Y, почему всё наоборот?» Ну перепутали мы X и Y, что это меняет? Кроме направления вращения :). Хотите, чтобы было всё по науке — перенесите xchg bx,ax выше, после первой метки @@. Завершает отрисовку инструкция loop .next, повторяющая внешний цикл с CX (радиусом) от 64 до 1. И fadd st,st1, добавляющая к углу в ST0 значение ST1 (0.0294, чуть больше градуса, напомню). У ещё одного внимательного читателя (вы оба здесь?) может возникнуть другое душное замечание: «При увеличении радиуса вращение должно происходить против часовой стрелки, но вы говорите, что обмен X и Y меняет направление, однако стрелка радара всё‑таки вращается против часовой стрелки. Вы явно что‑то не договариваете!» Всё правильно, ведь у нас ось ординат (Y) направлена сверху вниз, а не снизу вверх, как по классике. Ещё вопросы? «Минуточку, а как же Fadeout...» — «Уведите его!» Вот и славненько!

Итак, алгоритм рисования радара:

  1. FPU стек: angle, delta.

  2. radius = 64.

  3. Вычисляем синус и косинус, сохраняя угол: cos(angle), sin(angle), angle, delta.

  4. Умножаем: X = cos(a) * radius; Y = sin(a) * radius.

  5. Если radius and 15 == 0 (можно читать как radius % 16 == 0), color = circles_color, иначе color = arrow_color.

  6. Выводим точку цветом color по координатам X, Y.

  7. if (--radius > 0) goto 2.

Всё просто, не так ли? :))

Внесите занавес!

Я обещал рассказать про «Fadeout». Пацан сказал — пацан сделал.

Странная на первый взгляд конструкция add [di],dh + sbb [di],dh производит эффект «затухания» стрелки до цвета фона, оставляя белый шлейф. Работает это следующим образом: если цвет больше, чем not DH (not 0EEh = 11h = radbg_color), он уменьшается на 1, иначе он не меняется. Если посмотреть на палитру цветов, то видно, что от 21h до 11h цвета меняются от синего резко к белому и далее плавно к тёмно‑серому (стрелка — шлейф — фон).

Объясняю медленно. Сначала add добавляет к цвету значение 0EEh, а sbb, внезапно, это значение вычитает, вычитая ещё и значение флага CF. Нас интересуют исходные значения 11h и больше него (например, 12h).

  • Ситуация № 1: 11h + 0EEh = 0FFh, переноса не было, CF = 0, далее: 0FFh – 0EEh – CF(0) = 11h. Я не обманул, значение не изменилось.

  • Ситуация № 2: 12h + 0EEh = 0, произошёл перенос, CF = 1, далее: 0 – 0EEh – CF(1) = 11h. Значение уменьшилось на 1.

Теперь понимаете, почему цвет arrow_color = 21h должен быть больше, чем radbg_color = 11h (подсказка: иначе стрелка не будет гаснуть), а circles_color = 2 — меньше (подсказка: иначе кольца тоже будут гаснуть)?

Снова magic! Хотите ещё фокусов? Их есть у меня!

Весь этот балаган (начиная со второго кадра) повторяется 65 536 раз, побайтово перелопачивая весь сегмент, адресуемый регистром DS, поскольку значение DI увеличивается до изнеможения тех пор, пока не станет равным 0 (jnz @B).

Кстати, если при воспроизведении интро у вас возникает ощущение, что кто‑то впустил сюда мух, знайте: мерцающие точки в рандомных местах экрана — это результат работы этого цикла, когда значение уже увеличилось и попало на дисплей, не успев уменьшиться. Вы спросите: «Это баг или фича?» Разумеется, это специально задуманный эффект, а как же иначе?! :))

Выход без цыганочки

Стоит отдельно обратить внимание на закомментированную концовку:

;       in      al,60h
;       dec     ax
;       jnz     .repeat                 
;       ret

Это стандартная конструкция выхода из интро (к сожалению, с ней размер интро будет = 65 байтам, а это фейл). Читаем байт из порта клавиатуры 60h. Полученное значение хранит скан‑код последней нажатой клавиши. Если это значение = 1, значит мы удерживаем клавишу Esc. Значение AH = 0 (у нас радиус значительно меньше 256, так что тут без вариантов), поэтому dec ax при нажатии на Esc установит флаг ZF = 0 (как и значение AX). Ну и дальше, думаю, всё понятно. Инструкция ret осуществляет выход, поскольку при старте программы в стеке всегда лежит 0 (это даже задокументировано, в отличие от значений большинства регистров), а что по адресу CS:0, помните? Правильно, PSP, первое слово которого содержит инструкцию int 20h — завершение программы. Если по чесноку, то в данном случае затея хороша только при запуске из-под DOSBox, так как если мы пишем в сегмент 9FFFh, мы затираем системные данные с информацией о структуре памяти — это может привести к неопределённому поведению (как говорят любители и профессионалы языка C).

Выше я писал, что интро немного заточено под DOSBox. Так вот, второй момент заточки заключается в том, что в программе нет задержки, и при запуске на реальном железе радар взлетит, унеся с собой монитор (зачем вот это?) На этот счёт есть ещё один трюк (нет, не привязать монитор верёвками). Можно сделать задержку с помощью однобайтовой инструкции hlt (разместив её перед jmp .repeat), которая будет ожидать любого аппаратного прерывания. Обычно «любое аппаратное прерывание» — это прерывание от таймера (int 8), которое возникает каждые 55 миллисекунд. Если только вы или ваш кот не решите плясать на клавиатуре (в этом случае, пока клавиатура не сломается, будет возникать ещё и int 9). Однако для данного интро такая задержка слишком велика, и мне пришлось просто ограничить скорость виртуального компьютера параметром DOSBox cycles = fixed 30000 :). Это, конечно, не совсем «true», но демосцена такое допускает (хотя у особо впечатлительных от таких слов лицо может приобрести странный оттенок). А так, заменив lds на сами знаете что и добавив hlt мы получим честные 64 байта.

P. S. Для справки: значение из порта 60h в старшем бите хранит флаг удержания клавиши: если бит сброшен, значит клавиша нажата и удерживается прямо сейчас, а если установлен — отпущена (т. е. если вы нажали и отпустили клавишу Enter — скан‑код 1Ch, — вы будете постоянно читать значение 9Ch, пока не ударите по какой-нибудь другой клавише). При этом скан‑код меняют не только значимые клавиши, но и модификаторы Ctrl, Alt, Shift, Win и даже Caps Lock и пр.

Какой-то кролик из старого кинескопа

Какой-то кролик из старого кинескопа

Спасибо

...что дочитали до конца! Надеюсь, мой юмор вас не утомил, а материал оказался интересным и, что не менее важно, полезным.

Продолжить общение о сайзкодинге можно в тематическом ламповом Telegram-чате или в международном Discord-сервере (а оттуда попасть на большую сцену).

Будьте здоровы, живите богато! ;)

Автор: Евгений Красников

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js