Skip to content

Plugin Development Guide RU

Lafko edited this page Jun 24, 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  (12 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, поэтому она переживает перевыделения контейнеров хоста и циклы перезагрузки без проблем со временем жизни.

Для операций с файловой системой предпочитайте DirectoryPath(), а не Directory(). DirectoryPath() возвращает std::filesystem::path, построенный через явную конвертацию UTF-8→wide. Передача сырой строки Directory() в std::filesystem::path или std::ifstream приводит к тому, что системная ANSI-кодовая страница переинтерпретирует байты, — и при установке хоста в папку с кириллицей/CJK путь искажается, а запись конфига уходит мимо папки плагина. Используйте Directory() для отображения и логов; используйте DirectoryPath(), когда строите путь или открываете файл.

Если вы хотите рисовать через 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* — агрегат из 14 сервисов:

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>
    OverlayService    Overlay;     // SetIncludeSleepingEntities / SetWantsOverlayInput
    FlasksService     Flasks;       // фляги жизни/маны + талисманы: заряды, usable, active
    PricesService     Prices;       // цены предметов с poe2scout (грузятся хостом один раз, общие)
    RuneshapeService  Runeshape;    // устройства Expedition2Encounter + награды по каждому
    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}
OverlayService SetIncludeSleepingEntities / SetWantsOverlayInput — для map-picker UI
FlasksService Фляги жизни/маны + талисманы — заряды, Usable, Active, расход за использование, число модов
PricesService LookupPrice / GetRates / GetStatus — цены с poe2scout, загружаемые хостом и общие для всех плагинов
RuneshapeService Runeshapes / Rewards — найденные устройства Expedition2Encounter + награды по каждому

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

Поля ActiveSkill

EnumerateActiveSkills(actorAddr) возвращает по одному ActiveSkill на каждый дарованный/вставленный навык компонента Actor сущности. Помимо Name, структура несёт состояние перезарядки и декодированный дескриптор гнезда камня:

Поле Значение
Name Внутреннее имя навыка
CurrentSize / TotalUses / UseStage Счётчики стадий / использований (сырые)
CastType Сырой id типа применения
TotalCooldownMs Полная длительность перезарядки, миллисекунды
CanBeUsed Флаг хоста «можно применить сейчас»
MaxUses Сколько зарядов перезарядки у навыка (0 = не привязан к КД)
TotalActiveCooldowns Зарядов сейчас на перезарядке. Осталось применений = MaxUses - TotalActiveCooldowns при MaxUses > 0.
GrantedEffectsPerLevelAddr, ActiveSkillsDatAddr, GrantedEffectStatSetsPerLevelAddr, SkillDetailsAddr Сырые адреса строк DAT — передавайте в ctx()->Memory.Read* для более глубоких данных навыка.
EquipmentInfoPacked Сырое упакованное слово камня/сокета (декодируется в Equipment, ниже).

Упакованное слово декодируется за вас в skill.Equipment:

Поле Equipment Значение
GemNameHash Старшие 16 бит — хеш идентичности камня
InventorySlot 1-based слот экипировки, где находится камень
LinkIndex Индекс группы линков внутри предмета
SocketIndex Индекс сокета внутри группы линков
UnknownFlag / CanBeOnPlayerItem Остаточные флаги (битовая раскладка в PluginSDK.h)
auto skills = ctx()->Components.EnumerateActiveSkills(e.Components.Actor);
for (const auto& s : skills) {
    if (s.MaxUses > 0) {
        int remaining = s.MaxUses - s.TotalActiveCooldowns;
        ctx()->Log.Info((s.Name + ": " + std::to_string(remaining) +
                         "/" + std::to_string(s.MaxUses) + " charges").c_str());
    }
    // s.Equipment.LinkIndex / s.Equipment.SocketIndex — где сидит камень
}

Поля 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.

Текст модификатора в стиле игры + базовые / агрегированные статы (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()) { /* draw `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 */ }

// Агрегированные статы с ключом по 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.

Фляги и талисманы

ctx()->Flasks — это удобное представление над поясом утилит (Inventory[12]): он собирает заряды каждой фляги/талисмана, состояние активности и расход за использование, чтобы вам не пришлось вручную сшивать компонент Charges, баффы игрока и сетку инвентаря.

// Фляга жизни = слот 0, фляга маны = слот 1.
if (auto f = ctx()->Flasks.GetFlask(0); f && f->Valid) {
    ctx()->Log.Info((f->Name + " " +
        std::to_string(f->ChargesCurrent) + "/" +
        std::to_string(f->PerUseEffective) +
        (f->Usable ? " [usable]" : " [empty]") +
        (f->Active ? " [active]" : "")).c_str());
}

// Талисманы = слоты 0..2.
for (const auto& c : ctx()->Flasks.AllCharms()) {
    if (!c.Valid) continue;            // пустой слот пояса
    // c.Name, c.ChargesCurrent, c.Active, ...
}

GetFlask(slot) / GetCharm(slot) возвращают std::nullopt, когда слот вне диапазона или вы не в игре; надетый, но пустой слот возвращает значение с Valid == false. AllFlasks() / AllCharms() всегда возвращают FlaskSlotCount() / CharmSlotCount() элементов (2 / 3 в POE2), включая пустые, поэтому вы можете индексировать по позиции в поясе.

Поле Flask Значение
ChargesCurrent Текущий запас зарядов
PerUseBase Сколько зарядов тратит одно использование (без учёта модов)
PerUseEffective PerUseBase с учётом собственных модов фляги «increased charges used»
Usable PerUseBase > 0 && ChargesCurrent >= PerUseEffective
Active Бафф фляги сейчас активен (берётся из баффов игрока)
IsLife / IsMana Тип фляги (оба false = будущая утилитарная фляга)
Name / BaseType / Path Отображаемое имя (уникальное → откат к базовому), базовый тип, путь в метаданных
EntityAddress Сущность предмета — передайте в ReadItemMods, чтобы получить список модов
ModCount Подсказка о том, сколько модов несёт предмет

Charm имеет те же поля за вычетом PerUseEffective, Usable, IsLife, IsMana.

Чтение списка модов. Моды фляг/талисманов не встроены — ModCount лишь подсказка. За реальными аффиксами передайте EntityAddress в ридер модов инвентаря из предыдущего подраздела:

if (auto f = ctx()->Flasks.GetFlask(0); f && f->Valid) {
    PluginSDK::ItemMods mods = ctx()->Inventory.ReadItemMods(f->EntityAddress);
    for (const auto& m : mods.ExplicitMods) { /* m.AffixName, m.StatKey, m.Value0 */ }
}

Ограничение. PerUseEffective учитывает только собственные моды фляги «increased charges used». Общеигровой пассивный стат flask_charges_used_positive_percentage пока не складывается, поэтому PerUseEffective может занижать значение, когда пассивка повышает расход зарядов. ChargesCurrent, Active и список модов — точные.


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. OverlayService — захват ввода и спящие entity

ctx()->Overlay экспонирует два per-plugin «request-флага», влияющих на поведение overlay. Хост хранит per-plugin состояние, ключ — указатель this вашего плагина, и OR-агрегирует флаги со встроенным состоянием хоста (видимость главного меню, AutoCraft lock, встроенный пикер Add-Entity-from-Map, запросы других плагинов). Авто-очистка при Disable/Unload плагина — упавший или забывший выключить флаг плагин не залипит overlay в странном состоянии навечно.

SetIncludeSleepingEntities

По умолчанию EntitiesService.Enumerate и Snapshot.Entities скрывают entity с состоянием EntityState::Useless (хостовый «sleeping»-фильтр, отсекающий далёкие/спящие монстры, NPC, сундуки). Это ограничивает CPU на снапшот — в типичной зоне сотни Useless-entity, которые плагину не интересны.

Для map-picker UI и debug-вьюверов, которым нужен полный entity-пул зоны (чтобы пользователь мог кликнуть на entity, ещё не активного), выключите фильтр:

ctx()->Overlay.SetIncludeSleepingEntities(true);
// Теперь ctx()->Entities.Enumerate видит и Useless-entity тоже.
// Entity::IsSleeping помечает именно те, что пришли из отдельной хостовой
// SleepingEntities-коллекции (ортогонально EntityState::Useless).

Цена: примерно +5–15% CPU на снапшот за кадр пока включено. Оставляйте ВЫКЛ если не нужно.

SetWantsOverlayInput

Окно overlay обычно click-through (WS_EX_TRANSPARENT): каждый клик мыши проходит насквозь в игру. Это правильный дефолт для read-only оверлеев (радар, health-bars, DPS-readout) — игрок продолжает играть, не замечая overlay.

Как только плагину нужно, чтобы пользователь кликнул внутри overlay — подтвердить popup, выбрать entity на карте, перетащить маркер — дефолт ломается. SetWantsOverlayInput(true) просит хост начать ловить клики там, где их перекрывают ImGui-окна плагина:

ctx()->Overlay.SetWantsOverlayInput(true);
// ...
// Когда закончили (пользователь выбрал, закрыл popup, нажал Escape):
ctx()->Overlay.SetWantsOverlayInput(false);

Per-frame логика хоста ловит клики ТОЛЬКО там, где курсор над видимым ImGui-окном — везде ещё click-through сохраняется, и игрок продолжает двигаться/атаковать/лутать вокруг вашего popup.

Scope (важно):

  • Кнопки мыши (LMB/RMB): ДА — гейтятся этим флагом.
  • Позиция мыши / hover: ВСЕГДА работает независимо от флага. Hover-tooltip'ы не требуют этот флаг.
  • Клавиатура: ВСЕГДА доходит до плагина через WindowProc хоста, независимо от флага. ImGui::IsKeyPressed(ImGuiKey_Escape) работает в обоих режимах.

Caveat про background-draw-list — ЧИТАЙТЕ для map-picker плагинов

Если рисуете кликабельные маркеры через ImGui::GetBackgroundDrawList() (типично для radar / large-map оверлеев), background-draw-list не имеет ImGui-окна за собой. Hit-test хоста проходит по ctx->Windows, ничего не находит под курсором, восстанавливает click-through — маркеры видны, но не кликабельны.

Фикс: откройте настоящее ImGui-окно поверх picker-области и поместите ImGui::InvisibleButton внутри. Окно — это то, что видит hit-test хоста; InvisibleButton даёт ImGui::IsItemClicked() для детекции клика. Скетч:

auto snap = ctx()->Game.GetSnapshot();
ImVec2 screenSize{(float)snap.ScreenWidth, (float)snap.ScreenHeight};

ImGui::SetNextWindowPos({0, 0});
ImGui::SetNextWindowSize(screenSize);
ImGui::Begin("##picker", nullptr,
    ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoTitleBar |
    ImGuiWindowFlags_NoMove       | ImGuiWindowFlags_NoResize    |
    ImGuiWindowFlags_NoScrollbar);

ImGui::InvisibleButton("##picker_hit", screenSize);
bool clickedThisFrame = ImGui::IsItemClicked();

// Рисуем маркеры через ImGui::GetForegroundDrawList() (или GetWindowDrawList()):
ImDrawList* dl = ImGui::GetForegroundDrawList();
ctx()->Terrain.EnumerateTgtLocations([&](const auto& tgt) {
    float sx, sy;
    if (ctx()->Render.GridToLargeMap(tgt.X, tgt.Y, 0.f, sx, sy)) {
        ImVec2 mp = ImGui::GetMousePos();
        bool hovered = std::hypot(mp.x - sx, mp.y - sy) < 12.f;
        dl->AddCircleFilled({sx, sy}, 8.f, hovered ? 0xFF00FFFF : 0xFFFFFF00);
        if (clickedThisFrame && hovered) {
            // пользователь выбрал этот POI
        }
    }
    return true;
});
ImGui::End();

Меньшие окна, покрывающие только picker-область, работают так же — хосту неважен размер, важно лишь чтобы под курсором было какое-то ImGui-окно.

Полный пример — Add POI from Map

class RadarPlugin : public PluginSDK::Plugin {
    bool m_pickerMode = false;

    void DrawUI() override {
        if (!ctx()->Game.IsInGame()) return;
        ImGui::SetCurrentContext((ImGuiContext*)ctx()->ImGuiContext);

        // Toggle на хоткей. Клавиатура достаёт плагин независимо от
        // состояния захвата мыши, поэтому работает даже когда overlay в
        // click-through режиме.
        if (ImGui::IsKeyPressed(ImGuiKey_F8, /*repeat=*/false)) {
            m_pickerMode = !m_pickerMode;
            ctx()->Overlay.SetIncludeSleepingEntities(m_pickerMode);
            ctx()->Overlay.SetWantsOverlayInput     (m_pickerMode);
        }
        if (m_pickerMode && ImGui::IsKeyPressed(ImGuiKey_Escape, false)) {
            m_pickerMode = false;
            ctx()->Overlay.SetIncludeSleepingEntities(false);
            ctx()->Overlay.SetWantsOverlayInput     (false);
        }

        if (!m_pickerMode) return;
        // ... picker UI (см. Caveat про background-draw-list выше для
        //     паттерна ImGui::Begin + InvisibleButton).
    }

    void OnDisable() override {
        // Подстраховка. Хост и сам почистит флаги при disable, но явная
        // очистка держит состояние согласованным если синхронный кадр
        // выстрелит между OnDisable и bookkeeping в PluginManager.
        ctx()->Overlay.SetIncludeSleepingEntities(false);
        ctx()->Overlay.SetWantsOverlayInput     (false);
    }
};

Жизненный цикл и агрегация

  • Оба флага идемпотентны — повторный Set(true) подряд — no-op, счётчик не двоится.
  • Оба OR-агрегируются со встроенным состоянием хоста и с флагами каждого другого плагина. Несколько плагинов в picker-режиме одновременно сосуществуют без проблем.
  • Хост автоматически чистит все флаги плагина при его отключении (через Plugins-вкладку, crash-disable, или Shutdown). Краш в середине кадра не залипит overlay в captured-режиме навечно — но плагины должны всё равно явно парить on/off-вызовы, чтобы соседние плагины и игра оставались отзывчивыми между этим.
  • Латентность: вызов Set обновляет флаг синхронно, но реальное изменение поведения произойдёт на следующем кадре хоста (для overlay input) или следующем тике GameClient worker (для sleeping entities). Sub-frame, незаметно.
  • Все методы безопасны для вызова из любого потока.

14. Цены — загрузка цен предметов хостом

Хост загружает рыночные цены один раз за сессию с poe2scout в фоновом потоке и предоставляет их каждому плагину через ctx()->Prices. Плагины не запрашивают цены сами — за встроенным радаром, оверлеями хоста и всеми плагинами стоит единая общая база цен, поэтому API дёргается один раз, а не по разу на каждого потребителя.

  • Лига цен выбирается пользователем в Конфигурация → Настройки (по умолчанию Runes of Aldur) и сохраняется на стороне хоста. Плагины всегда читают выбранную пользователем лигу; сами они её не выбирают.
  • Загрузка — однократная с backoff по категориям (повтор через 1 → 5 → 10 → 20 → 30 → 60 мин при ошибке, затем отказ до перезапуска приложения). Периодического обновления нет — цены стабильны в течение всей сессии.
  • Все цены выражены в Chaos. GetRates() даёт курс Divine / Exalted, если хотите показывать в этих единицах.

Поиск цены

PluginSDK::PriceResult p = ctx()->Prices.LookupPrice("Divine Orb");
if (p.found) {
    ctx()->Log.Info(("Divine Orb = " + std::to_string(p.chaos) + "c").c_str());
    // p.category — какая категория poe2scout совпала (currency, fragments, runes,
    // …, либо категория уникального предмета). Удобно ветвить currency vs. unique.
}

LookupPrice принимает отображаемое имя предмета (имя валюты, имя уника или базовый тип) и выполняет нечёткое сопоставление на стороне хоста по всем загруженным категориям. Промах возвращает found == false и нулевые цены.

Поле PriceResult Тип Значение
found bool Цена найдена
chaos float Цена в Chaos Orb (каноническая единица)
divine float Та же цена в Divine Orb
exalt float Та же цена в Exalted Orb
category std::string Совпавшая категория poe2scout (currency / fragments / runes / … / категория уника)

Курсы и статус загрузки

PluginSDK::PriceRates  r = ctx()->Prices.GetRates();   // divineInChaos, exaltedInChaos
PluginSDK::PriceStatus s = ctx()->Prices.GetStatus();
if (!s.loaded) {
    // Ещё не готово (грузится или все категории упали).
    // s.catsOk / s.catsPending / s.catsFailed показывают, насколько продвинулся загрузчик.
}

GetStatus().loaded — это признак, который надо проверять перед показом цен: он становится true только когда пришли курсы конвертации плюс хотя бы первая категория. До этого показывайте состояние «загрузка цен…», а не нули.


15. Устройства Runeshape

ctx()->Runeshape предоставляет устройства Expedition2Encounter («Runeshape»), которые хост нашёл в текущей локации, вместе с наградой, которую дал бы каждый рецепт. Хост сам проходит цепочку устройства и делает офлайн-сопоставление рецепта; ваш плагин просто читает результат. Именно это питает тег награды встроенного радара и окно Runeshape в NinjaPricer — сторонний плагин может отрисовать те же данные.

for (const PluginSDK::Runeshape& rs : ctx()->Runeshape.Runeshapes()) {
    // rs.color даёт каждому устройству свой цвет (для группировки/подкраски).
    // rs.bestIndex — индекс самой дорогой награды (или -1).
    for (const PluginSDK::RuneshapeReward& rw : ctx()->Runeshape.Rewards(rs.entityId)) {
        if (rw.priced)
            ctx()->Log.Info((rw.name + " x" + std::to_string(rw.count) +
                             " = " + std::to_string(rw.totalChaos) + "c").c_str());
    }
}
Поле Runeshape Тип Значение
entityId uint64_t id сущности устройства — передаётся в Rewards()
color uint32_t Упакованный RGBA, стабильный на устройство (для группировки / подкраски)
isUnique bool Устройство предлагает рецепт уникального предмета
holeCount int Число отверстий под руны на якоре
anchorName std::string Имя якорной руны
rewardCount int Число слотов наград
bestIndex int Индекс награды с наибольшим totalChaos, либо -1
Поле RuneshapeReward Тип Значение
name std::string Имя предмета-награды
count int Выдаваемое количество
unitChaos float Цена за единицу в Chaos (из сервиса Prices)
totalChaos float unitChaos × count
priced bool Для этой награды найдена цена

Цены наград берутся из той же базы ctx()->Prices, поэтому награда без цены (priced == false) обычно означает, что цены ещё не загрузились или предмета нет на poe2scout.


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

Соглашение: <plugin directory>/config/settings.json. Directory() возвращает абсолютный UTF-8 путь к папке вашего плагина; для файловых операций используйте DirectoryPath() (UTF-8-safe std::filesystem::path — см. §3).

Для тривиальных настроек подойдёт самописный JSON-писатель — это сохраняет DLL самодостаточным. Рабочий пример смотрите в Plugins/Radar/src/RadarSettings.h. Скелет:

struct MySettings {
    bool  DrawEnabled = true;
    float Opacity     = 0.9f;

    void Save(const std::filesystem::path& dir) const {
        std::filesystem::path p = dir / "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::filesystem::path& dir) {
        std::filesystem::path p = dir / "config" / "settings.json";
        if (!std::filesystem::exists(p)) return;
        // ... parse ...
    }
};

// В вашем плагине — передавайте DirectoryPath() (UTF-8-safe fs::path), НЕ Directory():
void OnEnable(bool) override   { m_settings.Load(DirectoryPath()); }
void SaveSettings() override   { m_settings.Save(DirectoryPath()); }

Для структурированных данных (вложенные объекты, массивы) подключите к папке плагина настоящую JSON-библиотеку. Хост не навязывает выбор.

SaveSettings вызывается периодически (~5 с) и при выключении; вызывать его самостоятельно не нужно.


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

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


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

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

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


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

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

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

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


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

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

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

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

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

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 (см. §7 — таблица полей ActiveSkill)
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
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 Корень игрового 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) Ручное освобождение (деструктор всё равно делает это автоматически)

OverlayService

Метод Возвращает Назначение
SetIncludeSleepingEntities(enable) Запросить включение entity со state EntityState::Useless в EntitiesService.Enumerate
SetWantsOverlayInput(enable) Попросить overlay ловить клики мыши вместо click-through

Оба идемпотентны, per-plugin, OR-агрегируются с хостом + другими плагинами, авто-чистка при Disable/Unload. См. секцию 13 для полного паттерна (включая caveat про background-draw-list для map-picker плагинов).

FlasksService

Метод Возвращает Назначение
GetFlask(slot) std::optional<Flask> Фляга жизни/маны по слоту пояса (0=жизнь, 1=мана); nullopt вне диапазона или вне игры
GetCharm(slot) std::optional<Charm> Талисман по слоту пояса (0..2); nullopt вне диапазона или вне игры
AllFlasks() std::vector<Flask> Все слоты фляг, включая пустые (FlaskSlotCount() элементов)
AllCharms() std::vector<Charm> Все слоты талисманов, включая пустые (CharmSlotCount() элементов)
FlaskSlotCount() int32_t Количество слотов фляг (2 в POE2)
CharmSlotCount() int32_t Количество слотов талисманов (3 в POE2)

Таблицы полей Flask / Charm и ограничение PerUseEffective см. в разделе 8 («Фляги и талисманы»).

PricesService

Метод Возвращает Назначение
LookupPrice(name) PriceResult Нечёткий поиск цены на стороне хоста по имени (found, chaos, divine, exalt, category)
GetRates() PriceRates Курсы Divine / Exalted → Chaos
GetStatus() PriceStatus Признак loaded + счётчики категорий (catsOk / catsPending / catsFailed)

RuneshapeService

Метод Возвращает Назначение
Runeshapes() std::vector<Runeshape> Все найденные устройства Expedition2Encounter (id, цвет, якорь, bestIndex)
Rewards(entityId) std::vector<RuneshapeReward> Слоты наград по устройству, цена каждой — через сервис Prices

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

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

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

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

  • Plugins/ExamplePlugin/ — широкоохватная демонстрация. Один плагин, который касается почти каждого сервиса; организован как по одному examples/*.h на область SDK (Buffs, Entities, Inventory, Flasks, Memory, UI Explorer, Component Reader, Render, Terrain, Events, Log, Skills и Prices) плюс баннер с итоговым покрытием. Читайте его, когда хотите увидеть, как сервис используется в контексте — например, вкладка Prices — это канонический пример получения цен (§14).

  • Plugins/Radar/ — сфокусированный пример из реальной жизни. ~200 строк. Радар-оверлей, построенный целиком на публичном SDK — без смещений и без сырых чтений памяти. Рисует карту проходимости плюс точки по сущностям через Render.GridToLargeMap. Читайте его, когда хотите увидеть минимальный объём кода для конкретного результата.

  • Plugins/KillCount/ — трекер убийств / сундуков / смертей. SQLite + спрайт-атлас + состояние по локациям. Показывает, как уместить персистентность, прибившиеся данные и оверлей в одном DLL.

  • Plugins/NinjaPricer/ — оверлей цен poe2scout. Расценивает предметы в инвентаре и на земле через общий сервис цен хоста (ctx()->Prices, §14) — без собственного сетевого кода — плюс окно наград по каждому Runeshape (ctx()->Runeshape, §15). Показывает итерацию по инвентарю и расценку в реальном процессе на основе сервисов данных хоста.


← Home

Clone this wiki locally