Skip to content

Plugin Development Guide ZH

Lafko edited this page Jun 25, 2026 · 14 revisions

← Home


插件开发指南

POEFixer 插件是原生 C++ DLL,在运行时从 Plugins/<PluginName>/<PluginName>.dll 加载。它们读取实时游戏状态、绘制 ImGui 叠加层、持久化自身设置,并订阅宿主事件。


1. 概述

插件 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/


2. Hello-world 插件

一个能够加载并在宿主日志中打印消息的最小插件:

#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 选项卡启用。


3. 项目设置

使用 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));

4. 生命周期钩子

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()(在基类中定义,请勿重写),用以验证插件与宿主版本一致。不匹配 → 拒绝加载插件。


5. Context 上下文

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 树遍历、FindPanelByStringIdComputeScreenRect
RenderService WorldToScreenGridTo{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()


6. 读取游戏状态

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

快照本身直接携带的内容(无需进一步调用服务):

  • 状态与标志:StateIsAttachedIsWindowValidGameWindowForegroundIsTownIsHideoutIsPausedIsSkillTreeVisible
  • 区域:CurrentAreaNameCurrentAreaHashCurrentAreaLevelAreaChangeCounter
  • 世界:Player(完整 Entity)、Entities(完整 std::vector<Entity>)、VitalsLargeMapMiniMapWorldToScreenMatrix[16]
  • 窗口:ScreenWidthScreenHeightProcessIdGameWindowLastUpdateTimeWorldToGridConvertor

快照中包含的内容——需通过服务获取:背包内容(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();

7. 读取组件

实体通过 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 个组件读取器ReadLifeReadRenderReadPositionedReadTargetableReadChestReadShrineReadStackReadChargesReadPlayerReadAnimatedReadTransitionableReadTriggerableBlockageReadMinimapIconReadStateMachineReadBaseReadModsReadStatsReadBuffsReadActorReadNpcReadDiesAfterTime

ComponentAddresses 本身包含 24 个槽位:上述 21 个加上三个标记位(BuffsWorldItemAreaTransition)以及 OMP(宿主内部使用)。Buffs 是一个存在性标记——实际的增益列表来自 EnumerateBuffsWorldItem / 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 / 读取失败"的情况。如果你已经获得了父结构体(LifeMods 等),请直接访问其字段,而不要再调用辅助函数——辅助函数每次都会重新读取组件。

实体字段

每个 Entity(包括 snap.Player 以及 snap.Entities 中的成员)都携带相同的字段集合:

分组 字段
Identity IdAddressEntityDetailsAddressRenderComponentAddressIsValid
Classification EntityTypeEntitySubtypeEntityStateRarityReactionZoneNearbyZone:InnerCircle≈60 / OuterCircle≈120 / Far)
Position GridPositionXGridPositionYTerrainHeightWorldX/Y/ZModelBoundsZ
Quick vitals CurrentHPMaxHPCurrentESMaxES(当你只需要总量时,可避免一次 ReadLife
Strings Pathstd::wstringMetadata/...)、PlayerNamestd::wstring)、TgtPathstd::string,资源路径)
State IsSleepingIsChestOpened
Components ComponentsComponentAddresses 子结构体)

关注特定实体

如果你需要跨帧追踪某一个实体(例如玩家正在打开的箱子),并且不想每帧扫描完整的实体列表,可以注册一个关注:

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() 始终返回本地玩家。

地面物品(WorldItem 容器)

掉落在地面上的物品在 snap.Entities 中以路径 Metadata/MiscellaneousObjects/WorldItemEntityType::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 容器地址。


8. 背包

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);

物品词缀:两套 API,两种范围

ComponentsService::ReadMods(addr) 仅返回汇总标志(IsCorruptedIsRelicIsSplitIsMirroredIsSynthesisedIsIdentifiedRarityItemLevelRequiredLevelCraftedModCount),包含按类别的词缀列表。

要获取完整信息(汇总 + 词缀列表),请使用 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


9. UI 树

游戏的 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 threshold

StringId 值是游戏侧稳定的标识符;当它们存在时,应优先使用它们而非硬编码路径。


10. 渲染与投影

三个投影辅助函数,两个坐标系统。

透视(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 获取完全基于这些调用构建的雷达可运行示例。


11. 地形与可行走网格

可行走网格是每格 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 具有相同的形状,但每格保存一个 floatData()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
});

12. 事件

