Skip to content

Plugin Development Guide

Lafko edited this page Jul 9, 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  (16 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.

For filesystem operations, prefer DirectoryPath() over Directory(). DirectoryPath() returns a std::filesystem::path built through an explicit UTF-8→wide conversion. Passing the raw Directory() string into std::filesystem::path or std::ifstream lets the system ANSI codepage reinterpret the bytes, so a host installed under a Cyrillic/CJK user folder produces a mangled path and your config writes land outside the plugin folder. Use Directory() for display and logging; use DirectoryPath() whenever you build a path or open a file.

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 16 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>
    OverlayService    Overlay;     // SetIncludeSleepingEntities / SetWantsOverlayInput
    FlasksService     Flasks;       // life/mana flasks + charms: charges, usable, active
    PricesService     Prices;       // poe2scout item prices (host-loaded once, shared)
    RuneshapeService  Runeshape;    // Expedition2Encounter devices + per-device rewards
    AtlasService      Atlas;        // endgame-atlas nodes / adjacency / Rite selection / weights
    SekhemaService    Sekhema;      // Trial-of-the-Sekhemas floor graph / choices / content FKs
    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}
OverlayService SetIncludeSleepingEntities / SetWantsOverlayInput — map-picker friendly
FlasksService Life/mana flasks + charms — charges, Usable, Active, per-use, mod count
PricesService LookupPrice / GetRates / GetStatus — host-loaded poe2scout prices, shared by all plugins
RuneshapeService Runeshapes / Rewards — resolved Expedition2Encounter devices + per-device rewards
AtlasService GetPanel / Nodes / Connections / Selection / GetLineSeed / Weights — live endgame-atlas panel data
SekhemaService GetPanel / GetFloor / Rooms / Content / room-flag reads — Trial-of-the-Sekhemas floor-map data

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

GetHiveblood reads the Genesis-tree (Hiveblood) resource counter — a host-tail read routed through GameService (same family as GetGold / GetAreaId):

int32_t hiveblood = 0;
if (ctx()->Game.GetHiveblood(hiveblood)) {
    // in a Genesis map: hiveblood holds the current resource count
}
// Returns false (leaving the out param untouched) when not in game, the chain
// is broken, or the host predates the tail function — always gate on the return.

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>
auto mmods  = ctx()->Components.EnumerateMonsterMods(e.Components.OMP);        // std::vector<MonsterMod>

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)) { ... }

Detecting a monster's modifiers at spawnEnumerateMonsterMods reads a monster's rolled mods straight from its ObjectMagicProperties component (Components.OMP), so you can identify it the instant it appears, before any related buff (e.g. abyss_lightless_well_immune_XX) is applied. Each MonsterMod exposes Id / Name / Metadata (the Mods.dat columns) plus Hash16 / Hash32:

bool HasAmanamuMod(const PluginSDK::Entity& e) {
    if (!e.Components.HasOMP()) return false;
    for (const auto& mod : ctx()->Components.EnumerateMonsterMods(e.Components.OMP)) {
        if (mod.Id == "MonsterAbyssLightlessFaction1") return true;   // most stable key
        if (mod.Hash32 == 0xBFDA2A36) return true;
        if (mod.Metadata == "Metadata/Monsters/MonsterMods/LeagueAbyss/LightlessWells") return true;
    }
    return false;
}

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.

