Введение

В конце 80-х я кучу времени просидел, скрючившись за своим ZX Spectrum, вводя программы на Sinclair BASIC и скопированные из журналов рудиментарные ассемблерные процедуры в попытках воссоздать интро крэков с Amiga, которые видел дома у друга. Разумеется, тогда я так и не смог добраться до этой цели, но теперь, спустя много десятков лет, мне наконец‑то это удалось! Я стал гордым автором написанного вручную ретроинтро, работающего на стандартном Spectrum с 128 КБ. В нём есть все отличительные признаки демо конца 80-х: текст с плавным скроллингом (в крутых самодельных шрифтах!), трёхканальный саундтрек AY, скачущие индикаторы громкости и обязательное звёздное небо с параллаксом. Код занимает 10 287 байт, а полный образ плёнки — 19 552 байта; учитывая, что я работал над демо с 13 декабря прошлого года, средняя скорость написания кода составила приблизительно 455 байт в неделю!

В этом посте я расскажу, как добился этого, поделюсь изученными мной трюками и перечислю всё, чему мне пришлось научиться (и вспомнить) о Speccy и ассемблере Z80. Это один из моих самых любимых хобби‑проектов, но в то же время из‑за него мне зачастую хотелось пнуть монитор. Всем этим я поделюсь в конце статьи, а если вы просто хотите посмотреть на готовый результат, то скачайте образ кассетной плёнки .tap, который можно загрузить в любой эмулятор. Также можно посмотреть запись на YouTube, перейти по ссылке на pouet.net и запустить демо через браузер, зайдя на мой TNFS‑портал сайта и выбрав пункт 4 Recent uploads.

Пожалуйста, перематывайте кассету перед возвратом

Но пока давайте ненадолго вернёмся в 80-е. Я начинал с 8-битного компьютера, загружающегося с кассет, поэтому впервые увидев Amiga, я как будто встретился с инопланетной технологией. Как и все видевшие тогда этот компьютер, я был поражён потрясающей графикой и звуком, не говоря уже о библиотеке революционных игр. Но ещё сильнее меня очаровали загадочные (и невероятно крутые) «интро», отображаемые в начале спираченных игр.

Их создавали пиратские группы, которые взламывали защиту от копирования и распространяли игры на дискетах; интро представляли собой крошечные шедевры искусства — музыка, графика и крутые эффекты демонстрировали мощь группы. На YouTube есть отличный архив таких интро (или cracktro).

Посмотрев несколько видео, можно выделить общие для почти cracktro «дизайнерские» черты: крутой провокативный логотип в верхней части экрана (дополнительные очки он получал за капающую кровь и «металлический» стиль), какой‑нибудь хвастливый текст поверх эффекта (обычно звёздного неба с параллаксом или вращающегося 3D‑объекта) и последующий текст со скроллингом, где упоминались никнеймы взломщиков и реклама нелегальных систем BBS. Разумеется, для любого ребёнка 80-х/начала 90-х круче этого не было ничего.

I thought this one was the absolute bomb when I first saw it. Sine-scroller, pirate BBS adverts, cool logo with a sword - and of course, a starfield. There’s something for everyone here.
Я был потрясён, когда впервые увидел это интро: скроллинг по синусоиде, реклама пиратских BBS, крутой логотип с мечом и, разумеется, звёздное небо на фоне.
The music from this is burned into my brain. I think pretty much everyone of my classmates knew this intro given the popularity of Lemmings in the early 90s. Skid Row as a group are still active to this day and this is probably the quintessential Amiga cracktro of the period.
Музыку из этого интро я запомнил навсегда. Учитывая популярность Lemmings в начале 90-х, думаю, его знали почти все мои одноклассники. Группа Skid Row активна и по сей день, а это cracktro стало хрестоматийным примером интро для Amiga того периода.
Endless Piracy. Edgy. Another cool logo, sine-wave scroller, starfield and boasting text. The classics never go out of style.
«Безграничное пиратство»: провокативное название, ещё один крутой логотип с скроллингом текста по синусоиде, звёздным небом и хвалебным текстом. Классика никогда не выходит из моды.
Another great combination of all the essential ingredients. Cool logo, even cooler text font, scrolling starfield and a scroll-text running along the bottom. Vintage stuff.
Ещё одно великолепное сочетание всех необходимых компонентов: крутой логотип, ещё более крутой шрифт, скроллинг звёздного неба и текста в нижней части экрана. Винтаж!
Logo. Text. Scroller. But no starfield - otherwise a solid effort from Anthrox here :)
Логотип. Текст. Скроллинг. Но без звёздного неба. Тем не менее, получилось качественное интро Anthrox.

Из этих интро выросла демосцена. Взлом постепенно отошёл на второй план, а кодеры, художники и музыканты начали создавать творения, демонстрирующие их возможности, конкурируя с другими группами и выжимая из машин максимум. Сцена жива и по сей день, она реализует потрясающие примеры гениальных работ и «кода как вида искусства»: группы выпускают захватывающие работы; стоит помнить, что все они работают на 7-мегагерцовом компьютере, оснащённом приводом гибких дисков, 1 МБ памяти и звуковым чипом 1985 года выпуска.

Несмотря на необходимый для этого объём навыков и труда, меня всё равно больше привлекали первые одноэкранные cracktro со звёздным небом и скроллингом текста. Они кажутся мне чем‑то мистическим, окном в мир сцены и культуры, за которой я мог наблюдать только снаружи. Именно поэтому я часами безуспешно пытался воссоздать их на Sinclair BASIC и мечтал, что когда‑нибудь у меня появится своя Amiga.

Возврат назад

В конечном итоге сменил несколько систем Amiga, поучаствовал в нескольких группах сцены и занимался разным, пока не наступили экзамены и не завершился мой подростковый период. В то время я осваивал код Z80 и постепенно освоил ассемблер 68 k, изучая его и на Amiga и в университете, где мне доводилось программировать светофоры и контроллеры лифтов. Впрочем, на повседневной работе мне это не пригождалось, поэтому знания постепенно вымывались из памяти.

TNFS-сайт
TNFS‑сайт

