-
Notifications
You must be signed in to change notification settings - Fork 0
Plugin Development Guide UA
Плагіни 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. - Цей заголовок оголошує все в 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/.
Мінімальний плагін, що завантажується і пише повідомлення у лог хоста:
#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
- 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));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, щоб упевнитися, що плагін і хост узгоджені. Розбіжність → плагіну відмовлено.
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.
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();Сутності експонують свої компоненти через 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 (включно зі 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() завжди повертає локального гравця.
Предмети, кинуті на землю, з'являються у 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.
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);Предмети на землі через 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.
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 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 для робочого радара, побудованого виключно на цих викликах.
Сітка прохідності — це бітмап з 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
});Підпишіться на події, які емітує хост. Кожен 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 хоста й у файлі логу на диску. Форматуйте текст самостійно перед викликом; хост не приймає varargs у стилі printf.
Внутрішньо зручні методи емітують рядки "Debug", "Info", "Warning" та "Error" (Warn мапиться у "Warning"). Bridge хоста виконує case-insensitive порівняння, тож плагін, що викликає 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і робіть swap, коли змінюється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— per-DLL. Викликайте його в кожній точці входу, де ви малюєте (OnEnable,DrawUI,DrawSettings), бо DLL плагіна за замовчуванням має власний стан ImGui. -
Зручні хелпери щоразу читають компонент заново.
GetHealthPercent(addr)внутрішньо робить свіжийReadLife(addr). Якщо у вас уже є структураLifeз попереднього виклику, звертайтеся до її полів напряму.
Однорядковий підсумок кожного публічного методу кожного сервісу. Повні сигнатури типів і нотатки про використання — у текстових секціях вище.
| Метод | Повертає | Призначення |
|---|---|---|
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 |
| Метод | Повертає | Призначення |
|---|---|---|
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> |
Прочитати закріплені компоненти |
| Метод | Повертає | Призначення |
|---|---|---|
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 |
Один інвентар, предмети вже заповнено |
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 |
| Метод | Повертає | Призначення |
|---|---|---|
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 |
Цільовий пошук нащадка |
| Метод | Повертає | Призначення |
|---|---|---|
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 |
Коефіцієнт перетворення world → grid |
EnumerateTgtLocations(cb) |
— | Відвідати кожен TGT-екземпляр у поточній локації |
| Метод | Повертає | Призначення |
|---|---|---|
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 |
Пошук іменованого паттерну |
| Метод | Призначення |
|---|---|
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 (визначено inline у PluginSDK.h, коли задано PLUGIN_EXPORTS). Якщо якесь із цих полів не збігається з тим, на чому збирався плагін, ctx() непрацездатний. Аксесор базового класу HostCompatible() у цьому випадку повертає false, і будь-який плагін, що хоче поводитися ввічливо, має відмовитися від дій:
void OnEnable(bool) override {
if (!HostCompatible()) {
ctx()->Log.Error("Host ABI mismatch — disable plugin");
return;
}
// ...
}Чотири плагіни в репозиторії розраховані на читання як документація:
-
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) + сканування інвентаря + ціноутворення по предметах. Показує мережевий код, споживання сторонніх даних і ітерацію по інвентарю в реальному робочому процесі.