-
Notifications
You must be signed in to change notification settings - Fork 0
Plugin Development Guide ZH
POEFixer 插件是原生 C++ DLL,在运行时从 Plugins/<PluginName>/<PluginName>.dll 加载。它们读取实时游戏状态、绘制 ImGui 叠加层、持久化自身设置,并订阅宿主事件。
插件 SDK 采用三层架构:
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
- 插件作者只需包含一个头文件:
POEFixer/plugin_sdk/PluginSDK.h。 - 该头文件声明
PluginSDK::命名空间中的全部内容,并在底层引入PluginAbi.h中的 C ABI。你可以知道后者的存在,但几乎不需要去看它。 - 所有
std::*容器都保留在插件 DLL 内部。只有 POD 类型可以跨越宿主边界。这意味着用某个工具链版本构建的插件不会与宿主的 STL 纠缠在一起——双方共享的类型只有整数、浮点数、指针和小型结构体。
可以在以下位置找到 SDK 头文件:
-
POEFixer/plugin_sdk/PluginSDK.h—— 插件作者使用的 C++ 包装层。 -
POEFixer/plugin_sdk/PluginAbi.h—— 底层的纯 C ABI。
仓库中附带的参考插件(可作为文档阅读):Plugins/ExamplePlugin/、Plugins/Radar/、Plugins/KillCount/、Plugins/NinjaPricer/。
一个能够加载并在宿主日志中打印消息的最小插件:
#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; }构建为 Plugins/Hello/Hello.dll,重启宿主,从 Plugins 选项卡启用。
使用 Plugins/ExamplePlugin/ExamplePlugin.vcxproj 作为标准模板。关键设置如下:
- 配置类型: DynamicLibrary
- 平台工具集: v143(Visual Studio 2022)
- 字符集: Unicode
-
语言标准:
stdcpp20 -
运行库:
MultiThreadedDLL(Release)/MultiThreadedDebugDLL(Debug)。必须与宿主一致。 -
预处理器定义:
PLUGIN_EXPORTS;NDEBUG;_WINDOWS;_USRDLL;_CRT_SECURE_NO_WARNINGS -
附加包含目录:
$(SolutionDir)POEFixer -
输出目录:
$(SolutionDir)x64\Release\Plugins\<YourPlugin>\ -
目标名称: 必须与文件夹名一致(
Plugins/MyPlugin/→MyPlugin.dll)
宿主会扫描 Plugins/ 下的每个子文件夹,查找 <FolderName>.dll。该 DLL 必须导出三个符号:
-
CreatePlugin—— 工厂函数;返回PluginSDK::Plugin*。 -
DestroyPlugin—— 析构函数;接受PluginSDK::Plugin*。 -
PluginSDK_AttachHost—— 装配Context。在PluginSDK.h内为你定义好,并在设置PLUGIN_EXPORTS时自动发出。
推荐的源码布局:
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() 返回以宿主 EXE 目录为根的绝对 UTF-8 路径——不要自己再在前面拼接 EXE 路径。该目录字符串按值保存在 PluginSDK::Plugin 内部,因此可以在宿主容器重新分配和重新加载周期中存活,不存在生命周期问题。
如果你想绘制 ImGui,还要将以下内容添加到 <ClCompile>(宿主也会链接它们,但插件侧的 ImGui 是每个 DLL 独立的):
..\..\POEFixer\imgui\imgui.cpp
..\..\POEFixer\imgui\imgui_draw.cpp
..\..\POEFixer\imgui\imgui_tables.cpp
..\..\POEFixer\imgui\imgui_widgets.cpp
在 OnEnable 中,连接到宿主的 ImGui 上下文:
if (ctx()->ImGuiContext)
ImGui::SetCurrentContext(static_cast<ImGuiContext*>(ctx()->ImGuiContext));PluginSDK::Plugin 是一个虚基类。在你的插件中重写以下方法(按大致调用顺序排列):
| 方法 | 调用时机 | 典型用途 |
|---|---|---|
const char* GetName() const |
构造完成后调用一次 | 返回你的插件显示名称 |
void OnEnable(bool isGameAttached) |
用户启用插件时(或启动时若已持久化) | 加载设置、订阅事件、连接 ImGui 上下文 |
void DrawSettings() |
插件设置面板打开时的每一帧 | 配置项的 ImGui 控件 |
void DrawUI() |
插件启用时的每一帧 | 绘制 ImGui 叠加层(游戏叠加层使用 ImGui::GetBackgroundDrawList()) |
bool WantsOverlay() const |
每帧轮询 | 若需要宿主进入叠加(点击穿透)模式则返回 true
|
void SaveSettings() |
定期(约每 5 秒)以及禁用时 | 将配置持久化到磁盘 |
void OnDisable() |
用户禁用时,或宿主关闭时 | 释放资源、取消订阅事件 |
只有 GetName 是必须实现的;其余都有安全的默认实现。
宿主还会在 CreatePlugin 之后立即调用 GetSDKVersion()(在基类中定义,请勿重写),用以验证插件与宿主版本一致。不匹配 → 拒绝加载插件。
ctx() 返回 const PluginSDK::Context*,这是 10 个服务的聚合体:
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
};一眼看懂——每个服务的用途:
| 服务 | 用途 |
|---|---|
GameService |
快照、状态标志、屏幕与窗口信息 |
EntitiesService |
枚举、按 ID 查找、关注生命周期 |
ComponentsService |
21 个读取器 + 4 个枚举器 + 约 10 个便捷辅助函数 |
InventoryService |
扫描、枚举、按物品读取词缀 |
UiService |
树遍历、FindPanelByStringId、ComputeScreenRect
|
RenderService |
WorldToScreen、GridTo{Large,Mini}Map、各种变换 |
TerrainService |
可行走网格 + 高度网格(RAII)、TGT 位置 |
MemoryService |
RPM 原语——只在没有更高层调用时使用 |
LogService |
Debug / Info / Warn / Error
|
EventsService |
Subscribe / Unsubscribe / On{Area,Frame,Attach,Detach}
|
ctx() 从宿主调用 OnEnable 起到 OnDisable 返回之前一直有效。不要在热重载或 DLL 卸载边界之间缓存 ctx()。
ctx()->Game.GetSnapshot() 返回按值传递的 Snapshot——当前帧的完整不可变视图。GetSnapshot() 会遍历 abi->entities.enumerate 并在返回前填充 snap.Entities,因此开销随附近实体数量而增长。每帧只调用一次并复用结果。
PluginSDK::Snapshot snap = ctx()->Game.GetSnapshot();
if (snap.State != PluginSDK::GameState::InGame) return;
ctx()->Log.Info(snap.CurrentAreaName.c_str());
if (snap.IsTown || snap.IsHideout) return; // safe area
// snap.Vitals.HPPercent, snap.Vitals.MaxES, snap.Vitals.IsPaused
// snap.Player.GridPositionX, snap.Player.Path (wstring), snap.Player.Components
// snap.Entities is a std::vector<Entity> — every nearby entity, fully populated
// snap.LargeMap / snap.MiniMap — visibility + projection inputs
// snap.AreaChangeCounter — increments each portal transition快照本身直接携带的内容(无需进一步调用服务):
- 状态与标志:
State、IsAttached、IsWindowValid、GameWindowForeground、IsTown、IsHideout、IsPaused、IsSkillTreeVisible。 - 区域:
CurrentAreaName、CurrentAreaHash、CurrentAreaLevel、AreaChangeCounter。 - 世界:
Player(完整Entity)、Entities(完整std::vector<Entity>)、Vitals、LargeMap、MiniMap、WorldToScreenMatrix[16]。 - 窗口:
ScreenWidth、ScreenHeight、ProcessId、GameWindow、LastUpdateTime、WorldToGridConvertor。
快照中不包含的内容——需通过服务获取:背包内容(InventoryService)、增益(ComponentsService::EnumerateBuffs)、按物品的词缀列表(InventoryService::ReadItemMods)、UI 面板(UiService)。
当你不需要完整快照时可用的轻量辅助函数:
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();实体通过 entity.Components 暴露其组件——一个由 uintptr_t 地址构成的 ComponentAddresses 结构体。将每个地址传给对应的 ComponentsService::Read* 即可获得按值返回的快照。
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");
}
}共有 21 个组件读取器:ReadLife、ReadRender、ReadPositioned、ReadTargetable、ReadChest、ReadShrine、ReadStack、ReadCharges、ReadPlayer、ReadAnimated、ReadTransitionable、ReadTriggerableBlockage、ReadMinimapIcon、ReadStateMachine、ReadBase、ReadMods、ReadStats、ReadBuffs、ReadActor、ReadNpc、ReadDiesAfterTime。
ComponentAddresses 本身包含 24 个槽位:上述 21 个加上三个标记位(Buffs、WorldItem、AreaTransition)以及 OMP(宿主内部使用)。Buffs 是一个存在性标记——实际的增益列表来自 EnumerateBuffs。WorldItem / AreaTransition 是实体类型标记,而非真正的组件。所有槽位都在 ComponentAddresses 上有对应的 HasX() 谓词。
针对变长数据组件的集合式读取器:
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>便捷辅助函数(一次性使用——内部会为你调用 Read*):
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)) { ... }每个返回结构体上的 Valid 标志让你无需异常即可处理"组件地址为 0 / 读取失败"的情况。如果你已经获得了父结构体(Life、Mods 等),请直接访问其字段,而不要再调用辅助函数——辅助函数每次都会重新读取组件。
每个 Entity(包括 snap.Player 以及 snap.Entities 中的成员)都携带相同的字段集合:
| 分组 | 字段 |
|---|---|
| Identity |
Id、Address、EntityDetailsAddress、RenderComponentAddress、IsValid
|
| Classification |
EntityType、EntitySubtype、EntityState、Rarity、Reaction、Zone(NearbyZone:InnerCircle≈60 / OuterCircle≈120 / Far) |
| Position |
GridPositionX、GridPositionY、TerrainHeight、WorldX/Y/Z、ModelBoundsZ
|
| Quick vitals |
CurrentHP、MaxHP、CurrentES、MaxES(当你只需要总量时,可避免一次 ReadLife) |
| Strings |
Path(std::wstring,Metadata/...)、PlayerName(std::wstring)、TgtPath(std::string,资源路径) |
| State |
IsSleeping、IsChestOpened
|
| Components |
Components(ComponentAddresses 子结构体) |
如果你需要跨帧追踪某一个实体(例如玩家正在打开的箱子),并且不想每帧扫描完整的实体列表,可以注册一个关注:
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) 用于一次性查找,返回 std::optional<Entity>;GetPlayer() 始终返回本地玩家。
掉落在地面上的物品在 snap.Entities 中以路径 Metadata/MiscellaneousObjects/WorldItem 的 EntityType::Item 实体形式出现。它们是容器实体 — 它们不直接携带 Mods / Base / Stack / Sockets。真实的物品实体位于一次间接引用之外。
要将内部物品实体作为常规 Entity 快照获取,请使用 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 仅对真正的 WorldItem 容器成功 — 使用背包物品地址调用会返回 std::nullopt。如果您希望获得与背包物品相同的数据形态(无需手动遍历组件),下一节中的 Inventory.ReadItem* 系列会透明地自动解析 WorldItem 容器。
游戏内风格的词缀文本 + 基础 / 聚合属性(v6,2026-06-24)。 将任意属性键格式化为游戏内提示框中显示的相同文本,并读取物品的基础防御数值以及聚合的地图/路径石属性:
// 以游戏呈现的方式渲染词缀("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` */ }
}
// 物品基础防御数值。EnergyShield 是游戏内(计算后)的数值;
// Ward/Armour/Evasion 是物品的基础数值。当物品
// 没有 Armour 组件时(货币、宝石、珠宝、路径石等),Valid == false。
PluginSDK::ItemBaseStats bs = ctx()->Inventory.ReadItemBaseStats(item.Address);
if (bs.Valid) { /* bs.EnergyShield, bs.Ward, bs.Armour, bs.Evasion */ }
// 以属性 id 为键的聚合属性 —— 例如路径石的物品稀有度(8205)、
// 怪物包大小(8206)、怪物稀有度(8207)、怪物效力(8208)、
// 路径石掉落几率(8209)。
for (const auto& [statId, value] : ctx()->Inventory.ReadItemAggregatedStats(item.Address)) {
// 自行将 statId 映射到标签;数值为带符号的百分比
}FormatStat 使用宿主的 .csd 属性描述集(首次使用时下载),因此在该数据准备好之前会返回空字符串——此时可回退到原始 Mod 字段。ReadItemBaseStats / ReadItemAggregatedStats 均接受背包物品或 WorldItem 容器地址。
ctx()->Inventory.Scan(inventoryId) 触发宿主侧的重新扫描。使用 -1 扫描所有背包。
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
}
}每个 Inventory 同时暴露一个 Grid 结构体,描述该背包在屏幕上的绘制位置:
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)
}按 ID 获取单个背包(返回的结构体中 Items 已经填充好):
PluginSDK::Inventory backpack = ctx()->Inventory.Get(/*inventoryId=*/0);如果只想要物品列表而不需要包装结构体:
std::vector<PluginSDK::InventoryItem> items = ctx()->Inventory.GetItems(0);ComponentsService::ReadMods(addr) 仅返回汇总标志(IsCorrupted、IsRelic、IsSplit、IsMirrored、IsSynthesised、IsIdentified、Rarity、ItemLevel、RequiredLevel、CraftedModCount),不包含按类别的词缀列表。
要获取完整信息(汇总 + 词缀列表),请使用 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) { ... }其他按实体的直接读取(当你已经持有物品地址时,比重新扫描更省):
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);通过背包 API 读取地面物品。 以上所有七个 Inventory.ReadItem* 读取(以及 ReadItemMods)同时接受背包物品地址和 WorldItem 容器地址。容器地址在读取前会自动解析为内部物品,因此同一份插件代码路径既适用于背包中的物品也适用于地面上的物品:
// `addr` may be either an inventory item address or a WorldItem container.
PluginSDK::ItemMods im = ctx()->Inventory.ReadItemMods(addr);
int rarity = ctx()->Inventory.ReadItemRarity(addr);
std::string baseName = ctx()->Inventory.ReadItemBaseTypeName(addr);如果您需要直接获取内部物品的组件地址(例如调用 ctx()->Components.ReadStack(...) 或遍历插槽),请改用第 7 节中的 Entities.GetWorldItemInner。
游戏的 UI 树以 uintptr_t 元素地址的形式暴露。从一个根节点开始,遍历子节点,读取元素字段。
通过 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
}不知道 StringId 时的手动树遍历:
uintptr_t root = ctx()->Ui.GetUiRoot();
PluginSDK::UiElement e = ctx()->Ui.Read(root);
ctx()->Log.Info(("children=" + std::to_string(e.ChildCount)).c_str());
for (uintptr_t child : ctx()->Ui.GetChildren(root)) {
std::string sid = ctx()->Ui.GetStringId(child);
if (sid == "InventoriesPanel") { /* found it */ }
}
// Or use a known index path:
int path[] = { 5, 1, 2, 0 };
uintptr_t logInButton = ctx()->Ui.FollowPath(root, path, 4);
// Compute screen-space rect (post-scale, post-transform):
float x, y, w, h;
if (ctx()->Ui.ComputeScreenRect(invPanel, x, y, w, h)) {
// draw an overlay box at (x,y,w,h)
}
// Get displayed text:
std::string label = ctx()->Ui.GetText(child);
int cull = ctx()->Ui.GetCullValue(); // host's UI cull thresholdStringId 值是游戏侧稳定的标识符;当它们存在时,应优先使用它们而非硬编码路径。
三个投影辅助函数,两个坐标系统。
透视(3D 世界 → 屏幕) —— 与游戏在世界中绘制物体所用的投影一致。适合名牌、调试标记、目标指示器:
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));
}等距投影(网格 → 小地图) —— 用于绘制在大地图或小地图上的雷达式叠加层。这些投影会遵循当前可见地图的缩放、平移和旋转:
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)对于批量计算(避免按实体调用函数),一次性取得变换矩阵后内联投影:
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.参阅 Plugins/Radar/src/Radar.cpp 获取完全基于这些调用构建的雷达可运行示例。
可行走网格是每格 4 bit 的位图,标记玩家可踏入的地形单元。宿主在每次区域切换时更新它;插件接收一个稳定的句柄,该句柄会一直存在直到插件释放它(通过 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 具有相同的形状,但每格保存一个 float(Data() 是 const float*,并提供 ElementCount() 与 SizeBytes())。
不要订阅 OnAreaChange 来刷新句柄。 事件在宿主工作线程检测到区域变化时触发,但新的可行走网格可能还未被解析——你会持有过期指针一两帧。正确做法是在 DrawUI 中按帧轮询:
auto current = ctx()->Terrain.GetWalkableGrid();
if (current.Data() != m_walkable.Data()) {
m_walkable = std::move(current); // swap when the host re-parses
}这非常廉价(一次 ABI 调用 + 一次指针比较)。参阅 Plugins/Radar/src/Radar.cpp 获取生产版本。
其他地形访问器:
bool ok = ctx()->Terrain.IsWalkable(gx, gy);
float worldZ = ctx()->Terrain.GetTerrainHeight(gx, gy);
float worldToG = ctx()->Terrain.GetWorldToGridConvertor();
ctx()->Terrain.EnumerateTgtLocations([](const PluginSDK::TgtLocation& loc) {
// loc.Path, loc.TileX, loc.TileY, loc.X, loc.Y
return true; // continue
});订阅宿主发出的事件。每次 Subscribe 返回一个 Token,之后可以传给 Unsubscribe。EventsService 的析构函数(在插件禁用或卸载时触发)会自动释放所有未结清的订阅——因此你严格来说不必手动取消订阅,但这样做更礼貌。
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);
}
};四种事件类型为 AreaChange、Frame、GameAttached、GameDetached。如果你更愿意构建一张分发表,也可以使用通用的 Subscribe(EventKind, callback)。
需要 const_cast 是因为 Events 会改变其内部 token 映射。基类返回 const Context* 是为了防止意外修改其他服务。
如果你的插件拥有多个订阅,ExamplePlugin 模式是保持启用/禁用对称的一种简洁方式——将 token 和计数器打包成单个状态结构体,然后将所有操作集中到一对 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;
}完整模式参阅 Plugins/ExamplePlugin/examples/ExampleEvents.h。
ctx()->Runeshape 公开宿主在当前区域解析到的 Expedition2Encounter("Runeshape")设备,以及每个配方可获得的奖励。宿主负责完成设备链遍历和离线配方匹配,插件只需读取结果。这正是内置雷达奖励标签和 NinjaPricer 的 Runeshape 窗口的数据来源——第三方插件也可以渲染相同的数据。
for (const PluginSDK::Runeshape& rs : ctx()->Runeshape.Runeshapes()) {
// rs.color 为每个设备提供独特颜色(可用于分组/染色)。
// rs.bestIndex 是价格最高奖励的索引(若无则为 -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:延续到下一个遗迹的符文
ctx()->Log.Info((" propagates: " + rw.propagatingRunes).c_str());
}
}
Runeshape 字段 |
类型 | 含义 |
|---|---|---|
entityId |
uint64_t |
设备实体 id——传给 Rewards()
|
color |
uint32_t |
打包 RGBA,每个设备稳定不变(用于分组/染色) |
isUnique |
bool |
该设备提供稀有物品配方 |
holeCount |
int |
锚点上的符文孔数 |
anchorName |
std::string |
锚点符文名称 |
rewardCount |
int |
奖励槽数量 |
bestIndex |
int |
totalChaos 最高奖励的索引,若无则为 -1
|
propagatingSlots |
std::vector<int> |
符文孔槽索引列表,该槽中的符文会传递到下一个遗迹(0.5.4 延续机制);通常为 1 个,偶尔为 2 个 |
RuneshapeReward 字段 |
类型 | 含义 |
|---|---|---|
name |
std::string |
奖励物品名称 |
count |
int |
奖励数量 |
unitChaos |
float |
单件混沌石价格(来自 Prices 服务) |
totalChaos |
float |
unitChaos × count |
priced |
bool |
已找到该奖励的价格 |
propagatingRunes |
std::string |
此配方传递槽中的符文——完成配方后会延续的符文;例如 "Power" 或 "Cold, Time";若配方未覆盖该槽则为空 |
propagatingCount |
int |
此奖励的传递符文数量 |
propagatingHasRare |
bool |
任一传递符文为稀有("紫色"/高价值) |
奖励价格来自同一个 ctx()->Prices 数据库,因此未定价的奖励(priced == false)通常只是意味着价格尚未加载,或该物品未在 poe2scout 上架。
符文传递(0.5.4)。 每个遗迹随机选择一个符文槽,使该槽中的符文延续到下一个遗迹(游戏内:Runeshape Combinations 列表中高亮显示的金冠标记)。Runeshape::propagatingSlots 是原始槽位列表;由于它是槽位置,传递的符文因配方而异,因此 RuneshapeReward::propagatingRunes 会为每个奖励单独解析。这正是 NinjaPricer 黄色槽位点和每奖励标记的数据来源。
约定:<plugin directory>/config/settings.json。Directory() 返回插件文件夹的绝对 UTF-8 路径。
对于简单的设置,手写一个 JSON 序列化器即可,能保持 DLL 自包含。参阅 Plugins/Radar/src/RadarSettings.h 获取可运行示例。骨架代码:
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()); }对于结构化数据(嵌套对象、数组),可在插件文件夹中内嵌一个真正的 JSON 库。宿主不强制选择哪一个。
SaveSettings 会被定期调用(约每 5 秒),以及在禁用时调用;你不需要自己调用它。
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");所有四个等级都会路由到宿主的中央日志器。消息会出现在宿主的 Logs 选项卡以及磁盘日志文件中。请在调用前自行格式化;宿主不接受 printf 风格的可变参数。
在内部,便捷方法发出的字符串是 "Debug"、"Info"、"Warning" 和 "Error"(Warn 映射到 "Warning")。宿主桥接层做大小写不敏感匹配,因此插件调用 Log("warn", "msg") 仍可正确路由——但便捷方法更清晰。
直接内存原语。只要可能就优先使用高层服务——它们了解偏移、能处理 ABI 变化,并且是 SEH 安全的。直接内存读取只在没有更高层调用能满足需求时才合适。
// Read a fixed-size value:
uint64_t value = 0;
ctx()->Memory.Read(addr, &value, sizeof(value));
// Read game strings (null-terminated, narrow or wide):
std::string s = ctx()->Memory.ReadString (strAddr);
std::wstring ws = ctx()->Memory.ReadWString(wstrAddr);
// Read a std::wstring container in the game's memory (handles SSO):
std::wstring inner = ctx()->Memory.ReadStdWString(containerAddr);
// Read a std::vector<T>; returns raw bytes you reinterpret_cast:
std::vector<uint8_t> raw = ctx()->Memory.ReadStdVector(vecAddr, sizeof(MyT), /*maxElems=*/1024);
const MyT* items = reinterpret_cast<const MyT*>(raw.data());
size_t count = raw.size() / sizeof(MyT);
// Module info:
uintptr_t base = ctx()->Memory.GetBaseAddress();
uintptr_t sz = ctx()->Memory.GetModuleSize();
uintptr_t pat = ctx()->Memory.GetPatternAddress("GameStates"); // resolves a named pattern如果你发现自己频繁使用这些接口,请反问一下:你需要的数据是否本应属于更高层的服务。
宿主与插件之间的每一次跨 DLL 调用,在宿主侧都运行在 __try / __except 块中。一个行为异常的插件——解引用过期指针、除零、或在 SDK 调用内部以其他方式触发故障——会得到一条错误日志,但宿主进程不会崩溃,游戏继续运行,用户也可以继续使用其他插件。
但这并不意味着插件可以马虎。SEH 捕获的是症状而非根因。如果你的插件每一帧都触发故障,用户就会看到大量错误日志,并且你的数据实际上无法使用。请处理 SDK 调用的空返回,检查组件数据上的 Valid 标志,并且不要直接解引用 uintptr_t 地址——通过已经正确封装了 RPM 的 ComponentsService / Ui / Memory 调用来处理它们。
宿主能应付一个有 bug 的插件。但它无法应付一个挂起的插件 DLL——DrawSettings 耗时 100ms 就会阻塞整个 UI 线程。请保持每帧工作的开销低廉。
插件作者初次集成时容易踩到的若干问题。其中大多数已在上文中就地说明;此处汇总为一份清单。
-
OnAreaChange在可行走网格被重新解析之前就触发了。 不要从事件中刷新WalkableGridHandle——在DrawUI中按帧轮询,并在Data()变化时交换。(§11) -
Entity::Zone对本地玩家始终为None。 这是相对于玩家的距离分类,因此玩家按定义就处于距离零。不要在玩家信息展示中显示它。 -
Components.ReadMods()只返回汇总标志——不包含词缀列表。 要按类别获取词缀列表,请调用Inventory.ReadItemMods(entityAddr)。(§8) -
掉落到地面上的物品可能缺少
EntitySubtype。 如果你要过滤世界中的物品,更倾向使用EntityType == Item || EntityType == Chest,而不是更窄的子类型检查。 -
Directory()返回绝对路径。 不要自己在前面拼接 EXE 目录——你会得到EXEDIR\EXEDIR\Plugins\X,配置写入会落到插件文件夹之外。 -
ctx()返回const Context*。 像EventsService::Subscribe这种会修改状态的方法需要const_cast。这是有意为之——不需要修改的服务应该不可能被意外修改。 -
ImGui::SetCurrentContext是按 DLL 设置的。 在每个进行绘制的入口(OnEnable、DrawUI、DrawSettings)都要调用它,因为插件 DLL 默认拥有自己独立的 ImGui 状态。 -
便捷辅助函数每次调用都会重新读取组件。
GetHealthPercent(addr)内部会执行一次全新的ReadLife(addr)。如果你已经从之前的调用中持有Life结构体,请直接访问其字段。
每个服务每个公共方法的一行摘要。完整的类型签名和使用说明请参阅上文的章节。
| 方法 | 返回值 | 用途 |
|---|---|---|
GetSnapshot() |
Snapshot |
完整的每帧视图,含 Entities
|
GetState() |
GameState |
枚举:InGame、Login、Loading、… |
IsAttached() |
bool |
是否已附加到游戏进程 |
IsInGame() |
bool |
State == InGame |
IsForeground() |
bool |
游戏窗口是否获得焦点 |
IsMenuVisible() |
bool |
ESC 菜单 / 设置是否打开 |
IsOverlayMode() |
bool |
宿主是否处于叠加(点击穿透)模式 |
GetProcessId() |
DWORD |
游戏 PID |
GetGameWindow() |
HWND |
游戏窗口句柄 |
GetScreenSize() |
ScreenSize |
{Width, Height} 浮点数 |
| 方法 | 返回值 | 用途 |
|---|---|---|
Enumerate(cb) |
— | 遍历每个附近实体(返回 false 停止) |
GetPlayer() |
Entity |
本地玩家实体 |
FindById(id) |
std::optional<Entity> |
按实体 ID 查找 |
GetWorldItemInner(addr) |
std::optional<Entity> |
WorldItem 容器的内部物品实体(地面物品) |
Watch(id) |
— | 关注实体,使其组件保持可读 |
Unwatch(id) |
— | 释放关注 |
IsWatched(id) |
bool |
关注状态 |
GetWatchedComponents(id) |
std::optional<ComponentAddresses> |
读取已关注的组件 |
| 方法 | 返回值 | 用途 |
|---|---|---|
ReadLife / ReadRender / ReadPositioned / ReadTargetable / ReadChest / ReadShrine / ReadStack / ReadCharges / ReadPlayer / ReadAnimated / ReadTransitionable / ReadTriggerableBlockage / ReadMinimapIcon / ReadStateMachine / ReadBase / ReadMods / ReadStats / ReadBuffs / ReadActor / ReadNpc / ReadDiesAfterTime |
组件结构体 | 21 个读取器,每个组件类型一个 |
EnumerateBuffs(addr) |
std::vector<Buff> |
实体上的活跃增益 |
EnumerateActiveSkills(addr) |
std::vector<ActiveSkill> |
来自 Actor 组件的技能 |
EnumerateStats(addr) |
std::vector<StatEntry> |
物品 + 增益来源的属性 |
EnumerateItemMods(addr) |
std::vector<Mod> |
从 Mods 组件可达的词缀 |
GetHealthPercent / GetEsPercent / GetManaPercent |
float |
便捷的百分比辅助函数 |
IsAlive(addr) |
bool |
Health > 0 |
GetItemRarity(addr) |
int |
来自 Mods 组件的稀有度 |
IsItemIdentified(addr) |
bool |
鉴定标志 |
GetStackCount(addr) |
int |
当前堆叠数 |
IsChestOpened(addr) |
bool |
箱子是否打开 |
GetPlayerName(addr) |
std::string |
来自 Player 组件的玩家名 |
GetWorldPosition(renderAddr, x, y, z) |
bool |
世界坐标的便捷访问 |
| 方法 | 返回值 | 用途 |
|---|---|---|
Scan(inventoryId) |
— | 触发宿主侧重新扫描(-1 = 全部) |
Get(inventoryId) |
Inventory |
单个背包,物品已填充 |
GetItems(inventoryId) |
std::vector<InventoryItem> |
仅物品 |
GetAll() |
std::vector<Inventory> |
所有已扫描的背包 |
GetName(inventoryId) |
const char* |
显示名称("Backpack"、"Stash"、…) |
ReadItemRarity(addr) |
int |
按实体的稀有度(自动解析 WorldItem 容器) |
ReadItemStackCount(addr) |
int |
按实体的堆叠数(自动解析 WorldItem 容器) |
ReadItemBaseTypeName(addr) |
std::string |
基础类型,自动解析 WorldItem 容器 |
ReadItemUniqueName(addr) |
std::string |
唯一名称,自动解析 WorldItem 容器 |
ReadItemPath(addr) |
std::string |
Metadata/Items/... 路径,自动解析 WorldItem 容器 |
ReadItemMods(addr) |
ItemMods |
汇总标志 + 5 个按类别的词缀向量,自动解析 WorldItem 容器 |
FormatStat(statKey, v0, v1) |
std::string |
通过宿主 .csd 格式化器将属性键 + 数值格式化为游戏内风格文本;描述数据加载完成前返回空字符串 |
ReadItemBaseStats(addr) |
ItemBaseStats |
基础防御数值(计算后的能量护盾;基础护灵/护甲/闪避);无 Armour 组件时 Valid 为 false;自动解析 WorldItem 容器 |
ReadItemAggregatedStats(addr) |
std::vector<std::pair<int,int>> |
聚合的 {statId, value}(路径石物品稀有度 8205 / 怪物包大小 8206 / 怪物稀有度 8207 / 怪物效力 8208 / 路径石掉落几率 8209);自动解析 WorldItem 容器 |
| 方法 | 返回值 | 用途 |
|---|---|---|
Read(addr) |
UiElement |
元素字段(矩形、标志、子节点数) |
GetChildren(addr) |
std::vector<uintptr_t> |
子元素地址 |
GetChildAt(addr, index) |
uintptr_t |
按索引取单个子节点 |
FollowPath(root, indices, count) |
uintptr_t |
沿已知索引路径遍历 |
IsVisible(addr) |
bool |
元素是否在屏幕上 |
GetStringId(addr) |
std::string |
游戏侧稳定标识符 |
GetText(addr) |
std::string |
渲染的文本 |
ComputeScreenRect(addr, x, y, w, h) |
bool |
最终屏幕空间矩形 |
GetGameUiRoot() |
uintptr_t |
游戏内 UI 的根节点 |
GetUiRoot() |
uintptr_t |
顶层 UI 根节点 |
GetCullValue() |
int |
宿主的 UI 剔除阈值 |
FindPanelByStringId(parent, stringId) |
uintptr_t |
定向后代查找 |
| 方法 | 返回值 | 用途 |
|---|---|---|
WorldToScreen(wx, wy, wz, sx, sy) |
bool |
透视投影 |
GridToLargeMap(gx, gy, worldZ, sx, sy) |
bool |
投影到大地图叠加层 |
GridToMiniMap(gx, gy, worldZ, sx, sy) |
bool |
投影到小地图 |
GetLargeMapTransform() |
MapTransform |
用于批量计算的预乘变换 |
GetMiniMapTransform() |
MapTransform |
同上,用于小地图 |
| 方法 | 返回值 | 用途 |
|---|---|---|
GetWalkableGrid() |
WalkableGridHandle |
每格 4 bit 的可行走位图 RAII 句柄 |
GetHeightGrid() |
HeightGridHandle |
每格地形高度的 RAII 句柄 |
IsWalkable(gx, gy) |
bool |
单格谓词 |
GetTerrainHeight(gx, gy) |
float |
世界空间 Z |
GetWorldToGridConvertor() |
float |
世界 → 网格的转换系数 |
EnumerateTgtLocations(cb) |
— | 遍历当前区域内每个 TGT 实例 |
| 方法 | 返回值 | 用途 |
|---|---|---|
Read(addr, buf, size) |
bool |
原始 RPM |
ReadString(addr) |
std::string |
以空字符结尾的窄字符串 |
ReadWString(addr) |
std::wstring |
以空字符结尾的宽字符串 |
ReadStdWString(addr) |
std::wstring |
读取游戏侧的 std::wstring 容器(处理 SSO) |
ReadStdVector(addr, elemSize, maxElems) |
std::vector<uint8_t> |
原始字节;按你的类型重解释 |
GetBaseAddress() |
uintptr_t |
游戏模块基址 |
GetModuleSize() |
uintptr_t |
游戏模块大小 |
GetPatternAddress(name) |
uintptr_t |
命名特征码查找 |
| 方法 | 用途 |
|---|---|
Debug / Info / Warn / Error(msg) |
以对应等级输出 |
Log(level, msg) |
自定义等级字符串 |
| 方法 | 返回值 | 用途 |
|---|---|---|
Subscribe(kind, cb) |
Token |
通用分发 |
OnAreaChange / OnFrame / OnGameAttached / OnGameDetached(cb) |
Token |
一行式订阅辅助 |
Unsubscribe(token) |
— | 手动释放(析构函数也会自动释放) |
PluginAbi.h 中定义:
constexpr int PLUGIN_SDK_VERSION = 6;加载时宿主调用 plugin->GetSDKVersion(),并与自身的 PLUGIN_SDK_VERSION 比对。不匹配 → 宿主记录警告并拒绝加载该插件。
宿主还会在 PluginSDK_AttachHost(设置 PLUGIN_EXPORTS 时由 PluginSDK.h 内联定义)中检查 HostAbi::version 和 HostAbi::size_bytes。任一字段与插件构建时不一致,ctx() 都将无法工作。基类访问器 HostCompatible() 在此情况下返回 false;任何想要规范行事的插件都应拒绝执行:
void OnEnable(bool) override {
if (!HostCompatible()) {
ctx()->Log.Error("Host ABI mismatch — disable plugin");
return;
}
// ...
}仓库中有四个插件被设计为可作为文档阅读:
-
Plugins/ExamplePlugin/—— 广覆盖面展示。一个几乎涉及所有服务的插件,组织为 11 个examples/Example*.h子文件(Area & Vitals、Buffs、Entities、Inventory、Memory、UI Explorer、Component Reader、Render、Terrain、Events、Log),并附带一个覆盖率摘要横幅。当你想了解一个服务在上下文中如何被使用时,请阅读它。 -
Plugins/Radar/—— 聚焦的实际示例。约 200 行。完全基于公开 SDK 构建的雷达叠加层——无偏移、无原始内存读取。通过Render.GridToLargeMap渲染可行走地图以及按实体的圆点。当你想了解某一特定结果的最少代码时,请阅读它。 -
Plugins/KillCount/—— 击杀 / 箱子 / 死亡追踪器。SQLite + 精灵图集 + 按区域状态。展示如何在一个 DLL 中同时交付持久化、内嵌数据文件和叠加层。 -
Plugins/NinjaPricer/—— poe.ninja 价格叠加层。HTTP 拉取(Exchange API)+ 背包扫描 + 按物品定价。展示真实工作流中的网络代码、第三方数据接入和背包遍历。