插件开发与调试流程

构建、链接安装、注入、日志与设置,以 stellaris-mcp 为例

本文以开源插件 stellaris-mcp(一个注入游戏的 C++20 DLL,通过命名管道向外提供游戏数据)的实际做法为例,配合 stellaris-Launcher 的插件规范,走一遍开发循环。

项目布局

stellaris-mcp 仓库里与插件相关的部分:

代码
stellaris_bridge\           DLL 源码(CMake)
plugin\
  stl-plugin.json           清单
  defaults\
    stellaris_mcp.ini       默认设置
  README.md
scripts\
  build.ps1                 构建 DLL
  make_plugin.py            从构建产物组装插件文件夹 / zip
  reload_dll.py             把新构建热替换进正在运行的游戏

清单和默认设置放在源码仓库里,构建产物和它们组装成插件文件夹,这是推荐的组织方式。

1. 构建

  • 只支持 x64。
  • 建议静态链接 C/C++ 运行库(MSVC 的 /MT;CMake 中设 CMAKE_MSVC_RUNTIME_LIBRARY 为 MultiThreaded$<$<CONFIG:Debug>:Debug>),或把运行库 DLL 放进插件文件夹,避免玩家缺 VC++ 运行库导致 LoadLibraryW failed。

stellaris-mcp 的构建脚本有一个实用细节:游戏还占用着旧 DLL 时直接链接会报 LNK1104,所以脚本先把旧 DLL 改名为 .dll.old 再构建:

PowerShell
.\scripts\build.ps1      # -> build/stellaris_bridge/Release/stellaris_bridge.dll

2. 链接到启动器

开发时不必每次复制 DLL。用 --link 让启动器直接使用你的文件夹:

PowerShell
stl plugin install D:\dev\hello-stellaris --link
stl plugin enable hello-stellaris
stl launch

stl launch 的输出里会有 plugin hello-stellaris: loaded,或者跳过/失败的原因。也可以在启动器「插件」页点「链接开发中的插件」,并确认「设置 → 启动 → 加载 DLL 插件」是开的。链接的插件不会被自动更新覆盖,config\ 和 logs\ 也就在你的开发文件夹里。

stellaris-mcp 则先用 python scripts/make_plugin.py 把构建产物组装到 build\plugin\stellaris-mcp\,再 stl plugin install 这个文件夹。

3. 手动注入与热重载

游戏已经在运行时,可以手动注入:

PowerShell
stl inject D:\dev\hello-stellaris\hello_stellaris.dll
stl inject D:\dev\hello-stellaris\hello_stellaris.dll --pid 12345

注意 规范 v2 要求插件不得卸载自己,启动器也从不卸载插件:带着钩子卸载 DLL,在多个插件交叉挂钩时极易崩溃。手册推荐的换新版本方式是关闭游戏、重新启动。

stellaris-mcp 仓库里的 scripts/reload_dll.py 是一个开发期的例外:它在游戏里远程调用 FreeLibrary 卸下旧 DLL,再注入新构建(scripts/build_and_reload.py 把构建和重载串在一起)。这只适合只有这一个插件、并且清楚自己的钩子如何安全卸除的情况;同时开着别的挂钩插件或 Steam 覆盖层时,不要这么做。

4. DLL 的几条硬规则

DllMain 要极简。 DllMain 在加载器锁内执行,并且运行在启动器的远程线程上。只做一件事:开一个线程,真正的初始化在线程里做。

C++
BOOL APIENTRY DllMain(HMODULE module, DWORD reason, LPVOID) {
    if (reason == DLL_PROCESS_ATTACH) {
        DisableThreadLibraryCalls(module);
        if (HANDLE t = CreateThread(nullptr, 0, InitThread, nullptr, 0, nullptr)) CloseHandle(t);
    }
    return TRUE;
}

不要在 DllMain 里等待、LoadLibrary、调用游戏或 D3D 函数、做耗时 IO。

从自己的模块地址找到插件文件夹。 不能用当前工作目录(那是游戏目录),也不能写死 Documents\…\plugins\<id>(链接开发时插件在别处):