Distinguishing ground effects — many ground effects share the single entity path Metadata/Effects/Spells/ground_effects/VisibleServerGroundEffect, so the path alone can't tell Shocked Ground from Burning Ground. ReadGroundEffect resolves the entity's GroundEffect component and its groundeffects.datc64 row. Pass the entity address (the GroundEffect component isn't in Components, so the host resolves it for you — same convention as ReadPathfinding), then match on TypeId, the stable, patch-independent key:

for (const auto& e : snap.Entities) {
    if (e.Path != L"Metadata/Effects/Spells/ground_effects/VisibleServerGroundEffect") continue;
    PluginSDK::GroundEffect ge = ctx()->Components.ReadGroundEffect(e.Address);
    if (!ge.Valid) continue;
    // ge.TypeId -> "ShockedGround" / "IgnitedGround" / "CausticCloud" / "ChilledGround" / ...
    // ge.Radius -> world units; draw a circle at (e.WorldX, e.WorldY, e.WorldZ) of this radius
    if (ge.TypeId == "ShockedGround") {
        // highlight per your config (color/alpha keyed by ge.TypeId)
    }
}

The GroundEffect struct:

Field Meaning
Valid false if the entity has no GroundEffect component or the read failed
TypeId groundeffecttypes Id — the stable key to match on (e.g. ShockedGround)
Radius Effect radius in world units; 0 when the variant leaves it unset
EndEffect End behaviour: fadeout / close / end
BuffVisual1 buffvisuals Id (e.g. ground_fire_burn_white); empty if unset
BuffVisual2 buffdefinitions Name (e.g. ground_tar_gold); empty if unset
AoFile First .ao/.aoc visual path; empty if none
GroundEffectsRowAddr / GroundEffectTypesRowAddr Raw dat-row pointers (session-stable) for advanced cross-referencing

The effect's world position comes from the entity itself (Entity.WorldX/Y/Z, or the Render/Positioned components), so it isn't duplicated on the struct. ReadGroundEffect re-reads on each call, so cache the result per scan interval. It returns an invalid GroundEffect on hosts built before this API (it lives on the SDK v6 append-only tail and is null-checked).

ActiveSkill fields

EnumerateActiveSkills(actorAddr) returns one ActiveSkill per granted/socketed skill on the entity's Actor. Beyond Name, the struct carries cooldown state and a decoded gem-socket descriptor:

Field Meaning
Name Skill internal name
CurrentSize / TotalUses / UseStage Stage / use counters (raw)
CastType Raw cast-type id
TotalCooldownMs Full cooldown duration, milliseconds
CanBeUsed Host "usable right now" flag
MaxUses Cooldown charges the skill has (0 = not cooldown-bound)
TotalActiveCooldowns Charges currently on cooldown. Remaining uses = MaxUses - TotalActiveCooldowns when MaxUses > 0.
GrantedEffectsPerLevelAddr, ActiveSkillsDatAddr, GrantedEffectStatSetsPerLevelAddr, SkillDetailsAddr Raw DAT-row addresses — pass to ctx()->Memory.Read* for deeper skill data.
EquipmentInfoPacked Raw packed gem/socket word (decoded into Equipment, below).

The packed word is decoded for you into skill.Equipment:

Equipment field Meaning
GemNameHash Upper 16 bits — gem identity hash
InventorySlot 1-based equipment slot the gem sits in
LinkIndex Link-group index within the item
SocketIndex Socket index within the link group
UnknownFlag / CanBeOnPlayerItem Residual flags (bit layout in 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 — where the gem sits
}

Per-skill evaluated stats & DPS (EnumerateSkillStats)

EnumerateSkillStats(skillDetailsAddr) exposes the game's own evaluated stat containers for one skill — including the DPS family the in-game skills panel shows. Pass ActiveSkill::SkillDetailsAddr from an EnumerateActiveSkills result of the same frame (skill addresses go stale across frames/area changes; a stale address safely returns an empty vector, as does a host built before this API).

Each returned SkillStatEntry is {SetIndex, StatId, Value}:

  • SetIndex 0 is the skill's current-context stat set — present on every skill, persistent (survives closed panels), and the exact source of the skills-panel DPS line.
  • Later sets are the skill's per-part stat sets — for summon/command skills the minion-side stats live there.
  • StatId is the Stats.dat row index + 1 (the engine's runtime stat key; 0 is the game's "no stat" sentinel). Resolve names by dumping Stats.dat.
  • Value is a raw int32; many DPS-family stats are ×100 fixed-point.

Useful runtime ids:

StatId Stat (Stats.dat row + 1) Scaling
691 hundred_times_attacks_per_second ×100
692 hundred_times_damage_per_second ×100
695 hundred_times_casts_per_second ×100
1982 / 1983 hundred_times_average_damage_per_hit / ..._per_skill_use ×100
694 base_spell_cast_time_ms ms
2079 skill_show_average_damage_instead_of_dps flag
auto skills = ctx()->Components.EnumerateActiveSkills(snap.Player.Components.Actor);
for (const auto& s : skills) {
    for (const auto& st : ctx()->Components.EnumerateSkillStats(s.SkillDetailsAddr)) {
        if (st.StatId == 692) {  // hundred_times_damage_per_second
            ctx()->Log.Info((s.Name + " DPS: " +
                             std::to_string(st.Value / 100.0)).c_str());
        }
    }
}

Caveats worth knowing:

  • The DPS family is virtual. The engine computes those stats via callbacks (DPS = rate/100 × avg damage) and stores the result only for contexts it displayed. The current-context set carries the last value the game itself evaluated; alt contexts (infusion tooltip tabs, weapon-swap previews) are evaluated transiently on hover and are not persistently readable. Values can therefore lag the live tooltip by a few percent on monsters with dynamic damage buffs — the game's own skill list and tooltip disagree the same way.
  • Minion DPS lives on the minion. A summon skill's own sets only describe the summon; the "Basic Attack" numbers of the tooltip come from the minion entity's Actor — enumerate entities, find the friendly monster, then EnumerateActiveSkills(minion.Components.Actor)EnumerateSkillStats(...) on its attack skill.
  • EnumerateActiveSkills returns two entries per skill name (different evaluation contexts, e.g. weapon sets) — query both if you're hunting a specific stat.

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.

Ground items (WorldItem containers)

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.


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

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.

In-game-style mod text + base / aggregated stats (v6, 2026-06-24). Format any stat key into the same text the in-game tooltip shows, and read an item's base defensive values and aggregated map/waystone properties:

// Render a mod the way the game does ("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` */ }
}

