Примерно месяц назад основная поставка Flipper'ов таки доехала до России. Вопреки моим ожиданиям, это не вызвало волну публикаций про создание приложений под него. Хорошие публикации есть (например, эта и вот эта), но массовости нет. Слишком долго ждали и перегорели? Пишут долго и обстоятельно? Технологический стек устройства не подходит для быстрого и легкого старта? Как бы то ни было, такой расклад ничуть не убавил мотивации поиграться с устройством! С удовольствием уделил несколько вечеров созданию своего первого приложения под Flipper Zero: Hex Viewer, шестнадцатеричного просмотрщика. О своем опыте и интересных находках расскажу в теле статьи.

Небольшое лирическое отступление

Более 12 лет опыта работы в индустрии программирования научили меня ценить возможность "зрить в корень" файлов, видеть их шестнадцатеричное отображение. Большинство программ показывают нам свою интерпретацию файла. Например, MS Word и Libre Office могут по-разному показывать один и тот же DOCX-файл. А современный текстовый редактор может по своему усмотрению преобразовать переносы строк, выравнивание (tab vs space), иногда даже кодировку. В основном это сделано для нашего блага, так как убирает сложность. Однако платой за это является потеря контроля над настоящим содержимым файла: байтами на диске. Шестнадцатеричный просмотрщик помогает вернуть контроль назад. Обычно я использую Lister в Total Commander. Считаю, что его хоткей - F3 - это лучшая кнопка в TC. С помощью Lister можно в мгновение ока открыть файл любого размера (за счет memory-mapped files), поискать что-то внутри (в том числе по регулярным выражениям), быстро открыть файл из диалога результатов поиска (его хоткей: Alt+F7).

Итак, 3 ноября я получил свой Flipper Zero. Полазил по менюшкам, отсканировал парочку домофонных ключей, послушал marble machine в 8 битах (лайк за выбор мелодии). На флэшке начали появляться разные файлы, и специфичные приложения могли их открывать. Это, конечно, здорово, но захотелось разобраться, как они устроены, что представляют из себя на самом деле. Сделать это было невозможно - в официальной прошивке нет даже файлового менеджера. Так родилась идея создать шестнадцатеричный просмотрщик. Не мудрствуя лукаво, решил назвать его просто Hex Viewer.

Создание приложения

На момент написания статьи раздел про разработку firmware на официальном сайте Flipper'а пустует, поэтому было решено начать с страницы исходников в GitHub'е. Репозиторий проекта уже был на моей машине, так как летом отправлял небольшой PR для змейки (в учебных целях порефачили его с командой в онлайне). Забегая вперед скажу, что это сослужило мне плохую службу: с того момента API системных вызовов изменилось, пришлось делать лишнюю работу по замене системных вызовов. Поэтому перед началом разработки приложения рекомендую забирать самую свежую версию исходников прошивки.

Перед началом разработки приложения под Flipper Zero перво-наперво нужно добиться работоспособности FBT (Flipper Build Tool) на вашей машине. Эта утилита умеет собирать прошивку целиком, собирать приложения, собирать и запускать конкретное приложение на устройстве, включать режим отладки, настраивать VS Code, etc. В общем, швейцарский нож. Настроили и забыли, очень удобно пользоваться.

В качестве основы для приложения был взят музыкальный плеер, music_player. Он подкупал тем, что показывал на старте нужный мне диалог выбора файлов, и, собственно, работал с этими файлами. По большому счёту я просто скопировал кодовую базу в папку applications_user, переименовал файлы, удалил лишние файлы воркера проигрывателя, переименовал структуры данных и оставил только нужные поля в структурах. Помимо правки исходников на C, заполнил FAM-файл манифеста поиском/заменой. Он достаточно интуитивный, удалось заполнить с первого раза без чтения документации.

Наблюдение

Большинство приложений под Flipper Zero, которые мне удалось изучить, имеют общую структуру: основную точку входа в приложение, процедуры аллокации и деаллокации, структуру под хранение контекста и открытых системных ресурсов, etc. Напрашивается шаблон по принципу Cookiecutter, когда можно просто задать несколько параметров, а на выходе готовая основа для приложения. Возможно такой шаблон появится в будущем

