Skip to content

Plugin Development Guide RU

Lafko edited this page May 23, 2026 · 16 revisions

← Home


Руководство по разработке плагинов

Плагины POEFixer — это нативные C++ DLL, загружаемые во время выполнения из Plugins/<PluginName>/<PluginName>.dll. Они читают живое состояние игры, рисуют ImGui-оверлеи, сохраняют собственные настройки и подписываются на события хоста.


1. Обзор

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/.


2. Плагин «Hello, world»

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

#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.


3. Настройка проекта

В качестве канонического шаблона используйте 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));

4. Хуки жизненного цикла

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() (определён в базовом классе, переопределять НЕ нужно), чтобы проверить совместимость плагина и хоста. Несовпадение → плагин отклоняется.


5. Context

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.


6. Чтение состояния игры

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();

7. Чтение компонентов

Сущности предоставляют свои компоненты через 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

Каждая 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() всегда возвращает локального игрока.

Предметы на земле (контейнеры WorldItem)

Предметы, выброшенные на землю, появляются в snap.Entities как сущности с EntityType::Item по пути Metadata/MiscellaneousObjects/WorldItem. Это сущности-контейнеры — они не несут напрямую Mods / Base / Stack / Sockets. Настоящая сущность предмета находится на одну ступень дальше.

Чтобы получить внутреннюю сущность предмета как обычный снапшот Entity, используйте Entities.GetWorldItemInner:

for (const auto& e : snap.Entities) {
    if (e.EntityType != PluginSDK::EntityType::Item) continue;
    auto inner = ctx()->Entities.GetWorldItemInner(e.Address);
    if (!inner) continue;  // mid-spawn, retry next frame
    // inner->Path        — "Metadata/Items/Armours/Gloves/..."
    // inner->Components  — Mods / Base / Stack / Sockets / etc.
    PluginSDK::Mods mods = ctx()->Components.ReadMods(inner->Components.Mods);
    int iLvl   = mods.ItemLevel;
    int rarity = mods.Rarity;
}

GetWorldItemInner срабатывает только для настоящих контейнеров WorldItem — вызов с адресом предмета из инвентаря вернёт std::nullopt. Если вам нужна та же форма данных, что и для предметов в инвентаре (без ручного обхода компонентов), семейство Inventory.ReadItem* из следующего раздела прозрачно авторазрешает контейнеры WorldItem.


8. Инвентарь

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);

Модификаторы предметов: два API, две области применения

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);

Предметы на земле через API инвентаря. Все семь чтений Inventory.ReadItem* выше (и ReadItemMods) принимают КАК адреса предметов из инвентаря, ТАК и адреса контейнеров WorldItem. Адреса контейнеров автоматически разрешаются во внутренний предмет перед чтением, поэтому один и тот же код плагина работает и для предметов в сумках, и для предметов на земле:

// `addr` может быть либо адресом предмета инвентаря, либо контейнером WorldItem.
PluginSDK::ItemMods im = ctx()->Inventory.ReadItemMods(addr);
int          rarity   = ctx()->Inventory.ReadItemRarity(addr);
std::string  baseName = ctx()->Inventory.ReadItemBaseTypeName(addr);

Если вам нужны адреса компонентов внутреннего предмета напрямую (например, чтобы вызвать ctx()->Components.ReadStack(...) или обойти сокеты), используйте Entities.GetWorldItemInner из раздела 7.


9. Дерево UI

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 — это стабильные идентификаторы со стороны игры; когда они есть, предпочитайте их жёстко прописанным путям.


10. Рендеринг и проекция

Три помощника по проекции, две системы координат.

Перспектива (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 — рабочий радар, построенный исключительно на этих вызовах.


11. Ландшафт и сетка проходимости

Сетка проходимости — это битмап с 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
});

12. События

Подписывайтесь на события, эмитируемые хостом. Каждая 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.


13. Сохранение настроек

Соглашение: <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 с) и при выключении; вызывать его самостоятельно не нужно.


14. Логирование

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"), по-прежнему попадёт куда нужно — но удобные методы читаются яснее.


15. Память (для опытных пользователей)

Примитивы прямой работы с памятью. Везде, где возможно, отдавайте предпочтение высокоуровневым сервисам — они знают смещения, переживают изменения 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

Если вы часто тянетесь к этим вызовам, задайтесь вопросом: не место ли нужным данным в более высокоуровневых сервисах.


16. Мост / безопасность SEH

Каждый кросс-DLL-вызов между хостом и плагином выполняется внутри блока __try / __except на стороне хоста. Криво ведущий себя плагин, который разыменовывает устаревший указатель, делит на ноль или иным образом падает внутри SDK-вызова, получает запись в лог об ошибке — процесс хоста при этом не падает, игра продолжает работать, и пользователь может продолжать пользоваться остальными плагинами.

Это не значит, что плагины можно писать небрежно. SEH ловит симптом, а не причину. Если ваш плагин падает каждый кадр, пользователь видит поток лог-ошибок, а ваши данные фактически недоступны. Обрабатывайте null-возвраты от SDK-вызовов, проверяйте флаги Valid у данных компонентов и не разыменовывайте адреса uintptr_t напрямую — пропускайте их через вызовы ComponentsService / Ui / Memory, которые уже корректно оборачивают RPM.