В свободное время я продолжал работать над разными проектами, любимым из которых был мой TNFS‑сайт — своего рода олдскульная система BBS или интерактивный «веб‑сайт» для Spectrum, выросший из забавного эксперимента по раскрытию потенциала Sinclair BASIC. В нём используется современная сетевая карта, подключенная к Spectrum; через неё я могу общаться с ещё одним поклонником протокола TNFS под ником Desertkun. Сейчас он разрабатывает замечательную новую сетевую WiFi‑карт для Speccy и любезно отправил мне несколько прототипов для тестирования.

Меня сильно вдохновило это оборудование и низкоуровневое программирование; во время одного из наших видеосозвонов, в котором мы сравнивали наши системы для разработки, Desertkun спросил, почему бы мне самому не писать ассемблерный код Z80. Это был обычный проходной вопрос, но для меня он стал вызовом! Разумеется, я не справился бы с переписыванием всего сайта на ассемблере, но решил, что могу написать автономное интро в стиле первых BBStro, чтобы рекламировать сайт и наконец‑то стать одним из тех крутых парней, которые участвуют в сцене.

Казалось бы, что здесь сложного?..

Среда разработки

Моя среда разработки была довольно простой, это тот же стек, который я применял для разработки моего TNFS‑сайта. Я просто воспользовался системой сборки на основе Makefile, пропускавшей мой код через ассемблер Pasmo, мой форк zmakebas для сборки файлов BASIC и эмулятор FUSE для тестирования. Для финального тестирования на реальном оборудовании я загружал файлы .tap на локальный TNFS‑сервер, где мог запускать их на моём ZX Spectrum +3.

ASM Editor screenshot
ASM Editor

Здесь стоит упомянуть и другие инструменты, которые в конечном итоге оказали большую пользу: один из них — потрясающий онлайн‑редактор asm‑editor, найденный мной во время поиска информации о командах Z80. На этом сайте не только есть потрясающая справка по всем командам Z80, но и редактор, в котором можно исполнять примеры кода и наблюдать за изменениями состояния памяти и/или различных регистров. Возьмём для примера команду djnz — можно собрать и пошагово выполнить пример конструкции цикла по одной команде за раз. Или попробовать мой пример с командой add со скриншота, чтобы посмотреть, как работает флаг переноса, когда бит сдвигается и выходит за пределы из регистра. Хоть в нём используется немного отличающийся от Pasmo синтаксис (из‑за чего просто перекинуть мой код не удастся), он сильно помог мне в пошаговом выполнении кода.

Best debugging agents ever.
Лучшие агенты отладки в мире

И, наконец, самыми полезными инструментами отладки в этом проекте были блокнот и ручка, а также две моих собаки (Бискит и Пеббл). Подробнее я напишу об этом позже; это был крайне необычный для меня проект, ведь десятки лет я работал с более высокоуровневыми языками и конструкциями. Основную часть своего кода для z80 я мог по‑настоящему понять, только записывая вручную байты и битовые маски, ведя таблицу флагов регистров и рисуя блоки экранной памяти. А вечерние прогулки с собаками стали идеальными упражнениями с резиновым утёнком, хоть я и не уверен, что они поняли мои пространные бормотания о паре регистров hl. Как бы то ни было, крайне рекомендую вам отладку при помощи собак!

Отправная точка

TNFS site screenshot

Итак, приступим. Для проверки своей среды сборки я воссоздал великолепный пример Hello, world! с futurice.com. Первым делом я хотел научиться загружать свой логотип и фоновое изображение, которое будет использоваться как холст для остального запланированного интро. Я пока не знал толком, что собираюсь делать, мне просто хотелось вывести наверху логотип, а внизу текст со скроллингом. Так как шапка моего сайта TNFS уже походила на результат работы демосцены, я немного отредактировал её, добавив две синие строки, между которыми добавлял новый контент.

Структура памяти с breakintoprogram.co.uk
Структура памяти с breakintoprogram.co.uk

Файл SCREEN$ Spectrum (обычно отображаемый в виде экранов загрузки) — это просто дамп экранной памяти с байта 16 384 длиной 6 912 байт. Ниже я объясню, почему его странная структура так важна, а пока скажу, что для его полного отображения нужно загрузить свой файл в эту область памяти. Потратив несколько часов на изучение внутренностей Speccy и повторение действий из различных руководств, я получил очень простую отправную точку для своего демо, послужившую основой для всего последующего.

org $8000

screen  EQU $4000 ; адрес первого байта экранной памяти, 16384 в dec
scrlen  EQU 6912  ; общая длина экранной памяти, включая атрибуты
ula     EQU $FE   ; порт ввода-вывода для управления рамкой через ULA
black   EQU 0     ; номер цвета Spectrum, точно совпадающий с тем, который используется в BASIC

start:
  ld a,black      ; При помощи ULA задаём цвет рамки
  out (ula),a

  ; Копируем изображение заголовка логотипа в экранную память
  ld hl,header    ; HL = расположение моего файла экрана в этом коде
  ld de,screen    ; DE = начало экранной памяти
  ld bc,scrlen    ; BC = 6912 байт для копирования
  ldir            ; копирование блока, см. https://jnz.dk/z80/ldir.html

; активный цикл: остальная часть интро будет находиться здесь                    
hold:
  halt            ; ждём следующего прерывания (1/50 секунды),
  jr hold         ; затем выполняем переход назад и снова ждём бесконечно

header:
  incbin "assets/header.scr"  ; мой заголовок/фон логотипа

end start

Пока смотреть особо не на что, но эта отправная точка познакомила меня такими важными концепциями, как взаимодействие с оборудованием Spectrum (сначала с чипом ULA, а затем со звуковым чипом AY) при помощи команды out, подтягивание двоичных файлов при помощи директивы INCBIN Pasmo, использование команды копирования блока ldir Z80 и применение пар регистров для 16-битной адресации и простого ветвления к меткам.

Hello World