Подход с копированием плеера позволил быстро запустить условный "Hello, world!". Из функции рисования (render_callback) были удалены хитрости рисования клавиш фортепиано и проигрываемых нот. Оставлены утилитарные захват/освобождение мьютекса, подготовка канваса и шрифта, отрисовка названия приложения:

static void render_callback(Canvas* canvas, void* ctx) {
    HexViewer* hex_viewer = ctx;
    furi_check(furi_mutex_acquire(hex_viewer->model_mutex, FuriWaitForever) == FuriStatusOk);

    canvas_clear(canvas);
    canvas_set_color(canvas, ColorBlack);
    canvas_set_font(canvas, FontPrimary);
    canvas_draw_str(canvas, 0, 12, "HexViewer");

    furi_mutex_release(hex_viewer->model_mutex);
}

После нескольких итераций правок опечаток и очевидных ошибок, удалось запустить приложение на устройстве. На экране флиппера гордо загорелась надпись "Hex Viewer". Ура!

Кстати, запуск приложения в основном осуществлялся посредством всемогущего fbt:

sudo ./fbt launch_app APPSRC=applications_user/hex_viewer

Дальше мне было необходимо продумать:

  • Лэйаут приложения, понять какая информация будет показываться на экране

  • Как пользователь будет взаимодействовать с приложением

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

Дизайн приложения, нарисованный от руки. Практикую с 2014 года
Дизайн приложения, нарисованный от руки. Практикую с 2014 года

В общем-то ничего творческого, довольно типичные для такого класса приложений:

  • Колонка с оффсетом относительно начала файла

  • Байты файла в шестнадцатеричном отображении

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

После окончания проектирования, можно было приступать непосредственно к программированию логики просмотрщика. В модели был заведен буфер на 16 байт, а код рисования был дополнен отображением этого буфера. Строка формируется побайтно, а выводится единым вызовом canvas_draw_str. Учитывая еще один вызов для отрисовки адреса, получается по 2 вызова на строку, всего 8 на экран. Неплохо.

Запуск этих изменений через fbt привел к желаемому результату: на экране устройства отобразились заветные нули и растущий счётчик байт. Выглядело это вот так:

Фактически proof of concept того, что просмотрщик на флиппере использовать можно
Фактически proof of concept того, что просмотрщик на флиппере использовать можно

Кстати, шестнадцатеричное отображение байтового буфера и счётчика байт было создано с помощью старой доброй функций языка C под названием sprintf. Конечно, пришлось немного погуглить, вспомнить спецификаторы форматов функции printf, но в целом без особенной боли удалось достичь желаемого отображения.

Рисование элементов на экране в большинстве изученных приложений попиксельное. Т.е. нужно точно задать координаты на экране, никаких автоматических инструментов размещения а-ля Layout не обнаружил. Для отладки пиксельной точности размещения компонентов использовал макросъемку телефона. Можно было сделать проще: официальное desktop-приложение qFlipper умеет стримить экран устройства и делать скриншоты (наподобие того, что размещен в самом начале статьи). Но у меня не получилось одновременно держать запущенным qFlipper и запускать приложения через fbt. Видимо кто-то держит канал в блокирующем режиме. Поэтому быстрая итерация через фото на телефон оказалась удобнее.

Скриншот более поздней версии: честно прочитанные из файла байты и кнопка переключения режима
Скриншот более поздней версии: честно прочитанные из файла байты и кнопка переключения режима

Следующая задача: прочитать реальный файл с флэш-карты устройства. По простоте душевной решил поискать в проекте вызовы fopen, и к большому удивлению ничего не обнаружил. Далее решил обратиться к уже знакомому музыкальному плееру, и там напал на ложный след в функции music_player_worker_load_fmf_from_file. Обнаруженная группа функций flipper_format_file_* предназначается для работы с файлами в специальном внутреннем формате файлов Flipper'а (подсмотреть в него можно будет на одном из скриншотов в статье). Изучив заголовочный файл с описанием этих функций, я даже немного расстроился, потому что они плохо подходили под чтение файлов общего назначения (в произвольном формате). Из-за этого Hex Viewer мог получиться очень неэффективным. Благо буквально в соседней функции плеера music_player_worker_load_rtttl_from_file нашлось другое API для работы с файлами, группа функций storage_file _*. Они позволяли работать в привычном для языка C режиме: open/read/size/seek/close.