С глючным плагином хост справится. С зависшим DLL плагина — нет: DrawSettings длиной 100 мс блокирует весь UI-поток. Держите работу за кадр дешёвой.


17. Типичные подводные камни

Короткий список того, на что натыкаются авторы плагинов при первой интеграции. Большая часть пунктов уже задокументирована выше — здесь они собраны как чек-лист.

  1. OnAreaChange срабатывает до того, как переразобрана сетка проходимости. Не обновляйте WalkableGridHandle из события — опрашивайте каждый кадр в DrawUI и подменяйте, когда меняется Data(). (§11)
  2. Entity::Zone для локального игрока всегда None. Это классификация по расстоянию от игрока, а игрок по определению на нулевом расстоянии. Не показывайте это в панелях с информацией об игроке.
  3. Components.ReadMods() возвращает только сводные флаги — без списков модификаторов. Для списков по категориям вызывайте Inventory.ReadItemMods(entityAddr). (§8)
  4. У предметов, лежащих на земле, может отсутствовать EntitySubtype. Если фильтруете предметы в мире, предпочитайте EntityType == Item || EntityType == Chest более узкой проверке по подтипу.
  5. Directory() возвращает абсолютный путь. Не приклеивайте к нему путь EXE сами — получите EXEDIR\EXEDIR\Plugins\X, и запись конфига окажется вне папки плагина.
  6. ctx() возвращает const Context*. Мутирующие методы вроде EventsService::Subscribe требуют const_cast. Это сделано намеренно — сервисы, которые не мутируют, нельзя случайно изменить.
  7. ImGui::SetCurrentContext ставится для каждого DLL отдельно. Вызывайте его в каждой точке входа, где идёт отрисовка (OnEnable, DrawUI, DrawSettings), потому что у DLL плагина по умолчанию своё состояние ImGui.
  8. Удобные хелперы перечитывают компонент при каждом вызове. GetHealthPercent(addr) внутри делает свежий ReadLife(addr). Если структура Life у вас уже есть из предыдущего вызова, обращайтесь к её полям напрямую.

18. Краткий справочник по сервисам

Однострочное описание каждого публичного метода каждого сервиса. Полные сигнатуры типов и заметки по использованию ищите в разделах выше.

GameService

Метод Возвращает Назначение
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

EntitiesService

Метод Возвращает Назначение
Enumerate(cb) Обход всех ближайших сущностей (верните false, чтобы остановиться)
GetPlayer() Entity Сущность локального игрока
FindById(id) std::optional<Entity> Поиск по id сущности
GetWorldItemInner(addr) std::optional<Entity> Внутренняя сущность предмета для контейнера WorldItem (предметы на земле)
Watch(id) Закрепить сущность, чтобы её компоненты оставались читаемыми
Unwatch(id) Снять watch
IsWatched(id) bool Состояние watch
GetWatchedComponents(id) std::optional<ComponentAddresses> Чтение закреплённых компонентов

ComponentsService

Метод Возвращает Назначение
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 Удобный аксессор мировых координат

InventoryService

Метод Возвращает Назначение
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 Редкость по сущности (авторазрешение контейнеров WorldItem)
ReadItemStackCount(addr) int Размер стека по сущности (авторазрешение контейнеров WorldItem)
ReadItemBaseTypeName(addr) std::string Базовый тип, авторазрешение контейнеров WorldItem
ReadItemUniqueName(addr) std::string Уникальное имя, авторазрешение контейнеров WorldItem
ReadItemPath(addr) std::string Путь Metadata/Items/..., авторазрешение контейнеров WorldItem
ReadItemMods(addr) ItemMods Сводные флаги + 5 векторов модификаторов по категориям, авторазрешение контейнеров WorldItem

UiService

Метод Возвращает Назначение
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 Целевой поиск потомка

RenderService

Метод Возвращает Назначение
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 То же для миникарты

TerrainService

Метод Возвращает Назначение
GetWalkableGrid() WalkableGridHandle RAII-хендл к битмапу проходимости (4 бита на тайл)
GetHeightGrid() HeightGridHandle RAII-хендл к высотам рельефа по тайлам
IsWalkable(gx, gy) bool Предикат для одного тайла
GetTerrainHeight(gx, gy) float Мировая Z-координата
GetWorldToGridConvertor() float Коэффициент перевода мир → сетка
EnumerateTgtLocations(cb) Обход всех TGT-инстансов в текущей области

MemoryService

Метод Возвращает Назначение
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 Поиск по именованному паттерну

LogService

Метод Назначение
Debug / Info / Warn / Error(msg) Эмит на соответствующем уровне
Log(level, msg) Строка уровня по выбору

EventsService

Метод Возвращает Назначение
Subscribe(kind, cb) Token Универсальная диспетчеризация
OnAreaChange / OnFrame / OnGameAttached / OnGameDetached(cb) Token Однострочные хелперы подписки
Unsubscribe(token) Ручное освобождение (деструктор всё равно делает это автоматически)

19. Версионирование

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;
    }
    // ...
}

20. Примеры плагинов

Четыре плагина в репозитории спроектированы так, чтобы их читали как документацию:

  • 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) + сканирование инвентаря + расценка по предметам. Показывает сетевой код, потребление сторонних данных и итерацию по инвентарю в реальном рабочем процессе.


← Home

Clone this wiki locally