Skip to content

Plugin Development Guide UA

Lafko edited this page Jun 24, 2026 · 15 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.
  • Цей заголовок оголошує все в namespace PluginSDK:: і підтягує C ABI з PluginAbi.h нижче. Знати про його існування корисно; заглядати туди майже ніколи не доводиться.
  • Усі контейнери std::* живуть усередині DLL плагіна. Через межу хоста переходить лише POD. Це означає, що плагін, зібраний іншою версією тулчейну, не заплутається зі STL хоста — спільними типами лишаються лише цілі, float, вказівники й невеликі структури.

Заголовки 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
  • Platform toolset: v143 (Visual Studio 2022)
  • Набір символів: Unicode
  • Стандарт мови: stdcpp20
  • Бібліотека виконання: MultiThreadedDLL (Release) / MultiThreadedDebugDLL (Debug). МАЄ збігатися з хостом.
  • Визначення препроцесора: PLUGIN_EXPORTS;NDEBUG;_WINDOWS;_USRDLL;_CRT_SECURE_NO_WARNINGS
  • Додаткові каталоги include: $(SolutionDir)POEFixer
  • Каталог виводу: $(SolutionDir)x64\Release\Plugins\<ВашПлагін>\
  • Ім'я цілі: має збігатися з ім'ям папки (Plugins/MyPlugin/MyPlugin.dll)

Хост сканує кожну підпапку в Plugins/ і шукає <НазваПапки>.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 з боку плагіна — per-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, якщо хочете, щоб хост був у режимі оверлею (click-through)
void SaveSettings() Періодично (~5с) і при вимкненні Зберегти конфіг на диск
void OnDisable() Коли користувач вимикає плагін або при завершенні роботи хоста Звільнити ресурси, скасувати підписки на події

Обов'язковим є лише GetName; решта мають безпечні значення за замовчуванням.

Хост також викликає GetSDKVersion() (визначено у базовому класі, НЕ перевизначайте) одразу після CreatePlugin, щоб упевнитися, що плагін і хост узгоджені. Розбіжність → плагіну відмовлено.


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() через межі hot-reload або вивантаження 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

Що snapshot несе безпосередньо (додаткові виклики сервісів не потрібні):

  • Стан і прапорці: 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.

Чого немає в snapshot — отримуйте через сервіси: вміст інвентаря (InventoryService), баффи (ComponentsService::EnumerateBuffs), списки модів предмета (InventoryService::ReadItemMods), UI-панелі (UiService).

Дешеві хелпери, коли повний snapshot не потрібен:

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*, щоб отримати snapshot за значенням.

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 — маркери типу сутності, а не справжні компоненти. Усі слоти мають відповідні предикати HasX() у ComponentAddresses.

Ридери у вигляді колекцій для компонентів зі змінним розміром даних:

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>

Зручні хелпери (one-shot — внутрішньо викликають 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) несе той самий набір полів:

Група Поля
Identity Id, Address, EntityDetailsAddress, RenderComponentAddress, IsValid
Classification EntityType, EntitySubtype, EntityState, Rarity, Reaction, Zone (NearbyZone: InnerCircle≈60 / OuterCircle≈120 / Far)
Position GridPositionX, GridPositionY, TerrainHeight, WorldX/Y/Z, ModelBoundsZ
Quick vitals CurrentHP, MaxHP, CurrentES, MaxES (avoids a ReadLife if you only need the totals)
Strings Path (std::wstring, Metadata/...), PlayerName (std::wstring), TgtPath (std::string, asset path)
State IsSleeping, IsChestOpened
Components Components (ComponentAddresses sub-struct)

Відстеження конкретної сутності

Якщо потрібно відстежувати одну сутність крізь кадри (наприклад, скриню, яку відкриває гравець) і не хочеться сканувати весь список сутностей кожного кадру, зареєструйте 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.

Текст мода у стилі гри + базові / агреговані статистики (v6, 2026-06-24). Відформатуйте будь-який ключ статистики в той самий текст, який показує внутрішньоігровий тултіп, і прочитайте базові захисні значення предмета та агреговані властивості мап/вейстонів:

// Відрендерити мод так, як це робить гра ("19% increased Monster Damage").
for (const auto& m : im.ExplicitMods) {
    std::string text = ctx()->Inventory.FormatStat(m.StatKey, m.Value0, m.Value1);
    if (!text.empty()) { /* відобразити `text` */ }
}

// Базові захисні значення предмета. EnergyShield — це внутрішньоігрове (обчислене) значення;
// Ward/Armour/Evasion — базові значення предмета. Valid == false, якщо предмет
// не має компонента Armour (валюта, самоцвіти, ювелірні вироби, вейстони тощо).
PluginSDK::ItemBaseStats bs = ctx()->Inventory.ReadItemBaseStats(item.Address);
if (bs.Valid) { /* bs.EnergyShield, bs.Ward, bs.Armour, bs.Evasion */ }

// Агреговані статистики за ключем stat id — наприклад, Item Rarity вейстона (8205),
// Pack Size (8206), Monster Rarity (8207), Monster Effectiveness (8208),
// Waystone Drop Chance (8209).
for (const auto& [statId, value] : ctx()->Inventory.ReadItemAggregatedStats(item.Address)) {
    // відобразіть statId -> мітку самостійно; значення є знаковими відсотками
}

