-
Notifications
You must be signed in to change notification settings - Fork 0
Plugin Development Guide
POEFixer plugins are native C++ DLLs loaded at runtime from Plugins/<PluginName>/<PluginName>.dll. They read live game state, draw ImGui overlays, persist their own settings, and subscribe to host events.
The plugin SDK has a three-layer architecture:
Plugin DLL ───► PluginSDK.h (header-only C++ wrapper, owns std::string/vector/function)
│
▼ inline function-pointer calls only
HostAbi (pure-C ABI, POD structs only)
│
▼ SEH-wrapped on the host side
Host bridge: plugin_manager/bridge/Bridge_<Service>.cpp (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 fromPluginAbi.hunderneath. You can mention the latter exists; you almost never look at it. - All
std::*containers live inside the plugin DLL. Only POD crosses the host boundary. This means a plugin built with one toolchain version can't get tangled up with the host's STL — the only shared types are integers, floats, pointers, and small structs.
Find the SDK headers at:
-
POEFixer/plugin_sdk/PluginSDK.h— the C++ wrapper plugin authors use. -
POEFixer/plugin_sdk/PluginAbi.h— the pure-C ABI underneath.
Reference plugins shipped with the repo (read these as documentation): Plugins/ExamplePlugin/, Plugins/Radar/, Plugins/KillCount/, Plugins/NinjaPricer/.
Minimal plugin that loads and prints a message in the host log:
#define PLUGIN_EXPORTS
#include "POEFixer/plugin_sdk/PluginSDK.h"
class HelloPlugin : public PluginSDK::Plugin {
public:
const char* GetName() const override { return "Hello"; }
void OnEnable(bool) override { ctx()->Log.Info("Hello, world"); }
};
extern "C" PLUGIN_API PluginSDK::Plugin* CreatePlugin() { return new HelloPlugin(); }
extern "C" PLUGIN_API void DestroyPlugin(PluginSDK::Plugin* p) { delete p; }Build as Plugins/Hello/Hello.dll, restart the host, enable from the Plugins tab.
Use Plugins/ExamplePlugin/ExamplePlugin.vcxproj as the canonical template. The essential settings:
- Configuration type: DynamicLibrary
- Platform toolset: v143 (Visual Studio 2022)
- Character set: Unicode
-
Language standard:
stdcpp20 -
Runtime library:
MultiThreadedDLL(Release) /MultiThreadedDebugDLL(Debug). MUST match the host. -
Preprocessor definitions:
PLUGIN_EXPORTS;NDEBUG;_WINDOWS;_USRDLL;_CRT_SECURE_NO_WARNINGS -
Additional include directories:
$(SolutionDir)POEFixer -
Output directory:
$(SolutionDir)x64\Release\Plugins\<YourPlugin>\ -
Target name: must match the folder name (
Plugins/MyPlugin/→MyPlugin.dll)
The host scans each subfolder in Plugins/ and looks for <FolderName>.dll. The DLL must export three symbols:
-
CreatePlugin— factory; returnsPluginSDK::Plugin*. -
DestroyPlugin— destructor; takesPluginSDK::Plugin*. -
PluginSDK_AttachHost— wires up theContext. Defined for you insidePluginSDK.hand emitted automatically whenPLUGIN_EXPORTSis set.
Recommended source layout:
Plugins/YourPlugin/
YourPlugin.vcxproj
YourPlugin.vcxproj.filters
src/
YourPlugin.cpp // class YourPlugin : public PluginSDK::Plugin
YourPluginSettings.h // Save()/Load() POCO
config/ // runtime-created by SaveSettings()
settings.json
Directory() returns an absolute, UTF-8 path rooted at the host EXE directory — don't prepend the EXE path yourself. The directory string is owned by value inside PluginSDK::Plugin, so it survives the host's container reallocations and reload cycles without lifetime concerns.
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));PluginSDK::Plugin is a virtual base class. Override these in your plugin (in approximate call order):
| Method | Called when | Typical use |
|---|---|---|
const char* GetName() const |
Once, right after construction | Return your plugin's display name |
void OnEnable(bool isGameAttached) |
When the user enables the plugin (or at startup if persisted) | Load settings, subscribe to events, attach ImGui context |
void DrawSettings() |
Every frame while the plugin's settings panel is open | ImGui controls for your config |
void DrawUI() |
Every frame while the plugin is enabled | ImGui overlay drawing (use ImGui::GetBackgroundDrawList() for game overlay) |
bool WantsOverlay() const |
Polled every frame | Return true if you want the host in overlay (click-through) mode |
void SaveSettings() |
Periodically (~5s) and on disable | Persist config to disk |
void OnDisable() |
When the user disables, or on host shutdown | Free resources, unsubscribe events |
Only GetName is mandatory; the rest have safe defaults.
The host also calls GetSDKVersion() (defined on the base, do NOT override) immediately after CreatePlugin to verify the plugin and host agree. Mismatch → plugin refused.
ctx() returns const PluginSDK::Context*, an aggregate of 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.
ctx()->Game.GetSnapshot() returns a value-typed Snapshot — a full immutable view of the current frame. GetSnapshot() walks abi->entities.enumerate and populates snap.Entities before returning, so the cost scales with nearby-entity count. Call it once per frame and reuse.
PluginSDK::Snapshot snap = ctx()->Game.GetSnapshot();
if (snap.State != PluginSDK::GameState::InGame) return;
ctx()->Log.Info(snap.CurrentAreaName.c_str());
if (snap.IsTown || snap.IsHideout) return; // safe area
// snap.Vitals.HPPercent, snap.Vitals.MaxES, snap.Vitals.IsPaused
// snap.Player.GridPositionX, snap.Player.Path (wstring), snap.Player.Components
// snap.Entities is a std::vector<Entity> — every nearby entity, fully populated
// snap.LargeMap / snap.MiniMap — visibility + projection inputs
// snap.AreaChangeCounter — increments each portal transitionWhat the snapshot carries directly (no further service calls needed):
- State + flags:
State,IsAttached,IsWindowValid,GameWindowForeground,IsTown,IsHideout,IsPaused,IsSkillTreeVisible. - Area:
CurrentAreaName,CurrentAreaHash,CurrentAreaLevel,AreaChangeCounter. - World:
Player(fullEntity),Entities(fullstd::vector<Entity>),Vitals,LargeMap,MiniMap,WorldToScreenMatrix[16]. - Window:
ScreenWidth,ScreenHeight,ProcessId,GameWindow,LastUpdateTime,WorldToGridConvertor.
What's not on the snapshot — fetch via services: inventory contents (InventoryService), buffs (ComponentsService::EnumerateBuffs), per-item mod lists (InventoryService::ReadItemMods), UI panels (UiService).
Cheap helpers when you don't need a full snapshot:
if (ctx()->Game.IsInGame()) { ... }
if (ctx()->Game.IsForeground()) { ... } // game window focused
if (ctx()->Game.IsOverlayMode()) { ... } // host is in overlay (click-through)
if (ctx()->Game.IsMenuVisible()) { ... } // ESC menu, settings, etc.
auto sz = ctx()->Game.GetScreenSize(); // ScreenSize { Width, Height } floats
HWND hw = ctx()->Game.GetGameWindow();
DWORD pid = ctx()->Game.GetProcessId();
PluginSDK::GameState st = ctx()->Game.GetState();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.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 spawn — EnumerateMonsterMods 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).
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
}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 0is 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.
-
StatIdis the Stats.dat row index + 1 (the engine's runtime stat key;0is the game's "no stat" sentinel). Resolve names by dumpingStats.dat. -
Valueis a rawint32; 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. -
EnumerateActiveSkillsreturns two entries per skill name (different evaluation contexts, e.g. weapon sets) — query both if you're hunting a specific stat.
Every Entity (including snap.Player and members of snap.Entities) carries the same set of fields:
| Group | Fields |
|---|---|
| Identity |
Id, Address, EntityDetailsAddress, RenderComponentAddress, IsValid
|
| Classification |
EntityType, EntitySubtype, EntityState, Rarity, Reaction, Zone (NearbyZone: InnerCircle≈60 / OuterCircle≈120 / Far) |
| Position |
GridPositionX, GridPositionY, TerrainHeight, WorldX/Y/Z, ModelBoundsZ
|
| Quick vitals |
CurrentHP, MaxHP, CurrentES, MaxES (avoids a ReadLife if you only need the totals) |
| Strings |
Path (std::wstring, Metadata/...), PlayerName (std::wstring), TgtPath (std::string, asset path) |
| State |
IsSleeping, IsChestOpened
|
| Components |
Components (ComponentAddresses sub-struct) |
If you need to track one entity across frames (e.g. a chest the player is opening) and don't want to scan the full entity list each frame, register a watch:
ctx()->Entities.Watch(entityId);
// ...later:
if (auto opt = ctx()->Entities.GetWatchedComponents(entityId)) {
PluginSDK::ComponentAddresses comps = *opt;
PluginSDK::Life l = ctx()->Components.ReadLife(comps.Life);
}
bool active = ctx()->Entities.IsWatched(entityId);
ctx()->Entities.Unwatch(entityId);FindById(id) returns std::optional<Entity> for one-shot lookups, and GetPlayer() always returns the local player.
Items dropped on the ground appear in snap.Entities as EntityType::Item entities at path Metadata/MiscellaneousObjects/WorldItem. These are container entities — they don't carry Mods / Base / Stack / Sockets directly. The real item entity lives one indirection away.
To get the inner item entity as a regular Entity snapshot, use Entities.GetWorldItemInner:
for (const auto& e : snap.Entities) {
if (e.EntityType != PluginSDK::EntityType::Item) continue;
auto inner = ctx()->Entities.GetWorldItemInner(e.Address);
if (!inner) continue; // mid-spawn, retry next frame
// inner->Path — "Metadata/Items/Armours/Gloves/..."
// inner->Components — Mods / Base / Stack / Sockets / etc.
PluginSDK::Mods mods = ctx()->Components.ReadMods(inner->Components.Mods);
int iLvl = mods.ItemLevel;
int rarity = mods.Rarity;
}GetWorldItemInner only succeeds on real WorldItem containers — calling it with an inventory item address returns std::nullopt. If you want the same data shape as for inventory items (without manually walking components), the Inventory.ReadItem* family in the next section auto-resolves WorldItem containers transparently.
ctx()->Inventory.Scan(inventoryId) triggers a host-side rescan. Use -1 to scan all inventories.
ctx()->Inventory.Scan(-1);
std::vector<PluginSDK::Inventory> all = ctx()->Inventory.GetAll();
for (const auto& inv : all) {
const char* name = ctx()->Inventory.GetName(inv.InventoryId);
ctx()->Log.Info(name);
for (const auto& item : inv.Items) {
ctx()->Log.Info(item.BaseTypeName.c_str());
// item.SlotX, item.SlotY, item.Width, item.Height (grid metrics)
// item.Rarity, item.ItemLevel, item.RequiredLevel, item.CraftedModCount
// item.IsIdentified, item.IsCorrupted, item.IsCurrency
// item.Path (Metadata/Items/...), item.BaseTypeName, item.UniqueName
// item.Address — entity address for direct lookups below
}
}Each Inventory also exposes a Grid struct describing where the inventory is drawn on screen:
if (inv.Grid.Valid) {
float originX = inv.Grid.GridScreenX;
float originY = inv.Grid.GridScreenY;
float cell = inv.Grid.CellSize;
// Slot (x, y) screen-space top-left = (originX + x*cell, originY + y*cell)
}To grab a single inventory by id (returns the same struct with Items already populated):
PluginSDK::Inventory backpack = ctx()->Inventory.Get(/*inventoryId=*/0);Or if you only want the item vector without the wrapping struct:
std::vector<PluginSDK::InventoryItem> items = ctx()->Inventory.GetItems(0);ComponentsService::ReadMods(addr) returns only summary flags (IsCorrupted, IsRelic, IsSplit, IsMirrored, IsSynthesised, IsIdentified, Rarity, ItemLevel, RequiredLevel, CraftedModCount). It does not carry the per-kind mod lists.
For the full picture (summary + mod lists), use InventoryService::ReadItemMods(entityAddr):
PluginSDK::ItemMods im = ctx()->Inventory.ReadItemMods(item.Address);
if (!im.Valid) return;
// Same summary fields as the Mods component, plus:
for (const auto& m : im.ImplicitMods) { ... } // std::vector<Mod>
for (const auto& m : im.ExplicitMods) { ... }
for (const auto& m : im.EnchantMods) { ... }
for (const auto& m : im.HellscapeMods) { ... }
for (const auto& m : im.CrucibleMods) { ... }Other direct per-entity reads (cheaper than rescanning when you already hold an item address):
int rarity = ctx()->Inventory.ReadItemRarity(item.Address);
int stack = ctx()->Inventory.ReadItemStackCount(item.Address);
std::string base = ctx()->Inventory.ReadItemBaseTypeName(item.Address);
std::string uniq = ctx()->Inventory.ReadItemUniqueName(item.Address);
std::string path = ctx()->Inventory.ReadItemPath(item.Address);Ground items via the inventory API. All seven Inventory.ReadItem* reads above (and ReadItemMods) accept BOTH inventory-item addresses AND WorldItem container addresses. Container addresses are auto-resolved to the inner item before reading, so the same plugin code path works for items in bags and items on the ground:
// `addr` may be either an inventory item address or a WorldItem container.
PluginSDK::ItemMods im = ctx()->Inventory.ReadItemMods(addr);
int rarity = ctx()->Inventory.ReadItemRarity(addr);
std::string baseName = ctx()->Inventory.ReadItemBaseTypeName(addr);If you need the inner item's component addresses directly (e.g. to call ctx()->Components.ReadStack(...) or walk sockets), use Entities.GetWorldItemInner from section 7 instead.
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.
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.
The game's UI tree is exposed as uintptr_t element addresses. Start from a root, walk children, read element fields.
The clean way to find a known panel by its StringId:
uintptr_t gameUiRoot = ctx()->Ui.GetGameUiRoot();
uintptr_t invPanel = ctx()->Ui.FindPanelByStringId(gameUiRoot, "Inventory");
if (invPanel && ctx()->Ui.IsVisible(invPanel)) {
// panel is on-screen
}Manual tree walking when you don't know the StringId:
uintptr_t root = ctx()->Ui.GetUiRoot();
PluginSDK::UiElement e = ctx()->Ui.Read(root);
ctx()->Log.Info(("children=" + std::to_string(e.ChildCount)).c_str());
for (uintptr_t child : ctx()->Ui.GetChildren(root)) {
std::string sid = ctx()->Ui.GetStringId(child);
if (sid == "InventoriesPanel") { /* found it */ }
}
// Or use a known index path:
int path[] = { 5, 1, 2, 0 };
uintptr_t logInButton = ctx()->Ui.FollowPath(root, path, 4);
// Compute screen-space rect (post-scale, post-transform):
float x, y, w, h;
if (ctx()->Ui.ComputeScreenRect(invPanel, x, y, w, h)) {
// draw an overlay box at (x,y,w,h)
}
// Get displayed text:
std::string label = ctx()->Ui.GetText(child);
int cull = ctx()->Ui.GetCullValue(); // host's UI cull thresholdStringId values are stable game-side identifiers; prefer them over hardcoded paths when they exist.
0.5.x note.
Ui.GetStringId()returns the correct identifier on current (0.5.x) clients — the element'sStringIdfield offset moved (0x448→0x4C0) and the host bridge was corrected to match. (For the numeric StringId that trial-HUD field-leaves render their values into — a different field fromGetText()—SekhemaHelperreadsctx()->Sekhema.GetUiStringId().)
Three projection helpers, two coordinate systems.
Perspective (3D world → screen) — same projection the game uses to draw things in the world. Good for nameplates, debug markers, target indicators:
float sx, sy;
if (ctx()->Render.WorldToScreen(e.WorldX, e.WorldY, e.WorldZ, sx, sy)) {
ImGui::GetBackgroundDrawList()->AddCircleFilled({sx, sy}, 4.f, IM_COL32(255,0,0,255));
}Isometric (grid → minimap) — for radar-style overlays drawn on the large or mini map. These respect the visible map's zoom, pan, and rotation:
if (!snap.LargeMap.IsVisible) return;
for (const auto& e : snap.Entities) {
float sx, sy;
if (ctx()->Render.GridToLargeMap(e.GridPositionX, e.GridPositionY, e.TerrainHeight, sx, sy)) {
ImGui::GetBackgroundDrawList()->AddCircleFilled({sx, sy}, 4.f, color);
}
}
// Mirror: ctx()->Render.GridToMiniMap(gx, gy, worldZ, sx, sy)For batched math (skip per-entity function calls), grab the transform once and do the projection inline:
PluginSDK::MapTransform t = ctx()->Render.GetLargeMapTransform();
if (t.IsVisible) {
// dx = gx - t.PlayerGridX; dy = gy - t.PlayerGridY;
// sx = t.CenterX + (dx - dy) * t.ScaleX;
// sy = t.CenterY + (worldZ * worldToGrid - (dx + dy)) * t.ScaleY;
}
// And ctx()->Render.GetMiniMapTransform() for the minimap.See Plugins/Radar/src/Radar.cpp for a working radar built entirely on these calls.
The walkable grid is a 4-bit-per-tile bitmap of which terrain cells the player can step on. The host updates it on each area change; plugins receive a stable handle that survives until the plugin releases it (via RAII).
PluginSDK::WalkableGridHandle h = ctx()->Terrain.GetWalkableGrid();
if (h.Valid()) {
const uint8_t* data = h.Data();
const int w = h.Width();
const int height = h.Height();
const size_t sizeBytes = h.SizeBytes(); // (w * height) / 2
// POE2 packs two cells per byte:
// gx & 1 == 0 → low nibble (data[gy * (w/2) + gx/2] & 0x0F)
// gx & 1 == 1 → high nibble ((data[gy * (w/2) + gx/2] >> 4) & 0x0F)
// Non-zero nibble = walkable.
//
// Always bound your byte index against sizeBytes — the host enforces an
// atomic snapshot, but defense-in-depth has caught at least one real bug.
}
// h is RAII — destructor releases the host reference automatically.HeightGridHandle mirrors the same shape but holds one float per tile (Data() is const float*, plus ElementCount() and SizeBytes()).
Don't subscribe to OnAreaChange to refresh the handle. The event fires when the host worker detects an area change, but the new walkable grid may not have been parsed yet — you'd hold a stale pointer for one or two frames. Instead, poll per frame in DrawUI:
auto current = ctx()->Terrain.GetWalkableGrid();
if (current.Data() != m_walkable.Data()) {
m_walkable = std::move(current); // swap when the host re-parses
}This is cheap (one ABI call + one pointer compare). See Plugins/Radar/src/Radar.cpp for the production version.
Other terrain accessors:
bool ok = ctx()->Terrain.IsWalkable(gx, gy);
float worldZ = ctx()->Terrain.GetTerrainHeight(gx, gy);
float worldToG = ctx()->Terrain.GetWorldToGridConvertor();
ctx()->Terrain.EnumerateTgtLocations([](const PluginSDK::TgtLocation& loc) {
// loc.Path, loc.TileX, loc.TileY, loc.X, loc.Y
return true; // continue
});Subscribe to host-emitted events. Each Subscribe returns a Token you can later pass to Unsubscribe. The EventsService destructor (fired when the plugin disables or unloads) auto-releases anything still outstanding — so you don't strictly need to unsubscribe manually, but it's polite.
class MyPlugin : public PluginSDK::Plugin {
PluginSDK::EventsService::Token m_areaTok{};
PluginSDK::EventsService::Token m_frameTok{};
public:
void OnEnable(bool) override {
auto& ev = const_cast<PluginSDK::EventsService&>(ctx()->Events);
m_areaTok = ev.OnAreaChange([this]{
ctx()->Log.Info("area changed");
});
m_frameTok = ev.OnFrame([this]{
// called every frame; keep work cheap
});
ev.OnGameAttached([this]{ ctx()->Log.Info("game attached"); });
ev.OnGameDetached([this]{ ctx()->Log.Info("game detached"); });
}
void OnDisable() override {
auto& ev = const_cast<PluginSDK::EventsService&>(ctx()->Events);
ev.Unsubscribe(m_areaTok);
ev.Unsubscribe(m_frameTok);
}
};The four event kinds are AreaChange, Frame, GameAttached, GameDetached. There's also a generic Subscribe(EventKind, callback) if you'd rather build a dispatch table.
The const_cast is required because Events mutates its internal token map. The base class returns const Context* to discourage mutating other services accidentally.
If your plugin owns several subscriptions, the ExamplePlugin pattern is a clean way to keep enable/disable symmetric — bundle tokens and counters into a single state struct and route everything through one SubscribeAll/UnsubscribeAll pair:
struct EventsDemoState {
std::atomic<int> frameCount{0}, areaChangeCount{0};
PluginSDK::EventsService::Token frameTok{}, areaTok{};
bool subscribed = false;
};
void SubscribeAll(const PluginSDK::Context* ctx, EventsDemoState& s) {
auto& ev = const_cast<PluginSDK::EventsService&>(ctx->Events);
s.frameTok = ev.OnFrame ([&s]{ s.frameCount.fetch_add(1); });
s.areaTok = ev.OnAreaChange ([&s]{ s.areaChangeCount.fetch_add(1); });
s.subscribed = true;
}
void UnsubscribeAll(const PluginSDK::Context* ctx, EventsDemoState& s) {
auto& ev = const_cast<PluginSDK::EventsService&>(ctx->Events);
ev.Unsubscribe(s.frameTok);
ev.Unsubscribe(s.areaTok);
s.frameTok = {}; s.areaTok = {};
s.subscribed = false;
}See Plugins/ExamplePlugin/examples/ExampleEvents.h for the full pattern.
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.
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.
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.
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.
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);
}
};- 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.
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.
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) |
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.
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.
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.
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) |
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.
ctx()->Log.Debug("verbose detail");
ctx()->Log.Info ("normal status");
ctx()->Log.Warn ("something unexpected");
ctx()->Log.Error("operation failed");
ctx()->Log.Log ("custom-level", "message");All four levels route to the host's central logger. Messages appear in the host's Logs tab and in the on-disk log file. Format yourself before calling; the host doesn't accept printf-style varargs.
Internally the convenience methods emit the strings "Debug", "Info", "Warning", and "Error" (Warn maps to "Warning"). The host bridge does a case-insensitive match, so a plugin calling Log("warn", "msg") still routes correctly — but the convenience methods are clearer.
Direct memory primitives. Prefer the high-level services wherever possible — they understand offsets, handle ABI changes, and are SEH-safe. Direct memory reads are appropriate only when no higher-level call exists for what you need.
// Read a fixed-size value:
uint64_t value = 0;
ctx()->Memory.Read(addr, &value, sizeof(value));
// Read game strings (null-terminated, narrow or wide):
std::string s = ctx()->Memory.ReadString (strAddr);
std::wstring ws = ctx()->Memory.ReadWString(wstrAddr);
// Read a std::wstring container in the game's memory (handles SSO):
std::wstring inner = ctx()->Memory.ReadStdWString(containerAddr);
// Read a std::vector<T>; returns raw bytes you reinterpret_cast:
std::vector<uint8_t> raw = ctx()->Memory.ReadStdVector(vecAddr, sizeof(MyT), /*maxElems=*/1024);
const MyT* items = reinterpret_cast<const MyT*>(raw.data());
size_t count = raw.size() / sizeof(MyT);
// Module info:
uintptr_t base = ctx()->Memory.GetBaseAddress();
uintptr_t sz = ctx()->Memory.GetModuleSize();
uintptr_t pat = ctx()->Memory.GetPatternAddress("GameStates"); // resolves a named patternIf you find yourself reaching for these often, ask whether the data you need belongs in the higher-level services.
Every cross-DLL call between the host and a plugin runs inside an __try / __except block on the host side. A misbehaving plugin that dereferences a stale pointer, divides by zero, or otherwise faults inside an SDK call gets a logged error — the host process does not crash, the game keeps running, and the user can keep using other plugins.
That doesn't mean plugins can be sloppy. SEH catches the symptom, not the cause. If your plugin throws faults on every frame the user sees a flood of error logs and your data is effectively unavailable. Handle null returns from SDK calls, check Valid flags on component data, and don't dereference uintptr_t addresses directly — pass them through the ComponentsService / Ui / Memory calls that already wrap RPM properly.
The host can deal with a buggy plugin. It can't deal with a hung plugin DLL — a DrawSettings that takes 100ms blocks the entire UI thread. Keep per-frame work cheap.
A short list of things plugin authors hit when first integrating. Most of these are documented inline above; collected here as a checklist.
-
OnAreaChangefires before the walkable grid is re-parsed. Don't refreshWalkableGridHandlefrom the event — poll per frame inDrawUIand swap whenData()changes. (§11) -
Entity::Zoneis alwaysNonefor the local player. It's a distance-from-player classification, so the player is by definition at distance zero. Don't show it in player-info displays. -
Components.ReadMods()returns only summary flags — no mod lists. For per-kind mod lists, callInventory.ReadItemMods(entityAddr). (§8) -
Items dropped on the ground can lack
EntitySubtype. If you're filtering for items in the world, preferEntityType == Item || EntityType == Chestover a narrower subtype check. -
Directory()returns an absolute path. Don't prepend the EXE directory yourself — you'll getEXEDIR\EXEDIR\Plugins\Xand your config writes will land outside the plugin folder. -
ctx()returnsconst Context*. Mutating methods likeEventsService::Subscriberequireconst_cast. This is intentional — services that don't mutate should be impossible to mutate by accident. -
ImGui::SetCurrentContextis per-DLL. Call it at every entry point that draws (OnEnable,DrawUI,DrawSettings) because the plugin DLL has its own ImGui state by default. -
The convenience helpers re-read the component each call.
GetHealthPercent(addr)does a freshReadLife(addr)internally. If you already have theLifestruct from a prior call, access its fields directly instead.
One-line summary of every public method on every service. Use the prose sections above for the full type signatures and usage notes.
| Method | Returns | Purpose |
|---|---|---|
GetSnapshot() |
Snapshot |
Full per-frame view, including Entities
|
GetState() |
GameState |
Enum: InGame, Login, Loading, … |
IsAttached() |
bool |
Game process attached |
IsInGame() |
bool |
State == InGame |
IsForeground() |
bool |
Game window has focus |
IsMenuVisible() |
bool |
ESC menu / settings open |
IsOverlayMode() |
bool |
Host is in overlay (click-through) |
GetProcessId() |
DWORD |
Game PID |
GetGameWindow() |
HWND |
Game window handle |
GetScreenSize() |
ScreenSize |
{Width, Height} floats |
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 |
| Method | Returns | Purpose |
|---|---|---|
Enumerate(cb) |
— | Visit every nearby entity (return false to stop) |
GetPlayer() |
Entity |
The local player entity |
FindById(id) |
std::optional<Entity> |
Lookup by entity id |
GetWorldItemInner(addr) |
std::optional<Entity> |
Inner item entity for a WorldItem container (ground items) |
Watch(id) |
— | Pin an entity so its components stay readable |
Unwatch(id) |
— | Release a watch |
IsWatched(id) |
bool |
Watch state |
GetWatchedComponents(id) |
std::optional<ComponentAddresses> |
Read pinned components |
| Method | Returns | Purpose |
|---|---|---|
ReadLife / ReadRender / ReadPositioned / ReadTargetable / ReadChest / ReadShrine / ReadStack / ReadCharges / ReadPlayer / ReadAnimated / ReadTransitionable / ReadTriggerableBlockage / ReadMinimapIcon / ReadStateMachine / ReadBase / ReadMods / ReadStats / ReadBuffs / ReadActor / ReadNpc / ReadDiesAfterTime |
Component struct | 21 readers, one per component type |
EnumerateBuffs(addr) |
std::vector<Buff> |
Active buffs on the entity |
EnumerateActiveSkills(addr) |
std::vector<ActiveSkill> |
Skills from an Actor component (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 |
| 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 |
| Method | Returns | Purpose |
|---|---|---|
Read(addr) |
UiElement |
Element fields (rect, flags, children count) |
GetChildren(addr) |
std::vector<uintptr_t> |
Child element addresses |
GetChildAt(addr, index) |
uintptr_t |
Single child by index |
FollowPath(root, indices, count) |
uintptr_t |
Walk a known index path |
IsVisible(addr) |
bool |
Element is on-screen |
GetStringId(addr) |
std::string |
Game-side stable identifier |
GetText(addr) |
std::string |
Rendered text |
ComputeScreenRect(addr, x, y, w, h) |
bool |
Final screen-space rect |
GetGameUiRoot() |
uintptr_t |
Root of the in-game UI |
GetUiRoot() |
uintptr_t |
Top-level UI root |
GetCullValue() |
int |
Host's UI cull threshold |
FindPanelByStringId(parent, stringId) |
uintptr_t |
Targeted descendant lookup |
| Method | Returns | Purpose |
|---|---|---|
WorldToScreen(wx, wy, wz, sx, sy) |
bool |
Perspective projection |
GridToLargeMap(gx, gy, worldZ, sx, sy) |
bool |
Project to the large map overlay |
GridToMiniMap(gx, gy, worldZ, sx, sy) |
bool |
Project to the minimap |
GetLargeMapTransform() |
MapTransform |
Pre-multiplied transform for batched math |
GetMiniMapTransform() |
MapTransform |
Same, for the minimap |
| Method | Returns | Purpose |
|---|---|---|
GetWalkableGrid() |
WalkableGridHandle |
RAII handle to the 4-bit-per-tile walkable bitmap |
GetHeightGrid() |
HeightGridHandle |
RAII handle to the per-tile terrain heights |
IsWalkable(gx, gy) |
bool |
Single-tile predicate |
GetTerrainHeight(gx, gy) |
float |
World-space Z |
GetWorldToGridConvertor() |
float |
World → grid conversion factor |
EnumerateTgtLocations(cb) |
— | Visit every TGT instance in the current area |
| Method | Returns | Purpose |
|---|---|---|
Read(addr, buf, size) |
bool |
Raw RPM |
ReadString(addr) |
std::string |
Null-terminated narrow string |
ReadWString(addr) |
std::wstring |
Null-terminated wide string |
ReadStdWString(addr) |
std::wstring |
Reads a game-side std::wstring container (handles SSO) |
ReadStdVector(addr, elemSize, maxElems) |
std::vector<uint8_t> |
Raw bytes; reinterpret as your type |
GetBaseAddress() |
uintptr_t |
Game module base |
GetModuleSize() |
uintptr_t |
Game module size |
GetPatternAddress(name) |
uintptr_t |
Named pattern lookup |
| Method | Purpose |
|---|---|
Debug / Info / Warn / Error(msg) |
Emit at the corresponding level |
Log(level, msg) |
Custom level string |
| Method | Returns | Purpose |
|---|---|---|
Subscribe(kind, cb) |
Token |
Generic dispatch |
OnAreaChange / OnFrame / OnGameAttached / OnGameDetached(cb) |
Token |
One-line subscribe helpers |
Unsubscribe(token) |
— | Manual release (destructor auto-releases anyway) |
| 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).
| 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.
| 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) |
| 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 |
| 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) |
| 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) |
PluginAbi.h defines:
constexpr int PLUGIN_SDK_VERSION = 6;At load time the host calls plugin->GetSDKVersion() and compares against its own PLUGIN_SDK_VERSION. Mismatch → the host logs a warning and refuses to load the plugin.
The host also checks HostAbi::version and HostAbi::size_bytes inside PluginSDK_AttachHost (defined inline by PluginSDK.h when PLUGIN_EXPORTS is set). If either field disagrees with what the plugin was built against, ctx() is non-functional. The base class accessor HostCompatible() reports false in that case, and any plugin that wants to be polite should refuse to act:
void OnEnable(bool) override {
if (!HostCompatible()) {
ctx()->Log.Error("Host ABI mismatch — disable plugin");
return;
}
// ...
}Four plugins in the repo are designed to be read as documentation:
-
Plugins/ExamplePlugin/— broad-surface showcase. One plugin that touches almost every service, organized as oneexamples/*.hper 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 viaRender.GridToLargeMap. Read this when you want to see the minimum code for a particular outcome. -
Plugins/KillCount/— kill/chest/death tracker. SQLite + sprite atlas + per-area state. Shows how to ship persistence, vendored data files, and an overlay all in one DLL. -
Plugins/NinjaPricer/— 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.