Наверняка, многие, работающие в сфере gamedev знакомы с такой крупной компанией как Valve, владеющей на сегодняшний день наиболее популярной игровой платформой Steam. Известна она стала за счёт серии игр Half-Life, породивших огромное количество модов из-за своей открытости движка и его возможностей. В первой игре Half-Life Valve использовала примитивную q-physics, выполняющую только примитивные линейные сдвиги объектов без реалистичных отскоков от поверхностей, скольжения и т.д… В серии игр Half-Life 2 с 2004 был представлен движок Source – модифицированный GoldSource. В качестве физического движка Valve представили миру первый на тот момент движок, поддерживающий всевозможные петли, затворы, двигатели, сопротивление воздуха, однако, несмотря на то, что файл и все интерфейсы назывались vphysics (Valve Physics), внутри этот файл представлял собой набор интерфейсов в виде обёртки поверх IVP (Ipion Virtual Physics), и поблагодарить за то, что вся физика в Half-Life именно такая, какая есть, стоит немецких разработчиков, сделавших его и продавших Valve Software в конце 90-х.
В начале 2013 года, Valve выложили на своём гитхабе ssdk2013 – официальное Source SDK 2013 года, совместимое с текущими версиями Source игр, что открыло огромные возможности для модинга, и так как это sdk является основой всего их кода всех текущих Source игр от DOD до CSGO, то я думаю вам будет интересно услышать о косяках, багах, кривизне и непрофессионализме при разработке игр в такой крупной компании как Valve.
Начнём с того, как же я всё это обнаружил? Дело в том, что с раннего детства я любил Half-Life 2 как игру, и с декабря 2009 года я начал копаться в файлах этой самой игры, будучи ещё мелким ребёнком, не понимающим, как всё устроено. До появления ssdk2013 я довольствовался слитыми в сеть исходниками движка и игр 2007 года, однако с появлением их актуальной базы я тоже решил покопаться в их серверном коде и переписать те моменты, которые мне там не нравились. И так, я представляю вашему вниманию список самых заметных и раздражающих багов в игре Half-Life 2: Deathmatch.
Наверное самое первое, что может заметить любой игрок, зашедший первый раз в игру, так это то, что он не может поменять модель своего персонажа через настройки. Чтобы ты не нажимал – всегда выбирается стандартная модель models/combine_soldier.mdl. Почему же так происходит? Тут дело не совсем в самой игре, а в gameui.dll, отвечающий за базовые игровые панели, такие, как, например, окно настроек. В одном из обновлений движка, Valve изменила политику токенизации строк в командном обработчике, и, после этого наличие прямопадающего слэша вызывает у токенайзера разделение на всё что до слэша, слэш, и всё что после слэша. Как не сложно догадаться, на сервер доходит только cl_playermodel models.
Хочу рассмотреть пример того, как Valve реализовали класс кинутых игроком гранат npc_grenade_frag. Первое, что бросается в глаза, так это то, что эффект свечения и эффект следа за гранатой прикреплены к её пивоту (нулевой точке в локальной системе координат модели), вместо атачмента под названием fuse01, который, по какой-то непонятной причине отсутствует у копии гранаты для игры HL2DM. Также сломаны physprop звуки коллизии и трения о предметы. Помимо всех этих визуальных багов, данный класс предоставляет замечательную возможность абсолютно любому игроку без каких либо ограничений крашить любые игровые сервера, где это не исправлено, а исправлено это на нескольких серверах, включая мои. В случае, если сервер не исправлен, любому игроку достаточно в течении пары минут кидать и подбирать одну и ту же гранату, не давая ей взорваться, и с каждым разом, как гранату будет подбирать гравипушка, она будет исполнять OnPhysGunPickup, пересоздавая эффекты гранаты снова и снова, пока кол-во синхронизируемых объектов не превысит допустимый лимит движка, а именно 2048. Они не проверяют наличие эффектов у класса, а просто перезаписывают указатели каждый раз при вызове той самой функции.
Бесспорно, многие, кто начинал играть в HL2 любили её за наличие гравипушки, и если в синглплеере она работает на ура, то в мультиплеере остаётся надеется на лучшее…
Начнём с банального скейла спрайтов, которые должны быть всегда “пушистыми”, однако в их мультиплеерном коде они не применяют разницу FOV.
Также, у гравипушки зачастую переглючивают эффекты удара левой кнопкой. Это связано с общей проблемой предикции в HL2DM, о которой мы поговорим чуть позже.
Ко всему этому добавим кривой phys_swap, команда, по умолчанию поставленная на кнопку g и предназначена для моментального свопа с и на гравипушку. Этот phys_swap реализован без предикции и это приводит к тому, что он насильно говорит клиенту переключится с гравипушки, если она активна, что в свою очередь приводит к тому, что на клиенте не производится проверка на активный объект, и если таковой имеется, указатель на его физобъект не высвобождается, так как Valve забыли подчистить это перед переключением оружия, не смотря на то, что в соответствующих коллбеках находится код остановки звуков. В результате мы можем наблюдать зависший объект в воздухе, не смотря на то, что его настоящий инстанс находится на сервере вообще уже на полу, а возможно, уже совершенно в другом месте. Это не редко приводит к серьёзным сбоям клиента, в результате которых он падает. И это мы даже не подошли к технической реализации самой гравипушки!
Как известно, у неё существует 2 режима стрельбы: отталкивание (PrimaryAttack) и притягивание (SecondaryAttack). Логично предположить, что существует функция для трассирования впередилежащего объекта, которая говорит можно ли что-либо притянуть и т.д. Однако в Valve, видать, гравипушку писали сразу три человека! Потому что если взглянуть на код PrimaryAttack, SecondaryAttack и третей вспомогательной функции для анимирования открытия/закрытия форок CheckTarget, можно увидеть, что код отличается, хотя имеет одинаковый смысл, и вместо того, чтобы сократить и вынести этот код в отдельную функцию, они просто оставили такую инконсистентность в своём же коде.
А теперь ко всему этому ещё добавляется факт того, что один из наиболее распространённых объектов в игре prop_combine_ball, являющийся снарядом альтернативной атаки импульсной винтовки AR2, отстаёт от игрока на его задержду. Как, хотите спросить вы? Почему именно шар от AR2? Почему со всеми остальными объектами всё нормально, а с этим нет? Всё потому, что prop_combine_ball для чего-то реализует кастомный рендеринг своей модели. Когда гравипушка держит шар – он представляет собой 3D-эффект модель без физобъекта, когда его кидаешь или отпускаешь, включается оверрайд, и 3D эффект превращается в 2D спрайт. И несмотря на то, что из-за этого оверрайда на клиенте репортится неверный m_iModelIndex, у этой модели эффекта ИЗНАЧАЛЬНО нету физмодели.
Мы с моим другом, занимающимся 3DS-Max моделированием, сделали эту физмодель и поставили в список загрузок при входе на сервер. В результате, все игроки, поигравшее на наших серверах получали фикс для этих шаров и никто об этом даже не знал.
Думаете на этом баги гравипушки заканчиваются? Нет. Дальше нас ждут лаги предметов, которые мы держим. Во время передвижения, чем больше пинг игрока, тем сильнее дёргается предмет в его гравипушке. Это происходит из-за так называемой “оптимизации”, как написали Valve в коментах к переменной, которая сломала плавность раз и навсегда с того обновления. Причина кроется в том, что cl_pred_optimize 2, который равен именно 2 в стандартном клиенте, означает уровень допустимых ошибок при обработке предикционных буферов памяти. 2 – означает не проводить ретраверс предикционного буфера, если ошибки были в пределах нормы, 1 – не производить ретраверс предикционного буфера, если на клиенте наступил новый фрейм, а с сервера не пришло подтверждения соответствующего world update’а, 0 – обробатываеть каждый клиентский фрейм начиная с полного ретраверса предикционного буфера с последней подтверждённой команды. Поближе я рассмотрю систему клиентсайд предикции чуть позже. А сейчас вернёмся к гравипушке, у которой всё ещё есть куча нерассмотренных багов!
Частота вызова функции, которая должна обрабатывать притягивание объектов равна 10 раз в секунду, что делает ловлю быстрых снарядов и мелкой физики крайне затруднённым во время активного сражения.
Улучшается качество ловли банальному ускорению вызова функции ловли, заставляя её выполняться ежефреймово, при этом уменьшая силу притяжения в обратное количество раз. Результат великолепен, особенно если ты часто играешь в эту игру и привык к кривой гравипушке на стандартных серверах.
Далее на очереди идёт функция сортировки по полю обзора, по умолчанию равная скалярному произведению от векторов прямого направления взгляда и направлению от глаз игрока к геометрическому центру объекта, а именно значению в пределах 0.97. Это позволяет подбирать предметы не смотря чётко на центр модели. Однако в этой функции сортировки отсутствует консистентность проверки на “можно ли подобрать этот предмет”, что приводит к тому, что если игрок стоит в двигающимся лифте на полу которого лежит граната, гравипушка предпочтёт притянуть лифт, у неё это не выйдет, а граната к этому моменту уже как 1,5 секунд как взорвалась.
Ещё, на клиенте присутствует довольно забавный баг с отрисовкой объектов с рендергруппой, отличной от kFxRenderGroupNormal, что приводит, к, например тому, что при наличии альфа канала у текстуры браша, который по Z уровню находится дальше на экране, чем объект в гравипушке, этот самый объект в гравипушке будет отрисован насильно позади этого браша.
Так же у гравипушки не слышно звуков открытия/закрытия форок и звука притягивания/отпускания объектов, однако все остальные игроки будут их слышать. Это связано с тем, что они забыли настроить фильтр предикции локального игрока для того, чтобы он не игнорировал его, а наоборот, тоже посылал эти звуки.
Помимо таких, казалось бы, не критичных багов, в Source движке существуют действительно тупые ошибки, приводящие к эксплоитам разной степени тяжести. Например, написав в консоли impulse 51 многое кол-во раз, перед игроком начнутся спавниться рандомные аптечки, батарейки и разнообразные патроны, что при длительном исполнении приведёт к переполнению максимально возможного кол-ва синхронизируемых объектов.
Также в SE существует достаточно интересная технология под названием лагокомпенсация, однако для того, чтобы полностью понять как она устроена, требуется понимание модели клиент-сервер в SE.
Итак, модель передачи данных между клиентом и сервером достаточно проста: это 2 UDP сокета один в одну, другой в другую сторону. Прикол их протокола в том, что клиент и сервер на уровне движка реализуют подклассы для каждого типа пакета, и всё остальное движок делает автоматически. Далее они экспортируют интерфейс этого класса и разработчики непосредственно клиента и сервера используют готовые колбеки. Сервер по умолчанию настроен на тикрейт 66, что значит 66 обновлений мира в секунду. Однако это не значит что константа интервала между фреймами будет 1/66, что равно очень некрасивым 0.01515151515151515151515151515152, а более лаконичным 0.015f, что является дефолтной константой DEFAULT_TICK_INTERVAL. Однако тут мы натыкаемся на очередной косяк. Разработчиками заявлено, что тикрейт сервера динамичен и может быть реконфигурирован. Однако, стоит сменить тикрейт в HL2DM на 100, т.е. 0.010f между фремами, как ВНЕЗАПНО всякие триггеры, двери, лифты, и прочая физика начинает моментально убивать/не работать корректно при взаимодействии с игроком. Всё потому, что у Valve есть 2 константы: DEFAULT_TICK_INTERVAL и TICK_INTERVAL. Первый – захардкоденый дефайн на 0.015f, второй – алиас на gpGlobals->interval_per_tick, который выставляется динамически и может быть изменён параметром запуска –tickrate
Также мной и моим финским другом была обнаружена одна интересная уязвимость в реализации протокола таймаутов, которая буквально позволяла нам получать godmode на официальных серверах против всего лагокомпенсируемого оружия. Смысл эксплоита заключается в том, что когда клиент симулирует локальный мир для воссоздания предикционной модели, его серверная копия должна делать это синхронно. По этому игроки не симулируются каждый фрейм, в отличии от всех остальных объектов, а симулируются вообще в отдельном потоке, в котором обрабатываются пришедши пакеты от клиента. Т.е. фактически контролирует перемещение игрока клиент, а не сервер, и именно благодаря этому возможен такой эксплоит как airstuck, который по хитрому блокирует отправку пакетов о перемещении игрока и он застряёт в воздухе, словно завис. Так вот, этот эксплоит, который в будущем мы назвали Sequence Freezing Exploit, это эксплоит позволяющий с клиента заморозить время симуляции нашего объекта на сервере, что приводит к полной поломке анимаций и хитрегистрации. Всё из-за того что обе системы не рассчитаны на то, чтобы симуляционное значение останавливалось на одном месте. Секрет его реализации до сих пор находится в неразглашении, так как мы бы очень не хотели, чтобы этот эксплоит был пофикшен.
Ещё к списку интересных ляпов я пожалуй отнесу мой любимый класс CBaseCombatWeapon, в котором они умудрились поменять сигнатуру виртуальной функции CanHolster с не const на const но при этом не заменили все дочерные функции. Это приводит к тому, что всё оружие можно всегда переключать, даже если это не запланировано игрой.
Вернувшись к теме о нетворкинге, я хочу привести пример корректной настройки сервера, а именно:
sv_parallel_packentities 0 // Avoids engine crashes related to multithreading
net_maxfilesize 64 // Max file size for downloading
net_maxcleartime 0 // Magic
net_splitrate 32 // Magic
net_splitpacket_maxrate 1048576 // 1MB/Sec (1048576)
rate 1048576 // 1MB/Sec (1048576)
sv_lan 0 // Not a local game
sv_timeout 30 // 30 seconds
sv_max_connects_sec 1 // Disallow reconnect exploit
sv_maxrate 1048576 // Clamp rate
sv_minrate 1048576 // Clamp rate
sv_maxcmdrate 100 // Clamp cl_cmdrate
sv_mincmdrate 100 // Clamp cl_cmdrate
sv_maxupdaterate 100 // Clamp cl_updaterate
sv_minupdaterate 100 // Clamp cl_updaterate
sv_client_cmdrate_difference 0 // Clamp cmdrate between min/max
sv_allow_wait_command 0 // Disallow scripts
Почему именно эти значения? Ну, тут всё предельно просто: так как интервал между фреймами в нашем случае равен 0.01f, то, соответственно, оптимальное значение интерполяции будет IPT * 2, где 2 – максимальное кол-во потерянных пакетов перед тем, как на клиенте нарушится плавность интерполированного изображения. Интерполяция мира в этом случае нужна, так как UDP не гарантирует, что следующее обновление мира придет ровно через x времени, а соответственно, для сглаживания того, что мы видим на экране, необходимо иметь какой-то запас для вычислений. Элементарно, скорость, как величина не передаётся игрокам, так как не является networkable пропом, однако на клиенте существует аналог под названием EstimateAbsVelocity, предназначен для приблизительного вычисления скорости любого объекта по его интерполяционной истории. Раз уж я заговорил о рейтах и твикинге сорсовских серверов, я просто обязан упомянуть, что большинство людей в этом небольшом сообществе не знают что это и как оно устроено, однако по какой-то не понятной мне причине они ставят cl_interp в, скажем, 0.333383 и говорят что именно ЭТО значение лучше, однако не могут аргументировать почему. А не могут они это сделать, так как у таких значений на самом деле нету никакой аргументации. Дело в том, что серверный код лагокомпенсации, вычисляющий нужный фрейм во время произведения выстрела не может взять данные “между” фреймами. ОДНАКО, на клиенте, интерполяция может быть задана в значение не кратное межфреймовой задержке, что буквально ломает хитрег, так как, повторюсь, сервер не может лагокомпенсировать “между” фреймами. В итоге, имея интерполяцию в 20ms и интервал между тиками в 10ms, промежуточные значения вычислений в коде лагокомпенсации приведут к более кратным и лаконичным значениям.
А теперь давайте поближе познакомимся с тем как утроена лагокомпенсация.
Так как все выстрелы, произведенные клиентом, обрабатываются на сервере, во избежание откровенного читерства, Valve нужно было свести задержку между игроками на нет, и они справились с этим относительно на ура. По сути, цель лагокомпенсации ресеймплить состояние мира на протяжении последней секунды и в случае выстрела – восстановить состояние мира в нужное значение. Когда игрок 1 производит выстрел в игрока 2, сервер, при получении данной команды выстрела смотрит на то, какой номер фрейма был у игрока 1 во время выстрела, модифицирует это значение чтобы скомпенсировать визуальное отставание из-за интерполяции и ищет соответствующий снапошот состояния мира. Это в свою очередь означает, что с каждым выстрелом каждого игрока, сервер будет восстанавливать состояние мира, которое было в момент выстрела в локальной игре игрока 1, телепортируя всех других игроков туда и обратно. Работает эта система достаточно хорошо, за исключением парочки нюансов, а именно: 1) если задержка бектрекинга локального игрока не кратна интервалу между фреймами, лагокомпенсация округлит его в большую сторону, и, 2) в стандартном коде из ssdk у них не лагокомпенсируются альтернативные атаки. Т.е. в HL2DM не работает лагокомпенсация на правую кнопку мыши. И это потому что они банально отклоняют её, проверяя наличие IN_ATTACK и только.
Начнём с того, что если бы не было предикции в SE, то при вводе с клавиатуры, наш локальный игрок начинал бы двигаться только через x миллисекунд, где x – значение равное исходящей + входящей задержке + интерполяция. Однако, этого не происходит в меру того, что на клиенте реализована система, которая грубо говоря создаёт историю из сгенерированных, но ещё не обработанных команд, и каждый последующий фрейм эта система должна проверить последние полученные с сервера данные о положении, углах и скорости игрока, очистить самые старые, подтверждённые команды из вектора и вернуть себя на то самое “подтверждённое” сервером место, после чего локальная предикция проходится заново по буферу застакованых команд и перемещает игрока так, как он бы перемещался на сервере при таких же исходных данных. Грубо говоря, название этой системы как бы говорит, что её смысл в обработке ввода игрока, для того чтобы сгладить перемещение, да и вообще любой ввод. Вот тут то мы и натыкаемся на один не очень красивый нюанс в сорсе: по умолчанию существует так называемая мера оптимизации погрешностей, суть которой не перепроверять все застакованые команды в предикционном буфере, если ошибки были в предлелах допустимого. Это приводит к тому, что объекты в гравипушке начинают жутко дёргаться с увеличением физической задержки до сервера, и связано это в первую очередь с тем, что промежуточный буфер накапливает приличное кол-во ошибок, которое при достижении определённого значения просто говорит системе “ПЕРЕЗАПУСТИСЬ!” и она перепроганяет все данные заново, драматически “телепортируя” объект в гравипушке в его новое, реальное положение.
Что ж, вот мы и подошли к столь щепетильной теме, как Valve Anti-Cheat. Наверняка многие из вас слышали о нём, но не догадывались, как он реализован. Сейчас я расскажу список фактов и интересных нюансов, которые я обнаружил в процессе реверс инжиниринга и банального тестирования.
И так, VAC не эвристичен. Это банальный сигнатурный сканнер, который умеет сканировать сигнатуры. Не более. Для того, чтобы VAC обнаружил чит и выдал бан, сначала необходимо, чтобы его команда разработчиков непосредственно купила копию чита и составила по ней сигнатуры. Это означает, что VAC никогда не определит ваш приватный .exe/.dll если вы его нигде не светили и при его построении не использовали общедоступные сигнатуры строк и интерфейсов при интеграции чита с игрой. Однако что делать с банами, когда ты никому не давал копию своего чита? VAC умеет производить хеширование загруженных в память PE секций, что приводит к тому, что если вы поделились своим приватным читом с 10 своими друзьями, то при регулярных сканах VAC вычислит совпадение в памяти каждого человека и пошлёт данную секцию памяти далее на анализ. Это в свою очередь позволит команде VAC вручную отреверсить блок памяти, который был обнаружен, во избежание массовых банов в следствии различного видео-звукозаписывающего ПО, как Shadowplay, которые ТЕХНИЧЕСКИ ИСПОЛЬЗУЕТ ТАКИЕ ЖЕ МЕТОДЫ ИНЖЕКЦИИ КАК И ЧИТЫ. Так вот. Блокировать функции WinAPI – одна из самых плохих идей, так как у VAC существует VAC3 модуль в виде системного сервиса. Суть сервиса в сравнивании физического содержания DLL файлов ядра системы с их копией в памяти. Если VAC3 видит, что пролог ReadProcessMemory не совпадает с прологом на жестком диске – это гарантированный бан. VAC2 же в свою очередь, это модуль, непосредственно производящий скан памяти. Так же существует такое понятие как VAC1, однако его не используют с момента выхода HL2, так как его архитектура далека от совершенства и была использована только в качестве внутри-игровых хуков на популярные функции отрисовки, стрельбы и т.д., т.е. VAC1 был рассчитан на то, что читер в наглую заблоктрует исполнение определённых функций игры для получения преимущества. Достаточно долгое тестирование показало, что для обхода бана уже занесённых в чёрный список читов, достаточно поменять хеши файлов и пропатчить популярные строки, так как именно строки используются как основной источник сигнатур. Именно по этой причине мной была реализована система динамического XOR’а этих самых строк, что приводит VAC в полнейшее отчаяние, так как он не может найти закономерности между двумя билдами. Более того, в моём проекте активно используется псевдополиморфизм по маркерам, что позволяет эффективно обходить любые попытки составления сигнатур. Смысл в том, что наши маркеры представляют собой некие мутации машинного кода, словно усложнения уровнений в математики, которые сокращаются на ноль. Однако, каждый такой маркер является уникальной комбинацией машинных инструкций для каждого пользователя. Так как я применяю псевдорандом, это гарантирует что для клиента 1 у меня всегда будет сгенирирован один и тот же конечный результат, в отличии от любого другого клиента. Плюс ко всему, моим финским другом случайно был обнаружен метод, с помощью которого можно сломать процесс анализа функции в IDA. Грубо говоря, каждая функция, содержащая такой маркер, защищена от примитивного реверсинга в IDA, что только играет нам на руку. Это связано с определённым методом разворачивания стека, который вводит IDA в ступор и она говорит что не может найти конец функции. Вот, собственно, потихоньку мы и подошли к самим читам.
Чего только нельзя сделать, имея прямой доступ к клиенту. Так, например, я был первым человеком, который реализовал полноценную копию симуляции игроков для построения траекторий выстрелов для не моментального оружия. Точность этого симулятора поражает…
Самое забавное, что симулятор способен вычислять тректории с 99.9% точностью, однако это требует серьёзных ресурсов компьютера, так что мной было принято решение сделать симуляцию на “средних” настройках. Это позволяет без проблем попадать на средних и близких дистанциях, в то время как на дальних обычно попадать особо не требуется, так как либо карты не настолько огромны, либо не нужна такая сумасшедшая миллиметровая точность. Плюс ко всему этому стоит добавить человеческий фактор – пока ваш снаряд летит в цель, эта цель может 10 раз поменять свою тректорию. Также, вам может показаться забавным факт того, что с помощью того самого Sequence Freezing эксплоита можно избавляться от так называемых “состояний” игры. В TF2 широко распространены эти кондишены, и самым ярким примером действия этого эксплоита является возможность игрока сбрасывать кондишн огня, тем самым тушиться по среди поля боя без использования медиков или аптечек. Ещё одной интересной техникой читерства являются постоянные криты на оружие. Эта фича, пожалуй, существует только у нас, и позволяет производить неслучайно-случайные критические выстрелы. Это даёт невероятное преимущество в игре, даже если у вас и так всё выкручено на максимум.
Рассказывать про аимботы и триггерботы, я думаю, будет не так интересно, так как они не являются чем-то особенным в реализации. А вот про методику инжекций и личный опыт в виде VAC банов, полученных в результате проб и ошибок – расскажу.
Начнём с банального LoadLibraryA, когда в процессе выделяют память под путь к .dll файлу и создают выделенный поток, указывающий на начало LLA и аргументом в виде той строки. Это приводит к вызову LLA и дальнейшей подгрузки модуля в память. Однако, как известно, VAC3 мониторит вызовы API. Ошибочным является мнение, что если заинжектить .dll с помощью самодельного manual mapper’а, то VAC никогда не сможет тебя обнаружить. Это неправда. VAC вполне может посмотреть историю открытых хендлов любого процесса и обнаружить что вы попытались модифицировать процесс игры. Однако именно тут вступает в роль псевдополиморфизм, сбивающий его с толку. Также, ошибочным является мнение, что VMProteсt способен защитить вашу .dll от VAC. И в то время как VMP действительно заставляет VAC задуматься и потупить пару дней, это не спасает в конце концов от неминуемого бана.
Это в принципе вся основная информация о SE, которую я хотел до вас сегодня донести. Существует многочисленное кол-во всяких мелких приколов и багов, которые просто невозможно вспомнить и перечислить “за раз”. Некоторые из них безобидны и встречаются крайне редко, в то время как другие приводят к крашам и прочим уязвимостям. Именно на этой завершающей ноте я хотел бы закончить данную статью. До скорых встреч!