Следующим этапом стали вывод на экран и изучение некоторых процедур работы со встроенным ROM Speccy, которые немного походят на системные вызовы современных операционных систем. В Интернете есть куча дизассемблированного кода и руководств по Spectrum ROM; одно из лучших с комментариями и ссылками можно найти на https://skoolkid.github.io/rom/. Использовав его и различные туториалы по Hello, world! в качестве эталона, я смог разобраться, как можно создать подобное:

  at      EQU 22 ; управляющий символ "at", используемый для позиционирования курсора
  row     EQU 11 ; координаты вывода сообщения
  col     EQU 9

  prstring EQU $203C ; местонахождение процедуры pr_string в ROM

  ld de,msg        ; DE = адрес первого символа
  ld bc,msglen     ; BC = количество печатаемых символов
  call prstring    ; печать

; активный цикл остаётся таким же...

msg:
  db at,row,col            ; эквивалент "PRINT AT 11,9" из BASIC
  db "Hello, world!"
msglen  EQU $-msg          ; суммарно 16 байт, быстрая команда pasmo, $=счётчик команд

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

Кроме того, я создал версию кода в asm‑editor. Нажав на ссылку, вы увидите версию кода, загруженную в веб‑редактор. Нажмите на Build, а затем пошагово выполните код при помощи кнопки Step; хоть никакой текст и не отобразится (я пропустил вызов вектора restart Spectrum ROM, который это делает), вы увидите различные состояния регистров и, например, обратный отсчёт в регистре BC при обработке каждого байта строки.

Добавляем стиля

Проблема в том, что встроенный в Speccy шрифт по уровню крутости не очень подходит для демосцены. Я бы хотел использовать какие‑нибудь стилизованные шрифты, и сделать это оказалось крайне просто: обычным поисковым запросом я нашёл потрясающую коллекцию шрифтов Джима Блайми, в которой есть готовый к использованию набор ассемблерных файлов и инструкции, позволившие мне добавить в свой код следующее:

; CHARS — это адрес, который считывает процедура печати ROM для нахождения шрифта. 
; Для выбора собственного шрифта достаточно перезаписать это
chars   EQU $5C36 ; из документации Джима - POKE 23607=5C36 в hex

  ld hl,myfont-256 ; HL = адрес шрифта - 256 согласно документации
  ld (chars),hl    ; после этого ROM будет выполнять отрисовку с его использованием!

; остальная часть кода осталась такой же

myfont:
  include "textfont.asm" ; шрифт "Cartoon" с jimblimey.com

Вот и всё! Теперь надпись выглядит чуть элитарнее, то есть, простите, 31337. Держитесь крепче, ребята, мы покидаем территорию ламеров… На этом этапе, потратив примерно месяц на свои упражнения, я уже достаточно неплохо освоил популярные идиомы и начал думать, что из этого проекта что‑то получится. Проблема возникла, когда я перешёл к попыткам реализации самих эффектов демо: стало абсолютно очевидно, сколь многого я ещё не знаю о том, как всё это работает. Не говоря уже о том, что на этом уровне приходится очень тесно работать с особенностями физического оборудования, в том числе с решениями и компромиссами, на которые приходилось идти проектировщикам оборудования при работе с крайне ограниченными характеристиками железа начала 1980-х.

Безумная структура экрана

Первая такая проблема и причина, наверно, 90% моих мучений — это крайне странная структура экрана Speccy. Вместо стандартного дисплея с битовой картой, который можно встретить на более современном оборудовании, он разделён на три части, каждая со своим набором перемежающихся строк (то есть если прибавлять по единице к экранному адресу, то в определённый момент мы перейдём не к следующей строке, а на 8 строк ниже, и так далее, пока не достигнем низа части, а затем вернёмся назад к строке под той, с которой начинали!), за которым идёт блок байтов «атрибутов», определяющий цвета каждого блока 8×8. Именно из‑за блоков атрибутов каждая ячейка символов 8×8 может иметь только два цвета (атрибуты PAPER и INK), из‑за чего возникал аляповатый конфликт цветов игр Spectrum и уникальный стиль, при котором художники пытались максимально придерживаться блоков 8×8. Но самой мозголомной оказалась структура работы с битовой картой.

Это можно заметить на примере чтения экрана загрузки с плёнки. Каждая часть по очереди выстраивается из чередующихся строк, а затем отображаются цвета и другие атрибуты (например, мерцание). Подробности этого можно изучить на потрясающем сайте Break Into Program. В защиту разработчиков машины стоит сказать, что они работали с крайне ограниченным оборудованием и я не думаю, что Speccy вообще когда‑то проектировался как игровая машина. Когда начинаешь жонглировать байтами, то понимаешь, что такое решение оптимизировано под отображение текста, выстроенного в границах блоков 8×8. Например, если при использовании такого перепутанного порядка битов экранный адрес находится в паре регистров hl, то можно перейти к следующей строке пикселей нужного адреса, просто выполнив инкремент «старшего» байта. Вот другие дешёвые перемещения, которые возможны благодаря простому битовому сдвигу:

Перемещение

Команда

Вправо на 8 пикселей

inc l

Влево на 8 пикселей

dec l

Вниз на 1 строку пикселей

inc h

Вверх на 1 строку пикселей

dec h

Вниз на 1 строку символов

прибавить 32 к l

Это был разумный выбор в случае машины, которая чаще всего должна была отображать тексты программ на BASIC; странности возникают только тогда, когда начинаешь писать демо или спрайтовые процедуры. Также из‑за этого достаточно скоро возникает необходимость в процедуре для преобразования адресов X,Y в соответствующие адреса памяти. На сайте Break Into Program есть полезная процедура, а если вы дизассемблируете практически любую игру или демо для Spectrum, то где‑нибудь найдёте ту или иную версию этой процедуры. И да, позже это станет для нас ужасно важно…

Скроллинг

Да, это не самая интересная демонстрация, но зато мне впервые удалось выполнить перемещение на экране Spectrum при помощи ассемблера!

Итак, у меня есть потрясный логотип и текст Hello World, но интро из них выглядит не особо захватывающе. В качестве первого эффекта я хотел добавить скроллинг текста: без него это будет и не совсем настоящее интро. У Amiga есть всевозможные аппаратные спрайты, blitter‑операции и специализированные чипы, но у Spectrum ничего такого нет. Мне придётся разбираться, как делать всё это самостоятельно: непосредственно общаясь с железом и вручную сдвигая биты в экранной памяти; при этом всю работу должен выполнять CPU.

