-
Notifications
You must be signed in to change notification settings - Fork 0
Plugin Development Guide RU
Плагины POEFixer — это нативные C++ DLL, загружаемые во время выполнения из Plugins/<PluginName>/<PluginName>.dll. Они читают живое состояние игры, рисуют ImGui-оверлеи, сохраняют собственные настройки и подписываются на события хоста.
SDK плагинов построен на трёхслойной архитектуре:
Plugin DLL ───► PluginSDK.h (header-only C++ wrapper, owns std::string/vector/function)
│
▼ inline function-pointer calls only
HostAbi (pure-C ABI, POD structs only)
│
▼ SEH-wrapped on the host side
Host bridge: plugin_manager/bridge/Bridge_<Service>.cpp (10 files)
│
▼
GameClient + GameLibrary
- Авторы плагинов подключают ровно один заголовок:
POEFixer/plugin_sdk/PluginSDK.h. - Этот заголовок объявляет всё в пространстве имён
PluginSDK::и под капотом подтягивает C-ABI изPluginAbi.h. Знать о существовании последнего полезно; смотреть в него почти никогда не приходится. - Все контейнеры
std::*живут внутри DLL плагина. Через границу хоста проходят только POD-типы. Это значит, что плагин, собранный другой версией тулчейна, не будет конфликтовать с STL хоста — единственными общими типами остаются целые числа, числа с плавающей точкой, указатели и небольшие структуры.
Заголовки SDK находятся здесь:
-
POEFixer/plugin_sdk/PluginSDK.h— C++-обёртка, которую используют авторы плагинов. -
POEFixer/plugin_sdk/PluginAbi.h— чистый C-ABI под ней.
Эталонные плагины из репозитория (читайте их как документацию): Plugins/ExamplePlugin/, Plugins/Radar/, Plugins/KillCount/, Plugins/NinjaPricer/.
Минимальный плагин, который загружается и выводит сообщение в лог хоста:
#define PLUGIN_EXPORTS
#include "POEFixer/plugin_sdk/PluginSDK.h"
class HelloPlugin : public PluginSDK::Plugin {
public:
const char* GetName() const override { return "Hello"; }
void OnEnable(bool) override { ctx()->Log.Info("Hello, world"); }
};
extern "C" PLUGIN_API PluginSDK::Plugin* CreatePlugin() { return new HelloPlugin(); }
extern "C" PLUGIN_API void DestroyPlugin(PluginSDK::Plugin* p) { delete p; }Соберите как Plugins/Hello/Hello.dll, перезапустите хост и включите на вкладке Plugins.
В качестве канонического шаблона используйте Plugins/ExamplePlugin/ExamplePlugin.vcxproj. Ключевые параметры:
- Тип конфигурации: DynamicLibrary
- Платформенный тулсет: v143 (Visual Studio 2022)
- Кодировка: Unicode
-
Стандарт языка:
stdcpp20 -
Среда выполнения:
MultiThreadedDLL(Release) /MultiThreadedDebugDLL(Debug). ДОЛЖНА совпадать с хостом. -
Препроцессорные определения:
PLUGIN_EXPORTS;NDEBUG;_WINDOWS;_USRDLL;_CRT_SECURE_NO_WARNINGS -
Дополнительные пути включаемых файлов:
$(SolutionDir)POEFixer -
Выходной каталог:
$(SolutionDir)x64\Release\Plugins\<YourPlugin>\ -
Имя цели: должно совпадать с именем папки (
Plugins/MyPlugin/→MyPlugin.dll)
Хост сканирует каждый подкаталог в Plugins/ и ищет <FolderName>.dll. DLL обязана экспортировать три символа:
-
CreatePlugin— фабрика; возвращаетPluginSDK::Plugin*. -
DestroyPlugin— деструктор; принимаетPluginSDK::Plugin*. -
PluginSDK_AttachHost— пробрасываетContext. Определяется за вас внутриPluginSDK.hи автоматически эмитируется при заданномPLUGIN_EXPORTS.
Рекомендуемая раскладка исходников:
Plugins/YourPlugin/
YourPlugin.vcxproj
YourPlugin.vcxproj.filters
src/
YourPlugin.cpp // class YourPlugin : public PluginSDK::Plugin
YourPluginSettings.h // Save()/Load() POCO
config/ // runtime-created by SaveSettings()
settings.json
Directory() возвращает абсолютный UTF-8 путь, выровненный относительно директории EXE хоста — не нужно склеивать его с путём к EXE самостоятельно. Строка каталога хранится по значению внутри PluginSDK::Plugin, поэтому она переживает перевыделения контейнеров хоста и циклы перезагрузки без проблем со временем жизни.
Если вы хотите рисовать через ImGui, добавьте также в <ClCompile> (хост их тоже линкует, но ImGui на стороне плагина свой для каждой DLL):
..\..\POEFixer\imgui\imgui.cpp
..\..\POEFixer\imgui\imgui_draw.cpp
..\..\POEFixer\imgui\imgui_tables.cpp
..\..\POEFixer\imgui\imgui_widgets.cpp
В OnEnable подключитесь к ImGui-контексту хоста:
if (ctx()->ImGuiContext)
ImGui::SetCurrentContext(static_cast<ImGuiContext*>(ctx()->ImGuiContext));PluginSDK::Plugin — это виртуальный базовый класс. Переопределите следующие методы в своём плагине (примерный порядок вызова):
| Метод | Когда вызывается | Типичное использование |
|---|---|---|
const char* GetName() const |
Один раз сразу после конструирования | Возвращайте отображаемое имя плагина |
void OnEnable(bool isGameAttached) |
Когда пользователь включает плагин (или при запуске, если он был включён ранее) | Загрузка настроек, подписка на события, подключение ImGui-контекста |
void DrawSettings() |
Каждый кадр, пока открыта панель настроек плагина | Элементы управления ImGui для конфига |
void DrawUI() |
Каждый кадр, пока плагин включён | Отрисовка ImGui-оверлея (используйте ImGui::GetBackgroundDrawList() для оверлея поверх игры) |
bool WantsOverlay() const |
Опрашивается каждый кадр | Возвращайте true, если хочется, чтобы хост был в режиме оверлея (с проходом кликов) |
void SaveSettings() |
Периодически (~5 с) и при выключении | Сохранение конфига на диск |
void OnDisable() |
Когда пользователь выключает плагин или при завершении работы хоста | Освобождение ресурсов, отписка от событий |
Обязателен только GetName; у остальных есть безопасные значения по умолчанию.
Сразу после CreatePlugin хост также вызывает GetSDKVersion() (определён в базовом классе, переопределять НЕ нужно), чтобы проверить совместимость плагина и хоста. Несовпадение → плагин отклоняется.
ctx() возвращает const PluginSDK::Context* — агрегат из 10 сервисов:
struct Context {
GameService Game; // snapshot, state flags, screen size
EntitiesService Entities; // enumerate, find-by-id, watch
ComponentsService Components; // 21 component readers + collection enumerators
InventoryService Inventory; // scan + iterate + per-item helpers
UiService Ui; // tree walk, FindPanelByStringId, screen-rect
RenderService Render; // WorldToScreen + isometric map projection
TerrainService Terrain; // walkable grid (RAII), height, TGT locations
MemoryService Memory; // direct memory primitives (last resort)
LogService Log; // Debug/Info/Warn/Error
EventsService Events; // Subscribe / Unsubscribe / On<X>
void* ImGuiContext; // pass to ImGui::SetCurrentContext
void* D3DDevice; // ID3D11Device* for texture loading
};Кратко — для чего каждый сервис:
| Сервис | За чем к нему обращаются |
|---|---|
GameService |
Snapshot, флаги состояния, информация об экране и окне |
EntitiesService |
Перечисление, поиск по id, отслеживание жизненного цикла |
ComponentsService |
21 ридер + 4 перечислителя + ~10 удобных хелперов |
InventoryService |
Сканирование, перечисление, чтение модификаторов по предметам |
UiService |
Обход дерева, FindPanelByStringId, ComputeScreenRect
|
RenderService |
WorldToScreen, GridTo{Large,Mini}Map, преобразования |
TerrainService |
Сетки проходимости и высот (RAII), TGT-локации |
MemoryService |
Примитивы RPM — только когда не подходит ни один высокоуровневый вызов |
LogService |
Debug / Info / Warn / Error
|
EventsService |
Subscribe / Unsubscribe / On{Area,Frame,Attach,Detach}
|
ctx() валиден с момента вызова хостом OnEnable до возврата из OnDisable. Не кэшируйте ctx() между горячими перезагрузками или выгрузками DLL.
ctx()->Game.GetSnapshot() возвращает Snapshot по значению — полный неизменяемый срез текущего кадра. GetSnapshot() обходит abi->entities.enumerate и заполняет snap.Entities до возврата, поэтому стоимость пропорциональна количеству ближайших сущностей. Вызывайте его один раз за кадр и переиспользуйте.
PluginSDK::Snapshot snap = ctx()->Game.GetSnapshot();
if (snap.State != PluginSDK::GameState::InGame) return;
ctx()->Log.Info(snap.CurrentAreaName.c_str());
if (snap.IsTown || snap.IsHideout) return; // safe area
// snap.Vitals.HPPercent, snap.Vitals.MaxES, snap.Vitals.IsPaused
// snap.Player.GridPositionX, snap.Player.Path (wstring), snap.Player.Components
// snap.Entities is a std::vector<Entity> — every nearby entity, fully populated
// snap.LargeMap / snap.MiniMap — visibility + projection inputs
// snap.AreaChangeCounter — increments each portal transitionЧто снапшот несёт напрямую (дополнительные обращения к сервисам не нужны):
- Состояние и флаги:
State,IsAttached,IsWindowValid,GameWindowForeground,IsTown,IsHideout,IsPaused,IsSkillTreeVisible. - Локация:
CurrentAreaName,CurrentAreaHash,CurrentAreaLevel,AreaChangeCounter. - Мир:
Player(полныйEntity),Entities(полныйstd::vector<Entity>),Vitals,LargeMap,MiniMap,WorldToScreenMatrix[16]. - Окно:
ScreenWidth,ScreenHeight,ProcessId,GameWindow,LastUpdateTime,WorldToGridConvertor.
Чего нет в снапшоте — запрашивайте через сервисы: содержимое инвентаря (InventoryService), баффы (ComponentsService::EnumerateBuffs), списки модификаторов по предметам (InventoryService::ReadItemMods), панели UI (UiService).
Дешёвые хелперы, когда полный снапшот не нужен:
if (ctx()->Game.IsInGame()) { ... }
if (ctx()->Game.IsForeground()) { ... } // game window focused
if (ctx()->Game.IsOverlayMode()) { ... } // host is in overlay (click-through)
if (ctx()->Game.IsMenuVisible()) { ... } // ESC menu, settings, etc.
auto sz = ctx()->Game.GetScreenSize(); // ScreenSize { Width, Height } floats
HWND hw = ctx()->Game.GetGameWindow();
DWORD pid = ctx()->Game.GetProcessId();
PluginSDK::GameState st = ctx()->Game.GetState();Сущности предоставляют свои компоненты через entity.Components — структуру ComponentAddresses из адресов uintptr_t. Передайте каждый адрес в соответствующий ComponentsService::Read*, чтобы получить срез значения.
for (const auto& e : snap.Entities) {
if (!e.Components.HasLife()) continue;
PluginSDK::Life life = ctx()->Components.ReadLife(e.Components.Life);
if (life.Valid && life.Health.Current > 0) {
ctx()->Log.Info("alive monster");
}
}Всего предусмотрено 21 ридер компонентов: ReadLife, ReadRender, ReadPositioned, ReadTargetable, ReadChest, ReadShrine, ReadStack, ReadCharges, ReadPlayer, ReadAnimated, ReadTransitionable, ReadTriggerableBlockage, ReadMinimapIcon, ReadStateMachine, ReadBase, ReadMods, ReadStats, ReadBuffs, ReadActor, ReadNpc, ReadDiesAfterTime.
Сам ComponentAddresses содержит 24 слота: 21 перечисленный выше плюс три маркера (Buffs, WorldItem, AreaTransition) и OMP (внутренний для хоста). Buffs — маркер присутствия; реальный список баффов получают через EnumerateBuffs. WorldItem / AreaTransition — это скорее маркеры типа сущности, чем настоящие компоненты. Для всех слотов в ComponentAddresses доступны парные предикаты HasX().
Ридеры коллекций для компонентов с переменным размером данных:
auto buffs = ctx()->Components.EnumerateBuffs(e.Components.Buffs); // std::vector<Buff>
auto skills = ctx()->Components.EnumerateActiveSkills(e.Components.Actor); // std::vector<ActiveSkill>
auto stats = ctx()->Components.EnumerateStats(e.Components.Stats); // std::vector<StatEntry>
auto mods = ctx()->Components.EnumerateItemMods(e.Components.Mods); // std::vector<Mod>Удобные хелперы (одноразовые — внутри они сами вызывают нужный Read*):
float hpPct = ctx()->Components.GetHealthPercent(e.Components.Life);
bool alive = ctx()->Components.IsAlive(e.Components.Life);
float esPct = ctx()->Components.GetEsPercent(e.Components.Life);
float mpPct = ctx()->Components.GetManaPercent(e.Components.Life);
int rarity = ctx()->Components.GetItemRarity(e.Components.Mods);
bool ident = ctx()->Components.IsItemIdentified(e.Components.Mods);
int stack = ctx()->Components.GetStackCount(e.Components.Stack);
bool open = ctx()->Components.IsChestOpened(e.Components.Chest);
std::string name = ctx()->Components.GetPlayerName(e.Components.Player);
float wx, wy, wz;
if (ctx()->Components.GetWorldPosition(e.Components.Render, wx, wy, wz)) { ... }Флаг Valid в каждой возвращаемой структуре позволяет обрабатывать ситуации «адрес компонента был 0 / чтение не удалось» без исключений. Если у вас уже есть родительская структура (Life, Mods, …), обращайтесь к её полям напрямую вместо повторного вызова хелпера — каждый хелпер заново перечитывает компонент.
Каждая Entity (включая snap.Player и элементы snap.Entities) несёт одинаковый набор полей:
| Группа | Поля |
|---|---|
| Идентификация |
Id, Address, EntityDetailsAddress, RenderComponentAddress, IsValid
|
| Классификация |
EntityType, EntitySubtype, EntityState, Rarity, Reaction, Zone (NearbyZone: InnerCircle≈60 / OuterCircle≈120 / Far) |
| Позиция |
GridPositionX, GridPositionY, TerrainHeight, WorldX/Y/Z, ModelBoundsZ
|
| Быстрые витал-значения |
CurrentHP, MaxHP, CurrentES, MaxES (избавляет от вызова ReadLife, если нужны только суммарные значения) |
| Строки |
Path (std::wstring, Metadata/...), PlayerName (std::wstring), TgtPath (std::string, путь к ассету) |
| Состояние |
IsSleeping, IsChestOpened
|
| Компоненты |
Components (под-структура ComponentAddresses) |
Если нужно следить за одной сущностью на протяжении нескольких кадров (например, сундуком, который открывает игрок) и при этом не хочется сканировать полный список сущностей каждый кадр, зарегистрируйте watch:
ctx()->Entities.Watch(entityId);
// ...later:
if (auto opt = ctx()->Entities.GetWatchedComponents(entityId)) {
PluginSDK::ComponentAddresses comps = *opt;
PluginSDK::Life l = ctx()->Components.ReadLife(comps.Life);
}
bool active = ctx()->Entities.IsWatched(entityId);
ctx()->Entities.Unwatch(entityId);FindById(id) возвращает std::optional<Entity> для разовых поисков, а GetPlayer() всегда возвращает локального игрока.
ctx()->Inventory.Scan(inventoryId) инициирует пересканирование на стороне хоста. Используйте -1, чтобы просканировать все инвентари.
ctx()->Inventory.Scan(-1);
std::vector<PluginSDK::Inventory> all = ctx()->Inventory.GetAll();
for (const auto& inv : all) {
const char* name = ctx()->Inventory.GetName(inv.InventoryId);
ctx()->Log.Info(name);
for (const auto& item : inv.Items) {
ctx()->Log.Info(item.BaseTypeName.c_str());
// item.SlotX, item.SlotY, item.Width, item.Height (grid metrics)
// item.Rarity, item.ItemLevel, item.RequiredLevel, item.CraftedModCount
// item.IsIdentified, item.IsCorrupted, item.IsCurrency
// item.Path (Metadata/Items/...), item.BaseTypeName, item.UniqueName
// item.Address — entity address for direct lookups below
}
}Каждый Inventory также содержит структуру Grid, описывающую, где инвентарь нарисован на экране:
if (inv.Grid.Valid) {
float originX = inv.Grid.GridScreenX;
float originY = inv.Grid.GridScreenY;
float cell = inv.Grid.CellSize;
// Slot (x, y) screen-space top-left = (originX + x*cell, originY + y*cell)
}Чтобы получить отдельный инвентарь по id (возвращает ту же структуру с уже заполненным Items):
PluginSDK::Inventory backpack = ctx()->Inventory.Get(/*inventoryId=*/0);Или, если нужен только вектор предметов без обёртки:
std::vector<PluginSDK::InventoryItem> items = ctx()->Inventory.GetItems(0);ComponentsService::ReadMods(addr) возвращает только сводные флаги (IsCorrupted, IsRelic, IsSplit, IsMirrored, IsSynthesised, IsIdentified, Rarity, ItemLevel, RequiredLevel, CraftedModCount). Он не содержит списки модификаторов по категориям.
Для полной картины (сводка + списки модификаторов) используйте InventoryService::ReadItemMods(entityAddr):
PluginSDK::ItemMods im = ctx()->Inventory.ReadItemMods(item.Address);
if (!im.Valid) return;
// Same summary fields as the Mods component, plus:
for (const auto& m : im.ImplicitMods) { ... } // std::vector<Mod>
for (const auto& m : im.ExplicitMods) { ... }
for (const auto& m : im.EnchantMods) { ... }
for (const auto& m : im.HellscapeMods) { ... }
for (const auto& m : im.CrucibleMods) { ... }Другие прямые чтения по сущности (дешевле пересканирования, если у вас уже есть адрес предмета):
int rarity = ctx()->Inventory.ReadItemRarity(item.Address);
int stack = ctx()->Inventory.ReadItemStackCount(item.Address);
std::string base = ctx()->Inventory.ReadItemBaseTypeName(item.Address);
std::string uniq = ctx()->Inventory.ReadItemUniqueName(item.Address);
std::string path = ctx()->Inventory.ReadItemPath(item.Address);UI-дерево игры предоставляется как адреса элементов типа uintptr_t. Начните с корня, обходите потомков, читайте поля элементов.
Чистый способ найти известную панель по её StringId:
uintptr_t gameUiRoot = ctx()->Ui.GetGameUiRoot();
uintptr_t invPanel = ctx()->Ui.FindPanelByStringId(gameUiRoot, "Inventory");
if (invPanel && ctx()->Ui.IsVisible(invPanel)) {
// panel is on-screen
}Ручной обход дерева, если StringId неизвестен:
uintptr_t root = ctx()->Ui.GetUiRoot();
PluginSDK::UiElement e = ctx()->Ui.Read(root);
ctx()->Log.Info(("children=" + std::to_string(e.ChildCount)).c_str());
for (uintptr_t child : ctx()->Ui.GetChildren(root)) {
std::string sid = ctx()->Ui.GetStringId(child);
if (sid == "InventoriesPanel") { /* found it */ }
}
// Or use a known index path:
int path[] = { 5, 1, 2, 0 };
uintptr_t logInButton = ctx()->Ui.FollowPath(root, path, 4);
// Compute screen-space rect (post-scale, post-transform):
float x, y, w, h;
if (ctx()->Ui.ComputeScreenRect(invPanel, x, y, w, h)) {
// draw an overlay box at (x,y,w,h)
}
// Get displayed text:
std::string label = ctx()->Ui.GetText(child);
int cull = ctx()->Ui.GetCullValue(); // host's UI cull thresholdЗначения StringId — это стабильные идентификаторы со стороны игры; когда они есть, предпочитайте их жёстко прописанным путям.
Три помощника по проекции, две системы координат.
Перспектива (3D-мир → экран) — та же проекция, которой игра рисует объекты в мире. Подходит для имён над головой, отладочных маркеров, индикаторов целей:
float sx, sy;
if (ctx()->Render.WorldToScreen(e.WorldX, e.WorldY, e.WorldZ, sx, sy)) {
ImGui::GetBackgroundDrawList()->AddCircleFilled({sx, sy}, 4.f, IM_COL32(255,0,0,255));
}Изометрия (сетка → миникарта) — для радар-оверлеев, отрисованных на большой или миникарте. Эти вызовы учитывают зум, панорамирование и поворот видимой карты:
if (!snap.LargeMap.IsVisible) return;
for (const auto& e : snap.Entities) {
float sx, sy;
if (ctx()->Render.GridToLargeMap(e.GridPositionX, e.GridPositionY, e.TerrainHeight, sx, sy)) {
ImGui::GetBackgroundDrawList()->AddCircleFilled({sx, sy}, 4.f, color);
}
}
// Mirror: ctx()->Render.GridToMiniMap(gx, gy, worldZ, sx, sy)Для пакетной математики (без вызова функции на каждую сущность) возьмите трансформацию один раз и выполняйте проекцию инлайном:
PluginSDK::MapTransform t = ctx()->Render.GetLargeMapTransform();
if (t.IsVisible) {
// dx = gx - t.PlayerGridX; dy = gy - t.PlayerGridY;
// sx = t.CenterX + (dx - dy) * t.ScaleX;
// sy = t.CenterY + (worldZ * worldToGrid - (dx + dy)) * t.ScaleY;
}
// And ctx()->Render.GetMiniMapTransform() for the minimap.Смотрите Plugins/Radar/src/Radar.cpp — рабочий радар, построенный исключительно на этих вызовах.
Сетка проходимости — это битмап с 4 битами на тайл, показывающий, на какие клетки рельефа игрок может наступить. Хост обновляет её при каждой смене области; плагины получают стабильный хендл, живущий до тех пор, пока плагин его не освободит (через RAII).
PluginSDK::WalkableGridHandle h = ctx()->Terrain.GetWalkableGrid();
if (h.Valid()) {
const uint8_t* data = h.Data();
const int w = h.Width();
const int height = h.Height();
const size_t sizeBytes = h.SizeBytes(); // (w * height) / 2
// POE2 packs two cells per byte:
// gx & 1 == 0 → low nibble (data[gy * (w/2) + gx/2] & 0x0F)
// gx & 1 == 1 → high nibble ((data[gy * (w/2) + gx/2] >> 4) & 0x0F)
// Non-zero nibble = walkable.
//
// Always bound your byte index against sizeBytes — the host enforces an
// atomic snapshot, but defense-in-depth has caught at least one real bug.
}
// h is RAII — destructor releases the host reference automatically.HeightGridHandle повторяет ту же форму, но хранит по одному float на тайл (Data() — это const float*, плюс ElementCount() и SizeBytes()).
Не подписывайтесь на OnAreaChange, чтобы обновить хендл. Событие срабатывает, когда воркер хоста зафиксировал смену области, но новая сетка проходимости к этому моменту может быть ещё не разобрана — вы получите устаревший указатель на кадр-другой. Вместо этого опрашивайте её каждый кадр в DrawUI:
auto current = ctx()->Terrain.GetWalkableGrid();
if (current.Data() != m_walkable.Data()) {
m_walkable = std::move(current); // swap when the host re-parses
}Это дёшево (один вызов ABI и одно сравнение указателей). Боевую версию смотрите в Plugins/Radar/src/Radar.cpp.
Прочие аксессоры рельефа:
bool ok = ctx()->Terrain.IsWalkable(gx, gy);
float worldZ = ctx()->Terrain.GetTerrainHeight(gx, gy);
float worldToG = ctx()->Terrain.GetWorldToGridConvertor();
ctx()->Terrain.EnumerateTgtLocations([](const PluginSDK::TgtLocation& loc) {
// loc.Path, loc.TileX, loc.TileY, loc.X, loc.Y
return true; // continue
});Подписывайтесь на события, эмитируемые хостом. Каждая Subscribe возвращает Token, который позже можно передать в Unsubscribe. Деструктор EventsService (срабатывает, когда плагин выключается или выгружается) автоматически освобождает всё незавершённое — поэтому строго необходимости отписываться вручную нет, но это считается хорошим тоном.
class MyPlugin : public PluginSDK::Plugin {
PluginSDK::EventsService::Token m_areaTok{};
PluginSDK::EventsService::Token m_frameTok{};
public:
void OnEnable(bool) override {
auto& ev = const_cast<PluginSDK::EventsService&>(ctx()->Events);
m_areaTok = ev.OnAreaChange([this]{
ctx()->Log.Info("area changed");
});
m_frameTok = ev.OnFrame([this]{
// called every frame; keep work cheap
});
ev.OnGameAttached([this]{ ctx()->Log.Info("game attached"); });
ev.OnGameDetached([this]{ ctx()->Log.Info("game detached"); });
}
void OnDisable() override {
auto& ev = const_cast<PluginSDK::EventsService&>(ctx()->Events);
ev.Unsubscribe(m_areaTok);
ev.Unsubscribe(m_frameTok);
}
};Четыре вида событий: AreaChange, Frame, GameAttached, GameDetached. Есть также универсальный Subscribe(EventKind, callback), если вам удобнее построить таблицу диспетчеризации.
const_cast обязателен, потому что Events мутирует свою внутреннюю карту токенов. Базовый класс возвращает const Context*, чтобы случайно не мутировать остальные сервисы.
Если в вашем плагине несколько подписок, шаблон из ExamplePlugin — аккуратный способ держать симметрию enable/disable: соберите токены и счётчики в одну структуру состояния и пропустите всё через пару SubscribeAll/UnsubscribeAll:
struct EventsDemoState {
std::atomic<int> frameCount{0}, areaChangeCount{0};
PluginSDK::EventsService::Token frameTok{}, areaTok{};
bool subscribed = false;
};
void SubscribeAll(const PluginSDK::Context* ctx, EventsDemoState& s) {
auto& ev = const_cast<PluginSDK::EventsService&>(ctx->Events);
s.frameTok = ev.OnFrame ([&s]{ s.frameCount.fetch_add(1); });
s.areaTok = ev.OnAreaChange ([&s]{ s.areaChangeCount.fetch_add(1); });
s.subscribed = true;
}
void UnsubscribeAll(const PluginSDK::Context* ctx, EventsDemoState& s) {
auto& ev = const_cast<PluginSDK::EventsService&>(ctx->Events);
ev.Unsubscribe(s.frameTok);
ev.Unsubscribe(s.areaTok);
s.frameTok = {}; s.areaTok = {};
s.subscribed = false;
}Полный паттерн смотрите в Plugins/ExamplePlugin/examples/ExampleEvents.h.
Соглашение: <plugin directory>/config/settings.json. Directory() возвращает абсолютный UTF-8 путь к папке вашего плагина.
Для тривиальных настроек подойдёт самописный JSON-писатель — это сохраняет DLL самодостаточным. Рабочий пример смотрите в Plugins/Radar/src/RadarSettings.h. Скелет:
struct MySettings {
bool DrawEnabled = true;
float Opacity = 0.9f;
void Save(const std::string& directory) const {
std::filesystem::path p =
std::filesystem::path(directory) / "config" / "settings.json";
std::error_code ec;
std::filesystem::create_directories(p.parent_path(), ec);
std::ofstream out(p);
if (!out.is_open()) return;
out << "{\n";
out << " \"DrawEnabled\":" << (DrawEnabled ? "true" : "false") << ",\n";
out << " \"Opacity\":" << Opacity << "\n";
out << "}\n";
}
void Load(const std::string& directory) {
std::filesystem::path p =
std::filesystem::path(directory) / "config" / "settings.json";
if (!std::filesystem::exists(p)) return;
// ... parse ...
}
};
// In your plugin:
void OnEnable(bool) override { m_settings.Load(Directory()); }
void SaveSettings() override { m_settings.Save(Directory()); }Для структурированных данных (вложенные объекты, массивы) подключите к папке плагина настоящую JSON-библиотеку. Хост не навязывает выбор.
SaveSettings вызывается периодически (~5 с) и при выключении; вызывать его самостоятельно не нужно.
ctx()->Log.Debug("verbose detail");
ctx()->Log.Info ("normal status");
ctx()->Log.Warn ("something unexpected");
ctx()->Log.Error("operation failed");
ctx()->Log.Log ("custom-level", "message");Все четыре уровня направляются в центральный логгер хоста. Сообщения появляются на вкладке Logs хоста и в файле лога на диске. Форматируйте строку самостоятельно перед вызовом — хост не принимает printf-стиль с varargs.
Внутри удобные методы эмитируют строки "Debug", "Info", "Warning" и "Error" (Warn отображается в "Warning"). Мост хоста делает сравнение без учёта регистра, поэтому плагин, вызывающий Log("warn", "msg"), по-прежнему попадёт куда нужно — но удобные методы читаются яснее.
Примитивы прямой работы с памятью. Везде, где возможно, отдавайте предпочтение высокоуровневым сервисам — они знают смещения, переживают изменения ABI и безопасны относительно SEH. Прямые чтения памяти уместны только тогда, когда для нужной задачи нет более высокоуровневого вызова.
// Read a fixed-size value:
uint64_t value = 0;
ctx()->Memory.Read(addr, &value, sizeof(value));
// Read game strings (null-terminated, narrow or wide):
std::string s = ctx()->Memory.ReadString (strAddr);
std::wstring ws = ctx()->Memory.ReadWString(wstrAddr);
// Read a std::wstring container in the game's memory (handles SSO):
std::wstring inner = ctx()->Memory.ReadStdWString(containerAddr);
// Read a std::vector<T>; returns raw bytes you reinterpret_cast:
std::vector<uint8_t> raw = ctx()->Memory.ReadStdVector(vecAddr, sizeof(MyT), /*maxElems=*/1024);
const MyT* items = reinterpret_cast<const MyT*>(raw.data());
size_t count = raw.size() / sizeof(MyT);
// Module info:
uintptr_t base = ctx()->Memory.GetBaseAddress();
uintptr_t sz = ctx()->Memory.GetModuleSize();
uintptr_t pat = ctx()->Memory.GetPatternAddress("GameStates"); // resolves a named patternЕсли вы часто тянетесь к этим вызовам, задайтесь вопросом: не место ли нужным данным в более высокоуровневых сервисах.
Каждый кросс-DLL-вызов между хостом и плагином выполняется внутри блока __try / __except на стороне хоста. Криво ведущий себя плагин, который разыменовывает устаревший указатель, делит на ноль или иным образом падает внутри SDK-вызова, получает запись в лог об ошибке — процесс хоста при этом не падает, игра продолжает работать, и пользователь может продолжать пользоваться остальными плагинами.
Это не значит, что плагины можно писать небрежно. SEH ловит симптом, а не причину. Если ваш плагин падает каждый кадр, пользователь видит поток лог-ошибок, а ваши данные фактически недоступны. Обрабатывайте null-возвраты от SDK-вызовов, проверяйте флаги Valid у данных компонентов и не разыменовывайте адреса uintptr_t напрямую — пропускайте их через вызовы ComponentsService / Ui / Memory, которые уже корректно оборачивают RPM.
С глючным плагином хост справится. С зависшим DLL плагина — нет: DrawSettings длиной 100 мс блокирует весь UI-поток. Держите работу за кадр дешёвой.
Короткий список того, на что натыкаются авторы плагинов при первой интеграции. Большая часть пунктов уже задокументирована выше — здесь они собраны как чек-лист.
-
OnAreaChangeсрабатывает до того, как переразобрана сетка проходимости. Не обновляйтеWalkableGridHandleиз события — опрашивайте каждый кадр вDrawUIи подменяйте, когда меняетсяData(). (§11) -
Entity::Zoneдля локального игрока всегдаNone. Это классификация по расстоянию от игрока, а игрок по определению на нулевом расстоянии. Не показывайте это в панелях с информацией об игроке. -
Components.ReadMods()возвращает только сводные флаги — без списков модификаторов. Для списков по категориям вызывайтеInventory.ReadItemMods(entityAddr). (§8) -
У предметов, лежащих на земле, может отсутствовать
EntitySubtype. Если фильтруете предметы в мире, предпочитайтеEntityType == Item || EntityType == Chestболее узкой проверке по подтипу. -
Directory()возвращает абсолютный путь. Не приклеивайте к нему путь EXE сами — получитеEXEDIR\EXEDIR\Plugins\X, и запись конфига окажется вне папки плагина. -
ctx()возвращаетconst Context*. Мутирующие методы вродеEventsService::Subscribeтребуютconst_cast. Это сделано намеренно — сервисы, которые не мутируют, нельзя случайно изменить. -
ImGui::SetCurrentContextставится для каждого DLL отдельно. Вызывайте его в каждой точке входа, где идёт отрисовка (OnEnable,DrawUI,DrawSettings), потому что у DLL плагина по умолчанию своё состояние ImGui. -
Удобные хелперы перечитывают компонент при каждом вызове.
GetHealthPercent(addr)внутри делает свежийReadLife(addr). Если структураLifeу вас уже есть из предыдущего вызова, обращайтесь к её полям напрямую.
Однострочное описание каждого публичного метода каждого сервиса. Полные сигнатуры типов и заметки по использованию ищите в разделах выше.
| Метод | Возвращает | Назначение |
|---|---|---|
GetSnapshot() |
Snapshot |
Полный обзор кадра, включая Entities
|
GetState() |
GameState |
Перечисление: InGame, Login, Loading, … |
IsAttached() |
bool |
Подключение к процессу игры |
IsInGame() |
bool |
State == InGame |
IsForeground() |
bool |
Окно игры в фокусе |
IsMenuVisible() |
bool |
Открыто ESC-меню / настройки |
IsOverlayMode() |
bool |
Хост в режиме оверлея (click-through) |
GetProcessId() |
DWORD |
PID игры |
GetGameWindow() |
HWND |
Хендл окна игры |
GetScreenSize() |
ScreenSize |
{Width, Height} в float |
| Метод | Возвращает | Назначение |
|---|---|---|
Enumerate(cb) |
— | Обход всех ближайших сущностей (верните false, чтобы остановиться) |
GetPlayer() |
Entity |
Сущность локального игрока |
FindById(id) |
std::optional<Entity> |
Поиск по id сущности |
Watch(id) |
— | Закрепить сущность, чтобы её компоненты оставались читаемыми |
Unwatch(id) |
— | Снять watch |
IsWatched(id) |
bool |
Состояние watch |
GetWatchedComponents(id) |
std::optional<ComponentAddresses> |
Чтение закреплённых компонентов |
| Метод | Возвращает | Назначение |
|---|---|---|
ReadLife / ReadRender / ReadPositioned / ReadTargetable / ReadChest / ReadShrine / ReadStack / ReadCharges / ReadPlayer / ReadAnimated / ReadTransitionable / ReadTriggerableBlockage / ReadMinimapIcon / ReadStateMachine / ReadBase / ReadMods / ReadStats / ReadBuffs / ReadActor / ReadNpc / ReadDiesAfterTime |
Структура компонента | 21 ридер — по одному на каждый тип компонента |
EnumerateBuffs(addr) |
std::vector<Buff> |
Активные баффы на сущности |
EnumerateActiveSkills(addr) |
std::vector<ActiveSkill> |
Скиллы из компонента Actor
|
EnumerateStats(addr) |
std::vector<StatEntry> |
Статы от предметов и баффов |
EnumerateItemMods(addr) |
std::vector<Mod> |
Модификаторы, доступные из компонента Mods
|
GetHealthPercent / GetEsPercent / GetManaPercent |
float |
Удобные процентные хелперы |
IsAlive(addr) |
bool |
Health > 0 |
GetItemRarity(addr) |
int |
Редкость из компонента Mods
|
IsItemIdentified(addr) |
bool |
Флаг идентификации |
GetStackCount(addr) |
int |
Текущий размер стека |
IsChestOpened(addr) |
bool |
Флаг открытия сундука |
GetPlayerName(addr) |
std::string |
Имя игрока из компонента Player
|
GetWorldPosition(renderAddr, x, y, z) |
bool |
Удобный аксессор мировых координат |
| Метод | Возвращает | Назначение |
|---|---|---|
Scan(inventoryId) |
— | Запустить пересканирование на стороне хоста (-1 = все) |
Get(inventoryId) |
Inventory |
Один инвентарь с уже заполненными Items
|
GetItems(inventoryId) |
std::vector<InventoryItem> |
Только предметы |
GetAll() |
std::vector<Inventory> |
Все просканированные инвентари |
GetName(inventoryId) |
const char* |
Отображаемое имя ("Backpack", "Stash", …) |
ReadItemRarity(addr) |
int |
Редкость по сущности |
ReadItemStackCount(addr) |
int |
Размер стека по сущности |
ReadItemBaseTypeName(addr) |
std::string |
Базовый тип ("Gemcutter's Prism", …) |
ReadItemUniqueName(addr) |
std::string |
Уникальное имя (если применимо) |
ReadItemPath(addr) |
std::string |
Путь Metadata/Items/...
|
ReadItemMods(addr) |
ItemMods |
Сводные флаги + 5 векторов модификаторов по категориям |
| Метод | Возвращает | Назначение |
|---|---|---|
Read(addr) |
UiElement |
Поля элемента (rect, флаги, количество потомков) |
GetChildren(addr) |
std::vector<uintptr_t> |
Адреса потомков |
GetChildAt(addr, index) |
uintptr_t |
Один потомок по индексу |
FollowPath(root, indices, count) |
uintptr_t |
Пройти по известному пути индексов |
IsVisible(addr) |
bool |
Элемент виден на экране |
GetStringId(addr) |
std::string |
Стабильный идентификатор со стороны игры |
GetText(addr) |
std::string |
Отрисованный текст |
ComputeScreenRect(addr, x, y, w, h) |
bool |
Итоговый прямоугольник в экранных координатах |
GetGameUiRoot() |
uintptr_t |
Корень игрового UI |
GetUiRoot() |
uintptr_t |
Самый верхний UI-корень |
GetCullValue() |
int |
Порог отсечения UI у хоста |
FindPanelByStringId(parent, stringId) |
uintptr_t |
Целевой поиск потомка |
| Метод | Возвращает | Назначение |
|---|---|---|
WorldToScreen(wx, wy, wz, sx, sy) |
bool |
Перспективная проекция |
GridToLargeMap(gx, gy, worldZ, sx, sy) |
bool |
Проекция на оверлей большой карты |
GridToMiniMap(gx, gy, worldZ, sx, sy) |
bool |
Проекция на миникарту |
GetLargeMapTransform() |
MapTransform |
Предвычисленная трансформация для пакетной математики |
GetMiniMapTransform() |
MapTransform |
То же для миникарты |
| Метод | Возвращает | Назначение |
|---|---|---|
GetWalkableGrid() |
WalkableGridHandle |
RAII-хендл к битмапу проходимости (4 бита на тайл) |
GetHeightGrid() |
HeightGridHandle |
RAII-хендл к высотам рельефа по тайлам |
IsWalkable(gx, gy) |
bool |
Предикат для одного тайла |
GetTerrainHeight(gx, gy) |
float |
Мировая Z-координата |
GetWorldToGridConvertor() |
float |
Коэффициент перевода мир → сетка |
EnumerateTgtLocations(cb) |
— | Обход всех TGT-инстансов в текущей области |
| Метод | Возвращает | Назначение |
|---|---|---|
Read(addr, buf, size) |
bool |
Сырой RPM |
ReadString(addr) |
std::string |
Узкая строка с нулевым терминатором |
ReadWString(addr) |
std::wstring |
Широкая строка с нулевым терминатором |
ReadStdWString(addr) |
std::wstring |
Чтение игрового контейнера std::wstring (учитывает SSO) |
ReadStdVector(addr, elemSize, maxElems) |
std::vector<uint8_t> |
Сырые байты; интерпретируйте в нужный тип |
GetBaseAddress() |
uintptr_t |
Базовый адрес модуля игры |
GetModuleSize() |
uintptr_t |
Размер модуля игры |
GetPatternAddress(name) |
uintptr_t |
Поиск по именованному паттерну |
| Метод | Назначение |
|---|---|
Debug / Info / Warn / Error(msg) |
Эмит на соответствующем уровне |
Log(level, msg) |
Строка уровня по выбору |
| Метод | Возвращает | Назначение |
|---|---|---|
Subscribe(kind, cb) |
Token |
Универсальная диспетчеризация |
OnAreaChange / OnFrame / OnGameAttached / OnGameDetached(cb) |
Token |
Однострочные хелперы подписки |
Unsubscribe(token) |
— | Ручное освобождение (деструктор всё равно делает это автоматически) |
PluginAbi.h определяет:
constexpr int PLUGIN_SDK_VERSION = 6;При загрузке хост вызывает plugin->GetSDKVersion() и сравнивает с собственным PLUGIN_SDK_VERSION. Несовпадение → хост пишет предупреждение в лог и отказывается загружать плагин.
Кроме того, хост сверяет HostAbi::version и HostAbi::size_bytes внутри PluginSDK_AttachHost (определён инлайн в PluginSDK.h при заданном PLUGIN_EXPORTS). Если хотя бы одно поле не совпадает с тем, на чём собирался плагин, ctx() нерабочий. Аксессор HostCompatible() базового класса в этом случае вернёт false, и любой вежливый плагин должен отказаться от работы:
void OnEnable(bool) override {
if (!HostCompatible()) {
ctx()->Log.Error("Host ABI mismatch — disable plugin");
return;
}
// ...
}Четыре плагина в репозитории спроектированы так, чтобы их читали как документацию:
-
Plugins/ExamplePlugin/— широкоохватная демонстрация. Один плагин, который касается почти каждого сервиса; организован как 11 под-файловexamples/Example*.h(Area & Vitals, Buffs, Entities, Inventory, Memory, UI Explorer, Component Reader, Render, Terrain, Events, Log) плюс баннер с итоговым покрытием. Читайте его, когда хотите увидеть, как сервис используется в контексте. -
Plugins/Radar/— сфокусированный пример из реальной жизни. ~200 строк. Радар-оверлей, построенный целиком на публичном SDK — без смещений и без сырых чтений памяти. Рисует карту проходимости плюс точки по сущностям черезRender.GridToLargeMap. Читайте его, когда хотите увидеть минимальный объём кода для конкретного результата. -
Plugins/KillCount/— трекер убийств / сундуков / смертей. SQLite + спрайт-атлас + состояние по локациям. Показывает, как уместить персистентность, прибившиеся данные и оверлей в одном DLL. -
Plugins/NinjaPricer/— оверлей цен poe.ninja. HTTP-запросы (Exchange API) + сканирование инвентаря + расценка по предметам. Показывает сетевой код, потребление сторонних данных и итерацию по инвентарю в реальном рабочем процессе.