// Item base defensive values. EnergyShield is the in-game (computed) value;
// Ward/Armour/Evasion are the item's base values. Valid == false when the item
// has no Armour component (currency, gems, jewellery, waystones, ...).
PluginSDK::ItemBaseStats bs = ctx()->Inventory.ReadItemBaseStats(item.Address);
if (bs.Valid) { /* bs.EnergyShield, bs.Ward, bs.Armour, bs.Evasion */ }

// Aggregated stats keyed by stat id — e.g. a waystone's 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)) {
    // map statId -> label yourself; values are signed percentages
}

FormatStat uses the host's .csd stat-description set (downloaded on first use), so it returns an empty string until that data is ready — fall back to the raw Mod fields. ReadItemBaseStats / ReadItemAggregatedStats both accept inventory-item OR WorldItem container addresses.

Flasks & charms

ctx()->Flasks is a convenience view over the utility belt (Inventory[12]) — it bundles each flask/charm's charges, active state, and per-use cost so you don't have to stitch together the Charges component, the player's Buffs, and the inventory grid yourself.

// Life flask = slot 0, mana flask = slot 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());
}

// Charms = slots 0..2.
for (const auto& c : ctx()->Flasks.AllCharms()) {
    if (!c.Valid) continue;            // empty belt slot
    // c.Name, c.ChargesCurrent, c.Active, ...
}

GetFlask(slot) / GetCharm(slot) return std::nullopt when out of range or not in game; an equipped-but-empty slot returns a value with Valid == false. AllFlasks() / AllCharms() always return FlaskSlotCount() / CharmSlotCount() entries (2 / 3 on POE2), including the empty ones, so you can index by belt position.

Flask field Meaning
ChargesCurrent Charges currently stored
PerUseBase Charges a single use costs (raw)
PerUseEffective PerUseBase after the flask's own "increased charges used" mods
Usable PerUseBase > 0 && ChargesCurrent >= PerUseEffective
Active The flask's buff is currently up (from the player's Buffs)
IsLife / IsMana Flask type (both false = a future utility flask)
Name / BaseType / Path Display name (unique → base fallback), base type, metadata path
EntityAddress Item entity — pass to ReadItemMods for the mod list
ModCount Hint for how many mods the item carries

