-
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 (16 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*,这是 16 个服务的聚合体:
struct Context {
GameService Game; // snapshot, state flags, screen size
EntitiesService Entities; // enumerate, find-by-id, watch
ComponentsService Components; // 21 component readers + collection enumerators
InventoryService Inventory; // scan + iterate + per-item helpers
UiService Ui; // tree walk, FindPanelByStringId, screen-rect
RenderService Render; // WorldToScreen + isometric map projection
TerrainService Terrain; // walkable grid (RAII), height, TGT locations
MemoryService Memory; // direct memory primitives (last resort)
LogService Log; // Debug/Info/Warn/Error
EventsService Events; // Subscribe / Unsubscribe / On<X>
OverlayService Overlay; // SetIncludeSleepingEntities / SetWantsOverlayInput
FlasksService Flasks; // life/mana flasks + charms: charges, usable, active
PricesService Prices; // poe2scout item prices (host-loaded once, shared)
RuneshapeService Runeshape; // Expedition2Encounter devices + per-device rewards
AtlasService Atlas; // endgame-atlas nodes / adjacency / Rite selection / weights
SekhemaService Sekhema; // Trial-of-the-Sekhemas floor graph / choices / content FKs
void* ImGuiContext; // pass to ImGui::SetCurrentContext
void* D3DDevice; // ID3D11Device* for texture loading
};一眼看懂——每个服务的用途:
| 服务 | 用途 |
|---|---|
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}
|
OverlayService |
SetIncludeSleepingEntities / SetWantsOverlayInput——地图拾取器友好 |
FlasksService |
生命/魔力药瓶 + 护符——充能数、Usable、Active、每次使用量、词缀数量 |
PricesService |
LookupPrice / GetRates / GetStatus——宿主加载的 poe2scout 价格,所有插件共享 |
RuneshapeService |
Runeshapes / Rewards——已解析的 Expedition2Encounter 设备及每设备奖励 |
AtlasService |
GetPanel / Nodes / Connections / Selection / GetLineSeed / Weights——终局 Atlas 面板的实时数据 |
SekhemaService |
GetPanel / GetFloor / Rooms / Content / 房间标志读取——Trial of the Sekhemas 楼层地图数据 |
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();GetHiveblood 读取 Genesis 树(Hiveblood)资源计数器——这是一次经由 GameService 路由的 host-tail 读取(与 GetGold / GetAreaId 同族):
int32_t hiveblood = 0;
if (ctx()->Game.GetHiveblood(hiveblood)) {
// in a Genesis map: hiveblood holds the current resource count
}
// Returns false (leaving the out param untouched) when not in game, the chain
// is broken, or the host predates the tail function — always gate on the return.实体通过 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 等),请直接访问其字段,而不要再调用辅助函数——辅助函数每次都会重新读取组件。
区分地面效果 —— 许多地面效果共用同一个实体路径 Metadata/Effects/Spells/ground_effects/VisibleServerGroundEffect,因此仅凭路径无法区分「感电地面」和「燃烧地面」。ReadGroundEffect 解析该实体的 GroundEffect 组件及其 groundeffects.datc64 行。传入实体地址(GroundEffect 组件不在 Components 中,宿主会替你解析——与 ReadPathfinding 的惯例相同),然后通过 TypeId 进行匹配,这是稳定的、与补丁无关的键:
for (const auto& e : snap.Entities) {
if (e.Path != L"Metadata/Effects/Spells/ground_effects/VisibleServerGroundEffect") continue;
PluginSDK::GroundEffect ge = ctx()->Components.ReadGroundEffect(e.Address);
if (!ge.Valid) continue;
// ge.TypeId -> "ShockedGround" / "IgnitedGround" / "CausticCloud" / "ChilledGround" / ...
// ge.Radius -> world units; draw a circle at (e.WorldX, e.WorldY, e.WorldZ) of this radius
if (ge.TypeId == "ShockedGround") {
// highlight per your config (color/alpha keyed by ge.TypeId)
}
}GroundEffect 结构体:
| 字段 | 含义 |
|---|---|
Valid |
若实体没有 GroundEffect 组件或读取失败则为 false
|
TypeId |
groundeffecttypes Id —— 用于匹配的稳定键(如 ShockedGround) |
Radius |
效果半径(世界单位);变体未设置时为 0
|
EndEffect |
结束行为:fadeout / close / end
|
BuffVisual1 |
buffvisuals Id(如 ground_fire_burn_white);未设置时为空 |
BuffVisual2 |
buffdefinitions Name(如 ground_tar_gold);未设置时为空 |
AoFile |
首个 .ao/.aoc 视觉路径;无时为空 |
GroundEffectsRowAddr / GroundEffectTypesRowAddr
|
原始 dat 行指针(会话内稳定),用于高级交叉引用 |
效果的世界坐标来自实体本身(Entity.WorldX/Y/Z,或 Render/Positioned 组件),因此不在结构体中重复存储。ReadGroundEffect 每次调用都会重新读取,请按扫描间隔缓存结果。在此 API 加入之前构建的宿主上,该函数返回无效的 GroundEffect(它位于 SDK v6 只增尾部,已做空值检查)。
EnumerateActiveSkills(actorAddr) 为实体 Actor 上每个已授予/已镶嵌的技能各返回一个 ActiveSkill。除 Name 外,该结构体还携带冷却状态以及一个已解码的宝石/插槽描述符:
| 字段 | 含义 |
|---|---|
Name |
技能内部名称 |
CurrentSize / TotalUses / UseStage
|
阶段/使用次数计数器(原始值) |
CastType |
原始施法类型 id |
TotalCooldownMs |
完整冷却时长,单位毫秒 |
CanBeUsed |
宿主的"当前可用"标志 |
MaxUses |
该技能拥有的冷却充能数(0 = 不受冷却约束) |
TotalActiveCooldowns |
当前处于冷却中的充能数。当 MaxUses > 0 时,剩余可用次数 = MaxUses - TotalActiveCooldowns。 |
GrantedEffectsPerLevelAddr, ActiveSkillsDatAddr, GrantedEffectStatSetsPerLevelAddr, SkillDetailsAddr
|
原始 DAT 行地址——传给 ctx()->Memory.Read* 以获取更深层的技能数据。 |
EquipmentInfoPacked |
原始的宝石/插槽打包字(解码结果见下方 Equipment)。 |
该打包字已为你解码进 skill.Equipment:
Equipment 字段 |
含义 |
|---|---|
GemNameHash |
高 16 位——宝石身份哈希 |
InventorySlot |
宝石所在的装备槽位(从 1 开始编号) |
LinkIndex |
该物品内的连接组索引 |
SocketIndex |
该连接组内的插槽索引 |
UnknownFlag / CanBeOnPlayerItem
|
剩余标志位(位布局见 PluginSDK.h) |
auto skills = ctx()->Components.EnumerateActiveSkills(e.Components.Actor);
for (const auto& s : skills) {
if (s.MaxUses > 0) {
int remaining = s.MaxUses - s.TotalActiveCooldowns;
ctx()->Log.Info((s.Name + ": " + std::to_string(remaining) +
"/" + std::to_string(s.MaxUses) + " charges").c_str());
}
// s.Equipment.LinkIndex / s.Equipment.SocketIndex —— 宝石所在的位置
}EnumerateSkillStats(skillDetailsAddr) 暴露的是游戏引擎自身对单个技能的已评估属性容器——包括游戏内技能面板所显示的 DPS 系列属性。请传入同一帧内某次 EnumerateActiveSkills 结果中的 ActiveSkill::SkillDetailsAddr(技能地址会在跨帧/跨区域切换后过期;过期地址会安全地返回空向量,在此 API 加入之前构建的宿主上同样如此)。
每个返回的 SkillStatEntry 为 {SetIndex, StatId, Value}:
-
SetIndex 0是该技能的当前上下文属性集——每个技能都有,具有持久性(面板关闭后依旧保留),是技能面板 DPS 行数据的准确来源。 - 后续的集合是该技能按部分划分的属性集——对于召唤/指挥类技能,随从一侧的属性就存放在这里。
-
StatId是 Stats.dat 行索引 + 1(引擎运行时使用的属性键;0是游戏用于表示"无属性"的哨兵值)。属性名称需要通过导出Stats.dat来解析。 -
Value是原始int32;许多 DPS 系列属性采用 ×100 定点数表示。
常用的运行时 id:
| StatId | 属性(Stats.dat 行 + 1) | 缩放 |
|---|---|---|
| 691 | hundred_times_attacks_per_second |
×100 |
| 692 | hundred_times_damage_per_second |
×100 |
| 695 | hundred_times_casts_per_second |
×100 |
| 1982 / 1983 |
hundred_times_average_damage_per_hit / ..._per_skill_use
|
×100 |
| 694 | base_spell_cast_time_ms |
ms |
| 2079 | skill_show_average_damage_instead_of_dps |
标志 |
auto skills = ctx()->Components.EnumerateActiveSkills(snap.Player.Components.Actor);
for (const auto& s : skills) {
for (const auto& st : ctx()->Components.EnumerateSkillStats(s.SkillDetailsAddr)) {
if (st.StatId == 692) { // hundred_times_damage_per_second
ctx()->Log.Info((s.Name + " DPS: " +
std::to_string(st.Value / 100.0)).c_str());
}
}
}需要注意的几点:
-
DPS 系列属性是虚拟的。 引擎通过回调计算这些属性(
DPS = rate/100 × avg damage),并且只为它实际显示过的上下文保存结果。当前上下文属性集携带的是游戏本身最后一次评估出的数值;其他上下文(灌注提示页签、武器切换预览等)只在鼠标悬停时临时评估,无法持久读取。因此在拥有动态伤害增益的怪物身上,数值可能会比实时提示框滞后几个百分点——游戏自身的技能列表和提示框之间也是同样的不一致。 -
随从的 DPS 记录在随从自己身上。 召唤类技能自身的属性集只描述召唤物本身;提示框中"Basic Attack"数值来自随从实体的
Actor组件——需遍历实体列表找到友方怪物,再对其攻击技能调用EnumerateActiveSkills(minion.Components.Actor)→EnumerateSkillStats(...)。 -
EnumerateActiveSkills对每个技能名都会返回两条记录(对应不同的评估上下文,例如武器组)——如果你要查找特定属性,两条都需要查询。
每个 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 值是游戏侧稳定的标识符;当它们存在时,应优先使用它们而非硬编码路径。
0.5.x 说明。
Ui.GetStringId()在当前(0.5.x)客户端上返回正确的标识符——元素的StringId字段偏移已迁移(0x448→0x4C0),宿主桥接层也已随之修正。(对于试炼 HUD 的字段叶子节点渲染其数值的那个数字型 StringId——它与GetText()是不同的字段——SekhemaHelper通过ctx()->Sekhema.GetUiStringId()读取。)
三个投影辅助函数,两个坐标系统。
透视(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()->Overlay 公开两个逐插件的"请求标志",用于改变整个叠加层的行为。宿主以你的 this 指针为键存储每个插件的状态,并将其与宿主自身内置标志(主菜单可见性、AutoCraft 锁定、宿主内置的地图实体拾取器,以及所有其他插件的请求)进行 OR 聚合。这些标志在插件禁用/卸载时自动清除——崩溃或有缺陷的插件无法永久将叠加层卡在异常状态。
默认情况下,EntitiesService.Enumerate 和 Snapshot.Entities 会隐藏具有 EntityState::Useless 状态的实体(宿主的"休眠"过滤器,用于丢弃远处的休眠怪物/NPC/箱子)。这可以将每帧快照的开销控制在合理范围内——通常一个区域内有数百个插件不关心的 Useless 实体。
对于需要完整实体池的地图拾取 UI 和调试查看器(以便用户能够点击尚未激活的实体),可以关闭此过滤器:
ctx()->Overlay.SetIncludeSleepingEntities(true);
// 此时 ctx()->Entities.Enumerate 也能看到 Useless 实体。
// Entity::IsSleeping 专门标记那些来自宿主独立 SleepingEntities 集合的实体
// (与 EntityState::Useless 正交)。开销: 启用期间每帧快照 CPU 约增加 5–15%。除非有需要,请保持关闭。
叠加层窗口默认是穿透点击(WS_EX_TRANSPARENT)的:所有鼠标点击都直接传递给下方的游戏。对于只读叠加层(雷达、血量条、DPS 显示)来说这是正确的默认行为——玩家可以正常操作游戏,感觉不到叠加层的存在。
一旦你的插件需要用户在叠加层内点击某处——确认弹窗、在地图上选择实体、拖动标记——默认行为就会出问题。SetWantsOverlayInput(true) 请求宿主开始拦截光标所在 ImGui 窗口上的鼠标点击:
ctx()->Overlay.SetWantsOverlayInput(true);
// ...
// 完成后(用户已选择、关闭弹窗或按下 Escape):
ctx()->Overlay.SetWantsOverlayInput(false);宿主每帧逻辑仅在光标位于可见 ImGui 窗口上时才拦截点击——其他位置仍保持穿透,玩家依然可以在你的弹窗周围移动/攻击/拾取。
作用范围(重要):
- 鼠标按键(LMB/RMB): 是——受此标志控制。
- 鼠标位置 / 悬停: 始终有效,与此标志无关。悬停提示无需该标志。
-
键盘: 始终通过宿主的 WindowProc 传达给插件,与此标志无关。
ImGui::IsKeyPressed(ImGuiKey_Escape)在任何情况下均可工作。
如果你通过 ImGui::GetBackgroundDrawList() 绘制可点击标记(雷达/大地图叠加层的常见做法),背景绘制列表没有 ImGui 窗口作为支撑。宿主的命中测试遍历 ctx->Windows,在光标下方找不到任何内容,从而重新启用穿透——你的标记可见但无法被点击。
解决方案:打开一个覆盖拾取区域的真实 ImGui 窗口,并在其中放置一个 ImGui::InvisibleButton。窗口是宿主命中测试的目标;InvisibleButton 为你提供用于检测点击的 ImGui::IsItemClicked()。示意代码:
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();
// 通过 ImGui::GetForegroundDrawList()(或 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) {
// 用户选中了此 POI
}
}
return true;
});
ImGui::End();仅覆盖拾取区域的较小窗口同样有效——宿主不关心窗口大小,只要光标下方有窗口即可。
class RadarPlugin : public PluginSDK::Plugin {
bool m_pickerMode = false;
void DrawUI() override {
if (!ctx()->Game.IsInGame()) return;
ImGui::SetCurrentContext((ImGuiContext*)ctx()->ImGuiContext);
// 快捷键切换。键盘无论捕获状态如何都会传达给插件,
// 因此即使叠加层是穿透状态,此处也能正常工作。
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(有关 ImGui::Begin + InvisibleButton 模式,
// 请参阅上方"背景绘制列表注意事项")
}
void OnDisable() override {
// 双保险。宿主在禁用时也会清除标志,但显式清理可确保状态一致,
// 即便在 OnDisable 与 PluginManager 内务处理之间有同步帧触发。
ctx()->Overlay.SetIncludeSleepingEntities(false);
ctx()->Overlay.SetWantsOverlayInput (false);
}
};- 两个标志都是幂等的——连续调用
Set(true)两次,第二次是空操作,计数器不会重复递增。 - 两个标志都与宿主自身状态及所有其他插件的标志进行 OR 聚合。多个插件同时处于拾取模式可以正常共存。
- 宿主在插件被禁用时(通过 Plugins 选项卡、崩溃禁用或关闭)会自动清除该插件的所有标志。帧中途崩溃不会永久将叠加层卡在捕获模式——但行为良好的插件仍应配对开/关调用,以在期间保持其他插件和游戏的响应性。
- 延迟: Set 调用会同步更新标志,但实际行为变化将在下一个宿主帧(叠加层输入)或下一个 GameClient 工作线程节拍(休眠实体)时生效。亚帧级别,不可感知。
- 所有方法均可从任意线程安全调用。
宿主在每次会话中从 poe2scout 后台线程加载一次市场价格,并通过 ctx()->Prices 向所有插件公开。插件不需要自行获取价格——内置雷达、宿主叠加层和所有插件共享同一个价格数据库,因此该 API 只被调用一次,而非每个消费者各调用一次。
- 价格联赛由用户在配置 → 设置中选择(默认为 Runes of Aldur),并持久化在宿主端。插件始终读取用户选择的联赛,不能自行指定。
- 加载是触发一次,按类别回退的(失败后依次在 1 → 5 → 10 → 20 → 30 → 60 分钟后重试,然后放弃,直到应用重启)。没有定期刷新——价格在整个会话期间保持稳定。
- 所有价格均以混沌石计价。如果需要以其他单位显示,可通过
GetRates()获取神圣石/崇高石的换算率。
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 — 匹配到的 poe2scout 分类(currency、fragments、runes……
// 或某个传奇物品分类)。适合用于区分货币与传奇物品的逻辑分支。
}LookupPrice 接受物品的显示名称(货币名称、传奇名称或基础类型),在所有已加载分类中进行模糊宿主端匹配。未匹配时返回 found == false,所有价格字段均为零。
PriceResult 字段 |
类型 | 含义 |
|---|---|---|
found |
bool |
是否匹配到价格 |
chaos |
float |
以混沌石计的价格(规范单位) |
divine |
float |
以神圣石计的同等价格 |
exalt |
float |
以崇高石计的同等价格 |
category |
std::string |
匹配到的 poe2scout 分类(currency / fragments / runes / … / 某个传奇分类) |
PluginSDK::PriceRates r = ctx()->Prices.GetRates(); // divineInChaos, exaltedInChaos
PluginSDK::PriceStatus s = ctx()->Prices.GetStatus();
if (!s.loaded) {
// 尚未就绪(仍在加载,或所有分类均失败)。
// s.catsOk / s.catsPending / s.catsFailed 显示加载器的进度。
}GetStatus().loaded 是显示价格前应检查的门控条件——仅当换算率及至少第一个分类都已到达时,才会翻转为 true。在此之前,应渲染"正在加载价格…"状态而非零值。
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 黄色槽位点和每奖励标记的数据来源。
ctx()->Atlas 公开实时的终局 Atlas 面板——地图节点、每个锚点的邻接关系、当前的 Rite 选择以及原始资格权重——这些都由宿主侧通过 GameLibrary 的 Atlas 偏移读取。这正是内置 Atlas 叠加层和参考插件 ForetoldRewards 的数据来源。一切都以 GetPanel() 为起点:0 表示 Atlas UI 尚未构建(不在游戏中 / 面板已关闭)。
if (!ctx()->Atlas.GetPanel()) return; // atlas UI absent
for (const PluginSDK::AtlasNode& n : ctx()->Atlas.Nodes()) {
// n.gridX / n.gridY are stable per map; n.marker / n.mapState carry UI state.
std::string name = ctx()->Atlas.GetNodeName(n.uiAddress); // resolve lazily
}
uint32_t seed = ctx()->Atlas.GetLineSeed(); // 0 = no Rite line| 方法 | 返回值 | 用途 |
|---|---|---|
GetPanel() |
uintptr_t |
Atlas 面板地址;0 = 面板不存在 |
Nodes(detail) |
std::vector<AtlasNode> |
所有 Atlas 节点(gridX/Y、uiAddress、marker、flags、biome、mapState);默认 detail 跳过名称——通过 GetNodeName() 解析 |
Connections() |
std::vector<AtlasConnection> |
每个锚点的邻接关系(x/y + 最多 5 个 neighbors);依赖视口 |
Selection(which) |
std::vector<AtlasGridPoint> |
which=0 为已揭示的 Rite 线路地图,which=1 为已选锚点(按选择顺序) |
GetLineSeed() |
uint32_t |
Rite 奖励选择种子;0 = 无 Rite 线路 / 面板不存在 |
Weights() |
std::vector<AtlasWeight> |
原始资格权重行(key、value) |
GetNodeName(uiAddress) |
std::string |
节点 uiAddress 对应的显示名称(未解析时为 "") |
按值传递的 AtlasServiceAbi 已冻结(其后已追加了另一个 HostAbi 成员),因此未来任何新的 Atlas 读取都必须以新的 HostAbi 尾部函数形式落地——绝不能作为新的 AtlasServiceAbi 成员。
ctx()->Sekhema 公开 Trial of the Sekhemas 的楼层地图数据——静态房间图、本次挑战的选择以及每个房间的内容 FK 行——外加试炼房间所需的 StateMachine 标志读取。图相关的调用需要显式传入试炼面板的 UI 地址:从 GetPanel()(宿主的直接子索引解析)开始,或使用低开销的 ProbeFloor() 预筛选自行运行 UI 树 BFS。这正是参考插件 SekhemaHelper 的数据来源。
uintptr_t panel = ctx()->Sekhema.GetPanel();
if (!panel) return; // not on a trial floor
PluginSDK::SekhemaFloor floor = ctx()->Sekhema.GetFloor(panel);
for (const PluginSDK::SekhemaRoom& r : ctx()->Sekhema.Rooms(panel)) {
bool chosen = r.layer < (int)floor.choices.size() && floor.choices[r.layer] == r.index;
// r.connections lists the room indices in the NEXT layer
}| 方法 | 返回值 | 用途 |
|---|---|---|
GetPanel() |
uintptr_t |
宿主解析出的试炼面板;0 = 不存在 / 手柄模式 / 不在游戏中 |
ProbeFloor(uiAddress) |
int |
uiAddress 处 FloorData 的层数,0 = 不是试炼楼层(低开销的 BFS 预筛选) |
GetFloor(panel) |
SekhemaFloor |
楼层头部:layerCount、roomCounts、choices(每层已选索引,0xFF=无)、counter
|
Rooms(panel) |
std::vector<SekhemaRoom> |
按 (layer, index) 顺序的静态房间图;每个房间的 connections 指向下一层 |
Content(panel) |
std::vector<SekhemaContentEntry> |
每个房间的内容 FK 行,解析为 rowId / rowName(按 tablePath 分派) |
GetRoomUsedFlag(sm) |
int |
StateMachine 已用/已关闭标志:1=已用,0=激活中,-1=不可读 |
GetStateMachineValue(sm, i, out) |
bool |
单个 shared-state 值(每条 define_shared_state 记录 8 字节,按定义顺序) |
GetUiStringId(uiAddress) |
std::string |
UI 元素的 StringId(UTF-8)——试炼 HUD 叶子节点渲染数值的那个数字字段(不是 Ui.GetText) |
约定:<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} 浮点数 |
GetGold() |
int |
角色金币计数器(不在游戏中时为 0) |
GetAreaId() |
std::string |
当前区域的原始 WorldArea id(携带 Sekhemas 楼层编号) |
GetHiveblood(out) |
bool |
Genesis 树(Hiveblood)资源 → out;旧版宿主 / 不在游戏中时为 false
|
| 方法 | 返回值 | 用途 |
|---|---|---|
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 组件的技能(ActiveSkill 字段表见§7) |
EnumerateSkillStats(skillDetailsAddr) |
std::vector<SkillStatEntry> |
单个技能的已评估属性集({SetIndex, StatId, Value},StatId = Stats.dat 行 + 1)——集合 0 为当前上下文,含技能面板 DPS(692,×100);见§7 |
EnumerateStats(addr) |
std::vector<StatEntry> |
物品 + 增益来源的属性 |
EnumerateItemMods(addr) |
std::vector<Mod> |
从 Mods 组件可达的词缀 |
ReadGroundEffect(entityAddr) |
GroundEffect |
从 VisibleServerGroundEffect 实体读取地面效果类型与半径——传入实体地址;通过 TypeId(ShockedGround/IgnitedGround/…)进行匹配。可区分共用同一实体路径的多种效果 |
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) |
— | 手动释放(析构函数也会自动释放) |
| 方法 | 返回值 | 用途 |
|---|---|---|
SetIncludeSleepingEntities(enable) |
— | 选择接收 EntitiesService.Enumerate 中的 EntityState::Useless 实体 |
SetWantsOverlayInput(enable) |
— | 请求叠加层捕获鼠标点击而非穿透 |
两者均为幂等操作,按插件隔离,与宿主及其他插件进行 OR 聚合,在 Disable/Unload 时自动清除。完整模式(包括地图拾取器插件的背景绘制列表注意事项)见第 13 节。
| 方法 | 返回值 | 用途 |
|---|---|---|
GetFlask(slot) |
std::optional<Flask> |
按腰带槽位获取生命/魔力药瓶(0=生命,1=魔力);超出范围或不在游戏中返回 nullopt
|
GetCharm(slot) |
std::optional<Charm> |
按腰带槽位获取护符(0..2);超出范围或不在游戏中返回 nullopt
|
AllFlasks() |
std::vector<Flask> |
所有药瓶槽位,包含空槽(FlaskSlotCount() 个条目) |
AllCharms() |
std::vector<Charm> |
所有护符槽位,包含空槽(CharmSlotCount() 个条目) |
FlaskSlotCount() |
int32_t |
药瓶槽位数量(POE2 为 2) |
CharmSlotCount() |
int32_t |
护符槽位数量(POE2 为 3) |
Flask / Charm 字段表及 PerUseEffective 限制说明见第 8 节("Flasks & charms")。
| 方法 | 返回值 | 用途 |
|---|---|---|
LookupPrice(name) |
PriceResult |
按显示名称进行宿主端模糊价格查找(found、chaos、divine、exalt、category) |
GetRates() |
PriceRates |
神圣石 / 崇高石 → 混沌石汇率 |
GetStatus() |
PriceStatus |
loaded 门控 + 各分类计数(catsOk / catsPending / catsFailed) |
| 方法 | 返回值 | 用途 |
|---|---|---|
Runeshapes() |
std::vector<Runeshape> |
所有已解析的 Expedition2Encounter 设备(id、颜色、锚点、bestIndex) |
Rewards(entityId) |
std::vector<RuneshapeReward> |
每台设备的奖励槽位,各槽位通过 Prices 服务定价 |
| 方法 | 返回值 | 用途 |
|---|---|---|
GetPanel() |
uintptr_t |
Atlas 面板地址;0 = 不存在 |
Nodes(detail) |
std::vector<AtlasNode> |
所有 Atlas 节点(网格坐标、marker、mapState) |
Connections() |
std::vector<AtlasConnection> |
每个锚点的邻接关系(依赖视口) |
Selection(which) |
std::vector<AtlasGridPoint> |
0 = 已揭示的 Rite 线路地图,1 = 已选锚点 |
GetLineSeed() |
uint32_t |
Rite 奖励选择种子(0 = 无) |
Weights() |
std::vector<AtlasWeight> |
原始资格权重行(key、value) |
GetNodeName(uiAddress) |
std::string |
节点显示名称(未解析时为 "") |
| 方法 | 返回值 | 用途 |
|---|---|---|
GetPanel() |
uintptr_t |
宿主解析出的试炼面板;0 = 不存在 |
ProbeFloor(uiAddress) |
int |
FloorData 的层数(0 = 不是试炼楼层);低开销预筛选 |
GetFloor(panel) |
SekhemaFloor |
楼层头部(layerCount、roomCounts、choices、counter) |
Rooms(panel) |
std::vector<SekhemaRoom> |
按 (layer, index) 顺序的静态房间图 |
Content(panel) |
std::vector<SekhemaContentEntry> |
每个房间的内容 FK 行(rowId / rowName) |
GetRoomUsedFlag(sm) |
int |
1=已用,0=激活中,-1=不可读 |
GetStateMachineValue(sm, i, out) |
bool |
单个 shared-state 值(按定义顺序) |
GetUiStringId(uiAddress) |
std::string |
UI 元素的 StringId(UTF-8)(数字 HUD 字段) |
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)+ 背包扫描 + 按物品定价。展示真实工作流中的网络代码、第三方数据接入和背包遍历。