C++
const std::wstring& PluginDir() {
    static const std::wstring dir = [] {
        HMODULE self = nullptr;
        wchar_t path[MAX_PATH * 4] = {};
        GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
                           reinterpret_cast<LPCWSTR>(&PluginDir), &self);
        GetModuleFileNameW(self, path, static_cast<DWORD>(std::size(path)));
        std::wstring p(path);
        return p.substr(0, p.find_last_of(L"\\/") + 1);
    }();
    return dir;
}

stellaris-mcp 用的就是这个办法,所以同一个 DLL 被启动器加载时读写 plugins\stellaris-mcp\ 下的文件,被开发者手动注入时读写 build\stellaris_bridge\Release\ 下的文件。

引擎代码只在游戏主线程调用。 从自己的线程或远程线程直接调用引擎的 UI 或逻辑函数会死锁或静默崩溃。stellaris-mcp 的做法是:挂钩 IDXGISwapChain::Present,在钩子里处理一个任务队列;管道线程只往队列里放任务,再等待结果。

读内存要防崩溃。 用 SEH(__try / __except)包住对游戏内存的原始读取,一个坏指针不能拖垮游戏;用特征码扫描定位函数,定位失败就安全停用并写日志。不要直接套用 Linux 版反编译里的结构偏移:MSVC 和 GCC 的对象布局不同。

与其他插件共存。 钩子里必须调用原函数把调用链传下去,不要假设自己是第一个或最后一个挂钩的,不要覆盖别人的钩子。

5. 设置文件与日志

设置放在 <插件文件夹>\config\,日志写到 <插件文件夹>\logs\。stellaris-mcp 的默认设置 defaults\stellaris_mcp.ini:

ini
; Stellaris MCP bridge settings. Changes apply while the game runs (re-read within a couple of seconds).

[log]
; write logs\stellaris_mcp.log in the plugin folder: true / false
enabled = true
; info: start-up, errors, timeouts. debug: also every pipe request (method, duration, error code)
; and pipe connections
level = info

它的实现要点,也是手册推荐的做法:

  • 启动时读 config\stellaris_mcp.ini,文件缺失就用内置默认值照常运行(手动注入时可能没有 config\)。
  • 每 2 秒检查一次设置文件的修改时间,变了就重读——玩家在启动器插件页(齿轮按钮)里保存设置后,无需重启游戏即生效。
  • 读文件时容忍开头的 UTF-8 BOM。
  • 日志第一行记下插件文件夹和读取的设置文件,例如 [PLUGIN] folder …, settings … (read),排错时最有用。
  • 打开日志用 _wfsopen 加 _SH_DENYWR,让别人能在游戏运行时读取;不要用会锁文件到游戏退出的 _wfopen_s。

6. 排错

在哪看看什么
stl launch 的输出 / 启动器窗口日志每个插件 loaded、跳过或失败的原因
stl plugins已安装的插件,以及是否适配当前游戏构建
stl plugin info <id>清单、文件夹、DLL 的 SHA-256
<插件文件夹>\logs\你自己的日志
Documents\…\Stellaris\logs\error.log游戏自己的错误日志

常见报错:

现象原因
LoadLibraryW failed in the gameDLL 或其依赖找不到(缺 VC++ 运行库?)、DllMain 返回 FALSE、不是 x64
LoadLibraryW did not return within 30 sDllMain 里在等待或死锁了
is not among the game's modulesDLL 加载后又卸载了自己
跳过:made for the build …游戏更新了,exe_timestamps 不含新构建
安装失败:a file in it is in use游戏还在运行,DLL 被占用;关闭游戏重试,已安装的版本不受影响

需要单步调试时,用 Visual Studio「附加到进程」附加 stellaris.exe 并加载插件的 PDB。

7. 游戏更新之后

游戏更新会改变 exe 构建,插件会因 exe_timestamps 不匹配被跳过。重新验证地址(重跑特征码扫描或 SDK 生成器),确认无误后把新时间戳加进列表,再发布新版本。stellaris-mcp 的 make_plugin.py 会在打包时核对清单是否列出了 SDK 生成时对应的 exe 构建,没列就拒绝打包——值得借鉴。

发布流程见发布与索引。