Первая реализация чтения с карты была максимально простой:

  • Открытие файла

  • Сдвиг на нужное количество байт (вычисленное по формуле: номер строки в просмотрщике * 4 байта)

  • Чтение буфера (16 байт)

  • Закрытие файла

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

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

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

Два символа уместились буквально пиксель в пиксель
Два символа уместились буквально пиксель в пиксель

Кстати, с сохранением иконки были определенные трудности. Прямолинейное сохранение в PNG дало на выходе файл размером ~1Кб, тогда как оригинальная иконка весила ~140 байт. Пришлось доставать Lister и разбираться, что же такого Photoshop вставляет в файл :) Ответ был на поверхности: кучу метаданных. Немного помучившись с диалогом "Сохранить для Web", удалось ужать размер файла до ~150 байт (хоть и все метаданные выкусить не удалось). Картинка успешно открылась на устройстве, дальнейшая борьба за десятки байт показалась бессмысленной. UPD: как @Dr_Zlo13и @vvzvlad подсказали в комментариях, при сборке картинки автоматически преобразуются в компактный формат heatshrink-compressed xbm image. Получается, сэкономил только место в git'е.

Как уже говорилось ранее, экран у флиппера небольшой, поэтому уместить одновременно все три типичные колонки hex viewer'а (счётчик адреса, шестнадцатеричное отображение и отображение печатаемых символов) не получится. Поэтому было решено пойти на хитрость: сделать переключатель режима отображения, регулирующий вид пространства слева от шестнадцатеричного отображения байт. В качество переключателя используется физическая кнопка "Влево" флиппера. На экране же с помощью специальных библиотечных хелперов рисуется кнопка-подсказка. Она не только показывает пользователю возможность нажать кнопку, но и отображает текущий вид отображения по принципу "toggle".

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

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

Приложение на тот момент уже обрело целевую функциональность, пришло время заняться его производительностью. С самого начала меня беспокоил вопрос эффективности чтения байт файловой системы. В начальной версии на каждую прокрутку выполнялось открытие файла, чтение шестнадцати байт и закрытие файла. Это не очень эффективный способ работать с байтами хотя потому, что прокрутка содержимого выполняется на 4 байта, а вычитывается весь буфер из 16 байт. Поэтому я решил поискать в системе какие-нибудь API для буферизированного чтения, и как ни странно такой API нашелся. Рассказать о нем придется в два этапа. Во-первых, есть набор функций file_stream_*, которые предоставляют привычные методы для работы с файлом, но оперируют понятие "стрима", представленного структурой Stream. Фактически это более высокоуровневая это обертка, но не просто над file-like сущностью, а так же операциями над ним. Внутри структуры хранится реализованная вручную таблица виртуальных функций, выполняющих операции open/seek/read/close/etc. Во-вторых, есть группа функций buffered_file_stream_*, подменяющая виртуальные функции в структуре Stream на собственные, выполняющие магию буферизации. Основные функции для работы со стримами не догадываются о том, что используют буферизированные версии, переход абсолютно бесшовный. Моё уважение разработчикам стандартной библиотеки за удачное применение такого известного приема, как реализация в C самописной таблицы виртуальных функций (как в высокоуровневых языках).

struct StreamVTable {
    const StreamFreeFn free;
    const StreamEOFFn eof;
    const StreamCleanFn clean;
    const StreamSeekFn seek;
    const StreamTellFn tell;
    const StreamSizeFn size;
    const StreamWriteFn write;
    const StreamReadFn read;
    const StreamDeleteAndInsertFn delete_and_insert;
};

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

Несколько слов про рабочие окружение и работу в Visual Studio Code. Интеграция есть из коробки, выполняется вызовом одной единственной командной строки.

./fbt vscode_dist

