-
Notifications
You must be signed in to change notification settings - Fork 0
Plugin Development Guide
POEFixer plugins are native C++ DLLs loaded at runtime from Plugins/<PluginName>/<PluginName>.dll. They read live game state, draw ImGui overlays, persist their own settings, and subscribe to host events.
The plugin SDK has a three-layer architecture:
Plugin DLL ───► PluginSDK.h (header-only C++ wrapper, owns std::string/vector/function)
│
▼ inline function-pointer calls only
HostAbi (pure-C ABI, POD structs only)
│
▼ SEH-wrapped on the host side
Host bridge: plugin_manager/bridge/Bridge_<Service>.cpp (10 files)
│
▼
GameClient + GameLibrary
- Plugin authors include exactly one header:
POEFixer/plugin_sdk/PluginSDK.h. - That header declares everything in the
PluginSDK::namespace and pulls in the C ABI fromPluginAbi.hunderneath. You can mention the latter exists; you almost never look at it. - All
std::*containers live inside the plugin DLL. Only POD crosses the host boundary. This means a plugin built with one toolchain version can't get tangled up with the host's STL — the only shared types are integers, floats, pointers, and small structs.
Find the SDK headers at:
-
POEFixer/plugin_sdk/PluginSDK.h— the C++ wrapper plugin authors use. -
POEFixer/plugin_sdk/PluginAbi.h— the pure-C ABI underneath.
Reference plugins shipped with the repo (read these as documentation): Plugins/ExamplePlugin/, Plugins/Radar/, Plugins/KillCount/, Plugins/NinjaPricer/.
Minimal plugin that loads and prints a message in the host log:
#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; }Build as Plugins/Hello/Hello.dll, restart the host, enable from the Plugins tab.
Use Plugins/ExamplePlugin/ExamplePlugin.vcxproj as the canonical template. The essential settings:
- Configuration type: DynamicLibrary
- Platform toolset: v143 (Visual Studio 2022)
- Character set: Unicode
-
Language standard:
stdcpp20 -
Runtime library:
MultiThreadedDLL(Release) /MultiThreadedDebugDLL(Debug). MUST match the host. -
Preprocessor definitions:
PLUGIN_EXPORTS;NDEBUG;_WINDOWS;_USRDLL;_CRT_SECURE_NO_WARNINGS -
Additional include directories:
$(SolutionDir)POEFixer -
Output directory:
$(SolutionDir)x64\Release\Plugins\<YourPlugin>\ -
Target name: must match the folder name (
Plugins/MyPlugin/→MyPlugin.dll)
The host scans each subfolder in Plugins/ and looks for <FolderName>.dll. The DLL must export three symbols:
-
CreatePlugin— factory; returnsPluginSDK::Plugin*. -
DestroyPlugin— destructor; takesPluginSDK::Plugin*. -
PluginSDK_AttachHost— wires up theContext. Defined for you insidePluginSDK.hand emitted automatically whenPLUGIN_EXPORTSis set.
Recommended source layout:
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() returns an absolute, UTF-8 path rooted at the host EXE directory — don't prepend the EXE path yourself. The directory string is owned by value inside PluginSDK::Plugin, so it survives the host's container reallocations and reload cycles without lifetime concerns.
If you want to draw ImGui, also add these to <ClCompile> (the host links them too, but plugin-side ImGui is per-DLL):
..\..\POEFixer\imgui\imgui.cpp
..\..\POEFixer\imgui\imgui_draw.cpp
..\..\POEFixer\imgui\imgui_tables.cpp
..\..\POEFixer\imgui\imgui_widgets.cpp
In OnEnable, attach to the host's ImGui context:
if (ctx()->ImGuiContext)
ImGui::SetCurrentContext(static_cast<ImGuiContext*>(ctx()->ImGuiContext));PluginSDK::Plugin is a virtual base class. Override these in your plugin (in approximate call order):
| Method | Called when | Typical use |
|---|---|---|
const char* GetName() const |
Once, right after construction | Return your plugin's display name |
void OnEnable(bool isGameAttached) |
When the user enables the plugin (or at startup if persisted) | Load settings, subscribe to events, attach ImGui context |
void DrawSettings() |
Every frame while the plugin's settings panel is open | ImGui controls for your config |
void DrawUI() |
Every frame while the plugin is enabled | ImGui overlay drawing (use ImGui::GetBackgroundDrawList() for game overlay) |
bool WantsOverlay() const |
Polled every frame | Return true if you want the host in overlay (click-through) mode |
void SaveSettings() |
Periodically (~5s) and on disable | Persist config to disk |
void OnDisable() |
When the user disables, or on host shutdown | Free resources, unsubscribe events |
Only GetName is mandatory; the rest have safe defaults.
The host also calls GetSDKVersion() (defined on the base, do NOT override) immediately after CreatePlugin to verify the plugin and host agree. Mismatch → plugin refused.
ctx() returns const PluginSDK::Context*, an aggregate of 10 services:
struct Context {
GameService Game; // snapshot, state flags, screen size
EntitiesService Entities; // enumerate, find-by-id, watch
ComponentsService Components; // 21 component readers + collection enumerators
InventoryService Inventory; // scan + iterate + per-item helpers
UiService Ui; // tree walk, FindPanelByStringId, screen-rect
RenderService Render; // WorldToScreen + isometric map projection
TerrainService Terrain; // walkable grid (RAII), height, TGT locations
MemoryService Memory; // direct memory primitives (last resort)
LogService Log; // Debug/Info/Warn/Error
EventsService Events; // Subscribe / Unsubscribe / On<X>
void* ImGuiContext; // pass to ImGui::SetCurrentContext
void* D3DDevice; // ID3D11Device* for texture loading
};At a glance — what each service is for:
| Service | What you reach for it |
|---|---|
GameService |
Snapshot, state flags, screen + window info |
EntitiesService |
Enumerate, find-by-id, watch lifecycle |
ComponentsService |
21 readers + 4 enumerators + ~10 convenience helpers |
InventoryService |
Scan, enumerate, per-item mod reads |
UiService |
Tree walk, FindPanelByStringId, ComputeScreenRect
|
RenderService |
WorldToScreen, GridTo{Large,Mini}Map, transforms |
TerrainService |
Walkable + height grids (RAII), TGT locations |
MemoryService |
RPM primitives — only when no higher-level call fits |
LogService |
Debug / Info / Warn / Error
|
EventsService |
Subscribe / Unsubscribe / On{Area,Frame,Attach,Detach}
|
ctx() is valid from the moment the host calls OnEnable until OnDisable returns. Do not cache ctx() across hot-reloads or DLL unload boundaries.
ctx()->Game.GetSnapshot() returns a value-typed Snapshot — a full immutable view of the current frame. GetSnapshot() walks abi->entities.enumerate and populates snap.Entities before returning, so the cost scales with nearby-entity count. Call it once per frame and reuse.
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 transitionWhat the snapshot carries directly (no further service calls needed):
- State + flags:
State,IsAttached,IsWindowValid,GameWindowForeground,IsTown,IsHideout,IsPaused,IsSkillTreeVisible. - Area:
CurrentAreaName,CurrentAreaHash,CurrentAreaLevel,AreaChangeCounter. - World:
Player(fullEntity),Entities(fullstd::vector<Entity>),Vitals,LargeMap,MiniMap,WorldToScreenMatrix[16]. - Window:
ScreenWidth,ScreenHeight,ProcessId,GameWindow,LastUpdateTime,WorldToGridConvertor.
What's not on the snapshot — fetch via services: inventory contents (InventoryService), buffs (ComponentsService::EnumerateBuffs), per-item mod lists (InventoryService::ReadItemMods), UI panels (UiService).
Cheap helpers when you don't need a full snapshot:
if (ctx()->Game.IsInGame()) { ... }
if (ctx()->Game.IsForeground()) { ... } // game window focused
if (ctx()->Game.IsOverlayMode()) { ... } // host is in overlay (click-through)
if (ctx()->Game.IsMenuVisible()) { ... } // ESC menu, settings, etc.
auto sz = ctx()->Game.GetScreenSize(); // ScreenSize { Width, Height } floats
HWND hw = ctx()->Game.GetGameWindow();
DWORD pid = ctx()->Game.GetProcessId();
PluginSDK::GameState st = ctx()->Game.GetState();Entities expose their components via entity.Components — a ComponentAddresses struct of uintptr_t addresses. Pass each address to the matching ComponentsService::Read* to get a value-typed snapshot.
for (const auto& e : snap.Entities) {
if (!e.Components.HasLife()) continue;
PluginSDK::Life life = ctx()->Components.ReadLife(e.Components.Life);
if (life.Valid && life.Health.Current > 0) {
ctx()->Log.Info("alive monster");
}
}There are 21 component readers: ReadLife, ReadRender, ReadPositioned, ReadTargetable, ReadChest, ReadShrine, ReadStack, ReadCharges, ReadPlayer, ReadAnimated, ReadTransitionable, ReadTriggerableBlockage, ReadMinimapIcon, ReadStateMachine, ReadBase, ReadMods, ReadStats, ReadBuffs, ReadActor, ReadNpc, ReadDiesAfterTime.
ComponentAddresses itself holds 24 slots: the 21 above plus three markers (Buffs, WorldItem, AreaTransition) and OMP (host-internal). Buffs is a presence marker — the actual buff list comes from EnumerateBuffs. WorldItem / AreaTransition are entity-type markers rather than real components. All slots have matching HasX() predicates on ComponentAddresses.
Collection-style readers for components with variable-size data:
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>Convenience helpers (one-shot — they internally call Read* for you):
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)) { ... }A Valid flag on every returned struct lets you handle "component address was 0 / read failed" without exceptions. If you already have the parent struct (Life, Mods, …), access its fields directly rather than calling the helper again — the helper re-reads the component each time.
Every Entity (including snap.Player and members of snap.Entities) carries the same set of fields:
| Group | Fields |
|---|---|
| Identity |
Id, Address, EntityDetailsAddress, RenderComponentAddress, IsValid
|
| Classification |
EntityType, EntitySubtype, EntityState, Rarity, Reaction, Zone (NearbyZone: InnerCircle≈60 / OuterCircle≈120 / Far) |
| Position |
GridPositionX, GridPositionY, TerrainHeight, WorldX/Y/Z, ModelBoundsZ
|
| Quick vitals |
CurrentHP, MaxHP, CurrentES, MaxES (avoids a ReadLife if you only need the totals) |
| Strings |
Path (std::wstring, Metadata/...), PlayerName (std::wstring), TgtPath (std::string, asset path) |
| State |
IsSleeping, IsChestOpened
|
| Components |
Components (ComponentAddresses sub-struct) |
If you need to track one entity across frames (e.g. a chest the player is opening) and don't want to scan the full entity list each frame, register a 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) returns std::optional<Entity> for one-shot lookups, and GetPlayer() always returns the local player.
Items dropped on the ground appear in snap.Entities as EntityType::Item entities at path Metadata/MiscellaneousObjects/WorldItem. These are container entities — they don't carry Mods / Base / Stack / Sockets directly. The real item entity lives one indirection away.
To get the inner item entity as a regular Entity snapshot, use 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 only succeeds on real WorldItem containers — calling it with an inventory item address returns std::nullopt. If you want the same data shape as for inventory items (without manually walking components), the Inventory.ReadItem* family in the next section auto-resolves WorldItem containers transparently.
ctx()->Inventory.Scan(inventoryId) triggers a host-side rescan. Use -1 to scan all inventories.
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
}
}Each Inventory also exposes a Grid struct describing where the inventory is drawn on screen:
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)
}To grab a single inventory by id (returns the same struct with Items already populated):
PluginSDK::Inventory backpack = ctx()->Inventory.Get(/*inventoryId=*/0);Or if you only want the item vector without the wrapping struct:
std::vector<PluginSDK::InventoryItem> items = ctx()->Inventory.GetItems(0);ComponentsService::ReadMods(addr) returns only summary flags (IsCorrupted, IsRelic, IsSplit, IsMirrored, IsSynthesised, IsIdentified, Rarity, ItemLevel, RequiredLevel, CraftedModCount). It does not carry the per-kind mod lists.
For the full picture (summary + mod lists), use 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) { ... }Other direct per-entity reads (cheaper than rescanning when you already hold an item address):
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);Ground items via the inventory API. All seven Inventory.ReadItem* reads above (and ReadItemMods) accept BOTH inventory-item addresses AND WorldItem container addresses. Container addresses are auto-resolved to the inner item before reading, so the same plugin code path works for items in bags and items on the ground:
// `addr` may be either an inventory item address or a WorldItem container.
PluginSDK::ItemMods im = ctx()->Inventory.ReadItemMods(addr);
int rarity = ctx()->Inventory.ReadItemRarity(addr);
std::string baseName = ctx()->Inventory.ReadItemBaseTypeName(addr);If you need the inner item's component addresses directly (e.g. to call ctx()->Components.ReadStack(...) or walk sockets), use Entities.GetWorldItemInner from section 7 instead.
The game's UI tree is exposed as uintptr_t element addresses. Start from a root, walk children, read element fields.
The clean way to find a known panel by its 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
}Manual tree walking when you don't know the 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 thresholdStringId values are stable game-side identifiers; prefer them over hardcoded paths when they exist.
Three projection helpers, two coordinate systems.
Perspective (3D world → screen) — same projection the game uses to draw things in the world. Good for nameplates, debug markers, target indicators:
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));
}Isometric (grid → minimap) — for radar-style overlays drawn on the large or mini map. These respect the visible map's zoom, pan, and rotation:
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)For batched math (skip per-entity function calls), grab the transform once and do the projection inline:
PluginSDK::MapTransform t = ctx()->Render.GetLargeMapTransform();
if (t.IsVisible) {
// dx = gx - t.PlayerGridX; dy = gy - t.PlayerGridY;
// sx = t.CenterX + (dx - dy) * t.ScaleX;
// sy = t.CenterY + (worldZ * worldToGrid - (dx + dy)) * t.ScaleY;
}
// And ctx()->Render.GetMiniMapTransform() for the minimap.See Plugins/Radar/src/Radar.cpp for a working radar built entirely on these calls.
The walkable grid is a 4-bit-per-tile bitmap of which terrain cells the player can step on. The host updates it on each area change; plugins receive a stable handle that survives until the plugin releases it (via 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 mirrors the same shape but holds one float per tile (Data() is const float*, plus ElementCount() and SizeBytes()).
Don't subscribe to OnAreaChange to refresh the handle. The event fires when the host worker detects an area change, but the new walkable grid may not have been parsed yet — you'd hold a stale pointer for one or two frames. Instead, poll per frame in DrawUI:
auto current = ctx()->Terrain.GetWalkableGrid();
if (current.Data() != m_walkable.Data()) {
m_walkable = std::move(current); // swap when the host re-parses
}This is cheap (one ABI call + one pointer compare). See Plugins/Radar/src/Radar.cpp for the production version.
Other terrain accessors:
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 to host-emitted events. Each Subscribe returns a Token you can later pass to Unsubscribe. The EventsService destructor (fired when the plugin disables or unloads) auto-releases anything still outstanding — so you don't strictly need to unsubscribe manually, but it's polite.
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);
}
};The four event kinds are AreaChange, Frame, GameAttached, GameDetached. There's also a generic Subscribe(EventKind, callback) if you'd rather build a dispatch table.
The const_cast is required because Events mutates its internal token map. The base class returns const Context* to discourage mutating other services accidentally.
If your plugin owns several subscriptions, the ExamplePlugin pattern is a clean way to keep enable/disable symmetric — bundle tokens and counters into a single state struct and route everything through one SubscribeAll/UnsubscribeAll pair:
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;
}See Plugins/ExamplePlugin/examples/ExampleEvents.h for the full pattern.
Convention: <plugin directory>/config/settings.json. Directory() returns the absolute UTF-8 path to your plugin folder.
For trivial settings, a hand-rolled JSON writer works fine and keeps the DLL self-contained. See Plugins/Radar/src/RadarSettings.h for a working example. The skeleton:
struct MySettings {
bool DrawEnabled = true;
float Opacity = 0.9f;
void Save(const std::string& directory) const {
std::filesystem::path p =
std::filesystem::path(directory) / "config" / "settings.json";
std::error_code ec;
std::filesystem::create_directories(p.parent_path(), ec);
std::ofstream out(p);
if (!out.is_open()) return;
out << "{\n";
out << " \"DrawEnabled\":" << (DrawEnabled ? "true" : "false") << ",\n";
out << " \"Opacity\":" << Opacity << "\n";
out << "}\n";
}
void Load(const std::string& directory) {
std::filesystem::path p =
std::filesystem::path(directory) / "config" / "settings.json";
if (!std::filesystem::exists(p)) return;
// ... parse ...
}
};
// In your plugin:
void OnEnable(bool) override { m_settings.Load(Directory()); }
void SaveSettings() override { m_settings.Save(Directory()); }For structured data (nested objects, arrays), vendor a real JSON library in your plugin folder. The host doesn't impose a choice.
SaveSettings is called periodically (~5s) and on disable; you don't need to call it yourself.
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");All four levels route to the host's central logger. Messages appear in the host's Logs tab and in the on-disk log file. Format yourself before calling; the host doesn't accept printf-style varargs.
Internally the convenience methods emit the strings "Debug", "Info", "Warning", and "Error" (Warn maps to "Warning"). The host bridge does a case-insensitive match, so a plugin calling Log("warn", "msg") still routes correctly — but the convenience methods are clearer.
Direct memory primitives. Prefer the high-level services wherever possible — they understand offsets, handle ABI changes, and are SEH-safe. Direct memory reads are appropriate only when no higher-level call exists for what you need.
// 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 patternIf you find yourself reaching for these often, ask whether the data you need belongs in the higher-level services.
Every cross-DLL call between the host and a plugin runs inside an __try / __except block on the host side. A misbehaving plugin that dereferences a stale pointer, divides by zero, or otherwise faults inside an SDK call gets a logged error — the host process does not crash, the game keeps running, and the user can keep using other plugins.
That doesn't mean plugins can be sloppy. SEH catches the symptom, not the cause. If your plugin throws faults on every frame the user sees a flood of error logs and your data is effectively unavailable. Handle null returns from SDK calls, check Valid flags on component data, and don't dereference uintptr_t addresses directly — pass them through the ComponentsService / Ui / Memory calls that already wrap RPM properly.
The host can deal with a buggy plugin. It can't deal with a hung plugin DLL — a DrawSettings that takes 100ms blocks the entire UI thread. Keep per-frame work cheap.
A short list of things plugin authors hit when first integrating. Most of these are documented inline above; collected here as a checklist.
-
OnAreaChangefires before the walkable grid is re-parsed. Don't refreshWalkableGridHandlefrom the event — poll per frame inDrawUIand swap whenData()changes. (§11) -
Entity::Zoneis alwaysNonefor the local player. It's a distance-from-player classification, so the player is by definition at distance zero. Don't show it in player-info displays. -
Components.ReadMods()returns only summary flags — no mod lists. For per-kind mod lists, callInventory.ReadItemMods(entityAddr). (§8) -
Items dropped on the ground can lack
EntitySubtype. If you're filtering for items in the world, preferEntityType == Item || EntityType == Chestover a narrower subtype check. -
Directory()returns an absolute path. Don't prepend the EXE directory yourself — you'll getEXEDIR\EXEDIR\Plugins\Xand your config writes will land outside the plugin folder. -
ctx()returnsconst Context*. Mutating methods likeEventsService::Subscriberequireconst_cast. This is intentional — services that don't mutate should be impossible to mutate by accident. -
ImGui::SetCurrentContextis per-DLL. Call it at every entry point that draws (OnEnable,DrawUI,DrawSettings) because the plugin DLL has its own ImGui state by default. -
The convenience helpers re-read the component each call.
GetHealthPercent(addr)does a freshReadLife(addr)internally. If you already have theLifestruct from a prior call, access its fields directly instead.
One-line summary of every public method on every service. Use the prose sections above for the full type signatures and usage notes.
| Method | Returns | Purpose |
|---|---|---|
GetSnapshot() |
Snapshot |
Full per-frame view, including Entities
|
GetState() |
GameState |
Enum: InGame, Login, Loading, … |
IsAttached() |
bool |
Game process attached |
IsInGame() |
bool |
State == InGame |
IsForeground() |
bool |
Game window has focus |
IsMenuVisible() |
bool |
ESC menu / settings open |
IsOverlayMode() |
bool |
Host is in overlay (click-through) |
GetProcessId() |
DWORD |
Game PID |
GetGameWindow() |
HWND |
Game window handle |
GetScreenSize() |
ScreenSize |
{Width, Height} floats |
| Method | Returns | Purpose |
|---|---|---|
Enumerate(cb) |
— | Visit every nearby entity (return false to stop) |
GetPlayer() |
Entity |
The local player entity |
FindById(id) |
std::optional<Entity> |
Lookup by entity id |
GetWorldItemInner(addr) |
std::optional<Entity> |
Inner item entity for a WorldItem container (ground items) |
Watch(id) |
— | Pin an entity so its components stay readable |
Unwatch(id) |
— | Release a watch |
IsWatched(id) |
bool |
Watch state |
GetWatchedComponents(id) |
std::optional<ComponentAddresses> |
Read pinned components |
| Method | Returns | Purpose |
|---|---|---|
ReadLife / ReadRender / ReadPositioned / ReadTargetable / ReadChest / ReadShrine / ReadStack / ReadCharges / ReadPlayer / ReadAnimated / ReadTransitionable / ReadTriggerableBlockage / ReadMinimapIcon / ReadStateMachine / ReadBase / ReadMods / ReadStats / ReadBuffs / ReadActor / ReadNpc / ReadDiesAfterTime |
Component struct | 21 readers, one per component type |
EnumerateBuffs(addr) |
std::vector<Buff> |
Active buffs on the entity |
EnumerateActiveSkills(addr) |
std::vector<ActiveSkill> |
Skills from an Actor component |
EnumerateStats(addr) |
std::vector<StatEntry> |
Items + buffs sourced stats |
EnumerateItemMods(addr) |
std::vector<Mod> |
Mods reachable from a Mods component |
GetHealthPercent / GetEsPercent / GetManaPercent |
float |
Convenience % helpers |
IsAlive(addr) |
bool |
Health > 0 |
GetItemRarity(addr) |
int |
Rarity from a Mods component |
IsItemIdentified(addr) |
bool |
Identified flag |
GetStackCount(addr) |
int |
Current stack count |
IsChestOpened(addr) |
bool |
Chest open flag |
GetPlayerName(addr) |
std::string |
Player name from a Player component |
GetWorldPosition(renderAddr, x, y, z) |
bool |
Convenience accessor for world coords |
| Method | Returns | Purpose |
|---|---|---|
Scan(inventoryId) |
— | Trigger a host-side rescan (-1 = all) |
Get(inventoryId) |
Inventory |
One inventory, items already populated |
GetItems(inventoryId) |
std::vector<InventoryItem> |
Items only |
GetAll() |
std::vector<Inventory> |
All scanned inventories |
GetName(inventoryId) |
const char* |
Display name ("Backpack", "Stash", …) |
ReadItemRarity(addr) |
int |
Per-entity rarity (auto-resolves WorldItem containers) |
ReadItemStackCount(addr) |
int |
Per-entity stack (auto-resolves WorldItem containers) |
ReadItemBaseTypeName(addr) |
std::string |
Base type, auto-resolves WorldItem containers |
ReadItemUniqueName(addr) |
std::string |
Unique name, auto-resolves WorldItem containers |
ReadItemPath(addr) |
std::string |
Metadata/Items/... path, auto-resolves WorldItem containers |
ReadItemMods(addr) |
ItemMods |
Summary flags + 5 per-kind mod vectors, auto-resolves WorldItem containers |
| Method | Returns | Purpose |
|---|---|---|
Read(addr) |
UiElement |
Element fields (rect, flags, children count) |
GetChildren(addr) |
std::vector<uintptr_t> |
Child element addresses |
GetChildAt(addr, index) |
uintptr_t |
Single child by index |
FollowPath(root, indices, count) |
uintptr_t |
Walk a known index path |
IsVisible(addr) |
bool |
Element is on-screen |
GetStringId(addr) |
std::string |
Game-side stable identifier |
GetText(addr) |
std::string |
Rendered text |
ComputeScreenRect(addr, x, y, w, h) |
bool |
Final screen-space rect |
GetGameUiRoot() |
uintptr_t |
Root of the in-game UI |
GetUiRoot() |
uintptr_t |
Top-level UI root |
GetCullValue() |
int |
Host's UI cull threshold |
FindPanelByStringId(parent, stringId) |
uintptr_t |
Targeted descendant lookup |
| Method | Returns | Purpose |
|---|---|---|
WorldToScreen(wx, wy, wz, sx, sy) |
bool |
Perspective projection |
GridToLargeMap(gx, gy, worldZ, sx, sy) |
bool |
Project to the large map overlay |
GridToMiniMap(gx, gy, worldZ, sx, sy) |
bool |
Project to the minimap |
GetLargeMapTransform() |
MapTransform |
Pre-multiplied transform for batched math |
GetMiniMapTransform() |
MapTransform |
Same, for the minimap |
| Method | Returns | Purpose |
|---|---|---|
GetWalkableGrid() |
WalkableGridHandle |
RAII handle to the 4-bit-per-tile walkable bitmap |
GetHeightGrid() |
HeightGridHandle |
RAII handle to the per-tile terrain heights |
IsWalkable(gx, gy) |
bool |
Single-tile predicate |
GetTerrainHeight(gx, gy) |
float |
World-space Z |
GetWorldToGridConvertor() |
float |
World → grid conversion factor |
EnumerateTgtLocations(cb) |
— | Visit every TGT instance in the current area |
| Method | Returns | Purpose |
|---|---|---|
Read(addr, buf, size) |
bool |
Raw RPM |
ReadString(addr) |
std::string |
Null-terminated narrow string |
ReadWString(addr) |
std::wstring |
Null-terminated wide string |
ReadStdWString(addr) |
std::wstring |
Reads a game-side std::wstring container (handles SSO) |
ReadStdVector(addr, elemSize, maxElems) |
std::vector<uint8_t> |
Raw bytes; reinterpret as your type |
GetBaseAddress() |
uintptr_t |
Game module base |
GetModuleSize() |
uintptr_t |
Game module size |
GetPatternAddress(name) |
uintptr_t |
Named pattern lookup |
| Method | Purpose |
|---|---|
Debug / Info / Warn / Error(msg) |
Emit at the corresponding level |
Log(level, msg) |
Custom level string |
| Method | Returns | Purpose |
|---|---|---|
Subscribe(kind, cb) |
Token |
Generic dispatch |
OnAreaChange / OnFrame / OnGameAttached / OnGameDetached(cb) |
Token |
One-line subscribe helpers |
Unsubscribe(token) |
— | Manual release (destructor auto-releases anyway) |
PluginAbi.h defines:
constexpr int PLUGIN_SDK_VERSION = 6;At load time the host calls plugin->GetSDKVersion() and compares against its own PLUGIN_SDK_VERSION. Mismatch → the host logs a warning and refuses to load the plugin.
The host also checks HostAbi::version and HostAbi::size_bytes inside PluginSDK_AttachHost (defined inline by PluginSDK.h when PLUGIN_EXPORTS is set). If either field disagrees with what the plugin was built against, ctx() is non-functional. The base class accessor HostCompatible() reports false in that case, and any plugin that wants to be polite should refuse to act:
void OnEnable(bool) override {
if (!HostCompatible()) {
ctx()->Log.Error("Host ABI mismatch — disable plugin");
return;
}
// ...
}Four plugins in the repo are designed to be read as documentation:
-
Plugins/ExamplePlugin/— broad-surface showcase. One plugin that touches almost every service, organized as 11examples/Example*.hsub-files (Area & Vitals, Buffs, Entities, Inventory, Memory, UI Explorer, Component Reader, Render, Terrain, Events, Log) plus a coverage summary banner. Read this when you want to see how a service is used, in context. -
Plugins/Radar/— focused real-world example. ~200 lines. A radar overlay built entirely on the public SDK — no offsets, no raw memory reads. Renders the walkable map plus per-entity dots viaRender.GridToLargeMap. Read this when you want to see the minimum code for a particular outcome. -
Plugins/KillCount/— kill/chest/death tracker. SQLite + sprite atlas + per-area state. Shows how to ship persistence, vendored data files, and an overlay all in one DLL. -
Plugins/NinjaPricer/— poe.ninja price overlay. HTTP fetch (Exchange API) + inventory scan + per-item pricing. Shows network code, third-party data ingestion, and inventory iteration in a real workflow.