订阅宿主发出的事件。每次 Subscribe 返回一个 Token,之后可以传给 UnsubscribeEventsService 的析构函数(在插件禁用或卸载时触发)会自动释放所有未结清的订阅——因此你严格来说不必手动取消订阅,但这样做更礼貌。

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);
    }
};

四种事件类型为 AreaChangeFrameGameAttachedGameDetached。如果你更愿意构建一张分发表,也可以使用通用的 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


13. OverlayService — 输入捕获与休眠实体

ctx()->Overlay 公开两个逐插件的"请求标志",用于改变整个叠加层的行为。宿主以你的 this 指针为键存储每个插件的状态,并将其与宿主自身内置标志(主菜单可见性、AutoCraft 锁定、宿主内置的地图实体拾取器,以及所有其他插件的请求)进行 OR 聚合。这些标志在插件禁用/卸载时自动清除——崩溃或有缺陷的插件无法永久将叠加层卡在异常状态。

SetIncludeSleepingEntities

默认情况下,EntitiesService.EnumerateSnapshot.Entities 会隐藏具有 EntityState::Useless 状态的实体(宿主的"休眠"过滤器,用于丢弃远处的休眠怪物/NPC/箱子)。这可以将每帧快照的开销控制在合理范围内——通常一个区域内有数百个插件不关心的 Useless 实体。

对于需要完整实体池的地图拾取 UI 和调试查看器(以便用户能够点击尚未激活的实体),可以关闭此过滤器:

ctx()->Overlay.SetIncludeSleepingEntities(true);
// 此时 ctx()->Entities.Enumerate 也能看到 Useless 实体。
// Entity::IsSleeping 专门标记那些来自宿主独立 SleepingEntities 集合的实体
// (与 EntityState::Useless 正交)。

开销: 启用期间每帧快照 CPU 约增加 5–15%。除非有需要,请保持关闭。

SetWantsOverlayInput

叠加层窗口默认是穿透点击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();

仅覆盖拾取区域的较小窗口同样有效——宿主不关心窗口大小,只要光标下方有窗口即可。

完整示例——从地图添加 POI

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 工作线程节拍(休眠实体)时生效。亚帧级别,不可感知。
  • 所有方法均可从任意线程安全调用

14. Prices — 宿主加载的物品价格

宿主在每次会话中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。在此之前,应渲染"正在加载价格…"状态而非零值。


15. Runeshape 设备

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 黄色槽位点和每奖励标记的数据来源。


16. 设置持久化

约定:<plugin directory>/config/settings.jsonDirectory() 返回插件文件夹的绝对 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 秒),以及在禁用时调用;你不需要自己调用它。


17. 日志

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") 仍可正确路由——但便捷方法更清晰。


18. 内存(高级用法)

直接内存原语。只要可能就优先使用高层服务——它们了解偏移、能处理 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

如果你发现自己频繁使用这些接口,请反问一下:你需要的数据是否本应属于更高层的服务。


19. 桥接层 / SEH 安全

宿主与插件之间的每一次跨 DLL 调用,在宿主侧都运行在 __try / __except 块中。一个行为异常的插件——解引用过期指针、除零、或在 SDK 调用内部以其他方式触发故障——会得到一条错误日志,但宿主进程会崩溃,游戏继续运行,用户也可以继续使用其他插件。

但这并不意味着插件可以马虎。SEH 捕获的是症状而非根因。如果你的插件每一帧都触发故障,用户就会看到大量错误日志,并且你的数据实际上无法使用。请处理 SDK 调用的空返回,检查组件数据上的 Valid 标志,并且不要直接解引用 uintptr_t 地址——通过已经正确封装了 RPM 的 ComponentsService / Ui / Memory 调用来处理它们。

宿主能应付一个有 bug 的插件。但它无法应付一个挂起的插件 DLL——DrawSettings 耗时 100ms 就会阻塞整个 UI 线程。请保持每帧工作的开销低廉。


20. 常见陷阱