Charm has the same fields minus PerUseEffective, Usable, IsLife, IsMana.

Reading the mod list. Flask/charm mods are not inlined — ModCount is just a hint. For the actual affixes, pass EntityAddress to the inventory mod reader from the previous subsection:

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 */ }
}

Limitation. PerUseEffective applies only the flask's own "increased charges used" mods. The player-wide passive stat flask_charges_used_positive_percentage is not folded in yet, so PerUseEffective can under-count when a passive boosts charges used. ChargesCurrent, Active, and the mod list are exact.


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.

0.5.x note. Ui.GetStringId() returns the correct identifier on current (0.5.x) clients — the element's StringId field offset moved (0x4480x4C0) and the host bridge was corrected to match. (For the numeric StringId that trial-HUD field-leaves render their values into — a different field from GetText()SekhemaHelper reads ctx()->Sekhema.GetUiStringId().)


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. OverlayService — input capture and sleeping entities

ctx()->Overlay exposes two per-plugin "request flags" that change overlay-wide behavior. The host stores per-plugin state keyed by your this pointer and OR-aggregates it with the host's own built-in flags (main menu visibility, AutoCraft lock, host's built-in Add-Entity-from-Map picker, every other plugin's request). Auto-cleared on plugin disable/unload — a crashed or buggy plugin cannot permanently stick the overlay in a weird state.

SetIncludeSleepingEntities

By default, EntitiesService.Enumerate and Snapshot.Entities hide entities with EntityState::Useless (the host's "sleeping" filter that drops far-away dormant monsters / NPCs / chests). This keeps the per-frame snapshot cost bounded — typical area has hundreds of Useless entities the plugin doesn't care about.

For map-picker UIs and debug viewers that need the full entity pool of the area (so the user can click on an entity that isn't yet active), turn the filter off:

ctx()->Overlay.SetIncludeSleepingEntities(true);
// Now ctx()->Entities.Enumerate sees Useless entities too.
// Entity::IsSleeping marks specifically those that came from the host's
// separate SleepingEntities collection (orthogonal to EntityState::Useless).

Cost: roughly +5–15% snapshot CPU per frame while enabled. Leave OFF unless you need it.

SetWantsOverlayInput

The overlay window is normally click-through (WS_EX_TRANSPARENT): every mouse click passes straight to the game underneath. This is the right default for read-only overlays (radar, health bars, DPS readouts) — the player keeps playing without noticing the overlay.

The moment your plugin wants the user to click on something inside the overlay — confirm a popup, pick an entity on the map, drag a marker — that default breaks. SetWantsOverlayInput(true) asks the host to start consuming mouse clicks where your ImGui windows cover them:

ctx()->Overlay.SetWantsOverlayInput(true);
// ...
// When done (user picked, closed popup, pressed Escape):
ctx()->Overlay.SetWantsOverlayInput(false);

The host's per-frame logic claims clicks ONLY where the cursor is over a visible ImGui window — anywhere else, click-through is preserved so the player can still move/attack/loot around your popup.

Scope (important):

  • Mouse buttons (LMB/RMB): YES — gated by this flag.
  • Mouse position / hover: ALWAYS works regardless. Hover tooltips don't need this flag.
  • Keyboard: ALWAYS reaches the plugin via the host's WindowProc, independent of this flag. ImGui::IsKeyPressed(ImGuiKey_Escape) works either way.

Background-draw-list caveat — READ THIS for map-picker plugins

If you draw clickable markers via ImGui::GetBackgroundDrawList() (typical for radar / large-map overlays), the background draw list has no ImGui window backing it. The host's hit-test walks ctx->Windows, finds nothing under the cursor, and re-enables click-through — your markers are visible but unclickable.

The fix: open a real ImGui window covering your picker area and put an ImGui::InvisibleButton inside it. The window is what the host hit-tests; the InvisibleButton gives you ImGui::IsItemClicked() for click detection. Sketch:

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

// Draw markers via ImGui::GetForegroundDrawList() (or 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) {
            // user picked this POI
        }
    }
    return true;
});
ImGui::End();