Однако полноценно заставить VS Code видеть моё приложение и подсвечивать ошибки у меня почему-то не получилось. Возможно кто-то из читателей статьи подскажет, что для этого нужно сделать. Это не сильно мешало, ибо с помощью запуска приложения на устройстве посредством командной строки удавалось довольно быстро итерироваться. Компиляция и запуск занимали порядка 5 секунд что, собственно, никак не ограничивало мою производительность. Помимо небольшой сложности с настройкой Visual Studio Code непосредственно для моего приложения, в общем-то всё работало достаточно хорошо, среда позволяла "ходить по функциям", умела прыгать по определениям и так далее. То есть пользоваться было довольно удобно. Также при разработке помогает старый добрый Ctrl+Shift+F (глобальный поиск по проекту) в случаях когда нужно сделать что-то по аналогии или понять как пользоваться функциями.

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

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

Итог

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

Оказывается, родной формат файлов флиппера текстовый!
Оказывается, родной формат файлов флиппера текстовый!

Хотелось бы выразить огромную благодарность @vvzvlad, автору статьи про разработку приложения-счетчика для флиппера. Я почерпнул оттуда полезную информацию, уже до начала разработки примерно знал к чему готовиться, реализовал многое по аналогии.

Ссылки проекта на GitHub

Ссылка на собранное приложение (собрано под v0.70, работает после обновления на v0.71)

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


  1. Uris
    20.11.2022 12:26
    +3

    Мне тоже пришел Flipper. Забавная вестьч. Как я понимаю, удалось заказать в ряду последних покупателей, сейчас на Joom доставка в Россию прекращена(((((


    1. unwrecker
      20.11.2022 17:20
      +2

      После прихода Флиппера я ещё успел заказать на Joom WiFi плату. Впрочем, пока не понял что с ней делать :)


    1. Xobotun
      20.11.2022 21:34
      +5

      У меня с флиппером и логистикой тоже не сложилось. Прекрасно пришёл в Москву, только я страну жительства уже два раза сменил с начала года. :D

      Однажды да заберу, надеюсь, да попробую поковырять...


    1. efcadu
      21.11.2022 10:52

      Уже вполне официально, как я понял, российские интернет-магазины его продают.


      1. BIOACE
        21.11.2022 16:20

        Пока увидел только Joom который в рф не доставляет (хотя есть люди что успели заказать) и нашёл на Амперке за 16к. Но это имхо дорого.


  1. xi-tauw
    20.11.2022 15:38
    +2

    Спасибо автору. Очень удивило отсутствие нормальных инструкций от разрабов. Теперь может тоже попробую что-то написать.


    1. Dr_Zlo13
      20.11.2022 20:30
      +7

      отсутствие нормальных инструкций от разрабов

      Сейчас проблема (и она упомянута в статье) в том, что мы очень часто переделываем API или систему сборки, и пока мы не поймем что все работает как нужно, заморозим API и скажем что прошивка достигла версии 1.0 любая документация по сборке приложений преждевременна и замедлит нашу работу.

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


  1. Vsevo10d
    20.11.2022 18:25
    +3

    Вопреки моим ожиданиям, это не вызвало волну публикаций про создание приложений под него. Хорошие публикации есть (например, эта и вот эта), но массовости нет. Слишком долго ждали и перегорели? Пишут долго и обстоятельно?

    Заявленный функционал и область применения не особо мотивируют палить на мейнстримных ресурсах свои достижения?


    1. Dr_Zlo13
      20.11.2022 20:39
      +9

      Флиппер официально сертифицирован для ввоза большинством стран (а в те которые не сертифицировали - мы просто не подавали запросы), в основных странах даже имеет сертификацию из служб безопасности, плюс постоянно на таможне проходит проверки функционала и вопросов к нему нет. Единственная проблема с которой мы столкнулись - вопросы к частотному диапазону.

      Что же тут может демотивировать писать статьи? Мое предположение тут скорее нехватка документации и слишком злые правила написания под эмбед (которые мы еще ужесточили строгими проверками в компиляторе).


      1. QtRoS Автор
        21.11.2022 00:57
        +4

        Мое предположение тут скорее нехватка документации

        Могу рассказать чего не хватало мне:

        • понимания модели многопоточности

        • понимания модели (циклов?) обработки событий

        Поднимая во флиппере диалог с помощью dialog_message_show(dialogs, message);, я до конца не понимал, что именно происходит в этот момент. Запускается свой цикл обработки событий? Приходят ли нажатия кнопок в мой input_callback? Рисуется ли мое приложение под диалогом? Блокирующий ли это вообще вызов? ^^ Это можно проверить, но удобнее когда основные механики описаны.

        В качестве примера совершенно не случайно приведу Qt. Документация дает четкое понимание того, как работают события, циклы обработки событий, потоки, сигналы и слоты. Один раз прочитал - понял навсегда.


  1. vvzvlad
    20.11.2022 18:56
    +3

    Кстати, с сохранением иконки были определенные трудности. Прямолинейное сохранение в PNG дало на выходе файл размером ~1Кб, тогда как оригинальная иконка весила ~140 байт. Пришлось доставать Lister и разбираться, что же такого Photoshop вставляет в файл :) Ответ был на поверхности: кучу метаданных. Немного помучившись с диалогом «Сохранить для Web», удалось ужать размер файла до ~150 байт (хоть и все метаданные выкусить не удалось). Картинка успешно открылась на устройстве, дальнейшая борьба за десятки байт показалась бессмысленной.

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


    1. QtRoS Автор
      20.11.2022 19:15
      +3

      Ваша правда: в сгенерированном h-файле длина массива всего 21 байт. Еще одна продуманная мелочь, здорово!


      1. Dr_Zlo13
        20.11.2022 20:25
        +6

        Да, флиппер понимает только свой формат изображений, heatshrink-compressed xbm image. Из минусов - все нужно конвертировать. Из плюсов - изображения занимают очень мало места.


  1. koteeq
    20.11.2022 20:13
    +6

    Класс! Для тестирования графики есть удобный https://lab.flipper.net/paint. Работает в браузерах с поддержкой Web Serial (Chrome).


  1. Dr_Zlo13
    20.11.2022 20:23
    +4

    Моё уважение разработчикам стандартной библиотеки за удачное применение такого известного приема, как реализация в C самописной таблицы виртуальных функций (как в высокоуровневых языках).

    Спасибо, приятно слышать.

    просто задать несколько параметров, а на выходе готовая основа для приложения

    Что-то подобное есть в недавно зарелизившемся uFBT (standalone система сборки приложений для флиппера). Наверное перенесем это и в обычный FBT.

    заставить VS Code видеть моё приложение и подсвечивать ошибки у меня почему-то не получилось

    ./fbt firmware_cdb делали? Это генерация compile_commands.json, без которого vscode не знает как процессить файлы через intellisense.
    Постараемся хотя бы основные тонкости описать в документации.

    вопрос эффективности чтения байт файловой системы

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


    1. QtRoS Автор
      20.11.2022 22:35
      +1

      ./fbt firmware_cdb делали?

      Worked like a charm!


  1. Dr_Zlo13
    20.11.2022 20:48
    +4

    Ссылка на собранное приложение

    Чтобы хранить-распространять приложение - лучше завести отдельный репо в папке applications_user под него. В итоге получится папка которая может быть собрана как FBT, будучи склонированной в applications_user, так и uFBT отдельно. Под uFBT есть даже гитхаб-экшены, которые собирают приложение в облаке на каждый коммит.

    Например вот как приложение выглядит на гитхабе, и вот так оно расположено в applications_user.

    applications_user

    собрано под v0.70, работает после обновления на v0.71

    Для внешних приложений у Флиппера своя версия, указана в api_symbols.csv и увеличивается только при изменении API.


  1. P4elKSG
    20.11.2022 21:42

    Вчера только размышлял что дельфин нигде не мелькает и тут бац, отличная работа :)


  1. force
    22.11.2022 17:28
    +1

    Ээх... а мой флиппер путешествует по Германии. Так что весьма грустно читать, что и получили уже все и можно просто взять и купить...


    1. QtRoS Автор
      22.11.2022 18:44
      +1

      Хех, а я было расстроился, что мне на две недели позже пришла посылка, чем знакомым. Все в сравнении познается!


  1. radroxx
    22.11.2022 18:34
    +1

    Надо в него засунуть micropython или lua, что бы скрипты запускать прямо с флешки, закинул в папку и запускай. Я думаю разработка всякого интересного ускорится в разы.


    1. QtRoS Автор
      22.11.2022 18:47

      Тут в комментариях отметились создатели, возможно прокомментируют, @Dr_zlo13@koteeq