И на этом этапе у меня возникло понимание важности странной структуры экрана и ячеек символов Speccy. Оказывается, если оставаться в пределах одной строки символов, можно пользоваться удобным трюком — командой RL, которая выполняет «вращение» байта влево. Она вставляет значение из флага переноса в крайний правый бит, а то, что пропадает из байта при сдвиге, снова оказывается в бите переноса. Благодаря этому, объединив последовательность команд, можно перемещать набор пикселей по экрану — в момент, когда они «выпадают» из одной ячейки символов, они оказываются во флаге переноса, после чего следующая команда rl вставляет их в начало следующей ячейки символов слева. Если повторить это 32 раза, то мы переместим пиксель (или набор пикселей) по строке экрана:

linestart EQU $4880         ; крайний левый байт строки в 12 строках вниз
lineend   EQU linestart+31  ; столбец крайнего правого байта ($489F) 
linelen   EQU 32            ; одна полная строка составляет 32 байта (ячейки символов)
dot       EQU %00000001     ; "фигура"-точка: просто один пиксель, светящийся с правого края

newdot:
  ld a,dot                 ; загружаем фигуру-точку (только крайний правый светящийся пиксель) в a
  ld (lineend),a           ; сохраняем её в крайний правый байт строки; пиксель теперь зажёгся

frame:
  halt                     ; ждём следующего кадра, иначе текст пронесётся слишком быстро
  ld hl,lineend            ; hl = последний (крайний правый) байт строки
  ld b,linelen             ; счётчик цикла, обрабатываем 32 байта
  or a                     ; хитрый приём для сброса флага переноса: операция с самим регистром
                           ; не изменяет его содержимое, но сбрасывает несколько флагов, 
                           ; в том числе флаг переноса
                           ; см. https://jnz.dk/z80/or_r.html
shift:
  rl (hl)                  ; вращение этого байта влево (00000001 -> 00000010)
  dec hl                   ; указываем hl на следующий байт слева
  djnz shift               ; конструкция цикла: b=b-1, а затем переходим назад к метке shift, пока b не равно 0
  jr c,newdot              ; Теперь мы сдвинули всю строку, и если флаг переноса
                           ; установлен, значит, точка только что покинула экран,
                           ; поэтому мы переходим обратно к началу
  jr frame                 ; в противном случае, она всё ещё где-то на экране,
                           ; а значит, продолжаем выполнять сдвиг!

Круть! Почти настоящий скроллинг текста!

Циклический возврат этого кода в другом цикле, который каждый раз выполняет инкремент h (напомню, что это позволяет нам сдвинуться вниз на одну растровую строку), обеспечивает перемещение всей ячейки символов. Выше показано, как я перемещаю всю строку сообщения Hello world. Однако для реализации настоящего скроллинга текста требуется гораздо больше усилий, ведь каждая буква должна появляться плавно. Я начал с того, что просто выводил весь символ за раз в правой части экрана; всё работало, но выглядело довольно некрасиво, поскольку каждый новый символ резко появлялся справа, скроллился на 8 пикселей, после чего снова резко возникал следующий символ. Мне хотелось, чтобы каждый символ появлялся пиксель за пикселем, а для этого требовалось ещё больше жонглирования битами. В конечном итоге я написал процедуру, которая передаёт поток каждого пикселя строка за строкой, пиксель за пикселем, используя непосредственно данные шрифтов, а не вызывая процедуру вывода символа из ROM.

Моя процедура ищет код ASCII символа, а затем загружает соответствующее представление шрифта в 8-байтный буфер, по одному байту на каждую строку данных. Данные шрифта начинаются с символа пробела (код символа 32), поэтому для поиска и получения битов буквы достаточно выполнить font code base address + (ascii code - 32) * 8.

Font Letter H
Буква H шрифта

Выше показаны биты буквы H в данных шрифта, выстроенные в сетку 8×8. Для каждой строки битов я выполняю сдвиг строки влево при помощи удобного трюка: байт загружается в регистр a, а затем, складывая его с самим собой (add a,a), мы сдвигаем значение влево (в двоичном представлении умножение на 2 сдвигает всё на один бит), поэтому пиксель, который мне нужно нарисовать, оказывается отправленным во флаг переноса. Это идиоматический способ выполнения этой операции в коде Z80, потому что он быстрее и компактнее, чем команда sla. А если мы перерисовываем в каждом кадре 256 байт, то важен каждый цикл!

Font Letter H

Затем буфер символа выглядит так: верхняя строка сдвинута. Теперь, когда пара регистров hl указывает в правильное место экрана, можно применить описанный выше трюк с rl. Он выполняет сдвиг растровой строки от нижней строки символов экрана влево на один пиксель и отрисовывает мой пиксель в крайней правой позиции, копируя его из флага переноса. Выполнив цикл 32 раза, мы сместим всю строку на 1 пиксель, а новый пиксель при этом будет вставлен справа.

Font Letter H

Затем я снова повторяю этот этап при помощи inc h, чтобы сместиться вниз на одну растровую строку, пока не будет отрисован весь столбец символа. В этот момент буфер символа выглядит так, как показано выше, а все 8 строк нижней строки экрана сдвинулись влево на один бит благодаря проходу цикла по всем 32 столбцам символа. Затем весь процесс повторяется ещё 7 раз, пока не выполнится плавный скроллинг символа на экран.

Повторюсь, потому что мне кажется это важным: просто для скроллинга сообщения внизу экрана мы вынуждены в каждом кадре выполнять циклический обход и перерисовывать 256 пикселей! Это показывает, насколько ассемблер быстрее, чем интерпретатор BASIC, встроенный в ROM. Я поразился, когда мой код наконец заработал, представляя, как летают в коде биты и меняются значения регистров. Это заставило меня ценить тот труд, который требовался для создания игр моего детства.

Музыка

Конвертированный мной модуль с Amiga. Настоящий хит 1980-х!