Smaller windows covering just the picker region work the same way — the host doesn't care about size, only that some window is under the cursor.

Worked example — Add POI from Map

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

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

        // Hotkey toggle. Keyboard reaches the plugin regardless of capture
        // state, so this works even when overlay is 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 (see Background-draw-list caveat above for the
        //     ImGui::Begin + InvisibleButton pattern).
    }

    void OnDisable() override {
        // Belt-and-braces. The host also clears flags on disable, but
        // explicit cleanup keeps state consistent if any synchronous frame
        // fires between OnDisable and the PluginManager bookkeeping.
        ctx()->Overlay.SetIncludeSleepingEntities(false);
        ctx()->Overlay.SetWantsOverlayInput     (false);
    }
};

Lifecycle and aggregation

  • Both flags are idempotent — calling Set(true) twice in a row is a no-op the second time, counter not double-incremented.
  • Both are OR-aggregated with the host's own state and every other plugin's flag. Multiple plugins in picker mode simultaneously coexist fine.
  • The host auto-clears all of a plugin's flags when the plugin is disabled (via Plugins tab, crash-disable, or shutdown). A crash mid-frame won't permanently stick the overlay in capture mode — but well-behaved plugins still pair their on/off calls so other plugins and the game stay responsive in between.
  • Latency: a Set call updates the flag synchronously, but the effective behavior change manifests on the next host frame (overlay input) or next GameClient worker tick (sleeping entities). Sub-frame, unobservable.
  • All methods are safe to call from any thread.

14. Prices — host-loaded item pricing

The host loads market prices once per session from poe2scout on a background thread and exposes them to every plugin through ctx()->Prices. Plugins do not fetch prices themselves — there is a single shared price database behind the built-in radar, the host overlays, and all plugins, so the API is hit once rather than once per consumer.

  • The price league is chosen by the user in Configuration → Settings (default Runes of Aldur) and persisted host-side. Plugins always read the user's selected league; they don't pick it.
  • Loading is fire-once with per-category backoff (retry after 1 → 5 → 10 → 20 → 30 → 60 min on failure, then give up until the app restarts). There is no periodic refresh — prices are stable for the whole session.
  • Every price is Chaos-denominated. GetRates() gives the Divine / Exalted conversion if you want to display in those units.

Looking up a price

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 — which poe2scout bucket matched (currency, fragments, runes,
    // …, or a unique-item category). Handy to branch currency vs. unique.
}

LookupPrice takes an item's display name (currency name, unique name, or base type) and does fuzzy host-side matching across every loaded category. A miss returns found == false with all prices zero.

PriceResult field Type Meaning
found bool A price was matched
chaos float Price in Chaos Orbs (the canonical unit)
divine float Same price expressed in Divine Orbs
exalt float Same price expressed in Exalted Orbs
category std::string poe2scout category that matched (currency / fragments / runes / … / a unique category)

Rates and load status

PluginSDK::PriceRates  r = ctx()->Prices.GetRates();   // divineInChaos, exaltedInChaos
PluginSDK::PriceStatus s = ctx()->Prices.GetStatus();
if (!s.loaded) {
    // Not ready yet (still loading, or every category failed).
    // s.catsOk / s.catsPending / s.catsFailed show how far the loader got.
}

GetStatus().loaded is the gate to check before displaying prices — it flips true only once the conversion rates plus at least the first category have arrived. Until then, render a "loading prices…" state rather than zeros.


15. Runeshape devices

ctx()->Runeshape exposes the Expedition2Encounter ("Runeshape") devices the host has resolved in the current area, together with the reward each recipe would grant. The host does the device-chain walk and the offline recipe match; your plugin just reads the result. This is what powers the built-in radar's reward tag and NinjaPricer's Runeshape window — a third-party plugin can render the same data.

