Всем салют!
Очень приятно, что многим зашёл недавний разбор моего интро. Я рад, что у меня, наконец, дошли до этого руки. Лёд тронулся, господа присяжные заседатели. На сегодня мы имеем:
64b nano game: snake64 (вы находитесь здесь)
На сей раз предлагаю вашему вниманию 64-байтовую игру «змейка», того же автора. Да, друзья, это самая компактная по размеру бинарного кода змейка из существующих (у меня даже есть 45-байтовая версия, но не такая симпатичная). И это именно та самая любимая многими вечно голодная змейка со старых мобильников, которая бегает по экрану и постоянно что‑то жуёт, увеличиваясь в длине.
Если вам не терпится поиграть прямо здесь и сейчас, только сегодня и только для вас я подготовил онлайн-версию, играйте на здоровье! Важно: для игры нужно использовать стрелки на цифровой клавиатуре.
Мрак! Жуть! Блеск!
Именно так сказала бы Эллочка из известного произведения Ильфа и Петрова, увидев код. А что вы думаете об этом? (снова fasm 1)
; [ snake64 ] 64-byte game
; MAIN VERSION (pure code without if's)
; (c) 2020 Jin X
; Keys: direction control - numpad arrows (4, 6, 8, 2); exit - numpad 5 / enter.
; Snake can run through left and right screen bounds but dies when trying to run beyond top or bottom of screen.
; IMPORTANT:
; You should run the game via command "DOSBox.exe snake64.com" (but not from DOSBox terminal) !!!
; If you want to run it from FreeDOS / MS-DOS then you should do it from BAT-file with lines:
; @pause
; @snake64.com
; ...and press "down" key on pause. The game requires value 0 ($80) or $50 ($D0) in port $60 on start!
;-- USER SETTINGS ------------------------------------------------------------------------------------------------------
body_color = $1F ; highest bit must be reset & parity (number of set bits) must be even after xoring with 7
apple_color = $D9 ; highest bit must be set
init_len = 5 ; initial snake length
;-- MAIN CODE ----------------------------------------------------------------------------------------------------------
org $100
; Initializing (assume: ax = 0, bx = 0, ch = 0, si+bp > $140 ($100+$91x), ss = cs)
int $10 ; 40x25 video mode
push $B800 - (65536-40*25*2)/16 ; snake will die when run beyond screen top or bottom
pop ds ; video memory
lahf ; ah = 2
mov dx,(19-40)*2+1 - 40*25*2 ; initial coordinate (should be odd)
int $10 ; hide cursor
mov word [bp+si],dx ; initial coordinate
mov cl,init_len - 2 ; initial snake length minus 2
; Main loop (cx = snake length, [bp+si] = snake coordinate array (from head to tail))
.inc:
inc cx ; increase snake length
inc cx
.move:
hlt ; delay
hlt ; delay
; Apple appearance
imul bx,45 ; random number generator
inc bx
xor byte [bx],apple_color ; draw new apple
; Keypress processings
pusha ; cx & si
in al,$60 ; read scan code
aaa ; [need af=0] left=1 right=3 up=8 down=0
cbw ; ah = 0
dec ax
dec ax ; left=-1 right=1 up=6 down=-2
jc @F ; left or right (checking cf after aaa)
sub al,2 ; up=4, down=-4
imul ax,-10 ; up=-40, down=40
@@: shl ax,1 ; left=-2 right=2 up=-80 down=-80
out $61,al ; sound
add ax,[bp+si]
; Movement and snake redraw
xchg di,ax ; new head coordinate
xor byte [di],body_color ; draw snake (and set flags for jcc below)
@@: xchg [bp+si],di ; swap current value with next
lodsw ; next value (si += 2)
loop @B
mov byte [di],dl ; clear tail (dl = 7)
popa
; Checks and jumps
js .inc ; snake ate an apple
jpe .move ; moved to free space
ret ; die / exit
Как вы видите, этот набор малопонятных рядовой домохозяйке символов компилируется в программу в формате COM под DOS. Действие разворачивается в текстовом режиме 40x25 цветных знакомест. Для его установки необходимо вызвать int 10h
с AX = 0 (о начальных значениях регистров читайте статью про radar, ссылка в начале статьи, в комментариях исходника интересующие нас значения также указаны).
Видеопамять отображается на сегмент $B800, но мы занесём в регистр DS значение $B800 – (65 536–40*25*2)/16 = $A87D (16-ричные числа в этой статье я буду предварять знаком $ в стиле языка Pascal, fasm его тоже поддерживает; ИМХО, это гораздо удобнее и читабельнее, чем 0x или 0+h). Почему такое странное число? Во-первых, так прикольнее. Во-вторых, каждый знак на экране кодируется двумя байтами: первый байт пары — символ, второй — цвет (младший полубайт — цвет текста, старший — цвет фона, включающий признак мерцания в старшем бите). Таким образом, весь экран помещается в 40*25*2 = 2000 байтах. Если мы вычтем это значение из 65 536, получим смещение буфера перед концом 64 КБ сегмента, т. е. от 63 536 до 65 535 (включительно) у нас ровно 2000 байтов. Разделив это число на 16, получим разницу сегментной части адреса (вспоминаем, что адреса в реальном режиме состоят из сегмента и смещения; линейный адрес, он же физический, получается по формуле: сегмент*16 + смещение). Итак, $A87D*16 + 63 536 = $B8000 — это линейный адрес начала видеопамяти. Т. е. теперь нам нужно писать по смещениям от 63 536 до 65 535 (вкл), иначе мы ничего не увидим. Но на кой это всё? Лёгкие пути — для слабаков, настоящий сайзкодер их не ищет (разгадка в конце статьи, придётся читать весь текст) :)
Однобайтовой инструкцией lahf
записываем в регистр AH значение 2 (потому что все основные арифметические флаги на старте сброшены). В регистр DX заносим магическое значение (19-40)*2+1 – 40*25*2 = $F807 — это смещение адреса начальной позиции змейки (оно чуть меньше, чем 63 536, змейка исходно находится немного за пределами экрана, но это не страшно). Значение нечётное, так как мы будем рисовать цветом фона, а не символами. Далее нам нужно спрятать курсор, чтобы он не раздражал своим миганием. Для этого вызываем int 10h
(функция AH = 2 устанавливает позицию курсора на видеостранице BH = 0 по координатам X, Y = DL, DH = 7, 248 ($F8), что далеко за пределами экрана).
Заносим это значение в память по адресу SS:BP+SI — здесь у нас будет массив координат (вернее, смещений) туловища нашего зверя (конкретно по этому адресу находятся координаты головы). Вероятно, вы заметили, что в инструкции mov word [bp+si],dx
нет префикса SS. Он здесь и не нужен, поскольку при адресации через регистр BP (даже вместе с SI или DI) по умолчанию используется сегмент SS, а не DS (всё-таки, DS у нас указывает на видеопамять; а вот SS = CS). Один регистр BP мы использовать не можем, так как он требует дополнительного байта для хранения смещения (есть у него такая особенность), а вот при использовании пары BP+SI использование смещения опционально. К тому же, SI нам тоже тут нужен (наберитесь терпения). Нам также важно, чтобы эта сумма была не меньше, чем $140, т. е. указывала за пределы нашего кода (чтобы не затереть его; по факту же эта сумма превышает даже $A00, потому что... начальные значения регистров описаны в статье про radar) :). Далее записываем в CX (вернее, нам достаточно в CL) исходную длину змейки, уменьшенную на 2: init_len – 2 = 3. На этом инициализация закончилась. Но не стоит переживать.
Спокойно, Михельсон!
Раз мы уменьшили змейку при инициализации на 2, значит нам теперь нужно её увеличить, потратив 2 байта inc cx
+ inc cx
. А после сделать задержку, потратив ещё 2 байта hlt
+ hlt
(это, конечно, расточительство, но деваться некуда). Напомню, что hlt
ждёт любого аппаратного прерывания (т. е. прерывания от таймера, в данном случае это около 110 мсек).
Чтобы змейка от голода не откинула копыта хвост, нужно подбросить ей немного яблок (ключевое слово — немного). Для этого запустим генератор псевдослучайных чисел: imul bx,45
+ inc bx
, который даст нам результат в регистре BX. Да, это тот самый линейный конгруэнтный метод Лемера. В качестве множителя сойдёт практически любое значение (даже отрицательное), кратное 4 и увеличенное на 1, в т. ч. 45. После этого выполним операцию xor byte [bx],apple_color
, которая нарисует на экране яблоко (красивое квадратное розовое яблоко: apple_color = $D9
; $D9 xor 7 = $DE, где 7 — серый цвет на чёрном фоне, которым изначально заполнен весь экран). Почему xor
, а не or
? Потому что если мы вдруг попадём в змейку, то она со злости перекрасит после себя это яблоко в красный цвет (body_color or apple_color xor body_color = $1F or $D9 xor $1F = $C0
). А почему $D9, а не $D0 или $50, ведь цвет символа и признак мерцания нас вообще не интересуют? Потому что наша случайная координата может оказаться чётным числом, и тогда мы попадём в байт, соответствующий не цвету, а символу. Изначально все символы на экране — пробелы, имеющие код 32 ($20). А значит $50 xor $20 = $70 — это буква «p», $D0 xor $20 = $F0 — это символ «≡» (или «Ё»), зачем они нам? На самом деле точки, которые вы видите на экране — это не баг ($D9 xor $20 = $F9 — символ точки по центру). Можете назвать это дождиком или снежинками. Его можно заменить на более мелкую точку (если использовать $DA) или убрать вовсе ($DF xor $20 = $FF — запасной пробел). Публикуя эту работу, я сомневался, стоит ли оставлять эти точки, но решил, что так красивее (к тому же, мне кажется, что змеи любят дождь... по крайней мере, дождевые черви точно).
Вы думаете, что яблоко будет рисоваться на каждом шаге змейки? Отнюдь! Давайте посчитаем, какова вероятность появления на экране фрукта. Наш ГПСЧ выдаёт одно из 65 536 значений, но на видимую область экрана может попасть только 2000, причём половина из этих значений приходится на рисование дождика, а не яблок. Итого имеем 1 / 65,536 ≈ 1.5 %. Вполне нормально, ничего для ограничения кормёжки больше делать не нужно.
Крепитесь! Заграница нам поможет!
Пора реагировать на присутствие игрока. На всякий случай сохраним регистры CX и SI. Самый экономный способ сделать это — использовать инструкцию pusha
. Напомню, что в CX у нас хранится длина нашего червя, а по адресу SS:BP+SI — таблица координат (смещений в сегменте DS) его туловища. Читаем скан-код клавиши (in al,$60
) и выполняем магическую однобайтовую инструкцию aaa
(что она делает — RTFM). Эта инструкция действительно магическая, так же как и aas, daa, das, которые производят интересные трансформации с регистром AL (и иногда с AH). Эти инструкции иногда спасают, когда нужно одним байтом причесать значение регистра AL (или добавить разнообразия в цвета, или что-нибудь ещё). Можно не вникать в то, как они работают, а просто перебирать их все подряд и смотреть на результат через отладчик, надеясь на чудо. Бывает, что это срабатывает :). Так вот, давайте посмотрим что происходит (если вы не против). Нас интересуют клавиши влево, вправо, вверх и вниз (лучше на цифровой клавиатуре, так как обычные стрелки предварительно посылают ещё и код $E0, а он будет вносить только лишнюю суету в движения нашей бедной змейки). Заодно взглянем на Esc, Enter и центральную клавишу с цифрой 5.
|
|
Значение AX и флага CF после |
Значение AX после вычитания 2; повторного вычитания 2; умножения на -10; удвоения |
||||
нажатий не было |
0 |
AX=0 |
CF=0 |
AX=-2 |
-4 |
40 |
80 |
Влево (4) ← |
75 ($4B) |
AX=1 |
CF=1 |
AX=-1 |
-2 |
||
Вправо (6) → |
77 ($4D) |
AX=3 |
CF=1 |
AX=1 |
2 |
||
Вверх (8) ↑ |
72 ($48) |
AX=8 |
CF=0 |
AX=6 |
4 |
-40 |
-80 |
Вниз (2) ↓ |
80 ($50) |
AX=0 |
CF=0 |
AX=-2 |
-4 |
40 |
80 |
Центр (5) |
76 ($4C) |
AX=2 |
CF=1 |
AX=0 |
0 |
||
Esc |
1 |
AX=1 |
CF=0 |
AX=-1 |
-3 |
30 |
60 |
Enter |
28 ($1C) |
AX=2 |
CF=1 |
AX=0 |
0 |
Ситуация в первой строке крайне маловероятна на реальной машине, так как перед запуском мы по любому какую‑нибудь клавишу да нажмём (хотя в теории, конечно, мы можем запустить игру мышкой). Так что я рекомендую (как написано в комментах) создать BAT‑файл, начинающийся командой pause
, и при запуске нажать клавишу «вниз», поскольку Enter вызовет завершение игры (см. ниже). А вот в DOSBox, если мы ничего не нажимали, мы будем получать 0, и это будет работать аналогично клавише «вниз». При отпускании клавиши порт 60h выдаёт то же значение, но с установленным старшим битом, однако инструкция aaa
всё равно сбросит старшие 4 бита, так что всё будет работать так же, как и при удержании клавиши.
В данном случае для aaa
важно, чтобы флаг AF был предварительно сброшен (а он будет сброшен после xor
), иначе мы получим не то, что хотим. Далее очищаем регистр AH (однобайтовой инструкцией cbw
). Уменьшаем AX на 2. И если CF сброшен, вычитаем из AL (AX) ещё 2 и умножаем AX на -10. После этого (уже при любом CF) удваиваем AX :)))
Произошло чудо: всего за 13 байт кода регистр AX принял значение, равное смещению позиции на экране (-2 / 2 — влево / вправо на 1 символ, -80 / 80 — вверх / вниз на 1 символ). Центральная клавиша и Enter оставят змейку на месте (в этом случае произойдёт крах) — это клавиши выхода из игры. А вот с Esc нам не повезло: змейка будет вести себя неадекватно и извиваться, как червь, так что для выхода эта клавиша не годится.
Остался только один вопрос: при чём тут заграница и как она нам поможет? Дело в том, что этот трюк я подсмотрел в Hugi Compo (The Nibbles) у участника-победителя с именем Altair из группы ODDS (спасибо тебе, добрый труженик демосцены... хотя вряд ли ты это прочтёшь). В демосцене мы иногда подглядываем трюки друг у друга и используем их :). В этом нет ничего зазорного (к примеру, есть знаменитый «Řrřola trick» преобразования индекса точки на экране в координаты X и Y, которым многие пользуются).
Далее при нажатии на клавишу выводится щелчок (out $61,al
), и к значению регистра AX добавляется координата головы (add ax,[bp+si]
).
И тут змейку понесло...
Обновлённую координату головы змейки мы переносим в регистр DI и отрисовываем эту самую голову на экране (xor byte [di],body_color
). При этом у нас происходит следующая ситуация с флагами:
Если мы попали на пустое поле: [DI] = 7 xor $1F = $18: SF = 0, PF = 1;
Если мы попали в яблочко: [DI] = $DE xor $1F = $C1: SF = 1, PF = 0 (но в этом случае последний нам неважен). Операция
xor
, кстати, при съедании яблока создаёт эффект перемещения яблока по телу;Если мы решили заняться самоедством: [DI] = $18 xor $1F = 7: SF = 0, PF = 0.
Запомните это, чуть позже оно нам понадобится.
Начинаем цикл: меняем местами текущую координату-смещение и значение по адресу SS:BP+SI. На первой итерации мы записываем новую координату головы, получая прежнее значение. После этого увеличиваем SI на 2 (lodsw
, AX нам не нужен — это просто побочный эффект). И повторяем процесс CX раз (соответственно длине змейки). Таким образом, массив координат смещается на 1 элемент к хвосту. Красиво? То-то! :))
Осталось удалить хвост (mov [di],dl
; вы же помните, что в DL у нас лежит 7? Если нет, крутите на начало статьи). И восстановить регистры (popa
; нам нужно вернуть на место CX и SI).
Браво, гусар!
У нас осталось всего 3 инструкции и 5 байт кода:
js .inc
— прыгаем, если мы съели яблоко (SF = 1);jp .move
— прыгаем, если мы не съели яблоко, но ещё живы (PF = 1);ret
— увы и ах!
На этом можно было бы закончить, но если вы ещё не впали в транс от написанного, у вас может возникнуть вопрос: «А как же стулья стены? Неужели в этой игре нет стен?»... Без паники! Они есть. Правда, не везде. Змейка может проходить сквозь стены слева и справа (перемещаясь на строку выше или ниже — вселенная искривлена, знаете ли). А вот с перемещением вверх и вниз такой фокус не удастся. Почему? Как говорят некоторые менеджеры по продажам: «Давайте подумаем вместе». Мы не зря записали в DS значение $A87D вместо $B800 (и я обещал раскрыть тайну смысла). После инициализации видеорежима видеопамять отображается на сегмент $A000 (64 КБ), $B000 (32 КБ) или $B800 (32 КБ, наш случай) в зависимости от видеорежима. Другие сегменты из этого списка всё время обнулены (в DOSBox не всё время, но при старте — да). Таким образом, до $B800 у нас находятся нули (т. е. в сегменте $A87D по смещениям от 0 до 63 535 (вкл) нули, поскольку видимая область, напомню, лежит в диапазоне смещений от 63 536 до 65 535 (вкл), а ниже находятся области сегментов видеопамяти $A000 и $B000). При пересечении нижней границы у нас происходит перенос, и смещение становится меньше, чем 63 536 (например, было 65 495, добавилось 80, стало 39). При пересечении верхней границы — тоже. В обоих случаях, как вы понимаете, мы попадаем в область нулей. Если бы мы инициализировали DS = $B800, видимая область была бы по смещениям от 0 до 1999 (вкл), но и выше тоже не было бы нулей (были бы серые пробелы — слова $2007), так как сегмент видеопамяти имеет размер 32 КБ и состоит из нескольких страниц, которые можно переключать. А теперь давайте посмотрим, что будет с цветом, если наша змейка шагнёт в эту бездну: 0 xor $1F = $1F, флаг SF = 0, PF = 0 (а это верная гибель). Что и требовалось доказать.
Спасибо, что были со мной. До новых встреч! ;)
Если вам интересно взглянуть на 45-байтовую версию змейки, нажмите сюда.
; [ snake45 ] 45-byte game
; EXTREMELY MINIMAL CODE SIZE VERSION / ADAPTED FOR DOSBOX 0.74-3 (with corrections marked by ***)
; (c) 2020 Jin X
; Keys: direction control - numpad arrows (4, 6, 8, 2); exit - numpad 5 / enter.
; Snake can run beyond screen bounds.
; Screen must be cleared before execution of this version!
; IMPORTANT:
; You should run the game via command "DOSBox.exe snake45x.bat" (but not from DOSBox terminal) !!!
; The game requires value 0 ($80) or $50 ($D0) in port $60 on start!
;-- MAIN CODE ----------------------------------------------------------------------------------------------------------
org $100
; Initializing (assume: ax = bx = 0, cx = $FF, si+bp > $140 ($100+$91x), di = sp = -2, ss = cs)
push $B800 ;***
pop ds ; video memory
; xchg cx,ax ;*** initial snake length minus 1 = 0 (this line can be commented, then initial length will be 256 :D)
; Main loop (cx = snake length, [bp+si] = snake coordinate array (from head to tail))
.inc:
inc cx ; increase snake length
.move:
; hlt ;*** delay
; Apple appearance
xadd bx,di ;*** random number generator
xor byte [bx],dh ;*** draw new apple
@@:
; Keypress processings
pusha ; cx, si, di***
in al,$60 ; read scan code
aaa ; [need af=0] left=1 right=3 up=8 down=0
cbw ; ah = 0
dec ax
dec ax ; left=-1 right=1 up=6 down=-2
jc @F ; left or right (checking cf after aaa)
sub al,2 ; up=4, down=-4
imul ax,-20 ;*** up=-80, down=80
@@: shl ax,1 ; left=-2 right=2 up=-160 down=-160***
add ax,[bp+si]
; Movement and snake redraw
xchg di,ax ; new head coordinate
xor byte [di],dl ;*** draw snake (and set flags for jcc below)
@@: xchg [bp+si],di ; swap current value with next
lodsw ; next value (si += 2)
loop @B
mov byte [di],$20 ;*** clear tail
popa
; Checks and jumps
jpe .move ;*** moved to free space
js .inc ;*** snake ate an apple
ret ; die / exit
Для запуска через DOSBox рекомендую создать следующий BAT-файл (на реальном железе запускать эту версию смысла нет):
@echo off
core auto
cputype auto
cycles 10
cls
snake45x.com
exit
Комментарии (9)
forthuse
13.09.2023 08:11для вас я подготовил онлайн-версию, играйте на здоровье!
Поиграть можно в змейку и не только Online в KolibriOS
А, загрузив образ CD диска (30Мб) можно поиграть и в Quake, Doom и др. игры и не только.
(задержка, правда существует загрузки KolbriOS в браузере в отличии от "секундного" старта рабочего стола на реальном компьютерном железе)P.S. Змейка в KolibriOS как и ядро самой системы и многие демо программы на дискетке 1.44 Мб написаны на ассемблере Fasm.
!(видео тоже есть возможность просмотреть :)
anshev0
13.09.2023 08:11| Важно: для игры нужно использовать стрелки на цифровой клавиатуре.
Эх, на ноутбуке не поиграешь. Вспомнил детство так как писать в видео память и приходилось в первых собственных играх на ассемблере, поэтому код довольно понятен.
IgorPie
13.09.2023 08:11Не очень понятно как ведет себя движение хвоста, если хвост достаточно длинный, а змейка идет зигзагами. В "обычной" Версии структура мускул хранится в однонаправленном списке
jin_x Автор
13.09.2023 08:11Адрес SS:BP+SI указывает на голову змеи. Т.е. по этому адресу лежит "координата" головы (если точнее, то смещение адреса в сегменте DS, соответствующего цвету символа; для простоты я буду называть это координатой). После чтения скан-кода клавиши и преобразования его в смещение координаты (-2 / 2 / -80 / 80) мы добавляем к этому значению координату головы, получая т.о. новую координату. Далее эта координата записывается в SS:BP+SI, сдвигая всё остальное далее по массиву.
Например, длина (CX) = 5. В массиве (по адресу SS:BP+SI) были такие слова: [11] [13] [15] [17] [19] (на самом деле прямо таких значений не будет, но для простоты пусть будет так, можете мысленно прибавить к этим числам 64000). Мы нажали вниз (80), получили новую координату: 80+11=91, новый массив будет таким: [91] [11] [13] [15] [17].
При этом мы рисуем только голову (91) и затираем хвост (19). При съедании яблока после вышеуказанной процедуры длина увеличивается на 2, и на следующем кадре будет так: [171] [91] [11] [13] [15] [17] [мусор]. В этот раз мы затираем (один раз) "мусорную" координату. В теории, это может быть координата на экране, т.е. мы можем затереть какое-то яблоко, часть себя или даже нарисовать на экране более жирную точку, если координата окажется чётной (символ с кодом 7, т.к. мы затираем хвост цветом 7). Если присмотреться к скриншоту, то можно увидеть, что внизу есть более жирная точка. Этот артефакт — как раз результат такого "удаления" несуществующего хвоста с мусорной координатой, которая оказалась чётной и попала в экран.
Этот глюк можно убрать, добавив инструкцию
mov [bp+si],cx
послеloop
, но это +2 байта (хотя можно всё равно уложиться в 64 байта, убрав звуковой щелчокout $61,al
). Но тут всё равно нет 100% гарантии, что первые 5 (начальная длина змейки) координат не попадут в экран (а эти координаты исходно мусорные). Нужно добавлять ещё несколько байт в начало кода для их очистки. Но и в этом случае можно уложиться в 64 байта, если прибавлять длину змейки на 1, т.к. мусорной координаты при увеличении длины не будет, иmov [bp+si],cx
здесь уже будет не нужен + кол-воinc cx
уменьшится на 1 шт + можно до кучи убрать удаление курсора.P. S. Обычно сдвиг массива "вперёд" делается с конца назад (чтобы не затереть самого себя), но используя
xchg
можно делать это, двигаясь с начала вперёд (как здесь).
dlinyj
13.09.2023 08:11+1Прикольно, можно сделать карточку сразу с игрой змейка. Я имел в виду такое.
Автору респект за крутую статью, всегда круто прочитать об ассемблере!
jin_x Автор
Добавлена 45-байтовая версия змейки (в спойлере в конце статьи) :)