Разумеется, каждому качественному интро требуется музыка. На Amiga стандартом был формат «трекерных» модулей, имеющий сложную историю различных форков, версий и изменений. Композиция создаётся из набора паттернов, каждый из которых состоит из строк команд. Каждая команда обычно включает в себя информацию о нотах или высоте звука, воспроизводимом сэмпле и различные параметры (громкость, эффекты и так далее) или, в случае некоторых команд, данные о месте перехода к позиции в мелодии. Во многих отношениях это похоже на язык программирования для музыки. Например, в ProTracker типичная ячейка ноты состоит из 4 байт, отображаемых на экране примерно в таком виде: C-2 01 C20. Она разделена на три поля: ноту и октаву (C-2), номер сэмпла (01), эффект и его параметр (C20 — в этом случае «выставить громкость $20», что вдвое меньше максимальной $40).

Closeup of Protracker notes
Ноты в Protracker. Обратите внимание на индикаторы уровня звука — это ещё один классический эффект из демо, о котором я расскажу ниже

Впечатляющую демонстрацию возможностей трекера и внешний вид нотных данных на экране можно увидеть в работе h0ffman и, в частности, в его Professional Tracker, участвовавшем в конкурсе Revision 2014. Он бесспорный мастер науки (или искусства!) трекерной музыки, а в его докладе Secrets of Protracker представлен замечательный обзор возможностей программы.

В своё время звуковое оборудование Amiga намного превосходило потенциал всех конкурентов на рынке, и ZX Spectrum кажется на его фоне ничтожным. В оригинальных моделях 48K имелся лишь рудиментарный «бипер», однако в более поздних моделях 128K появился трёхканальный аудиочип AY-3-8912. Он был очень популярным в 8-битных микрокомпьютерах того времени, а его более‑менее идентичный вариант использовали в Atari ST. Хоть в нём возможны лишь базовые формы волн и обработка звука, он обеспечивал многоканальную «чип‑музыку», поэтому стал одним из столпов демосцены ZX Spectrum. Так же он породил множество клонов SoundTracker на самом Spectrum, хотя сегодня большинство музыкантов предпочитает работать с современными инструментами наподобие Vortex Tracker II, которые нативно работают в Windows.

Конвертация

Для своего интро я хотел портировать модуль Amiga, и у меня уже был подходящий на примете. Одним из моих любимых музыкальных дисков на Amiga был Sounds Of Gnome, выпущенный в 1989 году шведским дуэтом Mahoney & Kaktus. Mahoney также написал NoiseTracker — один из первых широко используемых трекеров на Amiga; я тоже в подростковом возрасте создавал в нём компьютерную музыку. В 2018 году я создал рок‑кавер одной из мелодий на этом диске, и написал о нём Mahoney (его реальное имя Pex). В итоге он показал моё «музыкальное видео» на демо‑конференции, за что я ему бесконечно благодарен! То есть казалось вполне логичным взять ещё одну композицию из этой коллекции и конвертировать её для Spectrum… Но прошу прощения! Я выбрал трек Milkman, написанный Glue Master при поддержке Mahoney, представившим в этой сборке несколько своих треков.

Это идеальный трек для конвертации: он короткий (всего семь паттернов), большинство этих паттернов использует одну последовательность аккордов; к тому же мне показалось, что будет достаточно просто ужать четыре канала оригинальной композиции до трёх каналов Spectrum. Плюс это невероятно привязчивая мелодия и я хорошо её знаю — я слушал её тридцать пять с лишним лет и часто подыгрывал ей на гитаре или басу. Поначалу конвертация происходила довольно просто: мне достаточно было нота за нотой копировать паттерны из mod Amiga, который был открыт в моём Windows‑трекере OpenMPT. Ниже показана сессия, где в обоих трекерах открыт одинаковый паттерн — слева оригинальный mod Amiga, а справа — моя трёхканальная версия для Spectrum.

Note the 11.7 in the 3rd channel of the Spectrum version. This is a “pitch slide down” effect in Vortex Tracker syntax, which drops the pitch by about a quarter-tone. It’s repeated on the next line, so the overall effect is a slide down from the A# that leads in to the next bass note. The original Amiga mod does the same thing using its own syntax - those are the 202 s you can see on the same notes, at the bottom of the 4th track.
Обратите внимание на 11.7 в третьем канале версии для Spectrum. Это эффект опускания тона вниз в синтаксисе Vortex Tracker, опускающий высоту примерно на четверть тона. Он повторяется на следующей строке, поэтому в целом эффект представляет собой спуск с A#, приводящий к следующей басовой ноте. В оригинальном mod Amiga выполнялось то же самое, но с использованием собственного синтаксиса — это 202, которые можно увидеть на тех же нотах внизу четвёртой дорожки.

Для проверки работоспособности композиции достаточно было единственного писка, и спустя несколько недель хакинга и работы по вечерам я получил базовую версию, которая для первого черновика звучала на удивление хорошо:

Следует особо отметить пост на форуме, который оказал мне большую помощь в начале работы. Наверно, это лучшее введение в Vortex Tracker из найденных мной. Огромная благодарность автору за то, что нашёл время всё это записать!

Vortex Tracker Sample

Реальная сложность заключалась в том, чтобы звук композиции был максимально близок к оригиналу. Для этого мне понадобилось составить библиотеку более качественных сэмплов для разных частей, а также активно задействовать «орнаменты» Vortex Tracker. В этом ПО есть отличный выбор звуков, а я, к счастью, тот чудак, которому нравится слушать чиптюн на фоне, пока работаю. Поэтому когда я находил что‑то особенно интересное, я сохранял композицию, чтобы позже украсть её вдохновляться ею. Сэмпл Vortex Tracker — это не совсем тот «сэмпл», под которым на Amiga подразумевается записанный звук: это определение звучания ноты по одной строке на кадр. Каждая строка может примешивать тон или генератор белого шума (полезен для эффектов малого барабана и тому подобного), а также различные другие параметры.