for (const PluginSDK::Runeshape& rs : ctx()->Runeshape.Runeshapes()) {
    // rs.color gives each device a distinct color (use it to group/tint).
    // rs.bestIndex is the index of the highest-priced reward (or -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());
        if (rw.propagatingCount > 0)   // 0.5.4: rune(s) that carry over to the next remnant
            ctx()->Log.Info(("  propagates: " + rw.propagatingRunes).c_str());
    }
}
Runeshape field Type Meaning
entityId uint64_t Device entity id — pass to Rewards()
color uint32_t Packed RGBA, stable per device (for grouping / tinting)
isUnique bool Device offers a unique-item recipe
holeCount int Number of rune holes on the anchor
anchorName std::string Anchor rune name
rewardCount int Number of reward slots
bestIndex int Index of the highest-totalChaos reward, or -1
propagatingSlots std::vector<int> Rune-hole slot index(es) whose rune propagates to the next remnant (0.5.4 carryover); usually 1, sometimes 2
RuneshapeReward field Type Meaning
name std::string Reward item name
count int Quantity granted
unitChaos float Per-unit Chaos price (from the Prices service)
totalChaos float unitChaos × count
priced bool A price was found for this reward
propagatingRunes std::string Rune(s) at this recipe's propagating slot(s) — what carries over if you complete this recipe; e.g. "Power" or "Cold, Time"; empty if the recipe doesn't cover the slot
propagatingCount int Number of propagating runes for this reward
propagatingHasRare bool Any propagating rune is rare ("purple"/valuable)

Reward prices come from the same ctx()->Prices database, so an unpriced reward (priced == false) usually just means prices haven't loaded yet, or the item isn't listed on poe2scout.

Rune propagation (0.5.4). Each remnant randomly picks one rune slot whose rune carries over to the next remnant (in-game: the gold-crowned highlight in the Runeshape Recipes list). Runeshape::propagatingSlots is the raw slot list; because it is a slot position, the rune that propagates differs per recipe, so RuneshapeReward::propagatingRunes resolves it per reward. This is what powers NinjaPricer's yellow slot-dot and per-reward marker.


16. Atlas panel data

ctx()->Atlas exposes the live endgame-atlas panel — the map nodes, their per-anchor adjacency, the current Rite selection, and the raw eligibility weights — read host-side through the GameLibrary atlas offsets. This is what powers the built-in atlas overlay and the ForetoldRewards reference plugin. Everything keys off GetPanel(): 0 means the atlas UI isn't built (not in game / panel closed).

if (!ctx()->Atlas.GetPanel()) return;                     // atlas UI absent
for (const PluginSDK::AtlasNode& n : ctx()->Atlas.Nodes()) {
    // n.gridX / n.gridY are stable per map; n.marker / n.mapState carry UI state.
    std::string name = ctx()->Atlas.GetNodeName(n.uiAddress);  // resolve lazily
}
uint32_t seed = ctx()->Atlas.GetLineSeed();               // 0 = no Rite line
Method Returns Purpose
GetPanel() uintptr_t Atlas panel address; 0 = panel absent
Nodes(detail) std::vector<AtlasNode> All atlas nodes (gridX/Y, uiAddress, marker, flags, biome, mapState); default detail skips names — resolve via GetNodeName()
Connections() std::vector<AtlasConnection> Per-anchor adjacency (x/y + up to 5 neighbors); viewport-dependent
Selection(which) std::vector<AtlasGridPoint> which=0 revealed Rite-line maps, which=1 picked anchors (in pick order)
GetLineSeed() uint32_t Rite reward-selection seed; 0 = no Rite line / panel absent
Weights() std::vector<AtlasWeight> Raw eligibility-weight rows (key, value)
GetNodeName(uiAddress) std::string Display name for a node's uiAddress ("" when unresolved)

The by-value AtlasServiceAbi is frozen (another HostAbi member was appended after it), so any future atlas read must land as a new HostAbi tail function — never a new AtlasServiceAbi member.


17. Sekhema trial data

