-
Notifications
You must be signed in to change notification settings - Fork 0
Plugin Development Guide ES
Los plugins de POEFixer son DLLs nativas en C++ cargadas en tiempo de ejecución desde Plugins/<PluginName>/<PluginName>.dll. Leen el estado del juego en vivo, dibujan superposiciones con ImGui, persisten su propia configuración y se suscriben a eventos del anfitrión.
El SDK de plugins tiene una arquitectura de tres capas:
Plugin DLL ───► PluginSDK.h (header-only C++ wrapper, owns std::string/vector/function)
│
▼ inline function-pointer calls only
HostAbi (pure-C ABI, POD structs only)
│
▼ SEH-wrapped on the host side
Host bridge: plugin_manager/bridge/Bridge_<Service>.cpp (10 files)
│
▼
GameClient + GameLibrary
- Los autores de plugins incluyen exactamente una cabecera:
POEFixer/plugin_sdk/PluginSDK.h. - Esa cabecera declara todo dentro del espacio de nombres
PluginSDK::e importa internamente la ABI de C desdePluginAbi.h. Puedes mencionar que la última existe; casi nunca tendrás que mirarla. - Todos los contenedores
std::*viven dentro de la DLL del plugin. Solo POD cruza la frontera del anfitrión. Esto significa que un plugin compilado con una versión de toolchain no puede enredarse con la STL del anfitrión — los únicos tipos compartidos son enteros, flotantes, punteros y pequeñas estructuras.
Encuentra las cabeceras del SDK en:
-
POEFixer/plugin_sdk/PluginSDK.h— el envoltorio C++ que utilizan los autores de plugins. -
POEFixer/plugin_sdk/PluginAbi.h— la ABI pura en C que está por debajo.
Plugins de referencia incluidos con el repositorio (léelos como documentación): Plugins/ExamplePlugin/, Plugins/Radar/, Plugins/KillCount/, Plugins/NinjaPricer/.
Plugin mínimo que se carga e imprime un mensaje en el registro del anfitrión:
#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; }Compílalo como Plugins/Hello/Hello.dll, reinicia el anfitrión y habilítalo desde la pestaña Plugins.
Usa Plugins/ExamplePlugin/ExamplePlugin.vcxproj como plantilla canónica. Los ajustes esenciales:
- Tipo de configuración: DynamicLibrary
- Conjunto de herramientas de plataforma: v143 (Visual Studio 2022)
- Conjunto de caracteres: Unicode
-
Estándar del lenguaje:
stdcpp20 -
Biblioteca en tiempo de ejecución:
MultiThreadedDLL(Release) /MultiThreadedDebugDLL(Debug). DEBE coincidir con el anfitrión. -
Definiciones del preprocesador:
PLUGIN_EXPORTS;NDEBUG;_WINDOWS;_USRDLL;_CRT_SECURE_NO_WARNINGS -
Directorios de inclusión adicionales:
$(SolutionDir)POEFixer -
Directorio de salida:
$(SolutionDir)x64\Release\Plugins\<YourPlugin>\ -
Nombre del objetivo: debe coincidir con el nombre de la carpeta (
Plugins/MyPlugin/→MyPlugin.dll)
El anfitrión escanea cada subcarpeta en Plugins/ y busca <FolderName>.dll. La DLL debe exportar tres símbolos:
-
CreatePlugin— fábrica; devuelvePluginSDK::Plugin*. -
DestroyPlugin— destructor; recibePluginSDK::Plugin*. -
PluginSDK_AttachHost— conecta elContext. Definido por ti dentro dePluginSDK.hy emitido automáticamente cuando se establecePLUGIN_EXPORTS.
Disposición recomendada del código fuente:
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() devuelve una ruta absoluta UTF-8 enraizada en el directorio del EXE del anfitrión — no antepongas tú mismo la ruta del EXE. La cadena del directorio se almacena por valor dentro de PluginSDK::Plugin, así que sobrevive a las reasignaciones de contenedores del anfitrión y a los ciclos de recarga sin problemas de tiempo de vida.
Si quieres dibujar con ImGui, añade también esto a <ClCompile> (el anfitrión también los enlaza, pero ImGui del lado del plugin es por DLL):
..\..\POEFixer\imgui\imgui.cpp
..\..\POEFixer\imgui\imgui_draw.cpp
..\..\POEFixer\imgui\imgui_tables.cpp
..\..\POEFixer\imgui\imgui_widgets.cpp
En OnEnable, conéctate al contexto ImGui del anfitrión:
if (ctx()->ImGuiContext)
ImGui::SetCurrentContext(static_cast<ImGuiContext*>(ctx()->ImGuiContext));PluginSDK::Plugin es una clase base virtual. Sobrescribe estos métodos en tu plugin (en orden aproximado de llamada):
| Método | Cuándo se llama | Uso típico |
|---|---|---|
const char* GetName() const |
Una vez, justo después de la construcción | Devuelve el nombre para mostrar de tu plugin |
void OnEnable(bool isGameAttached) |
Cuando el usuario habilita el plugin (o al inicio si está persistido) | Cargar configuración, suscribirse a eventos, adjuntar contexto ImGui |
void DrawSettings() |
Cada frame mientras el panel de configuración del plugin está abierto | Controles ImGui para tu configuración |
void DrawUI() |
Cada frame mientras el plugin está habilitado | Dibujado de superposición ImGui (usa ImGui::GetBackgroundDrawList() para superposición del juego) |
bool WantsOverlay() const |
Consultado cada frame | Devuelve true si quieres el anfitrión en modo superposición (transparente al click) |
void SaveSettings() |
Periódicamente (~5s) y al deshabilitar | Persistir configuración a disco |
void OnDisable() |
Cuando el usuario deshabilita, o al apagar el anfitrión | Liberar recursos, desuscribir eventos |
Solo GetName es obligatorio; el resto tiene valores predeterminados seguros.
El anfitrión también llama a GetSDKVersion() (definido en la base, NO sobrescribir) inmediatamente después de CreatePlugin para verificar que el plugin y el anfitrión están de acuerdo. Si no coinciden → plugin rechazado.
ctx() devuelve const PluginSDK::Context*, un agregado de 14 servicios:
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
void* ImGuiContext; // pass to ImGui::SetCurrentContext
void* D3DDevice; // ID3D11Device* for texture loading
};De un vistazo — para qué sirve cada servicio:
| Servicio | Para qué lo usas |
|---|---|
GameService |
Snapshot, banderas de estado, información de pantalla y ventana |
EntitiesService |
Enumerar, buscar por id, observar el ciclo de vida |
ComponentsService |
21 lectores + 4 enumeradores + ~10 ayudantes de conveniencia |
InventoryService |
Escanear, enumerar, leer mods por ítem |
UiService |
Recorrer el árbol, FindPanelByStringId, ComputeScreenRect
|
RenderService |
WorldToScreen, GridTo{Large,Mini}Map, transformaciones |
TerrainService |
Rejillas de caminabilidad y altura (RAII), ubicaciones TGT |
MemoryService |
Primitivas RPM — solo cuando no hay una llamada de mayor nivel adecuada |
LogService |
Debug / Info / Warn / Error
|
EventsService |
Subscribe / Unsubscribe / On{Area,Frame,Attach,Detach}
|
OverlayService |
SetIncludeSleepingEntities / SetWantsOverlayInput — compatible con el selector de mapa |
FlasksService |
Frascos de vida/maná + amuletos — cargas, Usable, Active, uso por aplicación, cantidad de mods |
PricesService |
LookupPrice / GetRates / GetStatus — precios poe2scout cargados por el anfitrión, compartidos por todos los plugins |
RuneshapeService |
Runeshapes / Rewards — dispositivos Expedition2Encounter resueltos + recompensas por dispositivo |
ctx() es válido desde el momento en que el anfitrión llama a OnEnable hasta que OnDisable retorna. No almacenes en caché ctx() entre recargas en caliente o fronteras de descarga de DLL.
ctx()->Game.GetSnapshot() devuelve un Snapshot de tipo por valor — una vista inmutable completa del frame actual. GetSnapshot() recorre abi->entities.enumerate y rellena snap.Entities antes de retornar, por lo que el costo escala con el número de entidades cercanas. Llámalo una vez por frame y reutilízalo.
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 transitionLo que el snapshot lleva directamente (sin necesidad de más llamadas a servicios):
- Estado y banderas:
State,IsAttached,IsWindowValid,GameWindowForeground,IsTown,IsHideout,IsPaused,IsSkillTreeVisible. - Área:
CurrentAreaName,CurrentAreaHash,CurrentAreaLevel,AreaChangeCounter. - Mundo:
Player(Entitycompleto),Entities(std::vector<Entity>completo),Vitals,LargeMap,MiniMap,WorldToScreenMatrix[16]. - Ventana:
ScreenWidth,ScreenHeight,ProcessId,GameWindow,LastUpdateTime,WorldToGridConvertor.
Lo que no está en el snapshot — recupéralo vía servicios: contenido del inventario (InventoryService), buffs (ComponentsService::EnumerateBuffs), listas de mods por ítem (InventoryService::ReadItemMods), paneles de UI (UiService).
Ayudantes económicos para cuando no necesitas un snapshot completo:
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();Las entidades exponen sus componentes a través de entity.Components — una estructura ComponentAddresses de direcciones uintptr_t. Pasa cada dirección al ComponentsService::Read* correspondiente para obtener una instantánea de tipo por valor.
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");
}
}Hay 21 lectores de componentes: ReadLife, ReadRender, ReadPositioned, ReadTargetable, ReadChest, ReadShrine, ReadStack, ReadCharges, ReadPlayer, ReadAnimated, ReadTransitionable, ReadTriggerableBlockage, ReadMinimapIcon, ReadStateMachine, ReadBase, ReadMods, ReadStats, ReadBuffs, ReadActor, ReadNpc, ReadDiesAfterTime.
ComponentAddresses en sí mismo contiene 24 ranuras: los 21 anteriores más tres marcadores (Buffs, WorldItem, AreaTransition) y OMP (interno del anfitrión). Buffs es un marcador de presencia — la lista real de buffs proviene de EnumerateBuffs. WorldItem / AreaTransition son marcadores de tipo de entidad en lugar de componentes reales. Todas las ranuras tienen predicados HasX() correspondientes en ComponentAddresses.
Lectores estilo colección para componentes con datos de tamaño variable:
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>Ayudantes de conveniencia (de una sola pasada — internamente llaman a Read* por ti):
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)) { ... }Una bandera Valid en cada estructura devuelta te permite manejar "la dirección del componente era 0 / la lectura falló" sin excepciones. Si ya tienes la estructura padre (Life, Mods, …), accede a sus campos directamente en lugar de llamar al ayudante de nuevo — el ayudante vuelve a leer el componente cada vez.
Cada Entity (incluyendo snap.Player y los miembros de snap.Entities) lleva el mismo conjunto de campos:
| Grupo | Campos |
|---|---|
| Identidad |
Id, Address, EntityDetailsAddress, RenderComponentAddress, IsValid
|
| Clasificación |
EntityType, EntitySubtype, EntityState, Rarity, Reaction, Zone (NearbyZone: InnerCircle≈60 / OuterCircle≈120 / Far) |
| Posición |
GridPositionX, GridPositionY, TerrainHeight, WorldX/Y/Z, ModelBoundsZ
|
| Vitales rápidos |
CurrentHP, MaxHP, CurrentES, MaxES (evita un ReadLife si solo necesitas los totales) |
| Cadenas |
Path (std::wstring, Metadata/...), PlayerName (std::wstring), TgtPath (std::string, ruta del asset) |
| Estado |
IsSleeping, IsChestOpened
|
| Componentes |
Components (subestructura ComponentAddresses) |
Si necesitas rastrear una entidad a lo largo de los frames (p. ej. un cofre que el jugador está abriendo) y no quieres escanear la lista completa de entidades en cada frame, registra una observación:
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) devuelve std::optional<Entity> para búsquedas puntuales, y GetPlayer() siempre devuelve el jugador local.
Los ítems tirados al suelo aparecen en snap.Entities como entidades EntityType::Item en la ruta Metadata/MiscellaneousObjects/WorldItem. Son entidades contenedoras — no llevan directamente Mods / Base / Stack / Sockets. La entidad real del ítem está a una indirección de distancia.
Para obtener la entidad interna del ítem como un snapshot Entity regular, usa 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 sólo tiene éxito en contenedores WorldItem reales — llamarlo con una dirección de ítem de inventario devuelve std::nullopt. Si quieres la misma forma de datos que para ítems de inventario (sin recorrer manualmente los componentes), la familia Inventory.ReadItem* de la siguiente sección resuelve los contenedores WorldItem de forma transparente.
ctx()->Inventory.Scan(inventoryId) desencadena un re-escaneo en el lado del anfitrión. Usa -1 para escanear todos los inventarios.
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
}
}Cada Inventory también expone una estructura Grid que describe dónde se dibuja el inventario en pantalla:
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)
}Para obtener un inventario único por id (devuelve la misma estructura con Items ya poblado):
PluginSDK::Inventory backpack = ctx()->Inventory.Get(/*inventoryId=*/0);O si solo quieres el vector de ítems sin la estructura envolvente:
std::vector<PluginSDK::InventoryItem> items = ctx()->Inventory.GetItems(0);ComponentsService::ReadMods(addr) devuelve solo banderas de resumen (IsCorrupted, IsRelic, IsSplit, IsMirrored, IsSynthesised, IsIdentified, Rarity, ItemLevel, RequiredLevel, CraftedModCount). No lleva las listas de mods por tipo.
Para la imagen completa (resumen + listas de mods), utiliza 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) { ... }Otras lecturas directas por entidad (más económicas que re-escanear cuando ya tienes la dirección de un ítem):
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);Ítems en el suelo a través de la API de inventario. Las siete lecturas Inventory.ReadItem* anteriores (y ReadItemMods) aceptan TANTO direcciones de ítems de inventario COMO direcciones de contenedores WorldItem. Las direcciones de contenedores se resuelven automáticamente al ítem interno antes de la lectura, por lo que la misma ruta de código de plugin funciona para ítems en bolsas y para ítems en el suelo:
// `addr` puede ser una dirección de ítem de inventario o un contenedor WorldItem.
PluginSDK::ItemMods im = ctx()->Inventory.ReadItemMods(addr);
int rarity = ctx()->Inventory.ReadItemRarity(addr);
std::string baseName = ctx()->Inventory.ReadItemBaseTypeName(addr);Si necesitas directamente las direcciones de componentes del ítem interno (por ejemplo, para llamar a ctx()->Components.ReadStack(...) o recorrer sockets), usa en su lugar Entities.GetWorldItemInner de la sección 7.
Texto de mod al estilo del juego + estadísticas base / agregadas (v6, 2026-06-24). Formatea cualquier clave de estadística al mismo texto que muestra el tooltip del juego, y lee los valores defensivos base de un ítem y las propiedades agregadas de mapas/piedras guía:
// Renderiza un mod tal como lo muestra el juego ("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` */ }
}
// Valores defensivos base del ítem. EnergyShield es el valor calculado en el juego;
// Ward/Armour/Evasion son los valores base del ítem. Valid == false cuando el ítem
// no tiene componente Armour (monedas, gemas, joyería, piedras guía, ...).
PluginSDK::ItemBaseStats bs = ctx()->Inventory.ReadItemBaseStats(item.Address);
if (bs.Valid) { /* bs.EnergyShield, bs.Ward, bs.Armour, bs.Evasion */ }
// Estadísticas agregadas indexadas por id de stat — por ejemplo, Item Rarity (8205),
// Pack Size (8206), Monster Rarity (8207), Monster Effectiveness (8208),
// Waystone Drop Chance (8209) de una piedra guía.
for (const auto& [statId, value] : ctx()->Inventory.ReadItemAggregatedStats(item.Address)) {
// asigna statId -> etiqueta tú mismo; los valores son porcentajes con signo
}FormatStat utiliza el conjunto de descripciones de estadísticas .csd del anfitrión (descargado en el primer uso), por lo que devuelve una cadena vacía hasta que esos datos estén listos — recurre a los campos crudos de Mod como alternativa. ReadItemBaseStats / ReadItemAggregatedStats aceptan tanto direcciones de ítems de inventario COMO direcciones de contenedores WorldItem.
El árbol de UI del juego se expone como direcciones uintptr_t de elementos. Empieza desde una raíz, recorre los hijos, lee los campos del elemento.
La forma limpia de encontrar un panel conocido por su 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
}Recorrido manual del árbol cuando no conoces el 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 thresholdLos valores StringId son identificadores estables del lado del juego; prefiérelos sobre rutas codificadas a mano cuando existan.
Tres ayudantes de proyección, dos sistemas de coordenadas.
Perspectiva (3D mundo → pantalla) — la misma proyección que usa el juego para dibujar cosas en el mundo. Útil para placas de identificación, marcadores de depuración, indicadores de objetivo:
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));
}Isométrico (rejilla → minimapa) — para superposiciones tipo radar dibujadas en el mapa grande o el minimapa. Estas respetan el zoom, paneo y rotación del mapa visible:
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)Para cálculos por lotes (sin llamadas de función por entidad), obtén la transformación una vez y realiza la proyección en línea:
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.Consulta Plugins/Radar/src/Radar.cpp para un radar funcional construido enteramente sobre estas llamadas.
La rejilla de caminabilidad es un mapa de bits de 4 bits por tile que indica qué celdas del terreno puede pisar el jugador. El anfitrión la actualiza en cada cambio de área; los plugins reciben un handle estable que sobrevive hasta que el plugin lo libera (vía 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 refleja la misma forma pero mantiene un float por tile (Data() es const float*, además de ElementCount() y SizeBytes()).
No te suscribas a OnAreaChange para refrescar el handle. El evento se dispara cuando el worker del anfitrión detecta un cambio de área, pero la nueva rejilla de caminabilidad puede no haber sido parseada todavía — mantendrías un puntero obsoleto durante uno o dos frames. En su lugar, sondea por frame en DrawUI:
auto current = ctx()->Terrain.GetWalkableGrid();
if (current.Data() != m_walkable.Data()) {
m_walkable = std::move(current); // swap when the host re-parses
}Esto es económico (una llamada ABI + una comparación de punteros). Consulta Plugins/Radar/src/Radar.cpp para la versión de producción.
Otros accesores de terreno:
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
});Suscríbete a eventos emitidos por el anfitrión. Cada Subscribe devuelve un Token que más tarde puedes pasar a Unsubscribe. El destructor de EventsService (disparado cuando el plugin se deshabilita o se descarga) libera automáticamente cualquier suscripción pendiente — por lo que estrictamente no necesitas desuscribirte manualmente, pero es cortés hacerlo.
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);
}
};Los cuatro tipos de eventos son AreaChange, Frame, GameAttached, GameDetached. También existe un Subscribe(EventKind, callback) genérico si prefieres construir una tabla de despacho.
El const_cast es necesario porque Events muta su mapa interno de tokens. La clase base devuelve const Context* para desincentivar la mutación accidental de otros servicios.
Si tu plugin posee varias suscripciones, el patrón de ExamplePlugin es una manera limpia de mantener la simetría entre enable/disable — agrupa los tokens y contadores en una sola estructura de estado y enruta todo a través de un único par SubscribeAll/UnsubscribeAll:
struct EventsDemoState {
std::atomic<int> frameCount{0}, areaChangeCount{0};
PluginSDK::EventsService::Token frameTok{}, areaTok{};
bool subscribed = false;
};
void SubscribeAll(const PluginSDK::Context* ctx, EventsDemoState& s) {
auto& ev = const_cast<PluginSDK::EventsService&>(ctx->Events);
s.frameTok = ev.OnFrame ([&s]{ s.frameCount.fetch_add(1); });
s.areaTok = ev.OnAreaChange ([&s]{ s.areaChangeCount.fetch_add(1); });
s.subscribed = true;
}
void UnsubscribeAll(const PluginSDK::Context* ctx, EventsDemoState& s) {
auto& ev = const_cast<PluginSDK::EventsService&>(ctx->Events);
ev.Unsubscribe(s.frameTok);
ev.Unsubscribe(s.areaTok);
s.frameTok = {}; s.areaTok = {};
s.subscribed = false;
}Consulta Plugins/ExamplePlugin/examples/ExampleEvents.h para el patrón completo.
ctx()->Overlay expone dos "marcadores de solicitud" por plugin que cambian el comportamiento global de la superposición. El anfitrión almacena el estado por plugin indexado por tu puntero this y lo agrega con OR a los propios marcadores integrados del anfitrión (visibilidad del menú principal, bloqueo de AutoCraft, selector integrado de Añadir-Entidad-desde-el-Mapa, la solicitud de cada otro plugin). Se limpia automáticamente al deshabilitar/descargar el plugin — un plugin bloqueado o con errores no puede dejar permanentemente la superposición en un estado extraño.
Por defecto, EntitiesService.Enumerate y Snapshot.Entities ocultan las entidades con EntityState::Useless (el filtro "durmiente" del anfitrión que descarta monstruos/NPCs/cofres lejanos inactivos). Esto mantiene acotado el costo de la instantánea por frame — un área típica tiene cientos de entidades Useless que el plugin no necesita.
Para UIs de selector de mapa y visores de depuración que necesitan la reserva completa de entidades del área (para que el usuario pueda hacer clic en una entidad que aún no está activa), desactiva el filtro:
ctx()->Overlay.SetIncludeSleepingEntities(true);
// Ahora ctx()->Entities.Enumerate también ve las entidades Useless.
// Entity::IsSleeping marca específicamente las que provienen de la colección
// SleepingEntities separada del anfitrión (ortogonal a EntityState::Useless).Costo: aproximadamente +5–15% de CPU de instantánea por frame mientras está habilitado. Déjalo DESACTIVADO salvo que lo necesites.
La ventana de superposición es normalmente transparente al click (WS_EX_TRANSPARENT): cada clic del ratón pasa directamente al juego que está por debajo. Este es el comportamiento correcto para superposiciones de solo lectura (radar, barras de vida, lecturas de DPS) — el jugador sigue jugando sin notar la superposición.
En el momento en que tu plugin quiere que el usuario haga clic en algo dentro de la superposición — confirmar un popup, seleccionar una entidad en el mapa, arrastrar un marcador — ese comportamiento predeterminado falla. SetWantsOverlayInput(true) pide al anfitrión que empiece a consumir los clics del ratón donde tus ventanas ImGui los cubren:
ctx()->Overlay.SetWantsOverlayInput(true);
// ...
// Cuando termines (el usuario eligió, cerró el popup, pulsó Escape):
ctx()->Overlay.SetWantsOverlayInput(false);La lógica por frame del anfitrión reclama los clics SOLO donde el cursor está sobre una ventana ImGui visible — en cualquier otro lugar, la transparencia al click se preserva para que el jugador pueda seguir moviéndose/atacando/saqueando alrededor de tu popup.
Alcance (importante):
- Botones del ratón (LMB/RMB): SÍ — controlados por este marcador.
- Posición del ratón / hover: SIEMPRE funciona independientemente. Los tooltips de hover no necesitan este marcador.
-
Teclado: SIEMPRE llega al plugin a través del WindowProc del anfitrión, independientemente de este marcador.
ImGui::IsKeyPressed(ImGuiKey_Escape)funciona de cualquier modo.
Si dibujas marcadores clicables mediante ImGui::GetBackgroundDrawList() (típico para superposiciones de radar / mapa grande), la lista de dibujo de fondo no tiene ninguna ventana ImGui que la respalde. La prueba de impacto del anfitrión recorre ctx->Windows, no encuentra nada bajo el cursor y vuelve a habilitar la transparencia al click — tus marcadores son visibles pero no se pueden clicar.
La solución: abre una ventana ImGui real que cubra tu área de selección y coloca un ImGui::InvisibleButton dentro de ella. La ventana es lo que el anfitrión somete a la prueba de impacto; el InvisibleButton te da ImGui::IsItemClicked() para detectar clics. Esquema:
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();
// Dibuja marcadores mediante ImGui::GetForegroundDrawList() (o 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) {
// el usuario seleccionó este POI
}
}
return true;
});
ImGui::End();Las ventanas más pequeñas que cubren solo la región del selector funcionan igual — al anfitrión no le importa el tamaño, solo que haya alguna ventana bajo el cursor.
class RadarPlugin : public PluginSDK::Plugin {
bool m_pickerMode = false;
void DrawUI() override {
if (!ctx()->Game.IsInGame()) return;
ImGui::SetCurrentContext((ImGuiContext*)ctx()->ImGuiContext);
// Alternar con tecla rápida. El teclado llega al plugin independientemente del estado
// de captura, así que esto funciona incluso cuando la superposición es transparente al click.
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;
// ... UI del selector (consulta la advertencia sobre la lista de dibujo de fondo
// para el patrón ImGui::Begin + InvisibleButton).
}
void OnDisable() override {
// Por precaución. El anfitrión también limpia los marcadores al deshabilitar, pero
// la limpieza explícita mantiene el estado consistente si algún frame síncrono
// se dispara entre OnDisable y la gestión interna del PluginManager.
ctx()->Overlay.SetIncludeSleepingEntities(false);
ctx()->Overlay.SetWantsOverlayInput (false);
}
};- Ambos marcadores son idempotentes — llamar a
Set(true)dos veces seguidas es una operación nula la segunda vez; el contador no se incrementa dos veces. - Ambos se agregan con OR con el propio estado del anfitrión y el marcador de cada otro plugin. Varios plugins en modo selector simultáneamente coexisten sin problemas.
- El anfitrión limpia automáticamente todos los marcadores de un plugin cuando el plugin se deshabilita (mediante la pestaña Plugins, deshabilitación por fallo o apagado). Un fallo a mitad de frame no dejará permanentemente la superposición en modo de captura — pero los plugins bien comportados siguen emparejando sus llamadas on/off para que los demás plugins y el juego permanezcan reactivos en el ínterin.
- Latencia: una llamada a Set actualiza el marcador de forma síncrona, pero el cambio de comportamiento efectivo se manifiesta en el próximo frame del anfitrión (entrada de superposición) o en el próximo tick del worker de GameClient (entidades durmientes). Infrafotograma, inobservable.
- Todos los métodos son seguros para llamar desde cualquier hilo.
El anfitrión carga los precios de mercado una vez por sesión desde poe2scout en un hilo de fondo y los expone a todos los plugins a través de ctx()->Prices. Los plugins no obtienen los precios por sí mismos — hay una única base de datos de precios compartida detrás del radar integrado, las superposiciones del anfitrión y todos los plugins, por lo que la API se consulta una sola vez en lugar de una vez por consumidor.
- La liga de precios la elige el usuario en Configuración → Ajustes (por defecto Runes of Aldur) y se persiste en el lado del anfitrión. Los plugins siempre leen la liga seleccionada por el usuario; no la eligen ellos.
- La carga es de disparo único con retroceso por categoría (reintentos tras 1 → 5 → 10 → 20 → 30 → 60 min en caso de fallo, luego se detiene hasta que se reinicia la aplicación). No hay refresco periódico — los precios son estables durante toda la sesión.
- Cada precio está denominado en Chaos.
GetRates()da la conversión a Divine / Exalted si quieres mostrarlo en esas unidades.
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 — qué categoría de poe2scout coincidió (currency, fragments, runes,
// …, o una categoría de objeto único). Útil para ramificar entre moneda y único.
}LookupPrice toma el nombre de visualización de un ítem (nombre de moneda, nombre único o tipo base) y realiza una comparación aproximada en el lado del anfitrión en todas las categorías cargadas. Una falta de coincidencia devuelve found == false con todos los precios a cero.
Campo PriceResult
|
Tipo | Significado |
|---|---|---|
found |
bool |
Se encontró un precio |
chaos |
float |
Precio en Chaos Orbs (la unidad canónica) |
divine |
float |
El mismo precio expresado en Divine Orbs |
exalt |
float |
El mismo precio expresado en Exalted Orbs |
category |
std::string |
Categoría de poe2scout que coincidió (currency / fragments / runes / … / una categoría de objeto único) |
PluginSDK::PriceRates r = ctx()->Prices.GetRates(); // divineInChaos, exaltedInChaos
PluginSDK::PriceStatus s = ctx()->Prices.GetStatus();
if (!s.loaded) {
// Aún no está listo (todavía cargando, o todas las categorías fallaron).
// s.catsOk / s.catsPending / s.catsFailed muestran hasta dónde llegó el cargador.
}GetStatus().loaded es la comprobación que hay que hacer antes de mostrar precios — pasa a verdadero solo cuando han llegado las tasas de conversión más al menos la primera categoría. Hasta entonces, muestra un estado "cargando precios…" en lugar de ceros.
ctx()->Runeshape expone los dispositivos Expedition2Encounter ("Runeshape") que el anfitrión ha resuelto en el área actual, junto con la recompensa que otorgaría cada receta. El anfitrión realiza el recorrido de la cadena de dispositivos y la comparación de recetas sin conexión; tu plugin simplemente lee el resultado. Esto es lo que impulsa la etiqueta de recompensa del radar integrado y la ventana Runeshape de NinjaPricer — un plugin de terceros puede renderizar los mismos datos.
for (const PluginSDK::Runeshape& rs : ctx()->Runeshape.Runeshapes()) {
// rs.color da a cada dispositivo un color distinto (úsalo para agrupar/teñir).
// rs.bestIndex es el índice de la recompensa con mayor precio (o -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: runa(s) que se transfieren al siguiente remanente
ctx()->Log.Info((" propagates: " + rw.propagatingRunes).c_str());
}
}Campo Runeshape
|
Tipo | Significado |
|---|---|---|
entityId |
uint64_t |
Id de entidad del dispositivo — pásalo a Rewards()
|
color |
uint32_t |
RGBA empaquetado, estable por dispositivo (para agrupar/teñir) |
isUnique |
bool |
El dispositivo ofrece una receta de objeto único |
holeCount |
int |
Número de ranuras de runa en el ancla |
anchorName |
std::string |
Nombre de la runa ancla |
rewardCount |
int |
Número de ranuras de recompensa |
bestIndex |
int |
Índice de la recompensa con mayor totalChaos, o -1
|
propagatingSlots |
std::vector<int> |
Índice(s) de ranura de runa cuya runa se propaga al siguiente remanente (transferencia 0.5.4); normalmente 1, a veces 2 |
Campo RuneshapeReward
|
Tipo | Significado |
|---|---|---|
name |
std::string |
Nombre del objeto de recompensa |
count |
int |
Cantidad otorgada |
unitChaos |
float |
Precio por unidad en Chaos (del servicio Prices) |
totalChaos |
float |
unitChaos × count |
priced |
bool |
Se encontró un precio para esta recompensa |
propagatingRunes |
std::string |
Runa(s) en las ranuras propagantes de esta receta — lo que se transfiere si completas esta receta; p. ej. "Power" o "Cold, Time"; vacío si la receta no cubre la ranura |
propagatingCount |
int |
Número de runas propagantes para esta recompensa |
propagatingHasRare |
bool |
Alguna runa propagante es rara ("morada"/valiosa) |
Los precios de las recompensas provienen de la misma base de datos ctx()->Prices, por lo que una recompensa sin precio (priced == false) normalmente significa que los precios aún no se han cargado, o que el objeto no está listado en poe2scout.
Propagación de runas (0.5.4). Cada remanente selecciona aleatoriamente una ranura de runa cuya runa se transfiere al siguiente remanente (en el juego: el resaltado con corona dorada en la lista de Runeshape Combinations). Runeshape::propagatingSlots es la lista de ranuras en bruto; como es una posición de ranura, la runa que se propaga difiere por receta, por lo que RuneshapeReward::propagatingRunes la resuelve por recompensa. Esto es lo que impulsa el punto amarillo de ranura de NinjaPricer y el marcador por recompensa.
Convención: <plugin directory>/config/settings.json. Directory() devuelve la ruta absoluta UTF-8 a la carpeta de tu plugin.
Para configuraciones triviales, un escritor JSON hecho a mano funciona bien y mantiene la DLL autocontenida. Consulta Plugins/Radar/src/RadarSettings.h para un ejemplo funcional. El esqueleto:
struct MySettings {
bool DrawEnabled = true;
float Opacity = 0.9f;
void Save(const std::string& directory) const {
std::filesystem::path p =
std::filesystem::path(directory) / "config" / "settings.json";
std::error_code ec;
std::filesystem::create_directories(p.parent_path(), ec);
std::ofstream out(p);
if (!out.is_open()) return;
out << "{\n";
out << " \"DrawEnabled\":" << (DrawEnabled ? "true" : "false") << ",\n";
out << " \"Opacity\":" << Opacity << "\n";
out << "}\n";
}
void Load(const std::string& directory) {
std::filesystem::path p =
std::filesystem::path(directory) / "config" / "settings.json";
if (!std::filesystem::exists(p)) return;
// ... parse ...
}
};
// In your plugin:
void OnEnable(bool) override { m_settings.Load(Directory()); }
void SaveSettings() override { m_settings.Save(Directory()); }Para datos estructurados (objetos anidados, arrays), incorpora una biblioteca JSON real en la carpeta de tu plugin. El anfitrión no impone una elección.
SaveSettings se llama periódicamente (~5s) y al deshabilitar; no necesitas llamarlo tú mismo.
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");Los cuatro niveles se enrutan al logger central del anfitrión. Los mensajes aparecen en la pestaña Logs del anfitrión y en el archivo de log en disco. Formatea tú mismo antes de llamar; el anfitrión no acepta varargs estilo printf.
Internamente, los métodos de conveniencia emiten las cadenas "Debug", "Info", "Warning" y "Error" (Warn se asigna a "Warning"). El puente del anfitrión hace una comparación insensible a mayúsculas/minúsculas, por lo que un plugin que llame a Log("warn", "msg") también se enruta correctamente — pero los métodos de conveniencia son más claros.
Primitivas de memoria directas. Prefiere los servicios de alto nivel siempre que sea posible — entienden los offsets, manejan cambios de ABI y son seguros frente a SEH. Las lecturas directas de memoria solo son apropiadas cuando no existe una llamada de mayor nivel para lo que necesitas.
// 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 patternSi te encuentras recurriendo a estas a menudo, pregúntate si los datos que necesitas pertenecen a los servicios de mayor nivel.
Cada llamada entre DLLs entre el anfitrión y un plugin se ejecuta dentro de un bloque __try / __except en el lado del anfitrión. Un plugin con mal comportamiento que dereferencia un puntero obsoleto, divide por cero o falla de otra forma dentro de una llamada SDK obtiene un error registrado — el proceso del anfitrión no crashea, el juego sigue funcionando y el usuario puede seguir utilizando otros plugins.
Eso no significa que los plugins puedan ser descuidados. SEH atrapa el síntoma, no la causa. Si tu plugin lanza fallos en cada frame, el usuario ve una avalancha de logs de error y tus datos son efectivamente no disponibles. Maneja los retornos nulos de las llamadas SDK, comprueba las banderas Valid en los datos de componentes y no dereferences direcciones uintptr_t directamente — pásalas a través de las llamadas ComponentsService / Ui / Memory que ya envuelven RPM correctamente.
El anfitrión puede lidiar con un plugin con errores. No puede lidiar con una DLL de plugin colgada — un DrawSettings que tarda 100ms bloquea todo el hilo de UI. Mantén el trabajo por frame económico.
Una lista corta de cosas con las que los autores de plugins se topan al integrarse por primera vez. La mayoría están documentadas en línea más arriba; recopiladas aquí como una lista de verificación.
-
OnAreaChangese dispara antes de que la rejilla de caminabilidad sea reparseada. No refresquesWalkableGridHandledesde el evento — sondea por frame enDrawUIe intercambia cuandoData()cambie. (§11) -
Entity::Zonesiempre esNonepara el jugador local. Es una clasificación de distancia desde el jugador, por lo que el jugador está por definición a distancia cero. No lo muestres en visualizaciones de información del jugador. -
Components.ReadMods()devuelve solo banderas de resumen — sin listas de mods. Para listas de mods por tipo, llama aInventory.ReadItemMods(entityAddr). (§8) -
Los ítems caídos en el suelo pueden carecer de
EntitySubtype. Si estás filtrando ítems en el mundo, prefiereEntityType == Item || EntityType == Chestsobre una comprobación de subtipo más estrecha. -
Directory()devuelve una ruta absoluta. No antepongas tú mismo el directorio del EXE — obtendrásEXEDIR\EXEDIR\Plugins\Xy tus escrituras de configuración aterrizarán fuera de la carpeta del plugin. -
ctx()devuelveconst Context*. Los métodos mutadores comoEventsService::Subscriberequierenconst_cast. Esto es intencional — los servicios que no mutan deberían ser imposibles de mutar accidentalmente. -
ImGui::SetCurrentContextes por DLL. Llámalo en cada punto de entrada que dibuje (OnEnable,DrawUI,DrawSettings) porque la DLL del plugin tiene su propio estado ImGui por defecto. -
Los ayudantes de conveniencia releen el componente en cada llamada.
GetHealthPercent(addr)hace unReadLife(addr)fresco internamente. Si ya tienes la estructuraLifede una llamada anterior, accede a sus campos directamente en su lugar.
Resumen de una línea de cada método público en cada servicio. Usa las secciones en prosa de arriba para las firmas de tipo completas y notas de uso.
| Método | Devuelve | Propósito |
|---|---|---|
GetSnapshot() |
Snapshot |
Vista completa por frame, incluyendo Entities
|
GetState() |
GameState |
Enum: InGame, Login, Loading, … |
IsAttached() |
bool |
Proceso del juego adjuntado |
IsInGame() |
bool |
State == InGame |
IsForeground() |
bool |
La ventana del juego tiene el foco |
IsMenuVisible() |
bool |
Menú ESC / ajustes abiertos |
IsOverlayMode() |
bool |
El anfitrión está en superposición (transparente al click) |
GetProcessId() |
DWORD |
PID del juego |
GetGameWindow() |
HWND |
Handle de ventana del juego |
GetScreenSize() |
ScreenSize |
floats {Width, Height}
|
| Método | Devuelve | Propósito |
|---|---|---|
Enumerate(cb) |
— | Visita cada entidad cercana (devuelve false para detener) |
GetPlayer() |
Entity |
La entidad del jugador local |
FindById(id) |
std::optional<Entity> |
Búsqueda por id de entidad |
GetWorldItemInner(addr) |
std::optional<Entity> |
Entidad interna del ítem para un contenedor WorldItem (ítems en el suelo) |
Watch(id) |
— | Fija una entidad para que sus componentes permanezcan legibles |
Unwatch(id) |
— | Libera una observación |
IsWatched(id) |
bool |
Estado de observación |
GetWatchedComponents(id) |
std::optional<ComponentAddresses> |
Lee los componentes fijados |
| Método | Devuelve | Propósito |
|---|---|---|
ReadLife / ReadRender / ReadPositioned / ReadTargetable / ReadChest / ReadShrine / ReadStack / ReadCharges / ReadPlayer / ReadAnimated / ReadTransitionable / ReadTriggerableBlockage / ReadMinimapIcon / ReadStateMachine / ReadBase / ReadMods / ReadStats / ReadBuffs / ReadActor / ReadNpc / ReadDiesAfterTime |
Estructura de componente | 21 lectores, uno por tipo de componente |
EnumerateBuffs(addr) |
std::vector<Buff> |
Buffs activos en la entidad |
EnumerateActiveSkills(addr) |
std::vector<ActiveSkill> |
Habilidades de un componente Actor
|
EnumerateStats(addr) |
std::vector<StatEntry> |
Stats provenientes de ítems + buffs |
EnumerateItemMods(addr) |
std::vector<Mod> |
Mods alcanzables desde un componente Mods
|
GetHealthPercent / GetEsPercent / GetManaPercent |
float |
Ayudantes de % de conveniencia |
IsAlive(addr) |
bool |
Health > 0 |
GetItemRarity(addr) |
int |
Rareza de un componente Mods
|
IsItemIdentified(addr) |
bool |
Bandera de identificado |
GetStackCount(addr) |
int |
Recuento actual de stack |
IsChestOpened(addr) |
bool |
Bandera de cofre abierto |
GetPlayerName(addr) |
std::string |
Nombre del jugador de un componente Player
|
GetWorldPosition(renderAddr, x, y, z) |
bool |
Accesor de conveniencia para coordenadas del mundo |
| Método | Devuelve | Propósito |
|---|---|---|
Scan(inventoryId) |
— | Desencadena un re-escaneo en el lado del anfitrión (-1 = todos) |
Get(inventoryId) |
Inventory |
Un inventario, ítems ya poblados |
GetItems(inventoryId) |
std::vector<InventoryItem> |
Solo ítems |
GetAll() |
std::vector<Inventory> |
Todos los inventarios escaneados |
GetName(inventoryId) |
const char* |
Nombre para mostrar ("Backpack", "Stash", …) |
ReadItemRarity(addr) |
int |
Rareza por entidad (resuelve automáticamente contenedores WorldItem) |
ReadItemStackCount(addr) |
int |
Stack por entidad (resuelve automáticamente contenedores WorldItem) |
ReadItemBaseTypeName(addr) |
std::string |
Tipo base, resuelve automáticamente contenedores WorldItem |
ReadItemUniqueName(addr) |
std::string |
Nombre único, resuelve automáticamente contenedores WorldItem |
ReadItemPath(addr) |
std::string |
Ruta Metadata/Items/..., resuelve automáticamente contenedores WorldItem |
ReadItemMods(addr) |
ItemMods |
Banderas de resumen + 5 vectores de mods por tipo, resuelve automáticamente contenedores WorldItem |
FormatStat(statKey, v0, v1) |
std::string |
Texto al estilo del juego para una clave de stat + valor(es) mediante el formateador .csd del anfitrión; vacío hasta que carguen las descripciones |
ReadItemBaseStats(addr) |
ItemBaseStats |
Valores defensivos base (Energy Shield calculado; Ward/Armour/Evasion base); Valid falso sin componente Armour; resuelve automáticamente contenedores WorldItem |
ReadItemAggregatedStats(addr) |
std::vector<std::pair<int,int>> |
{statId, value} agregados (piedra guía Item Rarity 8205 / Pack Size 8206 / Monster Rarity 8207 / Monster Effectiveness 8208 / Waystone Drop Chance 8209); resuelve automáticamente contenedores WorldItem |
| Método | Devuelve | Propósito |
|---|---|---|
Read(addr) |
UiElement |
Campos del elemento (rect, banderas, número de hijos) |
GetChildren(addr) |
std::vector<uintptr_t> |
Direcciones de elementos hijos |
GetChildAt(addr, index) |
uintptr_t |
Hijo único por índice |
FollowPath(root, indices, count) |
uintptr_t |
Recorre una ruta de índices conocida |
IsVisible(addr) |
bool |
El elemento está en pantalla |
GetStringId(addr) |
std::string |
Identificador estable del lado del juego |
GetText(addr) |
std::string |
Texto renderizado |
ComputeScreenRect(addr, x, y, w, h) |
bool |
Rect final en espacio de pantalla |
GetGameUiRoot() |
uintptr_t |
Raíz de la UI en el juego |
GetUiRoot() |
uintptr_t |
Raíz UI de nivel superior |
GetCullValue() |
int |
Umbral de descarte de UI del anfitrión |
FindPanelByStringId(parent, stringId) |
uintptr_t |
Búsqueda dirigida de descendientes |
| Método | Devuelve | Propósito |
|---|---|---|
WorldToScreen(wx, wy, wz, sx, sy) |
bool |
Proyección en perspectiva |
GridToLargeMap(gx, gy, worldZ, sx, sy) |
bool |
Proyectar a la superposición del mapa grande |
GridToMiniMap(gx, gy, worldZ, sx, sy) |
bool |
Proyectar al minimapa |
GetLargeMapTransform() |
MapTransform |
Transformación pre-multiplicada para cálculos por lotes |
GetMiniMapTransform() |
MapTransform |
Lo mismo, para el minimapa |
| Método | Devuelve | Propósito |
|---|---|---|
GetWalkableGrid() |
WalkableGridHandle |
Handle RAII al mapa de bits de caminabilidad de 4 bits por tile |
GetHeightGrid() |
HeightGridHandle |
Handle RAII a las alturas de terreno por tile |
IsWalkable(gx, gy) |
bool |
Predicado de tile único |
GetTerrainHeight(gx, gy) |
float |
Z en espacio del mundo |
GetWorldToGridConvertor() |
float |
Factor de conversión mundo → rejilla |
EnumerateTgtLocations(cb) |
— | Visita cada instancia TGT en el área actual |
| Método | Devuelve | Propósito |
|---|---|---|
Read(addr, buf, size) |
bool |
RPM crudo |
ReadString(addr) |
std::string |
Cadena estrecha terminada en nulo |
ReadWString(addr) |
std::wstring |
Cadena ancha terminada en nulo |
ReadStdWString(addr) |
std::wstring |
Lee un contenedor std::wstring del lado del juego (maneja SSO) |
ReadStdVector(addr, elemSize, maxElems) |
std::vector<uint8_t> |
Bytes crudos; reinterprétalos como tu tipo |
GetBaseAddress() |
uintptr_t |
Base del módulo del juego |
GetModuleSize() |
uintptr_t |
Tamaño del módulo del juego |
GetPatternAddress(name) |
uintptr_t |
Búsqueda de patrón con nombre |
| Método | Propósito |
|---|---|
Debug / Info / Warn / Error(msg) |
Emite al nivel correspondiente |
Log(level, msg) |
Cadena de nivel personalizada |
| Método | Devuelve | Propósito |
|---|---|---|
Subscribe(kind, cb) |
Token |
Despacho genérico |
OnAreaChange / OnFrame / OnGameAttached / OnGameDetached(cb) |
Token |
Ayudantes de suscripción de una línea |
Unsubscribe(token) |
— | Liberación manual (el destructor libera automáticamente de todos modos) |
| Método | Devuelve | Propósito |
|---|---|---|
SetIncludeSleepingEntities(enable) |
— | Activar la recepción de entidades EntityState::Useless en EntitiesService.Enumerate
|
SetWantsOverlayInput(enable) |
— | Solicitar al overlay que capture clics del ratón en lugar de ser transparente a clics |
Ambos son idempotentes, por plugin, combinados con OR junto al anfitrión y otros plugins, y se limpian automáticamente al desactivar o descargar. Consulta la sección 13 para el patrón completo (incluyendo la advertencia sobre la lista de dibujo en segundo plano para plugins selectores de mapa).
| Método | Devuelve | Propósito |
|---|---|---|
GetFlask(slot) |
std::optional<Flask> |
Frasco de vida/maná por ranura del cinturón (0=vida, 1=maná); nullopt fuera de rango o si no está en el juego |
GetCharm(slot) |
std::optional<Charm> |
Amuleto por ranura del cinturón (0..2); nullopt fuera de rango o si no está en el juego |
AllFlasks() |
std::vector<Flask> |
Todas las ranuras de frascos incl. vacías (entradas FlaskSlotCount()) |
AllCharms() |
std::vector<Charm> |
Todas las ranuras de amuletos incl. vacías (entradas CharmSlotCount()) |
FlaskSlotCount() |
int32_t |
Número de ranuras de frascos (2 en POE2) |
CharmSlotCount() |
int32_t |
Número de ranuras de amuletos (3 en POE2) |
Consulta la sección 8 ("Frascos y amuletos") para las tablas de campos Flask / Charm y la limitación de PerUseEffective.
| Método | Devuelve | Propósito |
|---|---|---|
LookupPrice(name) |
PriceResult |
Búsqueda difusa de precios en el lado del anfitrión por nombre de visualización (found, chaos, divine, exalt, category) |
GetRates() |
PriceRates |
Tasas de conversión Divine / Exalted → Chaos |
GetStatus() |
PriceStatus |
Compuerta loaded + conteos por categoría (catsOk / catsPending / catsFailed) |
| Método | Devuelve | Propósito |
|---|---|---|
Runeshapes() |
std::vector<Runeshape> |
Todos los dispositivos Expedition2Encounter resueltos (id, color, anchor, bestIndex) |
Rewards(entityId) |
std::vector<RuneshapeReward> |
Ranuras de recompensa por dispositivo, cada una con precio obtenido a través del servicio Prices |
PluginAbi.h define:
constexpr int PLUGIN_SDK_VERSION = 6;En el momento de la carga, el anfitrión llama a plugin->GetSDKVersion() y lo compara con su propio PLUGIN_SDK_VERSION. Si no coinciden → el anfitrión registra una advertencia y rehúsa cargar el plugin.
El anfitrión también verifica HostAbi::version y HostAbi::size_bytes dentro de PluginSDK_AttachHost (definido en línea por PluginSDK.h cuando se establece PLUGIN_EXPORTS). Si alguno de estos campos no concuerda con aquello contra lo que se compiló el plugin, ctx() no es funcional. El accesor de la clase base HostCompatible() reporta false en ese caso, y cualquier plugin que quiera ser cortés debería rehusarse a actuar:
void OnEnable(bool) override {
if (!HostCompatible()) {
ctx()->Log.Error("Host ABI mismatch — disable plugin");
return;
}
// ...
}Cuatro plugins en el repositorio están diseñados para leerse como documentación:
-
Plugins/ExamplePlugin/— escaparate de superficie amplia. Un plugin que toca casi todos los servicios, organizado como 11 subarchivosexamples/Example*.h(Area & Vitals, Buffs, Entities, Inventory, Memory, UI Explorer, Component Reader, Render, Terrain, Events, Log) más un banner de resumen de cobertura. Léelo cuando quieras ver cómo se utiliza un servicio, en contexto. -
Plugins/Radar/— ejemplo enfocado del mundo real. ~200 líneas. Una superposición de radar construida enteramente sobre el SDK público — sin offsets, sin lecturas directas de memoria. Renderiza el mapa caminable más puntos por entidad víaRender.GridToLargeMap. Léelo cuando quieras ver el código mínimo para un resultado particular. -
Plugins/KillCount/— rastreador de kills/cofres/muertes. SQLite + atlas de sprites + estado por área. Muestra cómo entregar persistencia, archivos de datos incorporados y una superposición todo en una DLL. -
Plugins/NinjaPricer/— superposición de precios de poe.ninja. Fetch HTTP (Exchange API) + escaneo de inventario + valoración por ítem. Muestra código de red, ingesta de datos de terceros e iteración de inventario en un flujo de trabajo real.