Орнаменты же обеспечивают дополнительный контроль за высотой ноты, что позволяет использовать крайне полезные трюки наподобие арпеджированных аккордов. Например, орнамент 0,4 обеспечивает быстрое переключение канала (25 раз в секунду) между воспроизведением ноты и нотой на четыре полутона выше неё. Переключение происходит настолько быстро, что звучит почти так, будто обе ноты играют одновременно. Благодаря этому можно создавать полноценно звучащие трезвучия, используя всего два канала. Возьмём начальный аккорд в моей конвертированной версии — до минор (Cm). Первый канал воспроизводит ноту C, а второй — D#. Затем я применяю ко второму каналу орнамент, который повышает ноту на четыре полутона, до квинты (G). Таким образом, звучат все три ноты аккорда, а третий канал остаётся свободным для других партий.

Помимо гораздо более эффективного использования ограниченных ресурсов трёх каналов, этот приём придаёт музыке на Spectrum характерное «пульсирующее» звучание, которое мне очень нравится своей ретро‑атмосферой! Благодаря подобным хитростям и сэмплам, в которых тональный звук смешивается с белым шумом (например, для создания комбинированного звука «бас‑бочка — малый барабан»), мне удалось уместить всю композицию в три канала. Проработав над конвертацией несколько месяцев, могу сказать, что я очень доволен итоговым результатом.

Воспроизведение

Похоже, оригинальный сайт, служивший домом для Vortex Tracker, документации и других ресурсов, сейчас находится офлайн. Мне удалось найти копии всего необходимого на archive.org — для описанных в этом разделе процедур воспроизведения вам понадобится файл PTxTools.7z.

Дополнение: пользователь Hacker News оставил полезный комментарий: «WWW‑сайт Сергея Бульбы всё ещё работает, и нужные файлы можно сегодня найти по адресу https://ay.strangled.net/progr_e.htm». Спасибо, JdeBP!

Теоретически, воспроизводить композицию легко: как отмечено в обзоре, по сути, мы подтягиваем процедуру проигрывателя, добавляем файл модуля и раз в кадр вызываем в основном цикле процедуру воспроизведения. Сложность в том, что ни одна из версий исходного кода в архиве не работала с моим ассемблером Pasmo. К счастью, вместе с исходниками был выложен заранее скомпилированный двоичный объект (VTII10bG в папке PTxTools/PT3Play). Его достаточно загрузить по адресу $C000: менять его нельзя, потому что в него запечена куча абсолютных адресов.

Теперь, после добавления загрузки двоичного модуля, мой код обработки воспроизведения модуля выглядит примерно так:

; Задаём начальный адрес процедуры проигрывателя и нужных нам функций
player EQU $C000      ; жёстко заданный адрес размещения двоичного проигрывателя
plinit EQU player+3   ; функция init из документации, адрес модуля находится в hl
plplay EQU player+5   ; воспроизведение и перемещение на один кадр

; цитата из документации: этот код "анализирует заголовок модуля PT3 и подготавливает 
; соответствующие таблицы нот и громкостей"
ld hl,moduleimg
call plinit

; основной цикл
hold:
  halt
  call plplay
  call scroll
  ; сюда добавляются другие эффекты/процедуры...
  jr hold

moduleimg:
  incbin "assets/music.pt3"

Звёздное небо

Что ж, всё работает не так, как я надеялся.

Итак, мы подходим к самому главному: ни одно уважающее себя cracktro не может обойтись без звёздного неба!

Моя первая наивная попытка реализации классического эффекта звёздного неба заключалась в том, что я разбросал по экрану случайные звёзды, а затем перемещал их вниз по растровым строкам при помощи того же трюка с rl, который я использовал для скроллинга текста. Я решил, что смогу добиться параллакса, сдвигая каждую строку на свою величину: на один пиксель для первой строки, на два для второй, на три для третьей, а затем снова на один, и так на весь экран, чтобы создать впечатление нескольких слоёв звёзд, движущихся с разными скоростями.

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

Было очевидно, что мне придётся отдельно заниматься каждой звездой. Я нашёл процедуру, которая более‑менее походила на задуманное мной и натолкнула на мысль о структуре для хранения состояния каждой звезды. Также в ней используется поиск экранных адресов, очень похожий на применённый в Break Into Program.

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

Если мне нужно было что‑то более интересное, чем пустое звёздное небо, то пришлось бы изначально отслеживать, какие ячейки заняты текстом, и избегать отрисовки звёзд на них вместо перерисовки их в каждом кадре. Начертив кучу черновиков в блокноте с визуализациями строк битов, я в итоге решил обе проблемы при помощи довольно масштабного «жульничества».

Итак, теперь я вообще не генерирую случайное звёздное небо, а создал таблицу заранее вычисленных пар позиций Y и X:

stdata:
  defb 109, 25,  88,182,  42, 96, 155,226
  defb  59,254, 137,169, 160, 84, 125,193
  ; ... и так далее для всех 32 звёзд ...

Это позволяет не заморачиваться с RNG: получив достаточно хорошую структуру, я буду знать, что она всегда будет выглядеть хорошо. Кроме того, я сделал так, чтобы никакие две звезды не имели одинаковую координату Y, что позволяет обойти целый класс проблем: например, нахождение звёзд на одной растровой строке и необходимость обработки наложения их друг на друга.

«Случайная» структура расположения звёзд сгенерирована простым скриптом на Ruby:

#!/usr/bin/env ruby

PAIRS    = 32
PER_LINE = 4          # пары на строку
Y_RANGE  = 40..175    # исключаем строки, занятые логотипом и синими полосами
X_RANGE  = 0..255

raise "y range too small" if Y_RANGE.size < PAIRS

# отсутствие повторяющихся значений y
ys    = Y_RANGE.to_a.sample(PAIRS)
pairs = ys.map { |y| [y, rand(X_RANGE)] }

pairs.each_slice(PER_LINE) do |row|
  body = row.map { |y, x| format('%3d,%3d,', y, x) }.join(' ')
  puts "defb #{body.chomp(',')}"
end

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