ctx()->Sekhema exposes Trial of the Sekhemas floor-map data — the static room graph, the per-run choices, and each room's content FK rows — plus the StateMachine flag reads trial rooms need. The graph calls take the trial panel UI address explicitly: start from GetPanel() (the host's direct child-index resolution) or run your own UI-tree BFS pre-filtered with the cheap ProbeFloor(). This is what powers the SekhemaHelper reference plugin.

uintptr_t panel = ctx()->Sekhema.GetPanel();
if (!panel) return;                                       // not on a trial floor
PluginSDK::SekhemaFloor floor = ctx()->Sekhema.GetFloor(panel);
for (const PluginSDK::SekhemaRoom& r : ctx()->Sekhema.Rooms(panel)) {
    bool chosen = r.layer < (int)floor.choices.size() && floor.choices[r.layer] == r.index;
    // r.connections lists the room indices in the NEXT layer
}
Method Returns Purpose
GetPanel() uintptr_t Host-resolved trial panel; 0 = absent / controller mode / not in game
ProbeFloor(uiAddress) int Layer count of the FloorData at uiAddress, 0 = not a trial floor (cheap BFS pre-filter)
GetFloor(panel) SekhemaFloor Floor header: layerCount, roomCounts, choices (per-layer chosen index, 0xFF=none), counter
Rooms(panel) std::vector<SekhemaRoom> Static room graph in (layer, index) order; each has connections into the next layer
Content(panel) std::vector<SekhemaContentEntry> Per-room content FK rows resolved to rowId / rowName (dispatch on tablePath)
GetRoomUsedFlag(sm) int StateMachine used/closed flag: 1=used, 0=active, -1=unreadable
GetStateMachineValue(sm, i, out) bool One shared-state value (8B per define_shared_state entry, in define order)
GetUiStringId(uiAddress) std::string A UI element's StringId as UTF-8 — the numeric field trial-HUD leaves render into (not Ui.GetText)

18. Settings persistence

Convention: <plugin directory>/config/settings.json. Directory() returns the absolute UTF-8 path to your plugin folder; for file operations use DirectoryPath() (a UTF-8-safe std::filesystem::path — see §3).

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

// In your plugin — pass DirectoryPath() (UTF-8-safe fs::path), NOT Directory():
void OnEnable(bool) override   { m_settings.Load(DirectoryPath()); }
void SaveSettings() override   { m_settings.Save(DirectoryPath()); }

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.


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


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


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


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

23. 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
GetGold() int Character gold counter (0 when not in game)
GetAreaId() std::string Raw WorldArea id of the current zone (carries the Sekhemas floor number)
GetHiveblood(out) bool Genesis-tree (Hiveblood) resource → out; false on older hosts / not in game

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

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 (see §7 for the ActiveSkill field table)
EnumerateSkillStats(skillDetailsAddr) std::vector<SkillStatEntry> Evaluated stat sets of ONE skill ({SetIndex, StatId, Value}, StatId = Stats.dat row + 1) — set 0 is the current context incl. the skills-panel DPS (692, ×100); see §7
EnumerateStats(addr) std::vector<StatEntry> Items + buffs sourced stats
EnumerateItemMods(addr) std::vector<Mod> Mods reachable from a Mods component
EnumerateMonsterMods(ompAddr) std::vector<MonsterMod> Monster mods from an ObjectMagicProperties component (Components.OMP) — Id / Name / Metadata + Hash16 / Hash32; detect at spawn before any buff
ReadGroundEffect(entityAddr) GroundEffect Ground-effect type + radius from a VisibleServerGroundEffect entity — pass the ENTITY address; match on TypeId (ShockedGround/IgnitedGround/…). Distinguishes effects that share one entity path
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 (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
FormatStat(statKey, v0, v1) std::string In-game-style text for a stat key + value(s) via the host .csd formatter; empty until descriptions load
ReadItemBaseStats(addr) ItemBaseStats Base defensive values (computed Energy Shield; base Ward/Armour/Evasion); Valid false without an Armour component; auto-resolves WorldItem containers
ReadItemAggregatedStats(addr) std::vector<std::pair<int,int>> Aggregated {statId, value} (waystone Item Rarity 8205 / Pack Size 8206 / Monster Rarity 8207 / Monster Effectiveness 8208 / Waystone Drop Chance 8209); auto-resolves WorldItem containers

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)