FormatStat використовує набір описів статистик .csd хоста (завантажується при першому використанні), тому повертає порожній рядок, поки ці дані не готові — використовуйте запасний варіант з необробленими полями Mod. ReadItemBaseStats / ReadItemAggregatedStats обидва приймають як адреси предметів інвентаря, так і адреси контейнерів WorldItem.


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. Render і проєкція

Три проєкційні хелпери, дві системи координат.

Перспектива (3D world → screen) — та сама проєкція, якою гра малює об'єкти у світі. Підходить для табличок з іменами, маркерів дебагу, індикаторів цілей:

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

Ізометрія (grid → minimap) — для оверлеїв на кшталт радара, що малюються на великій або міні-мапі. Вони враховують зум, панорамування й обертання видимої мапи:

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)

Для пакетної математики (без виклику функції на кожну сутність) візьміть трансформацію один раз і виконуйте проєкцію inline:

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 для production-версії.

Інші аксесори рельєфу:

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 хоста й у файлі логу на диску. Форматуйте текст самостійно перед викликом; хост не приймає varargs у стилі printf.

Внутрішньо зручні методи емітують рядки "Debug", "Info", "Warning" та "Error" (Warn мапиться у "Warning"). Bridge хоста виконує case-insensitive порівняння, тож плагін, що викликає Log("warn", "msg"), теж маршрутизується коректно — але зручні методи прозоріші.


15. Memory (power user)

Прямі примітиви читання пам'яті. Надавайте перевагу високорівневим сервісам скрізь, де можливо — вони знають оффсети, опрацьовують зміни 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. Bridge / SEH-безпека

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

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

Хост може впоратись з багатим плагіном. Він не може впоратись із зависаючим DLL-плагіном — DrawSettings, що займає 100 мс, блокує весь UI-потік. Тримайте роботу на кадр дешевою.


17. Поширені пастки

Короткий перелік речей, з якими стикаються автори плагінів під час першої інтеграції. Більшість з них описано вище інлайн; зібрано тут як чек-лист.

  1. OnAreaChange спрацьовує до того, як сітка прохідності розібрана наново. Не оновлюйте WalkableGridHandle з події — опитуйте щокадру в DrawUI і робіть swap, коли змінюється 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 — per-DLL. Викликайте його в кожній точці входу, де ви малюєте (OnEnable, DrawUI, DrawSettings), бо DLL плагіна за замовчуванням має власний стан ImGui.
  8. Зручні хелпери щоразу читають компонент заново. GetHealthPercent(addr) внутрішньо робить свіжий ReadLife(addr). Якщо у вас уже є структура Life з попереднього виклику, звертайтеся до її полів напряму.

18. Швидкий довідник по сервісах

Однорядковий підсумок кожного публічного методу кожного сервісу. Повні сигнатури типів і нотатки про використання — у текстових секціях вище.

GameService

Метод Повертає Призначення
GetSnapshot() Snapshot Повне per-frame представлення, включно з Entities
GetState() GameState Enum: 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} floats

EntitiesService

Метод Повертає Призначення
Enumerate(cb) Відвідати кожну сусідню сутність (повернути false, щоб зупинити)
GetPlayer() Entity Сутність локального гравця
FindById(id) std::optional<Entity> Пошук за 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 Один інвентар, предмети вже заповнено
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
FormatStat(statKey, v0, v1) std::string Текст у стилі гри для ключа статистики + значення через форматувальник .csd хоста; порожній, поки описи не завантажено
ReadItemBaseStats(addr) ItemBaseStats Базові захисні значення (обчислений Energy Shield; базові Ward/Armour/Evasion); Valid false без компонента Armour; автоматично розв'язує контейнери WorldItem
ReadItemAggregatedStats(addr) std::vector<std::pair<int,int>> Агреговані {statId, value} (Item Rarity вейстона 8205 / Pack Size 8206 / Monster Rarity 8207 / Monster Effectiveness 8208 / Waystone Drop Chance 8209); автоматично розв'язує контейнери 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 Корінь in-game 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 Коефіцієнт перетворення world → grid
EnumerateTgtLocations(cb) Відвідати кожен TGT-екземпляр у поточній локації

MemoryService

Метод Повертає Призначення
Read(addr, buf, size) bool Сирий RPM
ReadString(addr) std::string Narrow-рядок з нульовим завершенням
ReadWString(addr) std::wstring Wide-рядок з нульовим завершенням
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 (визначено inline у 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/ — оглядовий showcase з широкою поверхнею. Один плагін, що торкається майже кожного сервісу, організований як 11 sub-файлів 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 + sprite-атлас + стан по локаціях. Показує, як упакувати персистентність, постачені дані-файли й оверлей в одну DLL.

  • Plugins/NinjaPricer/ — оверлей цін з poe.ninja. HTTP-запит (Exchange API) + сканування інвентаря + ціноутворення по предметах. Показує мережевий код, споживання сторонніх даних і ітерацію по інвентарю в реальному робочому процесі.


← Home

Clone this wiki locally