STSCR   EQU 0  ; экранный адрес байта, в котором находится звезда, например, $4123 
STOCC   EQU 2  ; указатель на ячейку таблицы "проверки занятости"
STMASK  EQU 4  ; битовый паттерн звезды, например, %00010000 
STSTEP  EQU 5  ; "скорость" - количество пикселей, на которое эта звезда перемещается за кадр

Самое важное здесь то, что координаты X/Y вообще отсутствуют. Для каждой звезды я храню экранный байт, в котором она находится, и битовую маску внутри этого байта, поэтому она уже находится в том виде, который требуется для записи на экран. То есть в кадре вообще не происходит преобразования адресов, только простой сдвиг и вращение битов.

Благодаря этому перемещать звезду становится очень легко: я выполняю вращение её маски влево при помощи rlca, а когда бит вращением выводится за пределы бита 7, он возвращается в бит 0 с установленным флагом переноса. Это сигнал, который нужен мне для перемещения звезды на одну ячейку символов влево, а это одно из тех «дешёвых перемещений», о которых я говорил выше.

Так как я отслеживаю, в какой ячейке находится каждая звезда, проверка коллизий почти ничего не стоит. Для этого и нужен второй указатель: STOCC (Star Occupancy) перемещается синхронно с STSCR по таблице, хранящей по одному байту на ячейку символов. Процедура «пишущей машинки» присваивает байту 1 для каждой ячейки, содержащей текстовый символ или курсор, поэтому для проверки того, находится ли звезда под текстом, достаточно одной команды or. Если ячейка занята, я пропускаю отрисовку звезды, но продолжаю вращать и перемещать её, пока она не достигнет ячейки, в которой её можно отрисовать:

ld l,(ix+STOCC)
ld h,(ix+STOCC+1)
ld a,(hl)                ; A = проверка занятости для этой ячейки
or a                     ; проверяем A на равенство нулю
jr nz,stmove             ; она занята, поэтому отрисовку не выполняем

Параллакс по‑прежнему реализуется перемещением каждой звезды на разное количество пикселей в каждом кадре, но в данном случае затраты намного меньше, потому что мы обрабатываем только те ячейки, которые действительно заняты звёздами, а не каждый байт каждой строки.

Вот это уже больше похоже на правду! Процедура «пишущей машинки» просто копирует данные шрифта из памяти, и она очень малозатратна, потом что в ней можно использовать «дешёвые перемещения» для быстрой загрузки блока 8×8.

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

Разное

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

Индикаторы уровня звука

В первых демо часто использовалась графика, скачущая вместе с воспроизведением музыки. Изначально я думал над реализацией графического эквалайзера реального времени, но он был бы слишком сложным как с точки зрения бюджета на кадр, так и моих умственных способностей. Поэтому вместо него я решил ограничиться простым набором скачущих индикаторов.

В первую очередь я говорю об этом для того, чтобы упомянуть потрясающий веб‑сайт chibiakumas! Я обожаю веб‑дизайн конца 90-х: кто‑то может сказать, что его читаемость и accessibility были ниже, но как же здорово встретить сайт, всё ещё использующий эту эстетику. Видно, что он сделан с любовью, а ещё на нём есть куча полезного контента.

Именно благодаря руководству Making Sound with the AY-3-8910 я узнал о том, какие данные мне нужны для отрисовки индикаторов. В примерах для выбора регистра номер регистра записывается в адрес $FFFD; я осознал, что после выбора регистра можно использовать команду in для считывания его значения вместо записи нового. Как говорится на странице руководства, в R8, R9 и R10 хранятся амплитуды каналов, поэтому их можно задействовать для определения высоты индикатора. Для получения амплитуды канала A достаточно такого простого кода:

  ld a,8        ; Регистр канала A
  call ayread

; ... здесь находится остальной код ...

ayread:
  ld bc,$FFFD   ; Порт выбора регистра
  out (c),a
  in a,(c)
  ret

Twinkle, Twinkle, Little Star

Это простой эффект, делающий логотип в заголовке чуть интереснее. Не помню, откуда я взял эту идею, но точно видел её в нескольких демо конца 80-х/начала 90-х. Мне всего лишь хотелось выбирать случайные точки звёзд на фоне логотипа и анимировать их.

Header graphics
Header graphics

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

Эти точки соответствуют следующим координатам, равным верхней растровой линии максимального размера звезды (на два пикселя от центра):

twkdata:
  defb 1,5    ; char row 0, col 5,  centre y=3  ink 5
  defb 1,20   ; char row 0, col 20, centre y=3  ink 1 bright
  defb 26,7   ; char row 3, col 7,  centre y=4  ink 4
  defb 25,22  ; char row 3, col 22, centre y=3  ink 6 bright

Сама процедура мерцания достаточно проста: она добавляет пиксель по каждой из осей для увеличения звезды, ожидает несколько кадров, а затем снова уменьшает звезду. Интересен был способ создания псевдослучайного мерцания. Напомню, что звёздное небо полностью вычислялось заранее из таблицы данных, сгенерированных скриптом на Ruby, благодаря чему мне не требовался генератор случайных чисел в ассемблерном коде. В книге Джонатана Колдвелла How To Write Spectrum Games я нашёл ещё один замечательный «хак»: можно просто пройтись по началу ROM Spectrum, беря из него случайно выглядящие байты. Как сказал Джонатан, он использует этот хак в большинстве своих игр, и это сильно упрощает работу при условии, что вы не будете использовать блок структурированных повторяющихся данных, находящихся после первых 8 КБ этого ROM.

twkrand:
  push hl
  ld hl,(twkseed)
  ld a,h
  and 31                   ; ограничиваемся первыми 8 КБ ROM
  ld h,a
  ld a,(hl)                ; "случайный" байт
  inc hl
  ld (twkseed),hl
  pop hl
  ret

Кончилось место

Примерно на половине процесса написания интро я столкнулся со вполне предсказуемой проблемой: у меня закончилась память. Пытаясь сделать так, чтобы все данные автономно хранились в одном блоке кода, я подтягивал все ассеты (фон экрана, двоичный проигрыватель, файл музыки и так далее) в саму программу, а затем при помощи команды копирования блока ldir перемещал их в нужные места. Так как из‑за этого, по сути, все блоки хранились дважды, а у процедуры проигрывателя был жёстко прописанный начальный адрес $C000, у меня быстро закончилось место, и мой код начал вылетать в процедуре проигрывателя. Проще всего было разбить всё на отдельные блоки кода, загружать каждый непосредственно в адрес, где он должен находиться, а затем обернуть всё это в многоэтапный загрузчик с плёнки на BASIC:

10 CLEAR 32767
20 BORDER 0: PAPER 0: INK 0: CLS
30 LOAD "" CODE
40 LOAD "" CODE
50 LOAD "" CODE
60 RANDOMIZE USR 32768

Он загружает основную программу, затем процедуру проигрывателя непосредственно в $C000 и, наконец, экран фона напрямую в экранную память. Из‑за этого фон начинает отображаться до полной загрузки демо, но поскольку большинство людей будет смотреть его в эмуляторе или на моём TNFS‑сайте, это не так уж важно. Кроме того, на этом этапе я рисковал тем, что мне понадобится углубляться в изучение новых тем (я всё ещё очень хочу разобраться, как создавать многоцветные эффекты границ!), из‑за чего я так и не выпущу своё демо.

Дзен Z80

My Coding Notebook
Мой блокнот для кодинга

Оглядываясь назад, я понимаю, что этот проект был очень странным. С одной стороны, я исполнил свою детскую мечту и создал то, чем очень горжусь. Сегодня довольно редко можно получить двоичный файл, в котором тебе известно поведение и история появления практически каждого байта. С другой же стороны, теперь у меня есть ответ на вопрос о том, почему я не пишу код на языке ассемблера: потому что я ценю своё психическое здоровье (точнее то, что от него осталось)! Дело в том, что для написания такого кода приходится самому начинать думать, как машина. Нужно представлять в голове регистры и переключение флагов состояний, графику в виде строк битов, записанных в странном порядке, циклы и условные конструкции в виде двоичной логики и так далее. Такая близкая работа с железом усилила мою связь с компьютером моего детства, и мне теперь понятно, почему её любят пишущие на ассемблере и участники сцены. Тем не менее, я не тороплюсь переписывать все свои работы на язык ассемблера!

По‑настоящему мне нравятся те проекты, которые дают мне возможность узнать что‑то новое ради самой этой информации и немного замедлиться в мире современной работы, постоянно повышающем скорость и требования. Наверно, глубже, чем кодинг для Z80, вернуться назад уже невозможно (если не считать хакинга оборудования). Иногда он был для меня захватывающей головоломкой, но чаще — раздражающим упражнением, которое едва не заставило меня сдаться на некоторых этапах. Этот проект стал для меня самым долгим путём от этапа идеи до получения какого‑то осмысленного результата.

Да, я не проводил за ним каждый день прошлого года: скорее, тратил на него несколько часов пары вечеров в неделю. Но я думал о нём бо́льшую часть дней (мои собаки могут это подтвердить). Чаще всего озарения приходят не когда сидишь за клавиатурой, а когда пишешь в блокноте и раздумываешь о состояниях процессора, занимаясь сторонними делами.

Тем не менее, этот проект заставил меня сильно уважать и ценить труд кодеров, пишущих интро. Вдвойне я начал уважать подростков и домашних суперзвёзд кодинга, ухитрявшихся делать всё это в 80-х и вынужденных разбираться во всём при помощи экспериментов. Мне удалось добиться своего результата только благодаря куче книг, онлайн‑туториалов, репозиториев GitHub с примерами кода и видео на YouTube… Представить не могу, насколько больше времени бы мне понадобилось, если бы под рукой у меня были только Spectrum, кассетный магнитофон и блокнот.

Ставя в этом проекте точку, я поднимаю бокал и цитирую h0ffman: «Респект всем хакерам, художникам графики и музыкантам protracker».

Комментарии (8)


  1. Daar
    27.10.2026 05:15

    Эххх... вспоминаю эти времена на ZX, бессонные ночи... изучение бейсика а потом ассемблера без интернета и книг. Максимум это 100 копия с копии еле читаемых бумажек собранных в книжечку с названием ZX Review. Ну и да, ковыряние в чужих играх и интро, и написании своих.


  1. LimTroev
    27.10.2026 05:15

    Кстати на Z80 были еще теневые регистры, их как раз применяли (если отключить не нужные бейсиковские прерывания командой DI) для тяжелых математических или графических циклов основной программы (например, для быстрого вывода "спрайтов"). Еще не понятно, что мешало хранить "спрайты" как массив 8х8 или 16х16 и двигать их сверхбыстрой LDIR или через стек. Еще можно было в 128к спектруме (там была страничная уже память) в порт (вроде 7FFD) подать команду и промаппить 16кб, занятую бейсиком и ее использовать начиная с адреса 5С00.
    Это не как сейчас - раньше битва шла за каждый байт памяти и цикл процессора. Даже буфер принтера 256 байт и те использовали.


    1. Stanislavvv
      27.10.2026 05:15

      Куча LDI была быстрее LDIR... Там, если правильно помню, разница в такт на байт.


      1. LimTroev
        27.10.2026 05:15

        Да, цепочка LDI быстрее на 5 тактов на байт. Но сама LDIR -2 байта, а n-штук LDI это 2*n -байт. Для экранного буфера использовали цепь например из 16 - 64 LDI в цикле.


    1. KurlesHS
      27.10.2026 05:15

      LDIR очень медленная команда, вот через стек - да, быстро, собственно так и делали. Тупое копирование байт работало в случае, если "спрайт" 8x8 и ровненько помещается в знакоместо, иначе всё чуть сложнее из-за структуры спековского экрана


  1. voldemar_d
    27.10.2026 05:15

    WWW‑сайт Сергея Бульбы всё ещё работает, и нужные файлы можно сегодня найти по адресу https://ay.strangled.net/progr_e.htm»

    Лишняя кавычка в ссылке.


    1. PatientZero Автор
      27.10.2026 05:15

      Спасибо, исправлю


  1. peacemakerv
    27.10.2026 05:15

    Вот ведь монстр автор оригинала, не похваляется как он нейросетки замучил по теме :)