Skip to content

Plugin Development Guide

Lafko edited this page May 22, 2026 · 19 revisions

← Home


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.


1. Overview

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 from PluginAbi.h underneath. 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/.


2. Hello-world plugin

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.


3. Project setup

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; returns PluginSDK::Plugin*.
  • DestroyPlugin — destructor; takes PluginSDK::Plugin*.
  • PluginSDK_AttachHost — wires up the Context. Defined for you inside PluginSDK.h and emitted automatically when PLUGIN_EXPORTS is 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));

4. Lifecycle hooks

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.


5. The Context

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.


6. Reading game state

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 transition

What 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 (full Entity), Entities (full std::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();

7. Reading components

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.

Entity fields

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)

Watching a specific entity

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.


8. Inventory

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

Item mods: two APIs, two scopes

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

9. UI tree

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 threshold

StringId values are stable game-side identifiers; prefer them over hardcoded paths when they exist.


10. Render & projection

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.


11. Terrain & walkable grid

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

12. Events

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.

Grouping subscriptions

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.


13. Settings persistence

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.


14. Logging

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.


15. Memory (power user)

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 pattern

If you find yourself reaching for these often, ask whether the data you need belongs in the higher-level services.


16. Bridge / SEH safety

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.


17. Common pitfalls

A short list of things plugin authors hit when first integrating. Most of these are documented inline above; collected here as a checklist.

  1. OnAreaChange fires before the walkable grid is re-parsed. Don't refresh WalkableGridHandle from the event — poll per frame in DrawUI and swap when Data() changes. (§11)
  2. Entity::Zone is always None for 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.
  3. Components.ReadMods() returns only summary flags — no mod lists. For per-kind mod lists, call Inventory.ReadItemMods(entityAddr). (§8)
  4. Items dropped on the ground can lack EntitySubtype. If you're filtering for items in the world, prefer EntityType == Item || EntityType == Chest over a narrower subtype check.
  5. Directory() returns an absolute path. Don't prepend the EXE directory yourself — you'll get EXEDIR\EXEDIR\Plugins\X and your config writes will land outside the plugin folder.
  6. ctx() returns const Context*. Mutating methods like EventsService::Subscribe require const_cast. This is intentional — services that don't mutate should be impossible to mutate by accident.
  7. ImGui::SetCurrentContext is per-DLL. Call it at every entry point that draws (OnEnable, DrawUI, DrawSettings) because the plugin DLL has its own ImGui state by default.
  8. The convenience helpers re-read the component each call. GetHealthPercent(addr) does a fresh ReadLife(addr) internally. If you already have the Life struct from a prior call, access its fields directly instead.

18. Service quick reference

One-line summary of every public method on every service. Use the prose sections above for the full type signatures and usage notes.

GameService

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

EntitiesService

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
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

ComponentsService

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

InventoryService

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
ReadItemStackCount(addr) int Per-entity stack
ReadItemBaseTypeName(addr) std::string Base type ("Gemcutter's Prism", …)
ReadItemUniqueName(addr) std::string Unique name (if applicable)
ReadItemPath(addr) std::string Metadata/Items/... path
ReadItemMods(addr) ItemMods Summary flags + 5 per-kind mod vectors

UiService

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

RenderService

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

TerrainService

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

MemoryService

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

LogService

Method Purpose
Debug / Info / Warn / Error(msg) Emit at the corresponding level
Log(level, msg) Custom level string

EventsService

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)

19. Versioning

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

20. Sample plugins

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 11 examples/Example*.h sub-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 via Render.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.


← Home

Clone this wiki locally