插件作者初次集成时容易踩到的若干问题。其中大多数已在上文中就地说明;此处汇总为一份清单。

  1. OnAreaChange 在可行走网格被重新解析之前就触发了。 不要从事件中刷新 WalkableGridHandle——在 DrawUI 中按帧轮询,并在 Data() 变化时交换。(§11)
  2. Entity::Zone 对本地玩家始终为 None 这是相对于玩家的距离分类,因此玩家按定义就处于距离零。不要在玩家信息展示中显示它。
  3. Components.ReadMods() 只返回汇总标志——不包含词缀列表。 要按类别获取词缀列表,请调用 Inventory.ReadItemMods(entityAddr)。(§8)
  4. 掉落到地面上的物品可能缺少 EntitySubtype 如果你要过滤世界中的物品,更倾向使用 EntityType == Item || EntityType == Chest,而不是更窄的子类型检查。
  5. Directory() 返回绝对路径。 不要自己在前面拼接 EXE 目录——你会得到 EXEDIR\EXEDIR\Plugins\X,配置写入会落到插件文件夹之外。
  6. ctx() 返回 const Context*EventsService::Subscribe 这种会修改状态的方法需要 const_cast。这是有意为之——不需要修改的服务应该不可能被意外修改。
  7. ImGui::SetCurrentContext 是按 DLL 设置的。 在每个进行绘制的入口(OnEnableDrawUIDrawSettings)都要调用它,因为插件 DLL 默认拥有自己独立的 ImGui 状态。
  8. 便捷辅助函数每次调用都会重新读取组件。 GetHealthPercent(addr) 内部会执行一次全新的 ReadLife(addr)。如果你已经从之前的调用中持有 Life 结构体,请直接访问其字段。

21. 服务速查表

每个服务每个公共方法的一行摘要。完整的类型签名和使用说明请参阅上文的章节。

GameService

方法 返回值 用途
GetSnapshot() Snapshot 完整的每帧视图,含 Entities
GetState() GameState 枚举:InGameLoginLoading、…
IsAttached() bool 是否已附加到游戏进程
IsInGame() bool State == InGame
IsForeground() bool 游戏窗口是否获得焦点
IsMenuVisible() bool ESC 菜单 / 设置是否打开
IsOverlayMode() bool 宿主是否处于叠加(点击穿透)模式
GetProcessId() DWORD 游戏 PID
GetGameWindow() HWND 游戏窗口句柄
GetScreenSize() ScreenSize {Width, Height} 浮点数

EntitiesService

方法 返回值 用途
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> 读取已关注的组件

ComponentsService

方法 返回值 用途
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 世界坐标的便捷访问

InventoryService

方法 返回值 用途
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 容器

UiService

方法 返回值 用途
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 定向后代查找

RenderService

方法 返回值 用途
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 同上,用于小地图

TerrainService

方法 返回值 用途
GetWalkableGrid() WalkableGridHandle 每格 4 bit 的可行走位图 RAII 句柄
GetHeightGrid() HeightGridHandle 每格地形高度的 RAII 句柄
IsWalkable(gx, gy) bool 单格谓词
GetTerrainHeight(gx, gy) float 世界空间 Z
GetWorldToGridConvertor() float 世界 → 网格的转换系数
EnumerateTgtLocations(cb) 遍历当前区域内每个 TGT 实例

MemoryService

方法 返回值 用途
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 命名特征码查找

LogService

方法 用途
Debug / Info / Warn / Error(msg) 以对应等级输出
Log(level, msg) 自定义等级字符串

EventsService

方法 返回值 用途
Subscribe(kind, cb) Token 通用分发
OnAreaChange / OnFrame / OnGameAttached / OnGameDetached(cb) Token 一行式订阅辅助
Unsubscribe(token) 手动释放(析构函数也会自动释放)

22. 版本管理

PluginAbi.h 中定义:

constexpr int PLUGIN_SDK_VERSION = 6;

加载时宿主调用 plugin->GetSDKVersion(),并与自身的 PLUGIN_SDK_VERSION 比对。不匹配 → 宿主记录警告并拒绝加载该插件。

宿主还会在 PluginSDK_AttachHost(设置 PLUGIN_EXPORTS 时由 PluginSDK.h 内联定义)中检查 HostAbi::versionHostAbi::size_bytes。任一字段与插件构建时不一致,ctx() 都将无法工作。基类访问器 HostCompatible() 在此情况下返回 false;任何想要规范行事的插件都应拒绝执行:

void OnEnable(bool) override {
    if (!HostCompatible()) {
        ctx()->Log.Error("Host ABI mismatch — disable plugin");
        return;
    }
    // ...
}

23. 示例插件

仓库中有四个插件被设计为可作为文档阅读:

  • 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)+ 背包扫描 + 按物品定价。展示真实工作流中的网络代码、第三方数据接入和背包遍历。


← Home

Clone this wiki locally