OverlayService

Method Returns Purpose
SetIncludeSleepingEntities(enable) Opt into receiving EntityState::Useless entities in EntitiesService.Enumerate
SetWantsOverlayInput(enable) Ask the overlay to capture mouse clicks instead of being click-through

Both are idempotent, per-plugin, OR-aggregated with host + other plugins, auto-cleared on Disable/Unload. See section 13 for the full pattern (including the background-draw-list caveat for map-picker plugins).

FlasksService

Method Returns Purpose
GetFlask(slot) std::optional<Flask> Life/mana flask by belt slot (0=life, 1=mana); nullopt out of range or not in game
GetCharm(slot) std::optional<Charm> Charm by belt slot (0..2); nullopt out of range or not in game
AllFlasks() std::vector<Flask> Every flask slot incl. empty (FlaskSlotCount() entries)
AllCharms() std::vector<Charm> Every charm slot incl. empty (CharmSlotCount() entries)
FlaskSlotCount() int32_t Number of flask slots (2 on POE2)
CharmSlotCount() int32_t Number of charm slots (3 on POE2)

See section 8 ("Flasks & charms") for the Flask / Charm field tables and the PerUseEffective limitation.

PricesService

Method Returns Purpose
LookupPrice(name) PriceResult Fuzzy host-side price lookup by display name (found, chaos, divine, exalt, category)
GetRates() PriceRates Divine / Exalted → Chaos conversion rates
GetStatus() PriceStatus loaded gate + per-category counts (catsOk / catsPending / catsFailed)

RuneshapeService

Method Returns Purpose
Runeshapes() std::vector<Runeshape> All resolved Expedition2Encounter devices (id, color, anchor, bestIndex)
Rewards(entityId) std::vector<RuneshapeReward> Per-device reward slots, each priced via the Prices service

AtlasService

Method Returns Purpose
GetPanel() uintptr_t Atlas panel address; 0 = absent
Nodes(detail) std::vector<AtlasNode> All atlas nodes (grid coords, marker, mapState)
Connections() std::vector<AtlasConnection> Per-anchor adjacency (viewport-dependent)
Selection(which) std::vector<AtlasGridPoint> 0 = revealed Rite-line maps, 1 = picked anchors
GetLineSeed() uint32_t Rite reward-selection seed (0 = none)
Weights() std::vector<AtlasWeight> Raw eligibility-weight rows (key, value)
GetNodeName(uiAddress) std::string Node display name ("" when unresolved)

SekhemaService

Method Returns Purpose
GetPanel() uintptr_t Host-resolved trial panel; 0 = absent
ProbeFloor(uiAddress) int FloorData layer count (0 = not a trial floor); cheap pre-filter
GetFloor(panel) SekhemaFloor Floor header (layerCount, roomCounts, choices, counter)
Rooms(panel) std::vector<SekhemaRoom> Static room graph in (layer, index) order
Content(panel) std::vector<SekhemaContentEntry> Per-room content FK rows (rowId / rowName)
GetRoomUsedFlag(sm) int 1=used, 0=active, -1=unreadable
GetStateMachineValue(sm, i, out) bool One shared-state value (define order)
GetUiStringId(uiAddress) std::string UI element StringId as UTF-8 (numeric HUD field)

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

25. 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 one examples/*.h per SDK area (Buffs, Entities, Inventory, Flasks, Memory, UI Explorer, Component Reader, Render, Terrain, Events, Log, Skills, and Prices) plus a coverage summary banner. Read this when you want to see how a service is used, in context — e.g. the Prices tab is the canonical price-fetching demo (§14).

  • 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/ — poe2scout price overlay. Prices inventory + ground items through the shared host price service (ctx()->Prices, §14) — no network code of its own — plus a per-Runeshape rewards window (ctx()->Runeshape, §15). Shows real-world inventory iteration and pricing built on host data services.


← Home

Clone this wiki locally