Skip to content

Plugin Development Guide ZH

Lafko edited this page May 22, 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() 始终返回本地玩家。


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

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. 设置持久化

约定:<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 秒),以及在禁用时调用;你不需要自己调用它。


14. 日志

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


15. 内存(高级用法)

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

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


16. 桥接层 / SEH 安全

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

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

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


17. 常见陷阱

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

  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 结构体,请直接访问其字段。

18. 服务速查表

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

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 查找
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 按实体的稀有度
ReadItemStackCount(addr) int 按实体的堆叠数
ReadItemBaseTypeName(addr) std::string 基础类型("Gemcutter's Prism"、…)
ReadItemUniqueName(addr) std::string 唯一名称(如适用)
ReadItemPath(addr) std::string Metadata/Items/... 路径
ReadItemMods(addr) ItemMods 汇总标志 + 5 个按类别的词缀向量

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) 手动释放(析构函数也会自动释放)

19. 版本管理

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;
    }
    // ...
}

20. 示例插件

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

  • 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