Как создать мод для Cyberpunk 2077 и какие инструменты выбрать
Привет! Меня зовут Вова, я разработчик. Недавно мой коллега попросил помочь с его проектом — VR-жилетом, который «проецирует» ощущения урона, которые получает персонаж, на тело игрока. С моей стороны — мод, который будет отправлять данные из Cyberpunk 2077 на сам жилет.
Информации по теме моддинга мало, на русском языке материалов практически нет, а существующие статьи местами устарели — пора это исправить. Если вам интересно, как разработать свой мод для Cyberpunk 2077 и собрать VR-жилет, добро пожаловать под кат.
Подробнее о задаче
Необходимо в реальном времени перехватывать информацию об уроне, который получает персонаж, и передавать ее на контроллер жилета. Для таких задач идеально подходит отправка данных по сети с использованием UDP-датаграмм.
«Наивное» и самое суровое решение — сидеть с дизассемблером над игрой и перехватывать выполняемые функции на уровне нативного кода. Решение достойное, но тяжелое. А также требует поддержки с компиляцией под каждую сборку игры. Таким же образом написаны практически все загрузчики модов. Поэтому обратимся к готовым решениям.
Загрузчики модов
При создании модов необходимо редактировать ресурсы игры и изменять логику виртуального мире. С ресурсами более-менее очевидно: есть файлы на диске, их можно редактировать, а сама логика игры представлена на двух уровнях.
- Нативный уровень. Код написан на С++ и содержится в исполняемом файле игры. Этот уровень позволяет управлять буквально всем в игре — для применения изменений необходима только перезагрузка.
- Скриптовый уровень. Для выполнения на этом уровне используется интерпретируемый язык программирования. Он ограничен в функциональности, но позволяет изменять игру на ходу.
Рассмотрим популярные загрузчики модов.
Редактор WolvenKit
Первый в списке — неофициальный, но явно признанный проект WolvenKit. Это самостояльный редактор ресурсов для игр на движках REDengine 3 (Ведьмак 3: Дикая Охота) и REDengine 4 (Cyberpunk 2077).
Он позволяет удобно изучать архивы игры, а также разбирать и конвертировать некоторые проприетарные форматы. Ранее я использовал этот инструмент для поиска модели радио для своего проекта.
Хотя многие элементы игры, такие как квестовые последовательности, описаны в текстовых файлах внутри архива, а сам WolvenKit поддерживает различные инструменты моддинга, для решения задачи я его не использовал. Поэтому переходим далее.
Cyber Engine Tweaks (CET)
Следующим в моем списке идет главная зависимость для большинства существующих модов — Cyber Engine Tweaks, CET. Это фреймворк для создания пользовательских модификаций, который предоставляет доступ к скриптовому уровню игровой логики с помощью языка программирования Lua.
Помимо загрузки модов этот инструмент имеет интерактивную консоль и умеет читать внутреннюю базу данных объектов TweakDB. Таким образом, существенно упрощает процесс изучения игры непосредственно из игры. Это решение актуально для моей задачи, поэтому его стоит взять на вооружение.
RED4ext
Как я сказал ранее, возможности скриптового уровня ограничены. Например, в интерпретаторе Lua нет некоторых системных библиотек, поэтому открыть сокет и отправить данные не получится. Здесь пригодится RED4ext — загрузчик нативного кода.
Доступ к уровню нативного кода позволяет добавлять любую логику или переопределять существующую. Созданные функции можно экспортировать в скриптовый уровень и вызывать из консоли CET. Однако с большими возможностями приходит большая ответственность: необработанное исключение на этом уровне легко «уронит» игру, а сборка с отладочными символами несколько замедлит ее загрузку.
Весьма вероятно, что в игре нет легкодоступных функций для отправки случайных данных с помощью UDP-пакетов, так что RED4ext тоже пригодится.
REDmod
REDmod — это официальный набор для модификаций, который вышел спустя два года после релиза игры. Официально REDmod — это бесплатное дополнение, которое можно установить в EGS, Steam или GOG.
REDmod позволяет редактировать ресурсы игры без изменения оригинальных файлов. Главной и наиболее интересной его особенностью является наличие исходных текстов для функций из скриптового уровня.
Хотелось бы упомянуть…
Выше перечислены популярные инструменты, которые решают задачи разного аспекта. Тем не менее, хотелось бы упомянуть программы, которые также могут быть полезны.
- ArchiveXL — загрузчик модифицированных ресурсов, аналогичный REDmod, но появившийся раньше.
- RedScript — компилятор и декомпилятор для скриптового уровня игры. Использует swift-подобный синтаксис.
- CyberCAT-SimpleGUI — редактор сохранений для Cyberpunk 2077.
Изучение игры
После установки всех инструментов можно запустить игру и воспользоваться основным функционалом Cyber Engine Tweaks. При первом запуске CET попросит назначить сочетание клавиш, которое открывает графический интерфейс загрузчика с Lua-консолью.
В глобальной области видимости есть объект Game, все типы и перечисления, а также несколько служебных функций. Объект Game предоставляет доступ к скриптовым функциям игры и singleton-системам. Для получения информации о структуре объекта или класса доступны функции Dump
и DumpType
соответственно.
print(Dump(Game.GetPlayer(), false))
print(DumpType("PlayerPuppet", false))
Пример работы с Dump и DumpType.
Отмечу важный нюанс в работе с объектами в Lua. При обращении к статическим методам класса используется точка, а для доступа к членам класса — двоеточие.
-- Объект Game состоит из статических функций
player = Game.GetPlayer()
-- player — это экземпляр класса, метод GetWorldPosition не является статическим
print(player:GetWorldPosition())
Консоли достаточно, чтобы привыкнуть к Lua, навести немного хаоса в прокачке персонажа и посмотреть на актуальные методы различных singleton-систем. Теперь необходимо научиться выполнять код при совершении событий в игре. Для этого следует создать свой первый мод, совместимый с CET.
Любые ошибки в сценариях Lua заканчиваются прерыванием и выводом сообщения в лог. В редких случаях можно вызвать ошибку внутри CET и игра вылетит. Этого можно добиться, если, например, вызвать функцию Dump на перечисление (enum) в глобальной области видимости.
Находим корень игры — у меня это C:\Program Files (x86)\Steam\steamapps\common\Cyberpunk 2077
. Далее все пути будут строиться относительно корня игры. Переходим в каталог \bin\x64\plugins\cyber_engine_tweaks\mods
. Здесь создаем директорию для своего мода — например, damageSender
. Внутрь директории помещаем текстовый файл с именем init.lua
с таким содержимым:
registerForEvent("onInit", function()
print("Hello, world")
end)
Возвращаемся в игру, открываем интерфейс CET и нажимаем Reload all mods
. Если все сделано правильно, в консоли появятся два сообщения.
Mod damageSender loaded! ('C:\Program Files (x86)\Steam\steamapps\common\Cyberpunk 2077\bin\x64\plugins\cyber_engine_tweaks\mods\damageSender')
Hello, World
В случае ошибок, в том числе синтаксических, подробная информация появится в log-файле.
Для объекта player
класса PlayerPuppet
мне приглянулась функция OnHitUI, которая, вероятно, создает отображение полученного урона. Прикрепим к ней наблюдателя.
Observe("PlayerPuppet", "OnHitUI", function(self, hitEvent)
print("Hit!")
end)
За наблюдение отвечает функция Observe. Первый аргумент — имя класса, второй — название метода, а в конце — функция, которая будет выполнена после отрабатывания наблюдаемого метода. Сигнатура выполняемой функции должна совпадать с сигнатурой наблюдаемого метода.
Сохраняем файл, перезагружаем моды, заходим в игру и идем получать урон (благо в Найт-Сити это простая задача). В консоли появляется ожидаемое сообщение Hit!
Кажется, OnHitUI
— подходящая функция.
Хотя изучение типов данных в консоли CET эффективное, сама по себе она достаточно проста. Давайте заглянем в REDmod, может быть там найдется что-нибудь интересное.
Читаем исходный код
Все файлы REDmod спрятаны в каталоге tools\redmod
корня игры. Подкаталог scripts явно отсылает к исходному коду для скриптовой части. Недолгий поиск приводит к файлу tools\redmod\scripts\cyberpunk\player\player.script
. Внутри него можно ознакомиться с реализацией большинства функций класса PlayerPuppet
, включая наблюдаемую функцию OnHitUI
.
Обратите внимание: на скриптовом языке реализованы не все функции, которые доступны на скриптовом уровне. Квестовая система QuestsSystem
, например, описана в файле tools\redmod\scripts\core\systems\questSystem.script
и практически полностью состоит из импортов нативных функций.
В момент изучения функций рекомендую добавить пару отладочных print-ов, так как явного отладчика для модов нет.
Создать свой REDmod-мод достаточно просто. В корне игры должен быть каталог mods
. Если его нет, создаем и добавляем директиву для нашего мода. Я назвал ее damageSenderRED
. Далее создаем файл mods\damageSenderRED\info.json
и заполняем следующим образом:
{
"name": "Имя_мода",
"version": "0.0.1",
"customSounds": []
}
Для модификации любого файла из REDmod просто копируем его в каталог мода с полным сохранением пути. Например, так:
# Оригинальный файл, который хочется изменить
tools\redmod\scripts\cyberpunk\player\player.script
# Редактируемый файл
mods\damageSenderRED\scripts\cyberpunk\player\player.script
Точное повторение пути внутри каталога мода критически важно. Если у вас есть подозрение, что файл не «подхватывается» игрой, сделайте синтаксическую ошибку — игра не пропустит ее при загрузке мода.
Но даже интуитивного знания синтаксиса хватит, чтобы добавить логирование следующего вида:
private override final function OnHitUI( hitEvent : gameHitEvent )
{
var i : Int32;
var dmgType : gamedataDamageType;
var effName : CName;
var attackValues : array< Float >;
attackValues = hitEvent.attackComputed.GetAttackValues();
Log("Damage!");
…
}
Также посмотрим, как игра реагирует на появление новых функций, и добавим бесполезную функцию LogMeow
, которая будет записывать игровые логи.
class PlayerPuppet extends ScriptedPuppet
{
<здесь много объявлений переменных>
public function LogMeow()
{
Log( "Meow" );
}
}
Если все сделано правильно, при запуске игры появится опция включить моды. При каждом запуске они будут компилироваться и добавляться в игру, так что, возможно, REDmod — не лучший вариант для установки пары сотен модов.
При запуске с REDmod-модом мы сможем вызвать функцию LogMeow у экземпляра класса PlayerPuppet. Для примера на скриншоте приведен также вызов несуществующей функции.
Остается небольшая неточность. Функция LogMeow
логирует Meow
, но в консоли ничего нет. Логирование этими скриптами производится в GameLog
, который можно найти либо на отдельной вкладке внутри CET, либо в файле bin\x64\plugins\cyber_engine_tweaks\gamelog.log
.
Поиск по скриптовым исходным кодам привел меня в системе урона DamageSystem
, когда занимается обработкой всех событий, включая нулевой урон от падения.
Назначение многих полей в структуре gameHitEvent
мне было неясно и я логировал все, что казалось полезным. Но одно неудобство сильно замедляло процесс: для чтения логов нужно открывать интерфейс CET, из-за чего их сложнее сопоставлять с событиями в игре. Мне нужно было отображение в реальном времени и, желательно, в отдельном окне. А раз есть такая потребность, самое время научиться отправлять UDP-пакеты из одиночной игры!
Сетевые функции
Как я говорил ранее, скриптовой уровень, включая интерпретатор Lua в CET, не способен открывать сокеты. Здесь можно воспользоваться фреймворком RED4ext — он состоит из двух частей:
- загрузчик модов — RED4ext,
- набор для разработки модов — RED4ext.SDK.
Моды для RED4ext — это разделяемые (динамические) библиотеки, которые загружаются в адресное пространство игры. Библиотека, написанная на С++, может взаимодействовать с любыми интерфейсами операционной системы, включая сокеты.
Для создания мода RED4ext потребуется Visual Studio 2022. Создаем новый проект, динамическую библиотеку и настраиваем проект:
- Общие → Выходной каталог: C:\Program Files (x86)\Steam\steamapps\common\Cyberpunk 2077\red4ext\plugins.
- Общие → Стандарт языка С++: Стандарт ISO C++ 20 (/std:c++20).
- Каталоги VC++ → Внешние каталоги включения: Добавить путь до RED4ext.SDK/include.
- С/С++ → Предварительно откомпилированные заголовки -> Не использовать предварительно откомпилированные заголовки.
Для того, чтобы разделяемая библиотека стала RED4ext-совместимым модом, необходимо следовать интерфейсу. То есть определить точку входа и мета-информацию. Заполняем dllmain.cpp
из примера.
#include <RED4ext/RED4ext.hpp>
RED4EXT_C_EXPORT bool RED4EXT_CALL Main(RED4ext::PluginHandle aHandle, RED4ext::EMainReason aReason, const RED4ext::Sdk* aSdk)
{
switch (aReason)
{
case RED4ext::EMainReason::Load:
{
// Attach hooks, register RTTI types, add custom states or initialize your application.
// DO NOT try to access the game's memory at this point, it is not initialized yet.
break;
}
case RED4ext::EMainReason::Unload:
{
// Free memory, detach hooks.
// The game's memory is already freed, to not try to do anything with it.
break;
}
}
return true;
}
RED4EXT_C_EXPORT void RED4EXT_CALL Query(RED4ext::PluginInfo* aInfo)
{
aInfo->name = L"Plugin's name";
aInfo->author = L"Author's name";
aInfo->version = RED4EXT_SEMVER(1, 0, 0); // Set your version here.
aInfo->runtime = RED4EXT_RUNTIME_LATEST;
aInfo->sdk = RED4EXT_SDK_LATEST;
}
RED4EXT_C_EXPORT uint32_t RED4EXT_CALL Supports()
{
return RED4EXT_API_VERSION_LATEST;
}
Это минимальный пример, практически Hello world, который загружается и выгружается. Если скомпилировать библиотеку и запустить игру, RED4ext запишет метаданные обо всех загружаемых модах в свой лог-файл, который находится по пути red4ext\logs.
[2023-06-06 22:39:05.026] [RED4ext] [info] RED4ext (v1.13.1) is initializing...
[2023-06-06 22:39:05.026] [RED4ext] [info] Game patch: 1.62 Hotfix 1
[2023-06-06 22:39:05.026] [RED4ext] [info] Product version: 1.62
[2023-06-06 22:39:05.026] [RED4ext] [info] File version: 3.0.71.13361
[2023-06-06 22:39:05.053] [RED4ext] [info] RED4ext has been successfully initialized
[2023-06-06 22:39:05.089] [RED4ext] [info] RED4ext is starting up...
[2023-06-06 22:39:05.089] [RED4ext] [info] Loading plugins...
[2023-06-06 22:39:05.089] [RED4ext] [info] Loading plugin from 'C:\Program Files (x86)\Steam\steamapps\common\Cyberpunk 2077\red4ext\plugins\CP2077_Sockets.dll'...
[2023-06-06 22:39:05.095] [RED4ext] [info] Plugin's name (version: 1.0.0, author(s): Author's name) has been loaded
[2023-06-06 22:39:05.095] [RED4ext] [info] 1 plugin(s) loaded
[2023-06-06 22:39:05.095] [RED4ext] [info] RED4ext has been started
Обратите внимание, что при каждом запуске RED4ext пишет новый файл, а старые логи ротируются в файлы с цифровым обозначением. Старые логи хранятся в течение пяти запусков, после — удаляются. Но вернемся к библиотеке.
Для отправки данных, которые будут понятны человеку и машине, я выбрал формат JSON. Однако интерпретатор CET не умеет работать с JSON, поэтому это можно делегировать на нативный уровень, где nlohmann::json решит все проблемы. Для минимизации работы со сложными структурами я выбрал следующую архитектуру на нативном уровне:
- Класс
NetworkContoller
решает задачу формирования JSON и отправки по UDP. - В классе есть метод
Add(key, value)
, который принимает строковое значение в виде ключа и некоторый набор типов. - В классе должен быть метод для отправки всех записанных значений и метод для сброса внутреннего состояния.
- Все методы
NetworkContoller
должны быть доступны на скриптовом уровне игры.
Задумываться о потокобезопасности такой архитектуры нет смысла.
Так как С++ объектно-ориентированный, сделать класс должно быть просто. Но особенности RED4ext показали мне, что такое ООП Шредингера.
// Создаем класс, наследник IScriptable
struct NetworkController : RED4ext::IScriptable
{
RED4ext::CClass* GetNativeType();
nlohmann::json data;
};
// Cоздаем описание нашего класса для движка
RED4ext::TTypedClass<NetworkController > networkControllerClass("NetworkController");
// Метод класса должен вернуть описание о себе
RED4ext::CClass* NetworkController ::GetNativeType()
{
return &networkControllerClass;
}
Теперь регистрируем класс на скриптовом уровне.
void RegisterTypes()
{
RED4ext::CRTTISystem::Get()->RegisterType(&networkControllerClass);
}
void PostRegisterTypes()
{
// Уточняем родителя на скриптовом уровне
auto rtti = RED4ext::CRTTISystem::Get();
auto scriptable = rtti->GetClass("IScriptable");
networkControllerClass.parent = scriptable;
}
RED4EXT_C_EXPORT bool RED4EXT_CALL Main(RED4ext::PluginHandle aHandle,
RED4ext::EMainReason aReason, RED4ext::RED4ext* aRED4ext)
{
switch (aReason)
{
case RED4ext::EMainReason::Load:
{
RED4ext::RTTIRegistrator::Add(RegisterTypes, PostRegisterTypes);
break;
}
case RED4ext::EMainReason::Unload:
{
break;
}
}
return true;
}
Несмотря на то, что класс создан, его методы не появятся автомагически на скриптовом уровне — их нужно регистрировать отдельно. Дополнительную сложность вносит архитектура скриптового уровня, ведь это самая настоящая виртуальная машина со своим стеком. Рассмотрим в коде.
// Для экспорта функции на скриптовой уровень необходимо использовать эту сигнатуру</h3>
// Функция не может быть членом класса!
void AddString(RED4ext::IScriptable* aContext, RED4ext::CStackFrame* aFrame, void* aOut, int64_t a4)
{
// aContext - это указатель на экземпляр класса
// Мы уверены, что это именно наш контроллер, поэтому приводим к нужному типу
// В C++ есть ключевое слово this, которое указывает на “свой” экземляр,
// в данном случае оно неприменимо. Используем синоним self.
auto self = static_cast<NetworkController*>(aContext);
// Аргументы для функции нужно извлекать из местного стека
// Работать можно либо с примитивами, либо с RED4ext-типами
RED4ext::CString key, value;
RED4ext::GetParameter(aFrame, &key);
RED4ext::GetParameter(aFrame, &value);
aFrame->code++; // skip ParamEnd
// Вот только так мы можем получить доступ к членам класса.
// Вместо прямого редактирования полей можно определить метод, в который передавать
// собранные аргументы.
self->data[key.c_str()] = value.c_str();
}
Теперь определенную функцию нужно зарегистрировать на скриптовом уровне.
void PostRegisterTypes()
{
// Старый код
auto rtti = RED4ext::CRTTISystem::Get();
auto scriptable = rtti->GetClass("IScriptable");
networkControllerClass.parent = scriptable;
// Создаем метод для нашего контроллера, задаем полное и краткое имя,
// передаем указатель на функцию и ставим флаг, что функция нативная
auto addString = RED4ext::CClassFunction::Create(&networkControllerClass, "AddString", "AddString", &AddString, { .isNative = true });
// Регистрируем список параметров, сперва тип, потом имя.
// Тип совпадает с названием типа на скриптовом уровне
addString->AddParam("String", "key");
addString->AddParam("String", "value");
// Регистрируем функцию
networkControllerClass.RegisterFunction(addString);
// Повторяем операцию для каждого метода в классе
}
Работа с сокетами на Windows выходит за рамки данной статьи, но с исходным кодом можно ознакомиться по ссылке.
После компиляции можно создать объект нашего типа и проверить, что все нативные функции работают на скриптовом уровне.
-- Это выполнится как задумано
n = NewObject(“NetworkController”)
print(Dump(n, false))
-- А вот так не сработает, не знаю почему
print(DumpType(“NetworkController”, false))
Теперь мы можем использовать NetworkController
для отправки данных во внешний сервис.
Финальные штрихи
Наспех собираем UDP-сервер, который должен выводить данные из игры. Мне хватило приключений с С++ и Lua, поэтому UDP-сервер я напишу на своем рабочем языке, то есть на Python.
import datetime
import json
import socket
from json import JSONDecodeError
UDP_IP = "127.0.0.1"
UDP_PORT = 2020
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind((UDP_IP, UDP_PORT))
sock.settimeout(1)
while True:
try:
raw, addr = sock.recvfrom(8192)
data = json.loads(raw.decode("utf8"))
print(f"{datetime.datetime.now()} {json.dumps(data, indent=4, ensure_ascii=False)}")
except socket.timeout:
pass
except KeyboardInterrupt:
break
except JSONDecodeError as e:
print(e)
print(f"Original data: {raw}")
except Exception as e:
print(e)
pass
sock.close()
Я вернулся к изучению структуры hitEvent
через Lua-консоль и понял, что процесс затянется. Поэтому нарисовал приблизительную диаграмму, которая визуализирует иерархию и типы в данной структуре. Притом не детализировал некоторые сложные объекты, такие как GameObject
и WeaponObject
, но диаграмма получилась и без них впечатляющая. Из этой структуры можно выудить следующие факты:
- Существуют разные типы атак. Игра различает 14 типов урона, включая урон от «взлома» и внешние эффекты — электричество или горение.
- В структуре есть ссылки на все интересные объекты: кто получил урон, кто и с помощью чего его нанес.
- На текущий момент существует четыре вида урона — химический, электрический, физический и термальный.
- У события атаки есть флаги. На PostProcess-шаге флаги исчерпывающе описывают событие — например, урон может быть по времени (горение), нелетальным и заблокированным.
- Как и ожидалось, Cyberpunk 2077 не дает информацию вида «попадание в локоть».Но зато предоставляет векторы, связанные с полученным уроном. Всего их три:
attackPosition
,hitPosition
иhitDirection
. Разберемся за что отвечают эти векторы.
Поля attackPosition
и hitPosition
— это абсолютные координаты атакующего и получающего урон в игровом мире. Вектор hitDirection
задает направление атакующего.
Нам интересно направление урона хотя бы в виде слов «слева», «справа», «спереди» или «сзади». Для этого нужно провести небольшие математические расчеты, которые можно сделать с помощью функции GetAttackAngleInInt
.
network:AddVector4("player_direction", hitEvent.target:GetWorldForward())
local direction = Game.GetAttackAngleInInt(hitEvent)
if direction == 1 then
network:AddString("direction", "left")
elseif direction == 2 then
network:AddString("direction", "back")
elseif direction == 3 then
network:AddString("direction", "right")
else
network:AddString("direction", "front")
end
Положение игрока и атакующего записали, как и параметры атаки. Остается дополнительный параметр, который не фигурирует в этой структуре, — количество единиц здоровья у главного героя. Этот параметр можно получить в отдельной подсистеме, которая занимается статистикой персонажа.
local sps = Game.GetStatPoolsSystem()
local currHealth = sps:GetStatPoolValue(hitEvent.target:GetEntityID(), gamedataStatPoolType.Health)
network:AddFloat("health", currHealth)
Обратите внимание, что на странице описания этой подсистемы указан неверный синтаксис для получения объекта подсистемы.
{
"attack_position": [
-407.7556457519531,
309.58392333984375,
-219.35897827148438,
1.0
],
"attack_type": "Ranged",
"damage_chemical": 0.0,
"damage_electric": 0.0,
"damage_physical": 1.0,
"damage_thermal": 0.0,
"direction": "front",
"flags": [
"SuccessfulAttack",
"ImmortalTarget"
],
"health": 55.76613998413086,
"hit_direction": [
-0.5679446458816528,
0.4001407027244568,
0.13162928819656372,
0.7071067690849304
],
"hit_position": [
-415.9785461425781,
315.3772888183594,
-217.4532012939453,
1.0
],
"player_direction": [
0.9998522996902466,
-0.01719045639038086,
0.0,
1.0
]
}
Итоговый набор данных.
Этот набор данных получен в ходе обучения, поэтому у всех атак противника есть флаг ImmortalTarget
, из-за чего проиграть в этом режиме невозможно. Но так будет не во всей игре.
В остальном — все готово: созданная модификация собирает показания об уроне и отправляет их по UDP на сервер, к которому подключен контроллер VR-жилета. В следующей части расскажем, как его собрать и запустить. Если интересно, что из этого получится, следите за обновлениями в нашем блоге на Хабре и в Академии Selectel.
Заключение
Исходный код проекта доступен в репозитории на GitHub. Делайте форк и предлагайте свои улучшения!
Также рекомендую изучить следующие полезные материалы, если хотите погрузиться в моддинг: