-
Notifications
You must be signed in to change notification settings - Fork 0
Plugin Development Guide RU
Плагины POEFixer — это нативные C++ DLL, загружаемые во время выполнения из Plugins/<PluginName>/<PluginName>.dll. Они читают живое состояние игры, рисуют ImGui-оверлеи, сохраняют собственные настройки и подписываются на события хоста.
SDK плагинов построен на трёхслойной архитектуре:
Plugin DLL ───► PluginSDK.h (header-only C++ wrapper, owns std::string/vector/function)
│
▼ inline function-pointer calls only
HostAbi (pure-C ABI, POD structs only)
│
▼ SEH-wrapped on the host side
Host bridge: plugin_manager/bridge/Bridge_<Service>.cpp (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/.
Минимальный плагин, который загружается и выводит сообщение в лог хоста:
#define PLUGIN_EXPORTS
#include "POEFixer/plugin_sdk/PluginSDK.h"
class HelloPlugin : public PluginSDK::Plugin {
public:
const char* GetName() const override { return "Hello"; }
void OnEnable(bool) override { ctx()->Log.Info("Hello, world"); }
};
extern "C" PLUGIN_API PluginSDK::Plugin* CreatePlugin() { return new HelloPlugin(); }
extern "C" PLUGIN_API void DestroyPlugin(PluginSDK::Plugin* p) { delete p; }Соберите как Plugins/Hello/Hello.dll, перезапустите хост и включите на вкладке Plugins.
В качестве канонического шаблона используйте Plugins/ExamplePlugin/ExamplePlugin.vcxproj. Ключевые параметры:
- Тип конфигурации: DynamicLibrary
- Платформенный тулсет: v143 (Visual Studio 2022)
- Кодировка: Unicode
-
Стандарт языка:
stdcpp20 -
Среда выполнения:
MultiThreadedDLL(Release) /MultiThreadedDebugDLL(Debug). ДОЛЖНА совпадать с хостом. -
Препроцессорные определения:
PLUGIN_EXPORTS;NDEBUG;_WINDOWS;_USRDLL;_CRT_SECURE_NO_WARNINGS -
Дополнительные пути включаемых файлов:
$(SolutionDir)POEFixer -
Выходной каталог:
$(SolutionDir)x64\Release\Plugins\<YourPlugin>\ -
Имя цели: должно совпадать с именем папки (
Plugins/MyPlugin/→MyPlugin.dll)
Хост сканирует каждый подкаталог в Plugins/ и ищет <FolderName>.dll. DLL обязана экспортировать три символа:
-
CreatePlugin— фабрика; возвращаетPluginSDK::Plugin*. -
DestroyPlugin— деструктор; принимаетPluginSDK::Plugin*. -
PluginSDK_AttachHost— пробрасываетContext. Определяется за вас внутриPluginSDK.hи автоматически эмитируется при заданномPLUGIN_EXPORTS.
Рекомендуемая раскладка исходников:
Plugins/YourPlugin/
YourPlugin.vcxproj
YourPlugin.vcxproj.filters
src/
YourPlugin.cpp // class YourPlugin : public PluginSDK::Plugin
YourPluginSettings.h // Save()/Load() POCO
config/ // runtime-created by SaveSettings()
settings.json
Directory() возвращает абсолютный UTF-8 путь, выровненный относительно директории EXE хоста — не нужно склеивать его с путём к EXE самостоятельно. Строка каталога хранится по значению внутри PluginSDK::Plugin, поэтому она переживает перевыделения контейнеров хоста и циклы перезагрузки без проблем со временем жизни.
Для операций с файловой системой предпочитайте 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));PluginSDK::Plugin — это виртуальный базовый класс. Переопределите следующие методы в своём плагине (примерный порядок вызова):
| Метод | Когда вызывается | Типичное использование |
|---|---|---|
const char* GetName() const |
Один раз сразу после конструирования | Возвращайте отображаемое имя плагина |
void OnEnable(bool isGameAttached) |
Когда пользователь включает плагин (или при запуске, если он был включён ранее) | Загрузка настроек, подписка на события, подключение ImGui-контекста |
void DrawSettings() |
Каждый кадр, пока открыта панель настроек плагина | Элементы управления ImGui для конфига |
void DrawUI() |
Каждый кадр, пока плагин включён | Отрисовка ImGui-оверлея (используйте ImGui::GetBackgroundDrawList() для оверлея поверх игры) |
bool WantsOverlay() const |
Опрашивается каждый кадр | Возвращайте true, если хочется, чтобы хост был в режиме оверлея (с проходом кликов) |
void SaveSettings() |
Периодически (~5 с) и при выключении | Сохранение конфига на диск |
void OnDisable() |
Когда пользователь выключает плагин или при завершении работы хоста | Освобождение ресурсов, отписка от событий |
Обязателен только GetName; у остальных есть безопасные значения по умолчанию.
Сразу после CreatePlugin хост также вызывает GetSDKVersion() (определён в базовом классе, переопределять НЕ нужно), чтобы проверить совместимость плагина и хоста. Несовпадение → плагин отклоняется.
ctx() возвращает const PluginSDK::Context* — агрегат из 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.
ctx()->Game.GetSnapshot() возвращает Snapshot по значению — полный неизменяемый срез текущего кадра. GetSnapshot() обходит abi->entities.enumerate и заполняет snap.Entities до возврата, поэтому стоимость пропорциональна количеству ближайших сущностей. Вызывайте его один раз за кадр и переиспользуйте.
PluginSDK::Snapshot snap = ctx()->Game.GetSnapshot();
if (snap.State != PluginSDK::GameState::InGame) return;
ctx()->Log.Info(snap.CurrentAreaName.c_str());
if (snap.IsTown || snap.IsHideout) return; // safe area
// snap.Vitals.HPPercent, snap.Vitals.MaxES, snap.Vitals.IsPaused
// snap.Player.GridPositionX, snap.Player.Path (wstring), snap.Player.Components
// snap.Entities is a std::vector<Entity> — every nearby entity, fully populated
// snap.LargeMap / snap.MiniMap — visibility + projection inputs
// snap.AreaChangeCounter — increments each portal transitionЧто снапшот несёт напрямую (дополнительные обращения к сервисам не нужны):
- Состояние и флаги:
State,IsAttached,IsWindowValid,GameWindowForeground,IsTown,IsHideout,IsPaused,IsSkillTreeVisible. - Локация:
CurrentAreaName,CurrentAreaHash,CurrentAreaLevel,AreaChangeCounter. - Мир:
Player(полныйEntity),Entities(полныйstd::vector<Entity>),Vitals,LargeMap,MiniMap,WorldToScreenMatrix[16]. - Окно:
ScreenWidth,ScreenHeight,ProcessId,GameWindow,LastUpdateTime,WorldToGridConvertor.
Чего нет в снапшоте — запрашивайте через сервисы: содержимое инвентаря (InventoryService), баффы (ComponentsService::EnumerateBuffs), списки модификаторов по предметам (InventoryService::ReadItemMods), панели UI (UiService).
Дешёвые хелперы, когда полный снапшот не нужен:
if (ctx()->Game.IsInGame()) { ... }
if (ctx()->Game.IsForeground()) { ... } // game window focused
if (ctx()->Game.IsOverlayMode()) { ... } // host is in overlay (click-through)
if (ctx()->Game.IsMenuVisible()) { ... } // ESC menu, settings, etc.
auto sz = ctx()->Game.GetScreenSize(); // ScreenSize { Width, Height } floats
HWND hw = ctx()->Game.GetGameWindow();
DWORD pid = ctx()->Game.GetProcessId();
PluginSDK::GameState st = ctx()->Game.GetState();Сущности предоставляют свои компоненты через entity.Components — структуру ComponentAddresses из адресов uintptr_t. Передайте каждый адрес в соответствующий ComponentsService::Read*, чтобы получить срез значения.
for (const auto& e : snap.Entities) {
if (!e.Components.HasLife()) continue;
PluginSDK::Life life = ctx()->Components.ReadLife(e.Components.Life);
if (life.Valid && life.Health.Current > 0) {
ctx()->Log.Info("alive monster");
}
}Всего предусмотрено 21 ридер компонентов: ReadLife, ReadRender, ReadPositioned, ReadTargetable, ReadChest, ReadShrine, ReadStack, ReadCharges, ReadPlayer, ReadAnimated, ReadTransitionable, ReadTriggerableBlockage, ReadMinimapIcon, ReadStateMachine, ReadBase, ReadMods, ReadStats, ReadBuffs, ReadActor, ReadNpc, ReadDiesAfterTime.
Сам ComponentAddresses содержит 24 слота: 21 перечисленный выше плюс три маркера (Buffs, WorldItem, AreaTransition) и OMP (внутренний для хоста). Buffs — маркер присутствия; реальный список баффов получают через EnumerateBuffs. WorldItem / AreaTransition — это скорее маркеры типа сущности, чем настоящие компоненты. Для всех слотов в ComponentAddresses доступны парные предикаты HasX().
Ридеры коллекций для компонентов с переменным размером данных:
auto buffs = ctx()->Components.EnumerateBuffs(e.Components.Buffs); // std::vector<Buff>
auto skills = ctx()->Components.EnumerateActiveSkills(e.Components.Actor); // std::vector<ActiveSkill>
auto stats = ctx()->Components.EnumerateStats(e.Components.Stats); // std::vector<StatEntry>
auto mods = ctx()->Components.EnumerateItemMods(e.Components.Mods); // std::vector<Mod>Удобные хелперы (одноразовые — внутри они сами вызывают нужный Read*):
float hpPct = ctx()->Components.GetHealthPercent(e.Components.Life);
bool alive = ctx()->Components.IsAlive(e.Components.Life);
float esPct = ctx()->Components.GetEsPercent(e.Components.Life);
float mpPct = ctx()->Components.GetManaPercent(e.Components.Life);
int rarity = ctx()->Components.GetItemRarity(e.Components.Mods);
bool ident = ctx()->Components.IsItemIdentified(e.Components.Mods);
int stack = ctx()->Components.GetStackCount(e.Components.Stack);
bool open = ctx()->Components.IsChestOpened(e.Components.Chest);
std::string name = ctx()->Components.GetPlayerName(e.Components.Player);
float wx, wy, wz;
if (ctx()->Components.GetWorldPosition(e.Components.Render, wx, wy, wz)) { ... }Флаг Valid в каждой возвращаемой структуре позволяет обрабатывать ситуации «адрес компонента был 0 / чтение не удалось» без исключений. Если у вас уже есть родительская структура (Life, Mods, …), обращайтесь к её полям напрямую вместо повторного вызова хелпера — каждый хелпер заново перечитывает компонент.
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 (включая 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() всегда возвращает локального игрока.
Предметы, выброшенные на землю, появляются в 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()) { /* 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 и список модов — точные.
UI-дерево игры предоставляется как адреса элементов типа uintptr_t. Начните с корня, обходите потомков, читайте поля элементов.
Чистый способ найти известную панель по её StringId:
uintptr_t gameUiRoot = ctx()->Ui.GetGameUiRoot();
uintptr_t invPanel = ctx()->Ui.FindPanelByStringId(gameUiRoot, "Inventory");
if (invPanel && ctx()->Ui.IsVisible(invPanel)) {
// panel is on-screen
}Ручной обход дерева, если StringId неизвестен:
uintptr_t root = ctx()->Ui.GetUiRoot();
PluginSDK::UiElement e = ctx()->Ui.Read(root);
ctx()->Log.Info(("children=" + std::to_string(e.ChildCount)).c_str());
for (uintptr_t child : ctx()->Ui.GetChildren(root)) {
std::string sid = ctx()->Ui.GetStringId(child);
if (sid == "InventoriesPanel") { /* found it */ }
}
// Or use a known index path:
int path[] = { 5, 1, 2, 0 };
uintptr_t logInButton = ctx()->Ui.FollowPath(root, path, 4);
// Compute screen-space rect (post-scale, post-transform):
float x, y, w, h;
if (ctx()->Ui.ComputeScreenRect(invPanel, x, y, w, h)) {
// draw an overlay box at (x,y,w,h)
}
// Get displayed text:
std::string label = ctx()->Ui.GetText(child);
int cull = ctx()->Ui.GetCullValue(); // host's UI cull thresholdЗначения StringId — это стабильные идентификаторы со стороны игры; когда они есть, предпочитайте их жёстко прописанным путям.
Три помощника по проекции, две системы координат.
Перспектива (3D-мир → экран) — та же проекция, которой игра рисует объекты в мире. Подходит для имён над головой, отладочных маркеров, индикаторов целей:
float sx, sy;
if (ctx()->Render.WorldToScreen(e.WorldX, e.WorldY, e.WorldZ, sx, sy)) {
ImGui::GetBackgroundDrawList()->AddCircleFilled({sx, sy}, 4.f, IM_COL32(255,0,0,255));
}Изометрия (сетка → миникарта) — для радар-оверлеев, отрисованных на большой или миникарте. Эти вызовы учитывают зум, панорамирование и поворот видимой карты:
if (!snap.LargeMap.IsVisible) return;
for (const auto& e : snap.Entities) {
float sx, sy;
if (ctx()->Render.GridToLargeMap(e.GridPositionX, e.GridPositionY, e.TerrainHeight, sx, sy)) {
ImGui::GetBackgroundDrawList()->AddCircleFilled({sx, sy}, 4.f, color);
}
}
// Mirror: ctx()->Render.GridToMiniMap(gx, gy, worldZ, sx, sy)Для пакетной математики (без вызова функции на каждую сущность) возьмите трансформацию один раз и выполняйте проекцию инлайном:
PluginSDK::MapTransform t = ctx()->Render.GetLargeMapTransform();
if (t.IsVisible) {
// dx = gx - t.PlayerGridX; dy = gy - t.PlayerGridY;
// sx = t.CenterX + (dx - dy) * t.ScaleX;
// sy = t.CenterY + (worldZ * worldToGrid - (dx + dy)) * t.ScaleY;
}
// And ctx()->Render.GetMiniMapTransform() for the minimap.Смотрите Plugins/Radar/src/Radar.cpp — рабочий радар, построенный исключительно на этих вызовах.
Сетка проходимости — это битмап с 4 битами на тайл, показывающий, на какие клетки рельефа игрок может наступить. Хост обновляет её при каждой смене области; плагины получают стабильный хендл, живущий до тех пор, пока плагин его не освободит (через RAII).
PluginSDK::WalkableGridHandle h = ctx()->Terrain.GetWalkableGrid();
if (h.Valid()) {
const uint8_t* data = h.Data();
const int w = h.Width();
const int height = h.Height();
const size_t sizeBytes = h.SizeBytes(); // (w * height) / 2
// POE2 packs two cells per byte:
// gx & 1 == 0 → low nibble (data[gy * (w/2) + gx/2] & 0x0F)
// gx & 1 == 1 → high nibble ((data[gy * (w/2) + gx/2] >> 4) & 0x0F)
// Non-zero nibble = walkable.
//
// Always bound your byte index against sizeBytes — the host enforces an
// atomic snapshot, but defense-in-depth has caught at least one real bug.
}
// h is RAII — destructor releases the host reference automatically.HeightGridHandle повторяет ту же форму, но хранит по одному float на тайл (Data() — это const float*, плюс ElementCount() и SizeBytes()).
Не подписывайтесь на OnAreaChange, чтобы обновить хендл. Событие срабатывает, когда воркер хоста зафиксировал смену области, но новая сетка проходимости к этому моменту может быть ещё не разобрана — вы получите устаревший указатель на кадр-другой. Вместо этого опрашивайте её каждый кадр в DrawUI:
auto current = ctx()->Terrain.GetWalkableGrid();
if (current.Data() != m_walkable.Data()) {
m_walkable = std::move(current); // swap when the host re-parses
}Это дёшево (один вызов ABI и одно сравнение указателей). Боевую версию смотрите в Plugins/Radar/src/Radar.cpp.
Прочие аксессоры рельефа:
bool ok = ctx()->Terrain.IsWalkable(gx, gy);
float worldZ = ctx()->Terrain.GetTerrainHeight(gx, gy);
float worldToG = ctx()->Terrain.GetWorldToGridConvertor();
ctx()->Terrain.EnumerateTgtLocations([](const PluginSDK::TgtLocation& loc) {
// loc.Path, loc.TileX, loc.TileY, loc.X, loc.Y
return true; // continue
});Подписывайтесь на события, эмитируемые хостом. Каждая Subscribe возвращает Token, который позже можно передать в Unsubscribe. Деструктор EventsService (срабатывает, когда плагин выключается или выгружается) автоматически освобождает всё незавершённое — поэтому строго необходимости отписываться вручную нет, но это считается хорошим тоном.
class MyPlugin : public PluginSDK::Plugin {
PluginSDK::EventsService::Token m_areaTok{};
PluginSDK::EventsService::Token m_frameTok{};
public:
void OnEnable(bool) override {
auto& ev = const_cast<PluginSDK::EventsService&>(ctx()->Events);
m_areaTok = ev.OnAreaChange([this]{
ctx()->Log.Info("area changed");
});
m_frameTok = ev.OnFrame([this]{
// called every frame; keep work cheap
});
ev.OnGameAttached([this]{ ctx()->Log.Info("game attached"); });
ev.OnGameDetached([this]{ ctx()->Log.Info("game detached"); });
}
void OnDisable() override {
auto& ev = const_cast<PluginSDK::EventsService&>(ctx()->Events);
ev.Unsubscribe(m_areaTok);
ev.Unsubscribe(m_frameTok);
}
};Четыре вида событий: AreaChange, Frame, GameAttached, GameDetached. Есть также универсальный Subscribe(EventKind, callback), если вам удобнее построить таблицу диспетчеризации.
const_cast обязателен, потому что Events мутирует свою внутреннюю карту токенов. Базовый класс возвращает const Context*, чтобы случайно не мутировать остальные сервисы.
Если в вашем плагине несколько подписок, шаблон из ExamplePlugin — аккуратный способ держать симметрию enable/disable: соберите токены и счётчики в одну структуру состояния и пропустите всё через пару SubscribeAll/UnsubscribeAll:
struct EventsDemoState {
std::atomic<int> frameCount{0}, areaChangeCount{0};
PluginSDK::EventsService::Token frameTok{}, areaTok{};
bool subscribed = false;
};
void SubscribeAll(const PluginSDK::Context* ctx, EventsDemoState& s) {
auto& ev = const_cast<PluginSDK::EventsService&>(ctx->Events);
s.frameTok = ev.OnFrame ([&s]{ s.frameCount.fetch_add(1); });
s.areaTok = ev.OnAreaChange ([&s]{ s.areaChangeCount.fetch_add(1); });
s.subscribed = true;
}
void UnsubscribeAll(const PluginSDK::Context* ctx, EventsDemoState& s) {
auto& ev = const_cast<PluginSDK::EventsService&>(ctx->Events);
ev.Unsubscribe(s.frameTok);
ev.Unsubscribe(s.areaTok);
s.frameTok = {}; s.areaTok = {};
s.subscribed = false;
}Полный паттерн смотрите в Plugins/ExamplePlugin/examples/ExampleEvents.h.
ctx()->Overlay экспонирует два per-plugin «request-флага», влияющих на поведение overlay. Хост хранит per-plugin состояние, ключ — указатель this вашего плагина, и OR-агрегирует флаги со встроенным состоянием хоста (видимость главного меню, AutoCraft lock, встроенный пикер Add-Entity-from-Map, запросы других плагинов). Авто-очистка при Disable/Unload плагина — упавший или забывший выключить флаг плагин не залипит overlay в странном состоянии навечно.
По умолчанию 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 на снапшот за кадр пока включено. Оставляйте ВЫКЛ если не нужно.
Окно 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)работает в обоих режимах.
Если рисуете кликабельные маркеры через 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-окно.
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, незаметно.
- Все методы безопасны для вызова из любого потока.
Хост загружает рыночные цены один раз за сессию с 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 только когда пришли курсы конвертации плюс хотя бы первая категория. До этого показывайте состояние «загрузка цен…», а не нули.
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.
Соглашение: <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 с) и при выключении; вызывать его самостоятельно не нужно.
ctx()->Log.Debug("verbose detail");
ctx()->Log.Info ("normal status");
ctx()->Log.Warn ("something unexpected");
ctx()->Log.Error("operation failed");
ctx()->Log.Log ("custom-level", "message");Все четыре уровня направляются в центральный логгер хоста. Сообщения появляются на вкладке Logs хоста и в файле лога на диске. Форматируйте строку самостоятельно перед вызовом — хост не принимает printf-стиль с varargs.
Внутри удобные методы эмитируют строки "Debug", "Info", "Warning" и "Error" (Warn отображается в "Warning"). Мост хоста делает сравнение без учёта регистра, поэтому плагин, вызывающий Log("warn", "msg"), по-прежнему попадёт куда нужно — но удобные методы читаются яснее.
Примитивы прямой работы с памятью. Везде, где возможно, отдавайте предпочтение высокоуровневым сервисам — они знают смещения, переживают изменения ABI и безопасны относительно SEH. Прямые чтения памяти уместны только тогда, когда для нужной задачи нет более высокоуровневого вызова.
// Read a fixed-size value:
uint64_t value = 0;
ctx()->Memory.Read(addr, &value, sizeof(value));
// Read game strings (null-terminated, narrow or wide):
std::string s = ctx()->Memory.ReadString (strAddr);
std::wstring ws = ctx()->Memory.ReadWString(wstrAddr);
// Read a std::wstring container in the game's memory (handles SSO):
std::wstring inner = ctx()->Memory.ReadStdWString(containerAddr);
// Read a std::vector<T>; returns raw bytes you reinterpret_cast:
std::vector<uint8_t> raw = ctx()->Memory.ReadStdVector(vecAddr, sizeof(MyT), /*maxElems=*/1024);
const MyT* items = reinterpret_cast<const MyT*>(raw.data());
size_t count = raw.size() / sizeof(MyT);
// Module info:
uintptr_t base = ctx()->Memory.GetBaseAddress();
uintptr_t sz = ctx()->Memory.GetModuleSize();
uintptr_t pat = ctx()->Memory.GetPatternAddress("GameStates"); // resolves a named patternЕсли вы часто тянетесь к этим вызовам, задайтесь вопросом: не место ли нужным данным в более высокоуровневых сервисах.
Каждый кросс-DLL-вызов между хостом и плагином выполняется внутри блока __try / __except на стороне хоста. Криво ведущий себя плагин, который разыменовывает устаревший указатель, делит на ноль или иным образом падает внутри SDK-вызова, получает запись в лог об ошибке — процесс хоста при этом не падает, игра продолжает работать, и пользователь может продолжать пользоваться остальными плагинами.
Это не значит, что плагины можно писать небрежно. SEH ловит симптом, а не причину. Если ваш плагин падает каждый кадр, пользователь видит поток лог-ошибок, а ваши данные фактически недоступны. Обрабатывайте null-возвраты от SDK-вызовов, проверяйте флаги Valid у данных компонентов и не разыменовывайте адреса uintptr_t напрямую — пропускайте их через вызовы ComponentsService / Ui / Memory, которые уже корректно оборачивают RPM.
С глючным плагином хост справится. С зависшим DLL плагина — нет: DrawSettings длиной 100 мс блокирует весь UI-поток. Держите работу за кадр дешёвой.
Короткий список того, на что натыкаются авторы плагинов при первой интеграции. Большая часть пунктов уже задокументирована выше — здесь они собраны как чек-лист.
-
OnAreaChangeсрабатывает до того, как переразобрана сетка проходимости. Не обновляйтеWalkableGridHandleиз события — опрашивайте каждый кадр вDrawUIи подменяйте, когда меняетсяData(). (§11) -
Entity::Zoneдля локального игрока всегдаNone. Это классификация по расстоянию от игрока, а игрок по определению на нулевом расстоянии. Не показывайте это в панелях с информацией об игроке. -
Components.ReadMods()возвращает только сводные флаги — без списков модификаторов. Для списков по категориям вызывайтеInventory.ReadItemMods(entityAddr). (§8) -
У предметов, лежащих на земле, может отсутствовать
EntitySubtype. Если фильтруете предметы в мире, предпочитайтеEntityType == Item || EntityType == Chestболее узкой проверке по подтипу. -
Directory()возвращает абсолютный путь. Не приклеивайте к нему путь EXE сами — получитеEXEDIR\EXEDIR\Plugins\X, и запись конфига окажется вне папки плагина. -
ctx()возвращаетconst Context*. Мутирующие методы вродеEventsService::Subscribeтребуютconst_cast. Это сделано намеренно — сервисы, которые не мутируют, нельзя случайно изменить. -
ImGui::SetCurrentContextставится для каждого DLL отдельно. Вызывайте его в каждой точке входа, где идёт отрисовка (OnEnable,DrawUI,DrawSettings), потому что у DLL плагина по умолчанию своё состояние ImGui. -
Удобные хелперы перечитывают компонент при каждом вызове.
GetHealthPercent(addr)внутри делает свежийReadLife(addr). Если структураLifeу вас уже есть из предыдущего вызова, обращайтесь к её полям напрямую.
Однострочное описание каждого публичного метода каждого сервиса. Полные сигнатуры типов и заметки по использованию ищите в разделах выше.
| Метод | Возвращает | Назначение |
|---|---|---|
GetSnapshot() |
Snapshot |
Полный обзор кадра, включая Entities
|
GetState() |
GameState |
Перечисление: InGame, Login, Loading, … |
IsAttached() |
bool |
Подключение к процессу игры |
IsInGame() |
bool |
State == InGame |
IsForeground() |
bool |
Окно игры в фокусе |
IsMenuVisible() |
bool |
Открыто ESC-меню / настройки |
IsOverlayMode() |
bool |
Хост в режиме оверлея (click-through) |
GetProcessId() |
DWORD |
PID игры |
GetGameWindow() |
HWND |
Хендл окна игры |
GetScreenSize() |
ScreenSize |
{Width, Height} в float |
| Метод | Возвращает | Назначение |
|---|---|---|
Enumerate(cb) |
— | Обход всех ближайших сущностей (верните false, чтобы остановиться) |
GetPlayer() |
Entity |
Сущность локального игрока |
FindById(id) |
std::optional<Entity> |
Поиск по id сущности |
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 (см. §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 |
Удобный аксессор мировых координат |
| Метод | Возвращает | Назначение |
|---|---|---|
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 |
| Метод | Возвращает | Назначение |
|---|---|---|
Read(addr) |
UiElement |
Поля элемента (rect, флаги, количество потомков) |
GetChildren(addr) |
std::vector<uintptr_t> |
Адреса потомков |
GetChildAt(addr, index) |
uintptr_t |
Один потомок по индексу |
FollowPath(root, indices, count) |
uintptr_t |
Пройти по известному пути индексов |
IsVisible(addr) |
bool |
Элемент виден на экране |
GetStringId(addr) |
std::string |
Стабильный идентификатор со стороны игры |
GetText(addr) |
std::string |
Отрисованный текст |
ComputeScreenRect(addr, x, y, w, h) |
bool |
Итоговый прямоугольник в экранных координатах |
GetGameUiRoot() |
uintptr_t |
Корень игрового UI |
GetUiRoot() |
uintptr_t |
Самый верхний UI-корень |
GetCullValue() |
int |
Порог отсечения UI у хоста |
FindPanelByStringId(parent, stringId) |
uintptr_t |
Целевой поиск потомка |
| Метод | Возвращает | Назначение |
|---|---|---|
WorldToScreen(wx, wy, wz, sx, sy) |
bool |
Перспективная проекция |
GridToLargeMap(gx, gy, worldZ, sx, sy) |
bool |
Проекция на оверлей большой карты |
GridToMiniMap(gx, gy, worldZ, sx, sy) |
bool |
Проекция на миникарту |
GetLargeMapTransform() |
MapTransform |
Предвычисленная трансформация для пакетной математики |
GetMiniMapTransform() |
MapTransform |
То же для миникарты |
| Метод | Возвращает | Назначение |
|---|---|---|
GetWalkableGrid() |
WalkableGridHandle |
RAII-хендл к битмапу проходимости (4 бита на тайл) |
GetHeightGrid() |
HeightGridHandle |
RAII-хендл к высотам рельефа по тайлам |
IsWalkable(gx, gy) |
bool |
Предикат для одного тайла |
GetTerrainHeight(gx, gy) |
float |
Мировая Z-координата |
GetWorldToGridConvertor() |
float |
Коэффициент перевода мир → сетка |
EnumerateTgtLocations(cb) |
— | Обход всех TGT-инстансов в текущей области |
| Метод | Возвращает | Назначение |
|---|---|---|
Read(addr, buf, size) |
bool |
Сырой RPM |
ReadString(addr) |
std::string |
Узкая строка с нулевым терминатором |
ReadWString(addr) |
std::wstring |
Широкая строка с нулевым терминатором |
ReadStdWString(addr) |
std::wstring |
Чтение игрового контейнера std::wstring (учитывает SSO) |
ReadStdVector(addr, elemSize, maxElems) |
std::vector<uint8_t> |
Сырые байты; интерпретируйте в нужный тип |
GetBaseAddress() |
uintptr_t |
Базовый адрес модуля игры |
GetModuleSize() |
uintptr_t |
Размер модуля игры |
GetPatternAddress(name) |
uintptr_t |
Поиск по именованному паттерну |
| Метод | Назначение |
|---|---|
Debug / Info / Warn / Error(msg) |
Эмит на соответствующем уровне |
Log(level, msg) |
Строка уровня по выбору |
| Метод | Возвращает | Назначение |
|---|---|---|
Subscribe(kind, cb) |
Token |
Универсальная диспетчеризация |
OnAreaChange / OnFrame / OnGameAttached / OnGameDetached(cb) |
Token |
Однострочные хелперы подписки |
Unsubscribe(token) |
— | Ручное освобождение (деструктор всё равно делает это автоматически) |
| Метод | Возвращает | Назначение |
|---|---|---|
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 плагинов).
| Метод | Возвращает | Назначение |
|---|---|---|
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 («Фляги и талисманы»).
| Метод | Возвращает | Назначение |
|---|---|---|
LookupPrice(name) |
PriceResult |
Нечёткий поиск цены на стороне хоста по имени (found, chaos, divine, exalt, category) |
GetRates() |
PriceRates |
Курсы Divine / Exalted → Chaos |
GetStatus() |
PriceStatus |
Признак loaded + счётчики категорий (catsOk / catsPending / catsFailed) |
| Метод | Возвращает | Назначение |
|---|---|---|
Runeshapes() |
std::vector<Runeshape> |
Все найденные устройства Expedition2Encounter (id, цвет, якорь, bestIndex) |
Rewards(entityId) |
std::vector<RuneshapeReward> |
Слоты наград по устройству, цена каждой — через сервис Prices |
PluginAbi.h определяет:
constexpr int PLUGIN_SDK_VERSION = 6;При загрузке хост вызывает plugin->GetSDKVersion() и сравнивает с собственным PLUGIN_SDK_VERSION. Несовпадение → хост пишет предупреждение в лог и отказывается загружать плагин.
Кроме того, хост сверяет HostAbi::version и HostAbi::size_bytes внутри PluginSDK_AttachHost (определён инлайн в PluginSDK.h при заданном PLUGIN_EXPORTS). Если хотя бы одно поле не совпадает с тем, на чём собирался плагин, ctx() нерабочий. Аксессор HostCompatible() базового класса в этом случае вернёт false, и любой вежливый плагин должен отказаться от работы:
void OnEnable(bool) override {
if (!HostCompatible()) {
ctx()->Log.Error("Host ABI mismatch — disable plugin");
return;
}
// ...
}Четыре плагина в репозитории спроектированы так, чтобы их читали как документацию:
-
Plugins/ExamplePlugin/— широкоохватная демонстрация. Один плагин, который касается почти каждого сервиса; организован как по одному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). Показывает итерацию по инвентарю и расценку в реальном процессе на основе сервисов данных хоста.