![image](https://habrastorage.org/webt/ze/kk/7w/zekk7wazxwvmnocgyavn1ccjmn8.png)
Эта программа транслирует игры с Raspberry Pi на портативную консоль Game Boy Advance через его соединительный порт. Видео и аудио в режиме реального времени сжимаются и отправляются на консоль, с которой осуществляется управление, что позволяет запускать на геймбое игры с любой платформы (отсюда и название Remote Play — дистанционный запуск)
Особенности:
- Запускает любую игру на GBA через RetroPie
- 120х80 мощных пикселей!
- ~60fps на стандартном режиме отображения
- Ретро-растр как на старом телевизоре ?
- Ещё больше мощных пикселей на разогнанных GBA
- Эксперименты с поддержкой аудио!
Вылеты на Game Boy Micro! (да-да, это особенность)
Создано [r]labs
Зацените мои другие проекты:
- piuGBA: Эмулятор PIU ?↙️↘️⬜↖️↗️?
- gba-link-connection: Библиотека мультиплеера ???
Содержание:
- Демо-видео
- Как это работает
- Установка
- GBA Jam 2021
- Благодарности
- Демо-видео
Как это работает:
⚠️ В этом разделе обсуждаются только детали реализации. Инструкции по установке вы найдёте ниже! ⚠️
По сути, программ две:
- На Game Boy Advance стоит образ ПЗУ, который получает данные
- На RaspberryPi стоит программа, которая собирает и отправляет данные
ПЗУ отправляется на GBA посредством мультизагрузочного протокола, который позволяет отправку небольших программ через соединительный кабель Game Boy Advance. Картридж не нужен.
Последовательная передача данных
Передача данных осуществляется через соединительный кабель Game Boy Advance, припаянный к контактам на RaspberryPi.
![image](https://habrastorage.org/webt/sa/78/ct/sa78ctoy9xk5m4exma4he8hslbo.png)
Распиновка соединительного кабеля GBA
Режимы передачи данных
GBA поддерживает несколько режимов последовательной передачи данных. Работа контактов кабеля и порта зависит от режима. Самые распространённые режимы:
- Обычный режим: по сути это 3 режим SPI, но здесь он называется “Обычный режим”. Скорость передачи данных может быть либо 256 Кбит/сек либо 2 Мбит/сек, с пакетами по 8 или 32 бита.
- Режим мультиплеера: то, что обычно используют геймеры, подключаться может до 4 консолей одновременно. Максимальная скорость передачи 115200 бит/с, а пакеты только по 16 бит.
- GPIO (ввод/вывод общего назначения): классический интерфейс ввода/вывода общего назначения, используется для управления светодиодами, вибромоторчиками и прочими мелочами.
Для нормальной частоты кадров в этом проекте используется максимально возможная скорость: обычный режим на скорости 2 Мбит/сек с пакетами по 32 бита.
Обычный режим/SPI
SPI это синхронный стандарт, он позволяет полнодуплексную передачу данных и поддерживается аппаратным обеспечением на многих устройствах. Связь происходит между ведущим и ведомым (подчинённым) устройствами, и когда ведущий посылает тактовый сигнал, оба устройства начинают отправлять друг другу данные (бит за битом).
![image](https://habrastorage.org/webt/-g/kb/df/-gkbdf1c3sf6rad3y8aa1m4tvhk.png)
Схема работы SPI. Оба устройства используют сдвиг регистра для циклического перемещения битов. Подробней можно прочесть здесь.
GBA может работать и как ведущий, и как ведомый, но RaspberryPi — только как ведущий. Таким образом, тактовым сигналом будет управлять Raspberry.
Для самого соединения нужны только 4 сигнала: CLK (такт), MOSI (выход ведущего, вход ведомого), MISO (вход ведущего, выход ведомого), GND (земля)
На GBA за них отвечают контакты 5 (SC), 3 (SI) и 6 (GND). На RPI это контакты GPIO 11 (SPI0 SCLK), GPIO 10 (SPI0 MOSI), GPIO 9 (SPI0 MISO), а также один из нескольких GND.
![image](https://habrastorage.org/webt/c8/q_/g1/c8q_g1g07w9a5qr1eesw6wfkqb0.png)
Соединение GBA и RaspberryPi
Некоторые особенности обычного режима GBA:
- Если вы соединяете два Game Boy Advance, то нужно использовать кабель от Game Boy Color, в противном случае связь будет односторонней: ведомый будет получать данные, а ведущий — только нули.
- Соединение на скорости 2 Мб/с надёжно только на коротких проводах, потому что предназначено для подключения специального оборудования расширения. По крайней мере, так говорят: я тестировал соединение с длинным кабелем, оно не “ненадёжное”, а просто медленное.
Код:
- SPIВедущий для Raspberry Pi
- SPIВедомый для GBA
- SPIВедущий для GBA (он использовался для джема GBA Jam)
Максимальная скорость
Максимальные скорости, которых мне удалось достичь в своих тестах на Raspberry Pi 3:
- Двунаправленная связь: 1,6 Мб/с. При скорости выше на Raspberry Pi начинает приходить полная ерунда.
- Односторонняя: 2,6 Мб/с. При увеличении скорости повреждаются пакеты.
- Односторонняя на разогнанном GBA: 4,8 Мб/с с использованием кристаллического осциллятора 12 МГц вместо стандартного (4,194Мгц).
В нашем случае достаточно односторонней связи, потому что нам важны только входящие данные и несколько синхронизированных пакетов с GBA. Это значит, что код постоянно переключается между двумя частотами в зависимости от необходимости ответа.
Во всех случаях Raspberry Pi приходится ожидать несколько микросекунд, чтобы дать процессору бедного геймбоя отдохнуть.
![image](https://habrastorage.org/webt/5a/rh/rq/5arhrqqevq_cftdb9htecxzltrs.png)
Тест скорости
Каждая точка обозначает 4 000 пакетов в секунду и каждая следующая точка добавляет ещё по 5 000. На максимальной скорости они все будут зелёные. Точка справа означает отсутствие повреждённых пакетов. Если она красная — нужно продолжать настройку.
Код:
- Задержка перед каждым переносом
- Тест производительности GBA
- Тест производительности RPI
- Конфигурация SPI
Ожидание MISO
В классическом SPI ведущий без разбора посылает тактовый сигнал, а приём и обработка пакетов ложится на ведомого. Однако в нашем случае GBA иногда слишком занят, например, выводом пикселей на экран, поэтому ему нужно иметь возможность послать ведущему сигнал остановиться.
Согласно тому, что рекомендуется в руководстве GBA, во время простоя ведомый может перевести MISO на HIGH — мастер прочтёт это значение как входной контакт GPIO и будет ожидать, когда ведомый переключится на LOW.
![image](https://habrastorage.org/getpro/habr/post_images/8bd/3f7/2ab/8bd3f72abbfa144f6edac58effabf44b.gif)
Пожалуйста, не отправляй мне ничего!
Код:
Видео
Считывание экранных пикселей
Для начала нужно настроить Raspbian, чтобы размер кадрового буфера совпадал с разрешением на GBA: 240х160. Помогут нам в этом два свойства
framebuffer_width
и framebuffer_height
внутри /boot/config.txt
Линукс отображает все данные пикселей экрана (кадровые буферы) в файлах устройства, например
/dev/fb0
. Это подходит для настольных приложений, но не для полноразмерных игр использующих, например, OpenGL, так как они напрямую взаимодействуют с процессором Raspberry Pi. Поэтому, чтобы считать цвета с любого приложения, мы будем использовать API DispmanX (вызывая vc_dispmanx_snapshot(...)
один раз за кадр), который создаст нам хорошую RGBA32 матрицу пикселей со всей информацией экрана.![image](https://habrastorage.org/webt/rz/n_/oj/rzn_ojh5tqp9ytrgrm5lol0mlqa.jpeg)
Один из множества вариантов неправильного считывания кадрового буфера
Код:
Вывод на экран GBA
GBA воспринимает модель RGB555 (15 бит на пиксель для каждого цвета), то есть 5 бит на красный, 5 на зелёный и 5 на синий без альфа-канала. Так как это little-endian система (записывается от младшего к старшему), то первым цветом идёт красный.
Для вывода этих цветов на экран, он поддерживает 3 различных растровых режима. Я использовал 4 режим, где каждый пиксель является 8-битной отсылкой к палитре из 256 15-битных цветов. Но нужно помнить о том, что при использовании 4 режима VRAM не поддерживает 8-битную запись, поэтому сначала придётся прочесть содержимое адреса, чтобы переписать полуслово/слово.
![image](https://habrastorage.org/webt/f_/-z/-s/f_-z-sgqxyidmccl_iist1qjnds.png)
Схема 15-битного цвета
Код:
Цветовое квантование:
Raspberry Pi приходится квантовать каждый кадр по цветовой палитре в 256 цветов (т.е. уменьшать количество цветов изображения). При первой попытке я использовал библиотеку квантования, которая создавала самую оптимальную палитру для каждого кадра. Это был слишком медленный процесс, хоть он и хорошо сохраняет качество изображения. В итоге я стал использовать фиксированную палитру (эту) и подбирать ближайшее значение для каждого цвета к байту цвета из палитры.
![image](https://habrastorage.org/webt/5_/cq/vo/5_cqvo0xpwadp7gvbkiousjly_u.png)
Оригинал
![image](https://habrastorage.org/webt/8e/vl/a0/8evla0jinv8jy_4lltyitsgw_ay.png)
Квантованное изображение
Для ускорения подбора цветов, при первом запуске RPI создаёт таблицу поиска на 16 Мб под названием “palette cache” (кэш палитры — прим. переводчика) со всеми возможными цветовыми преобразованиями. Такой размер обусловлен количеством возможных цветов (2^24) и размером показателя цвета в палитре — 1 байт.
Код:
- 15-битовая палитра на GBA
- 24-битовая палитра на RPI
- Вычисление ближайшего цвета
- Palette cache
- Код JavaScript для построения таблицы
Масштабирование
Несмотря на то, что кадровый буфер имеет размер 240х160, данные, отправляемые на GBA возможно настраивать, так что если убойное разрешение для вас важнее деталей, вы можете отправить разрешение 120х80 и использовать эффект мозаики, чтобы увеличить масштаб изображения так, чтобы он заполнял весь экран. Если же вам больше нравятся старые кинескопы, то можно отправлять изображение разрешением 240х80 и выводить искусственные линии растра между настоящими строками.
Raspberry Pi отбрасывает каждый пиксель, не кратный масштабу. Например, если для ширины вы используете коэффициент масштаба х2, он отбросит все нечётные пиксели, и в итоге ширина будет 120, а не 240.
Нужно принять это во внимание при рендере, потому что 4 режим GBA будет ожидать матрицу 240х160 — в противном случае заполнится только часть экрана.
Три примера масштабирования одного и того же клипа 120х80
![image](https://habrastorage.org/getpro/habr/post_images/65f/051/444/65f0514444dc0b091601ed7097f4440f.gif)
Без масштабирования
![image](https://habrastorage.org/getpro/habr/post_images/762/9b2/e09/7629b2e09ac156b7a7c4eac06ef7bfdd.gif)
Увеличение в два раза с эффектом мозаики
![image](https://habrastorage.org/getpro/habr/post_images/f78/cfb/b2b/f78cfbb2b6ff63fb098762265577712e.gif)
С линиями развёртки
Код:
Сжатие изображения
Временные различия (ориг. Temporal diffs)
Код отправляет только те пиксели, которые изменились на новом кадре, и то, что понимается под “изменились”, можно настраивать: в конфигурации времени выполнения есть параметр “compression” (порог различия; “сжатие” — прим. переводчика), который контролирует насколько сильно цвет должен отличаться от предыдущего, чтобы его обновлять.
На стадии сжатия он создаёт битовую карту, где 1 означает что пиксель действительно изменился, а 0 — что он остался прежним. Затем он отправляет карту и все пиксели с 1.
Пример карты различий 13x1
Код:
Кодирование длин серий
Итоговый буфер временно́го сжатия (ориг. “temporal compression”) кодируется с помощью длин серий.
В изображениях с палитрой существует большая вероятность встретить последовательности пикселей одного цвета. Либо же, например, во время смены картинки на экране, когда все пиксели чёрные, вместо того, чтобы отправлять N чёрных пикселей (N байт), мы можем отправить 1 байт, обозначающий N и 2 байта, обозначающие чёрный цвет. Это называется кодирование длин серий (или кодирование повторов).
Правда, КДС не всегда улучшает ситуацию: иногда буфер получается длиннее изначального, потому что на каждый байт с информацией о повторе приходится добавлять один байт о количестве этих повторов. По этой причине кодирование проходит в два этапа, и КДС применяется только если таким образом данные действительно можно будет сжать. Затем в метаданных кадра записывается бит, который указывает, кодировалась ли информация с помощью КДС или нет.
![image](https://habrastorage.org/webt/m8/x1/ie/m8x1ieaj-6kb2odd8bvq6q5h1wi.png)
Кодирование сжатого буфера
Код:
Обрезка различий (ориг. Trimming the diffs)
Для разрешения рендеринга 120х80 битовая карта будет размером в 1200 байт (120Х80/8). Это довольно много для покадровой передачи, поэтому отправляется только часть от первой 1 до последней 1, конечно же 32-битными пакетами.
v startPacket v endPacket
PACKET 0 PACKET 1 PACKET 2 PACKET 3 PACKET 4 PACKET 5
BYTE 0 BYTE 1 BYTE 2 BYTE 3 BYTE 4 BYTE 5 BYTE 6 BYTE 7 BYTE 8 BYTE 9 BYTE 10 BYTE 11 BYTE 12 BYTE 13 BYTE 14 BYTE 15 BYTE 16 BYTE 17 BYTE 18 BYTE 19 BYTE 20 BYTE 21 BYTE 22 BYTE 23
00000000000000000000000000000000000000000000000000000000000000000000000000100100110010000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
^ startPixel ^endPixel
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ packetsToSend
Ввод
Каждый кадр GBA отправляет информацию о нажатых кнопках на Raspberry Pi. Он считывает реестр REG_KEYINPUT и переносит его во время изначального обмена метаданными.
![image](https://habrastorage.org/webt/kz/gm/wc/kzgmwcc8li6_zlxqkcgnmeq6quo.png)
Биты задаются, когда кнопки НЕ нажаты. Странный дизайн!
На Linux есть
/dev/uinput
, который позволяет процессам в пользовательском пространстве создавать виртуальные устройства и обновлять их состояние. Вы можете создать свой собственный виртуальный геймпад в каком угодно виде, например, добавив аналоговые джойстики и потом соотнеся D-pad геймбоя с аналоговыми значениями.На первой реализации был установлен простой геймпад с той же расстановкой кнопок, что у GBA. Последняя же версия позволяет пользователю самому настраивать файл
controls.cfg
и комбинации клавиш в нём, поэтому она поддерживает игры с более сложными паттернами управления.Код:
- Виртуальный геймпад (первая версия)
- Виртуальный геймпад (последняя версия)
- Настройка управления
- Название геймпада
Обзор протокола
В начале происходит синхронизация пакетов
reset
(сброса настроек)Далее для каждого кадра шаги следующие:
- (Сброс настроек при необходимости)
- Построение кадра (только на RPI)
- Начало синхронизации кадров
- Обмен метаданными
- (Синхронизация и перенос звука, если он есть)
- Начало синхронизации пикселей
- Перенос пикселей
- Конец синхронизации кадра
- Рендер (только на GBA)
Код:
Синхронизация пакетов reset
Часть конфигурации времени выполнения отправляется в данном reset пакете:
10011001100010000111000000000000
____________________^###****$$$$
| || | |
> magic number || | > render mode: frame width and height
|| > control layout: defined in the configuration file
| > compression: affects temporal diffs' threshold
> CPU overclock flag: if 1, it uses overclocked SPI timings
![image](https://habrastorage.org/webt/dx/5k/qn/dx5kqnvsdta2pxonxkj9ntbwlsk.png)
Меню конфигурации времени выполнения
Код:
- Меню конфигурации времени выполнения
- Синхронизация пакетов reset на GBA
- Синхронизация пакетов reset на RPI
Обмен метаданными
На этом шаге GBA отправляет свои вводные данные и получает пакет frame metadata (метаданных кадра)
00000000000000000000000000000000
^#**************$$$$$$$$$$$$$$$$
||| |
||| > start pixel index (for faster GBA rendering)
|| > number of expected pixel packets
| > compressed flag: if 1, the frame is RLEncoded
> audio flag: if 1, the frame includes an audio chunk
Для проверки этот перенос выполняется дважды. Во второй раз каждое устройство отправляет полученный в первый раз пакет. Если они не совпадают => Сброс настроек!
Код:
Аудио
Для аудио на GBA был портирован аудио кодек GSM Full Rate. Он ожидает 33-байтовые аудиофреймы, но чтобы уцелеть при падении фреймов, они группируются в блоки, а их длина определяется постоянной времени построения
AUDIO_CHUNK_SIZE
Код:
Чтение системного аудио
Со стороны Raspberry Pi мы будем использовать предустановленную виртуальную аудиокарту. Когда запускается модуль (
sudo modprobe snd-aloop
), появляются два новых звуковых устройства (для воспроизведения и записи).![image](https://habrastorage.org/webt/a5/to/gs/a5togszyqomlsiisbkbdjcvo6hi.png)
Устройства воспроизведения
![image](https://habrastorage.org/webt/gl/av/oj/glavoj7cx-nmp9vf9p3uel_1jbg.png)
Устройства захвата звука
Принцип работы таков: если одно приложение проигрывает звук на, например,
hw:0,0,0
(карта 0, устройство 0, субпоток 0), другое приложение может записывать на hw:0,1,0
, и захватывать аудио. Чтобы мы могли записывать любой звук с ОС, аудиовыходом по умолчанию должны стоять карты обратной связи (ориг. loopback cards).Кодирование фреймов GSM
Кодирование GSM выполняется с помощью ffmpeg. Порт GBA требует нестандартной частоты в 18157 Гц, поэтому нам нужно приказать ему игнорировать результаты своих сверок — “да-да, официально она не поддерживается, но мне плевать”, а также сообщить новую частоту.
Команда выглядит вот так:
<
ffmpeg -f alsa -i hw:0,1 -y -ac 1 -af 'aresample=18157' -strict unofficial -c:a gsm -f gsm -loglevel quiet -
>
Клянусь, это аудио!
Код:
- Аудио обратной связи (ориг. LoopbackAudio)
Управление конвейерами Linux
Тире — в конце команды ffmpeg означает “отправь результат на
stdout”
. Код запускает процесс с потоком popen и считывает всё через созданный конвейер.Так как перенос фрейма занимает время, иногда создаётся больше аудиофреймов, чем мы можем использовать. Если ничего с этим не делать, в какой-то момент накопится большая очередь из устаревших аудио и нам придётся слушать фреймы из прошлого, ещё и с большой задержкой.
![image](https://habrastorage.org/getpro/habr/post_images/2a6/9da/23d/2a69da23d5f93667686a2ede923caab8.gif)
Наш геймбой тусит под устаревшие аудиофреймы
Для решения этой проблемы есть ioctl (системный вызов) под названием FIONREAD, который поможет нам вытащить количество накопленных байтов. Чтобы пропустить их, мы призовём системный вызов splice; он перенаправит ненужные байты в
/dev/null
.Код:
Успеваем распаковать данные
Это была самая сложная часть проекта. GBA и без того занят выводом пикселей в растровом режиме, а теперь ещё и нужно распаковывать GSM фреймы! И без задержек. Многие могут потерпеть низкую частоту кадров, но вот слышать писк или не слышать ничего вообще между аудиосэмплами неприемлемо для всех.
Насколько я понял, GSMPlayer распаковывает GSM фреймы, помещает полученные сэмплы в двойной буфер и настраивает DMA1 на их копирование по аудио адресу GBA, используя специальный режим синхронизации, который согласуется с копией Timer 0.
![image](https://habrastorage.org/webt/jh/yg/gu/jhyggudkzwolhzd7fj3dy3y4pfm.png)
Я, пытающийся изменить код GSMPlayer
Чтобы предотвратить фрагментацию, помехи и так далее, аудио должно быть скопировано вовремя. В обычных играх это делается через прерывание VBlank, но здесь это не сработает. На скорости передачи 2,6 Мбс для обработки данных доступно очень ограниченное количество циклов, и если добавить в этот процесс ещё одно действие, пакеты просто придут в хаос.
Пришлось сделать так, чтобы любую передачу можно было отменить: если пришло время запускать аудио (мы сейчас в VBlank), мы всё останавливаем, запускаем аудио, а затем начинаем процесс восстановления, сообщая Raspberry Pi где мы в данный момент. При любых начальных и конечных пакетах, а также при пакетах
TRANSFER_SYNC_PERIOD
каждого потока RPI отправляет двунаправленный пакет (на низкой скорости), чтобы проверить, нужно ли запускать “режим восстановления”.Код:
Разгон EWRAM
Опционально, GBA может разогнать внешнюю RAM так, чтобы она использовала только одно состояние ожидания вместо двух. При запуске на GameBoy Micro процесс вылетает, но кто вообще будет запускать это всё на GB Micro?
![image](https://habrastorage.org/webt/uj/mn/1o/ujmn1omz5bjjgsql7eefshyseow.png)
Парень, который использует GB Micro с подключённым к нему Raspberry Pi
Код:
Установка
Полное руководство:
Быстрое руководство:
- Припаяйте соединительный кабель к Raspberry Pi, следуя информации в секции “Обычный режим/SPI”
- Установите RetroPie
- Откройте параметры RetroArch и установите:
Settings -> Video -> Scaling -> Aspect ratio -> 4:3
Configuration File -> Save current configuration
- Запустите
sudo raspi-config
и установите:
Interface Options: Enable SPI
- Запустите
sudo apt-get install -y wiringpi python-pigpio python3-pigpio
Добавьте следующие атрибуты в
/boot/config.txt
<
# Set Aspect Ratio (4:3)
hdmi_safe=0
disable_overscan=1
hdmi_group=2
hdmi_mode=6
# Set GBA Resolution
framebuffer_width=240
framebuffer_height=160
>
- Загрузите необходимые файлы в
/home/pi/gba-remote-play
(из раздела Releases)
- Из той же директории запустите
chmod +x gbarplay.sh multiboot.tool raspi.run
- Отредактируйте
/etc/rc.local
и добавьте туда/home/pi/gba-remote-play/gbarplay.sh &
перед строкойexit 0
- Перезапустите и включите ваш Game Boy Advance
Аудио (опционально)
Эта часть опциональна, потому что на Raspberry Pi уже есть контакты для старого доброго аналогового аудио, туда можно подключить колонку и получится звук хорошего качества. Вдобавок работа с аудио в этом проекте проходила в виде эксперимента, и подключение звука очень сильно ухудшает частоту кадров. Со звуком тестировалась только версия v1.0.
Если вы всё равно хотите, чтобы звук выходил из колонок на GBA, вот инструкция:
При загрузке захватите файл video-and-audio.zip из версии v1.0
Измените
/etc/modprobe.d/alsa-base.conf
следующим образом:<
options snd_aloop index=0
options snd_bcm2835 index=1
options snd_bcm2835 index=2
options snd slots=snd-aloop,snd-bcm2835
>
Далее, когда запустите
cat /proc/asound/modules
, вы должны увидеть следующее:<
0 snd_aloop
1 snd_bcm2835
2 snd_bcm2835
>
Теперь запустите sudo modprobe snd-aloop и установите Loopback (Stereo Full Duplex) как аудиовыход по умолчанию.
GBA Jam 2021
Большая часть кода для этого проекта была написана для джема GBA Jam 2021. Так как этот проект не подходит для джема по требованиям (привлекалось внешнее оборудование), в разделе Releases есть демо-версия, в которой один GBA отправляет видео со звуком на другой GBA через соединительный кабель.
Вот видео:
Код для этой демо-версии можно найти в ветке #gba-jam
Благодарности
Этот проект был реализован благодаря нескольким библиотекам открытого исходного кода:
- GBA hardware access by libtonc
- GBA audio decompression by gsmplayer-gba
- GBA multiboot writer by gba_03_multiboot
- Raspberry PI SPI transfers by the C library for Broadcom BCM 2835
В демо для GBA Jam было использовано два клипа из Blender под лицензией Creative Commons:
А также документация, которой я пользовался:
- TONC: Самое лучшее руководство по GBA всех времён и народов
- GBATEK: Документация по NO$GBA с технической информацией по GBA
- dispmanx API
- Руководство по использованию uinput на Linux
- Играемся с ALSA loopback-устройствами
И отдельное спасибо моему другу Лукасу Фризеку (@Hazematman), который обладает огромными знаниям о встраиваемых системах — он очень помог мне с дизайнерскими решениями.
![](https://habrastorage.org/webt/wn/cq/lp/wncqlp9abeml4npwzsybuvhzcta.png)