-
Notifications
You must be signed in to change notification settings - Fork 0
Plugin Development Guide FR
Les plugins POEFixer sont des DLL C++ natives chargées à l'exécution depuis Plugins/<PluginName>/<PluginName>.dll. Ils lisent l'état du jeu en direct, dessinent des superpositions ImGui, persistent leurs propres paramètres et s'abonnent aux événements de l'hôte.
Le SDK de plugin possède une architecture à trois couches :
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
- Les auteurs de plugins incluent exactement un en-tête :
POEFixer/plugin_sdk/PluginSDK.h. - Cet en-tête déclare tout dans l'espace de noms
PluginSDK::et tire l'ABI C depuisPluginAbi.hen dessous. Vous pouvez mentionner que ce dernier existe ; vous n'y jetez presque jamais un œil. - Tous les conteneurs
std::*vivent à l'intérieur de la DLL du plugin. Seuls les POD traversent la frontière de l'hôte. Cela signifie qu'un plugin construit avec une version de toolchain ne peut pas s'emmêler avec la STL de l'hôte — les seuls types partagés sont les entiers, flottants, pointeurs et petites structures.
Localisez les en-têtes du SDK ici :
-
POEFixer/plugin_sdk/PluginSDK.h— le wrapper C++ que les auteurs de plugins utilisent. -
POEFixer/plugin_sdk/PluginAbi.h— l'ABI C pure en dessous.
Plugins de référence livrés avec le dépôt (à lire comme documentation) : Plugins/ExamplePlugin/, Plugins/Radar/, Plugins/KillCount/, Plugins/NinjaPricer/.
Plugin minimal qui se charge et affiche un message dans le journal de l'hôte :
#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; }Compilez sous forme de Plugins/Hello/Hello.dll, redémarrez l'hôte, activez depuis l'onglet Plugins.
Utilisez Plugins/ExamplePlugin/ExamplePlugin.vcxproj comme modèle canonique. Les paramètres essentiels :
- Type de configuration : DynamicLibrary
- Jeu d'outils de plateforme : v143 (Visual Studio 2022)
- Jeu de caractères : Unicode
-
Standard du langage :
stdcpp20 -
Bibliothèque d'exécution :
MultiThreadedDLL(Release) /MultiThreadedDebugDLL(Debug). DOIT correspondre à l'hôte. -
Définitions du préprocesseur :
PLUGIN_EXPORTS;NDEBUG;_WINDOWS;_USRDLL;_CRT_SECURE_NO_WARNINGS -
Répertoires d'inclusion additionnels :
$(SolutionDir)POEFixer -
Répertoire de sortie :
$(SolutionDir)x64\Release\Plugins\<YourPlugin>\ -
Nom de la cible : doit correspondre au nom du dossier (
Plugins/MyPlugin/→MyPlugin.dll)
L'hôte analyse chaque sous-dossier dans Plugins/ et recherche <FolderName>.dll. La DLL doit exporter trois symboles :
-
CreatePlugin— factory ; retournePluginSDK::Plugin*. -
DestroyPlugin— destructeur ; prendPluginSDK::Plugin*. -
PluginSDK_AttachHost— connecte leContext. Défini pour vous à l'intérieur dePluginSDK.het émis automatiquement lorsquePLUGIN_EXPORTSest défini.
Disposition source recommandée :
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() renvoie un chemin UTF-8 absolu enraciné dans le répertoire de l'EXE de l'hôte — n'ajoutez pas vous-même le chemin de l'EXE. La chaîne du répertoire est possédée par valeur à l'intérieur de PluginSDK::Plugin, donc elle survit aux réallocations de conteneurs de l'hôte et aux cycles de rechargement sans préoccupation de durée de vie.
Si vous souhaitez dessiner avec ImGui, ajoutez également ceci à <ClCompile> (l'hôte les lie également, mais l'ImGui côté plugin est par DLL) :
..\..\POEFixer\imgui\imgui.cpp
..\..\POEFixer\imgui\imgui_draw.cpp
..\..\POEFixer\imgui\imgui_tables.cpp
..\..\POEFixer\imgui\imgui_widgets.cpp
Dans OnEnable, attachez-vous au contexte ImGui de l'hôte :
if (ctx()->ImGuiContext)
ImGui::SetCurrentContext(static_cast<ImGuiContext*>(ctx()->ImGuiContext));PluginSDK::Plugin est une classe de base virtuelle. Redéfinissez-les dans votre plugin (dans l'ordre d'appel approximatif) :
| Méthode | Appelée quand | Utilisation typique |
|---|---|---|
const char* GetName() const |
Une fois, juste après la construction | Renvoyer le nom d'affichage de votre plugin |
void OnEnable(bool isGameAttached) |
Lorsque l'utilisateur active le plugin (ou au démarrage si persisté) | Charger les paramètres, s'abonner aux événements, attacher le contexte ImGui |
void DrawSettings() |
Chaque frame tant que le panneau de paramètres du plugin est ouvert | Contrôles ImGui pour votre configuration |
void DrawUI() |
Chaque frame tant que le plugin est activé | Dessin de la superposition ImGui (utilisez ImGui::GetBackgroundDrawList() pour la superposition du jeu) |
bool WantsOverlay() const |
Interrogée chaque frame | Renvoyer true si vous souhaitez que l'hôte soit en mode superposition (transparent au clic) |
void SaveSettings() |
Périodiquement (~5s) et à la désactivation | Persister la config sur disque |
void OnDisable() |
Lorsque l'utilisateur désactive, ou à l'arrêt de l'hôte | Libérer les ressources, se désabonner des événements |
Seul GetName est obligatoire ; le reste a des valeurs par défaut sûres.
L'hôte appelle également GetSDKVersion() (défini sur la base, NE PAS redéfinir) immédiatement après CreatePlugin pour vérifier que le plugin et l'hôte s'accordent. Inadéquation → plugin refusé.
ctx() renvoie const PluginSDK::Context*, un agrégat de 10 services :
struct Context {
GameService Game; // snapshot, state flags, screen size
EntitiesService Entities; // enumerate, find-by-id, watch
ComponentsService Components; // 21 component readers + collection enumerators
InventoryService Inventory; // scan + iterate + per-item helpers
UiService Ui; // tree walk, FindPanelByStringId, screen-rect
RenderService Render; // WorldToScreen + isometric map projection
TerrainService Terrain; // walkable grid (RAII), height, TGT locations
MemoryService Memory; // direct memory primitives (last resort)
LogService Log; // Debug/Info/Warn/Error
EventsService Events; // Subscribe / Unsubscribe / On<X>
void* ImGuiContext; // pass to ImGui::SetCurrentContext
void* D3DDevice; // ID3D11Device* for texture loading
};En un coup d'œil — à quoi sert chaque service :
| Service | Pourquoi vous y avez recours |
|---|---|
GameService |
Snapshot, indicateurs d'état, infos écran + fenêtre |
EntitiesService |
Énumérer, rechercher par id, surveiller le cycle de vie |
ComponentsService |
21 lecteurs + 4 énumérateurs + ~10 helpers de commodité |
InventoryService |
Scan, énumération, lectures de mods par item |
UiService |
Parcours d'arbre, FindPanelByStringId, ComputeScreenRect
|
RenderService |
WorldToScreen, GridTo{Large,Mini}Map, transformations |
TerrainService |
Grilles walkable + hauteur (RAII), emplacements TGT |
MemoryService |
Primitives RPM — uniquement quand aucun appel de plus haut niveau ne convient |
LogService |
Debug / Info / Warn / Error
|
EventsService |
Subscribe / Unsubscribe / On{Area,Frame,Attach,Detach}
|
ctx() est valide depuis le moment où l'hôte appelle OnEnable jusqu'au retour de OnDisable. Ne mettez pas en cache ctx() à travers les rechargements à chaud ou les frontières de déchargement de DLL.
ctx()->Game.GetSnapshot() renvoie un Snapshot typé par valeur — une vue immuable complète de la frame actuelle. GetSnapshot() parcourt abi->entities.enumerate et peuple snap.Entities avant de retourner, donc le coût se met à l'échelle avec le nombre d'entités proches. Appelez-le une fois par frame et réutilisez.
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 transitionCe que le snapshot transporte directement (aucun autre appel de service nécessaire) :
- État + indicateurs :
State,IsAttached,IsWindowValid,GameWindowForeground,IsTown,IsHideout,IsPaused,IsSkillTreeVisible. - Zone :
CurrentAreaName,CurrentAreaHash,CurrentAreaLevel,AreaChangeCounter. - Monde :
Player(Entitycomplet),Entities(std::vector<Entity>complet),Vitals,LargeMap,MiniMap,WorldToScreenMatrix[16]. - Fenêtre :
ScreenWidth,ScreenHeight,ProcessId,GameWindow,LastUpdateTime,WorldToGridConvertor.
Ce qui n'est pas sur le snapshot — récupérez via les services : contenu de l'inventaire (InventoryService), buffs (ComponentsService::EnumerateBuffs), listes de mods par item (InventoryService::ReadItemMods), panneaux UI (UiService).
Helpers économiques lorsque vous n'avez pas besoin d'un snapshot complet :
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();Les entités exposent leurs composants via entity.Components — une structure ComponentAddresses d'adresses uintptr_t. Passez chaque adresse au ComponentsService::Read* correspondant pour obtenir un snapshot typé par valeur.
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");
}
}Il existe 21 lecteurs de composants : ReadLife, ReadRender, ReadPositioned, ReadTargetable, ReadChest, ReadShrine, ReadStack, ReadCharges, ReadPlayer, ReadAnimated, ReadTransitionable, ReadTriggerableBlockage, ReadMinimapIcon, ReadStateMachine, ReadBase, ReadMods, ReadStats, ReadBuffs, ReadActor, ReadNpc, ReadDiesAfterTime.
ComponentAddresses lui-même contient 24 slots : les 21 ci-dessus plus trois marqueurs (Buffs, WorldItem, AreaTransition) et OMP (interne à l'hôte). Buffs est un marqueur de présence — la liste réelle des buffs vient de EnumerateBuffs. WorldItem / AreaTransition sont des marqueurs de type d'entité plutôt que de vrais composants. Tous les slots ont des prédicats HasX() correspondants sur ComponentAddresses.
Lecteurs de style collection pour les composants avec données de taille 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>Helpers de commodité (à un coup — ils appellent en interne Read* pour vous) :
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)) { ... }Un indicateur Valid sur chaque structure renvoyée vous permet de gérer « adresse de composant à 0 / lecture échouée » sans exceptions. Si vous avez déjà la structure parent (Life, Mods, …), accédez directement à ses champs plutôt que d'appeler à nouveau le helper — le helper relit le composant à chaque fois.
Chaque Entity (y compris snap.Player et les membres de snap.Entities) porte le même ensemble de champs :
| Groupe | Champs |
|---|---|
| Identité |
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
|
| Vitaux rapides |
CurrentHP, MaxHP, CurrentES, MaxES (évite un ReadLife si vous n'avez besoin que des totaux) |
| Chaînes |
Path (std::wstring, Metadata/...), PlayerName (std::wstring), TgtPath (std::string, chemin d'asset) |
| État |
IsSleeping, IsChestOpened
|
| Composants |
Components (sous-structure ComponentAddresses) |
Si vous avez besoin de suivre une entité à travers les frames (par ex. un coffre que le joueur ouvre) et ne souhaitez pas analyser la liste complète des entités à chaque frame, enregistrez une surveillance :
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) renvoie std::optional<Entity> pour les recherches ponctuelles, et GetPlayer() renvoie toujours le joueur local.
ctx()->Inventory.Scan(inventoryId) déclenche un nouveau scan côté hôte. Utilisez -1 pour scanner tous les inventaires.
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
}
}Chaque Inventory expose également une structure Grid décrivant où l'inventaire est dessiné à l'écran :
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)
}Pour récupérer un seul inventaire par id (renvoie la même structure avec Items déjà peuplée) :
PluginSDK::Inventory backpack = ctx()->Inventory.Get(/*inventoryId=*/0);Ou si vous voulez uniquement le vecteur d'items sans la structure enveloppante :
std::vector<PluginSDK::InventoryItem> items = ctx()->Inventory.GetItems(0);ComponentsService::ReadMods(addr) ne renvoie que les indicateurs de résumé (IsCorrupted, IsRelic, IsSplit, IsMirrored, IsSynthesised, IsIdentified, Rarity, ItemLevel, RequiredLevel, CraftedModCount). Il ne porte pas les listes de mods par type.
Pour l'image complète (résumé + listes de mods), utilisez 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) { ... }Autres lectures directes par entité (moins coûteuses que de rescanner lorsque vous détenez déjà l'adresse d'un item) :
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);L'arbre UI du jeu est exposé sous forme d'adresses d'éléments uintptr_t. Commencez depuis une racine, parcourez les enfants, lisez les champs d'élément.
La manière propre de trouver un panneau connu par son 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
}Parcours manuel de l'arbre lorsque vous ne connaissez pas le 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 thresholdLes valeurs StringId sont des identifiants stables côté jeu ; préférez-les aux chemins codés en dur lorsqu'ils existent.
Trois helpers de projection, deux systèmes de coordonnées.
Perspective (monde 3D → écran) — même projection que le jeu utilise pour dessiner les choses dans le monde. Bon pour les plaques de noms, marqueurs de debug, indicateurs de cible :
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étrique (grille → minimap) — pour les superpositions de style radar dessinées sur la grande ou la mini carte. Celles-ci respectent le zoom, le panoramique et la rotation de la carte 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)Pour les calculs par lots (sauter les appels de fonction par entité), récupérez la transformation une fois et effectuez la projection en ligne :
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.Voir Plugins/Radar/src/Radar.cpp pour un radar fonctionnel construit entièrement sur ces appels.
La grille walkable est un bitmap de 4 bits par tuile indiquant les cellules de terrain sur lesquelles le joueur peut marcher. L'hôte la met à jour à chaque changement de zone ; les plugins reçoivent un handle stable qui survit jusqu'à ce que le plugin le libère (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 reflète la même forme mais contient un float par tuile (Data() est const float*, plus ElementCount() et SizeBytes()).
Ne vous abonnez pas à OnAreaChange pour rafraîchir le handle. L'événement se déclenche lorsque le worker de l'hôte détecte un changement de zone, mais la nouvelle grille walkable peut ne pas avoir encore été parsée — vous garderiez un pointeur obsolète pendant une ou deux frames. À la place, sondez par frame dans DrawUI :
auto current = ctx()->Terrain.GetWalkableGrid();
if (current.Data() != m_walkable.Data()) {
m_walkable = std::move(current); // swap when the host re-parses
}C'est peu coûteux (un appel ABI + une comparaison de pointeur). Voir Plugins/Radar/src/Radar.cpp pour la version de production.
Autres accesseurs de terrain :
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
});Abonnez-vous aux événements émis par l'hôte. Chaque Subscribe renvoie un Token que vous pouvez plus tard passer à Unsubscribe. Le destructeur EventsService (déclenché lorsque le plugin se désactive ou se décharge) libère automatiquement tout ce qui reste en cours — donc vous n'avez pas strictement besoin de vous désabonner manuellement, mais c'est poli.
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);
}
};Les quatre types d'événements sont AreaChange, Frame, GameAttached, GameDetached. Il existe également un Subscribe(EventKind, callback) générique si vous préférez construire une table de dispatch.
Le const_cast est nécessaire parce que Events mute sa carte de tokens interne. La classe de base renvoie const Context* pour décourager la mutation accidentelle d'autres services.
Si votre plugin possède plusieurs abonnements, le pattern ExamplePlugin est une manière propre de garder enable/disable symétriques — groupez les tokens et compteurs dans une seule structure d'état et routez tout par une seule paire 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;
}Voir Plugins/ExamplePlugin/examples/ExampleEvents.h pour le pattern complet.
Convention : <plugin directory>/config/settings.json. Directory() renvoie le chemin UTF-8 absolu vers le dossier de votre plugin.
Pour les paramètres triviaux, un writer JSON fait main fonctionne bien et garde la DLL auto-contenue. Voir Plugins/Radar/src/RadarSettings.h pour un exemple fonctionnel. Le squelette :
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()); }Pour les données structurées (objets imbriqués, tableaux), embarquez une vraie bibliothèque JSON dans votre dossier de plugin. L'hôte n'impose pas de choix.
SaveSettings est appelé périodiquement (~5s) et à la désactivation ; vous n'avez pas besoin de l'appeler vous-même.
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");Les quatre niveaux sont routés vers le logger central de l'hôte. Les messages apparaissent dans l'onglet Logs de l'hôte et dans le fichier journal sur disque. Formatez vous-même avant d'appeler ; l'hôte n'accepte pas de varargs de style printf.
En interne, les méthodes de commodité émettent les chaînes "Debug", "Info", "Warning" et "Error" (Warn se mappe sur "Warning"). Le pont de l'hôte effectue une correspondance insensible à la casse, donc un plugin appelant Log("warn", "msg") est tout de même routé correctement — mais les méthodes de commodité sont plus claires.
Primitives mémoire directes. Préférez les services de haut niveau chaque fois que possible — ils comprennent les offsets, gèrent les changements d'ABI et sont SEH-safe. Les lectures mémoire directes ne sont appropriées que lorsqu'aucun appel de plus haut niveau n'existe pour ce dont vous avez besoin.
// 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 vous vous retrouvez à les utiliser souvent, demandez-vous si les données dont vous avez besoin appartiennent aux services de plus haut niveau.
Chaque appel inter-DLL entre l'hôte et un plugin s'exécute à l'intérieur d'un bloc __try / __except côté hôte. Un plugin qui se comporte mal et déréférence un pointeur obsolète, divise par zéro, ou défaille autrement à l'intérieur d'un appel SDK obtient une erreur consignée — le processus hôte ne crashe pas, le jeu continue de tourner, et l'utilisateur peut continuer à utiliser d'autres plugins.
Cela ne signifie pas que les plugins peuvent être négligents. SEH attrape le symptôme, pas la cause. Si votre plugin lève des fautes à chaque frame, l'utilisateur voit un flot de journaux d'erreur et vos données sont effectivement indisponibles. Gérez les retours null des appels SDK, vérifiez les indicateurs Valid sur les données de composant, et ne déréférencez pas directement les adresses uintptr_t — passez-les par les appels ComponentsService / Ui / Memory qui enveloppent déjà RPM correctement.
L'hôte peut gérer un plugin buggé. Il ne peut pas gérer une DLL de plugin figée — un DrawSettings qui prend 100ms bloque tout le thread UI. Gardez le travail par frame peu coûteux.
Une courte liste de choses sur lesquelles les auteurs de plugins butent lors de l'intégration initiale. La plupart sont documentées en ligne plus haut ; rassemblées ici sous forme de checklist.
-
OnAreaChangese déclenche avant que la grille walkable ne soit re-parsée. Ne rafraîchissez pasWalkableGridHandledepuis l'événement — sondez par frame dansDrawUIet faites le swap quandData()change. (§11) -
Entity::Zoneest toujoursNonepour le joueur local. C'est une classification de distance-par-rapport-au-joueur, donc le joueur est par définition à distance zéro. Ne l'affichez pas dans les écrans d'info joueur. -
Components.ReadMods()ne renvoie que les indicateurs de résumé — pas de listes de mods. Pour les listes de mods par type, appelezInventory.ReadItemMods(entityAddr). (§8) -
Les items lâchés au sol peuvent manquer d'
EntitySubtype. Si vous filtrez les items dans le monde, préférezEntityType == Item || EntityType == Chestà une vérification de sous-type plus restrictive. -
Directory()renvoie un chemin absolu. N'ajoutez pas vous-même le répertoire de l'EXE — vous obtiendrezEXEDIR\EXEDIR\Plugins\Xet vos écritures de config atterriront en dehors du dossier du plugin. -
ctx()renvoieconst Context*. Les méthodes mutantes commeEventsService::Subscribenécessitent unconst_cast. C'est intentionnel — les services qui ne mutent pas devraient être impossibles à muter par accident. -
ImGui::SetCurrentContextest par DLL. Appelez-le à chaque point d'entrée qui dessine (OnEnable,DrawUI,DrawSettings) car la DLL du plugin a son propre état ImGui par défaut. -
Les helpers de commodité relisent le composant à chaque appel.
GetHealthPercent(addr)effectue unReadLife(addr)frais en interne. Si vous avez déjà la structureLifed'un appel précédent, accédez directement à ses champs au lieu de cela.
Résumé en une ligne de chaque méthode publique sur chaque service. Utilisez les sections de prose ci-dessus pour les signatures de types complètes et les notes d'utilisation.
| Méthode | Renvoie | Objectif |
|---|---|---|
GetSnapshot() |
Snapshot |
Vue complète par frame, incluant Entities
|
GetState() |
GameState |
Enum : InGame, Login, Loading, … |
IsAttached() |
bool |
Processus du jeu attaché |
IsInGame() |
bool |
State == InGame |
IsForeground() |
bool |
La fenêtre du jeu a le focus |
IsMenuVisible() |
bool |
Menu ESC / paramètres ouvert |
IsOverlayMode() |
bool |
L'hôte est en superposition (transparent au clic) |
GetProcessId() |
DWORD |
PID du jeu |
GetGameWindow() |
HWND |
Handle de la fenêtre du jeu |
GetScreenSize() |
ScreenSize |
Flottants {Width, Height}
|
| Méthode | Renvoie | Objectif |
|---|---|---|
Enumerate(cb) |
— | Visiter chaque entité proche (renvoyer false pour arrêter) |
GetPlayer() |
Entity |
L'entité du joueur local |
FindById(id) |
std::optional<Entity> |
Recherche par id d'entité |
Watch(id) |
— | Épingler une entité pour que ses composants restent lisibles |
Unwatch(id) |
— | Libérer une surveillance |
IsWatched(id) |
bool |
État de surveillance |
GetWatchedComponents(id) |
std::optional<ComponentAddresses> |
Lire les composants épinglés |
| Méthode | Renvoie | Objectif |
|---|---|---|
ReadLife / ReadRender / ReadPositioned / ReadTargetable / ReadChest / ReadShrine / ReadStack / ReadCharges / ReadPlayer / ReadAnimated / ReadTransitionable / ReadTriggerableBlockage / ReadMinimapIcon / ReadStateMachine / ReadBase / ReadMods / ReadStats / ReadBuffs / ReadActor / ReadNpc / ReadDiesAfterTime |
Structure de composant | 21 lecteurs, un par type de composant |
EnumerateBuffs(addr) |
std::vector<Buff> |
Buffs actifs sur l'entité |
EnumerateActiveSkills(addr) |
std::vector<ActiveSkill> |
Compétences depuis un composant Actor
|
EnumerateStats(addr) |
std::vector<StatEntry> |
Stats provenant d'items + buffs |
EnumerateItemMods(addr) |
std::vector<Mod> |
Mods accessibles depuis un composant Mods
|
GetHealthPercent / GetEsPercent / GetManaPercent |
float |
Helpers de commodité en % |
IsAlive(addr) |
bool |
Santé > 0 |
GetItemRarity(addr) |
int |
Rareté depuis un composant Mods
|
IsItemIdentified(addr) |
bool |
Indicateur d'identification |
GetStackCount(addr) |
int |
Nombre actuel dans la pile |
IsChestOpened(addr) |
bool |
Indicateur de coffre ouvert |
GetPlayerName(addr) |
std::string |
Nom du joueur depuis un composant Player
|
GetWorldPosition(renderAddr, x, y, z) |
bool |
Accesseur de commodité pour les coords monde |
| Méthode | Renvoie | Objectif |
|---|---|---|
Scan(inventoryId) |
— | Déclencher un nouveau scan côté hôte (-1 = tous) |
Get(inventoryId) |
Inventory |
Un inventaire, items déjà peuplés |
GetItems(inventoryId) |
std::vector<InventoryItem> |
Items uniquement |
GetAll() |
std::vector<Inventory> |
Tous les inventaires scannés |
GetName(inventoryId) |
const char* |
Nom d'affichage ("Backpack", "Stash", …) |
ReadItemRarity(addr) |
int |
Rareté par entité |
ReadItemStackCount(addr) |
int |
Pile par entité |
ReadItemBaseTypeName(addr) |
std::string |
Type de base ("Gemcutter's Prism", …) |
ReadItemUniqueName(addr) |
std::string |
Nom unique (le cas échéant) |
ReadItemPath(addr) |
std::string |
Chemin Metadata/Items/...
|
ReadItemMods(addr) |
ItemMods |
Indicateurs de résumé + 5 vecteurs de mods par type |
| Méthode | Renvoie | Objectif |
|---|---|---|
Read(addr) |
UiElement |
Champs d'élément (rect, indicateurs, nombre d'enfants) |
GetChildren(addr) |
std::vector<uintptr_t> |
Adresses d'éléments enfants |
GetChildAt(addr, index) |
uintptr_t |
Un seul enfant par index |
FollowPath(root, indices, count) |
uintptr_t |
Parcourir un chemin d'index connu |
IsVisible(addr) |
bool |
L'élément est à l'écran |
GetStringId(addr) |
std::string |
Identifiant stable côté jeu |
GetText(addr) |
std::string |
Texte rendu |
ComputeScreenRect(addr, x, y, w, h) |
bool |
Rect espace-écran final |
GetGameUiRoot() |
uintptr_t |
Racine de l'UI en jeu |
GetUiRoot() |
uintptr_t |
Racine UI de niveau supérieur |
GetCullValue() |
int |
Seuil de cull UI de l'hôte |
FindPanelByStringId(parent, stringId) |
uintptr_t |
Recherche ciblée de descendant |
| Méthode | Renvoie | Objectif |
|---|---|---|
WorldToScreen(wx, wy, wz, sx, sy) |
bool |
Projection en perspective |
GridToLargeMap(gx, gy, worldZ, sx, sy) |
bool |
Projeter sur la superposition grande carte |
GridToMiniMap(gx, gy, worldZ, sx, sy) |
bool |
Projeter sur la minimap |
GetLargeMapTransform() |
MapTransform |
Transformation pré-multipliée pour calculs par lots |
GetMiniMapTransform() |
MapTransform |
Idem, pour la minimap |
| Méthode | Renvoie | Objectif |
|---|---|---|
GetWalkableGrid() |
WalkableGridHandle |
Handle RAII vers le bitmap walkable 4 bits par tuile |
GetHeightGrid() |
HeightGridHandle |
Handle RAII vers les hauteurs de terrain par tuile |
IsWalkable(gx, gy) |
bool |
Prédicat à une tuile |
GetTerrainHeight(gx, gy) |
float |
Z espace-monde |
GetWorldToGridConvertor() |
float |
Facteur de conversion monde → grille |
EnumerateTgtLocations(cb) |
— | Visiter chaque instance TGT dans la zone actuelle |
| Méthode | Renvoie | Objectif |
|---|---|---|
Read(addr, buf, size) |
bool |
RPM brut |
ReadString(addr) |
std::string |
Chaîne étroite null-terminée |
ReadWString(addr) |
std::wstring |
Chaîne large null-terminée |
ReadStdWString(addr) |
std::wstring |
Lit un conteneur std::wstring côté jeu (gère SSO) |
ReadStdVector(addr, elemSize, maxElems) |
std::vector<uint8_t> |
Octets bruts ; réinterpréter selon votre type |
GetBaseAddress() |
uintptr_t |
Base du module du jeu |
GetModuleSize() |
uintptr_t |
Taille du module du jeu |
GetPatternAddress(name) |
uintptr_t |
Recherche de pattern nommé |
| Méthode | Objectif |
|---|---|
Debug / Info / Warn / Error(msg) |
Émettre au niveau correspondant |
Log(level, msg) |
Chaîne de niveau personnalisé |
| Méthode | Renvoie | Objectif |
|---|---|---|
Subscribe(kind, cb) |
Token |
Dispatch générique |
OnAreaChange / OnFrame / OnGameAttached / OnGameDetached(cb) |
Token |
Helpers d'abonnement en une ligne |
Unsubscribe(token) |
— | Libération manuelle (le destructeur libère automatiquement de toute façon) |
PluginAbi.h définit :
constexpr int PLUGIN_SDK_VERSION = 6;Au chargement, l'hôte appelle plugin->GetSDKVersion() et compare à sa propre PLUGIN_SDK_VERSION. Inadéquation → l'hôte journalise un avertissement et refuse de charger le plugin.
L'hôte vérifie également HostAbi::version et HostAbi::size_bytes à l'intérieur de PluginSDK_AttachHost (défini inline par PluginSDK.h lorsque PLUGIN_EXPORTS est défini). Si l'un ou l'autre champ ne s'accorde pas avec ce contre quoi le plugin a été construit, ctx() est non-fonctionnel. L'accesseur de la classe de base HostCompatible() renvoie false dans ce cas, et tout plugin qui veut être poli devrait refuser d'agir :
void OnEnable(bool) override {
if (!HostCompatible()) {
ctx()->Log.Error("Host ABI mismatch — disable plugin");
return;
}
// ...
}Quatre plugins dans le dépôt sont conçus pour être lus comme de la documentation :
-
Plugins/ExamplePlugin/— vitrine à large surface. Un plugin qui touche presque chaque service, organisé en 11 sous-fichiersexamples/Example*.h(Area & Vitals, Buffs, Entities, Inventory, Memory, UI Explorer, Component Reader, Render, Terrain, Events, Log) plus une bannière de résumé de couverture. Lisez ceci lorsque vous voulez voir comment un service est utilisé, en contexte. -
Plugins/Radar/— exemple ciblé du monde réel. ~200 lignes. Une superposition radar construite entièrement sur le SDK public — pas d'offsets, pas de lectures mémoire brutes. Rend la carte walkable plus des points par entité viaRender.GridToLargeMap. Lisez ceci lorsque vous voulez voir le minimum de code pour un résultat particulier. -
Plugins/KillCount/— tracker de kills/coffres/morts. SQLite + atlas de sprites + état par zone. Montre comment livrer persistance, fichiers de données embarqués et superposition, le tout dans une seule DLL. -
Plugins/NinjaPricer/— superposition de prix poe.ninja. Récupération HTTP (API Exchange) + scan d'inventaire + tarification par item. Montre du code réseau, l'ingestion de données tierces et l'itération d'inventaire dans un workflow réel.